import { EmbedBuilder, type Client, type Message } from "discord.js"; import type { Payload } from "payload"; import type { Mission, User } from "@/payload-types"; import { botConfig } from "@/bot/config"; import { getMissionAttendance } from "@/lib/attendance"; import { missionTitle } from "@/lib/intelligence/readyReminders"; import { getImminentReminderMissions } from "@/lib/intelligence/imminentReminders"; /** * Imminent-op reminder service: 15 minutes before a posted op starts, open a * thread on the roll-call message and ping everyone who RSVP'd yes, so the * attendees get a last-call nudge that the op is imminent. * * The pings deliberately ignore `preferences.discord.enabled` (that toggle * gates the generic notification DM bridge; a yes RSVP is an explicit intent * to attend). One-shot per mission via the imminentReminderSentAt marker, * which the cancel flow clears so a rescheduled op re-reminds. */ const EMBED_COLOR = 0xf59e0b; const MISSIONS_PER_TICK = 10; const THREAD_NAME_MAX = 100; let reconcileRunning = false; const errorMessage = (error: unknown): string => error instanceof Error ? error.message : "Unknown error"; const renderReminderEmbed = (mission: Mission): EmbedBuilder => { const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000); return new EmbedBuilder() .setTitle(`⏰ Op starting soon: ${missionTitle(mission)}`) .setDescription(`The operation starts . Load up and check in here.`) .setColor(EMBED_COLOR) .addFields({ name: "Open the op", value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`, }); }; const mentionsFor = (users: User[]): string[] => users .filter((user) => user.discordId) .map((user) => `<@${user.discordId as string}>`); const fetchRollCallMessage = async ( client: Client, opsChannelId: string, messageId: string, ): Promise => { const channel = await client.channels.fetch(opsChannelId); if (!channel || !("messages" in channel)) { throw new Error(`[Bot] Ops channel ${opsChannelId} is not a text channel.`); } return channel.messages.fetch(messageId); }; const processMission = async ( client: Client, payload: Payload, mission: Mission, now: Date, ): Promise => { const opsChannelId = botConfig.opsChannelId; if (!opsChannelId || !mission.discordMessageId) return; const summary = await getMissionAttendance(payload, mission.id); const message = await fetchRollCallMessage(client, opsChannelId, mission.discordMessageId); const thread = await message.startThread({ name: `Op starting soon: ${missionTitle(mission)}`.slice(0, THREAD_NAME_MAX), autoArchiveDuration: 60, }); const mentions = mentionsFor(summary.yes); await thread.send({ content: mentions.length > 0 ? mentions.join(" ") : undefined, embeds: [renderReminderEmbed(mission)], }); await payload.update({ collection: "missions", id: mission.id, data: { imminentReminderSentAt: now.toISOString() }, overrideAccess: true, }); payload.logger.info( `[Bot] Posted imminent-op reminder thread for mission #${mission.id} (${mission.name}); pinged ${mentions.length} attendee(s).`, ); }; export interface ReconcileSummary { processed: number; sent: number; } export const reconcile = async (client: Client, payload: Payload): Promise => { if (reconcileRunning) return { processed: 0, sent: 0 }; if (!botConfig.opsChannelId) return { processed: 0, sent: 0 }; reconcileRunning = true; let processed = 0; let sent = 0; try { const now = new Date(); const missions = (await getImminentReminderMissions(payload, now)).slice(0, MISSIONS_PER_TICK); for (const mission of missions) { processed += 1; try { await processMission(client, payload, mission, now); sent += 1; } catch (error) { payload.logger.error( `[Bot] Imminent reminder failed for mission #${mission.id}: ${errorMessage(error)}`, ); } } } catch (error) { payload.logger.error(`[Bot] Imminent reminder reconcile failed: ${errorMessage(error)}`); } finally { reconcileRunning = false; } return { processed, sent }; }; export const start = (client: Client, payload: Payload): void => { payload.logger.info("[Bot] Imminent reminder service started"); void reconcile(client, payload); setInterval(() => { void reconcile(client, payload); }, botConfig.imminentReminderPollMs); };