Title-case 'Operation Type' and 'Max Players' in the RSVP embed and apply prettier formatting to the listener error log call.
426 lines
13 KiB
TypeScript
426 lines
13 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 { 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;
|
|
|
|
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;
|
|
|
|
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();
|
|
};
|
|
|
|
/**
|
|
* 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 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 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: [
|
|
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
|
{ "ownershipAndStatus.visibility": { equals: "unit" } },
|
|
{
|
|
or: [
|
|
{ "ownershipAndStatus.status": { equals: "Ready" } },
|
|
{
|
|
and: [
|
|
{ "ownershipAndStatus.status": { equals: "Scheduled" } },
|
|
{ "classification.startDateTime": { less_than_equal: rollCallCutoff } },
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
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,
|
|
discordAttendanceSentAt: new Date().toISOString(),
|
|
},
|
|
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 (!isRollCallDue(mission)) {
|
|
// A sent roll call may still need its existing message reconciled.
|
|
if (!mission.discordAttendanceSentAt) return;
|
|
}
|
|
|
|
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));
|
|
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) ||
|
|
!isStillFuture(mission) ||
|
|
(!isRollCallDue(mission) && !mission.discordMessageId)
|
|
) {
|
|
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) => isStillFuture(mission) && (isRollCallDue(mission) || !!mission.discordMessageId),
|
|
);
|
|
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");
|
|
};
|