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.
130 lines
4.5 KiB
TypeScript
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);
|
|
};
|