第 7 章 · 02 国际平台与跨平台会话连续性


第 7 章 · 02 国际平台与跨平台会话连续性

本节摘要:本节扫描国际平台矩阵——plugins/platforms/ 下 24 个平台插件(telegram/discord/slack/whatsapp/signal/matrix/email/irc/line/mattermost/teams/google_chat/sms/ntfy/a2a/homeassistant/simplex/raft/buzz/photon 等),逐一辨析三种接入形态:Bot API 长轮询、WebSocket 网关协议、webhook 回调。随后解剖本节主菜——跨平台会话连续性build_session_key 的确定性键设计、channel_directory 的统一身份目录、DeliveryTarget 的跨平台寻址,让用户上午在 Telegram、下午在 Discord,agent 仍知道「这是同一个人、同一件事」。最后走读语音转写链路:_transcribe_and_echo_pending_voice 把语音消息经 STT 变成带引号的文字注入内循环。

内容来源:原项目源码 plugins/platforms/ 各平台插件、gateway/relay/adapter.pygateway/session.pygateway/channel_directory.pygateway/delivery.pygateway/run.py(STT 段 25044-25170)、tools/transcription_tools.py,以及 website/docs/user-guide/messaging/ 各平台文档。

⚠️ 注意:本源码快照(v0.20.5,2026-08)中 telegram/discord/slack 等多数平台已从 gateway/platforms/ 迁到 plugins/platforms/ 插件目录,gateway/platforms/ 里只剩 weixin/signal/qqbot/yuanbao/whatsapp_cloud/bluebubbles/webhook/api_server 等内置件。两处代码都继承同一个 BasePlatformAdapter,机制无差别。

学习目标

阅读完本节,你应当能够:

  1. 按接入形态把国际平台分成三类,并各举两个例子。
  2. 解释 RelayAdapter 如何做到「零平台代码」的通用接入。
  3. 说出跨平台会话连续性的三块拼图:确定性会话键、频道目录、DeliveryTarget 寻址。
  4. 走读语音转写的成功/静音/失败三分支,解释为什么失败提示刻意不提"未配置 STT"。
  5. 理解 DM 会话键在缺 chat_id 时为何必须回退到 participant_id(防跨用户串台)。

一、国际平台矩阵与三种接入形态

plugins/platforms/ 下每个平台一个目录(adapter.py + plugin.yaml),接入形态分三类:

第一类:Bot API 长轮询。Telegram 是典型——适配器循环调用 getUpdates,无需公网地址,家居 NAT 后面也能跑。配套的 telegram_network.py 处理代理解析(run.py 里的 resolve_proxy_url 支持 macOS 系统代理探测与 NO_PROXY 匹配)。

第二类:WebSocket 网关协议。Discord(discord.py 网关)、Slack(Socket Mode)、Matrix(客户端 API 长连接)、Line、Mattermost、Google Chat 都属此类:Hermes 主动外连,事件实时推入,同样不需要公网 webhook。这也是国内钉钉 Stream 模式、企微 AI Bot 网关的同款思路(下一节详述)。

第三类:webhook 回调。Email(IMAP/MSGraph)、SMS、Teams(部分模式)要求 Hermes 暴露 HTTP 端点接收推送,gateway/platforms/webhook.pyapi_server.py 提供通用底座。

值得单独一提的是 gateway/relay/——通用中继适配器。它的设计宣言写在模块头:

