Enhance giveaway job handling and update environment configuration

- Added support for a new `giveawayCreate` job in the bot's job processing, allowing for giveaways to be created via the WebUI.
- Introduced `runGiveawayCreateJob` function to handle the creation of giveaways, ensuring consistent behavior with existing commands.
- Updated `.env.example` to clarify the usage of `BOT_TOKEN` for both the bot and WebUI, enhancing documentation for environment variables.
- Added `bullmq` dependency in the WebUI package for job management capabilities.
This commit is contained in:
smueller
2026-07-22 15:19:04 +02:00
parent 2ef6b23136
commit 677142672f
7 changed files with 133 additions and 5 deletions

View File

@@ -1,4 +1,6 @@
NODE_ENV=development 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_TOKEN=
BOT_CLIENT_ID= BOT_CLIENT_ID=
BOT_CLIENT_SECRET= BOT_CLIENT_SECRET=

View File

@@ -12,7 +12,7 @@ import { completeVerification } from './modules/verification/handlers.js';
import { closePoll } from './modules/utility/poll.js'; import { closePoll } from './modules/utility/poll.js';
import { sendReminder } from './modules/utility/reminders.js'; import { sendReminder } from './modules/utility/reminders.js';
import { expireSelfRole } from './modules/selfroles/service.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 { closeInactiveTickets } from './modules/tickets/index.js';
import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js'; import { expireBirthdayRole, runBirthdayCheck } from './modules/birthdays/index.js';
import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js'; import { ensureStatsRecurringJobs, startStatsWorker } from './modules/stats/index.js';
@@ -76,6 +76,19 @@ type GiveawayEndJob = {
giveawayId: string; 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 = { type BirthdayRoleExpireJob = {
guildId: string; guildId: string;
userId: string; userId: string;
@@ -187,13 +200,16 @@ export function startWorkers(context: BotContext): Worker[] {
logger.error({ jobId: job?.id, error }, 'Ticket queue job failed'); logger.error({ jobId: job?.id, error }, 'Ticket queue job failed');
}); });
const giveawayWorker = new Worker<GiveawayEndJob>( const giveawayWorker = new Worker<GiveawayEndJob | GiveawayCreateJob>(
giveawayQueueName, giveawayQueueName,
async (job) => { async (job) => {
if (job.name !== 'giveawayEnd') { if (job.name === 'giveawayEnd') {
await endGiveaway(context, (job.data as GiveawayEndJob).giveawayId);
return; return;
} }
await endGiveaway(context, job.data.giveawayId); if (job.name === 'giveawayCreate') {
return runGiveawayCreateJob(context, job.data as GiveawayCreateJob);
}
}, },
{ connection: redis } { connection: redis }
); );

View File

@@ -1,3 +1,3 @@
export { giveawayCommands } from './commands.js'; export { giveawayCommands } from './commands.js';
export { isGiveawayButton, handleGiveawayButton } from './buttons.js'; export { isGiveawayButton, handleGiveawayButton } from './buttons.js';
export { endGiveaway, scheduleGiveawayEnd } from './service.js'; export { endGiveaway, scheduleGiveawayEnd, runGiveawayCreateJob } from './service.js';

View File

@@ -408,6 +408,31 @@ export async function deleteGiveaway(context: BotContext, giveawayId: string): P
await context.prisma.giveaway.delete({ where: { id: giveawayId } }); 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( export async function listActiveGiveaways(
context: BotContext, context: BotContext,
guildId: string guildId: string

View File

@@ -21,6 +21,7 @@
"@radix-ui/react-separator": "^1.1.12", "@radix-ui/react-separator": "^1.1.12",
"@radix-ui/react-slot": "^1.3.0", "@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-switch": "^1.3.4", "@radix-ui/react-switch": "^1.3.4",
"bullmq": "^5.34.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",

View File

@@ -9,6 +9,14 @@ const EnvSchema = z.object({
REDIS_URL: z.string().url(), REDIS_URL: z.string().url(),
BOT_CLIENT_ID: z.string().min(1), BOT_CLIENT_ID: z.string().min(1),
BOT_CLIENT_SECRET: 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'), WEBUI_URL: z.string().url().default('http://localhost:3000'),
SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'), SESSION_SECRET: z.string().min(32, 'SESSION_SECRET must be at least 32 characters long'),
SENTRY_DSN: z.string().optional(), SENTRY_DSN: z.string().optional(),

View File

@@ -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<T = unknown>(
queue: Queue,
queueEvents: QueueEvents,
jobName: string,
data: unknown,
options: Parameters<Queue['add']>[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 };
}
}