1
0
Fork 0

feat(bot): link roll-call embeds to the mission page with a summary

The embed title links to the mission page and an 'Open the op' field
carries the URL; the mission summary is shown under the title,
truncated to 300 characters with an ellipsis. The attendance hash gains
an embed schema version so live roll-calls re-render exactly once.
This commit is contained in:
Jason Fraley 2026-09-18 14:52:39 -04:00
parent efae94c8a9
commit 4fad7cd259

View file

@ -64,8 +64,22 @@ const operationTypeLabel = (type: Mission["operationType"]): string => {
/** /**
* Deterministic fingerprint of the attendance state and scheduled start time * Deterministic fingerprint of the attendance state and scheduled start time
* so reconcile can skip embeds that haven't changed (anti-loop), while still * so reconcile can skip embeds that haven't changed (anti-loop), while still
* detecting mission date changes. * detecting mission date changes. The schema version forces one re-render of
* already-live roll-calls when the embed layout changes (v2 added the
* mission-page link); bump it again for future layout-only changes.
*/ */
const EMBED_SCHEMA_VERSION = 3;
const SUMMARY_MAX_CHARS = 300;
const renderSummaryForEmbed = (summary: string | null | undefined): string => {
if (!summary?.trim()) return "";
const text = summary.trim();
return text.length > SUMMARY_MAX_CHARS
? `${text.slice(0, SUMMARY_MAX_CHARS).trimEnd()}...`
: text;
};
export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string): string => { export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string): string => {
const rows = docs const rows = docs
.map((doc) => { .map((doc) => {
@ -73,7 +87,7 @@ export const attendanceHash = (docs: MissionAttendance[], startDateTime?: string
return [userId, doc.response] as const; return [userId, doc.response] as const;
}) })
.sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1])); .sort((a, b) => a[0] - b[0] || a[1].localeCompare(b[1]));
return JSON.stringify({ rows, startDateTime: startDateTime ?? null }); return JSON.stringify({ rows, startDateTime: startDateTime ?? null, v: EMBED_SCHEMA_VERSION });
}; };
export const fetchAttendanceDocs = async ( export const fetchAttendanceDocs = async (
@ -172,11 +186,15 @@ export const renderAttendanceEmbed = (
summary: MissionAttendanceSummary, summary: MissionAttendanceSummary,
): EmbedBuilder => { ): EmbedBuilder => {
const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000); const startUnix = Math.floor(new Date(mission.classification.startDateTime).getTime() / 1000);
const missionUrl = `${botConfig.appUrl}/intelligence/missions/${mission.id}`;
const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "-"; const names = (users: User[]): string => users.map(memberName).join("\n").slice(0, 1024) || "-";
const summaryText = renderSummaryForEmbed(mission.summary);
return new EmbedBuilder() return new EmbedBuilder()
.setTitle(missionTitle(mission)) .setTitle(missionTitle(mission))
.setURL(missionUrl)
.setDescription( .setDescription(
[ [
...(summaryText ? [summaryText, ""] : []),
`**Operation Type:** ${operationTypeLabel(mission.operationType)}`, `**Operation Type:** ${operationTypeLabel(mission.operationType)}`,
`**Starts:** <t:${startUnix}:F> (<t:${startUnix}:R>)`, `**Starts:** <t:${startUnix}:F> (<t:${startUnix}:R>)`,
`**Max Players:** ${mission.missionRoles.maxPlayers}`, `**Max Players:** ${mission.missionRoles.maxPlayers}`,
@ -191,6 +209,7 @@ export const renderAttendanceEmbed = (
inline: true, inline: true,
}, },
{ name: `❌ No (${summary.counts.no})`, value: names(summary.no), inline: true }, { name: `❌ No (${summary.counts.no})`, value: names(summary.no), inline: true },
{ name: "Open the op", value: missionUrl },
); );
}; };