relay/adapter.py:1 """RelayAdapter — one generic gateway adapter fronted by the connector. ... 11 There is NO per-platform gateway code: the connector is the only side that knows 12 "this chat_id maps to a Discord channel, send it via the Discord websocket." 13 The gateway sees an ordinary ``MessageEvent`` in and calls ``adapter.send`` out.

连接器在握手时发来 CapabilityDescriptor(最大消息长度、长度单位是字符还是 UTF-16 码元等),RelayAdapter 据此伪装成任意平台。_LEN_FNS 表驱动长度计算——Telegram 用 UTF-16 码元计数(len(text.encode("utf-16-le")) // 2),其他平台数字符。这是「34 平台」之外的可扩展性预留:明日新平台只需写一个连接器,网关零改动。

几个代表平台各自的「绝活」也值得点名。SlackMAX_MESSAGE_LENGTH = 39000(API 上限 40000 留余量)且 splits_long_messages = True——send 自带分片;scope_id 进会话键区分多工作区。Discord:插件目录里的 voice_mixer.py 把任意音频经 ffmpeg 解码成 48kHz/s16le PCM 送进语音频道(run.py:22319 专门处理语音频道里转写用户说话),recovery.py 管断线恢复。WhatsAppwhatsapp_identity.py 处理 JID/LID 别名翻转——桥接层改写标识符形态时,canonical_whatsapp_identifier 保证同一人不裂成两个会话(session.py:1137-1138 的 DM 键与 1170-1174 的群参与者键都过这道规范化)。Signal:独立出 signal_rate_limit.py(发件限速)与 signal_format.py(纯文本格式化——Signal 不渲染 markdown)。Emailmsgraph_webhook.py 走 Microsoft Graph 订阅。Matrix:去中心化联邦网络,客户端保留字命令前缀改 !

二、跨平台会话连续性:三块拼图

用户在 Telegram 问了一半的问题,换到 Discord 想继续——Hermes 的答案是三块拼图。

拼图一:确定性会话键(session.py:1090)。键完全由 SessionSource 字段拼接而成,不含任何随机数或序号,因此同一来源的每条消息永远命中同一会话。DM 的构造尤其讲究:

session.py:1147 # No chat_id — fall back to the sender's own identifier before the 1148 # bare per-platform sink. Without this, every DM from every user that 1149 # arrives without a chat_id ... collapses into one shared 1150 # "<ns>:<platform>:dm" session, and a single cached agent ends up 1151 # serving multiple people's conversations — cross-user history bleed. 1152 # participant_id keeps DMs isolated per user. 1153 dm_participant_id = source.user_id_alt or source.user_id

没有 chat_id 的 DM 一律回退到发送者 ID,宁可错杀不可串台。群聊里 group_sessions_per_user=True(默认)让同一群的不同用户各有会话;线程则反向默认共享。Discord 的 prospective_thread_id(session.py:1188)处理「频道里发首帖、回复自动进线程」的场景:首帖的会话键预绑定到即将创建的线程 ID,后续线程内的消息与首帖命中同一会话——「频道发起、线程续聊」。

拼图二:频道目录(channel_directory.py)。每 5 分钟聚合各平台的可达频道/联系人,落盘 ~/.hermes/channel_directory.json,叠加用户手工维护的 channel_aliases.json 别名。这是 agent 的「通讯录」:send_message 工具按名字找到平台与 chat_id,跨平台身份在这里对齐。

拼图三:DeliveryTarget 跨平台寻址(delivery.py:214)。cron 任务可以声明 deliver=telegramdeliver=discord:123456,路由器把结果投到任意平台——不问任务当初在哪个平台发起。反向的连续性提示则由 build_channel_continuity_note(session.py:1009)提供:Slack/Discord 频道被每日重置开启新会话时,系统会注入一行提示「本频道此前有一个会话 session_id:xxx,如用户提及先前工作,先用 session_search 取回」——确定性提示、零额外 LLM 调用。

身份侧还有一套跨平台通用的配对(pairing)机制gateway/pairing.py):新用户不必再抄 allowlist 里的数字 ID,而是收到一次性配对码、由 bot 主人在 CLI 批准。安全设计逐条列在模块头:

pairing.py:9 Security features (based on OWASP + NIST SP 800-63-4 guidance): 10 - 8-char codes from 32-char unambiguous alphabet (no 0/O/1/I) 11 - Cryptographic randomness via secrets.choice() 12 - 1-hour code expiry 13 - Max 3 pending codes per platform 14 - Rate limiting: 1 request per user per 10 minutes 15 - Lockout after 5 failed approval attempts (1 hour) ... 17 - Codes are never logged to stdout

8 字符无歧义字母表(排除 0/O/1/I)、secrets 密码学随机、1 小时过期、每平台最多 3 个待批码、每用户 10 分钟限 1 次、5 次错批锁定 1 小时、码永不进 stdout——按 OWASP 与 NIST SP 800-63-4 的规范做「加好友」这件小事。数据落 ~/.hermes/pairing/(chmod 0600)。

图:dashboard 平台配置

三、语音转写:让耳朵接进内循环

语音消息的处理在 gateway/run.py:25044_transcribe_and_echo_pending_voice。签名注释写明返回 (enriched_text, successful_transcripts)——前者进 agent,后者回显给用户。核心逻辑三分支:

run.py:25103 result = await asyncio.to_thread( 25104 transcribe_audio, path, None, "gateway", 25105 ) 25106 if not result.get("success"): 25107 fallback = await asyncio.to_thread( 25108 transcribe_audio_local_fallback, path, 25109 ) ... 25117 if result["success"]: 25118 transcript = result["transcript"] ... 25124 if not (transcript or "").strip(): 25125 enriched_parts.append( 25126 "[The user sent a voice message but it came through " ... 25138 enriched_parts.append(f'"{transcript}"')

成功分支:转写文本以引号包裹成一行普通文字前置到消息里——注释特意说明早年的措辞"The user sent a voice message~ Here's what they said"会被 LLM 当成元指令、让它对语音模式发表评论而非回答内容,所以改成朴素引用。静音分支:STT 成功但返回空串(静音、截断、不可闻),注入哨兵说明「不要猜测内容,请用户重发」,防止 agent 对空气回复造成循环(#41603)。失败分支:主 STT 失败先试本地回退(transcribe_audio_local_fallback),仍失败则只留「音频在路径 X」的提示。失败提示的措辞经过刻意打磨:

run.py:25142 # DO NOT mention "no STT provider configured", "setup 25143 # instructions" ... those phrases get persisted in conversation 25144 # history and poison every later turn, so the model keeps 25145 # volunteering STT-setup advice even after transcription starts working.

「没配 STT」这类话一旦进历史就会毒化后续每一轮——模型会不停主动推荐 STT 安装教程。运维诊断走日志,LLM 可见面保持中性。配套的还有 auto-TTS 反向链路(/voice on 按会话开关,_auto_tts_enabled_chats 三集合管理全局默认与局部覆盖),实现「你说语音、它也回语音」的对讲机模式。

顺带辨析两个易混概念(run.py:18558-18559 注释):MessageType.AUDIO音频附件(.mp3/.m4a 文件)——绝不自动转写,媒体路径照常交给 agent 自己决定「听还是处理」;MessageType.VOICE语音消息(Opus/OGG)——总是进 STT 管线。_stt_eligible 判定(run.py:3265)决定哪些附件进入上述流程;语音频道的实时转写(Discord voice)是另一条路——handle_transcribed_voice(run.py:22319)直接消费已转写文本。转写成功后 transcripts 还会回显给用户(successful_transcripts 的用途,run.py:25058 注释)——发送方在聊天里看到自己话被听成了什么,错误能被立刻发现。

💡 循环要点:会话连续性的本质是身份与状态的正交分离——身份(谁、在哪个平台、哪个线程)完全由 SessionSource 派生,状态(transcript、缓存 agent)按确定性键存储。换平台不丢上下文不是靠同步多份会话,而是靠「任何平台的任何入口都能路由回同一份状态」。语音转写则展示了一个普适原则:非文本输入必须在进入内循环前归一化为文本,且归一化的失败信息本身也是 prompt 工程的一部分。

本节要点回顾

  1. 三种接入形态:Bot API 长轮询(Telegram)、WebSocket 网关(Discord/Slack/Matrix)、webhook(Email/SMS/Teams 部分);RelayAdapter 用 CapabilityDescriptor 实现零平台代码的通用中继。
  2. 会话连续性拼图:确定性会话键(DM 缺 chat_id 回退 participant_id 防串台、prospective_thread_id 绑定首帖)、channel_directory 通讯录、DeliveryTarget 跨平台寻址、pairing 配对码准入(OWASP/NIST 规范的 8 字符码 + 限速 + 锁定)。
  3. 连续性提示build_channel_continuity_note 在频道会话重置后注入确定性提示,引导 agent 用 session_search 找回旧会话。
  4. 语音转写三分支:成功→引号文本前置;静音→哨兵「勿猜」;失败→本地回退→中性提示(刻意不提 STT 配置,防历史毒化);VOICE 必转写、AUDIO 不转写;成功转写回显发送方。
  5. auto-TTS:三集合(默认/显式开/显式关)管理按会话的语音回播。
  6. 平台绝活:Slack 39000 分片与 scope_id 多工作区、Discord voice_mixer 进语音频道、WhatsApp JID/LID 规范化、Signal 限速与纯文本化、Email MSGraph 订阅。

下一节回到主场:微信、飞书、钉钉、QQ 机器人、企微、元宝六大国内平台的实战接入——iLink 长轮询、AES 加密 CDN、扫码建应用,以及国民级 IM 里的 agent 该怎么活。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U