import { ActionRowBuilder, AttachmentBuilder, ButtonBuilder, ButtonStyle, type Client, EmbedBuilder, type TextBasedChannelFields, } from "discord.js"; import fsPromises from "fs/promises"; import path from "path"; import type { Payload } from "payload"; import type { Mission, MissionAttendance, ModList, User } from "@/payload-types"; import { getMissionAttendance, type MissionAttendanceSummary } from "@/lib/attendance"; import { ATTENDANCE_CHANGED_CHANNEL } from "@/lib/attendance/realtime"; import { botConfig } from "@/bot/config"; const EMBED_COLOR = 0x3b82f6; const CANCELLED_COLOR = 0x6b7280; const MAX_ELIGIBLE_MISSIONS = 10; const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000; // Discord attachment cap (8 MiB) — larger modlists fall back to a link. const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024; // Payload's default staticDir for an upload collection is its slug, resolved // against the process cwd — the bot shares the web app's filesystem. const MODLIST_STATIC_DIR = "mod-lists"; let opsChannelWarned = false; let reconcileRunning = false; const errorMessage = (error: unknown): string => error instanceof Error ? error.message : "Unknown error"; const missionTitle = (mission: Mission): string => mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name; const memberName = (user: User): string => user.displayName ?? user.username; /** * Roll call is sent once: as soon as the mission is Ready, or three days * before a Scheduled mission, whichever happens first. */ export const isRollCallDue = (mission: Mission, now = Date.now()): boolean => { if (mission.discordAttendanceSentAt) return false; if (mission.ownershipAndStatus.status === "Ready") return true; if (mission.ownershipAndStatus.status !== "Scheduled") return false; const start = new Date(mission.classification.startDateTime).getTime(); return start - now <= ROLL_CALL_LEAD_TIME_MS; }; const operationTypeLabel = (type: Mission["operationType"]): string => { switch (type) { case "main": return "Main Operation"; case "side": return "Side Operation"; case "external": return "External Operation"; } }; /** * Deterministic fingerprint of the attendance state and scheduled start time * so reconcile can skip embeds that haven't changed (anti-loop), while still * detecting mission date changes. */ export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string): string => { const rows = docs .map((doc) => { const userId = typeof doc.user === "object" ? doc.user.id : doc.user; return [userId, doc.response] as const; }) .sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1])); return JSON.stringify({ rows, startDateTime: startDateTime ?? null }); }; export const fetchAttendanceDocs = async ( payload: Payload, missionId: number, ): Promise => { const res = await payload.find({ collection: "mission-attendances", where: { mission: { equals: missionId } }, depth: 0, limit: 1000, sort: "createdAt", overrideAccess: true, }); return res.docs as unknown as MissionAttendance[]; }; export const getEligibleMissions = async (payload: Payload): Promise => { const rollCallCutoff = new Date(Date.now() + ROLL_CALL_LEAD_TIME_MS).toISOString(); const res = await payload.find({ collection: "missions", overrideAccess: true, depth: 1, sort: "-classification.startDateTime", limit: MAX_ELIGIBLE_MISSIONS, where: { and: [ { "ownershipAndStatus.visibility": { equals: "unit" } }, { or: [ { and: [ { "classification.startDateTime": { greater_than: new Date().toISOString() } }, { "ownershipAndStatus.status": { equals: "Ready" } }, ], }, { and: [ { "classification.startDateTime": { greater_than: new Date().toISOString() } }, { "ownershipAndStatus.status": { equals: "Scheduled" } }, { "classification.startDateTime": { less_than_equal: rollCallCutoff } }, ], }, { discordMessageId: { exists: true } }, ], }, ], }, }); return res.docs as unknown as Mission[]; }; const getCancelledMissions = async (payload: Payload): Promise => { const res = await payload.find({ collection: "missions", overrideAccess: true, depth: 1, sort: "-classification.startDateTime", limit: MAX_ELIGIBLE_MISSIONS, where: { and: [ { "ownershipAndStatus.status": { equals: "Cancelled" } }, { discordMessageId: { exists: true } }, ], }, }); return res.docs as unknown as Mission[]; }; export const renderAttendanceEmbed = ( mission: Mission, summary: MissionAttendanceSummary, ): EmbedBuilder => { const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000); const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "—"; return new EmbedBuilder() .setTitle(missionTitle(mission)) .setDescription( [ `**Operation Type:** ${operationTypeLabel(mission.operationType)}`, `**Starts:** ()`, `**Max Players:** ${mission.missionRoles.maxPlayers}`, ].join("\n"), ) .setColor(EMBED_COLOR) .addFields( { name: `✅ Yes (${summary.counts.yes})`, value: names(summary.yes), inline: true }, { name: `🤔 Tentative (${summary.counts.tentative})`, value: names(summary.tentative), inline: true, }, { name: `❌ No (${summary.counts.no})`, value: names(summary.no), inline: true }, ); }; export const renderCancelledEmbed = (mission: Mission): EmbedBuilder => new EmbedBuilder() .setTitle(missionTitle(mission)) .setDescription("This operation has been cancelled.") .setColor(CANCELLED_COLOR); export const buildAttendanceRow = (missionId: number): ActionRowBuilder => new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`ptf-att:${missionId}:yes`) .setStyle(ButtonStyle.Success) .setLabel("✅ Yes"), new ButtonBuilder() .setCustomId(`ptf-att:${missionId}:tentative`) .setStyle(ButtonStyle.Secondary) .setLabel("🤔 Tentative"), new ButtonBuilder() .setCustomId(`ptf-att:${missionId}:no`) .setStyle(ButtonStyle.Danger) .setLabel("❌ No"), ); const getOpsChannel = async ( client: Client, opsChannelId: string, ): Promise => { const channel = await client.channels.fetch(opsChannelId); if (!channel || !("send" in channel)) { throw new Error(`[Bot] Ops channel ${opsChannelId} is not a text channel.`); } return channel as unknown as TextBasedChannelFields; }; const postEmbed = async ( client: Client, payload: Payload, opsChannelId: string, mission: Mission, ): Promise => { const summary = await getMissionAttendance(payload, mission.id); const hash = attendanceHash( await fetchAttendanceDocs(payload, mission.id), mission.classification.startDateTime, ); const channel = await getOpsChannel(client, opsChannelId); const message = await channel.send({ embeds: [renderAttendanceEmbed(mission, summary)], components: [buildAttendanceRow(mission.id)], }); await payload.update({ collection: "missions", id: mission.id, data: { discordMessageId: message.id, discordAttendanceHash: hash, discordAttendanceSentAt: new Date().toISOString(), }, overrideAccess: true, }); payload.logger.info( `[Bot] Posted attendance embed for mission #${mission.id} (${mission.name}).`, ); await syncModlistMessage(client, payload, opsChannelId, { ...mission, discordAttendanceSentAt: new Date().toISOString(), }); }; interface ModlistInfo { id: number; name: string; filename: string; url: string | null; filesize: number | null; updatedAt: string; } const resolveModlist = async ( payload: Payload, mission: Mission, ): Promise => { const raw = mission.gameDetails.gameSetup?.requiredMods; if (raw == null || typeof raw === "number") return null; const modlist = raw as ModList; if (!modlist.filename) return null; return { id: modlist.id, name: modlist.modlistName, filename: modlist.filename, url: modlist.url ?? null, filesize: modlist.filesize ?? null, updatedAt: modlist.updatedAt, }; }; /** * Fingerprint of the attached modlist so reconcile can detect changes and * announce re-downloads without looping (same pattern as attendanceHash). */ const modlistHash = (modlist: ModlistInfo): string => JSON.stringify({ id: modlist.id, filename: modlist.filename, filesize: modlist.filesize, updatedAt: modlist.updatedAt, }); const renderModlistEmbed = ( mission: Mission, modlist: ModlistInfo, kind: "new" | "updated", ): EmbedBuilder => { const embed = new EmbedBuilder() .setTitle(kind === "new" ? `Modlist — ${modlist.name}` : `Modlist updated — ${modlist.name}`) .setColor(EMBED_COLOR) .setTimestamp(); embed.setDescription( kind === "new" ? `The modlist for **${missionTitle(mission)}** is attached. Please download it before the operation.` : `The modlist for **${missionTitle(mission)}** has changed. Please re-download the attached file before the operation.`, ); return embed; }; const postModlistMessage = async ( client: Client, payload: Payload, opsChannelId: string, mission: Mission, modlist: ModlistInfo, kind: "new" | "updated", ): Promise => { const channel = await getOpsChannel(client, opsChannelId); const embed = renderModlistEmbed(mission, modlist, kind); let files: AttachmentBuilder[] | undefined; const tooBig = modlist.filesize != null && modlist.filesize > MAX_ATTACHMENT_BYTES; if (!tooBig) { try { const dir = path.resolve(process.cwd(), MODLIST_STATIC_DIR); const filePath = path.resolve(dir, modlist.filename); if (!filePath.startsWith(dir + path.sep)) throw new Error("invalid modlist filename"); files = [ new AttachmentBuilder(await fsPromises.readFile(filePath), { name: modlist.filename }), ]; } catch (error) { payload.logger.warn( `[Bot] Could not read modlist file for mission #${mission.id}: ${errorMessage(error)}; falling back to a link.`, ); } } else { payload.logger.warn( `[Bot] Modlist for mission #${mission.id} exceeds the attachment size cap; linking instead.`, ); } if (!files && modlist.url) { embed.addFields({ name: "Download", value: `${botConfig.appUrl}${modlist.url}` }); } await channel.send({ embeds: [embed], files }); }; /** * Runs once a roll call has been sent: announces a newly attached modlist, * announces changes with a re-download reminder, and clears the marker when * the modlist is removed. The hash marker keeps this idempotent. */ const syncModlistMessage = async ( client: Client, payload: Payload, opsChannelId: string, mission: Mission, ): Promise => { if (!mission.discordAttendanceSentAt) return; const modlist = await resolveModlist(payload, mission); const currentHash = modlist ? modlistHash(modlist) : null; const storedHash = mission.discordModlistHash ?? null; if (currentHash === storedHash) return; if (modlist) { const kind = storedHash ? "updated" : "new"; await postModlistMessage(client, payload, opsChannelId, mission, modlist, kind); payload.logger.info( `[Bot] Posted ${kind} modlist message for mission #${mission.id} (${mission.name}).`, ); } else if (storedHash) { payload.logger.info( `[Bot] Modlist removed from mission #${mission.id} (${mission.name}); marker cleared.`, ); } await payload.update({ collection: "missions", id: mission.id, data: { discordModlistHash: currentHash }, overrideAccess: true, }); }; const manageMission = async ( client: Client, payload: Payload, opsChannelId: string, mission: Mission, ): Promise => { if (!isRollCallDue(mission) && !mission.discordAttendanceSentAt) { return; } if (mission.discordAttendanceSentAt) { await syncModlistMessage(client, payload, opsChannelId, mission); } if (!mission.discordMessageId) { if (mission.discordAttendanceSentAt) return; await postEmbed(client, payload, opsChannelId, mission); return; } const channel = await getOpsChannel(client, opsChannelId); let message; try { message = await channel.messages.fetch(mission.discordMessageId); } catch { // The roll call is intentionally one-shot. Do not replace a deleted // message, even for missions created before the persistent send marker. await payload.update({ collection: "missions", id: mission.id, data: { discordMessageId: null, discordAttendanceSentAt: new Date().toISOString() }, overrideAccess: true, }); payload.logger.warn( `[Bot] Attendance embed for mission #${mission.id} is missing; will not repost a one-shot roll call.`, ); return; } const hash = attendanceHash( await fetchAttendanceDocs(payload, mission.id), mission.classification.startDateTime, ); if (hash === mission.discordAttendanceHash) { return; } const summary = await getMissionAttendance(payload, mission.id); await message.edit({ embeds: [renderAttendanceEmbed(mission, summary)], components: [buildAttendanceRow(mission.id)], }); await payload.update({ collection: "missions", id: mission.id, data: { discordAttendanceHash: hash, ...(mission.discordAttendanceSentAt ? {} : { discordAttendanceSentAt: new Date().toISOString() }), }, overrideAccess: true, }); }; export const reconcileMission = async ( client: Client, payload: Payload, missionId: number, ): Promise => { const opsChannelId = botConfig.opsChannelId; if (!opsChannelId) return; const mission = (await payload.findByID({ collection: "missions", id: missionId, depth: 1, overrideAccess: true, })) as Mission; if ( mission.ownershipAndStatus.visibility !== "unit" || !["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) || (!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt) ) { return; } await manageMission(client, payload, opsChannelId, mission); }; const cancelMissionEmbed = async ( client: Client, payload: Payload, opsChannelId: string, mission: Mission, ): Promise => { const channel = await getOpsChannel(client, opsChannelId); let message; try { message = await channel.messages.fetch(mission.discordMessageId as string); } catch { await payload.update({ collection: "missions", id: mission.id, data: { discordMessageId: null }, overrideAccess: true, }); return; } await message.edit({ embeds: [renderCancelledEmbed(mission)], components: [] }); await payload.update({ collection: "missions", id: mission.id, data: { discordMessageId: null }, overrideAccess: true, }); payload.logger.info( `[Bot] Marked attendance embed for cancelled mission #${mission.id} (${mission.name}).`, ); }; export const reconcile = async (client: Client, payload: Payload): Promise => { if (reconcileRunning) return; reconcileRunning = true; try { const opsChannelId = botConfig.opsChannelId; if (!opsChannelId) { if (!opsChannelWarned) { payload.logger.warn( "[Bot] Attendance embed service: DISCORD_OPS_CHANNEL_ID is not set; skipping embed work.", ); opsChannelWarned = true; } return; } const eligible = (await getEligibleMissions(payload)).filter( (mission) => isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt, ); for (const mission of eligible) { try { await manageMission(client, payload, opsChannelId, mission); } catch (error) { payload.logger.error( `[Bot] Attendance embed reconcile failed for mission #${mission.id}: ${errorMessage(error)}`, ); } } const cancelled = await getCancelledMissions(payload); for (const mission of cancelled) { try { await cancelMissionEmbed(client, payload, opsChannelId, mission); } catch (error) { payload.logger.error( `[Bot] Cancelled-embed reconcile failed for mission #${mission.id}: ${errorMessage(error)}`, ); } } } catch (error) { payload.logger.error(`[Bot] Attendance embed reconcile failed: ${errorMessage(error)}`); } finally { reconcileRunning = false; } }; export const start = (client: Client, payload: Payload): void => { payload.logger.info("[Bot] Attendance embed service started"); void startAttendanceListener(client, payload).catch((error) => { payload.logger.error( `[Bot] Attendance realtime listener failed to start: ${errorMessage(error)}`, ); }); void reconcile(client, payload); setInterval(() => { void reconcile(client, payload); }, botConfig.attendancePollMs); }; const startAttendanceListener = async (client: Client, payload: Payload): Promise => { if (!botConfig.opsChannelId) return; const listener = await payload.db.pool.connect(); listener.on("notification", (notification) => { if (notification.channel !== ATTENDANCE_CHANGED_CHANNEL || !notification.payload) return; let missionId: unknown; try { missionId = JSON.parse(notification.payload).missionId; } catch { payload.logger.warn("[Bot] Ignoring malformed attendance change notification."); return; } if (typeof missionId !== "number" || !Number.isInteger(missionId)) return; void reconcileMission(client, payload, missionId).catch((error) => { payload.logger.error( `[Bot] Attendance change reconcile failed for mission #${missionId}: ${errorMessage(error)}`, ); }); }); listener.on("error", (error) => { payload.logger.error(`[Bot] Attendance listener failed: ${errorMessage(error)}`); }); await listener.query(`LISTEN ${ATTENDANCE_CHANGED_CHANNEL}`); payload.logger.info("[Bot] Attendance realtime listener started"); };