import crypto from "node:crypto"; import { resolveMSTeamsStorePath } from "./storage.js"; import { readJsonFile, withFileLock, writeJsonFile } from "./store-fs.js"; export type MSTeamsPollVote = { pollId: string; selections: string[]; }; export type MSTeamsPoll = { id: string; question: string; options: string[]; maxSelections: number; createdAt: string; updatedAt?: string; conversationId?: string; messageId?: string; votes: Record; }; export type MSTeamsPollStore = { createPoll: (poll: MSTeamsPoll) => Promise; getPoll: (pollId: string) => Promise; recordVote: (params: { pollId: string; voterId: string; selections: string[]; }) => Promise; }; export type MSTeamsPollCard = { pollId: string; question: string; options: string[]; maxSelections: number; card: Record; fallbackText: string; }; type PollStoreData = { version: 1; polls: Record; }; const STORE_FILENAME = "msteams-polls.json"; const MAX_POLLS = 1000; const POLL_TTL_MS = 30 * 24 * 60 * 60 * 1000; function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function normalizeChoiceValue(value: unknown): string | null { if (typeof value === "string") { const trimmed = value.trim(); return trimmed ? trimmed : null; } if (typeof value === "number" && Number.isFinite(value)) { return String(value); } return null; } function extractSelections(value: unknown): string[] { if (Array.isArray(value)) { return value.map(normalizeChoiceValue).filter((entry): entry is string => Boolean(entry)); } const normalized = normalizeChoiceValue(value); if (!normalized) return []; if (normalized.includes(",")) { return normalized .split(",") .map((entry) => entry.trim()) .filter(Boolean); } return [normalized]; } function readNestedValue(value: unknown, keys: Array): unknown { let current: unknown = value; for (const key of keys) { if (!isRecord(current)) return undefined; current = current[key as keyof typeof current]; } return current; } function readNestedString(value: unknown, keys: Array): string | undefined { const found = readNestedValue(value, keys); return typeof found === "string" && found.trim() ? found.trim() : undefined; } export function extractMSTeamsPollVote( activity: { value?: unknown } | undefined, ): MSTeamsPollVote | null { const value = activity?.value; if (!value || !isRecord(value)) return null; const pollId = readNestedString(value, ["clawdbotPollId"]) ?? readNestedString(value, ["pollId"]) ?? readNestedString(value, ["clawdbot", "pollId"]) ?? readNestedString(value, ["clawdbot", "poll", "id"]) ?? readNestedString(value, ["data", "clawdbotPollId"]) ?? readNestedString(value, ["data", "pollId"]) ?? readNestedString(value, ["data", "clawdbot", "pollId"]); if (!pollId) return null; const directSelections = extractSelections(value.choices); const nestedSelections = extractSelections(readNestedValue(value, ["choices"])); const dataSelections = extractSelections(readNestedValue(value, ["data", "choices"])); const selections = directSelections.length > 0 ? directSelections : nestedSelections.length > 0 ? nestedSelections : dataSelections; if (selections.length === 0) return null; return { pollId, selections, }; } export function buildMSTeamsPollCard(params: { question: string; options: string[]; maxSelections?: number; pollId?: string; }): MSTeamsPollCard { const pollId = params.pollId ?? crypto.randomUUID(); const maxSelections = typeof params.maxSelections === "number" && params.maxSelections > 1 ? Math.floor(params.maxSelections) : 1; const cappedMaxSelections = Math.min(Math.max(1, maxSelections), params.options.length); const choices = params.options.map((option, index) => ({ title: option, value: String(index), })); const hint = cappedMaxSelections > 1 ? `Select up to ${cappedMaxSelections} option${cappedMaxSelections === 1 ? "" : "s"}.` : "Select one option."; const card = { type: "AdaptiveCard", version: "1.5", body: [ { type: "TextBlock", text: params.question, wrap: true, weight: "Bolder", size: "Medium", }, { type: "Input.ChoiceSet", id: "choices", isMultiSelect: cappedMaxSelections > 1, style: "expanded", choices, }, { type: "TextBlock", text: hint, wrap: true, isSubtle: true, spacing: "Small", }, ], actions: [ { type: "Action.Submit", title: "Vote", data: { clawdbotPollId: pollId, }, msteams: { type: "messageBack", text: "clawdbot poll vote", displayText: "Vote recorded", value: { clawdbotPollId: pollId }, }, }, ], }; const fallbackLines = [ `Poll: ${params.question}`, ...params.options.map((option, index) => `${index + 1}. ${option}`), ]; return { pollId, question: params.question, options: params.options, maxSelections: cappedMaxSelections, card, fallbackText: fallbackLines.join("\n"), }; } export type MSTeamsPollStoreFsOptions = { env?: NodeJS.ProcessEnv; homedir?: () => string; stateDir?: string; storePath?: string; }; function parseTimestamp(value?: string): number | null { if (!value) return null; const parsed = Date.parse(value); return Number.isFinite(parsed) ? parsed : null; } function pruneExpired(polls: Record) { const cutoff = Date.now() - POLL_TTL_MS; const entries = Object.entries(polls).filter(([, poll]) => { const ts = parseTimestamp(poll.updatedAt ?? poll.createdAt) ?? 0; return ts >= cutoff; }); return Object.fromEntries(entries); } function pruneToLimit(polls: Record) { const entries = Object.entries(polls); if (entries.length <= MAX_POLLS) return polls; entries.sort((a, b) => { const aTs = parseTimestamp(a[1].updatedAt ?? a[1].createdAt) ?? 0; const bTs = parseTimestamp(b[1].updatedAt ?? b[1].createdAt) ?? 0; return aTs - bTs; }); const keep = entries.slice(entries.length - MAX_POLLS); return Object.fromEntries(keep); } export function normalizeMSTeamsPollSelections(poll: MSTeamsPoll, selections: string[]) { const maxSelections = Math.max(1, poll.maxSelections); const mapped = selections .map((entry) => Number.parseInt(entry, 10)) .filter((value) => Number.isFinite(value)) .filter((value) => value >= 0 && value < poll.options.length) .map((value) => String(value)); const limited = maxSelections > 1 ? mapped.slice(0, maxSelections) : mapped.slice(0, 1); return Array.from(new Set(limited)); } export function createMSTeamsPollStoreFs(params?: MSTeamsPollStoreFsOptions): MSTeamsPollStore { const filePath = resolveMSTeamsStorePath({ filename: STORE_FILENAME, env: params?.env, homedir: params?.homedir, stateDir: params?.stateDir, storePath: params?.storePath, }); const empty: PollStoreData = { version: 1, polls: {} }; const readStore = async (): Promise => { const { value } = await readJsonFile(filePath, empty); const pruned = pruneToLimit(pruneExpired(value.polls ?? {})); return { version: 1, polls: pruned }; }; const writeStore = async (data: PollStoreData) => { await writeJsonFile(filePath, data); }; const createPoll = async (poll: MSTeamsPoll) => { await withFileLock(filePath, empty, async () => { const data = await readStore(); data.polls[poll.id] = poll; await writeStore({ version: 1, polls: pruneToLimit(data.polls) }); }); }; const getPoll = async (pollId: string) => await withFileLock(filePath, empty, async () => { const data = await readStore(); return data.polls[pollId] ?? null; }); const recordVote = async (params: { pollId: string; voterId: string; selections: string[] }) => await withFileLock(filePath, empty, async () => { const data = await readStore(); const poll = data.polls[params.pollId]; if (!poll) return null; const normalized = normalizeMSTeamsPollSelections(poll, params.selections); poll.votes[params.voterId] = normalized; poll.updatedAt = new Date().toISOString(); data.polls[poll.id] = poll; await writeStore({ version: 1, polls: pruneToLimit(data.polls) }); return poll; }); return { createPoll, getPoll, recordVote }; }