578 lines
18 KiB
TypeScript
578 lines
18 KiB
TypeScript
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, 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";
|
|
|
|
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;
|
|
|
|
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;
|
|
|
|
/**
|
|
* Roll call is sent once: as soon as the mission is Ready, or three days
|
|
* before a Scheduled mission, whichever happens first.
|
|
*/
|
|
export const isRollCallDue = (mission: Mission, now = Date.now()): boolean => {
|
|
if (mission.discordAttendanceSentAt) return false;
|
|
if (mission.ownershipAndStatus.status === "Ready") return true;
|
|
if (mission.ownershipAndStatus.status !== "Scheduled") return false;
|
|
|
|
const start = new Date(mission.classification.startDateTime).getTime();
|
|
return start - now <= ROLL_CALL_LEAD_TIME_MS;
|
|
};
|
|
|
|
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 and scheduled start time
|
|
* so reconcile can skip embeds that haven't changed (anti-loop), while still
|
|
* detecting mission date changes.
|
|
*/
|
|
export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string): 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, startDateTime: startDateTime ?? null });
|
|
};
|
|
|
|
export const fetchAttendanceDocs = async (
|
|
payload: Payload,
|
|
missionId: number,
|
|
): Promise<MissionAttendance[]> => {
|
|
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<Mission[]> => {
|
|
const rollCallCutoff = new Date(Date.now() + ROLL_CALL_LEAD_TIME_MS).toISOString();
|
|
const res = await payload.find({
|
|
collection: "missions",
|
|
overrideAccess: true,
|
|
depth: 1,
|
|
sort: "-classification.startDateTime",
|
|
limit: MAX_ELIGIBLE_MISSIONS,
|
|
where: {
|
|
and: [
|
|
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
|
{
|
|
or: [
|
|
{
|
|
and: [
|
|
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
|
{ "ownershipAndStatus.status": { equals: "Ready" } },
|
|
],
|
|
},
|
|
{
|
|
and: [
|
|
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
|
{ "ownershipAndStatus.status": { equals: "Scheduled" } },
|
|
{ "classification.startDateTime": { less_than_equal: rollCallCutoff } },
|
|
],
|
|
},
|
|
{ discordMessageId: { exists: true } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
return res.docs as unknown as Mission[];
|
|
};
|
|
|
|
const getCancelledMissions = async (payload: Payload): Promise<Mission[]> => {
|
|
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:** <t:${startUnix}:F> (<t:${startUnix}:R>)`,
|
|
`**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<ButtonBuilder> =>
|
|
new ActionRowBuilder<ButtonBuilder>().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<TextBasedChannelFields> => {
|
|
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<void> => {
|
|
const summary = await getMissionAttendance(payload, mission.id);
|
|
const hash = attendanceHash(
|
|
await fetchAttendanceDocs(payload, mission.id),
|
|
mission.classification.startDateTime,
|
|
);
|
|
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,
|
|
discordAttendanceSentAt: new Date().toISOString(),
|
|
},
|
|
overrideAccess: true,
|
|
});
|
|
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 (
|
|
client: Client,
|
|
payload: Payload,
|
|
opsChannelId: string,
|
|
mission: Mission,
|
|
): Promise<void> => {
|
|
if (!isRollCallDue(mission) && !mission.discordAttendanceSentAt) {
|
|
return;
|
|
}
|
|
|
|
if (mission.discordAttendanceSentAt) {
|
|
await syncModlistMessage(client, payload, opsChannelId, mission);
|
|
}
|
|
|
|
if (!mission.discordMessageId) {
|
|
if (mission.discordAttendanceSentAt) return;
|
|
await postEmbed(client, payload, opsChannelId, mission);
|
|
return;
|
|
}
|
|
|
|
const channel = await getOpsChannel(client, opsChannelId);
|
|
let message;
|
|
try {
|
|
message = await channel.messages.fetch(mission.discordMessageId);
|
|
} catch {
|
|
// The roll call is intentionally one-shot. Do not replace a deleted
|
|
// message, even for missions created before the persistent send marker.
|
|
await payload.update({
|
|
collection: "missions",
|
|
id: mission.id,
|
|
data: { discordMessageId: null, discordAttendanceSentAt: new Date().toISOString() },
|
|
overrideAccess: true,
|
|
});
|
|
payload.logger.warn(
|
|
`[Bot] Attendance embed for mission #${mission.id} is missing; will not repost a one-shot roll call.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
const hash = attendanceHash(
|
|
await fetchAttendanceDocs(payload, mission.id),
|
|
mission.classification.startDateTime,
|
|
);
|
|
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,
|
|
...(mission.discordAttendanceSentAt
|
|
? {}
|
|
: { discordAttendanceSentAt: new Date().toISOString() }),
|
|
},
|
|
overrideAccess: true,
|
|
});
|
|
};
|
|
|
|
export const reconcileMission = async (
|
|
client: Client,
|
|
payload: Payload,
|
|
missionId: number,
|
|
): Promise<void> => {
|
|
const opsChannelId = botConfig.opsChannelId;
|
|
if (!opsChannelId) return;
|
|
|
|
const mission = (await payload.findByID({
|
|
collection: "missions",
|
|
id: missionId,
|
|
depth: 1,
|
|
overrideAccess: true,
|
|
})) as Mission;
|
|
|
|
if (
|
|
mission.ownershipAndStatus.visibility !== "unit" ||
|
|
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
|
|
(!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
await manageMission(client, payload, opsChannelId, mission);
|
|
};
|
|
|
|
const cancelMissionEmbed = async (
|
|
client: Client,
|
|
payload: Payload,
|
|
opsChannelId: string,
|
|
mission: Mission,
|
|
): Promise<void> => {
|
|
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<void> => {
|
|
if (reconcileRunning) return;
|
|
reconcileRunning = true;
|
|
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(
|
|
(mission) => isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,
|
|
);
|
|
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)}`);
|
|
} finally {
|
|
reconcileRunning = false;
|
|
}
|
|
};
|
|
|
|
export const start = (client: Client, payload: Payload): void => {
|
|
payload.logger.info("[Bot] Attendance embed service started");
|
|
void startAttendanceListener(client, payload).catch((error) => {
|
|
payload.logger.error(
|
|
`[Bot] Attendance realtime listener failed to start: ${errorMessage(error)}`,
|
|
);
|
|
});
|
|
void reconcile(client, payload);
|
|
setInterval(() => {
|
|
void reconcile(client, payload);
|
|
}, botConfig.attendancePollMs);
|
|
};
|
|
|
|
const startAttendanceListener = async (client: Client, payload: Payload): Promise<void> => {
|
|
if (!botConfig.opsChannelId) return;
|
|
|
|
const listener = await payload.db.pool.connect();
|
|
listener.on("notification", (notification) => {
|
|
if (notification.channel !== ATTENDANCE_CHANGED_CHANNEL || !notification.payload) return;
|
|
|
|
let missionId: unknown;
|
|
try {
|
|
missionId = JSON.parse(notification.payload).missionId;
|
|
} catch {
|
|
payload.logger.warn("[Bot] Ignoring malformed attendance change notification.");
|
|
return;
|
|
}
|
|
if (typeof missionId !== "number" || !Number.isInteger(missionId)) return;
|
|
|
|
void reconcileMission(client, payload, missionId).catch((error) => {
|
|
payload.logger.error(
|
|
`[Bot] Attendance change reconcile failed for mission #${missionId}: ${errorMessage(error)}`,
|
|
);
|
|
});
|
|
});
|
|
|
|
listener.on("error", (error) => {
|
|
payload.logger.error(`[Bot] Attendance listener failed: ${errorMessage(error)}`);
|
|
});
|
|
await listener.query(`LISTEN ${ATTENDANCE_CHANGED_CHANNEL}`);
|
|
payload.logger.info("[Bot] Attendance realtime listener started");
|
|
};
|