
Qwen Code 飞书渠道观测联系人标签补全基于后置观测钩子的后台 OpenAPI 名称解析实现【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code本文基于仓库内 实现计划 与 设计文档并结合packages/channels下的源码与测试展开。全文以仓库当前实现为准。导读Qwen Code 的渠道层packages/channels会在入站消息通过 preflight 后把发送者与群组 ID 作为观测联系人observed contact落盘供会话路由、消息来源标注source label等场景使用。但飞书Feishu/Lark场景下这些记录最初只有原始 ID如ou_xxx、oc_xxx可读性差。本文讲解仓库如何通过ChannelBase 后置观测钩子 飞书后台 OpenAPI 名称查询在不延迟入站消息处理的前提下用真实用户名与群名补全标签包括扩展点设计、双 OpenAPI 查询、进程内缓存与去重、失败静默、daemon 重启后的标签回填以及完整的测试与验证路径。读完你可以掌握该机制的完整实现脉络并能在自己的渠道适配器中复用这套先落盘、后异步补全的模式。一、背景观测联系人标签与名称缺失问题在渠道层ChannelBase的observedContacts选项见 ChannelBase.ts提供了两个回调observe(channelName, observation)持久化一条观测记录list()读取已持久化的观测图ObservedChannelContactGraph供适配器在重启后回填标签缓存。ChannelBase.recordObservedContactChannelBase.ts负责构造ObservedChannelContactObservation用户标签优先取经过sanitizeSenderName净化的senderName净化为unknown或为空时回退为senderId群组标签同理回退为chatId。也就是说飞书适配器在未解析出真实名称前观测记录中的 label 就是原始 ID——这正是本文要解决的问题让标签变成AliceProject Group这样可读的名字。核心目标引自 设计文档在不延迟飞书入站消息处理的前提下用真实用户名和群名补全观测联系人的 label。名称查询不可用时继续保留现有 ID label。这带来两条硬性约束不能阻塞入站链路名称查询绝不能出现在handleInbound或 Agent prompt 主链路的 await 路径上先落盘再补全ID 观测记录必须先持久化查询成功后用第二次写入把 label 从 ID 升级为名称。二、总体架构一个同步钩子 一套后台查询整体架构可以用一句话概括设计文档ChannelBase在入站消息通过 preflight 后仍先立即落盘基于 ID 的观测记录随后调用一个同步的 protected 后置观测钩子。默认实现不执行任何操作返回值也不会被等待因此不影响其他渠道。ChannelBase侧暴露protected recordObservedContact(envelope)持久化路径与protected onObservedContact(envelope)后置钩子默认空实现钩子在首次落盘尝试完成后同步触发FeishuChannel侧覆写onObservedContact以 fire-and-forget 方式发起两类 OpenAPI 查询POST /open-apis/contact/v3/users/basic_batch解析发送者姓名GET /open-apis/im/v1/chats/:chat_id解析群名。用户与群分别维护进程内缓存但共享同一套查询生命周期同一个 ID 的并发请求复用同一个 Promise见 设计文档 与 FeishuAdapter.ts。三、核心机制一ChannelBase 后置观测扩展点3.1 两个 protected 方法在 ChannelBase.ts 中protected async recordObservedContact(envelope: Envelope): Promisevoid { // 构造 observationuserLabel sanitizeSenderName(senderName) ! unknown ? 净化的名字 : senderId // 群组同理threadId 作为 topic 一并记录 // 调用 this.observedContacts.observe(this.name, observation) } protected onObservedContact(_envelope: Envelope): void {}钩子默认是空实现void返回因此对钉钉、Telegram、企微等其他渠道完全无感无需改动。3.2 调用顺序先持久化后通知关键调用点位于processInboundChannelBase.tsprotected async processInbound(envelope: Envelope): Promisevoid { await this.waitForBridgeRecovery(); if (!this.preflightedEnvelopes.delete(envelope)) { throw new Error(processInbound called without a successful preflightInbound check.); } if (this.observedContacts !this.observedContactEnvelopes.has(envelope)) { this.observedContactEnvelopes.add(envelope); await this.recordObservedContact(envelope); // 1. 先落盘await this.onObservedContact(envelope); // 2. 后触发钩子同步不 await 返回值 } // ... 后续命令解析、记忆意图、prompt 路由等 }这里有三层保证对应 实现计划 的 Task 1 接口约束preflight 边界processInbound强制要求preflightedEnvelopes中有该 envelope 的记录否则直接抛错——被拒绝的消息根本走不到钩子一次触发observedContactEnvelopesWeakSet保证同一 envelope 只落盘、只通知一次顺序await recordObservedContact完成后才同步调用onObservedContact即使持久化抛错内部已 try/catch 并写 stderr钩子依然会触发。3.3 测试证据钩子顺序与容错ChannelBase.test.ts 用本地测试子类验证先持久化后通知class ObservedHookChannel extends TestChannel { readonly observedEnvelopes: Envelope[] []; protected override onObservedContact(envelope: Envelope): void { order.push(hook); this.observedEnvelopes.push(envelope); } } it(notifies the adapter after an approved contact is persisted, async () { // observe 回调里 push(persisted)钩子里 push(hook) // 断言 order 严格等于 [persisted, hook] // 且 bridge.prompt 已被调用说明入站处理继续执行 });此外 ChannelBase.test.ts 覆盖了持久化被拒绝observe 抛错时钩子仍触发的容错路径同时 stderr 中出现observed contact persistence failed——这正是首次落盘尝试完成后语义的测试固化。四、核心机制二飞书后台名称解析4.1 实例级缓存字段FeishuAdapter.ts 定义了 5 组实例级状态private readonly observedUserNames new Mapstring, string(); // 已解析成功的用户名 private readonly observedChatNames new Mapstring, string(); // 已解析成功的群名 private readonly observedUserLookups new Mapstring, Promisestring | undefined(); private readonly observedChatLookups new Mapstring, Promisestring | undefined(); private readonly observedContactWrites new Mapstring, { senderName: string; chatName: string | undefined }();*Names成功缓存后续 envelope 同步复用*Lookups在途 Promise 缓存同一 ID 的并发消息共享同一次 HTTP 请求解析为undefined的 Promise 也会被保留抑制重试直到 daemon 重启observedContactWrites记录最近一次写出的标签组合用于幂等去重——若待写标签与上次一致则跳过第二次写入。4.2 钩子覆写与二次写入FeishuAdapter.tsprotected override onObservedContact(envelope: Envelope): void { this.observedContactWrites.set(this.observedContactKey(envelope), { senderName: envelope.senderName, chatName: envelope.chatName, }); this.capObservedCache(this.observedContactWrites); void this.enrichObservedContact(envelope).catch(() {}); // fire-and-forget } private async enrichObservedContact(envelope: Envelope): Promisevoid { const [senderName, chatName] await Promise.all([ this.observedUserName(envelope.senderId), envelope.isGroup ? this.observedChatName(envelope.chatId) : Promise.resolve(undefined), ]); if (!senderName !chatName) return; // 全部失败保留 ID 观测 // ... 与 observedContactWrites 比较相同则跳过 await this.recordObservedContact({ ...envelope, ...(senderName ? { senderName } : {}), ...(chatName ? { chatName } : {}), }); }要点钩子不同步返回、不 await查询 PromisehandleInbound与 Agent prompt 主链路完全不被拖慢只有至少一个查询成功才写第二次观测全失败时首次 ID 观测原样保留由于 channel、user、group、topic 的 ID 都没变ObservedChannelContactStore会直接用名称 label 替换 ID label存储格式无需任何改动设计文档。4.3 两类 OpenAPI 请求与 ID 类型映射用户查询FeishuAdapter.tsprivate observedUserName(userId: string): Promisestring | undefined { const userIdType userId.startsWith(ou_) ? open_id : userId.startsWith(on_) ? union_id : user_id; return this.observedNameLookup({ lookups: this.observedUserLookups, names: this.observedUserNames, id: userId, request: (token) fetch(${BASE_URL}/contact/v3/users/basic_batch?user_id_type${userIdType}, { method: POST, headers: { Authorization: Bearer ${token}, Content-Type: application/json }, body: JSON.stringify({ user_ids: [userId] }), signal: AbortSignal.timeout(15_000), }), extractName: (body) { const data body as { code?: number; data?: { users?: Array{ name?: string } } }; return data.code 0 ? data.data?.users?.[0]?.name : undefined; }, }); }群查询FeishuAdapter.tsprivate observedChatName(chatId: string): Promisestring | undefined { return this.observedNameLookup({ lookups: this.observedChatLookups, names: this.observedChatNames, id: chatId, request: (token) fetch(${BASE_URL}/im/v1/chats/${encodeURIComponent(chatId)}, { headers: { Authorization: Bearer ${token} }, signal: AbortSignal.timeout(15_000), }), extractName: (body) { const data body as { code?: number; data?: { name?: string } }; return data.code 0 ? data.data?.name : undefined; }, }); }关键实现细节ID 类型映射ou_前缀按open_id、on_前缀按union_id、其余按user_id作为user_id_type查询参数群 ID 经encodeURIComponent后再拼 URL超时所有查询统一使用AbortSignal.timeout(15_000)与仓库中fetchBotInfo、fetchMessageContent等既有请求的超时策略一致成功判定HTTP 2xx JSONcode 0 非空name三者缺一不可安全校验仓库通过FEISHU_ID_RE /^[a-zA-Z0-9_.:-]$/校验飞书 ID 格式防止 URL 插值中的路径穿越FeishuAdapter.ts。4.4 统一查询生命周期observedNameLookup核心的缓存/去重/失败抑制逻辑集中在 observedNameLookupprivate observedNameLookup(options: { lookups: Mapstring, Promisestring | undefined; names: Mapstring, string; id: string; request: (token: string) PromiseResponse; extractName: (body: unknown) string | undefined; }): Promisestring | undefined { const cached options.names.get(options.id); if (cached) return Promise.resolve(cached); // 1. 成功缓存命中 const existing options.lookups.get(options.id); if (existing) return existing; // 2. 在途 Promise 复用 const lookup (async () { try { const token await this.getTenantAccessToken({ silent: true }); if (!token) { options.lookups.delete(options.id); // 未发出请求可重试 return undefined; } const response await options.request(token); if (!response.ok) { if (response.status 401) { this.tokenCache undefined; // 401使 tenant token 失效 options.lookups.delete(options.id); // 且保持可重试 } return undefined; // 其他非 2xx保留 Promise抑制重试 } const name options.extractName(await response.json())?.trim(); if (!name) return undefined; const label sanitizeSenderName(name); // 共享发送者名称净化 if (label unknown) return undefined; options.names.set(options.id, label); if (this.capObservedCache(options.names)) { this.hydratedObservedNames false; // 缓存淘汰后允许重新回填 } return label; } catch { return undefined; // HTTP/解析/超时错误静默 } })(); options.lookups.set(options.id, lookup); this.capObservedCache(options.lookups); return lookup; }这段代码精确实现了 设计文档 中的四类失败语义失败场景处理重启后是否重试tenant token 获取失败请求未发出删除 lookup 条目可重试收到 401删除 lookup 条目 tokenCache undefined可重试token 自动刷新请求已送达飞书但非 2xx /code ! 0/ 空名保留undefined的 Promise 条目直到 daemon 重启HTTP 异常、JSON 解析错误、超时catch静默吞掉直到 daemon 重启所有失败路径都不写日志不调用process.stderr.write保证飞书侧日志不被打扰——这与getTenantAccessToken({ silent: true })的静默刷新配合名称补全引发的 token 刷新失败不会输出而核心投递链路触发的刷新失败仍会记录见 FeishuAdapter.ts 中tokenRefreshHasCoreWaiters机制。4.5 成功名称复用与容量上限后续消息复用在构造Envelope时实现计划 Task 2 Step 4 的代码先查成功缓存const cachedSenderName this.observedUserNames.get(senderId); const cachedChatName isGroup ? this.observedChatNames.get(chatId) : undefined; const envelope: Envelope { channelName: this.name, senderId, senderName: cachedSenderName || senderId, chatId, text: cleanText, messageId: msgId, threadId: msg.root_id || undefined, isGroup, isMentioned, isReplyToBot: false, ...(cachedChatName ? { chatName: cachedChatName } : {}), };于是同一发送者的第二条消息在recordObservedContact首次写入时 label 就已是名称无需二次查询。容量上限OBSERVED_LABEL_CACHE_LIMIT 500FeishuAdapter.ts与持久化观测注册表500 条保持一致防止长跑 daemon 无限保留见过的每个 ID。capObservedCache采用 FIFO 淘汰最旧条目FeishuAdapter.ts当已解析名称缓存发生淘汰时会把hydratedObservedNames复位为false允许下一条消息重新从注册表回填避免首次写入用原始 ID 覆盖已持久化名称的回退问题对应 adapter.test.ts 中的re-hydrates persisted labels after in-lifetime cache eviction用例。五、daemon 重启后的标签回填hydrate由于失败查询的 Promise 在 daemon 重启前不会重试重启后第一条入站消息若只靠新查询会把已知名称暂时回退为原始 ID。为此FeishuChannel实现了hydrateObservedNamesFeishuAdapter.ts通过persistedObservedContacts()读取本渠道的观测图list()回调且只保留channelName this.name的记录见 ChannelBase.ts遍历users、groups及群内成员选取每个联系人的最新非 ID 标签按lastObservedAt比较标签等于 ID 的跳过避免旧的过期标签覆盖新标签将选出的标签写入observedUserNames/observedChatNames缓存并执行容量截断hydratedObservedNames标记保证每次 channel 实例生命周期内只回填一次除非缓存淘汰后复位。对应测试hydrates label caches from persisted observations after a restartadapter.test.ts验证回填后第一条消息的观测 label 直接是Alice/Project Group且enrichment 的 HTTP 请求次数为 0回填短路了查询另一渠道实例持久化的标签不会串入本渠道缓存。六、顺序与访问控制哪些消息不触发查询设计文档 明确列出不触发查询的消息类型未通过 preflight 的消息processInbound的preflightedEnvelopes强校验重复事件seenMessages去重TTL 5 分钟见 FeishuAdapter.ts被适配器丢弃的空消息未通过发送者SenderGate或群组GroupGate策略的消息。即名称补全严格在preflight 通过 首次观测落盘尝试完成之后才开始且后台查询不被handleInbound或 Agent prompt 路径 await——这是不延迟入站处理的根本保证同时也有对应测试用外部受控的未决 Promise 验证lookup 未决期间bridge.prompt照常执行拒绝后 ID 观测仍保留、stderr 无查询错误输出实现计划 Task 2 Step 2。七、权限设计最小权限 scope发送者姓名查询使用contact:user.basic_profile:readonly群名查询使用im:chat:readonly不使用 full-contact 类 API设计文档。这两个 scope 需要在飞书开放平台为应用申请并开通。需要说明的边界源自 设计文档ID 仍具有应用隔离性因此跨应用 ID 和外部用户可能无法补全。即ou_/on_等 ID 是应用作用域的其他应用创建的用户或外部联系人可能查询不到名称此时观测记录保持原始 ID label 不变属预期行为而非故障。八、测试矩阵与验证命令8.1 测试覆盖点按 实现计划 与 设计文档 的 Testing 节层次文件覆盖点Channel BaseChannelBase.test.ts钩子仅在 preflight 后触发、先持久化后通知、持久化失败钩子仍触发、不可用 label 回退完整 sender IDFeishu Adapteradapter.test.ts成功补全Alice/Project Group、进程内去重每个 endpoint 只请求一次、后续消息复用缓存名称、重启后回填、失败静默、401 token 失效、unknown/不可见字符净化为不缓存、DM 场景只查用户不查群、容量淘汰后重新回填其中每个 OpenAPI endpoint 恰好请求一次的断言方式值得留意adapter.test.ts测试先投递第一条群消息等待两次observe调用先 ID、后名称再投递第二条消息并断言第三次观测已带缓存名称且fetchSpy总调用次数为 2用户 群各一次。8.2 聚焦验证命令实现计划中给出的聚焦测试与全量校验命令cd packages/channels/base npx vitest run src/ChannelBase.test.ts cd ../feishu npx vitest run src/adapter.test.ts cd ../../.. npm run build npm run typecheck git diff --check origin/main...HEADnpx vitest run src/ChannelBase.test.ts -t notifies the adapter after an approved contact is persisted单跑钩子顺序用例RED→GREEN 的 TDD 起点npx vitest run src/adapter.test.ts -t observed contact单跑飞书标签补全相关用例npm run build npm run typecheck验证 ESM、严格 TypeScript、无any约束。九、实现落地Task 划分与关键约束回顾实现计划 将该特性拆为 3 个任务Task 1基座扩展点修改 ChannelBase.ts把recordObservedContact由 private 调整为 protected方法体不变新增默认空实现的protected onObservedContact(envelope): void并在processInbound中落盘后同步调用以 ChannelBase.test.ts 的失败测试先行RED→GREEN。Task 2飞书后台补全修改 FeishuAdapter.ts新增名称/查询 Promise 双缓存、observedNameLookup统一生命周期、enrichObservedContact二次写入、hydrateObservedNames重启回填并在 adapter.test.ts 中补齐成功、去重、非阻塞静默失败、重启回填等用例。Task 3验证与发布跑聚焦测试 buildtypecheckgit diff --check按实现计划描述的流程做自查与审查最后提交。全程需要遵守的全局约束实现计划 Global Constraints保留既有 preflight 边界被拒绝的消息不得触发查询先持久化 ID 观测再开始补全handleInbound与 Agent prompt 路径永不 await 飞书标签查询每个 user/group ID 在每个FeishuChannel实例内最多查询一次并发观测共享同一 Promise失败尝试缓存到 daemon 重启且不输出查询相关日志仅使用contact:user.basic_profile:readonly与im:chat:readonly不用 full-contact API保持 ESM、严格 TypeScript、无any、沿用现有文件命名。十、小结飞书观测联系人标签补全是一套先保证可用、再异步增强可读性的典型实现ChannelBase提供一个同步、默认空实现、返回值不被等待的后置钩子作为通用扩展点FeishuChannel在其上叠加进程内缓存、Promise 去重、FIFO 容量上限、静默失败与重启回填最终在不拖慢入站链路的前提下把观测联系人 label 从原始 ID 升级为真实用户名与群名。存储侧无需任何格式变更——同名 ID 的第二次写入天然完成 label 替换。仓库内 ChannelBase.ts、FeishuAdapter.ts 及其对应测试文件提供了完整可验证的实现证据其他渠道适配器也可参考这一模式为各自的观测联系人做名称补全。【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考