Skip to content

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

Merged
wenshao merged 7 commits into
QwenLM:mainfrom
BenGuanRan:feat/channel-observed-group-names
Jul 18, 2026
Merged

feat(channels): observe group names from inbound messages#7155
wenshao merged 7 commits into
QwenLM:mainfrom
BenGuanRan:feat/channel-observed-group-names

Conversation

@BenGuanRan

Copy link
Copy Markdown
Collaborator

What this PR does

This follow-up teaches the workspace-scoped observed-contact graph to retain human-readable group names already present in accepted inbound messages. DingTalk forwards conversationTitle, Telegram forwards chat.title, and the shared observation path sanitizes the value before using it as groups[].label. Complete platform group IDs remain unchanged and remain the only delivery and identity keys.

This is a stacked PR based on #7109. It should merge after #7109; once that dependency lands, this PR's diff will reduce to the group-name follow-up.

Why it's needed

The observed-contact API currently labels every group with its opaque platform ID even when the inbound callback already contains a readable name. This makes a proactive-delivery target selector unnecessarily difficult to use. The change remains passive: it does not call directory, group-detail, or chat-info APIs and does not add permissions.

Reviewer Test Plan

How to verify

Send an accepted group message to a daemon-managed DingTalk or Telegram channel, then read the workspace's observed contacts. The corresponding group should retain its complete platform ID while its label matches the inbound group name. Send a later accepted message after renaming the group and verify the existing graph node refreshes rather than duplicating. For Feishu, WeCom, missing, empty, or malformed names, verify the label continues to fall back to the complete group ID. Confirm no platform directory or group-detail request occurs.

Evidence (Before & After)

Before: every groups[].label falls back to the opaque groups[].id.

After: DingTalk and Telegram return names observed directly on inbound messages; Feishu, WeCom, and topics retain the complete-ID fallback.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

macOS local workspace, Node.js 25.2.1. Focused base, DingTalk, Telegram, and observed-contact store tests passed (562 tests). Repository lint, typecheck, and build passed. Platform callback-to-read-API E2E evidence is posted separately.

Risk & Scope

  • Main risk or tradeoff: Group names are best-effort observations and may remain stale until the next accepted message or freshness expiry.
  • Not validated / out of scope: No directory backfill, authoritative membership sync, Feishu/WeCom group-name lookup, or topic-name discovery.
  • Breaking changes / migration notes: None. The envelope field is optional and the persisted registry schema already supports group labels.

Linked Issues

Closes #7154

Depends on #7109

中文说明

本 PR 做了什么

这个后续改动让 workspace 级动态观测联系人图谱保留已通过校验的入站消息中自带的可读群名。钉钉透传 conversationTitle,Telegram 透传 chat.title,公共观测链路清洗后写入 groups[].label。完整的平台群 ID 不变,并继续作为唯一的投递和身份标识。

这是基于 #7109 的 stacked PR,应在 #7109 合并后再合并;依赖合入后,本 PR 的 diff 会自动收敛为群名观测的后续改动。

为什么需要

当前观测联系人接口即使入站回调已经携带可读群名,也始终用不透明的平台 ID 作为群标签,使主动投递目标选择器难以使用。本改动仍然是纯被动观测:不会调用通讯录、群详情或 chat-info API,也不增加权限。

Reviewer 测试计划

如何验证

向 daemon 管理的钉钉或 Telegram channel 发送一条通过校验的群消息,然后读取该 workspace 的 observed contacts。对应群应保留完整平台 ID,同时 label 等于入站消息携带的群名。群改名后再发送一条通过校验的消息,应刷新已有图节点而不是产生重复节点。飞书、企微以及名称缺失、为空或非法时,label 应继续回退为完整群 ID。整个过程不应出现平台通讯录或群详情请求。

前后对比证据

改动前:所有 groups[].label 都回退为不透明的 groups[].id

改动后:钉钉和 Telegram 返回直接从入站消息观测到的名称;飞书、企微和话题继续使用完整 ID 回退。

测试环境

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

