Kord/src/services/EventService.ts

266 lines
8.0 KiB
TypeScript

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: `<t:${unix}:F>`,
relative: `<t:${unix}:R>`,
};
}
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<TextChannel | null> {
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(() => {});
}
}
}