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 {
|
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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue