fix(discord): send mission roll calls once
This commit is contained in:
parent
dcc58327e3
commit
4ad49d3b95
5 changed files with 97 additions and 7 deletions
|
|
@ -13,10 +13,11 @@ import { botConfig } from "@/bot/config";
|
|||
|
||||
const EMBED_COLOR = 0x3b82f6;
|
||||
const CANCELLED_COLOR = 0x6b7280;
|
||||
const ELIGIBLE_STATUSES = ["Ready", "Scheduled"];
|
||||
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";
|
||||
|
|
@ -32,6 +33,19 @@ const isStillFuture = (mission: Mission): boolean => {
|
|||
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":
|
||||
|
|
@ -73,6 +87,7 @@ export const fetchAttendanceDocs = async (
|
|||
};
|
||||
|
||||
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,
|
||||
|
|
@ -82,8 +97,18 @@ export const getEligibleMissions = async (payload: Payload): Promise<Mission[]>
|
|||
where: {
|
||||
and: [
|
||||
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
||||
{ "ownershipAndStatus.status": { in: ELIGIBLE_STATUSES } },
|
||||
{ "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({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
data: { discordMessageId: message.id, discordAttendanceHash: hash },
|
||||
data: {
|
||||
discordMessageId: message.id,
|
||||
discordAttendanceHash: hash,
|
||||
discordAttendanceSentAt: new Date().toISOString(),
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
payload.logger.info(
|
||||
|
|
@ -197,7 +226,13 @@ const manageMission = async (
|
|||
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;
|
||||
}
|
||||
|
|
@ -207,8 +242,17 @@ const manageMission = async (
|
|||
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.`);
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -224,7 +268,12 @@ const manageMission = async (
|
|||
await payload.update({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
data: { discordAttendanceHash: hash },
|
||||
data: {
|
||||
discordAttendanceHash: hash,
|
||||
...(mission.discordAttendanceSentAt
|
||||
? {}
|
||||
: { discordAttendanceSentAt: new Date().toISOString() }),
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
};
|
||||
|
|
@ -261,6 +310,8 @@ const cancelMissionEmbed = async (
|
|||
};
|
||||
|
||||
export const reconcile = async (client: Client, payload: Payload): Promise<void> => {
|
||||
if (reconcileRunning) return;
|
||||
reconcileRunning = true;
|
||||
try {
|
||||
const opsChannelId = botConfig.opsChannelId;
|
||||
if (!opsChannelId) {
|
||||
|
|
@ -273,7 +324,9 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
|||
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) {
|
||||
try {
|
||||
await manageMission(client, payload, opsChannelId, mission);
|
||||
|
|
@ -296,6 +349,8 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
|||
}
|
||||
} catch (error) {
|
||||
payload.logger.error(`[Bot] Attendance embed reconcile failed: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
reconcileRunning = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,14 @@ export const Missions: CollectionConfig = {
|
|||
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",
|
||||
type: "upload",
|
||||
|
|
|
|||
16
src/migrations/20260821_060000.ts
Normal file
16
src/migrations/20260821_060000.ts
Normal 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";
|
||||
`);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import * as migration_20260818_024510 from './_archived/20260818_024510';
|
||||
import * as migration_20260820_004157 from './20260820_004157';
|
||||
import * as migration_20260821_043007 from './20260821_043007';
|
||||
import * as migration_20260821_060000 from './20260821_060000';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -13,4 +14,9 @@ export const migrations = [
|
|||
down: migration_20260821_043007.down,
|
||||
name: '20260821_043007'
|
||||
},
|
||||
{
|
||||
up: migration_20260821_060000.up,
|
||||
down: migration_20260821_060000.down,
|
||||
name: '20260821_060000'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -1802,6 +1802,10 @@ export interface Mission {
|
|||
* Hash of the last attendance state rendered to Discord (managed by the bot).
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
|
@ -3338,6 +3342,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
|||
operationType?: T;
|
||||
discordMessageId?: T;
|
||||
discordAttendanceHash?: T;
|
||||
discordAttendanceSentAt?: T;
|
||||
coverImage?: T;
|
||||
classification?:
|
||||
| T
|
||||
|
|
|
|||
Loading…
Reference in a new issue