import { PermissionFlagsBits, type GuildTextBasedChannel, type MessageCreateOptions, type TextChannel } from 'discord.js'; import { DEFAULT_GUILD_TIMEZONE, WelcomeEmbedSchema, expandBracketChannelMentions, parseMessageComponentsV2, resolveScheduleRunAt, t, tf } from '@nexumi/shared'; import type { ScheduledMessage } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.js'; import { buildComponentsV2Payload } from '../../lib/components-v2-payload.js'; import { safeRemoveBullJob } from '../../lib/safe-remove-job.js'; import { logger } from '../../logger.js'; import { scheduleQueue } from '../../queues.js'; import type { BotContext } from '../../types.js'; import { parseDuration } from '../moderation/duration.js'; export class SchedulerError extends Error { constructor(public readonly code: string) { super(code); } } export type AnnouncementInput = { guildId: string; channelId: string; creatorId: string; content: string; embedTitle?: string | null; embedDescription?: string | null; embedColor?: number | null; rolePingId?: string | null; }; export type ScheduleCreateInput = AnnouncementInput & { whenInput?: string | null; cron?: string | null; }; function parseScheduleWhen(input: string, timeZone: string): Date { const trimmed = input.trim(); if (/^\d+[smhd]$/i.test(trimmed)) { const ms = parseDuration(trimmed); if (ms <= 0) { throw new SchedulerError('invalid_when'); } return new Date(Date.now() + ms); } try { const parsed = resolveScheduleRunAt(trimmed, timeZone); if (parsed.getTime() <= Date.now()) { throw new SchedulerError('invalid_when'); } return parsed; } catch { throw new SchedulerError('invalid_when'); } } async function getGuildTimezone(prisma: BotContext['prisma'], guildId: string): Promise { const settings = await prisma.guildSettings.findUnique({ where: { guildId }, select: { timezone: true } }); return settings?.timezone || DEFAULT_GUILD_TIMEZONE; } export function scheduleJobId(scheduleId: string): string { return `schedule-${scheduleId}`; } function isValidCron(pattern: string): boolean { const parts = pattern.trim().split(/\s+/); return parts.length >= 5 && parts.length <= 6; } function buildEmbedJson(input: { embedTitle?: string | null; embedDescription?: string | null; embedColor?: number | null; }): { title?: string; description?: string; color?: number } | null { const title = input.embedTitle?.trim(); const description = input.embedDescription?.trim(); const color = input.embedColor ?? undefined; if (!title && !description && color === undefined) { return null; } const embed: { title?: string; description?: string; color?: number } = {}; if (title) { embed.title = title; } if (description) { embed.description = description; } if (color !== undefined) { embed.color = color; } return embed; } function parseStoredEmbed(raw: unknown) { const parsed = WelcomeEmbedSchema.safeParse(raw); return parsed.success ? parsed.data : null; } export function buildAnnouncementPayload(schedule: { content: string | null; embed: unknown; components?: unknown; rolePingId: string | null; id?: string; }): MessageCreateOptions { const componentsData = parseMessageComponentsV2(schedule.components); if (componentsData && schedule.id) { const componentsPayload = buildComponentsV2Payload(componentsData, { source: 's', ref: schedule.id, renderText: expandBracketChannelMentions }); if (componentsPayload) { if (schedule.rolePingId) { // Components V2 cannot mix classic content; role ping is skipped. } return componentsPayload; } } const embedData = parseStoredEmbed(schedule.embed); const embed = buildEmbedFromPayload(embedData, { renderText: expandBracketChannelMentions }); const baseContent = expandBracketChannelMentions(schedule.content?.trim() ?? ''); const roleMention = schedule.rolePingId ? `<@&${schedule.rolePingId}>` : ''; const content = [roleMention, baseContent].filter(Boolean).join(' ').trim(); const payload: MessageCreateOptions = {}; if (content) { payload.content = content; } if (embed) { payload.embeds = [embed]; } if (schedule.rolePingId) { payload.allowedMentions = { roles: [schedule.rolePingId] }; } return payload; } async function assertSendableChannel( context: BotContext, channelId: string ): Promise { const channel = await context.client.channels.fetch(channelId); if (!channel?.isTextBased() || channel.isDMBased()) { throw new SchedulerError('invalid_channel'); } const me = channel.guild.members.me; if ( !me?.permissionsIn(channel).has(PermissionFlagsBits.SendMessages) || !me.permissionsIn(channel).has(PermissionFlagsBits.ViewChannel) ) { throw new SchedulerError('channel_unavailable'); } return channel; } async function enqueueScheduleJob( schedule: ScheduledMessage, cron: string | null, runAt: Date | null, timeZone: string ): Promise { const jobId = scheduleJobId(schedule.id); if (cron) { // BullMQ 5 job schedulers honor guild timezone for cron patterns. await scheduleQueue.upsertJobScheduler( jobId, { pattern: cron, tz: timeZone }, { name: 'scheduleSend', data: { scheduleId: schedule.id }, opts: { removeOnComplete: true, removeOnFail: 50 } } ); return jobId; } if (!runAt) { throw new SchedulerError('missing_schedule_time'); } const job = await scheduleQueue.add( 'scheduleSend', { scheduleId: schedule.id }, { jobId, removeOnComplete: true, removeOnFail: 50, delay: Math.max(0, runAt.getTime() - Date.now()) } ); return String(job.id); } /** * Removes a one-shot delayed job and/or cron job scheduler. Locked (active) * jobs cannot be removed — do not call this from inside the scheduleSend * worker for the job that is currently running. */ export async function removeScheduleJob( jobId: string | null, options?: { scheduleId?: string; cron?: string | null } ): Promise { const schedulerId = options?.scheduleId ? scheduleJobId(options.scheduleId) : jobId; if (schedulerId && options?.cron) { try { await scheduleQueue.removeJobScheduler(schedulerId); } catch (error) { logger.warn({ schedulerId, error }, 'Failed to remove schedule job scheduler'); } } const candidateIds = new Set(); if (jobId) { candidateIds.add(jobId); } if (schedulerId) { candidateIds.add(schedulerId); } for (const id of candidateIds) { const job = await scheduleQueue.getJob(id); await safeRemoveBullJob(job, { label: 'schedule', jobId: id }); } } function validateAnnouncementContent(input: AnnouncementInput): void { const hasContent = input.content.trim().length > 0; const hasEmbed = Boolean(input.embedTitle?.trim()) || Boolean(input.embedDescription?.trim()) || input.embedColor !== null && input.embedColor !== undefined; if (!hasContent && !hasEmbed) { throw new SchedulerError('content_required'); } } export async function createScheduledMessage( context: BotContext, input: ScheduleCreateInput ): Promise { await ensureGuild(context.prisma, input.guildId); validateAnnouncementContent(input); const whenInput = input.whenInput?.trim(); const cron = input.cron?.trim(); if (!whenInput && !cron) { throw new SchedulerError('missing_schedule_time'); } let runAt: Date | null = null; let cronPattern: string | null = null; const timeZone = await getGuildTimezone(context.prisma, input.guildId); if (cron) { if (!isValidCron(cron)) { throw new SchedulerError('invalid_cron'); } cronPattern = cron; } else if (whenInput) { runAt = parseScheduleWhen(whenInput, timeZone); } await assertSendableChannel(context, input.channelId); const embed = buildEmbedJson(input); const schedule = await context.prisma.scheduledMessage.create({ data: { guildId: input.guildId, channelId: input.channelId, creatorId: input.creatorId, content: input.content.trim() || null, embed: embed ?? undefined, rolePingId: input.rolePingId ?? null, cron: cronPattern, runAt } }); const jobId = await enqueueScheduleJob(schedule, cronPattern, runAt, timeZone); return context.prisma.scheduledMessage.update({ where: { id: schedule.id }, data: { jobId } }); } export async function listScheduledMessages( context: BotContext, guildId: string, locale: 'de' | 'en' ): Promise { const schedules = await context.prisma.scheduledMessage.findMany({ where: { guildId, enabled: true }, orderBy: { createdAt: 'asc' }, take: 20 }); if (schedules.length === 0) { return t(locale, 'scheduler.list.empty'); } return schedules .map((schedule) => { const preview = schedule.content?.slice(0, 60) ?? (schedule.components ? t(locale, 'scheduler.list.componentsPreview') : schedule.embed ? t(locale, 'scheduler.list.embedPreview') : '—'); const timing = schedule.cron ? tf(locale, 'scheduler.list.cron', { cron: schedule.cron }) : schedule.runAt ? `` : '—'; return tf(locale, 'scheduler.list.line', { id: schedule.id.slice(0, 8), channel: `<#${schedule.channelId}>`, timing, preview }); }) .join('\n'); } export async function deleteScheduledMessage( context: BotContext, guildId: string, scheduleIdPrefix: string ): Promise { const schedules = await context.prisma.scheduledMessage.findMany({ where: { guildId, enabled: true } }); const match = schedules.find((entry) => entry.id.startsWith(scheduleIdPrefix)); if (!match) { return false; } await removeScheduleJob(match.jobId, { scheduleId: match.id, cron: match.cron }); await context.prisma.scheduledMessage.delete({ where: { id: match.id } }); return true; } export async function sendAnnouncement( context: BotContext, input: AnnouncementInput ): Promise { validateAnnouncementContent(input); const channel = await assertSendableChannel(context, input.channelId); const payload = buildAnnouncementPayload({ content: input.content.trim() || null, embed: buildEmbedJson(input), rolePingId: input.rolePingId ?? null }); await (channel as TextChannel).send(payload); } export async function sendScheduledMessage(context: BotContext, scheduleId: string): Promise { const schedule = await context.prisma.scheduledMessage.findUnique({ where: { id: scheduleId } }); if (!schedule || !schedule.enabled) { return; } try { const channel = await assertSendableChannel(context, schedule.channelId); const payload = buildAnnouncementPayload(schedule); if (!payload.content && !payload.embeds?.length && !payload.components?.length) { throw new SchedulerError('content_required'); } await (channel as TextChannel).send(payload); } catch (error) { logger.error({ scheduleId, error }, 'Failed to send scheduled message'); if (error instanceof SchedulerError && error.code === 'channel_unavailable') { await context.prisma.scheduledMessage.update({ where: { id: scheduleId }, data: { enabled: false } }); } throw error; } // One-shot: delete the DB row only. Do not remove the active BullMQ job // from inside its own worker (locked remove throws → retry → duplicate send). // removeOnComplete on enqueue cleans the job up after the processor returns. if (!schedule.cron) { await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } }); } }