Compare commits

..
Author SHA1 Message Date
DennisandClaude Opus 4.8 d41214988a fix(shortcut): keep external contacts in name resolution; alias resource-url msg-id
Two independent shortcut correctness fixes surfaced by the audit:

- Name→ID resolution (chat +dm / +broadcast / … via the shared resolver) dropped
  every search_contact_by_key_word row with an empty userId. External /
  cross-org contacts arrive with only an openDingTalkId, so they were silently
  discarded — making resolution report a real person as missing, or collapse to
  the wrong single match when an in-org namesake existed. Keep any row with at
  least one usable identity (userId or openDingTalkId) and fall the display name
  back through nick/showName/flowerName/staffName/userName.

- chat +messages-resource-url required --message-id with no alias, so an agent
  copying the message list's openMessageId/msgId output field hit "unknown
  flag". Accept --msg-id / --open-message-id as aliases (declared via an
  at-least-one constraint since a shortcut's Required check only sees the
  primary flag name), mirroring the earlier chat message download-media fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 20:11:26 +08:00
github-actions[bot] 5783c4e82a chore: update beta formula for v1.0.55-beta.5 [skip ci] 2026-07-28 07:10:13 +00:00
chichuanandchichuan baafd6fe7d docs(CHANGELOG): 补充 v1.0.55-beta.5 精确发布说明(风险等级:文档级) (#812)
Co-authored-by: chichuan <haofeng.hf@alibaba-inc.com>
2026-07-28 15:02:24 +08:00
github-actions[bot] 23c3b74979 Merge pull request #803 from DingTalk-Real-AI/codex/fix-contract-defects
fix: harden dws contract edge cases
2026-07-28 14:46:06 +08:00
Dennis 258e7ed872 fix: align doc rename schema contract 2026-07-28 14:30:29 +08:00
Dennis 69d813afef fix: address contract review feedback 2026-07-28 12:09:57 +08:00
Dennis 00305d941d fix: preserve document info schema compatibility 2026-07-28 11:37:32 +08:00
Dennis ec7fdb0f0d fix: harden dws contract edge cases 2026-07-28 11:36:44 +08:00
github-actions[bot] a8e83e5e7e Merge pull request #804 from wxianfeng/feature/aone84760010-qwenwork-agent-host
feat: add agent host observation metadata
2026-07-28 03:02:43 +00:00
修雨 488d90b73c Merge branch 'main' into feature/aone84760010-qwenwork-agent-host 2026-07-28 10:51:27 +08:00
修雨 2c10be2a1a feat(schema): publish 210 built-in shortcuts (#802)
Publishes all 210 public built-in shortcuts as reviewed Agent-visible
leaf tools across 16 product groups, with stable canonical identities,
executable +shortcut CLI paths, parameter and cross-parameter
constraints, selection guidance, interface metadata, and runtime-aligned
safety/confirmation semantics. Catalog grows from 603 to 813 tools.
2026-07-28 00:11:29 +08:00
wxianfeng 3985c4c98f feat: add agent host observation metadata (Aone 84760010) 2026-07-27 22:09:58 +08:00
wxianfeng 196bf929c1 Merge remote-tracking branch 'upstream/main' 2026-07-27 20:50:49 +08:00
github-actions[bot] 2dfc39f0d3 Merge pull request #790 from wxianfeng/feature/dws-event-im-phase3
feat(event): add multi-event and group lifecycle subscriptions
2026-07-27 10:00:25 +00:00
wxianfeng 97a16c43b4 fix(event): harden targeted consumer stop 2026-07-27 17:50:31 +08:00
wxianfeng afe7d860ff Merge remote-tracking branch 'upstream/main' 2026-07-27 15:57:39 +08:00
wxianfeng 63b3e72fad test(event): close coverage gate gaps 2026-07-27 15:35:49 +08:00
wxianfeng 984529b4cb test(event): cover multi-event edge paths 2026-07-27 14:59:00 +08:00
wxianfeng 298ea5b341 Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3
# Conflicts:
#	CHANGELOG.md
2026-07-27 13:47:23 +08:00
修雨 5c01ae845f fix: move event changelog entry to [Unreleased] + update npm propagation test
- Add missing ## [Unreleased] heading required by Policy CI check
- Update npm test assertion (12 → 60) to match extended propagation wait
2026-07-25 09:44:39 +08:00
修雨 36b58d08b0 Merge branch 'main' into feature/dws-event-im-phase3 2026-07-25 09:33:53 +08:00
wxianfeng 3749c6b793 docs: update changelog for personal events 2026-07-24 17:59:19 +08:00
wxianfeng e2390c3385 Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3
# Conflicts:
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	skills/mono/SKILL.md
2026-07-24 16:31:29 +08:00
wxianfeng 125bc88d52 fix(event): switch personal defaults to production 2026-07-24 15:40:37 +08:00
炳昱 6b9fcf9289 fix(event): preserve nested message context when flattened 2026-07-24 14:26:34 +08:00
wxianfeng 5a3617c21d feat(event): flatten group member events 2026-07-23 15:30:49 +08:00
wxianfeng 1c9b09b23f Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3
# Conflicts:
#	internal/app/event_command.go
#	internal/app/event_personal_command.go
#	internal/cli/schema_agent_metadata/event.json
#	internal/cli/schema_agent_metadata/index.json
#	internal/cli/schema_agent_metadata_audit.json
#	internal/cli/schema_catalog.json
#	internal/cli/schema_hints/selection/event.json
#	internal/event/personal/registry_test.go
#	skills/mono/references/products/event.md
#	skills/multi/dingtalk-event/SKILL.md
#	skills/multi/dingtalk-event/references/event-im.md
2026-07-22 11:54:32 +08:00
wxianfeng 4cded1ef6c Merge remote-tracking branch 'upstream/main' 2026-07-21 19:24:43 +08:00
炳昱 0182060757 fix(skill): advertise group member event triggers 2026-07-21 17:05:43 +08:00
wxianfeng c9cf1cc9c1 feat(event): support multi-event consume 2026-07-21 16:59:36 +08:00
炳昱 b234015e7f feat(event): add group member lifecycle events 2026-07-20 16:10:47 +08:00
wxianfeng ca6e520610 chore(event): default personal events to pre-release 2026-07-20 11:47:55 +08:00
wxianfeng b731050dad feat(event): add all-message and group lifecycle events 2026-07-20 11:32:46 +08:00
wxianfeng bb2dd2ba76 Merge remote-tracking branch 'upstream/main' into feature/dws-event-im-phase3 2026-07-20 10:08:19 +08:00
wxianfeng 049af9fc30 Merge remote-tracking branch 'upstream/main' 2026-07-17 17:38:04 +08:00
wxianfeng d19a15a6ed Merge branch 'main' of github.com:wxianfeng/dingtalk-workspace-cli
# Conflicts:
#	.github/badges/coverage.svg
2026-07-17 17:36:26 +08:00
github-actions[bot] e85d9bc314 chore: update coverage badge [skip ci] 2026-07-13 02:36:36 +00:00
79 changed files with 6350 additions and 481 deletions
+19 -1
View File
@@ -6,9 +6,27 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
### Fixed
- **Name→ID resolution kept external contacts** — the shared contact resolver (`chat +dm`, `+broadcast`, …) no longer drops `search_contact_by_key_word` rows that carry only an `openDingTalkId` (external / cross-org contacts have an empty `userId`), so those people are found instead of reported missing or collapsed into a wrong single match; the display name also falls back through `nick`/`showName`/`flowerName`/`staffName`/`userName`.
- **`chat +messages-resource-url` flag aliases** — the media download-URL shortcut now accepts `--msg-id` / `--open-message-id` as aliases for `--message-id` (matching the `openMessageId`/`msgId` output field), so agents chaining from a message list no longer hit "unknown flag".
## [1.0.55-beta.5] - 2026-07-28
This beta validates expanded personal event consumption, complete Agent-visible
Runtime Schema coverage for all 210 built-in shortcuts, Agent host
observability, and hardened document, Drive, approval, and Todo command
contracts on top of the `v1.0.55-beta.4` baseline.
### Added
- **Shortcut Runtime Schema delivery** — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
- **Expanded personal event consumption** (#790) — adds eight IM personal event keys, supports subscribing to and consuming multiple event keys in one `dws event consume` invocation, and adds targeted local-consumer shutdown when a subscription is stopped so other consumers can continue on the shared event bus.
- **Shortcut Runtime Schema delivery** (#802) — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
- **Agent host observability** (#804) — accepts an optional, validated `DWS_AGENT_HOST` label and sends it as `x-dws-agent-host` for logs and BI only; invalid values fail before CLI network activity, and the label never participates in authentication or routing.
### Fixed
- **Command contract edge cases** (#803) — approval revocation and document-version rollback now honor `--dry-run` before confirmation or remote preflight; `drive rename` removes only a suffix matching the node's current extension to avoid duplicate extensions while `doc rename` preserves the caller's exact display name; `doc info` keeps its stable MCP contract while `drive info` restores Drive-only metadata such as a non-null `fileSize`; and Todo reminder writes now reject invalid rule JSON while Help, Schema, and Skills distinguish a due time from an independently unreadable reminder rule.
## [1.0.55-beta.4] - 2026-07-27
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.55-beta.4"
version "1.0.55-beta.5"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-darwin-arm64.tar.gz"
sha256 "05b269fe44a125ee8b368d6228c5229950b8216872fb569741d1e30a83ce952a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-arm64.tar.gz"
sha256 "ac826a88062c6b839808eb28312dc026cc76bfc298cdedec34c468b87d5c22d6"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-darwin-amd64.tar.gz"
sha256 "b0d7604299336c83b7805d3b1a47a90668f2e2f3fc54702b0e846bb2907b6170"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-darwin-amd64.tar.gz"
sha256 "361faab5cae2299fa2d8d305fd12dde7a992d408cfd5b1ad54ecd99073cd0a45"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-linux-arm64.tar.gz"
sha256 "a9c1dd5c6171091a84fc18e5081c9f75d037826cd3966726545d715ce832ae31"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-arm64.tar.gz"
sha256 "a8d0d39f037d6cb73aae3e4f7432fc58d3430971ce25d74c3c78580377d4dade"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-linux-amd64.tar.gz"
sha256 "5e97ba398f5a3e15b9d235bc53f596d31a4d6b7af7bb2185b7b48beeda6a2ebb"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-linux-amd64.tar.gz"
sha256 "4a0fc75b9b81f2d8670b9f71bace510515963c4285e9ec62c21aacb3c645c939"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.4/dws-skills.zip"
sha256 "4ebc0294b65d90adb5c5d639a548b528af240e4117ccca3d388e2efb6030170a"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.55-beta.5/dws-skills.zip"
sha256 "7120a49c8bac90ea4c77668115b11edeca843e7fef0cc0ff2de538635a9884e1"
end
def install
+21 -1
View File
@@ -474,7 +474,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
<details>
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
@@ -503,6 +503,25 @@ dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId
# Listen for messages in a specified group
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# Listen for all one-to-one or all group messages
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
# Listen for a specified group's title changes, member changes, or disband event
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# Listen for multiple events for the same user in one process
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
# Inspect local consumers and cancel a subscription
dws event status
dws event stop <subscribe_id>
@@ -514,6 +533,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|---------|---------|
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
| Shared connection | Consumers for the same user share one local bus and cloud connection |
| Multi-event process | One consume process can listen for compatible events for the same target while retaining one subscription per event |
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
+21 -1
View File
@@ -468,7 +468,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
<details>
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
@@ -497,6 +497,25 @@ dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId
# 监听指定群的消息
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
# 监听所有单聊或所有群消息
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
# 监听指定群标题变更、成员进退群或群解散
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
# 一个进程监听同一用户的多个事件
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user <userId> \
--flatten \
-f ndjson
# 查看本地 consume,并取消指定订阅
dws event status
dws event stop <subscribe_id>
@@ -508,6 +527,7 @@ dws event stop <subscribe_id>
|------|------|
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
| 多事件进程 | 同一目标的兼容事件可由一个 consume 进程监听,每个事件仍有独立订阅 |
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
+203
View File
@@ -0,0 +1,203 @@
# 5分钟搭好团队知识库:DWS 让 IT 服务台自己开口答疑,你值得拥有
> 💡 **省流版摘要:**
> 别再用鼠标一个个建文档了,真的没必要!本文带你用 DWS(钉钉命令行工具),5 分钟搭好一个团队知识库:建空间、搭目录、批量导入 FAQ、配权限,全程命令行一步到位。最后再花 30 秒把它挂到钉钉机器人上——同事在群里 @ 一下,知识库自己开口答疑。知识管理员从"搬运工"升级为"甩手掌柜",就是这么简单。
>
> (温馨提示:本文内容可以直接丢给智能体,让智能体一次性逐步安装和使用)
## 一、痛点吐槽:管知识库有多累?
身为企业 IT / 知识管理员,你肯定经历过这些"九九八十一难"……
- **点击马拉松**:建空间点 5 下、建文件夹点 3 下、建一篇文档再点 4 下。一个季度下来,鼠标点击次数比写的字还多。
- **搬运工噩梦**:几百篇历史 FAQ 散落在本地 Word / Markdown 里,要搬进钉钉知识库?复制粘贴到天荒地老,格式还经常翻车。
- **权限苦差**:新同事入职要加权限、转岗要改角色、离职要移除。逐个空间点进去操作,漏一个就是安全隐患。
- **知识沉睡**:库是建好了,可同事还是习惯私聊问你"VPN 又连不上了怎么办"。知识库躺着吃灰,你继续当人肉客服。😭
今天,DWS(DingTalk Workspace CLI)闪亮登场!🌟
你不需要写一行代码,只要在终端敲几行命令,知识库的"建、搬、管、用"全链路一次搞定。更香的是:搭好的知识库可以直接挂到钉钉机器人上,让知识自己开口答疑。
## 二、DWS 是个啥?知识库的"遥控指挥中心"
DWS 是钉钉能力的原子化封装,把复杂的 OpenAPI 打包成简单指令。管知识库这件事,主要靠它的"三驾马车":
| 命令族 | 能干什么 |
|---|---|
| 🗂️ `dws wiki` | 知识库空间、目录节点、成员权限的全生命周期管理 |
| 📄 `dws doc` | 文档内容读写、本地文件批量导入、模板套用 |
| 📁 `dws drive` | 钉盘文件上传下载、全局搜索、归档备份 |
你可以把它想象成知识库的"遥控指挥中心" 🎮——既能你手动按(终端敲命令,比点界面快 10 倍),也能让 AI 帮你按(Claude Code、Qoder 等智能体直接听懂并调用)。
**适合谁用:**
- **企业 IT / 知识管理员**:批量建库、批量导入、批量管权限,脚本化解放双手
- **开发者 / AI 玩家**:把知识库挂到机器人上,打造 24 小时答疑小能手
- **重度钉钉用户 / 效率党**:一条命令搜全库,比在界面里翻目录快得多
## 三、搭好的知识库能帮你做什么?
| 场景 | 玩法 |
|---|---|
| 🛟 IT 服务台 FAQ 库 | VPN、邮箱、打印机常见问题集中沉淀,机器人自动答疑 |
| 📜 制度流程库 | 报销、请假、采购制度批量导入,全文秒搜 |
| 🎓 新人上岗手册 | 按部门建目录,入职即授权限,自助通关 |
| 🤖 知识库 + 机器人 | `--knowledge-source wiki:<spaceId>` 一挂,群里 @ 它就答 |
所想即所得,拒绝画饼,直接上菜!🍽️
## 四、5分钟倒计时,搭好你的团队知识库
> 以下命令全部经过真实环境跑通验证,放心照抄。
### 0. 安装并登录 DWS(约 1 分钟)
把以下指令复制给你的智能体(Claude Code、Qoder、Codex 等)执行,或手动在终端跑:
macOS / Linux:
```bash
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
```
Windows(PowerShell):
```powershell
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
```
登录(提示授权请扫码):
```bash
dws auth login
```
【截图位:dws auth status 显示 token_valid: true】
### 1. 建一个知识库空间(10 秒)
```bash
dws wiki space create --name "IT服务台知识库" --desc "IT 常见问题与制度流程"
```
返回里的 `workspaceId` 就是空间的身份证号,后面每步都要用它。
【截图位:返回 workspaceId 与 spaceUrl】
### 2. 搭目录结构(20 秒)
知识库的结构 = 文件夹节点 + 文档节点。先建分类文件夹:
```bash
dws wiki node create --workspace <workspaceId> --name "常见问题FAQ" --type folder
dws wiki node create --workspace <workspaceId> --name "制度流程" --type folder
```
在文件夹下建一篇空文档(不加 `--folder` 就建在根目录):
```bash
dws wiki node create --workspace <workspaceId> --name "VPN连接失败排查指南" --folder <文件夹nodeId>
```
### 3. 批量导入历史文档(1 分钟,重头戏!)
几百篇本地 FAQ 不用复制粘贴,`doc import` 直接整批灌进知识库,Word、Excel、Markdown、txt 通吃:
```bash
# 单篇导入到指定文件夹
dws doc import --file ./vpn-faq.md --workspace <workspaceId> --folder <文件夹nodeId> --name "VPN连接失败排查指南"
# 批量导入整个目录(bash 一把梭)
for f in ./faq/*.md; do
dws doc import --file "$f" --workspace <workspaceId> --folder <文件夹nodeId>
done
```
已有在线文档想补内容?Markdown 直接写入:
```bash
dws doc update --node <文档nodeId> --content-file ./补充内容.md --mode append
```
【截图位:终端批量导入的滚动输出 + 知识库里齐刷刷的文档列表】
### 4. 配权限:把人拉进来(30 秒)
```bash
# 先用通讯录查到同事的 userId
dws contact user search --query "张三"
# 加为编辑者(--users 支持逗号分隔批量加)
dws wiki member add --workspace <workspaceId> --users <userId1>,<userId2> --role EDITOR
# 随时盘点成员
dws wiki member list --workspace <workspaceId>
```
### 5. 验收:搜一下,秒级命中(10 秒)
```bash
# 库内全文搜索
dws wiki node search --workspace <workspaceId> --query "VPN"
# 全局搜知识库空间
dws wiki space search --query "IT服务台"
```
【截图位:搜索结果命中文档标题】
### 6. 封神一步:挂到机器人上,知识自己开口答疑(30 秒)
如果你已经按《5分钟抱走你的嘴替机器人》建好了钉钉机器人,只需加一个参数:
```bash
dws dev connect --channel claudecode \
--robot-client-id <你的机器人ID> --robot-client-secret <你的机器人密钥> \
--knowledge-source wiki:<workspaceId>
```
机器人会自动从知识库拉取知识并缓存。同事在群里 @ 它问"VPN 连不上怎么办",它直接引用你刚导入的排查指南回答——你,终于不用当复读机了。😎
## 五、进阶使用技巧
### 知识库管理速查表
| 操作 | 命令 |
|---|---|
| 列出我的个人空间 | `dws wiki space list --type myWikiSpace` |
| 列出组织知识库 | `dws wiki space list --type orgWikiSpace` |
| 浏览库内节点树 | `dws wiki node list --workspace <ID> [--folder <nodeId>]` |
| 移动 / 复制节点 | `dws wiki node move` / `dws wiki node copy` |
| 改成员角色 | `dws wiki member update --users <UID> --role VIEWER` |
| 移除成员 | `dws wiki member remove --users <UID>` |
| 删除整个空间 | `dws wiki space delete --workspace <ID>`(进回收站,可恢复) |
### 老手避坑指南 ⛳
- 建在线表格用 `--type axls`,**`asheet` 服务端不支持**,别踩坑。
- `member list` 只返回姓名和角色、**不返回 userId**;要串联 `update` / `remove`,先用 `dws contact user search --query "<姓名>"` 反查。
- 搜索关键词的 flag 是 `--query`,`--keyword` 是遗留别名,新脚本请用 `--query`。
- 所有命令加 `--format json`,配合 `--jq` 过滤字段,写脚本时稳得一批。
- 破坏性操作(删空间、覆盖写文档)前加 `--dry-run` 先预览,确认无误再执行。
### 让机器人答得更好
| 开关 | 作用 |
|---|---|
| `--knowledge-source wiki:<spaceId>` | 从钉钉知识库拉知识作为答疑来源(本文主角) |
| `--knowledge-dir <目录>` | 挂本地 .md/.txt 知识目录,可与上面并存 |
| `--allowed-groups / --allowed-users` | 白名单,只让指定群或人触发 |
| `--daemon` | 后台常驻,关掉终端也不断线 |
## 六、更多 DWS 官方信息
- 钉钉 CLI 官网:https://open.dingtalk.com/dingtalk-cli
- 开源仓库:https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli
- 上一篇姊妹篇:《5分钟抱走你的嘴替机器人:启动钉钉DWS,你值得拥有》
## 七、欢迎加入交流群
【截图位:DWS 交流群二维码】
遇到问题来群里喊一声,官方同学在线答疑。下一篇想看什么?批量备份知识库?给知识库做权限审计?留言区点菜!🍻
+1
View File
@@ -5,6 +5,7 @@
| Variable | Purpose / 用途 |
|---------|---------|
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
| `DWS_AGENT_HOST` | Optional Agent host observation label sent as `x-dws-agent-host` (for example `qwenwork_cloud`). Values are trimmed and must match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. Used only for logs and BI, never for authentication or routing. / 可选 Agent 宿主观测标识,经裁剪并校验后作为 `x-dws-agent-host` 发送;仅用于日志与 BI,不参与鉴权或路由 |
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
+65
View File
@@ -0,0 +1,65 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"regexp"
"strings"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
)
const (
envDWSAgentHost = "DWS_AGENT_HOST"
headerDWSAgentHost = "x-dws-agent-host"
)
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
func init() {
configmeta.Register(configmeta.ConfigItem{
Name: envDWSAgentHost,
Category: configmeta.CategoryExternal,
Description: "调用 DWS 的 Agent 宿主标识;仅用于日志和 BI 观测",
Example: "qwenwork_cloud",
})
}
// parseAgentHost normalizes and validates the caller-provided observation
// label. CR/LF is rejected before trimming so it can never be hidden at the
// edge of a value. An unset or whitespace-only value means "do not emit".
func parseAgentHost(raw string) (string, error) {
if strings.ContainsAny(raw, "\r\n") {
return "", invalidAgentHostError()
}
value := strings.TrimSpace(raw)
if value == "" {
return "", nil
}
if !agentHostPattern.MatchString(value) {
return "", invalidAgentHostError()
}
return value, nil
}
func invalidAgentHostError() error {
// Do not include the raw environment value in the error: it is an
// untrusted caller-controlled string and may contain sensitive data.
return apperrors.NewValidation(
"DWS_AGENT_HOST must match ^[a-z0-9][a-z0-9_-]*$",
apperrors.WithReason("invalid_agent_host"),
)
}
+196
View File
@@ -0,0 +1,196 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package app
import (
"errors"
"io"
"strings"
"testing"
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
func TestParseAgentHost(t *testing.T) {
valid := []struct {
name string
raw string
want string
}{
{name: "unset", raw: "", want: ""},
{name: "whitespace only", raw: " \t\u3000", want: ""},
{name: "cloud", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
{name: "desktop", raw: "qwenwork_desktop", want: "qwenwork_desktop"},
{name: "trim", raw: " \tqwenwork_cloud\t ", want: "qwenwork_cloud"},
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
}
for _, tc := range valid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentHost(tc.raw)
if err != nil {
t.Fatalf("parseAgentHost() error = %v", err)
}
if got != tc.want {
t.Fatalf("parseAgentHost() = %q, want %q", got, tc.want)
}
})
}
invalid := []struct {
name string
raw string
}{
{name: "carriage return", raw: "qwenwork_cloud\r"},
{name: "line feed", raw: "\nqwenwork_cloud"},
{name: "uppercase", raw: "Qwenwork_cloud"},
{name: "internal space", raw: "qwenwork cloud"},
{name: "internal tab", raw: "qwenwork\tcloud"},
{name: "unicode", raw: "千问办公"},
{name: "leading dash", raw: "-qwenwork"},
{name: "leading underscore", raw: "_qwenwork"},
{name: "control character", raw: "qwenwork\x00cloud"},
}
for _, tc := range invalid {
t.Run(tc.name, func(t *testing.T) {
got, err := parseAgentHost(tc.raw)
if err == nil {
t.Fatalf("parseAgentHost(%q) = %q, want error", tc.raw, got)
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("parseAgentHost() error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation {
t.Fatalf("category = %q, want validation", appErr.Category)
}
if appErr.Reason != "invalid_agent_host" {
t.Fatalf("reason = %q, want invalid_agent_host", appErr.Reason)
}
if tc.raw != "" && strings.Contains(err.Error(), tc.raw) {
t.Fatalf("error must not echo invalid value %q: %v", tc.raw, err)
}
})
}
}
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
t.Setenv(envDWSChannel, "channel-test")
t.Setenv(envDingtalkAgent, "agent-test")
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
mergeSawAgentHost := ""
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
mergeSawAgentHost = headers[headerDWSAgentHost]
headers["claw-type"] = "test-claw"
return headers
},
})
headers := resolveIdentityHeaders()
if got := mergeSawAgentHost; got != "qwenwork_desktop" {
t.Fatalf("MergeHeaders saw agent host %q, want qwenwork_desktop", got)
}
if got := headers[headerDWSAgentHost]; got != "qwenwork_desktop" {
t.Fatalf("%s = %q, want qwenwork_desktop", headerDWSAgentHost, got)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
if got := headers["x-dingtalk-dws-agent-code"]; got != "agent-code-test" {
t.Fatalf("agentCode header = %q, want agent-code-test", got)
}
if got := headers["x-dws-channel"]; got != "channel-test" {
t.Fatalf("channel header = %q, want channel-test", got)
}
if got := headers["claw-type"]; got != "test-claw" {
t.Fatalf("claw-type = %q, want test-claw", got)
}
}
func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
t.Setenv(envDWSChannel, "channel-test")
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
edition.Override(&edition.Hooks{
MergeHeaders: func(headers map[string]string) map[string]string {
return headers
},
})
for _, raw := range []string{"", " \t ", "DO_NOT_ECHO"} {
t.Setenv(envDWSAgentHost, raw)
headers := resolveIdentityHeaders()
if _, ok := headers[headerDWSAgentHost]; ok {
t.Fatalf("%s must be omitted for %q: %#v", headerDWSAgentHost, raw, headers)
}
if got := headers["x-dingtalk-source"]; got != "github" {
t.Fatalf("x-dingtalk-source = %q, want github", got)
}
if got := headers["x-dws-channel"]; got != "channel-test" {
t.Fatalf("channel header = %q, want channel-test", got)
}
}
}
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
const invalidValue = "DO_NOT_ECHO"
t.Setenv(envDWSAgentHost, invalidValue)
oldEdition := edition.Get()
t.Cleanup(func() { edition.Override(oldEdition) })
hookCalled := false
edition.Override(&edition.Hooks{
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
hookCalled = true
return nil
},
})
root := NewRootCommand()
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs([]string{"version"})
err := root.Execute()
if err == nil {
t.Fatal("root command accepted invalid DWS_AGENT_HOST")
}
if hookCalled {
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_HOST validation")
}
var appErr *apperrors.Error
if !errors.As(err, &appErr) {
t.Fatalf("root error type = %T, want *errors.Error", err)
}
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_host" {
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
}
if strings.Contains(err.Error(), invalidValue) {
t.Fatalf("root error must not echo invalid value: %v", err)
}
}
+36 -8
View File
@@ -118,7 +118,7 @@ func newEventConsumeCommand() *cobra.Command {
)
cmd := &cobra.Command{
Use: "consume [event_key]",
Use: "consume [event_key...]",
Short: "订阅事件流并输出到 stdout",
Long: `订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。
@@ -138,9 +138,14 @@ func newEventConsumeCommand() *cobra.Command {
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览、确认后加
--yes,绝不要 kill -9。
可提供多个 event_key。多事件会各自创建订阅和本地 consumer,但共享同一 bus、
远程连接、输出和 duration/max-events。用户类事件必须共享一个 --user 或
--open-dingtalk-id,群类事件必须共享一个 --group;用户类与群类不能混用。
全部 consumer 就绪后 stderr 输出 [event] ready event_count=<n> bus_pid=<pid>。
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
通常不需要设置。`,
Args: cobra.MaximumNArgs(1),
Args: cobra.ArbitraryArgs,
DisableAutoGenTag: true,
RunE: func(c *cobra.Command, args []string) error {
as, err := eventNormalizeAs(asIdentity)
@@ -148,8 +153,17 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
return err
}
if as == "user" {
personalOpts.EventKey = firstArg(args)
personalOpts.EventKeys = dedupePersonalEventKeys(args)
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
personalOpts.Flatten = flatten
if len(personalOpts.EventKeys) > 1 {
if err := rejectPersonalMultiEventFlags(c,
"subscribe-id", "rule", "event-types", "filter",
"foreground", "force", "debug-raw-events",
); err != nil {
return fmt.Errorf("event consume: %w", err)
}
}
personalOpts.Common = commonConsumeOptions{
EventTypes: eventTypes,
Filter: filter,
@@ -329,7 +343,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.StringVar(&personalOpts.GroupID, "group", "",
"group 规则:openConversationId")
f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "",
"个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
"个人事件控制面 base URL;默认由 MCP base 派生 /dws")
f.BoolVar(&personalOpts.DebugRawEvents, "debug-raw-events", false,
"个人事件联调:绕过本地 event type/subscribe_id 过滤,输出当前 personal stream bus 收到的所有事件")
f.StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
@@ -337,13 +351,14 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
f.StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人 Stream sourceId;开源版默认 open,可由 edition 覆盖")
f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
"个人 Stream 取票 URL;默认由 MCP base URL 派生")
"个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket")
hideEventInternalFlags(cmd, "as")
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
Name: "event_key",
Type: "string",
Description: "要消费的个人事件码;省略时仅适用于显式配置其它事件来源的兼容模式",
Description: "要消费的一个或多个个人事件码;多个事件必须共享同一目标和过滤上下文",
Required: false,
Variadic: true,
Index: 0,
})
return cmd
@@ -777,7 +792,7 @@ func newEventStatusCommand() *cobra.Command {
cmd.Flags().StringVar(&personalOpts.EventKey, "event", "", "个人事件 event_key 过滤")
cmd.Flags().StringVar(&personalOpts.Status, "status", "active", "个人订阅状态过滤: active|paused|error|deleted|all")
cmd.Flags().StringVar(&personalOpts.SubscribeID, "subscribe-id", "", "个人订阅 ID 过滤")
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan")
@@ -1075,7 +1090,7 @@ func newEventStopCommand() *cobra.Command {
},
}
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
cmd.Flags().StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅")
@@ -1192,6 +1207,19 @@ func rejectPersonalEventUnsupportedFlags(c *cobra.Command, names ...string) erro
return fmt.Errorf("%s are not supported for personal events", strings.Join(changed, ", "))
}
func rejectPersonalMultiEventFlags(c *cobra.Command, names ...string) error {
changed := make([]string, 0, len(names))
for _, name := range names {
if f := c.Flags().Lookup(name); f != nil && f.Changed {
changed = append(changed, "--"+name)
}
}
if len(changed) == 0 {
return nil
}
return fmt.Errorf("%s are not supported when consuming multiple events", strings.Join(changed, ", "))
}
func rejectChangedFlags(c *cobra.Command, supportedAs string, names ...string) error {
changed := make([]string, 0, len(names))
for _, name := range names {
+309 -2
View File
@@ -61,6 +61,7 @@ type commonConsumeOptions struct {
type personalConsumeOptions struct {
Common commonConsumeOptions
EventKey string
EventKeys []string
Flatten bool
DebugRawEvents bool
SubscribeID string
@@ -112,6 +113,7 @@ type personalStreamSourceOptions struct {
var (
personalResolveEventIdentity = resolvePersonalEventIdentity
personalLookupDefinition = personal.Lookup
personalEnsureSubscription = ensurePersonalSubscription
personalGetSubscription = (*personal.Client).GetSubscription
personalCreateSubscription = (*personal.Client).CreateSubscription
@@ -121,6 +123,7 @@ var (
personalRemoveRunStates = personal.RemoveRunStates
personalLoadRunStates = personal.LoadRunStates
personalConsumeRun = consume.Run
personalConsumeRunMany = consume.RunMany
personalValidateConsumeConfig = consume.ValidateConfig
personalValidateNoOutputConflict = consume.ValidateNoOutputConflict
personalNewStreamSource = newPersonalStreamSource
@@ -129,6 +132,7 @@ var (
personalQueryEntry = busctl.QueryEntry
personalQueryStatus = busctl.QueryStatus
personalStopBus = busctl.Stop
personalStopConsumers = busctl.StopConsumers
personalFindProcess = os.FindProcess
personalSignalProcess = (*os.Process).Signal
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
@@ -206,6 +210,21 @@ func renderPersonalSchema(w io.Writer, def personal.Definition, format string, f
}
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
keys := dedupePersonalEventKeys(opts.EventKeys)
if len(keys) == 0 && strings.TrimSpace(opts.EventKey) != "" {
keys = []string{strings.TrimSpace(opts.EventKey)}
}
if len(keys) <= 1 {
if len(keys) == 1 {
opts.EventKey = keys[0]
}
return runPersonalEventConsumeSingle(c, opts)
}
opts.EventKeys = keys
return runPersonalEventConsumeMany(c, opts)
}
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
ctx := c.Context()
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
return err
@@ -375,6 +394,266 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
return err
}
type personalMultiSubscription struct {
Sub *personal.Subscription
EventKey string
RuleType string
}
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
plans, err := preparePersonalMultiOptions(opts)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
rawFormat := ""
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
rawFormat = opts.Common.FormatRaw
}
normalised, fellback := consume.NormalizeFormat(rawFormat)
if fellback && !opts.Common.Quiet {
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
}
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
projector := personalEventProjector(false, opts.Flatten)
ctx := c.Context()
configDir := defaultConfigDir()
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
identityHash := dwsevent.IdentityHash(identity.Key())
editionName := editionNameOrDefault()
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
if err != nil {
return fmt.Errorf("event consume --as user: %w", err)
}
baseCfg := consume.Config{
WorkDir: workDir,
IPCEndpoint: ipcEndpoint,
ClientID: identity.ClientID,
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
Compact: opts.Common.Compact,
MaxEvents: opts.Common.MaxEvents,
Duration: opts.Common.Duration,
Format: normalised,
Flatten: opts.Flatten,
OutputDir: opts.Common.OutputDir,
Routes: routes,
Projector: projector,
Stdout: c.OutOrStdout(),
Stderr: c.ErrOrStderr(),
Quiet: opts.Common.Quiet,
}
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
if err := personalValidateConsumeConfig(baseCfg); err != nil {
return err
}
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
return err
}
}
if opts.Common.DryRun {
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
return nil
}
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
created := make([]personalMultiSubscription, 0, len(plans))
cleanup := func() {
ids := make([]string, 0, len(created))
for i := len(created) - 1; i >= 0; i-- {
id := strings.TrimSpace(created[i].Sub.SubscribeID)
ids = append(ids, id)
if err := personalDeleteSubscription(client, context.Background(), id); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
}
}
if len(ids) > 0 {
if err := personalRemoveRunStates(workDir, ids); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal event run state: %v\n", err)
}
}
}
seenSubscribeIDs := make(map[string]struct{}, len(plans))
for _, plan := range plans {
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
if err != nil {
cleanup()
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
}
if sub == nil {
cleanup()
return fmt.Errorf("event consume --as user: server returned an empty subscription for %s", plan.EventKey)
}
id := strings.TrimSpace(sub.SubscribeID)
if id == "" {
cleanup()
return fmt.Errorf("event consume --as user: server returned empty subscribe_id for %s", plan.EventKey)
}
if _, exists := seenSubscribeIDs[id]; exists {
_ = personalDeleteSubscription(client, context.Background(), id)
cleanup()
return fmt.Errorf("event consume --as user: server returned duplicate subscribe_id %s", id)
}
seenSubscribeIDs[id] = struct{}{}
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
created = append(created, item)
if err := personalUpsertRunState(workDir, personal.RunState{
SubscribeID: id,
EventKey: eventKey,
RuleType: ruleType,
ClientID: identity.ClientID,
SourceID: identity.SourceID,
IdentityHash: identityHash,
}); err != nil {
cleanup()
return fmt.Errorf("event consume --as user: save run state for %s: %w", eventKey, err)
}
}
defer cleanup()
specs := make([]consume.ConsumerSpec, 0, len(created))
for _, item := range created {
specs = append(specs, consume.ConsumerSpec{
EventKey: item.EventKey,
EventTypes: []string{item.EventKey},
SubscribeID: item.Sub.SubscribeID,
ReadySubscribeID: item.Sub.SubscribeID,
})
}
if err := personalConsumeRunMany(ctx, baseCfg, specs); err != nil {
return err
}
return nil
}
func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsumeOptions, error) {
if strings.TrimSpace(opts.SubscribeID) != "" {
return nil, errors.New("--subscribe-id is not supported when consuming multiple events")
}
if strings.TrimSpace(opts.Rule) != "" {
return nil, errors.New("--rule is not supported when consuming multiple events")
}
if len(opts.Common.EventTypes) > 0 {
return nil, errors.New("--event-types is not supported when consuming multiple events; use event_key positionals")
}
if strings.TrimSpace(opts.Common.Filter) != "" {
return nil, errors.New("--filter is not supported when consuming multiple events; use event_key positionals")
}
if opts.Common.Foreground || opts.Common.Force {
return nil, errors.New("--foreground/--force are not supported when consuming multiple events")
}
if opts.DebugRawEvents {
return nil, errors.New("--debug-raw-events is not supported when consuming multiple events")
}
keys := dedupePersonalEventKeys(opts.EventKeys)
if len(keys) < 2 {
return nil, errors.New("multiple event keys are required")
}
hasUserScope := false
hasGroupScope := false
for _, eventKey := range keys {
def, ok := personalLookupDefinition(eventKey)
if !ok {
return nil, fmt.Errorf("unknown personal event key %q", eventKey)
}
if !def.Public {
return nil, personal.PublicAvailabilityError(eventKey)
}
switch def.RuleType {
case "singleChat", "sender":
hasUserScope = true
case "group":
hasGroupScope = true
}
if (strings.TrimSpace(opts.QueryCSV) != "" || strings.TrimSpace(opts.FilterJSON) != "") && !personal.SupportsMessageFilter(eventKey) {
return nil, fmt.Errorf("--query/--filter-json require all selected events to be message receive events; %s is not", eventKey)
}
}
if hasUserScope && hasGroupScope {
return nil, errors.New("user-scoped and group-scoped events cannot be consumed in one command")
}
userID := strings.TrimSpace(opts.UserID)
openID := strings.TrimSpace(opts.OpenDingTalkID)
groupID := strings.TrimSpace(opts.GroupID)
if userID != "" && openID != "" {
return nil, errors.New("--user and --open-dingtalk-id are mutually exclusive")
}
switch {
case hasUserScope:
if groupID != "" {
return nil, errors.New("--group cannot be used with user-scoped events")
}
if userID == "" && openID == "" {
return nil, errors.New("one of --user or --open-dingtalk-id is required for the selected events")
}
case hasGroupScope:
if userID != "" || openID != "" {
return nil, errors.New("--user/--open-dingtalk-id cannot be used with group-scoped events")
}
if groupID == "" {
return nil, errors.New("--group is required for the selected events")
}
default:
if userID != "" || openID != "" || groupID != "" {
return nil, errors.New("the selected events do not use --user, --open-dingtalk-id, or --group")
}
}
plans := make([]personalConsumeOptions, 0, len(keys))
for _, eventKey := range keys {
def, _ := personalLookupDefinition(eventKey)
plan := opts
plan.EventKey = eventKey
plan.EventKeys = nil
switch def.RuleType {
case "at", "all":
plan.UserID = ""
plan.OpenDingTalkID = ""
plan.GroupID = ""
case "singleChat", "sender":
plan.GroupID = ""
case "group":
plan.UserID = ""
plan.OpenDingTalkID = ""
}
if err := validatePersonalSubscriptionOptions(plan); err != nil {
return nil, err
}
plans = append(plans, plan)
}
return plans, nil
}
func printPersonalMultiDryRun(w io.Writer, cfg consume.Config, plans []personalConsumeOptions) {
preview := cfg
preview.EventTypes = make([]string, 0, len(plans))
for _, plan := range plans {
preview.EventTypes = append(preview.EventTypes, plan.EventKey)
}
consume.PrintDryRun(w, preview)
for i, plan := range plans {
ruleType, ruleParam, _ := personal.BuildRuleParam(plan.EventKey, personal.RuleOptions{
UserID: plan.UserID, OpenDingTalkID: plan.OpenDingTalkID, GroupID: plan.GroupID,
})
_, filter, _ := personal.BuildFilter(plan.FilterJSON, plan.QueryCSV)
ruleJSON, _ := personal.CanonicalJSON(ruleParam)
fmt.Fprintf(w, " subscription[%d] : event_key=%s rule_type=%s rule_param=%s",
i, plan.EventKey, ruleType, ruleJSON)
if filter != "" {
fmt.Fprintf(w, " filter=%s", filter)
}
fmt.Fprintln(w)
}
}
func personalEventProjector(debugRawEvents, flatten bool) consume.Projector {
if debugRawEvents {
return func(ev transport.Event) (any, error) { return ev, nil }
@@ -648,7 +927,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil {
return fmt.Errorf("event stop --as user: update local state: %w", err)
}
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
if err := stopPersonalConsumers(c.ErrOrStderr(), ipcEndpoint, subscribeIDs); err != nil {
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
}
@@ -736,6 +1015,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
return nil
}
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
return nil
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
return err
} else {
fmt.Fprintf(w, "WARN: running bus does not support targeted consumer stop; falling back to process signal: %v\n", err)
}
return interruptPersonalConsumers(ipcEndpoint, subscribeIDs)
}
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
if single && len(subscribeIDs) == 1 {
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
@@ -968,6 +1258,23 @@ func firstNonEmptyPersonalString(values ...string) string {
return ""
}
func dedupePersonalEventKeys(values []string) []string {
out := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func personalEventControlBaseURL(raw, configDir string) string {
if v := strings.TrimSpace(raw); v != "" {
return strings.TrimRight(v, "/")
@@ -993,7 +1300,7 @@ func personalEventMCPBaseURL(configDir string) string {
if v := configuredMCPBaseURL(configDir); v != "" {
return strings.TrimRight(v, "/")
}
return config.DefaultMCPBaseURL
return strings.TrimRight(config.DefaultMCPBaseURL, "/")
}
func configuredMCPBaseURL(configDir string) string {
+661
View File
@@ -0,0 +1,661 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package app
import (
"bytes"
"context"
"errors"
"io"
"os"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
"github.com/spf13/cobra"
)
func TestEventConsumeAcceptsOrderedVariadicEventKeys(t *testing.T) {
oldRun := eventRunPersonalConsume
defer func() { eventRunPersonalConsume = oldRun }()
var got personalConsumeOptions
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
got = opts
return nil
}
cmd := newEventConsumeCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{
personal.EventMention,
personal.EventSingleChat,
personal.EventMention,
"--user", "test-user-001",
})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute() error = %v", err)
}
want := []string{personal.EventMention, personal.EventSingleChat}
if !reflect.DeepEqual(got.EventKeys, want) || got.EventKey != personal.EventMention {
t.Fatalf("event keys = %#v, first = %q", got.EventKeys, got.EventKey)
}
}
func TestPreparePersonalMultiOptionsCombinationMatrix(t *testing.T) {
tests := []struct {
name string
opts personalConsumeOptions
wantErr string
}{
{
name: "no target events",
opts: personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}},
},
{
name: "user and no target",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O, personal.EventMention},
UserID: "test-user-001",
},
},
{
name: "group and no target",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated, personal.EventMention},
GroupID: "cid-test",
},
},
{
name: "open dingtalk id",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventRecallO2O},
OpenDingTalkID: "open-test-user",
},
},
{
name: "user and group mixed",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventInChat},
UserID: "test-user-001",
},
wantErr: "cannot be consumed in one command",
},
{
name: "duplicate keys collapse to one",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventMention},
},
wantErr: "multiple event keys are required",
},
{
name: "unknown event",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, "user_im_unknown"},
},
wantErr: "unknown personal event key",
},
{
name: "missing user target",
opts: personalConsumeOptions{EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O}},
wantErr: "one of --user or --open-dingtalk-id",
},
{
name: "missing group target",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
},
wantErr: "--group is required",
},
{
name: "user identity flags conflict",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
UserID: "test-user-001",
OpenDingTalkID: "open-test-user",
},
wantErr: "mutually exclusive",
},
{
name: "group target on user events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
UserID: "test-user-001",
GroupID: "cid-test",
},
wantErr: "--group cannot be used",
},
{
name: "user target on group events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
UserID: "test-user-001",
GroupID: "cid-test",
},
wantErr: "cannot be used with group-scoped events",
},
{
name: "target on no target events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
UserID: "test-user-001",
},
wantErr: "do not use --user",
},
{
name: "filter message events",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllGroupChat},
QueryCSV: "alarm",
},
},
{
name: "filter mixed with action",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
UserID: "test-user-001",
QueryCSV: "alarm",
},
wantErr: "require all selected events to be message receive events",
},
{
name: "invalid message filter",
opts: personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
FilterJSON: "{",
},
wantErr: "filter",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
plans, err := preparePersonalMultiOptions(test.opts)
if test.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want %q", err, test.wantErr)
}
return
}
if err != nil {
t.Fatalf("preparePersonalMultiOptions() error = %v", err)
}
if len(plans) != len(test.opts.EventKeys) {
t.Fatalf("plans = %d, want %d", len(plans), len(test.opts.EventKeys))
}
for _, plan := range plans {
def, _ := personal.Lookup(plan.EventKey)
if def.RuleType == "at" || def.RuleType == "all" {
if plan.UserID != "" || plan.OpenDingTalkID != "" || plan.GroupID != "" {
t.Fatalf("no-target plan retained target: %#v", plan)
}
}
}
})
}
}
func TestPreparePersonalMultiOptionsRejectsNonPublicEvent(t *testing.T) {
oldLookup := personalLookupDefinition
t.Cleanup(func() { personalLookupDefinition = oldLookup })
personalLookupDefinition = func(eventKey string) (personal.Definition, bool) {
def, ok := personal.Lookup(eventKey)
if eventKey == personal.EventAllSingleChat {
def.Public = false
}
return def, ok
}
_, err := preparePersonalMultiOptions(personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if err == nil || !strings.Contains(err.Error(), "not publicly available") {
t.Fatalf("error = %v", err)
}
}
func TestDedupePersonalEventKeysSkipsEmptyValues(t *testing.T) {
got := dedupePersonalEventKeys([]string{"", " event-a ", "event-a", "event-b"})
if !reflect.DeepEqual(got, []string{"event-a", "event-b"}) {
t.Fatalf("deduped keys = %#v", got)
}
}
func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
base := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
tests := []struct {
name string
set func(*personalConsumeOptions)
}{
{name: "subscribe-id", set: func(o *personalConsumeOptions) { o.SubscribeID = "sub" }},
{name: "rule", set: func(o *personalConsumeOptions) { o.Rule = "all" }},
{name: "event-types", set: func(o *personalConsumeOptions) { o.Common.EventTypes = []string{"x"} }},
{name: "filter", set: func(o *personalConsumeOptions) { o.Common.Filter = "x" }},
{name: "foreground", set: func(o *personalConsumeOptions) { o.Common.Foreground = true }},
{name: "force", set: func(o *personalConsumeOptions) { o.Common.Force = true }},
{name: "debug-raw-events", set: func(o *personalConsumeOptions) { o.DebugRawEvents = true }},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
opts := base
test.set(&opts)
if _, err := preparePersonalMultiOptions(opts); err == nil {
t.Fatal("option succeeded")
}
})
}
}
func TestEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
oldRun := eventRunPersonalConsume
defer func() { eventRunPersonalConsume = oldRun }()
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
t.Fatal("personal consume ran after explicit multi-event flag")
return nil
}
flags := []string{
"--subscribe-id=",
"--rule=",
"--event-types=",
"--filter=",
"--foreground=false",
"--force=false",
"--debug-raw-events=false",
}
for _, flag := range flags {
t.Run(flag, func(t *testing.T) {
cmd := newEventConsumeCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{personal.EventMention, personal.EventAllSingleChat, flag})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "not supported when consuming multiple events") {
t.Fatalf("Execute() error = %v", err)
}
})
}
}
func TestRunPersonalEventConsumeManyCreatesAndCleansAllSubscriptions(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "open"}
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
createdKeys := make([]string, 0, 2)
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
createdKeys = append(createdKeys, opts.EventKey)
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
}
var states []personal.RunState
personalUpsertRunState = func(_ string, state personal.RunState) error {
states = append(states, state)
return nil
}
var deleted []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
var removed []string
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
if !cfg.Flatten || cfg.Projector == nil || len(specs) != 2 {
t.Fatalf("consume config/specs = %#v / %#v", cfg, specs)
}
for i, spec := range specs {
if spec.EventKey != createdKeys[i] || spec.SubscribeID != "sub-"+createdKeys[i] || !reflect.DeepEqual(spec.EventTypes, []string{createdKeys[i]}) {
t.Fatalf("spec[%d] = %#v", i, spec)
}
}
return nil
}
cmd := newPersonalCoverageCommand()
err := runPersonalEventConsume(cmd, personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
Flatten: true,
})
if err != nil {
t.Fatalf("runPersonalEventConsume() error = %v", err)
}
if len(states) != 2 || len(deleted) != 2 || len(removed) != 2 {
t.Fatalf("states=%#v deleted=%#v removed=%#v", states, deleted, removed)
}
}
func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
wantErr := errors.New("second subscription failed")
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
if calls == 2 {
return nil, "", "", wantErr
}
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
var deleted []string
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
deleted = append(deleted, id)
return nil
}
var removed []string
personalRemoveRunStates = func(_ string, ids []string) error {
removed = append(removed, ids...)
return nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("RunMany called after partial creation failure")
return nil
}
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if !errors.Is(err, wantErr) {
t.Fatalf("error = %v", err)
}
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
}
}
func TestRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
for _, test := range []struct {
name string
ensure func(int, personalConsumeOptions) *personal.Subscription
upsertErr error
wantErr string
}{
{
name: "nil subscription",
ensure: func(int, personalConsumeOptions) *personal.Subscription { return nil },
wantErr: "empty subscription",
},
{
name: "empty subscribe id",
ensure: func(int, personalConsumeOptions) *personal.Subscription { return &personal.Subscription{} },
wantErr: "empty subscribe_id",
},
{
name: "duplicate subscribe id",
ensure: func(int, personalConsumeOptions) *personal.Subscription {
return &personal.Subscription{SubscribeID: "sub-duplicate"}
},
wantErr: "duplicate subscribe_id",
},
{
name: "run state write failure",
ensure: func(_ int, opts personalConsumeOptions) *personal.Subscription {
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}
},
upsertErr: errors.New("state write failed"),
wantErr: "save run state",
},
} {
t.Run(test.name, func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
calls := 0
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
calls++
return test.ensure(calls, opts), opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return test.upsertErr }
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
personalRemoveRunStates = func(string, []string) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
t.Fatal("RunMany called with invalid subscription result")
return nil
}
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
})
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want %q", err, test.wantErr)
}
})
}
}
func TestRunPersonalEventConsumeManyDryRunDoesNotCreateSubscriptions(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
t.Fatal("dry-run created a subscription")
return nil, "", "", nil
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
cmd := newPersonalCoverageCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
err := runPersonalEventConsume(cmd, personalConsumeOptions{
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
QueryCSV: "alarm",
Common: commonConsumeOptions{DryRun: true},
})
if err != nil {
t.Fatalf("dry-run error = %v", err)
}
if !strings.Contains(stderr.String(), "subscription[0]") ||
!strings.Contains(stderr.String(), "subscription[1]") ||
!strings.Contains(stderr.String(), "filter=") {
t.Fatalf("dry-run subscriptions missing:\n%s", stderr.String())
}
}
func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t *testing.T) {
valid := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
t.Run("prepare error", func(t *testing.T) {
err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
EventKeys: []string{personal.EventMention},
})
if err == nil || !strings.Contains(err.Error(), "multiple event keys") {
t.Fatalf("error = %v", err)
}
})
t.Run("format warning and identity error", func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
cmd := newPersonalCoverageCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := cmd.Flags().Set("format", "bogus"); err != nil {
t.Fatal(err)
}
wantErr := errors.New("identity")
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{}, wantErr
}
opts := valid
opts.Common.FormatRaw = "bogus"
if err := runPersonalEventConsumeMany(cmd, opts); !errors.Is(err, wantErr) {
t.Fatalf("error = %v", err)
}
if !strings.Contains(stderr.String(), "using ndjson") {
t.Fatalf("warning = %q", stderr.String())
}
})
t.Run("flatten raw conflict", func(t *testing.T) {
cmd := newPersonalCoverageCommand()
if err := cmd.Flags().Set("format", "raw"); err != nil {
t.Fatal(err)
}
opts := valid
opts.Flatten = true
opts.Common.FormatRaw = "raw"
if err := runPersonalEventConsumeMany(cmd, opts); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v", err)
}
})
t.Run("route validation and output conflict", func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
opts := valid
opts.Common.RoutesRaw = []string{"bad-route"}
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), opts); err == nil {
t.Fatal("invalid route succeeded")
}
wantErr := errors.New("validate")
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), valid); !errors.Is(err, wantErr) {
t.Fatalf("config validation error = %v", err)
}
personalValidateConsumeConfig = func(consume.Config) error { return nil }
personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr }
cmd := newPersonalCoverageCommand()
if err := cmd.Flags().Set("output", "events.json"); err != nil {
t.Fatal(err)
}
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, wantErr) {
t.Fatalf("output conflict error = %v", err)
}
})
t.Run("runtime error reports cleanup failures", func(t *testing.T) {
restore := installPersonalManySeams(t)
defer restore()
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
}
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
}
personalUpsertRunState = func(string, personal.RunState) error { return nil }
personalValidateConsumeConfig = func(consume.Config) error { return nil }
cleanupErr := errors.New("cleanup")
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return cleanupErr }
personalRemoveRunStates = func(string, []string) error { return cleanupErr }
runErr := errors.New("run many")
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error { return runErr }
cmd := newPersonalCoverageCommand()
var stderr bytes.Buffer
cmd.SetErr(&stderr)
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, runErr) {
t.Fatalf("runtime error = %v", err)
}
if !strings.Contains(stderr.String(), "failed to clean personal subscription") ||
!strings.Contains(stderr.String(), "failed to clean personal event run state") {
t.Fatalf("cleanup warnings = %q", stderr.String())
}
})
}
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
oldStop := personalStopConsumers
oldQuery := personalQueryStatus
oldFind := personalFindProcess
oldSignal := personalSignalProcess
defer func() {
personalStopConsumers = oldStop
personalQueryStatus = oldQuery
personalFindProcess = oldFind
personalSignalProcess = oldSignal
}()
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy status queried after targeted stop succeeded")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); err != nil {
t.Fatal(err)
}
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{}, busctl.ErrConsumerStopUnsupported
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
return &transport.StatusResp{Consumers: []transport.StatusConsumer{{PID: 321, SubscribeID: "sub-a"}}}, nil
}
proc := &os.Process{}
personalFindProcess = func(int) (*os.Process, error) { return proc, nil }
signals := 0
personalSignalProcess = func(*os.Process, os.Signal) error { signals++; return nil }
var warning bytes.Buffer
if err := stopPersonalConsumers(&warning, "endpoint", []string{"sub-a"}); err != nil {
t.Fatal(err)
}
if signals != 1 || !strings.Contains(warning.String(), "falling back to process signal") {
t.Fatalf("signals=%d warning=%q", signals, warning.String())
}
wantErr := errors.New("targeted stop transport failed")
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
return transport.ConsumerStopResp{}, wantErr
}
personalQueryStatus = func(string) (*transport.StatusResp, error) {
t.Fatal("legacy fallback ran for a non-compatibility error")
return nil, nil
}
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); !errors.Is(err, wantErr) {
t.Fatalf("transport error = %v", err)
}
}
func installPersonalManySeams(t *testing.T) func() {
t.Helper()
oldIdentity := personalResolveEventIdentity
oldLookup := personalLookupDefinition
oldEnsure := personalEnsureSubscription
oldUpsert := personalUpsertRunState
oldDelete := personalDeleteSubscription
oldRemove := personalRemoveRunStates
oldRunMany := personalConsumeRunMany
oldValidate := personalValidateConsumeConfig
oldConflict := personalValidateNoOutputConflict
return func() {
personalResolveEventIdentity = oldIdentity
personalLookupDefinition = oldLookup
personalEnsureSubscription = oldEnsure
personalUpsertRunState = oldUpsert
personalDeleteSubscription = oldDelete
personalRemoveRunStates = oldRemove
personalConsumeRunMany = oldRunMany
personalValidateConsumeConfig = oldValidate
personalValidateNoOutputConflict = oldConflict
}
}
+173
View File
@@ -47,12 +47,18 @@ func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
assertPersonalOutputHidesSchemaIDs(t, got)
for _, eventKey := range []string{
personal.EventFromUser,
personal.EventAllSingleChat,
personal.EventAllGroupChat,
personal.EventReadO2O,
personal.EventReadGroup,
personal.EventRecallO2O,
personal.EventRecallGroup,
personal.EventReactionO2O,
personal.EventReactionGroup,
personal.EventGroupUpdated,
personal.EventGroupMemberAdded,
personal.EventGroupMemberExited,
personal.EventGroupDisbanded,
} {
if !strings.Contains(got, eventKey) {
t.Fatalf("list output missing %s: %s", eventKey, got)
@@ -230,6 +236,8 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
personal.EventMention,
personal.EventSingleChat,
personal.EventInChat,
personal.EventAllSingleChat,
personal.EventAllGroupChat,
} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
@@ -265,6 +273,8 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
"message_id",
"create_time",
"event_time",
"quoted_message",
"forward_messages",
} {
if !strings.Contains(got, want) {
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
@@ -309,6 +319,103 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
if _, ok := props["content"].(map[string]any); !ok {
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
}
quoted, ok := props["quoted_message"].(map[string]any)
if !ok || quoted["type"] != "object" {
t.Fatalf("schema.properties.quoted_message = %#v, want object", props["quoted_message"])
}
forward, ok := props["forward_messages"].(map[string]any)
if !ok || forward["type"] != "array" {
t.Fatalf("schema.properties.forward_messages = %#v, want array", props["forward_messages"])
}
})
}
}
func TestPersonalGroupLifecycleEventSchemaUsesConservativePayload(t *testing.T) {
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupDisbanded} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
t.Fatalf("schema metadata = %#v", doc)
}
required, ok := doc["required_params"].([]any)
if !ok || len(required) != 1 || required[0] != "group" {
t.Fatalf("required_params = %#v, want [group]", doc["required_params"])
}
schema := doc["schema"].(map[string]any)
properties := schema["properties"].(map[string]any)
if len(properties) != 5 {
t.Fatalf("schema.properties = %#v, want five conservative fields", properties)
}
payload, ok := properties["payload"].(map[string]any)
if !ok || payload["type"] != "object" || payload["additionalProperties"] != true {
t.Fatalf("schema.properties.payload = %#v", properties["payload"])
}
})
}
}
func TestPersonalGroupMemberEventSchemaMatchesFlatOutput(t *testing.T) {
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
"operator", "operator_open_dingtalk_id", "members", "event_time",
}
for _, eventKey := range []string{personal.EventGroupMemberAdded, personal.EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
cmd := newEventSchemaCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{eventKey, "--flatten"})
if err := cmd.Execute(); err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
}
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
t.Fatalf("schema metadata = %#v", doc)
}
properties := doc["schema"].(map[string]any)["properties"].(map[string]any)
if len(properties) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d flat fields", properties, len(wantProperties))
}
for _, field := range wantProperties {
if _, ok := properties[field].(map[string]any); !ok {
t.Fatalf("schema missing %q: %#v", field, properties)
}
}
members := properties["members"].(map[string]any)
items, ok := members["items"].(map[string]any)
if !ok || members["type"] != "array" || items["type"] != "object" {
t.Fatalf("members schema = %#v", members)
}
memberProperties, ok := items["properties"].(map[string]any)
if !ok {
t.Fatalf("members.items.properties = %#v", items["properties"])
}
for _, field := range []string{"nick", "open_dingtalk_id"} {
if _, ok := memberProperties[field].(map[string]any); !ok {
t.Fatalf("member schema missing %q: %#v", field, memberProperties)
}
}
if _, ok := properties["payload"]; ok {
t.Fatalf("group member schema exposed generic payload: %#v", properties)
}
})
}
}
@@ -485,6 +592,64 @@ func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
}
}
func TestPersonalNewIMEventsDryRunAndValidation(t *testing.T) {
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresAt: time.Now().Add(time.Hour),
RefreshExpAt: time.Now().Add(24 * time.Hour),
CorpID: "corp-1",
UserID: "user-1",
ClientID: "client-1",
})
t.Setenv("DWS_CONFIG_DIR", configDir)
for _, test := range []struct {
eventKey string
args []string
}{
{eventKey: personal.EventAllSingleChat},
{eventKey: personal.EventAllGroupChat},
{eventKey: personal.EventGroupUpdated, args: []string{"--group", "cid-test-group"}},
{eventKey: personal.EventGroupMemberAdded, args: []string{"--group", "cid-test-group"}},
{eventKey: personal.EventGroupMemberExited, args: []string{"--group", "cid-test-group"}},
{eventKey: personal.EventGroupDisbanded, args: []string{"--group", "cid-test-group"}},
} {
t.Run(test.eventKey, func(t *testing.T) {
if err := ensurePublicPersonalEvent(test.eventKey); err != nil {
t.Fatalf("ensurePublicPersonalEvent() error = %v", err)
}
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs(append([]string{test.eventKey}, append(test.args, "--dry-run")...))
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run Execute() error = %v", err)
}
})
}
for _, eventKey := range []string{personal.EventAllSingleChat, personal.EventAllGroupChat} {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{eventKey, "--user", "test-user-001", "--dry-run"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+eventKey) {
t.Fatalf("%s scoped user error = %v", eventKey, err)
}
}
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupMemberAdded, personal.EventGroupMemberExited, personal.EventGroupDisbanded} {
cmd := newEventConsumeCommand()
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetArgs([]string{eventKey, "--dry-run"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) {
t.Fatalf("%s missing group error = %v", eventKey, err)
}
}
}
func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
root := NewRootCommand()
root.SilenceUsage = true
@@ -550,6 +715,14 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
t.Fatalf("schema constraint %s = %#v, missing %#v", field, groups, want)
}
assertJSONConstraintGroup("require_one_of", []string{"event_key", "subscribe-id"})
positionals, ok := doc["positionals"].([]any)
if !ok || len(positionals) != 1 {
t.Fatalf("schema positionals = %#v", doc["positionals"])
}
eventKey, ok := positionals[0].(map[string]any)
if !ok || eventKey["name"] != "event_key" || eventKey["variadic"] != true {
t.Fatalf("event_key positional = %#v, want variadic", positionals[0])
}
}
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
+7
View File
@@ -353,6 +353,13 @@ func newRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine,
return cmd.Help()
},
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Validate the optional observation label before any edition hook
// or command network activity can run. Header-only library callers
// use the best-effort path in resolveIdentityHeaders instead.
if _, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err != nil {
return err
}
authpkg.SetRuntimeProfile(flags.Profile)
// Apply OAuth credential overrides from CLI flags (highest priority).
if flags.ClientID != "" {
+8
View File
@@ -1038,6 +1038,14 @@ func resolveIdentityHeaders() map[string]string {
headers["x-dws-channel"] = v
}
// DWS_AGENT_HOST is a caller-provided observation label only. Root command
// execution validates it strictly in PersistentPreRunE. Library callers
// that bypass the root command keep this best-effort API contract: invalid
// values are omitted rather than changing the public function signature.
if agentHost, err := parseAgentHost(os.Getenv(envDWSAgentHost)); err == nil && agentHost != "" {
headers[headerDWSAgentHost] = agentHost
}
if fn := edition.Get().MergeHeaders; fn != nil {
headers = fn(headers)
}
+15 -9
View File
@@ -9382,7 +9382,8 @@
"availability": "available",
"avoid_when": [
"已确认是 adoc 且只要正文改用 dws doc read",
"只要目录列表改用 dws drive list / wiki node list"
"只要目录列表改用 dws drive list / wiki node list",
"需要可靠文件大小 fileSize 时改用 dws drive info;文档元信息接口可能不返回大小"
],
"confirmation": "not_required",
"effect": "read",
@@ -9433,7 +9434,8 @@
"avoid_when": {
"value": [
"已确认是 adoc 且只要正文改用 dws doc read",
"只要目录列表改用 dws drive list / wiki node list"
"只要目录列表改用 dws drive list / wiki node list",
"需要可靠文件大小 fileSize 时改用 dws drive info;文档元信息接口可能不返回大小"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -9443,7 +9445,8 @@
{
"value": [
"已确认是 adoc 且只要正文改用 dws doc read",
"只要目录列表改用 dws drive list / wiki node list"
"只要目录列表改用 dws drive list / wiki node list",
"需要可靠文件大小 fileSize 时改用 dws drive info;文档元信息接口可能不返回大小"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -12068,7 +12071,8 @@
"availability": "available",
"avoid_when": [
"改正文标题/章节标题改用 doc block update",
"不要用 update 或重新 create 来改名"
"不要用 update 或重新 create 来改名",
"文件或文件夹重命名优先用 dws drive rename,由该命令读取真实节点类型和当前扩展名"
],
"confirmation": "not_required",
"effect": "write",
@@ -12118,7 +12122,8 @@
"avoid_when": {
"value": [
"改正文标题/章节标题改用 doc block update",
"不要用 update 或重新 create 来改名"
"不要用 update 或重新 create 来改名",
"文件或文件夹重命名优先用 dws drive rename,由该命令读取真实节点类型和当前扩展名"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -12128,7 +12133,8 @@
{
"value": [
"改正文标题/章节标题改用 doc block update",
"不要用 update 或重新 create 来改名"
"不要用 update 或重新 create 来改名",
"文件或文件夹重命名优先用 dws drive rename,由该命令读取真实节点类型和当前扩展名"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -12274,7 +12280,7 @@
},
"use_when": {
"value": [
"用户要改文档/文件在列表与链接中展示的名称时"
"用户要改在线文档在列表与链接中展示的名称时"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -12283,7 +12289,7 @@
"candidates": [
{
"value": [
"用户要改文档/文件在列表与链接中展示的名称时"
"用户要改在线文档在列表与链接中展示的名称时"
],
"source": "internal/cli/schema_hints/selection/doc.json",
"precedence": "reviewed_explicit",
@@ -12315,7 +12321,7 @@
"structured-hint:internal/cli/schema_hints/selection-review.json#doc.rename_document"
],
"use_when": [
"用户要改文档/文件在列表与链接中展示的名称时"
"用户要改在线文档在列表与链接中展示的名称时"
]
},
"doc search": {
+20 -17
View File
@@ -7083,12 +7083,13 @@
]
},
"drive rename": {
"agent_summary": "修改文档空间中文档或文件的名称",
"agent_summary": "安全重命名文档空间中的文档、文件或文件夹,并按节点真实扩展名避免双后缀",
"agent_summary_source": "dws-agent-selection/drive",
"availability": "available",
"avoid_when": [
"要改正文里的标题/章节 H1 改用 dws doc block update,不要用 rename",
"只要复制或移动改用 copy/move"
"只要复制或移动改用 copy/move",
"dry-run 不读取节点元数据,输出名称尚未做基于当前扩展名的规范化"
],
"confirmation": "not_required",
"effect": "write",
@@ -7098,14 +7099,14 @@
],
"field_provenance": {
"agent_summary": {
"value": "修改文档空间中文档或文件的名称",
"value": "安全重命名文档空间中的文档、文件或文件夹,并按节点真实扩展名避免双后缀",
"source": "internal/cli/schema_hints/selection/drive.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工审阅:结合本机 dws schema 实时 MCP description/parameters(经 interface_ref)、产品 Skill、Cobra Long 与 Runtime 确认门禁,按飞书风格重写选型文案;不改变命令身份、参数契约或接口绑定。",
"candidates": [
{
"value": "修改文档空间中文档或文件的名称",
"value": "安全重命名文档空间中的文档、文件或文件夹,并按节点真实扩展名避免双后缀",
"source": "internal/cli/schema_hints/selection/drive.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -7118,14 +7119,14 @@
"source": "internal/cli/schema_hints/metadata/drive.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/drive marks this tool as reviewed",
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/drive.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/drive marks this tool as reviewed"
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist."
},
{
"value": "available",
@@ -7138,7 +7139,8 @@
"avoid_when": {
"value": [
"要改正文里的标题/章节 H1 改用 dws doc block update,不要用 rename",
"只要复制或移动改用 copy/move"
"只要复制或移动改用 copy/move",
"dry-run 不读取节点元数据,输出名称尚未做基于当前扩展名的规范化"
],
"source": "internal/cli/schema_hints/selection/drive.json",
"precedence": "reviewed_explicit",
@@ -7148,7 +7150,8 @@
{
"value": [
"要改正文里的标题/章节 H1 改用 dws doc block update,不要用 rename",
"只要复制或移动改用 copy/move"
"只要复制或移动改用 copy/move",
"dry-run 不读取节点元数据,输出名称尚未做基于当前扩展名的规范化"
],
"source": "internal/cli/schema_hints/selection/drive.json",
"precedence": "reviewed_explicit",
@@ -7224,14 +7227,14 @@
"source": "internal/cli/schema_hints/metadata/drive.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/drive marks this tool as reviewed",
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist.",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/drive.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/drive marks this tool as reviewed"
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist."
},
{
"value": "mcp",
@@ -7246,14 +7249,14 @@
"source": "internal/cli/schema_hints/metadata/drive.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/drive marks this tool as reviewed",
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist.",
"candidates": [
{
"value": "doc.rename_document",
"source": "internal/cli/schema_hints/metadata/drive.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/drive marks this tool as reviewed"
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist."
},
{
"value": "doc.rename_document",
@@ -7268,14 +7271,14 @@
"source": "internal/cli/schema_hints/metadata/drive.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/drive marks this tool as reviewed",
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/drive.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/drive marks this tool as reviewed"
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist."
},
{
"value": true,
@@ -7302,7 +7305,7 @@
},
"use_when": {
"value": [
"用户要重命名钉盘/文档空间中的文件或文档显示名称时"
"用户要重命名钉盘/文档空间中的文件、文档或文件夹时;实际执行会读取节点类型和当前扩展名,仅去掉完全匹配的一层后缀"
],
"source": "internal/cli/schema_hints/selection/drive.json",
"precedence": "reviewed_explicit",
@@ -7311,7 +7314,7 @@
"candidates": [
{
"value": [
"用户要重命名钉盘/文档空间中的文件或文档显示名称时"
"用户要重命名钉盘/文档空间中的文件、文档或文件夹时;实际执行会读取节点类型和当前扩展名,仅去掉完全匹配的一层后缀"
],
"source": "internal/cli/schema_hints/selection/drive.json",
"precedence": "reviewed_explicit",
@@ -7342,7 +7345,7 @@
"structured-hint:internal/cli/schema_hints/selection-review.json#drive.rename_document"
],
"use_when": [
"用户要重命名钉盘/文档空间中的文件或文档显示名称时"
"用户要重命名钉盘/文档空间中的文件、文档或文件夹时;实际执行会读取节点类型和当前扩展名,仅去掉完全匹配的一层后缀"
]
},
"drive search": {
+21 -12
View File
@@ -2,7 +2,7 @@
"product_id": "event",
"tools": {
"event consume": {
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary_source": "dws-agent-selection/event",
"availability": "available",
"avoid_when": [
@@ -14,18 +14,18 @@
"effect_source": "agent-hint",
"examples": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
],
"field_provenance": {
"agent_summary": {
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"value": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
"candidates": [
{
"value": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"value": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -106,7 +106,7 @@
"examples": {
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -116,7 +116,7 @@
{
"value": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -231,8 +231,11 @@
"use_when": {
"value": [
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -242,8 +245,11 @@
{
"value": [
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -270,8 +276,11 @@
],
"use_when": [
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件"
]
},
"event list": {
@@ -763,7 +772,7 @@
},
"use_when": {
"value": [
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -772,7 +781,7 @@
"candidates": [
{
"value": [
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -797,7 +806,7 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
]
},
"event status": {
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:f7d9911baa7c829febb0f461c1ae773d9bd4747b73a79c6fc4cd8a0e3956561c",
"source_hash": "sha256:f71480e58eda8f2be041e07d7faf6fb20f5c21f861995d3c4dacc311b46c3e81",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"coverage": {
"surface_products": 26,
@@ -12,8 +12,8 @@
"tools_with_avoid_when": 813,
"tools_with_examples": 813,
"tools_with_interface_mode": 813,
"unmatched_skill_tools": 118,
"unreviewed_skill_tools": 7
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
"products": {
"aisearch": {
@@ -938,20 +938,20 @@
]
},
"event": {
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"agent_summary": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
"agent_summary_source": "dws-agent-selection/event",
"avoid_when": [
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
],
"field_provenance": {
"agent_summary": {
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"value": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"candidates": [
{
"value": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"value": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
"selected": true
@@ -978,7 +978,7 @@
},
"use_when": {
"value": [
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -986,7 +986,7 @@
"candidates": [
{
"value": [
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
],
"source": "internal/cli/schema_hints/selection/event.json",
"precedence": "reviewed_explicit",
@@ -1006,7 +1006,7 @@
"skills/mono/references/products/event.md"
],
"use_when": [
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
]
},
"hrbrain": {
+50 -36
View File
@@ -2324,11 +2324,12 @@
]
},
"todo +remind": {
"agent_summary": "给自己创建一条带截止/提醒时间的待办",
"agent_summary": "给自己创建一条带可选截止时间的待办",
"agent_summary_source": "dws-agent-selection/todo",
"availability": "available",
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令",
"用户需要独立提醒规则时改用 dws todo task add-reminder;不要把 --at 解释成提醒时间"
],
"confirmation": "user_required",
"effect": "write",
@@ -2338,14 +2339,14 @@
],
"field_provenance": {
"agent_summary": {
"value": "给自己创建一条带截止/提醒时间的待办",
"value": "给自己创建一条带可选截止时间的待办",
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "Agent-authored and reviewed from the built-in Shortcut intent, executable Cobra path, and declared outcome; it affects selection only and does not alter execution or safety facts.",
"candidates": [
{
"value": "给自己创建一条带截止/提醒时间的待办",
"value": "给自己创建一条带可选截止时间的待办",
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -2371,7 +2372,8 @@
},
"avoid_when": {
"value": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令",
"用户需要独立提醒规则时改用 dws todo task add-reminder;不要把 --at 解释成提醒时间"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -2380,7 +2382,8 @@
"candidates": [
{
"value": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令",
"用户需要独立提醒规则时改用 dws todo task add-reminder;不要把 --at 解释成提醒时间"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -2546,7 +2549,7 @@
},
"use_when": {
"value": [
"当你想给自己记一件事、并(可选)设一个截止/提醒时间,又不想先查自己的 userId 时使用;内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 会按 ISO8601 解析为截止时间。会真实创建待办。"
"当你想给自己记一件事、并(可选)设一个截止时间,又不想先查自己的 userId 时使用;内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 只会按 ISO8601 写入截止时间 dueTime,不会创建独立提醒规则。会真实创建待办。"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -2555,7 +2558,7 @@
"candidates": [
{
"value": [
"当你想给自己记一件事、并(可选)设一个截止/提醒时间,又不想先查自己的 userId 时使用;内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 会按 ISO8601 解析为截止时间。会真实创建待办。"
"当你想给自己记一件事、并(可选)设一个截止时间,又不想先查自己的 userId 时使用;内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 只会按 ISO8601 写入截止时间 dueTime,不会创建独立提醒规则。会真实创建待办。"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -2578,7 +2581,7 @@
"internal/cli/schema_hints/selection/todo.json"
],
"use_when": [
"当你想给自己记一件事、并(可选)设一个截止/提醒时间,又不想先查自己的 userId 时使用;内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 会按 ISO8601 解析为截止时间。会真实创建待办。"
"当你想给自己记一件事、并(可选)设一个截止时间,又不想先查自己的 userId 时使用;内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 只会按 ISO8601 写入截止时间 dueTime,不会创建独立提醒规则。会真实创建待办。"
]
},
"todo +todo-done": {
@@ -5829,12 +5832,13 @@
]
},
"todo task add-reminder": {
"agent_summary": "添加待办提醒",
"agent_summary": "写入一条待办提醒规则(上游不支持规则读回)",
"agent_summary_source": "dws-agent-selection/todo",
"availability": "available",
"avoid_when": [
"要重置/清除提醒规则时改用 dws todo task reset-reminder",
"提醒时间与目标待办未确认时不要添加"
"提醒时间与目标待办未确认时不要添加",
"业务要求写后读取并核验 reminderRules 时不要声称可验证;当前 task get/list 均不返回提醒规则"
],
"confirmation": "not_required",
"effect": "write",
@@ -5845,14 +5849,14 @@
],
"field_provenance": {
"agent_summary": {
"value": "添加待办提醒",
"value": "写入一条待办提醒规则(上游不支持规则读回)",
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "结合本机 dws schema 实时描述、Skill 与 Cobra 手写选型;pinned schema_mcp_metadata 仅对照。",
"candidates": [
{
"value": "添加待办提醒",
"value": "写入一条待办提醒规则(上游不支持规则读回)",
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -5885,7 +5889,8 @@
"avoid_when": {
"value": [
"要重置/清除提醒规则时改用 dws todo task reset-reminder",
"提醒时间与目标待办未确认时不要添加"
"提醒时间与目标待办未确认时不要添加",
"业务要求写后读取并核验 reminderRules 时不要声称可验证;当前 task get/list 均不返回提醒规则"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -5895,7 +5900,8 @@
{
"value": [
"要重置/清除提醒规则时改用 dws todo task reset-reminder",
"提醒时间与目标待办未确认时不要添加"
"提醒时间与目标待办未确认时不要添加",
"业务要求写后读取并核验 reminderRules 时不要声称可验证;当前 task get/list 均不返回提醒规则"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -6043,7 +6049,7 @@
},
"use_when": {
"value": [
"已知 taskId,需要按截止时间偏移或自定义时间戳添加提醒时(dueTime 模式要求待办已有截止时间)"
"已知 taskId,需要按截止时间偏移或自定义时间戳添加提醒时(dueTime 模式要求待办已有截止时间);成功响应仅作为写入回执"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -6052,7 +6058,7 @@
"candidates": [
{
"value": [
"已知 taskId,需要按截止时间偏移或自定义时间戳添加提醒时(dueTime 模式要求待办已有截止时间)"
"已知 taskId,需要按截止时间偏移或自定义时间戳添加提醒时(dueTime 模式要求待办已有截止时间);成功响应仅作为写入回执"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -6078,11 +6084,12 @@
"internal/cli/schema_hints/selection/todo.json",
"internal/cli/schema_mcp_metadata.json#tools.todo.add_todo_reminder",
"live-dws-schema:todo.add_todo_reminder",
"skills/mono/references/intent-guide.md",
"skills/mono/references/products/todo.md",
"structured-hint:internal/cli/schema_hints/selection-review.json#todo.add_todo_reminder"
],
"use_when": [
"已知 taskId,需要按截止时间偏移或自定义时间戳添加提醒时(dueTime 模式要求待办已有截止时间)"
"已知 taskId,需要按截止时间偏移或自定义时间戳添加提醒时(dueTime 模式要求待办已有截止时间);成功响应仅作为写入回执"
]
},
"todo task create": {
@@ -6343,6 +6350,7 @@
"internal/cli/schema_hints/selection/todo.json",
"internal/cli/schema_mcp_metadata.json#tools.todo.create_personal_todo",
"live-dws-schema:todo.create_personal_todo",
"skills/mono/references/intent-guide.md",
"skills/mono/references/products/todo.md",
"structured-hint:internal/cli/schema_hints/imported/wukong.json#todo.create_personal_todo",
"structured-hint:internal/cli/schema_hints/selection-review.json#todo.create_personal_todo"
@@ -7183,7 +7191,8 @@
"availability": "available",
"avoid_when": [
"需要按条件查多个待办时改用 dws todo task list",
"需要修改待办时改用 update/done/delete 等写命令"
"需要修改待办时改用 update/done/delete 等写命令",
"需要读取或验证提醒规则时不要使用;当前详情接口不返回 reminderRules"
],
"confirmation": "not_required",
"effect": "read",
@@ -7234,7 +7243,8 @@
"avoid_when": {
"value": [
"需要按条件查多个待办时改用 dws todo task list",
"需要修改待办时改用 update/done/delete 等写命令"
"需要修改待办时改用 update/done/delete 等写命令",
"需要读取或验证提醒规则时不要使用;当前详情接口不返回 reminderRules"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -7244,7 +7254,8 @@
{
"value": [
"需要按条件查多个待办时改用 dws todo task list",
"需要修改待办时改用 update/done/delete 等写命令"
"需要修改待办时改用 update/done/delete 等写命令",
"需要读取或验证提醒规则时不要使用;当前详情接口不返回 reminderRules"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -8559,12 +8570,13 @@
]
},
"todo task reset-reminder": {
"agent_summary": "重置待办提醒",
"agent_summary": "整体替换或清除待办提醒规则(上游不支持规则读回)",
"agent_summary_source": "dws-agent-selection/todo",
"availability": "available",
"avoid_when": [
"只需追加一条提醒时改用 dws todo task add-reminder",
"新规则与目标待办未确认时不要重置"
"新规则与目标待办未确认时不要重置",
"业务要求写后读取并核验 reminderRules 时不要声称可验证;当前 task get/list 均不返回提醒规则"
],
"confirmation": "not_required",
"effect": "write",
@@ -8575,14 +8587,14 @@
],
"field_provenance": {
"agent_summary": {
"value": "重置待办提醒",
"value": "整体替换或清除待办提醒规则(上游不支持规则读回)",
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "结合本机 dws schema 实时描述、Skill 与 Cobra 手写选型;pinned schema_mcp_metadata 仅对照。",
"candidates": [
{
"value": "重置待办提醒",
"value": "整体替换或清除待办提醒规则(上游不支持规则读回)",
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
"selected": true,
@@ -8595,14 +8607,14 @@
"source": "internal/cli/schema_hints/metadata/todo.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed",
"review_reason": "The reviewed CLI rejects malformed or structurally invalid non-empty reminder rules before the reset mutation, while preserving omission and [] as explicit clear forms.",
"candidates": [
{
"value": "available",
"source": "internal/cli/schema_hints/metadata/todo.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed"
"review_reason": "The reviewed CLI rejects malformed or structurally invalid non-empty reminder rules before the reset mutation, while preserving omission and [] as explicit clear forms."
},
{
"value": "available",
@@ -8615,7 +8627,8 @@
"avoid_when": {
"value": [
"只需追加一条提醒时改用 dws todo task add-reminder",
"新规则与目标待办未确认时不要重置"
"新规则与目标待办未确认时不要重置",
"业务要求写后读取并核验 reminderRules 时不要声称可验证;当前 task get/list 均不返回提醒规则"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -8625,7 +8638,8 @@
{
"value": [
"只需追加一条提醒时改用 dws todo task add-reminder",
"新规则与目标待办未确认时不要重置"
"新规则与目标待办未确认时不要重置",
"业务要求写后读取并核验 reminderRules 时不要声称可验证;当前 task get/list 均不返回提醒规则"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -8703,14 +8717,14 @@
"source": "internal/cli/schema_hints/metadata/todo.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed",
"review_reason": "The reviewed CLI rejects malformed or structurally invalid non-empty reminder rules before the reset mutation, while preserving omission and [] as explicit clear forms.",
"candidates": [
{
"value": "mcp",
"source": "internal/cli/schema_hints/metadata/todo.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed"
"review_reason": "The reviewed CLI rejects malformed or structurally invalid non-empty reminder rules before the reset mutation, while preserving omission and [] as explicit clear forms."
},
{
"value": "mcp",
@@ -8739,14 +8753,14 @@
"source": "internal/cli/schema_hints/metadata/todo.json",
"precedence": "reviewed_explicit",
"resolution": "highest_precedence",
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed",
"review_reason": "The reviewed CLI rejects malformed or structurally invalid non-empty reminder rules before the reset mutation, while preserving omission and [] as explicit clear forms.",
"candidates": [
{
"value": true,
"source": "internal/cli/schema_hints/metadata/todo.json",
"precedence": "reviewed_explicit",
"selected": true,
"review_reason": "dws-tool-metadata/todo marks this tool as reviewed"
"review_reason": "The reviewed CLI rejects malformed or structurally invalid non-empty reminder rules before the reset mutation, while preserving omission and [] as explicit clear forms."
},
{
"value": true,
@@ -8773,7 +8787,7 @@
},
"use_when": {
"value": [
"需要清除或整体替换待办提醒规则时(可不传 reminder-rules 以清除)"
"需要清除或整体替换待办提醒规则时(不传 reminder-rules 或传 [] 可清除);非空规则会在远端调用前严格校验,成功响应仅作为写入回执"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -8782,7 +8796,7 @@
"candidates": [
{
"value": [
"需要清除或整体替换待办提醒规则时(可不传 reminder-rules 以清除)"
"需要清除或整体替换待办提醒规则时(不传 reminder-rules 或传 [] 可清除);非空规则会在远端调用前严格校验,成功响应仅作为写入回执"
],
"source": "internal/cli/schema_hints/selection/todo.json",
"precedence": "reviewed_explicit",
@@ -8812,7 +8826,7 @@
"structured-hint:internal/cli/schema_hints/selection-review.json#todo.reset_todo_reminder"
],
"use_when": [
"需要清除或整体替换待办提醒规则时(可不传 reminder-rules 以清除)"
"需要清除或整体替换待办提醒规则时(不传 reminder-rules 或传 [] 可清除);非空规则会在远端调用前严格校验,成功响应仅作为写入回执"
]
},
"todo task update": {
+46 -6
View File
@@ -1,6 +1,6 @@
{
"version": 1,
"source_hash": "sha256:f7d9911baa7c829febb0f461c1ae773d9bd4747b73a79c6fc4cd8a0e3956561c",
"source_hash": "sha256:f71480e58eda8f2be041e07d7faf6fb20f5c21f861995d3c4dacc311b46c3e81",
"surface_hash": "sha256:4cf8460240b19f896c3a330c69982cbb5f57aa8576cdf30373172082eea893ed",
"source_files": 160,
"hint_files": 54,
@@ -36,8 +36,8 @@
"tools_with_avoid_when": 813,
"tools_with_examples": 813,
"tools_with_interface_mode": 813,
"unmatched_skill_tools": 118,
"unreviewed_skill_tools": 7
"unmatched_skill_tools": 122,
"unreviewed_skill_tools": 11
},
"source_products": [
"agoal",
@@ -164,7 +164,7 @@
{
"tool_path": "calendar participant delete",
"source": "skills/mono/SKILL.md",
"line": 148,
"line": 150,
"candidates": [
"calendar attendee delete",
"calendar event delete",
@@ -2797,6 +2797,46 @@
"reason": "旧版 Skill 或说明性引用,当前公开命令面无等价 leaf,禁止词法模糊映射"
}
},
{
"tool_path": "event consume user_im_message_receive_o2o_all",
"source": "skills/mono/references/products/event.md",
"line": 117,
"candidates": [
"event consume",
"event list",
"event schema"
]
},
{
"tool_path": "event consume user_im_message_receive_group_all",
"source": "skills/mono/references/products/event.md",
"line": 118,
"candidates": [
"event consume",
"event list",
"event schema"
]
},
{
"tool_path": "event consume user_im_message_receive_o2o user_im_message_read_o2o user_im_message_recall_o2o",
"source": "skills/mono/references/products/event.md",
"line": 134,
"candidates": [
"event consume",
"event list",
"event schema"
]
},
{
"tool_path": "event consume user_im_message_receive_group user_im_group_updated user_im_group_disbanded",
"source": "skills/mono/references/products/event.md",
"line": 142,
"candidates": [
"event consume",
"event list",
"event schema"
]
},
{
"tool_path": "mail mailbox profile",
"source": "skills/mono/references/products/mail.md",
@@ -3850,7 +3890,7 @@
{
"tool_path": "todo task remove-attachment",
"source": "skills/mono/references/products/todo.md",
"line": 438,
"line": 440,
"candidates": [
"todo task add-attachment",
"todo task add-executor",
@@ -3864,7 +3904,7 @@
{
"tool_path": "todo task list-sub",
"source": "skills/mono/references/products/todo.md",
"line": 441,
"line": 443,
"candidates": [
"todo task add-attachment",
"todo task add-executor",
File diff suppressed because it is too large Load Diff
+60
View File
@@ -658,6 +658,16 @@
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_o2o_all -f ndjson": {
"status": "alias",
"target": "event consume",
"reason": "已审查的全量单聊事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_group_all -f ndjson": {
"status": "alias",
"target": "event consume",
"reason": "已审查的全量群消息事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_o2o": {
"status": "alias",
"target": "event consume",
@@ -688,6 +698,26 @@
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_group_updated": {
"status": "alias",
"target": "event consume",
"reason": "已审查的群标题变更事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_group_member_added": {
"status": "alias",
"target": "event consume",
"reason": "已审查的群成员加入事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_group_member_exited": {
"status": "alias",
"target": "event consume",
"reason": "已审查的群成员退出事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_group_disbanded": {
"status": "alias",
"target": "event consume",
"reason": "已审查的群解散事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event schema user_im_message_receive_at": {
"status": "alias",
"target": "event schema",
@@ -708,6 +738,16 @@
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_o2o_all": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_group_all": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_read_o2o": {
"status": "alias",
"target": "event schema",
@@ -738,6 +778,26 @@
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_group_updated": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_group_member_added": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_group_member_exited": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_group_disbanded": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"mail aisearch person": {
"status": "alias",
"target": "aisearch person",
@@ -203,7 +203,14 @@
},
"interface_mode": "mcp",
"availability": "available",
"reviewed": true
"reviewed": true,
"parameters": {
"name": {
"description": "新显示名称;实际执行前读取节点类型与当前扩展名,仅对非文件夹且末尾后缀与当前扩展名一致的名称去掉一层,避免双扩展名"
}
},
"review_reason": "The reviewed CLI reads node metadata before mutation and strips only the exact current extension for non-folder nodes, avoiding both duplicate extensions and dotted-folder corruption without a suffix whitelist.",
"cli_path": "drive rename"
},
"drive.search_files": {
"interface_mode": "mcp",
+8 -1
View File
@@ -126,7 +126,14 @@
"todo.reset_todo_reminder": {
"interface_mode": "mcp",
"availability": "available",
"reviewed": true
"reviewed": true,
"parameters": {
"reminder-rules": {
"description": "提醒规则 JSON 数组;不传表示清除,显式传值必须为对象数组,且每条按 baseTime 提供整数 dueDateOffset 或 ISO8601 reminderTimeStamp"
}
},
"review_reason": "The reviewed CLI rejects malformed or structurally invalid non-empty reminder rules before the reset mutation, while preserving omission and [] as explicit clear forms.",
"cli_path": "todo task reset-reminder"
},
"todo.update_todo_done_status": {
"interface_mode": "mcp",
@@ -601,6 +601,16 @@
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_o2o_all -f ndjson": {
"status": "alias",
"target": "event consume",
"reason": "已审查的全量单聊事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_receive_group_all -f ndjson": {
"status": "alias",
"target": "event consume",
"reason": "已审查的全量群消息事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_message_read_o2o": {
"status": "alias",
"target": "event consume",
@@ -631,6 +641,26 @@
"target": "event consume",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_group_updated": {
"status": "alias",
"target": "event consume",
"reason": "已审查的群标题变更事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_group_member_added": {
"status": "alias",
"target": "event consume",
"reason": "已审查的群成员加入事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_group_member_exited": {
"status": "alias",
"target": "event consume",
"reason": "已审查的群成员退出事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event consume user_im_group_disbanded": {
"status": "alias",
"target": "event consume",
"reason": "已审查的群解散事件 Skill 引用,固定映射到当前公开 event consume leaf"
},
"event schema user_im_message_receive_at": {
"status": "alias",
"target": "event schema",
@@ -651,6 +681,16 @@
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_o2o_all": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_receive_group_all": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_message_read_o2o": {
"status": "alias",
"target": "event schema",
@@ -681,6 +721,26 @@
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_group_updated": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_group_member_added": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_group_member_exited": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"event schema user_im_group_disbanded": {
"status": "alias",
"target": "event schema",
"reason": "已审查的带 event_key 参数的 Skill 引用,固定映射到当前公开 event schema leaf"
},
"mail aisearch person": {
"status": "alias",
"target": "aisearch person",
+5 -3
View File
@@ -328,7 +328,8 @@
],
"avoid_when": [
"已确认是 adoc 且只要正文改用 dws doc read",
"只要目录列表改用 dws drive list / wiki node list"
"只要目录列表改用 dws drive list / wiki node list",
"需要可靠文件大小 fileSize 时改用 dws drive info;文档元信息接口可能不返回大小"
],
"examples": [
"dws doc info --node <DOC_ID> --format json",
@@ -581,11 +582,12 @@
"doc.rename_document": {
"agent_summary": "兼容入口:重命名文档或文件;文件重命名能力已迁移到 drive。",
"use_when": [
"用户要改文档/文件在列表与链接中展示的名称时"
"用户要改在线文档在列表与链接中展示的名称时"
],
"avoid_when": [
"改正文标题/章节标题改用 doc block update",
"不要用 update 或重新 create 来改名"
"不要用 update 或重新 create 来改名",
"文件或文件夹重命名优先用 dws drive rename,由该命令读取真实节点类型和当前扩展名"
],
"examples": [
"dws doc rename --node <DOC_ID> --name \"新名称\" --format json"
@@ -545,13 +545,14 @@
]
},
"drive.rename_document": {
"agent_summary": "修改文档空间中文档或文件的名称",
"agent_summary": "安全重命名文档空间中的文档、文件或文件夹,并按节点真实扩展名避免双后缀",
"use_when": [
"用户要重命名钉盘/文档空间中的文件或文档显示名称时"
"用户要重命名钉盘/文档空间中的文件、文档或文件夹时;实际执行会读取节点类型和当前扩展名,仅去掉完全匹配的一层后缀"
],
"avoid_when": [
"要改正文里的标题/章节 H1 改用 dws doc block update,不要用 rename",
"只要复制或移动改用 copy/move"
"只要复制或移动改用 copy/move",
"dry-run 不读取节点元数据,输出名称尚未做基于当前扩展名的规范化"
],
"examples": [
"dws drive rename --node <ID> --name \"新名称\" --format json"
@@ -9,11 +9,14 @@
},
"tools": {
"event.consume": {
"agent_summary": "订阅并持续消费指定个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"agent_summary": "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
"use_when": [
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
"用户明确要求监听当前身份的所有单聊或所有群消息",
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标"
"需要监听指定群的标题变更、成员进退群或群解散事件",
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
"同一目标、同一过滤条件需要同时监听多个兼容事件"
],
"avoid_when": [
"只查历史聊天记录时用 chat 查询命令",
@@ -21,7 +24,7 @@
],
"examples": [
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
"dws event consume user_im_message_reaction_group --group cid-example --flatten --max-events 1 --format ndjson"
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson"
],
"reviewed": true,
"review_reason": "人工依据实时 dws schema(或 Skill/Cobra/pinned MCP 对照)决策化选型文案与门禁;不改写命令身份与参数契约;示例不含 --yes。",
@@ -59,7 +62,7 @@
"event.schema": {
"agent_summary": "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
"use_when": [
"已知任一公开个人消息 event_key,消费前需要理解扁平输出字段"
"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"
],
"avoid_when": [
"查询 CLI 命令参数契约时用顶层 dws schema",
@@ -125,9 +128,9 @@
},
"products": {
"event": {
"agent_summary": "订阅/消费个人消息接收、已读、撤回和表情回应事件,并管理订阅生命周期",
"agent_summary": "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
"use_when": [
"需要实时监听个人消息接收、已读、撤回或表情回应事件,或管理个人事件订阅生命周期"
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期"
],
"avoid_when": [
"查历史聊天或主动发消息分别用 chat 查询/发送命令"
+14 -10
View File
@@ -103,13 +103,14 @@
]
},
"todo.add_todo_reminder": {
"agent_summary": "添加待办提醒",
"agent_summary": "写入一条待办提醒规则(上游不支持规则读回)",
"use_when": [
"已知 taskId,需要按截止时间偏移或自定义时间戳添加提醒时(dueTime 模式要求待办已有截止时间)"
"已知 taskId,需要按截止时间偏移或自定义时间戳添加提醒时(dueTime 模式要求待办已有截止时间);成功响应仅作为写入回执"
],
"avoid_when": [
"要重置/清除提醒规则时改用 dws todo task reset-reminder",
"提醒时间与目标待办未确认时不要添加"
"提醒时间与目标待办未确认时不要添加",
"业务要求写后读取并核验 reminderRules 时不要声称可验证;当前 task get/list 均不返回提醒规则"
],
"examples": [
"dws todo task add-reminder --task-id <taskId> --base-time dueTime --due-date-offset -30",
@@ -224,7 +225,8 @@
],
"avoid_when": [
"需要按条件查多个待办时改用 dws todo task list",
"需要修改待办时改用 update/done/delete 等写命令"
"需要修改待办时改用 update/done/delete 等写命令",
"需要读取或验证提醒规则时不要使用;当前详情接口不返回 reminderRules"
],
"examples": [
"dws todo task get --task-id <taskId>",
@@ -356,13 +358,14 @@
]
},
"todo.reset_todo_reminder": {
"agent_summary": "重置待办提醒",
"agent_summary": "整体替换或清除待办提醒规则(上游不支持规则读回)",
"use_when": [
"需要清除或整体替换待办提醒规则时(可不传 reminder-rules 以清除)"
"需要清除或整体替换待办提醒规则时(不传 reminder-rules 或传 [] 可清除);非空规则会在远端调用前严格校验,成功响应仅作为写入回执"
],
"avoid_when": [
"只需追加一条提醒时改用 dws todo task add-reminder",
"新规则与目标待办未确认时不要重置"
"新规则与目标待办未确认时不要重置",
"业务要求写后读取并核验 reminderRules 时不要声称可验证;当前 task get/list 均不返回提醒规则"
],
"examples": [
"dws todo task reset-reminder --task-id <taskId>",
@@ -603,12 +606,13 @@
]
},
"todo.shortcut_remind": {
"agent_summary": "给自己创建一条带截止/提醒时间的待办",
"agent_summary": "给自己创建一条带可选截止时间的待办",
"use_when": [
"当你想给自己记一件事、并(可选)设一个截止/提醒时间,又不想先查自己的 userId 时使用;内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 会按 ISO8601 解析为截止时间。会真实创建待办。"
"当你想给自己记一件事、并(可选)设一个截止时间,又不想先查自己的 userId 时使用;内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 只会按 ISO8601 写入截止时间 dueTime,不会创建独立提醒规则。会真实创建待办。"
],
"avoid_when": [
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"
"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令",
"用户需要独立提醒规则时改用 dws todo task add-reminder;不要把 --at 解释成提醒时间"
],
"examples": [
"dws todo +remind --task \"交周报\" --at 2026-03-10T18:00:00+08:00"
+126
View File
@@ -10,6 +10,8 @@ import (
"net"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
@@ -448,6 +450,45 @@ func TestCrossPlatformCoverageHandleConnectionProtocolEdges(t *testing.T) {
t.Fatal(err)
}
})
run(newDaemon(), func(c net.Conn) {
w, r := transport.NewWriter(c), transport.NewReader(c)
_ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Role: transport.HelloRoleConsumerStop})
_ = w.WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq})
var resp transport.ConsumerStopResp
if err := r.ReadJSON(&resp); err != nil {
t.Fatal(err)
}
if resp.Type != transport.FrameTypeConsumerStopResp || !strings.Contains(resp.Error, "unexpected") {
t.Fatalf("consumer stop protocol error = %#v", resp)
}
})
run(newDaemon(), func(c net.Conn) {
w, r := transport.NewWriter(c), transport.NewReader(c)
_ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Role: transport.HelloRoleConsumerStop})
_, _ = c.Write([]byte("{\n"))
var resp transport.ConsumerStopResp
if err := r.ReadJSON(&resp); err != nil {
t.Fatal(err)
}
if resp.Type != transport.FrameTypeConsumerStopResp || resp.Error != "malformed consumer stop request" {
t.Fatalf("malformed consumer stop response = %#v", resp)
}
})
run(newDaemon(), func(c net.Conn) {
w, r := transport.NewWriter(c), transport.NewReader(c)
_ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Role: transport.HelloRoleConsumerStop})
_ = w.WriteJSON(transport.ConsumerStopReq{
Type: transport.FrameTypeConsumerStopReq,
SubscribeIDs: []string{"", "missing", "missing"},
})
var resp transport.ConsumerStopResp
if err := r.ReadJSON(&resp); err != nil {
t.Fatal(err)
}
if len(resp.NotFound) != 1 || resp.NotFound[0] != "missing" {
t.Fatalf("consumer stop response = %#v", resp)
}
})
run(newDaemon(), func(c net.Conn) {
w, r := transport.NewWriter(c), transport.NewReader(c)
_ = w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Filter: "["})
@@ -495,6 +536,91 @@ func TestCrossPlatformCoverageHandleConnectionProtocolEdges(t *testing.T) {
})
}
func TestCrossPlatformCoverageHandleConnectionPrioritizesQueuedConsumerStop(t *testing.T) {
d := &daemon{
cfg: Config{ClientID: "client", Edition: "open", IdleTimeout: time.Second},
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
hub: NewHub(2),
started: time.Now(),
idleStop: make(chan struct{}),
}
server, client := net.Pipe()
countedServer := &countingCloseConn{Conn: server}
done := make(chan struct{})
go func() {
d.handleConnection(context.Background(), countedServer)
close(done)
}()
w, r := transport.NewWriter(client), transport.NewReader(client)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
SubscribeID: "sub-priority",
}); err != nil {
t.Fatal(err)
}
deadline := time.Now().Add(time.Second)
for d.hub.Len() != 1 {
if time.Now().After(deadline) {
t.Fatal("consumer was not registered")
}
time.Sleep(time.Millisecond)
}
if stopped := d.hub.StopConsumers([]string{"sub-priority"}, "priority-stop"); len(stopped) != 1 {
t.Fatalf("stopped = %#v", stopped)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil {
t.Fatal(err)
}
var bye transport.Bye
if err := r.ReadJSON(&bye); err != nil {
t.Fatal(err)
}
if bye.Reason != "priority-stop" {
t.Fatalf("bye = %#v", bye)
}
_ = client.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection handler did not stop")
}
if got := countedServer.closeCount.Load(); got != 1 {
t.Fatalf("underlying connection close count = %d, want 1", got)
}
}
func TestCrossPlatformCoverageEnsureCloseOnceReusesWrapper(t *testing.T) {
server, client := net.Pipe()
defer client.Close()
counted := &countingCloseConn{Conn: server}
wrapped := ensureCloseOnce(counted)
if got := ensureCloseOnce(wrapped); got != wrapped {
t.Fatal("ensureCloseOnce wrapped an already managed connection")
}
if err := wrapped.Close(); err != nil {
t.Fatal(err)
}
if err := wrapped.Close(); err != nil {
t.Fatal(err)
}
if got := counted.closeCount.Load(); got != 1 {
t.Fatalf("underlying connection close count = %d, want 1", got)
}
}
type countingCloseConn struct {
net.Conn
closeCount atomic.Int32
}
func (c *countingCloseConn) Close() error {
c.closeCount.Add(1)
return c.Conn.Close()
}
type queryConn struct{}
func (*queryConn) Read([]byte) (int, error) { return 0, io.EOF }
+97 -5
View File
@@ -22,6 +22,8 @@ import (
"net"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
@@ -274,6 +276,28 @@ type daemon struct {
idleStop chan struct{}
}
// closeOnceConn makes every connection close path idempotent. A live consumer
// can be closed by its writer, the connection handler, or daemon shutdown.
type closeOnceConn struct {
net.Conn
once sync.Once
err error
}
func (c *closeOnceConn) Close() error {
c.once.Do(func() {
c.err = c.Conn.Close()
})
return c.err
}
func ensureCloseOnce(conn net.Conn) net.Conn {
if _, ok := conn.(*closeOnceConn); ok {
return conn
}
return &closeOnceConn{Conn: conn}
}
// acceptLoop drives the IPC accept goroutine. Each accepted connection is
// passed to handleConnection in its own goroutine; the accept loop returns
// when the listener Close()s (typically during shutdown).
@@ -292,6 +316,7 @@ func (d *daemon) acceptLoop(ctx context.Context) {
d.log.Warn("bus: accept error", "err", err)
continue
}
conn = ensureCloseOnce(conn)
// Track the connection before publishing the handler goroutine. Once
// acceptLoop returns, shutdown can therefore close every accepted
// connection before waiting for handlers to drain.
@@ -308,9 +333,10 @@ func (d *daemon) acceptLoop(ctx context.Context) {
// Hello → register with Hub → spawn writer goroutine → read until EOF/Bye.
// Always Unregisters and Closes on exit (plan invariant #5).
func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
conn = ensureCloseOnce(conn)
defer func() {
d.conns.Delete(conn)
conn.Close()
_ = conn.Close()
}()
r := transport.NewReader(conn)
@@ -341,6 +367,10 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
go d.triggerShutdown("stop_request")
return
}
if hello.Role == transport.HelloRoleConsumerStop {
d.handleConsumerStopRPC(w, r)
return
}
// Regular consumer registration
c, err := d.hub.Register(hello)
@@ -370,10 +400,28 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
writerDone := make(chan struct{})
go func() {
defer close(writerDone)
for frame := range c.SendCh {
if err := w.WriteJSON(frame); err != nil {
// Wire error: peer dead. Returning here will let the
// reader goroutine notice EOF and Unregister.
for {
// Give an already-queued targeted stop priority over buffered
// events. The second select still handles a stop that arrives
// between this check and the blocking wait.
select {
case reason := <-c.StopCh:
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
_ = conn.Close()
return
default:
}
select {
case frame, ok := <-c.SendCh:
if !ok {
return
}
if err := w.WriteJSON(frame); err != nil {
return
}
case reason := <-c.StopCh:
_ = w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
_ = conn.Close()
return
}
}
@@ -406,6 +454,50 @@ func (d *daemon) handleConnection(ctx context.Context, conn net.Conn) {
_ = ctx // for future use (writer ctx-cancel propagation)
}
func (d *daemon) handleConsumerStopRPC(w *transport.Writer, r *transport.Reader) {
var req transport.ConsumerStopReq
if err := r.ReadJSON(&req); err != nil {
d.log.Debug("bus: malformed consumer stop request", "err", err)
_ = w.WriteJSON(transport.ConsumerStopResp{
Type: transport.FrameTypeConsumerStopResp,
Error: "malformed consumer stop request",
})
return
}
if req.Type != transport.FrameTypeConsumerStopReq {
_ = w.WriteJSON(transport.ConsumerStopResp{
Type: transport.FrameTypeConsumerStopResp,
Error: fmt.Sprintf("unexpected consumer stop request type %q", req.Type),
})
return
}
stopped := d.hub.StopConsumers(req.SubscribeIDs, transport.ByeReasonSubscriptionStopped)
stoppedSet := make(map[string]struct{}, len(stopped))
for _, id := range stopped {
stoppedSet[id] = struct{}{}
}
notFoundSet := make(map[string]struct{})
for _, id := range req.SubscribeIDs {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := stoppedSet[id]; !ok {
notFoundSet[id] = struct{}{}
}
}
notFound := make([]string, 0, len(notFoundSet))
for id := range notFoundSet {
notFound = append(notFound, id)
}
sort.Strings(notFound)
_ = w.WriteJSON(transport.ConsumerStopResp{
Type: transport.FrameTypeConsumerStopResp,
Stopped: stopped,
NotFound: notFound,
})
}
// handleStatusRPC services a single status_req and returns. The connection
// is closed by the caller's defer.
func (d *daemon) handleStatusRPC(w *transport.Writer, r *transport.Reader) {
+116
View File
@@ -17,6 +17,7 @@ import (
"context"
"errors"
"io"
"net"
"os"
"path/filepath"
"runtime"
@@ -218,6 +219,121 @@ func TestDaemon_ConsumerReceivesEvents(t *testing.T) {
<-runDone
}
func TestDaemon_ConsumerStopClosesOnlyMatchingSubscription(t *testing.T) {
skipOnWindows(t, "uses Unix socket dial")
workDir := shortTempDir(t)
sockPath := filepath.Join(workDir, "bus.sock")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
runDone := make(chan error, 1)
go func() {
runDone <- Run(ctx, Config{
WorkDir: workDir,
IPCEndpoint: sockPath,
ClientID: "ding_test",
Edition: "open",
Source: &fakeSource{},
})
}()
defer func() { cancel(); <-runDone }()
waitForFile(t, sockPath, 2*time.Second)
connect := func(subscribeID string) (net.Conn, *transport.Reader) {
t.Helper()
conn, err := transport.Dial(sockPath)
if err != nil {
t.Fatalf("dial consumer: %v", err)
}
w := transport.NewWriter(conn)
r := transport.NewReader(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
SubscribeID: subscribeID,
}); err != nil {
t.Fatalf("write hello: %v", err)
}
var ack transport.HelloAck
if err := r.ReadJSON(&ack); err != nil || ack.Type != transport.FrameTypeHelloAck {
t.Fatalf("read hello ack: %#v, %v", ack, err)
}
return conn, r
}
aConn, aReader := connect("sub-a")
defer aConn.Close()
bConn, _ := connect("sub-b")
defer bConn.Close()
ctl, err := transport.Dial(sockPath)
if err != nil {
t.Fatal(err)
}
ctlW := transport.NewWriter(ctl)
ctlR := transport.NewReader(ctl)
if err := ctlW.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
Role: transport.HelloRoleConsumerStop,
}); err != nil {
t.Fatal(err)
}
if err := ctlW.WriteJSON(transport.ConsumerStopReq{
Type: transport.FrameTypeConsumerStopReq,
SubscribeIDs: []string{"sub-a", "missing"},
}); err != nil {
t.Fatal(err)
}
var resp transport.ConsumerStopResp
if err := ctlR.ReadJSON(&resp); err != nil {
t.Fatal(err)
}
_ = ctl.Close()
if len(resp.Stopped) != 1 || resp.Stopped[0] != "sub-a" || len(resp.NotFound) != 1 || resp.NotFound[0] != "missing" {
t.Fatalf("consumer stop response = %#v", resp)
}
var bye transport.Bye
if err := aReader.ReadJSON(&bye); err != nil {
t.Fatalf("read targeted bye: %v", err)
}
if bye.Type != transport.FrameTypeBye || bye.Reason != transport.ByeReasonSubscriptionStopped {
t.Fatalf("targeted bye = %#v", bye)
}
deadline := time.Now().Add(time.Second)
for {
status := queryDaemonStatus(t, sockPath)
if len(status.Consumers) == 1 && status.Consumers[0].SubscribeID == "sub-b" {
break
}
if time.Now().After(deadline) {
t.Fatalf("consumers after targeted stop = %#v", status.Consumers)
}
time.Sleep(10 * time.Millisecond)
}
}
func queryDaemonStatus(t *testing.T, endpoint string) transport.StatusResp {
t.Helper()
conn, err := transport.Dial(endpoint)
if err != nil {
t.Fatalf("dial status: %v", err)
}
defer conn.Close()
w := transport.NewWriter(conn)
r := transport.NewReader(conn)
if err := w.WriteJSON(transport.Hello{Type: transport.FrameTypeHello, Role: transport.HelloRoleStatus}); err != nil {
t.Fatal(err)
}
if err := w.WriteJSON(transport.StatusReq{Type: transport.FrameTypeStatusReq}); err != nil {
t.Fatal(err)
}
var status transport.StatusResp
if err := r.ReadJSON(&status); err != nil {
t.Fatal(err)
}
return status
}
func TestDaemon_LockBusyOnSecondRun(t *testing.T) {
skipOnWindows(t, "uses Unix socket / flock semantics")
workDir := shortTempDir(t)
+49 -1
View File
@@ -41,7 +41,8 @@ type Consumer struct {
Filter string // raw regex from Hello (for status display)
SubscribeID string // optional personal subscription label and local isolation key
SubscribedAt time.Time
SendCh chan any // bus → consume frames (Event/SourceState/Heartbeat/Bye)
SendCh chan any // bus → consume frames (Event/SourceState/Heartbeat/Bye)
StopCh chan string // targeted local stop reason; consumed by daemon writer
matcher consumerMatcher
sendMu sync.Mutex // serialises Deliver/Broadcast with SendCh close
closed bool // guarded by sendMu
@@ -192,12 +193,59 @@ func (h *Hub) Register(hello transport.Hello) (*Consumer, error) {
SubscribeID: strings.TrimSpace(hello.SubscribeID),
SubscribedAt: time.Now().UTC(),
SendCh: make(chan any, h.bufferSize),
StopCh: make(chan string, 1),
matcher: m,
}
h.consumers[c.ID] = c
return c, nil
}
// StopConsumers requests a graceful close for every consumer whose exact
// SubscribeID appears in subscribeIDs. The daemon writer owns the wire, so
// this method signals StopCh instead of writing or closing SendCh directly.
func (h *Hub) StopConsumers(subscribeIDs []string, reason string) []string {
targets := make(map[string]struct{}, len(subscribeIDs))
for _, id := range subscribeIDs {
if id = strings.TrimSpace(id); id != "" {
targets[id] = struct{}{}
}
}
if len(targets) == 0 {
return nil
}
if strings.TrimSpace(reason) == "" {
reason = transport.ByeReasonSubscriptionStopped
}
matched := make(map[string]struct{}, len(targets))
stopTargets := make([]*Consumer, 0, len(targets))
h.mu.RLock()
for _, c := range h.consumers {
id := strings.TrimSpace(c.SubscribeID)
if _, ok := targets[id]; !ok {
continue
}
matched[id] = struct{}{}
stopTargets = append(stopTargets, c)
}
h.mu.RUnlock()
for _, target := range stopTargets {
select {
case target.StopCh <- reason:
default:
// A stop is already queued for this consumer.
}
}
out := make([]string, 0, len(matched))
for id := range matched {
out = append(out, id)
}
sort.Strings(out)
return out
}
// Unregister removes a consumer by ID and closes its sendCh. Idempotent —
// calling twice or on an unknown ID is a no-op. closeSend shares the same
// per-consumer lock as Deliver/Broadcast, so a stale Hub snapshot cannot send
+80
View File
@@ -15,6 +15,7 @@ package bus
import (
"errors"
"fmt"
"sync"
"testing"
"time"
@@ -290,6 +291,85 @@ func TestHub_UnregisterIdempotent(t *testing.T) {
h.Unregister(9999) // unknown ID
}
func TestHub_StopConsumersTargetsExactSubscribeID(t *testing.T) {
h := NewHub(10)
if stopped := h.StopConsumers([]string{"", " "}, "ignored"); stopped != nil {
t.Fatalf("empty targets stopped = %#v", stopped)
}
a, err := h.Register(transport.Hello{SubscribeID: "sub-a"})
if err != nil {
t.Fatal(err)
}
b, err := h.Register(transport.Hello{SubscribeID: "sub-b"})
if err != nil {
t.Fatal(err)
}
stopped := h.StopConsumers([]string{" sub-a ", "sub-a", "missing"}, "")
if len(stopped) != 1 || stopped[0] != "sub-a" {
t.Fatalf("stopped = %#v, want [sub-a]", stopped)
}
select {
case reason := <-a.StopCh:
if reason != transport.ByeReasonSubscriptionStopped {
t.Fatalf("reason = %q", reason)
}
case <-time.After(time.Second):
t.Fatal("sub-a stop was not signalled")
}
select {
case reason := <-b.StopCh:
t.Fatalf("sub-b was stopped: %q", reason)
case <-time.After(50 * time.Millisecond):
}
}
func TestHub_StopConsumersCoalescesQueuedStop(t *testing.T) {
h := NewHub(10)
c, err := h.Register(transport.Hello{SubscribeID: "sub-a"})
if err != nil {
t.Fatal(err)
}
if got := h.StopConsumers([]string{"sub-a"}, "first"); len(got) != 1 {
t.Fatalf("first stop = %#v", got)
}
if got := h.StopConsumers([]string{"sub-a"}, "second"); len(got) != 1 {
t.Fatalf("second stop = %#v", got)
}
if reason := <-c.StopCh; reason != "first" {
t.Fatalf("queued reason = %q, want first", reason)
}
select {
case reason := <-c.StopCh:
t.Fatalf("duplicate stop queued: %q", reason)
default:
}
}
func TestHub_ConcurrentStopConsumersRegisterUnregister(t *testing.T) {
h := NewHub(4)
const workers = 32
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
subscribeID := fmt.Sprintf("sub-%d", i)
c, err := h.Register(transport.Hello{SubscribeID: subscribeID})
if err != nil {
t.Errorf("register: %v", err)
return
}
h.StopConsumers([]string{subscribeID}, "concurrent-stop")
h.Unregister(c.ID)
}(i)
}
wg.Wait()
if got := h.Len(); got != 0 {
t.Fatalf("remaining consumers = %d", got)
}
}
func TestHub_ConcurrentDeliverBroadcastUnregister(t *testing.T) {
for iteration := 0; iteration < 200; iteration++ {
h := NewHub(4)
+78
View File
@@ -0,0 +1,78 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package busctl
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// ErrConsumerStopUnsupported lets callers fall back to the legacy process
// signal when an already-running bus predates the targeted stop protocol.
var ErrConsumerStopUnsupported = errors.New("busctl: targeted consumer stop is unsupported")
var consumerStopDial = transport.Dial
// StopConsumers asks a running bus to close only consumers matching the
// supplied personal subscription IDs.
func StopConsumers(endpoint string, subscribeIDs []string) (transport.ConsumerStopResp, error) {
var empty transport.ConsumerStopResp
if strings.TrimSpace(endpoint) == "" {
return empty, errors.New("busctl: consumer stop endpoint is required")
}
ids := make([]string, 0, len(subscribeIDs))
seen := map[string]struct{}{}
for _, id := range subscribeIDs {
id = strings.TrimSpace(id)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return empty, errors.New("busctl: at least one subscribe_id is required")
}
conn, err := consumerStopDial(endpoint)
if err != nil {
return empty, fmt.Errorf("busctl: dial bus for consumer stop: %w", err)
}
defer conn.Close()
_ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout))
w := transport.NewWriter(conn)
r := transport.NewReader(conn)
if err := w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
Role: transport.HelloRoleConsumerStop,
}); err != nil {
return empty, fmt.Errorf("busctl: write consumer stop hello: %w", err)
}
if err := w.WriteJSON(transport.ConsumerStopReq{
Type: transport.FrameTypeConsumerStopReq,
SubscribeIDs: ids,
}); err != nil {
return empty, fmt.Errorf("busctl: write consumer stop request: %w", err)
}
var resp transport.ConsumerStopResp
if err := r.ReadJSON(&resp); err != nil {
return empty, fmt.Errorf("busctl: read consumer stop response: %w", err)
}
if resp.Type != transport.FrameTypeConsumerStopResp {
return empty, fmt.Errorf("%w: unexpected response type %q", ErrConsumerStopUnsupported, resp.Type)
}
if strings.TrimSpace(resp.Error) != "" {
return empty, fmt.Errorf("busctl: consumer stop request rejected: %s", resp.Error)
}
return resp, nil
}
+161
View File
@@ -0,0 +1,161 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package busctl
import (
"errors"
"net"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
func TestStopConsumersRoundTrip(t *testing.T) {
client, server := net.Pipe()
oldDial := consumerStopDial
consumerStopDial = func(string) (net.Conn, error) { return client, nil }
t.Cleanup(func() {
consumerStopDial = oldDial
_ = server.Close()
})
requestCh := make(chan transport.ConsumerStopReq, 1)
errCh := make(chan error, 1)
go func() {
r := transport.NewReader(server)
w := transport.NewWriter(server)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
errCh <- err
return
}
if hello.Role != transport.HelloRoleConsumerStop {
errCh <- errors.New("unexpected hello role")
return
}
var req transport.ConsumerStopReq
if err := r.ReadJSON(&req); err != nil {
errCh <- err
return
}
requestCh <- req
errCh <- w.WriteJSON(transport.ConsumerStopResp{
Type: transport.FrameTypeConsumerStopResp,
Stopped: []string{"sub-a"},
NotFound: []string{"sub-b"},
})
}()
resp, err := StopConsumers("pipe", []string{" sub-a ", "sub-a", "", "sub-b"})
if err != nil {
t.Fatalf("StopConsumers() error = %v", err)
}
if !reflect.DeepEqual(resp.Stopped, []string{"sub-a"}) || !reflect.DeepEqual(resp.NotFound, []string{"sub-b"}) {
t.Fatalf("response = %#v", resp)
}
if err := <-errCh; err != nil {
t.Fatalf("server error = %v", err)
}
req := <-requestCh
if req.Type != transport.FrameTypeConsumerStopReq || !reflect.DeepEqual(req.SubscribeIDs, []string{"sub-a", "sub-b"}) {
t.Fatalf("request = %#v", req)
}
}
func TestStopConsumersDetectsLegacyBus(t *testing.T) {
client, server := net.Pipe()
oldDial := consumerStopDial
consumerStopDial = func(string) (net.Conn, error) { return client, nil }
t.Cleanup(func() {
consumerStopDial = oldDial
_ = server.Close()
})
go func() {
r := transport.NewReader(server)
w := transport.NewWriter(server)
var hello transport.Hello
_ = r.ReadJSON(&hello)
var req transport.ConsumerStopReq
_ = r.ReadJSON(&req)
_ = w.WriteJSON(transport.HelloAck{Type: transport.FrameTypeHelloAck, BusPID: 1})
}()
_, err := StopConsumers("pipe", []string{"sub-a"})
if !errors.Is(err, ErrConsumerStopUnsupported) {
t.Fatalf("StopConsumers() error = %v, want ErrConsumerStopUnsupported", err)
}
}
func TestStopConsumersReturnsServerProtocolError(t *testing.T) {
client, server := net.Pipe()
oldDial := consumerStopDial
consumerStopDial = func(string) (net.Conn, error) { return client, nil }
t.Cleanup(func() {
consumerStopDial = oldDial
_ = server.Close()
})
go func() {
r := transport.NewReader(server)
w := transport.NewWriter(server)
var hello transport.Hello
_ = r.ReadJSON(&hello)
var req transport.ConsumerStopReq
_ = r.ReadJSON(&req)
_ = w.WriteJSON(transport.ConsumerStopResp{
Type: transport.FrameTypeConsumerStopResp,
Error: "malformed consumer stop request",
})
}()
_, err := StopConsumers("pipe", []string{"sub-a"})
if err == nil || !strings.Contains(err.Error(), "malformed consumer stop request") {
t.Fatalf("StopConsumers() error = %v", err)
}
}
func TestStopConsumersValidatesInputAndDialErrors(t *testing.T) {
if _, err := StopConsumers("", []string{"sub-a"}); err == nil {
t.Fatal("empty endpoint succeeded")
}
if _, err := StopConsumers("pipe", []string{"", " "}); err == nil {
t.Fatal("empty subscriptions succeeded")
}
wantErr := errors.New("dial")
oldDial := consumerStopDial
consumerStopDial = func(string) (net.Conn, error) { return nil, wantErr }
t.Cleanup(func() { consumerStopDial = oldDial })
if _, err := StopConsumers("pipe", []string{"sub-a"}); !errors.Is(err, wantErr) {
t.Fatalf("dial error = %v", err)
}
}
func TestCrossPlatformCoverageStopConsumersProtocolErrors(t *testing.T) {
oldDial := consumerStopDial
t.Cleanup(func() { consumerStopDial = oldDial })
for _, test := range []struct {
name string
failAt int
want string
}{
{name: "hello write", failAt: 1, want: "write consumer stop hello"},
{name: "request write", failAt: 2, want: "write consumer stop request"},
{name: "response read", failAt: 0, want: "read consumer stop response"},
} {
t.Run(test.name, func(t *testing.T) {
consumerStopDial = func(string) (net.Conn, error) {
return &queryErrorConn{failAt: test.failAt}, nil
}
_, err := StopConsumers("pipe", []string{"sub-a"})
if !errors.Is(err, errBusctlInjected) || !strings.Contains(err.Error(), test.want) {
t.Fatalf("error = %v, want %q", err, test.want)
}
})
}
}
+314
View File
@@ -0,0 +1,314 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package consume
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"sort"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
// ConsumerSpec describes one logical subscription within a multi-event
// foreground process. Each spec gets an independent IPC connection so the bus
// can continue enforcing the exact event_type + subscribe_id pair.
type ConsumerSpec struct {
EventKey string
EventTypes []string
Filter string
SubscribeID string
ReadySubscribeID string
}
type manySession struct {
spec ConsumerSpec
conn net.Conn
w *transport.Writer
r *transport.Reader
ack transport.HelloAck
}
type manyFrame struct {
index int
raw []byte
err error
}
var runManyIsCtxCancelled = isCtxCancelled
// RunMany consumes multiple independently isolated subscriptions in one
// process. It shares one formatter/sink pipeline and one command lifecycle,
// while retaining one bus IPC connection per subscription.
func RunMany(ctx context.Context, cfg Config, specs []ConsumerSpec) error {
if cfg.WorkDir == "" || cfg.IPCEndpoint == "" || cfg.ClientID == "" {
return errors.New("consume: WorkDir, IPCEndpoint, and ClientID are required")
}
if len(specs) < 2 {
return errors.New("consume: RunMany requires at least two consumers")
}
if err := validateConsumerSpecs(specs); err != nil {
return err
}
if cfg.Stdout == nil {
cfg.Stdout = os.Stdout
}
if cfg.Stderr == nil {
cfg.Stderr = os.Stderr
}
if cfg.Quiet {
cfg.Stderr = io.Discard
}
if cfg.Format == "" {
cfg.Format = FormatNDJSON
}
if cfg.DryRun {
PrintDryRunMany(cfg.Stderr, cfg, specs)
return nil
}
parentCtx := ctx
var timeoutCtx context.Context
if cfg.Duration > 0 {
var cancel context.CancelFunc
timeoutCtx, cancel = context.WithTimeout(ctx, cfg.Duration)
defer cancel()
ctx = timeoutCtx
}
runCtx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
ctx = runCtx
pipeline, err := BuildPipeline(
cfg.Format,
cfg.OutputDir,
cfg.Routes,
cfg.Stdout,
WithProjector(cfg.Projector),
WithProjectionWarnings(cfg.Stderr),
)
if err != nil {
return fmt.Errorf("consume: build pipeline: %w", err)
}
defer pipeline.Close()
sessions := make([]*manySession, 0, len(specs))
closeSessions := func() {
for _, session := range sessions {
_ = session.conn.Close()
}
}
defer closeSessions()
for _, spec := range specs {
conn, err := discoverBus(busctl.DiscoverConfig{
WorkDir: cfg.WorkDir,
IPCEndpoint: cfg.IPCEndpoint,
ClientID: cfg.ClientID,
SpawnExtraArgs: cfg.SpawnExtraArgs,
})
if err != nil {
return fmt.Errorf("consume: discover bus for %s: %w", spec.EventKey, err)
}
session := &manySession{
spec: spec,
conn: conn,
w: transport.NewWriter(conn),
r: transport.NewReader(conn),
}
sessions = append(sessions, session)
closeOnContext(ctx, session.conn)
if err := session.w.WriteJSON(transport.Hello{
Type: transport.FrameTypeHello,
ConsumerPID: os.Getpid(),
EventTypes: spec.EventTypes,
Filter: spec.Filter,
SubscribeID: spec.SubscribeID,
Compact: cfg.Compact,
}); err != nil {
return fmt.Errorf("consume: write hello for %s: %w", spec.EventKey, err)
}
if err := session.r.ReadJSON(&session.ack); err != nil {
return fmt.Errorf("consume: read hello_ack for %s: %w", spec.EventKey, err)
}
if session.ack.Type != transport.FrameTypeHelloAck {
return fmt.Errorf("consume: unexpected first frame type %q for %s", session.ack.Type, spec.EventKey)
}
if len(sessions) > 1 && session.ack.BusPID != sessions[0].ack.BusPID {
return fmt.Errorf("consume: consumers connected to different bus processes (%d and %d)", sessions[0].ack.BusPID, session.ack.BusPID)
}
}
if !cfg.Quiet {
for _, session := range sessions {
fmt.Fprintf(cfg.Stderr, "[event] subscription event_key=%s subscribe_id=%s\n",
session.spec.EventKey, session.spec.ReadySubscribeID)
}
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=%d\n", len(sessions), sessions[0].ack.BusPID)
fmt.Fprintf(cfg.Stderr, "[event] bus source=%s state=%s idle_timeout=%ds\n",
sessions[0].ack.StateSource, sessions[0].ack.SourceState, sessions[0].ack.IdleTimeoutSecs)
}
if cfg.Stdin != nil {
go watchStdinEOF(runCtx, cfg.Stdin, cfg.Stderr, cancelRun)
}
frames := make(chan manyFrame, len(sessions)*2)
active := make(map[int]struct{}, len(sessions))
for index, session := range sessions {
active[index] = struct{}{}
go readManySession(ctx, index, session, frames)
}
received := 0
start := time.Now()
reason := ""
defer func() {
if !cfg.Quiet && reason != "" {
fmt.Fprintf(cfg.Stderr, "[event] exited — received %d event(s) in %s (reason: %s)\n",
received, time.Since(start).Round(time.Millisecond), reason)
}
}()
classifyCancel := func() string {
if timeoutCtx != nil && errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) && parentCtx.Err() == nil {
return "timeout"
}
return "signal"
}
for len(active) > 0 {
select {
case <-ctx.Done():
reason = classifyCancel()
return nil
case frame := <-frames:
if frame.err != nil {
if runManyIsCtxCancelled(ctx) {
reason = classifyCancel()
return nil
}
return fmt.Errorf("consume: read frame for %s: %w", sessions[frame.index].spec.EventKey, frame.err)
}
typ, err := transport.PeekType(frame.raw)
if err != nil {
continue
}
switch typ {
case transport.FrameTypeEvent:
var ev transport.Event
if err := json.Unmarshal(frame.raw, &ev); err != nil {
continue
}
if err := pipeline.Deliver(ev); err != nil {
if errors.Is(err, ErrPipeClosed) {
sendManyBye(sessions, active, "client_done")
reason = "signal"
return nil
}
return fmt.Errorf("consume: deliver event: %w", err)
}
received++
if cfg.MaxEvents > 0 && received >= cfg.MaxEvents {
sendManyBye(sessions, active, "client_done")
reason = "limit"
return nil
}
case transport.FrameTypeBye:
var bye transport.Bye
_ = json.Unmarshal(frame.raw, &bye)
if bye.Reason == transport.ByeReasonSubscriptionStopped {
delete(active, frame.index)
_ = sessions[frame.index].conn.Close()
if !cfg.Quiet {
fmt.Fprintf(cfg.Stderr, "[event] subscription stopped event_key=%s subscribe_id=%s remaining=%d\n",
sessions[frame.index].spec.EventKey,
sessions[frame.index].spec.ReadySubscribeID,
len(active))
}
continue
}
if !cfg.Quiet {
fmt.Fprintf(cfg.Stderr, "[event] bus closing: %s\n", bye.Reason)
}
reason = "bus_shutdown"
return nil
case transport.FrameTypeSourceState:
if !cfg.Quiet && frame.index == firstActiveSession(active) {
var state transport.SourceState
_ = json.Unmarshal(frame.raw, &state)
fmt.Fprintf(cfg.Stderr, "source state: %s (source=%s, attempt=%d)\n", state.State, state.StateSource, state.Attempt)
}
case transport.FrameTypeHeartbeat:
// silent
}
}
}
reason = "subscriptions_stopped"
return nil
}
func validateConsumerSpecs(specs []ConsumerSpec) error {
seen := map[string]struct{}{}
for _, spec := range specs {
if strings.TrimSpace(spec.EventKey) == "" {
return errors.New("consume: consumer event_key is required")
}
id := strings.TrimSpace(spec.SubscribeID)
if id == "" {
return fmt.Errorf("consume: subscribe_id is required for %s", spec.EventKey)
}
if _, ok := seen[id]; ok {
return fmt.Errorf("consume: duplicate subscribe_id %q", id)
}
seen[id] = struct{}{}
}
return nil
}
func readManySession(ctx context.Context, index int, session *manySession, out chan<- manyFrame) {
for {
raw, err := session.r.Read()
frame := manyFrame{index: index, raw: raw, err: err}
select {
case out <- frame:
case <-ctx.Done():
return
}
if err != nil {
return
}
if typ, _ := transport.PeekType(raw); typ == transport.FrameTypeBye {
return
}
}
}
func sendManyBye(sessions []*manySession, active map[int]struct{}, reason string) {
indices := make([]int, 0, len(active))
for index := range active {
indices = append(indices, index)
}
sort.Ints(indices)
for _, index := range indices {
_ = sessions[index].w.WriteJSON(transport.Bye{Type: transport.FrameTypeBye, Reason: reason})
}
}
func firstActiveSession(active map[int]struct{}) int {
first := -1
for index := range active {
if first == -1 || index < first {
first = index
}
}
return first
}
+535
View File
@@ -0,0 +1,535 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package consume
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"strings"
"sync"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
)
type synchronizedBuffer struct {
mu sync.Mutex
b bytes.Buffer
}
func (b *synchronizedBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.Write(p)
}
func (b *synchronizedBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.b.String()
}
type manyFakeBus struct {
client net.Conn
server net.Conn
hello chan transport.Hello
acked chan struct{}
ackGate <-chan struct{}
ack transport.HelloAck
send chan any
}
func newManyFakeBus(busPID int, ackGate <-chan struct{}) *manyFakeBus {
client, server := net.Pipe()
f := &manyFakeBus{
client: client,
server: server,
hello: make(chan transport.Hello, 1),
acked: make(chan struct{}),
ackGate: ackGate,
ack: transport.HelloAck{
Type: transport.FrameTypeHelloAck,
BusPID: busPID,
SourceState: "connected",
StateSource: "inferred",
IdleTimeoutSecs: 300,
},
send: make(chan any, 8),
}
go f.serve()
return f
}
func (f *manyFakeBus) serve() {
r := transport.NewReader(f.server)
w := transport.NewWriter(f.server)
var hello transport.Hello
if err := r.ReadJSON(&hello); err != nil {
return
}
f.hello <- hello
if f.ackGate != nil {
select {
case <-f.ackGate:
case <-time.After(5 * time.Second):
return
}
}
if err := w.WriteJSON(f.ack); err != nil {
return
}
close(f.acked)
go func() {
for {
if _, err := r.Read(); err != nil {
return
}
}
}()
for frame := range f.send {
if err := w.WriteJSON(frame); err != nil {
return
}
}
}
func installManyDiscover(t *testing.T, buses ...*manyFakeBus) {
t.Helper()
oldDiscover := discoverBus
index := 0
discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) {
if index >= len(buses) {
return nil, errors.New("unexpected discover call")
}
conn := buses[index].client
index++
return conn, nil
}
t.Cleanup(func() {
discoverBus = oldDiscover
for _, bus := range buses {
_ = bus.client.Close()
_ = bus.server.Close()
close(bus.send)
}
})
}
func manyTestConfig(stdout, stderr io.Writer) Config {
return Config{
WorkDir: "workdir",
IPCEndpoint: "endpoint",
ClientID: "client",
Stdout: stdout,
Stderr: stderr,
Format: FormatNDJSON,
}
}
func manyTestSpecs() []ConsumerSpec {
return []ConsumerSpec{
{EventKey: "event-a", EventTypes: []string{"event-a"}, SubscribeID: "sub-a", ReadySubscribeID: "sub-a"},
{EventKey: "event-b", EventTypes: []string{"event-b"}, SubscribeID: "sub-b", ReadySubscribeID: "sub-b"},
}
}
func waitForBuffer(t *testing.T, b *synchronizedBuffer, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if strings.Contains(b.String(), want) {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("buffer did not contain %q:\n%s", want, b.String())
}
func TestRunManyWaitsForAllConsumersAndStopsOneAtATime(t *testing.T) {
secondAck := make(chan struct{})
busA := newManyFakeBus(101, nil)
busB := newManyFakeBus(101, secondAck)
installManyDiscover(t, busA, busB)
var stdout, stderr synchronizedBuffer
done := make(chan error, 1)
go func() {
done <- RunMany(context.Background(), manyTestConfig(&stdout, &stderr), manyTestSpecs())
}()
helloA := <-busA.hello
helloB := <-busB.hello
if helloA.SubscribeID != "sub-a" || strings.Join(helloA.EventTypes, ",") != "event-a" {
t.Fatalf("first hello = %#v", helloA)
}
if helloB.SubscribeID != "sub-b" || strings.Join(helloB.EventTypes, ",") != "event-b" {
t.Fatalf("second hello = %#v", helloB)
}
select {
case err := <-done:
t.Fatalf("RunMany exited before all acknowledgements: %v", err)
default:
}
if strings.Contains(stderr.String(), "[event] ready") || strings.Contains(stderr.String(), "[event] subscription") {
t.Fatalf("ready output appeared before all acknowledgements:\n%s", stderr.String())
}
close(secondAck)
waitForBuffer(t, &stderr, "[event] ready event_count=2 bus_pid=101")
if !strings.Contains(stderr.String(), "event_key=event-a subscribe_id=sub-a") ||
!strings.Contains(stderr.String(), "event_key=event-b subscribe_id=sub-b") {
t.Fatalf("subscription markers missing:\n%s", stderr.String())
}
busA.send <- transport.Event{Type: transport.FrameTypeEvent, EventID: "event-1", EventType: "event-a", SubscribeID: "sub-a", Data: `{}`}
waitForBuffer(t, &stdout, `"event_id":"event-1"`)
busA.send <- transport.Bye{Type: transport.FrameTypeBye, Reason: transport.ByeReasonSubscriptionStopped}
waitForBuffer(t, &stderr, "subscribe_id=sub-a remaining=1")
busB.send <- transport.Event{Type: transport.FrameTypeEvent, EventID: "event-2", EventType: "event-b", SubscribeID: "sub-b", Data: `{}`}
waitForBuffer(t, &stdout, `"event_id":"event-2"`)
busB.send <- transport.Bye{Type: transport.FrameTypeBye, Reason: transport.ByeReasonSubscriptionStopped}
select {
case err := <-done:
if err != nil {
t.Fatalf("RunMany() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not exit after the last subscription stopped")
}
if !strings.Contains(stderr.String(), "subscribe_id=sub-b remaining=0") {
t.Fatalf("last stop marker missing:\n%s", stderr.String())
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
if len(lines) != 2 {
t.Fatalf("stdout lines = %d:\n%s", len(lines), stdout.String())
}
for i, line := range lines {
var event transport.Event
if err := json.Unmarshal([]byte(line), &event); err != nil {
t.Fatalf("line %d: %v", i, err)
}
}
}
func TestRunManyMaxEventsIsSharedAcrossConsumers(t *testing.T) {
busA := newManyFakeBus(202, nil)
busB := newManyFakeBus(202, nil)
installManyDiscover(t, busA, busB)
var stdout, stderr synchronizedBuffer
cfg := manyTestConfig(&stdout, &stderr)
cfg.MaxEvents = 1
done := make(chan error, 1)
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
<-busA.hello
<-busB.hello
<-busA.acked
<-busB.acked
busB.send <- transport.Event{Type: transport.FrameTypeEvent, EventID: "only", EventType: "event-b", SubscribeID: "sub-b", Data: `{}`}
select {
case err := <-done:
if err != nil {
t.Fatalf("RunMany() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not stop at the shared max-events limit")
}
if got := strings.Count(strings.TrimSpace(stdout.String()), "\n") + 1; got != 1 {
t.Fatalf("output event count = %d", got)
}
}
func TestRunManyRejectsInvalidSpecsAndDifferentBuses(t *testing.T) {
cfg := manyTestConfig(io.Discard, io.Discard)
for _, test := range []struct {
name string
specs []ConsumerSpec
}{
{name: "one", specs: []ConsumerSpec{{EventKey: "a", SubscribeID: "sub-a"}}},
{name: "empty event", specs: []ConsumerSpec{{SubscribeID: "sub-a"}, {EventKey: "b", SubscribeID: "sub-b"}}},
{name: "empty subscription", specs: []ConsumerSpec{{EventKey: "a"}, {EventKey: "b", SubscribeID: "sub-b"}}},
{name: "duplicate subscription", specs: []ConsumerSpec{{EventKey: "a", SubscribeID: "sub"}, {EventKey: "b", SubscribeID: "sub"}}},
} {
t.Run(test.name, func(t *testing.T) {
if err := RunMany(context.Background(), cfg, test.specs); err == nil {
t.Fatal("RunMany succeeded")
}
})
}
busA := newManyFakeBus(1, nil)
busB := newManyFakeBus(2, nil)
installManyDiscover(t, busA, busB)
if err := RunMany(context.Background(), cfg, manyTestSpecs()); err == nil || !strings.Contains(err.Error(), "different bus processes") {
t.Fatalf("different bus error = %v", err)
}
}
func TestRunManyContextCancellationInterruptsHandshake(t *testing.T) {
neverAck := make(chan struct{})
busA := newManyFakeBus(1, neverAck)
busB := newManyFakeBus(1, nil)
installManyDiscover(t, busA, busB)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- RunMany(ctx, manyTestConfig(io.Discard, io.Discard), manyTestSpecs()) }()
<-busA.hello
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("RunMany handshake did not unblock on context cancellation")
}
close(neverAck)
}
func TestCrossPlatformCoverageRunManySetupAndHandshakeEdges(t *testing.T) {
specs := manyTestSpecs()
if err := RunMany(context.Background(), Config{}, specs); err == nil {
t.Fatal("missing required config should fail")
}
cfg := Config{
WorkDir: "workdir", IPCEndpoint: "endpoint", ClientID: "client",
Quiet: true, DryRun: true,
}
if err := RunMany(context.Background(), cfg, specs); err != nil {
t.Fatalf("defaulted quiet dry run: %v", err)
}
cfg = manyTestConfig(io.Discard, io.Discard)
cfg.Format = Format("invalid")
if err := RunMany(context.Background(), cfg, specs); err == nil {
t.Fatal("invalid format should fail before discovery")
}
oldDiscover := discoverBus
t.Cleanup(func() { discoverBus = oldDiscover })
wantErr := errors.New("synthetic discover failure")
discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) { return nil, wantErr }
if err := RunMany(context.Background(), manyTestConfig(io.Discard, io.Discard), specs); !errors.Is(err, wantErr) {
t.Fatalf("discover error = %v", err)
}
discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) {
return &faultConn{writeErr: wantErr}, nil
}
if err := RunMany(context.Background(), manyTestConfig(io.Discard, io.Discard), specs); !errors.Is(err, wantErr) {
t.Fatalf("hello write error = %v", err)
}
discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) {
return &faultConn{readErr: io.EOF}, nil
}
if err := RunMany(context.Background(), manyTestConfig(io.Discard, io.Discard), specs); err == nil || !strings.Contains(err.Error(), "hello_ack") {
t.Fatalf("hello ack read error = %v", err)
}
wrongAck := append(mustJSONFrame(t, transport.HelloAck{Type: transport.FrameTypeHeartbeat}), '\n')
discoverBus = func(busctl.DiscoverConfig) (net.Conn, error) {
return &faultConn{readData: append([]byte(nil), wrongAck...), readErr: io.EOF}, nil
}
if err := RunMany(context.Background(), manyTestConfig(io.Discard, io.Discard), specs); err == nil || !strings.Contains(err.Error(), "unexpected first frame") {
t.Fatalf("unexpected ack error = %v", err)
}
}
func TestRunManyDurationAndStdinEOFStopTheWholeGroup(t *testing.T) {
t.Run("duration", func(t *testing.T) {
busA := newManyFakeBus(301, nil)
busB := newManyFakeBus(301, nil)
installManyDiscover(t, busA, busB)
cfg := manyTestConfig(io.Discard, io.Discard)
cfg.Duration = 15 * time.Millisecond
cfg.Quiet = true
if err := RunMany(context.Background(), cfg, manyTestSpecs()); err != nil {
t.Fatalf("duration run: %v", err)
}
})
t.Run("stdin EOF", func(t *testing.T) {
busA := newManyFakeBus(302, nil)
busB := newManyFakeBus(302, nil)
installManyDiscover(t, busA, busB)
var stderr synchronizedBuffer
cfg := manyTestConfig(io.Discard, &stderr)
cfg.Stdin = strings.NewReader("")
if err := RunMany(context.Background(), cfg, manyTestSpecs()); err != nil {
t.Fatalf("stdin EOF run: %v", err)
}
if !strings.Contains(stderr.String(), "reason: signal") {
t.Fatalf("stdin exit marker missing:\n%s", stderr.String())
}
})
}
func TestRunManySourceStateAndBusShutdown(t *testing.T) {
busA := newManyFakeBus(401, nil)
busB := newManyFakeBus(401, nil)
installManyDiscover(t, busA, busB)
var stderr synchronizedBuffer
done := make(chan error, 1)
go func() {
done <- RunMany(context.Background(), manyTestConfig(io.Discard, &stderr), manyTestSpecs())
}()
<-busA.hello
<-busB.hello
<-busA.acked
<-busB.acked
busA.send <- transport.SourceState{Type: transport.FrameTypeSourceState, State: "reconnecting", StateSource: "hook", Attempt: 2}
waitForBuffer(t, &stderr, "source state: reconnecting")
busB.send <- transport.Heartbeat{Type: transport.FrameTypeHeartbeat}
busB.send <- transport.Bye{Type: transport.FrameTypeBye, Reason: "shutdown"}
select {
case err := <-done:
if err != nil {
t.Fatalf("bus shutdown run: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not exit on bus shutdown")
}
if !strings.Contains(stderr.String(), "bus closing: shutdown") {
t.Fatalf("bus shutdown marker missing:\n%s", stderr.String())
}
if got := firstActiveSession(map[int]struct{}{3: {}, 1: {}, 2: {}}); got != 1 {
t.Fatalf("firstActiveSession() = %d", got)
}
}
func TestRunManyOutputAndReadFailures(t *testing.T) {
t.Run("broken output pipe is graceful", func(t *testing.T) {
busA := newManyFakeBus(501, nil)
busB := newManyFakeBus(501, nil)
installManyDiscover(t, busA, busB)
cfg := manyTestConfig(errorWriter{err: testBrokenPipeError()}, io.Discard)
done := make(chan error, 1)
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
<-busA.hello
<-busB.hello
<-busA.acked
<-busB.acked
busA.send <- transport.Event{Type: transport.FrameTypeEvent, EventID: "pipe", EventType: "event-a", SubscribeID: "sub-a", Data: `{}`}
select {
case err := <-done:
if err != nil {
t.Fatalf("broken pipe run: %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not exit on broken output pipe")
}
})
t.Run("ordinary output error fails group", func(t *testing.T) {
busA := newManyFakeBus(502, nil)
busB := newManyFakeBus(502, nil)
installManyDiscover(t, busA, busB)
wantErr := errors.New("output failed")
cfg := manyTestConfig(errorWriter{err: wantErr}, io.Discard)
done := make(chan error, 1)
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
<-busA.hello
<-busB.hello
<-busA.acked
<-busB.acked
busB.send <- transport.Event{Type: transport.FrameTypeEvent, EventID: "error", EventType: "event-b", SubscribeID: "sub-b", Data: `{}`}
select {
case err := <-done:
if !errors.Is(err, wantErr) {
t.Fatalf("output error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not exit on output failure")
}
})
t.Run("unexpected peer close fails group", func(t *testing.T) {
ack := mustJSONFrame(t, transport.HelloAck{Type: transport.FrameTypeHelloAck, BusPID: 503})
oldDiscover := discoverBus
discoverBus = pipeDiscover(t, [][]byte{ack}, true)
t.Cleanup(func() { discoverBus = oldDiscover })
if err := RunMany(context.Background(), manyTestConfig(io.Discard, io.Discard), manyTestSpecs()); err == nil || !strings.Contains(err.Error(), "read frame") {
t.Fatalf("peer close error = %v", err)
}
})
}
func TestCrossPlatformCoverageRunManyIgnoresMalformedFrames(t *testing.T) {
busA := newManyFakeBus(601, nil)
busB := newManyFakeBus(601, nil)
installManyDiscover(t, busA, busB)
var stdout synchronizedBuffer
cfg := manyTestConfig(&stdout, io.Discard)
cfg.MaxEvents = 1
done := make(chan error, 1)
go func() { done <- RunMany(context.Background(), cfg, manyTestSpecs()) }()
<-busA.hello
<-busB.hello
<-busA.acked
<-busB.acked
busA.send <- "not-an-object"
busA.send <- map[string]any{"type": "event", "seq": "not-a-number"}
busA.send <- transport.Event{
Type: transport.FrameTypeEvent, EventID: "valid",
EventType: "event-a", SubscribeID: "sub-a", Data: `{}`,
}
select {
case err := <-done:
if err != nil {
t.Fatalf("RunMany() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not stop after the valid event")
}
if !strings.Contains(stdout.String(), `"event_id":"valid"`) {
t.Fatalf("stdout = %q", stdout.String())
}
}
func TestCrossPlatformCoverageRunManyTreatsCancelledReadAsGraceful(t *testing.T) {
busA := newManyFakeBus(602, nil)
busB := newManyFakeBus(602, nil)
installManyDiscover(t, busA, busB)
oldCancelled := runManyIsCtxCancelled
runManyIsCtxCancelled = func(context.Context) bool { return true }
t.Cleanup(func() { runManyIsCtxCancelled = oldCancelled })
done := make(chan error, 1)
go func() {
done <- RunMany(context.Background(), manyTestConfig(io.Discard, io.Discard), manyTestSpecs())
}()
<-busA.hello
<-busB.hello
<-busA.acked
<-busB.acked
_ = busA.server.Close()
select {
case err := <-done:
if err != nil {
t.Fatalf("cancelled read error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("RunMany did not stop after the cancelled read")
}
}
func TestPrintDryRunManyDisplaysPendingSubscription(t *testing.T) {
var output bytes.Buffer
PrintDryRunMany(&output, manyTestConfig(io.Discard, io.Discard), []ConsumerSpec{
{EventKey: "event-a", EventTypes: []string{"event-a"}},
})
if !strings.Contains(output.String(), "subscribe_id=(pending)") {
t.Fatalf("dry-run output = %q", output.String())
}
}
+22
View File
@@ -150,3 +150,25 @@ func PrintDryRun(w io.Writer, cfg Config) {
fmt.Fprintf(w, " foreground : %v\n", cfg.Foreground)
fmt.Fprintf(w, " force : %v\n", cfg.Force)
}
// PrintDryRunMany renders the shared consume configuration plus every local
// logical consumer without opening the bus.
func PrintDryRunMany(w io.Writer, cfg Config, specs []ConsumerSpec) {
preview := cfg
preview.EventTypes = nil
for _, spec := range specs {
preview.EventTypes = append(preview.EventTypes, spec.EventTypes...)
}
PrintDryRun(w, preview)
for i, spec := range specs {
fmt.Fprintf(w, " consumer[%d] : event_key=%s subscribe_id=%s event_types=%s\n",
i, spec.EventKey, displayDryRunValue(spec.SubscribeID), strings.Join(spec.EventTypes, ","))
}
}
func displayDryRunValue(value string) string {
if value = strings.TrimSpace(value); value != "" {
return value
}
return "(pending)"
}
+6
View File
@@ -125,9 +125,15 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
{"reaction_o2o/openDingtalkId", EventReactionO2O, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_user/staffId", EventFromUser, RuleOptions{UserID: "staff-1"}, map[string]any{"targetUid": "staff-1", "targetUidType": "staffId"}},
{"receive_user/openDingtalkId", EventFromUser, RuleOptions{OpenDingTalkID: "open-user-1"}, map[string]any{"targetUid": "open-user-1", "targetUidType": "openDingtalkId"}},
{"receive_o2o_all", EventAllSingleChat, RuleOptions{}, map[string]any{}},
{"receive_group_all", EventAllGroupChat, RuleOptions{}, map[string]any{}},
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"recall_group", EventRecallGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"reaction_group", EventReactionGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"group_updated", EventGroupUpdated, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"group_member_added", EventGroupMemberAdded, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"group_member_exited", EventGroupMemberExited, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
{"group_disbanded", EventGroupDisbanded, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+249 -26
View File
@@ -27,17 +27,31 @@ import (
// message receive events. Schema output is generated from these tags so the
// documented fields cannot drift from the values written by consume.
type MessageEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Sender string `json:"sender" description:"发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"发送人开放 ID" format:"open_dingtalk_id"`
Content string `json:"content" description:"消息正文"`
CreateTime string `json:"create_time" description:"消息创建时间"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
MessageID string `json:"message_id" description:"开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"会话 ID" format:"open_conversation_id"`
Sender string `json:"sender" description:"发送人展示名"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"发送人开放 ID" format:"open_dingtalk_id"`
Content string `json:"content" description:"消息正文"`
CreateTime string `json:"create_time" description:"消息创建时间"`
EventTime int64 `json:"event_time" description:"消息事件时间戳" format:"timestamp_ms"`
QuotedMessage *MessageEventContext `json:"quoted_message,omitempty" description:"引用回复所引用的原消息;非引用回复时不输出"`
ForwardMessages []MessageEventContext `json:"forward_messages,omitempty" description:"合并转发包含的原消息列表;非合并转发时不输出"`
}
// MessageEventContext preserves the business context nested under a quoted
// reply or merged-forward message. Keep these fields structured instead of
// parsing the localized outer content summary.
type MessageEventContext struct {
MessageID string `json:"message_id" description:"内部消息的开放消息 ID" format:"open_message_id"`
ConversationID string `json:"conversation_id" description:"内部消息原来所在的会话 ID" format:"open_conversation_id"`
Sender string `json:"sender" description:"内部消息发送人展示名;服务端未提供时可能为空或为 null 字符串"`
SenderOpenDingTalkID string `json:"sender_open_dingtalk_id" description:"内部消息发送人开放 ID;服务端未提供时为空" format:"open_dingtalk_id"`
Content string `json:"content" description:"内部消息正文;媒体消息可能包含 mediaId 等下载定位信息"`
CreateTime string `json:"create_time" description:"内部消息创建时间"`
}
type ReadEventOutput struct {
@@ -95,6 +109,35 @@ type baseEventOutput struct {
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
}
// GroupLifecycleEventOutput is intentionally conservative until stable group
// event payload samples are available. Payload keeps unknown business fields
// while transport identity and routing metadata remain available only in raw
// output.
type GroupLifecycleEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
Payload map[string]any `json:"payload" description:"群生命周期事件业务数据,字段以服务端实际推送为准" additional_properties:"true"`
}
type GroupMemberEventOutput struct {
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
ConversationID string `json:"conversation_id" description:"发生成员变更的群会话 ID" format:"open_conversation_id"`
Operator string `json:"operator" description:"执行成员变更操作的用户展示名,系统操作或成员自行退出时可能为空"`
OperatorOpenDingTalkID string `json:"operator_open_dingtalk_id" description:"执行成员变更操作的用户开放 ID,系统操作或成员自行退出时可能为空" format:"open_dingtalk_id"`
Members []GroupMemberEventMember `json:"members" description:"本次加入或退出的成员列表"`
EventTime int64 `json:"event_time" description:"群成员变更事件时间戳" format:"timestamp_ms"`
}
type GroupMemberEventMember struct {
Nick string `json:"nick" description:"成员展示名"`
OpenDingTalkID string `json:"open_dingtalk_id" description:"成员开放 ID" format:"open_dingtalk_id"`
}
type personalEventData struct {
EventID string `json:"eventId"`
EventKey string `json:"eventKey"`
@@ -106,15 +149,26 @@ type personalEventData struct {
type personalMessagePayload struct {
EventTime int64 `json:"event_time"`
Body struct {
CreateTime string `json:"createTime"`
Sender string `json:"sender"`
OpenMessageID string `json:"openMessageId"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
OpenConversationID string `json:"openConversationId"`
Content string `json:"content"`
CreateTime string `json:"createTime"`
Sender string `json:"sender"`
OpenMessageID string `json:"openMessageId"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
OpenConversationID string `json:"openConversationId"`
Content string `json:"content"`
QuotedMessage *personalMessageContext `json:"quotedMessage"`
ForwardMessages []personalMessageContext `json:"forwardMessages"`
} `json:"body"`
}
type personalMessageContext struct {
CreateTime string `json:"createTime"`
Sender string `json:"sender"`
OpenMessageID string `json:"openMessageId"`
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
OpenConversationID string `json:"openConversationId"`
Content string `json:"content"`
}
type personalReadPayload struct {
EventTime int64 `json:"event_time"`
Body struct {
@@ -159,6 +213,23 @@ type personalReactionBody struct {
SenderOpenDingTalkID string `json:"senderOpenDingTalkId"`
}
type personalGroupMemberPayload struct {
EventTime int64 `json:"event_time"`
Body personalGroupMemberBody `json:"body"`
}
type personalGroupMemberBody struct {
ConversationID string `json:"openConversationId"`
Operator string `json:"operNick"`
OperatorOpenDingTalkID string `json:"-"`
Members []personalGroupMemberRecord `json:"members"`
}
type personalGroupMemberRecord struct {
Nick string `json:"nick"`
OpenDingTalkID string `json:"openDingTalkId"`
}
func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
// encoding/json otherwise falls back to case-insensitive field matching.
// Read this protocol field from a map so only operOpenDingtalkId is accepted.
@@ -182,6 +253,29 @@ func (b *personalReactionBody) UnmarshalJSON(data []byte) error {
return nil
}
func (b *personalGroupMemberBody) UnmarshalJSON(data []byte) error {
// Keep the protocol spelling strict: encoding/json would otherwise accept
// operOpenDingTalkId through case-insensitive fallback matching.
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
type bodyAlias personalGroupMemberBody
var decoded bodyAlias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
if raw, ok := fields["operOpenDingtalkId"]; ok {
if err := json.Unmarshal(raw, &decoded.OperatorOpenDingTalkID); err != nil {
return fmt.Errorf("decode operOpenDingtalkId: %w", err)
}
}
*b = personalGroupMemberBody(decoded)
return nil
}
// ProjectOutput converts the transport envelope into the stable personal
// event output. On malformed Data it returns the original envelope together
// with an error; the formatter logs the warning and still emits that envelope.
@@ -204,6 +298,18 @@ func ProjectOutput(ev transport.Event) (any, error) {
if err := decodeRequiredPayload(data.Payload, &payload); err != nil {
return ev, fmt.Errorf("decode personal message payload: %w", err)
}
var quotedMessage *MessageEventContext
if payload.Body.QuotedMessage != nil {
projected := projectMessageEventContext(*payload.Body.QuotedMessage)
quotedMessage = &projected
}
var forwardMessages []MessageEventContext
if len(payload.Body.ForwardMessages) > 0 {
forwardMessages = make([]MessageEventContext, 0, len(payload.Body.ForwardMessages))
for _, message := range payload.Body.ForwardMessages {
forwardMessages = append(forwardMessages, projectMessageEventContext(message))
}
}
return MessageEventOutput{
Type: eventType,
EventID: eventID,
@@ -216,6 +322,8 @@ func ProjectOutput(ev transport.Event) (any, error) {
Content: payload.Body.Content,
CreateTime: payload.Body.CreateTime,
EventTime: payload.EventTime,
QuotedMessage: quotedMessage,
ForwardMessages: forwardMessages,
}, nil
}
@@ -232,11 +340,57 @@ func ProjectOutput(ev transport.Event) (any, error) {
return projectRecallEvent(ev, base, data.Payload)
case isReactionEvent(eventType):
return projectReactionEvent(ev, base, data.Payload)
case isGroupMemberEvent(eventType):
return projectGroupMemberEvent(ev, base, data.Payload)
case isGroupLifecycleEvent(eventType):
payload, err := decodeGroupLifecyclePayload(data.Payload)
if err != nil {
return ev, fmt.Errorf("decode personal group lifecycle payload: %w", err)
}
return GroupLifecycleEventOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
Payload: payload,
}, nil
default:
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
}
}
func projectMessageEventContext(message personalMessageContext) MessageEventContext {
return MessageEventContext{
MessageID: message.OpenMessageID,
ConversationID: message.OpenConversationID,
Sender: message.Sender,
SenderOpenDingTalkID: message.SenderOpenDingTalkID,
Content: message.Content,
CreateTime: message.CreateTime,
}
}
func decodeGroupLifecyclePayload(raw json.RawMessage) (map[string]any, error) {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return nil, fmt.Errorf("payload is missing")
}
var payload map[string]any
if err := json.Unmarshal(trimmed, &payload); err != nil {
return nil, err
}
if len(payload) == 0 {
return nil, fmt.Errorf("payload is empty")
}
for key := range payload {
switch strings.ToLower(key) {
case "uid", "corpid", "clientid", "filtersubid", "bizid", "orgid", "sourceid":
delete(payload, key)
}
}
return payload, nil
}
func projectReadEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalReadPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
@@ -303,6 +457,38 @@ func projectReactionEvent(ev transport.Event, base baseEventOutput, raw json.Raw
}, nil
}
func projectGroupMemberEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
var payload personalGroupMemberPayload
if err := decodeRequiredPayload(raw, &payload); err != nil {
return ev, fmt.Errorf("decode personal group member payload: %w", err)
}
if strings.TrimSpace(payload.Body.ConversationID) == "" {
return ev, fmt.Errorf("decode personal group member payload: openConversationId is required")
}
if len(payload.Body.Members) == 0 {
return ev, fmt.Errorf("decode personal group member payload: members is required")
}
members := make([]GroupMemberEventMember, 0, len(payload.Body.Members))
for _, member := range payload.Body.Members {
members = append(members, GroupMemberEventMember{
Nick: member.Nick,
OpenDingTalkID: member.OpenDingTalkID,
})
}
return GroupMemberEventOutput{
Type: base.Type,
EventID: base.EventID,
Timestamp: base.Timestamp,
SubscribeID: base.SubscribeID,
ConversationID: payload.Body.ConversationID,
Operator: payload.Body.Operator,
OperatorOpenDingTalkID: payload.Body.OperatorOpenDingTalkID,
Members: members,
EventTime: payload.EventTime,
}, nil
}
func decodeRequiredPayload(raw json.RawMessage, target any) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
@@ -364,16 +550,26 @@ func decodePersonalEventData(raw string) (personalEventData, error) {
func outputSchema(eventKey string) map[string]any {
outputType := outputTypeForEvent(eventKey)
properties := make(map[string]any, outputType.NumField())
for i := 0; i < outputType.NumField(); i++ {
field := outputType.Field(i)
schema := schemaForStruct(outputType)
properties := schema["properties"].(map[string]any)
if property, ok := properties["type"].(map[string]any); ok {
property["enum"] = []string{eventKey}
}
return schema
}
func schemaForStruct(t reflect.Type) map[string]any {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
properties := make(map[string]any, t.NumField())
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
continue
}
property := map[string]any{
"type": schemaType(field.Type),
}
property := schemaForType(field.Type)
if description := field.Tag.Get("description"); description != "" {
property["description"] = description
}
@@ -383,9 +579,6 @@ func outputSchema(eventKey string) map[string]any {
if field.Tag.Get("additional_properties") == "true" {
property["additionalProperties"] = true
}
if name == "type" {
property["enum"] = []string{eventKey}
}
properties[name] = property
}
return map[string]any{
@@ -394,6 +587,23 @@ func outputSchema(eventKey string) map[string]any {
}
}
func schemaForType(t reflect.Type) map[string]any {
for t.Kind() == reflect.Pointer {
t = t.Elem()
}
switch t.Kind() {
case reflect.Struct:
return schemaForStruct(t)
case reflect.Slice, reflect.Array:
return map[string]any{
"type": "array",
"items": schemaForType(t.Elem()),
}
default:
return map[string]any{"type": schemaType(t)}
}
}
func transportEnvelopeSchema(eventKey string) map[string]any {
eventType := reflect.TypeOf(transport.Event{})
properties := make(map[string]any, eventType.NumField())
@@ -437,6 +647,10 @@ func outputTypeForEvent(eventKey string) reflect.Type {
return reflect.TypeOf(RecallEventOutput{})
case isReactionEvent(eventKey):
return reflect.TypeOf(ReactionEventOutput{})
case isGroupMemberEvent(eventKey):
return reflect.TypeOf(GroupMemberEventOutput{})
case isGroupLifecycleEvent(eventKey):
return reflect.TypeOf(GroupLifecycleEventOutput{})
default:
return reflect.TypeOf(baseEventOutput{})
}
@@ -454,6 +668,15 @@ func isReactionEvent(eventKey string) bool {
return eventKey == EventReactionO2O || eventKey == EventReactionGroup
}
func isGroupMemberEvent(eventKey string) bool {
return eventKey == EventGroupMemberAdded || eventKey == EventGroupMemberExited
}
func isGroupLifecycleEvent(eventKey string) bool {
return eventKey == EventGroupUpdated ||
eventKey == EventGroupDisbanded
}
func schemaType(t reflect.Type) string {
switch t.Kind() {
case reflect.String:
+423 -1
View File
@@ -124,8 +124,34 @@ func personalReactionData(eventKey, messageID, conversationID string) string {
}`, eventKey, conversationID, messageID)
}
func personalGroupMemberData(eventKey string) string {
return fmt.Sprintf(`{
"eventId":"group-member-event",
"eventKey":%q,
"occurredAtMs":1784782513647,
"subId":"group-member-sub",
"payload":{
"uid":100001,
"clientId":"internal-client",
"corpid":"internal-corp",
"bizid":"internal-biz",
"filterSubId":"internal-filter",
"body":{
"operNick":"测试用户甲",
"members":[
{"nick":"测试用户乙","openDingTalkId":"member-open-id-1"},
{"nick":"测试用户丙","openDingTalkId":"member-open-id-2"}
],
"operOpenDingtalkId":"operator-open-id",
"openConversationId":"cid-group-1"
},
"event_time":1784782513502
}
}`, eventKey)
}
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
Type: transport.FrameTypeEvent,
@@ -158,6 +184,367 @@ func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
if !reflect.DeepEqual(got, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", got, want)
}
encoded, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
for _, absent := range []string{"quoted_message", "forward_messages"} {
if strings.Contains(string(encoded), `"`+absent+`"`) {
t.Fatalf("ordinary message output contains optional field %q: %s", absent, encoded)
}
}
})
}
}
func TestProjectOutputPreservesQuotedMessageContext(t *testing.T) {
data := `{
"eventId":"quoted-event",
"eventKey":"user_im_message_receive_group",
"occurredAtMs":1784792292580,
"subId":"quoted-sub",
"payload":{
"body":{
"createTime":"2026-07-23 15:38:11",
"sender":"郑御白",
"openMessageId":"outer-message",
"senderOpenDingTalkId":"outer-sender-open-id",
"openConversationId":"target-conversation",
"content":"引用回复",
"quotedMessage":{
"createTime":"2026-07-23 15:35:03",
"sender":"null",
"openMessageId":"quoted-message",
"senderOpenDingTalkId":"quoted-sender-open-id",
"openConversationId":"source-conversation",
"content":"被引用的原消息"
}
},
"event_time":1784792291637
}
}`
projected, err := ProjectOutput(transport.Event{
EventType: EventInChat,
SubscribeID: "outer-sub",
Data: data,
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
want := &MessageEventContext{
MessageID: "quoted-message",
ConversationID: "source-conversation",
Sender: "null",
SenderOpenDingTalkID: "quoted-sender-open-id",
Content: "被引用的原消息",
CreateTime: "2026-07-23 15:35:03",
}
if !reflect.DeepEqual(got.QuotedMessage, want) {
t.Fatalf("quoted_message = %#v, want %#v", got.QuotedMessage, want)
}
if got.ForwardMessages != nil {
t.Fatalf("forward_messages = %#v, want nil", got.ForwardMessages)
}
}
func TestProjectOutputPreservesMergedForwardContextAndMediaLocator(t *testing.T) {
data := `{
"eventId":"forward-event",
"eventKey":"user_im_message_receive_group",
"occurredAtMs":1784861030151,
"subId":"forward-sub",
"payload":{
"body":{
"createTime":"2026-07-24 10:43:49",
"sender":"郑御白",
"openMessageId":"outer-forward-message",
"senderOpenDingTalkId":"outer-sender-open-id",
"openConversationId":"target-conversation",
"content":"Chat history between two users\nUser A:[Image]\nUser A:Forwarded chat record",
"forwardMessages":[
{
"createTime":"2026-07-24 10:33:31",
"sender":"null",
"openMessageId":"image-message",
"senderOpenDingTalkId":"image-sender-open-id",
"openConversationId":"source-conversation",
"content":"[图片消息](mediaId=media-1) 注意:如需下载使用dws chat message download-media命令下载"
},
{
"createTime":"2026-07-24 10:34:46",
"sender":"null",
"openMessageId":"text-message",
"openConversationId":"source-conversation",
"content":"转发聊天记录"
}
]
},
"event_time":1784861029265
}
}`
projected, err := ProjectOutput(transport.Event{
EventType: EventInChat,
SubscribeID: "outer-sub",
Data: data,
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(MessageEventOutput)
want := []MessageEventContext{
{
MessageID: "image-message",
ConversationID: "source-conversation",
Sender: "null",
SenderOpenDingTalkID: "image-sender-open-id",
Content: "[图片消息](mediaId=media-1) 注意:如需下载使用dws chat message download-media命令下载",
CreateTime: "2026-07-24 10:33:31",
},
{
MessageID: "text-message",
ConversationID: "source-conversation",
Sender: "null",
Content: "转发聊天记录",
CreateTime: "2026-07-24 10:34:46",
},
}
if !reflect.DeepEqual(got.ForwardMessages, want) {
t.Fatalf("forward_messages = %#v, want %#v", got.ForwardMessages, want)
}
if got.QuotedMessage != nil {
t.Fatalf("quoted_message = %#v, want nil", got.QuotedMessage)
}
encoded, err := json.Marshal(got)
if err != nil {
t.Fatal(err)
}
for _, wantFragment := range []string{
`"forward_messages"`,
`"message_id":"image-message"`,
`"conversation_id":"source-conversation"`,
`mediaId=media-1`,
} {
if !strings.Contains(string(encoded), wantFragment) {
t.Fatalf("flattened merged-forward output missing %q: %s", wantFragment, encoded)
}
}
for _, localizedDetection := range []string{"群聊的聊天记录", "的聊天记录"} {
if strings.Contains(got.Content, localizedDetection) {
t.Fatalf("test fixture should not require localized title %q for projection", localizedDetection)
}
}
}
func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) {
for _, eventKey := range []string{EventGroupUpdated, EventGroupDisbanded} {
t.Run(eventKey, func(t *testing.T) {
data := fmt.Sprintf(`{
"eventId":"group-event",
"eventKey":%q,
"occurredAtMs":1784009000000,
"subId":"data-sub",
"payload":{
"uid":100001,
"CORPID":"internal-corp",
"clientId":"internal-client",
"filterSubId":"internal-filter",
"bizid":"internal-biz",
"orgId":100002,
"sourceId":"open",
"event_time":1784008999000,
"body":{
"openConversationId":"cid-group-1",
"title":"测试群新标题",
"operator":{"uid":"business-user-1"}
}
}
}`, eventKey)
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventType: eventKey,
SubscribeID: "outer-sub",
Data: data,
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got, ok := projected.(GroupLifecycleEventOutput)
if !ok {
t.Fatalf("ProjectOutput() type = %T", projected)
}
if got.Type != eventKey || got.EventID != "group-event" || got.Timestamp != 1784009000000 || got.SubscribeID != "outer-sub" {
t.Fatalf("common fields = %#v", got)
}
for _, internal := range []string{"uid", "CORPID", "clientId", "filterSubId", "bizid", "orgId", "sourceId"} {
if _, ok := got.Payload[internal]; ok {
t.Fatalf("payload retained internal field %q: %#v", internal, got.Payload)
}
}
body, ok := got.Payload["body"].(map[string]any)
if !ok || body["title"] != "测试群新标题" {
t.Fatalf("payload body = %#v", got.Payload["body"])
}
operator := body["operator"].(map[string]any)
if operator["uid"] != "business-user-1" {
t.Fatalf("nested business uid was removed: %#v", operator)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputGroupMemberEvents(t *testing.T) {
for _, eventKey := range []string{EventGroupMemberAdded, EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
projected, err := ProjectOutput(transport.Event{
EventID: "outer-event",
EventBornTime: 11,
EventType: eventKey,
SubscribeID: "outer-sub",
Data: personalGroupMemberData(eventKey),
})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
want := GroupMemberEventOutput{
Type: eventKey,
EventID: "group-member-event",
Timestamp: 1784782513647,
SubscribeID: "outer-sub",
ConversationID: "cid-group-1",
Operator: "测试用户甲",
OperatorOpenDingTalkID: "operator-open-id",
Members: []GroupMemberEventMember{
{Nick: "测试用户乙", OpenDingTalkID: "member-open-id-1"},
{Nick: "测试用户丙", OpenDingTalkID: "member-open-id-2"},
},
EventTime: 1784782513502,
}
if !reflect.DeepEqual(projected, want) {
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, want)
}
assertNoInternalActionFields(t, projected)
})
}
}
func TestCrossPlatformCoverageProjectOutputGroupMemberAllowsMissingOperator(t *testing.T) {
data := strings.ReplaceAll(personalGroupMemberData(EventGroupMemberExited), `"operNick":"测试用户甲",`, "")
data = strings.ReplaceAll(data, `"operOpenDingtalkId":"operator-open-id",`, "")
projected, err := ProjectOutput(transport.Event{EventType: EventGroupMemberExited, Data: data})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(GroupMemberEventOutput)
if got.Operator != "" || got.OperatorOpenDingTalkID != "" {
t.Fatalf("operator fields = %q/%q, want empty", got.Operator, got.OperatorOpenDingTalkID)
}
if len(got.Members) != 2 {
t.Fatalf("members = %#v, want two members", got.Members)
}
}
func TestCrossPlatformCoverageProjectOutputGroupMemberRejectsLegacyOperatorOpenIDSpellings(t *testing.T) {
for _, legacyField := range []string{"operOpenDingtlkId", "operOpenDingTalkId"} {
t.Run(legacyField, func(t *testing.T) {
data := strings.Replace(personalGroupMemberData(EventGroupMemberAdded), "operOpenDingtalkId", legacyField, 1)
projected, err := ProjectOutput(transport.Event{EventType: EventGroupMemberAdded, Data: data})
if err != nil {
t.Fatalf("ProjectOutput() error = %v", err)
}
got := projected.(GroupMemberEventOutput)
if got.OperatorOpenDingTalkID != "" {
t.Fatalf("operator_open_dingtalk_id = %q, want empty for legacy field %s", got.OperatorOpenDingTalkID, legacyField)
}
if got.Members[0].OpenDingTalkID != "member-open-id-1" {
t.Fatalf("members[0].open_dingtalk_id = %q, want protocol openDingTalkId value", got.Members[0].OpenDingTalkID)
}
})
}
}
func TestCrossPlatformCoverageGroupMemberBodyUnmarshalErrors(t *testing.T) {
var malformed personalGroupMemberBody
if err := malformed.UnmarshalJSON([]byte(`{`)); err == nil {
t.Fatal("UnmarshalJSON() error = nil, want malformed object error")
}
tests := []struct {
name string
data string
}{
{name: "invalid members", data: `{"members":"invalid"}`},
{name: "invalid operator open id", data: `{"operOpenDingtalkId":{"unexpected":true}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body personalGroupMemberBody
if err := json.Unmarshal([]byte(tt.data), &body); err == nil {
t.Fatal("json.Unmarshal() error = nil, want protocol error")
}
})
}
}
func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupMemberPayloads(t *testing.T) {
tests := []struct {
name string
data string
}{
{
name: "missing conversation",
data: `{"eventKey":"user_im_group_member_added","payload":{"body":{"members":[{"nick":"测试用户甲","openDingTalkId":"member-1"}]},"event_time":1}}`,
},
{
name: "empty conversation",
data: `{"eventKey":"user_im_group_member_added","payload":{"body":{"openConversationId":" ","members":[{"nick":"测试用户甲","openDingTalkId":"member-1"}]},"event_time":1}}`,
},
{
name: "missing members",
data: `{"eventKey":"user_im_group_member_added","payload":{"body":{"openConversationId":"cid-1"},"event_time":1}}`,
},
{
name: "empty members",
data: `{"eventKey":"user_im_group_member_added","payload":{"body":{"openConversationId":"cid-1","members":[]},"event_time":1}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := transport.Event{EventID: "outer-event", EventType: EventGroupMemberAdded, Data: tt.data}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want group member validation error")
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupLifecyclePayloads(t *testing.T) {
tests := []struct {
name string
data string
}{
{name: "missing", data: `{"eventKey":"user_im_group_updated"}`},
{name: "null", data: `{"eventKey":"user_im_group_updated","payload":null}`},
{name: "empty object", data: `{"eventKey":"user_im_group_updated","payload":{}}`},
{name: "array", data: `{"eventKey":"user_im_group_updated","payload":[]}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ev := transport.Event{EventID: "outer-event", EventType: EventGroupUpdated, Data: tt.data}
projected, err := ProjectOutput(ev)
if err == nil {
t.Fatal("ProjectOutput() error = nil, want payload validation error")
}
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
}
})
}
}
@@ -333,12 +720,16 @@ func TestCrossPlatformCoverageProjectOutputRejectsEmptyPayloads(t *testing.T) {
EventSingleChat,
EventInChat,
EventFromUser,
EventAllSingleChat,
EventAllGroupChat,
EventReadO2O,
EventReadGroup,
EventRecallO2O,
EventRecallGroup,
EventReactionO2O,
EventReactionGroup,
EventGroupMemberAdded,
EventGroupMemberExited,
}
payloads := []struct {
name string
@@ -384,3 +775,34 @@ func TestCrossPlatformCoverageProjectOutputMalformedDataReturnsRawEnvelope(t *te
t.Fatalf("ProjectOutput() fallback = %#v", projected)
}
}
func TestCrossPlatformCoverageSchemaReflectionSupportsNestedArraysAndPointers(t *testing.T) {
type nested struct {
Value string `json:"value" description:"nested value" format:"nested_id"`
Hidden string `json:"-"`
}
type fixture struct {
Items []*nested `json:"items" description:"nested items"`
Meta map[string]any `json:"meta" additional_properties:"true"`
}
schema := schemaForStruct(reflect.TypeOf((*fixture)(nil)))
properties := schema["properties"].(map[string]any)
if len(properties) != 2 {
t.Fatalf("schema properties = %#v, want items and meta", properties)
}
items := properties["items"].(map[string]any)
itemSchema := items["items"].(map[string]any)
itemProperties := itemSchema["properties"].(map[string]any)
if items["type"] != "array" || itemSchema["type"] != "object" || len(itemProperties) != 1 {
t.Fatalf("nested items schema = %#v", items)
}
value := itemProperties["value"].(map[string]any)
if value["type"] != "string" || value["description"] != "nested value" || value["format"] != "nested_id" {
t.Fatalf("nested value schema = %#v", value)
}
meta := properties["meta"].(map[string]any)
if meta["type"] != "object" || meta["additionalProperties"] != true {
t.Fatalf("meta schema = %#v", meta)
}
}
+91 -12
View File
@@ -23,16 +23,22 @@ import (
)
const (
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
EventMention = "user_im_message_receive_at"
EventSingleChat = "user_im_message_receive_o2o"
EventInChat = "user_im_message_receive_group"
EventFromUser = "user_im_message_receive_user"
EventAllSingleChat = "user_im_message_receive_o2o_all"
EventAllGroupChat = "user_im_message_receive_group_all"
EventReadO2O = "user_im_message_read_o2o"
EventReadGroup = "user_im_message_read_group"
EventRecallO2O = "user_im_message_recall_o2o"
EventRecallGroup = "user_im_message_recall_group"
EventReactionO2O = "user_im_message_reaction_o2o"
EventReactionGroup = "user_im_message_reaction_group"
EventGroupUpdated = "user_im_group_updated"
EventGroupMemberAdded = "user_im_group_member_added"
EventGroupMemberExited = "user_im_group_member_exited"
EventGroupDisbanded = "user_im_group_disbanded"
)
const (
@@ -132,6 +138,28 @@ var definitions = []Definition{
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventAllSingleChat,
DisplayName: "全部单聊消息",
Description: "当前用户收到的所有单聊消息",
Category: "im",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventAllGroupChat,
DisplayName: "全部群消息",
Description: "当前用户收到的所有群聊消息",
Category: "im",
RuleType: "all",
Status: StatusEnabled,
RequiredParams: nil,
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventReadO2O,
DisplayName: "指定单聊消息已读",
@@ -201,6 +229,50 @@ var definitions = []Definition{
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventGroupUpdated,
DisplayName: "群标题变更",
Description: "指定群聊的标题发生变更",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventGroupMemberAdded,
DisplayName: "群成员加入",
Description: "指定群聊有成员加入",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventGroupMemberExited,
DisplayName: "群成员退出",
Description: "指定群聊有成员退出",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
{
EventKey: EventGroupDisbanded,
DisplayName: "群解散",
Description: "指定群聊被解散",
Category: "im",
RuleType: "group",
Status: StatusEnabled,
RequiredParams: []string{"group"},
Auth: map[string]any{"identity": "user"},
Public: true,
},
}
func targetUIDConstraints() *ParameterConstraints {
@@ -332,7 +404,7 @@ func BuildRuleParam(eventKey string, opts RuleOptions) (ruleType string, rulePar
openDingTalkID := strings.TrimSpace(opts.OpenDingTalkID)
groupID := strings.TrimSpace(opts.GroupID)
switch def.RuleType {
case "at":
case "at", "all":
if userID != "" {
return "", nil, fmt.Errorf("--user is not supported for %s", eventKey)
}
@@ -490,13 +562,20 @@ func normalizeFilterAliases(v any) any {
func isMessageReceiveEvent(eventKey string) bool {
switch eventKey {
case EventMention, EventSingleChat, EventInChat, EventFromUser:
case EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat:
return true
default:
return false
}
}
// SupportsMessageFilter reports whether --query/--filter-json describe a
// stable message payload for this event. Action and group lifecycle events do
// not expose the same filter fields.
func SupportsMessageFilter(eventKey string) bool {
return isMessageReceiveEvent(eventKey)
}
func IsSchemaPending(err error) bool {
var pending *SchemaPendingError
return errors.As(err, &pending)
+180 -4
View File
@@ -34,12 +34,18 @@ func TestCatalogEnabledEvents(t *testing.T) {
EventSingleChat,
EventInChat,
EventFromUser,
EventAllSingleChat,
EventAllGroupChat,
EventReadO2O,
EventReadGroup,
EventRecallO2O,
EventRecallGroup,
EventReactionO2O,
EventReactionGroup,
EventGroupUpdated,
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
}
if !reflect.DeepEqual(keys, want) {
t.Fatalf("keys = %#v, want %#v", keys, want)
@@ -96,7 +102,24 @@ func TestDefinitionJSONHidesInternalSchemaIDs(t *testing.T) {
}
func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
for _, eventKey := range []string{
EventMention,
EventSingleChat,
EventInChat,
EventFromUser,
EventAllSingleChat,
EventAllGroupChat,
EventReadO2O,
EventReadGroup,
EventRecallO2O,
EventRecallGroup,
EventReactionO2O,
EventReactionGroup,
EventGroupUpdated,
EventGroupMemberAdded,
EventGroupMemberExited,
EventGroupDisbanded,
} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
@@ -189,7 +212,7 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
}
func TestFlattenedSchemaDocumentsUseMessageDTO(t *testing.T) {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser} {
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
@@ -206,7 +229,7 @@ func TestFlattenedSchemaDocumentsUseMessageDTO(t *testing.T) {
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "message_id",
"conversation_id", "sender", "sender_open_dingtalk_id", "content",
"create_time", "event_time",
"create_time", "event_time", "quoted_message", "forward_messages",
}
if len(props) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d DTO fields", props, len(wantProperties))
@@ -221,6 +244,22 @@ func TestFlattenedSchemaDocumentsUseMessageDTO(t *testing.T) {
t.Fatalf("flattened schema exposed transport field %q", transportField)
}
}
quoted := props["quoted_message"].(map[string]any)
if quoted["type"] != "object" {
t.Fatalf("quoted_message schema = %#v, want object", quoted)
}
quotedProperties, ok := quoted["properties"].(map[string]any)
if !ok || len(quotedProperties) != 6 {
t.Fatalf("quoted_message properties = %#v, want six context fields", quoted["properties"])
}
forward := props["forward_messages"].(map[string]any)
if forward["type"] != "array" {
t.Fatalf("forward_messages schema = %#v, want array", forward)
}
items, ok := forward["items"].(map[string]any)
if !ok || items["type"] != "object" {
t.Fatalf("forward_messages items = %#v, want object", forward["items"])
}
})
}
}
@@ -269,6 +308,15 @@ func TestTargetUIDEventSchemasRequireUserOrOpenDingTalkID(t *testing.T) {
if group.Constraints != nil {
t.Fatalf("group constraints = %#v, want none", group.Constraints)
}
for _, eventKey := range []string{EventAllSingleChat, EventAllGroupChat} {
def, _ := Lookup(eventKey)
if len(def.RequiredParams) != 0 {
t.Fatalf("%s required_params = %#v, want none", eventKey, def.RequiredParams)
}
if def.Constraints != nil {
t.Fatalf("%s constraints = %#v, want none", eventKey, def.Constraints)
}
}
}
func TestDefinitionCopiesDoNotMutateRegistryConstraints(t *testing.T) {
@@ -353,6 +401,86 @@ func TestActionSchemaDocumentsMatchOutputDTOs(t *testing.T) {
}
}
func TestGroupLifecycleSchemaDocumentsUseConservativePayload(t *testing.T) {
wantProperties := []string{"type", "event_id", "timestamp", "subscribe_id", "payload"}
for _, eventKey := range []string{EventGroupUpdated, EventGroupDisbanded} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
if want := []string{"group"}; !reflect.DeepEqual(def.RequiredParams, want) {
t.Fatalf("required_params = %#v, want %#v", def.RequiredParams, want)
}
doc := BuildSchemaDocumentForMode(def, true)
props, ok := doc.Schema["properties"].(map[string]any)
if !ok {
t.Fatalf("schema.properties = %#v", doc.Schema["properties"])
}
if len(props) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d fields", props, len(wantProperties))
}
for _, name := range wantProperties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
payload := props["payload"].(map[string]any)
if payload["type"] != "object" || payload["additionalProperties"] != true {
t.Fatalf("schema.properties.payload = %#v, want open object", payload)
}
})
}
}
func TestGroupMemberSchemaDocumentsMatchOutputDTO(t *testing.T) {
wantProperties := []string{
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
"operator", "operator_open_dingtalk_id", "members", "event_time",
}
for _, eventKey := range []string{EventGroupMemberAdded, EventGroupMemberExited} {
t.Run(eventKey, func(t *testing.T) {
def, ok := Lookup(eventKey)
if !ok {
t.Fatalf("Lookup(%q) failed", eventKey)
}
if want := []string{"group"}; !reflect.DeepEqual(def.RequiredParams, want) {
t.Fatalf("required_params = %#v, want %#v", def.RequiredParams, want)
}
doc := BuildSchemaDocumentForMode(def, true)
props, ok := doc.Schema["properties"].(map[string]any)
if !ok || len(props) != len(wantProperties) {
t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(wantProperties))
}
for _, name := range wantProperties {
if _, ok := props[name].(map[string]any); !ok {
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
}
}
members := props["members"].(map[string]any)
if members["type"] != "array" {
t.Fatalf("schema.properties.members = %#v, want array", members)
}
items, ok := members["items"].(map[string]any)
if !ok || items["type"] != "object" {
t.Fatalf("schema.properties.members.items = %#v, want object", members["items"])
}
memberProps, ok := items["properties"].(map[string]any)
if !ok || len(memberProps) != 2 {
t.Fatalf("schema.properties.members.items.properties = %#v", items["properties"])
}
for _, name := range []string{"nick", "open_dingtalk_id"} {
if _, ok := memberProps[name].(map[string]any); !ok {
t.Fatalf("member schema missing %q: %#v", name, memberProps)
}
}
if _, ok := props["payload"]; ok {
t.Fatalf("group member schema exposed generic payload: %#v", props)
}
})
}
}
func TestBuildRuleParamMention(t *testing.T) {
rule, param, err := BuildRuleParam(EventMention, RuleOptions{})
if err != nil {
@@ -366,6 +494,29 @@ func TestBuildRuleParamMention(t *testing.T) {
}
}
func TestBuildRuleParamAllEvents(t *testing.T) {
for _, eventKey := range []string{EventAllSingleChat, EventAllGroupChat} {
t.Run(eventKey, func(t *testing.T) {
rule, param, err := BuildRuleParam(eventKey, RuleOptions{})
if err != nil {
t.Fatalf("BuildRuleParam() error = %v", err)
}
if rule != "all" || len(param) != 0 {
t.Fatalf("rule = %q, param = %#v; want all and empty map", rule, param)
}
for name, opts := range map[string]RuleOptions{
"user": {UserID: "staff-1"},
"open-dingtalk-id": {OpenDingTalkID: "open-user-1"},
"group": {GroupID: "cid-1"},
} {
if _, _, err := BuildRuleParam(eventKey, opts); err == nil || !strings.Contains(err.Error(), "--"+name+" is not supported for "+eventKey) {
t.Fatalf("%s error = %v, want unsupported flag", name, err)
}
}
})
}
}
func TestBuildRuleParamSingleChatRequiresPeer(t *testing.T) {
_, _, err := BuildRuleParam(EventSingleChat, RuleOptions{})
if err == nil || !strings.Contains(err.Error(), "one of --user or --open-dingtalk-id is required") {
@@ -469,7 +620,7 @@ func TestBuildRuleParamActionEvents(t *testing.T) {
})
}
for _, eventKey := range []string{EventReadGroup, EventRecallGroup, EventReactionGroup} {
for _, eventKey := range []string{EventReadGroup, EventRecallGroup, EventReactionGroup, EventGroupUpdated, EventGroupMemberAdded, EventGroupMemberExited, EventGroupDisbanded} {
t.Run(eventKey, func(t *testing.T) {
if _, _, err := BuildRuleParam(eventKey, RuleOptions{}); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) {
t.Fatalf("missing group error = %v", err)
@@ -533,6 +684,31 @@ func TestBuildFilterQueryAndJSON(t *testing.T) {
}
}
func TestSupportsMessageFilter(t *testing.T) {
for _, eventKey := range []string{
EventMention,
EventSingleChat,
EventInChat,
EventFromUser,
EventAllSingleChat,
EventAllGroupChat,
} {
if !SupportsMessageFilter(eventKey) {
t.Fatalf("SupportsMessageFilter(%q) = false, want true", eventKey)
}
}
for _, eventKey := range []string{
EventReadO2O,
EventReactionGroup,
EventGroupUpdated,
"unknown_event",
} {
if SupportsMessageFilter(eventKey) {
t.Fatalf("SupportsMessageFilter(%q) = true, want false", eventKey)
}
}
}
func TestIdempotencyKeyUsesLocalIdentityKey(t *testing.T) {
left := Identity{LocalSubject: "refresh:left", ClientID: "client-1", SourceID: "open"}
right := Identity{LocalSubject: "refresh:right", ClientID: "client-1", SourceID: "open"}
+46
View File
@@ -691,6 +691,52 @@ func TestPersonalSourceSenderEventPassesNormalBusFilter(t *testing.T) {
}
}
func TestPersonalSourceNewIMEventsPassNormalBusFilter(t *testing.T) {
for _, eventKey := range []string{
"user_im_message_receive_o2o_all",
"user_im_message_receive_group_all",
"user_im_group_updated",
"user_im_group_member_added",
"user_im_group_member_exited",
"user_im_group_disbanded",
} {
t.Run(eventKey, func(t *testing.T) {
subscribeID := "sub-" + eventKey
src := personalSourceForRawEventTests()
raw := src.rawEventFromDataFrame(&payload.DataFrame{
Headers: payload.DataFrameHeader{
"EVENT_TYPE": eventKey,
"SUB_ID": subscribeID,
},
Data: fmt.Sprintf(`{"eventKey":%q,"subId":%q,"payload":{"body":{"content":"test"}}}`, eventKey, subscribeID),
})
h := bus.NewHub(10)
consumer, err := h.Register(transport.Hello{
EventTypes: []string{eventKey},
SubscribeID: subscribeID,
})
if err != nil {
t.Fatal(err)
}
h.Deliver(raw)
select {
case frame := <-consumer.SendCh:
eventFrame, ok := frame.(transport.Event)
if !ok {
t.Fatalf("frame = %T, want transport.Event", frame)
}
if eventFrame.EventType != eventKey || eventFrame.SubscribeID != subscribeID {
t.Fatalf("event = %#v", eventFrame)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for filtered event")
}
})
}
}
func personalSourceForRawEventTests() *PersonalSource {
return &PersonalSource{cfg: PersonalConfig{
SourceID: "fallback_source",
+35 -11
View File
@@ -26,14 +26,16 @@ import (
type FrameType string
const (
FrameTypeHello FrameType = "hello" // consume → bus
FrameTypeHelloAck FrameType = "hello_ack" // bus → consume
FrameTypeEvent FrameType = "event" // bus → consume
FrameTypeHeartbeat FrameType = "heartbeat" // bidirectional
FrameTypeSourceState FrameType = "source_state" // bus → consume
FrameTypeBye FrameType = "bye" // bidirectional
FrameTypeStatusReq FrameType = "status_req" // consume/ad-hoc → bus
FrameTypeStatusResp FrameType = "status_resp" // bus → consume/ad-hoc
FrameTypeHello FrameType = "hello" // consume → bus
FrameTypeHelloAck FrameType = "hello_ack" // bus → consume
FrameTypeEvent FrameType = "event" // bus → consume
FrameTypeHeartbeat FrameType = "heartbeat" // bidirectional
FrameTypeSourceState FrameType = "source_state" // bus → consume
FrameTypeBye FrameType = "bye" // bidirectional
FrameTypeStatusReq FrameType = "status_req" // consume/ad-hoc → bus
FrameTypeStatusResp FrameType = "status_resp" // bus → consume/ad-hoc
FrameTypeConsumerStopReq FrameType = "consumer_stop_req" // ad-hoc → bus
FrameTypeConsumerStopResp FrameType = "consumer_stop_resp" // bus → ad-hoc
)
// Hello is the first frame a consumer sends after dialing the bus. The bus
@@ -56,9 +58,10 @@ type Hello struct {
type HelloRole string
const (
HelloRoleConsumer HelloRole = "" // default
HelloRoleStatus HelloRole = "status" // event list / event status
HelloRoleStop HelloRole = "stop" // event stop (graceful trigger)
HelloRoleConsumer HelloRole = "" // default
HelloRoleStatus HelloRole = "status" // event list / event status
HelloRoleStop HelloRole = "stop" // event stop (graceful trigger)
HelloRoleConsumerStop HelloRole = "consumer_stop" // stop selected consumers only
)
// HelloAck is the bus's reply on accepted Hello. SourceState/StateSource
@@ -117,11 +120,32 @@ type SourceState struct {
// "shutdown" — bus SIGTERM/SIGINT
// "idle_timeout" — bus IdleTimeout fired with no consumers
// "stop_request" — bus received explicit Stop RPC
// "subscription_stopped" — one consumer was removed by subscribe_id
type Bye struct {
Type FrameType `json:"type"`
Reason string `json:"reason"`
}
const ByeReasonSubscriptionStopped = "subscription_stopped"
// ConsumerStopReq asks the bus to close consumers whose exact personal
// subscription IDs match. It is an additive local IPC control operation;
// the remote Stream connection remains alive while other consumers exist.
type ConsumerStopReq struct {
Type FrameType `json:"type"`
SubscribeIDs []string `json:"subscribe_ids"`
}
// ConsumerStopResp reports which requested subscriptions had a live local
// consumer. Missing IDs are not errors because a server subscription can be
// cancelled after its foreground consumer has already exited.
type ConsumerStopResp struct {
Type FrameType `json:"type"`
Stopped []string `json:"stopped,omitempty"`
NotFound []string `json:"not_found,omitempty"`
Error string `json:"error,omitempty"`
}
// StatusReq is an empty JSON frame ad-hoc tooling sends after Hello to
// request a full StatusResp. Bus replies with one StatusResp then closes
// the connection.
+47 -11
View File
@@ -28,14 +28,16 @@ func TestFrameType_StableWireValues(t *testing.T) {
// change. The test value list is duplicated here on purpose so a
// reviewer renaming a constant is forced to also update the test.
wants := map[FrameType]string{
FrameTypeHello: "hello",
FrameTypeHelloAck: "hello_ack",
FrameTypeEvent: "event",
FrameTypeHeartbeat: "heartbeat",
FrameTypeSourceState: "source_state",
FrameTypeBye: "bye",
FrameTypeStatusReq: "status_req",
FrameTypeStatusResp: "status_resp",
FrameTypeHello: "hello",
FrameTypeHelloAck: "hello_ack",
FrameTypeEvent: "event",
FrameTypeHeartbeat: "heartbeat",
FrameTypeSourceState: "source_state",
FrameTypeBye: "bye",
FrameTypeStatusReq: "status_req",
FrameTypeStatusResp: "status_resp",
FrameTypeConsumerStopReq: "consumer_stop_req",
FrameTypeConsumerStopResp: "consumer_stop_resp",
}
for ft, want := range wants {
if string(ft) != want {
@@ -46,9 +48,10 @@ func TestFrameType_StableWireValues(t *testing.T) {
func TestHelloRole_StableWireValues(t *testing.T) {
wants := map[HelloRole]string{
HelloRoleConsumer: "",
HelloRoleStatus: "status",
HelloRoleStop: "stop",
HelloRoleConsumer: "",
HelloRoleStatus: "status",
HelloRoleStop: "stop",
HelloRoleConsumerStop: "consumer_stop",
}
for r, want := range wants {
if string(r) != want {
@@ -57,6 +60,39 @@ func TestHelloRole_StableWireValues(t *testing.T) {
}
}
func TestConsumerStopFrames_Roundtrip(t *testing.T) {
inReq := ConsumerStopReq{
Type: FrameTypeConsumerStopReq,
SubscribeIDs: []string{"sub-a", "sub-b"},
}
var outReq ConsumerStopReq
roundTrip(t, inReq, &outReq)
if outReq.Type != FrameTypeConsumerStopReq || strings.Join(outReq.SubscribeIDs, ",") != "sub-a,sub-b" {
t.Fatalf("request roundtrip = %#v", outReq)
}
inResp := ConsumerStopResp{
Type: FrameTypeConsumerStopResp,
Stopped: []string{"sub-a"},
NotFound: []string{"sub-b"},
}
var outResp ConsumerStopResp
roundTrip(t, inResp, &outResp)
if outResp.Type != FrameTypeConsumerStopResp || strings.Join(outResp.Stopped, ",") != "sub-a" || strings.Join(outResp.NotFound, ",") != "sub-b" {
t.Fatalf("response roundtrip = %#v", outResp)
}
inError := ConsumerStopResp{
Type: FrameTypeConsumerStopResp,
Error: "malformed consumer stop request",
}
var outError ConsumerStopResp
roundTrip(t, inError, &outError)
if outError.Type != FrameTypeConsumerStopResp || outError.Error != inError.Error {
t.Fatalf("error response roundtrip = %#v", outError)
}
}
func roundTrip(t *testing.T, in any, dst any) {
t.Helper()
b, err := json.Marshal(in)
+285
View File
@@ -0,0 +1,285 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package helpers
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
"github.com/spf13/cobra"
)
type contractDefectCaller struct {
dryRun bool
calls []guardedMutationCall
responses map[string]string
errors map[string]error
}
func (c *contractDefectCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, guardedMutationCall{productID: productID, toolName: toolName, args: args})
key := productID + "/" + toolName
if err := c.errors[key]; err != nil {
return nil, err
}
text := c.responses[key]
if text == "" {
text = `{}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func (*contractDefectCaller) Format() string { return "json" }
func (c *contractDefectCaller) DryRun() bool { return c.dryRun }
func (*contractDefectCaller) Fields() string { return "" }
func (*contractDefectCaller) JQ() string { return "" }
func executeContractDefectCommand(t *testing.T, caller *contractDefectCaller, build func() *cobra.Command, args ...string) (string, error) {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
InitDeps(caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = io.Discard
root := build()
if root.PersistentFlags().Lookup("yes") == nil {
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
}
if root.PersistentFlags().Lookup("dry-run") == nil {
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
}
root.SilenceErrors = true
root.SilenceUsage = true
root.SetArgs(args)
err := root.Execute()
return stdout.String(), err
}
func TestApprovalRevokeDryRunSkipsConfirmationAndEmitsPreview(t *testing.T) {
caller := &contractDefectCaller{dryRun: true}
output, err := executeContractDefectCommand(t, caller, newOaCommand,
"approval", "revoke", "--instance-id", "instance-dry-run", "--dry-run")
if err != nil {
t.Fatalf("approval revoke dry-run returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", caller.calls)
}
if !strings.Contains(output, `"tool": "revoke_processInstance"`) ||
!strings.Contains(output, `"processInstanceId": "instance-dry-run"`) {
t.Fatalf("dry-run output = %q, want revoke preview", output)
}
}
func TestDocVersionRevertDryRunSkipsRemotePreflightAndEmitsPreview(t *testing.T) {
caller := &contractDefectCaller{dryRun: true}
output, err := executeContractDefectCommand(t, caller, newDocCommand,
"version", "revert", "--node", "node-dry-run", "--version", "7", "--dry-run")
if err != nil {
t.Fatalf("doc version revert dry-run returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", caller.calls)
}
if !strings.Contains(output, `"tool": "revert_doc_version"`) ||
!strings.Contains(output, `"version": 7`) {
t.Fatalf("dry-run output = %q, want version revert preview", output)
}
}
func TestDocRenamePreservesCallerProvidedDisplayName(t *testing.T) {
renameCmd, remaining, err := newDocCommand().Find([]string{"rename"})
if err != nil || len(remaining) != 0 {
t.Fatalf("find doc rename: command=%v remaining=%v err=%v", renameCmd, remaining, err)
}
nameFlag := renameCmd.Flags().Lookup("name")
if nameFlag == nil {
t.Fatal("doc rename --name flag is missing")
}
if !strings.Contains(nameFlag.Usage, "原样传给服务端") ||
!strings.Contains(nameFlag.Usage, "drive rename") ||
strings.Contains(nameFlag.Usage, "自动去掉") {
t.Fatalf("doc rename --name usage = %q, want verbatim forwarding and drive rename routing", nameFlag.Usage)
}
caller := &contractDefectCaller{}
if _, err = executeContractDefectCommand(t, caller, newDocCommand,
"rename", "--node", "node-1", "--name", "release.v2"); err != nil {
t.Fatalf("doc rename returned error: %v", err)
}
if len(caller.calls) != 1 {
t.Fatalf("tool calls = %#v, want one rename call", caller.calls)
}
if got := caller.calls[0].args["newName"]; got != "release.v2" {
t.Fatalf("newName = %#v, want release.v2", got)
}
}
func TestDriveRenameUsesNodeTypeAndCurrentExtension(t *testing.T) {
tests := []struct {
name string
metadata string
nodeID string
wantFileID string
requested string
wantNewName string
}{
{
name: "file strips matching extension outside old whitelist",
metadata: `{"result":{"type":"file","extension":"heic"}}`,
nodeID: "https://alidocs.dingtalk.com/i/nodes/node-1",
wantFileID: "node-1",
requested: "photo.HEIC",
wantNewName: "photo",
},
{
name: "folder preserves dotted display name",
metadata: `{"result":{"type":"folder","extension":"v2"}}`,
requested: "release.v2",
wantNewName: "release.v2",
},
{
name: "different current extension is preserved",
metadata: `{"result":{"nodeType":"file","fileExtension":"txt"}}`,
requested: "report.final",
wantNewName: "report.final",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &contractDefectCaller{responses: map[string]string{
"drive/get_file_info": test.metadata,
}}
nodeID := test.nodeID
if nodeID == "" {
nodeID = "node-1"
}
if _, err := executeContractDefectCommand(t, caller, newDriveCommand,
"rename", "--node", nodeID, "--name", test.requested); err != nil {
t.Fatalf("drive rename returned error: %v", err)
}
if len(caller.calls) != 2 {
t.Fatalf("tool calls = %#v, want metadata read plus rename", caller.calls)
}
if caller.calls[0].productID != "drive" || caller.calls[0].toolName != "get_file_info" {
t.Fatalf("first call = %#v, want drive/get_file_info", caller.calls[0])
}
wantFileID := test.wantFileID
if wantFileID == "" {
wantFileID = nodeID
}
if got := caller.calls[0].args["fileId"]; got != wantFileID {
t.Fatalf("metadata fileId = %#v, want %q", got, wantFileID)
}
if caller.calls[1].productID != "doc" || caller.calls[1].toolName != "rename_document" {
t.Fatalf("second call = %#v, want doc/rename_document", caller.calls[1])
}
if got := caller.calls[1].args["newName"]; got != test.wantNewName {
t.Fatalf("newName = %#v, want %q", got, test.wantNewName)
}
})
}
}
func TestDriveRenameMetadataFailuresDoNotMutate(t *testing.T) {
tests := []struct {
name string
responses map[string]string
errors map[string]error
}{
{
name: "metadata call error",
errors: map[string]error{"drive/get_file_info": errors.New("metadata unavailable")},
},
{
name: "invalid metadata JSON",
responses: map[string]string{"drive/get_file_info": `{`},
},
{
name: "metadata result missing",
responses: map[string]string{"drive/get_file_info": `{}`},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller := &contractDefectCaller{responses: test.responses, errors: test.errors}
_, err := executeContractDefectCommand(t, caller, newDriveCommand,
"rename", "--node", "node-1", "--name", "report.txt")
if err == nil {
t.Fatal("drive rename returned nil error")
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_file_info" {
t.Fatalf("tool calls = %#v, want metadata read only", caller.calls)
}
})
}
}
func TestDriveRenameDryRunDefersMetadataNormalization(t *testing.T) {
caller := &contractDefectCaller{dryRun: true}
output, err := executeContractDefectCommand(t, caller, newDriveCommand,
"rename", "--node", "node-1", "--name", "photo.heic", "--dry-run")
if err != nil {
t.Fatalf("drive rename dry-run returned error: %v", err)
}
if len(caller.calls) != 0 {
t.Fatalf("dry-run tool calls = %#v, want none", caller.calls)
}
if !strings.Contains(output, `"newName": "photo.heic"`) {
t.Fatalf("dry-run output = %q, want unmodified input name", output)
}
}
func TestDriveRenameBaseNameConservativeEdges(t *testing.T) {
tests := []struct {
name string
nodeType string
extension string
want string
}{
{name: ".txt", nodeType: "file", extension: "txt", want: ".txt"},
{name: "report.txt", nodeType: "directory", extension: "txt", want: "report.txt"},
{name: "report.txt", nodeType: "dir", extension: "txt", want: "report.txt"},
{name: " report.txt ", nodeType: "file", extension: "", want: "report.txt"},
{name: "report.txt", nodeType: "file", extension: ".txt", want: "report"},
}
for _, test := range tests {
if got := driveRenameBaseName(test.name, test.nodeType, test.extension); got != test.want {
t.Fatalf("driveRenameBaseName(%q, %q, %q) = %q, want %q",
test.name, test.nodeType, test.extension, got, test.want)
}
}
}
func TestDriveInfoRestoresFileSizeWhenDocMetadataIsNull(t *testing.T) {
caller := &contractDefectCaller{responses: map[string]string{
"drive/get_file_info": `{"result":{"fileId":"node-1","extension":"adoc","fileSize":93682}}`,
"doc/get_document_info": `{"result":{"title":"Doc","fileSize":null}}`,
}}
output, err := executeContractDefectCommand(t, caller, newDriveCommand,
"info", "--node", "node-1")
if err != nil {
t.Fatalf("drive info returned error: %v", err)
}
var payload struct {
Result struct {
FileSize int64 `json:"fileSize"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(output), &payload); err != nil {
t.Fatalf("decode drive info output %q: %v", output, err)
}
if payload.Result.FileSize != 93682 {
t.Fatalf("fileSize = %d, want 93682", payload.Result.FileSize)
}
}
+13 -13
View File
@@ -925,9 +925,7 @@ func newDocCommand() *cobra.Command {
if err != nil {
return err
}
return callMCPTool("get_document_info", map[string]any{
"nodeId": nodeID,
})
return callMCPTool("get_document_info", map[string]any{"nodeId": nodeID})
},
}
@@ -1740,7 +1738,7 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
// rename
renameCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
renameCmd.Flags().String("name", "", "新名称 (必填)")
renameCmd.Flags().String("name", "", "新名称 (必填;原样传给服务端,不做扩展名规范化;如需根据节点类型和当前后缀规范化,请使用 drive rename)")
// delete
deleteCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
@@ -2709,15 +2707,17 @@ CLI 内部自动完成全部流程:
return fmt.Errorf("flag --version is required")
}
version, _ := cmd.Flags().GetInt("version")
exists, err := docVersionExists(cmd.Context(), nodeID, version)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("文档版本 %d 不存在,已停止回滚;请先执行 dws doc version list --node %s --format json 获取可回滚版本", version, nodeID)
}
if !confirmDangerousAction(cmd, fmt.Sprintf("revert document to version %d", version), nodeID) {
return nil
if !commandDryRun(cmd) {
exists, err := docVersionExists(cmd.Context(), nodeID, version)
if err != nil {
return err
}
if !exists {
return fmt.Errorf("文档版本 %d 不存在,已停止回滚;请先执行 dws doc version list --node %s --format json 获取可回滚版本", version, nodeID)
}
if !confirmDangerousAction(cmd, fmt.Sprintf("revert document to version %d", version), nodeID) {
return nil
}
}
return callMCPToolOnServer("doc", "revert_doc_version", map[string]any{
"nodeId": nodeID,
+60 -5
View File
@@ -19,6 +19,51 @@ import (
// get_upload_info, commit_upload
// ──────────────────────────────────────────────────────────
func driveRenameBaseName(name, nodeType, currentExtension string) string {
trimmed := strings.TrimSpace(name)
switch strings.ToLower(strings.TrimSpace(nodeType)) {
case "folder", "dir", "directory":
return trimmed
}
extension := strings.TrimLeft(strings.TrimSpace(currentExtension), ".")
if extension == "" {
return trimmed
}
suffix := "." + extension
if len(trimmed) <= len(suffix) || !strings.EqualFold(trimmed[len(trimmed)-len(suffix):], suffix) {
return trimmed
}
return trimmed[:len(trimmed)-len(suffix)]
}
func resolveDriveRenameName(ctx context.Context, nodeID, name string) (string, error) {
fileID := nodeID
if parsedNodeID := extractNodeIDFromDocURL(nodeID); parsedNodeID != "" {
fileID = parsedNodeID
}
text, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", map[string]any{
"fileId": fileID,
})
if err != nil {
return "", fmt.Errorf("无法读取节点元数据,未执行重命名: %w", err)
}
var response map[string]any
if err := json.Unmarshal([]byte(text), &response); err != nil {
return "", fmt.Errorf("无法解析节点元数据,未执行重命名: %w", err)
}
result, ok := response["result"].(map[string]any)
if !ok {
return "", fmt.Errorf("节点元数据缺少 result,未执行重命名")
}
return driveRenameBaseName(
name,
firstStringField(result, "type", "nodeType", "fileType"),
firstStringField(result, "extension", "fileExtension", "ext"),
), nil
}
func runDriveUpload(cmd *cobra.Command, _ []string) error {
filePath := mustGetFlag(cmd, "file")
if filePath == "" {
@@ -834,7 +879,9 @@ func newDriveCommand() *cobra.Command {
driveRenameCmd := &cobra.Command{
Use: "rename",
Short: "重命名文件/文档",
Long: `修改文档空间中文档或文件的名称。
Long: `修改文档空间中文档、文件或文件夹的名称。
实际执行前会读取节点类型和当前扩展名:文件的新名称仅在末尾扩展名与当前扩展名一致时去掉一层,
避免 report.txt.txt;文件夹和扩展名不匹配的名称保持不变。dry-run 不读取远端元数据,因此保留输入名称。
权限要求: 对文档有"编辑"权限。`,
Example: ` dws drive rename --node DOC_ID --name "新名称"`,
@@ -846,14 +893,21 @@ func newDriveCommand() *cobra.Command {
if err != nil {
return err
}
newName := flagOrFallback(cmd, "name", "title")
if !commandDryRun(cmd) {
newName, err = resolveDriveRenameName(cmd.Context(), nodeID, newName)
if err != nil {
return err
}
}
return callMCPToolOnServer("doc", "rename_document", map[string]any{
"nodeId": nodeID,
"newName": flagOrFallback(cmd, "name", "title"),
"newName": newName,
})
},
}
driveRenameCmd.Flags().String("node", "", "文档/文件 ID 或 URL (必填)")
driveRenameCmd.Flags().String("name", "", "新名称 (必填)")
driveRenameCmd.Flags().String("name", "", "新名称 (必填;实际执行时仅去掉与节点当前扩展名完全匹配的一层后缀)")
driveStatsCmd := &cobra.Command{
Use: "stats",
@@ -1383,7 +1437,7 @@ func driveInfoWithDocFallback(fileID string, driveArgs map[string]any) error {
ctx := context.Background()
// Step 1: 调用 drive get_file_info
driveText, err := callMCPToolReturnText(ctx, "get_file_info", driveArgs)
driveText, err := callMCPToolReturnTextOnServer(ctx, "drive", "get_file_info", driveArgs)
if err != nil {
return err
}
@@ -1444,7 +1498,8 @@ func driveInfoWithDocFallback(fileID string, driveArgs map[string]any) error {
driveOnlyFields := []string{"dentryId", "path", "fileSize", "extension", "type"}
for _, field := range driveOnlyFields {
if val, ok := driveResult[field]; ok {
if _, exists := docResult[field]; !exists {
current, exists := docResult[field]
if (!exists || current == nil) && val != nil {
docResult[field] = val
}
}
+1 -1
View File
@@ -129,7 +129,7 @@ func newOaCommand() *cobra.Command {
return err
}
instanceId := mustGetFlag(cmd, "instance-id")
if !confirmDelete("审批实例", instanceId) {
if !commandDryRun(cmd) && !confirmDelete("审批实例", instanceId) {
return nil
}
argsMap := map[string]any{
+91 -24
View File
@@ -282,6 +282,10 @@ func newTodoCommand() *cobra.Command {
todoTaskGetCmd := &cobra.Command{
Use: "get",
Short: "待办详情",
Long: `查询单条待办的详情。
当前上游详情接口不返回 reminderRules;本命令不能读取或验证 add-reminder /
reset-reminder 写入的提醒规则。提醒写命令的成功响应只能作为写入回执。`,
Example: ` dws todo task get --task-id <taskId>
# 查询 taskId: dws todo task list`,
@@ -427,6 +431,10 @@ func newTodoCommand() *cobra.Command {
todoTaskAddReminderCmd := &cobra.Command{
Use: "add-reminder",
Short: "添加待办提醒",
Long: `为已有待办写入一条提醒规则。
当前上游没有提醒规则查询接口,task get/list 也不返回 reminderRules;
成功响应是写入回执,不代表 CLI 能再次读取并核验该规则。`,
Example: ` dws todo task add-reminder --task-id <taskId> --base-time dueTime --due-date-offset -30
dws todo task add-reminder --task-id <taskId> --base-time customTime --reminder-time-stamp 2026-03-10T18:00:00+08:00
# 查询 taskId: dws todo task list
@@ -473,6 +481,11 @@ func newTodoCommand() *cobra.Command {
todoTaskUpdateReminderCmd := &cobra.Command{
Use: "reset-reminder",
Short: "重置待办提醒",
Long: `整体替换待办提醒规则;不传 --reminder-rules 时清除提醒。显式传值必须是合法 JSON 数组,
每条规则必须按 baseTime 提供 dueDateOffset 或 reminderTimeStamp;非法输入会在远端调用前失败。
当前上游没有提醒规则查询接口,task get/list 也不返回 reminderRules;
成功响应是写入回执,不代表 CLI 能再次读取并核验最终规则。`,
Example: ` dws todo task reset-reminder --task-id <taskId>
dws todo task reset-reminder --task-id <taskId> --reminder-rules <reminderRules>
# 查询 taskId: dws todo task list
@@ -481,28 +494,14 @@ func newTodoCommand() *cobra.Command {
if err := validateRequiredFlags(cmd, "task-id"); err != nil {
return err
}
var reminderRules []any
if v, _ := cmd.Flags().GetString("reminder-rules"); v != "" {
var att []any
if err := json.Unmarshal([]byte(v), &att); err == nil && len(att) > 0 {
for i, item := range att {
m, ok := item.(map[string]any)
if !ok {
continue
}
if m["baseTime"] == "customTime" {
if ts, ok := m["reminderTimeStamp"].(string); ok && ts != "" {
ms, err := parseISOTimeToMillis("reminderTimeStamp", ts)
if err != nil {
return err
}
m["reminderTimeStamp"] = ms
att[i] = m
}
}
}
reminderRules = att
var reminderRules []map[string]any
if cmd.Flags().Changed("reminder-rules") {
value, _ := cmd.Flags().GetString("reminder-rules")
parsedRules, err := parseTodoReminderRules(value)
if err != nil {
return err
}
reminderRules = parsedRules
}
return callMCPTool("reset_todo_reminder", map[string]any{
"todoReminderUpdateRequest": map[string]any{
@@ -674,7 +673,7 @@ func newTodoCommand() *cobra.Command {
todoTaskAddReminderCmd.Flags().String("reminder-time-stamp", "", "自定义提醒时间 ISO-8601 (如 2026-03-10T18:00:00+08:00,baseTime=customTime 时必填)")
todoTaskUpdateReminderCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskUpdateReminderCmd.Flags().String("reminder-rules", "", "提醒规则 JSON 数组 (可选,为空则清除提醒)")
todoTaskUpdateReminderCmd.Flags().String("reminder-rules", "", "提醒规则 JSON 数组 (不传则清除;显式传值必须合法)")
todoTaskListSubCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskListAttachmentCmd.Flags().String("task-id", "", "待办任务 ID (必填)")
todoTaskAddAttachment.Flags().String("task-id", "", "待办任务 ID (必填)")
@@ -922,13 +921,81 @@ func rejectUnsupportedTodoReminderFlags(cmd *cobra.Command) error {
return nil
}
return &CLIError{
Code: CodeMissingParam,
Code: CodeInvalidParam,
Message: fmt.Sprintf("todo 当前不支持独立 reminder 参数: %s", strings.Join(unsupported, ", ")),
Suggestion: "请使用 --due 表示截止时间;如果用户要的是精确提醒时间,请明确说明该能力当前不支持,而不是把 --due 当作 reminder。",
Suggestion: "需要截止时间请改用 --due;需要独立提醒请用 dws todo task add-reminder。dueTime 模式要求待办已有截止时间,customTime 模式直接传 --reminder-time-stamp,不必先设置 --due。当前上游不支持读取提醒规则,不能用 task get/list 验证写入结果。",
Operation: "todo.task.reminder",
}
}
func parseTodoReminderRules(raw string) ([]map[string]any, error) {
value := strings.TrimSpace(raw)
if value == "" {
return nil, invalidTodoReminderRules(CodeInvalidParam, "显式传入的 --reminder-rules 不能为空;清除提醒请省略该参数或传 []", nil)
}
if !json.Valid([]byte(value)) {
return nil, invalidTodoReminderRules(CodeInvalidJSON, "--reminder-rules 必须是合法 JSON 数组", nil)
}
var rules []map[string]any
if err := unmarshalJSONUseNumber(value, &rules); err != nil {
return nil, invalidTodoReminderRules(CodeInvalidParam, "--reminder-rules 必须是由对象组成的 JSON 数组", err)
}
if rules == nil {
return nil, invalidTodoReminderRules(CodeInvalidParam, "--reminder-rules 不能是 null;清除提醒请省略该参数或传 []", nil)
}
for i, rule := range rules {
position := i + 1
if rule == nil {
return nil, invalidTodoReminderRules(CodeInvalidParam, fmt.Sprintf("--reminder-rules 第 %d 条必须是对象", position), nil)
}
baseTime, ok := rule["baseTime"].(string)
baseTime = strings.TrimSpace(baseTime)
if !ok || baseTime == "" {
return nil, invalidTodoReminderRules(CodeInvalidParam, fmt.Sprintf("--reminder-rules 第 %d 条缺少字符串 baseTime", position), nil)
}
rule["baseTime"] = baseTime
switch baseTime {
case "dueTime":
offset, ok := rule["dueDateOffset"].(json.Number)
if !ok {
return nil, invalidTodoReminderRules(CodeInvalidParam, fmt.Sprintf("--reminder-rules 第 %d 条在 baseTime=dueTime 时必须提供整数 dueDateOffset", position), nil)
}
parsed, err := strconv.ParseInt(offset.String(), 10, 64)
if err != nil {
return nil, invalidTodoReminderRules(CodeInvalidParam, fmt.Sprintf("--reminder-rules 第 %d 条的 dueDateOffset 必须是整数", position), err)
}
rule["dueDateOffset"] = parsed
case "customTime":
timestamp, ok := rule["reminderTimeStamp"].(string)
timestamp = strings.TrimSpace(timestamp)
if !ok || timestamp == "" {
return nil, invalidTodoReminderRules(CodeInvalidParam, fmt.Sprintf("--reminder-rules 第 %d 条在 baseTime=customTime 时必须提供 ISO8601 字符串 reminderTimeStamp", position), nil)
}
parsed, err := parseISOTimeToMillis("reminderTimeStamp", timestamp)
if err != nil {
return nil, invalidTodoReminderRules(CodeInvalidParam, fmt.Sprintf("--reminder-rules 第 %d 条的 reminderTimeStamp 无效", position), err)
}
rule["reminderTimeStamp"] = parsed
default:
return nil, invalidTodoReminderRules(CodeInvalidParam, fmt.Sprintf("--reminder-rules 第 %d 条的 baseTime 必须是 dueTime 或 customTime", position), nil)
}
}
return rules, nil
}
func invalidTodoReminderRules(code, message string, cause error) error {
return &CLIError{
Code: code,
Message: message,
Suggestion: "使用 dueTime + 整数 dueDateOffset,或 customTime + ISO8601 reminderTimeStamp;清除提醒请省略 --reminder-rules 或传 []。",
Operation: "todo.task.reset-reminder.reminder-rules",
Cause: cause,
}
}
// todoListAutoPage 当 size > 20 时自动分页请求并合并结果。pageStr 为起始页码,wantSize 为期望条数。
func todoListAutoPage(cmd *cobra.Command, pageStr string, wantSize int) error {
ctx := context.Background()
@@ -137,6 +137,8 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
{"task", "update", "--task-id", "1", "--due", "bad"},
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", "bad"},
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[{"baseTime":"customTime","reminderTimeStamp":"bad"}]`},
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `not-json`},
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[1,{"baseTime":"dueTime"},{"baseTime":"customTime","reminderTimeStamp":"` + validDate + `"}]`},
}
for _, args := range errorCases {
if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err == nil {
@@ -148,8 +150,6 @@ func TestCrossPlatformCoverageTodoUpdateReminderAndDetailEdges(t *testing.T) {
{"task", "update", "--task-id", "1", "--priority", "bad"},
{"task", "add-reminder", "--task-id", "1", "--base-time", "dueTime", "--due-date-offset", "-10"},
{"task", "add-reminder", "--task-id", "1", "--base-time", "customTime", "--reminder-time-stamp", validDate},
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `not-json`},
{"task", "reset-reminder", "--task-id", "1", "--reminder-rules", `[1,{"baseTime":"dueTime"},{"baseTime":"customTime","reminderTimeStamp":"` + validDate + `"}]`},
}
for _, args := range validCases {
if err := executeTodoEdge(t, &scriptedToolCaller{}, args...); err != nil {
+115 -1
View File
@@ -33,6 +33,10 @@ func (*todoReminderCaller) Fields() string { return "" }
func (*todoReminderCaller) JQ() string { return "" }
func runTodoReminderCommand(t *testing.T, args ...string) (*todoReminderCaller, error) {
return runTodoTaskCommandForReminderTests(t, "add-reminder", args...)
}
func runTodoTaskCommandForReminderTests(t *testing.T, leaf string, args ...string) (*todoReminderCaller, error) {
t.Helper()
previousDeps := deps
t.Cleanup(func() { deps = previousDeps })
@@ -44,7 +48,7 @@ func runTodoReminderCommand(t *testing.T, args ...string) (*todoReminderCaller,
cmd := newTodoCommand()
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append([]string{"task", "add-reminder"}, args...))
cmd.SetArgs(append([]string{"task", leaf}, args...))
return caller, cmd.Execute()
}
@@ -137,6 +141,116 @@ func TestTodoAddReminderMapsReviewedModes(t *testing.T) {
}
}
func TestTodoResetReminderRejectsInvalidNonEmptyRulesBeforeCallingTool(t *testing.T) {
tests := []struct {
name string
value string
wantErr string
}{
{name: "explicit empty", value: "", wantErr: "不能为空"},
{name: "whitespace", value: " ", wantErr: "不能为空"},
{name: "malformed JSON", value: `[`, wantErr: "合法 JSON 数组"},
{name: "object instead of array", value: `{}`, wantErr: "由对象组成的 JSON 数组"},
{name: "scalar item", value: `[1]`, wantErr: "由对象组成的 JSON 数组"},
{name: "null array", value: `null`, wantErr: "不能是 null"},
{name: "null item", value: `[null]`, wantErr: "第 1 条必须是对象"},
{name: "missing base time", value: `[{}]`, wantErr: "缺少字符串 baseTime"},
{name: "non-string base time", value: `[{"baseTime":1}]`, wantErr: "缺少字符串 baseTime"},
{name: "due time missing offset", value: `[{"baseTime":"dueTime"}]`, wantErr: "必须提供整数 dueDateOffset"},
{name: "due time string offset", value: `[{"baseTime":"dueTime","dueDateOffset":"-30"}]`, wantErr: "必须提供整数 dueDateOffset"},
{name: "due time fractional offset", value: `[{"baseTime":"dueTime","dueDateOffset":-1.5}]`, wantErr: "dueDateOffset 必须是整数"},
{name: "custom time missing timestamp", value: `[{"baseTime":"customTime"}]`, wantErr: "必须提供 ISO8601 字符串"},
{name: "custom time numeric timestamp", value: `[{"baseTime":"customTime","reminderTimeStamp":1}]`, wantErr: "必须提供 ISO8601 字符串"},
{name: "custom time invalid timestamp", value: `[{"baseTime":"customTime","reminderTimeStamp":"tomorrow"}]`, wantErr: "reminderTimeStamp 无效"},
{name: "unknown base time", value: `[{"baseTime":"deadline"}]`, wantErr: "baseTime 必须是 dueTime 或 customTime"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller, err := runTodoTaskCommandForReminderTests(t, "reset-reminder",
"--task-id", "task-smoke", "--reminder-rules", test.value)
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
})
}
}
func TestTodoResetReminderMapsValidatedRulesAndExplicitClears(t *testing.T) {
tests := []struct {
name string
args []string
wantRules []map[string]any
}{
{
name: "omitted rules clear with null",
args: []string{"--task-id", "task-smoke"},
wantRules: nil,
},
{
name: "explicit empty array clears with array",
args: []string{"--task-id", "task-smoke", "--reminder-rules", `[]`},
wantRules: []map[string]any{},
},
{
name: "valid rules are normalized",
args: []string{
"--task-id", "task-smoke",
"--reminder-rules",
`[{"baseTime":"dueTime","dueDateOffset":-30},{"baseTime":"customTime","reminderTimeStamp":"2026-03-10T18:00:00+08:00","label":"keep"}]`,
},
wantRules: []map[string]any{
{"baseTime": "dueTime", "dueDateOffset": int64(-30)},
{"baseTime": "customTime", "reminderTimeStamp": int64(1773136800000), "label": "keep"},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
caller, err := runTodoTaskCommandForReminderTests(t, "reset-reminder", test.args...)
if err != nil {
t.Fatalf("todo reset-reminder returned error: %v", err)
}
if len(caller.calls) != 1 || caller.calls[0].toolName != "reset_todo_reminder" {
t.Fatalf("tool calls = %#v, want one reset_todo_reminder call", caller.calls)
}
request, ok := caller.calls[0].args["todoReminderUpdateRequest"].(map[string]any)
if !ok {
t.Fatalf("request = %#v, want object", caller.calls[0].args["todoReminderUpdateRequest"])
}
if request["taskId"] != "task-smoke" {
t.Fatalf("taskId = %#v, want task-smoke", request["taskId"])
}
rules, ok := request["reminderRules"].([]map[string]any)
if !ok {
t.Fatalf("reminderRules = %#v, want []map[string]any", request["reminderRules"])
}
if !reflect.DeepEqual(rules, test.wantRules) {
t.Fatalf("reminderRules = %#v, want %#v", rules, test.wantRules)
}
})
}
}
func TestUnsupportedRemindAtGuidanceDoesNotRequireDueForCustomTime(t *testing.T) {
caller, err := runTodoTaskCommandForReminderTests(t, "create",
"--title", "x", "--executors", "user-1", "--remind-at", "2026-03-10T18:00:00+08:00")
if err == nil {
t.Fatal("todo create --remind-at returned nil error")
}
if !strings.Contains(err.Error(), "customTime") ||
!strings.Contains(err.Error(), "不必先设置 --due") {
t.Fatalf("error = %v, want customTime guidance without due requirement", err)
}
if len(caller.calls) != 0 {
t.Fatalf("tool calls = %#v, want none", caller.calls)
}
}
func TestTodoRoleTypesCSVExampleUsesRuntimeParser(t *testing.T) {
got, err := parseRoleTypes("creator,executor")
if err != nil {
+11 -2
View File
@@ -730,15 +730,24 @@ var MessagesResourceURL = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Default: "mediaId", Desc: "资源类型", Enum: []string{"mediaId"}},
{Name: "resource-id", Type: shortcut.FlagString, Desc: "资源 ID(消息中的 mediaId)", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId", Required: true},
{Name: "message-id", Type: shortcut.FlagString, Desc: "消息 openMessageId"},
{Name: "msg-id", Type: shortcut.FlagString, Desc: "--message-id 的别名", Hidden: true},
{Name: "open-message-id", Type: shortcut.FlagString, Desc: "--message-id 的别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "会话 openConversationId", Required: true},
},
// message-id is required, but accept the natural aliases agents reach for
// (the message-list output field is openMessageId/msgId). Declared via a
// constraint rather than Required because a shortcut's Required check only
// looks at the primary flag name, so a hidden alias could not satisfy it.
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"message-id", "msg-id", "open-message-id"}},
},
Tips: []string{`dws chat +messages-resource-url --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId>`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("get_resource_download_url", map[string]any{
"resourceType": rt.Str("type"),
"resourceId": rt.Str("resource-id"),
"openMessageId": rt.Str("message-id"),
"openMessageId": rt.StrFirst("message-id", "msg-id", "open-message-id"),
"openConversationId": rt.Str("open-conversation-id"),
})
},
@@ -103,6 +103,36 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
wantTool: "query_msg_read_status",
wantArgs: map[string]any{"openConversationId": "cid-1"},
},
{
name: "message resource url msg id alias",
argv: []string{
"chat", "+messages-resource-url", "--resource-id", "resource-1",
"--msg-id", "msg-1", "--open-conversation-id", "cid-1",
},
wantProduct: "im",
wantTool: "get_resource_download_url",
wantArgs: map[string]any{
"resourceType": "mediaId",
"resourceId": "resource-1",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
},
},
{
name: "message resource url open message id alias",
argv: []string{
"chat", "+messages-resource-url", "--resource-id", "resource-1",
"--open-message-id", "msg-1", "--open-conversation-id", "cid-1",
},
wantProduct: "im",
wantTool: "get_resource_download_url",
wantArgs: map[string]any{
"resourceType": "mediaId",
"resourceId": "resource-1",
"openMessageId": "msg-1",
"openConversationId": "cid-1",
},
},
}
for _, tc := range tests {
+1 -1
View File
@@ -67,7 +67,7 @@ var Broadcast = shortcut.Shortcut{
// Step 1 — resolve this name to a unique userId. On failure
// (unknown / ambiguous) record it and keep going.
user, err := resolveUser(rt, name)
user, err := resolveOpenDingTalkUser(rt, name)
if err != nil {
failed = append(failed, fmt.Sprintf("%s(%s)", name, err.Error()))
continue
@@ -16,6 +16,7 @@ package smart
import (
"context"
"io"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
@@ -31,7 +32,8 @@ type platformCoverageCall struct {
}
type platformCoverageCaller struct {
calls []platformCoverageCall
calls []platformCoverageCall
contactSearchResult string
}
func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -39,13 +41,39 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
text := `{"result":[]}`
switch product + "/" + tool {
case "contact/search_contact_by_key_word":
text = `{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}`
text = f.contactSearchResult
if text == "" {
text = `{"result":[{"userId":"u1","name":"张三","openDingTalkId":"open1"}]}`
}
case "contact/get_current_user_profile":
text = `{"result":{"userId":"u1"}}`
case "im/search_groups":
text = `{"result":[{"openConversationId":"cid-1","title":"项目冲刺"}]}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
func TestCrossPlatformCoverageExternalContactAmbiguity(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[
{"userId":"u1","name":"张三","openDingTalkId":"open1"},
{"openDingtalkId":"open-external","nick":"外部张三"}
]}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+dm", "--to", "张三", "--text", "你好", "--yes"})
err := root.Execute()
if err == nil {
t.Fatal("ambiguous internal and external contacts unexpectedly resolved")
}
for _, want := range []string{"张三(u1)", "外部张三(open-external)"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("ambiguity error %q does not contain %q", err, want)
}
}
}
func (f *platformCoverageCaller) Format() string { return "json" }
func (f *platformCoverageCaller) DryRun() bool { return false }
func (f *platformCoverageCaller) Fields() string { return "" }
+1 -1
View File
@@ -49,7 +49,7 @@ var DM = shortcut.Shortcut{
text := rt.Str("text")
// Step 1 — resolve the recipient name to a unique userId.
user, err := resolveUser(rt, rt.Str("to"))
user, err := resolveOpenDingTalkUser(rt, rt.Str("to"))
if err != nil {
return err
}
+6 -6
View File
@@ -21,7 +21,7 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
// Remind: create a personal todo for YOURSELF with an optional due/reminder time,
// Remind: create a personal todo for YOURSELF with an optional due time,
// in one command. It resolves the current user and explicitly passes executorIds;
// the real todo backend does not reliably default a missing executor to "me".
//
@@ -35,13 +35,13 @@ var Remind = shortcut.Shortcut{
Service: "todo",
Command: "+remind",
Product: "todo",
Description: "给自己创建一条带截止/提醒时间的待办",
Intent: "当你想给自己记一件事、并(可选)设一个截止/提醒时间,又不想先查自己的 userId 时使用;" +
"内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 会按 ISO8601 解析为截止时间。会真实创建待办。",
Description: "给自己创建一条带可选截止时间的待办",
Intent: "当你想给自己记一件事、并(可选)设一个截止时间,又不想先查自己的 userId 时使用;" +
"内部先解析当前登录用户的 userId,再显式设置 executorIds,--at 只会按 ISO8601 写入截止时间 dueTime,不会创建独立提醒规则。会真实创建待办。",
Risk: shortcut.RiskWrite,
Flags: []shortcut.Flag{
{Name: "task", Type: shortcut.FlagString, Desc: "待办标题/内容", Required: true},
{Name: "at", Type: shortcut.FlagString, Desc: "截止/提醒时间(ISO8601,可选,如 2026-03-10T18:00:00+08:00)"},
{Name: "at", Type: shortcut.FlagString, Desc: "截止时间(ISO8601,可选,不是提醒时间;如 2026-03-10T18:00:00+08:00)"},
},
Tips: []string{`dws todo +remind --task "交周报" --at 2026-03-10T18:00:00+08:00`},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -59,7 +59,7 @@ var Remind = shortcut.Shortcut{
"executorIds": []string{userID},
}
// Optional due/reminder time. The todo helper feeds --due through
// Optional due time. The todo helper feeds --due through
// parseISOTimeToMillis and stores dueTime as epoch milliseconds (int64),
// so we do the same here rather than passing a raw string.
if rt.Changed("at") {
@@ -0,0 +1,68 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package smart
import (
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestRemindShortcutDoesNotAdvertiseDueTimeAsReminder(t *testing.T) {
if strings.Contains(Remind.Description, "提醒时间") || strings.Contains(Remind.Intent, "截止/提醒") {
t.Fatalf("shortcut contract still conflates dueTime with a reminder: %#v", Remind)
}
for _, flag := range Remind.Flags {
if flag.Name == "at" && !strings.Contains(flag.Desc, "不是提醒时间") {
t.Fatalf("--at description = %q, want explicit dueTime boundary", flag.Desc)
}
}
}
func TestRemindShortcutWritesAtOnlyAsDueTime(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"todo", "+remind",
"--task", "交周报",
"--at", "2026-03-10T18:00:00+08:00",
"--yes",
})
if err := root.Execute(); err != nil {
t.Fatalf("execute +remind: %v", err)
}
if len(fake.calls) != 2 {
t.Fatalf("tool calls = %#v, want profile read plus todo create", fake.calls)
}
call := fake.calls[1]
if call.product != "todo" || call.tool != "create_personal_todo" {
t.Fatalf("create call = %s/%s, want todo/create_personal_todo", call.product, call.tool)
}
request, ok := call.args["PersonalTodoCreateVO"].(map[string]any)
if !ok {
t.Fatalf("PersonalTodoCreateVO = %#v, want object", call.args["PersonalTodoCreateVO"])
}
if got := request["dueTime"]; got != int64(1773136800000) {
t.Fatalf("dueTime = %#v, want 1773136800000", got)
}
if _, exists := request["reminderRules"]; exists {
t.Fatalf("unexpected reminderRules in %#v", request)
}
}
func TestRemindShortcutRejectsInvalidAtBeforeTodoCreate(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"todo", "+remind", "--task", "交周报", "--at", "tomorrow", "--yes"})
err := root.Execute()
if err == nil || !strings.Contains(err.Error(), "--at 时间格式无效") {
t.Fatalf("error = %v, want invalid --at validation", err)
}
if len(fake.calls) != 1 || fake.calls[0].tool != "get_current_user_profile" {
t.Fatalf("tool calls = %#v, want profile read only", fake.calls)
}
}
+47 -6
View File
@@ -35,6 +35,17 @@ type contactUser struct {
// - errors with a clear message if nobody matches;
// - errors listing the candidates if the name is ambiguous (never guesses).
func resolveUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) {
return resolveUserByName(rt, name, false)
}
// resolveOpenDingTalkUser also accepts external / cross-org contacts that have
// an openDingTalkId but no organization-scoped userId. Use it only for flows
// whose downstream interface consumes openDingTalkId.
func resolveOpenDingTalkUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) {
return resolveUserByName(rt, name, true)
}
func resolveUserByName(rt *shortcut.RuntimeContext, name string, includeOpenIDOnly bool) (contactUser, error) {
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{
"keyword": name,
})
@@ -42,18 +53,31 @@ func resolveUser(rt *shortcut.RuntimeContext, name string) (contactUser, error)
return contactUser{}, err
}
users := extractUsers(data)
if !includeOpenIDOnly {
users = usersWithUserID(users)
}
switch {
case len(users) == 0:
return contactUser{}, apperrors.NewValidation(
fmt.Sprintf("通讯录里没找到叫 %q 的人;换个更完整的姓名再试。", name))
case len(users) > 1:
return contactUser{}, apperrors.NewValidation(fmt.Sprintf(
"%q 匹配到 %d 个人:%s。请用更精确的姓名,或直接用对应命令传 userId。",
"%q 匹配到 %d 个人:%s。请用更精确的姓名,或改用对应命令直接传用户 ID。",
name, len(users), strings.Join(userLabels(users), "、")))
}
return users[0], nil
}
func usersWithUserID(users []contactUser) []contactUser {
out := make([]contactUser, 0, len(users))
for _, user := range users {
if user.userID != "" {
out = append(out, user)
}
}
return out
}
// extractUsers pulls {userId, openDingTalkId, name} out of a
// search_contact_by_key_word response ({"result": [ {userId, name, ...} ]}).
func extractUsers(data map[string]any) []contactUser {
@@ -68,14 +92,27 @@ func extractUsers(data map[string]any) []contactUser {
continue
}
id, _ := m["userId"].(string)
if id == "" {
continue
}
nm, _ := m["name"].(string)
openID, _ := m["openDingTalkId"].(string)
if openID == "" {
openID, _ = m["openDingtalkId"].(string)
}
// External / cross-org contacts come back with an empty userId and only
// an openDingTalkId (verified live). Dropping them here made name→ID
// resolution miss those people, or collapse to the wrong single match
// when an in-org namesake also existed. Keep any row with at least one
// usable identity; downstream (e.g. +dm) can act on the openDingTalkId.
if id == "" && openID == "" {
continue
}
nm, _ := m["name"].(string)
if nm == "" {
for _, k := range []string{"nick", "showName", "flowerName", "staffName", "userName"} {
if v, _ := m[k].(string); v != "" {
nm = v
break
}
}
}
out = append(out, contactUser{userID: id, openDingTalkID: openID, name: nm})
}
return out
@@ -84,7 +121,11 @@ func extractUsers(data map[string]any) []contactUser {
func userLabels(users []contactUser) []string {
out := make([]string, 0, len(users))
for _, u := range users {
out = append(out, fmt.Sprintf("%s(%s)", u.name, u.userID))
id := u.userID
if id == "" {
id = u.openDingTalkID
}
out = append(out, fmt.Sprintf("%s(%s)", u.name, id))
}
return out
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package smart
import "testing"
func TestExtractUsers(t *testing.T) {
data := map[string]any{
"result": []any{
// in-org contact: full identity
map[string]any{"userId": "024083", "name": "朱鸿", "openDingTalkId": "DHUGO"},
// external / cross-org contact: no userId, only openDingTalkId +
// display name under a non-"name" key — must be kept, not dropped.
map[string]any{"openDingtalkId": "DEXT", "nick": "外部小王"},
// unusable row (no identity at all) is skipped
map[string]any{"name": "无ID的人"},
// non-map entry is skipped
"garbage",
},
}
users := extractUsers(data)
if len(users) != 2 {
t.Fatalf("extractUsers kept %d, want 2: %#v", len(users), users)
}
if users[0].userID != "024083" || users[0].openDingTalkID != "DHUGO" || users[0].name != "朱鸿" {
t.Errorf("in-org user = %#v", users[0])
}
// external contact kept via openDingTalkId, name resolved from "nick"
if users[1].userID != "" || users[1].openDingTalkID != "DEXT" || users[1].name != "外部小王" {
t.Errorf("external user = %#v", users[1])
}
}
func TestExtractUsersNoResult(t *testing.T) {
if got := extractUsers(map[string]any{}); got != nil {
t.Errorf("no result should be nil, got %#v", got)
}
}
func TestUsersWithUserIDExcludesOpenIDOnlyContacts(t *testing.T) {
users := []contactUser{
{userID: "user-1", openDingTalkID: "open-1", name: "内部用户"},
{openDingTalkID: "open-external", name: "外部联系人"},
}
got := usersWithUserID(users)
if len(got) != 1 || got[0].userID != "user-1" {
t.Fatalf("usersWithUserID() = %#v, want only the organization user", got)
}
}
func TestUserLabelsFallsBackToOpenDingTalkID(t *testing.T) {
got := userLabels([]contactUser{{openDingTalkID: "open-external", name: "外部联系人"}})
if len(got) != 1 || got[0] != "外部联系人(open-external)" {
t.Fatalf("userLabels() = %#v", got)
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ var ShareDoc = shortcut.Shortcut{
note := rt.Str("note")
// Step 1 — resolve the recipient name to a unique userId.
user, err := resolveUser(rt, rt.Str("to"))
user, err := resolveOpenDingTalkUser(rt, rt.Str("to"))
if err != nil {
return err
}
+4 -2
View File
@@ -1,6 +1,6 @@
---
name: dws
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件、实时监听群成员加入、群成员退出、群改名和群解散时使用。
cli_version: ">=1.0.15"
---
@@ -120,7 +120,9 @@ cli_version: ">=1.0.15"
用户提到"在线电子表格/钉钉表格/axls/工作表/单元格读写/合并单元格/筛选视图/导出 xlsx" → `sheet`
用户提到"待办/TODO/任务提醒/循环待办" → `todo`
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
用户提到"监听有人@我/监听单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/个人事件流/event consume user_im_message_*/监听并自动回复消息/驱动 Agent 处理消息" → `event`
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/监听群成员加入/监听群成员退出/监听群改名或群解散/订阅个人 IM 事件/实时接收钉钉事件/个人事件流/event consume user_im_message_*/监听并自动回复消息/驱动 Agent 处理消息" → `event`
事件监听中,同一目标、同一过滤条件的多个兼容事件优先生成一个 `dws event consume <event_key> [event_key...] --flatten`;不同用户、不同群或不同过滤条件拆成多个 consume 进程。
关键区分: aitable(数据表格) vs todo(待办任务)
关键区分: report(钉钉日志/日报周报) vs todo(待办任务)
+93
View File
@@ -0,0 +1,93 @@
# 受控渠道与阿里巴巴组织登录
## 使用场景
在以下任一场景读取本参考:
- 目标组织是阿里巴巴;
- 登录返回 `CHANNEL_REQUIRED`、`channel_not_allowed`、`enterprise_not_authorized` 或“应用暂不受信任”;
- 用户提到渠道码、`DWS_CHANNEL`、渠道白名单或渠道归因;
- 需要判断 `DWS_CHANNEL` 与 `DINGTALK_DWS_AGENTCODE` 的边界。
## 核心契约
- 将 `DWS_CHANNEL` 作为产品/分发渠道 `channelCode`。CLI 在登录权限检查和后续 MCP 请求中把它发送为 `x-dws-channel`。
- 将 `DINGTALK_DWS_AGENTCODE` 作为执行 Agent 身份。两者是独立维度,禁止互相回填或复用。
- 在受控渠道组织中,把 `DWS_CHANNEL` 同时加到 `auth login` 和每一条后续 `dws` 命令。只在单条命令作用域设置,禁止写入 shell profile 或对其他组织全局导出。
- 仅使用与真实宿主/业务场景匹配的已登记渠道。禁止为了通过登录随机尝试其他渠道或伪装成别的产品。
- 把静态 `channelCode` 视为公开路由标识,不视为密钥或可信归因凭证。长期方案必须由服务端校验宿主身份并签发短期、绑定组织和渠道的会话凭证。
## 当前本机映射
当前 Codex 中的 DWS skill/办公能力验证场景使用:
| 项目 | 值 |
|---|---|
| 目标组织 | 阿里巴巴 |
| 稳定 profile | `dingd8e1123006514592:04061459256343` |
| 渠道 | EI智能体评测 |
| `DWS_CHANNEL` | `18451e165920b301ade00efae99b2c253e1e900b` |
登录:
```bash
DWS_CHANNEL='18451e165920b301ade00efae99b2c253e1e900b' \
dws auth login \
--profile 'dingd8e1123006514592:04061459256343' \
--format json
```
后续命令:
```bash
DWS_CHANNEL='18451e165920b301ade00efae99b2c253e1e900b' \
dws <product> <command> \
--profile 'dingd8e1123006514592:04061459256343' \
--format json
```
2026-07-22 已用 CLI v1.0.54 验证:不带渠道码时阿里巴巴组织登录被拒;带上述渠道码后 OAuth 登录成功,随后 `minutes list all` 调用成功。
## 组织白名单
当前服务端渠道配置组织白名单:
```text
793652894
515819978
21001
```
该数字组织白名单属于服务端控制面,不等同于本地 `profile` 中的字符串 `corpId`。禁止自行推导两者的映射。
## 已登记渠道
下表来自 `availableChannels` 渠道目录,不代表任一组织实时返回的 `allowedChannels`。执行登录时仍以服务端组织策略为准。
| channelCodeTitle | channelCode | 适用宿主/业务 |
|---|---|---|
| QoderWork | `51d4ceade40174304fc591dbf17448aeebf50328` | 阿里云 qteam 桌面 Agent |
| Oneday | `2a4a658e467998befb7fa333c19ba2b3a3bacfa4` | 自然语言获取、加工并写回文档 |
| ei_shuziren_v1 | `17590d48d77f9b1ab47ec7d37c16d9ff513f96fb` | 集团业务线数字员工 |
| ideaLAB | `d3417efb28cf4b8dc0c074705e74eb6208f54154` | 企业级 Agent 构建与智能应用平台 |
| QoderWake | `a3f5d3f4ab8cb87a2a1c93cbf69c713dab11b16e` | 持久身份、长期记忆的数字员工 |
| AI-SOP | `1e54a489615d81b3a6815d1e99b22ea6358303fd` | 天猫国际运营 AI 助理 |
| AgentX | `394af5a87e1044dbbb52ad037f49e2b74bea8881` | 菜鸟 AI 供应链平台 |
| EI智能体评测 | `18451e165920b301ade00efae99b2c253e1e900b` | DWS 办公能力与 Agent 自动化评测 |
| it-digital-human | `49edc1b7679b8d9f804af345920adbbb9472f2a6` | IT 数字人 |
| Devix | `bc623574bfc80b76b642e35f676d42e1921ee6ef` | AONE AI Native 研发平台 |
| ai-lab-agent | `dbd7cf1fea014e6b1d22bfbd179d00b6523b0e39` | 企业发展 Web Agent |
| Otter | `d7b20e3e1a154102deb2cf4b785c9b86aecbed83` | 淘天数据平台 OtterAgent |
| leto-dws | `ea51a28080a83b7466943c2b87f6d3f256460233` | 章鱼 DWS |
| CRM AI助理 | `cfeeb8e52530f77e1c4698b6d2faf4f2bf38008b` | 阿里云 CIO CRM AI 助理 |
| 法务AI助理桌面端 | `2b11bed6c34473aba7998d44801c1ad5cccb17b9` | 法务桌面 Agent |
| qianwen-aiworks | `b3e3d7e31ca3a5d626943a5cb72acb70b4fc76e3` | 千问 aiworks |
| Nexa | `02dbe0983567bb3bc2977e0a80826cb97ca0a1e9` | 直播业务 AI-Native 组织 |
## 排查顺序
1. 运行 `dws profile list --format json`,解析目标组织的稳定 `profile`。
2. 按真实宿主从登记表选择渠道;当前本机 Codex 规则命中“EI智能体评测”。
3. 使用命令级 `DWS_CHANNEL` 重新执行 `dws auth login --profile ... --format json`。
4. 使用相同 `DWS_CHANNEL` 和 `profile` 执行一个最小只读产品命令验证。
5. 若仍失败,加 `--verbose` 重试一次并按原始服务端错误分类;禁止轮询尝试整张渠道表。
+2 -2
View File
@@ -26,7 +26,7 @@
| "搜一下有没有叫XX的文件" | 全局搜索 | `drive search` | `wiki node search` | 未指定空间 → drive search 全局聚合搜索 |
| "在知识库里创建一个文档" | 创建空文件实体 | `wiki node create --type adoc` | `doc create` | 空间内创建节点归 wiki;doc create 是向已有文档写入内容,不是创建文件节点 |
| "帮我建一个明天下午的日程" | 日历日程 | `calendar` | — | 日历日程管理(可含参与者/会议室);视频会议(conference)当前开源 CLI 不支持 |
| "明早 9 点提醒我提交周报" | 创建个人待办,但需先声明 reminder 边界 | `todo` | `calendar` | todo 当前只支持 dueTime 截止时间,不支持独立精确 reminder |
| "明早 9 点提醒我提交周报" | 创建个人待办后添加提醒 | `todo task create` + `todo task add-reminder` | `calendar` | `+remind --at` 只写截止时间;提醒可写入,但上游不支持规则读回 |
| "帮我建一个项目群" | 创建群聊 | `chat group create` | — | 群聊管理,不是日历日程 |
| "把张三拉进群" | 添加群成员 | `chat group members add` | — | 先查 userId,再添加 |
| "通知群里的人都来开会" | 个人身份群发 | `chat message send` | `chat message send-by-bot` | 以个人身份向群发消息 |
@@ -330,7 +330,7 @@ dws chat message send --open-dingtalk-id <openDingTalkId> --msg-type file --file
**用 `todo` 的场景**:
- "记一下这周要做的事" — 个人任务管理
- "创建一个待办提醒" — 仍归 `todo`,但要先说明当前只有 dueTime 截止时间,没有独立 reminder schedule
- "创建一个待办提醒" — 仍归 `todo`:先创建待办,再用 `task add-reminder` 写入;需说明上游不支持读取 reminderRules,不能写后读回核验
**判断关键**:钉钉日志系统(日报/周报模版,含按模版创建汇报)→ `report`;文档/知识库长文→ `doc`;任务清单→ `todo`
+1 -1
View File
@@ -425,7 +425,7 @@ Flags:
--name string 新名称 (仅 rename 必填)
```
> **rename 只传主名,不要带扩展名**:服务端会按文件原扩展名自动补一个后缀。若 `--name` 里已带扩展名(如 `报告.txt`),回读会变成双扩展名 `报告.txt.txt`。正确做法:`dws drive rename --node <ID> --name "报告"`(不含 `.txt`),系统自动补回 `报告.txt`。
> **rename 会按真实节点元数据处理扩展名**:实际执行前先读取节点类型和当前扩展名。文件的新名称若以当前扩展名结尾,只去掉完全匹配的一层再交给会保留原后缀的服务端;文件夹(包括 `release.v2` 这类带点名称)保持原样,也不再依赖扩展名白名单。`--dry-run` 不读取远端元数据,因此预览保留输入名称。
权限要求:copy 需对源文档有"阅读"权限且对目标文件夹有"编辑"权限;move 需对源文档有"管理"权限且对目标文件夹有"编辑"权限;rename 需对文档有"编辑"权限。
+60 -9
View File
@@ -1,6 +1,6 @@
# dws event — 个人 IM 事件
通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回和表情回应事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。
通过个人 Stream 长连接监听当前用户的钉钉消息接收、全量消息、已读、撤回、表情回应和群生命周期事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。实时监听、自动回复、订阅事件都必须使用 `dws event consume`,不要写脚本轮询消息历史。
## 运行方式
@@ -14,7 +14,7 @@
| Command | Purpose |
|---|---|
| `dws event schema <event_key> --flatten` | 查看 Agent 使用的顶层业务字段 schema |
| `dws event consume <event_key> --flatten [flags]` | 阻塞消费,事件写到 stdout,用 `-f ndjson` |
| `dws event consume <event_key> [event_key...] --flatten [flags]` | 阻塞消费一个或多个兼容事件,事件写到 stdout,用 `-f ndjson` |
| `dws event status --event <event_key>` | 查看个人订阅、bus、本地 consume |
| `dws event stop <subscribe_id> --dry-run` / `--yes` | 先预览,再确认取消订阅并停止对应本地消费 |
| `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下全部个人订阅 |
@@ -29,14 +29,20 @@
| `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_receive_group` | 当前用户所在指定群聊/会话的消息 | `--group` |
| `user_im_message_receive_user` | 当前用户收到的指定用户发送的消息(单聊和群聊) | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_receive_o2o_all` | 当前用户收到的所有单聊消息 | 无 |
| `user_im_message_receive_group_all` | 当前用户收到的所有群聊消息 | 无 |
| `user_im_message_read_o2o` | 指定单聊中当前用户发送的消息被已读 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_read_group` | 指定群聊中当前用户发送的消息被已读 | `--group` |
| `user_im_message_recall_o2o` | 指定单聊中的消息被撤回 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_recall_group` | 指定群聊中的消息被撤回 | `--group` |
| `user_im_message_reaction_o2o` | 指定单聊中的消息收到表情回应 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_reaction_group` | 指定群聊中的消息收到表情回应 | `--group` |
| `user_im_group_updated` | 指定群聊的标题发生变更 | `--group` |
| `user_im_group_member_added` | 指定群聊有成员加入 | `--group` |
| `user_im_group_member_exited` | 指定群聊有成员退出 | `--group` |
| `user_im_group_disbanded` | 指定群聊被解散 | `--group` |
只承认上表 10 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。
只承认上表 16 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。
## Intent mapping
@@ -48,27 +54,35 @@
| "监听 XX 群消息" | 先 `dws chat search --query "XX" --format json`,确认后 consume group |
| "监听 userId test-user-001 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--user test-user-001 --flatten -f ndjson` |
| "监听 openDingtalkId abc 发给我的消息" | `event consume`,事件码 `user_im_message_receive_user`,参数 `--open-dingtalk-id abc --flatten -f ndjson` |
| "监听我的所有单聊消息" | `event consume`,事件码 `user_im_message_receive_o2o_all`,参数 `--flatten -f ndjson` |
| "监听我所在的所有群消息" | `event consume`,事件码 `user_im_message_receive_group_all`,参数 `--flatten -f ndjson` |
| "监听我发给 userId test-user-001 的消息是否已读" | `event consume`,事件码 `user_im_message_read_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
| "监听 XX 群消息已读" | 先解析群 ID,再 consume `user_im_message_read_group --group <id>` |
| "监听我和 userId test-user-001 的消息撤回" | `event consume`,事件码 `user_im_message_recall_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
| "监听 XX 群消息撤回" | 先解析群 ID,再 consume `user_im_message_recall_group --group <id>` |
| "监听我和 userId test-user-001 的消息贴表情" | `event consume`,事件码 `user_im_message_reaction_o2o`,参数 `--user test-user-001 --flatten -f ndjson` |
| "监听 XX 群消息表情回应" | 先解析群 ID,再 consume `user_im_message_reaction_group --group <id>` |
| "监听 XX 群改名" | 先解析群 ID,再 consume `user_im_group_updated --group <id>` |
| "监听有人加入 XX 群" | 先解析群 ID,再 consume `user_im_group_member_added --group <id>` |
| "监听有人退出 XX 群" | 先解析群 ID,再 consume `user_im_group_member_exited --group <id>` |
| "监听 XX 群解散" | 先解析群 ID,再 consume `user_im_group_disbanded --group <id>`;破坏性自测只能用测试群 |
| "监听并自动回复某人的单聊消息" | 先解析对端 userId,再启动 o2o consume;不要写轮询脚本 |
| "同时监听同一人的单聊、已读和撤回" | 一个 consume 放入 3 个 event key,共享同一个 `--user` |
| "同时监听同一群的消息、改名和解散" | 一个 consume 放入 3 个 event key,共享同一个 `--group` |
| "查看个人消息事件 schema" | `dws event schema <event_key> --flatten` |
| "看个人事件订阅状态" | `dws event status --event <event_key>` |
| "停止这个个人事件订阅" | `dws event stop <subscribe_id> --dry-run`,确认后改用 `--yes` |
多候选让用户确认。缺必填 ID 且解析不出先追问,不要猜。企业内部 userId 使用 `--user`;明确给出 openDingtalkId,或目标是外部联系人、机器人、跨组织身份时使用 `--open-dingtalk-id`。两者严格二选一,不得混填、猜测或自动转换身份类型。
“我和某人的单聊”使用 `receive_o2o`;“某人发给我的消息/某人发送的消息”使用 `receive_user`,后者覆盖该发送人的单聊和群聊消息。用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。
“我和某人的单聊”使用 `receive_o2o`;“某人发给我的消息/某人发送的消息”使用 `receive_user`,后者覆盖该发送人的单聊和群聊消息。只有明确说“所有”时才使用 `receive_o2o_all/receive_group_all`,指定对象仍使用范围更小的事件。用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。
## Call flow
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
3. 启动 `dws event consume <event_key> ... --flatten -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;直接按该事件的 `schema.properties` 读取顶层字段。
3. 启动 `dws event consume <event_key> [event_key...] ... --flatten -f ndjson`。单事件等待 `ready event_key=...`;多事件记录每条 `subscription event_key=... subscribe_id=...`,再等待整体 `ready event_count=...`。不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop <subscribe_id> --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。
@@ -79,12 +93,18 @@ dws event schema user_im_message_receive_at --flatten
dws event schema user_im_message_receive_o2o --flatten
dws event schema user_im_message_receive_group --flatten
dws event schema user_im_message_receive_user --flatten
dws event schema user_im_message_receive_o2o_all --flatten
dws event schema user_im_message_receive_group_all --flatten
dws event schema user_im_message_read_o2o --flatten
dws event schema user_im_message_read_group --flatten
dws event schema user_im_message_recall_o2o --flatten
dws event schema user_im_message_recall_group --flatten
dws event schema user_im_message_reaction_o2o --flatten
dws event schema user_im_message_reaction_group --flatten
dws event schema user_im_group_updated --flatten
dws event schema user_im_group_member_added --flatten
dws event schema user_im_group_member_exited --flatten
dws event schema user_im_group_disbanded --flatten
```
```bash
@@ -94,14 +114,42 @@ dws event consume user_im_message_receive_o2o --open-dingtalk-id abc --flatten -
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
dws event consume user_im_message_receive_user --user test-user-001 --flatten -f ndjson
dws event consume user_im_message_receive_user --open-dingtalk-id abc --flatten -f ndjson
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
dws event consume user_im_message_receive_group_all --flatten -f ndjson
dws event consume user_im_message_read_o2o --user test-user-001 --flatten -f ndjson
dws event consume user_im_message_read_group --group <openConversationId> --flatten -f ndjson
dws event consume user_im_message_recall_o2o --user test-user-001 --flatten -f ndjson
dws event consume user_im_message_recall_group --group <openConversationId> --flatten -f ndjson
dws event consume user_im_message_reaction_o2o --user test-user-001 --flatten -f ndjson
dws event consume user_im_message_reaction_group --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
```
同一目标、同一过滤条件的兼容事件优先使用一个多事件命令:
```bash
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user test-user-001 \
--flatten \
-f ndjson
dws event consume \
user_im_message_receive_group \
user_im_group_updated \
user_im_group_disbanded \
--group <openConversationId> \
--flatten \
-f ndjson
```
用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是消息接收事件。
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user <userId>` 替换为 `--open-dingtalk-id <openDingtalkId>`,但两个参数不能同时使用。
```bash
@@ -117,22 +165,25 @@ dws event stop --all --yes
## Subprocess contract
- 就绪:连上后 stderr 打 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`,父进程等这行再读 stdout。不要 `--quiet`(会抑制它)。
- 就绪:单事件等待 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`;多事件等待 `[event] ready event_count=<n> bus_pid=<pid>`,并保存此前每条 `[event] subscription ...` 的 subscribe ID。不要 `--quiet`。
- 退出:末行 `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)`;受控退出码 0,失败非 0 且无 exited 行、有 Error 行。
- stdin 关闭 = 停机:仅当 stdin 是管道且未设 `--max-events/--duration` 时生效;交互终端和 `< /dev/null` 不触发。用管道 stdin 又要常驻就喂 `< <(tail -f /dev/null)`。
- 订阅清理:本次新建的订阅任意退出即自动退订;`--subscribe-id` 复用的保留;`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部先预览 `dws event stop <subscribe_id> --dry-run`、确认后加 `--yes`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
- 一 consume 一 event_key;监听 N 个就起 N 个 consume,共用一个 bus。
- 一个 consume 可监听多个兼容 event key,每个事件仍有独立订阅和 consumer,共用一个 bus、远程连接及输出。`event stop <subscribe_id>` 只移除目标事件,最后一个被移除后进程退出。
## Output parsing
- 推荐 `--flatten -f ndjson`:顶层业务字段,一行一个事件 JSON,适合 Agent 管道读取。
- 人工取样可用 `--flatten -f json --max-events 1`。`--format` 只控制序列化,`--flatten` 控制数据结构。
- `--flatten` 的 `jq_root_path` 为 `.`;消息正文、发送人和会话 ID 分别直接读取顶层 `content`、`sender`、`conversation_id`。
- 引用回复读取可选的 `quoted_message`;合并转发读取可选的 `forward_messages` 数组。两者保留内部消息的 `message_id/conversation_id/sender/sender_open_dingtalk_id/content/create_time`,不要解析可能随语言变化的外层聊天记录摘要。
- Agent 已显式使用 `--flatten`,不要再生成 `fromjson` 或内部 payload 路径。不传时默认保持兼容 envelope,业务 payload 在 `.data | fromjson`。正常处理直接持续读取 stdout,不要改写为 `--output-dir` watcher。
- 群自动回复使用顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。
- 已读事件直接读取 `reader/reader_open_dingtalk_id/read_time`;撤回事件读取 `recaller/recaller_open_dingtalk_id/recall_time`。
- 表情回应事件直接读取 `operator/operator_open_dingtalk_id/reaction_name/reaction_text/operation_type/operation_time`。
- 图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
- 群成员加入/退出事件读取 `conversation_id/operator/operator_open_dingtalk_id/members/event_time`。`operator` 是执行操作的人,`members` 是本次加入或退出的成员数组,成员项包含 `nick/open_dingtalk_id`;系统操作或成员自行退出时操作人字段可能为空。
- 群标题变更和群解散当前只承诺 `type/event_id/timestamp/subscribe_id/payload`;以实际 `payload` 为准,不猜测群标题、操作者等字段。
- 图片、文件等媒体消息的 `content` 可能是可读描述;合并转发媒体的下载定位信息位于对应 `forward_messages[].content`。需要实际媒体文件时调用 `dws chat message download-media`。
- 正常动作事件输出不含内部 `payload/uid/corpid/clientId/filterSubId/bizid`;原始排查才使用 `-f raw` 或 `--debug-raw-events`。
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤);自发验证会看到 0 事件,测试投递使用别人或机器人发消息。
- `--jq <表达式>` 可进一步过滤或投影扁平输出。
+8 -4
View File
@@ -268,7 +268,7 @@ Example:
dws todo task reset-reminder --task-id <taskId>
dws todo task reset-reminder --task-id <taskId> --reminder-rules '[{"dueDateOffset":-30,"baseTime":"dueTime"},{"reminderTimeStamp":"2026-03-10T18:00:00+08:00","baseTime":"customTime"}]'
Flags:
--reminder-rules string 提醒规则 JSON 数组 (可选,为空则清除提醒)
--reminder-rules string 提醒规则 JSON 数组 (不传则清除;显式传值必须合法)
--task-id string 待办任务 ID (必填)
```
@@ -291,6 +291,8 @@ JSON 数组,每个元素为一条提醒规则,支持两种 `baseTime` 模式
```
以上表示两条提醒规则:第一条在截止时间前 30 分钟提醒,第二条在指定时间(ISO-8601)提醒。
不传 `--reminder-rules` 或显式传 `[]` 表示清除提醒。其他显式值必须是合法对象数组:每条规则必须提供 `baseTime`;`dueTime` 必须带整数 `dueDateOffset`,`customTime` 必须带 ISO-8601 `reminderTimeStamp`。非法 JSON、`null`、标量元素或缺失模式字段都会在调用远端前报错,不会静默按 `null` 清空提醒。
### 给待办打标签
```
Usage:
@@ -457,9 +459,10 @@ dws todo task list-sub --task-id <taskId> --format json
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒”
- `todo +remind --at` 同样只写截止时间 dueTime,不会创建独立提醒规则;不要把它解释成“几点提醒”
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule
- 独立提醒可在待办创建后通过 `task add-reminder` 写入,或用 `task reset-reminder` 整体替换/清除
- 当前上游没有提醒规则查询接口,`task get/list` 均不返回 `reminderRules`;写命令成功响应只能作为写入回执,不能声称已读回核验
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
- `task list --query-all` 查询当前用户跨组织的全部待办;不传时保持当前组织范围
- todo 是个人待办管理产品
@@ -471,7 +474,8 @@ dws todo task list-sub --task-id <taskId> --format json
- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId
- 执行人 (executor) 与参与人 (participant) 的区别:执行人负责完成待办,参与人仅关注待办进度
- `task add-reminder` 用于为待办添加提醒,`--base-time` 支持 `dueTime`(基于截止时间偏移,待办必须有截止时间)和 `customTime`(自定义时间戳)两种模式
- `task reset-reminder` 用于重置待办提醒规则,不传 `--reminder-rules` 则清除所有提醒
- `customTime` 直接使用 `--reminder-time-stamp`,不要求先设置 `--due`;只有 `dueTime` 模式依赖待办截止时间
- `task reset-reminder` 用于重置待办提醒规则,不传 `--reminder-rules` 或传 `[]` 则清除所有提醒;其他值会严格校验,非法输入不会发出远端调用
- `task add-attachment` / `list-attachment` / `remove-attachment` 三条附件命令均可用;`add-attachment` 会真实上传文件,勿用于试探性调用,先确认待办存在
- 附件 ID 的取法:`add-attachment` 从 `result.attachmentIds[]` 取,`list-attachment` 从顶层 `attachments[].attachmentId` 取;`remove-attachment` 用 `--attachment-id` + `--yes`
- 子待办 ID 只能从 `task list-sub` 的顶层 `subTasks[].taskId` 取;`task get` 的 `result.todoDetailModel.subTodos[]` 没有 taskId 字段
+2 -2
View File
@@ -55,7 +55,7 @@ metadata:
| "上传本地文件" | `dws drive upload --file ./report.pdf [--folder <fileId>]` |
| "覆盖钉盘或知识库里的已有文件" | `dws drive upload --file ./updated.pdf --node <fileId> --dry-run`,确认后加 `--yes` |
| "建文件夹" | `dws drive mkdir --name "<名称>" [--folder <fileId>]` |
| "复制 / 移动 / 重命名" | `dws drive copy` / `move` / `rename --node <fileId> --name "<主名>"` |
| "复制 / 移动 / 重命名" | `dws drive copy` / `move` / `rename --node <fileId> --name "<新名称>"` |
| "删除文件 / 移到回收站(需确认)" | `dws drive delete --node <fileId> --yes` |
| "回收站 / 还原" | `dws drive recycle list` / `recycle restore --id <recycleItemId>` |
| "公开 / 取消公开 / 查公开状态" | `dws drive publish set` / `unset` / `get --node <fileId>` |
@@ -65,7 +65,7 @@ metadata:
- 找文件优先用 `drive search --query "<关键词>"`(不知道位置时全局搜);只有需要逐层浏览时才用 `drive list`。命中后必须 `drive info --node <fileId> --format json` 回读元数据。
- **ID 字段选择**:`drive list` 返回同时有 `dentryId`(纯数字)和 `fileId`(UUID 格式)。所有 `--node` 和 `--folder` 参数**必须用 `fileId`**,纯数字 `dentryId` 会被拒绝。
- `drive list` 默认 `--limit 20`,最大 50;要更多用 `--cursor` 翻页,不要因参数边界报错反复重试。
- `rename` 的 `--name` **只传主名,不带扩展名**;服务端会按原扩展名自动补后缀,带了扩展名会变成双扩展名(如 `报告.txt` → `报告.txt.txt`)。
- `rename` 实际执行前读取节点类型和当前扩展名;仅对非文件夹且末尾后缀与当前扩展名完全匹配的名称去掉一层,避免双扩展名。带点文件夹名保持原样;`--dry-run` 不读取元数据,预览保留输入名称。
- `drive download` 需要 `--output` 指定本地保存路径或目录;不要省略必填输出位置。
- 删除、覆盖、移动、公开(publish set/unset)等破坏性操作必须先确认;上传、创建文件夹、下载后要读回或列目录验证。
- `drive upload --node <fileId>` 覆盖已有文件,`--node` 与新建文件使用的 `--folder` 互斥;先 `--dry-run`,获得明确确认后再加 `--yes`。
@@ -425,7 +425,7 @@ Flags:
--name string 新名称 (仅 rename 必填)
```
> **rename 只传主名,不要带扩展名**:服务端会按文件原扩展名自动补一个后缀。若 `--name` 里已带扩展名(如 `报告.txt`),回读会变成双扩展名 `报告.txt.txt`。正确做法:`dws drive rename --node <ID> --name "报告"`(不含 `.txt`),系统自动补回 `报告.txt`。
> **rename 会按真实节点元数据处理扩展名**:实际执行前先读取节点类型和当前扩展名。文件的新名称若以当前扩展名结尾,只去掉完全匹配的一层再交给会保留原后缀的服务端;文件夹(包括 `release.v2` 这类带点名称)保持原样,也不再依赖扩展名白名单。`--dry-run` 不读取远端元数据,因此预览保留输入名称。
权限要求:copy 需对源文档有"阅读"权限且对目标文件夹有"编辑"权限;move 需对源文档有"管理"权限且对目标文件夹有"编辑"权限;rename 需对文档有"编辑"权限。
+72 -10
View File
@@ -1,6 +1,6 @@
---
name: dingtalk-event
description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消息接收、指定发送人、已读、撤回和表情回应,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、被@消息、监听单聊或群消息、监听某人发送的消息、监听消息已读、监听消息撤回、监听消息贴表情或表情回应、实时接收钉钉事件、用事件驱动 Agent。命令前缀:dws event。
description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消息接收、全部单聊/群消息、指定发送人、已读、撤回、表情回应和群生命周期,输出 NDJSON 到 stdout。Use when 用户提到 监听个人消息事件、监听所有单聊或群消息、被@消息、监听单聊或群消息、监听某人发送的消息、监听消息已读、监听消息撤回、监听消息贴表情或表情回应、监听群成员加入、监听群成员退出、监听群改名或群解散、实时接收钉钉事件、用事件驱动 Agent。命令前缀:dws event。
---
# 钉钉个人 IM 事件
@@ -20,7 +20,7 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
|---|---|
| `dws event list` | 查看当前个人事件目录;不要把它当能力菜单主动展示 |
| `dws event schema <event_key> --flatten` | 查看 Agent 使用的顶层业务字段 schema,默认 JSON |
| `dws event consume <event_key> --flatten [flags]` | 阻塞消费;事件写到 stdout,推荐 `-f ndjson` |
| `dws event consume <event_key> [event_key...] --flatten [flags]` | 阻塞消费一个或多个兼容事件;事件写到 stdout,推荐 `-f ndjson` |
| `dws event status --event <event_key>` | 查看个人订阅、personal bus 和本地 consume |
| `dws event stop <subscribe_id> --dry-run` / `--yes` | 先预览,再确认取消个人订阅并停止对应本地消费 |
| `dws event stop --all --dry-run` / `--yes` | 先预览,再确认清理当前身份下本地记录的全部个人订阅 |
@@ -35,14 +35,20 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
| `user_im_message_receive_o2o` | 当前用户与指定用户的单聊消息 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_receive_group` | 当前用户所在指定群聊/会话的消息 | `--group` |
| `user_im_message_receive_user` | 当前用户收到的指定用户发送的消息(单聊和群聊) | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_receive_o2o_all` | 当前用户收到的所有单聊消息 | 无 |
| `user_im_message_receive_group_all` | 当前用户收到的所有群聊消息 | 无 |
| `user_im_message_read_o2o` | 指定单聊中当前用户发送的消息被已读 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_read_group` | 指定群聊中当前用户发送的消息被已读 | `--group` |
| `user_im_message_recall_o2o` | 指定单聊中的消息被撤回 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_recall_group` | 指定群聊中的消息被撤回 | `--group` |
| `user_im_message_reaction_o2o` | 指定单聊中的消息收到表情回应 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_reaction_group` | 指定群聊中的消息收到表情回应 | `--group` |
| `user_im_group_updated` | 指定群聊的标题发生变更 | `--group` |
| `user_im_group_member_added` | 指定群聊有成员加入 | `--group` |
| `user_im_group_member_exited` | 指定群聊有成员退出 | `--group` |
| `user_im_group_disbanded` | 指定群聊被解散 | `--group` |
只承认上表 10 个事件码。其它身份模式、应用凭证模式、非个人 IM 事件不在本 skill 范围内。
只承认上表 16 个事件码。其它身份模式、应用凭证模式、非个人 IM 事件不在本 skill 范围内。
## Command rules
@@ -54,10 +60,15 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
- 企业内部 userId 使用 `--user`;用户明确提供 openDingtalkId,或目标是外部联系人、机器人、跨组织身份时,使用 `--open-dingtalk-id`。
- `--user` 与 `--open-dingtalk-id` 严格二选一。不要把 openDingtalkId 填入 `--user`,不要自动猜测或转换身份类型;缺少外部目标的 openDingtalkId 时先追问。
- “监听我和某人的单聊”使用 `user_im_message_receive_o2o`;“监听某人发给我的消息/监听某人发送的消息”使用 `user_im_message_receive_user`,后者覆盖该发送人的单聊和群聊消息。
- 只有用户明确要求“所有单聊消息”或“所有群消息”时才使用 `user_im_message_receive_o2o_all` / `user_im_message_receive_group_all`;指定人或指定群仍使用范围更小的事件。
- 用户只给群名时,先运行 `dws chat search --query "<group>" --format json` 解析 openConversationId;多候选必须让用户确认。
- “监听群改名/群标题变更”使用 `user_im_group_updated`;“监听有人进群”使用 `user_im_group_member_added`;“监听有人退群”使用 `user_im_group_member_exited`;“监听群解散”使用 `user_im_group_disbanded`。群解散自测只能使用明确的测试群,并在执行解散操作前再次提示其不可逆影响。
- 用户要求执行“撤回消息”时使用 `dws chat`;只有“监听/订阅消息撤回”才使用 `dws event consume user_im_message_recall_*`。
- 用户说“贴标签”且语义是给消息贴表情时,按消息表情回应事件处理,event key 使用 `reaction`。
- 正常 Agent 消费统一显式使用 `--flatten -f ndjson`。抓一条样本可用 `--flatten --max-events 1 -f json`。`--format` 只控制 JSON 序列化,`--flatten` 才控制数据结构。
- 同一目标、同一过滤条件的兼容事件优先放在一个 `consume` 命令中:用户类事件共享一个 `--user` 或 `--open-dingtalk-id`,群类事件共享一个 `--group`,无目标事件可加入任一类组合。
- 用户类与群类事件不能放进同一命令;不同用户、不同群或不同过滤条件必须启动多个 consume 进程。多事件命令不使用 `--subscribe-id`、`--rule`、`--event-types`、`--filter`、`--foreground`、`--force` 或 `--debug-raw-events`。
- 多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是消息接收事件;已读、撤回、表情回应或群生命周期事件混入后不能使用消息过滤参数。
- 监听非默认组织时带 `--profile <corpId 或 profile 名>`;漏传会退回默认 profile 而失败。
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤):边监听边 `dws chat message send` 回复不成环;测试投递用别人 / 机器人发(自发会看到 0 事件)。
- `--debug-raw-events` 只用于联调确认服务端推送是否到达本地连接;正常任务不要使用。它和 `--flatten` 互斥,`-f raw` 也不能与 `--flatten` 同时使用。
@@ -67,22 +78,22 @@ description: 钉钉个人 IM 事件长连接监听、订阅与消费,覆盖消
1. 从用户意图选择事件码;人名或群名先解析成必填 ID。
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
3. 启动 `dws event consume <event_key> ... --flatten -f ndjson`,等待 stderr 出现 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 后开始处理 stdout,不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;直接按该事件的 `schema.properties` 读取顶层字段。
3. 启动 `dws event consume <event_key> [event_key...] ... --flatten -f ndjson`。单事件等待 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`;多事件先记录每条 `[event] subscription event_key=<key> subscribe_id=<id>`,再等待 `[event] ready event_count=<n> bus_pid=<pid>`。不要用 `sleep` 猜测。
4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件直接读取顶层业务字段。群标题变更和群解散只读取公共字段与 `payload` 中实际存在的字段。
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先用 `dws event stop <subscribe_id> --dry-run` 预览,向用户确认后再加 `--yes`;临时测试可用 `--max-events` 或 `--duration` 自动退出。
## Subprocess contract
- `event consume` 阻塞式长连接。stdout 只出事件;stderr 只出状态 / debug / 错误。
- 就绪:连上后 stderr 打 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`,父进程等这行再读 stdout。不要 `--quiet`(会抑制它)。
- 就绪:单事件使用 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`;多事件在全部逻辑 consumer 就绪后使用 `[event] ready event_count=<n> bus_pid=<pid>`,其前每个事件各有一条 `[event] subscription ...`。父进程等对应 ready 行再处理 stdout;不要 `--quiet`。
- 退出:末行 `[event] exited — received N event(s) in Xs (reason: limit|timeout|signal|bus_shutdown)`;受控退出码 0,失败非 0 无 exited 行。
- stdin 关闭 = 停机:仅当 stdin 是管道且未设 `--max-events/--duration` 时生效;交互终端和 `< /dev/null` 不触发。用管道 stdin 又要常驻就喂 `< <(tail -f /dev/null)`。
- 正常事件处理持续读取 stdout 管道,不要改写为 `--output-dir` watcher。
- 无界监听需外部进程管理;有界自测用 `--max-events N` 或 `--duration 10m`。
- 订阅清理:本次新建的订阅任意干净退出即自动退订;`--subscribe-id` 复用的保留,`--ephemeral` 强制退订。优雅停用 SIGTERM、关 stdin,或外部先用 `dws event stop <subscribe_id> --dry-run` 预览、确认后加 `--yes`。不要 `kill -9`(跳过退订、泄漏服务端订阅)。
- 批量清理先用 `dws event stop --all --dry-run` 预览,确认后加 `--yes`。
- 一 consume 一事件订阅;监听多个对象起多个 consume,本机连接可复用,输出按 `subscribe_id` 隔离。
- 一个 consume 可监听多个兼容事件,并为每个事件建立独立订阅和逻辑 consumer;它们共享本机 bus、远程连接、输出和生命周期,仍按 `event_type + subscribe_id` 隔离。`dws event stop <subscribe_id>` 只移除对应事件,最后一个被移除后进程退出。
## Examples
@@ -114,6 +125,12 @@ dws event consume user_im_message_receive_user \
--flatten \
-f ndjson
# 当前用户收到的所有单聊消息(仅在用户明确要求“所有”时使用)
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
# 当前用户收到的所有群聊消息(仅在用户明确要求“所有”时使用)
dws event consume user_im_message_receive_group_all --flatten -f ndjson
# 使用 openDingtalkId 监听指定发送人的消息
dws event consume user_im_message_receive_user \
--open-dingtalk-id open-user-1 \
@@ -138,6 +155,48 @@ dws event consume user_im_message_reaction_o2o \
--flatten \
-f ndjson
# 指定群标题变更
dws event consume user_im_group_updated \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定群有成员加入
dws event consume user_im_group_member_added \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定群有成员退出
dws event consume user_im_group_member_exited \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定群解散
dws event consume user_im_group_disbanded \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 同一用户的单聊消息、已读和撤回(一个进程)
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user test-user-001 \
--flatten \
-f ndjson
# 同一群的消息和生命周期事件(一个进程)
dws event consume \
user_im_message_receive_group \
user_im_group_updated \
user_im_group_disbanded \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 有界自测
dws event consume user_im_message_receive_at \
--duration 10m \
@@ -159,14 +218,17 @@ dws event consume user_im_message_receive_o2o \
- `dws event schema <event_key> --flatten` 是 Agent 写解析逻辑的依据。
- `--flatten` 模式的顶层 `jq_root_path` 为 `.`;不传时为兼容存量脚本的 transport envelope,业务 payload 在 `.data | fromjson`。
- `schema.properties` 是业务字段列表,例如 `content`、`sender`、`conversation_id`、`message_id`、`event_time`。
- Agent 命令已显式传 `--flatten`,直接读取顶层字段;不要对该模式再生成 `fromjson` 或内部 payload 路径。
- Agent 命令已显式传 `--flatten`,消息接收、已读、撤回和表情回应事件直接读取顶层业务字段;不要对该模式再生成 `fromjson` 或内部 transport 路径。
- 引用回复读取可选的 `quoted_message`;合并转发读取可选的 `forward_messages` 数组。两者保留内部消息的 `message_id/conversation_id/sender/sender_open_dingtalk_id/content/create_time`;不要通过“聊天记录”等本地化外层文案识别或拆分合并转发。
- 群成员加入/退出事件读取顶层 `conversation_id`、`operator`、`operator_open_dingtalk_id`、`members`、`event_time`。`operator` 是执行操作的人,`members` 是本次加入或退出的成员数组;成员项读取 `nick` 和 `open_dingtalk_id`。系统操作或成员自行退出时,操作人字段可能为空。
- 群标题变更和群解散当前只承诺顶层 `type/event_id/timestamp/subscribe_id/payload`。读取 `payload` 时以实际键为准,不猜测群标题、操作者等尚未确认的字段;完整原始协议用 `-f raw` 或 `--debug-raw-events` 排查。
- 群自动回复使用事件顶层 `conversation_id`;单聊自动回复使用顶层 `sender_open_dingtalk_id`。
- 已读事件读取顶层 `reader`、`reader_open_dingtalk_id`、`read_time`;撤回事件读取 `recaller`、`recaller_open_dingtalk_id`、`recall_time`。
- 表情回应事件读取顶层 `operator`、`operator_open_dingtalk_id`、`reaction_name`、`reaction_text`、`operation_type`、`operation_time`。
- 图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
- 图片、文件等媒体消息的 `content` 可能是可读描述;合并转发媒体的下载定位信息位于对应 `forward_messages[].content`。需要实际媒体文件时调用 `dws chat message download-media`。
## Topic index
| Topic | Reference | Coverage |
|---|---|---|
| IM | [references/event-im.md](references/event-im.md) | 十类个人 IM 事件命令、参数、生命周期、输出解析、自测和排障 |
| IM | [references/event-im.md](references/event-im.md) | 十六类个人 IM 事件命令、参数、生命周期、输出解析、自测和排障 |
@@ -1,6 +1,6 @@
# IM 个人事件
先读上层 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的 IM 个人事件:消息接收、已读、撤回和表情回应。
先读上层 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的 IM 个人事件:消息接收、全量消息、已读、撤回、表情回应和群生命周期。
实时监听、自动回复、订阅事件都必须使用 `dws event consume` 长连接,不要写轮询脚本。
@@ -19,12 +19,18 @@ dws event schema user_im_message_receive_at --flatten
dws event schema user_im_message_receive_o2o --flatten
dws event schema user_im_message_receive_group --flatten
dws event schema user_im_message_receive_user --flatten
dws event schema user_im_message_receive_o2o_all --flatten
dws event schema user_im_message_receive_group_all --flatten
dws event schema user_im_message_read_o2o --flatten
dws event schema user_im_message_read_group --flatten
dws event schema user_im_message_recall_o2o --flatten
dws event schema user_im_message_recall_group --flatten
dws event schema user_im_message_reaction_o2o --flatten
dws event schema user_im_message_reaction_group --flatten
dws event schema user_im_group_updated --flatten
dws event schema user_im_group_member_added --flatten
dws event schema user_im_group_member_exited --flatten
dws event schema user_im_group_disbanded --flatten
```
schema 默认 JSON。Agent 使用 `--flatten` schema,业务字段在 `schema.properties`,`jq_root_path` 为 `.`。不传 `--flatten` 时查看兼容 transport envelope,其 `jq_root_path` 为 `.data | fromjson`。
@@ -37,12 +43,18 @@ schema 默认 JSON。Agent 使用 `--flatten` schema,业务字段在 `schema.p
| `user_im_message_receive_o2o` | `singleChat` | 当前用户与指定用户的单聊消息 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_receive_group` | `group` | 当前用户所在指定群聊/会话的消息 | `--group` |
| `user_im_message_receive_user` | `sender` | 当前用户收到的指定用户发送的消息(单聊和群聊) | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_receive_o2o_all` | `all` | 当前用户收到的所有单聊消息 | 无 |
| `user_im_message_receive_group_all` | `all` | 当前用户收到的所有群聊消息 | 无 |
| `user_im_message_read_o2o` | `singleChat` | 指定单聊中当前用户发送的消息被已读 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_read_group` | `group` | 指定群聊中当前用户发送的消息被已读 | `--group` |
| `user_im_message_recall_o2o` | `singleChat` | 指定单聊中的消息被撤回 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_recall_group` | `group` | 指定群聊中的消息被撤回 | `--group` |
| `user_im_message_reaction_o2o` | `singleChat` | 指定单聊中的消息收到表情回应 | `--user` 或 `--open-dingtalk-id` |
| `user_im_message_reaction_group` | `group` | 指定群聊中的消息收到表情回应 | `--group` |
| `user_im_group_updated` | `group` | 指定群聊的标题发生变更 | `--group` |
| `user_im_group_member_added` | `group` | 指定群聊有成员加入 | `--group` |
| `user_im_group_member_exited` | `group` | 指定群聊有成员退出 | `--group` |
| `user_im_group_disbanded` | `group` | 指定群聊被解散 | `--group` |
默认身份就是当前用户。不要额外加身份切换 flag,不要使用应用凭证模式,不要使用本表以外的事件码。
@@ -52,11 +64,13 @@ schema 默认 JSON。Agent 使用 `--flatten` schema,业务字段在 `schema.p
- 企业内部 userId → `--user`;明确给出 openDingtalkId,或目标是外部联系人、机器人、跨组织身份 → `--open-dingtalk-id`。
- 两个身份参数严格二选一,不得把 openDingtalkId 放进 `--user`,也不要自动猜测或转换;缺少外部目标的 openDingtalkId 时先追问。
- “我和某人的单聊”选择 `user_im_message_receive_o2o`;“某人发给我的消息/某人发送的消息”选择 `user_im_message_receive_user`。
- 只有明确要求“所有单聊消息”或“所有群消息”时才选择对应 `*_all` 事件;指定人或指定群继续选择范围更小的事件。
- 群名 → `dws chat search --query "<group>" --format json`,确认后取 `openConversationId`。
- 多候选 → 展示候选并让用户确认。
- 仍缺必填 ID → 先追问,不要编造。
- “撤回消息”表示执行操作时走 `dws chat`;“监听/订阅消息撤回”才走本事件能力。
- “贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。
- “群改名/群标题变更”对应 `user_im_group_updated`;“有人进群”对应 `user_im_group_member_added`;“有人退群”对应 `user_im_group_member_exited`;“群解散”对应 `user_im_group_disbanded`。群解散自测必须使用测试群,并在执行解散动作前再次确认破坏性影响。
## Consume commands
@@ -94,6 +108,12 @@ dws event consume user_im_message_receive_user \
--flatten \
-f ndjson
# 所有单聊消息
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
# 所有群聊消息
dws event consume user_im_message_receive_group_all --flatten -f ndjson
# 指定单聊已读事件
dws event consume user_im_message_read_o2o \
--user test-user-001 \
@@ -129,8 +149,72 @@ dws event consume user_im_message_reaction_group \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定群标题变更
dws event consume user_im_group_updated \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定群有成员加入
dws event consume user_im_group_member_added \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定群有成员退出
dws event consume user_im_group_member_exited \
--group cidxxxxxxxx \
--flatten \
-f ndjson
# 指定群解散
dws event consume user_im_group_disbanded \
--group cidxxxxxxxx \
--flatten \
-f ndjson
```
## Multi-event consume
同一目标、同一过滤条件需要监听多个事件时,把多个 event key 作为位置参数放在同一个命令中:
```bash
# 同一用户的消息接收、已读和撤回
dws event consume \
user_im_message_receive_o2o \
user_im_message_read_o2o \
user_im_message_recall_o2o \
--user test-user-001 \
--flatten \
-f ndjson
# 同一群的消息和群生命周期
dws event consume \
user_im_message_receive_group \
user_im_group_updated \
user_im_group_disbanded \
--group cidxxxxxxxx \
--flatten \
-f ndjson
```
- 用户类事件共享一个 `--user` 或 `--open-dingtalk-id`;群类事件共享一个 `--group`;无目标事件可加入用户类、群类或仅由无目标事件组成的组合。
- 用户类与群类不能混在一个命令中。不同用户、不同群或不同过滤条件要启动多个 consume 进程。
- `--query` / `--filter-json` 只有在全部事件都是消息接收事件时才能共享使用。动作事件和群生命周期事件不能混用消息过滤。
- `--duration`、`--max-events`、输出格式、route 和 output-dir 对整个命令生效;多事件不支持 `--subscribe-id`、`--rule`、`--event-types`、`--filter`、`--foreground`、`--force`、`--debug-raw-events`。
- 每个事件仍有独立服务端订阅和 `subscribe_id`。用 `dws event stop <subscribe_id> --yes` 可只移除一个监听,其余继续运行;移除最后一个后前台进程退出。
多事件启动时,stderr 先逐条输出订阅信息,全部 IPC consumer 就绪后才输出整体 ready:
```text
[event] subscription event_key=<key> subscribe_id=<id>
[event] subscription event_key=<key> subscribe_id=<id>
[event] ready event_count=2 bus_pid=<pid>
```
只有整体 ready 出现后才开始处理 stdout。启动中任一订阅或 IPC 建联失败时,本次已创建的订阅会统一回滚。
## Self-test triggers
| 事件码 | 自测参数 | 触发方式 |
@@ -139,14 +223,20 @@ dws event consume user_im_message_reaction_group \
| `user_im_message_receive_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 让对端用户给当前登录用户发送单聊消息 |
| `user_im_message_receive_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 让任意用户在该群发送消息 |
| `user_im_message_receive_user` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 让指定用户分别在单聊或共同群聊中发送消息 |
| `user_im_message_receive_o2o_all` | `--flatten --duration 10m -f ndjson` | 让任意其他用户给当前登录用户发送单聊消息 |
| `user_im_message_receive_group_all` | `--flatten --duration 10m -f ndjson` | 让任意其他用户在当前登录用户所在的任意群发送消息 |
| `user_im_message_read_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 当前用户给对端发送单聊消息,再让对端打开并阅读 |
| `user_im_message_read_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 当前用户在群内发送消息,再让群成员打开并阅读 |
| `user_im_message_recall_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 在指定单聊中发送并撤回一条消息 |
| `user_im_message_recall_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 在指定群聊中发送并撤回一条消息 |
| `user_im_message_reaction_o2o` | `--user <userId>` 或 `--open-dingtalk-id <id>`,加 `--flatten --duration 10m -f ndjson` | 在指定单聊中给消息添加表情回应 |
| `user_im_message_reaction_group` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 在指定群聊中给消息添加表情回应 |
| `user_im_group_updated` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 修改测试群的群标题 |
| `user_im_group_member_added` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 邀请一名测试成员加入测试群 |
| `user_im_group_member_exited` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 让一名测试成员主动退出测试群 |
| `user_im_group_disbanded` | `--group <openConversationId> --flatten --duration 10m -f ndjson` | 确认是可销毁测试群后再解散;该操作不可逆 |
stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 表示本地 consume 已连接到事件 bus;父进程等这行再读 stdout。stdout 每行是一个扁平事件 JSON。
单事件以 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>` 为就绪标志;多事件以 `[event] ready event_count=<n> bus_pid=<pid>` 为整体就绪标志。父进程等对应 ready 行后再处理 stdout。stdout 每行是一个扁平事件 JSON。
## Runtime flags
@@ -159,11 +249,11 @@ stderr 出现固定就绪行 `[event] ready event_key=<key> bus_pid=<pid> subscr
| `--duration <duration>` | 到时退出,例如 `30s`、`10m` |
| `--output-dir <dir>` | 每个事件写入一个文件 |
| `--route '<regex>=dir:<path>'` | 按事件类型路由到目录 |
| `--subscribe-id <id>` | 复用已有个人订阅 |
| `--subscribe-id <id>` | 单事件模式复用已有个人订阅;多事件不支持 |
| `--ephemeral` | 即使复用已有订阅,也在 consume 退出时取消订阅 |
| `--query <csv>` | 按消息正文关键词过滤,逗号分隔 |
| `--filter-json <json>` | 使用个人事件 Filter DSL 过滤 |
| `--debug-raw-events` | 联调用:绕过本地过滤,输出当前 personal stream 实际收到的可解析事件 |
| `--debug-raw-events` | 单事件联调用:绕过本地过滤,输出当前 personal stream 实际收到的可解析事件 |
正常 Agent 消费不要使用 `--debug-raw-events`。它会输出当前连接收到的所有可解析事件,只用于判断服务端是否推到了本机连接,并且不能与 `--flatten` 同时使用。
@@ -184,8 +274,12 @@ Agent 使用 `--flatten -f ndjson`,stdout 每行是一个扁平业务事件对
| `sender_open_dingtalk_id` | 发送人的开放钉钉 ID |
| `create_time` | 消息创建时间 |
| `event_time` | 消息事件时间戳 |
| `quoted_message` | 可选;引用回复所引用的原消息对象 |
| `forward_messages` | 可选;合并转发包含的原消息数组 |
在 `--flatten` 模式下直接按顶层字段解析,不要再使用 `fromjson` 或内部 payload 路径。不传 `--flatten` 时保持兼容 transport envelope,字段为 `type/event_type/data/headers`,业务 payload 需从 `.data | fromjson` 读取。图片、文件等媒体消息的 `content` 可能是可读描述;需要实际媒体文件时调用 `dws chat message download-media`。
`quoted_message` 和 `forward_messages[]` 的内部字段为 `message_id`、`conversation_id`、`sender`、`sender_open_dingtalk_id`、`content`、`create_time`。服务端未提供内部发送人时,`sender` 可能为空或为 `null` 字符串。合并转发必须按 `forward_messages` 判断和解析,不要匹配可能随语言变化的外层聊天记录摘要。
在 `--flatten` 模式下直接按顶层字段解析,不要再使用 `fromjson` 或内部 payload 路径。不传 `--flatten` 时保持兼容 transport envelope,字段为 `type/event_type/data/headers`,业务 payload 需从 `.data | fromjson` 读取。图片、文件等媒体消息的 `content` 可能是可读描述;合并转发媒体的下载定位信息位于对应 `forward_messages[].content`。需要实际媒体文件时调用 `dws chat message download-media`。
所有动作事件都包含顶层 `type`、`event_id`、`timestamp`、`subscribe_id`、`message_id`、`conversation_id`、`sender`、`sender_open_dingtalk_id` 和 `event_time`。各类动作的专有字段如下:
@@ -197,6 +291,20 @@ Agent 使用 `--flatten -f ndjson`,stdout 每行是一个扁平业务事件对
正常输出不会暴露 `payload`、`uid`、`corpid`、`clientId`、`filterSubId`、`bizid` 等内部字段。需要检查原始协议时才使用 `-f raw` 或 `--debug-raw-events`。
群成员加入/退出事件使用稳定的顶层字段:
| 字段 | 说明 |
|---|---|
| `conversation_id` | 发生成员变更的群会话 ID |
| `operator` | 执行操作的人;系统操作或成员自行退出时可能为空 |
| `operator_open_dingtalk_id` | 执行操作人的开放 ID;可能为空 |
| `members` | 本次加入或退出的成员数组,支持多人 |
| `members[].nick` | 成员展示名 |
| `members[].open_dingtalk_id` | 成员开放 ID |
| `event_time` | 群成员变更事件时间戳 |
群标题变更和群解散仍采用保守输出:顶层只承诺 `type`、`event_id`、`timestamp`、`subscribe_id` 和 `payload`。`payload` 会保留服务端推送的未知业务字段,并移除已知的顶层身份/路由字段;不要猜测尚未由真实样本确认的键。
## Event-driven replies
- 群消息自动回复:读取顶层 `conversation_id`,再调用 `dws chat message send --group "<conversation_id>" --text "<reply>" --format json`。
@@ -209,6 +317,7 @@ Agent 使用 `--flatten -f ndjson`,stdout 每行是一个扁平业务事件对
- 单聊用 `--user`。
- 群消息用 `--group`。
- 全量单聊/群聊事件没有范围参数,只在用户明确要求“所有”时使用。
收消息事件需要额外文本过滤时再用 `--query` 或 `--filter-json`:
@@ -222,7 +331,7 @@ dws event consume user_im_message_receive_group \
`--filter-json` 使用 `content`、`sender`、`conversation_id`、`sender_open_dingtalk_id` 等业务别名表达意图。
动作事件只使用 `--user` 或 `--group` 限定订阅范围。`--query` 和消息内容 `--filter-json` 面向接收消息事件,不用于已读、撤回或表情回应事件。
动作事件只使用 `--user` 或 `--group` 限定订阅范围。`--query` 和消息内容 `--filter-json` 面向接收消息事件(包括两个 `*_all` 事件),不用于已读、撤回、表情回应或群生命周期事件。
## Status and stop
@@ -231,12 +340,18 @@ dws event status --event user_im_message_receive_at
dws event status --event user_im_message_receive_o2o
dws event status --event user_im_message_receive_group
dws event status --event user_im_message_receive_user
dws event status --event user_im_message_receive_o2o_all
dws event status --event user_im_message_receive_group_all
dws event status --event user_im_message_read_o2o
dws event status --event user_im_message_recall_group
dws event status --event user_im_message_reaction_o2o
dws event status --event user_im_group_updated
dws event status --event user_im_group_member_added
dws event status --event user_im_group_member_exited
dws event status --event user_im_group_disbanded
```
`status` 同时展示服务端 `Subscriptions` 和本地 `Consumers`。`Consumers` 表里的 PID、事件码、`subscribe_id`、received/dropped 计数用于确认当前前台 consume 是否还挂在 personal bus 上。
`status` 同时展示服务端 `Subscriptions` 和本地 `Consumers`。`Consumers` 表里的 PID、事件码、`subscribe_id`、received/dropped 计数用于确认当前前台 consume 是否还挂在 personal bus 上。同一个多事件前台进程会显示多行 consumer,PID 相同但 event key 和 `subscribe_id` 不同。
停止指定订阅:
@@ -252,11 +367,11 @@ dws event stop --all --dry-run
dws event stop --all --yes
```
裸 `dws event stop` 不会取消订阅。本次 consume 新建的订阅会在 SIGTERM、Ctrl+C、stdin EOF、duration 或 max-events 等干净退出时自动取消;通过 `--subscribe-id` 复用的订阅默认保留。需要从外部取消时,使用事件输出或 `status` 里的 `subscribe_id`,先执行 `dws event stop <subscribe_id> --dry-run`,确认预览后再加 `--yes`。不要使用 `kill -9`,它会跳过清理。
裸 `dws event stop` 不会取消订阅。本次 consume 新建的订阅会在 SIGTERM、Ctrl+C、stdin EOF、duration 或 max-events 等干净退出时自动取消;通过 `--subscribe-id` 复用的订阅默认保留。多事件进程中,停止一个 `subscribe_id` 只移除对应逻辑 consumer,其余事件继续监听;最后一个被移除时进程退出。需要从外部取消时,使用事件输出或 `status` 里的 `subscribe_id`,先执行 `dws event stop <subscribe_id> --dry-run`,确认预览后再加 `--yes`。不要使用 `kill -9`,它会跳过清理。
## Troubleshooting
- 没有输出:先确认 stderr 已出现 `[event] ready event_key=... bus_pid=... subscribe_id=...`。
- 没有输出:单事件确认 stderr 已出现 `ready event_key=...`;多事件确认已出现 `ready event_count=...`,不要把某条 `subscription` 行误认为整体就绪。
- 参数缺失:所有 o2o 事件必须有对端 ID,所有 group 事件必须有 openConversationId。
- 收到非预期消息:检查 stdout 的 `subscribe_id` 是否等于当前命令创建/复用的订阅 ID。
- 需要判断服务端是否推到当前连接:临时加 `--debug --debug-raw-events`,排查后去掉。
+3 -1
View File
@@ -39,7 +39,7 @@ metadata:
| `dws todo +list-comment` | read | 查询待办评论列表 |
| `dws todo +list-sub` | read | 查询子待办列表 |
| `dws todo +overdue` | read | 列出我已过期未完成的待办 |
| `dws todo +remind` | write | 给自己创建一条带截止/提醒时间的待办 |
| `dws todo +remind` | write | 给自己创建一条带可选截止时间的待办(`--at` 只写 dueTime,不创建提醒规则) |
| `dws todo +todo-done` | write | 按标题关键词把我的某条待办标记完成(自动定位 taskId) |
<!-- VISIBLE_SHORTCUTS_END -->
@@ -71,6 +71,8 @@ metadata:
- `--id` / `--ids` 是隐藏兼容别名,文档和生成命令统一写 `--task-id`,减少模型漂移。
- 优先级映射:低=10,普通=20,较高/高/重要=30,紧急/最高/P0/马上处理=40;不要把"较高"写成 40。
- 截止时间必须是 ISO-8601。相对日期按当前日期计算;例如周五说"下周二"就是紧接下一个自然周的周二,不要再加一周。
- 独立提醒使用 `task add-reminder`:`dueTime` 依赖待办已有截止时间,`customTime` 直接传 `--reminder-time-stamp`,不要求先设置 `--due`。隐藏兼容参数 `--remind-at` 不可用。
- `task reset-reminder` 不传 `--reminder-rules` 或传 `[]` 表示清除;其他显式值必须是合法对象数组并通过模式字段校验,非法输入禁止发出远端调用。
- 附件命令 `task add-attachment` / `list-attachment` / `remove-attachment` 均可用:`add-attachment --task-id <taskId> --file-path <本地文件>`(真实上传,先确认待办存在,返回 `result.attachmentIds`);`list-attachment --task-id <taskId>`(返回顶层 `attachments[].attachmentId`);`remove-attachment --task-id <taskId> --attachment-id <id> --yes`(不可逆,先确认)。
- `tag add` 只是把标签关联到任务;`tag delete` 删除标签定义且不可恢复,必须先确认再加 `--yes`。`tag add` 最多传 2 个标签编码。
- 创建、标记完成、重开、删除后必须 `task get` 或对应 `task list --status ...` 验证,不要只凭创建返回或口头计划结束。
@@ -267,7 +267,7 @@ Example:
dws todo task reset-reminder --task-id <taskId>
dws todo task reset-reminder --task-id <taskId> --reminder-rules '[{"dueDateOffset":-30,"baseTime":"dueTime"},{"reminderTimeStamp":"2026-03-10T18:00:00+08:00","baseTime":"customTime"}]'
Flags:
--reminder-rules string 提醒规则 JSON 数组 (可选,为空则清除提醒)
--reminder-rules string 提醒规则 JSON 数组 (不传则清除;显式传值必须合法)
--task-id string 待办任务 ID (必填)
```
@@ -290,6 +290,8 @@ JSON 数组,每个元素为一条提醒规则,支持两种 `baseTime` 模式
```
以上表示两条提醒规则:第一条在截止时间前 30 分钟提醒,第二条在指定时间(ISO-8601)提醒。
不传 `--reminder-rules` 或显式传 `[]` 表示清除提醒。其他显式值必须是合法对象数组:每条规则必须提供 `baseTime`;`dueTime` 必须带整数 `dueDateOffset`,`customTime` 必须带 ISO-8601 `reminderTimeStamp`。非法 JSON、`null`、标量元素或缺失模式字段都会在调用远端前报错,不会静默按 `null` 清空提醒。
### 给待办打标签
```
Usage:
@@ -455,9 +457,10 @@ dws todo task list-sub --task-id <taskId> --format json
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒”
- `todo +remind --at` 同样只写截止时间 dueTime,不会创建独立提醒规则;不要把它解释成“几点提醒”
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule
- 独立提醒可在待办创建后通过 `task add-reminder` 写入,或用 `task reset-reminder` 整体替换/清除
- 当前上游没有提醒规则查询接口,`task get/list` 均不返回 `reminderRules`;写命令成功响应只能作为写入回执,不能声称已读回核验
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
- `task list --query-all` 查询当前用户跨组织的全部待办;不传时保持当前组织范围
- todo 是个人待办管理产品
@@ -469,7 +472,8 @@ dws todo task list-sub --task-id <taskId> --format json
- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId
- 执行人 (executor) 与参与人 (participant) 的区别:执行人负责完成待办,参与人仅关注待办进度
- `task add-reminder` 用于为待办添加提醒,`--base-time` 支持 `dueTime`(基于截止时间偏移,待办必须有截止时间)和 `customTime`(自定义时间戳)两种模式
- `task reset-reminder` 用于重置待办提醒规则,不传 `--reminder-rules` 则清除所有提醒
- `customTime` 直接使用 `--reminder-time-stamp`,不要求先设置 `--due`;只有 `dueTime` 模式依赖待办截止时间
- `task reset-reminder` 用于重置待办提醒规则,不传 `--reminder-rules` 或传 `[]` 则清除所有提醒;其他值会严格校验,非法输入不会发出远端调用
- `task add-attachment` / `list-attachment` / `remove-attachment` 三条附件命令均可用;`add-attachment` 会真实上传文件,勿用于试探性调用,先确认待办存在
- 附件 ID 的取法:`add-attachment` 从 `result.attachmentIds[]` 取,`list-attachment` 从顶层 `attachments[].attachmentId` 取;`remove-attachment` 用 `--attachment-id` + `--yes`
- 子待办 ID 只能从 `task list-sub` 的顶层 `subTasks[].taskId` 取;`task get` 的 `result.todoDetailModel.subTodos[]` 没有 taskId 字段
@@ -0,0 +1,93 @@
# 受控渠道与阿里巴巴组织登录
## 使用场景
在以下任一场景读取本参考:
- 目标组织是阿里巴巴;
- 登录返回 `CHANNEL_REQUIRED`、`channel_not_allowed`、`enterprise_not_authorized` 或“应用暂不受信任”;
- 用户提到渠道码、`DWS_CHANNEL`、渠道白名单或渠道归因;
- 需要判断 `DWS_CHANNEL` 与 `DINGTALK_DWS_AGENTCODE` 的边界。
## 核心契约
- 将 `DWS_CHANNEL` 作为产品/分发渠道 `channelCode`。CLI 在登录权限检查和后续 MCP 请求中把它发送为 `x-dws-channel`。
- 将 `DINGTALK_DWS_AGENTCODE` 作为执行 Agent 身份。两者是独立维度,禁止互相回填或复用。
- 在受控渠道组织中,把 `DWS_CHANNEL` 同时加到 `auth login` 和每一条后续 `dws` 命令。只在单条命令作用域设置,禁止写入 shell profile 或对其他组织全局导出。
- 仅使用与真实宿主/业务场景匹配的已登记渠道。禁止为了通过登录随机尝试其他渠道或伪装成别的产品。
- 把静态 `channelCode` 视为公开路由标识,不视为密钥或可信归因凭证。长期方案必须由服务端校验宿主身份并签发短期、绑定组织和渠道的会话凭证。
## 当前本机映射
当前 Codex 中的 DWS skill/办公能力验证场景使用:
| 项目 | 值 |
|---|---|
| 目标组织 | 阿里巴巴 |
| 稳定 profile | `dingd8e1123006514592:04061459256343` |
| 渠道 | EI智能体评测 |
| `DWS_CHANNEL` | `18451e165920b301ade00efae99b2c253e1e900b` |
登录:
```bash
DWS_CHANNEL='18451e165920b301ade00efae99b2c253e1e900b' \
dws auth login \
--profile 'dingd8e1123006514592:04061459256343' \
--format json
```
后续命令:
```bash
DWS_CHANNEL='18451e165920b301ade00efae99b2c253e1e900b' \
dws <product> <command> \
--profile 'dingd8e1123006514592:04061459256343' \
--format json
```
2026-07-22 已用 CLI v1.0.54 验证:不带渠道码时阿里巴巴组织登录被拒;带上述渠道码后 OAuth 登录成功,随后 `minutes list all` 调用成功。
## 组织白名单
当前服务端渠道配置组织白名单:
```text
793652894
515819978
21001
```
该数字组织白名单属于服务端控制面,不等同于本地 `profile` 中的字符串 `corpId`。禁止自行推导两者的映射。
## 已登记渠道
下表来自 `availableChannels` 渠道目录,不代表任一组织实时返回的 `allowedChannels`。执行登录时仍以服务端组织策略为准。
| channelCodeTitle | channelCode | 适用宿主/业务 |
|---|---|---|
| QoderWork | `51d4ceade40174304fc591dbf17448aeebf50328` | 阿里云 qteam 桌面 Agent |
| Oneday | `2a4a658e467998befb7fa333c19ba2b3a3bacfa4` | 自然语言获取、加工并写回文档 |
| ei_shuziren_v1 | `17590d48d77f9b1ab47ec7d37c16d9ff513f96fb` | 集团业务线数字员工 |
| ideaLAB | `d3417efb28cf4b8dc0c074705e74eb6208f54154` | 企业级 Agent 构建与智能应用平台 |
| QoderWake | `a3f5d3f4ab8cb87a2a1c93cbf69c713dab11b16e` | 持久身份、长期记忆的数字员工 |
| AI-SOP | `1e54a489615d81b3a6815d1e99b22ea6358303fd` | 天猫国际运营 AI 助理 |
| AgentX | `394af5a87e1044dbbb52ad037f49e2b74bea8881` | 菜鸟 AI 供应链平台 |
| EI智能体评测 | `18451e165920b301ade00efae99b2c253e1e900b` | DWS 办公能力与 Agent 自动化评测 |
| it-digital-human | `49edc1b7679b8d9f804af345920adbbb9472f2a6` | IT 数字人 |
| Devix | `bc623574bfc80b76b642e35f676d42e1921ee6ef` | AONE AI Native 研发平台 |
| ai-lab-agent | `dbd7cf1fea014e6b1d22bfbd179d00b6523b0e39` | 企业发展 Web Agent |
| Otter | `d7b20e3e1a154102deb2cf4b785c9b86aecbed83` | 淘天数据平台 OtterAgent |
| leto-dws | `ea51a28080a83b7466943c2b87f6d3f256460233` | 章鱼 DWS |
| CRM AI助理 | `cfeeb8e52530f77e1c4698b6d2faf4f2bf38008b` | 阿里云 CIO CRM AI 助理 |
| 法务AI助理桌面端 | `2b11bed6c34473aba7998d44801c1ad5cccb17b9` | 法务桌面 Agent |
| qianwen-aiworks | `b3e3d7e31ca3a5d626943a5cb72acb70b4fc76e3` | 千问 aiworks |
| Nexa | `02dbe0983567bb3bc2977e0a80826cb97ca0a1e9` | 直播业务 AI-Native 组织 |
## 排查顺序
1. 运行 `dws profile list --format json`,解析目标组织的稳定 `profile`。
2. 按真实宿主从登记表选择渠道;当前本机 Codex 规则命中“EI智能体评测”。
3. 使用命令级 `DWS_CHANNEL` 重新执行 `dws auth login --profile ... --format json`。
4. 使用相同 `DWS_CHANNEL` 和 `profile` 执行一个最小只读产品命令验证。
5. 若仍失败,加 `--verbose` 重试一次并按原始服务端错误分类;禁止轮询尝试整张渠道表。
+61 -1
View File
@@ -994,7 +994,7 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
---
### event(18 条)
### event(26 条)
#### `dws event consume user_im_message_receive_at`
@@ -1044,6 +1044,20 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
- Expected: `dws event consume user_im_message_receive_user --open-dingtalk-id abc --flatten -f ndjson`
- Flags: `--open-dingtalk-id` = `abc`
#### `dws event consume user_im_message_receive_o2o_all`
**event_event_consume_o2o_all_001**
- Prompt: 监听我收到的所有单聊消息
- Expected: `dws event consume user_im_message_receive_o2o_all --flatten -f ndjson`
- Contract: 只有用户明确要求“所有”时使用,不得替代指定用户的 `receive_o2o`
#### `dws event consume user_im_message_receive_group_all`
**event_event_consume_group_all_001**
- Prompt: 监听我所在的所有群消息
- Expected: `dws event consume user_im_message_receive_group_all --flatten -f ndjson`
- Contract: 只有用户明确要求“所有”时使用,不得替代指定群的 `receive_group`
#### `dws event consume user_im_message_read_o2o`
**event_event_consume_read_o2o_001**
@@ -1098,6 +1112,38 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
- Flags: `--group` = `cid123`
- Contract: 从每行 NDJSON 顶层读取 `conversation_id`、`operator`、`reaction_name`、`reaction_text`、`operation_type`
#### `dws event consume user_im_group_updated`
**event_event_consume_group_updated_001**
- Prompt: 监听 openConversationId cid123 群的群标题变更
- Expected: `dws event consume user_im_group_updated --group cid123 --flatten -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 只读取顶层公共字段和实际 `payload`,不得猜测群标题或操作者字段
#### `dws event consume user_im_group_member_added`
**event_event_consume_group_member_added_001**
- Prompt: 监听有人加入 openConversationId cid123 群
- Expected: `dws event consume user_im_group_member_added --group cid123 --flatten -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 从顶层读取 `conversation_id`、`operator`、`operator_open_dingtalk_id`、`members`、`event_time`;`members` 支持多人并读取 `nick/open_dingtalk_id`
#### `dws event consume user_im_group_member_exited`
**event_event_consume_group_member_exited_001**
- Prompt: 监听有人退出 openConversationId cid123 群
- Expected: `dws event consume user_im_group_member_exited --group cid123 --flatten -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 从顶层读取 `conversation_id`、`operator`、`operator_open_dingtalk_id`、`members`、`event_time`;成员自行退出时允许操作人字段为空
#### `dws event consume user_im_group_disbanded`
**event_event_consume_group_disbanded_001**
- Prompt: 监听 openConversationId cid-test-disbanded 测试群被解散
- Expected: `dws event consume user_im_group_disbanded --group cid-test-disbanded --flatten -f ndjson`
- Flags: `--group` = `cid-test-disbanded`
- Contract: 只监听事件;如需触发自测,必须确认目标是可销毁测试群并提示解散不可逆
#### `dws chat search`
**event_lookup_group_001**
@@ -1105,6 +1151,20 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然
- Expected: `dws chat search --query "项目群" --format json`
- Flags: `--query` = `"项目群"`
#### `dws event consume` 多事件
**event_event_consume_many_user_001**
- Prompt: 同时监听我和 userId test-user-001 的单聊消息、已读和撤回事件
- Expected: `dws event consume user_im_message_receive_o2o user_im_message_read_o2o user_im_message_recall_o2o --user test-user-001 --flatten -f ndjson`
- Flags: `--user` = `test-user-001`
- Contract: 保存每条 `[event] subscription` 的 subscribe ID,等待 `[event] ready event_count=3` 后处理 stdout;停止一个 subscribe ID 时其余两个继续监听
**event_event_consume_many_group_001**
- Prompt: 同时监听 openConversationId cid123 的群消息、群改名和群解散事件
- Expected: `dws event consume user_im_message_receive_group user_im_group_updated user_im_group_disbanded --group cid123 --flatten -f ndjson`
- Flags: `--group` = `cid123`
- Contract: 同一群使用一个多事件进程;不同群或不同过滤条件必须拆成多个 consume 进程
#### `dws event status`
**event_event_status_001**
+37
View File
@@ -104,6 +104,15 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) {
"operation_type",
"dws chat message download-media",
"--open-dingtalk-id",
"user_im_message_receive_o2o_all",
"user_im_message_receive_group_all",
"user_im_group_updated",
"user_im_group_member_added",
"user_im_group_member_exited",
"user_im_group_disbanded",
"operator_open_dingtalk_id",
"members",
"open_dingtalk_id",
} {
if !strings.Contains(text, required) {
t.Errorf("%s missing event contract %q", path, required)
@@ -121,6 +130,34 @@ func TestEventSkillUsesFlatOutputContract(t *testing.T) {
}
}
func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) {
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("runtime.Caller(0) failed")
}
root := filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
paths := []string{
filepath.Join(root, "skills", "mono", "SKILL.md"),
filepath.Join(root, "skills", "multi", "dingtalk-event", "SKILL.md"),
}
for _, path := range paths {
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
parts := strings.SplitN(string(content), "---", 3)
if len(parts) != 3 {
t.Fatalf("%s missing YAML frontmatter", path)
}
frontmatter := parts[1]
for _, required := range []string{"个人 IM 事件", "群成员加入", "群成员退出"} {
if !strings.Contains(frontmatter, required) {
t.Errorf("%s frontmatter missing event discovery trigger %q", path, required)
}
}
}
}
func hasAny(s string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(s, needle) {