Ctrl K
ComunidadGuías…

src/server/polls.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 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));
  }
}