Initial commit

This commit is contained in:
2025-11-09 10:23:23 +01:00
commit f890341ffe
34 changed files with 1058 additions and 0 deletions

52
wwwroot/core/appData.js Normal file
View File

@@ -0,0 +1,52 @@
import 'dotenv/config';
import {Client, GatewayIntentBits} from "discord.js";
import {MessageSender} from "./base/MessageSender.js";
import {InstagramTokenManager} from "./instagram/instagramTokenManager.js";
import {TikTokTokenManager} from "./tiktok/tiktokTokenManager.js";
import JsonManager from "./utils/jsonManager.js";
import {InstagramPoller} from "./instagram/instagramPoller.js";
import {UsersToken} from "./usersToken.js";
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers,
]
});
const sender = new MessageSender(client);
const instagramID = process.env.INSTAGRAM_CLIENT_ID;
const instagramSecret = process.env.INSTAGRAM_CLIENT_SECRET;
const updateChannelID = process.env.BOT_UPDATE_CHANNEL_ID;
const socialChannelID = process.env.SOCIAL_CHANNEL_ID
const filePath = ".instagram_client_tokens.json";
const usersToken = new UsersToken(filePath)
const instagramTokenManager = new InstagramTokenManager(
{clientID : instagramID, clientSecret : instagramSecret, usersToken : usersToken}
);
const instagramPoller = new InstagramPoller(process.env.INSTAGRAM_POST_LIST, process.env.INSTAGRAM_POST_DETAILS, instagramTokenManager);
export const data = {
client,
sender,
instagramPoller,
updateChannelID,
socialChannelID,
instagramTokenManager,
//tiktokTokenManager
};
/*
const tiktokTokenManager = new TikTokTokenManager(
{filepath : ".tiktok_client_tokens.json", clientKey : "", clientSecret : ""}
);
*/

View File

@@ -0,0 +1,20 @@
import {data} from "../appData.js";
export class MessageSender {
constructor(client) {
this.client = client;
}
async send(channelID, message) {
const channel = await data.client.channels.fetch(channelID);
if (channel && channel.isTextBased()) {
await channel.send(message);
} else {
console.log('❌ Channel not found!');
}
}
}

View File

@@ -0,0 +1,32 @@
import EventEmitter from "events";
import {Logger} from "../logging/logger.js";
export class BasePoller extends EventEmitter {
constructor() {
super();
this.task = null;
}
async pollOnce(){
throw new Error("Not implemented.");
}
start(interval) {
if(this.task){
Logger.error("A task has already been scheduled");
return;
}
super.task = setInterval(async () => {
await this.pollOnce();
}, interval);
}
stop() {
if (this.task) {
clearInterval(this.task);
super.task = null;
}
}
}

View File

@@ -0,0 +1,47 @@
export class BaseTokenManager {
/**
*
* @param usersToken {UsersToken}
*/
constructor(usersToken) {
this.usersToken = usersToken;
this.watchDelay = 3600*1000; //1 hour in milliseconds
}
/**
* Launches watch for all users
*/
startWatching() {
this.usersToken.getAll().forEach((user, index) => {
this.watchUser(index);
})
}
/**
* Watches token for a single user
*/
watchUser(userIndex){
setInterval(async () => {
let userData = await this.getUserData(userIndex);
if(this.mustBeRenewed(userData.expiresAt)){
await this.renew(userData);
}
}, this.watchDelay);
}
renew(){
throw new Error("Not implemented.");
}
mustBeRenewed(expiresAt) {
const expirationThreshold = 3600*1000; //1 hour in milliseconds
const timeBeforeExpiration = expiresAt - Date.now();
return timeBeforeExpiration <= expirationThreshold;
}
getUserData(index){
return this.usersToken.get(index);
}
}

View File

