308 lines
9.4 KiB
TypeScript
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);
|
|
};
|