diff --git a/.env.example b/.env.example index b2aa0c7..9929571 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ NODE_ENV=development +# Shared by apps/bot (gateway connection) and apps/webui (narrow REST calls + +# BullMQ job enqueueing for guild backups, giveaways and the scheduler). BOT_TOKEN= BOT_CLIENT_ID= BOT_CLIENT_SECRET= diff --git a/apps/bot/src/jobs.ts b/apps/bot/src/jobs.ts index 6c8804d..220baf3 100644 --- a/apps/bot/src/jobs.ts +++ b/apps/bot/src/jobs.ts @@ -12,7 +12,7 @@ import { completeVerification } from './modules/verification/handlers.js'; import { closePoll } from './modules/utility/poll.js'; import { sendReminder } from './modules/utility/reminders.js'; import { expireSelfRole } from './modules/selfroles/service.js'; -import { endGiveaway } from './modules/giveaways/index.js'; +import { endGiveaway, runGiveawayCreateJob } from './modules/giveaways/index.js'; import { closeInactiveTickets } from './modules/tickets/index.js'; import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; @@ -76,6 +76,19 @@ type GiveawayEndJob = { giveawayId: string; }; +type GiveawayCreateJob = { + guildId: string; + channelId: string; + hostId: string; + prize: string; + winnerCount: number; + durationMs: number; + requiredRoleId?: string | null; + requiredLevel?: number | null; + requiredMemberDays?: number | null; +}; + + type BirthdayRoleExpireJob = { guildId: string; userId: string; @@ -187,13 +200,16 @@ export function startWorkers(context: BotContext): Worker[] { logger.error({ jobId: job?.id, error }, 'Ticket queue job failed'); }); - const giveawayWorker = new Worker( + const giveawayWorker = new Worker( giveawayQueueName, async (job) => { - if (job.name !== 'giveawayEnd') { + if (job.name === 'giveawayEnd') { + await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId); return; } - await endGiveaway(context, job.data.giveawayId); + if (job.name === 'giveawayCreate') { + return runGiveawayCreateJob(context, job.data as GiveawayCreateJob); + } }, { connection: redis } ); diff --git a/apps/bot/src/modules/giveaways/index.ts b/apps/bot/src/modules/giveaways/index.ts index b92a05e..a27c919 100644 --- a/apps/bot/src/modules/giveaways/index.ts +++ b/apps/bot/src/modules/giveaways/index.ts @@ -1,3 +1,3 @@ export { giveawayCommands } from './commands.js'; export { isGiveawayButton, handleGiveawayButton } from './buttons.js'; -export { endGiveaway, scheduleGiveawayEnd } from './service.js'; +export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob } from './service.js'; diff --git a/apps/bot/src/modules/giveaways/service.ts b/apps/bot/src/modules/giveaways/service.ts index 012dc3e..161a395 100644 --- a/apps/bot/src/modules/giveaways/service.ts +++ b/apps/bot/src/modules/giveaways/service.ts @@ -408,6 +408,31 @@ export async function deleteGiveaway(context: BotContext, giveawayId: string): P await context.prisma.giveaway.delete({ where: { id: giveawayId } }); } +/** + * Entry point for the `giveawayCreate` BullMQ job, enqueued by the WebUI + * dashboard (which has no discord.js Client to post the giveaway message + * itself). Resolves the guild's locale and delegates to {@link startGiveaway} + * so dashboard-created giveaways behave identically to `/giveaway start`. + */ +export async function runGiveawayCreateJob( + context: BotContext, + params: { + guildId: string; + channelId: string; + hostId: string; + prize: string; + winnerCount: number; + durationMs: number; + requiredRoleId?: string | null; + requiredLevel?: number | null; + requiredMemberDays?: number | null; + } +): Promise<{ id: string }> { + const locale = await getGuildLocale(context.prisma, params.guildId); + const giveaway = await startGiveaway(context, params, locale); + return { id: giveaway.id }; +} + export async function listActiveGiveaways( context: BotContext, guildId: string diff --git a/apps/webui/package.json b/apps/webui/package.json index 8c80735..d2486ad 100644 --- a/apps/webui/package.json +++ b/apps/webui/package.json @@ -21,6 +21,7 @@ "@radix-ui/react-separator": "^1.1.12", "@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-switch": "^1.3.4", + "bullmq": "^5.34.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^16.4.7", diff --git a/apps/webui/src/lib/env.ts b/apps/webui/src/lib/env.ts index 41ade88..fbbe3e0 100644 --- a/apps/webui/src/lib/env.ts +++ b/apps/webui/src/lib/env.ts @@ -9,6 +9,14 @@ const EnvSchema = z.object({ REDIS_URL: z.string().url(), BOT_CLIENT_ID: z.string().min(1), BOT_CLIENT_SECRET: z.string().min(1), + /** + * Discord bot token. The WebUI does not run a discord.js Client/gateway + * connection, but uses this token for narrow, on-demand Discord REST calls + * (e.g. building a guild backup snapshot) and to enqueue BullMQ jobs that + * the bot process picks up for actions that require a live Client + * (posting messages, restoring a backup). + */ + BOT_TOKEN: z.string().min(1), WEBUI_URL: z.string().url().default('http://localhost:3000'), SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'), SENTRY_DSN: z.string().optional(), diff --git a/apps/webui/src/lib/queues.ts b/apps/webui/src/lib/queues.ts new file mode 100644 index 0000000..3ded4e3 --- /dev/null +++ b/apps/webui/src/lib/queues.ts @@ -0,0 +1,76 @@ +import { Queue, QueueEvents } from 'bullmq'; +import { redis } from './redis'; + +/** + * BullMQ queue/job names mirrored 1:1 from `apps/bot/src/queues.ts` and + * `apps/bot/src/jobs.ts`. The WebUI has no discord.js Client, so any + * dashboard action that needs to touch Discord (posting a giveaway message, + * drawing winners, restoring a guild backup) is enqueued here and picked up + * by the bot's existing workers - the WebUI never talks to Discord directly + * for these actions. + */ +export const giveawayQueueName = 'giveaways'; +export const scheduleQueueName = 'schedules'; +export const guildBackupQueueName = 'guild-backups'; + +const globalForQueues = globalThis as unknown as { + __nexumiGiveawayQueue?: Queue; + __nexumiScheduleQueue?: Queue; + __nexumiGuildBackupQueue?: Queue; + __nexumiGiveawayQueueEvents?: QueueEvents; + __nexumiGuildBackupQueueEvents?: QueueEvents; +}; + +export const giveawayQueue: Queue = + globalForQueues.__nexumiGiveawayQueue ?? new Queue(giveawayQueueName, { connection: redis }); + +export const scheduleQueue: Queue = + globalForQueues.__nexumiScheduleQueue ?? new Queue(scheduleQueueName, { connection: redis }); + +export const guildBackupQueue: Queue = + globalForQueues.__nexumiGuildBackupQueue ?? new Queue(guildBackupQueueName, { connection: redis }); + +/** + * QueueEvents instances are only needed for jobs where the dashboard needs + * to wait for the bot to finish (e.g. ending a giveaway so the winners list + * is immediately visible). They open their own Redis connection, so they are + * cached on the global object the same way as the queues themselves. + */ +export const giveawayQueueEvents: QueueEvents = + globalForQueues.__nexumiGiveawayQueueEvents ?? + new QueueEvents(giveawayQueueName, { connection: redis.duplicate() }); + +export const guildBackupQueueEvents: QueueEvents = + globalForQueues.__nexumiGuildBackupQueueEvents ?? + new QueueEvents(guildBackupQueueName, { connection: redis.duplicate() }); + +globalForQueues.__nexumiGiveawayQueue = giveawayQueue; +globalForQueues.__nexumiScheduleQueue = scheduleQueue; +globalForQueues.__nexumiGuildBackupQueue = guildBackupQueue; +globalForQueues.__nexumiGiveawayQueueEvents = giveawayQueueEvents; +globalForQueues.__nexumiGuildBackupQueueEvents = guildBackupQueueEvents; + +const DEFAULT_WAIT_TIMEOUT_MS = 15_000; + +/** + * Adds a job and waits (bounded) for the bot worker to finish it, so the + * dashboard can immediately reflect the result instead of polling. Falls + * back to "not confirmed" (does not throw) on timeout - the DB row created + * by the worker is still the source of truth and will show up on next load. + */ +export async function addJobAndAwait( + queue: Queue, + queueEvents: QueueEvents, + jobName: string, + data: unknown, + options: Parameters[2] = {}, + timeoutMs = DEFAULT_WAIT_TIMEOUT_MS +): Promise<{ confirmed: boolean; result: T | null }> { + const job = await queue.add(jobName, data, options); + try { + const result = (await job.waitUntilFinished(queueEvents, timeoutMs)) as T; + return { confirmed: true, result }; + } catch { + return { confirmed: false, result: null }; + } +}