1
0
Fork 0

feat(bot): announce mission modlists in the ops channel

This commit is contained in:
Jason Fraley 2026-09-07 00:52:24 -04:00
parent 8d6405247e
commit 399f7f0bcf

View file

@ -1,13 +1,16 @@
import { import {
ActionRowBuilder, ActionRowBuilder,
AttachmentBuilder,
ButtonBuilder, ButtonBuilder,
ButtonStyle, ButtonStyle,
type Client, type Client,
EmbedBuilder, EmbedBuilder,
type TextBasedChannelFields, type TextBasedChannelFields,
} from "discord.js"; } from "discord.js";
import fsPromises from "fs/promises";
import path from "path";
import type { Payload } from "payload"; 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 { getMissionAttendance, type MissionAttendanceSummary } from "@/lib/attendance";
import { ATTENDANCE_CHANGED_CHANNEL } from "@/lib/attendance/realtime"; import { ATTENDANCE_CHANGED_CHANNEL } from "@/lib/attendance/realtime";
import { botConfig } from "@/bot/config"; import { botConfig } from "@/bot/config";
@ -16,6 +19,11 @@ const EMBED_COLOR = 0x3b82f6;
const CANCELLED_COLOR = 0x6b7280; const CANCELLED_COLOR = 0x6b7280;
const MAX_ELIGIBLE_MISSIONS = 10; const MAX_ELIGIBLE_MISSIONS = 10;
const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000; 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 opsChannelWarned = false;
let reconcileRunning = false; let reconcileRunning = false;
@ -223,6 +231,141 @@ const postEmbed = async (
payload.logger.info( payload.logger.info(
`[Bot] Posted attendance embed for mission #${mission.id} (${mission.name}).`, `[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<ModlistInfo | null> => {
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<void> => {
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<void> => {
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 ( const manageMission = async (
@ -231,9 +374,12 @@ const manageMission = async (
opsChannelId: string, opsChannelId: string,
mission: Mission, mission: Mission,
): Promise<void> => { ): Promise<void> => {
if (!isRollCallDue(mission)) { if (!isRollCallDue(mission) && !mission.discordAttendanceSentAt) {
// A sent roll call may still need its existing message reconciled. return;
if (!mission.discordAttendanceSentAt) return; }
if (mission.discordAttendanceSentAt) {
await syncModlistMessage(client, payload, opsChannelId, mission);
} }
if (!mission.discordMessageId) { if (!mission.discordMessageId) {
@ -304,7 +450,7 @@ export const reconcileMission = async (
if ( if (
mission.ownershipAndStatus.visibility !== "unit" || mission.ownershipAndStatus.visibility !== "unit" ||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) || !["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
(!isRollCallDue(mission) && !mission.discordMessageId) (!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt)
) { ) {
return; return;
} }
@ -359,7 +505,7 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
} }
const eligible = (await getEligibleMissions(payload)).filter( const eligible = (await getEligibleMissions(payload)).filter(
(mission) => isRollCallDue(mission) || !!mission.discordMessageId, (mission) => isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,
); );
for (const mission of eligible) { for (const mission of eligible) {
try { try {