Compare commits
2 commits
24662060df
...
b61db9dd3a
| Author | SHA1 | Date | |
|---|---|---|---|
| b61db9dd3a | |||
| f1da86ae81 |
8 changed files with 26947 additions and 15 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.
|
||||
- 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.
|
||||
- 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.
|
||||
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."
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.36",
|
||||
"version": "0.2.37",
|
||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { botConfig } from "@/bot/config";
|
|||
|
||||
const EMBED_COLOR = 0x3b82f6;
|
||||
const CANCELLED_COLOR = 0x6b7280;
|
||||
const RESCHEDULED_COLOR = 0x22c55e;
|
||||
const MAX_ELIGIBLE_MISSIONS = 10;
|
||||
const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
// 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[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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 = (
|
||||
mission: Mission,
|
||||
summary: MissionAttendanceSummary,
|
||||
|
|
@ -175,6 +200,32 @@ export const renderCancelledEmbed = (mission: Mission): EmbedBuilder =>
|
|||
.setDescription("This operation has been cancelled.")
|
||||
.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> =>
|
||||
new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
|
|
@ -455,6 +506,13 @@ export const reconcileMission = async (
|
|||
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 (
|
||||
client: Client,
|
||||
payload: Payload,
|
||||
|
|
@ -462,23 +520,24 @@ const cancelMissionEmbed = async (
|
|||
mission: Mission,
|
||||
): Promise<void> => {
|
||||
const channel = await getOpsChannel(client, opsChannelId);
|
||||
let message;
|
||||
try {
|
||||
message = await channel.messages.fetch(mission.discordMessageId as string);
|
||||
const message = await channel.messages.fetch(mission.discordMessageId as string);
|
||||
await message.edit({ embeds: [renderCancelledEmbed(mission)], components: [] });
|
||||
} catch {
|
||||
await payload.update({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
data: { discordMessageId: null },
|
||||
overrideAccess: true,
|
||||
});
|
||||
return;
|
||||
payload.logger.warn(
|
||||
`[Bot] Attendance embed for cancelled mission #${mission.id} is missing; posting the cancellation notice only.`,
|
||||
);
|
||||
}
|
||||
await message.edit({ embeds: [renderCancelledEmbed(mission)], components: [] });
|
||||
await channel.send({ embeds: [renderCancelledNoticeEmbed(mission)] });
|
||||
await payload.update({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
data: { discordMessageId: null },
|
||||
data: {
|
||||
discordMessageId: null,
|
||||
discordAttendanceHash: null,
|
||||
discordAttendanceSentAt: null,
|
||||
discordCancelledAt: new Date().toISOString(),
|
||||
},
|
||||
overrideAccess: true,
|
||||
});
|
||||
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> => {
|
||||
if (reconcileRunning) return;
|
||||
reconcileRunning = true;
|
||||
|
|
@ -501,6 +592,19 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
|||
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(
|
||||
(mission) =>
|
||||
isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
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;
|
||||
|
||||
|
|
@ -215,6 +219,15 @@ export const Missions: CollectionConfig = {
|
|||
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",
|
||||
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_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_20260912_231219_add_discord_cancelled_at from './20260912_231219_add_discord_cancelled_at';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -340,6 +341,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260911_201559_add_award_advancement_series.up,
|
||||
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).
|
||||
*/
|
||||
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).
|
||||
*/
|
||||
|
|
@ -6006,6 +6010,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
|||
discordAttendanceHash?: T;
|
||||
discordAttendanceSentAt?: T;
|
||||
discordModlistHash?: T;
|
||||
discordCancelledAt?: T;
|
||||
evaluationRemindersSentAt?: T;
|
||||
readyReminderWebSentAt?: T;
|
||||
readyReminderDmSentAt?: T;
|
||||
|
|
|
|||
Loading…
Reference in a new issue