主题
04 - 渠道对接:企业 IM 统一聚合
一句话:全企业共用一个 IM 应用,聚合器承担验签、event_id 去重、sender 到 user_id 的绑定查找与 chat 到 session 的映射,把三种平台协议压成一次 console 入站;凭证与审计因此集中在一处,代价是绑定表成了路由正确性的唯一依据,同步滞后就会把消息交给别人的分身,而同一会话必须串行投递才保得住上下文。
一、核心设计决策
问题:全企业员工在飞书/企微/钉钉里 @ "我的数字分身",消息如何统一收口?
结论:渠道聚合在平台层——一个企业 IM 应用(机器人)接收全部员工消息,正确区分"这是发给谁的",路由到对应员工实例。实例只保留 console 入站 API,不直接对接任何 IM。
二、为什么实例不留原生 IM 通道
QwenPaw 原生支持 18 种 channel(feishu/wecom/dingtalk/...),每种 channel 走长连接 + 应用级凭证。如果每个实例都配置独立的 IM 凭证:
| 问题 | 影响 |
|---|---|
| 凭证爆炸 | 1000 员工 × 3 IM 平台 = 3000 套凭证管理 |
| 回调地址不统一 | 每个实例需要独立公网回调 URL,NAT 穿透复杂 |
| 消息无处聚合 | 各实例独立收消息,无法统一审计和管控 |
| 资源浪费 | 每个实例维护 3 个长连接,空闲时占用连接池 |
收敛方案:实例设置 QWENPAW_ENABLED_CHANNELS=console,仅保留 console 入站。所有 IM 消息由平台级渠道聚合器统一处理后,转为 POST /api/console/chat 调用。
三、身份映射:IM sender → 企业 user_id
3.1 绑定表结构
sql
CREATE TABLE im_user_binding (
id BIGSERIAL PRIMARY KEY,
platform VARCHAR(20) NOT NULL, -- feishu / wecom / dingtalk
platform_user_id VARCHAR(128) NOT NULL, -- 飞书 open_id / 企微 userid / 钉钉 userId
user_id VARCHAR(64) NOT NULL, -- 企业员工号(数字分身 owner)
bind_status VARCHAR(20) DEFAULT 'bound', -- unbound/binding/bound/expired
source VARCHAR(20) DEFAULT 'sync', -- sync(组织同步) / manual(引导绑定)
bound_at TIMESTAMPTZ,
unbound_at TIMESTAMPTZ,
metadata JSONB DEFAULT '{}',
UNIQUE(platform, platform_user_id)
);
CREATE INDEX idx_binding_user ON im_user_binding(user_id);
CREATE INDEX idx_binding_status ON im_user_binding(bind_status);一个员工可在多个 IM 平台绑定(同一 user_id 对应多个 platform_user_id),同一平台内 platform_user_id 唯一。
3.2 两条绑定路径
主路径:组织架构同步
兜底路径:引导绑定
3.3 绑定缓存
四、消息协议归一化
4.1 三平台消息格式
飞书(事件订阅 v2):
json
{
"schema": "2.0",
"header": {
"event_id": "evt_xxx",
"event_type": "im.message.receive_v1",
"token": "verify-token"
},
"event": {
"sender": {
"sender_id": {"open_id": "ou_xxx"},
"sender_type": "user"
},
"message": {
"chat_id": "oc_xxx",
"msg_type": "text",
"content": "{\"text\":\"你好\"}"
}
}
}企微(回调 URL,AES-CBC 加密):
json
{
"ToUserName": "corpid",
"FromUserName": "ZhangSan",
"MsgType": "text",
"Content": "你好",
"MsgId": "msg_xxx",
"CreateTime": 1698000000
}钉钉(Stream 模式,长连接):
json
{
"senderStaffId": "staff_xxx",
"conversationId": "cid_xxx",
"msgtype": "text",
"text": {"content": "你好"}
}4.2 统一 ChannelMessage 格式
python
@dataclass
class ChannelMessage:
platform: str # "feishu" | "wecom" | "dingtalk"
event_id: str # 去重幂等键(MsgId/event_id)
sender: str # 平台用户标识(open_id/userid/userId)
chat_id: str # 单聊/群会话标识
msg_type: str # "text" | "image" | "file" | "interactive"
content: str # 解析后的消息内容
raw_payload: dict # 原始消息体(用于特殊处理)
verified: bool # 是否已通过平台验签
received_at: datetime # 接收时间
# 归一化后填充
user_id: str = "" # 绑定表查到的企业员工号
session_id: str = "" # 会话映射结果4.3 适配器接口
python
class IMAdapter(ABC):
"""IM 平台适配器基类,每个平台实现一个"""
@abstractmethod
def verify(self, request: Request) -> bool:
"""验证请求来源(平台签名校验)"""
@abstractmethod
def parse_message(self, request: Request) -> ChannelMessage:
"""解析平台消息为统一格式"""
@abstractmethod
async def send_text(self, chat_id: str, text: str):
"""发送文本消息"""
@abstractmethod
async def send_image(self, chat_id: str, image_url: str):
"""发送图片消息"""
@abstractmethod
async def send_file(self, chat_id: str, file_url: str, filename: str):
"""发送文件消息"""
@abstractmethod
async def send_card(self, chat_id: str, card_template: dict):
"""发送交互卡片(审批/确认)"""
@abstractmethod
def handle_callback(self, request: Request) -> dict:
"""处理卡片回调(审批点击等)"""新增 IM 平台 = 实现一个适配器。一次实现,全企业员工可用。
五、会话映射
5.1 映射策略
| 模式 | session_key | 说明 | 默认 |
|---|---|---|---|
| 统一模式 | user_id 名下默认会话 | IM 单聊与 Web 同会话,上下文连续 | ✅ |
| 群聊模式 | (platform, chat_id) | 群内独立会话 | 按需 |
| 端隔离模式 | (platform, user_id, device) | IM/Web 各一,互不串扰 | 可选 |
5.2 统一模式(默认)
5.3 群聊模式
六、消息路由与回发时序
七、IM 交互卡片(AI-UI)
7.1 卡片类型
| 交互形态 | 场景 | 载体 |
|---|---|---|
| 按钮卡片(批准/拒绝/重试) | HITL 审批、委托授权 | 飞书交互卡片 / 企微模板卡片 / 钉钉卡片 |
| 多选/填空卡片 | 工具调用参数确认、技能启用确认 | 同上(表单回调) |
| 富文本 + 图片/文件消息 | 产物交付、报告预览 | IM 文件/图片消息 |
| 对话式确认 | "是否继续?/ 换方案?"多轮收敛 | 机器人提问,员工自然语言回复 |
7.2 审批卡片协议
json
{
"card_type": "approval",
"request_id": "req_xyz789",
"title": "需要你的授权",
"content": {
"action": "删除文件",
"target": "/data/old_reports/",
"risk_level": "high",
"instance_id": "inst_abc123"
},
"actions": [
{"id": "approve", "label": "批准", "style": "primary"},
{"id": "reject", "label": "拒绝", "style": "danger"},
{"id": "comment", "label": "附备注", "type": "input"}
],
"callback_url": "https://agent.example.com/api/channel/callback",
"timeout_seconds": 3600
}7.3 卡片回调闭环
八、产物经 IM 通道回传
8.1 回传流程
8.2 产物类型与平台适配
| 产物类型 | IM 回传形态 | 平台 API |
|---|---|---|
| 图片/截图 | image 消息(可预览) | 飞书 im/v1/images、企微 media、钉钉 uploadMedia |
| 文档/报告 | file 消息(点击下载) | 飞书 im/v1/files、企微 media、钉钉 uploadMedia |
| 压缩包/大文件 | 对象存储链接 + 卡片 | MinIO presigned URL + IM 文本/卡片消息 |
| 审批/确认 | 交互卡片 | 飞书交互卡片、企微模板卡片、钉钉卡片 |
8.3 失败处理
九、可靠性保障
| 项 | 方案 |
|---|---|
| 幂等去重 | event_id + 5 分钟时间窗去重(IM 回调可能重发/乱序) |
| 并发串行 | 同一 session_key 串行投递(Redis 分布式锁),不同会话并行 |
| 保活重连 | 长连接/Stream 断线重连 + 指数退避(1s → 2s → 4s → 最大 60s) |
| 限流 | 平台侧限流(IM 平台 QPS 限制)+ 聚合器侧限流(防风暴) |
| 可观测 | 消息链路 trace(IM 事件 → 实例 → 回发),审计全量留痕 |
| 失败处理 | 实例暂存 → 唤醒(返回"助手唤醒中");投递失败 → 重试 → 失败回执 |
十、多平台接入配置
yaml
# 渠道聚合器配置
channel_adapters:
feishu:
enabled: true
app_id: "${FEISHU_APP_ID}"
app_secret: "${FEISHU_APP_SECRET}" # 从 Vault 注入
verify_token: "${FEISHU_VERIFY_TOKEN}"
encrypt_key: "${FEISHU_ENCRYPT_KEY}"
event_url: "https://agent.example.com/channel/feishu/events"
callback_url: "https://agent.example.com/channel/feishu/callback"
wecom:
enabled: true
corp_id: "${WECOM_CORP_ID}"
agent_id: "${WECOM_AGENT_ID}"
secret: "${WECOM_SECRET}"
encoding_aes_key: "${WECOM_AES_KEY}"
callback_url: "https://agent.example.com/channel/wecom/callback"
dingtalk:
enabled: true
app_key: "${DINGTALK_APP_KEY}"
app_secret: "${DINGTALK_APP_SECRET}"
# Stream 模式,无需回调 URL所有凭证从 Vault 注入,不落配置文件、不进日志、不进备份。