Files
Nexumi/apps/bot/src/jobs.ts
TheOnlyMace 1eec10ba80 Implement ticket panel synchronization and configuration updates
- Added `panelChannelId` and `panelMessageId` fields to the `TicketConfig` model for managing ticket panel settings.
- Introduced `syncTicketPanel` function to handle posting and updating the ticket panel in Discord channels via BullMQ.
- Enhanced error handling for ticket panel synchronization, including specific error messages for missing categories and posting failures.
- Updated WebUI components to support panel channel selection and improved error messaging for synchronization issues.
- Added localization entries for new error messages related to ticket panel synchronization in both English and German.
- Refactored ticket category management to trigger panel synchronization upon category creation, update, or deletion.
2026-07-25 16:06:17 +02:00

725 lines
20 KiB
TypeScript

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<TempBanJob>(
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<VerificationCompleteJob | VerificationPanelSyncJob>(
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<ReminderSendJob | PollCloseJob>(
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<DashboardMessageSendJob>(
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<SuggestionStatusUpdateJob>(
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<void> {
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<void> {
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<void> {
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<void>((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<void> {
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 });
})
);
}