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>
215 lines
6.9 KiB
TypeScript
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);
|
|
};
|