Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
137 changes: 137 additions & 0 deletions docs/design/2026-07-17-observed-channel-delivery-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
# Workspace-scoped observed channel contacts

## Problem

Daemon-managed channel workers receive platform user, group, and topic identifiers on inbound messages, but the identifiers are transient. Authenticated workspace clients need a read API that lists recently observed IM contacts so a user can select a complete platform delivery target without manually finding or retyping identifiers.

## Scope

This change observes accepted inbound messages, persists a bounded relationship graph per daemon workspace, and returns complete platform identifiers for DingTalk, Feishu, Telegram, and WeCom channels.

It does not change webhook configuration or proactive delivery, query a platform directory, claim to return complete group membership, observe bot output, or backfill historical traffic. Standalone `qwen channel start` is unchanged.

## Ownership and persistence

The daemon workspace runtime owns the registry:

```text
$QWEN_HOME/channels/daemon/<workspaceHash>/observed-contacts.json
```

`QWEN_HOME` is process-level, but `<workspaceHash>` partitions data by canonical workspace path. The registry is not stored in the workspace checkout and is not shared as one process-global graph. Its directory uses mode `0700` where supported; the atomic JSON file uses mode `0600`.

The registry stores at most 500 relationship observations across all channels and conversations in the workspace. Each observation contains `channelName`, a user identity, an optional group identity, an optional topic identity, and `lastObservedAt`. The deduplication key is `[channelName, user.id, group?.id, topic?.id]`. A noisy conversation can therefore evict older observations from another conversation. Observations older than the maximum 365-day readable window are removed on the next accepted write.

## Observation boundary

Recording occurs after the shared inbound preflight accepts a real IM message and before command or Agent handling begins. Direct/group policy, mention, sender allowlist, and pairing rejection therefore happen before persistence.

The same `Envelope` object is recorded at most once. A later message refreshes the matching relationship timestamp and labels. Persistence is best-effort: a sanitized error is logged without identifiers, and accepted message handling continues.

The registry never stores message text, message IDs, attachments, payloads, credentials, webhook requests, proactive sends, or bot output.

## Relationship model

```ts
interface ObservedChannelContactObservation {
user: { id: string; label: string };
group?: { id: string; label: string };
topic?: { id: string; label: string };
}
```

- A direct message records a top-level user from the complete platform `senderId`.
- A group message records the group from the complete platform `chatId` and the observed user inside that group.
- A threaded group message also records the topic from `threadId` and the observed user inside that topic.
- A user seen only in groups does not appear in top-level `users`. If the same user also sends a direct message, it appears both at the top level and under the relevant groups.
- `groups[].users` and `groups[].topics[].users` mean users observed in those conversations. They are not authoritative platform membership lists.
- Sender labels use the sanitized inbound display name, falling back to the complete user ID. The current common envelope has no portable group/topic display name, so those labels fall back to their complete IDs.

Feishu maps `root_id` to `threadId`; Telegram maps `message_thread_id` to `threadId`. Current DingTalk and WeCom envelopes do not expose a stable topic identifier, so their observations stop at the group level.

## Freshness

People, conversations, and relationships change. The read API filters observations rather than presenting the registry as permanent truth:

- default freshness: seven days;
- caller override: `freshWithinSeconds`, from 1 second through 365 days;
- user, group-user, topic-user, group, and topic timestamps are derived independently from recent observations;
- passive observation cannot immediately detect a leave, deletion, or rename that produces no new message, so stale relationships disappear only when they exceed the requested window.

## Read API

Primary workspace:

```http
GET /workspace/channel/observed-contacts?freshWithinSeconds=604800
Authorization: Bearer <daemon token>
```

Selected registered workspace:

```http
GET /workspaces/:workspace/channel/observed-contacts?freshWithinSeconds=604800
Authorization: Bearer <daemon token>
```

Example:

```json
{
"users": [
{
"channelName": "feishu-main",
"label": "Example User",
"id": "ou_complete_user_id",
"lastObservedAt": "2026-07-17T08:00:00.000Z"
}
],
"groups": [
{
"channelName": "feishu-main",
"label": "oc_complete_chat_id",
"id": "oc_complete_chat_id",
"lastObservedAt": "2026-07-17T08:05:00.000Z",
"users": [
{
"label": "Example User",
"id": "ou_complete_user_id",
"lastObservedAt": "2026-07-17T08:05:00.000Z"
}
],
"topics": [
{
"label": "om_complete_root_id",
"id": "om_complete_root_id",
"lastObservedAt": "2026-07-17T08:05:00.000Z",
"users": [
{
"label": "Example User",
"id": "ou_complete_user_id",
"lastObservedAt": "2026-07-17T08:05:00.000Z"
}
]
}
]
}
]
}
```

