Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
258e7ed872 | ||
|
|
69d813afef | ||
|
|
00305d941d | ||
|
|
ec7fdb0f0d | ||
|
|
a8e83e5e7e | ||
|
|
488d90b73c | ||
|
|
2c10be2a1a | ||
|
|
3985c4c98f | ||
|
|
196bf929c1 | ||
|
|
2dfc39f0d3 | ||
|
|
97a16c43b4 | ||
|
|
afe7d860ff | ||
|
|
63b3e72fad | ||
|
|
984529b4cb | ||
|
|
298ea5b341 | ||
|
|
5c01ae845f | ||
|
|
36b58d08b0 | ||
|
|
3749c6b793 | ||
|
|
e2390c3385 | ||
|
|
125bc88d52 | ||
|
|
6b9fcf9289 | ||
|
|
5a3617c21d | ||
|
|
1c9b09b23f | ||
|
|
4cded1ef6c | ||
|
|
0182060757 | ||
|
|
c9cf1cc9c1 | ||
|
|
b234015e7f | ||
|
|
ca6e520610 | ||
|
|
b731050dad | ||
|
|
bb2dd2ba76 | ||
|
|
049af9fc30 | ||
|
|
d19a15a6ed | ||
|
|
e85d9bc314 |
@@ -8,6 +8,7 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
### Added
|
||||
|
||||
- **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** — 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.
|
||||
|
||||
## [1.0.55-beta.4] - 2026-07-27
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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 交流群二维码】
|
||||
|
||||
遇到问题来群里喊一声,官方同学在线答疑。下一篇想看什么?批量备份知识库?给知识库做权限审计?留言区点菜!🍻
|
||||
@@ -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) |
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+237
-162
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 查询/发送命令"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -40,6 +40,8 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
|
||||
switch product + "/" + tool {
|
||||
case "contact/search_contact_by_key_word":
|
||||
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":"项目冲刺"}]}`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(待办任务)
|
||||
|
||||
@@ -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` 重试一次并按原始服务端错误分类;禁止轮询尝试整张渠道表。
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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 需对文档有"编辑"权限。
|
||||
|
||||
|
||||
@@ -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 <表达式>` 可进一步过滤或投影扁平输出。
|
||||
|
||||
@@ -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 字段
|
||||
|
||||
@@ -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 需对文档有"编辑"权限。
|
||||
|
||||
|
||||
@@ -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`,排查后去掉。
|
||||
|
||||
@@ -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
@@ -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**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user