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
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
50 changes: 50 additions & 0 deletions docs/design/2026-07-18-observed-channel-group-names.md
Original file line number Diff line number Diff line change
@@ -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`.
62 changes: 62 additions & 0 deletions docs/plans/2026-07-18-observed-channel-group-names.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/users/features/channels/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
1 change: 1 addition & 0 deletions packages/channels/base/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion packages/channels/base/src/ChannelBase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@ describe('ChannelBase', () => {
await ch.processAfterAdapterPreflight(
envelope({
chatId: 'group-1',
chatName: 'Project Group',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This changes the former unnamed-group fixture into the named success case, so the documented missing-chatName fallback for Feishu, WeCom, and other adapters is no longer covered. The new control-character case exercises the separate sanitizedChatName === 'unknown' branch, so a regression in the final empty-string fallback could still pass. Add a group observation with chatName omitted and assert the complete chatId is used as the label.

— Codex GPT-5 via Qwen Code /review

threadId: 'topic-1',
isGroup: true,
isMentioned: true,
Expand All @@ -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 } });
Expand Down
9 changes: 8 additions & 1 deletion packages/channels/base/src/ChannelBase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3859,11 +3859,18 @@ export abstract class ChannelBase {
sanitizedSenderName === 'unknown'
? envelope.senderId
: sanitizedSenderName || envelope.senderId;
const sanitizedChatName = envelope.chatName
? sanitizeSenderName(envelope.chatName)
: '';
Comment on lines +3862 to +3864

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Group display names from DingTalk conversationTitle and Telegram chat.title are sanitized through sanitizeSenderName, which hard-truncates to 64 code points — but the downstream store supports labels up to 256 characters and the design doc states the store's 256-character bound is the operative limit.

Failure scenario: A DingTalk group named "Engineering All-Hands — Cross-Team Sync for Platform, Infrastructure, Developer Experience, and Site Reliability Teams" (~95 chars) is truncated to its first 64 code points before reaching the store. The read API returns the truncated name to clients selecting a delivery target. Person names rarely exceed 64 characters, but group/conversation names routinely do on both DingTalk and Telegram.

Suggested fix: Use a dedicated sanitizer for group names that strips the same control characters and unsafe invisibles as sanitizeSenderName but truncates to MAX_LABEL_LENGTH (256) instead of 64.

— qwen3.7-max via Qwen Code /review

const groupLabel =
sanitizedChatName === 'unknown'
? envelope.chatId
: sanitizedChatName || envelope.chatId;
Comment on lines +3862 to +3868

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The label-derivation pattern (sanitize → sentinel check → fallback) is duplicated verbatim from the userLabel block four lines above. When topic labels eventually gain the same inbound-name treatment the design doc defers, a third copy will be added. Any future change to the fallback semantics must be replicated across all copies.

Suggested change
const sanitizedChatName = envelope.chatName
? sanitizeSenderName(envelope.chatName)
: '';
const groupLabel =
sanitizedChatName === 'unknown'
? envelope.chatId
: sanitizedChatName || envelope.chatId;
const userLabel = this.resolveLabel(envelope.senderName, envelope.senderId);
const groupLabel = this.resolveLabel(envelope.chatName, envelope.chatId);

with a private helper:

private resolveLabel(raw: string | undefined, fallback: string): string {
  const sanitized = raw ? sanitizeSenderName(raw) : '';
  return sanitized === 'unknown' ? fallback : sanitized || fallback;
}

— qwen3.7-max via Qwen Code /review

const observation: ObservedChannelContactObservation = {
user: { id: envelope.senderId, label: userLabel },
...(envelope.isGroup
? {
group: { id: envelope.chatId, label: envelope.chatId },
group: { id: envelope.chatId, label: groupLabel },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Variable group labels make the store's equal-timestamp ordering observable. If user A is recorded with the old title and a later user B message carries the renamed title in the same millisecond, their relationship keys differ, both records survive, and the store's timestamp-only stable sort keeps the older record first; graph construction initializes the group from that old label and never updates it. Add an explicit newest-wins tie-breaker (for example, place the new observation before retained equal-timestamp entries) and a same-timestamp, two-user rename regression test.

— Codex GPT-5 via Qwen Code /review

...(envelope.threadId
? {
topic: {
Expand Down
1 change: 1 addition & 0 deletions packages/channels/base/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
32 changes: 32 additions & 0 deletions packages/channels/dingtalk/src/DingtalkAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
8 changes: 8 additions & 0 deletions packages/channels/dingtalk/src/DingtalkAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface DingTalkMessageData {
msgtype?: string;
conversationType?: string;
conversationId?: string;
conversationTitle?: string;
sessionWebhook?: string;
senderId?: string;
senderStaffId?: string;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1310,6 +1315,9 @@ export class DingtalkChannel extends ChannelBase {
senderId,
senderName,
chatId,
...(isGroup && conversationTitle
? { chatName: conversationTitle }
: {}),
text: envelopeText,
isGroup,
isMentioned,
Expand Down
32 changes: 31 additions & 1 deletion packages/channels/telegram/src/TelegramAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion packages/channels/telegram/src/TelegramAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
},
Expand Down Expand Up @@ -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)
Expand Down
Loading