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 { botConfig } from "@/bot/config"; const EMBED_COLOR = 0x3b82f6; const CANCELLED_COLOR = 0x6b7280; const ELIGIBLE_STATUSES = ["Ready", "Scheduled"]; const MAX_ELIGIBLE_MISSIONS = 10; let opsChannelWarned = 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(); }; 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 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.status": { in: ELIGIBLE_STATUSES } }, { "ownershipAndStatus.visibility": { equals: "unit" } }, ], }, }); 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 }, 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 (!mission.discordMessageId) { await postEmbed(client, payload, opsChannelId, mission); return; } const channel = await getOpsChannel(client, opsChannelId); let message; try { message = await channel.messages.fetch(mission.discordMessageId); } catch { await postEmbed(client, payload, opsChannelId, mission); payload.logger.warn(`[Bot] Attendance embed for mission #${mission.id} was deleted; reposted.`); 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 }, overrideAccess: true, }); }; 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 => { 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(isStillFuture); 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)}`); } }; export const start = (client: Client, payload: Payload): void => { payload.logger.info("[Bot] Attendance embed service started"); void reconcile(client, payload); setInterval(() => { void reconcile(client, payload); }, botConfig.attendancePollMs); };