Responses use `Cache-Control: no-store`. The primary route reads only the primary workspace partition. The qualified route requires an exact registered, trusted runtime and never falls back to primary for unknown, untrusted, bootstrapping, draining, or removed workspaces.

A missing registry returns an empty graph. Malformed data returns a sanitized `500` with code `channel_observed_contacts_unavailable`. Delete the workspace's `observed-contacts.json` file to reset a malformed or unsupported registry; accepted traffic recreates it. Invalid freshness returns `400 invalid_freshness`.

Clients discover the route through the `workspace_channel_observed_contacts` serve capability. The route is read-only and is registered after daemon bearer authentication.

## Compatibility

Webhook parsing, requests, target resolution, and delivery are identical to `main`. This API only exposes observed identifiers; callers decide how to use them. The registry begins at schema version 1 because the earlier opaque-reference prototype was never released.

## Test strategy

- Base-channel tests cover the preflight boundary, topic normalization, Envelope deduplication, and non-blocking persistence failures.
- Store tests cover direct-versus-group semantics, group/topic relationships, freshness, refreshes, bounds, permissions, and malformed data.
- Route tests cover complete identifiers, no-store responses, freshness validation, exact workspace ownership, and sanitized failures.
- Server tests cover bearer authentication and capability advertisement.
- Webhook regression tests verify no behavior differs from `main`.
70 changes: 70 additions & 0 deletions docs/plans/2026-07-17-observed-channel-delivery-targets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# Observed Channel Contacts 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.

**Goal:** Expose a fresh, workspace-scoped graph of dynamically observed direct users, groups, topics, and their observed users with complete platform identifiers.

**Architecture:** `ChannelBase` normalizes accepted inbound envelopes into relationship observations. The daemon worker writes them to a bounded workspace-partitioned JSON registry. Authenticated read-only routes derive a fresh graph for the exact workspace runtime. Webhook behavior remains unchanged.

## Constraints

- Persist under `$QWEN_HOME/channels/daemon/<workspaceHash>/observed-contacts.json`.
- Record after inbound preflight and before command or Agent handling.
- Record each Envelope once; never record rejected input, bot output, proactive sends, or webhook traffic.
- Return complete IDs and sanitized/fallback labels.
- Top-level `users` contains direct-message users only.
- `groups[].users` and `groups[].topics[].users` are observed relationships, not authoritative membership.
- Default freshness is seven days; accept `freshWithinSeconds` from 1 second through 365 days.
- Keep at most 500 most-recent relationship observations.
- Preserve exact workspace ownership and never fall back on qualified routes.
- Do not change webhook configuration, requests, or delivery.

## Task 1: Base observation contract

**Files:** `packages/channels/base/src/types.ts`, `ChannelBase.ts`, `ChannelBase.test.ts`, `index.ts`

- [x] Add identity, observation, graph, group, topic, and related-user types.
- [x] Add `observedContacts.observe` to `ChannelBaseOptions`.
- [x] Normalize `senderId`, `senderName`, `chatId`, and `threadId` after successful preflight.
- [x] Deduplicate the same Envelope object and keep persistence failures non-blocking.
- [x] Cover direct, group, topic, rejection, pairing, duplicate Envelope, and failure cases.

## Task 2: Workspace relationship store

**Files:** `packages/cli/src/commands/channel/observed-contact-store.ts`, `observed-contact-store.test.ts`, `daemon-worker.ts`, `daemon-worker.test.ts`

- [x] Persist version 1 relationship observations with atomic mode-`0600` writes.
- [x] Deduplicate by channel, user, group, and topic; refresh labels and timestamps.
- [x] Derive direct users separately from group and topic relationships.
- [x] Filter stale observations and expose independent relationship timestamps.
- [x] Enforce validation and the 500-observation bound.
- [x] Wire daemon-managed channels to the workspace-partitioned store.

## Task 3: Authenticated dynamic-observation API

