本节摘要:本节扫描国际平台矩阵——
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.py、gateway/session.py、gateway/channel_directory.py、gateway/delivery.py、gateway/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,机制无差别。
阅读完本节,你应当能够:
RelayAdapter 如何做到「零平台代码」的通用接入。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.py 与 api_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 平台」之外的可扩展性预留:明日新平台只需写一个连接器,网关零改动。
几个代表平台各自的「绝活」也值得点名。Slack:MAX_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 管断线恢复。WhatsApp:whatsapp_identity.py 处理 JID/LID 别名翻转——桥接层改写标识符形态时,canonical_whatsapp_identifier 保证同一人不裂成两个会话(session.py:1137-1138 的 DM 键与 1170-1174 的群参与者键都过这道规范化)。Signal:独立出 signal_rate_limit.py(发件限速)与 signal_format.py(纯文本格式化——Signal 不渲染 markdown)。Email:msgraph_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=telegram 或 deliver=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)。

语音消息的处理在 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 工程的一部分。
RelayAdapter 用 CapabilityDescriptor 实现零平台代码的通用中继。build_channel_continuity_note 在频道会话重置后注入确定性提示,引导 agent 用 session_search 找回旧会话。下一节回到主场:微信、飞书、钉钉、QQ 机器人、企微、元宝六大国内平台的实战接入——iLink 长轮询、AES 加密 CDN、扫码建应用,以及国民级 IM 里的 agent 该怎么活。