@@ -0,0 +1,53 @@
import { Logger } from '../logging/logger.js';
import StringFormatter from '../utils/stringFormatter.js';
import {BasePoller} from "../base/basePoller.js";
import {Requester} from "../utils/requester.js";
export class InstagramPoller extends BasePoller {
/**
*
* @param rawPostListUrl {string}
* @param rawMediaDetailsUrl {string}
* @param tokenManager {InstagramTokenManager}
*/
constructor(rawPostListUrl, rawMediaDetailsUrl, tokenManager){
super();
this.rawPostListUrl = rawPostListUrl;
this.rawMediaDetailsUrl = rawMediaDetailsUrl;
this.tokenManager = tokenManager;
}
async pollOnce(){
try{
let userData = this.tokenManager .getUserData(0);
let postListUrl = this.formatPostListUrl(userData.userId, userData.token);
let mediaData = await Requester.doGetRequest(postListUrl);
console.log(mediaData);
let mediaId = this.getMediaId(mediaData);
let detailsUrl = this.formatMediaDetailsUrl(mediaId, userData.token);
let mediaDetails = await Requester.doGetRequest(detailsUrl);
console.log(mediaDetails);
console.log({permalink : mediaDetails.permalink, userId : userData.userId});
super.emit("newPost", {permalink : mediaDetails.permalink, userId : userData.userId});
} catch(err) {
console.error(err);
await Logger.error(`Unbale to fetch instagram content for`, err)
}
}
formatPostListUrl(userId, accessToken){
return StringFormatter.format(this.rawPostListUrl, [{key: "userId", value: userId}, {key: "access_token", value: accessToken}]);
}
formatMediaDetailsUrl(mediaId, accessToken){
return StringFormatter.format(this.rawMediaDetailsUrl, [{key: "access_token", value: accessToken}, {key: "media_id", value: mediaId}]);
}
getMediaId(mediaData){
return mediaData.data[0].id;
}
}

View File

@@ -0,0 +1,88 @@
import {BaseTokenManager} from "../base/baseTokenManager.js";
import {Requester} from "../utils/requester.js";
export class InstagramTokenManager extends BaseTokenManager {
/**
*
* @param clientID {string}
* @param clientSecret {string}
* @param usersToken {UsersToken}
*/
constructor({clientID, clientSecret, usersToken}) {
super(usersToken);
this.clientID = clientID;
this.clientSecret = clientSecret;
}
getOauthUrl(){
return "https://www.instagram.com/oauth/authorize"
+ `?client_id=${this.clientID}`
+ "&redirect_uri=https://the-jailor.naaturel.be/oauth/"
+ "&scope=instagram_business_basic,instagram_business_manage_messages,instagram_business_manage_comments,"
+ "instagram_business_content_publish,instagram_business_manage_insights"
+ "&response_type=code";
}
async generate(code) {
let headers = {
"Content-Type": "application/x-www-form-urlencoded"
};
let body = {
"client_id": this.clientID,
"client_secret": this.clientSecret,
"grant_type": "authorization_code",
"redirect_uri": "https://the-jailor.naaturel.be/oauth/",
"code": code
};
let encodedBody = new URLSearchParams(body);
let shortLiveAccessTokenData = await Requester.doPostRequest(
"https://api.instagram.com/oauth/access_token", headers, encodedBody);
let userId = shortLiveAccessTokenData.user_id;
let shortLiveAccessToken = shortLiveAccessTokenData.access_token;
let longLiveAccessTokenData = await Requester.doGetRequest(
"https://graph.instagram.com/access_token?grant_type=ig_exchange_token" +
`&client_secret=${this.clientSecret}` +
`&access_token=${shortLiveAccessToken}`);
let accessToken = longLiveAccessTokenData.access_token;
let expiresAt = Date.now() + longLiveAccessTokenData.expires_in*1000; //convert from sec to millis
const result = {
userId : userId,
token : accessToken,
expiresAt : expiresAt
};
this.usersToken.upsert(result);
return result;
}
async renew(userData){
const data = await Requester.doGetRequest(
"https://graph.instagram.com/refresh_access_token" +
"?grant_type=ig_refresh_token" +
`&access_token=${userData.token}`
);
let accessToken = data.access_token;
let expiresAt = Date.now() + data.expires_in*1000; //convert from sec to millis
const result = {
userId : userData.userId,
token : accessToken,
expiresAt : expiresAt
}
this.usersToken.upsert(result);
return result;
}
}

