Skip to content

plugin-chat-approval — 聊天审批:owner 的 IM 私聊与群窗口作审批面 ​

包名: @aalis/plugin-chat-approval
源码: packages/plugin-chat-approval/src/index.ts
契约: api-approval(审批面的登记与接入)

概述 ​

两种接入,登记在同一个审批面 chat 上:

  • owner 的 IM 私聊:每个(owner 的 IM 身份,机器人账号)一份。她经这个账号发起的、只有她能批的审批(要她确认的,与参与者审批合成的;在与这个账号的私聊里、在群里都算),推到她与这个账号的私聊。她经这个账号亲手说话(私聊或群里,点没点名都算)即在场。
  • 群窗口:每个(群窗口,发言者)一份。这个群发起的参与者审批(多人的一轮里有人自己做不了的动作),发在这个群里,自己就能做这个动作的人(owner 也在内)都能批。他在这个群里亲手说话即在场。只有 owner 能批的从不发进群。

在私聊或群里引用那条审批消息回复「同意」或「拒绝 原因」即结,只认本人亲手发的。WebUI 与终端发起的、经别的机器人账号的审批不推到这里。

只经契约组合,不认平台:owner 取权限服务的判定,一对一取适配器的声明,群窗口取会话表记下的多方标记(originChat.multiParty),引用取投递结果里的平台消息 id(schema-message 的 messageIds)。

插件声明 ​

ts
definePlugin({
  name: '@aalis/plugin-chat-approval',
  displayName: '聊天审批',
  subsystem: 'authority',
  uses: {
    approvals,
    events,
    gateway,
    hooks,
    lifecycle,
    logger,
    authority: optional(authority),
    sessionManager: optional(sessionManager),
    platform: optional(platform),
  },
  apply: run,
});
  • 激活时登记一个审批面,登记名 chat,随激活撤回。停用(lifecycle.signal 断开)之后不再发消息、不再等投递回执。消息发不出(网关不在、发送出错、适配器的回执出错)时记一条 warn,不抛。
  • events:监听 inbound:message 记在场、建接入。
  • authority、sessionManager、platform 任一缺席时认不出 owner 的私聊:不接入、不推;sessionManager 缺席时也认不出群窗口。审批照旧只推给 WebUI 与终端。

认亲手发的消息 ​

一条入站消息不带 source、有发言者,就是发言者亲手发的;私聊这一种另要发言者是 owner(权限服务的 isOwner,owner 的 IM 身份要登记在 owners 里)。不带 source 的消息不带 actor(触发器不给没点名的话回填身份),这与权限服务判这一轮为 hand、参与者就是发言者等价。在场、建接入与应答都用这一判定。

接入与在场 ​

owner 的 IM 私聊 ​

接入按(身份,机器人账号)记,身份是这条消息的平台与发言者,机器人账号取消息的 selfId。她经一个账号第一次亲手说话时建,建时定下她与这个账号的私聊:会话表里的根会话,机器人账号(originChat.selfId)就是这个账号,且按 api-session-manager 的 oneToOneWith(不给 isOwner)是她自己的私聊:

  • 根会话的出生平台就是她这个身份的平台;
  • 出生平台的适配器(与会话管理建档时判一对一用的同一个)在 sessionTypes 里明确声明了这个窗口的类型(originChat.type)为一对一。不声明会话类型的平台整个按一对一推断(api-platform 的 sessionTypesOf),这种推断出的一对一不算:那样的平台上群员也会被当成唯一的对端;
  • 对端(originChat.targetId)就是她。实现端上报了机器人自己发的消息、机器人账号又登记成 owner 时,那条消息的发言者是机器人账号,它没有这样的私聊,不建接入,也就不算在场、不能应答。

没有这样的私聊就不建,等她来私聊。WebUI、终端的会话没有出生平台,它们的 owner 身份不建接入。群号恰好是 owner 的 QQ 号的群是多方窗口,不会被当成她的私聊。

接入经 attend(owner, sink, { selfId }) 限定:只覆盖只有 owner 能批的、这一轮的参与者里有她、且所在会话的根会话经这个账号的审批。从 WebUI 往群里或往她的私聊插话发起的,参与者是 WebUI 的身份,不推到这里;参与者审批不推到这里,发在这一轮所在的群里。

她经这个账号亲手说话即对这份接入上报在场,私聊与群里说的都算。

群窗口 ​

