import { Client, Colors, EmbedBuilder, Guild, TextChannel } from 'discord.js'; import { prisma } from '../database'; import { logger } from '../utils/logger'; import { auditLogService } from './AuditLogService'; const REMINDER_INTERVAL_MS = 60 * 1000; const ONE_HOUR_MS = 60 * 60 * 1000; const TEN_MINUTES_MS = 10 * 60 * 1000; function toDiscordTimestamps(date: Date): { full: string; relative: string } { const unix = Math.floor(date.getTime() / 1000); return { full: ``, relative: ``, }; } function buildEventEmbed( mode: 'announcement' | 'oneHourReminder' | 'tenMinuteReminder' | 'started', title: string, description: string | null, startsAt: Date, createdByUserId: string, ) { const timestamps = toDiscordTimestamps(startsAt); const config = { announcement: { color: Colors.Blurple, titlePrefix: 'Event Scheduled', lead: 'A new server event has been scheduled.', }, oneHourReminder: { color: Colors.Gold, titlePrefix: 'Event Reminder', lead: 'This event starts in about 1 hour.', }, tenMinuteReminder: { color: Colors.Orange, titlePrefix: 'Event Reminder', lead: 'This event starts in about 10 minutes.', }, started: { color: Colors.Green, titlePrefix: 'Event Started', lead: 'This event is starting now.', }, }[mode]; const embed = new EmbedBuilder() .setColor(config.color) .setTitle(`${config.titlePrefix}: ${title}`) .setDescription(description || config.lead) .addFields( { name: 'Starts At', value: timestamps.full, inline: true }, { name: 'Relative', value: timestamps.relative, inline: true }, { name: 'Created By', value: `<@${createdByUserId}>`, inline: true }, ) .setTimestamp(); if (description) { embed.addFields({ name: 'Summary', value: config.lead, inline: false }); } return embed; } async function resolveAnnouncementChannel(guild: Guild, channelId: string | null): Promise { if (!channelId) return null; const channel = await guild.channels.fetch(channelId).catch(() => null); if (!channel || !channel.isTextBased() || !(channel instanceof TextChannel)) { return null; } return channel; } export class EventService { private static reminderInterval: NodeJS.Timeout | null = null; public static async announceEvent(guild: Guild, eventId: string) { const event = await prisma.guildEvent.findFirst({ where: { id: eventId, guildId: guild.id, }, }); if (!event) { throw new Error(`GuildEvent ${eventId} not found for guild ${guild.id}`); } const channel = await resolveAnnouncementChannel(guild, event.announcementChannelId); if (!channel) { throw new Error(`Announcement channel is not configured or unavailable for event ${eventId}`); } const embed = buildEventEmbed( 'announcement', event.title, event.description, event.startsAt, event.createdByUserId, ); await channel.send({ embeds: [embed] }); await prisma.guildEvent.update({ where: { id: event.id }, data: { announcedAt: new Date() }, }); } public static startReminderLoop(client: Client) { if (this.reminderInterval) { clearInterval(this.reminderInterval); } this.processDueEvents(client).catch((error) => { logger.error('EventService: Initial event processing failed', error); }); this.reminderInterval = setInterval(() => { this.processDueEvents(client).catch((error) => { logger.error('EventService: Scheduled event processing failed', error); }); }, REMINDER_INTERVAL_MS); logger.info('EventService: Reminder loop started (1m interval).'); } public static stopReminderLoop() { if (this.reminderInterval) { clearInterval(this.reminderInterval); this.reminderInterval = null; logger.info('EventService: Reminder loop stopped.'); } } private static async processDueEvents(client: Client) { const now = new Date(); const upcomingEvents = await prisma.guildEvent.findMany({ where: { status: 'SCHEDULED', startsAt: { gt: now }, }, orderBy: { startsAt: 'asc' }, }); for (const event of upcomingEvents) { if (event.reminderOffsets.length === 0) { continue; } const guild = client.guilds.cache.get(event.guildId) ?? await client.guilds.fetch(event.guildId).catch(() => null); if (!guild) continue; const channel = await resolveAnnouncementChannel(guild, event.announcementChannelId); if (!channel) continue; const diff = event.startsAt.getTime() - now.getTime(); const dueOffsets = event.reminderOffsets.filter(offset => offset > 0 && !event.sentReminderOffsets.includes(offset) && diff <= offset * 60 * 1000 && diff > 0 ); for (const offset of dueOffsets) { await this.sendReminder(guild, channel, event.id, offset); } } const startedEvents = await prisma.guildEvent.findMany({ where: { status: 'SCHEDULED', startsAt: { lte: now }, }, orderBy: { startsAt: 'asc' }, }); for (const event of startedEvents) { const guild = client.guilds.cache.get(event.guildId) ?? await client.guilds.fetch(event.guildId).catch(() => null); if (!guild) continue; if (event.reminderOffsets.includes(0) && !event.sentReminderOffsets.includes(0)) { const channel = await resolveAnnouncementChannel(guild, event.announcementChannelId); if (channel) { try { const embed = buildEventEmbed( 'started', event.title, event.description, event.startsAt, event.createdByUserId, ); await channel.send({ embeds: [embed] }); } catch (error) { logger.error(`EventService: Failed to send start announcement for event ${event.id}`, error); await auditLogService.log(guild, { category: 'SYSTEM', severity: 'WARN', title: 'Event Start Announcement Failed', description: `Failed to send start announcement for event **${event.title}** (\`${event.id}\`).`, }).catch(() => {}); } } } await prisma.guildEvent.update({ where: { id: event.id }, data: { startedAnnounced: event.reminderOffsets.includes(0) ? true : event.startedAnnounced, sentReminderOffsets: event.reminderOffsets.includes(0) && !event.sentReminderOffsets.includes(0) ? [...event.sentReminderOffsets, 0] : event.sentReminderOffsets, status: 'COMPLETED', }, }); } } private static async sendReminder( guild: Guild, channel: TextChannel, eventId: string, offsetMinutes: number, ) { const event = await prisma.guildEvent.findUnique({ where: { id: eventId } }); if (!event) return; const mode = offsetMinutes >= 60 ? 'oneHourReminder' : 'tenMinuteReminder'; const embed = buildEventEmbed( mode, event.title, event.description, event.startsAt, event.createdByUserId, ); try { await channel.send({ embeds: [embed] }); await prisma.guildEvent.update({ where: { id: event.id }, data: { sentReminderOffsets: [...event.sentReminderOffsets, offsetMinutes], remindedOneHour: mode === 'oneHourReminder' ? true : event.remindedOneHour, remindedTenMinutes: mode === 'tenMinuteReminder' ? true : event.remindedTenMinutes, }, }); } catch (error) { logger.error(`EventService: Failed to send ${offsetMinutes}m reminder for event ${event.id}`, error); await auditLogService.log(guild, { category: 'SYSTEM', severity: 'WARN', title: 'Event Reminder Failed', description: `Failed to send ${offsetMinutes}m reminder for event **${event.title}** (\`${event.id}\`).`, }).catch(() => {}); } } }