import { Worker } from 'bullmq'; import { PermissionFlagsBits } from 'discord.js'; import { mkdir, readdir, rm, stat } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { spawn } from 'node:child_process'; import { redis } from './redis.js'; import { logger } from './logger.js'; import type { BotContext } from './types.js'; import { env } from './env.js'; import { refreshPhishingDomains } from './modules/automod/filters.js'; import { activateLockdown, deactivateLockdown } from './modules/automod/actions.js'; import { completeVerification, runVerificationPanelSyncJob } from './modules/verification/handlers.js'; import { closePoll } from './modules/utility/poll.js'; import { sendReminder } from './modules/utility/reminders.js'; import { expireSelfRole, runSelfRolePanelCreateJob, runSelfRolePanelUpdateJob, runSelfRolePanelDeleteJob } from './modules/selfroles/index.js'; import type { SelfRoleBehavior, SelfRoleEntry, SelfRoleMode } from '@nexumi/shared'; import { endGiveaway, runGiveawayCreateJob, runGiveawayPauseJob, runGiveawayDeleteJob, GiveawayError } from './modules/giveaways/index.js'; import { closeInactiveTickets, runTicketPanelSyncJob } from './modules/tickets/index.js'; import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; import { pollAllFeeds } from './modules/feeds/index.js'; import { sendScheduledMessage } from './modules/scheduler/index.js'; import { sendDashboardMessage, type DashboardMessageSendJob } from './modules/components-v2/send.js'; import { runGuildBackupRestoreJob } from './modules/guildbackup/index.js'; import { runSuggestionStatusUpdateJob } from './modules/suggestions/index.js'; import { runPresenceRefreshJob } from './presence.js'; import { automodQueue, automodQueueName, backupQueue, backupQueueName, birthdayQueue, birthdayQueueName, feedsQueue, feedsQueueName, giveawayQueueName, guildBackupQueueName, messagesQueueName, moderationQueueName, presenceQueue, presenceQueueName, reminderQueueName, retentionQueue, retentionQueueName, scheduleQueueName, selfrolesQueueName, suggestionsQueueName, ticketQueue, ticketQueueName, verificationQueueName } from './queues.js'; export { automodQueue, backupQueue, moderationQueue, moderationQueueName, reminderQueue, verificationQueue } from './queues.js'; type TempBanJob = { guildId: string; userId: string; reason: string | null; }; type VerificationCompleteJob = { guildId: string; userId: string; ipHash?: string | null; userAgentHash?: string | null; captchaProvider?: string | null; }; type VerificationPanelSyncJob = { guildId: string; }; type ReminderSendJob = { reminderId: string; }; type PollCloseJob = { pollId: string; }; type SelfRoleExpireJob = { guildId: string; userId: string; roleId: string; }; type GiveawayEndJob = { giveawayId: string; /** When true, end even if the giveaway is paused (dashboard/slash paths). */ force?: boolean; }; type GiveawayCreateJob = { guildId: string; channelId: string; hostId: string; prize: string; winnerCount: number; durationMs: number; requiredRoleId?: string | null; requiredLevel?: number | null; requiredMemberDays?: number | null; }; type GiveawayPauseJob = { giveawayId: string; paused: boolean; }; type GiveawayDeleteJob = { giveawayId: string; }; type BirthdayRoleExpireJob = { guildId: string; userId: string; roleId: string; }; type SuggestionStatusUpdateJob = { suggestionId: string; guildId: string; status: 'OPEN' | 'APPROVED' | 'DENIED' | 'CONSIDERED' | 'IMPLEMENTED'; reason: string; }; type SelfRolePanelCreateJob = { guildId: string; channelId: string; title: string; description?: string | null; mode: SelfRoleMode; behavior: SelfRoleBehavior; roles: SelfRoleEntry[]; temporaryDurationMs?: number; expiresAt?: string | null; }; type SelfRolePanelUpdateJob = { panelId: string; guildId: string; channelId?: string; title?: string; description?: string | null; mode?: SelfRoleMode; behavior?: SelfRoleBehavior; roles?: SelfRoleEntry[]; temporaryDurationMs?: number; expiresAt?: string | null; }; type SelfRolePanelDeleteJob = { panelId: string; guildId: string; }; export function startWorkers(context: BotContext): Worker[] { const moderationWorker = new Worker( moderationQueueName, async (job) => { if (job.name !== 'tempBanExpire') { return; } const { guildId, userId, reason } = job.data; const guild = context.client.guilds.cache.get(guildId); if (!guild) { if (!context.client.shard) { throw new Error(`Guild ${guildId} not available for temp ban expiration`); } const results = await context.client.shard.broadcastEval( async (c, payload) => { const g = c.guilds.cache.get(payload.guildId); if (!g) { return false; } const me = await g.members.fetchMe(); if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { throw new Error('Missing BanMembers permission for temp ban expiration'); } try { await g.members.unban(payload.userId, payload.reason ?? 'Temporary ban expired'); } catch (error) { const code = typeof error === 'object' && error && 'code' in error ? Number((error as { code: unknown }).code) : 0; // Unknown ban (10026) — already unbanned if (code === 10026) { return true; } throw error; } return true; }, { context: { guildId, userId, reason } } ); if (!results.some(Boolean)) { throw new Error(`Guild ${guildId} not found on any shard for temp ban expiration`); } } else { const me = await guild.members.fetchMe(); if (!me.permissions.has(PermissionFlagsBits.BanMembers)) { throw new Error('Missing BanMembers permission for temp ban expiration'); } try { await guild.members.unban(userId, reason ?? 'Temporary ban expired'); } catch (error) { const code = typeof error === 'object' && error && 'code' in error ? Number((error as { code: unknown }).code) : 0; if (code !== 10026) { throw error; } } } const { createCase } = await import('./modules/moderation/service.js'); await createCase(context, { guildId, targetUserId: userId, moderatorId: context.client.user?.id ?? 'system', action: 'UNBAN', reason: reason ?? 'Temporary ban expired', source: 'job', metadata: { tempBanExpire: true } }); }, { connection: redis } ); moderationWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Moderation job failed'); }); const backupWorker = new Worker( backupQueueName, async (job) => { if (job.name !== 'dailyPgDump') { return; } await runPgDumpBackup(); }, { connection: redis } ); backupWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Backup job failed'); }); const automodWorker = new Worker( automodQueueName, async (job) => { if (job.name === 'refreshPhishingList') { const count = await refreshPhishingDomains(redis); logger.info({ domainCount: count }, 'Phishing domain list refreshed'); return; } if (job.name === 'deactivateLockdown' || job.name === 'activateLockdown') { const guildId = (job.data as { guildId?: string }).guildId; if (!guildId) { return; } const guild = await context.client.guilds.fetch(guildId).catch(() => null); if (!guild) { return; } if (job.name === 'deactivateLockdown') { await deactivateLockdown(guild); logger.info({ guildId }, 'Lockdown deactivated via dashboard'); return; } await activateLockdown(guild); logger.info({ guildId }, 'Lockdown activated via dashboard'); } }, { connection: redis } ); automodWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'AutoMod job failed'); }); const verificationWorker = new Worker( verificationQueueName, async (job) => { if (job.name === 'verificationPanelSync') { return runVerificationPanelSyncJob(context, job.data as VerificationPanelSyncJob); } if (job.name !== 'verificationComplete') { return; } const data = job.data as VerificationCompleteJob; await completeVerification(context, data.guildId, data.userId, { ipHash: data.ipHash, userAgentHash: data.userAgentHash, captchaProvider: data.captchaProvider }); }, { connection: redis } ); verificationWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Verification job failed'); }); const reminderWorker = new Worker( reminderQueueName, async (job) => { if (job.name === 'reminderSend') { await sendReminder(context, (job.data as ReminderSendJob).reminderId); return; } if (job.name === 'pollClose') { await closePoll(context, (job.data as PollCloseJob).pollId); } }, { connection: redis } ); reminderWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Reminder job failed'); }); const ticketWorker = new Worker( ticketQueueName, async (job) => { if (job.name === 'ticketPanelSync') { return runTicketPanelSyncJob(context, job.data as { guildId: string }); } if (job.name === 'selfRoleExpire') { const data = job.data as SelfRoleExpireJob; await expireSelfRole(context, data.guildId, data.userId, data.roleId); return; } if (job.name === 'ticketAutoClose') { await closeInactiveTickets(context); } }, { connection: redis } ); ticketWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Ticket queue job failed'); }); const selfrolesWorker = new Worker< SelfRolePanelCreateJob | SelfRolePanelUpdateJob | SelfRolePanelDeleteJob >( selfrolesQueueName, async (job) => { if (job.name === 'selfRolePanelCreate') { return runSelfRolePanelCreateJob(context, job.data as SelfRolePanelCreateJob); } if (job.name === 'selfRolePanelUpdate') { return runSelfRolePanelUpdateJob(context, job.data as SelfRolePanelUpdateJob); } if (job.name === 'selfRolePanelDelete') { const data = job.data as SelfRolePanelDeleteJob; await runSelfRolePanelDeleteJob(context, data.panelId, data.guildId); } }, { connection: redis } ); selfrolesWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Self-roles queue job failed'); }); const giveawayWorker = new Worker< GiveawayEndJob | GiveawayCreateJob | GiveawayPauseJob | GiveawayDeleteJob >( giveawayQueueName, async (job) => { if (job.name === 'giveawayEnd') { try { const data = job.data as GiveawayEndJob; if (!data.force) { const current = await context.prisma.giveaway.findUnique({ where: { id: data.giveawayId } }); // Timer/recover must not end a paused giveaway. if (current?.paused) { return; } } await endGiveaway(context, data.giveawayId); } catch (error) { if ( error instanceof GiveawayError && (error.code === 'already_ended' || error.code === 'not_found') ) { return; } throw error; } return; } if (job.name === 'giveawayCreate') { return runGiveawayCreateJob(context, job.data as GiveawayCreateJob); } if (job.name === 'giveawayPause') { const data = job.data as GiveawayPauseJob; return runGiveawayPauseJob(context, data.giveawayId, data.paused); } if (job.name === 'giveawayDelete') { await runGiveawayDeleteJob(context, (job.data as GiveawayDeleteJob).giveawayId); } }, { connection: redis } ); giveawayWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Giveaway job failed'); }); const birthdayWorker = new Worker( birthdayQueueName, async (job) => { if (job.name === 'birthdayDailyCheck') { await runBirthdayCheck(context); return; } if (job.name === 'birthdayRoleExpire') { const data = job.data as BirthdayRoleExpireJob; await expireBirthdayRole(context, data.guildId, data.userId, data.roleId); } }, { connection: redis } ); birthdayWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Birthday job failed'); }); const statsWorker = startStatsWorker(context); const feedsWorker = new Worker( feedsQueueName, async (job) => { if (job.name !== 'pollFeeds') { return; } await pollAllFeeds(context); }, { connection: redis } ); feedsWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Feeds job failed'); }); const scheduleWorker = new Worker<{ scheduleId: string }>( scheduleQueueName, async (job) => { if (job.name !== 'scheduleSend') { return; } await sendScheduledMessage(context, job.data.scheduleId); }, { connection: redis } ); scheduleWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Schedule job failed'); }); const messagesWorker = new Worker( messagesQueueName, async (job) => { if (job.name !== 'dashboardMessageSend') { return; } return sendDashboardMessage(context, job.data); }, { connection: redis } ); messagesWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Dashboard message send job failed'); }); const guildBackupWorker = new Worker<{ backupId: string; guildId: string }>( guildBackupQueueName, async (job) => { if (job.name !== 'restoreGuildBackup') { return; } await runGuildBackupRestoreJob(context, job.data.backupId, job.data.guildId); }, { connection: redis } ); guildBackupWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Guild backup restore job failed'); }); const suggestionsWorker = new Worker( suggestionsQueueName, async (job) => { if (job.name !== 'suggestionStatusUpdate') { return; } return runSuggestionStatusUpdateJob(context, job.data); }, { connection: redis } ); suggestionsWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Suggestion status update job failed'); }); const presenceWorker = new Worker( presenceQueueName, async (job) => { if (job.name !== 'presenceRefresh') { return; } await runPresenceRefreshJob(context); }, { connection: redis } ); presenceWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Presence refresh job failed'); }); const retentionWorker = new Worker( retentionQueueName, async (job) => { if (job.name !== 'purgeExpiredLogs') { return; } await runLogRetentionJob(context); }, { connection: redis } ); retentionWorker.on('failed', (job, error) => { logger.error({ jobId: job?.id, error }, 'Log retention job failed'); }); return [ moderationWorker, backupWorker, automodWorker, verificationWorker, reminderWorker, ticketWorker, selfrolesWorker, giveawayWorker, birthdayWorker, statsWorker, feedsWorker, scheduleWorker, messagesWorker, guildBackupWorker, suggestionsWorker, presenceWorker, retentionWorker ]; } export async function ensureRecurringJobs(): Promise { await backupQueue.add( 'dailyPgDump', {}, { repeat: { pattern: env.BACKUP_CRON }, removeOnComplete: 100, removeOnFail: 100 } ); await automodQueue.add( 'refreshPhishingList', {}, { repeat: { pattern: '0 */6 * * *' }, removeOnComplete: 20, removeOnFail: 20 } ); await automodQueue.add('refreshPhishingList', {}, { removeOnComplete: 20, removeOnFail: 20 }); await ticketQueue.add( 'ticketAutoClose', {}, { repeat: { pattern: '0 * * * *' }, removeOnComplete: 20, removeOnFail: 20 } ); await birthdayQueue.add( 'birthdayDailyCheck', {}, { repeat: { pattern: '0 * * * *' }, removeOnComplete: 20, removeOnFail: 20 } ); await ensureStatsRecurringJobs(); await feedsQueue.add( 'pollFeeds', {}, { repeat: { pattern: '*/5 * * * *' }, removeOnComplete: 20, removeOnFail: 20 } ); await presenceQueue.add( 'presenceRefresh', {}, { repeat: { every: 60_000 }, removeOnComplete: 5, removeOnFail: 5 } ); await retentionQueue.add( 'purgeExpiredLogs', {}, { repeat: { pattern: '15 4 * * *' }, removeOnComplete: 20, removeOnFail: 20 } ); } async function runLogRetentionJob(context: BotContext): Promise { const configs = await context.prisma.loggingConfig.findMany({ where: { retentionDays: { gt: 0 } }, select: { guildId: true, retentionDays: true } }); for (const config of configs) { const cutoff = new Date(Date.now() - config.retentionDays * 24 * 60 * 60 * 1000); const [cases, audits] = await Promise.all([ context.prisma.case.deleteMany({ where: { guildId: config.guildId, createdAt: { lt: cutoff } } }), context.prisma.dashboardAuditLog.deleteMany({ where: { guildId: config.guildId, createdAt: { lt: cutoff } } }) ]); if (cases.count > 0 || audits.count > 0) { logger.info( { guildId: config.guildId, cases: cases.count, audits: audits.count, retentionDays: config.retentionDays }, 'Log retention purge completed' ); } } } async function runPgDumpBackup(): Promise { await mkdir(env.BACKUP_DIR, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `nexumi-${timestamp}.sql`; const outputPath = join(env.BACKUP_DIR, filename); await new Promise((resolve, reject) => { const dump = spawn('pg_dump', ['--dbname', env.DATABASE_URL, '--file', outputPath], { stdio: 'pipe' }); let stderr = ''; dump.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); }); dump.on('close', (code) => { if (code === 0) { resolve(); return; } reject(new Error(stderr || `pg_dump exited with code ${code ?? 'unknown'}`)); }); }); await cleanupOldBackups(); logger.info({ backupFile: basename(outputPath) }, 'Database backup finished'); } async function cleanupOldBackups(): Promise { const files = await readdir(env.BACKUP_DIR, { withFileTypes: true }); const cutoff = Date.now() - env.BACKUP_RETENTION_DAYS * 24 * 60 * 60 * 1000; await Promise.all( files .filter((entry) => entry.isFile() && entry.name.endsWith('.sql')) .map(async (entry) => { const fullPath = join(env.BACKUP_DIR, entry.name); const fileStat = await stat(fullPath); const created = fileStat.mtime.getTime(); if (Number.isNaN(created) || created >= cutoff) { return; } await rm(fullPath, { force: true }); }) ); }