/** * In-memory broker for `botmux ask` (v0.1.8). * * Holds the pending-ask registry, runs the deadline timers, and arbitrates * click resolution. IM-agnostic: the im/lark side wires a dispatcher via * `setCardDispatcher` so the broker doesn't import Lark types. * * §3 / §6 / §7 / §8 of /tmp/botmux-ask.md. */ import { type AskPersistStore } from './ask-persist-store.js'; import type { AskCardDispatcher, AskClickOutcome, AskResult, CreateAskInput, PendingAsk } from './ask-types.js'; /** Wire the durable persist store. Called once at daemon bootstrap with the * real dir; tests inject a temp store (and only clean their own sentinel dir). */ export declare function setAskPersistStore(store: AskPersistStore | null): void; /** Shrink the handoff-retention window — for tests only, so the absolute-expiry * reaper (codex P1-4) can be verified with a short real timer. Restored to the * default by `_resetForTest`. */ export declare function _setHandoffRetentionForTest(ms: number): void; /** Optional actor context for the talk check. Card-click paths (toggle/submit) * omit it — Lark card-action callbacks carry no sender union / bot flag, so the * checker degrades to the human `evaluateTalk(openId, chatType)`. The custom * text-reply path (submitCustomReply) DOES have the full message event, so it * passes actor context and the checker dispatches to the same predicate as the * dispatcher gate / quota recheck (bot → evaluateBotTalk, human → evaluateTalk * with the teamMember union leg). Without this, a cross-deployment team bot or a * platform teamMember human answering by text is wrongly rejected. */ export interface AskAnswerActor { /** Feishu-stamped bot sender (sender_type ∈ app|bot, or a cross-ref sibling). */ botSender?: boolean; /** Bot-locked union (evaluateTalk teamBot leg / evaluateBotTalk). */ senderUnionId?: string; /** Raw sender union (evaluateTalk teamMember leg — may be a human union). */ memberUnionId?: string; } /** Wire the canTalk predicate. Called once during daemon bootstrap. */ export declare function setCanTalkChecker(fn: (larkAppId: string, chatId: string, openId: string, chatType?: 'group' | 'p2p', actor?: AskAnswerActor) => boolean): void; /** Wire the IM-side dispatcher. Called once during daemon bootstrap from * daemon.ts after im/lark/ask-card.ts is constructed. */ export declare function setCardDispatcher(d: AskCardDispatcher): void; /** Register a new pending ask. Returns a Promise that settles when: * - a valid click arrives (`kind:'answered'`) * - the deadline elapses (`kind:'timedOut'`) * - the broker invalidates the ask (`kind:'invalidated'`) * * Side effects: * - generates askId + nonce * - starts the deadline timer * - dispatches the card; if the card send fails, the ask is immediately * invalidated and the Promise settles with `kind:'invalidated'`. * * Throws synchronously only if no dispatcher has been wired — that's a * daemon-misconfiguration bug, not a runtime ask failure. */ export declare function registerAsk(input: CreateAskInput): Promise; /** * 勾选/取消勾选某问题的某个选项(累积模式,不 settle)。 * * 校验同 `tryResolveAsk`:askId 存在 / nonce 匹配 / 未 settle / 已授权 / * questionIndex 合法 / key 在该问题的 options 中。 * * 对于单选问题(multiSelect:false),翻转时 Set 内只保留该 key(相当于"换选")。 * 对于多选问题(multiSelect:true),翻转规则:已在 Set 中则移除,否则添加。 * * 成功返回 `'toggled'`;非法返回对应 AskClickOutcome。 */ export declare function toggleAsk(args: { askId: string; nonce: string; questionIndex: number; key: string; by: string; }): AskClickOutcome; /** * 提交答案并 settle。 * * `selections` 显式传入时直接使用(按钮单选 / 一次性表单提交场景); * 否则使用 `toggleAsk` 累积的勾选状态。 * * 对于 `multiSelect:false` 的问题,要求恰好 1 个选中,否则返回 `'stale'`。 * 校验通过则 settle 并返回 `'accepted'`;非法返回对应 AskClickOutcome。 */ export declare function submitAsk(args: { askId: string; nonce: string; by: string; selections?: ReadonlyArray>; /** 空提交二次确认已通过(用户在 arm 卡片上再点了一次)。仅影响「全多选 + 全空」 * 这一种可确认的空提交;其它情形不看它。缺省 false。 */ confirmEmpty?: boolean; }): AskClickOutcome; /** * 提交一段自定义回复(用户在话题里直接打字作答,替代点按钮)并 settle。 * * 校验:askId 存在 / 未 settle / `by` 可 canTalk / text trim 后非空。 * settle 为 `kind:'answered'`,各问 `answers` 为空数组、`comment` 携带 trim 后原文 * (替代语义:没有任何选项被选中,CLI 侧 formatAnswer 用 comment 回落作答)。 * * 不需要 nonce:调用方(daemon 消息路由)用 `findPendingAskByAnchor` 从在线 * pending 表按话题 anchor 查到 askId,本身就排除了「重启后的陈旧卡片」场景。 * * `actor`:文字作答路径拿得到完整消息事件,把 bot / union context 传进来,让 talk * 判定与 dispatcher 外层闸 / quota 复查同源(bot → evaluateBotTalk,人 → evaluateTalk * 的 teamMember union 腿)。不传则退化为纯 open_id 判定(与卡片点击一致)。 * * 成功返回 `'accepted'`;非法返回对应 AskClickOutcome。 */ export declare function submitCustomReply(args: { askId: string; by: string; text: string; actor?: AskAnswerActor; }): AskClickOutcome; /** * 按话题 anchor 查找一个**未 settle**的 pending ask,供 daemon 判断「这条文字回复 * 是不是在回答某个 ask」。匹配条件: * - larkAppId 相同(不跨 bot 命中) * - chatId 相同 * - thread-scope:ask.rootMessageId === anchor(话题根 message_id) * - chat-scope:ask.rootMessageId === null(anchor 实为 chatId,已由 chatId 命中) * * 命中多个时返回最先注册的(实践中同一 anchor 同时最多一个 pending ask,因为发起 * ask 的 CLI 此刻正阻塞等待结果)。返回 snapshot,改它不影响 broker 状态。 */ export declare function findPendingAskByAnchor(args: { larkAppId: string; chatId: string; anchor: string; }): PendingAsk | undefined; /** Resolve attempt from a card-button click. Returns one of the §10 outcomes; * caller (card click handler) maps to user-facing toast. * * v0.1.8 起退化为单问单选的便捷封装:等价于 * `submitAsk({..., selections:[[selected]]})`. * 使 `botmux ask buttons` 与其已有测试零回归。 * * All four "no-op" outcomes (`unauthorized`/`stale`/`already_settled`) leave * the broker state unchanged so the original CLI Promise keeps waiting for * the real winner or the deadline. */ export declare function tryResolveAsk(args: { askId: string; nonce: string; selected: string; by: string; }): AskClickOutcome; /** Invalidate every pending ask. Intended for daemon shutdown / restart paths * so CLI subprocesses unblock with `kind:'invalidated'` instead of waiting * forever on a dead daemon. Returns the number of asks actually settled * (settled-but-retained entries from the race window are skipped). */ export declare function invalidateAll(reason: string): number; /** * Restore pending asks from disk after a daemon restart. Each becomes a DORMANT * entry: its card is still live in Feishu (we do NOT re-post — cardMessageId is * preserved), so a click can settle it, but there is no waiter Promise until the * surviving CLI hook reconnects and re-registers (findDormantByKey → * reattachDormantAsk installs a fresh resolve). Each dormant ask arms a timer to * its ORIGINAL absolute deadline so it can't linger forever if the CLI never * comes back. Called once during daemon bootstrap. Returns the count restored. * * Idempotent-ish: an askKey already present (e.g. re-invoked) is skipped. */ export declare function restorePersistedAsks(now?: number, larkAppId?: string): number; /** Count of asks still awaiting a click / timeout — excludes settled entries * retained within the race-loser feedback window. For tests and metrics only. */ export declare function _pendingCount(): number; /** Read a pending ask by id. Returns a snapshot; mutating it has no effect on * broker state. Used by the card handler to PATCH toggle state. */ export declare function getAskSnapshot(askId: string): PendingAsk | undefined; /** List unsettled asks for Desktop / dashboard aggregation (read-only snapshots). */ export declare function listPendingAsks(): PendingAsk[]; /** * Desktop / trusted-host answer path. Bypasses canTalk (no Feishu openId) — * caller must be authenticated as the local dashboard/desktop operator. */ export declare function submitAskFromDesktop(args: { askId: string; /** Selected option keys per question (same shape as submitAsk selections). */ selections: ReadonlyArray>; by?: string; }): AskClickOutcome; /** Read a pending ask by id — for tests only. Returns a snapshot; mutating it * has no effect on broker state. */ export declare function _getPending(askId: string): PendingAsk | undefined; /** 返回当前 pending map 中所有 askId 列表(含 settled 但仍在 retention 内的条目)。 * 仅供测试使用。 */ export declare function _allAskIds(): string[]; /** Reset broker state — for tests only. Does NOT resolve outstanding promises, * so tests must not call this while real CLI processes might be waiting. */ export declare function _resetForTest(): void; //# sourceMappingURL=ask-broker.d.ts.map