1
0
Fork 0
polaris-task-force/src/bot/services/readyReminders.ts
Z8MB1E d757c58c5d feat(missions): daily ready-check reminders for unfinished side ops
Five days before a player-created side op starts, if it is not yet Ready, the creator gets one in-app notification per day (via mission-tick) plus one Discord DM per day (new bot service). Per-server-local-day dedupe via hidden readyReminderWebSentAt/readyReminderDmSentAt markers. The ready-reminder type is excluded from the notification bridge to avoid double-DMing. Markers are nulled on clone.
2026-09-07 05:07:46 -04:00

130 lines
4.5 KiB
TypeScript

import { type Client, EmbedBuilder } from "discord.js";
import type { Payload } from "payload";
import type { Mission, User } from "@/payload-types";
import { botConfig } from "@/bot/config";
import {
READY_REMINDER_LEAD_DAYS,
daysUntil,
getReadyReminderMissions,
isReminderDue,
missionTitle,
} from "@/lib/intelligence/readyReminders";
/**
* Ready-reminder service: starting READY_REMINDER_LEAD_DAYS days before a
* player-scheduled side op, the op's creator gets a once-per-day DM nudging
* them to finish the op and mark it "Ready" — the status that posts the
* roll-call, locks in details, and delivers the mission hooks.
*
* The DM deliberately ignores `preferences.discord.enabled` (the notification
* bridge opt-in): that toggle defaults to false and is not yet exposed in the
* web UI, so honoring it here would silently DM nobody. This is a dedicated
* creator-prompt flow, not a forwarding of the generic notification stream
* (the web half of each reminder is excluded from the bridge via
* NON_BRIDGED_TYPES). A per-day marker on the mission (readyReminderDmSentAt)
* keeps the cadence to one DM per day.
*/
const EMBED_COLOR = 0xf59e0b;
const MISSIONS_PER_TICK = 10;
const SEND_DELAY_MS = 300;
let reconcileRunning = false;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const creatorIdOf = (mission: Mission): number | null =>
typeof mission.createdBy === "object" ? (mission.createdBy?.id ?? null) : (mission.createdBy ?? null);
const renderReminderEmbed = (mission: Mission, now: Date): EmbedBuilder => {
const days = daysUntil(mission.classification.startDateTime, now);
const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`;
return new EmbedBuilder()
.setTitle(`⏰ Ready check: ${missionTitle(mission)}`)
.setDescription(
`Your op starts **${when}** and it isn't marked **Ready** yet.\n\n` +
"Finish up the operation and set the mission status to **Ready** so the roll-call can be posted, the details can be locked in, and all the proper mission hooks can be delivered.",
)
.setColor(EMBED_COLOR)
.addFields({
name: "Open the op",
value: `${botConfig.appUrl}/intelligence/missions/${mission.id}`,
})
.setFooter({ text: "You'll get this reminder once a day until the op is Ready." });
};
const processMission = async (
client: Client,
payload: Payload,
mission: Mission,
now: Date,
): Promise<boolean> => {
const creatorId = creatorIdOf(mission);
if (!creatorId) return false;
const creator = (await payload.findByID({
collection: "users",
id: creatorId,
depth: 0,
overrideAccess: true,
})) as unknown as User;
if (!creator.discordId) return false;
const target = await client.users.fetch(creator.discordId);
await target.send({ embeds: [renderReminderEmbed(mission, now)] });
await payload.update({
collection: "missions",
id: mission.id,
data: { readyReminderDmSentAt: now.toISOString() },
overrideAccess: true,
});
payload.logger.info(
`[Bot] Sent ready reminder DM for mission #${mission.id} (${mission.name}) to user #${creatorId}.`,
);
return true;
};
export interface ReconcileSummary {
processed: number;
sent: number;
}
export const reconcile = async (client: Client, payload: Payload): Promise<ReconcileSummary> => {
if (reconcileRunning) return { processed: 0, sent: 0 };
reconcileRunning = true;
let processed = 0;
let sent = 0;
try {
const now = new Date();
const missions = (await getReadyReminderMissions(payload, now)).slice(0, MISSIONS_PER_TICK);
for (const mission of missions) {
if (!isReminderDue(mission.readyReminderDmSentAt, now)) continue;
processed += 1;
try {
if (await processMission(client, payload, mission, now)) {
sent += 1;
await new Promise((resolve) => setTimeout(resolve, SEND_DELAY_MS));
}
} catch (error) {
payload.logger.error(
`[Bot] Ready reminder failed for mission #${mission.id}: ${errorMessage(error)}`,
);
}
}
} catch (error) {
payload.logger.error(`[Bot] Ready reminder reconcile failed: ${errorMessage(error)}`);
} finally {
reconcileRunning = false;
}
return { processed, sent };
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Ready reminder service started");
void reconcile(client, payload);
setInterval(() => {
void reconcile(client, payload);
}, botConfig.readyReminderPollMs);
};