feat(channels): observe group names from inbound messages (#7155)

* feat(channels): expose observed workspace contacts

* fix(channels): address observed contacts review feedback

* docs(channels): design observed group names

* docs(channels): plan observed group names

* feat(channels): observe inbound group names

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
This commit is contained in:
BaboBen 2026-07-18 17:26:12 +08:00 committed by GitHub
parent 769cdf5f1d
commit a9a6ca4ac6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 232 additions and 6 deletions

View file

@ -45,7 +45,7 @@ interface ObservedChannelContactObservation {
- 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.
- Sender labels use the sanitized inbound display name, falling back to the complete user ID. Group labels use a sanitized name when the accepted inbound envelope supplies one; DingTalk maps `conversationTitle` and Telegram maps `chat.title`. Feishu and WeCom group labels, and all topic 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.

View file

@ -0,0 +1,50 @@
# Observed channel group names
## Problem
The workspace-scoped observed-contact graph introduced by #7109 preserves complete platform group IDs, but every `groups[].label` currently falls back to that ID. Some inbound channel callbacks already carry a human-readable group name, and the adapters discard it before the shared observation boundary.
Users selecting a proactive-delivery target need the readable name alongside the complete, stable platform ID. The name is observational metadata, not a routing key.
## Scope
Add an optional group name to the shared inbound envelope and populate it only from metadata already present in an accepted inbound message.
- DingTalk maps the Stream callback's `conversationTitle`.
- Telegram maps the inbound chat's `title` for groups and supergroups.
- Feishu keeps the complete `chat_id` fallback because `im.message.receive_v1` does not include a chat display name.
- Other adapters keep the ID fallback unless their existing inbound payload has a documented group-name field.
This change does not call a platform directory, group-detail, or chat-info API; add permissions; alter routing or session identity; discover authoritative membership; observe bot output; or add topic names.
## Contract
`Envelope` gains one optional field:
```ts
chatName?: string;
```
The field describes the display name of `chatId` as observed on that message. It is ignored for direct messages. `chatId` remains the complete platform delivery key and continues to determine sessions, deduplication, and graph identity.
The common observation path uses a sanitized, non-empty `chatName` as the group label. Missing or unusable values fall back to the complete `chatId`. The existing registry store bounds persisted labels to 256 UTF-16 code units without splitting surrogate pairs.
## Refresh semantics
An accepted later message for the same channel, user, and group refreshes the observation. If it carries a different usable `chatName`, the existing store replacement semantics update the derived group label without creating another group node. Freshness remains `lastObservedAt`; names are not treated as permanent or authoritative.
A platform that omits a group name on a later message contributes the ID fallback for that observation. Graph derivation already selects the most recent observation, so the returned label represents the newest accepted evidence rather than a hidden long-lived name cache.
## Platform evidence
- DingTalk's Stream robot-message example includes `conversationTitle` in the inbound callback: [DingTalk Stream protocol](https://opensource.dingtalk.com/developerpedia/docs/learn/stream/protocol/#%E5%9B%9E%E8%B0%83%E6%8E%A8%E9%80%81).
- Telegram defines `Message.chat` as a `Chat`, whose `title` is available for group chats and supergroups: [Telegram Bot API — Chat](https://core.telegram.org/bots/api/#chat).
- Feishu's receive-message event enumerates `chat_id`, `chat_type`, and `thread_id`, but no chat display name: [Feishu Open Platform — Receive message](https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/events/receive).
## Test strategy
- Base-channel tests prove usable group names propagate, unusable names fall back to complete IDs, direct messages ignore `chatName`, and later observations can refresh labels.
- DingTalk adapter tests prove `conversationTitle` enters the envelope without changing callback handling.
- Telegram adapter tests prove group and supergroup titles enter the envelope while private chats remain unchanged.
- Existing Feishu tests continue to prove the ID fallback path without API traffic.
- Focused store tests cover replacement by newer labels; no schema migration is needed because persisted observations already contain `group.label`.

View file

@ -0,0 +1,62 @@
# Observed Channel Group Names Implementation Plan
> **For agentic workers:** Execute each task test-first and keep the change limited to inbound metadata already supplied by the platform.
**Goal:** Return human-readable observed group labels when an accepted inbound callback already contains a group name, while retaining complete platform IDs and the existing ID fallback.
**Architecture:** Channel adapters copy an optional inbound group name into `Envelope.chatName`. `ChannelBase` sanitizes that observation and writes it as `group.label`; the existing workspace store handles bounds, refresh, freshness, and graph derivation without a schema change. No adapter performs additional network requests.
## Constraints
- Never call a platform directory, group-detail, or chat-info API.
- Keep `chatId` as the routing, session, deduplication, and graph identity key.
- Use `chatName` only for `groups[].label` on group messages.
- Preserve complete-ID fallback for missing or unusable names.
- Implement only platforms with verified inbound fields: DingTalk and Telegram.
- Keep Feishu, WeCom, and topic labels on their existing ID fallback paths.
## Task 1: Shared envelope and observation behavior
**Files:** `packages/channels/base/src/types.ts`, `packages/channels/base/src/ChannelBase.test.ts`, `packages/channels/base/src/ChannelBase.ts`
1. Add failing base-channel tests proving a group `chatName` becomes `group.label`, malformed or empty names fall back to the complete `chatId`, and direct messages ignore `chatName`.
2. Run `cd packages/channels/base && npx vitest run src/ChannelBase.test.ts` and confirm the new assertions fail for the missing contract.
3. Add optional `chatName?: string` to `Envelope`.
4. Sanitize the observed group name at the existing post-preflight observation boundary and fall back to `chatId` when unusable.
5. Re-run the focused base test and confirm it passes.
## Task 2: DingTalk inbound group title
**Files:** `packages/channels/dingtalk/src/DingtalkAdapter.test.ts`, `packages/channels/dingtalk/src/DingtalkAdapter.ts`
1. Add a failing adapter test whose Stream callback contains `conversationTitle` and assert the processed envelope contains `chatName` while preserving `chatId`.
2. Run `cd packages/channels/dingtalk && npx vitest run src/DingtalkAdapter.test.ts` and confirm the new assertion fails.
3. Add `conversationTitle` to the raw inbound type, validate it as a string, and place it on the group envelope.
4. Do not change acknowledgements, webhook caching, routing, logging, or send behavior.
5. Re-run the focused DingTalk test and confirm it passes.
## Task 3: Telegram inbound chat title
**Files:** `packages/channels/telegram/src/TelegramAdapter.test.ts`, `packages/channels/telegram/src/TelegramAdapter.ts`
1. Add failing tests proving group and supergroup `chat.title` values become `chatName`, while a private chat does not expose a group name.
2. Run `cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts` and confirm the new assertions fail.
3. Extend the adapter's local inbound chat shape with optional `title` and copy it only for group or supergroup envelopes.
4. Re-run the focused Telegram test and confirm it passes.
## Task 4: User-facing contract documentation
**Files:** `docs/design/2026-07-17-observed-channel-delivery-targets.md`, `docs/users/features/channels/overview.md`
1. Replace the statement that all group labels fall back to IDs with the best-effort inbound-name behavior.
2. Document that DingTalk and Telegram currently supply names and that Feishu/WeCom retain the complete-ID fallback.
3. Keep topic-label and membership limitations explicit.
## Task 5: Verification and publication
1. Run Prettier on all changed files.
2. Run the focused base, DingTalk, Telegram, and existing observed-contact store tests.
3. Run `npm run lint && npm run typecheck && npm run build`.
4. Inspect the complete diff in two clean self-audit passes; any fix resets the clean-pass count and relevant tests.
5. Commit the implementation, push `feat/channel-observed-group-names`, and open a stacked ready-for-review PR that declares its dependency on #7109 and links #7154.
6. Add the E2E plan/result as a separate PR comment. Exercise the complete DingTalk callback-to-read-API path with a `conversationTitle` payload, use #7109's live DingTalk transport result as the transport precondition, and record Feishu as a negative schema/fallback check rather than making an API request.

View file

@ -495,7 +495,7 @@ curl -H "Authorization: Bearer $QWEN_SERVER_TOKEN" \
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:
The response returns complete platform IDs and labels. Group labels use names already present in accepted inbound messages when available: DingTalk supplies `conversationTitle`, and Telegram supplies `chat.title`. Feishu and WeCom group labels currently fall back to their complete IDs; no platform directory or group-detail API is queried. Topic labels also fall back to complete IDs. 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
{