src/server/polls.ts
Rama main · réplica de lectura del repositorio original.
// Proactive Slack poll / directed-question state. The mcp-slack-use `post_slack_poll`
// tool posts the interactive message; shraga owns everything after: vote state, live
// tally updates (chat.update), closing on deadline/quorum/first-answer, and waking the
// originating agent session once with the result ("close then report").
//
// Decoupled from claude.ts via injected runners (see initPolls) to avoid an import cycle.
import { mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import path from 'node:path';
import { dataPath } from './paths.ts';
import { getSession } from './sessions.ts';
import { slackPost, getUserName, buildPollBlocks, type PollSpec } from './slack/api.ts';
import { initWake, wakeSession, deliverToSession, type TurnRunner } from './wake.ts';
const PREFIX = '[polls]';
export interface PollRecord extends PollSpec {
channel: string;
ts: string;
useUserToken?: boolean;
cardBlocks?: unknown[]; // a post_slack_card card: keep its content, swap only its buttons on close
votes: Record<string, number[]>; // userId -> chosen option indices
status: 'open' | 'closed';
deadlineAt?: number;
quorum?: number;
sessionId: string; // originating session to report back to
uid: string;
userEmail?: string;
createdAt: number;
}
// ── IoC: injected by boot.ts at startup to avoid a claude.ts <-> polls.ts cycle. The runners live
// in wake.ts now — every subsystem that reports back after its turn ended shares them. ──
export function initPolls(deps: { runTurn: TurnRunner; broadcast: (ev: object) => void }): void {
initWake(deps);
setInterval(sweep, 60_000);
console.log(`${PREFIX} sweeper started`);
}
const PRUNE_AFTER_MS = 7 * 24 * 60 * 60_000; // delete closed poll files after 7 days
/** Close polls past their deadline; prune long-closed poll files. */
function sweep(): void {
const now = Date.now();
let files: string[];
try { files = readdirSync(dir()).filter((f) => f.endsWith('.json')); } catch { return; }
for (const f of files) {
let p: PollRecord | null = null;
try { p = JSON.parse(readFileSync(path.join(dir(), f), 'utf-8')) as PollRecord; } catch { continue; }
if (!p) continue;
if (p.status === 'open' && p.deadlineAt && now >= p.deadlineAt) {
closePoll(p.pollId, 'deadline').catch((e) => console.error(`${PREFIX} sweep close failed:`, (e as Error)?.message));
} else if (p.status === 'closed' && now - p.createdAt > PRUNE_AFTER_MS) {
try { rmSync(path.join(dir(), f)); } catch { /* ignore */ }
}
}
}
// ── Storage ───────────────────────────────────────────────────────────────────
const dir = (): string => { const d = dataPath('polls'); mkdirSync(d, { recursive: true }); return d; };
const file = (id: string): string => path.join(dir(), `${id}.json`);
export function loadPoll(id: string): PollRecord | null {
try { return JSON.parse(readFileSync(file(id), 'utf-8')) as PollRecord; } catch { return null; }
}
function save(p: PollRecord): void { writeFileSync(file(p.pollId), JSON.stringify(p, null, 2)); }
const toSpec = (p: PollRecord): PollSpec => ({ pollId: p.pollId, title: p.title, options: p.options, kind: p.kind, multi: p.multi, targetUser: p.targetUser });
function tallies(p: PollRecord): number[] {
const t = p.options.map(() => 0);
for (const idxs of Object.values(p.votes)) for (const i of idxs) if (t[i] !== undefined) t[i]++;
return t;
}
const voterCount = (p: PollRecord): number => Object.keys(p.votes).length;
/** A card keeps its content; on close its action rows become one line naming the choice. */
export function closedCardBlocks(p: PollRecord): unknown[] {
const [userId, idxs] = Object.entries(p.votes)[0] ?? [];
const choice = userId && idxs ? `✅ <@${userId}> chose *${p.options[idxs[0]]?.label ?? '?'}*` : '🔒 Closed without an answer';
const kept = (p.cardBlocks ?? []).filter((b: any) => b?.type !== 'actions');
return [...kept, { type: 'context', elements: [{ type: 'mrkdwn', text: choice }] }];
}
async function rerender(p: PollRecord, closed = false): Promise<void> {
if (p.cardBlocks && !closed) return; // a card has no live tally to refresh
const blocks = p.cardBlocks ? closedCardBlocks(p) : buildPollBlocks(toSpec(p), tallies(p), voterCount(p), closed);
await slackPost('chat.update', { channel: p.channel, ts: p.ts, text: p.title, blocks }, p.useUserToken)
.catch((e) => console.error(`${PREFIX} rerender failed:`, (e as Error)?.message));
}
// ── Registration (called from claude.ts on a post_slack_poll tool result) ───────
export interface RegisterPollInput {
pollId: string; channel: string; ts: string; title: string;
options: { label: string; description?: string }[];
kind: 'poll' | 'question'; multi?: boolean; targetUser?: string;
deadlineMinutes?: number; quorum?: number; useUserToken?: boolean; cardBlocks?: unknown[];
sessionId: string; uid: string; userEmail?: string;
}
export function registerPoll(i: RegisterPollInput): void {
const p: PollRecord = {
pollId: i.pollId, title: i.title, options: i.options, kind: i.kind, multi: i.multi, targetUser: i.targetUser,
channel: i.channel, ts: i.ts, useUserToken: i.useUserToken, cardBlocks: i.cardBlocks,
votes: {}, status: 'open', createdAt: Date.now(),
deadlineAt: i.deadlineMinutes ? Date.now() + i.deadlineMinutes * 60_000 : undefined,
quorum: i.quorum, sessionId: i.sessionId, uid: i.uid, userEmail: i.userEmail,
};
save(p);
console.log(`${PREFIX} registered ${p.pollId} kind=${p.kind} ch=${p.channel} deadline=${i.deadlineMinutes ?? '-'}m quorum=${i.quorum ?? '-'}`);
}
// ── Interactivity (called from slack/questions.ts) ──────────────────────────────
export async function handlePollVote(pollId: string, userId: string, optIdx: number): Promise<void> {
const p = loadPoll(pollId);
if (!p || p.status === 'closed') return;
// Directed question: only the addressed user may answer.
if (p.kind === 'question' && p.targetUser && userId !== p.targetUser) return;
const cur = new Set(p.votes[userId] ?? []);
if (p.kind === 'question' || !p.multi) {
if (cur.has(optIdx)) cur.delete(optIdx); else { cur.clear(); cur.add(optIdx); }
} else {
cur.has(optIdx) ? cur.delete(optIdx) : cur.add(optIdx);
}
if (cur.size) p.votes[userId] = [...cur]; else delete p.votes[userId];
save(p);
const complete = p.kind === 'question'
? (p.targetUser ? (p.votes[p.targetUser]?.length ?? 0) > 0 : voterCount(p) > 0)
: (p.quorum ? voterCount(p) >= p.quorum : false);
if (complete) await closePoll(pollId, 'quorum'); else await rerender(p, false);
}
export async function handlePollClose(pollId: string): Promise<void> {
await closePoll(pollId, 'manual');
}
async function closePoll(pollId: string, reason: 'deadline' | 'quorum' | 'manual'): Promise<void> {
const p = loadPoll(pollId);
if (!p || p.status === 'closed') return;
p.status = 'closed';
save(p);
await rerender(p, true);
console.log(`${PREFIX} closed ${pollId} reason=${reason} voters=${voterCount(p)}`);
await wakeAgent(p, reason).catch((e) => console.error(`${PREFIX} wake failed:`, (e as Error)?.message));
}
// ── Close then report: wake the originating session once with the tally ─────────
async function wakeAgent(p: PollRecord, reason: string): Promise<void> {
if (!getSession(p.sessionId)) { console.warn(`${PREFIX} session ${p.sessionId} gone; skipping wake`); return; }
// Per-option voters, with Slack user ids resolved to display names (cached).
const votersByOption: string[][] = p.options.map(() => []);
for (const [userId, idxs] of Object.entries(p.votes)) for (const i of idxs) if (votersByOption[i]) votersByOption[i].push(userId);
const nameCache = new Map<string, string>();
const resolveName = async (uid: string): Promise<string> => {
if (nameCache.has(uid)) return nameCache.get(uid)!;
const n = (await getUserName(uid).catch(() => null)) || uid;
nameCache.set(uid, n);
return n;
};
const lines = (await Promise.all(p.options.map(async (o, i) => {
const names = await Promise.all(votersByOption[i].map(resolveName));
const c = votersByOption[i].length;
return `- ${o.label}: ${c} vote${c === 1 ? '' : 's'}${names.length ? ` (${names.join(', ')})` : ''}`;
}))).join('\n');
const headline = p.kind === 'question' ? 'Your question was answered' : `Your poll closed (${reason})`;
const prompt = `[Poll result] ${headline}. Title: "${p.title}". ${voterCount(p)} participant(s).\n${lines}\n\nFollow up appropriately (summarize, take the next action, or notify the relevant people). Do not re-post the poll.`;
// The return value is NOT decoration: a wake that could not run a turn ('no-output' — no runner
// wired, or the session stayed busy past the wake lock's wait) leaves this poll CLOSED, already
// re-rendered as closed in Slack, and the transcript holding a `[Poll result]` prompt with no
// answer. The tally would then be lost for good (the record is pruned after 7 days). So when no
// turn ran, deliver the lines we already built — the same raw-report fallback the background-job
// caller makes. `unreadFallback` above cannot cover this: wake.ts returns before it consults it.
const outcome = await wakeSession({ sessionId: p.sessionId, uid: p.uid, userEmail: p.userEmail, prompt, channel: 'poll', title: p.title, unreadFallback: 'Poll closed' });
if (outcome !== 'woke') {
console.warn(`${PREFIX} wake for ${p.pollId} returned '${outcome}' — delivering the tally as plain text instead`);
await deliverToSession({
sessionId: p.sessionId, uid: p.uid, title: p.title,
text: `${headline} — "${p.title}" (${voterCount(p)} participant(s)).\n${lines}`,
}).catch((e) => console.error(`${PREFIX} raw tally deliver failed:`, (e as Error)?.message));
}
}