群窗口是会话表里聊天窗口记成多方的根会话(originChat.multiParty 为真,建档时按出生平台的适配器的声明记下;不声明会话类型的平台没有群窗口)。接入按(窗口,发言者)记,每个在这个群里亲手说话的人一份,owner 也有;从 WebUI 往群里插话的不是在群里说的(消息的平台不是窗口的出生平台),不建。接入经 attend(who, sink, { window }) 限定,window 是这个窗口的根会话 id(根会话已经定了机器人账号):只覆盖这个窗口发起的(所在会话的根会话就是它,连同它下面的子会话)、不是只有 owner 能批的审批。owner 自己在这个窗口的接入也一样,要她确认的照旧只到她的私聊与 WebUI、终端。

他在这个群里亲手说话即对这份接入上报在场。能不能收到、能不能批由审批服务按发起方给的 canAnswer 判(自己就能做这个动作的人),本插件不判:做不了的人也有接入,只是收不到推送,应答回不能批。

两种共用的 ​

建之前,这条消息的会话还不在会话表里时(她第一次私聊这个账号、群里的第一条消息,会话与这条入站同拍建档),先等会话管理收录这条入站(recordInbound,与网关等的是同一次),第一条消息就能建。

在场:监听 inbound:message,没点名、在触发相位就被吞掉的群聊也算(所以不挂在入站相位上)。之后按审批服务的规则(默认实现:最近一次上报起 10 分钟内,或推给它的审批还没结)。审批服务说一份接入已不在场时(接入句柄的 present())撤掉它,他再亲手说话时重建:接入不随说过话的人一直增多。推给它、还没结的审批照留这份接入(推给它的都没送到、最近又没说话时它已不在场,撤了就收不到这些审批的结算,过期那一行发不出)。撤的时机是有人亲手说话时顺带检查各份接入;本插件不另记时限,审批服务的在场规则怎么定,撤的时机就跟着怎么变。

推送 ​

审批推到一份接入时,经网关往这份接入所在的一处发一条宿主提示(source: 'system'):私聊这一种发到接入建时定下的私聊;群窗口这一种发到这个窗口(根会话),不按审批所在的会话找(子会话里的审批同样发在它的根会话所在的群里)。同一张审批在同一处只发一条:群里几个能批的人都在场时也只发一条。不设 markup,正文按纯文字发,参数里的 <at>、<image> 之类逐字显示。文字进本插件字典,网关按会话的语言渲染。

推到私聊的(这张来自群里派出的子任务「整理资料」;主会话里的没有子任务一行):

需要你批准:exec
来源:群 20001(读书会)
子任务:整理资料
命令:rm -rf build
目录:workspace:/proj
引用这条消息回复「同意」,或「拒绝」加原因;5 分钟内不回按过期。

发在群里的(就发在发起它的这个群里,不写来源):

需要批准:read_file
参数:{"path":"workspace:/notes.md"}
这一轮里有人自己做不了这件事。
自己就能做这件事的人引用这条消息回复「同意」,或「拒绝」加原因;5 分钟内不回按过期。

私聊的每张都有来源一行:审批所在会话的根会话是群还是私聊(按 originChat.multiParty,本插件写的固定字)与窗口 id(originChat.targetId),会话名只作附注,换行与制表符换成空格,与事实一样经 clipForCard 清洗并截断,不能另起一行;只写名字的话,群名由群管理员控制,改成「私聊」之类就能让她以为审批来自自己的会话。会话管理不在、判不出窗口时写「会话」与会话 id。

子会话里的审批另有一行「子任务:」,写审批对象的来源链(api-approval 的 chain)里各级的标题,以「 › 」相接:私聊的在来源一行之后,群里的在工具那一行之后,都在各项事实之前。标题的处理与会话名相同(换行与制表符换成空格、经清洗并截断,子任务名由模型起,不能冒充来源一行或事实,也不能把消息撑长);子任务没取名的是默认名「子任务 <建档时间>」。

事实按审批对象的 facts 逐行列出命令、目录、文件、改动、参数,与 WebUI、终端收到的是同一份逐字的事实,各经 clipForCard 清洗并截断:每项最多 500 字,超出的保留首尾各一半、写明省略了多少字。发在群里的同样不打码,群里的人都看得到。末行的剩余分钟数按审批对象的到期时刻 expiresAt 算,不足一分钟的按一分钟;墙钟已过到期时刻、等待的计时器还没到时(系统休眠、改了时钟)也写一分钟。

消息确定没送达(网关不在、没有适配器接手、投递失败或适配器的回执出错)时,那里的人看不到它:这张还在等就对拿着它的接入报这一张没送到(接入句柄的 undelivered),审批服务只把这一张对它们算没收到,推给它们的别的卡照旧。WebUI 或终端在场时照常在那里等,否则按 30 秒的重连宽限结算,不白等满时限;其间能批的人再亲手说话,接入上报在场,审批服务补推,没送达的这张重发,已经送达过的不再发(按审批 id 认)。已经结了的那张迟到的投递失败不碰在场。结果未知(适配器报未确认,或等满 90 秒没等到回执:发出去了只是没等到平台确认)的可能已经送达,不报没送到、不重发;没有平台消息 id,认不出引用它的回复,过期时也不发那一行。送达、投递结果带了平台消息 id 时,记下这些 id,认引用它的回复。

