Relay ↔ Connector 协议(v1,实验性版本)
状态: 实验性版本。在至少有两个一级平台(Discord 和 Telegram)对其完成验证之前,该协议可能随时变更而无需经过废弃流程。在实验阶段,协议的演进仅限于增量式修改,且受
contract_version的控制。任何破坏性变更都会同步更新两个代码库。
本文档定义了 Hermes 网关(Python,位于 gateway/relay/ 目录)与 连接器(Node/TypeScript,位于 NousResearch/gateway-gateway 目录)之间的正式接口。连接器实现者首先需要阅读此文件。
网关会运行一个通用的 RelayAdapter,该适配器会向外连接连接器,在握手阶段接收 CapabilityDescriptor,随后通过每轮双向 WebSocket 交换标准化的 MessageEvent(入站消息)及操作指令(出站指令)。网关永远无法知晓自身所对接的具体平台是哪一个;所有与特定平台相关的套接字及身份验证逻辑均由连接器负责处理。
1. 握手流程
- 网关建立传输连接(
connect)。 - 网关调用
handshake()方法,连接器则返回一个CapabilityDescriptor(见第 2 节),其中描述了该适配器实例所支持的平台信息。 - 网关根据该描述符配置适配器的相关参数(如字符限制、长度单位、草稿/编辑/线程/Markdown 支持能力等),并注册入站消息处理函数。
- 此后,连接器开始流式传输入站事件,并接收网关发出的出站操作指令。
contract_version(当前为 1)会包含在描述符中。为保证向前兼容性,网关会忽略未知的描述符字段,并用默认值填充缺失的可选字段。
2. CapabilityDescriptor(握手数据载荷)
这是一个 JSON 对象。其权威定义位于 gateway/relay/descriptor.py 文件中。
| 字段 | 类型 | 是否必填 | 含义 |
|---|---|---|---|
contract_version | int | 是 | 协议版本号(同一版本内仅支持增量式修改)。 |
platform | string | 是 | 平台名称(例如 "discord"、"telegram")。 |
label | string | 是 | 便于人类理解的标签。 |
max_message_length | int | 是 | 字符限制上限;网关会将其暴露为 MAX_MESSAGE_LENGTH。若值为 0,则视为 4096 字符。 |
supports_draft_streaming | bool | 是 | 是否支持原生的草稿流预览功能。 |
supports_edit | bool | 是 | 是否支持基于编辑的流式传输;若为 false,客户端将只能以每段一条消息的方式接收内容。 |
supports_threads | bool | 是 | 是否具备 create_handoff_thread 能力。 |
markdown_dialect | string | 是 | "plain"、"markdown_v2"、"discord" 等(该字段会决定 supports_code_blocks 的行为)。 |
len_unit | string | 是 | "chars"(使用内置长度函数)或 "utf16"(Telegram 使用的 UTF-16 代码单元)。 |
emoji | string | 否 | 显示用的表情符号(默认为 🔌)。 |
platform_hint | string | 否 | 系统提示用的平台标识。 |
pii_safe | bool | 否 | 是否会在会话描述中隐去个人身份信息。 |
大部分字段实际上都是从网关现有的 PlatformEntry 对象中提取而来的;而仅在运行时才需要的字段(如 len_unit、supports_*、markdown_dialect)则来自对应平台适配器的能力方法。
3. 入站消息:MessageEvent 数据结构
连接器会将每个平台的原始消息事件转换为标准的 MessageEvent 对象(定义位于 gateway/platforms/base.py),然后将其发送给网关。入站消息是通过网关的出站方向 /relay WebSocket 传输的(详见下文的传输说明)——连接器会通过网关已建立的套接字,向下推送 inbound 类型的数据帧。网关会利用嵌入在消息中的 SessionSource 对象通过 build_session_key() 方法生成会话密钥;因此,正确填写各类标识符是连接器需要承担的最重要的职责。
入站传输方式(使用 WebSocket 通道,而非 HTTP)
网关会向外连接连接器的 /relay WebSocket,用于执行握手、发送出站操作指令(见第 4 节)以及发送自身的 /stop 停止指令(见第 5 节)。入站消息则通过同一套接字的反向通道传输:连接器会通过网关的出站 WebSocket,向下推送 inbound 类型的数据帧(若需要中断入站传输,则会使用 interrupt_inbound 类型帧,见第 5 节)。网关没有专门的入站 HTTP 接口——托管式的网关无需也无法暴露任何入站端口;所有数据都通过其主动建立的连接进行传输。
多实例路由机制。负责处理某个平台消息的连接器实例(即生成入站事件的实例),通常并非网关建立出站 WebSocket 连接的目标实例。因此,该生成事件的实例会将消息发布到连接器内部的中继总线上(基于 Redis 的发布/订阅机制,位于 src/core/relayBus.ts 文件中的 RelayBus 类)。每个连接器实例都会订阅该总线,并将收到的每条消息路由到对应租户的本地会话中(通过 RelayServer.routeBusMessage 方法实现)。最终,只有持有网关套接字的那个实例才会处理该消息;而那些没有该租户本地会话的实例则不会执行任何操作。因此,跨实例的消息传递实际上只是在同一集群内的 Redis 通信,而非公共 HTTP 调用。
数据帧格式(从连接器发送到网关,通过 WebSocket)如下:
{"type":"inbound", "event": <MessageEvent>, "bufferId"?}{"type":"interrupt_inbound", "session_key", "chat_id"}(用于中断入站传输,见第 5 节){"type":"passthrough_forward", "forward": <PassthroughForward>, "bufferId"?}(用于转发请求,见第 5.1 节)
PassthroughForward 是一种用于转发请求的专用数据格式,适用于二级/三级 webhook 场景(如 Discord 交互、Twilio 请求),其结构为 {platform, botId, method, path, headers: [[k,v],…], bodyB64}。消息体采用 Base64 编码,这样即使使用以换行符分隔的 JSON 格式传输,任意字节也能被完整传递;网关会对其进行 Base64 解码,从而得到连接器发送来的原始字节数据(连接器已在边缘节点验证了服务提供商的签名,并去除了任何共享身份凭证——见第 6 节),因此网关只需处理经过清理、不含令牌的消息体,再通过无需令牌的 follow_up 机制来处理该请求。详情请参见第 3.1 节。
信任机制。 WebSocket 升级过程会使用网关为每个网关配置的专用密钥进行身份验证(见第 6.1 节),因此整个通信通道是端到端可信的——入站数据帧无需单独进行 HMAC 签名(因为经过身份验证的套接字本身就已经具备了旧版 HTTP 接口所需的传输来源证明功能)。而通过中继总线进行的消息传递则发生在连接器的信任域内(与该实例的租约、缓冲区及能力信息存储处于同一安全域)。
该协议的早期版本是通过带签名的HTTP POST请求将入站消息发送到
gatewayEndpoint地址(即HttpGatewayDelivery机制,以及网关端的inbound_receiver组件),并且使用针对每个租户的专用密钥进行 HMAC 签名。但这种方式要求每个网关都必须暴露一个可访问的入站 URL,而托管式的网关由于没有公网 IP,根本无法做到这一点。因此,现在的 WebSocket 通道机制取代了旧方案;虽然为保持向前兼容性,仍保留了针对每个租户的专用密钥,但已不再用于入站消息传输。至于转发平面(即二级/三级 webhook,如 Discord 交互、Twilio 请求),过去仍会使用gatewayEndpoint进行 POST 请求后的响应转发;在第五阶段的第 5.1 节中,这一流程也被改为了通过 WebSocket 实现(即上述的passthrough_forward数据帧),这样一来,托管式的网关就完全不需要暴露任何公网入站端口,而gatewayEndpoint也将在迁移完成后被废弃。
3.1 转发平面的请求转发(见第 5.1 节)
在转发平面中,系统会在连接器的边缘节点处响应服务提供商发出的、对延迟要求极高的 ACK 请求(例如 Discord 在约 3 秒内返回的延迟交互响应),随后直接转发原始请求至网关。由于服务提供商已经收到了响应,因此无需再等待回复,所以该请求会通过与入站消息相同的出站 WebSocket 通道,以 passthrough_forward 数据帧的形式发送,而非通过 HTTP POST。网关会通过常规的代理处理流程来解析该请求(例如,Discord 的交互请求会被解析为 MessageEvent 对象,并像普通消息一样被处理;回复则通过出站的 /follow_up 通道发送)。当请求被缓存时,数据帧中会包含 bufferId 字段(见第五阶段的第 5.3 节关于仅缓存模式的说明),网关会在完成持久化转发后对该请求予以确认。
SessionSource 字段(网络传输层的数据结构)
这些字段的权威定义位于 gateway/session.py 文件中的 SessionSource.to_dict() 方法。它们代表了网关在网络传输层所接收到的所有数据键值对。platform、chat_id、chat_type、user_id、user_name、thread_id、chat_name 和 chat_topic 这些字段始终都会出现(可能值为 null);其余字段则仅在对应值被设置时才会出现在传输数据中。
| 字段 | 类型 | 是否始终发送 | 含义 |
|---|---|---|---|
platform | string | 是 | 平台名称,与描述符中的 platform 字段一致。 |
chat_id | string | 是 | 主要对话 ID(频道/聊天室),同时也是会话密钥的标识字段。 |
chat_type | string | 是 | 对话类型,包括 dm、group、channel、thread、forum 等。 |
chat_name | string|null | 是 | 便于人类理解的聊天室名称。 |
user_id | string|null | 是 | 消息发送者的 ID,同时也是会话密钥的标识字段。 |
user_name | string|null | 是 | 发送者的显示名称。 |
thread_id | string|null | 是 | 当消息位于线程中时,用于标识该线程或论坛主题的 ID,同样可作为会话密钥。 |
chat_topic | string|null | 是 | 频道或聊天室的标题/描述信息(适用于 Discord、Slack 等平台)。 |
user_id_alt | string | 否 | 平台特定的稳定备用 ID,例如 Signal 的 UUID 或飞书中的 union_id。 |
chat_id_alt | string | 否 | 备用聊天 ID,例如 Signal 群组内部的内部 ID。 |
scope_id | string | 否 | 与平台无关的作用域标识字段,用于区分不同的 Discord 社群、Slack 工作空间或 Matrix 服务器。对于实现 Discord/Slack 平台间的会话隔离功能而言,该字段是必需的,同样可作为会话密钥。(该字段的规范名称源自 D-Q2.5 版本的协议格式迁移。) |
guild_id | string | 否 | **旧版别名,连接器已不再读取该字段。**从 D-Q2.5c 版本开始,连接器仅读取和写入 scope_id;不过网关端的 SessionSource.to_dict() 方法仍会输出 guild_id 字段(其值会与 scope_id 保持一致),目的是为了实现非中继会话的持久化存储,因此该字段仍可能出现在传输数据中,但连接器会直接忽略它。请勿依赖该字段。 |
parent_chat_id | string | 否 | 当 chat_id 指向某个线程时,用于标识该线程所在的父频道。 |
message_id | string | 否 | 触发该消息的原始消息 ID,可用于固定消息、回复或回应操作。 |
is_bot字段用于标识消息发送者是否为机器人或属于 webhook 类型,该字段存在于网关端的 dataclass 中,但在 v1 版本的协议格式中刻意未包含在网络传输数据中——它也不在to_dict()方法的输出范围内。在将该字段加入连接器端的SessionSource对象之前,请务必先在网关端添加该字段并将其纳入to_dict()的输出中(属于增量式修改)。
各平台的 SessionSource 标识字段对照表
| 平台 | chat_id | chat_type | user_id | thread_id | scope_id |
|---|---|---|---|---|---|
| Discord | 频道 ID | dm/group/thread | 发送者 ID | 线程所在频道的 ID(仅在线程中存在) | guild_id(用于实现服务器级会话隔离,为必需字段) |
| Telegram | 聊天 ID | dm/group/forum | 发件人 ID | 论坛主题 ID(仅用于论坛) | — |
**如果将 Discord 的 guild_id 设置错误,会导致两个不同的服务器被合并为一个会话。**这是当前最严重的风险之一。网关的 build_session_key() 方法是判断是否符合协议规范的依据:对于给定的 SessionSource 对象,连接器经过标准化处理后生成的密钥,必须与 Python 适配器生成的密钥完全一致。(第一阶段的测试用例会确保输入相同则生成的密钥也相同。)
机器人身份与租户概念的区分(单机器人多租户整合机制,详见附录 A)该封装将发起方机器人身份作为与“租户”字段相互独立的字段进行携带。租户信息是通过事件自身的标识符来确定的(Discord的guild_id、Telegram的chat_id、webhook路径/子域名)——绝不会根据传递该事件的令牌/套接字/进程来确定。这样一来,一个共享机器人便能够在不占用现有字段容量的情况下为多个租户提供服务(第6阶段)。
以作者优先的解析方式 + 账户关联(私信)路径(第7阶段)
第7阶段引入了面向用户的自助式共享机器人入驻流程,这一改动决定了针对路由进来的消息,究竟是哪个标识符用于解析对应的实例——同时还为用户提供了绑定自己账户的管理路径。
以作者优先的解析方式(多租户公会规则,D-7.2)。一个Discord公会可以容纳多个租户——不同的成员各自关联着独立的代理。因此,在消息传递时,连接器会通过已认证的作者绑定信息(user_instance_binding,其键为(tenant, platform, platform_user_id),并通过resolveByUser函数解析)来确定目标实例,而非通过公会→实例的映射关系。具体而言:
- 由已关联用户发送的路由消息只会送达该用户对应的实例——即便同一公会中另有已关联用户,他们也会被不同的实例服务(每个人仅能收到自己的消息)。
- 由未关联用户发送的消息则无法解析出任何实例,会被直接丢弃(即“失败即终止”——绝不会被广播给公会的其他租户)。
- 所使用的作者ID是来自原始事件的真实
user_id,也就是上文提到的SessionSource.user_id——绝非网关自行设定的值,也不是管理帧中携带的数值。
这正是连接器在WsGatewayDelivery模块中实施的、基于每个user_id的仅所有者可访问的路由机制(网关侧的多租户公会端到端驱动程序gateway_multitenant_guild_driver.py则作为跨仓库的参考实现)。
账户关联(私信)路径。用户可通过一次性代码将自身账户与某个实例绑定,该代码需通过向共享机器人发送私信来使用:
- 账户所有者可从门户网站(或自托管的CLI工具)触发绑定流程。连接器会为已认证的实例生成一个短时效的链接码(通过
POST /manage/link接口生成;instanceId来自调用方的身份凭证——即经过NAS签名的、包含aud=agent:{instanceId}字段的令牌,或是该实例自身的网关专用密钥——绝非请求正文中的内容)。 - 用户使用想要绑定的账户,向共享机器人发送包含
/link <code>内容的直接消息。 - 连接器的进站监听器会接收这条私信(它不会被路由到任何代理),并利用原始私信事件中的**真实
user_id**来创建user_instance_binding记录。从那时起,基于作者优先的解析规则,该用户的消息就会被路由到已绑定的实例。
取消绑定的权限由连接器掌控。当某个实例被停用时(通过POST /manage/deprovision接口操作),其作者绑定信息会被删除(这样该实例的用户就不再能通过它来接收消息)——同时,该实例的网关专用密钥也会被撤销(导致其套接字无法再进行身份验证,下一次WS升级请求将会收到4401错误码)。如果网关在之前已成功完成握手后却收到4401错误码,它会将此视为最终性的撤销操作:停止尝试重新连接,并将该中继平台标记为已禁用状态(不属于可重试的错误)。而在任何成功握手之前出现的4401错误则仍属于可重试范畴(属于冷启动或实例尚未被配置完成的情况,而非真正的撤销操作)。
3.2 进入空闲状态 / 缓冲翻转原语(§5.3)
这是一种用于实现“零规模扩展”的原语(并非指实际的行为逻辑——此处并未规定让机器进入睡眠或暂停状态;相关功能将由后续的工作组来实现)。它允许网关在处于空闲状态时仍能接收传入的消息,具体做法是让连接器为该实例创建缓冲区,并在重新连接时重新发送这些消息。
该原语涉及三个数据帧(所有帧的键均为连接过程中已认证的实例专用ID——该ID在WS升级时从存储的密钥记录中读取,绝不会在数据帧中显式指定):
{"type":"going_idle"}(网关 → 连接器)——作为网关当前空闲转换流程的一部分被发送(适配器在断开套接字之前会发送此消息)。该消息要求连接器将此实例切换为仅支持缓冲传输模式。{"type":"going_idle_ack"}(连接器 → 网关)——表示连接器已完成切换:实时传输已停止,此后该实例接收的所有消息都将被持久存储在缓冲区中。网关会持续提供服务,直到收到此确认消息(这样在切换窗口内到达的消息仍能被实时传送,而不会丢失——这与消息总线所遵循的“先订阅再传输”顺序规则一致)。只有在收到确认消息后,才能安全地关闭该实例。{"type":"inbound_ack", "bufferId"}(网关 → 连接器)——表示已成功接收一个被缓冲的传入消息(该消息包含其bufferId字段),连接器会在重新连接时重新发送该消息。连接器只有在收到此确认消息后,才会对对应的缓冲记录予以确认,从而在传输环节实现无重复处理:如果在空闲转换过程中实例意外关闭,它只会重新发送那些尚未被确认的未处理消息;而已被确认的消息则绝不会被再次发送。
缓冲与空闲状态。在切换为仅支持缓冲模式后,连接器会将新接收的消息存储到该实例专用的持久性传输缓冲区中(路径为delivery:<instanceId>),而非立即进行实时传输。当网关重新连接时(即在意外断开后,通过全新的重连循环再次建立连接并完成握手),新的握手请求会促使连接器通过新的套接字,按照顺序、并在收到确认消息后再重新发送缓冲区中的积压消息,之后再清除切换状态,从而恢复实时传输。这一机制与Discord→连接器之间的消息接收流程所使用的“无重复空闲处理”机制类似,只是应用在了连接器→网关的传输环节。整个过程中,所有操作均由连接器掌控:网关只能对自己拥有的实例进行切换/空闲处理。
不在当前设计范围内(属于后续实现的内容):自动决定进入空闲状态的定时器、实际的机器暂停功能,以及NAS实现的健康状态监控模型。该原语仅规定了“当网关进入空闲状态时,中继层应切换为缓冲模式,并在重新连接时重新发送消息,且不会导致消息丢失或重复”;而究竟是什么触发了空闲状态,则不在当前设计范围内。
3.3 唤醒操作(§5.2)
这是睡眠/唤醒循环中的另一部分:用于让处于暂停状态的网关知晓自己有缓冲的消息需要处理。这也是一种原语——此处并未规定让机器进入暂停状态;它只是定义了唤醒信号,以便未来的零规模扩展功能层能够基于“缓冲消息 ⇒ 触发唤醒”这一逻辑来工作。
- 注册流程。网关在注册/配置阶段会指定一个唤醒URL——即连接器可以通过GET请求访问的任何可访问地址,用于唤醒该网关(可以是Fly平台的自动启动主机地址,也可以是控制面板的主机地址)。对于自托管场景,可使用命令
hermes gateway enroll --wake-url <url>进行设置(或通过环境变量GATEWAY_RELAY_WAKE_URL/gateway.relay_wake_url指定)。对于托管型/NAS部署的场景,该地址会与GATEWAY_RELAY_URL一起被写入容器环境变量中。在/relay/provision请求的请求体中,该地址会以wakeUrl的格式被传递,并由连接器将其存储在每个实例的密钥记录中(该地址由网关指定,但具有严格的访问限制——其安全级别与instanceId相同;由于租户/组织身份仍需通过令牌验证,因此网关只能为自己拥有的实例指定唤醒目标)。该地址与已废弃的gatewayEndpoint不同:它是一个唤醒目标,而非消息传输目标。 - 唤醒操作本身。当一个处于仅支持缓冲模式(即进入空闲状态)的目标实例收到其第一个被缓冲的消息时,连接器会直接向该实例注册的
wakeUrl发送一个不带有效载荷、且未签名的GET请求——这一操作不通过NAS中间件完成,因此中继层依然保持与NAS解耦的状态。该请求不包含任何租户相关数据,也不携带任何传入消息内容,仅表示“你有缓冲的消息,請重新连接”。当网关再次尝试建立连接时(即通过已认证的WS升级流程),租户身份信息会以常规方式重新验证,因此,即使有唤醒URL被泄露或被猜测出来,最坏的情况也只会导致该网关尝试重新连接其自己的实例。该唤醒操作的频率是针对每个实例进行限流的(在每个冷却周期内仅允许一次唤醒尝试,而非每次收到消息都尝试),且属于尽力而为的机制——如果唤醒请求失败,也会被忽略;网关仍会在下次尝试重新连接时自行处理消息。该操作不会生成新的数据帧:唤醒操作是通过独立的HTTP GET请求实现的,而非通过中继WS消息——因为此时套接字处于关闭状态,这正是采用此方式的初衷。
不在当前设计范围内(属于后续实现的内容):实际的机器暂停功能(Fly平台中的
autostop:"suspend"指令),以及自动决定让机器进入睡眠状态的定时器。该原语仅规定了“对于处于睡眠状态的实例,一旦有缓冲消息到达,就会向其唤醒URL发送请求”;而究竟是什么导致实例进入睡眠状态(以及何时被唤醒开始工作),则属于后续功能层需要处理的内容。
3.4 对未来零规模扩展功能层的要求
§3.2和§3.3部分提供了相关的原语;本节则规定了另一个独立的零规模扩展功能工作组在安全地使用这些原语时必须遵守的契约。该工作组负责做出是否让机器进入暂停状态的决策,执行实际的机器暂停操作,以及设计平台相关的健康状态监控模型——这些内容并未包含在上述原语中——但它必须提供这些保障,因为这些原语正是基于这些保障而设计的。1. 在实例被暂停之前必须先注册 wakeUrl。 若已暂停的实例未注册 wakeUrl,则相当于一个“黑洞”——进入缓冲区的入站消息将永远无法触发唤醒操作,该实例会一直处于休眠状态,直到有其他进程重新连接它。行为层必须确保在允许暂停之前,已注册一个可访问的唤醒目标(自托管场景下使用 --wake-url 参数;托管场景则由系统自动设置),否则暂停操作将无法执行。当机器处于暂停状态时,若该唤醒 URL 无法访问(例如指向了本身处于暂停状态的机器,且没有平台自动启动机制),则等同于未注册任何唤醒目标。
-
在关闭套接字或暂停实例之前,必须先通过
going_idle过程并等待going_idle_ack回执。 绝不可在正在处理的请求尚未收到确认回执的情况下就暂停实例。该确认回执是连接器发出的信号,表明该实例的当前消息仅处于缓冲状态;如果在发送going_idle请求后但在收到确认回执之前就暂停实例,那些比请求更早到达的入站消息将会丢失。网关已根据确认回执来控制套接字的关闭操作(参见 Q-5.3c 规定);因此暂停操作必须在该缓冲清理过程完全完成后才能执行,绝不能与之并行。 -
在允许暂停实例之前,必须确保 NET-NEW 重连循环处于激活状态。 唤醒与数据清除之间的流程为:“发送唤醒请求 → 网关重新拨号 → 连接器在重连握手过程中完成数据清除”。如果该重连循环被禁用,那么唤醒请求只会发送到那些不会重新拨号的机器上,从而导致缓冲区中的消息无法被清除。行为层绝不能对那些在唤醒后无法重新建立连接的实例进行暂停处理。
-
在健康状态模型中,应将“暂停状态”视为与“故障状态”不同的正常状态(参见 Q-5.3b)。 处于暂停状态的实例只是处于正常休眠状态,并非出现故障。健康监控层必须能够区分这两种状态(例如通过平台提供的机器状态信息),从而避免对暂停状态的实例进行重启、触发警报或判定为异常状态——否则就会违背暂停机制的初衷,还可能干扰后续的唤醒与数据清除流程。
-
唤醒请求属于尽力而为型操作,并且会受到速率限制——切勿期望它能实现一次性或即时的唤醒效果。 每个实例在每个冷却周期内最多只能发送一次唤醒请求,且失败的唤醒请求会被忽略。行为层绝不能将唤醒请求视为一种可靠或即时的信号;系统的正确性依然取决于“网关在下次重新连接时就会进行数据清除”这一原则。如果需要更可靠的唤醒机制(例如结合定时任务与自动重连功能),那应由行为层自行实现,而非依赖底层原始功能。
-
仅当实例真正处于空闲状态时才可暂停——而且这种空闲状态必须是连接器能够检测到的,而非网关凭猜测判断的。 何为“空闲状态”(即没有正在处理的请求,且连续 N 分钟没有新的入站消息)是由行为层根据自身策略来定义的,但该定义必须与现有的数据清除机制相协调(例如当
gateway_state显示为运行中时才进行数据清除),而不能引入一个独立的、仅针对中继连接的空闲检测路径——这与 §3.2 节中对going_idle功能所规定的集成约束是一致的。
以上这些都是行为层对底层原始功能所应提供的保障;而底层原始功能仅需满足 §3.2/§3.3 节中已明确规定的要求(即在进入空闲状态时触发相应操作、为每个实例提供持久化的缓冲区并配合确认回执实现有序的数据清除,以及针对处于空闲状态的实例在首次出现缓冲消息时立即发送唤醒请求)即可。
4. 出站操作:操作集
网关通过操作字典来调用传输层功能。相关实现代码位于:
gateway/relay/transport.py 和 gateway/relay/adapter.py。
op | 字段 | 返回结果 |
|---|---|---|
send | chat_id, content, reply_to?, metadata? | {success: bool, message_id?, error?} |
edit | chat_id, message_id, content, metadata? | {success: bool, error?} |
typing | chat_id | {success: bool} |
follow_up | session_key, kind, content, metadata? | {success: bool, message_id?, error?} |
get_chat_info(chat_id) 是一个独立的代理调用,至少会返回 {name, type} 信息。媒体相关操作也采用相同的结构格式(具体细节将在后续的协议版本中进一步定义,目前为可扩展设计)。
follow_up(A2 能力操作)。 某些入站消息会携带用于操作共享机器人身份的凭证(例如 Discord 的交互续传令牌)。根据 §6 规定,连接器会在边缘节点处提取这些凭证,并将其存储在以会话为键的能力存储库中;这些凭证绝不会传递到网关。若要使用这些凭证,网关会发起 follow_up 操作,需指定其当前所处的会话键(session_key)以及相应的能力类型 kind(例如 discord.interaction_token)——绝不能直接传递令牌本身。连接器会从其存储库中获取真实的凭证值,检查该凭证是否属于当前租户(租户 B 绝不能使用租户 A 的凭证),之后再将处理结果发送出去。如果该能力不存在、已过期或租户不匹配,返回的 success 值将为 false——按照设计,网关无需再尝试重新发送请求(因为泄露的网关根本不持有任何能力相关数据)。相关实现代码位于:
gateway/relay/transport.py(send_follow_up 函数)和 gateway/relay/adapter.py。
5. 中断操作(/stop 路由)
- 网关 → 连接器: 网关会通过出站 WebSocket 发送
send_interrupt(session_key, reason?)请求,以在当前处理请求的过程中插入一个/stop指令。根据路由规则,连接器必须将该指令转发给正在处理该session_key的网关实例。 - 连接器 → 网关: 针对某个
session_key的中断请求会以interrupt_inbound数据帧的形式通过网关的出站 WebSocket 传输过来(参见 §3 节关于传输方式的说明)——该请求会通过中继总线在各个实例之间路由,最终传递给持有对应套接字的实例——随后连接器会通过其on_interrupt(session_key, chat_id)方法将该中断请求接入现有的会话级中断处理机制,从而取消当前正在处理的请求(其他并行处理的请求则不受影响)。
两种方向的通信均通过网关的出站 WebSocket 进行:网关向连接器发送 /stop 指令时也使用该通道,而连接器向网关发送中断请求时则作为标准化的事件通过同一条入站回传通道传输。
6. 信任边界与签名数据体的处理(A2 版本)
连接器是唯一的加密与身份验证边界。网关不会对任何内容进行重新验证。
Webhook 签名机制(如 Discord 的 ed25519、Twilio 的 HMAC、WeCom 的 BizMsgCrypt)都是基于原始字节数据进行计算的,而且某些消息内容还会使用共享密钥进行加密处理。由于连接器需要为多个租户提供共享的机器人服务,并且必须存储每个租户的平台相关密钥,因此它需要:
- 在边缘节点处对数据进行验证或解密(因为这些密钥仅存储在边缘节点);
- 将处理后的数据标准化为针对特定租户的
MessageEvent格式(参见 §3 节); - 从消息中移除所有与共享身份相关的凭证信息,并将其存储在以会话为键的能力存储库中(具体实现方式参见 §4 节关于
follow_up的说明); - 仅转发经过清洗后的
MessageEvent数据——绝不会原封不动地传递带有签名的原始数据体。
因此,网关在传输过程中不会对任何平台相关的签名或加密内容进行验证;它完全信任经过标准化的事件数据。这是网关侧所遵循的强制性规则(相关测试代码位于 tests/gateway/relay/test_relay_sheds_crypto.py,该测试验证了中继模块并未导入或调用任何平台级的加密功能)。
为何不采用“逐字节转发签名后的数据体,让网关重新进行验证”的方式? 因为在不可信、可被轻易替换的租户级网关环境下,这种方案存在诸多问题:
- 若要对 Twilio 的 HMAC 或 WeCom 的加密内容进行重新验证,就必须将共享的签名密钥交给网关——而这本身就意味着安全漏洞;而在共享机器人场景下,这种漏洞甚至会导致跨租户数据泄露。
- WeCom 的消息内容是使用共享密钥进行加密的;连接器为了能够路由这些消息,必须在边缘节点处先进行解密,因此如果继续转发加密后的数据体,同样需要将密钥交给网关。
- Discord 的交互令牌是存储在已签名 JSON 数据体内部的——你无法同时保留原始字节格式并移除其中的凭证信息,因为它们实际上是同一组字节。
因此,刻意放弃了字节级保留的做法:连接器会对清洗后的事件数据重新进行序列化处理,而网关则信任这些重新序列化后的数据。这种方式也实现了透传模式与中继模式的一体化——两种模式都遵循“在边缘节点进行验证,然后生成标准化事件”的原则,仅在传输方式上有所区别。关于 A2 版本的完整设计思路以及连接器侧的能力存储机制,可参考文档 docs/capability-trust-boundary.md(连接器代码库位于 gateway-gateway 目录中)。
6.1 频道身份认证(连接器与网关之间的连接链路)
在 A2 版本中,由于网关可能是由客户自行管理且直接暴露在公网上的,因此连接器成为唯一持有平台相关密钥的实体,所以连接器与网关之间的连接链路本身也需要进行身份认证。网关会持有由注册或配置流程生成的针对每个网关的专用密钥(可通过 hermes gateway enroll 命令让连接器执行 /relay/enroll 操作,或通过托管式自服务方式执行 /relay/provision 操作),该密钥用于验证其发起的出站 WebSocket 升级请求。该认证机制采用 HMAC-SHA256 算法,并配有多密钥轮换验证机制(网关侧实现代码位于 gateway/relay/auth.py,连接器侧实现代码位于 src/core/relayAuthToken.ts)。
| 通信方向 | 用于认证的凭证 | 认证机制 |
|---|---|---|
| 网关 → 连接器:WebSocket 升级请求 | 针对每个网关的专用密钥 | 在 /relay 升级请求中添加一个 Authorization 类型的 bearer 头部。该头部包含的令牌格式为 base64url(payload:exp:sig),其中 payload 的值为网关标识符 gatewayId,sig 的值为使用该密钥对 payload 及其过期时间进行 HMAC 加密后的结果。连接器会验证该令牌,若发现令牌格式错误、缺失或已被撤销,则会拒绝升级请求,并返回 4401 错误码。经过认证的租户信息来自连接器的内部存储,而非初始的 hello 请求帧中包含的信息。 |
连接器 → 网关:入站请求(inbound / interrupt_inbound 数据帧) | ——(通过已认证的 WebSocket 通道传输) | 入站请求会直接通过网关已经过身份验证的出站套接字进行传输(参见 §3 节),因此无需为每条消息单独添加签名。虽然在注册或配置阶段仍会为每个租户生成一个专用传输密钥,并保留以保持向后兼容性,但该密钥已不再用于对入站请求进行签名。 |
这就是所谓的通道级认证机制——它与平台级的加密机制是相互独立的,因为中继路径仍然会完全丢弃平台级的加密内容(参见 §6 节)。网关本身不持有任何平台相关的密钥;只有针对每个网关的专用密钥用于验证连接器与网关之间的连接链路。关于完整的威胁模型以及注册、密钥轮换和紧急关闭机制的设计细节,可参考文档 docs/connector-gateway-auth-design.md(连接器代码库所在目录为 gateway-gateway)。
7. 每个实例的消息投递与管理平面(第 6 阶段)
在之前的第 1–5 阶段中,连接器被视作单租户服务接口:某个租户的入站消息会被直接发送到该租户对应的网关套接字。第 6 阶段则实现了按实例进行消息投递——一个共享机器人可以在同一个租户下为多个用户或代理提供服务(例如同一个 Discord 社群或 Telegram 机器人),而不会导致消息在不同用户之间交叉投递——同时该阶段还引入了一个小型管理平面,供代理程序或托管型门户工具用来定义谁可以查看哪些内容以及哪些内容具有相关性。所有这些功能都位于连接器端;网关新增的唯一职责就是在启动时声明自身的相关内容处理策略(参见 §7.3 节)。
7.1 投递控制机制(位于连接器端,仅用于信息传递)
对于每条入站消息,连接器会通过组合三个“与”逻辑运算的过滤条件来确定哪些实例应该接收该消息。这些过滤条件的实现不在网关端,而是在连接器端完成,但它们决定了网关在处理消息时所依赖的投递规则。| 层级 | 问题 | 真实数据来源 |
| — | — | — |
| 所有者/范围 ∧ 主体 | 该实例是否可以查看此作者的消息? | 每用户的 user_id → instance 绑定关系(即所有者层级)+ 每个实例的 (guild, channel) 范围授权 + owner-only/allow-list/any 主体策略。 |
| 可见性层级 | 该实例的所有者是否真的能在 Discord 中VIEW_CHANNEL查看此消息? | 实时的 Discord ACL(生效权限),采用“失败即停止”机制。用于缩小过宽的范围授权范围。 |
| 相关性 | 既然该实例可以查看消息,那么代理是否应该响应? | 第 7.3 节中定义的相关性策略(地址过滤/自由回复/允许机器人参与)。 |
该组合逻辑仅会缩小消息的传递条件(deliver ⇔ authorized ∧ visible ∧ relevant);所有者层级可绕过相关性层级(作者自己的消息始终能送达其所在实例——因为你不会@提及自己的代理)。未被授权的用户发送的消息则无法送达任何实例(直接失败)。完整的设计方案及不变量均保存在连接器仓库中(NousResearch/gateway-gateway),本节仅为面向网关的概要说明。
7.2 管理接口(连接器端,需身份验证)
连接器会挂载经过身份验证的管理接口。这些接口与 WebSocket 升级流程采用相同的双重认证机制:要么是经过管理的 NAS 签名的、包含 aud=agent:{instanceId} 的 RS256 JWT,要么是网关自身为每个网关生成的密钥令牌(参见第 6.1 节的 make_upgrade_token)。在两种情况下,连接器都会从其存储的记录中获取权威的 {tenant, instanceId} 值——绝不会从请求体中获取(请求体中指定的 instanceId 会被忽略)。
| 接口 | 用途 |
|---|---|
POST /manage/link | 生成一个短期有效的代码,用于将平台账户绑定到已通过身份验证的实例(即 /link <code> 流程;连接器会从传入的事件中读取已认证的 user_id)。 |
POST /manage/scope, /manage/scope/release | 为已通过身份验证的实例申请/释放 (guild, channel) 范围权限。一个频道最多只能被一个实例拥有(非重叠性是主键约束)。 |
POST /manage/principal | 设置该实例的主体策略(owner-only | allow-list | any)。 |
POST /manage/dm-default | 设置用户的默认私信实例(当用户绑定多个实例时的私信判定规则)。 |
POST /relay/policy | 声明该实例的相关性策略(参见第 7.3 节)。 |
这些接口由连接器管理(管理平面并不属于网关代理的处理路径);网关仅会调用 POST /relay/policy 接口(参见第 7.3 节)。其余接口则由托管的 Portal 或 hermes CLI 来驱动。
7.3 相关性策略声明(网关的责任)
相关性层级(参见第 7.1 节)是网关自身行为参数(require_mention、free_response_channels、{PLATFORM}_ALLOW_BOTS)在每个租户层面的统一体现。因此,用于消息转发的规则也与这些参数一致,网关会将这些参数转化为与具体平台无关的策略,并在启动时(在获取到对应网关的密钥后)将其通过 POST /relay/policy 接口发送出去。
代码实现位于(gateway/relay/__init__.py 中的 relay_relevance_policy() 函数,进而调用 send_relay_policy() 函数):
| 字段 | 类型 | 来源 | 含义 |
|---|---|---|---|
platform | 字符串 | relay_platform_identity | 该策略适用的平台。 |
requireAddress | 布尔值 | require_mention | 非所有者发送的消息必须@提及或回复该机器人,才被视为相关消息。 |
freeResponseScopes | 字符串数组 | free_response_channels | 可免除 requireAddress 要求的频道ID列表。其词汇与第 7.1 节中的范围授权定义相同。 |
allowOtherBots | 布尔值 | {PLATFORM}_ALLOW_BOTS ∈ {mentions, all} | 是否允许机器人发送的消息通过(默认为关闭)。 |
认证使用的是每个网关的升级令牌(参见第 6.1 节),因此连接器会将该策略绑定到已通过身份验证的实例上。网关是真实数据来源,并且会在每次启动时重新声明该策略(实现完全替换,类似于配置阶段对 routeKeys 的更新——具备自修复能力)。当生成的策略为全部默认值时,网关不会发送任何内容(因为连接器已有的默认规则与之一致)。该 POST 请求采用软失败机制:出现故障时会记录日志,然后继续启动流程——相关性策略只是叠加在授权机制之上的优化层(参见第 7.1 节),并非启动的必要条件。该机制不会新增网关的入站接口,也不会需要新的凭证——它直接复用每个网关的密钥以及与 /relay/provision 相同的服务器。
相关性过滤会在连接器唤醒已缩减到零个实例的代理之前(即第 5 阶段)完成,因此被过滤掉的聊天内容不会导致代理启动——相关性策略既是实现实例数量缩减的主要手段,也是一种正确性过滤机制。
8. 版本控制策略
contract_version是一个整数;仅在实验阶段出现增量式变更时才会升级版本(例如新增可选字段、新增操作类型)。- 若出现破坏性变更(如字段重命名/删除、语义改变),则需同时协调更新两个仓库,并相应提升版本号。
- 连接器的第一个 Pull Request 应注明其所依据的文件的提交 SHA 值。