diff --git a/bun.lock b/bun.lock index 77b8f0b..a534e10 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "ai": "^5.0.68", "bson": "^6.10.4", "bullmq": "^5.61.0", + "cron": "^4.3.4", "discord-api-types": "^0.38.30", "ioredis": "^5.8.1", "zod": "^4.1.12", @@ -140,6 +141,8 @@ "@types/bun": ["@types/bun@1.3.0", "", { "dependencies": { "bun-types": "1.3.0" } }, "sha512-+lAGCYjXjip2qY375xX/scJeVRmZ5cY0wyHYyCYxNcdEXrQ4AOe3gACgd4iQ8ksOslJtW4VNxBJ8llUwc3a6AA=="], + "@types/luxon": ["@types/luxon@3.7.1", "", {}, "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg=="], + "@types/node": ["@types/node@24.7.2", "", { "dependencies": { "undici-types": "~7.14.0" } }, "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA=="], "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], @@ -182,6 +185,8 @@ "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "cron": ["cron@4.3.4", "", { "dependencies": { "@types/luxon": "~3.7.0", "luxon": "~3.7.0" } }, "sha512-OiO0l73MGhQOZQCjYZ0v7r8yFWpBOWteemwR1RIxiHtfVsIOwiTJZDvg7GmKzggkwC0RO8tI3P1QYBUCIZNYRQ=="], + "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="], "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], diff --git a/package.json b/package.json index 83cbf5c..d6683b9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "ai": "^5.0.68", "bson": "^6.10.4", "bullmq": "^5.61.0", + "cron": "^4.3.4", "discord-api-types": "^0.38.30", "ioredis": "^5.8.1", "zod": "^4.1.12" diff --git a/src/index.ts b/src/index.ts index cdee91e..828fe4f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ import { ObjectId } from "bson"; import { rateLimiter } from "./services/rate-limiter"; import { messageQueue, setWorkerContext } from "./services/message-queue"; import axios from "axios"; +import { startMigrationCronJob } from "./scripts/cron-jobs"; import { startServer } from "./server"; const prisma = new PrismaClient(); @@ -74,6 +75,8 @@ client.once(GatewayDispatchEvents.Ready, ({ data }) => { }); console.log("โœ… Worker context initialized"); + + startMigrationCronJob(client.api, botId); }); client.on( diff --git a/src/scripts/cron-jobs.ts b/src/scripts/cron-jobs.ts new file mode 100644 index 0000000..1112e12 --- /dev/null +++ b/src/scripts/cron-jobs.ts @@ -0,0 +1,36 @@ +import { CronJob } from "cron"; +import { migrateRoles } from "./migrate-roles"; +import { Client } from "@discordjs/core"; + +export function startMigrationCronJob(api: Client["api"], botId: string) { + console.log("[CRON] ๐Ÿ•’ Scheduling role migration job..."); + + const migrateRolesJob = new CronJob( + "*/10 * * * *", + async () => { + try { + console.log("[CRON] ๐Ÿš€ Starting scheduled role migration..."); + const stats = await migrateRoles(api, botId); + console.log( + `[CRON] โœ… Scheduled role migration finished successfully. Added/Removed: ${ + stats.premiumAdded + + stats.premiumRemoved + + stats.freemiumAdded + + stats.freemiumRemoved + }`, + ); + } catch (error) { + console.error("[CRON] โŒ Scheduled role migration failed:", error); + } + }, + null, + true, + "America/Sao_Paulo", + ); + + console.log( + `[CRON] โœ… Role migration job scheduled. Next run: ${migrateRolesJob + .nextDate() + .toString()}`, + ); +} diff --git a/src/scripts/migrate-roles.ts b/src/scripts/migrate-roles.ts index 0c7a357..f63042b 100644 --- a/src/scripts/migrate-roles.ts +++ b/src/scripts/migrate-roles.ts @@ -1,13 +1,7 @@ -import { - Client, - GatewayDispatchEvents, - GatewayIntentBits, -} from "@discordjs/core"; -import { REST } from "@discordjs/rest"; -import { WebSocketManager } from "@discordjs/ws"; +import { Client } from "@discordjs/core"; import axios, { AxiosError } from "axios"; - import dotenv from "dotenv"; + dotenv.config(); const sleep = (ms: number): Promise => @@ -29,347 +23,257 @@ interface MigrationStats { errors: number; } -(async () => { - const args = process.argv.slice(2); - const isProdFlag = args.includes("--prod"); - - const limitIndex = args.indexOf("--limit"); - let limit: number | undefined; - - if (limitIndex !== -1 && args[limitIndex + 1]) { - const limitValue = parseInt(args[limitIndex + 1], 10); - if (!isNaN(limitValue) && limitValue > 0) { - limit = limitValue; - } else { - console.error("โŒ Invalid --limit value. Must be a positive number."); - console.log("\nUsage: bun migrate-roles [--limit N] [--prod]"); - process.exit(1); - } - } - - const isProduction = isProdFlag || process.env.NODE_ENV === "prod"; - - console.log(`๐ŸŒ Environment: ${isProduction ? "PRODUCTION" : "DEVELOPMENT"}`); - - if (limit !== undefined) { - console.log(`๐Ÿงช TEST MODE: Processing only ${limit} users`); - } - - console.log(""); - - // const FREEMIUM_ROLE_ID = isProduction - // ? "1403038020642275389" - // : "1433233637444288605"; - // const PREMIUM_ROLE_ID = isProduction - // ? "1407855781872799845" - // : "1433233614367097002"; +export async function migrateRoles( + api: Client["api"], + botId: string +): Promise { + const isProduction = process.env.NODE_ENV === "prod"; + console.log( + `[CRON] ๐ŸŒ Starting role migration. Environment: ${ + isProduction ? "PRODUCTION" : "DEVELOPMENT" + }` + ); const FREEMIUM_ROLE_ID = "1403038020642275389"; const PREMIUM_ROLE_ID = "1407855781872799845"; - console.log(`๐ŸŽญ Using roles:`); - console.log(` FREEMIUM: ${FREEMIUM_ROLE_ID}`); - console.log(` PREMIUM: ${PREMIUM_ROLE_ID}\n`); + console.log(`[CRON] ๐ŸŽญ Using roles:`); + console.log(`[CRON] FREEMIUM: ${FREEMIUM_ROLE_ID}`); + console.log(`[CRON] PREMIUM: ${PREMIUM_ROLE_ID}\n`); - const rest = new REST({ version: "10" }).setToken( - process.env.DISCORD_TOKEN ?? "" - ); + const guildId = process.env.DISCORD_GUILD_ID; - const gateway = new WebSocketManager({ - token: process.env.DISCORD_TOKEN, - intents: - GatewayIntentBits.Guilds | - GatewayIntentBits.GuildMessages | - GatewayIntentBits.MessageContent | - GatewayIntentBits.GuildMembers, - rest, - }); + if (!guildId) { + throw new Error( + "[CRON] โŒ DISCORD_GUILD_ID not found in environment variables" + ); + } - const client = new Client({ rest, gateway }); + console.log(`[CRON] Fetching guild with ID: ${guildId}`); - client.once(GatewayDispatchEvents.Ready, async ({ data, api }) => { - const botId = data.user.id; - console.log(`Ready! Logged in as ${data.user.username}`); + try { + const guild = await api.guilds.get(guildId); + console.log(`[CRON] โœ… Guild found: ${guild.name} (${guildId})`); - const guildId = process.env.DISCORD_GUILD_ID; + const guildRoles = await api.guilds.getRoles(guildId); + const hasFreemiumRole = guildRoles.some((r) => r.id === FREEMIUM_ROLE_ID); + const hasPremiumRole = guildRoles.some((r) => r.id === PREMIUM_ROLE_ID); - if (!guildId) { - console.error("โŒ DISCORD_GUILD_ID not found in environment variables"); - process.exit(1); + if (!hasFreemiumRole || !hasPremiumRole) { + let errorMessage = "[CRON] โŒ ERROR: Required roles not found in server!"; + if (!hasFreemiumRole) { + errorMessage += `\n Missing FREEMIUM role: ${FREEMIUM_ROLE_ID}`; + } + if (!hasPremiumRole) { + errorMessage += `\n Missing PREMIUM role: ${PREMIUM_ROLE_ID}`; + } + throw new Error(errorMessage); } - console.log(`Fetching guild with ID: ${guildId}`); - - try { - const guild = await api.guilds.get(guildId); - console.log(`โœ… Guild found: ${guild.name} (${guildId})`); + console.log("[CRON] โœ… Roles validated in server"); - const guildRoles = await api.guilds.getRoles(guildId); - const hasFreemiumRole = guildRoles.some((r) => r.id === FREEMIUM_ROLE_ID); - const hasPremiumRole = guildRoles.some((r) => r.id === PREMIUM_ROLE_ID); + const botMember = await api.guilds.getMember(guildId, botId); + const botRoles = botMember.roles; - if (!hasFreemiumRole || !hasPremiumRole) { - console.error("\nโŒ ERROR: Required roles not found in this server!"); - if (!hasFreemiumRole) { - console.error(` Missing FREEMIUM role: ${FREEMIUM_ROLE_ID}`); - } - if (!hasPremiumRole) { - console.error(` Missing PREMIUM role: ${PREMIUM_ROLE_ID}`); - } - console.error( - "\n๐Ÿ’ก Tip: Make sure you're using the correct environment flag (--prod or dev)" - ); - process.exit(1); + let botHighestPosition = 0; + for (const roleId of botRoles) { + const role = guildRoles.find((r) => r.id === roleId); + if (role && role.position > botHighestPosition) { + botHighestPosition = role.position; } + } - console.log("โœ… Roles validated in server"); - - const botMember = await api.guilds.getMember(guildId, data.user.id); - const botRoles = botMember.roles; - - let botHighestPosition = 0; - for (const roleId of botRoles) { - const role = guildRoles.find((r) => r.id === roleId); - if (role && role.position > botHighestPosition) { - botHighestPosition = role.position; - } + const freemiumRole = guildRoles.find((r) => r.id === FREEMIUM_ROLE_ID); + const premiumRole = guildRoles.find((r) => r.id === PREMIUM_ROLE_ID); + + const freemiumPosition = freemiumRole?.position ?? 0; + const premiumPosition = premiumRole?.position ?? 0; + + console.log(`[CRON] \n๐Ÿ” Checking role hierarchy...`); + console.log(`[CRON] Bot's highest role position: ${botHighestPosition}`); + console.log( + `[CRON] FREEMIUM role (${freemiumRole?.name}): position ${freemiumPosition}` + ); + console.log( + `[CRON] PREMIUM role (${premiumRole?.name}): position ${premiumPosition}` + ); + + if ( + freemiumPosition >= botHighestPosition || + premiumPosition >= botHighestPosition + ) { + let errorMessage = + "[CRON] โŒ HIERARCHY ERROR: Bot's role is below target roles!"; + if (premiumPosition >= botHighestPosition) { + errorMessage += `\n PREMIUM role is at position ${premiumPosition} (TOO HIGH)`; + } + if (freemiumPosition >= botHighestPosition) { + errorMessage += `\n FREEMIUM role is at position ${freemiumPosition} (TOO HIGH)`; } + throw new Error(errorMessage); + } - const freemiumRole = guildRoles.find((r) => r.id === FREEMIUM_ROLE_ID); - const premiumRole = guildRoles.find((r) => r.id === PREMIUM_ROLE_ID); + console.log("[CRON] โœ… Role hierarchy is correct\n"); - const freemiumPosition = freemiumRole?.position ?? 0; - const premiumPosition = premiumRole?.position ?? 0; + console.log("[CRON] ๐Ÿ“ฅ Fetching all members (with pagination)..."); + const members = []; + let lastMemberId: string | undefined; + let fetchedCount = 0; + let hasMore = true; - console.log(`\n๐Ÿ” Checking role hierarchy...`); - console.log(` Bot's highest role position: ${botHighestPosition}`); - console.log( - ` FREEMIUM role (${freemiumRole?.name}): position ${freemiumPosition}` - ); - console.log( - ` PREMIUM role (${premiumRole?.name}): position ${premiumPosition}` - ); + while (hasMore) { + const chunk = await api.guilds.getMembers(guildId, { + limit: 1000, + after: lastMemberId, + }); - if ( - freemiumPosition >= botHighestPosition || - premiumPosition >= botHighestPosition - ) { - console.error( - "\nโŒ HIERARCHY ERROR: Bot's role is below target roles!" - ); - console.error(` Bot's highest position: ${botHighestPosition}`); - if (premiumPosition >= botHighestPosition) { - console.error( - ` PREMIUM role is at position ${premiumPosition} (TOO HIGH)` - ); - } - if (freemiumPosition >= botHighestPosition) { - console.error( - ` FREEMIUM role is at position ${freemiumPosition} (TOO HIGH)` - ); - } - console.error("\n๐Ÿ’ก Solution: In Discord Server Settings > Roles:"); - console.error( - " Drag the bot's role ABOVE both PREMIUM and FREEMIUM roles\n" - ); - process.exit(1); + fetchedCount += chunk.length; + members.push(...chunk); + + if (chunk.length > 0) { + lastMemberId = chunk[chunk.length - 1]?.user?.id; + console.log(`[CRON] Fetched ${fetchedCount} members so far...`); } - console.log("โœ… Role hierarchy is correct\n"); + hasMore = chunk.length === 1000; + } - console.log("๐Ÿ“ฅ Fetching all members (with pagination)..."); - const members = []; - let lastMemberId: string | undefined; - let fetchedCount = 0; - let hasMore = true; + console.log( + `[CRON] โœ… Found ${members.length} total members in Discord server` + ); - while (hasMore) { - const chunk = await api.guilds.getMembers(guildId, { - limit: 1000, - after: lastMemberId, - }); + console.log( + `[CRON] \n๐Ÿ“ก Fetching members from BeroLab API: ${process.env.BEROLAB_API_ENDPOINT}/discord` + ); - fetchedCount += chunk.length; - members.push(...chunk); + let blabMembers: BeroLabMember[] = []; - if (chunk.length > 0) { - lastMemberId = chunk[chunk.length - 1]?.user?.id; - console.log(` Fetched ${fetchedCount} members so far...`); + try { + const blabReq = await axios.get( + `${process.env.BEROLAB_API_ENDPOINT}/discord`, + { + headers: { + Cookie: `__Host-next-auth.csrf-token=${process.env.BEROLAB_AUTH_CSRF_TOKEN}; __Secure-next-auth.callback-url=${process.env.BEROLAB_AUTH_CALLBACK_URL}; next-auth.session-token=${process.env.BEROLAB_AUTH_TOKEN}`, + }, } + ); - hasMore = chunk.length === 1000; - } - - console.log(`โœ… Found ${members.length} total members in Discord server`); - + blabMembers = blabReq.data; console.log( - `\n๐Ÿ“ก Fetching members from BeroLab API: ${process.env.BEROLAB_API_ENDPOINT}/discord` + `[CRON] โœ… Found ${blabMembers.length} members in BeroLab API` ); + } catch (error) { + if (error instanceof AxiosError) { + throw new Error(`[CRON] โŒ Error fetching blab members: ${error.message}`); + } else { + throw new Error(`[CRON] โŒ Error fetching blab members: ${error}`); + } + } + + const stats: MigrationStats = { + total: blabMembers.length, + alreadyCorrect: 0, + premiumAdded: 0, + premiumRemoved: 0, + freemiumAdded: 0, + freemiumRemoved: 0, + errors: 0, + }; - let blabMembers: BeroLabMember[] = []; + console.log("[CRON] \n๐Ÿ”„ Starting role migration...\n"); + for (const blabMember of blabMembers) { try { - const blabReq = await axios.get( - `${process.env.BEROLAB_API_ENDPOINT}/discord`, - { - headers: { - Cookie: `__Host-next-auth.csrf-token=${process.env.BEROLAB_AUTH_CSRF_TOKEN}; __Secure-next-auth.callback-url=${process.env.BEROLAB_AUTH_CALLBACK_URL}; next-auth.session-token=${process.env.BEROLAB_AUTH_TOKEN}`, - }, - } + const discordMember = members.find( + (m) => m.user?.id === blabMember.discordId ); - blabMembers = blabReq.data; - console.log(`โœ… Found ${blabMembers.length} members in BeroLab API`); - } catch (error) { - if (error instanceof AxiosError) { - console.error("โŒ Error fetching blab members:", error.message); - } else { - console.error("โŒ Error fetching blab members:", error); + if (!discordMember) { + console.log( + `[CRON] โš ๏ธ User ${blabMember.discordId} not found in Discord server, skipping...` + ); + continue; } - process.exit(1); - } - const membersToProcess = - limit !== undefined ? blabMembers.slice(0, limit) : blabMembers; - - const stats: MigrationStats = { - total: membersToProcess.length, - alreadyCorrect: 0, - premiumAdded: 0, - premiumRemoved: 0, - freemiumAdded: 0, - freemiumRemoved: 0, - errors: 0, - }; - - console.log("\n๐Ÿ”„ Starting role migration...\n"); - - for (const blabMember of membersToProcess) { - try { - const discordMember = members.find( - (m) => m.user?.id === blabMember.discordId - ); + const userRoles = discordMember.roles ?? []; + const hasPremium = userRoles.includes(PREMIUM_ROLE_ID); + const hasFreemium = userRoles.includes(FREEMIUM_ROLE_ID); + + const expectedRole = blabMember.isPremium ? "PREMIUM" : "FREEMIUM"; + const currentRole = hasPremium + ? "PREMIUM" + : hasFreemium + ? "FREEMIUM" + : "NONE"; + + if ( + (blabMember.isPremium && hasPremium) || + (!blabMember.isPremium && hasFreemium) + ) { + stats.alreadyCorrect++; + continue; + } - if (!discordMember) { - console.log( - `โš ๏ธ User ${blabMember.discordId} not found in Discord server, skipping...` + if (blabMember.isPremium) { + if (hasFreemium) { + await api.guilds.removeRoleFromMember( + guildId, + blabMember.discordId, + FREEMIUM_ROLE_ID ); - continue; + stats.freemiumRemoved++; + await sleep(500); } - const userRoles = discordMember.roles ?? []; - const hasPremium = userRoles.includes(PREMIUM_ROLE_ID); - const hasFreemium = userRoles.includes(FREEMIUM_ROLE_ID); - - const expectedRole = blabMember.isPremium ? "PREMIUM" : "FREEMIUM"; - const currentRole = hasPremium - ? "PREMIUM" - : hasFreemium - ? "FREEMIUM" - : "NONE"; - - console.log( - `๐Ÿ‘ค ${discordMember.user?.username} (${blabMember.discordId}): Current=${currentRole}, Expected=${expectedRole}` + await api.guilds.addRoleToMember( + guildId, + blabMember.discordId, + PREMIUM_ROLE_ID ); - - if (blabMember.isPremium && hasPremium) { - console.log(" โœ“ Already has correct PREMIUM role"); - stats.alreadyCorrect++; - continue; - } - - if (!blabMember.isPremium && hasFreemium) { - console.log(" โœ“ Already has correct FREEMIUM role"); - stats.alreadyCorrect++; - continue; - } - - let rolesModified = false; - - if (blabMember.isPremium) { - if (hasFreemium) { - console.log(" ๐Ÿ”„ Removing FREEMIUM role..."); - await api.guilds.removeRoleFromMember( - guildId, - blabMember.discordId, - FREEMIUM_ROLE_ID - ); - stats.freemiumRemoved++; - rolesModified = true; - await sleep(500); - } - - console.log(" โž• Adding PREMIUM role..."); - await api.guilds.addRoleToMember( + stats.premiumAdded++; + } else { + if (hasPremium) { + await api.guilds.removeRoleFromMember( guildId, blabMember.discordId, PREMIUM_ROLE_ID ); - stats.premiumAdded++; - console.log(" โœ… PREMIUM role added successfully"); - rolesModified = true; - } else { - if (hasPremium) { - console.log(" ๐Ÿ”„ Removing PREMIUM role..."); - await api.guilds.removeRoleFromMember( - guildId, - blabMember.discordId, - PREMIUM_ROLE_ID - ); - stats.premiumRemoved++; - rolesModified = true; - await sleep(500); - } - - console.log(" โž• Adding FREEMIUM role..."); - await api.guilds.addRoleToMember( - guildId, - blabMember.discordId, - FREEMIUM_ROLE_ID - ); - stats.freemiumAdded++; - console.log(" โœ… FREEMIUM role added successfully"); - rolesModified = true; + stats.premiumRemoved++; + await sleep(500); } - if (rolesModified) { - await sleep(1000); - } - } catch (error) { - stats.errors++; - console.error( - ` โŒ Error processing user ${blabMember.discordId}:`, - error instanceof Error ? error.message : error + await api.guilds.addRoleToMember( + guildId, + blabMember.discordId, + FREEMIUM_ROLE_ID ); + stats.freemiumAdded++; } - - console.log(""); - } - - console.log("\n" + "=".repeat(50)); - console.log("๐Ÿ“Š MIGRATION SUMMARY"); - console.log("=".repeat(50)); - if (limit !== undefined) { - console.log( - `๐Ÿงช TEST MODE: Processed ${stats.total} of ${blabMembers.length} total members` + await sleep(1000); + } catch (error) { + stats.errors++; + console.error( + `[CRON] โŒ Error processing user ${blabMember.discordId}:`, + error instanceof Error ? error.message : error ); } - console.log(`Total members processed: ${stats.total}`); - console.log(`Already correct: ${stats.alreadyCorrect}`); - console.log(`PREMIUM roles added: ${stats.premiumAdded}`); - console.log(`PREMIUM roles removed: ${stats.premiumRemoved}`); - console.log(`FREEMIUM roles added: ${stats.freemiumAdded}`); - console.log(`FREEMIUM roles removed: ${stats.freemiumRemoved}`); - console.log(`Errors: ${stats.errors}`); - console.log("=".repeat(50) + "\n"); - - process.exit(0); - } catch (error) { - console.error("โŒ Error fetching guild:", error); - console.error(`Make sure the bot is in the guild with ID: ${guildId}`); - process.exit(1); } - }); - gateway.connect(); -})(); + console.log("\n" + "=".repeat(50)); + console.log("๐Ÿ“Š [CRON] MIGRATION SUMMARY"); + console.log("=".repeat(50)); + console.log(`Total members processed: ${stats.total}`); + console.log(`Already correct: ${stats.alreadyCorrect}`); + console.log(`PREMIUM roles added: ${stats.premiumAdded}`); + console.log(`PREMIUM roles removed: ${stats.premiumRemoved}`); + console.log(`FREEMIUM roles added: ${stats.freemiumAdded}`); + console.log(`FREEMIUM roles removed: ${stats.freemiumRemoved}`); + console.log(`Errors: ${stats.errors}`); + console.log("=".repeat(50) + "\n"); + + return stats; + } catch (error) { + console.error("[CRON] โŒ Error during role migration:", error); + throw error; + } +}