feat(bot): announce mission modlists in the ops channel
This commit is contained in:
parent
8d6405247e
commit
399f7f0bcf
1 changed files with 152 additions and 6 deletions
|
|
@ -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<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 (
|
||||
|
|
@ -231,9 +374,12 @@ const manageMission = async (
|
|||
opsChannelId: string,
|
||||
mission: Mission,
|
||||
): Promise<void> => {
|
||||
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<void>
|
|||
}
|
||||
|
||||
const eligible = (await getEligibleMissions(payload)).filter(
|
||||
(mission) => isRollCallDue(mission) || !!mission.discordMessageId,
|
||||
(mission) => isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,
|
||||
);
|
||||
for (const mission of eligible) {
|
||||
try {
|
||||
|
|
|
|||
Loading…
Reference in a new issue