1
0
Fork 0

fix(discord): send mission roll calls once

This commit is contained in:
Jason Fraley 2026-08-21 01:56:12 -04:00
parent dcc58327e3
commit 4ad49d3b95
5 changed files with 97 additions and 7 deletions

View file

@ -13,10 +13,11 @@ import { botConfig } from "@/bot/config";
const EMBED_COLOR = 0x3b82f6; const EMBED_COLOR = 0x3b82f6;
const CANCELLED_COLOR = 0x6b7280; const CANCELLED_COLOR = 0x6b7280;
const ELIGIBLE_STATUSES = ["Ready", "Scheduled"];
const MAX_ELIGIBLE_MISSIONS = 10; const MAX_ELIGIBLE_MISSIONS = 10;
const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000;
let opsChannelWarned = false; let opsChannelWarned = false;
let reconcileRunning = false;
const errorMessage = (error: unknown): string => const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error"; error instanceof Error ? error.message : "Unknown error";
@ -32,6 +33,19 @@ const isStillFuture = (mission: Mission): boolean => {
return end > Date.now(); 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 => { const operationTypeLabel = (type: Mission["operationType"]): string => {
switch (type) { switch (type) {
case "main": case "main":
@ -73,6 +87,7 @@ export const fetchAttendanceDocs = async (
}; };
export const getEligibleMissions = async (payload: Payload): Promise<Mission[]> => { 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({ const res = await payload.find({
collection: "missions", collection: "missions",
overrideAccess: true, overrideAccess: true,
@ -82,8 +97,18 @@ export const getEligibleMissions = async (payload: Payload): Promise<Mission[]>
where: { where: {
and: [ and: [
{ "classification.startDateTime": { greater_than: new Date().toISOString() } }, { "classification.startDateTime": { greater_than: new Date().toISOString() } },
{ "ownershipAndStatus.status": { in: ELIGIBLE_STATUSES } },
{ "ownershipAndStatus.visibility": { equals: "unit" } }, { "ownershipAndStatus.visibility": { equals: "unit" } },
{
or: [
{ "ownershipAndStatus.status": { equals: "Ready" } },
{
and: [
{ "ownershipAndStatus.status": { equals: "Scheduled" } },
{ "classification.startDateTime": { less_than_equal: rollCallCutoff } },
],
},
],
},
], ],
}, },
}); });
@ -183,7 +208,11 @@ const postEmbed = async (
await payload.update({ await payload.update({
collection: "missions", collection: "missions",
id: mission.id, id: mission.id,
data: { discordMessageId: message.id, discordAttendanceHash: hash }, data: {
discordMessageId: message.id,
discordAttendanceHash: hash,
discordAttendanceSentAt: new Date().toISOString(),
},
overrideAccess: true, overrideAccess: true,
}); });
payload.logger.info( payload.logger.info(
@ -197,7 +226,13 @@ const manageMission = async (
opsChannelId: string, opsChannelId: string,
mission: Mission, mission: Mission,
): Promise<void> => { ): 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.discordMessageId) {
if (mission.discordAttendanceSentAt) return;
await postEmbed(client, payload, opsChannelId, mission); await postEmbed(client, payload, opsChannelId, mission);
return; return;
} }
@ -207,8 +242,17 @@ const manageMission = async (
try { try {
message = await channel.messages.fetch(mission.discordMessageId); message = await channel.messages.fetch(mission.discordMessageId);
} catch { } catch {
await postEmbed(client, payload, opsChannelId, mission); // The roll call is intentionally one-shot. Do not replace a deleted
payload.logger.warn(`[Bot] Attendance embed for mission #${mission.id} was deleted; reposted.`); // 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; return;
} }
@ -224,7 +268,12 @@ const manageMission = async (
await payload.update({ await payload.update({
collection: "missions", collection: "missions",
id: mission.id, id: mission.id,
data: { discordAttendanceHash: hash }, data: {
discordAttendanceHash: hash,
...(mission.discordAttendanceSentAt
? {}
: { discordAttendanceSentAt: new Date().toISOString() }),
},
overrideAccess: true, overrideAccess: true,
}); });
}; };
@ -261,6 +310,8 @@ const cancelMissionEmbed = async (
}; };
export const reconcile = async (client: Client, payload: Payload): Promise<void> => { export const reconcile = async (client: Client, payload: Payload): Promise<void> => {
if (reconcileRunning) return;
reconcileRunning = true;
try { try {
const opsChannelId = botConfig.opsChannelId; const opsChannelId = botConfig.opsChannelId;
if (!opsChannelId) { if (!opsChannelId) {
@ -273,7 +324,9 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
return; return;
} }
const eligible = (await getEligibleMissions(payload)).filter(isStillFuture); const eligible = (await getEligibleMissions(payload)).filter(
(mission) => isStillFuture(mission) && (isRollCallDue(mission) || !!mission.discordMessageId),
);
for (const mission of eligible) { for (const mission of eligible) {
try { try {
await manageMission(client, payload, opsChannelId, mission); await manageMission(client, payload, opsChannelId, mission);
@ -296,6 +349,8 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
} }
} catch (error) { } catch (error) {
payload.logger.error(`[Bot] Attendance embed reconcile failed: ${errorMessage(error)}`); payload.logger.error(`[Bot] Attendance embed reconcile failed: ${errorMessage(error)}`);
} finally {
reconcileRunning = false;
} }
}; };

View file

@ -166,6 +166,14 @@ export const Missions: CollectionConfig = {
hidden: true, hidden: true,
}, },
}, },
{
name: "discordAttendanceSentAt",
type: "date",
admin: {
description: "When the attendance roll-call was first posted to Discord (managed by the bot).",
hidden: true,
},
},
{ {
name: "coverImage", name: "coverImage",
type: "upload", type: "upload",

View file

@ -0,0 +1,16 @@
import { MigrateDownArgs, MigrateUpArgs, sql } from "@payloadcms/db-postgres";
/** Persist the one-time Discord roll-call send marker. */
export async function up({ db }: MigrateUpArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions"
ADD COLUMN IF NOT EXISTS "discord_attendance_sent_at" timestamp(3) with time zone;
`);
}
export async function down({ db }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "missions"
DROP COLUMN IF EXISTS "discord_attendance_sent_at";
`);
}

View file

@ -1,6 +1,7 @@
import * as migration_20260818_024510 from './_archived/20260818_024510'; import * as migration_20260818_024510 from './_archived/20260818_024510';
import * as migration_20260820_004157 from './20260820_004157'; import * as migration_20260820_004157 from './20260820_004157';
import * as migration_20260821_043007 from './20260821_043007'; import * as migration_20260821_043007 from './20260821_043007';
import * as migration_20260821_060000 from './20260821_060000';
export const migrations = [ export const migrations = [
{ {
@ -13,4 +14,9 @@ export const migrations = [
down: migration_20260821_043007.down, down: migration_20260821_043007.down,
name: '20260821_043007' name: '20260821_043007'
}, },
{
up: migration_20260821_060000.up,
down: migration_20260821_060000.down,
name: '20260821_060000'
},
]; ];

View file

@ -1802,6 +1802,10 @@ export interface Mission {
* Hash of the last attendance state rendered to Discord (managed by the bot). * Hash of the last attendance state rendered to Discord (managed by the bot).
*/ */
discordAttendanceHash?: string | null; discordAttendanceHash?: string | null;
/**
* When the attendance roll-call was first posted to Discord (managed by the bot).
*/
discordAttendanceSentAt?: string | null;
/** /**
* Background image for the mission page hero header. Renders translucent with a top-to-bottom fade to black. Recommended aspect ratio: 16:9 or wider. * Background image for the mission page hero header. Renders translucent with a top-to-bottom fade to black. Recommended aspect ratio: 16:9 or wider.
*/ */
@ -3338,6 +3342,7 @@ export interface MissionsSelect<T extends boolean = true> {
operationType?: T; operationType?: T;
discordMessageId?: T; discordMessageId?: T;
discordAttendanceHash?: T; discordAttendanceHash?: T;
discordAttendanceSentAt?: T;
coverImage?: T; coverImage?: T;
classification?: classification?:
| T | T