qwen-code 通道消息组名观测:Envelope.chatName契约设计与 DingTalk/Telegram 适配器落地解析
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
导读
chatName是 qwen-code 多通道(DingTalk/Telegram/Feishu/WeCom)入站消息信封Envelope上的一个可选字段,用于把平台回调中已经携带的"群聊可读名称"(如钉钉conversationTitle、Telegramchat.title)传递到共享的"已观测联系人"图,使用户在主动投递目标选择界面中看到可读的群组名,而不是一长串不透明的平台 ID。本文以设计文档 2026-07-18-observed-channel-group-names.md 为主线,结合packages/channels下的真实源码与测试,完整讲解该字段的契约语义、各平台适配器的映射规则、刷新(refresh)语义、落库边界以及测试策略,并给出可对照源码逐行验证的实操依据。
背景:为什么群组需要"可读名称"?
qwen-code 的守护进程(daemon)会为每个工作区维护一张"已观测联系人"图(observed-contact graph)。这张图由 [#7109] 引入,持久化在:
$QWEN_HOME/channels/daemon/<workspaceHash>/observed-contacts.json图中记录的是真实 IM 入站消息里出现的用户、群组、话题(topic)标识符,供已认证的工作区客户端通过只读 API(GET /workspace/channel/observed-contacts)查询,从而让用户选择"完整、稳定的平台投递目标",而无需手动查找或重新输入标识符。相关设计见 2026-07-17-observed-channel-delivery-targets.md。
问题在于:这张图"完整保留了平台的群组 ID,但每个groups[].label目前都回退为这个 ID"。也就是说,用户在投递目标列表里只能看到oc_xxxx、cid_xxxx这类机器可读 ID,完全无法辨认这是哪个群。而部分平台(钉钉、Telegram)的入站回调本身就携带了人类可读的群名,只是适配器(adapter)在进入共享观测边界之前就把它丢弃了。
本次变更的核心思路很克制:给共享入站信封加一个可选字段chatName,且只从"已接受的入站消息中已存在的元数据"填充,不调用任何平台目录/群详情/聊天信息 API,也不新增权限、不改变路由或会话身份、不发现权威成员列表、不观测机器人输出、不增加话题名称。
契约:Envelope新增可选字段chatName
在 packages/channels/base/src/types.ts 中,Envelope接口新增了可选字段:
export interface Envelope { channelName: string; senderId: string; senderName: string; chatId: string; chatName?: string; // ← 新增:chatId 的显示名称(观测元数据) text: string; // ... threadId?: string; messageId?: string; isGroup: boolean; isMentioned: boolean; isReplyToBot: boolean; // ... }该字段的契约语义如下:
chatName描述的是"这条消息上观测到的chatId的显示名称",是观测性元数据(observational metadata),不是路由键(routing key);chatId仍然是完整的平台投递键,继续决定会话(session)、去重(deduplication)和图身份(graph identity);- 对私聊消息(direct message)该字段被忽略;
- 公共观测路径使用经过清洗(sanitize)且非空的
chatName作为群组标签(group label);缺失或不可用的值回退到完整的chatId; - 已持久化的标签上限为256 个 UTF-16 码元(code units),且不拆分代理对(surrogate pairs),该边界由现有 registry store 承担,因此不需要 schema 迁移——持久化的观测记录中本来就含有
group.label。
为什么不能把chatName当作路由键?
从 GroupGate.ts 的群组门控逻辑可以看出,群组策略(allowlist/pairing)判断、权限校验、会话路由全部以envelope.chatId为唯一键:
// allowlist 模式:必须按 ID 显式列出 if (!this.groups[envelope.chatId]) { return { allowed: false, reason: 'not_allowlisted' }; }群名是人写的、可变的、可重复的,一旦改名就会导致路由错乱;而平台 ID 是稳定的。chatName只在配对请求创建时作为展示信息参与(createGroupRequest(envelope.chatId, envelope.chatName || envelope.chatId, ...)),即"名字用于给人看,ID 用于给机器路由"。
各平台适配器的映射规则
设计文档明确规定了各适配器的映射策略:
| 平台 | 来源字段 | 行为 |
|---|---|---|
| DingTalk | Stream 回调的conversationTitle | 群消息时映射为chatName |
| Telegram | 入站chat.title(group / supergroup) | 群和超级群映射,私聊不变 |
| Feishu | 无(im.message.receive_v1不含聊天显示名) | 保持完整chat_id回退 |
| 其他适配器 | 取决于入站 payload 是否有文档化的群名字段 | 默认保持 ID 回退 |
DingTalk:conversationTitle→chatName
在 packages/channels/dingtalk/src/DingtalkAdapter.ts 中,适配器从回调数据中读取conversationTitle:
const conversationTitle = typeof data.conversationTitle === 'string' ? data.conversationTitle : undefined; // ... const isGroup = data.conversationType === '2'; // ... const envelope: Envelope = { channelName: this.name, senderId, senderName, chatId, ...(isGroup && conversationTitle ? { chatName: conversationTitle } : {}), text: messageText, // ... isGroup, isMentioned, // ... };注意两点实现细节:
- 仅在群消息时携带:
isGroup && conversationTitle双条件判断,私聊即使回调里有标题也不进chatName; - 回调处理逻辑完全不变:
conversationTitle的读取是独立于既有流程的,chatId的推导(conversationId || sessionWebhook)、sessionWebhook缓存、去重(seenMessages)等原有逻辑不受影响。设计文档强调的"without changing callback handling"在源码中可逐行验证。
Telegram:chat.title→chatName
在 packages/channels/telegram/src/TelegramAdapter.ts 的buildEnvelope中:
const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup'; // ... return { channelName: this.name, senderId: String(msg.from.id), senderName: msg.from.first_name + (msg.from.last_name ? ` ${msg.from.last_name}` : ''), chatId: String(msg.chat.id), ...(isGroup && msg.chat.title ? { chatName: msg.chat.title } : {}), threadId: typeof msg.message_thread_id === 'number' ? String(msg.message_thread_id) : undefined, text: cleanText, // ... isGroup, isMentioned, isReplyToBot, referencedText, };映射规则与 Telegram Bot API 的Chat类型对齐:title仅对群聊(group)和超级群(supergroup)可用,私聊(private)没有title,因此私聊的chatName自然缺失,符合"私聊忽略chatName"的契约。同时threadId由message_thread_id映射,与群名观测互不影响。
Feishu:保持chat_id回退
Feishu 的im.message.receive_v1事件枚举了chat_id、chat_type、thread_id,但不包含聊天显示名。因此在 packages/channels/feishu/src/FeishuAdapter.ts 中不产生chatName,群组标签回退为完整chat_id(形如oc_xxxx)。现有 Feishu 测试继续验证 ID 回退路径,且不产生任何 API 流量。
公共观测路径:清洗与回退逻辑
chatName从适配器进入共享观测边界后,由 ChannelBase.ts 的recordObservedContact统一处理:
protected async recordObservedContact(envelope: Envelope): Promise<void> { if (!this.observedContacts) return; const sanitizedSenderName = envelope.senderName ? sanitizeSenderName(envelope.senderName) : ''; const userLabel = sanitizedSenderName === 'unknown' ? envelope.senderId : sanitizedSenderName || envelope.senderId; const sanitizedChatName = envelope.chatName ? sanitizeSenderName(envelope.chatName) : ''; const groupLabel = sanitizedChatName === 'unknown' ? envelope.chatId : sanitizedChatName || envelope.chatId; const observation: ObservedChannelContactObservation = { user: { id: envelope.senderId, label: userLabel }, ...(envelope.isGroup ? { group: { id: envelope.chatId, label: groupLabel }, ...(envelope.threadId ? { topic: { id: envelope.threadId, label: envelope.threadId }, } : {}), } : {}), }; // ... }关键语义:
- 私聊不产生 group:只有
envelope.isGroup为真时才记录group节点,所以私聊消息即使带了chatName也会被忽略; - 清洗与回退:
chatName经过sanitizeSenderName清洗;清洗结果为空或等于'unknown'时回退到完整chatId; - topic 标签固定用 ID:话题(thread)标签始终使用
threadId本身,不参与chatName逻辑; - 持久化尽力而为:
observe失败只记录一条不含标识符的脱敏日志,不影响已接受的入站消息继续处理(best-effort persistence)。
测试证据:三种边界情形
packages/channels/base/src/ChannelBase.test.ts 中的测试精确覆盖了契约的三个关键面:
- 可用群名传播:
await ch.processAfterAdapterPreflight( envelope({ chatId: 'group-1', chatName: 'Project Group', threadId: 'topic-1', isGroup: true, isMentioned: true, }), ); expect(observe).toHaveBeenCalledWith('test-chan', { user: { id: 'user1', label: 'User 1' }, group: { id: 'group-1', label: 'Project Group' }, topic: { id: 'topic-1', label: 'topic-1' }, });不可用名称回退完整 ID:当
chatName为'\u0000\n'(清洗后不可用)时,group label 回退为'group-1'(即完整chatId)。私聊忽略
chatName:envelope({ chatName: 'Not a group' })且非群消息时,观测结果只有顶层user,不产生 group 节点。
另有测试验证"同一个Envelope对象最多只记录一次"(dedup),与设计文档"sameEnvelopeobject is recorded at most once"的约束对应。
刷新语义:名字只是"最新观测证据"
设计文档对刷新语义的界定非常明确,群名不被视为永久或权威信息:
- 同一 channel、user、group 的后续被接受消息会刷新该观测;若携带了不同的可用
chatName,现有 store 的替换语义会更新派生出的群组标签,而不会创建新的群组节点; - 新鲜度(freshness)仍以
lastObservedAt为准; - 若平台在后续消息中省略了群名,该次观测贡献的是ID 回退值;
- 图派生(graph derivation)总是选取最近一次观测,因此返回的标签代表的是"最新的被接受证据",而不是某个隐藏的长期名称缓存。
这带来一个实践上的行为预期:如果钉钉/Telegram 群在收到消息期间改了名,下一次同群消息到达后,observed-contacts.json中的groups[].label会被替换为最新群名;如果某平台后续消息不再携带群名,标签会回退为 ID,直到再次出现携带群名的消息。
非目标边界(明确不做的事)
设计文档用一段话划定了严格的边界,这在工程评审中尤其值得借鉴:
- 不调用平台目录(directory)、群详情(group-detail)或聊天信息(chat-info)API;
- 不新增任何权限;
- 不改变路由或会话身份(
chatId仍是唯一投递键); - 不发现权威成员列表(
groups[].users仅表示"在那些会话中被观测到的用户",见 2026-07-17-observed-channel-delivery-targets.md 的关系模型说明); - 不观测机器人输出;
- 不增加话题名称(topic 标签固定回退到
threadId)。
测试策略与回归保障
设计文档规划的测试策略在仓库中均有对应落点:
| 测试层 | 覆盖内容 | 仓库位置 |
|---|---|---|
| base-channel 测试 | 可用群名传播、不可用名称回退、私聊忽略chatName、后续观测刷新标签 | ChannelBase.test.ts |
| DingTalk 适配器测试 | conversationTitle进入信封且不改变回调处理 | DingtalkAdapter.test.ts |
| Telegram 适配器测试 | 群/超级群 title 进入信封,私聊保持不变 | TelegramAdapter.test.ts |
| Feishu 既有测试 | ID 回退路径,无 API 流量 | FeishuAdapter.ts |
| store 聚焦测试 | 新标签替换旧标签;无需 schema 迁移 | packages/channels/base观测存储相关测试 |
由于持久化观测记录原本就包含group.label字段,本次变更不需要任何 schema 迁移,存量observed-contacts.json可直接沿用,这也是该设计"低侵入"的直接体现。
实践要点小结
chatName是观测元数据:它只服务于"人类可读的展示",路由、去重、会话、图身份全部继续由chatId决定;- 来源受限:只使用入站消息自身携带的元数据,钉钉
conversationTitle、Telegramchat.title;不引入任何额外的平台 API 调用; - 清洗与回退是硬约束:非空 + 清洗后可用才作为群组标签,否则回退完整 ID;私聊一律忽略;
- 标签会随观测刷新:最新可用群名替换旧标签,缺失时回退 ID,不维护隐藏的名称缓存;
- 无迁移、无权限变更:字段可选、存储结构不变,适配器回调处理逻辑不变,回归风险被压缩到最小。
对于正在集成新 IM 平台的开发者,遵循本设计的扩展路径是:如果该平台入站 payload 有文档化的群名字段,就在适配器构建Envelope时按isGroup && field ? { chatName: field } : {}的模式填充,并配套一条"群名传播 + 私聊忽略"的适配器测试即可;如果没有该字段,则保持 ID 回退,无需任何额外工作。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考