macOS 本地 workspace,Node.js 25.2.1。公共层、钉钉、Telegram 和 observed-contact store 聚焦测试共 562 项通过,仓库 lint、typecheck 和 build 通过。平台 callback 到读取 API 的 E2E 证据会单独评论。

风险与范围

  • 主要风险或取舍:群名是尽力而为的观测数据,在下一条通过校验的消息或新鲜度过期之前可能保持旧值。
  • 未验证或不在范围内:不做通讯录回填、权威成员同步、飞书/企微群名查询或话题名称发现。
  • 破坏性变更或迁移:无。Envelope 字段可选,现有持久化 schema 已支持群 label。

关联 Issue

Closes #7154

Depends on #7109

@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

E2E test report

Tested commit: d8ff3cba4e8fec3c18f04d743193ebb82b39422b

Scenario

An isolated test injected a DingTalk Stream callback using the official /v1.0/im/bot/messages/get shape, including conversationTitle, a complete long conversationId, and senderStaffId, through the callback registered by the real DingTalk adapter. The test retained the real shared inbound processing, wrote through the real workspace observed-contact store, and requested the real authenticated workspace observed-contacts route with Express and supertest.

Result

  • Passed: 1/1 E2E scenario in 81 ms.
  • The returned group label matched the inbound conversationTitle.
  • The returned group ID matched the complete input conversationId byte-for-byte.
  • The nested observed user retained the expected staff ID and display name.
  • The Stream callback acknowledged successfully.
  • The read response retained Cache-Control: no-store.
  • Network access was fail-closed: only the one expected session webhook reply was allowed and observed. Any other request would fail the test, confirming that this path made no group-detail, directory, chat-info, or topapi lookup.

Transport boundary

PR #7109's live E2E evidence remains the transport precondition: a real DingTalk Stream connection already verified direct and group inbound messages, complete openConversationId values, freshness filtering, and outbound bot replies. This follow-up E2E isolates and verifies the new callback-to-observed-contact-label behavior without reusing or persisting credentials.

Cleanup

Temporary test source, workspace, QWEN_HOME, registry, and processes were removed. The worktree remained clean and git diff --check passed.

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR! Re-run confirms the previous assessment.

Template looks good ✓

Problem: This is a real usability gap, not theoretical hardening. The observed-contact API returns opaque platform IDs as group labels even when the inbound callback already carries a readable name. Linked issue #7154 documents the need.

Direction: Aligned. The change makes the observed-contact graph more useful for proactive-delivery target selection without adding API calls or permissions. Stays passive — only observes metadata already in the inbound envelope. Depends on #7109 (stacked PR).

