import { ActionRowBuilder, ButtonBuilder, ButtonStyle, type Client, EmbedBuilder, type TextBasedChannelFields, } from "discord.js"; import type { Payload } from "payload"; import type { Mission, MissionAttendance, 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; 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; const isStillFuture = (mission: Mission): boolean => { const start = new Date(mission.classification.startDateTime).getTime(); const end = start + mission.classification.estimatedDuration * 60_000; return end > Date.now(); }; /** * 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 so reconcile can skip * embeds that haven't changed (anti-loop). */ export const attendanceHash = (docs: MissionAttendance[]): 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); }; 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: [ { "classification.startDateTime": { greater_than: new Date().toISOString() } }, { "ownershipAndStatus.visibility": { equals: "unit" } }, { or: [ { "ownershipAndStatus.status": { equals: "Ready" } }, { and: [ { "ownershipAndStatus.status": { equals: "Scheduled" } }, { "classification.startDateTime": { less_than_equal: rollCallCutoff } }, ], }, ], }, ], }, }); 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)); 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}).`, ); }; const manageMission = async ( client: Client, payload: Payload, opsChannelId: string, mission: Mission, ): Promise => { if (!isRollCallDue(mission)) { // A sent roll call may still need its existing message reconciled. if (!mission.discordAttendanceSentAt) return; } 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)); 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) || !isStillFuture(mission) || (!isRollCallDue(mission) && !mission.discordMessageId) ) { 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) => isStillFuture(mission) && (isRollCallDue(mission) || !!mission.discordMessageId), ); 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"); };