1
0
Fork 0
polaris-task-force/src/bot/services/missionEmbeds.ts

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");
};