平台不给消息 id 时审批消息照发,但认不出引用它的回复,随后补一句「这条审批无法在这里应答,请到 WebUI 或终端处理。」

这些消息与别的宿主提示一样记成事件标记进会话历史,不进模型的上下文。

应答 ​

在 inbound:command 相位截下。只认在私聊或群窗口里亲手发的消息:私聊里是她自己(发言者与机器人账号对得上一份私聊接入,消息的会话就是那个私聊),群里是任何在这个群里亲手说话的人。同时满足下面两条才处理,其余照常往下(交给指令解析与这一轮),不回提示:没引用审批消息的(私聊与群里一样,群里不引用的「同意」也是)、引用了但不是应答词的,都是普通消息。

  • 引用了这一处(这个私聊或这个群)的某条审批消息(平台给了它的消息 id 才认得出)。经另一个机器人账号到达同一个群的回复不算:那是另一个群窗口。
  • 正文是应答词:开头对她的 @(适配器写下的 <at self …> 标记:在群里引用她的消息回复时,QQ 客户端会自动在开头插一个)不算正文;再去掉首尾的空白(含全角空格)与常见标点(。!!.~,,??::)后,整条恰为「同意」或「approve」为批准;以「拒绝」或单词「deny」开头为拒绝(「denylist」不算),后面是原因,不论长短,照原样作为说明转给模型(只去掉它与「拒绝」之间的空白、标点和末尾的空白,结尾的问号也在)。英文不分大小写,不收 y、n。「同意吗」「同意不了」「先别批」之类不是应答词。

审批消息 id 与去重都按这个私聊或群窗口的会话 id 记。

处理:

  • 按入站消息 id 去重:同一条重复到达的静默吞掉。
  • 按发言者找他在这一处的接入(还没有就先建),带回审批的内容哈希应答。应答成功不另发,回合本身的回复会说明结果;这张已经结算(含在别处处理过的)回「这张已经处理过。」;其余被拒的回「应答没有生效:」加审批服务给的原因,如群里做不了这件事的人引用它回复时是「应答人不能批这一张」,不写他为什么不能。

处理过的消息一律吞掉:不进指令解析、不进触发判定、不交给 agent,不会被当成插进这一轮的输入。A 结算后紧接推出 B 时,引用 A 的回复只认到 A,回「这张已经处理过」,不会批 B。

照常往下的消息交给 agent。新消息不打断在跑的一轮:在私聊或群里发普通消息(包括没引用审批消息的「同意」、引用了审批消息但不是应答词的)时,这条叠进在等审批的那一轮、等这批工具结束后插进去,或排在它之后,在等的审批不撤销。

结算 ​

只在过期与撤销时、且这张送达过这一处时发一行「exec 的审批已过期。」「exec 的审批已撤销。」(群里几份接入收到同一个结算,也只发一行):过期是等满时限的,与覆盖它的在场都失效、宽限内没有恢复的;撤销是发起它的回合被停下、会话被删或 /clear 清掉的,那一轮不一定有回复。在这里回的、在别处批准或拒绝的不另发。

已知的边角 ​

  • 指令的意图确认(plugin-commands)在等时,会把这个人在这个会话里的下一条消息整条吞掉(y 为确认,其余取消)。两者同时在等、且指令插件的中间件先挂上时,回审批的那条会被当成取消意图确认,审批照旧等着。反过来不会误吞:回意图确认的那条不引用审批消息,不会被当成应答。
  • 审批消息经 IM 平台的服务器转发,命令、目录、参数与改动原样在内,群里发起的也一样;发在群里的参与者审批群里的人都看得到(含别人私聊里文件的存储地址、本机路径)。审批服务不按文字或路径猜密钥,不想让这些经平台的,不装本插件。
  • 群里 10 分钟内没有能批的人亲手说过话时,参与者审批不发进群;owner 的 WebUI 与终端也不在场就按没人在场结算。
  • 她在群里说过话、但没看私聊时,推到私聊的审批等满时限才过期;发在群里、能批的人推出之后离开了的,同样等满时限或有人应答才结。
  • 结果未知的那张其实没送达时,这里的人看不到它,也不补推(补推可能让同一张出现两次):在 WebUI 或终端处理,否则等满时限过期。
  • 记住最近 1000 条审批消息的 id(只记它是哪一张审批的消息)与 1000 条应答过的入站消息 id,更早的认不出。

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布