diff --git a/src/bot/services/missionEmbeds.ts b/src/bot/services/missionEmbeds.ts index 14671e9..b0b3e50 100644 --- a/src/bot/services/missionEmbeds.ts +++ b/src/bot/services/missionEmbeds.ts @@ -1,13 +1,16 @@ 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, User } from "@/payload-types"; +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"; @@ -16,6 +19,11 @@ 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; @@ -223,6 +231,141 @@ const postEmbed = async ( 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 ( @@ -231,9 +374,12 @@ const manageMission = async ( opsChannelId: string, mission: Mission, ): Promise => { - if (!isRollCallDue(mission)) { - // A sent roll call may still need its existing message reconciled. - if (!mission.discordAttendanceSentAt) return; + if (!isRollCallDue(mission) && !mission.discordAttendanceSentAt) { + return; + } + + if (mission.discordAttendanceSentAt) { + await syncModlistMessage(client, payload, opsChannelId, mission); } if (!mission.discordMessageId) { @@ -304,7 +450,7 @@ export const reconcileMission = async ( if ( mission.ownershipAndStatus.visibility !== "unit" || !["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) || - (!isRollCallDue(mission) && !mission.discordMessageId) + (!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt) ) { return; } @@ -359,7 +505,7 @@ export const reconcile = async (client: Client, payload: Payload): Promise } const eligible = (await getEligibleMissions(payload)).filter( - (mission) => isRollCallDue(mission) || !!mission.discordMessageId, + (mission) => isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt, ); for (const mission of eligible) { try {