/** * Lark event dispatcher — handles WSClient setup, bot identity probing, * and message routing (group access checks, @mention detection). * Extracted from daemon.ts for modularity. */ import * as Lark from '@larksuiteoapi/node-sdk'; import { type DocSubscription } from '../../services/doc-subs-store.js'; import { type Brand } from './lark-hosts.js'; import { type SummaryCommandRuntimeContext } from './summary-command.js'; import { type MessageListenerMatch } from '../../services/message-listener.js'; import type { VcMeetingPushContext } from '../../vc-agent/types.js'; import type { VcMeetingImTurnOrigin } from '../../types.js'; /** Set the bot's open_id. Callers should also call writeBotInfoFile() to persist. */ export declare function setBotOpenId(larkAppId: string, id: string): void; /** Persist bot registry info to disk for agent-facing CLI subcommands to read. * Merges current process's bot(s) into the existing file so that * multiple daemon processes (one per bot) don't overwrite each other. */ export declare function writeBotInfoFile(dataDir: string): void; /** * Ensure the bot's own open_id is resolved before @-detection. `probeBotOpenId` * is fired fire-and-forget at daemon startup, so events can arrive while * `botOpenId` is still undefined — `isBotMentioned` then can't recognize an @ as * ours and silently drops it. The WSClient still ACKs that dropped event, so * Lark never redelivers it (the @ is lost until manually re-sent). Awaiting the * deduped probe here closes that window: concurrent events share one probe, and * each is held only until the open_id lands, then processed normally. */ export declare function ensureBotOpenId(larkAppId: string): Promise; export declare function probeBotOpenId(larkAppId: string): Promise; export declare function checkRequiredScopes(larkAppId: string): Promise; /** * Startup: ensure this bot is subscribed to the VC meeting events * (`vc.bot.meeting_*` + participant_meeting_joined) so ANY invited bot can * receive meeting invites — the physical prerequisite for bot-agnostic * auto-join. Check-first: a read-only probe over the cached Feishu web session * (no QR at boot); only when events are missing / event mode is wrong do we run * the full open-platform automation (which subscribes them AND publishes a * version). This mirrors checkRequiredScopes ("fix only when something's * missing") so a bot with events already subscribed makes zero mutating calls * and never republishes. Best-effort: never throws into boot; apiOnly and * VC-disabled bots are skipped by the caller / the active-config gate. */ export declare function ensureVcMeetingEventsSubscribed(larkAppId: string): Promise; export declare const CHAT_CACHE_TTL: number; /** * Synchronous first-stage routing lane for inbound Lark messages. * * Routing can await bot identity, chat mode, oncall binding, summary expansion, * and VC catch-up before it discovers the canonical daemon anchor. Reserving * only at that later anchor lets a faster N+1 reach the session before N. * * This barrier must be chat-wide, not thread-wide: a topic seed has no thread_id * and initially looks chat-shaped, while its first reply carries thread_id; both * later canonicalize to the seed message_id. Shared-topic aliases likewise fold * a thread-shaped event back into a chat owner. Per-thread raw lanes therefore * cannot prove arrival order. The barrier is released as soon as canonical work * is synchronously enqueued below, so handlers for distinct canonical topics can * still run concurrently. */ export declare function rawMessageIngressAnchor(larkAppId: string, message: any): string; /** Test-only: clear callback dedupe claims between cases. */ export declare function __resetEventClaimsForTest(): void; export declare function getGroupStats(larkAppId: string, chatId: string): Promise<{ userCount: number; botCount: number; }>; /** * Drop the cached group stats for one chat — called when a membership-change * event that is VISIBLE to this app arrives (user add/delete broadcast, or our * own bot being added/removed), so the 1v1 relax gate sees fresh counts on the * very next message instead of coasting on stale numbers for up to * CHAT_CACHE_TTL. TTL 继续保留,作为事件未订阅(老应用)时的兜底。 * 注意跨进程边界:「别的 bot 进群/离群」事件只推给当事 bot 自己的 app, * 一 bot 一 daemon 部署下此函数够不到兄弟进程的缓存(见 handler 处注释)。 */ export declare function invalidateChatStats(larkAppId: string, chatId: string): void; /** Test-only: clear the group-stats cache between cases. */ export declare function __resetChatStatsForTest(): void; /** Read the per-bot cross-reference: botName(lowercase) → openId as seen by larkAppId's app */ export declare function readBotOpenIdCrossRef(dataDir: string, larkAppId: string): Map; /** Is `senderOpenId` a registered botmux peer (from larkAppId's cross-ref)? * Used to gate chat-scope foreign-bot @mention spawning to vetted peers. */ export declare function isKnownPeerBot(dataDir: string, larkAppId: string, senderOpenId: string | undefined): boolean; /** * Auth-grade local sibling check for the narrow `/repo` exception. * * Cross-ref proves only "this receiver has seen this sender open_id as some * peer" and may be stale or name-poisoned. For authorization we additionally * require the Lark-stamped sender union_id to match exactly one currently * configured, transport-enabled sibling app's learned own union_id. */ export declare function isVerifiedLocalSiblingBot(dataDir: string, larkAppId: string, senderOpenId: string | undefined, senderUnionId: string | undefined): boolean; /** * Resolve a foreign bot sender's CURRENT display name from its tenant-stable * `union_id` — rename-proof, unlike the per-app name→open_id cross-ref. * * Why this exists: `lookupForeignBotName` historically read the receiver-local * cross-ref (`bot-openids-.json`), which only learns a bot's name * when THAT bot is @-mentioned in one of the receiver's own events. When a * renamed bot merely *sends* to us (the `[来自 X 的 @mention]` prefix path), no * mention of X flows through us, so its cross-ref entry stays frozen at the old * name. `union_id` is stamped on every inbound event's `sender.sender_id` and is * stable across renames AND shared tenant-wide, so it maps the sender to the one * locally-configured sibling that learned the same union_id, whose live * `bots-info.json` entry is rewritten by its OWN daemon on every rename * (setBotRenamer → writeBotInfoFile). That is the freshest cross-process name. * * Matches exactly one sibling (same ambiguity guard as isVerifiedLocalSiblingBot). * Returns undefined on empty/unknown union_id, ambiguity, or a missing bots-info * entry, so the caller falls back to the cross-ref / "Bot". Cross-deployment team * peers aren't locally configured, so they return undefined here and rely on the * cross-ref (whose stale aliases the writer now evicts on the next @ by new name). */ export declare function resolveSiblingBotNameByUnionId(dataDir: string, larkAppId: string, senderUnionId: string | undefined): string | undefined; /** * Should a FOREIGN bot sender be trusted to collaborate (route/spawn a session) * WITHOUT `/grant`, because it's a teammate? Two trust sources, both rooted in * tenant-stable identity or team-controlled group membership — never a name: * * 1. **Learned teammate** — its `union_id` (from the inbound event's * `sender.sender_id.union_id`) is in the team-bot store, i.e. we've seen it * vouched inside a team-assembled group before. Honoured in ANY chat. * 2. **Team-assembled group** — the chat itself is a 拉群 group (team-controlled * membership), so a botmux bot speaking there is a vouched teammate even on * first contact (mirrors the existing oncall-chat exemption). * * `isKnownPeerBot` (same-deployment siblings) is checked separately by callers; * this covers cross-deployment TEAM peers. Returns false when neither holds, so * the caller falls back to the /grant request card. * * 平台团队与旧版联邦团队同权:isPlatformTeamBot(平台 team-sync 下发的团队 bot * union_id roster)与 isTeamBot(团队群里学来的 union_id)都是 union_id 锚定的 * bot 专属信任;平台拉的团队群则经 platform: 前缀镜像进 team-groups,走同一个 * isTeamGroupChat。两条路都算团队模式,都不需要 /grant。 * * 调用方只剩 {@link evaluateBotTalk} 一个(bot 闸门的唯一入口);(1) 的两条腿与 * evaluateTalk 的 teamBot 腿重合,真正额外放行的是 (2)。保留本谓词是因为它是 * 「bot 团队信任」的定义所在,且有专属单测(team-peer-trust / platform-team-trust)。 */ export declare function isTrustedTeamBotSender(dataDir: string, chatId: string | undefined, senderUnionId: string | undefined): boolean; /** Update the per-bot cross-reference from @mention data in an event. * mentionsList comes from Lark event message.mentions array. */ export declare function updateBotOpenIdCrossRef(dataDir: string, larkAppId: string, mentionsList: Array<{ name?: string; id?: { open_id?: string; } | string; id_type?: string; }>): void; /** * If `message` is a /introduce command, side-effect it (record observed bots * + send ack) and return `true` so the caller skips normal CLI routing. * * /introduce 不需要任何授权:它只是把群里别的 bot 记进花名册(observed),不授予 * 任何对话/操作权,所以群里任何人都能用(与 /grant 的 owner 强闸门相反)。 * * Consumed without side effects (still returns true) when: * - mentions[] minus self is empty — nothing to learn, ignore quietly * * Writes ALL mentions including self to the store. Each receiving bot's * daemon owns its own per-observer file (see observed-bots-store), so the * self entry is the only authoritative record of this app's own open_id * in the per-observer view — useful for `botmux bots list` self-identification. */ export declare function tryHandleIntroduceCommand(larkAppId: string, message: any, senderOpenId: string | undefined): Promise; /** Check if the bot was @mentioned in this message */ export declare function isBotMentioned(larkAppId: string, message: any, _senderOpenId: string | undefined): boolean; /** Does this message @mention a *specific other member* (a person or bot that * is NOT this bot)? Used by the 'ambient' mention policy to decide whether to * back off: under 'ambient' the bot answers un-@ messages, but if the user * explicitly addresses SOMEONE ELSE it stays quiet (the redirect carve-out). * `@all` addresses everyone including this bot, so it is NOT an "other member" * and does NOT trigger backoff. Mirrors isBotMentioned's two shapes: * message.mentions[] (user text) and inline `at` nodes in post content. */ export declare function mentionsAnotherMember(larkAppId: string, message: any): boolean; export declare function resolveSubstituteTrigger(larkAppId: string, message: any): import('../../types.js').SubstituteTrigger | undefined; /** 会话类型(与 daemon / DaemonSession 的 chatType 同域)。p2pOpen 腿据此判定。 */ export type ChatKind = 'group' | 'p2p'; /** * 全部 talk 放行源的**运行时**枚举(TalkReason 由它派生)。 * * 之所以是数组而不是纯 type:`test/bot-talk-parity.test.ts` 按它逐条核对 * 「这条腿对人和对 bot 分别是什么行为」,少一格就红。tsconfig 的 include 只有 * `src/**`,测试不过 tsc——纯类型层的穷尽约束在这里执行不了,必须落到运行时。 * * 新增一条 talk 源时:改 evaluateTalk(人/bot 同时生效)+ 在那张表补一格说明, * **不要**去 bot 路由闸里加 OR 腿(那正是 allowedChatGroups 漏掉的老路)。 */ export declare const TALK_REASONS: readonly ["allowedUser", "oncall", "peer", "teamBot", "teamMember", "allowedChatGroup", "open", "chatGrant", "globalGrant", "p2pOpen", "none"]; export type TalkReason = typeof TALK_REASONS[number]; export interface TalkEvaluation { allowed: boolean; reason: TalkReason; quotaKey?: string; /** * 仅在 reason==='oncall' 且命中 quotaKey 时可能为 true:本 oncall 用户在**同群**还持有一条 * 「未过期的显式 chatGrant」。oncall 与 chatGrant 共用同一把 chat quotaKey,owner 给 oncall * 用户 /grant「不限」时磁盘上无 quota 记录(= 显式不限),若额度层再按 messageQuota.defaultLimit * 懒初始化就会把「显式不限」静默套回 default。带上这个标志让额度层对这种交集**不兜 default**。 * 显式 N 授权已有 quota 记录,consumeQuota 直接消费现有记录,与本标志无关。 */ explicitGrantOverride?: boolean; /** * 仅在 reason==='oncall' 且该用户同群持有一条**已过期** chatGrant 时给出:透传给 * consumeQuota,由它在**同一把额度锁内**以「当前 expiry」为权威判定——当前 expiry<=now 则原子清 *「成员+quota+expiry」并回落 oncall default;当前无 expiry/未来 expiry 则该 grant 仍 live(成员在→ * 不兜 default 按现有记录/不限消费;成员已被清→普通 oncall→回落 default)。收口进一把锁,杜绝跨 * await 用陈旧 ev 决策,也不会让过期成员关系残留成永久授权。(pure chatGrant/globalGrant 的过期走 * grantNotExpired 拒发。) */ expiredGrantCleanup?: { scope: 'chat'; chatId: string; openId: string; }; /** * 仅会话群继承腿会给出:这条 chatGrant **实际所在**的 chat(= 出生前那个私聊), * 与本次判定的 chatId(会话群)不是同一个。额度耗尽后的 revoke 必须落在它上面, * 否则会去删一条会话群上根本不存在的授权 —— 撤销静默失效、硬上限形同虚设。 */ grantChatId?: string; } export type GrantCommandRestrictionReason = 'chatGrant' | 'globalGrant'; export declare function grantCommandRestriction(larkAppId: string, chatId: string | undefined, senderOpenId: string | undefined): { blocked: boolean; reason?: GrantCommandRestrictionReason; }; export declare function canTalk(larkAppId: string, chatId: string | undefined, senderOpenId: string | undefined, senderUnionId?: string, memberUnionId?: string, chatType?: ChatKind): boolean; /** * @param senderUnionId BOT-trust union(teamBot 腿)——调用方必须已按 sender_type * 锁定为「飞书盖章的 bot 发送方」(daemon 的 teamTrustUnionId),否则恶意成员把 * 真人 union 报成 bot 就能让真人继承 bot 信任。 * @param memberUnionId 发送方 union(teamMember 腿)——可为真人 union(未锁 bot)。 * 授 talk、不授 operate,要求 union 与本 bot 同属一个平台团队(memberUnionIds * 来自平台鉴权的团队成员,非机器自报),不限 chat,故喂真人 union 是安全的。 * @param chatType 当前会话类型。**仅 p2pOpen 腿读它**;省略时该腿不生效(fail-closed), * 所以拿不到 chatType 的调用点保持原语义、不会误放行。 */ export declare function evaluateTalk(larkAppId: string, chatId: string | undefined, senderOpenId: string | undefined, senderUnionId?: string, memberUnionId?: string, chatType?: ChatKind): TalkEvaluation; /** * BOT 发送方的 talk 判定 —— bot 路由闸(外部 bot @ 本 bot)的**唯一**入口。 * * = 人侧 evaluateTalk 的完整模型 + 一条 bot 专属腿。历史上 bot 侧手抄了一份 * talk 源子集(oncall / peer / teamBot / chatGrant / globalGrant 的 OR 链), * 于是每加一条新 talk 源就漏一次、复发一次同类 bug:oncall 漏过一次、开放模式 * 漏过一次、allowedChatGroups 又漏一次(owner 整群 `/grant` 后真人能说话、外部 bot * 一 @ 仍弹授权卡)。收敛成单一谓词后,**新增 talk 源只需改 evaluateTalk**; * test/bot-talk-parity.test.ts 按 TALK_REASONS 逐条盯着这条约束。 * * 与人侧仅有的两条差异,都是刻意的,改动前先读: * * 1. `isTrustedTeamBotSender`(+,bot 才有):其中真正额外放行的是**团队拉群**那条腿 * ——本群是团队拉群(团队掌控成员)→ 说话的 botmux bot 即视为背书队友,首次接触 * 也放行,与 oncall 群豁免同构。chat 维度、不看 union,覆盖 sender 事件没带 * `union_id` 的情况(此时 gate 前那次 `recordTeamBot` 被 `senderUnionId &&` 短路, * evaluateTalk 的 teamBot 腿必然落空)。**这条不能下沉进 evaluateTalk**:那是人/bot * 共用的,加进去等于团队群里任何真人都自动过 canTalk,绕开 teamMember 腿的成员校验 * (isPlatformTeamMember 要求 union 与本 bot 同属一个平台团队)。它另外两条 union 锚定 * 的腿与 evaluateTalk 的 teamBot 腿重合,留着是为了让「bot 团队信任」只有一处定义。 * * 2. `p2pOpen`(−):不传 chatType → 该腿 fail-closed 不生效。飞书里 bot 之间不存在 * 私聊,开着只是白扩边界。 * * @param senderUnionId 必须是飞书盖章的 bot 发送方 union(`sender_type ∈ app|bot`), * 与 evaluateTalk 的 bot-trust 腿同一契约。真人 union 走 memberUnionId,本函数恒不传。 */ export declare function evaluateBotTalk(larkAppId: string, chatId: string | undefined, senderOpenId: string | undefined, senderUnionId?: string): TalkEvaluation; /** ask 答复者身份上下文(文字作答路径带,卡片点击路径不带)。与 ask-broker 的 * AskAnswerActor 同形;放这里是为了让「ask 答复的 talk 分派」有一个生产共用真源, * daemon wiring 与回归测试都调 {@link evaluateAskAnswerTalk},不各自手抄分派。 */ export interface AskAnswerActorContext { botSender?: boolean; senderUnionId?: string; memberUnionId?: string; } /** * `botmux ask` 答复者是否可作答(= 该 chat 的 canTalk 门)——ask-broker 注入的 * canTalkChecker 的**唯一生产实现**,daemon bootstrap 一行转调它。 * * 抽成独立导出函数(而非把分派内联进 daemon setter 闭包)的意义:让**分派谓词本身** * 能被回归测试直接咬住——测试对本函数喂真实 team store 状态断言三组行为,改坏这里的 * 分派逻辑(如退回旧 evaluateTalk / 不认 actor.botSender)会立刻红。注意测试只覆盖到 * 本函数;daemon setter 的一行转调与 submitCustomReply 的 actor 构造仍靠人工核对 * (setter 只有一行,不经测试机械覆盖)。 * * - bot 发送方(actor.botSender)→ evaluateBotTalk(含团队拉群那条 chat 维度腿, * 覆盖 sender 事件没带 union_id 的场景),与 dispatcher 外层闸 / quota 复查同源。 * - 人 / 无 actor(卡片点击路径)→ evaluateTalk 全模型:actor.senderUnionId 走 * teamBot 腿(人路径恒 undefined)、actor.memberUnionId 走 teamMember 腿。 * actor 省略时两者都是 undefined → 退化为纯 evaluateTalk(openId, chatType), * 与本次改动前的卡片点击语义完全一致。 */ export declare function evaluateAskAnswerTalk(larkAppId: string, chatId: string | undefined, senderOpenId: string | undefined, chatType?: ChatKind, actor?: AskAnswerActorContext): boolean; /** 一条 thread reply 被判定为「待答 ask 的文字答复」后,拦截器需要的三个已收窄值。 */ export interface AskCustomReplyCandidate { senderOpenId: string; chatId: string; text: string; } /** * 这条 thread reply 能不能被待答 ask 当成「自定义回复」吞掉——handleThreadReply * 里那道拦截器的唯一生产判据。 * * 命中时该消息会走 submitCustomReply settle 掉 ask,**不再**作为新一轮输入转发给 * CLI。因此只有**真正的纯文字答复**才可以被吞: * * - `resourceCount > 0` 一律不吞。文件 / 图片消息也有非空正文(解析器给的 * `[文件 1: x.pdf]` / `[图片 1]` 占位文本),只看文本非空会让成员在旧操作卡 * 有效期内发的新附件被当成旧问题的答案消费掉:新资料不进附件路由,旧 ask 被 * 一段占位文本 settle。带资源的消息必须落回正常消息 / 附件路由。 * - workflow grill 触发(`/workflow [new] <目标>`)不吞:grill 分支只改写 * promptContent 后 fall-through,cmdContent 仍是字面量,被吞掉 grill 就永远 * 不启动。 * - 没有 senderOpenId / chatId 时不吞:答复权限与 ask 归属都无从判定。 * * 抽成导出谓词(而非把条件内联进 daemon)的意义与 {@link evaluateAskAnswerTalk} * 一致:让**判据本身**能被回归测试直接咬住。拦截命中后的鉴权仍在 broker 内由 * canTalkChecker 判定,本函数不涉及权限。 */ export declare function askCustomReplyCandidate(input: { senderOpenId: string | undefined; chatId: string | undefined; cmdContent: string; resourceCount: number; isWorkflowGrillTrigger: boolean; }): AskCustomReplyCandidate | undefined; export declare function canOperate(larkAppId: string, _chatId: string | undefined, senderOpenId: string | undefined, senderUnionId?: string | undefined): boolean; /** * Daemon 命令统一闸:canOperate 恒放行;此外,bot 配置的 `canTalkDaemonCommands` * 名单内的命令降到 canTalk 判定(oncall / allowedChatGroup / grant / p2pOpen 等 * 对话放行腿命中即可)。名单外或未配置 → 与 canOperate 完全等价(现状不变)。 * * 只作用于 daemon.ts 两条路由的 DAEMON_COMMANDS 统一闸;在统一闸之前特判的命令 * (/vc-auth /term)与 handler 内部自带 owner 闸的命令(/card /insight) * 不受影响——内部闸仍是最终权威(fail-closed)。 * * chatType 省略时 p2pOpen 腿不生效(fail-closed),与 canTalk 语义一致—— * 私聊路径的调用点必须把 chatType 传进来。 * * botSender:降权到 talk 判定这一段,必须与 dispatcher 外层闸 / quota 复查用**同一个** * 谓词。bot 发送方走 evaluateBotTalk(含团队拉群那条 chat 维度腿,覆盖 sender 事件没带 * union_id 的情况);否则「团队拉群里外部 bot 能 talk 却执行不了降权到 canTalk 的命令 * (如 /status)」——单一 talk 谓词在这道 daemon 命令闸继续分叉。不传(人的路径)→ * 原样走 canTalk / evaluateTalk,语义不变。canOperate 那段人/bot 通用,不受影响。 * * `/repo` 另有一个更窄的同部署 sibling bot 例外:仅当飞书事件把发送方盖章为 bot, * receiver cross-ref 命中 sender open_id,且 sender union_id 精确匹配当前仍配置的唯一 * 本机 sibling app 已学习的自身 union_id 时放行;不把 sibling 提升为 canOperate,也不把其他 daemon 命令降权。 */ export declare function canRunDaemonCommand(larkAppId: string, chatId: string | undefined, senderOpenId: string | undefined, senderUnionId: string | undefined, cmd: string, memberUnionId?: string | undefined, chatType?: 'p2p' | 'group', botSender?: boolean, larkStampedBotSender?: boolean): boolean; /** * Check group message addressing: * - 'allowed' -> sender is allowed, bot was @mentioned or solo group * - 'not_allowed' -> bot was @mentioned but sender is not in allowlist * - 'ignore' -> not addressed to bot at all */ export declare function checkGroupMessageAccess(larkAppId: string, message: any, chatId: string, senderOpenId: string | undefined, memberUnionId?: string): Promise<'allowed' | 'not_allowed' | 'ignore'>; /** Routing context computed from the incoming message — describes the * conversational unit (`scope`) and the addressing key (`anchor`) used * throughout the rest of the system. The dispatcher computes this once * per message and hands it to the daemon's session handlers, so the * daemon never has to re-derive it. */ export interface RoutingContext { chatId: string; /** message_id of the inbound message that triggered this routing. */ messageId: string; chatType: 'group' | 'p2p'; /** 'thread' → reply_in_thread to a (real or freshly seeded) thread root. * 'chat' → plain message to the chat (no threading). */ scope: 'thread' | 'chat'; /** Routing key. `chatId` for chat-scope, the thread root id for * thread-scope (an existing rootMessageId, or this messageId when * it's the seed of a brand-new thread). A beforeSessionTurn hook may * replace it with a daemon-owned synthetic anchor for a dedicated receiver * session; the visible chatId/scope remain unchanged. */ anchor: string; /** Chat-scope shared-topic reply target for this turn, if any. */ replyRootId?: string; /** Command prompt that should be sent to the CLI instead of raw text. */ promptOverride?: string; /** Durable VC routing succeeded but the bounded pre-turn catch-up did not. * Prompt builders must surface this instead of pretending context is fresh. */ vcMeetingContextMayLag?: boolean; /** The receiver belongs to an ended meeting whose final delivery is sealed. * Explicit human follow-ups still reuse that transcript, but meeting-side * effects remain closed. */ vcMeetingContextLifecycle?: 'active' | 'sealed'; /** Daemon-derived authority snapshot for an explicit human IM turn routed * into one dedicated meeting receiver. Never populated from message text. */ vcMeetingImTurnOrigin?: VcMeetingImTurnOrigin; /** Metadata for the summary command that produced promptOverride. */ summaryCommand?: SummaryCommandRuntimeContext; /** This turn was triggered by @mentioning a configured substitute person. */ substituteTrigger?: import('../../types.js').SubstituteTrigger; /** This turn was triggered by a configured group message listener. */ messageListener?: MessageListenerMatch; /** Earlier topic seed coalesced into this root-linked clarification. */ forwardSeedData?: any; /** Set by the session-group birth flow (p2pMode='group') after it has * re-homed this turn from a DM into a freshly-created session group — * prevents the birth logic from re-triggering on the rewritten context. */ sessionGroupBirth?: boolean; /** This turn was already AUTHORIZED and CHARGED against the source DM, before * any session-group side effect was allowed (a quota denial must create no * Feishu chat). Set only by the birth flow, immediately after that gate * returned true — so the rewritten group turn must neither charge it twice * NOR re-decide authorization: the new chat cannot yet carry any chat-scoped * grant, so a recheck there only ever produces false negatives, and dropping * an already-charged turn is exactly the "charged, then lost the task" * failure. See enforceMessageQuotaForCliInput's alreadyAuthorizedAndCharged. */ sessionGroupQuotaConsumed?: boolean; /** Session-group birth only: the in-group intro message id used as the * turn's REPLY anchor (quote target / session rootMessageId), so the first * turn's outputs land in the group. `messageId` stays the ORIGINAL inbound * DM message id — resource downloads and merge-forward expansion must keep * using it (the resource keys belong to the source message, PR review P1). */ replyAnchorMessageId?: string; larkAppId: string; /** 本轮 inbound 的接纳阶段标记,由 daemon 的普通消息入口初始化、各接纳点翻转。 * 必须是共享 mutable box 而非布尔字段:reroute 交接会浅拷贝 ctx * (`{ ...ctx, scope, anchor }`),box 引用随拷贝共享,接纳发生在拷贝之后 * 也能被最外层 ingress catch 看到。admitted 为 true 后该 catch 不得再提示 * 重发——本轮已进 durable queue / worker,重发会让同一任务再次入队执行。 */ ingressAdmission?: { admitted: boolean; }; } export declare function __pollMessageListenersOnceForTest(larkAppId: string, handlers: EventHandlers, now?: number): Promise; /** Mark a bot's active sessions as restored so pending forward-followup seeds can flush. */ export declare function markForwardFollowupsSessionsReady(larkAppId: string): void; export interface EventHandlers { handleCardAction: (data: any, larkAppId: string) => Promise; handleNewTopic: (data: any, ctx: RoutingContext) => Promise; handleThreadReply: (data: any, ctx: RoutingContext) => Promise; /** 主动开工 — 场景①: fired when this bot is added to a chat * (`im.chat.member.bot.added_v1`). The daemon decides whether to auto-start * based on the bot's `autoStartOnGroupJoin` toggle + allowedUser membership. * Best-effort fire-and-forget. `operatorOpenId` is who added the bot. */ handleBotAdded?: (chatId: string, operatorOpenId: string | undefined, larkAppId: string) => Promise; /** Check if this bot owns an active session anchored at the given id * (rootMessageId for thread-scope, chatId for chat-scope). */ isSessionOwner?: (anchor: string, larkAppId: string) => boolean; /** Resolve a persisted topic reply alias back to its owning chat-scope session. */ resolveReplyThreadAlias?: (rootId: string, chatId: string, larkAppId: string) => { chatId: string; sessionId: string; anchor?: string; } | null; /** Fired when the dispatcher detects that a chat with a live chat-scope * session has been converted to topic mode (chat_mode 'group' → 'topic' * via Lark group settings). Daemon should evict the stale chat-scope * session from its activeSessions map so future routing doesn't hit it * and so scheduler/dashboard sends stop going through sendMessage(chatId) * — which in a 话题群 wraps each top-level message in a fresh topic. * Best-effort fire-and-forget; the dispatcher proceeds either way. */ onChatModeConverted?: (chatId: string, larkAppId: string) => void; /** 文档评论入口(/watch-comment / /subscribe-lark-doc):一条命中文档绑定的评论被喂进 * 会话。daemon 负责定位会话、投递给 worker、记录该轮回评论的落点。 */ handleDocComment?: (ctx: DocCommentContext) => Promise; /** VC bot meeting push events (`vc.bot.meeting_*_v1`). ACK-safe; daemon owns meeting session state. */ handleVcMeetingPush?: (ctx: VcMeetingPushContext) => Promise; /** Best-effort hook before a human inbound turn reaches the CLI session. Used * by VC meeting listener groups to catch up pending meeting context before a * user asks the selected agent a follow-up question. */ beforeSessionTurn?: (data: any, ctx: RoutingContext, meta: { senderOpenId?: string; explicitlyMentionedThisBot: boolean; }) => Promise; /** 授权成功后重放之前被拦截的消息,让用户无需再 @ 一遍。 * 由 startLarkEventDispatcher 内部注入,daemon 的 cardDeps.replayGrantedMessage 调用。 */ replayMessageEvent?: (data: any) => void; } /** 一条已通过订阅 + 触发范围 + 自触发过滤的文档评论,交给 daemon 投递。 */ export interface DocCommentContext { larkAppId: string; /** 命中的文档订阅(含 sessionAnchor / scope / chatId)。 */ sub: DocSubscription; /** 触发评论的 comment_id。 */ commentId: string; /** 触发的具体回复 id(作 turnId,回评论落点也按它走);缺省退回 commentId。 */ replyId?: string; /** 评论纯文本(喂给模型的用户消息)。 */ text: string; /** 局部评论选中的文档原文。 */ selectedText?: string; /** 触发回复之前,该评论串已有的讨论。 */ priorReplies?: Array<{ authorOpenId?: string; text: string; }>; /** 是否为整篇文档的全文评论。 */ isWhole?: boolean; /** 评论发表者 open_id。 */ authorOpenId?: string; } /** * Best-effort plain-text extraction from a Lark message for routing-level * decisions (currently: `/t` / `/topic` detection). Handles the two common * shapes — `text` (`{"text": "..."}`) and `post` (zh_cn/en_us nested * paragraphs of `text` / `at` nodes). Other types (image, file, sticker, * interactive, …) return null so the caller falls through to the default * routing path. * * Kept deliberately tiny rather than reusing parseEventMessage: the dispatcher * runs on every inbound event and we only need a quick text peek before the * permission gates / scope override; full parseEventMessage still runs once * inside the chosen handler. */ export declare function extractMessageTextForRouting(message: any): string | null; /** * If the inbound message starts with `/t` / `/topic` AND the routing * currently lands on chat-scope, override to thread-scope anchored at * the inbound message_id. This makes "force topic mode" work even when * the bot already owns a chat-scope session in the chat — the dispatcher * routes to handleNewTopic at a fresh anchor instead of falling into * handleThreadReply on the chat-scope owner. * * Already-thread messages (real Lark 话题, p2p, 话题群) are left alone: * the prefix is still stripped downstream by handleNewTopic. */ export declare function maybeApplyForceTopicOverride(routing: { scope: 'thread' | 'chat'; anchor: string; }, message: any, messageId: string): boolean; export declare function decideRouting(larkAppId: string, message: any): Promise<{ scope: 'thread' | 'chat'; anchor: string; }>; /** * Create and start the Lark WSClient with event dispatching. * Returns the WSClient instance for lifecycle management. */ export declare function startLarkEventDispatcher(larkAppId: string, larkAppSecret: string, handlers: EventHandlers, brand?: Brand): Lark.WSClient; //# sourceMappingURL=event-dispatcher.d.ts.map