import { PermissionFlagsBits, type GuildTextBasedChannel, type MessageCreateOptions, type TextChannel } from 'discord.js'; import { WelcomeEmbedSchema, expandBracketChannelMentions, t, tf } from '@nexumi/shared'; import type { ScheduledMessage } from '@prisma/client'; import { ensureGuild } from '../../guild.js'; import { buildEmbedFromPayload } from '../../lib/embed-payload.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): 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); } const parsed = Date.parse(trimmed); if (Number.isNaN(parsed) || parsed <= Date.now()) { throw new SchedulerError('invalid_when'); } return new Date(parsed); } 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; rolePingId: string | null; }): MessageCreateOptions { 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 ): Promise { const jobOptions: { jobId: string; removeOnComplete: boolean; removeOnFail: number; delay?: number; repeat?: { pattern: string }; } = { jobId: `schedule-${schedule.id}`, removeOnComplete: true, removeOnFail: 50 }; if (cron) { jobOptions.repeat = { pattern: cron }; } else if (runAt) { jobOptions.delay = Math.max(0, runAt.getTime() - Date.now()); } const job = await scheduleQueue.add('scheduleSend', { scheduleId: schedule.id }, jobOptions); return String(job.id); } export async function removeScheduleJob(jobId: string | null): Promise { if (!jobId) { return; } const job = await scheduleQueue.getJob(jobId); if (job) { await job.remove(); } } 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; if (cron) { if (!isValidCron(cron)) { throw new SchedulerError('invalid_cron'); } cronPattern = cron; } else if (whenInput) { runAt = parseScheduleWhen(whenInput); } 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); 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.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); 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) { 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; } if (!schedule.cron) { await removeScheduleJob(schedule.jobId); await context.prisma.scheduledMessage.delete({ where: { id: scheduleId } }); } }