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:
@@ -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=
|
||||||
|
|||||||
@@ -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 }
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
76
apps/webui/src/lib/queues.ts
Normal file
76
apps/webui/src/lib/queues.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user