feat(bot): cancel roll-calls and announce rescheduled ops
- Cancelled ops: roll-call embed edited to the cancelled state (buttons removed) plus a standalone cancellation notice in the ops channel; clears the roll-call markers and sets the new discordCancelledAt marker on the mission. - Rescheduled ops (Cancelled back to Ready/Scheduled, future start): immediate announcement embed with the new start time and when the fresh roll-call goes up (usual 3-day lead, or live-in-channel when already due), then clears the marker so the normal flow posts a new roll-call. Prior RSVPs carry over. - Hidden bot-managed discordCancelledAt field on Missions + migration. - Bot embed poll loop handles both transitions idempotently.
This commit is contained in:
parent
24662060df
commit
f1da86ae81
7 changed files with 26946 additions and 14 deletions
|
|
@ -104,7 +104,8 @@ Dependency: `discord.js` ^14 (add to root `package.json`). Script: `"bot": "bun
|
||||||
1. Periodic reconcile loop (every `DISCORD_ATTENDANCE_POLL_MS`, default 60s): query eligible missions.
|
1. Periodic reconcile loop (every `DISCORD_ATTENDANCE_POLL_MS`, default 60s): query eligible missions.
|
||||||
- No `discordMessageId` yet → post embed + button row, store `discordMessageId` on the mission.
|
- No `discordMessageId` yet → post embed + button row, store `discordMessageId` on the mission.
|
||||||
- Has `discordMessageId` → compute attendance hash (sorted member lists + counts, JSON) → if ≠ stored `discordAttendanceHash`, edit the message in place, store the new hash.
|
- Has `discordMessageId` → compute attendance hash (sorted member lists + counts, JSON) → if ≠ stored `discordAttendanceHash`, edit the message in place, store the new hash.
|
||||||
- Status `Cancelled` → edit embed to a cancelled state, stop polling it.
|
- Status `Cancelled` → edit embed to a cancelled state (buttons removed), post a standalone cancellation notice, clear `discordMessageId` / `discordAttendanceHash` / `discordAttendanceSentAt`, and set the `discordCancelledAt` marker (so the same op can roll-call again later).
|
||||||
|
- Status back to `Ready`/`Scheduled` with `discordCancelledAt` set (rescheduled op) → immediately post a reschedule announcement: the new `<t:start>` time plus when the fresh roll-call goes up (the usual 3-day lead, or "live in this channel" when it is already due / already posted). Then clear the marker and let the normal flow post the fresh roll-call. Requires a future `startDateTime`.
|
||||||
- Past end time → leave final attendance as-is, stop polling.
|
- Past end time → leave final attendance as-is, stop polling.
|
||||||
2. **RSVP buttons**: action row on each embed: ✅ Yes / 🤔 Tentative / ❌ No, customId `ptf-att:<missionId>:<response>` (well under Discord's 100-char limit).
|
2. **RSVP buttons**: action row on each embed: ✅ Yes / 🤔 Tentative / ❌ No, customId `ptf-att:<missionId>:<response>` (well under Discord's 100-char limit).
|
||||||
- Click → resolve user via `discordId`; unlinked → ephemeral "Run /link or /signup first."
|
- Click → resolve user via `discordId`; unlinked → ephemeral "Run /link or /signup first."
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { botConfig } from "@/bot/config";
|
||||||
|
|
||||||
const EMBED_COLOR = 0x3b82f6;
|
const EMBED_COLOR = 0x3b82f6;
|
||||||
const CANCELLED_COLOR = 0x6b7280;
|
const CANCELLED_COLOR = 0x6b7280;
|
||||||
|
const RESCHEDULED_COLOR = 0x22c55e;
|
||||||
const MAX_ELIGIBLE_MISSIONS = 10;
|
const MAX_ELIGIBLE_MISSIONS = 10;
|
||||||
const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000;
|
const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000;
|
||||||
// Discord attachment cap (8 MiB) — larger modlists fall back to a link.
|
// Discord attachment cap (8 MiB) — larger modlists fall back to a link.
|
||||||
|
|
@ -142,6 +143,30 @@ const getCancelledMissions = async (payload: Payload): Promise<Mission[]> => {
|
||||||
return res.docs as unknown as Mission[];
|
return res.docs as unknown as Mission[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancelled missions that are live again (status back to Ready/Scheduled) and
|
||||||
|
* still carry the cancellation marker: the reschedule announcement has not
|
||||||
|
* happened yet.
|
||||||
|
*/
|
||||||
|
const getRescheduledMissions = 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.visibility": { equals: "unit" } },
|
||||||
|
{ "ownershipAndStatus.status": { in: ["Ready", "Scheduled"] } },
|
||||||
|
{ "classification.startDateTime": { greater_than: new Date().toISOString() } },
|
||||||
|
{ discordCancelledAt: { exists: true } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return res.docs as unknown as Mission[];
|
||||||
|
};
|
||||||
|
|
||||||
export const renderAttendanceEmbed = (
|
export const renderAttendanceEmbed = (
|
||||||
mission: Mission,
|
mission: Mission,
|
||||||
summary: MissionAttendanceSummary,
|
summary: MissionAttendanceSummary,
|
||||||
|
|
@ -175,6 +200,32 @@ export const renderCancelledEmbed = (mission: Mission): EmbedBuilder =>
|
||||||
.setDescription("This operation has been cancelled.")
|
.setDescription("This operation has been cancelled.")
|
||||||
.setColor(CANCELLED_COLOR);
|
.setColor(CANCELLED_COLOR);
|
||||||
|
|
||||||
|
const renderCancelledNoticeEmbed = (mission: Mission): EmbedBuilder =>
|
||||||
|
new EmbedBuilder()
|
||||||
|
.setTitle(`Cancelled: ${missionTitle(mission)}`)
|
||||||
|
.setDescription(
|
||||||
|
"This operation has been cancelled and its roll-call is closed. " +
|
||||||
|
"If it is rescheduled, a fresh roll-call will be posted here.",
|
||||||
|
)
|
||||||
|
.setColor(CANCELLED_COLOR)
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
const renderRescheduledEmbed = (mission: Mission, rollCallDueUnix: number | null): EmbedBuilder => {
|
||||||
|
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTitle(`Rescheduled: ${missionTitle(mission)}`)
|
||||||
|
.setDescription(
|
||||||
|
[
|
||||||
|
`This operation has been rescheduled for <t:${startUnix}:F> (<t:${startUnix}:R>).`,
|
||||||
|
rollCallDueUnix
|
||||||
|
? `A new roll-call will be posted <t:${rollCallDueUnix}:R>, the usual lead time before the op.`
|
||||||
|
: "The new roll-call is live in this channel; react when you know your availability.",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
.setColor(RESCHEDULED_COLOR)
|
||||||
|
.setTimestamp();
|
||||||
|
};
|
||||||
|
|
||||||
export const buildAttendanceRow = (missionId: number): ActionRowBuilder<ButtonBuilder> =>
|
export const buildAttendanceRow = (missionId: number): ActionRowBuilder<ButtonBuilder> =>
|
||||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||||
new ButtonBuilder()
|
new ButtonBuilder()
|
||||||
|
|
@ -455,6 +506,13 @@ export const reconcileMission = async (
|
||||||
await manageMission(client, payload, opsChannelId, mission);
|
await manageMission(client, payload, opsChannelId, mission);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cancelled op gets its roll-call edited into the cancelled state (buttons
|
||||||
|
* removed) plus a standalone cancellation notice. The message id, attendance
|
||||||
|
* hash, and one-shot send marker are cleared, and `discordCancelledAt` marks
|
||||||
|
* the cancellation so a later reschedule can be announced and get a fresh
|
||||||
|
* roll-call.
|
||||||
|
*/
|
||||||
const cancelMissionEmbed = async (
|
const cancelMissionEmbed = async (
|
||||||
client: Client,
|
client: Client,
|
||||||
payload: Payload,
|
payload: Payload,
|
||||||
|
|
@ -462,23 +520,24 @@ const cancelMissionEmbed = async (
|
||||||
mission: Mission,
|
mission: Mission,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const channel = await getOpsChannel(client, opsChannelId);
|
const channel = await getOpsChannel(client, opsChannelId);
|
||||||
let message;
|
|
||||||
try {
|
try {
|
||||||
message = await channel.messages.fetch(mission.discordMessageId as string);
|
const 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 message.edit({ embeds: [renderCancelledEmbed(mission)], components: [] });
|
||||||
|
} catch {
|
||||||
|
payload.logger.warn(
|
||||||
|
`[Bot] Attendance embed for cancelled mission #${mission.id} is missing; posting the cancellation notice only.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await channel.send({ embeds: [renderCancelledNoticeEmbed(mission)] });
|
||||||
await payload.update({
|
await payload.update({
|
||||||
collection: "missions",
|
collection: "missions",
|
||||||
id: mission.id,
|
id: mission.id,
|
||||||
data: { discordMessageId: null },
|
data: {
|
||||||
|
discordMessageId: null,
|
||||||
|
discordAttendanceHash: null,
|
||||||
|
discordAttendanceSentAt: null,
|
||||||
|
discordCancelledAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
overrideAccess: true,
|
overrideAccess: true,
|
||||||
});
|
});
|
||||||
payload.logger.info(
|
payload.logger.info(
|
||||||
|
|
@ -486,6 +545,38 @@ const cancelMissionEmbed = async (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A cancelled op came back (status Cancelled -> Ready/Scheduled): announce the
|
||||||
|
* new start time immediately, note when the fresh roll-call goes up (the usual
|
||||||
|
* lead time before the op, or right away when it is already due), then clear
|
||||||
|
* the marker so the reconcile loop posts the roll-call as usual.
|
||||||
|
*/
|
||||||
|
const announceReschedule = async (
|
||||||
|
client: Client,
|
||||||
|
payload: Payload,
|
||||||
|
opsChannelId: string,
|
||||||
|
mission: Mission,
|
||||||
|
): Promise<void> => {
|
||||||
|
const start = new Date(mission.classification.startDateTime).getTime();
|
||||||
|
const rollCallAlreadyLive = !!mission.discordAttendanceSentAt;
|
||||||
|
const rollCallDueNow =
|
||||||
|
mission.ownershipAndStatus.status === "Ready" || start - Date.now() <= ROLL_CALL_LEAD_TIME_MS;
|
||||||
|
const rollCallDueUnix =
|
||||||
|
rollCallAlreadyLive || rollCallDueNow
|
||||||
|
? null
|
||||||
|
: Math.floor((start - ROLL_CALL_LEAD_TIME_MS) / 1000);
|
||||||
|
|
||||||
|
const channel = await getOpsChannel(client, opsChannelId);
|
||||||
|
await channel.send({ embeds: [renderRescheduledEmbed(mission, rollCallDueUnix)] });
|
||||||
|
await payload.update({
|
||||||
|
collection: "missions",
|
||||||
|
id: mission.id,
|
||||||
|
data: { discordCancelledAt: null },
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
payload.logger.info(`[Bot] Announced reschedule for mission #${mission.id} (${mission.name}).`);
|
||||||
|
};
|
||||||
|
|
||||||
export const reconcile = async (client: Client, payload: Payload): Promise<void> => {
|
export const reconcile = async (client: Client, payload: Payload): Promise<void> => {
|
||||||
if (reconcileRunning) return;
|
if (reconcileRunning) return;
|
||||||
reconcileRunning = true;
|
reconcileRunning = true;
|
||||||
|
|
@ -501,6 +592,19 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rescheduled ops first, so the announcement lands in the channel before
|
||||||
|
// any same-tick roll-call post.
|
||||||
|
const rescheduled = await getRescheduledMissions(payload);
|
||||||
|
for (const mission of rescheduled) {
|
||||||
|
try {
|
||||||
|
await announceReschedule(client, payload, opsChannelId, mission);
|
||||||
|
} catch (error) {
|
||||||
|
payload.logger.error(
|
||||||
|
`[Bot] Reschedule reconcile failed for mission #${mission.id}: ${errorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const eligible = (await getEligibleMissions(payload)).filter(
|
const eligible = (await getEligibleMissions(payload)).filter(
|
||||||
(mission) =>
|
(mission) =>
|
||||||
isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,
|
isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import type { CollectionConfig, Payload, PayloadRequest, Where } from "payload";
|
import type { CollectionConfig, Payload, PayloadRequest, Where } from "payload";
|
||||||
import { requirePermission, hasPermission, isSuperuser } from "@/utils/access-control/hasPermission";
|
import {
|
||||||
|
requirePermission,
|
||||||
|
hasPermission,
|
||||||
|
isSuperuser,
|
||||||
|
} from "@/utils/access-control/hasPermission";
|
||||||
|
|
||||||
type AssignmentRef = { id: number } | number;
|
type AssignmentRef = { id: number } | number;
|
||||||
|
|
||||||
|
|
@ -215,6 +219,15 @@ export const Missions: CollectionConfig = {
|
||||||
hidden: true,
|
hidden: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "discordCancelledAt",
|
||||||
|
type: "date",
|
||||||
|
admin: {
|
||||||
|
description:
|
||||||
|
"When the roll-call was marked cancelled in Discord (managed by the bot; cleared when a cancelled op is rescheduled).",
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "evaluationRemindersSentAt",
|
name: "evaluationRemindersSentAt",
|
||||||
type: "date",
|
type: "date",
|
||||||
|
|
|
||||||
26792
src/migrations/20260912_231219_add_discord_cancelled_at.json
Normal file
26792
src/migrations/20260912_231219_add_discord_cancelled_at.json
Normal file
File diff suppressed because it is too large
Load diff
11
src/migrations/20260912_231219_add_discord_cancelled_at.ts
Normal file
11
src/migrations/20260912_231219_add_discord_cancelled_at.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||||
|
|
||||||
|
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions" ADD COLUMN "discord_cancelled_at" timestamp(3) with time zone;`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||||
|
await db.execute(sql`
|
||||||
|
ALTER TABLE "missions" DROP COLUMN "discord_cancelled_at";`)
|
||||||
|
}
|
||||||
|
|
@ -55,6 +55,7 @@ import * as migration_20260910_222518_add_dashboard_layout_preference from './20
|
||||||
import * as migration_20260911_044754_add_poker_minigame_stats from './20260911_044754_add_poker_minigame_stats';
|
import * as migration_20260911_044754_add_poker_minigame_stats from './20260911_044754_add_poker_minigame_stats';
|
||||||
import * as migration_20260911_052147_add_ribbon_submissions from './20260911_052147_add_ribbon_submissions';
|
import * as migration_20260911_052147_add_ribbon_submissions from './20260911_052147_add_ribbon_submissions';
|
||||||
import * as migration_20260911_201559_add_award_advancement_series from './20260911_201559_add_award_advancement_series';
|
import * as migration_20260911_201559_add_award_advancement_series from './20260911_201559_add_award_advancement_series';
|
||||||
|
import * as migration_20260912_231219_add_discord_cancelled_at from './20260912_231219_add_discord_cancelled_at';
|
||||||
|
|
||||||
export const migrations = [
|
export const migrations = [
|
||||||
{
|
{
|
||||||
|
|
@ -340,6 +341,11 @@ export const migrations = [
|
||||||
{
|
{
|
||||||
up: migration_20260911_201559_add_award_advancement_series.up,
|
up: migration_20260911_201559_add_award_advancement_series.up,
|
||||||
down: migration_20260911_201559_add_award_advancement_series.down,
|
down: migration_20260911_201559_add_award_advancement_series.down,
|
||||||
name: '20260911_201559_add_award_advancement_series'
|
name: '20260911_201559_add_award_advancement_series',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
up: migration_20260912_231219_add_discord_cancelled_at.up,
|
||||||
|
down: migration_20260912_231219_add_discord_cancelled_at.down,
|
||||||
|
name: '20260912_231219_add_discord_cancelled_at'
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -2461,6 +2461,10 @@ export interface Mission {
|
||||||
* Fingerprint of the attached modlist last announced to Discord (managed by the bot).
|
* Fingerprint of the attached modlist last announced to Discord (managed by the bot).
|
||||||
*/
|
*/
|
||||||
discordModlistHash?: string | null;
|
discordModlistHash?: string | null;
|
||||||
|
/**
|
||||||
|
* When the roll-call was marked cancelled in Discord (managed by the bot; cleared when a cancelled op is rescheduled).
|
||||||
|
*/
|
||||||
|
discordCancelledAt?: string | null;
|
||||||
/**
|
/**
|
||||||
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
|
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
|
||||||
*/
|
*/
|
||||||
|
|
@ -6006,6 +6010,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
||||||
discordAttendanceHash?: T;
|
discordAttendanceHash?: T;
|
||||||
discordAttendanceSentAt?: T;
|
discordAttendanceSentAt?: T;
|
||||||
discordModlistHash?: T;
|
discordModlistHash?: T;
|
||||||
|
discordCancelledAt?: T;
|
||||||
evaluationRemindersSentAt?: T;
|
evaluationRemindersSentAt?: T;
|
||||||
readyReminderWebSentAt?: T;
|
readyReminderWebSentAt?: T;
|
||||||
readyReminderDmSentAt?: T;
|
readyReminderDmSentAt?: T;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue