1
0
Fork 0
polaris-task-force/src/bot/services/evaluationReminders.ts
Z8MB1E e3c4eada09 feat(bot): add post-mission evaluation reminder DMs
Sends one-shot DMs once a mission is evaluable asking members to rate their leadership (and leaders to rate subordinates), gated by a new evaluationRemindersSentAt marker on Missions with its migration. Adds the /remind-evaluations staff command (manual trigger/sweep) and DISCORD_EVALUATION_POLL_MS config; updates bot and root docs.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-06 01:24:17 -04:00

215 lines
6.9 KiB
TypeScript

import { type Client, EmbedBuilder } from "discord.js";
import type { Payload } from "payload";
import type { Mission } from "@/payload-types";
import { botConfig } from "@/bot/config";
import { computeEvaluationReminderPlan, type ReminderLink } from "@/lib/evaluations/reminders";
import { isMissionEvaluable } from "@/lib/evaluations";
/**
* Evaluation reminder service: after a mission becomes evaluable (completed, or
* its scheduled start has passed), every participant with a linked Discord
* account gets a one-shot DM asking them to rate their leadership — and every
* leader additionally gets a section asking them to rate the subordinates who
* participated. Each DM links to the relevant profile pages, where the rating
* dialog lives.
*
* 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
* unit-admin flow, not a forwarding of the generic notification stream.
*/
const EMBED_COLOR = 0xf59e0b;
const MISSIONS_PER_TICK = 3;
const MAX_DMS_PER_TICK = 40;
const SEND_DELAY_MS = 300;
let reconcileRunning = false;
const errorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error";
const missionTitle = (mission: Mission): string =>
mission.codeName ? `${mission.name} (${mission.codeName})` : mission.name;
const getReminderMissions = async (payload: Payload): Promise<Mission[]> => {
const now = new Date().toISOString();
const res = await payload.find({
collection: "missions",
overrideAccess: true,
depth: 0,
sort: "-classification.startDateTime",
limit: MISSIONS_PER_TICK,
where: {
and: [
{ evaluationRemindersSentAt: { exists: false } },
{
or: [
{ "ownershipAndStatus.status": { equals: "Completed" } },
{
and: [
{ "ownershipAndStatus.status": { in: ["Scheduled", "Active"] } },
{ "classification.startDateTime": { less_than: now } },
],
},
],
},
],
},
});
return res.docs as unknown as Mission[];
};
const formatLinks = (links: ReminderLink[], appUrl: string): string => {
const lines = links.map(
(link) => `• [${link.displayName}](${appUrl}${link.profilePath})`,
);
// Discord embed field values cap at 1024 chars.
return (lines.join("\n").slice(0, 1024) || "—");
};
const renderReminderEmbed = (
mission: Mission,
rateLeaders: ReminderLink[],
rateSubordinates: ReminderLink[],
appUrl: string,
): EmbedBuilder => {
const embed = new EmbedBuilder()
.setTitle(`Leadership feedback requested — ${missionTitle(mission)}`)
.setColor(EMBED_COLOR)
.setFooter({
text: "Ratings are private and anonymous in aggregate. A leader's results become visible once 3+ members have rated them.",
});
if (rateLeaders.length > 0) {
embed.addFields({
name: "⭐ Rate your leadership",
value: formatLinks(rateLeaders, appUrl),
});
}
if (rateSubordinates.length > 0) {
embed.addFields({
name: "🪖 Rate your subordinates' performance",
value: formatLinks(rateSubordinates, appUrl),
});
}
return embed;
};
const sendReminderDms = async (
client: Client,
payload: Payload,
mission: Mission,
): Promise<{ sent: number; capped: boolean }> => {
const plan = await computeEvaluationReminderPlan(payload, mission.id);
const targets = plan.recipients.filter((r) => r.discordId);
const skipped = plan.recipients.length - targets.length;
if (skipped > 0) {
payload.logger.warn(
`[Bot] Evaluation reminders for mission #${mission.id}: skipping ${skipped} user(s) without a linked Discord account.`,
);
}
let sent = 0;
for (const target of targets) {
if (sent >= MAX_DMS_PER_TICK) {
payload.logger.warn(
`[Bot] Evaluation reminder DM cap (${MAX_DMS_PER_TICK}) reached for mission #${mission.id}; remaining users will be sent next tick.`,
);
return { sent, capped: true };
}
try {
const user = await client.users.fetch(target.discordId as string);
await user.send({
embeds: [
renderReminderEmbed(mission, target.rateLeaders, target.rateSubordinates, botConfig.appUrl),
],
});
sent += 1;
} catch (error) {
payload.logger.warn(
`[Bot] Evaluation reminder DM failed for user #${target.userId} (mission #${mission.id}): ${errorMessage(error)}`,
);
}
await new Promise((resolve) => setTimeout(resolve, SEND_DELAY_MS));
}
return { sent, capped: false };
};
/**
* Sends reminder DMs for one mission, then stamps the one-shot marker —
* unless the per-tick DM cap was hit, in which case the marker is left unset
* so the remaining users are sent on a later pass. Exported for the
* /remind-evaluations command's manual trigger.
*
* @returns the number of DMs actually sent.
*/
export const processMission = async (
client: Client,
payload: Payload,
mission: Mission,
): Promise<number> => {
const { sent, capped } = await sendReminderDms(client, payload, mission);
if (capped) {
payload.logger.warn(
`[Bot] Evaluation reminders for mission #${mission.id}: DM cap hit — marker not set, will retry next tick.`,
);
return sent;
}
await payload.update({
collection: "missions",
id: mission.id,
data: { evaluationRemindersSentAt: new Date().toISOString() },
overrideAccess: true,
});
payload.logger.info(
`[Bot] Sent ${sent} evaluation reminder DM(s) for mission #${mission.id} (${mission.name}).`,
);
return sent;
};
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 missions = (await getReminderMissions(payload)).filter((mission) =>
isMissionEvaluable(
mission.ownershipAndStatus?.status ?? "",
mission.classification?.startDateTime ?? null,
),
);
for (const mission of missions) {
try {
sent += await processMission(client, payload, mission);
processed += 1;
} catch (error) {
payload.logger.error(
`[Bot] Evaluation reminder reconcile failed for mission #${mission.id}: ${errorMessage(error)}`,
);
}
}
} catch (error) {
payload.logger.error(`[Bot] Evaluation reminder reconcile failed: ${errorMessage(error)}`);
} finally {
reconcileRunning = false;
}
return { processed, sent };
};
export const start = (client: Client, payload: Payload): void => {
payload.logger.info("[Bot] Evaluation reminder service started");
void reconcile(client, payload);
setInterval(() => {
void reconcile(client, payload);
}, botConfig.evaluationPollMs);
};