View File

@@ -0,0 +1,38 @@
import path from "path";
import { appendFile, mkdir } from "fs/promises";
export class Logger{
static log(message){
console.log(message)
}
static async error(message, error) {
try {
const currentDateTime = new Date();
const date = currentDateTime.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "2-digit"
}).replace(/\//g, "-");
const time = currentDateTime.toLocaleTimeString("fr-FR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
});
const logsDir = path.resolve("./logs");
const fullPath = path.join(logsDir, `${date}.error.log`);
await mkdir(logsDir, { recursive: true });
await appendFile(fullPath, `${time} - ${message} -> ${JSON.stringify(error)} \n`);
console.error(`An error occured. The incident has been logged in ${fullPath}`)
} catch (err) {
console.error("Error writing log:", err);
}
}
}

View File

@@ -0,0 +1,26 @@
import {BasePoller} from "../base/basePoller.js";
import {Logger} from "../logging/logger.js";
export class TiktokPoller extends BasePoller {
constructor({userToken : userToken, endpoint : endpoint}) {
super();
this.endpoint = endpoint;
this.userToken = userToken;
}
async pollOnce() {
try {
//{
// 'Content-Type': 'application/json',
// 'Authorization': 'Bearer ' + this.userToken,
//}
//let data = super.doPostRequest(this.endpoint, this.userToken, {"max_count" : 1});
} catch (err) {
Logger.error("Unbale to fetch TikTok content", err)
}
}
}

View File

@@ -0,0 +1,38 @@
import {BaseTokenManager} from "../base/baseTokenManager.js";
import {Requester} from "../utils/requester.js";
export class TikTokTokenManager extends BaseTokenManager {
constructor({filepath, clientKey, clientSecret}) {
super(filepath);
this.clientKey = clientKey;
this.clientSecret = clientSecret;
}
async renew() {
let url = `https://www.tiktok.com/v2/auth/authorize?client_key=${this.clientKey}&response_type=code&scope=user.info.basic,video.list&redirect_uri=https://the-jailor.naaturel.be/oauth&state=SOME_RANDOM_STRING`
let response = await Requester.doGetRequest(url);
let userCode = response.code; //Might not be correct, please update
url = "https://open.tiktokapis.com/v2/oauth/token/";
let headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Cache-Control": "no-cache"
}
let params = {
"client_key": this.clientKey,
"client_secret": this.clientSecret,
"code": userCode,
"grant_type": "authorization_code",
"redirect_uri": "https://the-jailor.naaturel.be/oauth"
}
let data = await Requester.doPostRequest(url, headers, params);
let userId = data.open_id;
let token = data.access_token;
//Update token registry
}
}

View File

@@ -0,0 +1,26 @@
import JsonManager from "./utils/jsonManager.js";
export class UsersToken {
constructor(filePath) {
this.filePath = filePath;
this.data = JsonManager.read(this.filePath);
}
reload() {
this.data = JsonManager.read(this.filePath);
}
upsert(data) {
JsonManager.upsertToken(this.filePath, data);
this.reload();
}
get(index){
return this.data[index];
}
getAll(){
return this.data;
}
}

View File

@@ -0,0 +1,84 @@
import path from 'path';
import {readFileSync, writeFileSync} from 'fs';
import {Logger} from '../logging/logger.js';
export default class JsonManager {
/**
* Read the current data in a file
* @param {*} filePath
* @returns deserialized content
*/
static read(filePath) {
try {
const fullPath = path.resolve(filePath);
const jsonString = readFileSync(fullPath, 'utf8');
return JSON.parse(jsonString);
} catch (err) {
Logger.error('Error reading JSON file: ', err.message);
return null;
}
}
/**
* Write the given data in a file by overriding the previous data
* @param {*} filePath
* @param {*} data
* @returns
*/
static writeUnsafe(filePath, data) {
try {
const fullPath = path.resolve(filePath);
const jsonString = JSON.stringify(data);
writeFileSync(fullPath, jsonString, 'utf8', async err => {
if(err) await Logger.error(err.message);
});
} catch (err) {
Logger.error('Error writting in JSON file: ', err.message);
return null;
}
}
static upsertToken(filePath, userData) {
const existingData = JsonManager.read(filePath);
const index = existingData.findIndex(e => e.userId === userData.userId);
if (index !== -1) {
existingData.splice(index, 1, userData);
} else {
existingData.push(userData);
}
JsonManager.writeUnsafe(filePath, existingData);
return existingData;
}
static upsertPost(filePath, { userId, permalink }) {
const data = JsonManager.read(filePath);
let modified = false;
const index = data.findIndex(e => e.userId === userId);
if (index !== -1) {
if (data[index].permalink !== permalink) {
data[index].permalink = permalink;
modified = true;
}
} else {
data.push({ userId, permalink });
modified = true;
}
if (modified) {
JsonManager.writeUnsafe(filePath, data);
}
return modified;
}
}

