Skip to content

feat(channels): expose workspace-scoped observed contacts - #7109

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
BenGuanRan:feat/channel-observed-targets
Jul 18, 2026
Merged

feat(channels): expose workspace-scoped observed contacts#7109
wenshao merged 3 commits into
QwenLM:mainfrom
BenGuanRan:feat/channel-observed-targets

Conversation

@BenGuanRan

@BenGuanRan BenGuanRan commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR adds a workspace-scoped graph of contacts dynamically observed by daemon-managed DingTalk, Feishu, Telegram, and WeCom channels. After a real inbound message passes the existing direct/group, mention, sender, and pairing gates, the worker records the complete platform user ID and, when present, the group and topic IDs before command or Agent handling begins.

The authenticated read API separates direct-message users from group observations. Top-level users contains only users observed in direct conversations; each group contains users observed in that group, and Feishu or Telegram topics contain users observed in that topic. Results include labels and independent lastObservedAt timestamps, default to a seven-day freshness window, and accept a bounded freshWithinSeconds override.

Observations are stored in a bounded private registry partitioned by the daemon workspace hash under QWEN_HOME. Qualified workspace requests resolve an exact registered, trusted runtime without primary fallback. Responses return complete IDs intentionally, use Cache-Control: no-store, require daemon bearer authentication, and are discoverable through the workspace_channel_observed_contacts capability. Message content, payloads, attachments, credentials, bot output, proactive sends, and webhook traffic are never stored. Existing webhook behavior is unchanged.

Why it's needed

Clients that help users configure inspection reports or scheduled IM notifications need a simple way to present selectable delivery destinations. The channel worker already receives the relevant platform identifiers during normal interactions, but they are transient. This API exposes recent label + id choices without platform directory permissions, member enumeration, or manual ID discovery.

Reviewer Test Plan

How to verify

  1. Start an authenticated daemon for a workspace with a supported channel enabled. Confirm the daemon advertises workspace_channel_observed_contacts, unauthenticated requests are rejected, and GET /workspace/channel/observed-contacts initially returns empty users and groups.
  2. Send an authorized direct message. Confirm the direct sender appears under top-level users with the complete platform ID, label, and observation timestamp.
  3. Send an authorized group message and, for Feishu or Telegram, a topic message. Confirm the sender appears under the group and topic while a group-only sender does not appear under top-level users.
  4. Send traffic rejected by direct/group, mention, sender, or pairing gates and confirm it is not observed. Repeat an accepted interaction and confirm the matching relationship timestamp and label refresh without creating a duplicate.
  5. Query with the default window and custom freshWithinSeconds values. Confirm stale users, groups, topics, and relationships are filtered. Query a trusted secondary workspace and confirm exact partition isolation; missing or untrusted selectors must not fall back to the primary workspace.
  6. Restart the daemon and confirm observations remain available from the same workspace partition. Confirm existing webhook configuration and delivery behavior are unchanged.

The released qwen 0.18.5 baseline returns HTTP 401 without its bearer token and HTTP 404 with a valid token for GET /workspace/channel/observed-contacts.

Evidence (Before & After)

N/A — this changes daemon APIs and channel runtime behavior, with no TUI surface.

Live E2E evidence

  • Feishu WebSocket: sent authorized direct, group, and group-topic messages through a real published bot. The API returned the direct sender under top-level users, the group participant under groups[].users, and the topic participant under groups[].topics[].users, with complete platform IDs and canonical UTC timestamps. A one-second freshness window returned an empty graph while a 600-second window returned the relationships, and the bot response was delivered back to Feishu.
  • DingTalk Stream: sent authorized direct and group messages through a real Stream-mode bot. The API returned the staff user ID and display label for the direct contact, the complete openConversationId for the group, and the participant under groups[].users; topics remained empty as expected for the current DingTalk adapter. Freshness filtering and the bot reply both succeeded.
  • Both live runs used temporary workspace configuration and a local deterministic OpenAI-compatible responder solely to exercise the complete inbound-to-outbound transport. Platform secrets were injected through process input rather than persisted or committed. The daemons, responder, temporary settings, logs, and workspace registry partitions were removed after verification.

Tested on

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

Environment (optional)

Node.js 22+ on macOS with 833 focused channel/store/daemon/route tests, repository lint and typecheck, a complete build and bundle, and the exact no-AK capability integration test. Live Feishu and DingTalk transport was verified as described above. Telegram and WeCom were covered by automated tests but were not exercised against live platform credentials.

Risk & Scope

  • Main risk or tradeoff: Each accepted inbound relationship performs a synchronous bounded registry update, and the authenticated API intentionally returns platform identifiers and display labels.
  • Known limitation: The shared inbound envelope does not currently carry group or topic display names, so those labels fall back to the complete platform IDs. Follow-up feat(channels): observe group display names from inbound messages #7154 tracks best-effort human-readable labels without changing the delivery keys. The registry is capped, workspace-partitioned, stored with private permissions, omitted from caches, and never includes message content.
  • Not validated / out of scope: Live Telegram and WeCom platform runs, platform directory or authoritative group-member enumeration, immediate leave/deletion detection, standalone channel mode, and downstream delivery configuration.
  • Breaking changes / migration notes: None. This is a new API and a version 1 registry; the earlier opaque-reference prototype was never released. Webhook contracts remain unchanged.

Linked Issues

Closes #7103

Follow-up: #7154

中文说明

本 PR 做了什么

本 PR 为 daemon 管理的 DingTalk、Feishu、Telegram 和 WeCom channel 增加 workspace 级动态观测联系人图谱。真实 IM 入站消息通过现有的单聊/群聊、@、发送者和配对校验后,worker 会在命令或 Agent 开始处理前记录完整的平台用户 ID,以及存在时的群聊和话题 ID。

带认证的读取 API 会区分单聊用户与群聊观测关系。顶层 users 只包含在单聊中观测到的用户;每个群包含在该群中观测到的用户,飞书或 Telegram 话题还包含在对应话题中观测到的用户。结果包含标签和独立的 lastObservedAt 时间戳,默认使用七天新鲜度窗口,并支持有边界的 freshWithinSeconds 覆盖。

