Initial commit
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
.idea
|
||||
logs
|
||||
node_modules
|
||||
package-lock.json
|
||||
|
||||
.env
|
||||
.instagram_client_tokens.json
|
||||
.tiktok_client_tokens.json
|
||||
posts.json
|
||||
11
Dockerfile
Normal file
11
Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM node:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["node", "app.js"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Naaturel
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
51
app.js
Normal file
51
app.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Logger } from './wwwroot/core/logging/logger.js';
|
||||
import JsonManager from './wwwroot/core/utils/jsonManager.js';
|
||||
import { data } from "./wwwroot/core/appData.js";
|
||||
|
||||
try{
|
||||
|
||||
data.instagramTokenManager.startWatching();
|
||||
|
||||
//data.tiktokTokenManager.startWatching();
|
||||
|
||||
data.instagramPoller.on("newPost", async ({permalink, userId}) => {
|
||||
const modified = JsonManager.upsertPost("./posts.json", {permalink, userId})
|
||||
if(!modified) return;
|
||||
|
||||
const message = `Behold ! Some new awesome content has been posted ! \n\n${permalink}`;
|
||||
await data.sender.send(data.socialChannelID, message)
|
||||
});
|
||||
|
||||
data.client.once('clientReady', async () => {
|
||||
await data.sender.send(data.updateChannelID, "I'm now online ! ✅")
|
||||
console.log(`✅ Logged in as ${data.client.user.tag}`);
|
||||
});
|
||||
|
||||
data.client.on('messageCreate', (message) => {
|
||||
const isGuildOwner = message.guild && message.author.id === message.guild.ownerId;
|
||||
if (message.content === '/login' && isGuildOwner) {
|
||||
message.reply(data.instagramTokenManager.getOauthUrl());
|
||||
}
|
||||
});
|
||||
|
||||
data.client.on('guildMemberAdd', member => {
|
||||
const avatar = member.user.avatarURL();
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
try {
|
||||
await data.sender.send(data.updateChannelID, "I'm shutting down, now. Bye! 👋")
|
||||
} catch (error) {
|
||||
await Logger.error("Error while shutting down", error);
|
||||
} finally {
|
||||
await data.client.destroy();
|
||||
process.exit(0);
|
||||
}
|
||||
});
|
||||
|
||||
await data.client.login(process.env.DISCORD_TOKEN);
|
||||
data.instagramPoller.start(600000);
|
||||
|
||||
} catch(err){
|
||||
await Logger.error("Unexpected error", err)
|
||||
}
|
||||
BIN
namecard.png
Normal file
BIN
namecard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "the-jailor",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "A simple discord bot",
|
||||
"main": "app.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=18.x"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node app.js",
|
||||
"register": "node commands.js",
|
||||
"dev": "nodemon app.js",
|
||||
"publish-patch-note" : "node patchnote-publisher.js",
|
||||
"name-card-test": "node tests/name-card-test.js",
|
||||
"istg-token-test": "node tests/token-renew.js",
|
||||
"token-watch-test" : "node tests/token-watch.js",
|
||||
"polling-test" : "node tests/polling.js"
|
||||
},
|
||||
"author": "Naaturel",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"discord.js": "^14.22.1",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"prompt-sync": "^4.2.0",
|
||||
"sharp": "^0.34.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.0"
|
||||
}
|
||||
}
|
||||
17
patchnote-publisher.js
Normal file
17
patchnote-publisher.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { data } from "./wwwroot/core/appData.js";
|
||||
import path from "path";
|
||||
import {readFileSync} from "fs";
|
||||
import {Logger} from "./wwwroot/core/logging/logger.js";
|
||||
|
||||
try{
|
||||
const filePath = "./patchnote.md";
|
||||
const fullPath = path.resolve(filePath);
|
||||
const patchNoteContent = readFileSync(fullPath, 'utf8');
|
||||
|
||||
await data.client.login(process.env.DISCORD_TOKEN);
|
||||
await data.sender.send(data.updateChannelID, patchNoteContent);
|
||||
await data.client.destroy();
|
||||
|
||||
} catch (err){
|
||||
await Logger.log("An error occurred while publishing patch note", err);
|
||||
}
|
||||
21
patchnote.md
Normal file
21
patchnote.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# What's new?
|
||||
|
||||
## Patch notes
|
||||
> The jailor will now send a patch note every time a new feature is added.
|
||||
|
||||
## Logging in
|
||||
> You can grant permission to the jailor by typing /login command. A link will be sent, click it, follow the instructions and... done!
|
||||
> NB : This command can be only be used by guild owner
|
||||
|
||||
## Refreshing logging
|
||||
> Granted permissions are not eternal, the jailor will now ask Instagram and TikTok APIs for new tokens as soon as needed. No need to manually re-log in anymore
|
||||
# What's next?
|
||||
|
||||
## Customized name card
|
||||
> A customized name card will be generated and sent in a specific channel when a new user join the guild!
|
||||
|
||||
## Tiktok login
|
||||
> You will soon be able to log in with TikTok to post updates about you content
|
||||
|
||||
# Questions?
|
||||
> Ask me on Twitter! https://twitter.com/naaturel_
|
||||
11
renovate.json
Normal file
11
renovate.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":disableDependencyDashboard",
|
||||
":preserveSemverRanges"
|
||||
],
|
||||
"ignorePaths": [
|
||||
"**/node_modules/**"
|
||||
]
|
||||
}
|
||||
6
tests/name-card-test.js
Normal file
6
tests/name-card-test.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import {NameCardCreator} from "../wwwroot/core/welcome/nameCardCreator.js";
|
||||
|
||||
const templatePath = "./assets/name-card-template.png";
|
||||
const avatarPath = "./assets/avatar-test.png";
|
||||
|
||||
const res = await NameCardCreator.getWelcomeCard(templatePath, avatarPath);
|
||||
8
tests/polling.js
Normal file
8
tests/polling.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { data } from "../wwwroot/core/appData.js";
|
||||
|
||||
try {
|
||||
console.log("Testing polling...");
|
||||
await data.instagramPoller.pollOnce();
|
||||
} catch(e){
|
||||
console.error(e);
|
||||
}
|
||||
31
tests/token-renew.js
Normal file
31
tests/token-renew.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import {data} from '../wwwroot/core/appData.js';
|
||||
import promptSync from 'prompt-sync';
|
||||
|
||||
|
||||
|
||||
const userData = await generate();
|
||||
console.log(userData);
|
||||
const newUserData = await renew(userData)
|
||||
|
||||
console.log(newUserData);
|
||||
|
||||
async function generate(){
|
||||
console.log("Testing token generation...")
|
||||
|
||||
const url = data.instagramTokenManager.getOauthUrl();
|
||||
|
||||
console.log("Go to this url : \n" + url);
|
||||
|
||||
const prompt = promptSync();
|
||||
const code = prompt('Enter generated code : ');
|
||||
|
||||
return await data.instagramTokenManager.generate(code)
|
||||
}
|
||||
|
||||
async function renew(userData){
|
||||
console.log("Testing token refresh...")
|
||||
|
||||
const res = await data.instagramTokenManager.renew(userData);
|
||||
|
||||
console.log(res)
|
||||
}
|
||||
5
tests/token-watch.js
Normal file
5
tests/token-watch.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import {data} from "../wwwroot/core/appData.js";
|
||||
|
||||
|
||||
data.instagramTokenManager.watchDelay = 5*1000;
|
||||
data.instagramTokenManager.startWatching();
|
||||
BIN
wwwroot/assets/avatar-test.png
Normal file
BIN
wwwroot/assets/avatar-test.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 764 KiB |
BIN
wwwroot/assets/name-card-template.png
Normal file
BIN
wwwroot/assets/name-card-template.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 946 KiB |
52
wwwroot/core/appData.js
Normal file
52
wwwroot/core/appData.js
Normal 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 : ""}
|
||||
);
|
||||
*/
|
||||
20
wwwroot/core/base/MessageSender.js
Normal file
20
wwwroot/core/base/MessageSender.js
Normal 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!');
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
32
wwwroot/core/base/basePoller.js
Normal file
32
wwwroot/core/base/basePoller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
47
wwwroot/core/base/baseTokenManager.js
Normal file
47
wwwroot/core/base/baseTokenManager.js
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
53
wwwroot/core/instagram/instagramPoller.js
Normal file
53
wwwroot/core/instagram/instagramPoller.js
Normal 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;
|
||||
}
|
||||
}
|
||||
88
wwwroot/core/instagram/instagramTokenManager.js
Normal file
88
wwwroot/core/instagram/instagramTokenManager.js
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
38
wwwroot/core/logging/logger.js
Normal file
38
wwwroot/core/logging/logger.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
wwwroot/core/tiktok/tikTokPoller.js
Normal file
26
wwwroot/core/tiktok/tikTokPoller.js
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
38
wwwroot/core/tiktok/tiktokTokenManager.js
Normal file
38
wwwroot/core/tiktok/tiktokTokenManager.js
Normal 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
|
||||
}
|
||||
}
|
||||
26
wwwroot/core/usersToken.js
Normal file
26
wwwroot/core/usersToken.js
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
84
wwwroot/core/utils/jsonManager.js
Normal file
84
wwwroot/core/utils/jsonManager.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
35
wwwroot/core/utils/requester.js
Normal file
35
wwwroot/core/utils/requester.js
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
20
wwwroot/core/utils/stringFormatter.js
Normal file
20
wwwroot/core/utils/stringFormatter.js
Normal 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;
|
||||
}
|
||||
}
|
||||
78
wwwroot/core/welcome/nameCardCreator.js
Normal file
78
wwwroot/core/welcome/nameCardCreator.js
Normal 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();
|
||||
}
|
||||
}
|
||||
67
wwwroot/legal/privacy/content.html
Normal file
67
wwwroot/legal/privacy/content.html
Normal file
@@ -0,0 +1,67 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Privacy notice for Instagram and TikTok API integrations." />
|
||||
<title>Privacy Notice | The Jailor</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #fafafa;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.privacy-notice {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #222;
|
||||
}
|
||||
p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
footer {
|
||||
font-size: 0.9rem;
|
||||
color: #777;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="privacy-notice" aria-labelledby="privacy-title" role="region">
|
||||
<h1 id="privacy-title">Privacy Notice</h1>
|
||||
<p>
|
||||
This app connects to Instagram and/or TikTok APIs to display or manage your social content with your permission.
|
||||
We only access basic profile information and media data that you authorize.
|
||||
This data is used solely to provide the features you request.
|
||||
</p>
|
||||
<p>
|
||||
We do not share personal data with third parties for marketing or analytics purposes.
|
||||
You can revoke access at any time through your Instagram or TikTok account settings.
|
||||
</p>
|
||||
<footer>© 2025 The Jailor. All rights reserved.</footer>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
103
wwwroot/legal/terms-of-service/content.html
Normal file
103
wwwroot/legal/terms-of-service/content.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Terms of Service for Social App using Instagram and TikTok APIs." />
|
||||
<title>Terms of Service | The Jailor</title>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #fafafa;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.privacy-notice {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
max-width: 600px;
|
||||
padding: 2rem;
|
||||
text-align: left;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #222;
|
||||
}
|
||||
p {
|
||||
font-size: 1rem;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
footer {
|
||||
font-size: 0.9rem;
|
||||
color: #777;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="terms" aria-labelledby="terms-title" role="region">
|
||||
<h1 id="terms-title">Terms of Service</h1>
|
||||
<p>
|
||||
By using this app, you agree to these Terms of Service and our
|
||||
<a href="https://the-jailor.naaturel.be/privacy" target="_blank" rel="noopener noreferrer">Privacy Policy</a>.
|
||||
Please read them carefully before using our features.
|
||||
</p>
|
||||
|
||||
<h2>1. Use of Service</h2>
|
||||
<p>
|
||||
This app allows you to connect your Instagram and/or TikTok accounts to access and manage your content with your consent.
|
||||
You agree to use the service only as permitted by law and in accordance with the respective platform policies.
|
||||
</p>
|
||||
|
||||
<h2>2. User Data and Permissions</h2>
|
||||
<p>
|
||||
We only access data that you explicitly authorize through the Instagram or TikTok APIs.
|
||||
This includes limited profile and media data necessary to deliver the requested functionality.
|
||||
You can revoke this access at any time in your social platform account settings.
|
||||
</p>
|
||||
|
||||
<h2>3. Restrictions</h2>
|
||||
<p>
|
||||
You may not use this service to infringe on the rights of others, engage in unauthorized scraping,
|
||||
distribute spam, or violate any laws or platform terms.
|
||||
</p>
|
||||
|
||||
<h2>4. Service Availability</h2>
|
||||
<p>
|
||||
We may modify, suspend, or discontinue certain features at any time.
|
||||
We are not liable for downtime or disruptions caused by third-party API changes or technical issues.
|
||||
</p>
|
||||
|
||||
<h2>5. Limitation of Liability</h2>
|
||||
<p>
|
||||
To the maximum extent permitted by law, we are not responsible for any indirect, incidental, or consequential damages arising from your use of the service.
|
||||
</p>
|
||||
|
||||
<h2>6. Changes to Terms</h2>
|
||||
<p>
|
||||
We may update these Terms from time to time. Continued use of the app after updates means you accept the revised terms.
|
||||
</p>
|
||||
|
||||
<footer>
|
||||
© 2025 The Jailor. All rights reserved. |
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
26
wwwroot/oauth/index.html
Normal file
26
wwwroot/oauth/index.html
Normal file
@@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>The Jailor auth</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="result-text"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<script type="module">
|
||||
import {data} from "../core/appData.js";
|
||||
|
||||
try{
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get("code");
|
||||
|
||||
await data.instagramTokenManager.generate(code)
|
||||
|
||||
document.querySelector("#code").innerText = "Authentication successful. You can close this tab";
|
||||
} catch(err){
|
||||
document.querySelector("#code").innerText = "Authentication failed.";
|
||||
}
|
||||
|
||||
</script>
|
||||
Reference in New Issue
Block a user