266 lines
8.0 KiB
TypeScript
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(() => {});
|
|
}
|
|
}
|
|
}
|