观测数据保存在 QWEN_HOME 下按 daemon workspace 哈希分区、容量受限的私有注册表中。带 workspace selector 的请求会解析到精确、已注册且可信的 runtime,不会回退到 primary。响应有意返回完整 ID,使用 Cache-Control: no-store,要求 daemon Bearer 认证,并通过 workspace_channel_observed_contacts capability 供客户端发现。消息内容、payload、附件、凭据、机器人出站、主动推送和 webhook 流量均不会写入注册表。现有 webhook 行为保持不变。

为什么需要它

帮助用户配置巡检报告或定时 IM 通知的客户端,需要一种简单方式展示可选择的投递目标。channel worker 在正常交互中已经收到相关平台标识,但这些信息目前是瞬时的。该 API 无需平台目录权限、成员枚举或用户手工发现 ID,即可提供近期的 label + id 选项。

Reviewer 测试计划

如何验证

  1. 为某个 workspace 启动带认证的 daemon,并启用一个受支持的 channel。确认 daemon 广告 workspace_channel_observed_contacts,未认证请求会被拒绝,且 GET /workspace/channel/observed-contacts 初始返回空的 usersgroups
  2. 发送通过授权的单聊消息。确认单聊发送者出现在顶层 users 中,且包含完整平台 ID、标签和观测时间。
  3. 发送通过授权的群聊消息;对于飞书或 Telegram,再发送一条话题消息。确认发送者出现在群聊和话题下,而只在群中出现的用户不会进入顶层 users
  4. 发送被单聊/群聊、@、发送者或配对 gate 拒绝的流量,确认不会产生观测。重复一次已接受的交互,确认对应关系的时间戳和标签会刷新且不会产生重复项。
  5. 使用默认窗口和自定义 freshWithinSeconds 查询。确认过期的用户、群聊、话题和关系会被过滤。查询可信的第二 workspace,确认严格的分区隔离;缺失或不可信的 selector 不得回退到 primary workspace。
  6. 重启 daemon,确认同一 workspace 分区中的观测仍然可用。确认现有 webhook 配置和投递行为保持不变。

已发布的 qwen 0.18.5 在没有 Bearer token 时对 GET /workspace/channel/observed-contacts 返回 HTTP 401,携带有效 token 后返回 HTTP 404。

证据(Before & After)

N/A — 本变更影响 daemon API 和 channel runtime 行为,没有 TUI 界面。

真实 E2E 证据

  • 飞书 WebSocket:通过真实已发布机器人发送了通过授权的私聊、群聊和群内话题消息。接口在顶层 users 返回单聊用户,在 groups[].users 返回群参与者,在 groups[].topics[].users 返回话题参与者,并保留完整平台 ID 和规范 UTC 时间。1 秒新鲜度窗口返回空图,600 秒窗口返回完整关系,机器人回复也成功回发飞书。
  • 钉钉 Stream:通过真实 Stream 模式机器人发送了通过授权的私聊和群聊消息。接口返回单聊联系人的 staff user ID 与展示名称、群聊完整 openConversationId,以及 groups[].users 中的参与者;当前钉钉适配器下 topics 按预期为空。新鲜度过滤和机器人回复均成功。
  • 两次真实测试均使用临时 workspace 配置和本地固定 OpenAI-compatible 响应器,后者只用于贯通从 IM 入站到 IM 回发的完整传输链路。平台 Secret 通过进程输入注入,没有持久化或提交。验证后 daemon、固定响应器、临时设置、日志和 workspace 注册表分区均已删除。

已测试系统

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

环境(可选)

在 macOS 与 Node.js 22+ 上运行了 833 个 channel/store/daemon/route 聚焦测试、仓库 lint 与 typecheck、完整 build 与 bundle,以及此前失败的 no-AK capability 精确集成测试。真实飞书和钉钉传输验证见上文;Telegram 和 WeCom 有自动化测试覆盖,但未使用真实平台凭据执行 E2E。

风险与范围

  • 主要风险或取舍:每条已接受的入站关系都会执行一次同步、容量受限的注册表更新,且带认证的 API 会有意返回平台标识和展示标签。
  • 已知限制:共享入站 Envelope 当前不携带群聊或话题展示名称,因此这些 label 会回退为完整平台 ID。后续 feat(channels): observe group display names from inbound messages #7154 将在不改变投递 ID 的前提下增加尽力而为的人类可读名称。注册表有容量上限,按 workspace 分区,以私有权限存储,不进入缓存,也绝不包含消息内容。
  • 未验证或范围外:Telegram 和 WeCom 真实平台运行、平台目录或权威群成员枚举、即时检测退群/删除、standalone channel 模式,以及下游投递配置。
  • 破坏性变更或迁移说明:无。这是新 API 和 version 1 注册表;之前的不透明引用原型从未发布。Webhook 契约保持不变。

关联 Issue

Closes #7103

后续优化:#7154

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 026113b, 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

@BenGuanRan
BenGuanRan force-pushed the feat/channel-observed-targets branch from 3650a2c to f840333 Compare July 17, 2026 12:42
@BenGuanRan BenGuanRan changed the title feat(channels): add workspace-scoped observed delivery targets feat(channels): expose workspace-scoped observed contacts Jul 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@wenshao

wenshao commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Review — workspace-scoped observed contacts

Reviewed at head e5398ac. Overall: LGTM with non-blocking suggestions. The observation boundary, storage hygiene, and route gating are all done carefully, the diff is additive-only (18 files, no webhook production changes — verified), and the test suite covers the paths that matter. CI (Ubuntu test + Serve A/B) was still pending at review time, and the PR is a draft.

Overview

