钉钉、飞书、企微三家 IM 的 API 风格天差地别,但站在「AI 分身」视角,它们要做的事高度一致:收消息、发消息、标已读、拉历史。把这份一致性抽出来,差异留在适配器内部,是多平台支持能成立的前提。
上下文隔离是第一个坑
每个账号一个会话数据库,靠 ContextVar 在请求链路里传递。问题在于:守护线程不会自动继承父线程的 ContextVar,导致跨线程取到的 DB 是错的。修复方式是显式把 context 在 spawn 时 copy 进子线程。
平台门控不能挂在单例路由上
ToolRouter 是三平台共享的单实例。如果在 router 上判断「这个工具属于哪个平台」,必然错判。正确的归属判断必须挂在 orchestrator / agent 上,因为那是 per-account 的上下文。
三个最反直觉的边界 case
- 自身消息识别:我们存的 AI 回复 UUID,被平台抓回时变成了 openMessageId,永不相等。只能退回到内容兜底——归一化后比对文本。
- 媒体消息:钉钉视频/语音的 msgType 经常为空,content 里却藏着
[视频消息](mediaId=...)。统一用_detect_media_kind()探测,宁可不 OCR 也绝不误 OCR。 - 去重防抖:内容相同且时间差 ≤ 2s 才算同一物理消息;时间戳缺失、时区混用一律保守判重复。只收紧不放松。
异常归属规则
根异常类在 src/exceptions.py,具体子类由各族 owner 模块单一持有(LLM 异常归 llm/、IM 异常归 im_adapter/)。在本模块另立同名具体类是历史事故源头,现在已在代码评审里卡死。
这类抽象没有银弹,真正稳健靠的是把每条平台差异都变成一桩可复盘的工程事件。