feat(feishu): enrich observed contact labels (#8569)

* docs: design feishu observed contact enrichment

* docs: add Chinese Feishu enrichment design

* feat(feishu): enrich observed contact labels

* fix(feishu): preserve enriched contact labels

* fix(feishu): harden observed-contact label enrichment lifecycle

* fix(feishu): bound label caches, honor observation recency, silence enrichment token failures (#8569)

- hydrate runtime label caches from the newest observation per contact so
  stale group membership labels cannot overwrite more recent ones
- cap the user/chat label, in-flight lookup, and write-dedup maps at 500
  entries (matching the persisted registry) and evict oldest entries
- route best-effort label lookups through a silent token refresh path so
  enrichment failures no longer write to stderr
- add tests for silent token refresh, newest-label hydration, cache cap,
  and the persisted-observation reject path in hook ordering

* fix(feishu): address observed-contact label review feedback (#8569)

* Track core (non-silent) waiters on the shared tenant-token refresh so a
  silent-initiated refresh still logs token errors for joined delivery
  callers.
* Short-circuit label lookups on the resolved names cache so evicted
  lookup entries do not trigger redundant API requests.
* Re-hydrate label caches from the persisted registry after an
  in-lifetime cache eviction so the next initial write cannot clobber a
  persisted label with the raw ID.
* Add mutation-proof regression tests for the channel-isolation filter,
  the list-failure swallow, the silent HTTP-error branch, and the
  'unknown' label guard.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci@service.alibaba.com>
This commit is contained in:
BaboBen 2026-08-08 00:41:37 +08:00 committed by GitHub
parent b40719a8bb
commit 028747aa41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1957 additions and 10 deletions

View file

@ -0,0 +1,114 @@
# 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 重
启为止,因 tenant token 获取失败而未能发出的请求以及收到 401 的请求
保持可重试401 同时使缓存的 tenant token 失效。daemon 重启后第一条
入站消息会从已持久化的观测联系人记录回填此前解析出的名称,避免已知
名称在重新查询期间被回退为原始 ID。查询请求的 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 后触发且不会被等待。
飞书适配器测试验证成功补全、进程内去重、后续消息复用名称、daemon 重启
后回填已解析名称,以及失败静默时原始 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 share one lookup lifecycle: process-local caches dedupe
by ID, and concurrent requests for the same ID share one promise. A successful
name is sanitized with the shared sender-name sanitizer and reused on later
envelopes. A failed attempt whose request reached Feishu is retained until the
daemon restarts; attempts never issued because tenant-token acquisition failed,
and requests answered with 401, remain retryable, and a 401 also invalidates
the cached tenant token. The first inbound message after a daemon restart
hydrates labels resolved by earlier runs from the persisted observed-contact
registry, so known names are not reverted to raw IDs while the fresh lookup is
pending. 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, hydration of
previously resolved labels after a daemon restart, and silent failure while the
original ID-based observation and inbound processing remain available.

View file

@ -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.

View file

@ -38,6 +38,15 @@ Enable the following permissions under **Permissions & Scopes** (权限管理):
- `im:message:send_as_bot` — Send messages as bot
- `im:resource` — Access message resources (images, files)
To show user and group names instead of IDs in daemon-discovered contacts,
optionally enable:
- `contact:user.basic_profile:readonly` — Read user display names
- `im:chat:readonly` — Read group names
Without these optional permissions, messages still work and discovered contacts
keep their Feishu user and chat IDs as labels.
### Publish the Application
After configuring permissions and events, create a version and publish it. The bot won't work until the application is published and approved.

View file

@ -916,6 +916,68 @@ describe('ChannelBase', () => {
expect(bridge.prompt).toHaveBeenCalled();
});
it('notifies the adapter after an approved contact is persisted', async () => {
const order: string[] = [];
class ObservedHookChannel extends TestChannel {
readonly observedEnvelopes: Envelope[] = [];
protected override onObservedContact(envelope: Envelope): void {
order.push('hook');
this.observedEnvelopes.push(envelope);
}
}
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', 'hook']);
expect(ch.observedEnvelopes).toEqual([message]);
expect(bridge.prompt).toHaveBeenCalled();
});
it('still notifies the adapter after a rejected contact persistence', async () => {
const order: string[] = [];
class ObservedHookChannel extends TestChannel {
readonly observedEnvelopes: Envelope[] = [];
protected override onObservedContact(envelope: Envelope): void {
order.push('hook');
this.observedEnvelopes.push(envelope);
}
}
const observe = vi.fn(async () => {
throw new Error('persistence unavailable');
});
const stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
const ch = new ObservedHookChannel('test-chan', defaultConfig(), bridge, {
observedContacts: { observe },
});
const message = envelope();
await ch.handleInbound(message);
const stderrOutput = stderrSpy.mock.calls
.map((call) => String(call[0]))
.join('');
stderrSpy.mockRestore();
expect(observe).toHaveBeenCalledTimes(1);
expect(order).toEqual(['hook']);
expect(ch.observedEnvelopes).toEqual([message]);
expect(bridge.prompt).toHaveBeenCalled();
expect(stderrOutput).toContain('observed contact persistence failed');
});
it('falls back to the complete sender ID for an unusable label', async () => {
const observe = vi.fn();
const ch = createChannel({}, { observedContacts: { observe } });

View file

@ -20,6 +20,7 @@ import type {
ChannelUserQuestion,
DispatchMode,
Envelope,
ObservedChannelContactGraph,
ObservedChannelContactObservation,
SanitizedToolCallEvent,
SessionTarget,
@ -230,6 +231,8 @@ export interface ChannelBaseOptions {
channelName: string,
observation: ObservedChannelContactObservation,
): void | Promise<void>;
/** Read persisted observations so adapters can hydrate label caches. */
list?(): ObservedChannelContactGraph;
};
}
@ -4927,7 +4930,7 @@ export abstract class ChannelBase {
await this.processInbound(envelope);
}
private async recordObservedContact(envelope: Envelope): Promise<void> {
protected async recordObservedContact(envelope: Envelope): Promise<void> {
if (!this.observedContacts) return;
const sanitizedSenderName = envelope.senderName
? sanitizeSenderName(envelope.senderName)
@ -4968,6 +4971,29 @@ export abstract class ChannelBase {
}
}
protected onObservedContact(_envelope: Envelope): void {}
/**
* Observations persisted for this channel, when a read path is configured.
* Adapters hydrate label caches from it after a restart so known labels are
* not reverted to raw IDs by the next initial write.
*/
protected persistedObservedContacts():
| ObservedChannelContactGraph
| undefined {
const list = this.observedContacts?.list;
if (!list) return undefined;
try {
const graph = list();
return {
users: graph.users.filter((user) => user.channelName === this.name),
groups: graph.groups.filter((group) => group.channelName === this.name),
};
} catch {
return undefined;
}
}
protected markPreflighted(envelope: Envelope): void {
this.preflightedEnvelopes.add(envelope);
}
@ -5000,6 +5026,7 @@ export abstract class ChannelBase {
if (this.observedContacts && !this.observedContactEnvelopes.has(envelope)) {
this.observedContactEnvelopes.add(envelope);
await this.recordObservedContact(envelope);
this.onObservedContact(envelope);
}
let memoryIntent: ResolvedChannelMemoryIntent | null =

View file

@ -10,6 +10,7 @@ import {
ChannelProactiveDeliveryError,
isChannelProactiveDeliveryError,
isTerminalTaskLifecycleType,
sanitizeSenderName,
} from '@qwen-code/channel-base';
import { buildCardContent, extractTitle, splitChunks } from './markdown.js';
import { downloadMedia } from './media.js';
@ -83,6 +84,12 @@ interface CardSessionState {
/** Track seen message IDs to deduplicate retried events. */
const DEDUP_TTL_MS = 5 * 60 * 1000;
/**
* Runtime label/lookup caches are bounded like the persisted observed-contact
* registry (500 observations) so a long-running daemon does not retain every
* user/chat/thread ID it ever sees.
*/
const OBSERVED_LABEL_CACHE_LIMIT = 500;
/** Minimum interval between card updates (ms) to avoid API rate limiting. */
const CARD_UPDATE_INTERVAL_MS = 1500;
@ -142,6 +149,24 @@ export class FeishuChannel extends ChannelBase {
private botOpenId?: string;
private tokenCache?: { token: string; expiresAt: number };
private tokenRefreshPromise?: Promise<string | undefined>;
// Core (non-silent) callers waiting on the shared token refresh, so a
// silent-initiated refresh still logs token errors for them.
private tokenRefreshHasCoreWaiters = false;
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>
>();
private readonly observedContactWrites = new Map<
string,
{ senderName: string; chatName: string | undefined }
>();
private hydratedObservedNames = false;
private collapsible: boolean;
private collapsibleThreshold: number;
@ -621,21 +646,27 @@ export class FeishuChannel extends ChannelBase {
return text.trim() || undefined;
}
private async getTenantAccessToken(): Promise<string | undefined> {
private async getTenantAccessToken(options?: {
silent?: boolean;
}): Promise<string | undefined> {
if (this.tokenCache && Date.now() < this.tokenCache.expiresAt) {
return this.tokenCache.token;
}
if (!options?.silent) this.tokenRefreshHasCoreWaiters = true;
if (this.tokenRefreshPromise) return this.tokenRefreshPromise;
this.tokenRefreshPromise = this.refreshToken();
try {
return await this.tokenRefreshPromise;
} finally {
this.tokenRefreshPromise = undefined;
this.tokenRefreshHasCoreWaiters = false;
}
}
private async refreshToken(): Promise<string | undefined> {
// Best-effort label enrichment initiates silent refreshes; failures must
// still surface when a core delivery caller initiated or joined it.
try {
const resp = await fetch(
`${BASE_URL}/auth/v3/tenant_access_token/internal`,
@ -651,9 +682,11 @@ export class FeishuChannel extends ChannelBase {
);
if (!resp.ok) {
process.stderr.write(
`[Feishu:${this.name}] getTenantAccessToken failed: HTTP ${resp.status}\n`,
);
if (this.tokenRefreshHasCoreWaiters) {
process.stderr.write(
`[Feishu:${this.name}] getTenantAccessToken failed: HTTP ${resp.status}\n`,
);
}
if (resp.status === 401) this.tokenCache = undefined;
return undefined;
}
@ -669,13 +702,213 @@ export class FeishuChannel extends ChannelBase {
};
return this.tokenCache.token;
} catch (err) {
process.stderr.write(
`[Feishu:${this.name}] getTenantAccessToken error: ${err}\n`,
);
if (this.tokenRefreshHasCoreWaiters) {
process.stderr.write(
`[Feishu:${this.name}] getTenantAccessToken error: ${err}\n`,
);
}
return undefined;
}
}
private hydrateObservedNames(): void {
if (this.hydratedObservedNames) return;
this.hydratedObservedNames = true;
const graph = this.persistedObservedContacts();
if (!graph) return;
// Select the newest non-ID label per contact so an older observation
// (for example a stale group membership) cannot overwrite a more recent
// one during the traversal.
const newestUser = new Map<string, { label: string; at: string }>();
const newestChat = new Map<string, { label: string; at: string }>();
const consider = (
best: Map<string, { label: string; at: string }>,
id: string,
label: string,
at: string,
): void => {
if (label === id) return;
const current = best.get(id);
if (!current || at >= current.at) best.set(id, { label, at });
};
for (const user of graph.users) {
consider(newestUser, user.id, user.label, user.lastObservedAt);
}
for (const group of graph.groups) {
consider(newestChat, group.id, group.label, group.lastObservedAt);
for (const member of group.users) {
consider(newestUser, member.id, member.label, member.lastObservedAt);
}
}
for (const [id, entry] of newestUser) {
this.observedUserNames.set(id, entry.label);
}
for (const [id, entry] of newestChat) {
this.observedChatNames.set(id, entry.label);
}
this.capObservedCache(this.observedUserNames);
this.capObservedCache(this.observedChatNames);
}
/** Evicts the oldest-inserted entries once a runtime cache exceeds the cap. */
private capObservedCache(cache: Map<string, unknown>): boolean {
let evicted = false;
while (cache.size > OBSERVED_LABEL_CACHE_LIMIT) {
const oldest = cache.keys().next();
if (oldest.done) break;
cache.delete(oldest.value);
evicted = true;
}
return evicted;
}
private observedUserName(userId: string): Promise<string | 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;
},
});
}
private observedChatName(chatId: string): Promise<string | 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;
},
});
}
private observedNameLookup(options: {
lookups: Map<string, Promise<string | undefined>>;
names: Map<string, string>;
id: string;
request: (token: string) => Promise<Response>;
extractName: (body: unknown) => string | undefined;
}): Promise<string | undefined> {
const cached = options.names.get(options.id);
if (cached) return Promise.resolve(cached);
const existing = options.lookups.get(options.id);
if (existing) return existing;
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;
options.lookups.delete(options.id);
}
return undefined;
}
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);
// Evicting a resolved label drops the next envelope back to the raw
// ID, and the initial persistence write would clobber the persisted
// label, so re-hydrate from the registry on the next message.
if (this.capObservedCache(options.names)) {
this.hydratedObservedNames = false;
}
return label;
} catch {
return undefined;
}
})();
options.lookups.set(options.id, lookup);
this.capObservedCache(options.lookups);
return lookup;
}
protected 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(() => {});
}
private observedContactKey(envelope: Envelope): string {
return envelope.isGroup
? `${envelope.senderId}\u0000${envelope.chatId}\u0000${
envelope.threadId ?? ''
}`
: envelope.senderId;
}
private async enrichObservedContact(envelope: Envelope): Promise<void> {
const [senderName, chatName] = await Promise.all([
this.observedUserName(envelope.senderId),
envelope.isGroup
? this.observedChatName(envelope.chatId)
: Promise.resolve(undefined),
]);
if (!senderName && !chatName) return;
const key = this.observedContactKey(envelope);
const nextLabels = {
senderName: senderName ?? envelope.senderName,
chatName: chatName ?? envelope.chatName,
};
const persistedLabels = this.observedContactWrites.get(key);
if (
persistedLabels &&
persistedLabels.senderName === nextLabels.senderName &&
persistedLabels.chatName === nextLabels.chatName
) {
return;
}
this.observedContactWrites.set(key, nextLabels);
this.capObservedCache(this.observedContactWrites);
await this.recordObservedContact({
...envelope,
...(senderName ? { senderName } : {}),
...(chatName ? { chatName } : {}),
});
}
async sendMessage(chatId: string, text: string): Promise<void> {
await this.sendMessageInternal(chatId, text, false);
}
@ -1951,6 +2184,9 @@ export class FeishuChannel extends ChannelBase {
sender.sender_id?.user_id ||
sender.sender_id?.union_id ||
'';
this.hydrateObservedNames();
const senderName = this.observedUserNames.get(senderId) || senderId;
const chatName = isGroup ? this.observedChatNames.get(chatId) : undefined;
// Parse message content
const content = this.extractContent(msg.message_type, msg.content);
@ -1995,8 +2231,9 @@ export class FeishuChannel extends ChannelBase {
const envelope: Envelope = {
channelName: this.name,
senderId,
senderName: senderId,
senderName,
chatId,
...(chatName ? { chatName } : {}),
text: cleanText,
messageId: msgId,
threadId: msg.root_id || undefined,

File diff suppressed because it is too large Load diff

View file

@ -40,9 +40,11 @@ const mockDaemonChannelStateDir = vi.hoisted(() =>
),
);
const mockObserveContact = vi.hoisted(() => vi.fn());
const mockListContacts = vi.hoisted(() => vi.fn());
const mockObservedContactStore = vi.hoisted(() =>
vi.fn(() => ({
observe: mockObserveContact,
list: mockListContacts,
})),
);
const mockLoadSettings = vi.hoisted(() =>
@ -246,6 +248,7 @@ vi.mock('./runtime.js', () => ({
}));
vi.mock('./observed-contact-store.js', () => ({
OBSERVED_CONTACT_MAX_FRESH_WITHIN_SECONDS: 365 * 24 * 60 * 60,
ObservedChannelContactStore: mockObservedContactStore,
}));
@ -798,6 +801,7 @@ describe('runChannelDaemonWorker', () => {
channelMemoryRecallObserver: mockRecordChannelMemoryRecallMetrics,
observedContacts: {
observe: expect.any(Function),
list: expect.any(Function),
},
stateDir:
'/tmp/qwen/channels/daemon/workspace-hash/instances/telegram-hash',
@ -814,6 +818,7 @@ describe('runChannelDaemonWorker', () => {
const channelOptions = mockCreateChannel.mock.calls[0]![3] as {
observedContacts: {
observe(channelName: string, observation: unknown): unknown;
list(): unknown;
};
};
const observation = {
@ -822,6 +827,10 @@ describe('runChannelDaemonWorker', () => {
};
channelOptions.observedContacts.observe('telegram', observation);
expect(mockObserveContact).toHaveBeenCalledWith('telegram', observation);
channelOptions.observedContacts.list();
expect(mockListContacts).toHaveBeenCalledWith({
freshWithinSeconds: 365 * 24 * 60 * 60,
});
expect(mockRegisterPermissionRelay).toHaveBeenCalledWith(
bridgeFacade,
mockSessionRouter.mock.results[0]!.value,

View file

@ -84,7 +84,10 @@ import {
type ParsedChannel,
} from './runtime.js';
import { BridgeChannelMemoryIntentClassifier } from './memory-intent-classifier.js';
import { ObservedChannelContactStore } from './observed-contact-store.js';
import {
OBSERVED_CONTACT_MAX_FRESH_WITHIN_SECONDS,
ObservedChannelContactStore,
} from './observed-contact-store.js';
import {
createChannelLoopController,
isChannelCronEnabled,
@ -554,6 +557,10 @@ export async function runChannelDaemonWorker(
observe: (channelName, observation) => {
observedContacts.observe(channelName, observation);
},
list: () =>
observedContacts.list({
freshWithinSeconds: OBSERVED_CONTACT_MAX_FRESH_WITHIN_SECONDS,
}),
},
...(loopController ? { loopController } : {}),
}),