Accepted inbound IM messages (post direct/group, mention, sender, pairing gates) are normalized into {user, group?, topic?} observations in ChannelBase.processInbound, persisted by the daemon worker into a bounded, workspace-hash-partitioned JSON registry, and exposed read-only via GET /workspace[s/:workspace]/channel/observed-contacts behind daemon bearer auth, advertised as workspace_channel_observed_contacts.

What holds up under scrutiny

  • Gate placement: the hook sits at the top of processInbound, after the preflightedEnvelopes guard, so every rejection class runs first — and each class has a test asserting observe is never called.
  • Path pairing: hashDaemonWorkspace hashes the raw string, but both sides canonicalize first — the worker via canonicalizeWorkspace in runChannelDaemonWorker, serve at boot via validateAndCanonicalizeWorkspace — and canonicalization is idempotent, so worker-writes and route-reads resolve to the same file. Same contract the existing routes.json relies on.
  • Storage hygiene: atomicWriteFileSync with mode: 0o600 + forceMode + noFollow is used correctly (forceMode+mode skips permission preservation as intended), dir 0700, and the registry is strictly re-validated on read with bounded strings and canonical-timestamp checks.
  • Route hygiene: registered after bearerAuth (server.ts:1038 vs :1490, plus the 401 test), Cache-Control: no-store, X-Content-Type-Options: nosniff, and the qualified route uses the same resolveWorkspaceRuntimeFromParam + requireTrustedWorkspaceRuntime pattern as the other workspace routes — no primary fallback.
  • Sanitization: user labels go through sanitizeSenderName (64-code-point cap fits the 256 label bound); failure logging uses sanitizeLogText, and tests assert leak-absence (private ID never logged, registry path never in the 500 body).
  • Collect-mode drain (checked, benign): the drain paths synthesize a new envelope object via spread, which misses the observedContactEnvelopes WeakSet and re-records — but each buffered message was already observed at its own processInbound entry, and the synthetic carries data from a gate-accepted envelope, so this is only a redundant timestamp refresh, not a gate bypass. Worth keeping in mind if the hook ever moves out of processInbound.

Suggestions (non-blocking)

  1. A corrupted registry never recovers. observe() re-reads and re-validates the file before every write, so once observed-contacts.json is malformed (hand edit, downgrade from a future registry version, non-atomic corruption), every accepted message logs observed contact persistence failed forever and every API read returns 500 — nothing repairs the file, and neither error names it (the user docs give the path but not the recovery action). Since the write path owns this file and the data is best-effort observational, consider reset-to-empty on unreadable data in observe() (the read API can stay fail-closed for tamper evidence), or at minimum document "delete the file to reset" as the recovery.
  2. On-disk retention exceeds the maximum readable window. list() can never return anything older than 365 days, but entries are evicted only by the 500 cap, so platform IDs + display names below the cap persist indefinitely. A single filter on the write path dropping entries older than MAX_FRESH_WITHIN_SECONDS would align storage with the API's own ceiling and the PR's data-minimization framing.
  3. Write amplification on the hot path. Every accepted message does a synchronous read + validate (≤500 entries) + sort + pretty-print + fsync rewrite, awaited before command/agent handling — an active conversation rewrites the whole file per message just to bump one timestamp. The PR body acknowledges the tradeoff; a cheap cut would be skipping the rewrite when the dedup key already exists with identical labels and a timestamp fresher than, say, 60s.
  4. Shared eviction pool. One noisy group with many distinct accepted senders can evict direct-user entries, since the 500-entry cap spans all channels and groups in the workspace. Fine as v1 behavior — maybe worth one sentence in the docs.

Nits

  • The two new files are named …target… (observed-target-store.ts, workspace-channel-targets.ts) while everything inside says "contacts" (class, route registrar, capability, registry filename) — residue of the earlier prototype. Renaming is free now, costly after merge.
  • The route's catch returns a sanitized 500 but logs nothing server-side; one sanitized stderr line would help operators correlate.
  • MAX_FRESH_WITHIN_SECONDS is duplicated in the store and the route.
  • Group/topic labels are raw IDs validated against MAX_LABEL_LENGTH (256) while IDs allow 4096, and the user-label fallback is the raw sender ID — an ID longer than 256 chars drops the whole observation instead of truncating the label. Unlikely on current platforms; truncation would be more robust than rejection.

Test coverage

Strong overall: every gate class, dedup, non-blocking failure with leak-absence, direct-vs-group semantics, refresh, staleness, bounds, permissions, malformed data, workspace isolation, no-fallback, auth, and capability advertisement. Small gaps: route-level invalid freshness only covers 0 (non-numeric, repeated query param, >365d would round it out); the design doc's "same user in DMs and groups appears in both places" claim isn't directly asserted; the default 7-day window is asserted only indirectly.

Security

Good posture. Returning complete platform IDs is intentional and clearly documented; bearer auth ordering, trust gating, workspace partition isolation, private file modes, symlink-safe writes, and sanitized error/log surfaces all check out. No message content, credentials, or webhook traffic is persisted, and the webhook production surface is untouched in this diff.

中文版本

评审 — workspace 级观测联系人

基于 head e5398ac 评审。总体结论:LGTM,附非阻塞建议。 观测边界、存储卫生和路由防护都做得很细,diff 为纯新增(18 个文件,未触碰 webhook 生产代码,已核实),测试覆盖了关键路径。评审时 CI(Ubuntu 测试 + Serve A/B)仍在运行,PR 为草稿状态。

概述

通过单聊/群聊、@、发送者、配对各 gate 的入站 IM 消息,在 ChannelBase.processInbound 中规范化为 {user, group?, topic?} 观测记录,由 daemon worker 写入按 workspace 哈希分区、容量受限的 JSON 注册表,并经 daemon Bearer 认证通过 GET /workspace[s/:workspace]/channel/observed-contacts 只读暴露,capability 为 workspace_channel_observed_contacts