**Files:** `packages/cli/src/serve/routes/workspace-channel-observed-contacts.ts`, its test, `server.ts`, `server.test.ts`, `capabilities.ts`

- [x] Add singular and qualified `/channel/observed-contacts` GET routes.
- [x] Parse `freshWithinSeconds`, default to seven days, and reject invalid values.
- [x] Return `{users, groups}` with nested group/topic users and complete IDs.
- [x] Add `Cache-Control: no-store` and sanitized failure responses.
- [x] Require exact trusted workspace resolution on qualified routes.
- [x] Advertise `workspace_channel_observed_contacts`.
- [x] Run the focused server authentication and capability tests.

## Task 4: Remove prototype webhook integration

**Files:** `ChannelWebhookTask.ts`, `ChannelBase.ts`, channel config parsing/tests, channel overview documentation

- [x] Remove observed-reference webhook config and resolution.
- [x] Restore concrete webhook behavior to `origin/main`.
- [x] Verify webhook production files have no diff from `origin/main`.
- [x] Replace prototype documentation with the observed-contacts API.

## Task 5: Verify and publish

- [x] Run Prettier on changed files.
- [x] Run focused base, CLI store, daemon-worker, route, server, runtime, and webhook tests.
- [x] Run `npm run build && npm run typecheck`.
- [x] Audit the full diff twice, including untracked files.
- [x] Commit, push `feat/channel-observed-targets`, and update Draft PR #7109.
42 changes: 42 additions & 0 deletions docs/users/features/channels/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,48 @@ Example channel config:

For DingTalk, set `isGroup` explicitly on every target. A direct-message target uses the DingTalk user ID as `chatId` with `isGroup: false`; a group target uses the group `openConversationId` with `isGroup: true`. Other adapters may require their own proactive target shape.

Daemon-managed DingTalk, Feishu, Telegram, and WeCom channels dynamically observe contacts from authorized inbound messages. List contacts observed in the primary workspace during the default seven-day freshness window:

```bash
curl -H "Authorization: Bearer $QWEN_SERVER_TOKEN" \
http://127.0.0.1:4170/workspace/channel/observed-contacts
```

Use `GET /workspaces/:workspace/channel/observed-contacts` to select another registered, trusted workspace. Add `?freshWithinSeconds=N` to choose a window from one second through 365 days. The daemon advertises this API with the `workspace_channel_observed_contacts` capability.

The response returns complete platform IDs and labels. Each `lastObservedAt` is a canonical ISO 8601 UTC timestamp with millisecond precision; clients can convert it to the user's local time zone for display. Top-level `users` contains users observed in direct messages. `groups` contains observed group conversations, `groups[].users` contains users observed in each group, and `groups[].topics[].users` contains users observed in Feishu or Telegram topics:

```json
{
"users": [
{
"channelName": "feishu-main",
"label": "Example User",
"id": "ou_complete_user_id",
"lastObservedAt": "2026-07-17T08:00:00.000Z"
}
],
"groups": [
{
"channelName": "feishu-main",
"label": "oc_complete_chat_id",
"id": "oc_complete_chat_id",
"lastObservedAt": "2026-07-17T08:05:00.000Z",
"users": [
{
"label": "Example User",
"id": "ou_complete_user_id",
"lastObservedAt": "2026-07-17T08:05:00.000Z"
}
],
"topics": []
}
]
}
```

These nested users are observed participants, not authoritative group membership. Only messages that pass direct/group, mention, sender, and pairing gates are recorded. Repeated observations refresh labels and timestamps; passive observation cannot detect a leave or deletion until the relationship becomes stale. Message content is never stored. The bounded registry lives under `$QWEN_HOME/channels/daemon/<workspaceHash>/observed-contacts.json`, outside the workspace checkout and partitioned per workspace. Its 500-observation limit is shared by all channels and conversations in that workspace, and observations older than 365 days are removed on the next accepted write. If the registry becomes malformed or uses an unsupported version, delete that file to reset it; accepted traffic recreates it. Webhook configuration and delivery are unchanged.

Start `qwen serve` with the channel worker enabled:

```bash
Expand Down
1 change: 1 addition & 0 deletions integration-tests/cli/qwen-serve-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_branch',
'workspace_reload',
'channel_control',
'workspace_channel_observed_contacts',
'persistent_workspace_registration',
'workspace_runtime_removal',
'workspace_qualified_rest_core',
Expand Down
Loading
Loading