ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

qwen-code 通道消息组名观测:`Envelope.chatName` 契约设计与 DingTalk/Telegram 适配器落地解析

qwen-code 通道消息组名观测:`Envelope.chatName` 契约设计与 DingTalk/Telegram 适配器落地解析 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标识符供已认证的工作区客户端通过只读 APIGET /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 keychatId仍然是完整的平台投递键继续决定会话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 用于给机器路由。各平台适配器的映射规则设计文档明确规定了各适配器的映射策略平台来源字段行为DingTalkStream 回调的conversationTitle群消息时映射为chatNameTelegram入站chat.titlegroup / supergroup群和超级群映射私聊不变Feishu无im.message.receive_v1不含聊天显示名保持完整chat_id回退其他适配器取决于入站 payload 是否有文档化的群名字段默认保持 ID 回退DingTalkconversationTitle→chatName在 packages/channels/dingtalk/src/DingtalkAdapter.ts 中适配器从回调数据中读取conversationTitleconst 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在源码中可逐行验证。Telegramchat.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): Promisevoid { 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时回退到完整chatIdtopic 标签固定用 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。私聊忽略chatNameenvelope({ 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-infoAPI不新增任何权限不改变路由或会话身份chatId仍是唯一投递键不发现权威成员列表groups[].users仅表示在那些会话中被观测到的用户见 2026-07-17-observed-channel-delivery-targets.md 的关系模型说明不观测机器人输出不增加话题名称topic 标签固定回退到threadId。测试策略与回归保障设计文档规划的测试策略在仓库中均有对应落点测试层覆盖内容仓库位置base-channel 测试可用群名传播、不可用名称回退、私聊忽略chatName、后续观测刷新标签ChannelBase.test.tsDingTalk 适配器测试conversationTitle进入信封且不改变回调处理DingtalkAdapter.test.tsTelegram 适配器测试群/超级群 title 进入信封私聊保持不变TelegramAdapter.test.tsFeishu 既有测试ID 回退路径无 API 流量FeishuAdapter.tsstore 聚焦测试新标签替换旧标签无需 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),仅供参考
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进