From a9a6ca4ac6847e1c1fa9ebeb4e251b78176f4ece Mon Sep 17 00:00:00 2001 From: BaboBen <117555359+BenGuanRan@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:26:12 +0800 Subject: [PATCH] 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 Co-authored-by: qwen-code-dev-bot --- ...07-17-observed-channel-delivery-targets.md | 2 +- ...2026-07-18-observed-channel-group-names.md | 50 +++++++++++++++ ...2026-07-18-observed-channel-group-names.md | 62 +++++++++++++++++++ docs/users/features/channels/overview.md | 2 +- packages/channels/base/README.md | 1 + .../channels/base/src/ChannelBase.test.ts | 36 ++++++++++- packages/channels/base/src/ChannelBase.ts | 9 ++- packages/channels/base/src/types.ts | 1 + .../dingtalk/src/DingtalkAdapter.test.ts | 32 ++++++++++ .../channels/dingtalk/src/DingtalkAdapter.ts | 8 +++ .../telegram/src/TelegramAdapter.test.ts | 32 +++++++++- .../channels/telegram/src/TelegramAdapter.ts | 3 +- 12 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 docs/design/2026-07-18-observed-channel-group-names.md create mode 100644 docs/plans/2026-07-18-observed-channel-group-names.md diff --git a/docs/design/2026-07-17-observed-channel-delivery-targets.md b/docs/design/2026-07-17-observed-channel-delivery-targets.md index 1e304520b9..bbe5e4a408 100644 --- a/docs/design/2026-07-17-observed-channel-delivery-targets.md +++ b/docs/design/2026-07-17-observed-channel-delivery-targets.md @@ -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. diff --git a/docs/design/2026-07-18-observed-channel-group-names.md b/docs/design/2026-07-18-observed-channel-group-names.md new file mode 100644 index 0000000000..448d81a505 --- /dev/null +++ b/docs/design/2026-07-18-observed-channel-group-names.md @@ -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`. diff --git a/docs/plans/2026-07-18-observed-channel-group-names.md b/docs/plans/2026-07-18-observed-channel-group-names.md new file mode 100644 index 0000000000..317e21d4f3 --- /dev/null +++ b/docs/plans/2026-07-18-observed-channel-group-names.md @@ -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. diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 333a6592cb..992c40f093 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -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 { diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md index 0f9db6d7b1..9ad82d2225 100644 --- a/packages/channels/base/README.md +++ b/packages/channels/base/README.md @@ -320,6 +320,7 @@ interface Envelope { senderId: string; // stable, unique sender ID senderName: string; // display name chatId: string; // distinguishes DMs from groups + chatName?: string; // inbound group display name, when provided text: string; // message text (@mentions stripped) messageId?: string; // platform message ID threadId?: string; // for thread-scoped sessions diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts index c02eabe051..a3a7c9da84 100644 --- a/packages/channels/base/src/ChannelBase.test.ts +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -690,6 +690,7 @@ describe('ChannelBase', () => { await ch.processAfterAdapterPreflight( envelope({ chatId: 'group-1', + chatName: 'Project Group', threadId: 'topic-1', isGroup: true, isMentioned: true, @@ -698,11 +699,44 @@ describe('ChannelBase', () => { expect(observe).toHaveBeenCalledWith('test-chan', { user: { id: 'user1', label: 'User 1' }, - group: { id: 'group-1', label: 'group-1' }, + group: { id: 'group-1', label: 'Project Group' }, topic: { id: 'topic-1', label: 'topic-1' }, }); }); + it('falls back to the complete group ID for an unusable group name', async () => { + const observe = vi.fn(); + const ch = createChannel( + { groupPolicy: 'open' }, + { observedContacts: { observe } }, + ); + + await ch.processAfterAdapterPreflight( + envelope({ + chatId: 'group-1', + chatName: '\u0000\n', + isGroup: true, + isMentioned: true, + }), + ); + + expect(observe).toHaveBeenCalledWith('test-chan', { + user: { id: 'user1', label: 'User 1' }, + group: { id: 'group-1', label: 'group-1' }, + }); + }); + + it('ignores a chat name on direct messages', async () => { + const observe = vi.fn(); + const ch = createChannel({}, { observedContacts: { observe } }); + + await ch.handleInbound(envelope({ chatName: 'Not a group' })); + + expect(observe).toHaveBeenCalledWith('test-chan', { + user: { id: 'user1', label: 'User 1' }, + }); + }); + it('records the same inbound envelope only once', async () => { const observe = vi.fn(); const ch = createChannel({}, { observedContacts: { observe } }); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index ce122be8d6..4fc74bb66f 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -3859,11 +3859,18 @@ export abstract class ChannelBase { sanitizedSenderName === 'unknown' ? envelope.senderId : sanitizedSenderName || envelope.senderId; + const sanitizedChatName = envelope.chatName + ? sanitizeSenderName(envelope.chatName) + : ''; + const groupLabel = + sanitizedChatName === 'unknown' + ? envelope.chatId + : sanitizedChatName || envelope.chatId; const observation: ObservedChannelContactObservation = { user: { id: envelope.senderId, label: userLabel }, ...(envelope.isGroup ? { - group: { id: envelope.chatId, label: envelope.chatId }, + group: { id: envelope.chatId, label: groupLabel }, ...(envelope.threadId ? { topic: { diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts index 7988b62113..8c9dfbca10 100644 --- a/packages/channels/base/src/types.ts +++ b/packages/channels/base/src/types.ts @@ -100,6 +100,7 @@ export interface Envelope { senderId: string; senderName: string; chatId: string; + chatName?: string; text: string; threadId?: string; /** Platform-specific message ID for response correlation. */ diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts index c0fca18525..d1111323ee 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.test.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.test.ts @@ -1002,6 +1002,38 @@ describe('DingtalkChannel unroutable-message logging', () => { }); describe('DingtalkChannel parsed-message logging', () => { + it('forwards the inbound conversation title as the group name', () => { + const channel = createChannel(); + const downstream = { + data: JSON.stringify({ + msgId: 'group-name-m1', + conversationType: '2', + conversationId: 'cid123', + conversationTitle: 'Project Group', + sessionWebhook: + 'https://oapi.dingtalk.com/robot/send?access_token=token', + senderNick: 'Alice', + senderStaffId: 'staff-1', + senderId: 'sender-1', + isInAtList: true, + text: { content: '@qwen-code hello' }, + }), + headers: { messageId: 'group-name-m1' }, + } as unknown as DWClientDownStream; + + ( + channel as unknown as { onMessage(d: DWClientDownStream): void } + ).onMessage(downstream); + + expect(channel.handleInbound).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: 'cid123', + chatName: 'Project Group', + isGroup: true, + }), + ); + }); + it('logs debug payloads when enabled for the channel', () => { const oldDebugPayload = process.env['QWEN_CHANNEL_DEBUG_PAYLOAD']; process.env['QWEN_CHANNEL_DEBUG_PAYLOAD'] = 'test-dingtalk'; diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts index 779d86d9f1..0ff623767d 100644 --- a/packages/channels/dingtalk/src/DingtalkAdapter.ts +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -55,6 +55,7 @@ interface DingTalkMessageData { msgtype?: string; conversationType?: string; conversationId?: string; + conversationTitle?: string; sessionWebhook?: string; senderId?: string; senderStaffId?: string; @@ -1231,6 +1232,10 @@ export class DingtalkChannel extends ChannelBase { typeof data.conversationId === 'string' ? data.conversationId : undefined; + const conversationTitle = + typeof data.conversationTitle === 'string' + ? data.conversationTitle + : undefined; const isMentioned = Boolean(data.isInAtList); const senderNick = typeof data.senderNick === 'string' ? data.senderNick : undefined; @@ -1310,6 +1315,9 @@ export class DingtalkChannel extends ChannelBase { senderId, senderName, chatId, + ...(isGroup && conversationTitle + ? { chatName: conversationTitle } + : {}), text: envelopeText, isGroup, isMentioned, diff --git a/packages/channels/telegram/src/TelegramAdapter.test.ts b/packages/channels/telegram/src/TelegramAdapter.test.ts index a305bffe99..e1e50b6895 100644 --- a/packages/channels/telegram/src/TelegramAdapter.test.ts +++ b/packages/channels/telegram/src/TelegramAdapter.test.ts @@ -14,7 +14,7 @@ type LifecycleBase = Omit< type TestTelegramMessage = { from: { id: number; first_name: string; last_name?: string }; - chat: { id: number; type: string }; + chat: { id: number; type: string; title?: string }; message_thread_id?: number; reply_to_message?: { from?: { id: number }; text?: string }; }; @@ -387,6 +387,36 @@ describe('TelegramChannel', () => { expect(topicMessage.threadId).toBe('42'); }); + it('preserves group and supergroup display names in envelopes', () => { + const channel = createChannel(); + + const groupMessage = channel.buildTestEnvelope( + { + from: { id: 1, first_name: 'User' }, + chat: { id: 2, type: 'group', title: 'Project Group' }, + }, + 'group message', + ); + const supergroupMessage = channel.buildTestEnvelope( + { + from: { id: 1, first_name: 'User' }, + chat: { id: 3, type: 'supergroup', title: 'Project Supergroup' }, + }, + 'supergroup message', + ); + const privateMessage = channel.buildTestEnvelope( + { + from: { id: 1, first_name: 'User' }, + chat: { id: 1, type: 'private', title: 'Ignored Title' }, + }, + 'direct message', + ); + + expect(groupMessage.chatName).toBe('Project Group'); + expect(supergroupMessage.chatName).toBe('Project Supergroup'); + expect(privateMessage.chatName).toBeUndefined(); + }); + it('sends proactive messages back to the Telegram forum topic', async () => { const channel = createChannel(); const bot = installFakeBot(channel); diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index d1a8dd095b..afb127459e 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -391,7 +391,7 @@ export class TelegramChannel extends ChannelBase { private buildEnvelope( msg: { from: { id: number; first_name: string; last_name?: string }; - chat: { id: number; type: string }; + chat: { id: number; type: string; title?: string }; message_thread_id?: number; reply_to_message?: { from?: { id: number }; text?: string }; }, @@ -436,6 +436,7 @@ export class TelegramChannel extends ChannelBase { msg.from.first_name + (msg.from.last_name ? ` ${msg.from.last_name}` : ''), chatId: String(msg.chat.id), + ...(isGroup && msg.chat.title ? { chatName: msg.chat.title } : {}), threadId: typeof msg.message_thread_id === 'number' ? String(msg.message_thread_id)