# 通道契约(Channel contract) [← 全部文档](../README.md) · [English](../../providers/README.md) > 英文为主文档:[providers/README.md](../../providers/README.md)。这一页是它的中文对照。 > 内容与英文版一致;两版不一致时以英文版为准。 `pocket-console` 的核心只负责:两条 answerer seam、升级计时器、待决注册表、决策解码, 以及设置命名空间和浏览器卡片调用的同源路由。 **消息怎么送到手机上、按钮怎么点回来,全部由通道模块负责。** 换通道 = 新增一个模块 + 改 `channel` 配置项,**不需要改核心**。 ## 模块形状 通道模块是一个 ESM 文件,导出: ```js export async function create({ ctx, config, binding, log, messages }) { return channel } ``` | 入参 | 说明 | |---|---| | `ctx` | Host context。需要 `credentials` 时可自行解析凭据。 | | `config` | `channelConfig` 原样透传,**由通道自己解释与校验**(字段集因通道而异)。 | | `messages` | 卡片文案字典的读取函数(`() => ({ … })`),随部署的 `locale` 变化。**所有面向用户的文案都从这里取**,语言格式(数字、分隔符、标点)也归它。 | | `binding` | 接收人持久化:`{ read(): Promise, write(id): Promise, clear(): Promise }`。底层是本插件的凭据记录,跨重启保留。通道若不需要可忽略。 | | `log` | `{ info(message), warn(message, error), debug(message) }`。 | ## channel 对象 | 成员 | 必需 | 说明 | |---|---|---| | `available()` | 否 | 当前能否投递。返回 `false` 时核心**不挂计时器、不升级**,桌面链路保持权威。缺省视为始终可用。 | | `supportsForms` | 是 | 能否渲染表单并回传多选/自由文本。`false` 时核心只升级「全是单选选项」的提问,其余留给桌面。 | | `deliver(view, options?)` | 是 | 投递一条消息,resolve 出不透明句柄(供 `update` 使用)。失败请抛错——核心会记警告并回落到桌面。给了 `options.uuid` 时,它是一条消息的**幂等键**:能承载它的通道必须让**同一个键的重复投递 resolve 到它已经接受的那条消息**,而不是再投一条。见下。 | | `update(handle, view)` | 否 | 用新视图替换已投递的消息。缺失时决策后不回报结果。 | | `subscribe(onAction)` | 是 | 订阅用户操作。返回取消订阅的函数。 | | `close()` | 否 | 释放传输资源。 | ### 上手(enrollment,均为可选) 需要「扫码绑定」这类一次性上手的通道额外实现这三个成员;不需要上手的通道 (例如一个固定的 webhook URL)全部省略即可。 | 成员 | 说明 | |---|---| | `enrollmentState()` | 返回当前状态:`{ state: 'unbound' \| 'starting' \| 'awaiting' \| 'bound' \| 'failed', stage?, recipient?, verifyUrl?, expiresIn?, message?, appId?, connected?, slow?, persisted?, persistError? }`。**不得包含任何密钥**(`appId` 不是密钥,它是应用的名字)。`bound` 只允许在**传输真的能收事件之后**发布,并持续反映连接是否在线——卡片把「连上了」当作事实来显示,含糊的乐观状态正是用户无法判断成败的原因。`starting` 时用 `stage: 'creating' \| 'connecting'` 说明在等什么:等二维码(还在创建应用)还是等长连接握手;`slow` 表示这一等已经异常久。卡片据此给出不同文案——**点击后必须立刻能看到「在处理中」**,否则用户无法区分「卡住了」和「正在做」。 | | `beginEnrollment(mode?)` | 启动上手流程。必须**幂等**:设备授权轮询会比触发它的 HTTP 请求活得更久,进行中的那一轮要共享而不是每次重启。**返回前先发布等待状态**(返回可以是 Promise):二维码要一个网络往返才回来,若等 `connect` 自己发布,触发它的那次点击回的就是「它替换掉的那个状态」,卡片上什么都看不到。已经连着传输时直接返回现状,不要再开第二条连接。 | | `adoptCredentials({ appId, appSecret })` | 可选:用**用户已有的应用凭据**直接绑定。必须先向平台校验这组凭据再建立连接——长连接的握手对错误凭据是**重试而不是报错**,只有这一次校验能让卡片说出原因;被平台拒绝的凭据不要留在凭据库里,平台不可达时则保留。凭据属于打开它的那条连接,所以换应用时要关掉旧连接。 | | `clearEnrollment()` | 撤销绑定与凭据,回到 `unbound`。 | | `resume()` | **只用已存凭据重连**,不启动任何上手流程;没有凭据时保持 `unbound` 并返回当前状态。核心在插件加载时调用它,因为「重启后还要点一次绑定」不是用户该承担的事。 | 核心不会自动启动上手,除非 `webServer` 缺席——那时没有卡片可以询问, 核心会直接调用 `beginEnrollment()` 并把链接打进日志。 **有界面时由用户在设置卡片里点按钮触发。** 加载顺序是 `resume()` → 有界面则等待用户点按钮 → 无界面且仍未连接才 `beginEnrollment()`。 所以一个已经绑定过的部署重启后应当**不打印任何链接、不需要点击**;`resume()` 里 唯一允许的日志是失败时的警告。 ### 投递与幂等键 一张卡片就是一次通知,而**重复到达的通知**正是这个插件要消灭的代价。一张卡变成两条消息的唯一途径,是**平台已经接受、而回答没有回来**:这一侧分不清"发出去了"和"没发出去",于是会重试的卡片就会投出第二条。 `deliver(view, { uuid })` 就是通道用来堵住它的地方。核心给出 `uuid` 时,通道必须让**同一个键的重复投递 resolve 到它已经接受的那条消息**,而不是再投一条。飞书用的是请求自带的 `uuid` 字段,平台保留一小时。没有这种机制的通道可以忽略该选项——核心仍然不会重用一个已经拿到的句柄,剩下它覆盖不到的那一种失败更窄:回答丢了**且**平台无法识别重复。 ## 日志 通道要用宿主给的 `log`(`info` / `warn` / `debug`)说话,**不要**让第三方 SDK 直接往终端打印。 SDK 通常提供 `logger` 与 `loggerLevel`:把它的日志接进 `log.warn` / `log.debug`, 就让**部署自己的日志级别**决定谁能看到什么,而不是替所有部署决定。 飞书通道的具体做法:SDK 的启动横幅、每条连接步骤、事件分发器的就绪行都归到 `debug`, 错误与警告归到 `warn`——正常运行时不刷屏,出问题时照样看得见。 ## 视图(核心 → 通道) ```js { title: string, tone: 'warning' | 'info' | 'success' | 'danger' | 'muted', body: string[], // 每项一个文本块 details?: { // 可选的折叠记录;冻结的卡片上会有 title: string, // 折叠面板上的标签 blocks: string[], // 折叠里的内容,每项一个文本块 }, buttons: [{ payload, label, tone: 'default' | 'primary' | 'danger' }], forms: [{ payload, // 提交时原样回传 fieldId, // 核心为「提交值」起的名字 options?, // {label, value}[];缺省渲染自由文本输入 customFieldId?, // 核心为那次提交里额外那个自由文本起的名字 multiSelect: boolean, submitLabel: string, }], } ``` `details` 是视图里**唯一一个"要被打开"而不是"被读到"**的部分。**通道应当在自己的平台上把它折叠**——飞书渲染为 `collapsible_panel`,默认收起、就地展开——平台不能折叠时就当普通文本渲染。**静默丢弃它的通道会丢掉一次已结束运行留下的记录**,而那是那份记录唯一存在的地方。 **一个视图就是一条消息。** 一个请求可以携带多道题,而核心**一道题一个视图**:答完一题就把同 一条消息改写为下一题——这正是桌面弹窗本来就在走的节奏。核心绝不会把两道题的控件放进同一个视 图:卡片渲染时先出全部文本块、再出全部控件,两道题放在一张卡上就会变成「两段题干 + 两组控件」, 读者无从判断哪个按钮回答的是哪道题。 ## 操作(通道 → 核心) 通道必须把 `payload` **原样**回传: ```js onAction({ payload, values, messageId, sender }) ``` - `payload`:用户按下的按钮所携带的 `payload`。 - `values`:表单提交值,形如 `{ [控件名]: string | string[] }`;非表单按钮时为 `undefined`。 一个表单可以同时声明 `fieldId` 与 `customFieldId`,那时两个值在同一次提交里一起到达—— 多选题的选项与「补充说明」就是这样一起回传的。 - `messageId`:**这次操作来自哪条消息**,核心确实要用它:当一次操作指向的请求已经不存在时, 核心会就地把那条消息改写成「已结束」并去掉全部控件。活动请求只存在内存里,所以进程重启后 手机上会留下没人能应答的卡片——没有这个 id,按下只会弹一个 toast,卡片仍显示可作答。它必须 能标识卡片所在的那条消息,`update` 才够得着。 - `sender`:**操作发起者在该通道上的身份**。核心不用它做授权,因为「谁能操作」是通道自己 的信任模型;飞书通道就是在这里校验它等于绑定接收人。 返回值是 `{ toast, accepted }`,通道可据此给用户即时反馈(飞书里映射为 toast 弹窗)。 **控件名由通道自己起,起了什么就要说清楚。** `values` 里的键是卡片元素实际携带的名字,所以 通道一旦改名,就必须在提交回传时按视图里请求的 `fieldId` / `customFieldId` 附上这份对应关系: ```js // 卡片上第三个表单的提交按钮 payload { rid, q, submit: true, submits: { value: 'form_2_value', custom: 'form_2_custom' } } ``` 核心按这份对应关系去读 `values`:查的是 `form_2_value`,而不是 `value`。改了名却不回传对应 关系的通道,在核心看来就是一次「没有答案」的提交——值到了,然后被丢掉。 通道为什么可能需要改名:**卡片不允许出现两个同名元素**。核心给一个表单的控件起的名字是 `value` 和 `custom`——每个表单都叫这两个名字,因为一个表单只对应一道题——所以通道一旦构建出 一张带有两个表单的卡片,名字就会重复,平台会拒收整张卡片,读者那边表现为**消息根本没到**。 核心不会构建这样的卡片:它一道题一个视图。飞书通道仍然给每个表单的控件加前缀并回传对应关系, 这样它的渲染器对**任何**视图都成立,而不只是对当前核心恰好会构建出来的那些。 ## 安全性要求 核心已经做了这件事:`payload.rid` 一次性随机、结算后立即失效、 选项必须来自该问题自己提供的标签。 **通道的责任是**: - 不要自行解释或改写 `payload`,不要在没有用户交互时伪造 `onAction` 调用; - **校验操作者**:卡片是一张凭证,拿到消息的人都能按。手机上传回的答案会以人类归属进入会话 (`{ kind: 'user' }`),所以「这次点击确实来自绑定接收人」必须由通道自己确认——飞书通道 用回调里的 `operator.open_id` 对照接收人,不符则直接拒绝并且不调用 `onAction`。 - **不要让「能联系到 bot 的人」成为接收人**:飞书通道只在**未绑定的部署**收到**私聊**时绑定, 群消息永不绑定,已绑定的部署也不会被其他账号顶替——见 [ADR 0006](../decisions/0006-binding-is-not-up-for-grabs.md)。传输层有类似「发送者自己指定自己」 路径的通道,要自行承担同一个决定。 ## 通道是一个文件 通道之所以是一个文件,是因为这个契约的计量单位就是那个文件:非飞书传输是 `providers/` 下的 **新增文件**,而不是核心的重写。理由与「什么情况下该改」记在 [ADR 0009](../decisions/0009-a-channel-is-one-file.md)。 ## 现有通道 | 模块 | 传输 | 上手方式 | |---|---|---| | `feishu.js` | 飞书长连接(WebSocket),只需出网 | **扫码一键创建应用**(OAuth 2.0 Device Authorization Grant),凭据与接收人自动落到凭据库 | ### 未来通道的候选 | 通道 | 出网即可 | 能否回传 | 备注 | |---|---|---|---| | Telegram Bot | ✅ `getUpdates` 长轮询 | ✅ 内联键盘 | `deliver` 发消息,`subscribe` 轮询回调 | | ntfy | 需手机能访问服务 | ✅ `X-Actions` 按钮 | 按钮可直连核心自建的小 HTTP 端点 | | 企业微信 / 钉钉 | ✅ 长连接 | ✅ 交互卡片 | 与飞书同构,可直接照 `feishu.js` 改写 | | Bark / Server酱 | ✅ | ❌ 只推不收 | `supportsForms: false`,只能做通知 |