经得起推敲的部分

  • Gate 位置:钩子位于 processInbound 顶部、preflightedEnvelopes 校验之后,所有拒绝类别都先于持久化执行,且每类都有断言 observe 未被调用的测试。
  • 路径配对hashDaemonWorkspace 对原始字符串哈希,但两侧都先做 canonicalize(worker 在 runChannelDaemonWorker,serve 在启动时),且该操作幂等,因此 worker 写与路由读解析到同一文件,与现有 routes.json 的契约一致。
  • 存储卫生:正确使用 atomicWriteFileSyncmode: 0o600 + forceMode + noFollow,目录 0700,读取时做严格重校验(字符串边界 + 规范时间戳)。
  • 路由卫生:注册在 bearerAuth 之后(server.ts:1038 vs :1490,另有 401 测试),no-storenosniff,qualified 路由复用与其他 workspace 路由相同的解析 + 信任门控模式,不回退 primary。
  • 净化:用户标签经 sanitizeSenderName(64 码点上限,满足 256 标签边界);失败日志经 sanitizeLogText,测试断言无泄漏。
  • collect 模式排空(已核查,无害):排空路径用展开语法合成新 envelope 对象,绕过 WeakSet 去重后会再次记录——但每条被缓冲消息在各自的 processInbound 入口已被观测过,合成对象的数据也来自已通过 gate 的真实 envelope,因此只是冗余的时间戳刷新,不构成 gate 绕过。若日后移动钩子位置需注意此点。

建议(非阻塞)

  1. 注册表损坏后永远无法恢复。 observe() 每次写前都会重读并重校验文件,一旦 observed-contacts.json 损坏(手工编辑、从未来版本降级、非原子损坏),此后每条消息都记录失败日志、每次 API 读取都返回 500,且无任何修复机制,错误信息也不指明文件(用户文档给出了路径但没有恢复操作)。建议 observe() 在数据不可读时重置为空(读 API 可保持 fail-closed 以保留篡改证据),或至少在文档中写明"删除该文件即可重置"。
  2. 磁盘保留超出最大可读窗口。 list() 最多只能返回 365 天内的数据,但条目仅靠 500 上限淘汰,低于上限时平台 ID + 显示名会无限期保留。写路径加一个过滤丢弃超过 MAX_FRESH_WITHIN_SECONDS 的条目即可与 API 上限及本 PR 的数据最小化立场对齐。
  3. 热路径写放大。 每条被接受的消息都做同步 读取 + 校验(≤500 条)+ 排序 + 美化序列化 + fsync 重写,且在命令/Agent 处理前同步等待——活跃会话每条消息都为刷新一个时间戳而重写整个文件。PR 已声明此取舍;低成本优化:当去重键已存在、标签相同且时间戳在如 60 秒内时跳过重写。
  4. 共享淘汰池。 一个高流量群的大量不同发送者可能挤掉单聊用户条目(500 上限覆盖整个 workspace 的所有 channel 和群)。作为 v1 可接受,建议文档加一句说明。

细节

  • 两个新文件命名为 …target…,而内部一律是 "contacts"(类名、路由注册函数、capability、注册表文件名)——早期原型残留。现在改名零成本,合并后就是历史了。
  • 路由 catch 返回净化后的 500 但服务端不打日志;加一行净化的 stderr 有助于运维定位。
  • MAX_FRESH_WITHIN_SECONDS 在 store 和路由中重复定义。
  • 群/话题标签直接用原始 ID,但按 MAX_LABEL_LENGTH(256)校验而 ID 允许 4096,用户标签兜底也是原始 sender ID——超过 256 字符的 ID 会导致整条观测被丢弃而非截断标签。现有平台上不太可能出现;截断比拒绝更稳健。

测试覆盖

整体扎实:全部 gate 类别、去重、非阻塞失败 + 无泄漏断言、单聊/群聊语义、刷新、过期、边界、权限、损坏数据、workspace 隔离、不回退、认证与 capability 广告。小缺口:路由层非法 freshness 仅覆盖 0(可补非数字、重复参数、>365d);设计文档中"同一用户同时出现在单聊与群聊"未直接断言;默认 7 天窗口仅间接断言。

安全

