Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions docs/design/feishu-observed-contact-label-enrichment.md
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
Comment thread
BenGuanRan marked this conversation as resolved.
Resolved
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.
290 changes: 290 additions & 0 deletions docs/plans/feishu-observed-contact-label-enrichment.md
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.
Loading
Loading