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 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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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_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'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue