Repository navigation
feat(feishu): enrich observed contact labels #8569
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
wenshao
merged 11 commits into
QwenLM:main
from
BenGuanRan:feat/feishu-observed-contact-labels
Aug 7, 2026
Merged
Changes from 5 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
529f865
docs: design feishu observed contact enrichment
BenGuanRan a9dd003
docs: add Chinese Feishu enrichment design
BenGuanRan c82d3f1
feat(feishu): enrich observed contact labels
BenGuanRan 8ef1b1e
fix(feishu): preserve enriched contact labels
BenGuanRan 47b2dff
Merge remote-tracking branch 'origin/main' into feat/feishu-observed-…
BenGuanRan d2dec01
fix(feishu): harden observed-contact label enrichment lifecycle
BenGuanRan 3d1b501
Merge branch 'main' into feat/feishu-observed-contact-labels
qwen-code-dev-bot 93bd433
fix(feishu): bound label caches, honor observation recency, silence e…
BenGuanRan 27ed09f
Merge branch 'main' into feat/feishu-observed-contact-labels
wenshao 85f4d07
Merge branch 'main' into feat/feishu-observed-contact-labels
qwen-code-dev-bot 119e565
fix(feishu): address observed-contact label review feedback (#8569)
qwen-code-ci-bot File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
102 changes: 102 additions & 0 deletions
102
docs/design/feishu-observed-contact-label-enrichment.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| # Feishu observed-contact label enrichment | ||
|
|
||
| ## 中文说明 | ||
|
|
||
| ### 目标 | ||
|
|
||
| 在不延迟飞书入站消息处理的前提下,用真实用户名和群名补全观测联系人 | ||
| 的 label。名称查询不可用时继续保留现有 ID label。 | ||
|
|
||
| ### 设计 | ||
|
|
||
| `ChannelBase` 在入站消息通过 preflight 后,仍先立即落盘基于 ID 的观测 | ||
| 记录。随后调用一个同步的 protected 后置观测钩子。默认实现不执行任何 | ||
| 操作,返回值也不会被等待,因此不影响其他渠道。 | ||
|
|
||
| `FeishuChannel` 覆写该钩子,对当前 channel 实例生命周期内尚未尝试过的 | ||
| ID 发起后台查询: | ||
|
|
||
| - `POST /open-apis/contact/v3/users/basic_batch` 查询发送者姓名。 | ||
| - `GET /open-apis/im/v1/chats/:chat_id` 查询群名。 | ||
|
|
||
| 用户和群分别使用进程内缓存。同一个 ID 的并发请求复用同一个 Promise。 | ||
| 查询成功的名称供后续消息直接复用;查询失败则记录到 daemon 重启为止。 | ||
| 查询请求的 HTTP 错误、API 错误、解析错误和超时均不输出日志。 | ||
|
|
||
| 任一名称查询成功后,飞书通过 `ChannelBase` 现有持久化方法再次写入观测 | ||
| 记录。由于 channel、用户、群和话题 ID 均未改变, | ||
| `ObservedChannelContactStore` 会直接用名称 label 替换 ID label,无需修改 | ||
| 存储格式。查询均失败时,首次写入的 ID 观测记录保持不变。 | ||
|
|
||
| ### 顺序与访问控制 | ||
|
|
||
| 只有现有 preflight 通过且首次观测落盘尝试完成后,才开始名称补全。重复事件、被 | ||
| 适配器丢弃的空消息,以及未通过发送者或群策略的消息都不会触发查询。 | ||
| 后台查询不会被 `handleInbound` 或 Agent prompt 主链路等待。 | ||
|
|
||
| ### 权限 | ||
|
|
||
| 发送者姓名查询使用最小权限 `contact:user.basic_profile:readonly`,群名查询 | ||
| 使用 `im:chat:readonly`。ID 仍具有应用隔离性,因此跨应用 ID 和外部用户 | ||
| 可能无法补全。 | ||
|
|
||
| ### 测试 | ||
|
|
||
| Channel Base 测试验证后置观测钩子只在 preflight 后触发且不会被等待。 | ||
| 飞书适配器测试验证成功补全、进程内去重、后续消息复用名称,以及失败静默 | ||
| 时原始 ID 观测记录和入站消息处理仍然可用。 | ||
|
|
||
| ## English | ||
|
|
||
| ### Goal | ||
|
|
||
| Populate Feishu observed-contact labels with the sender name and group name | ||
| without delaying inbound message processing. Keep the current ID labels when | ||
| lookup is unavailable. | ||
|
|
||
| ### Design | ||
|
|
||
| `ChannelBase` continues to persist the ID-based observation immediately after | ||
| inbound preflight succeeds. It then invokes a synchronous, protected | ||
| post-observation hook. The default hook does nothing and its return value is | ||
| not awaited, so other channel implementations are unchanged. | ||
|
|
||
| `FeishuChannel` overrides the hook and starts background lookups for IDs that | ||
| have not been attempted during the current channel instance lifetime: | ||
|
|
||
| - `POST /open-apis/contact/v3/users/basic_batch` resolves the sender name. | ||
| - `GET /open-apis/im/v1/chats/:chat_id` resolves the group name. | ||
|
|
||
| User and group lookups have separate process-local caches. Concurrent requests | ||
| for the same ID share one promise. A successful name is reused on later | ||
| envelopes; a failed attempt is retained until the daemon restarts. Lookup HTTP, | ||
| API, parsing, and timeout failures produce no log output. | ||
|
|
||
| When either lookup succeeds, Feishu writes a second observation through the | ||
| existing `ChannelBase` persistence method. The observation has the same | ||
| channel, user, group, and topic IDs, so `ObservedChannelContactStore` replaces | ||
| the ID labels without requiring a storage-format change. An unsuccessful | ||
| lookup leaves the first ID-based observation intact. | ||
|
|
||
| ### Ordering and access control | ||
|
|
||
| Enrichment starts only after the existing inbound preflight succeeds and the | ||
| initial observation attempt completes. Duplicate events, empty messages | ||
| rejected by the adapter, and messages rejected by sender or group policy do not | ||
| trigger lookups. The background lookup is not awaited by `handleInbound` or the | ||
| agent prompt path. | ||
|
|
||
| ### Permissions | ||
|
|
||
| Sender-name enrichment uses the least-privilege | ||
| `contact:user.basic_profile:readonly` scope. Group-name enrichment uses | ||
| `im:chat:readonly`. IDs remain application-scoped, so cross-application IDs and | ||
| external users may remain unresolved. | ||
|
|
||
| ### Testing | ||
|
|
||
| Channel-base tests verify that the post-observation hook runs after preflight | ||
| and is not awaited. Feishu adapter tests verify successful enrichment, | ||
| process-local de-duplication, cached labels on later envelopes, and silent | ||
| failure while the original ID-based observation and inbound processing remain | ||
| available. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,290 @@ | ||
| # Feishu Observed-Contact Label Enrichment Implementation Plan | ||
|
|
||
| > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | ||
|
|
||
| **Goal:** Resolve Feishu sender and group IDs into recognizable observed-contact labels without delaying inbound message processing. | ||
|
|
||
| **Architecture:** `ChannelBase` exposes a synchronous post-observation hook and its existing protected persistence path. `FeishuChannel` starts cached, fire-and-forget OpenAPI lookups from that hook, then writes one enriched observation after successful resolution. The existing store schema and non-Feishu channels remain unchanged. | ||
|
|
||
| **Tech Stack:** TypeScript, Node.js 22+, Vitest, Feishu tenant-access-token OpenAPI, existing channel-base observed-contact sink. | ||
|
|
||
| ## Global Constraints | ||
|
|
||
| - Preserve the existing preflight boundary: rejected messages must not trigger lookups. | ||
| - Persist the ID observation before starting enrichment. | ||
| - Never await Feishu label lookup from `handleInbound` or the Agent prompt path. | ||
| - Query each user and group ID at most once per `FeishuChannel` instance; concurrent observations share the same promise. | ||
| - Cache failed attempts until daemon restart and emit no lookup-specific logs. | ||
| - Use `contact:user.basic_profile:readonly` and `im:chat:readonly`; do not use full-contact APIs. | ||
| - Keep ESM, strict TypeScript, no `any`, and existing file naming. | ||
|
|
||
| --- | ||
|
|
||
| ### Task 1: Add the post-observation extension point | ||
|
|
||
| **Files:** | ||
|
|
||
| - Modify: `packages/channels/base/src/ChannelBase.ts` | ||
| - Test: `packages/channels/base/src/ChannelBase.test.ts` | ||
|
|
||
| **Interfaces:** | ||
|
|
||
| - Produces: `protected onObservedContact(envelope: Envelope): void` | ||
| - Produces: `protected recordObservedContact(envelope: Envelope): Promise<void>` | ||
| - Guarantees: the hook runs once, after the initial persistence attempt, only for preflight-approved envelopes. | ||
|
|
||
| - [ ] **Step 1: Write the failing hook-order test** | ||
|
|
||
| Add a local test subclass and assert that persistence happens before the hook: | ||
|
|
||
| ```ts | ||
| class ObservedHookChannel extends TestChannel { | ||
| readonly observedEnvelopes: Envelope[] = []; | ||
|
|
||
| protected override onObservedContact(envelope: Envelope): void { | ||
| this.observedEnvelopes.push(envelope); | ||
| } | ||
| } | ||
|
|
||
| it('notifies the adapter after an approved contact is persisted', async () => { | ||
| const order: string[] = []; | ||
| const observe = vi.fn(() => { | ||
| order.push('persisted'); | ||
| }); | ||
| const ch = new ObservedHookChannel('test-chan', defaultConfig(), bridge, { | ||
| observedContacts: { observe }, | ||
| }); | ||
| const message = envelope(); | ||
|
|
||
| await ch.handleInbound(message); | ||
|
|
||
| expect(order).toEqual(['persisted']); | ||
| expect(ch.observedEnvelopes).toEqual([message]); | ||
| expect(bridge.prompt).toHaveBeenCalled(); | ||
| }); | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Run the base test and verify RED** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| cd packages/channels/base | ||
| npx vitest run src/ChannelBase.test.ts -t "notifies the adapter after an approved contact is persisted" | ||
| ``` | ||
|
|
||
| Expected: compilation fails because `onObservedContact` does not exist. | ||
|
|
||
| - [ ] **Step 3: Implement the minimal base hook** | ||
|
|
||
| Change only the persistence method visibility from `private` to `protected`; keep its body unchanged. Add the default no-op hook and invoke it synchronously after the initial observation: | ||
|
|
||
| ```ts | ||
| protected async recordObservedContact(envelope: Envelope): Promise<void> { | ||
| ``` | ||
|
|
||
| ```ts | ||
| protected onObservedContact(_envelope: Envelope): void {} | ||
|
|
||
| // Inside processInbound, after await this.recordObservedContact(envelope): | ||
| this.onObservedContact(envelope); | ||
| ``` | ||
|
|
||
| - [ ] **Step 4: Run the base test and verify GREEN** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| cd packages/channels/base | ||
| npx vitest run src/ChannelBase.test.ts | ||
| ``` | ||
|
|
||
| Expected: all ChannelBase tests pass. | ||
|
|
||
| ### Task 2: Enrich Feishu observations in the background | ||
|
|
||
| **Files:** | ||
|
|
||
| - Modify: `packages/channels/feishu/src/FeishuAdapter.ts` | ||
| - Test: `packages/channels/feishu/src/adapter.test.ts` | ||
|
|
||
| **Interfaces:** | ||
|
|
||
| - Consumes: `onObservedContact(envelope)` and `recordObservedContact(envelope)` from Task 1. | ||
| - Produces: process-local user/group name and lookup-promise caches. | ||
| - Produces: `basic_batch` requests with the callback ID type and `GET /im/v1/chats/:chat_id` requests. | ||
|
|
||
| - [ ] **Step 1: Write failing success and cache tests** | ||
|
|
||
| Allow the test factory to pass `ChannelBaseOptions`, preload `tokenCache`, and send two complete Feishu group-event fixtures with different message IDs. Mock these complete API responses: | ||
|
|
||
| ```ts | ||
| new Response( | ||
| JSON.stringify({ | ||
| code: 0, | ||
| msg: 'success', | ||
| data: { users: [{ user_id: 'ou_user', name: 'Alice' }] }, | ||
| }), | ||
| { status: 200 }, | ||
| ); | ||
|
|
||
| new Response( | ||
| JSON.stringify({ | ||
| code: 0, | ||
| msg: 'success', | ||
| data: { name: 'Project Group' }, | ||
| }), | ||
| { status: 200 }, | ||
| ); | ||
| ``` | ||
|
|
||
| Assert the observable store calls, not private maps: | ||
|
|
||
| ```ts | ||
| expect(observe).toHaveBeenNthCalledWith(1, 'test', { | ||
| user: { id: 'ou_user', label: 'ou_user' }, | ||
| group: { id: 'oc_group', label: 'oc_group' }, | ||
| }); | ||
| expect(observe).toHaveBeenNthCalledWith(2, 'test', { | ||
| user: { id: 'ou_user', label: 'Alice' }, | ||
| group: { id: 'oc_group', label: 'Project Group' }, | ||
| }); | ||
| ``` | ||
|
|
||
| After the first lookup settles, deliver the second fixture and assert its first observation already contains the cached names and that each OpenAPI endpoint was requested exactly once. | ||
|
|
||
| - [ ] **Step 2: Write the failing non-blocking and silent-failure test** | ||
|
|
||
| Use an externally controlled rejected lookup promise, a real mock bridge, and a no-op `onPromptStart` test subclass. Assert `bridge.prompt` runs while lookup work is unresolved. After rejecting the lookups, assert the ID observation remains and `process.stderr.write` received no lookup error. | ||
|
|
||
| - [ ] **Step 3: Run Feishu tests and verify RED** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| cd packages/channels/feishu | ||
| npx vitest run src/adapter.test.ts -t "observed contact" | ||
| ``` | ||
|
|
||
| Expected: the enriched observation and background request assertions fail because Feishu does not yet resolve labels. | ||
|
|
||
| - [ ] **Step 4: Implement cached background lookups** | ||
|
|
||
| Add successful-name maps and promise caches: | ||
|
|
||
| ```ts | ||
| private readonly observedUserNames = new Map<string, string>(); | ||
| private readonly observedChatNames = new Map<string, string>(); | ||
| private readonly observedUserLookups = new Map< | ||
| string, | ||
| Promise<string | undefined> | ||
| >(); | ||
| private readonly observedChatLookups = new Map< | ||
| string, | ||
| Promise<string | undefined> | ||
| >(); | ||
| ``` | ||
|
|
||
| Populate later envelopes synchronously from successful caches: | ||
|
|
||
| ```ts | ||
| 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 } : {}), | ||
| }; | ||
| ``` | ||
|
|
||
| Override the base hook without returning or awaiting the lookup promise: | ||
|
|
||
| ```ts | ||
| protected override onObservedContact(envelope: Envelope): void { | ||
| void this.enrichObservedContact(envelope); | ||
| } | ||
| ``` | ||
|
|
||
| Implement user and chat request helpers with `AbortSignal.timeout(15_000)`. Convert `ou_` to `open_id`, `on_` to `union_id`, and other IDs to `user_id`. Return `undefined` for missing tokens, non-2xx responses, non-zero API codes, malformed JSON, empty names, and thrown errors without writing logs. Store the promise before awaiting it so concurrent messages share the same request; retain resolved `undefined` promises to suppress retries until restart. | ||
|
|
||
| After `Promise.all`, call: | ||
|
|
||
| ```ts | ||
| await this.recordObservedContact({ | ||
| ...envelope, | ||
| ...(senderName ? { senderName } : {}), | ||
| ...(chatName ? { chatName } : {}), | ||
| }); | ||
| ``` | ||
|
|
||
| only when at least one lookup returned a name. | ||
|
|
||
| - [ ] **Step 5: Run Feishu tests and verify GREEN** | ||
|
|
||
| Run: | ||
|
|
||
| ```bash | ||
| cd packages/channels/feishu | ||
| npx vitest run src/adapter.test.ts | ||
| ``` | ||
|
|
||
| Expected: all Feishu adapter tests pass with no unexpected output from the new failure cases. | ||
|
|
||
| ### Task 3: Verify, review, and publish | ||
|
|
||
| **Files:** | ||
|
|
||
| - Verify: `packages/channels/base/src/ChannelBase.ts` | ||
| - Verify: `packages/channels/base/src/ChannelBase.test.ts` | ||
| - Verify: `packages/channels/feishu/src/FeishuAdapter.ts` | ||
| - Verify: `packages/channels/feishu/src/adapter.test.ts` | ||
| - Include: `docs/design/feishu-observed-contact-label-enrichment.md` | ||
| - Include: `docs/plans/feishu-observed-contact-label-enrichment.md` | ||
|
|
||
| **Interfaces:** | ||
|
|
||
| - Links: GitHub issue `QwenLM/qwen-code#8566`. | ||
| - Produces: one draft pull request from `BenGuanRan:feat/feishu-observed-contact-labels` to `QwenLM/qwen-code:main`. | ||
|
|
||
| - [ ] **Step 1: Run focused verification** | ||
|
|
||
| ```bash | ||
| 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...HEAD | ||
| ``` | ||
|
|
||
| - [ ] **Step 2: Self-audit the complete diff** | ||
|
|
||
| Read `git diff origin/main...HEAD` in open-ended passes. Check every changed behavior against the design, verify the tests would fail if the hook or enrichment write were removed, and stop after two consecutive clean passes. | ||
|
|
||
| - [ ] **Step 3: Run the Codex review workflow** | ||
|
|
||
| Review the exact branch diff against `origin/main`. Triage every finding as valid, false positive, or overthinking; accepted fixes return to focused tests and self-audit. | ||
|
|
||
| - [ ] **Step 4: Commit after explicit staging and commit authorization** | ||
|
|
||
| ```bash | ||
| git add -- packages/channels/base/src/ChannelBase.ts packages/channels/base/src/ChannelBase.test.ts packages/channels/feishu/src/FeishuAdapter.ts packages/channels/feishu/src/adapter.test.ts docs/plans/feishu-observed-contact-label-enrichment.md | ||
| git commit -m "feat(feishu): enrich observed contact labels" | ||
| ``` | ||
|
|
||
| - [ ] **Step 5: Push after explicit push authorization** | ||
|
|
||
| ```bash | ||
| git push -u fork feat/feishu-observed-contact-labels | ||
| ``` | ||
|
|
||
| - [ ] **Step 6: Create the authorized draft PR with the repository template** | ||
|
|
||
| Create exactly one draft PR with title `feat(feishu): enrich observed contact labels`, base `QwenLM/qwen-code:main`, head `BenGuanRan:feat/feishu-observed-contact-labels`, and `Closes #8566`. Fill every English template section and provide a complete paragraph-for-paragraph Chinese translation in the `<details>` block. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.