View File

@@ -0,0 +1,35 @@
import {Logger} from "../logging/logger.js";
export class Requester {
static async doGetRequest(url){
return fetch(url)
.then(async response => {
if (!response.ok) {
throw new Error(`HTTP ${response.status} ${response.statusText} : ${await response.text()}`);
}
return response.json();
})
.then(data => {
return data;
})
.catch(async error => {
await Logger.error(`Failed to fetch`, error);
throw error;
});
}
static async doPostRequest(url, headers, body){
return await fetch(url,{
method: 'POST',
headers: headers,
body: body
})
.then(response => response.json())
.then(data => {
return data;
})
.catch(error => {
Logger.error(`Unable to fetch`, error);
});
}
}

View File

@@ -0,0 +1,20 @@
export default class StringFormatter{
/**
* Replaces tokens in a string with their corresponding values.
*
* Each token in the string should match a key in the `values` array.
* For example, given `s = "Hi :name !"` and `values = [{ key: ":name", value: "Foo" }]`,
* the function will return "Hi Foo !".
*
* @param {string} s - The string containing tokens to replace.
* @param {Array<{key: string, value: string}>} values - An array of objects mapping each token (`key`) to its replacement (`value`).
* @returns {string} The string with tokens replaced by their corresponding values.
*/
static format(s, values) {
values.forEach(v => {
s = s.replace(`:${v.key}`, v.value);
});
return s;
}
}

View File

@@ -0,0 +1,78 @@
import sharp from "sharp";
import {Logger} from "../logging/logger.js";
export class NameCardCreator {
/**
* Combines a template image with a user avatar and saves it
* @param {string} templatePath - path to the template image
* @param {string} avatarPath - path to the avatar
* @returns {Promise<sharp.OutputInfo>} resulting image buffer
*/
static async getWelcomeCard(templatePath, avatarPath) {
try{
const template = await this.loadTemplate(templatePath);
const avatar = await this.handleAvatar(avatarPath);
const result = await template
.composite([{
input: avatar,
top: 215,
left: 200,
}])
.toFile("namecard.png")
console.log("✅ Welcome card created: welcome-card.png");
return result;
} catch(err) {
await Logger.error("Unable to create name card", err);
}
}
/**
* Loads the template file into a sharp instance
* @param {string} path - file path
* @returns {sharp.Sharp} sharp image object
*/
static async loadTemplate(path) {
return sharp(path);
}
/**
* Overlays an image (item) on top of a base image
* @param {sharp.Sharp|string} base - sharp image or path
* @param {{item:string, x:number, y:number}} overlay - overlay info
* @returns {Promise<sharp.Sharp>} combined image
*/
static async mergeBitmaps(base, { item, x, y }) {
return null;
}
static async handleAvatar(avatarPath) {
const avatarSize = 670;
const borderSize = 8;
const radius = avatarSize / 2;
const totalSize = avatarSize + borderSize * 2;
const avatarBuffer = await sharp(avatarPath)
.resize(avatarSize, avatarSize, { fit: "cover" })
.png()
.toBuffer();
return await sharp({
create: {
width: totalSize,
height: totalSize,
channels: 4,
background: "#0000"
}
})
.composite([
{ input: avatarBuffer, top: borderSize, left: borderSize }
])
.png()
.toBuffer();
}
}