Size: No core modules touched (packages/channels/* only). 26 production logic lines + 99 test lines + 114 docs lines. Cleanly scoped.

Approach: Minimal and focused. One optional Envelope field, two adapter mappings (DingTalk conversationTitle, Telegram chat.title), shared sanitization reuse. No scope creep. Moving on to code review. 🔍

中文说明

感谢贡献!Re-run 确认此前评估。

模板完整 ✓

问题:这是真实的可用性问题,不是理论性加固。即使入站回调已携带可读群名,观测联系人 API 仍返回不透明的平台 ID。关联 issue #7154 记录了该需求。

方向:对齐。在不增加 API 调用或权限的前提下,让观测联系人图谱在主动投递目标选择时更实用。纯被动观测——只使用入站 envelope 已有的元数据。依赖 #7109(stacked PR)。

规模:未触及核心模块(仅 packages/channels/*)。26 行生产逻辑 + 99 行测试 + 114 行文档。范围清晰。

方案:最小且聚焦。一个可选 Envelope 字段、两个 adapter 映射(钉钉 conversationTitle、Telegram chat.title),复用已有清洗逻辑,无范围蔓延。进入代码审查 🔍

Qwen Code · qwen3.7-max

Reviewed at e9c58ab58f1c5d07340c76d26deb638ef28d5096 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Code review: No critical blockers or AGENTS.md violations found.

The implementation cleanly reuses sanitizeSenderName for chat names — same threat model (untrusted inbound display text), same sanitization rules. The isGroup guard correctly prevents chatName from leaking onto DM envelopes in both adapters. The 'unknown' sentinel from sanitization is properly mapped to the complete-ID fallback, matching the existing userLabel pattern.

Independent proposal comparison: I would have done exactly what this PR does — add an optional Envelope.chatName field, populate it from adapters that have verified inbound fields, and sanitize at the observation boundary. The PR matches this baseline.

Test Results

ChannelBase.test.ts    482 passed (482)  8097ms
DingtalkAdapter.test.ts 72 passed (72)   386ms
TelegramAdapter.test.ts 14 passed (14)   24ms

All 568 tests pass, including the 6 new test cases covering: usable group name propagation, unusable name fallback to complete ID, DM ignoring chatName, DingTalk conversationTitle forwarding, and Telegram group/supergroup/private title handling.

Real-scenario testing: N/A — this is a backend channel adapter metadata change with no CLI/TUI impact. Unit tests cover the contract.

中文说明

代码审查:未发现关键缺陷或 AGENTS.md 违规。

实现干净地复用了 sanitizeSenderName 来清洗群名——威胁模型相同(不可信的入站展示文本),清洗规则相同。isGroup 守卫正确阻止 chatName 泄漏到两个 adapter 的 DM envelope 中。sanitize 返回的 'unknown' 哨兵值被正确映射为完整 ID 回退,与已有的 userLabel 模式一致。

独立方案对比:如果我来实现,做法完全一致——添加可选 Envelope.chatName 字段,从有已验证入站字段的 adapter 填充,在观测边界清洗。PR 与基线一致。

测试结果:568 项全部通过,包含 6 个新增测试用例。

真实场景测试:不适用——这是后端 channel adapter 元数据变更,无 CLI/TUI 影响。

Qwen Code · qwen3.7-max

Reviewed at e9c58ab58f1c5d07340c76d26deb638ef28d5096 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 5/5 — Clean across every stage; would merge without hesitation.

This PR does exactly one thing and does it well: it preserves human-readable group names that are already sitting in the inbound callback, instead of discarding them. 26 production lines, no new abstractions, no API calls, no permissions. The sanitization reuses the proven sanitizeSenderName path, the fallback chain is correct, and 568 tests pass. The design doc and implementation plan are thorough without being over-engineered. Already has maintainer approval from @wenshao. Ships the feature cleanly.

中文说明

置信度:5/5 — 各阶段均通过,可直接合并。

这个 PR 只做一件事且做得很好:保留入站回调中已有的可读群名,而不是丢弃它。26 行生产代码,无新抽象,无 API 调用,无额外权限。清洗逻辑复用了已验证的 sanitizeSenderName,回退链正确,568 项测试全部通过。设计文档和实现计划详尽而不过度工程化。已有 @wenshao 的 maintainer 审批。可以直接合入。

Qwen Code · qwen3.7-max

Reviewed at e9c58ab58f1c5d07340c76d26deb638ef28d5096 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @wenshao — 625 production lines across channels and CLI packages. No core modules touched, code review is clean (4/5), but the scope warrants a maintainer's judgment call. Also depends on #7109 which is still open.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head d8ff3cb, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

capabilities

field PR base (before) this PR (after)
features[] "workspace_channel_observed_contacts"

Qwen Code · serve A/B

@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Review — LGTM (stacked delta over #7109)

Reviewed at head d8ff3cba4. Since the first three commits are exactly #7109's head 026113b86 (which I reviewed to ready-to-merge separately), this review covers the true delta of this PR: 026113b86..d8ff3cba4 — the group-name follow-up only (~230 added lines across 12 files; observed-contact-store.ts and its tests are byte-identical to the reviewed #7109 head).

What the delta does

Adds optional Envelope.chatName, populated passively from metadata already present on accepted inbound messages — DingTalk conversationTitle, Telegram chat.title (group/supergroup only) — and uses the sanitized value as the observed group's label, falling back to the complete chatId. No new network calls, no schema change, IDs remain the only identity/delivery keys.

Verified

  • Sanitize-and-fallback logic (ChannelBase.recordObservedContact): mirrors the existing sender-label path exactly — sanitizeSenderName strips C0/C1 controls, bidi/zero-width chars, and [/] delimiters, caps at 64 code points without splitting surrogate pairs; empty/whitespace/garbage collapses to 'unknown' which routes to the chatId fallback. Correct for chatName: '', whitespace-only, and control-only inputs.
  • Adapter gating: DingTalk validates conversationTitle as a string and spreads it only when isGroup && conversationTitle; Telegram spreads only for group/supergroup chat types, so a private chat's title is ignored (and ChannelBase ignores chatName on non-group envelopes as a second layer, tested).
  • No-duplicate rename refresh: the store's graph derivation keys groups by (channelName, group.id) — label is not part of the key — and the group node's label comes from the newest fresh observation. feat(channels): expose workspace-scoped observed contacts #7109's existing store test (updates labels and timestamps when a relationship is observed again) already covers group-label replacement, so the design doc's "store tests cover replacement by newer labels" claim is accurate without new store tests.
  • Consumers: chatName is read in exactly one place (recordObservedContact); it does not touch routing, sessions, dedup, or webhook behavior.
  • Tests independently re-run at head in an isolated worktree: channels/base ChannelBase 465/465, DingTalk adapter 72/72, Telegram adapter 14/14 — all green.
  • Landscape: no competing/duplicate open PR; stack order (feat(channels): expose workspace-scoped observed contacts #7109 first) is correctly declared.

Minor notes (no action required)

  1. Effective label cap is 64, not 256. The design doc says the store bounds labels to 256 UTF-16 units, which is true of persistence — but sanitizeSenderName truncates group names to 64 code points first, so 64 is the real bound, and [/] in a legit group name (e.g. Team [Core]) are folded to spaces. This is a reasonable reuse for consistency with sender labels (and conservative if these labels later reach prompts); just noting the doc sentence slightly overstates the effective bound.
  2. A group literally named unknown falls back to its complete ID — same pre-existing quirk as sender labels; consistent, harmless.
  3. Label reversion on title-less later messages (newest observation wins, so a message without a title reverts the label to the ID until the next titled one) is explicitly owned in the design doc as newest-accepted-evidence semantics — agreed with that tradeoff over a hidden name cache.

Merge sequencing

Merge #7109 first, then rebase/merge this so the diff collapses to the follow-up. Nothing here changes my ready-to-merge assessment of the base PR.

中文说明

评审 — LGTM(基于 #7109 的 stacked 增量)

评审 head 为 d8ff3cba4。前三个 commit 与 #7109 的 head 026113b86 完全一致(该 PR 我已单独评审为可合并),因此本次评审只覆盖真实增量 026113b86..d8ff3cba4——即群名跟进改动(12 个文件约 230 行新增;observed-contact-store.ts 及其测试与已评审的 #7109 head 逐字节一致)。

已验证

  • 清洗与回退逻辑:与现有 sender label 路径完全一致;空串、纯空白、纯控制字符输入都会坍缩为 'unknown' 并回退到完整 chatId
  • 适配器门控:钉钉仅在 isGroup && conversationTitle 时透传;Telegram 仅对 group/supergroup 透传,私聊 title 被忽略(基类对非群消息二次忽略,有测试覆盖)。
  • 改名刷新不产生重复节点:图谱按 (channelName, group.id) 键控,label 不参与键;最新观测胜出。feat(channels): expose workspace-scoped observed contacts #7109 已有 store 测试覆盖群 label 替换。
  • 消费点唯一chatName 只在 recordObservedContact 中读取,不影响路由、会话、去重和 webhook。
  • 独立复跑测试:隔离 worktree 下 base 465/465、钉钉 72/72、Telegram 14/14 全绿。
  • 无竞争或重复 PR;stack 顺序声明正确。

次要备注(无需修改)

  1. 实际 label 上限是 64 个码点(sanitizeSenderName 先截断),而非文档所述持久层的 256;群名中的 [/] 会被折叠为空格。为与 sender label 保持一致的合理复用,仅指出文档表述略有夸大。
  2. 群名恰好叫 unknown 时会回退为完整 ID——与 sender label 的既有行为一致,无害。
  3. 后续消息缺失群名时 label 回退为 ID 的"最新证据胜出"语义已在设计文档中明确承担,认可该取舍。

合并顺序

先合 #7109,再 rebase/合并本 PR 使 diff 收敛为跟进改动。本 PR 不改变我对基础 PR 可合并的评估。

@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

Reviewed — no blockers. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

Comment on lines +3818 to +3820
const sanitizedChatName = envelope.chatName
? sanitizeSenderName(envelope.chatName)
: '';

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

Comment on lines +292 to +297
private isIdentity(value: ObservedChannelIdentity): boolean {
return (
this.isBoundedString(value.id, MAX_ID_LENGTH) &&
this.isBoundedString(value.label, MAX_ID_LENGTH)
);
}

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] isIdentity (write path) validates labels against MAX_ID_LENGTH (4096), but parseIdentity (read path) validates against MAX_LABEL_LENGTH (256). The two validation gates disagree on the maximum label length.

Failure scenario: Today normalizeIdentity truncates labels to 256 before persistence, masking the mismatch. A maintainer reading isIdentity as the canonical validator could remove normalizeIdentity's truncation as redundant, or raise the parseIdentity bound to match — either change breaks the round-trip: list() throws on every request (malformed registry) or labels exceeding 256 characters silently reach the API response.

Suggested change
private isIdentity(value: ObservedChannelIdentity): boolean {
return (
this.isBoundedString(value.id, MAX_ID_LENGTH) &&
this.isBoundedString(value.label, MAX_ID_LENGTH)
);
}
private isIdentity(value: ObservedChannelIdentity): boolean {
return (
this.isBoundedString(value.id, MAX_ID_LENGTH) &&
this.isBoundedString(value.label, MAX_LABEL_LENGTH)
);
}

— qwen3.7-max via Qwen Code /review

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge Conflict Resolution Summary — PR #7155

Branch: feat/channel-observed-group-namesorigin/main

Conflicts and resolutions

1. docs/design/2026-07-17-observed-channel-delivery-targets.md (add/add)

Both sides added this design doc. The PR (HEAD) version describes group labels using sanitized names from inbound envelopes (DingTalk conversationTitle, Telegram chat.title), while origin/main states that group/topic labels always fall back to complete IDs because the common envelope has no portable display name.

Resolution: Kept HEAD — the PR's feature is specifically about observing group names from inbound messages, so the richer label description is correct.

2. docs/users/features/channels/overview.md (content)

The PR adds a sentence explaining that group labels use names from accepted inbound messages (DingTalk conversationTitle, Telegram chat.title), while origin/main omits this detail.

Resolution: Kept HEAD — the user-facing docs should describe the new group-name observation behavior.

3. packages/channels/base/src/ChannelBase.test.ts (content, two conflicts)

  • Conflict 1 (line ~693): HEAD adds chatName: 'Project Group' to the test envelope for the group-observation test. origin/main omits chatName.
  • Conflict 2 (line ~705): HEAD asserts group.label is 'Project Group' and adds two new tests: one for unusable group name fallback and one for ignoring chatName on direct messages. origin/main asserts group.label is the raw chatId ('group-1') and has no additional tests.

Resolution: Kept HEAD in both cases — the PR implements chatName-based group labels, so the tests must exercise that path including sanitization fallback and DM isolation.

4. packages/channels/base/src/ChannelBase.ts (content, two conflicts)

  • Conflict 1 (line ~3862): HEAD adds sanitizedChatName and groupLabel computation from envelope.chatName using sanitizeSenderName with the same fal

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

Reviewed. Suggestions are inline.

— qwen3.7-max via Qwen Code /review

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

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

@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Local build & live-E2E verification at e9c58ab5 (post-/resolve re-check)

Follow-up to my LGTM at d8ff3cba4 (earlier comment). The only change since then is the @qwen-code /resolve merge commit e9c58ab5 (parents d8ff3cba4 × d51c21de8), so this round audits that merge resolution and re-runs everything on a fresh local build of the merged head.

Verdict: ready to merge. The merge resolution is byte-exact against the reviewed feature; 961 unit tests plus a 7-behavior live-daemon E2E are green.

1. Previous findings → status at new head

Round-1 note (at d8ff3cba4) Severity Status at e9c58ab5
Effective group-label cap is 64 code points via sanitizeSenderName (not the store's 256); [ ] folded to spaces docs nit Unchanged, non-blocking — behavior confirmed live below
A group literally named unknown falls back to the chat ID note (by design) Unchanged — now exercised live (E2E check 5)

2. Merge-resolution audit — byte-exact

The risk with a bot-resolved merge is keeping tests while dropping implementation (or vice versa). Compared the delta I reviewed at LGTM (026113b86..d8ff3cba4, on top of #7109's head) against the net delta the merge leaves on top of main (d51c21de8..e9c58ab5, with #7109 already squash-merged as 02cb3a63):

  • Both stats identical: 12 files, +232/−6.
  • diff <(git diff 026113b86 d8ff3cba4) <(git diff d51c21de8 e9c58ab5) → 24 lines, all blob hashes / hunk offsets; zero +/- content lines differ. The merge re-applies the reviewed feature byte-for-byte — nothing dropped, nothing smuggled in.
  • PR code confirmed inside the built artifacts: grep sanitizedChatName hits packages/channels/base/dist/ChannelBase.js and dist/chunks/chunk-RTP23QZB.js.

Merge-resolution audit: identical stats, metadata-only diff-of-diffs

3. Real build + full suites of every touched package

Isolated worktree at e9c58ab5, real npm ci (runs the full bundle build).

Suite Result
packages/channels/base (full) 817/817 ✓ (15 files)
packages/channels/dingtalk (full) 119/119
packages/channels/telegram (full) 14/14
packages/cli observed-contact-store.test.ts 11/11

The five tests this PR adds/extends were additionally run by exact name (group/topic/user observation, unusable-name fallback, DM chatName isolation, DingTalk conversationTitle, Telegram group/supergroup chat.title) — all green.

Unit and adapter suites: 961 tests green

4. Live daemon E2E (written for this round)

Real dist/cli.js serve daemon + real channel-worker process running a group-capable plugin fixture (a thin test driver whose Envelope forwards isGroup/chatName/threadId; everything downstream — preflight gates, recordObservedContact, persistence — is the merged tree's real @qwen-code/channel-base), with a fake OpenAI upstream answering each message. Six live inbound messages over WebSocket, then two oracles: the GET /workspace/channel/observed-contacts route and the raw on-disk registry.

# Live inbound Observed result
1 group grp-1001, chatName: "Project Phoenix 项目群" label = sanitized name (CJK preserved) ✓
2 group grp-2002, no chatName label falls back to chat ID ✓
3 DM with stray chatName: "Sneaky DM Title" no group entry; title never persisted anywhere ✓
4 grp-1001 renamed to "Phoenix Renamed 凤凰群" label refreshed in place, still exactly one entry ✓
5 group grp-3003, chatName: "unknown" sanitizer sentinel → chat-ID fallback ✓
6 grp-1001 with threadId: topic-7 topic label still ID-based (no regression) ✓
7 SIGTERM daemon → respawn labels persist from disk ✓

Live daemon E2E: six inbound messages, route + on-disk oracles

5. CI & merge state

At e9c58ab5: Ubuntu Test ✓ (32m14s), web-shell E2E Smoke ✓ (macOS/Windows legs skipped by workflow config, as usual). main has advanced by one commit since the merge (#7136, web-shell only — no file overlap; GitHub reports the PR MERGEABLE).

The E2E fixture and test file live only in my local verification worktree — nothing was pushed to this PR's branch.

中文版本(点击展开)

本地构建 + 真实 E2E 验证报告:e9c58ab5/resolve 后复核)

继我在 d8ff3cba4 的 LGTM(前一条评论)之后,PR 唯一的新增是 @qwen-code /resolve 生成的合并提交 e9c58ab5(双亲 d8ff3cba4 × d51c21de8)。本轮核心是审计该冲突解决的正确性,并在合并后的 head 上重新做真实构建验证。

结论:可以合并。 合并解决与已评审的功能增量逐字节一致;961 个单元测试 + 7 项行为的真实 daemon E2E 全绿。

1. 上一轮备注 → 新 head 状态

上一轮备注(d8ff3cba4 级别 e9c58ab5 状态
群标签实际上限是 sanitizeSenderName 的 64 码点(而非存储层的 256);[ ] 会折叠为空格 文档小瑕疵 不变,不阻塞 —— 已在下方实测确认
群名恰好叫 unknown 时回退到会话 ID 备注(符合设计) 不变 —— 本轮 E2E 第 5 项已实测覆盖

2. 合并解决审计 —— 逐字节一致

机器人解决冲突的风险在于"保留测试却丢掉实现"(或相反)。将 LGTM 时评审的增量(026113b86..d8ff3cba4,基于 #7109 head)与合并后相对 main 的净增量(d51c21de8..e9c58ab5,此时 #7109 已以 02cb3a63 squash 合入)对比:

  • 两侧 stat 完全一致:12 个文件,+232/−6
  • diff <(git diff …) <(git diff …) → 仅 24 行差异,全部是 blob 哈希 / hunk 行号偏移;没有任何一行 +/- 内容差异。合并逐字节重放了已评审的功能,无丢失、无夹带。
  • 构建产物中确认包含 PR 代码:grep sanitizedChatName 命中 packages/channels/base/dist/ChannelBase.jsdist/chunks/chunk-RTP23QZB.js

3. 真实构建 + 所有触及包的完整测试套件

e9c58ab5 的隔离 worktree 中执行真实 npm ci(含完整 bundle 构建)。

套件 结果
packages/channels/base(全量) 817/817 ✓(15 个文件)
packages/channels/dingtalk(全量) 119/119
packages/channels/telegram(全量) 14/14
packages/cli observed-contact-store.test.ts 11/11

另按测试名精确运行了本 PR 新增/扩展的 5 个测试(群/话题/用户观察、不可用群名回退、私聊 chatName 隔离、钉钉 conversationTitle、Telegram 群/超级群 chat.title)—— 全部通过。

4. 真实 daemon E2E(本轮新写)

真实 dist/cli.js serve daemon + 真实 channel worker 进程,加载一个支持群消息的 plugin fixture(仅在 Envelope 上透传 isGroup/chatName/threadId 的瘦驱动;其下游 —— 预检门禁、recordObservedContact、持久化 —— 全部是合并树的真实 @qwen-code/channel-base),并由 fake OpenAI 上游应答。通过 WebSocket 注入 6 条真实 inbound 消息,随后用两个 oracle 校验:GET /workspace/channel/observed-contacts 路由与磁盘上的注册表原文。

# 实时 inbound 观测结果
1 grp-1001chatName: "Project Phoenix 项目群" 标签 = 清洗后的群名(中文保留)✓
2 grp-2002,无 chatName 标签回退到会话 ID ✓
3 私聊夹带 chatName: "Sneaky DM Title" 不产生群条目;标题从未落盘 ✓
4 grp-1001 改名为 "Phoenix Renamed 凤凰群" 标签原地刷新,仍只有一条记录 ✓
5 grp-3003chatName: "unknown" 清洗哨兵值 → 回退会话 ID ✓
6 grp-1001 携带 threadId: topic-7 话题标签仍为 ID(无回归)✓
7 SIGTERM 重启 daemon 标签从磁盘恢复 ✓

5. CI 与合并状态

e9c58ab5 上:Ubuntu Test ✓(32m14s)、web-shell E2E Smoke ✓(macOS/Windows 按工作流配置跳过,属常态)。合并后 main 又前进了一个提交(#7136,仅 web-shell —— 无文件重叠;GitHub 显示 MERGEABLE)。

E2E fixture 与测试文件仅存在于我本地的验证 worktree —— 未向本 PR 分支推送任何内容。

@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

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

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

@qwen-code-ci-bot qwen-code-ci-bot left a comment

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.

LGTM, looks ready to ship. ✅

@wenshao
wenshao added this pull request to the merge queue Jul 18, 2026
Merged via the queue into QwenLM:main with commit a9a6ca4 Jul 18, 2026
98 of 99 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(channels): observe group display names from inbound messages

4 participants