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

308 lines
9.4 KiB
TypeScript

import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
type Client,
EmbedBuilder,
type TextBasedChannelFields,
} from "discord.js";
import type { Payload } from "payload";
import type { Mission, MissionAttendance, User } from "@/payload-types";
import { getMissionAttendance, type MissionAttendanceSummary } from "@/lib/attendance";
import { botConfig } from "@/bot/config";
const EMBED_COLOR = 0x3b82f6;
const CANCELLED_COLOR = 0x6b7280;
const ELIGIBLE_STATUSES = ["Ready", "Scheduled"];
const MAX_ELIGIBLE_MISSIONS = 10;
let opsChannelWarned = 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;
const isStillFuture = (mission: Mission): boolean => {
const start = new Date(mission.classification.startDateTime).getTime();
const end = start + mission.classification.estimatedDuration * 60_000;
return end > Date.now();
};
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 so reconcile can skip
* embeds that haven't changed (anti-loop).
*/
export const attendanceHash = (docs: MissionAttendance[]): 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);
};
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 res = await payload.find({
collection: "missions",
overrideAccess: true,
depth: 1,
sort: "-classification.startDateTime",
limit: MAX_ELIGIBLE_MISSIONS,
where: {
and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.status": { in: ELIGIBLE_STATUSES } },
{ "ownershipAndStatus.visibility": { equals: "unit" } },
],
},
});
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));
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 },
overrideAccess: true,
});
payload.logger.info(
`[Bot] Posted attendance embed for mission #${mission.id} (${mission.name}).`,
);
};
const manageMission = async (
client: Client,
payload: Payload,
opsChannelId: string,
mission: Mission,
): Promise<void> => {
if (!mission.discordMessageId) {
await postEmbed(client, payload, opsChannelId, mission);
return;
}
const channel = await getOpsChannel(client, opsChannelId);
let message;
try {
message = await channel.messages.fetch(mission.discordMessageId);
} catch {
await postEmbed(client, payload, opsChannelId, mission);
payload.logger.warn(`[Bot] Attendance embed for mission #${mission.id} was deleted; reposted.`);
return;
}
const hash = attendanceHash(await fetchAttendanceDocs(payload, mission.id));
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 },
overrideAccess: true,
});
};
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> => {
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(isStillFuture);
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)}`);
}
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Attendance embed service started");
void reconcile(client, payload);
setInterval(() => {
void reconcile(client, payload);
}, botConfig.attendancePollMs);
};