姿态良好。返回完整平台 ID 是有意为之且文档明确;Bearer 认证顺序、信任门控、分区隔离、私有文件权限、防符号链接写入、净化的错误/日志面均核查通过。不持久化消息内容、凭据或 webhook 流量,本 diff 未触碰 webhook 生产面。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: This is a feature request (closes #7103), not a bug fix. The use case is well-defined — clients that help users configure delivery destinations need to discover platform user IDs that are currently transient. Live E2E evidence with real Feishu and DingTalk bots is described in the PR body.

Direction: Aligned with qwen-code's channel infrastructure. The feature adds a read-only, workspace-scoped API for recently observed IM contacts behind existing bearer auth. No existing contracts are modified; webhook behavior is explicitly preserved. This is additive daemon API surface.

Size: 19 files, +1511/-0 total. Breakdown: ~563 production logic lines, ~699 test lines, ~249 doc/design lines. No core module paths (packages/core/src/**, auth/, providers/, models/, config/, tools/, services/) are touched — changes are in packages/channels/base/ and packages/cli/src/commands/channel/ + serve/.

Approach: The scope feels right for the stated goal. Observation hook in ChannelBase.processInbound after all gates pass → bounded workspace-partitioned JSON store → authenticated GET routes with freshness filtering. The design is straightforward, well-tested (700+ lines of tests covering every gate class, dedup, freshness, workspace isolation, auth, malformed data), and the diff is purely additive. Design doc and implementation plan are included under docs/. Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题: 这是一个功能请求(关闭 #7103),不是 bug 修复。用例定义清晰——帮助用户配置投递目标的客户端需要发现当前瞬态的平台用户 ID。PR 正文描述了飞书和钉钉真实机器人的 E2E 证据。

方向: 与 qwen-code 的 channel 基础设施对齐。该功能在现有 Bearer 认证后增加只读的、workspace 级 IM 联系人观测 API。不修改现有契约,webhook 行为明确保持不变。这是纯新增的 daemon API 面。

规模: 19 个文件,+1511/-0。分解:约 563 行生产逻辑,约 699 行测试,约 249 行文档/设计。未触及核心模块路径(packages/core/src/**auth/providers/models/config/tools/services/)——变更在 packages/channels/base/packages/cli/src/commands/channel/ + serve/

方案: 范围与目标匹配。在 ChannelBase.processInbound 所有 gate 通过后记录观测 → 按 workspace 分区的有界 JSON 存储 → 带新鲜度过滤的认证 GET 路由。设计简洁、测试充分(700+ 行测试覆盖所有 gate 类别、去重、新鲜度、workspace 隔离、认证、损坏数据),diff 为纯新增。包含设计文档和实施计划。进入代码审查 🔍

Qwen Code · qwen3.7-max

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

@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Round 2 — re-review at 026113b

Delta-reviewed e5398ac..026113b (single commit, fix(channels): address observed contacts review feedback). Verdict: LGTM — ready to merge. All round-1 suggestions were addressed or reasonably resolved, and the fix commit itself verifies clean.

Round-1 items → resolution

  1. Retention beyond the 365-day readable window — ✅ fixed in code. observe() now drops entries older than OBSERVED_CONTACT_MAX_FRESH_WITHIN_SECONDS on every accepted write, with a dedicated test (drops observations older than the maximum readable window on write) and matching design/user-doc updates.
  2. Corrupt registry never recovers — ✅ resolved by documentation + observability rather than auto-reset: the route catch now emits a server-side stderr line (asserted in the route test), and both the design doc and user overview now document the manual reset path (delete observed-contacts.json; accepted traffic recreates it). Keeping the fail-closed behavior instead of silently discarding a malformed private registry is a defensible call. Residual nit (non-blocking): neither the worker's observed contact persistence failed line nor the serve-side observed channel contacts unavailable line names the registry file path, so an operator still has to find the doc to know what to delete — worth adding to the follow-up if you touch this again.
  3. Per-message synchronous registry rewrite — unchanged in code, but now explicitly owned as the main tradeoff in the PR body. Given the 500-entry bound and typical accepted-IM-message rates, this is acceptable as shipped.
  4. Nits — ✅ all four: files renamed to observed-contact-store.ts / workspace-channel-observed-contacts.ts (tests, imports, and plan doc updated); the duplicated MAX_FRESH_WITHIN_SECONDS is now a single exported constant shared by store and route; the route logs server-side on failure; and the label≤256 vs id≤4096 asymmetry no longer drops whole observations — labels are accepted up to the id bound and truncated at write time.

Delta verification (what I checked in the new code)

  • truncateLabel iterates code points but budgets in UTF-16 units, so it never splits a surrogate pair, never produces an empty label from a valid (non-empty) input, and always persists ≤256 units — which keeps the read path's parseIdentity bound (MAX_LABEL_LENGTH) round-trip consistent with everything the write path can persist. The boundary-emoji test covers exactly the right edge.
  • The write-path retention filter runs on entries already validated as canonical timestamps, and its >= retentionCutoff boundary matches list()'s >= cutoff convention.
  • New tests are meaningful additions, not just renames: repeated freshWithinSeconds query params → 400, a semantic 7-day-default proof (stale-at-8-days invisible by default, visible at 9 days), the same user appearing in both direct and group scopes, and the stderr assertion on the corrupt-registry 500.
  • Integration capability list updated; no production-behavior changes outside the store/route pair beyond the renames — verified against the full delta stat.

CI at this head: Serve A/B green (capability diff shows exactly the one expected workspace_channel_observed_contacts addition), classify/precheck green, Ubuntu Test still pending at review time.

中文说明

第二轮 — 于 026113b 复审

e5398ac..026113b(单个提交)做了增量复审。结论:LGTM,可以合并。 第一轮的全部建议均已修复或合理解决,修复提交本身验证无误。

第一轮问题 → 处理情况

  1. 保留期超过 365 天可读窗口 — ✅ 代码修复:observe() 每次写入时按 OBSERVED_CONTACT_MAX_FRESH_WITHIN_SECONDS 过滤过期条目,并新增了对应测试和文档更新。
  2. 注册表损坏后永不恢复 — ✅ 通过文档 + 可观测性解决:路由 catch 现在输出服务端 stderr 日志(测试已断言),设计文档和用户文档均说明了手动重置路径(删除 observed-contacts.json,后续放行流量会重建)。保持 fail-closed 而不自动丢弃畸形的私有注册表是合理的选择。遗留小建议(不阻塞): worker 和 serve 侧的 stderr 信息都未包含注册表文件路径,运维仍需查文档才知道该删哪个文件,后续可顺手补上。
  3. 每条消息同步重写注册表 — 代码未变,但 PR 描述已明确将其列为主要取舍。考虑到 500 条上限和 IM 消息的实际频率,可以接受。
  4. 小问题 — ✅ 全部修复:文件改名为 observed-contact-store.ts / workspace-channel-observed-contacts.ts;重复的 MAX_FRESH_WITHIN_SECONDS 合并为共享导出常量;路由失败时有服务端日志;label≤256 与 id≤4096 的不对称不再导致整条观测被丢弃 — label 按 id 上限接受并在写入时截断。

增量验证要点

  • truncateLabel 按码点遍历、按 UTF-16 单元计算预算:不会拆分代理对、非空输入不会产生空 label、持久化结果恒 ≤256 单元,与读取路径 parseIdentity 的校验上限保持往返一致。边界 emoji 测试覆盖了正确的边缘。
  • 写入路径的保留过滤作用于已校验的规范时间戳,>= retentionCutoff 边界与 list()>= cutoff 约定一致。
  • 新增测试有实质意义:重复 freshWithinSeconds 参数返回 400、7 天默认窗口的语义证明、同一用户同时出现在单聊与群聊范围、损坏注册表 500 时的 stderr 断言。
  • 集成测试的 capability 列表已更新;除改名外,store/route 之外无生产行为变更(已对照完整增量核对)。

本 head 的 CI:Serve A/B 通过(capability 差异恰好只有预期新增的 workspace_channel_observed_contacts),classify/precheck 通过,Ubuntu Test 在复审时仍在运行。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Reviewed the full diff at 026113b. No critical blockers found.

Architecture — The observation hook sits in ChannelBase.processInbound after the preflightedEnvelopes guard, so every gate rejection class runs before persistence. A WeakSet<Envelope> prevents double-recording within the same process. The daemon worker passes a synchronous observe callback that writes to a bounded, workspace-hash-partitioned JSON registry. Two GET routes (primary and qualified workspace) read from the same store behind bearer auth.

What holds up well:

  • Gate placement is correct — every rejection class (DM/group policy, mention, sender allowlist, pairing) runs before recordObservedContact. Tests assert each gate class prevents observation.
  • Storage hygiene is solid — atomicWriteFileSync with mode: 0o600, forceMode: true, noFollow: true, directory 0o700. Registry is strictly re-validated on read with bounded strings and canonical-timestamp checks.
  • Route hygiene is correct — registered after bearerAuth middleware (verified at server.ts:1487), Cache-Control: no-store, X-Content-Type-Options: nosniff, and the qualified route uses resolveWorkspaceRuntimeFromParam + requireTrustedWorkspaceRuntime with no primary fallback.
  • Sanitization is careful — user labels go through sanitizeSenderName (64-code-point cap), error logging uses sanitizeLogText, and tests assert leak-absence (private ID never logged, registry path never in the 500 body).
  • Reuses existing infrastructure well — atomicWriteFileSync from core, hashDaemonWorkspace for partitioning, resolveWorkspaceRuntimeFromParam / requireTrustedWorkspaceRuntime for workspace route gating.

Non-blocking observations:

  1. A corrupted registry never recovers automatically — once observed-contacts.json is malformed, every accepted message logs a failure and every API read returns 500 until the file is manually deleted. The design doc documents the recovery action; consider reset-to-empty on unreadable data in observe() as a future improvement.
  2. File naming: observed-contact-store.ts and workspace-channel-observed-contacts.ts use "contacts" consistently in the shipped diff (the earlier prototype used "targets" — this was already resolved).
  3. isIdentity validates labels against MAX_ID_LENGTH (4096) while parseIdentity validates against MAX_LABEL_LENGTH (256). This is intentional (input validation vs stored-data validation, with normalizeIdentity truncating before persist) but could use a one-line comment for future readers.

Test Results

All 1,308 focused tests pass locally:

ChannelBase.test.ts          463 passed
observed-contact-store.test.ts 11 passed
daemon-worker.test.ts         66 passed
runtime.test.ts               12 passed
workspace-channel-observed-contacts.test.ts  6 passed
server.test.ts               750 passed

PR-specific code typechecks clean (pre-existing ink export errors in src/ui/selection/ are unrelated to this PR).

Tmux testing: N/A — this PR changes daemon API and channel runtime behavior with no TUI surface. The comprehensive unit test suite (covering every gate class, dedup, freshness, workspace isolation, auth, malformed data, and capability advertisement) is the appropriate verification.

中文说明

代码审查

基于 026113b 审查了完整 diff。未发现阻塞性问题。

架构 — 观测钩子位于 ChannelBase.processInboundpreflightedEnvelopes 校验之后,所有 gate 拒绝类别都在持久化之前执行。WeakSet<Envelope> 防止同一进程内重复记录。daemon worker 传递同步 observe 回调,写入按 workspace 哈希分区、容量受限的 JSON 注册表。两个 GET 路由(primary 和 qualified workspace)在 Bearer 认证后从同一存储读取。

做得好的部分:

  • Gate 位置正确——每个拒绝类别(DM/群策略、@、发送者白名单、配对)都在 recordObservedContact 之前执行。测试断言每个 gate 类别阻止观测。
  • 存储卫生扎实——atomicWriteFileSync 使用 mode: 0o600forceMode: truenoFollow: true,目录 0o700。读取时严格重校验。
  • 路由卫生正确——注册在 bearerAuth 中间件之后,no-storenosniff,qualified 路由使用相同的 workspace 解析 + 信任门控模式,不回退 primary。
  • 净化仔细——用户标签经 sanitizeSenderName,错误日志经 sanitizeLogText,测试断言无泄漏。
  • 良好复用现有基础设施——atomicWriteFileSynchashDaemonWorkspaceresolveWorkspaceRuntimeFromParam / requireTrustedWorkspaceRuntime

非阻塞建议:

  1. 损坏的注册表无法自动恢复——observed-contacts.json 一旦损坏,每条消息记录失败、每次 API 读取返回 500,直到手动删除文件。设计文档已记录恢复操作;未来可考虑在 observe() 中对不可读数据重置为空。
  2. 文件命名已在发布的 diff 中统一使用 "contacts"(早期原型使用 "targets",已修正)。
  3. isIdentity 对标签使用 MAX_ID_LENGTH(4096)校验,而 parseIdentity 使用 MAX_LABEL_LENGTH(256)。这是有意为之(输入校验 vs 存储数据校验,normalizeIdentity 在持久化前截断),但建议加一行注释方便未来阅读。

测试结果

全部 1,308 个聚焦测试本地通过:ChannelBase 463、observed-contact-store 11、daemon-worker 66、runtime 12、route 6、server 750。

PR 相关代码 typecheck 通过(src/ui/selection/ 中预存的 ink 导出错误与本 PR 无关)。

Tmux 测试: 不适用——本 PR 仅修改 daemon API 和 channel 运行时行为,无 TUI 界面。全面的单元测试套件(覆盖所有 gate 类别、去重、新鲜度、workspace 隔离、认证、损坏数据和 capability 广告)是合适的验证方式。

Qwen Code · qwen3.7-max

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 4/5

Verdict: Approve ✅

This PR adds a well-scoped, read-only daemon API for workspace-scoped observed channel contacts. The observation boundary, storage hygiene, route gating, and security posture are all done carefully. The diff is purely additive (19 files, +1511/-0), webhook behavior is explicitly preserved, and the test suite is thorough.

Summary:

  • Gate: Template complete, problem is a real feature request (feat(channels): expose workspace-scoped observed contacts #7103) with clear use case, direction aligned with channel infrastructure. No core module paths touched.
  • Code review: No critical blockers. Architecture is sound — observation hook after all gates pass, bounded workspace-partitioned JSON store with atomic writes, authenticated GET routes with freshness filtering. Non-blocking suggestions documented in Stage 2.
  • Tests: All 1,308 focused tests pass (ChannelBase 463, store 11, daemon-worker 66, runtime 12, route 6, server 750). PR code typechecks clean.
  • Security: Bearer auth, private file modes (0o600/0o700), no message content stored, sanitized error responses, workspace isolation with no primary fallback, no-store caching. Good posture.

What I'd flag for the maintainer's attention (non-blocking):

The 500-entry cap spans all channels and groups in the workspace — a noisy group could evict direct-user entries. Fine as v1 behavior, but worth noting if this API sees heavy use.

中文说明

置信度:4/5

结论:批准 ✅

本 PR 增加了范围明确、只读的 daemon API,用于 workspace 级观测联系人。观测边界、存储卫生、路由门控和安全姿态都做得很仔细。diff 为纯新增(19 个文件,+1511/-0),webhook 行为明确保持不变,测试套件充分。

摘要:

  • 门控: 模板完整,问题是真实的功能请求(feat(channels): expose workspace-scoped observed contacts #7103),用例清晰,方向与 channel 基础设施对齐。未触及核心模块路径。
  • 代码审查: 无阻塞性问题。架构合理——观测钩子在所有 gate 通过后执行,按 workspace 分区的有界 JSON 存储使用原子写入,带新鲜度过滤的认证 GET 路由。非阻塞建议见 Stage 2。
  • 测试: 全部 1,308 个聚焦测试通过。PR 代码 typecheck 通过。
  • 安全: Bearer 认证、私有文件权限(0o600/0o700)、不存储消息内容、净化的错误响应、workspace 隔离不回退 primary、no-store 缓存。姿态良好。

提请 maintainer 注意(非阻塞):

500 条上限覆盖整个 workspace 的所有 channel 和群——高流量群可能挤掉单聊用户条目。作为 v1 可接受,但如果此 API 使用量大需注意。

Qwen Code · qwen3.7-max

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

@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. ✅

@qwen-code-ci-bot qwen-code-ci-bot added category/integration External integrations daemon type/feature-request New feature or enhancement request labels Jul 18, 2026
@wenshao

wenshao commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Local build & live-run verification — merge reference

Verified at head 026113b86 (the round-1 feedback commit). This is the real-run companion to the two review comments (round 1 @ e5398ac, round 2 @ 026113b): isolated worktree at the PR head, real npm ci + full build, then everything below was driven against the built dist/cli.js (bundle provenance confirmed: workspace_channel_observed_contacts present in dist/chunks/). Node v22.23.1, macOS.

Verdict: GREEN — ready to merge from my side. 1,680 tests green, and a 17-check live E2E against the real daemon confirms every behavior the PR body claims, including both round-1 fixes.

Round-1 findings → status at this head

# Round-1 finding (e5398ac) Status at 026113b86 Proof
1 Corrupt registry never recovers; route catch logged nothing server-side Fail-closed kept by design, now with server-side stderr log, sanitized 500 body, and a documented manual reset (docs/users/features/channels/overview.md: delete observed-contacts.json, accepted traffic recreates it) — reset verified working live. Residual nit (non-blocking): the log line still doesn't name the file path live checks 15–16
2 Retention exceeded the 365-day readable window (500-cap was the only eviction) Fixed — write-path retentionCutoff filter evicts >365d entries on every accepted write, plus a dedicated unit test live check 14
3 Per-message synchronous registry rewrite Unchanged — explicitly owned in the PR body's Risk section; bounded (500 entries) + atomic write. Acceptable
4 Nits: "targets" naming, duplicated MAX_FRESH_WITHIN_SECONDS, label≤256 vs id≤4096 asymmetry dropping whole observations All fixed — files renamed to observed-contact-store.ts / workspace-channel-observed-contacts.ts, one shared exported constant, labels now truncated (surrogate-pair-safe) instead of dropping the observation live checks 8, 12 + unit tests

Test suites (isolated worktree, real install)

suites

  • packages/channels/base full suite: 798/798
  • packages/cli, all 5 PR-touched test files (store, route, daemon-worker, runtime, server): 845/845
  • integration-tests against the real bundle: qwen-serve-routes (incl. the exact no-AK capability set with the new capability) + qwen-serve-channel-workers (real mock-plugin workers): 36/36
  • 17-check live E2E (below): 1/1

Live E2E — real daemon, real channel worker, full inbound→outbound loop

Built dist/cli.js serve with two workspaces, isolated QWEN_HOME, a real plugin-example channel worker connected over WebSocket, and a local fake-OpenAI backend; real inbound messages drove the observation pipeline end to end.

live-e2e

Highlights beyond the PR's own test plan:

  • Write-path retention proven on disk (check 14): seeded a 2-year-old entry directly into the registry; one real accepted message later it was evicted from the file while an 8-day-old entry survived.
  • Corruption resilience (checks 15–16): with a corrupt registry the API fails closed (500 channel_observed_contacts_unavailable, no path leak) while the message pipeline keeps replying; restoring the file recovers to 200 — the documented reset path works.
  • Privacy on disk (check 9): registry dir 700 / file 600; a unique marker string sent in the message body never appears in the registry file — message content is genuinely not stored.
  • Partition isolation over the wire (check 13): qualified wsA route returns its data, wsB partition is empty, unknown selector → 400 workspace_mismatch with no primary fallback.
  • Graph shaping (check 11): group+topic observation appears under groups[].topics[].users and not in top-level users.
  • Observations survive a daemon restart from the same workspace partition (check 17).

What the fix commit changed, tied to live proof

fix-proofs

Notes for the merger

  • Serve A/B bot at this head shows exactly one capability added (workspace_channel_observed_contacts) and no other response drift — consistent with the additive-only diff (+1511/−0).
  • One boundary observation (informational, matches design): live user labels are effectively capped at 64 code points by the pre-existing sanitizeSenderName before the store's 256-char bound applies; the 256 bound matters for group/topic labels that fall back to platform IDs. Follow-up feat(channels): observe group display names from inbound messages #7154 already tracks human-readable group/topic labels.
  • Residual non-blocking nit from round 2 stands: the two stderr lines don't name the registry file path, which would speed up the documented manual reset.
中文版本(Chinese version)

本地构建与真实运行验证 — 合并参考

在 head 026113b86(回应 round-1 评审的修复提交)完成验证。本报告是两条评审评论(round 1 @ e5398acround 2 @ 026113b)的真实运行补充:在 PR head 建立隔离 worktree,真实 npm ci + 完整构建,以下所有验证均针对构建产物 dist/cli.js 进行(已确认 bundle 中含 workspace_channel_observed_contacts)。Node v22.23.1,macOS。

结论:GREEN — 我这边认为可以合并。 1,680 个测试全绿,另有针对真实 daemon 的 17 项 live E2E 检查,逐一证实了 PR 描述声称的行为,包括两处 round-1 修复。

Round-1 发现 → 当前 head 状态

# Round-1 发现(e5398ac 026113b86 状态 证据
1 注册表损坏后永不恢复;路由 catch 无服务端日志 有意保持 fail-closed,现已加服务端 stderr 日志、脱敏 500 响应,并在文档中写明手动重置方式(删除 observed-contacts.json,正常流量会重建)— 重置路径已实测有效。遗留小问题(不阻塞):日志行仍未包含文件路径 live 检查 15–16
2 保留期超过 365 天可读窗口(此前仅靠 500 条上限淘汰) 已修复 — 写路径 retentionCutoff 过滤器在每次接受写入时淘汰超过 365 天的条目,并有专门单测 live 检查 14
3 每条消息同步重写注册表 未改动 — PR 描述 Risk 部分已明确承担该取舍;有界(500 条)+ 原子写入,可接受
4 命名 "targets"、重复常量、label≤256 与 id≤4096 不对称导致整条观察被丢弃 全部修复 — 文件重命名、共享导出常量、label 改为截断(代理对安全)而非丢弃整条观察 live 检查 8、12 + 单测

测试套件(隔离 worktree,真实安装)

  • packages/channels/base 全量:798/798
  • packages/cli PR 涉及的全部 5 个测试文件:845/845
  • 针对真实 bundle 的集成测试(qwen-serve-routes 精确能力集 + qwen-serve-channel-workers 真实插件 worker):36/36
  • 17 项 live E2E:1/1

Live E2E — 真实 daemon + 真实 channel worker + 完整出入站回路

构建产物 dist/cli.js serve 挂两个工作区、隔离 QWEN_HOME、真实 plugin-example channel worker(WebSocket 接入)、本地 fake-OpenAI 后端,用真实入站消息端到端驱动观察管线。超出 PR 自带测试计划的亮点:

  • 写路径保留期在磁盘上实证(检查 14):直接在注册表中植入 2 年前的条目,一条真实消息后即从文件中被淘汰,8 天前的条目保留。
  • 损坏韧性(检查 15–16):注册表损坏时 API fail-closed(500 channel_observed_contacts_unavailable,不泄露路径),消息管线仍正常回复;恢复文件后 API 恢复 200 — 文档中的重置路径有效。
  • 磁盘隐私(检查 9):注册表目录 700 / 文件 600;消息正文中的唯一标记串从未出现在注册表文件中 — 消息内容确实不落盘。
  • 分区隔离(检查 13):wsA 限定路由返回自身数据,wsB 分区为空,未知 selector → 400 workspace_mismatch,无主工作区回退。
  • 图形状(检查 11):群组+话题观察出现在 groups[].topics[].users,不在顶层 users
  • daemon 重启后观察数据从同一工作区分区持久恢复(检查 17)。

给合并者的说明

  • Serve A/B 机器人在此 head 仅显示新增一个能力,无其他响应漂移 — 与纯增量 diff(+1511/−0)一致。
  • 一个边界观察(信息性,符合设计):真实用户 label 实际先被既有的 sanitizeSenderName 截到 64 code points,store 的 256 上限主要作用于回退为平台 ID 的群组/话题 label;后续 feat(channels): observe group display names from inbound messages #7154 已跟踪人类可读 label。
  • round-2 遗留的非阻塞小问题仍在:两条 stderr 日志未包含注册表文件路径,补上会让文档中的手动重置更快定位。

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

Not reviewed: chunk 1 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 5 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 4 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 3 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 6 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 6 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 2 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 5 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 1 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 3 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 4 — launched with a prompt that is not the one the CLI built.

Not reviewed: chunk 2 — launched with a prompt that is not the one the CLI built.

— qwen3.7-max via Qwen Code /review

Comment thread packages/cli/src/commands/channel/observed-contact-store.ts
Comment thread packages/cli/src/commands/channel/observed-contact-store.test.ts
@BenGuanRan

Copy link
Copy Markdown
Collaborator Author

@qwen-code /review

@BenGuanRan

BenGuanRan commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator Author

@wenshao @qqqys The two latest bot Critical threads were verified against the current head and are false positives: raw fallback labels intentionally accept the complete platform ID bound, then persistence truncates only the display label to 256 while preserving the delivery ID. I replied with the contract evidence and resolved both threads; no code change is needed. My @qwen-code /review request was skipped because manual review commands require repository write+ permission. Could you please rerun @qwen-code /review or dismiss the stale bot change request?

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

Labels

category/integration External integrations daemon type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(channels): expose workspace-scoped observed contacts

4 participants