// 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; // 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 { 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 { 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 { await closePoll(pollId, 'manual'); } async function closePoll(pollId: string, reason: 'deadline' | 'quorum' | 'manual'): Promise { 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 { 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(); const resolveName = async (uid: string): Promise => { 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)); } }