Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7849116a69 | ||
|
|
b082135e6e | ||
|
|
9ae0191270 | ||
|
|
211a5fa393 | ||
|
|
af199e73e2 | ||
|
|
fd24619437 | ||
|
|
6337058d15 | ||
|
|
d38868c8aa | ||
|
|
de8040ecc2 | ||
|
|
96f406be6b | ||
|
|
0d99d18acc | ||
|
|
9377abc5f6 | ||
|
|
e8ca78fe49 | ||
|
|
b923f522d5 | ||
|
|
28bc577e88 | ||
|
|
82dfee7291 | ||
|
|
1d2edbaa9f | ||
|
|
25a5f5b7d2 | ||
|
|
0b43905697 | ||
|
|
28227b19c7 | ||
|
|
622632908e | ||
|
|
2ec25ebb98 | ||
|
|
bccc9eb056 | ||
|
|
2274fd96f0 | ||
|
|
10fe258e4b | ||
|
|
22ab166c9b | ||
|
|
d3e444cb56 | ||
|
|
0148ad1800 | ||
|
|
4a4a1e0407 | ||
|
|
e38fd9ab93 | ||
|
|
fb33a0b9e0 | ||
|
|
e94c7063ed | ||
|
|
d6b51a04f4 | ||
|
|
cd3a09e153 | ||
|
|
2f925d29fd | ||
|
|
68483f05b2 | ||
|
|
730d3fa27f | ||
|
|
6eb3efa065 | ||
|
|
783e1eeef9 | ||
|
|
9e0a67f728 | ||
|
|
19f9285f8c | ||
|
|
c295027e84 | ||
|
|
3817ac230d | ||
|
|
75b54a9467 | ||
|
|
24437fc1a5 | ||
|
|
851d491d2a | ||
|
|
b55f243780 | ||
|
|
257ac94fb1 | ||
|
|
9834a84888 | ||
|
|
a68c06540c | ||
|
|
44c5ef13b4 | ||
|
|
6f73e5187a | ||
|
|
b70e109e89 | ||
|
|
3fc144a699 | ||
|
|
4d274c9da3 | ||
|
|
7ffb48c9ae | ||
|
|
a24fd542c0 | ||
|
|
b8418b6a5f | ||
|
|
af71efd253 | ||
|
|
8c19b0048b | ||
|
|
a9751fa74d | ||
|
|
8a0bd34e13 | ||
|
|
5a160cefd8 | ||
|
|
a9c0e0409c | ||
|
|
9616441e54 | ||
|
|
08ee5dc573 | ||
|
|
5c45bd57da | ||
|
|
349537e336 | ||
|
|
b243b38d65 | ||
|
|
819355b31f | ||
|
|
c3d4de52a7 | ||
|
|
0e14f69aae | ||
|
|
9f14035483 | ||
|
|
f478b7d3e1 | ||
|
|
d84c73e8b2 | ||
|
|
8daf5c71cd | ||
|
|
431f64be85 | ||
|
|
d979d86fa3 | ||
|
|
b150911da9 | ||
|
|
37d6a4ea2e | ||
|
|
20c8e0dfec | ||
|
|
cde050f146 | ||
|
|
e02410dae6 | ||
|
|
74d31566ff | ||
|
|
6765a74d83 | ||
|
|
13e5914638 | ||
|
|
8fcc6baee0 | ||
|
|
6774d423b7 | ||
|
|
8156528c05 | ||
|
|
e5a60386c6 | ||
|
|
1ee37ec4c2 | ||
|
|
06b0a9eef3 | ||
|
|
83f72377a7 | ||
|
|
50712d3305 | ||
|
|
7e27fa384a | ||
|
|
8bf6c15fad | ||
|
|
f79a6fc707 | ||
|
|
5a09204bf5 | ||
|
|
0d11b2be45 | ||
|
|
effe7c829e | ||
|
|
7c76dfea4b | ||
|
|
c803cf7eeb | ||
|
|
832d3ab886 | ||
|
|
47f303d3fc | ||
|
|
1199240a36 | ||
|
|
354d39a6f1 | ||
|
|
6704eda83a | ||
|
|
b581426488 | ||
|
|
703406df13 | ||
|
|
753d538140 | ||
|
|
f890dda7e7 | ||
|
|
c870d2ebdc |
@@ -148,6 +148,7 @@ jobs:
|
||||
filename === '.github/actionlint.yaml' ||
|
||||
filename.startsWith('scripts/') ||
|
||||
filename.startsWith('verify/') ||
|
||||
filename.startsWith('internal/helpers/') ||
|
||||
filename.startsWith('internal/generator/') ||
|
||||
filename.startsWith('internal/cli/schema') ||
|
||||
filename.startsWith('internal/interfacesnapshot/') ||
|
||||
|
||||
@@ -8,11 +8,30 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
### Added
|
||||
|
||||
- **Aitable workflow execution and history** — adds `dws aitable workflow run` for confirmed asynchronous execution of scheduled or record-triggered workflows, plus `dws aitable workflow history` for status-, time-, and page-filtered execution records. The commands map directly to `aitable/run_workflow` and `aitable/get_flow_record_list`, validate trigger-specific arguments locally, and document the `executionId` / `instanceId` correlation.
|
||||
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
|
||||
targets to the initial card-creation request and prepending its returned
|
||||
`atTag` to the automatic streaming update.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Machine-readable export and download receipts** — `dws doc export`,
|
||||
`dws drive download`, and `dws drive download --version` now keep progress
|
||||
logs on stderr under `--format json` and emit one JSON result on stdout after
|
||||
a successful local write. The result includes the saved path and byte size;
|
||||
document exports additionally report the node, requested format, job/task
|
||||
ID, and final status.
|
||||
### Changed
|
||||
|
||||
- **Minutes `permission apply --policy` type** — `--policy` is now declared as
|
||||
an `int` flag and its required check uses `Flags().Changed`, matching the
|
||||
numeric-parameter convention. `--help` reports `int` instead of `string`;
|
||||
accepted values (2/3/4) and gateway behavior are unchanged.
|
||||
- **Minutes skill references** — document `permission apply` in both Minutes
|
||||
skill references: list it in the command trees, describe its policy values and
|
||||
how it differs from `permission add`, and add its intent routing.
|
||||
|
||||
## [1.0.58-beta.2] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -471,7 +471,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 covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
|
||||
`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, group lifecycle events, and six OA approval task/instance 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`.
|
||||
|
||||
@@ -481,28 +481,33 @@ For an event-focused installation, use the official convenience installer:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
|
||||
# Or install the standalone multi skill from an existing dws installation
|
||||
dws skill setup --mode multi -s event
|
||||
```
|
||||
|
||||
```bash
|
||||
# Inspect the public personal event catalog and schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event list --category oa
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
|
||||
# Listen for messages that mention the current user
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
dws event +listen-im --kind at-me -f ndjson
|
||||
|
||||
# Listen for one-to-one messages with a specified user
|
||||
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
|
||||
# Listen for messages from a specified sender
|
||||
dws event +listen-im --kind sender --user <userId> -f ndjson
|
||||
|
||||
# Listen by openDingtalkId (external contact, bot, or cross-organization identity)
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
|
||||
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
dws event +listen-im --kind group --chat-id <openConversationId> -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
|
||||
dws event +listen-im --kind all-direct -f ndjson
|
||||
dws event +listen-im --kind all-group -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
|
||||
@@ -510,14 +515,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
|
||||
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
|
||||
# Listen for messages, reads, and recalls from the same sender in one process
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# Listen for all six public OA approval events 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
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
@@ -536,7 +546,7 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
+26
-16
@@ -465,7 +465,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。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -475,28 +475,33 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-event.sh | sh
|
||||
|
||||
# 或在已有 dws 环境中安装独立的 multi skill
|
||||
dws skill setup --mode multi -s event
|
||||
```
|
||||
|
||||
```bash
|
||||
# 查看公开个人事件目录和 schema
|
||||
dws event list
|
||||
dws event schema user_im_message_receive_o2o --flatten
|
||||
dws event list --category oa
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
|
||||
# 监听当前用户被 @ 的消息
|
||||
dws event consume user_im_message_receive_at --flatten -f ndjson
|
||||
dws event +listen-im --kind at-me -f ndjson
|
||||
|
||||
# 监听与指定用户的单聊消息
|
||||
dws event consume user_im_message_receive_o2o --user <userId> --flatten -f ndjson
|
||||
# 监听指定发送人的消息
|
||||
dws event +listen-im --kind sender --user <userId> -f ndjson
|
||||
|
||||
# 使用 openDingtalkId 监听外部联系人、机器人或跨组织身份
|
||||
dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId> --flatten -f ndjson
|
||||
dws event +listen-im --kind sender --open-dingtalk-id <openDingtalkId> -f ndjson
|
||||
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
dws event +listen-im --kind group --chat-id <openConversationId> -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 +listen-im --kind all-direct -f ndjson
|
||||
dws event +listen-im --kind all-group -f ndjson
|
||||
|
||||
# 监听指定群标题变更、成员进退群或群解散
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
@@ -504,14 +509,19 @@ dws event consume user_im_group_member_added --group <openConversationId> --flat
|
||||
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 +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# 一个进程监听全部六个公开 OA 审批事件
|
||||
dws event consume \
|
||||
user_im_message_receive_o2o \
|
||||
user_im_message_read_o2o \
|
||||
user_im_message_recall_o2o \
|
||||
--user <userId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
@@ -530,7 +540,7 @@ dws event stop <subscribe_id>
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageChatMessageReceiptActionsBindToRunnableCommands(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
payload map[string]any
|
||||
}{
|
||||
{
|
||||
name: "send receipt awaiting status",
|
||||
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
|
||||
"openTaskId": "task-pending",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "send receipt ready for message actions",
|
||||
payload: chatmsg.ProjectMessageSendReceipt(map[string]any{
|
||||
"openTaskId": "task-ready",
|
||||
"openMessageId": "message-ready",
|
||||
"openConversationId": "conversation-ready",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "send status awaiting message reference",
|
||||
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
|
||||
"status": "PENDING",
|
||||
}, "task-pending"),
|
||||
},
|
||||
{
|
||||
name: "send status ready for message actions",
|
||||
payload: chatmsg.ProjectMessageSendStatus(map[string]any{
|
||||
"openTaskId": "task-ready",
|
||||
"openMessageId": "message-ready",
|
||||
"openConversationId": "conversation-ready",
|
||||
"status": "SUCCESS",
|
||||
}, "task-ready"),
|
||||
},
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
actions, ok := testCase.payload["nextActions"].([]map[string]any)
|
||||
if !ok || len(actions) == 0 {
|
||||
t.Fatalf("nextActions = %#v, want non-empty []map[string]any", testCase.payload["nextActions"])
|
||||
}
|
||||
|
||||
for index, action := range actions {
|
||||
cliPath, ok := action["cliPath"].(string)
|
||||
if !ok || strings.TrimSpace(cliPath) == "" {
|
||||
t.Fatalf("nextActions[%d].cliPath = %#v, want non-empty string", index, action["cliPath"])
|
||||
}
|
||||
|
||||
command, remaining, err := root.Find(strings.Fields(cliPath))
|
||||
if err != nil {
|
||||
t.Fatalf("nextActions[%d].cliPath %q does not bind: %v", index, cliPath, err)
|
||||
}
|
||||
if command == nil || len(remaining) != 0 || !command.Runnable() {
|
||||
t.Fatalf("nextActions[%d].cliPath %q resolved to command=%v remaining=%v runnable=%v", index, cliPath, command, remaining, command != nil && command.Runnable())
|
||||
}
|
||||
|
||||
arguments, ok := action["arguments"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nextActions[%d].arguments = %#v, want map[string]any", index, action["arguments"])
|
||||
}
|
||||
for name := range arguments {
|
||||
if command.Flags().Lookup(name) == nil && command.InheritedFlags().Lookup(name) == nil {
|
||||
t.Errorf("nextActions[%d] argument %q is not a flag of runnable command %q", index, name, cliPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -80,12 +80,12 @@ func newEventCommand() *cobra.Command {
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "event",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
|
||||
AgentSummary: "实时监听当前用户相关的个人 IM 与 OA 审批事件,并管理订阅生命周期",
|
||||
UseWhen: []string{
|
||||
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
|
||||
"需要实时监听未来发生的个人消息、消息动作、群生命周期或 OA 审批任务/实例事件,或管理个人事件订阅生命周期",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
|
||||
"查历史聊天或主动发消息用 chat;查询或处理审批实例/任务用 oa;配置开放平台应用事件回调用 dev app event",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -333,7 +333,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
f.BoolVar(&force, "force", false,
|
||||
"仅 --foreground 模式生效:跳过单实例锁 (慎用:会让云事件被随机切分)")
|
||||
f.BoolVar(&dryRun, "dry-run", false,
|
||||
"仅打印解析后的配置,不连接 bus / 云端")
|
||||
"仅打印解析后的配置;不创建订阅、不连接 bus;复用 --subscribe-id 时会只读查询控制面")
|
||||
f.BoolVar(&foreground, "foreground", false,
|
||||
"当前进程直接跑 bus 服务、不 fork、不打印事件(给 systemd/k8s 托管用);读事件不要用它")
|
||||
f.StringVar(&personalOpts.SubscribeID, "subscribe-id", "",
|
||||
@@ -398,22 +398,21 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"用户明确要求监听当前身份的所有单聊或所有群消息",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
|
||||
"同一目标、同一过滤条件需要同时监听多个兼容事件",
|
||||
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"普通 @我、指定发送人/群、全部单聊/群聊及 message/reaction/read/recall 监听优先使用 event +listen-im",
|
||||
"只查历史聊天记录时用 chat 查询命令",
|
||||
"查询、同意、拒绝、转交、撤销或发起审批时用 oa;配置应用事件回调时用 dev app event",
|
||||
"只看事件目录/字段时用 event list / event schema",
|
||||
},
|
||||
Examples: []string{
|
||||
"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_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson",
|
||||
"dws event consume user_oa_approval_task_created user_oa_approval_instance_finished --flatten --duration 10m --format ndjson",
|
||||
"dws event consume user_im_group_member_added --group cid-example --flatten --max-events 1 --format ndjson",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -71,7 +71,7 @@ func newEventListenIMCommand() *cobra.Command {
|
||||
Use: "+listen-im",
|
||||
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
|
||||
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
|
||||
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
|
||||
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期;本命令只处理 IM,不接收 OA 审批事件。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
@@ -143,12 +143,12 @@ func newEventListenIMCommand() *cobra.Command {
|
||||
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
|
||||
AgentSummary: "按 @我、发送人、群或全量范围监听普通 IM message/reaction/read/recall 事件",
|
||||
UseWhen: []string{
|
||||
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
|
||||
"OA 审批事件、群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层控制使用 event consume;只查历史消息使用 chat 查询入口",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws event +listen-im --kind at-me --max-events 1",
|
||||
|
||||
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount != 16 {
|
||||
t.Fatalf("public personal events = %d, want 16", publicCount)
|
||||
if publicCount != 22 {
|
||||
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
|
||||
}
|
||||
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
|
||||
if !ruleTypes[ruleType] {
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
@@ -201,12 +202,15 @@ func newEventSchemaCommand() *cobra.Command {
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
|
||||
UseWhen: []string{"已知任一公开个人 IM 或 OA event_key,消费前需要理解 --flatten 输出字段或 payload 契约"},
|
||||
AvoidWhen: []string{
|
||||
"查询 CLI 命令参数契约时用顶层 dws schema",
|
||||
"要实际收事件时用 event consume",
|
||||
},
|
||||
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
|
||||
Examples: []string{
|
||||
"dws event schema user_im_message_receive_at --flatten --format json",
|
||||
"dws event schema user_oa_approval_task_created --flatten --format json",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -262,6 +266,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
rawFormat = opts.Common.FormatRaw
|
||||
@@ -290,11 +297,18 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
if opts.Common.DryRun {
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
if err := validatePersonalSubscriptionOptions(opts); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
} else {
|
||||
_, eventKey, _, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
opts.EventKey = eventKey
|
||||
}
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
@@ -369,7 +383,6 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
}
|
||||
}
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
var attempt *personalSubscriptionAttemptReservation
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
attempt, err = reservePersonalSubscriptionAttempts(
|
||||
@@ -408,9 +421,17 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
cleanup := func(cleanupCtx context.Context) {
|
||||
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
ownsSubscription := selfCreated || opts.Ephemeral
|
||||
var cleanupOnce sync.Once
|
||||
cleanupOwnedSubscription := func(cleanupCtx context.Context) {
|
||||
if !ownsSubscription {
|
||||
return
|
||||
}
|
||||
cleanupOnce.Do(func() {
|
||||
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
})
|
||||
}
|
||||
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
@@ -421,19 +442,19 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
wrapped := fmt.Errorf("save run state: %w", err)
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, wrapped) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
if attempt != nil {
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, wrapped) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
classification := personalSubscriptionLocalFailure()
|
||||
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
|
||||
cleanup(cleanupCtx)
|
||||
}
|
||||
cleanupOwnedSubscription(cleanupCtx)
|
||||
return fmt.Errorf("event consume --as user: %w", wrapped)
|
||||
}
|
||||
if err := attempt.completeSuccess(); err != nil {
|
||||
cleanup(context.Background())
|
||||
cleanupOwnedSubscription(context.Background())
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
// Ownership-based cleanup: a subscription this run CREATED is
|
||||
@@ -442,9 +463,8 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
// leaks server-side. A subscription REUSED via --subscribe-id is left
|
||||
// intact — the caller owns its lifecycle. --ephemeral forces cleanup
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup(context.Background())
|
||||
if ownsSubscription {
|
||||
defer cleanupOwnedSubscription(context.Background())
|
||||
}
|
||||
|
||||
cfg.EventKey = eventKey
|
||||
@@ -466,17 +486,9 @@ func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions
|
||||
Source: foregroundSource,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = personalBusRun(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
return personalBusRun(ctx, busCfg)
|
||||
}
|
||||
err = personalConsumeRun(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
return personalConsumeRun(ctx, cfg)
|
||||
}
|
||||
|
||||
type personalMultiSubscription struct {
|
||||
@@ -688,6 +700,9 @@ func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsume
|
||||
if !def.Public {
|
||||
return nil, personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch def.RuleType {
|
||||
case "singleChat", "sender":
|
||||
hasUserScope = true
|
||||
@@ -814,6 +829,9 @@ func applyPersonalConsumeFilters(cfg *consume.Config, opts personalConsumeOption
|
||||
}
|
||||
|
||||
func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
@@ -826,6 +844,37 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func validatePersonalOAOptions(eventKey string, opts personalConsumeOptions) error {
|
||||
changed := personalOAOptionNames(opts)
|
||||
if len(changed) == 0 {
|
||||
return nil
|
||||
}
|
||||
def, ok := personalLookupDefinition(strings.TrimSpace(eventKey))
|
||||
if !ok || def.Category != "oa" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s not supported for OA event %s", strings.Join(changed, ", "), eventKey)
|
||||
}
|
||||
|
||||
func personalOAOptionNames(opts personalConsumeOptions) []string {
|
||||
var changed []string
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "--user", value: opts.UserID},
|
||||
{name: "--open-dingtalk-id", value: opts.OpenDingTalkID},
|
||||
{name: "--group", value: opts.GroupID},
|
||||
{name: "--query", value: opts.QueryCSV},
|
||||
{name: "--filter-json", value: opts.FilterJSON},
|
||||
} {
|
||||
if strings.TrimSpace(item.value) != "" {
|
||||
changed = append(changed, item.name)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
type personalPreparedSubscription struct {
|
||||
EventKey string
|
||||
RuleType string
|
||||
@@ -839,6 +888,9 @@ func preparePersonalSubscription(identity personal.Identity, opts personalConsum
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
if err := validatePersonalOAOptions(opts.EventKey, opts); err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
@@ -885,13 +937,32 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
eventKey := firstNonEmptyPersonalString(opts.EventKey, sub.EventKey)
|
||||
if sub == nil {
|
||||
return nil, "", "", errors.New("personal event: server returned an empty subscription")
|
||||
}
|
||||
requestedEventKey := strings.TrimSpace(opts.EventKey)
|
||||
actualEventKey := strings.TrimSpace(sub.EventKey)
|
||||
if requestedEventKey != "" && actualEventKey != "" && requestedEventKey != actualEventKey {
|
||||
return nil, "", "", fmt.Errorf(
|
||||
"event_key %q does not match reused subscription %q event_key %q",
|
||||
requestedEventKey,
|
||||
strings.TrimSpace(opts.SubscribeID),
|
||||
actualEventKey,
|
||||
)
|
||||
}
|
||||
eventKey := actualEventKey
|
||||
if eventKey == "" {
|
||||
eventKey = requestedEventKey
|
||||
}
|
||||
if eventKey == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required when --subscribe-id lookup returns no event_key")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(eventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if err := validatePersonalOAOptions(eventKey, opts); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType := firstNonEmptyPersonalString(sub.RuleType, opts.Rule)
|
||||
if ruleType == "" {
|
||||
if def, ok := personal.Lookup(eventKey); ok {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/source"
|
||||
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -36,18 +37,20 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
|
||||
}
|
||||
}
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldCreate := personalCreateSubscription
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalCreateSubscription = oldCreate
|
||||
})
|
||||
testseam.Protect(t, &personalGetSubscription)
|
||||
testseam.Protect(t, &personalCreateSubscription)
|
||||
client := personal.NewClient("https://example.test", personal.Identity{})
|
||||
wantErr := errors.New("subscription")
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) { return nil, wantErr }
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("get subscription error = %v", err)
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return nil, nil
|
||||
}
|
||||
if _, _, _, err := ensurePersonalSubscription(context.Background(), client, personal.Identity{}, personalConsumeOptions{SubscribeID: "sub"}); err == nil || !strings.Contains(err.Error(), "empty subscription") {
|
||||
t.Fatalf("nil subscription = %v", err)
|
||||
}
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
|
||||
list := newEventListCommand()
|
||||
list.SilenceUsage = true
|
||||
list.SilenceErrors = true
|
||||
var listOut bytes.Buffer
|
||||
list.SetOut(&listOut)
|
||||
list.SetArgs([]string{"--category", "oa"})
|
||||
if err := list.Execute(); err != nil {
|
||||
t.Fatalf("event list --category oa error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
eventKey string
|
||||
properties []string
|
||||
}{
|
||||
{
|
||||
eventKey: personal.EventOAApprovalTaskCreated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalTaskFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalTaskRedirected,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceStarted,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "result", "create_time", "finish_time",
|
||||
"event_time",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
eventKey := tt.eventKey
|
||||
if !strings.Contains(listOut.String(), eventKey) {
|
||||
t.Fatalf("OA event list missing %s:\n%s", eventKey, listOut.String())
|
||||
}
|
||||
|
||||
schema := newEventSchemaCommand()
|
||||
schema.SilenceUsage = true
|
||||
schema.SilenceErrors = true
|
||||
var schemaOut bytes.Buffer
|
||||
schema.SetOut(&schemaOut)
|
||||
schema.SetArgs([]string{eventKey, "--flatten"})
|
||||
if err := schema.Execute(); err != nil {
|
||||
t.Fatalf("event schema %s --flatten error = %v", eventKey, err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(schemaOut.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("decode schema for %s: %v\n%s", eventKey, err, schemaOut.String())
|
||||
}
|
||||
if doc["event_key"] != eventKey || doc["rule_type"] != "all" || doc["jq_root_path"] != "." {
|
||||
t.Fatalf("schema document for %s = %#v", eventKey, doc)
|
||||
}
|
||||
schemaBody, ok := doc["schema"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("schema body for %s = %#v", eventKey, doc["schema"])
|
||||
}
|
||||
properties, ok := schemaBody["properties"].(map[string]any)
|
||||
if !ok || len(properties) != len(tt.properties) {
|
||||
t.Fatalf("schema properties for %s = %#v, want %d fields", eventKey, schemaBody["properties"], len(tt.properties))
|
||||
}
|
||||
for _, name := range tt.properties {
|
||||
if _, ok := properties[name].(map[string]any); !ok {
|
||||
t.Fatalf("schema property %s for %s = %#v", name, eventKey, properties[name])
|
||||
}
|
||||
}
|
||||
if _, ok := properties["payload"]; ok {
|
||||
t.Fatalf("schema for %s exposed generic payload: %#v", eventKey, properties)
|
||||
}
|
||||
}
|
||||
if strings.Contains(listOut.String(), personal.EventMention) {
|
||||
t.Fatalf("OA category list leaked IM event:\n%s", listOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalGetSubscription = oldGet
|
||||
})
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
LocalSubject: "subject",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
}, nil
|
||||
}
|
||||
personalGetSubscription = func(_ *personal.Client, _ context.Context, subscribeID string) (*personal.Subscription, error) {
|
||||
switch subscribeID {
|
||||
case "oa-sub-task":
|
||||
return &personal.Subscription{
|
||||
SubscribeID: subscribeID,
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
case "im-sub-at":
|
||||
return &personal.Subscription{
|
||||
SubscribeID: subscribeID,
|
||||
EventKey: personal.EventMention,
|
||||
RuleType: "at",
|
||||
}, nil
|
||||
default:
|
||||
t.Fatalf("unexpected subscription lookup %q", subscribeID)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
oaEvents := []string{
|
||||
personal.EventOAApprovalTaskCreated,
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
for _, eventKey := range oaEvents {
|
||||
t.Run(eventKey+"/dry-run", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{eventKey, "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("OA dry-run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "event_types : "+eventKey) {
|
||||
t.Fatalf("OA dry-run does not select %s:\n%s", eventKey, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"--user", "user-1"},
|
||||
{"--open-dingtalk-id", "open-user-1"},
|
||||
{"--group", "cid-1"},
|
||||
{"--query", "urgent"},
|
||||
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
|
||||
} {
|
||||
name := strings.TrimPrefix(args[0], "--")
|
||||
t.Run(eventKey+"/reject-"+name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(append([]string{eventKey}, append(args, "--dry-run")...))
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported") {
|
||||
t.Fatalf("OA consume %s error = %v, want unsupported option", args[0], err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("multi-dry-run", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs(append(append([]string(nil), oaEvents...), "--dry-run"))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("multi OA dry-run error = %v", err)
|
||||
}
|
||||
for _, eventKey := range oaEvents {
|
||||
want := "event_key=" + eventKey + " rule_type=all rule_param={}"
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Fatalf("multi OA dry-run missing %q:\n%s", want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
reuseOverrides := [][]string{
|
||||
{"--user", "user-1"},
|
||||
{"--open-dingtalk-id", "open-user-1"},
|
||||
{"--group", "cid-1"},
|
||||
{"--query", "urgent"},
|
||||
{"--filter-json", `{"field":"content","op":"eq","value":"urgent"}`},
|
||||
}
|
||||
t.Run("reuse-dry-run/implicit-event-key/resolves-oa-event", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("implicit reused OA dry-run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "event_types : "+personal.EventOAApprovalTaskCreated) {
|
||||
t.Fatalf("implicit reused OA dry-run did not resolve event key:\n%s", stderr.String())
|
||||
}
|
||||
})
|
||||
for _, explicitEventKey := range []bool{true, false} {
|
||||
mode := "implicit-event-key"
|
||||
if explicitEventKey {
|
||||
mode = "explicit-event-key"
|
||||
}
|
||||
for _, override := range reuseOverrides {
|
||||
flag := override[0]
|
||||
t.Run("reuse-dry-run/"+mode+"/"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
|
||||
args := make([]string, 0, 6)
|
||||
if explicitEventKey {
|
||||
args = append(args, personal.EventOAApprovalTaskCreated)
|
||||
}
|
||||
args = append(args, "--subscribe-id", "oa-sub-task", flag, override[1], "--dry-run")
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), flag+" not supported for OA event") {
|
||||
t.Fatalf("%s reused OA dry-run %s error = %v", mode, flag, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("reuse-dry-run/implicit-im-remains-supported", func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--subscribe-id", "im-sub-at", "--query", "urgent", "--dry-run"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("implicit reused IM dry-run error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
for _, override := range reuseOverrides {
|
||||
flag, value := override[0], override[1]
|
||||
t.Run("multi-reject-"+strings.TrimPrefix(flag, "--"), func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
args := append([]string(nil), oaEvents...)
|
||||
args = append(args, flag, value, "--dry-run")
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported for OA event") {
|
||||
t.Fatalf("multi OA consume %s error = %v", flag, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "message query remains supported",
|
||||
args: []string{personal.EventMention, "--query", "urgent", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "single group lifecycle filter remains supported",
|
||||
args: []string{
|
||||
personal.EventGroupUpdated,
|
||||
"--group", "cid-1",
|
||||
"--filter-json", `{"field":"future","op":"eq","value":"value"}`,
|
||||
"--dry-run",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs(test.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("existing IM consume behavior changed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalOAValidationBranches(t *testing.T) {
|
||||
invalid := personalConsumeOptions{
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
UserID: "user-1",
|
||||
}
|
||||
if err := validatePersonalSubscriptionOptions(invalid); err == nil ||
|
||||
!strings.Contains(err.Error(), "--user not supported for OA event") {
|
||||
t.Fatalf("validatePersonalSubscriptionOptions() error = %v", err)
|
||||
}
|
||||
if _, err := preparePersonalSubscription(personal.Identity{}, invalid); err == nil ||
|
||||
!strings.Contains(err.Error(), "--user not supported for OA event") {
|
||||
t.Fatalf("preparePersonalSubscription() error = %v", err)
|
||||
}
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "oa-sub-without-event-key",
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
_, _, _, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
personalConsumeOptions{
|
||||
SubscribeID: "oa-sub-without-event-key",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
UserID: "user-1",
|
||||
},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--user not supported for OA event") {
|
||||
t.Fatalf("ensurePersonalSubscription() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *testing.T) {
|
||||
restoreMany := installPersonalManySeams(t)
|
||||
defer restoreMany()
|
||||
oldCreate := personalCreateSubscription
|
||||
defer func() { personalCreateSubscription = oldCreate }()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
identity := personal.Identity{
|
||||
AccessToken: "token",
|
||||
LocalSubject: "subject",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return identity, nil
|
||||
}
|
||||
var requests []personal.CreateSubscriptionRequest
|
||||
personalCreateSubscription = func(_ *personal.Client, _ context.Context, req personal.CreateSubscriptionRequest) (*personal.Subscription, error) {
|
||||
requests = append(requests, req)
|
||||
return &personal.Subscription{SubscribeID: "sub-" + req.EventKey}, nil
|
||||
}
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
var states []personal.RunState
|
||||
personalUpsertRunState = func(_ string, state personal.RunState) error {
|
||||
states = append(states, state)
|
||||
return nil
|
||||
}
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
runManyCalls := 0
|
||||
var gotSpecs []consume.ConsumerSpec
|
||||
personalConsumeRunMany = func(_ context.Context, _ consume.Config, specs []consume.ConsumerSpec) error {
|
||||
runManyCalls++
|
||||
gotSpecs = append([]consume.ConsumerSpec(nil), specs...)
|
||||
return nil
|
||||
}
|
||||
|
||||
eventKeys := []string{
|
||||
personal.EventOAApprovalTaskCreated,
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
if err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: eventKeys,
|
||||
Flatten: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("multi OA consume error = %v", err)
|
||||
}
|
||||
if runManyCalls != 1 {
|
||||
t.Fatalf("RunMany calls = %d, want one shared-bus consume call", runManyCalls)
|
||||
}
|
||||
if len(requests) != len(eventKeys) || len(states) != len(eventKeys) || len(gotSpecs) != len(eventKeys) {
|
||||
t.Fatalf("requests=%d states=%d specs=%d, want %d each", len(requests), len(states), len(gotSpecs), len(eventKeys))
|
||||
}
|
||||
for i, eventKey := range eventKeys {
|
||||
req := requests[i]
|
||||
if req.EventKey != eventKey || req.RuleType != "all" || req.RuleParam == nil || len(req.RuleParam) != 0 || req.Filter != nil {
|
||||
t.Fatalf("subscription request[%d] = %#v, want %s all/{}", i, req, eventKey)
|
||||
}
|
||||
if states[i].EventKey != eventKey || states[i].RuleType != "all" {
|
||||
t.Fatalf("run state[%d] = %#v", i, states[i])
|
||||
}
|
||||
wantSpec := consume.ConsumerSpec{
|
||||
EventKey: eventKey,
|
||||
EventTypes: []string{eventKey},
|
||||
SubscribeID: "sub-" + eventKey,
|
||||
ReadySubscribeID: "sub-" + eventKey,
|
||||
}
|
||||
if !reflect.DeepEqual(gotSpecs[i], wantSpec) {
|
||||
t.Fatalf("consumer spec[%d] = %#v, want %#v", i, gotSpecs[i], wantSpec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAReusedSubscriptionRejectsDefinitionOverridesAtRuntime(t *testing.T) {
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
getCalls := 0
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
getCalls++
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "oa-sub-task",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(*personalConsumeOptions)
|
||||
}{
|
||||
{name: "user", set: func(opts *personalConsumeOptions) { opts.UserID = "user-1" }},
|
||||
{name: "open-dingtalk-id", set: func(opts *personalConsumeOptions) { opts.OpenDingTalkID = "open-user-1" }},
|
||||
{name: "group", set: func(opts *personalConsumeOptions) { opts.GroupID = "cid-1" }},
|
||||
{name: "query", set: func(opts *personalConsumeOptions) { opts.QueryCSV = "urgent" }},
|
||||
{name: "filter-json", set: func(opts *personalConsumeOptions) { opts.FilterJSON = `{"field":"content","op":"eq","value":"urgent"}` }},
|
||||
}
|
||||
for _, explicitEventKey := range []bool{true, false} {
|
||||
mode := "implicit-event-key"
|
||||
if explicitEventKey {
|
||||
mode = "explicit-event-key"
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(mode+"/"+test.name, func(t *testing.T) {
|
||||
opts := personalConsumeOptions{SubscribeID: "oa-sub-task"}
|
||||
if explicitEventKey {
|
||||
opts.EventKey = personal.EventOAApprovalTaskCreated
|
||||
}
|
||||
test.set(&opts)
|
||||
before := getCalls
|
||||
_, _, _, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
opts,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--"+test.name+" not supported for OA event") {
|
||||
t.Fatalf("reused OA subscription %s error = %v", test.name, err)
|
||||
}
|
||||
if getCalls != before+1 {
|
||||
t.Fatalf("subscription lookup calls = %d, want %d", getCalls, before+1)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAImplicitReuseRuntimeLooksUpEventBeforeValidation(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalGetSubscription = oldGet
|
||||
})
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
LocalSubject: "subject",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
}, nil
|
||||
}
|
||||
getCalls := 0
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
getCalls++
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "oa-sub-task",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"--subscribe-id", "oa-sub-task", "--group", "cid-1"})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--group not supported for OA event "+personal.EventOAApprovalTaskCreated) {
|
||||
t.Fatalf("implicit reused OA runtime error = %v", err)
|
||||
}
|
||||
if getCalls != 1 {
|
||||
t.Fatalf("subscription lookup calls = %d, want 1", getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalIMReusedSubscriptionWithExistingOverridesRemainsSupported(t *testing.T) {
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "im-sub",
|
||||
EventKey: personal.EventSingleChat,
|
||||
RuleType: "singleChat",
|
||||
}, nil
|
||||
}
|
||||
|
||||
sub, eventKey, ruleType, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
personalConsumeOptions{
|
||||
SubscribeID: "im-sub",
|
||||
EventKey: personal.EventSingleChat,
|
||||
UserID: "user-1",
|
||||
QueryCSV: "urgent",
|
||||
FilterJSON: `{"field":"content","op":"eq","value":"urgent"}`,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("reused IM subscription error = %v", err)
|
||||
}
|
||||
if sub.SubscribeID != "im-sub" || eventKey != personal.EventSingleChat || ruleType != "singleChat" {
|
||||
t.Fatalf("reused IM subscription = %#v, event=%q rule=%q", sub, eventKey, ruleType)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalOAStatusAndStopCommandWiring(t *testing.T) {
|
||||
oldStatus := eventRunPersonalStatus
|
||||
oldStop := eventRunPersonalStop
|
||||
t.Cleanup(func() {
|
||||
eventRunPersonalStatus = oldStatus
|
||||
eventRunPersonalStop = oldStop
|
||||
})
|
||||
|
||||
var statusOpts personalStatusOptions
|
||||
eventRunPersonalStatus = func(_ *cobra.Command, opts personalStatusOptions) error {
|
||||
statusOpts = opts
|
||||
return nil
|
||||
}
|
||||
status := newEventStatusCommand()
|
||||
status.SilenceUsage = true
|
||||
status.SilenceErrors = true
|
||||
status.SetOut(io.Discard)
|
||||
status.SetErr(io.Discard)
|
||||
status.SetArgs([]string{
|
||||
"--event", personal.EventOAApprovalTaskCreated,
|
||||
"--subscribe-id", "oa-sub-task",
|
||||
"--status", "all",
|
||||
})
|
||||
if err := status.Execute(); err != nil {
|
||||
t.Fatalf("OA event status error = %v", err)
|
||||
}
|
||||
if statusOpts.EventKey != personal.EventOAApprovalTaskCreated ||
|
||||
statusOpts.SubscribeID != "oa-sub-task" ||
|
||||
statusOpts.Status != "all" {
|
||||
t.Fatalf("OA status options = %#v", statusOpts)
|
||||
}
|
||||
|
||||
var stopOpts personalStopOptions
|
||||
eventRunPersonalStop = func(_ *cobra.Command, opts personalStopOptions) error {
|
||||
stopOpts = opts
|
||||
return nil
|
||||
}
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
event := &cobra.Command{Use: "event"}
|
||||
event.AddCommand(newEventStopCommand())
|
||||
root.AddCommand(event)
|
||||
root.SetArgs([]string{"event", "stop", "oa-sub-task", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("OA event stop error = %v", err)
|
||||
}
|
||||
if stopOpts.SubscribeID != "oa-sub-task" || stopOpts.All {
|
||||
t.Fatalf("OA stop options = %#v", stopOpts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"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/source"
|
||||
)
|
||||
|
||||
func TestPersonalConsumeCleanupOwnershipRuntimeMatrix(t *testing.T) {
|
||||
runErr := errors.New("runtime failed")
|
||||
for _, foreground := range []bool{false, true} {
|
||||
for _, selfCreated := range []bool{false, true} {
|
||||
for _, ephemeral := range []bool{false, true} {
|
||||
for _, failRuntime := range []bool{false, true} {
|
||||
name := strings.Join([]string{
|
||||
map[bool]string{false: "background", true: "foreground"}[foreground],
|
||||
map[bool]string{false: "reused", true: "self-created"}[selfCreated],
|
||||
map[bool]string{false: "persistent", true: "ephemeral"}[ephemeral],
|
||||
map[bool]string{false: "success", true: "error"}[failRuntime],
|
||||
}, "/")
|
||||
t.Run(name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldNewSource := personalNewStreamSource
|
||||
oldBusRun := personalBusRun
|
||||
oldConsumeRun := personalConsumeRun
|
||||
t.Cleanup(func() {
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
personalConsumeRun = oldConsumeRun
|
||||
})
|
||||
|
||||
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) {
|
||||
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
|
||||
deleteCalls := 0
|
||||
removeCalls := 0
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
|
||||
deleteCalls++
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error {
|
||||
removeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
personalBusRun = func(context.Context, bus.Config) error {
|
||||
if failRuntime {
|
||||
return runErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
personalConsumeRun = func(context.Context, consume.Config) error {
|
||||
if failRuntime {
|
||||
return runErr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
Ephemeral: ephemeral,
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
Common: commonConsumeOptions{
|
||||
Foreground: foreground,
|
||||
},
|
||||
}
|
||||
if !selfCreated {
|
||||
opts.SubscribeID = "sub-one"
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts)
|
||||
if failRuntime {
|
||||
if !errors.Is(err, runErr) {
|
||||
t.Fatalf("runtime error = %v, want %v", err, runErr)
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Fatalf("consume error = %v", err)
|
||||
}
|
||||
|
||||
wantCleanup := 0
|
||||
if selfCreated || ephemeral {
|
||||
wantCleanup = 1
|
||||
}
|
||||
if deleteCalls != wantCleanup || removeCalls != wantCleanup {
|
||||
t.Fatalf(
|
||||
"cleanup delete/remove = %d/%d, want %d/%d",
|
||||
deleteCalls,
|
||||
removeCalls,
|
||||
wantCleanup,
|
||||
wantCleanup,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalConsumeCleanupOwnershipOnRunStateFailure(t *testing.T) {
|
||||
stateErr := errors.New("save state failed")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
selfCreated bool
|
||||
ephemeral bool
|
||||
wantCleanup int
|
||||
}{
|
||||
{name: "self-created", selfCreated: true, wantCleanup: 1},
|
||||
{name: "reused persistent", wantCleanup: 0},
|
||||
{name: "reused ephemeral", ephemeral: true, wantCleanup: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(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"}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(
|
||||
context.Context,
|
||||
*personal.Client,
|
||||
personal.Identity,
|
||||
personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-one"}, personal.EventMention, "at", nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return stateErr }
|
||||
deleteCalls := 0
|
||||
removeCalls := 0
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
|
||||
deleteCalls++
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error {
|
||||
removeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
opts := personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
Ephemeral: test.ephemeral,
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
}
|
||||
if !test.selfCreated {
|
||||
opts.SubscribeID = "sub-one"
|
||||
}
|
||||
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), opts); !errors.Is(err, stateErr) {
|
||||
t.Fatalf("state error = %v, want %v", err, stateErr)
|
||||
}
|
||||
if deleteCalls != test.wantCleanup || removeCalls != test.wantCleanup {
|
||||
t.Fatalf(
|
||||
"cleanup delete/remove = %d/%d, want %d/%d",
|
||||
deleteCalls,
|
||||
removeCalls,
|
||||
test.wantCleanup,
|
||||
test.wantCleanup,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalReusedSubscriptionEventKeyResolution(t *testing.T) {
|
||||
oldGet := personalGetSubscription
|
||||
t.Cleanup(func() { personalGetSubscription = oldGet })
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
requested string
|
||||
actual string
|
||||
wantKey string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "matching key uses actual",
|
||||
requested: personal.EventMention,
|
||||
actual: personal.EventMention,
|
||||
wantKey: personal.EventMention,
|
||||
},
|
||||
{
|
||||
name: "implicit key uses actual",
|
||||
actual: personal.EventOAApprovalTaskCreated,
|
||||
wantKey: personal.EventOAApprovalTaskCreated,
|
||||
},
|
||||
{
|
||||
name: "missing actual falls back to requested",
|
||||
requested: personal.EventOAApprovalTaskCreated,
|
||||
wantKey: personal.EventOAApprovalTaskCreated,
|
||||
},
|
||||
{
|
||||
name: "requested IM mismatches actual OA",
|
||||
requested: personal.EventMention,
|
||||
actual: personal.EventOAApprovalTaskCreated,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "requested OA mismatches actual IM",
|
||||
requested: personal.EventOAApprovalTaskCreated,
|
||||
actual: personal.EventMention,
|
||||
wantErr: true,
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "sub-one",
|
||||
EventKey: test.actual,
|
||||
}, nil
|
||||
}
|
||||
_, eventKey, _, err := ensurePersonalSubscription(
|
||||
context.Background(),
|
||||
nil,
|
||||
personal.Identity{},
|
||||
personalConsumeOptions{SubscribeID: "sub-one", EventKey: test.requested},
|
||||
)
|
||||
if test.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
|
||||
t.Fatalf("mismatch error = %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), test.requested) || !strings.Contains(err.Error(), test.actual) {
|
||||
t.Fatalf("mismatch error does not identify both keys: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("resolve reused subscription: %v", err)
|
||||
}
|
||||
if eventKey != test.wantKey {
|
||||
t.Fatalf("resolved event key = %q, want %q", eventKey, test.wantKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalReusedSubscriptionMismatchStopsDryRunAndRuntime(t *testing.T) {
|
||||
for _, mode := range []struct {
|
||||
name string
|
||||
dryRun bool
|
||||
foreground bool
|
||||
}{
|
||||
{name: "dry-run", dryRun: true},
|
||||
{name: "background"},
|
||||
{name: "foreground", foreground: true},
|
||||
} {
|
||||
t.Run(mode.name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldNewSource := personalNewStreamSource
|
||||
oldBusRun := personalBusRun
|
||||
oldConsumeRun := personalConsumeRun
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
personalConsumeRun = oldConsumeRun
|
||||
})
|
||||
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
|
||||
}
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "sub-one",
|
||||
EventKey: personal.EventOAApprovalTaskCreated,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
|
||||
upsertCalls := 0
|
||||
consumeCalls := 0
|
||||
busCalls := 0
|
||||
personalUpsertRunState = func(string, personal.RunState) error {
|
||||
upsertCalls++
|
||||
return nil
|
||||
}
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, nil
|
||||
}
|
||||
personalBusRun = func(context.Context, bus.Config) error {
|
||||
busCalls++
|
||||
return nil
|
||||
}
|
||||
personalConsumeRun = func(context.Context, consume.Config) error {
|
||||
consumeCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
SubscribeID: "sub-one",
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
Common: commonConsumeOptions{
|
||||
DryRun: mode.dryRun,
|
||||
Foreground: mode.foreground,
|
||||
},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match reused subscription") {
|
||||
t.Fatalf("mismatch error = %v", err)
|
||||
}
|
||||
if upsertCalls != 0 || consumeCalls != 0 || busCalls != 0 {
|
||||
t.Fatalf(
|
||||
"mismatch reached upsert/consumer/bus = %d/%d/%d",
|
||||
upsertCalls,
|
||||
consumeCalls,
|
||||
busCalls,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalReusedSubscriptionUsesActualKeyInDryRunAndRuntime(t *testing.T) {
|
||||
for _, dryRun := range []bool{true, false} {
|
||||
name := map[bool]string{false: "runtime", true: "dry-run"}[dryRun]
|
||||
t.Run(name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldGet := personalGetSubscription
|
||||
oldConsumeRun := personalConsumeRun
|
||||
t.Cleanup(func() {
|
||||
personalGetSubscription = oldGet
|
||||
personalConsumeRun = oldConsumeRun
|
||||
})
|
||||
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open"}, nil
|
||||
}
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
personalGetSubscription = func(*personal.Client, context.Context, string) (*personal.Subscription, error) {
|
||||
return &personal.Subscription{
|
||||
SubscribeID: "sub-oa",
|
||||
EventKey: personal.EventOAApprovalInstanceFinished,
|
||||
RuleType: "all",
|
||||
}, nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
personalUpsertRunState = func(_ string, state personal.RunState) error {
|
||||
if state.EventKey != personal.EventOAApprovalInstanceFinished {
|
||||
t.Fatalf("run state event key = %q", state.EventKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var got consume.Config
|
||||
personalConsumeRun = func(_ context.Context, cfg consume.Config) error {
|
||||
got = cfg
|
||||
return nil
|
||||
}
|
||||
if err := runPersonalEventConsumeSingle(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
SubscribeID: "sub-oa",
|
||||
ControlBaseURL: "https://mcp.example.test/dws",
|
||||
Common: commonConsumeOptions{
|
||||
DryRun: dryRun,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("reuse subscription: %v", err)
|
||||
}
|
||||
if got.EventKey != personal.EventOAApprovalInstanceFinished ||
|
||||
len(got.EventTypes) != 1 || got.EventTypes[0] != personal.EventOAApprovalInstanceFinished ||
|
||||
got.SubscribeID != "sub-oa" {
|
||||
t.Fatalf("consume config = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeDryRunHelpDescribesReuseLookup(t *testing.T) {
|
||||
usage := newEventConsumeCommand().Flags().Lookup("dry-run").Usage
|
||||
for _, want := range []string{"不创建订阅", "不连接 bus", "复用 --subscribe-id", "只读查询控制面"} {
|
||||
if !strings.Contains(usage, want) {
|
||||
t.Fatalf("dry-run help %q missing %q", usage, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageEventAgentSelectionBoundaries(t *testing.T) {
|
||||
_ = NewRootCommand()
|
||||
|
||||
eventProduct, ok := contract.LookupProductDecl("event")
|
||||
if !ok {
|
||||
t.Fatal("event ProductDecl is not registered")
|
||||
}
|
||||
assertSelectionContains(t, "event product", eventProduct.Selection.AgentSummary,
|
||||
[]string{"IM", "OA"})
|
||||
assertSelectionContains(t, "event product use_when", strings.Join(eventProduct.Selection.UseWhen, "\n"),
|
||||
[]string{"消息", "群生命周期", "OA"})
|
||||
assertSelectionContains(t, "event product avoid_when", strings.Join(eventProduct.Selection.AvoidWhen, "\n"),
|
||||
[]string{"chat", "oa", "dev app event"})
|
||||
|
||||
listenMeta, ok := cli.ResolveMeta("event +listen-im")
|
||||
if !ok {
|
||||
t.Fatal("event +listen-im metadata is not registered")
|
||||
}
|
||||
assertSelectionContains(t, "event.listen_im use_when", strings.Join(listenMeta.Selection.UseWhen, "\n"),
|
||||
[]string{"@我", "message/reaction/read/recall"})
|
||||
assertSelectionContains(t, "event.listen_im avoid_when", strings.Join(listenMeta.Selection.AvoidWhen, "\n"),
|
||||
[]string{"OA 审批事件", "群标题", "Filter DSL", "event consume", "历史消息"})
|
||||
|
||||
consumeMeta, ok := cli.ResolveMeta("event consume")
|
||||
if !ok {
|
||||
t.Fatal("event consume metadata is not registered")
|
||||
}
|
||||
consumeUse := strings.Join(consumeMeta.Selection.UseWhen, "\n")
|
||||
assertSelectionContains(t, "event.consume use_when", consumeUse,
|
||||
[]string{"OA", "群", "EventKey", "Filter DSL", "subscribe_id", "transport envelope", "高级多事件"})
|
||||
consumeAvoid := strings.Join(consumeMeta.Selection.AvoidWhen, "\n")
|
||||
assertSelectionContains(t, "event.consume avoid_when", consumeAvoid,
|
||||
[]string{"event +listen-im", "历史聊天", "oa", "dev app event"})
|
||||
|
||||
schemaMeta, ok := cli.ResolveMeta("event schema")
|
||||
if !ok {
|
||||
t.Fatal("event schema metadata is not registered")
|
||||
}
|
||||
assertSelectionContains(t, "event.schema use_when", strings.Join(schemaMeta.Selection.UseWhen, "\n"),
|
||||
[]string{"IM", "OA", "--flatten"})
|
||||
|
||||
for productID, want := range map[string]string{
|
||||
"chat": "event +listen-im",
|
||||
"oa": "event consume",
|
||||
} {
|
||||
decl, found := contract.LookupProductDecl(productID)
|
||||
if !found {
|
||||
t.Fatalf("%s ProductDecl is not registered", productID)
|
||||
}
|
||||
assertSelectionContains(t, productID+" avoid_when", strings.Join(decl.Selection.AvoidWhen, "\n"), []string{want})
|
||||
}
|
||||
}
|
||||
|
||||
func assertSelectionContains(t *testing.T, label, text string, fragments []string) {
|
||||
t.Helper()
|
||||
for _, fragment := range fragments {
|
||||
if !strings.Contains(text, fragment) {
|
||||
t.Errorf("%s = %q, want fragment %q", label, text, fragment)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,14 @@ func TestReviewedMutationSafetyReachesFinalSchema(t *testing.T) {
|
||||
wants := []finalSchemaSafetyWant{
|
||||
{canonical: "aitable.form_field_hide", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "idempotent", provenance: declared},
|
||||
{canonical: "chat.dismiss_group", effect: "destructive", risk: "high", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
// Card update intentionally layers confirmation: the atomic typed command
|
||||
// preserves its original contract, while the Agent-facing shortcut owns
|
||||
// the outer confirmation boundary.
|
||||
{canonical: "chat.update_streaming_card", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_send", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_send_by_webhook", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_send_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "chat.shortcut_messages_update_card", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "drive.recycle_restore", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "minutes.create_speaker_summary", effect: "write", risk: "medium", confirmation: "not_required", idempotency: "unknown", provenance: declared},
|
||||
{canonical: "sheet.clear_range", effect: "write", risk: "medium", confirmation: "user_required", idempotency: "unknown", provenance: declared},
|
||||
|
||||
@@ -81,6 +81,7 @@ func newServerFailureAPIError(
|
||||
apperrors.WithReason(fallbackReason),
|
||||
apperrors.WithServerKey(serverKey),
|
||||
apperrors.WithHint(fallbackHint),
|
||||
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
|
||||
@@ -95,6 +95,9 @@ func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing
|
||||
if typed.Reason != "business_error" || typed.Origin != "" || typed.FailureStage != "" || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("unexpected fallback classification: %#v", typed)
|
||||
}
|
||||
if len(typed.Actions) == 0 || !strings.Contains(typed.Actions[0], "dws doctor") {
|
||||
t.Fatalf("fallback error has no stable troubleshooting entry: %#v", typed.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *testing.T) {
|
||||
|
||||
+577
-1
@@ -51,6 +51,8 @@ var (
|
||||
skillSetupInstallMono = installSkillToHomes
|
||||
skillSetupInstallMulti = installMultiSkillToHomes
|
||||
skillSetupCopyDir = copyDir
|
||||
skillSetupMkdirTemp = os.MkdirTemp
|
||||
skillSetupRename = os.Rename
|
||||
skillSetupRunForm = (*huh.Form).Run
|
||||
skillSetupInteractive = isInteractiveTerminal
|
||||
skillSetupReadDir = os.ReadDir
|
||||
@@ -136,6 +138,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
// multi 模式枚举 src 下的子 skill 名,供确认信息与安装步骤共用
|
||||
var multiSkillNames []string
|
||||
var foldedEventMiscTargets []string
|
||||
var migrateEventMiscTargets []string
|
||||
var installsEventMiscCompanion bool
|
||||
if mode == skillSetupModeMulti {
|
||||
allMultiSkillNames, listErr := skillSetupListMulti(skillSrc)
|
||||
if listErr != nil {
|
||||
@@ -151,6 +156,30 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
// dingtalk-shared carries the global rules every product skill declares as a
|
||||
// PREREQUISITE; it must ship even when --skill / --exclude narrows the set.
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
|
||||
foldedEventMiscTargets = findFoldedEventMiscTargets(dests)
|
||||
if len(foldedEventMiscTargets) > 0 {
|
||||
hasEvent := containsSkillName(multiSkillNames, multiEventSkill)
|
||||
hasMisc := containsSkillName(multiSkillNames, multiMiscSkill)
|
||||
switch {
|
||||
case normalizedSkillListContains(excludeRaw, multiEventSkill):
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能显式 --exclude event,请先完成 dingtalk-event 迁移")
|
||||
case hasMisc && !hasEvent:
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;不能只覆盖 dingtalk-misc,必须同时迁移 dingtalk-event")
|
||||
case hasEvent:
|
||||
if normalizedSkillListContains(excludeRaw, multiMiscSkill) {
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由;本次安装 dingtalk-event 必须同时迁移 dingtalk-misc,不能显式 --exclude misc")
|
||||
}
|
||||
if !containsSkillName(allMultiSkillNames, multiMiscSkill) {
|
||||
return fmt.Errorf("检测到已有 dingtalk-misc 仍承载个人 Event 路由,但当前 multi 源缺少迁移所需的 %s", multiMiscSkill)
|
||||
}
|
||||
if err := validateEventMiscMigrationSource(skillSrc); err != nil {
|
||||
return err
|
||||
}
|
||||
migrateEventMiscTargets = append(migrateEventMiscTargets, foldedEventMiscTargets...)
|
||||
installsEventMiscCompanion = !hasMisc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
|
||||
@@ -162,11 +191,15 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
|
||||
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
|
||||
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
if mode == skillSetupModeMulti {
|
||||
printEventMiscMigrationPreview(out, migrateEventMiscTargets, installsEventMiscCompanion)
|
||||
}
|
||||
ok, err := skillSetupConfirm(out, mode, skillSrc, dests, multiSkillNames)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -182,7 +215,14 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
case skillSetupModeMono:
|
||||
installed, skipped, err = skillSetupInstallMono(skillSrc, dests, out, errOut)
|
||||
case skillSetupModeMulti:
|
||||
installed, skipped, err = skillSetupInstallMulti(skillSrc, multiSkillNames, dests, out, errOut)
|
||||
installed, skipped, err = installMultiSkillsWithEventMigration(
|
||||
skillSrc,
|
||||
multiSkillNames,
|
||||
dests,
|
||||
migrateEventMiscTargets,
|
||||
out,
|
||||
errOut,
|
||||
)
|
||||
default:
|
||||
return fmt.Errorf("内部错误:未知 mode %q", mode)
|
||||
}
|
||||
@@ -191,6 +231,7 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped)
|
||||
fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -204,6 +245,226 @@ const multiSkillPrefix = "dingtalk-"
|
||||
// dingtalk-shared that was never installed.
|
||||
const multiSharedSkill = "dingtalk-shared"
|
||||
|
||||
// legacyMultiSharedSkill is the retired name shipped by older multi-skill
|
||||
// bundles. Once the replacement has been installed successfully, remove this
|
||||
// exact directory so Agent discovery cannot load both routing contracts.
|
||||
const legacyMultiSharedSkill = "dws-shared"
|
||||
|
||||
const (
|
||||
multiEventSkill = "dingtalk-event"
|
||||
multiMiscSkill = "dingtalk-misc"
|
||||
)
|
||||
|
||||
var eventMigrationRequiredReferences = []string{
|
||||
"event-im.md",
|
||||
"event-im-keys.md",
|
||||
"event-im-lifecycle.md",
|
||||
"event-im-operations.md",
|
||||
"event-im-output.md",
|
||||
"event-oa.md",
|
||||
}
|
||||
|
||||
func containsSkillName(names []string, want string) bool {
|
||||
for _, name := range names {
|
||||
if name == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizedSkillListContains(raw []string, want string) bool {
|
||||
for _, name := range raw {
|
||||
if normalizeMultiSkillName(name) == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findFoldedEventMiscTargets identifies the short-lived multi-skill layout in
|
||||
// which personal Event routing lived inside dingtalk-misc. Both markers are
|
||||
// required so an unrelated misc install is never treated as a migration target.
|
||||
func findFoldedEventMiscTargets(dests []string) []string {
|
||||
var targets []string
|
||||
for _, dest := range dests {
|
||||
miscRoot := filepath.Join(dest, multiMiscSkill)
|
||||
skillBody, err := os.ReadFile(filepath.Join(miscRoot, "SKILL.md"))
|
||||
if err != nil || !containsPersonalEventRoute(skillBody) {
|
||||
continue
|
||||
}
|
||||
eventRef, err := skillSetupStat(filepath.Join(miscRoot, "references", "event.md"))
|
||||
if err != nil || eventRef.IsDir() {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, dest)
|
||||
}
|
||||
sort.Strings(targets)
|
||||
return targets
|
||||
}
|
||||
|
||||
func containsPersonalEventRoute(skillBody []byte) bool {
|
||||
body := strings.ToLower(string(skillBody))
|
||||
for _, marker := range []string{
|
||||
"dws event",
|
||||
"个人 event",
|
||||
"个人 im 事件",
|
||||
"个人 im/oa",
|
||||
"personal event",
|
||||
} {
|
||||
if strings.Contains(body, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func printEventMiscMigrationPreview(out io.Writer, targets []string, installsCompanion bool) {
|
||||
if len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
action := "将原子切换 dingtalk-event 与本次已选择的干净 dingtalk-misc"
|
||||
if installsCompanion {
|
||||
action = "将原子切换 dingtalk-event,并额外安装干净的 dingtalk-misc 作为迁移伴侣(仅限以下目标)"
|
||||
}
|
||||
fmt.Fprintf(out, "Event Skill 迁移:%s:\n", action)
|
||||
for _, target := range targets {
|
||||
fmt.Fprintf(out, " - %s\n", target)
|
||||
}
|
||||
}
|
||||
|
||||
func validateEventMiscMigrationSource(src string) error {
|
||||
if err := validateEventMigrationSkillRoot(filepath.Join(src, multiEventSkill)); err != nil {
|
||||
return fmt.Errorf("event Skill 迁移源无效: %w", err)
|
||||
}
|
||||
if err := validateMigrationSkillRoot(filepath.Join(src, multiMiscSkill), multiMiscSkill, nil); err != nil {
|
||||
return fmt.Errorf("event Skill 迁移源无效: %w", err)
|
||||
}
|
||||
|
||||
if err := validateCleanEventMiscRoot(filepath.Join(src, multiMiscSkill)); err != nil {
|
||||
return fmt.Errorf("event Skill 迁移源无效: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEventMigrationSkillRoot(root string) error {
|
||||
required := make([]string, 0, len(eventMigrationRequiredReferences))
|
||||
for _, name := range eventMigrationRequiredReferences {
|
||||
required = append(required, filepath.Join("references", name))
|
||||
}
|
||||
return validateMigrationSkillRoot(root, multiEventSkill, required)
|
||||
}
|
||||
|
||||
func validateMigrationSkillRoot(root, expectedName string, requiredFiles []string) error {
|
||||
skillPath := filepath.Join(root, "SKILL.md")
|
||||
skillBody, err := os.ReadFile(skillPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法读取 %s: %w", skillPath, err)
|
||||
}
|
||||
name, err := parseMigrationSkillFrontmatter(skillBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s 无效: %w", skillPath, err)
|
||||
}
|
||||
if name != expectedName {
|
||||
return fmt.Errorf("%s 的 name=%q,期望 %q", skillPath, name, expectedName)
|
||||
}
|
||||
for _, rel := range requiredFiles {
|
||||
path := filepath.Join(root, rel)
|
||||
info, statErr := skillSetupStat(path)
|
||||
if statErr != nil || info.IsDir() {
|
||||
if statErr == nil {
|
||||
statErr = errors.New("is a directory")
|
||||
}
|
||||
return fmt.Errorf("缺少有效文件 %s: %w", path, statErr)
|
||||
}
|
||||
body, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return fmt.Errorf("无法读取 %s: %w", path, readErr)
|
||||
}
|
||||
if strings.TrimSpace(string(body)) == "" {
|
||||
return fmt.Errorf("文件为空 %s", path)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMigrationSkillFrontmatter(body []byte) (string, error) {
|
||||
normalized := strings.ReplaceAll(string(body), "\r\n", "\n")
|
||||
lines := strings.Split(normalized, "\n")
|
||||
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
|
||||
return "", errors.New("缺少 YAML frontmatter")
|
||||
}
|
||||
name := ""
|
||||
description := ""
|
||||
closingLine := -1
|
||||
for i := 1; i < len(lines); i++ {
|
||||
rawLine := lines[i]
|
||||
line := strings.TrimSpace(rawLine)
|
||||
if line == "---" {
|
||||
closingLine = i
|
||||
break
|
||||
}
|
||||
// Only inspect top-level frontmatter keys. Nested metadata may legally
|
||||
// contain its own `name` without changing the Skill identity.
|
||||
if strings.TrimLeft(rawLine, " \t") != rawLine {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
value = strings.Trim(strings.TrimSpace(value), "\"'")
|
||||
switch strings.TrimSpace(key) {
|
||||
case "name":
|
||||
if name != "" {
|
||||
return "", errors.New("frontmatter 含重复 name")
|
||||
}
|
||||
name = value
|
||||
case "description":
|
||||
description = value
|
||||
}
|
||||
}
|
||||
if closingLine < 0 {
|
||||
return "", errors.New("YAML frontmatter 未闭合")
|
||||
}
|
||||
if name == "" {
|
||||
return "", errors.New("frontmatter 缺少 name")
|
||||
}
|
||||
if description == "" {
|
||||
return "", errors.New("frontmatter 缺少 description")
|
||||
}
|
||||
if strings.TrimSpace(strings.Join(lines[closingLine+1:], "\n")) == "" {
|
||||
return "", errors.New("SKILL.md 正文为空")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func validateCleanEventMiscRoot(miscRoot string) error {
|
||||
miscSkillPath := filepath.Join(miscRoot, "SKILL.md")
|
||||
miscBody, err := os.ReadFile(miscSkillPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法读取 %s: %w", miscSkillPath, err)
|
||||
}
|
||||
if containsPersonalEventRoute(miscBody) {
|
||||
return fmt.Errorf("%s 仍包含个人 Event 路由", miscSkillPath)
|
||||
}
|
||||
refsRoot := filepath.Join(miscRoot, "references")
|
||||
entries, err := skillSetupReadDir(refsRoot)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法检查 %s: %w", refsRoot, err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := strings.ToLower(entry.Name())
|
||||
if !entry.IsDir() && strings.HasPrefix(name, "event") && strings.HasSuffix(name, ".md") {
|
||||
return fmt.Errorf("%s 仍存在折叠 Event 参考页", filepath.Join(refsRoot, entry.Name()))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureMandatorySharedSkill guarantees the shared dependency skill is included
|
||||
// whenever it exists in the source, even if --skill / --exclude narrowed it out.
|
||||
func ensureMandatorySharedSkill(selected, all []string) []string {
|
||||
@@ -607,6 +868,21 @@ func cleanupMutualExclusion(dest, mode string, out, errOut io.Writer) {
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupLegacyMultiSharedSkill(dest string, out, errOut io.Writer) {
|
||||
legacyPath := filepath.Join(dest, legacyMultiSharedSkill)
|
||||
if _, err := skillSetupStat(legacyPath); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Fprintf(errOut, " ⚠️ 无法检查已退役 Skill 残留 %s: %v\n", legacyPath, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := skillSetupRemoveAll(legacyPath); err != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ 已退役 Skill 清理失败(已安装 %s) %s: %v\n", multiSharedSkill, legacyPath, err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " × 已清理已退役 Skill 残留 %s\n", legacyPath)
|
||||
}
|
||||
|
||||
func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
sort.Strings(dests)
|
||||
for _, dest := range dests {
|
||||
@@ -634,6 +910,299 @@ func installSkillToHomes(src string, dests []string, out, errOut io.Writer) (ins
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
func installMultiSkillsWithEventMigration(
|
||||
src string,
|
||||
skillNames []string,
|
||||
dests []string,
|
||||
migrationTargets []string,
|
||||
out, errOut io.Writer,
|
||||
) (installed, skipped int, err error) {
|
||||
if len(migrationTargets) == 0 {
|
||||
return skillSetupInstallMulti(src, skillNames, dests, out, errOut)
|
||||
}
|
||||
|
||||
migrationSet := make(map[string]struct{}, len(migrationTargets))
|
||||
for _, dest := range migrationTargets {
|
||||
migrationSet[dest] = struct{}{}
|
||||
}
|
||||
var ordinaryTargets []string
|
||||
for _, dest := range dests {
|
||||
if _, migrates := migrationSet[dest]; !migrates {
|
||||
ordinaryTargets = append(ordinaryTargets, dest)
|
||||
}
|
||||
}
|
||||
|
||||
if len(ordinaryTargets) > 0 {
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
return installed, skipped, err
|
||||
}
|
||||
if nSkipped > 0 {
|
||||
return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
// The folded pair is excluded from the ordinary best-effort installer. All
|
||||
// other selected skills (especially dingtalk-shared) must succeed before the
|
||||
// old Event route is touched.
|
||||
for _, dest := range migrationTargets {
|
||||
cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut)
|
||||
}
|
||||
var prerequisiteNames []string
|
||||
for _, name := range skillNames {
|
||||
if name != multiEventSkill && name != multiMiscSkill {
|
||||
prerequisiteNames = append(prerequisiteNames, name)
|
||||
}
|
||||
}
|
||||
if len(prerequisiteNames) > 0 {
|
||||
var n, nSkipped int
|
||||
n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut)
|
||||
installed += n
|
||||
skipped += nSkipped
|
||||
if err != nil {
|
||||
return installed, skipped, err
|
||||
}
|
||||
if nSkipped > 0 {
|
||||
return installed, skipped, fmt.Errorf("event Skill 迁移前置安装不完整(skipped=%d);已保留折叠版 Event/misc", nSkipped)
|
||||
}
|
||||
}
|
||||
|
||||
migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut)
|
||||
installed += migrated
|
||||
if migrationErr != nil {
|
||||
return installed, skipped, migrationErr
|
||||
}
|
||||
return installed, skipped, nil
|
||||
}
|
||||
|
||||
type eventMiscMigration struct {
|
||||
dest string
|
||||
|
||||
stageRoot string
|
||||
stagedEvent string
|
||||
stagedMisc string
|
||||
backupEvent string
|
||||
backupMisc string
|
||||
|
||||
eventPath string
|
||||
miscPath string
|
||||
|
||||
eventBackedUp bool
|
||||
miscBackedUp bool
|
||||
newEventEnabled bool
|
||||
newMiscEnabled bool
|
||||
}
|
||||
|
||||
func prepareEventMiscMigration(src, dest string) (*eventMiscMigration, error) {
|
||||
stageRoot, err := skillSetupMkdirTemp(dest, ".dws-event-migration-")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无法在目标文件系统创建 Event Skill 迁移 staging %s: %w", dest, err)
|
||||
}
|
||||
migration := &eventMiscMigration{
|
||||
dest: dest,
|
||||
stageRoot: stageRoot,
|
||||
stagedEvent: filepath.Join(stageRoot, "new-event"),
|
||||
stagedMisc: filepath.Join(stageRoot, "new-misc"),
|
||||
backupEvent: filepath.Join(stageRoot, "old-event"),
|
||||
backupMisc: filepath.Join(stageRoot, "old-misc"),
|
||||
eventPath: filepath.Join(dest, multiEventSkill),
|
||||
miscPath: filepath.Join(dest, multiMiscSkill),
|
||||
}
|
||||
cleanupOnError := func(cause error) (*eventMiscMigration, error) {
|
||||
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
|
||||
cause = errors.Join(cause, fmt.Errorf("清理 staging %s 失败: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
return nil, cause
|
||||
}
|
||||
|
||||
if err := skillSetupCopyDir(filepath.Join(src, multiEventSkill), migration.stagedEvent); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("预备 dingtalk-event 失败 %s: %w", dest, err))
|
||||
}
|
||||
if err := skillSetupCopyDir(filepath.Join(src, multiMiscSkill), migration.stagedMisc); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("预备 dingtalk-misc 失败 %s: %w", dest, err))
|
||||
}
|
||||
if err := validateEventMigrationSkillRoot(migration.stagedEvent); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedEvent, err))
|
||||
}
|
||||
if err := validateMigrationSkillRoot(migration.stagedMisc, multiMiscSkill, nil); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(migration.stagedMisc); err != nil {
|
||||
return cleanupOnError(fmt.Errorf("迁移 staging 验证失败 %s: %w", migration.stagedMisc, err))
|
||||
}
|
||||
return migration, nil
|
||||
}
|
||||
|
||||
func migrateEventMiscAtomically(src string, dests []string, out, errOut io.Writer) (int, error) {
|
||||
sortedDests := append([]string(nil), dests...)
|
||||
sort.Strings(sortedDests)
|
||||
migrations := make([]*eventMiscMigration, 0, len(sortedDests))
|
||||
|
||||
// Stage every target before switching any target. This prevents a source or
|
||||
// copy failure on a later Agent home from leaving earlier homes upgraded.
|
||||
for _, dest := range sortedDests {
|
||||
migration, err := prepareEventMiscMigration(src, dest)
|
||||
if err != nil {
|
||||
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
|
||||
err = errors.Join(err, cleanupErr)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
migrations = append(migrations, migration)
|
||||
}
|
||||
|
||||
committed := make([]*eventMiscMigration, 0, len(migrations))
|
||||
for _, migration := range migrations {
|
||||
if err := commitEventMiscMigration(migration); err != nil {
|
||||
rollbackErr := rollbackEventMiscMigrations(committed)
|
||||
if rollbackErr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("已切换目标回滚失败: %w", rollbackErr))
|
||||
}
|
||||
var recoveryRoots []string
|
||||
for _, candidate := range migrations {
|
||||
if eventMiscMigrationNeedsRecovery(candidate) {
|
||||
recoveryRoots = append(recoveryRoots, candidate.stageRoot)
|
||||
}
|
||||
}
|
||||
if len(recoveryRoots) > 0 {
|
||||
err = errors.Join(err, fmt.Errorf("回滚不完整,已保留恢复目录(请勿删除): %s", strings.Join(recoveryRoots, ", ")))
|
||||
}
|
||||
if cleanupErr := cleanupEventMiscStages(migrations, true, errOut); cleanupErr != nil {
|
||||
err = errors.Join(err, cleanupErr)
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
committed = append(committed, migration)
|
||||
}
|
||||
|
||||
for _, migration := range migrations {
|
||||
fmt.Fprintf(out, " ✓ %s\n", migration.eventPath)
|
||||
fmt.Fprintf(out, " ✓ %s(Event 原子迁移)\n", migration.miscPath)
|
||||
}
|
||||
if cleanupErr := cleanupEventMiscStages(migrations, false, errOut); cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ Event Skill 迁移已完成,但 staging 清理不完整: %v\n", cleanupErr)
|
||||
}
|
||||
return len(migrations) * 2, nil
|
||||
}
|
||||
|
||||
func cleanupEventMiscStages(migrations []*eventMiscMigration, preserveRecovery bool, errOut io.Writer) error {
|
||||
var cleanupErr error
|
||||
for _, migration := range migrations {
|
||||
if preserveRecovery && eventMiscMigrationNeedsRecovery(migration) {
|
||||
fmt.Fprintf(errOut, " ⚠️ 已保留 Event Skill 恢复目录 %s\n", migration.stageRoot)
|
||||
continue
|
||||
}
|
||||
if err := skillSetupRemoveAll(migration.stageRoot); err != nil {
|
||||
cleanupErr = errors.Join(cleanupErr, fmt.Errorf("清理 Event Skill staging %s 失败: %w", migration.stageRoot, err))
|
||||
}
|
||||
}
|
||||
return cleanupErr
|
||||
}
|
||||
|
||||
func eventMiscMigrationNeedsRecovery(migration *eventMiscMigration) bool {
|
||||
return migration.eventBackedUp || migration.miscBackedUp || migration.newEventEnabled || migration.newMiscEnabled
|
||||
}
|
||||
|
||||
func commitEventMiscMigration(migration *eventMiscMigration) error {
|
||||
eventExists, err := skillSetupPathExists(migration.eventPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法检查旧 dingtalk-event %s: %w", migration.dest, err)
|
||||
}
|
||||
miscExists, err := skillSetupPathExists(migration.miscPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法检查旧 dingtalk-misc %s: %w", migration.dest, err)
|
||||
}
|
||||
if !miscExists {
|
||||
return fmt.Errorf("event Skill 迁移中止:折叠版 dingtalk-misc 已不存在 %s", migration.dest)
|
||||
}
|
||||
|
||||
rollbackFailure := func(cause error) error {
|
||||
if rollbackErr := rollbackEventMiscMigration(migration); rollbackErr != nil {
|
||||
return errors.Join(cause, fmt.Errorf("回滚 Event/misc 失败 %s: %w", migration.dest, rollbackErr))
|
||||
}
|
||||
return cause
|
||||
}
|
||||
if eventExists {
|
||||
if err := skillSetupRename(migration.eventPath, migration.backupEvent); err != nil {
|
||||
return fmt.Errorf("备份旧 dingtalk-event 失败 %s: %w", migration.dest, err)
|
||||
}
|
||||
migration.eventBackedUp = true
|
||||
}
|
||||
if err := skillSetupRename(migration.stagedEvent, migration.eventPath); err != nil {
|
||||
return rollbackFailure(fmt.Errorf("切换 dingtalk-event 失败 %s: %w", migration.dest, err))
|
||||
}
|
||||
migration.newEventEnabled = true
|
||||
if err := skillSetupRename(migration.miscPath, migration.backupMisc); err != nil {
|
||||
return rollbackFailure(fmt.Errorf("备份旧 dingtalk-misc 失败 %s: %w", migration.dest, err))
|
||||
}
|
||||
migration.miscBackedUp = true
|
||||
if err := skillSetupRename(migration.stagedMisc, migration.miscPath); err != nil {
|
||||
return rollbackFailure(fmt.Errorf("切换 dingtalk-misc 失败 %s: %w", migration.dest, err))
|
||||
}
|
||||
migration.newMiscEnabled = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func rollbackEventMiscMigrations(migrations []*eventMiscMigration) error {
|
||||
var rollbackErr error
|
||||
for i := len(migrations) - 1; i >= 0; i-- {
|
||||
if err := rollbackEventMiscMigration(migrations[i]); err != nil {
|
||||
rollbackErr = errors.Join(rollbackErr, err)
|
||||
}
|
||||
}
|
||||
return rollbackErr
|
||||
}
|
||||
|
||||
func rollbackEventMiscMigration(migration *eventMiscMigration) error {
|
||||
move := func(enabled *bool, from, to, label string) error {
|
||||
if !*enabled {
|
||||
return nil
|
||||
}
|
||||
if err := skillSetupRename(from, to); err != nil {
|
||||
return fmt.Errorf("%s: %w", label, err)
|
||||
}
|
||||
*enabled = false
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop at the first rollback failure. In particular, do not remove the
|
||||
// already-working standalone Event while the folded misc route has not been
|
||||
// restored: even an incomplete rollback must leave at least one Event entry
|
||||
// point live and preserve the remaining assets in staging for recovery.
|
||||
steps := []struct {
|
||||
enabled *bool
|
||||
from string
|
||||
to string
|
||||
label string
|
||||
}{
|
||||
{&migration.newMiscEnabled, migration.miscPath, migration.stagedMisc, "移出新 dingtalk-misc"},
|
||||
{&migration.miscBackedUp, migration.backupMisc, migration.miscPath, "恢复旧 dingtalk-misc"},
|
||||
{&migration.newEventEnabled, migration.eventPath, migration.stagedEvent, "移出新 dingtalk-event"},
|
||||
{&migration.eventBackedUp, migration.backupEvent, migration.eventPath, "恢复旧 dingtalk-event"},
|
||||
}
|
||||
for _, step := range steps {
|
||||
if err := move(step.enabled, step.from, step.to, step.label); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillSetupPathExists(path string) (bool, error) {
|
||||
_, err := skillSetupStat(path)
|
||||
switch {
|
||||
case err == nil:
|
||||
return true, nil
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return false, nil
|
||||
default:
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// installMultiSkillToHomes installs each subdir of src (dingtalk-*) into
|
||||
// dest as a sibling skill directory. installed/skipped is counted per
|
||||
// (agent-home × sub-skill) pair so the user sees granular progress.
|
||||
@@ -649,6 +1218,7 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
continue
|
||||
}
|
||||
|
||||
sharedInstalled := false
|
||||
for _, name := range skillNames {
|
||||
subSrc := filepath.Join(src, name)
|
||||
subDest := filepath.Join(dest, name)
|
||||
@@ -664,6 +1234,12 @@ func installMultiSkillToHomes(src string, skillNames []string, dests []string, o
|
||||
}
|
||||
fmt.Fprintf(out, " ✓ %s\n", subDest)
|
||||
installed++
|
||||
if name == multiSharedSkill {
|
||||
sharedInstalled = true
|
||||
}
|
||||
}
|
||||
if sharedInstalled {
|
||||
cleanupLegacyMultiSharedSkill(dest, out, errOut)
|
||||
}
|
||||
}
|
||||
return installed, skipped, nil
|
||||
|
||||
@@ -58,8 +58,9 @@ func TestMaterializeEmbeddedSkillSourceMono(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestMaterializeEmbeddedSkillSourceMulti verifies that the peer multi bundle
|
||||
// contains both the shared routing skill and misc (including folded PAT docs). Structured
|
||||
// Schema hints are build inputs and must not become a third installable mode.
|
||||
// contains the standalone Event skill, shared routing skill, and clean misc
|
||||
// (including PAT docs). Structured Schema hints are build inputs and must not
|
||||
// become a third installable mode.
|
||||
func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
if err != nil {
|
||||
@@ -71,6 +72,8 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
t.Fatalf("extracted dir %s is not a valid multi skill source root", dir)
|
||||
}
|
||||
for _, rel := range []string{
|
||||
filepath.Join("dingtalk-event", "SKILL.md"),
|
||||
filepath.Join("dingtalk-event", "references", "event-oa.md"),
|
||||
filepath.Join("dingtalk-shared", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "SKILL.md"),
|
||||
filepath.Join("dingtalk-misc", "references", "pat.md"),
|
||||
@@ -79,6 +82,11 @@ func TestMaterializeEmbeddedSkillSourceMulti(t *testing.T) {
|
||||
t.Errorf("expected embedded multi skill to contain %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "dingtalk-misc", "references", "event.md")); err == nil {
|
||||
t.Fatal("embedded misc must not retain the folded personal Event reference")
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat embedded misc event reference: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "schema-hints")); err == nil {
|
||||
t.Fatal("embedded multi skill must not contain build-only schema-hints")
|
||||
} else if !os.IsNotExist(err) {
|
||||
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -120,12 +122,112 @@ func TestCrossPlatformCoverageSkillSetupHighLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupMigratesLegacySharedAfterReplacement(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiSharedSkill, "dingtalk-chat"})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
legacyPath := filepath.Join(home, legacyMultiSharedSkill)
|
||||
customPath := filepath.Join(home, "custom-skill")
|
||||
for _, path := range []string{legacyPath, customPath} {
|
||||
if err := os.MkdirAll(path, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(path, "SKILL.md"), []byte("legacy or custom\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var out, errOut bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
src,
|
||||
[]string{multiSharedSkill, "dingtalk-chat"},
|
||||
[]string{home},
|
||||
&out,
|
||||
&errOut,
|
||||
)
|
||||
if err != nil || installed != 2 || skipped != 0 {
|
||||
t.Fatalf("install = %d/%d, err=%v, stderr=%s", installed, skipped, err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("legacy shared skill still exists: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiSharedSkill, "SKILL.md")); err != nil {
|
||||
t.Fatalf("replacement shared skill missing: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(customPath, "SKILL.md")); err != nil {
|
||||
t.Fatalf("unrelated custom skill changed: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "已清理已退役 Skill 残留") {
|
||||
t.Fatalf("legacy cleanup was not reported: %s", out.String())
|
||||
}
|
||||
|
||||
t.Run("failed replacement preserves legacy", func(t *testing.T) {
|
||||
missingSource := t.TempDir()
|
||||
failureHome := filepath.Join(t.TempDir(), "skills")
|
||||
failureLegacy := filepath.Join(failureHome, legacyMultiSharedSkill)
|
||||
if err := os.MkdirAll(failureLegacy, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(failureLegacy, "SKILL.md"), []byte("legacy\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var failureOut, failureErr bytes.Buffer
|
||||
installed, skipped, err := installMultiSkillToHomes(
|
||||
missingSource,
|
||||
[]string{multiSharedSkill},
|
||||
[]string{failureHome},
|
||||
&failureOut,
|
||||
&failureErr,
|
||||
)
|
||||
if err != nil || installed != 0 || skipped != 1 {
|
||||
t.Fatalf("failed replacement = %d/%d, err=%v", installed, skipped, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(failureLegacy, "SKILL.md")); err != nil {
|
||||
t.Fatalf("failed replacement removed legacy shared skill: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupLegacySharedCleanupFailures(t *testing.T) {
|
||||
fail := errors.New("legacy cleanup failure")
|
||||
|
||||
t.Run("missing legacy is a no-op", func(t *testing.T) {
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupLegacyMultiSharedSkill(t.TempDir(), &out, &errOut)
|
||||
if out.Len() != 0 || errOut.Len() != 0 {
|
||||
t.Fatalf("missing legacy emitted output: stdout=%q stderr=%q", out.String(), errOut.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stat failure is reported", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
|
||||
if out.Len() != 0 || !strings.Contains(errOut.String(), "无法检查已退役 Skill 残留") {
|
||||
t.Fatalf("stat failure output: stdout=%q stderr=%q", out.String(), errOut.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("remove failure is reported", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
return skillSetupFileInfo{name: filepath.Base(path), mode: os.ModeDir}, nil
|
||||
})
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return fail })
|
||||
var out, errOut bytes.Buffer
|
||||
cleanupLegacyMultiSharedSkill("dest", &out, &errOut)
|
||||
if out.Len() != 0 || !strings.Contains(errOut.String(), "已退役 Skill 清理失败") {
|
||||
t.Fatalf("remove failure output: stdout=%q stderr=%q", out.String(), errOut.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T) {
|
||||
oldRunForm, oldInteractive := skillSetupRunForm, skillSetupInteractive
|
||||
oldReadDir, oldStat := skillSetupReadDir, skillSetupStat
|
||||
oldExecutable, oldGetwd, oldHome := skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir
|
||||
oldRemove, oldMkdir := skillSetupRemoveAll, skillSetupMkdirAll
|
||||
oldCopyDir, oldWalk, oldRel := skillSetupCopyDir, skillSetupWalk, skillSetupRel
|
||||
oldMkdirTemp, oldRename := skillSetupMkdirTemp, skillSetupRename
|
||||
oldReadlink, oldOpen, oldOpenFile, oldCopy := skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy
|
||||
t.Cleanup(func() {
|
||||
skillSetupRunForm, skillSetupInteractive = oldRunForm, oldInteractive
|
||||
@@ -133,6 +235,7 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
skillSetupExecutable, skillSetupGetwd, skillSetupUserHomeDir = oldExecutable, oldGetwd, oldHome
|
||||
skillSetupRemoveAll, skillSetupMkdirAll = oldRemove, oldMkdir
|
||||
skillSetupCopyDir, skillSetupWalk, skillSetupRel = oldCopyDir, oldWalk, oldRel
|
||||
skillSetupMkdirTemp, skillSetupRename = oldMkdirTemp, oldRename
|
||||
skillSetupReadlink, skillSetupOpen, skillSetupOpenFile, skillSetupCopy = oldReadlink, oldOpen, oldOpenFile, oldCopy
|
||||
})
|
||||
fail := errors.New("failure")
|
||||
@@ -310,3 +413,236 @@ func TestCrossPlatformCoverageSkillSetupLowLevelRemainingCoverage(t *testing.T)
|
||||
}
|
||||
_ = fs.ValidPath("path")
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing.T) {
|
||||
fail := errors.New("injected failure")
|
||||
validSkill := func(name string) []byte {
|
||||
return []byte("---\nname: " + name + "\ndescription: valid migration skill\n---\n\n# Skill\n")
|
||||
}
|
||||
|
||||
t.Run("folded discovery rejects directory reference", func(t *testing.T) {
|
||||
dest := t.TempDir()
|
||||
miscRoot := filepath.Join(dest, multiMiscSkill)
|
||||
if err := os.MkdirAll(miscRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("dws event\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if strings.HasSuffix(path, filepath.Join("references", "event.md")) {
|
||||
return skillSetupFileInfo{name: "event.md", mode: os.ModeDir}, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
if got := findFoldedEventMiscTargets([]string{dest}); len(got) != 0 {
|
||||
t.Fatalf("directory event reference accepted: %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migration root validation failures", func(t *testing.T) {
|
||||
if err := validateMigrationSkillRoot(filepath.Join(t.TempDir(), "missing"), multiEventSkill, nil); err == nil {
|
||||
t.Fatal("missing SKILL.md succeeded")
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "references", "directory.md"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{filepath.Join("references", "directory.md")}); err == nil || !strings.Contains(err.Error(), "is a directory") {
|
||||
t.Fatalf("directory required file = %v", err)
|
||||
}
|
||||
|
||||
missing := filepath.Join("references", "missing.md")
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if path == filepath.Join(root, missing) {
|
||||
return skillSetupFileInfo{name: "missing.md"}, nil
|
||||
}
|
||||
return os.Stat(path)
|
||||
})
|
||||
if err := validateMigrationSkillRoot(root, multiEventSkill, []string{missing}); err == nil || !strings.Contains(err.Error(), "无法读取") {
|
||||
t.Fatalf("unreadable required file = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("frontmatter validation branches", func(t *testing.T) {
|
||||
validWithIgnoredLine := []byte("---\nignored line\nname: dingtalk-event\ndescription: valid\n---\n\nbody\n")
|
||||
if name, err := parseMigrationSkillFrontmatter(validWithIgnoredLine); err != nil || name != multiEventSkill {
|
||||
t.Fatalf("ignored frontmatter line = %q, %v", name, err)
|
||||
}
|
||||
for name, body := range map[string][]byte{
|
||||
"duplicate name": []byte("---\nname: one\nname: two\ndescription: valid\n---\nbody\n"),
|
||||
"unclosed": []byte("---\nname: one\ndescription: valid\nbody\n"),
|
||||
"missing name": []byte("---\ndescription: valid\n---\nbody\n"),
|
||||
"missing desc": []byte("---\nname: one\n---\nbody\n"),
|
||||
"empty body": []byte("---\nname: one\ndescription: valid\n---\n \n"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := parseMigrationSkillFrontmatter(body); err == nil {
|
||||
t.Fatal("invalid frontmatter succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clean misc validation branches", func(t *testing.T) {
|
||||
if err := validateCleanEventMiscRoot(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing misc root succeeded")
|
||||
}
|
||||
|
||||
routed := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(routed, "SKILL.md"), append(validSkill(multiMiscSkill), []byte("dws event\n")...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(routed); err == nil || !strings.Contains(err.Error(), "仍包含个人 Event 路由") {
|
||||
t.Fatalf("routed misc = %v", err)
|
||||
}
|
||||
|
||||
clean := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(clean, "SKILL.md"), validSkill(multiMiscSkill), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateCleanEventMiscRoot(clean); err != nil {
|
||||
t.Fatalf("missing references should be clean: %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, fail })
|
||||
if err := validateCleanEventMiscRoot(clean); !errors.Is(err, fail) {
|
||||
t.Fatalf("read-dir failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ordinary and prerequisite install errors", func(t *testing.T) {
|
||||
testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
return 0, 0, fail
|
||||
})
|
||||
migration := filepath.Join(t.TempDir(), "migration")
|
||||
ordinary := filepath.Join(t.TempDir(), "ordinary")
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
t.Fatalf("ordinary install failure = %v", err)
|
||||
}
|
||||
if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, io.Discard, io.Discard); !errors.Is(err, fail) {
|
||||
t.Fatalf("prerequisite install failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preparation cleanup and staged misc validation", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
dest := t.TempDir()
|
||||
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return fail })
|
||||
cleanupFail := errors.New("cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
if _, err := prepareEventMiscMigration(src, dest); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
|
||||
t.Fatalf("joined preparation cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid staged misc root", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), validSkill(multiEventSkill), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := prepareEventMiscMigration(src, t.TempDir()); err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
|
||||
t.Fatalf("invalid staged misc = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("later staging failure joins cleanup error", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
first := filepath.Join(t.TempDir(), "a")
|
||||
second := filepath.Join(t.TempDir(), "b")
|
||||
if err := os.MkdirAll(first, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(second, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
originalMkdirTemp := skillSetupMkdirTemp
|
||||
calls := 0
|
||||
testseam.Swap(t, &skillSetupMkdirTemp, func(dir, pattern string) (string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return "", fail
|
||||
}
|
||||
return originalMkdirTemp(dir, pattern)
|
||||
})
|
||||
cleanupFail := errors.New("stage cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, fail) || !errors.Is(err, cleanupFail) {
|
||||
t.Fatalf("later preparation failure = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("successful migration reports cleanup warning", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
cleanupFail := errors.New("final cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
var stderr bytes.Buffer
|
||||
installed, err := migrateEventMiscAtomically(src, []string{home}, io.Discard, &stderr)
|
||||
if err != nil || installed != 2 || !strings.Contains(stderr.String(), cleanupFail.Error()) {
|
||||
t.Fatalf("successful migration cleanup warning: installed=%d err=%v stderr=%s", installed, err, stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit rollback and cleanup failures are joined", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
root := t.TempDir()
|
||||
first := filepath.Join(root, "a", "skills")
|
||||
second := filepath.Join(root, "b", "skills")
|
||||
for _, home := range []string{first, second} {
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
}
|
||||
commitFail := errors.New("second commit failure")
|
||||
rollbackFail := errors.New("first rollback failure")
|
||||
originalRename := skillSetupRename
|
||||
testseam.Swap(t, &skillSetupRename, func(oldPath, newPath string) error {
|
||||
if filepath.Base(oldPath) == "new-misc" && newPath == filepath.Join(second, multiMiscSkill) {
|
||||
return commitFail
|
||||
}
|
||||
if filepath.Base(oldPath) == "old-misc" && newPath == filepath.Join(first, multiMiscSkill) {
|
||||
return rollbackFail
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
})
|
||||
cleanupFail := errors.New("post-rollback cleanup failure")
|
||||
testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return cleanupFail })
|
||||
if _, err := migrateEventMiscAtomically(src, []string{second, first}, io.Discard, io.Discard); err == nil || !errors.Is(err, commitFail) || !errors.Is(err, rollbackFail) || !errors.Is(err, cleanupFail) {
|
||||
t.Fatalf("joined commit/rollback/cleanup error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("commit preflight and rollback aggregation", func(t *testing.T) {
|
||||
migration := &eventMiscMigration{dest: "dest", eventPath: "event", miscPath: "misc"}
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, fail })
|
||||
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
|
||||
t.Fatalf("event stat failure = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
|
||||
if path == migration.eventPath {
|
||||
return skillSetupFileInfo{name: "event", mode: os.ModeDir}, nil
|
||||
}
|
||||
return nil, fail
|
||||
})
|
||||
if err := commitEventMiscMigration(migration); !errors.Is(err, fail) {
|
||||
t.Fatalf("misc stat failure = %v", err)
|
||||
}
|
||||
|
||||
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
|
||||
if err := commitEventMiscMigration(migration); err == nil || !strings.Contains(err.Error(), "已不存在") {
|
||||
t.Fatalf("missing misc = %v", err)
|
||||
}
|
||||
|
||||
migration.newMiscEnabled = true
|
||||
testseam.Swap(t, &skillSetupRename, func(string, string) error { return fail })
|
||||
if err := rollbackEventMiscMigrations([]*eventMiscMigration{migration}); !errors.Is(err, fail) {
|
||||
t.Fatalf("rollback aggregation = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -184,12 +187,20 @@ func writeMultiSkillSource(t *testing.T, names []string) string {
|
||||
if err := os.MkdirAll(filepath.Join(sub, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte("# "+n), 0o644); err != nil {
|
||||
skillBody := "---\nname: " + n + "\ndescription: test skill\n---\n\n# " + n + "\n"
|
||||
if err := os.WriteFile(filepath.Join(sub, "SKILL.md"), []byte(skillBody), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sub, "references", n+".md"), []byte("ref "+n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n == multiEventSkill {
|
||||
for _, ref := range eventMigrationRequiredReferences {
|
||||
if err := os.WriteFile(filepath.Join(sub, "references", ref), []byte("ref "+ref+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
@@ -549,3 +560,787 @@ func TestResolveSkillSetupSourceMultiFinds(t *testing.T) {
|
||||
t.Fatalf("expected %s, got %s", multiDir, got)
|
||||
}
|
||||
}
|
||||
|
||||
func executeMultiSkillSetupTest(t *testing.T, src string, dests []string, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
originalTargets := skillSetupResolveTargets
|
||||
skillSetupResolveTargets = func(string, string) ([]string, error) {
|
||||
return append([]string(nil), dests...), nil
|
||||
}
|
||||
t.Cleanup(func() { skillSetupResolveTargets = originalTargets })
|
||||
|
||||
cmd := newSkillSetupCommand()
|
||||
cmd.Flags().Bool("dry-run", false, "")
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.SetOut(&stdout)
|
||||
cmd.SetErr(&stderr)
|
||||
baseArgs := []string{"--mode", "multi", "--source", src}
|
||||
cmd.SetArgs(append(baseArgs, args...))
|
||||
err := cmd.Execute()
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
|
||||
func writeFoldedEventMisc(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
miscRoot := filepath.Join(agentHome, multiMiscSkill)
|
||||
if err := os.MkdirAll(filepath.Join(miscRoot, "references"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(miscRoot, "SKILL.md"), []byte("personal IM route: dws event consume\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(miscRoot, "references", "event.md"), []byte("folded event docs\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeOldStandaloneEvent(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
eventRoot := filepath.Join(agentHome, multiEventSkill)
|
||||
if err := os.MkdirAll(eventRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(eventRoot, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertOldEventMiscPair(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
eventBody, err := os.ReadFile(filepath.Join(agentHome, multiEventSkill, "SKILL.md"))
|
||||
if err != nil || string(eventBody) != "old standalone event\n" {
|
||||
t.Fatalf("old standalone event was not restored: body=%q err=%v", eventBody, err)
|
||||
}
|
||||
miscBody, err := os.ReadFile(filepath.Join(agentHome, multiMiscSkill, "SKILL.md"))
|
||||
if err != nil || !strings.Contains(string(miscBody), "dws event") {
|
||||
t.Fatalf("folded misc was not restored: body=%q err=%v", miscBody, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(agentHome, multiMiscSkill, "references", "event.md")); err != nil {
|
||||
t.Fatalf("folded Event reference was not restored: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoEventMigrationStages(t *testing.T, agentHome string) {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(agentHome)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
|
||||
t.Fatalf("unexpected leftover Event migration stage %s", filepath.Join(agentHome, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventMigratesOnlyFoldedTargets(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
|
||||
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
|
||||
writeFoldedEventMisc(t, foldedHome)
|
||||
if err := os.MkdirAll(filepath.Join(foldedHome, multiEventSkill), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"), []byte("old standalone event\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(foldedHome, "dingtalk-chat"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event")
|
||||
if err != nil {
|
||||
t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "迁移伴侣") || !strings.Contains(stdout, foldedHome) {
|
||||
t.Fatalf("confirmation output should expose folded misc migration: %s", stdout)
|
||||
}
|
||||
if !strings.Contains(stdout, "重新加载 Skills") {
|
||||
t.Fatalf("completion should tell the user to reload skills: %s", stdout)
|
||||
}
|
||||
|
||||
for _, home := range []string{freshHome, foldedHome} {
|
||||
for _, name := range []string{multiSharedSkill, multiEventSkill} {
|
||||
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
|
||||
t.Errorf("%s missing from %s: %v", name, home, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc")); !os.IsNotExist(err) {
|
||||
t.Errorf("unselected doc appeared in %s: %v", home, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(freshHome, multiMiscSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("fresh selective target must not receive misc, stat err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("folded event reference survived clean misc replacement, stat err=%v", err)
|
||||
}
|
||||
eventBody, err := os.ReadFile(filepath.Join(foldedHome, multiEventSkill, "SKILL.md"))
|
||||
if err != nil || strings.Contains(string(eventBody), "old standalone") {
|
||||
t.Fatalf("old standalone event was not replaced: body=%q err=%v", eventBody, err)
|
||||
}
|
||||
siblingBody, err := os.ReadFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"))
|
||||
if err != nil || string(siblingBody) != "keep sibling\n" {
|
||||
t.Fatalf("unrelated sibling changed: body=%q err=%v", siblingBody, err)
|
||||
}
|
||||
|
||||
// A second selective run sees the already-clean misc, does not plan another
|
||||
// migration, and leaves that unselected sibling in place.
|
||||
stdout, stderr, err = executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("idempotent event setup failed: %v\nstderr=%s", err, stderr)
|
||||
}
|
||||
if strings.Contains(stdout, "迁移伴侣") {
|
||||
t.Fatalf("clean second run should not re-detect folded misc: %s", stdout)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(foldedHome, multiMiscSkill, "SKILL.md")); err != nil {
|
||||
t.Fatalf("second selective run removed clean misc: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationDryRunAndExplicitExclude(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
|
||||
t.Run("dry run reports companion without writes", func(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--dry-run", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("dry run failed: %v\nstderr=%s", err, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "DRY-RUN") || !strings.Contains(stdout, "迁移伴侣") {
|
||||
t.Fatalf("dry run did not expose migration: %s", stdout)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("dry run changed folded misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("dry run installed event, stat err=%v", statErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("excluding required misc fails before writes", func(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--exclude", "misc", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "不能显式 --exclude misc") {
|
||||
t.Fatalf("expected clear migration exclusion error, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed migration installed event, stat err=%v", statErr)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("failed migration changed misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRequiresCleanMiscInSource(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "当前 multi 源缺少") {
|
||||
t.Fatalf("expected missing migration companion error, got %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed preflight installed event, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationAcceptsShippedMultiBundle(t *testing.T) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := filepath.Clean(filepath.Join(wd, "..", "..", "skills", "multi"))
|
||||
if err := validateEventMiscMigrationSource(src); err != nil {
|
||||
t.Fatalf("shipped multi bundle is not a valid Event migration source: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRejectsInvalidSkillBundlesBeforeWrites(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(t *testing.T, src string)
|
||||
}{
|
||||
{
|
||||
name: "empty event root",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong event name",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
body := "---\nname: dingtalk-chat\ndescription: wrong skill\n---\n\n# Wrong\n"
|
||||
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing event reference",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
if err := os.Remove(filepath.Join(src, multiEventSkill, "references", "event-oa.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty event reference",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(src, multiEventSkill, "references", "event-im.md"), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong misc name",
|
||||
mutate: func(t *testing.T, src string) {
|
||||
t.Helper()
|
||||
body := "---\nname: dingtalk-event\ndescription: wrong skill\n---\n\n# Wrong\n"
|
||||
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "SKILL.md"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
test.mutate(t, src)
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "迁移源无效") {
|
||||
t.Fatalf("invalid migration source was accepted: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("invalid migration source reported success: %s", stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("invalid source wrote shared skill: %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventPreservesFoldedMiscAfterPrimarySkip(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("misc migration companion ran after a primary install skip")
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
|
||||
t.Fatalf("expected preserved-fallback error, got %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("install calls = %d, want 1", calls)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("partial migration reported success: %s", stdout)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("primary skip changed folded misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); statErr != nil {
|
||||
t.Fatalf("primary skip removed folded Event reference: %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupFreshTargetFailureDoesNotTouchFoldedPair(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
freshHome := filepath.Join(t.TempDir(), "fresh", "skills")
|
||||
foldedHome := filepath.Join(t.TempDir(), "folded", "skills")
|
||||
writeFoldedEventMisc(t, foldedHome)
|
||||
writeOldStandaloneEvent(t, foldedHome)
|
||||
|
||||
originalInstallMulti := skillSetupInstallMulti
|
||||
t.Cleanup(func() { skillSetupInstallMulti = originalInstallMulti })
|
||||
calls := 0
|
||||
skillSetupInstallMulti = func(string, []string, []string, io.Writer, io.Writer) (int, int, error) {
|
||||
calls++
|
||||
if calls > 1 {
|
||||
t.Fatal("folded target prerequisites ran after fresh target failure")
|
||||
}
|
||||
return 1, 1, nil
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(
|
||||
t,
|
||||
src,
|
||||
[]string{foldedHome, freshHome},
|
||||
"--skill", "event",
|
||||
"--yes",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "已保留折叠版 Event/misc") {
|
||||
t.Fatalf("fresh target failure did not block migration: %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("partial mixed-target install reported success: %s", stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, foldedHome)
|
||||
assertNoEventMigrationStages(t, foldedHome)
|
||||
}
|
||||
|
||||
func TestSkillSetupUnrelatedSelectiveInstallLeavesFoldedPairUntouched(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "doc", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated selective install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if strings.Contains(stdout, "Event Skill 迁移") || strings.Contains(stdout, "Event 原子迁移") {
|
||||
t.Fatalf("unrelated selective install planned Event migration: %s", stdout)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, "dingtalk-doc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("selected doc was not installed: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventAtomicStageFailurePreservesFoldedPair(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if strings.HasSuffix(dest, "new-misc") {
|
||||
return errors.New("injected stage failure")
|
||||
}
|
||||
return originalCopyDir(src, dest)
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "预备 dingtalk-misc 失败") {
|
||||
t.Fatalf("expected atomic staging error, got %v", err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("partial atomic migration reported success: %s", stdout)
|
||||
}
|
||||
body, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(body), "dws event") {
|
||||
t.Fatalf("stage failure changed folded misc: body=%q err=%v", body, readErr)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("stage failure installed standalone event, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationPreparationFailuresPreserveFoldedPair(t *testing.T) {
|
||||
t.Run("same-filesystem staging creation", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalMkdirTemp := skillSetupMkdirTemp
|
||||
t.Cleanup(func() { skillSetupMkdirTemp = originalMkdirTemp })
|
||||
skillSetupMkdirTemp = func(dir, pattern string) (string, error) {
|
||||
if dir != home {
|
||||
t.Fatalf("staging dir = %s, want target filesystem root %s", dir, home)
|
||||
}
|
||||
return "", errors.New("injected mkdir-temp failure")
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "injected mkdir-temp failure") {
|
||||
t.Fatalf("staging creation failure was not returned: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
})
|
||||
|
||||
t.Run("event staging copy", func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if strings.HasSuffix(dest, "new-event") {
|
||||
return errors.New("injected event copy failure")
|
||||
}
|
||||
return originalCopyDir(src, dest)
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "injected event copy failure") {
|
||||
t.Fatalf("event staging failure was not returned: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
})
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventRejectsCorruptStagedMisc(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if err := originalCopyDir(src, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(dest, "new-misc") {
|
||||
return os.WriteFile(filepath.Join(dest, "references", "event-partial.md"), []byte("corrupt\n"), 0o644)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
|
||||
t.Fatalf("corrupt staged misc was accepted: %v", err)
|
||||
}
|
||||
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
|
||||
t.Fatalf("staging validation failure changed folded misc: body=%q err=%v", miscBody, readErr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("staging validation failure installed event: %v", err)
|
||||
}
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupSelectiveEventRejectsIncompleteStagedEvent(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalCopyDir := skillSetupCopyDir
|
||||
t.Cleanup(func() { skillSetupCopyDir = originalCopyDir })
|
||||
skillSetupCopyDir = func(src, dest string) error {
|
||||
if err := originalCopyDir(src, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasSuffix(dest, "new-event") {
|
||||
return os.Remove(filepath.Join(dest, "references", "event-oa.md"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "staging 验证失败") {
|
||||
t.Fatalf("incomplete staged Event was accepted: %v", err)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupFullEventMigrationIsAtomicAndPreservesSiblings(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
sibling := filepath.Join(home, "dingtalk-private-sibling")
|
||||
if err := os.MkdirAll(sibling, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sibling, "SKILL.md"), []byte("keep\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("full setup migration failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if !strings.Contains(stdout, "Event 原子迁移") || !strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("full setup did not report atomic migration success: %s", stdout)
|
||||
}
|
||||
for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill, "dingtalk-doc"} {
|
||||
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("full setup missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiMiscSkill, "references", "event.md")); !os.IsNotExist(err) {
|
||||
t.Fatalf("full setup retained folded Event reference: %v", err)
|
||||
}
|
||||
body, err := os.ReadFile(filepath.Join(sibling, "SKILL.md"))
|
||||
if err != nil || string(body) != "keep\n" {
|
||||
t.Fatalf("full setup changed unrelated sibling: body=%q err=%v", body, err)
|
||||
}
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationWithoutSharedStillCleansMonoLeftover(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
monoLeftover := filepath.Join(home, "dws")
|
||||
if err := os.MkdirAll(monoLeftover, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(monoLeftover, "SKILL.md"), []byte("old mono\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err != nil {
|
||||
t.Fatalf("migration without shared failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr)
|
||||
}
|
||||
if _, err := os.Stat(monoLeftover); !os.IsNotExist(err) {
|
||||
t.Fatalf("migration without prerequisites retained mono leftover: %v", err)
|
||||
}
|
||||
for _, name := range []string{multiEventSkill, multiMiscSkill} {
|
||||
if _, err := os.Stat(filepath.Join(home, name, "SKILL.md")); err != nil {
|
||||
t.Fatalf("migration without shared missing %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
|
||||
func TestSkillSetupFoldedEventMigrationSelectionPreflight(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{
|
||||
multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc",
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "misc only", args: []string{"--skill", "misc", "--yes"}, want: "不能只覆盖 dingtalk-misc"},
|
||||
{name: "explicitly excludes event", args: []string{"--exclude", "event", "--yes"}, want: "不能显式 --exclude event"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("preflight error = %v, want %q", err, tt.want)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight failure wrote shared skill: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiEventSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("preflight failure wrote event skill: %v", err)
|
||||
}
|
||||
miscBody, readErr := os.ReadFile(filepath.Join(home, multiMiscSkill, "SKILL.md"))
|
||||
if readErr != nil || !strings.Contains(string(miscBody), "dws event") {
|
||||
t.Fatalf("preflight failure changed folded misc: body=%q err=%v", miscBody, readErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRejectsEveryFoldedReferenceVariant(t *testing.T) {
|
||||
for _, filename := range []string{"event.md", "event-im.md", "event-oa.md", "EVENT-legacy.MD"} {
|
||||
t.Run(filename, func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
if err := os.WriteFile(filepath.Join(src, multiMiscSkill, "references", filename), []byte("stale\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "仍存在折叠 Event 参考页") {
|
||||
t.Fatalf("source with %s was accepted: %v", filename, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiSharedSkill)); !os.IsNotExist(err) {
|
||||
t.Fatalf("invalid source wrote shared skill: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRenameFailuresRollbackPair(t *testing.T) {
|
||||
for failAt := 1; failAt <= 4; failAt++ {
|
||||
t.Run(fmt.Sprintf("rename_%d", failAt), func(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
renameCalls := 0
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
renameCalls++
|
||||
if renameCalls == failAt {
|
||||
return errors.New("injected rename failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
stdout, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "injected rename failure") {
|
||||
t.Fatalf("rename failure %d was not returned: %v", failAt, err)
|
||||
}
|
||||
if strings.Contains(stdout, "Skill 安装完成") {
|
||||
t.Fatalf("rename failure %d reported success: %s", failAt, stdout)
|
||||
}
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationFailureRollsBackEarlierTargets(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
root := t.TempDir()
|
||||
firstHome := filepath.Join(root, "a", "skills")
|
||||
secondHome := filepath.Join(root, "b", "skills")
|
||||
for _, home := range []string{firstHome, secondHome} {
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
}
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
failed := false
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
if !failed && oldPath == filepath.Join(secondHome, multiMiscSkill) {
|
||||
failed = true
|
||||
return errors.New("second target failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{secondHome, firstHome}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "second target failure") {
|
||||
t.Fatalf("second target failure was not returned: %v", err)
|
||||
}
|
||||
for _, home := range []string{firstHome, secondHome} {
|
||||
assertOldEventMiscPair(t, home)
|
||||
assertNoEventMigrationStages(t, home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRollbackFailurePreservesRecoveryDirectory(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
writeOldStandaloneEvent(t, home)
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("commit failure")
|
||||
}
|
||||
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("rollback restore failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
_, stderr, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "回滚不完整") || !strings.Contains(err.Error(), "恢复目录") {
|
||||
t.Fatalf("rollback failure did not expose recovery directory: %v", err)
|
||||
}
|
||||
entries, readErr := os.ReadDir(home)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
var recoveryRoot string
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
|
||||
recoveryRoot = filepath.Join(home, entry.Name())
|
||||
break
|
||||
}
|
||||
}
|
||||
if recoveryRoot == "" {
|
||||
t.Fatal("rollback failure deleted the only recovery directory")
|
||||
}
|
||||
if !strings.Contains(err.Error(), recoveryRoot) || !strings.Contains(stderr, recoveryRoot) {
|
||||
t.Fatalf("recovery directory was not reported: err=%v stderr=%s", err, stderr)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-misc", "SKILL.md")); err != nil {
|
||||
t.Fatalf("old folded misc backup is missing from recovery directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(recoveryRoot, "old-event", "SKILL.md")); err != nil {
|
||||
t.Fatalf("old standalone Event backup is missing from recovery directory: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); err != nil {
|
||||
t.Fatalf("rollback failure removed the live standalone Event entry: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSetupEventMigrationRollbackFailureKeepsNewEventWithoutOldStandalone(t *testing.T) {
|
||||
src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill})
|
||||
home := filepath.Join(t.TempDir(), "skills")
|
||||
writeFoldedEventMisc(t, home)
|
||||
|
||||
originalRename := skillSetupRename
|
||||
t.Cleanup(func() { skillSetupRename = originalRename })
|
||||
skillSetupRename = func(oldPath, newPath string) error {
|
||||
if strings.HasSuffix(oldPath, filepath.Join("new-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("commit failure")
|
||||
}
|
||||
if strings.HasSuffix(oldPath, filepath.Join("old-misc")) && newPath == filepath.Join(home, multiMiscSkill) {
|
||||
return errors.New("rollback restore failure")
|
||||
}
|
||||
return originalRename(oldPath, newPath)
|
||||
}
|
||||
|
||||
_, _, err := executeMultiSkillSetupTest(t, src, []string{home}, "--skill", "event", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), "回滚不完整") {
|
||||
t.Fatalf("rollback failure was not returned: %v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, multiEventSkill, "SKILL.md")); statErr != nil {
|
||||
t.Fatalf("rollback failure removed the only live Event entry: %v", statErr)
|
||||
}
|
||||
entries, readErr := os.ReadDir(home)
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.Name(), ".dws-event-migration-") {
|
||||
continue
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(home, entry.Name(), "old-misc", "SKILL.md")); statErr != nil {
|
||||
t.Fatalf("rollback failure lost the folded misc recovery copy: %v", statErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("rollback failure did not preserve a recovery directory")
|
||||
}
|
||||
|
||||
@@ -256,6 +256,10 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.list_conversation_message_v2 --open-dingtalk-id": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_conversation_message_v2 --user": "selects the alternate list_individual_chat_message branch",
|
||||
"chat.list_message_favorites --cursor": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.list_message_favorites --max-items": "Reviewed helper-only pagination control: --max-items caps aggregated CLI output for chat.list_message_favorites and is not sent to the remote adapter.",
|
||||
"chat.list_message_favorites --page-all": "Reviewed helper-only pagination control: --page-all enables CLI-side looping for chat.list_message_favorites and is not sent to the remote adapter.",
|
||||
"chat.list_message_favorites --page-delay": "Reviewed helper-only pagination control: --page-delay controls local delay between chat.list_message_favorites pages and is not sent to the remote adapter.",
|
||||
"chat.list_message_favorites --page-limit": "Reviewed helper-only pagination control: --page-limit caps CLI-side page fetches for chat.list_message_favorites and is not sent to the remote adapter.",
|
||||
"chat.list_message_favorites --size": "Reviewed unpinned adapter: chat.list_message_favorites has no singular pinned interface_ref; --size is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.query_msg_read_status --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.query_msg_read_status --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to targetUserIds or targetOpenDingTalkIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestCrossPlatformCoverageDiagnosticsAndErrorRenderingEdges(t *testing.T) {
|
||||
t.Fatalf("PrintJSON friendly diagnostics = %q, %v", out.String(), err)
|
||||
}
|
||||
out.Reset()
|
||||
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "开启地址") {
|
||||
if err := PrintHumanAt(&out, err, VerbosityVerbose); err != nil || !strings.Contains(out.String(), "处理入口") {
|
||||
t.Fatalf("PrintHuman friendly diagnostics = %q, %v", out.String(), err)
|
||||
}
|
||||
out.Reset()
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -460,7 +461,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
lines = append(lines, tui.Cyan("Hint: "+friendlyHint))
|
||||
}
|
||||
if actionURL != "" {
|
||||
lines = append(lines, tui.White("Action: 开启地址: "+actionURL))
|
||||
lines = append(lines, tui.White("Action: 处理入口: "+actionURL))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,7 +541,7 @@ func PrintHumanAt(w io.Writer, err error, v Verbosity) error {
|
||||
|
||||
func serverGuidance(diag ServerDiagnostics) (string, string) {
|
||||
friendlyHint := strings.TrimSpace(diag.FriendlyHint)
|
||||
actionURL := strings.TrimSpace(diag.ActionURL)
|
||||
actionURL := safeServerActionURL(diag.ActionURL)
|
||||
if friendlyHint == "" || actionURL == "" {
|
||||
switch diag.ServerErrorCode {
|
||||
case "TOKEN_VERIFIED_FAILED", "CLI_ORG_NOT_AUTHORIZED":
|
||||
@@ -555,6 +556,19 @@ func serverGuidance(diag ServerDiagnostics) (string, string) {
|
||||
return friendlyHint, actionURL
|
||||
}
|
||||
|
||||
func safeServerActionURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || !strings.EqualFold(parsed.Scheme, "https") ||
|
||||
parsed.Hostname() == "" || parsed.User != nil {
|
||||
return ""
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func category(err error) string {
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
|
||||
@@ -336,11 +336,40 @@ func TestCrossPlatformCoveragePrintHumanIncludesServerGuidance(t *testing.T) {
|
||||
if !strings.Contains(got, "Hint: 请联系管理员开通消息搜索权益") {
|
||||
t.Fatalf("expected server guidance in output, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Action: 开启地址: https://example.test/enable-search") {
|
||||
if !strings.Contains(got, "Action: 处理入口: https://example.test/enable-search") {
|
||||
t.Fatalf("expected server action URL in output, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerGuidanceSuppressesUnsafeActionURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, actionURL := range []string{
|
||||
"http://example.test/help",
|
||||
"javascript:alert(1)",
|
||||
"https://user:secret@example.test/help",
|
||||
"not a url",
|
||||
} {
|
||||
var human strings.Builder
|
||||
err := NewAPI("server error", WithServerDiag(ServerDiagnostics{
|
||||
FriendlyHint: "保留 Trace ID 后排查",
|
||||
ActionURL: actionURL,
|
||||
}))
|
||||
if printErr := PrintHuman(&human, err); printErr != nil {
|
||||
t.Fatal(printErr)
|
||||
}
|
||||
if strings.Contains(human.String(), actionURL) || strings.Contains(human.String(), "处理入口") {
|
||||
t.Fatalf("unsafe action URL %q leaked to human output: %q", actionURL, human.String())
|
||||
}
|
||||
var jsonOutput strings.Builder
|
||||
if printErr := PrintJSON(&jsonOutput, err); printErr != nil {
|
||||
t.Fatal(printErr)
|
||||
}
|
||||
if strings.Contains(jsonOutput.String(), `"action_url"`) {
|
||||
t.Fatalf("unsafe action URL %q leaked to JSON output: %q", actionURL, jsonOutput.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePrintJSONIncludesRPCCodeAndData(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -128,6 +128,12 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
|
||||
{"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{}},
|
||||
{"oa_approval_task_created", EventOAApprovalTaskCreated, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, 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"}},
|
||||
|
||||
@@ -121,6 +121,94 @@ type GroupLifecycleEventOutput struct {
|
||||
Payload map[string]any `json:"payload" description:"群生命周期事件业务数据,字段以服务端实际推送为准" additional_properties:"true"`
|
||||
}
|
||||
|
||||
type OAApprovalTaskCreatedOutput 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"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
TaskID string `json:"task_id" description:"审批任务 ID"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批任务状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalTaskFinishedOutput 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"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
TaskID string `json:"task_id" description:"审批任务 ID"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批任务状态"`
|
||||
Result string `json:"result" description:"审批任务处理结果,值以服务端实际推送为准"`
|
||||
CreateTime int64 `json:"create_time" description:"审批任务创建时间" format:"timestamp_ms"`
|
||||
FinishTime int64 `json:"finish_time" description:"审批任务完成时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批任务事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalTaskRedirectedOutput 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"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
TaskID string `json:"task_id" description:"原审批任务 ID"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"原审批任务状态"`
|
||||
Result string `json:"result" description:"审批任务转交结果,值以服务端实际推送为准"`
|
||||
CreateTime int64 `json:"create_time" description:"原审批任务创建时间" format:"timestamp_ms"`
|
||||
FinishTime int64 `json:"finish_time" description:"原审批任务转交完成时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批任务转交事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceStartedOutput 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"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceTerminatedOutput 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"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
FinishTime int64 `json:"finish_time" description:"审批实例终止时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批实例终止事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceFinishedOutput 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"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例状态"`
|
||||
Result string `json:"result" description:"审批实例处理结果,值以服务端实际推送为准"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
FinishTime int64 `json:"finish_time" description:"审批实例完成时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type GroupMemberEventOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
@@ -230,6 +318,22 @@ type personalGroupMemberRecord struct {
|
||||
OpenDingTalkID string `json:"openDingTalkId"`
|
||||
}
|
||||
|
||||
type personalOAApprovalPayload struct {
|
||||
EventTime int64 `json:"event_time"`
|
||||
Body personalOAApprovalBody `json:"body"`
|
||||
}
|
||||
|
||||
type personalOAApprovalBody struct {
|
||||
ProcessInstanceID string `json:"processInstanceId"`
|
||||
ProcessCode string `json:"processCode"`
|
||||
TaskID string `json:"taskId"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
Result string `json:"result"`
|
||||
CreateTime int64 `json:"createTime"`
|
||||
FinishTime int64 `json:"finishTime"`
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -343,7 +447,7 @@ func ProjectOutput(ev transport.Event) (any, error) {
|
||||
case isGroupMemberEvent(eventType):
|
||||
return projectGroupMemberEvent(ev, base, data.Payload)
|
||||
case isGroupLifecycleEvent(eventType):
|
||||
payload, err := decodeGroupLifecyclePayload(data.Payload)
|
||||
payload, err := decodeConservativePayload(data.Payload)
|
||||
if err != nil {
|
||||
return ev, fmt.Errorf("decode personal group lifecycle payload: %w", err)
|
||||
}
|
||||
@@ -354,6 +458,8 @@ func ProjectOutput(ev transport.Event) (any, error) {
|
||||
SubscribeID: base.SubscribeID,
|
||||
Payload: payload,
|
||||
}, nil
|
||||
case isOAEvent(eventType):
|
||||
return projectOAApprovalEvent(ev, base, data.Payload)
|
||||
default:
|
||||
return ev, fmt.Errorf("unsupported personal event type %q", eventType)
|
||||
}
|
||||
@@ -370,7 +476,7 @@ func projectMessageEventContext(message personalMessageContext) MessageEventCont
|
||||
}
|
||||
}
|
||||
|
||||
func decodeGroupLifecyclePayload(raw json.RawMessage) (map[string]any, error) {
|
||||
func decodeConservativePayload(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")
|
||||
@@ -489,6 +595,112 @@ func projectGroupMemberEvent(ev transport.Event, base baseEventOutput, raw json.
|
||||
}, nil
|
||||
}
|
||||
|
||||
func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.RawMessage) (any, error) {
|
||||
var payload personalOAApprovalPayload
|
||||
if err := decodeRequiredPayload(raw, &payload); err != nil {
|
||||
return ev, fmt.Errorf("decode personal OA payload: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(payload.Body.ProcessInstanceID) == "" {
|
||||
return ev, fmt.Errorf("decode personal OA payload: processInstanceId is required")
|
||||
}
|
||||
if isOAApprovalTaskEvent(base.Type) && strings.TrimSpace(payload.Body.TaskID) == "" {
|
||||
return ev, fmt.Errorf("decode personal OA payload: taskId is required for %s", base.Type)
|
||||
}
|
||||
|
||||
switch base.Type {
|
||||
case EventOAApprovalTaskCreated:
|
||||
return OAApprovalTaskCreatedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
TaskID: payload.Body.TaskID,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalTaskFinished:
|
||||
return OAApprovalTaskFinishedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
TaskID: payload.Body.TaskID,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
Result: payload.Body.Result,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
FinishTime: payload.Body.FinishTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalTaskRedirected:
|
||||
return OAApprovalTaskRedirectedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
TaskID: payload.Body.TaskID,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
Result: payload.Body.Result,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
FinishTime: payload.Body.FinishTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceStarted:
|
||||
return OAApprovalInstanceStartedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
return OAApprovalInstanceTerminatedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
FinishTime: payload.Body.FinishTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceFinished:
|
||||
return OAApprovalInstanceFinishedOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
Result: payload.Body.Result,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
FinishTime: payload.Body.FinishTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
default:
|
||||
return ev, fmt.Errorf("unsupported personal OA event type %q", base.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeRequiredPayload(raw json.RawMessage, target any) error {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
|
||||
@@ -651,6 +863,18 @@ func outputTypeForEvent(eventKey string) reflect.Type {
|
||||
return reflect.TypeOf(GroupMemberEventOutput{})
|
||||
case isGroupLifecycleEvent(eventKey):
|
||||
return reflect.TypeOf(GroupLifecycleEventOutput{})
|
||||
case eventKey == EventOAApprovalTaskCreated:
|
||||
return reflect.TypeOf(OAApprovalTaskCreatedOutput{})
|
||||
case eventKey == EventOAApprovalTaskFinished:
|
||||
return reflect.TypeOf(OAApprovalTaskFinishedOutput{})
|
||||
case eventKey == EventOAApprovalTaskRedirected:
|
||||
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceStarted:
|
||||
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceTerminated:
|
||||
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceFinished:
|
||||
return reflect.TypeOf(OAApprovalInstanceFinishedOutput{})
|
||||
default:
|
||||
return reflect.TypeOf(baseEventOutput{})
|
||||
}
|
||||
@@ -677,6 +901,21 @@ func isGroupLifecycleEvent(eventKey string) bool {
|
||||
eventKey == EventGroupDisbanded
|
||||
}
|
||||
|
||||
func isOAEvent(eventKey string) bool {
|
||||
return eventKey == EventOAApprovalTaskCreated ||
|
||||
eventKey == EventOAApprovalTaskFinished ||
|
||||
eventKey == EventOAApprovalTaskRedirected ||
|
||||
eventKey == EventOAApprovalInstanceStarted ||
|
||||
eventKey == EventOAApprovalInstanceTerminated ||
|
||||
eventKey == EventOAApprovalInstanceFinished
|
||||
}
|
||||
|
||||
func isOAApprovalTaskEvent(eventKey string) bool {
|
||||
return eventKey == EventOAApprovalTaskCreated ||
|
||||
eventKey == EventOAApprovalTaskFinished ||
|
||||
eventKey == EventOAApprovalTaskRedirected
|
||||
}
|
||||
|
||||
func schemaType(t reflect.Type) string {
|
||||
switch t.Kind() {
|
||||
case reflect.String:
|
||||
|
||||
@@ -150,6 +150,59 @@ func personalGroupMemberData(eventKey string) string {
|
||||
}`, eventKey)
|
||||
}
|
||||
|
||||
func personalOAData(eventKey string) string {
|
||||
body := map[string]any{
|
||||
"processInstanceId": "process-instance-1",
|
||||
"createTime": int64(1785229100000),
|
||||
"processCode": "PROC-TEST-1",
|
||||
"title": "测试审批",
|
||||
}
|
||||
switch eventKey {
|
||||
case EventOAApprovalTaskCreated:
|
||||
body["taskId"] = "approval-task-1"
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalTaskFinished:
|
||||
body["taskId"] = "approval-task-1"
|
||||
body["status"] = "FINISHED"
|
||||
body["result"] = "agree"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalTaskRedirected:
|
||||
body["taskId"] = "approval-task-1"
|
||||
body["status"] = "FINISHED"
|
||||
body["result"] = "redirect"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalInstanceStarted:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
body["status"] = "TERMINATED"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalInstanceFinished:
|
||||
body["status"] = "FINISHED"
|
||||
body["result"] = "agree"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
}
|
||||
data := map[string]any{
|
||||
"eventId": "oa-event",
|
||||
"eventKey": eventKey,
|
||||
"occurredAtMs": int64(1785229200123),
|
||||
"subId": "oa-data-sub",
|
||||
"payload": map[string]any{
|
||||
"uid": 100001,
|
||||
"CORPID": "internal-corp",
|
||||
"clientId": "internal-client",
|
||||
"filterSubId": "internal-filter",
|
||||
"bizid": "internal-biz",
|
||||
"orgId": 100002,
|
||||
"sourceId": "open",
|
||||
"body": body,
|
||||
"event_time": int64(1785229199000),
|
||||
"futureField": map[string]any{"nested": true},
|
||||
},
|
||||
}
|
||||
encoded, _ := json.Marshal(data)
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputMessageEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventMention, EventSingleChat, EventInChat, EventFromUser, EventAllSingleChat, EventAllGroupChat} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
@@ -396,6 +449,170 @@ func TestCrossPlatformCoverageProjectOutputGroupLifecycleEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
|
||||
tests := []struct {
|
||||
eventKey string
|
||||
want any
|
||||
}{
|
||||
{
|
||||
eventKey: EventOAApprovalTaskCreated,
|
||||
want: OAApprovalTaskCreatedOutput{
|
||||
Type: EventOAApprovalTaskCreated,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
TaskID: "approval-task-1",
|
||||
Title: "测试审批",
|
||||
Status: "RUNNING",
|
||||
CreateTime: 1785229100000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalTaskFinished,
|
||||
want: OAApprovalTaskFinishedOutput{
|
||||
Type: EventOAApprovalTaskFinished,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
TaskID: "approval-task-1",
|
||||
Title: "测试审批",
|
||||
Status: "FINISHED",
|
||||
Result: "agree",
|
||||
CreateTime: 1785229100000,
|
||||
FinishTime: 1785229199000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalTaskRedirected,
|
||||
want: OAApprovalTaskRedirectedOutput{
|
||||
Type: EventOAApprovalTaskRedirected,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
TaskID: "approval-task-1",
|
||||
Title: "测试审批",
|
||||
Status: "FINISHED",
|
||||
Result: "redirect",
|
||||
CreateTime: 1785229100000,
|
||||
FinishTime: 1785229199000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceStarted,
|
||||
want: OAApprovalInstanceStartedOutput{
|
||||
Type: EventOAApprovalInstanceStarted,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "RUNNING",
|
||||
CreateTime: 1785229100000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
want: OAApprovalInstanceTerminatedOutput{
|
||||
Type: EventOAApprovalInstanceTerminated,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "TERMINATED",
|
||||
CreateTime: 1785229100000,
|
||||
FinishTime: 1785229199000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceFinished,
|
||||
want: OAApprovalInstanceFinishedOutput{
|
||||
Type: EventOAApprovalInstanceFinished,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "FINISHED",
|
||||
Result: "agree",
|
||||
CreateTime: 1785229100000,
|
||||
FinishTime: 1785229199000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.eventKey, func(t *testing.T) {
|
||||
projected, err := ProjectOutput(transport.Event{
|
||||
EventID: "outer-event",
|
||||
EventBornTime: 11,
|
||||
EventType: tt.eventKey,
|
||||
SubscribeID: "outer-sub",
|
||||
Data: personalOAData(tt.eventKey),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(projected, tt.want) {
|
||||
t.Fatalf("ProjectOutput() = %#v, want %#v", projected, tt.want)
|
||||
}
|
||||
assertNoInternalActionFields(t, projected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputRejectsUnsupportedOAType(t *testing.T) {
|
||||
ev := transport.Event{EventID: "outer-event", EventType: "user_oa_approval_unknown"}
|
||||
projected, err := projectOAApprovalEvent(
|
||||
ev,
|
||||
baseEventOutput{Type: ev.EventType, EventID: ev.EventID},
|
||||
json.RawMessage(`{"body":{"processInstanceId":"process-instance-1"},"event_time":1}`),
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), `unsupported personal OA event type "user_oa_approval_unknown"`) {
|
||||
t.Fatalf("projectOAApprovalEvent() error = %v", err)
|
||||
}
|
||||
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
|
||||
t.Fatalf("projectOAApprovalEvent() fallback = %#v, want %#v", projected, ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputOADecodesDoublyWrappedJSONString(t *testing.T) {
|
||||
once, err := json.Marshal(personalOAData(EventOAApprovalTaskCreated))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
twice, err := json.Marshal(string(once))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
projected, err := ProjectOutput(transport.Event{Data: string(twice)})
|
||||
if err != nil {
|
||||
t.Fatalf("ProjectOutput() error = %v", err)
|
||||
}
|
||||
got, ok := projected.(OAApprovalTaskCreatedOutput)
|
||||
if !ok {
|
||||
t.Fatalf("ProjectOutput() type = %T, want OAApprovalTaskCreatedOutput", projected)
|
||||
}
|
||||
if got.Type != EventOAApprovalTaskCreated || got.EventID != "oa-event" || got.SubscribeID != "oa-data-sub" {
|
||||
t.Fatalf("ProjectOutput() = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputGroupMemberEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventGroupMemberAdded, EventGroupMemberExited} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
@@ -549,6 +766,93 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidGroupLifecyclePayloads(
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
payload string
|
||||
}{
|
||||
{name: "missing"},
|
||||
{name: "null", payload: `,"payload":null`},
|
||||
{name: "empty object", payload: `,"payload":{}`},
|
||||
{name: "array", payload: `,"payload":[]`},
|
||||
{name: "string", payload: `,"payload":"invalid"`},
|
||||
{name: "missing body", payload: `,"payload":{"event_time":1}`},
|
||||
{name: "null body", payload: `,"payload":{"body":null,"event_time":1}`},
|
||||
{name: "empty body", payload: `,"payload":{"body":{},"event_time":1}`},
|
||||
}
|
||||
for _, eventKey := range []string{
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
for _, tt := range tests {
|
||||
t.Run(eventKey+"/"+tt.name, func(t *testing.T) {
|
||||
ev := transport.Event{
|
||||
EventID: "outer-event",
|
||||
EventType: eventKey,
|
||||
Data: fmt.Sprintf(`{"eventKey":%q%s}`, eventKey, tt.payload),
|
||||
}
|
||||
projected, err := ProjectOutput(ev)
|
||||
if err == nil {
|
||||
t.Fatal("ProjectOutput() error = nil, want OA payload validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "decode personal OA payload") {
|
||||
t.Fatalf("ProjectOutput() error = %v, want OA payload context", err)
|
||||
}
|
||||
got, ok := projected.(transport.Event)
|
||||
if !ok || !reflect.DeepEqual(got, ev) {
|
||||
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputRejectsOAWithoutStableIDs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
eventKey string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing process instance",
|
||||
eventKey: EventOAApprovalInstanceStarted,
|
||||
body: `{"status":"RUNNING"}`,
|
||||
want: "processInstanceId is required",
|
||||
},
|
||||
{
|
||||
name: "missing task",
|
||||
eventKey: EventOAApprovalTaskCreated,
|
||||
body: `{"processInstanceId":"process-instance-1","status":"RUNNING"}`,
|
||||
want: "taskId is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ev := transport.Event{
|
||||
EventID: "outer-event",
|
||||
EventType: tt.eventKey,
|
||||
Data: fmt.Sprintf(
|
||||
`{"eventKey":%q,"payload":{"body":%s,"event_time":1}}`,
|
||||
tt.eventKey,
|
||||
tt.body,
|
||||
),
|
||||
}
|
||||
projected, err := ProjectOutput(ev)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("ProjectOutput() error = %v, want %q", err, tt.want)
|
||||
}
|
||||
if got, ok := projected.(transport.Event); !ok || !reflect.DeepEqual(got, ev) {
|
||||
t.Fatalf("ProjectOutput() fallback = %#v, want %#v", projected, ev)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectOutputDecodesWrappedJSONString(t *testing.T) {
|
||||
wrapped, err := json.Marshal(personalMessageData(EventSingleChat))
|
||||
if err != nil {
|
||||
|
||||
@@ -23,22 +23,28 @@ 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"
|
||||
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"
|
||||
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"
|
||||
EventOAApprovalTaskCreated = "user_oa_approval_task_created"
|
||||
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
|
||||
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
|
||||
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
|
||||
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
|
||||
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -273,6 +279,72 @@ var definitions = []Definition{
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalTaskCreated,
|
||||
DisplayName: "审批任务创建",
|
||||
Description: "审批任务创建,发送给审批人",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalTaskFinished,
|
||||
DisplayName: "审批任务完成",
|
||||
Description: "审批任务已完成",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalTaskRedirected,
|
||||
DisplayName: "审批人任务转交",
|
||||
Description: "审批任务已转交",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceStarted,
|
||||
DisplayName: "发起审批单",
|
||||
Description: "审批实例已发起",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceTerminated,
|
||||
DisplayName: "审批单终止",
|
||||
Description: "审批实例已终止",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceFinished,
|
||||
DisplayName: "审批单完成",
|
||||
Description: "审批实例完成,发送给审批单发起人",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
}
|
||||
|
||||
func targetUIDConstraints() *ParameterConstraints {
|
||||
|
||||
@@ -46,12 +46,47 @@ func TestCatalogEnabledEvents(t *testing.T) {
|
||||
EventGroupMemberAdded,
|
||||
EventGroupMemberExited,
|
||||
EventGroupDisbanded,
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
if !reflect.DeepEqual(keys, want) {
|
||||
t.Fatalf("keys = %#v, want %#v", keys, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAEventCatalogDefinitions(t *testing.T) {
|
||||
items := Catalog("oa", true, false)
|
||||
wantKeys := []string{
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
if len(items) != len(wantKeys) {
|
||||
t.Fatalf("Catalog(oa) = %#v, want %d events", items, len(wantKeys))
|
||||
}
|
||||
for i, item := range items {
|
||||
if item.EventKey != wantKeys[i] {
|
||||
t.Fatalf("Catalog(oa)[%d].event_key = %q, want %q", i, item.EventKey, wantKeys[i])
|
||||
}
|
||||
if item.Category != "oa" || item.RuleType != "all" || item.Status != StatusEnabled || !item.Public {
|
||||
t.Fatalf("Catalog(oa)[%d] = %#v, want public enabled oa/all event", i, item)
|
||||
}
|
||||
if len(item.RequiredParams) != 0 || item.Constraints != nil {
|
||||
t.Fatalf("Catalog(oa)[%d] parameters = %#v/%#v, want none", i, item.RequiredParams, item.Constraints)
|
||||
}
|
||||
if item.Auth["identity"] != "user" {
|
||||
t.Fatalf("Catalog(oa)[%d].auth = %#v, want user identity", i, item.Auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventFromUserIsPublic(t *testing.T) {
|
||||
if _, ok := Lookup(EventFromUser); !ok {
|
||||
t.Fatalf("Lookup(%q) failed", EventFromUser)
|
||||
@@ -119,6 +154,12 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
|
||||
EventGroupMemberAdded,
|
||||
EventGroupMemberExited,
|
||||
EventGroupDisbanded,
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
def, ok := Lookup(eventKey)
|
||||
@@ -433,6 +474,96 @@ func TestGroupLifecycleSchemaDocumentsUseConservativePayload(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
|
||||
tests := []struct {
|
||||
eventKey string
|
||||
properties []string
|
||||
}{
|
||||
{
|
||||
eventKey: EventOAApprovalTaskCreated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalTaskFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalTaskRedirected,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "task_id", "title", "status", "result", "create_time",
|
||||
"finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceStarted,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "finish_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceFinished,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "result", "create_time", "finish_time",
|
||||
"event_time",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.eventKey, func(t *testing.T) {
|
||||
def, ok := Lookup(tt.eventKey)
|
||||
if !ok {
|
||||
t.Fatalf("Lookup(%q) failed", tt.eventKey)
|
||||
}
|
||||
doc := BuildSchemaDocumentForMode(def, true)
|
||||
if doc.JQRootPath != "." {
|
||||
t.Fatalf("jq_root_path = %q, want .", doc.JQRootPath)
|
||||
}
|
||||
props, ok := doc.Schema["properties"].(map[string]any)
|
||||
if !ok || len(props) != len(tt.properties) {
|
||||
t.Fatalf("schema.properties = %#v, want exactly %d fields", doc.Schema["properties"], len(tt.properties))
|
||||
}
|
||||
for _, name := range tt.properties {
|
||||
if _, ok := props[name].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.%s = %#v, want object", name, props[name])
|
||||
}
|
||||
}
|
||||
eventType := props["type"].(map[string]any)
|
||||
if !reflect.DeepEqual(eventType["enum"], []string{tt.eventKey}) {
|
||||
t.Fatalf("schema.properties.type.enum = %#v, want %q", eventType["enum"], tt.eventKey)
|
||||
}
|
||||
if _, ok := props["payload"]; ok {
|
||||
t.Fatalf("schema.properties exposed generic payload: %#v", props)
|
||||
}
|
||||
for _, name := range []string{"timestamp", "create_time", "finish_time", "event_time"} {
|
||||
property, exists := props[name].(map[string]any)
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
if property["type"] != "integer" || property["format"] != "timestamp_ms" {
|
||||
t.Fatalf("schema.properties.%s = %#v, want timestamp_ms integer", name, property)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupMemberSchemaDocumentsMatchOutputDTO(t *testing.T) {
|
||||
wantProperties := []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
|
||||
@@ -495,7 +626,16 @@ func TestBuildRuleParamMention(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBuildRuleParamAllEvents(t *testing.T) {
|
||||
for _, eventKey := range []string{EventAllSingleChat, EventAllGroupChat} {
|
||||
for _, eventKey := range []string{
|
||||
EventAllSingleChat,
|
||||
EventAllGroupChat,
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
rule, param, err := BuildRuleParam(eventKey, RuleOptions{})
|
||||
if err != nil {
|
||||
@@ -701,6 +841,12 @@ func TestSupportsMessageFilter(t *testing.T) {
|
||||
EventReadO2O,
|
||||
EventReactionGroup,
|
||||
EventGroupUpdated,
|
||||
EventOAApprovalTaskCreated,
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
"unknown_event",
|
||||
} {
|
||||
if SupportsMessageFilter(eventKey) {
|
||||
|
||||
+178
-2
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/paging"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
@@ -125,6 +126,70 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
|
||||
return dsl, nil
|
||||
}
|
||||
|
||||
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
|
||||
tableID, _ := cmd.Flags().GetString("table-id")
|
||||
tableID = strings.TrimSpace(tableID)
|
||||
recordIDs, _ := cmd.Flags().GetStringSlice("record-ids")
|
||||
cleaned := make([]string, 0, len(recordIDs))
|
||||
seen := make(map[string]struct{}, len(recordIDs))
|
||||
for _, recordID := range recordIDs {
|
||||
recordID = strings.TrimSpace(recordID)
|
||||
if recordID == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[recordID]; ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--record-ids 不能包含重复值 %q", recordID))
|
||||
}
|
||||
seen[recordID] = struct{}{}
|
||||
cleaned = append(cleaned, recordID)
|
||||
}
|
||||
if cmd.Flags().Changed("table-id") && tableID == "" {
|
||||
return apperrors.NewValidation("--table-id 不能为空")
|
||||
}
|
||||
if cmd.Flags().Changed("record-ids") && len(cleaned) == 0 {
|
||||
return apperrors.NewValidation("--record-ids 必须包含 1 到 5 个非空记录 ID")
|
||||
}
|
||||
if len(cleaned) > 5 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--record-ids 最多支持 5 个记录 ID,got %d", len(cleaned)))
|
||||
}
|
||||
if (tableID != "") != (len(cleaned) > 0) {
|
||||
return apperrors.NewValidation("--table-id 与 --record-ids 必须同时提供;定时触发工作流则两者都不传")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWorkflowHistoryFlags(cmd *cobra.Command, _ []string) error {
|
||||
if cmd.Flags().Changed("page") {
|
||||
page, _ := cmd.Flags().GetInt("page")
|
||||
if page < 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--page 必须 >= 0,got %d", page))
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("size") {
|
||||
size, _ := cmd.Flags().GetInt("size")
|
||||
if size < 1 || size > 100 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--size 必须在 [1, 100] 范围内,got %d", size))
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"after-time", "before-time"} {
|
||||
if !cmd.Flags().Changed(name) {
|
||||
continue
|
||||
}
|
||||
value, _ := cmd.Flags().GetInt(name)
|
||||
if value < 0 {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 >= 0 的 Unix 毫秒时间戳,got %d", name, value))
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("after-time") && cmd.Flags().Changed("before-time") {
|
||||
afterTime, _ := cmd.Flags().GetInt("after-time")
|
||||
beforeTime, _ := cmd.Flags().GetInt("before-time")
|
||||
if afterTime >= beforeTime {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--after-time 必须小于 --before-time,got %d >= %d", afterTime, beforeTime))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordQueryFetchAll implements --all auto-pagination for record query.
|
||||
// It prints only a complete result. A page limit, empty/invalid response,
|
||||
// transport failure, or cursor cycle returns a non-zero structured error whose
|
||||
@@ -1038,7 +1103,7 @@ func newAitableCommand() *cobra.Command {
|
||||
dws aitable form [list|delete|update] 表单管理
|
||||
dws aitable form field [list|update|hide] 表单字段管理
|
||||
dws aitable form share [get|update|notify] 表单分享管理
|
||||
dws aitable workflow [edit-example|create|update|enable|disable|get|list] 自动化工作流管理
|
||||
dws aitable workflow [edit-example|create|update|enable|disable|run|history|get|list] 自动化工作流管理
|
||||
dws aitable dashboard [get|create|update|delete|config-example] 仪表盘管理
|
||||
dws aitable chart [get|create|update|delete|widgets-example] 图表管理
|
||||
dws aitable export data 数据导出
|
||||
@@ -4878,7 +4943,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
|
||||
workflowCmd := &cobra.Command{
|
||||
Use: "workflow",
|
||||
Short: "自动化工作流管理(创建 / 更新 / 启停 / 查看 / 列表)",
|
||||
Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
@@ -5223,6 +5288,116 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
},
|
||||
})
|
||||
|
||||
workflowRunCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "run",
|
||||
Short: "执行指定自动化工作流",
|
||||
Long: `立即执行指定 Base 中的自动化工作流。此命令会启动真实的异步执行,并可能产生该工作流配置的消息发送、记录写入等副作用,因此执行前需要确认;CLI 不自动重试。
|
||||
|
||||
记录类触发器必须同时提供 --table-id 与 --record-ids;--table-id 必须与触发器绑定的数据表一致,--record-ids 接受 1 到 5 个不重复记录 ID。定时触发器不传这两个参数。
|
||||
返回每条记录的提交状态;提交成功项包含 executionId,可用 workflow history 返回项的 instanceId 匹配执行记录。`,
|
||||
Example: ` dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID --table-id TABLE_ID --record-ids RECORD_ID_1,RECORD_ID_2
|
||||
dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID`,
|
||||
Tool: "run_workflow",
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Validate: validateWorkflowRunFlags,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}},
|
||||
{Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "workflowId", Trim: true, Required: true},
|
||||
{Name: "table-id", Usage: "记录类触发器绑定的 Table ID;定时触发器不传", Bind: "tableId", Trim: true, OmitEmpty: true, RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"},
|
||||
{Name: "record-ids", Usage: "触发工作流的记录 ID,逗号分隔;记录类触发器必填,1 到 5 个且不可重复", Kind: LeafStringSlice, Bind: "recordIds", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"},
|
||||
},
|
||||
Constraints: []LeafConstraint{{
|
||||
Kind: corecmd.Custom,
|
||||
Flags: []string{"table-id", "record-ids"},
|
||||
Description: "记录类触发器必须同时提供 --table-id 与 --record-ids;定时触发器两者都不传",
|
||||
}},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "workflow_run",
|
||||
CanonicalPath: "aitable.workflow_run",
|
||||
CLIPath: "aitable workflow run",
|
||||
PrimaryCLIPath: "aitable workflow run",
|
||||
},
|
||||
Description: "立即执行 AI 表格自动化工作流,并返回异步执行提交结果。",
|
||||
Interface: aitableMCPInterface("run_workflow"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "立即执行已知 AI 表格自动化工作流,并获取 executionId。",
|
||||
UseWhen: []string{"用户明确要求立即执行已知工作流,已确认真实 base-id、workflow-id、触发类型及可能产生的业务副作用;记录类触发器还需确认绑定的 table-id 和 1 到 5 个真实 record-id"},
|
||||
AvoidWhen: []string{"仅开启后续自动触发用 workflow enable;查询工作流定义用 workflow get;查询既有执行结果用 workflow history;返回 executionId 后应以 history 的 instanceId 核对,不要在结果不确定时直接重复执行"},
|
||||
Examples: []string{
|
||||
"dws aitable workflow run --base-id <BASE_ID> --workflow-id <WORKFLOW_ID> --table-id <TABLE_ID> --record-ids <RECORD_ID>",
|
||||
"dws aitable workflow run --base-id <BASE_ID> --workflow-id <WORKFLOW_ID>",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"},
|
||||
{Name: "workflow-id", Property: "workflowId", Required: boolPtr(true), InterfaceType: "string"},
|
||||
{Name: "table-id", Property: "tableId", InterfaceType: "string", RequiredWhen: "record-ids is provided or the workflow uses a record-based trigger"},
|
||||
{Name: "record-ids", Property: "recordIds", InterfaceType: "array", RequiredWhen: "table-id is provided or the workflow uses a record-based trigger"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
workflowHistoryCmd := NewLeafCommand(LeafSpec{
|
||||
Use: "history",
|
||||
Short: "查询工作流执行历史",
|
||||
Long: `分页查询指定 AI 表格工作流的执行历史。
|
||||
可按状态和 Unix 毫秒时间范围筛选;同时提供 --after-time 与 --before-time 时,前者必须小于后者。--page 从 0 开始,--size 默认 20、最大 100。
|
||||
返回 totalCount 与 list;run 返回的 executionId 可与历史项 instanceId 匹配。`,
|
||||
Example: ` dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID
|
||||
dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID --status failed --after-time 1786000000000 --before-time 1787000000000 --page 0 --size 50`,
|
||||
Tool: "get_flow_record_list",
|
||||
Safety: aitableSafetyRead(),
|
||||
Validate: validateWorkflowHistoryFlags,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "base-id", Usage: "目标 Base ID (必填)", Bind: "baseId", Trim: true, Required: true, Aliases: []string{"base"}},
|
||||
{Name: "workflow-id", Usage: "目标工作流 ID (必填)", Bind: "flowId", Trim: true, Required: true},
|
||||
{Name: "status", Usage: "执行状态筛选;不传表示全部", Bind: "status", Trim: true, OmitEmpty: true, Enum: []string{"success", "failed", "running", "break", "untrigger"}},
|
||||
{Name: "after-time", Usage: "开始时间(Unix 毫秒)", Kind: LeafInt, Bind: "afterTime"},
|
||||
{Name: "before-time", Usage: "结束时间(Unix 毫秒)", Kind: LeafInt, Bind: "beforeTime"},
|
||||
{Name: "page", Usage: "页码,从 0 开始", Kind: LeafInt, Default: "0", Bind: "page"},
|
||||
{Name: "size", Usage: "每页条数 [1, 100]", Kind: LeafInt, Default: "20", Bind: "size"},
|
||||
},
|
||||
Constraints: []LeafConstraint{{
|
||||
Kind: corecmd.Custom,
|
||||
Flags: []string{"after-time", "before-time"},
|
||||
Description: "同时提供 --after-time 与 --before-time 时,--after-time 必须小于 --before-time",
|
||||
}},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "workflow_history",
|
||||
CanonicalPath: "aitable.workflow_history",
|
||||
CLIPath: "aitable workflow history",
|
||||
PrimaryCLIPath: "aitable workflow history",
|
||||
},
|
||||
Description: "分页查询 AI 表格自动化工作流执行历史。",
|
||||
Interface: aitableMCPInterface("get_flow_record_list"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按状态、时间和分页条件查询工作流执行历史。",
|
||||
UseWhen: []string{"需要核对工作流是否执行、执行结果或定位 run 返回的 executionId 时;executionId 与历史项 instanceId 相同,running 为非终态"},
|
||||
AvoidWhen: []string{"查询工作流定义用 workflow get;列出工作流用 workflow list;立即发起执行用 workflow run"},
|
||||
Examples: []string{
|
||||
"dws aitable workflow history --base-id <BASE_ID> --workflow-id <WORKFLOW_ID>",
|
||||
"dws aitable workflow history --base-id <BASE_ID> --workflow-id <WORKFLOW_ID> --status failed --page 0 --size 50",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true), InterfaceType: "string"},
|
||||
{Name: "workflow-id", Property: "flowId", Required: boolPtr(true), InterfaceType: "string"},
|
||||
{Name: "status", Property: "status", InterfaceType: "string", Enum: []string{"success", "failed", "running", "break", "untrigger"}},
|
||||
{Name: "after-time", Property: "afterTime", InterfaceType: "number"},
|
||||
{Name: "before-time", Property: "beforeTime", InterfaceType: "number"},
|
||||
{Name: "page", Property: "page", InterfaceType: "number"},
|
||||
{Name: "size", Property: "size", InterfaceType: "number"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// ── dashboard: 仪表盘管理 ────────────────────────────────────
|
||||
|
||||
dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}
|
||||
@@ -7424,6 +7599,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
workflowCmd.AddCommand(
|
||||
workflowEditExampleCmd, workflowCreateCmd, workflowUpdateCmd,
|
||||
workflowEnableCmd, workflowDisableCmd,
|
||||
workflowRunCmd, workflowHistoryCmd,
|
||||
workflowGetCmd, workflowListCmd,
|
||||
)
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -40,26 +41,23 @@ func (*aitableWorkflowCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
previousArgs := os.Args
|
||||
t.Cleanup(func() {
|
||||
deps = previousDeps
|
||||
os.Args = previousArgs
|
||||
})
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableWorkflowCaller{}
|
||||
InitDeps(caller)
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
|
||||
|
||||
cmd := newAitableCommand()
|
||||
cmd.PersistentFlags().String("format", "json", "output format")
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"workflow"}, args...))
|
||||
if stdin != nil {
|
||||
cmd.SetIn(stdin)
|
||||
if stdin == nil {
|
||||
stdin = strings.NewReader("")
|
||||
}
|
||||
cmd.SetIn(stdin)
|
||||
return caller, cmd.Execute()
|
||||
}
|
||||
|
||||
@@ -197,3 +195,158 @@ func TestAitableWorkflowWriteRejectsInvalidInput(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowRunMapsRecordTrigger(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"run",
|
||||
"--base-id", "base-run",
|
||||
"--workflow-id", "workflow-run",
|
||||
"--table-id", "table-run",
|
||||
"--record-ids", "record-1,record-2",
|
||||
"--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow run returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "aitable" || call.toolName != "run_workflow" {
|
||||
t.Fatalf("tool call = %s/%s, want aitable/run_workflow", call.productID, call.toolName)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-run",
|
||||
"workflowId": "workflow-run",
|
||||
"tableId": "table-run",
|
||||
"recordIds": []string{"record-1", "record-2"},
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowRunMapsScheduledTrigger(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"run", "--base", "base-scheduled", "--workflow-id", "workflow-scheduled", "--yes",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("scheduled workflow run returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-scheduled",
|
||||
"workflowId": "workflow-scheduled",
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want one scheduled invocation %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowRunRejectsUnsafeOrInvalidInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "confirmation", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow"}, want: "用户确认"},
|
||||
{name: "blank table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", " ", "--yes"}, want: "--table-id 不能为空"},
|
||||
{name: "blank records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", " , ", "--yes"}, want: "--record-ids 必须包含"},
|
||||
{name: "table without records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--yes"}, want: "必须同时提供"},
|
||||
{name: "records without table", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--record-ids", "record", "--yes"}, want: "必须同时提供"},
|
||||
{name: "duplicate records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "record,record", "--yes"}, want: "不能包含重复值"},
|
||||
{name: "too many records", args: []string{"run", "--base-id", "base", "--workflow-id", "workflow", "--table-id", "table", "--record-ids", "r1,r2,r3,r4,r5,r6", "--yes"}, want: "最多支持 5 个"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
|
||||
if err == nil || !strings.Contains(strings.ToLower(err.Error()), strings.ToLower(tc.want)) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid run reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsFilters(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"history",
|
||||
"--base-id", "base-history",
|
||||
"--workflow-id", "workflow-history",
|
||||
"--status", "failed",
|
||||
"--after-time", "1786000000000",
|
||||
"--before-time", "1787000000000",
|
||||
"--page", "2",
|
||||
"--size", "50",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow history returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.productID != "aitable" || call.toolName != "get_flow_record_list" {
|
||||
t.Fatalf("tool call = %s/%s, want aitable/get_flow_record_list", call.productID, call.toolName)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-history",
|
||||
"flowId": "workflow-history",
|
||||
"status": "failed",
|
||||
"afterTime": 1786000000000,
|
||||
"beforeTime": 1787000000000,
|
||||
"page": 2,
|
||||
"size": 50,
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowHistoryMapsSingleTimeFilter(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil,
|
||||
"history",
|
||||
"--base-id", "base-history",
|
||||
"--workflow-id", "workflow-history",
|
||||
"--after-time", "1786000000000",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("workflow history returned error: %v", err)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"baseId": "base-history",
|
||||
"flowId": "workflow-history",
|
||||
"afterTime": 1786000000000,
|
||||
"size": 20,
|
||||
}
|
||||
if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, wantArgs) {
|
||||
t.Fatalf("calls = %#v, want one history invocation %#v", caller.calls, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowHistoryRejectsInvalidFilters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "status", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--status", "unknown"}, want: "允许值"},
|
||||
{name: "negative page", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--page", "-1"}, want: "--page 必须 >= 0"},
|
||||
{name: "zero size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "0"}, want: "--size 必须在"},
|
||||
{name: "large size", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--size", "101"}, want: "--size 必须在"},
|
||||
{name: "negative after", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "-1"}, want: "Unix 毫秒时间戳"},
|
||||
{name: "reversed range", args: []string{"history", "--base-id", "base", "--workflow-id", "workflow", "--after-time", "200", "--before-time", "100"}, want: "必须小于"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid history query reached MCP: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+763
-268
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func runNativeCardUpdate(t *testing.T, caller *scriptedToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installScriptedCaller(t, caller)
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if root.PersistentFlags().Lookup("dry-run") == nil {
|
||||
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
||||
}
|
||||
if root.PersistentFlags().Lookup("yes") == nil {
|
||||
root.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeMessageUpdateCardVerifiesWrite(t *testing.T) {
|
||||
t.Run("atomic command preserves no-extra-confirmation contract", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-confirm","updated":true}}`}}}
|
||||
if err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-confirm",
|
||||
"--content", "原子更新",
|
||||
"--flow-status", "3",
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantArgs := map[string]any{
|
||||
"bizId": "biz-confirm",
|
||||
"msgContent": "原子更新",
|
||||
"flowStatus": 3,
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" || !reflect.DeepEqual(caller.args, wantArgs) {
|
||||
t.Fatalf("atomic call = count:%d server:%q tool:%q args:%#v", caller.calls, caller.server, caller.tool, caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit evidence succeeds", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":{"bizId":"biz-1","updated":true}}`}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-1",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "im" || caller.tool != "update_streaming_card" {
|
||||
t.Fatalf("call = count:%d server:%q tool:%q", caller.calls, caller.server, caller.tool)
|
||||
}
|
||||
if caller.args["bizId"] != "biz-1" {
|
||||
t.Fatalf("args = %#v", caller.args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic success is unverified", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"success":true,"errorCode":null}`}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "not-a-real-card",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
|
||||
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lower write error is preserved", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{err: errors.New("write unavailable")}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-1",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("lower write error was ignored")
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
response string
|
||||
wantReason string
|
||||
}{
|
||||
{name: "empty response", response: "", wantReason: "streaming_card_update_unverified"},
|
||||
{name: "invalid response", response: "{", wantReason: "streaming_card_update_response_invalid"},
|
||||
{name: "not applied", response: `{"result":{"updated":false}}`, wantReason: "streaming_card_update_not_applied"},
|
||||
{name: "biz id drift", response: `{"result":{"bizId":"biz-other","updated":true}}`, wantReason: "streaming_card_update_biz_id_mismatch"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: test.response}}}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-1",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != test.wantReason {
|
||||
t.Fatalf("error = %#v, want reason %q", err, test.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("invalid arguments make no call", func(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"message", "update-card", "--biz-id", "<bizId>", "--content", "完成", "--flow-status", "3"},
|
||||
{"message", "update-card", "--biz-id", "biz-1", "--content", "完成", "--flow-status", "6"},
|
||||
} {
|
||||
caller := &scriptedToolCaller{}
|
||||
if err := runNativeCardUpdate(t, caller, args...); err == nil {
|
||||
t.Fatalf("args %v unexpectedly succeeded", args)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("args %v made %d calls", args, caller.calls)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run publishes unverified plan without write", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
err := runNativeCardUpdate(t, caller,
|
||||
"message", "update-card",
|
||||
"--biz-id", "biz-preview",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
"--dry-run",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("dry-run made %d calls", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func runChatCoverageCommand(t *testing.T, caller edition.ToolCaller, args ...str
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
root.SetArgs(append(append([]string(nil), args...), "--yes"))
|
||||
return root.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type chatMessagePaginationCaller struct {
|
||||
steps []scriptedToolStep
|
||||
calls []pagedCommandCall
|
||||
}
|
||||
|
||||
func (c *chatMessagePaginationCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := map[string]any{}
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied})
|
||||
if len(c.steps) == 0 {
|
||||
return textToolResult(`{"result":{"messages":[],"items":[],"hasMore":false,"nextCursor":"0"}}`), nil
|
||||
}
|
||||
step := c.steps[len(c.calls)-1]
|
||||
if step.err != nil {
|
||||
return nil, step.err
|
||||
}
|
||||
return textToolResult(step.text), nil
|
||||
}
|
||||
|
||||
func (*chatMessagePaginationCaller) Format() string { return "json" }
|
||||
func (*chatMessagePaginationCaller) DryRun() bool { return false }
|
||||
func (*chatMessagePaginationCaller) Fields() string { return "" }
|
||||
func (*chatMessagePaginationCaller) JQ() string { return "" }
|
||||
|
||||
func executeChatMessagePaginationCommand(t *testing.T, caller *chatMessagePaginationCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
oldDeps := deps
|
||||
oldSleep := helperSleep
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
helperSleep = oldSleep
|
||||
})
|
||||
InitDeps(caller)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = io.Discard
|
||||
helperSleep = func(d time.Duration) {}
|
||||
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
err := root.ExecuteContext(context.Background())
|
||||
if out.Len() == 0 {
|
||||
return nil, err
|
||||
}
|
||||
var parsed map[string]any
|
||||
if unmarshalErr := json.Unmarshal(out.Bytes(), &parsed); unmarshalErr != nil {
|
||||
t.Fatalf("stdout JSON = %q, err = %v", out.String(), unmarshalErr)
|
||||
}
|
||||
return parsed, err
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationDefaultSinglePageUnchanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
server string
|
||||
tool string
|
||||
want map[string]any
|
||||
}{
|
||||
{
|
||||
name: "list-all",
|
||||
args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"},
|
||||
server: "",
|
||||
tool: "search_messages_by_time_range",
|
||||
want: map[string]any{"startTime": "2026-08-01 00:00:00", "endTime": "2026-08-02 00:00:00", "limit": 50, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "list-by-sender",
|
||||
args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "",
|
||||
tool: "search_messages_by_sender",
|
||||
want: map[string]any{"senderUserId": "u1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "list-mentions",
|
||||
args: []string{"message", "list-mentions", "--group", "cid1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "",
|
||||
tool: "search_at_me_message",
|
||||
want: map[string]any{"openConversationId": "cid1", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 50, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "list-focused",
|
||||
args: []string{"message", "list-focused"},
|
||||
server: "",
|
||||
tool: "list_special_focus_messages",
|
||||
want: map[string]any{"limit": 50},
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "",
|
||||
tool: "search_messages_by_keyword",
|
||||
want: map[string]any{"keyword": "发布", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "search-advanced",
|
||||
args: []string{"message", "search-advanced", "--query", "周报", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "im",
|
||||
tool: "search_messages",
|
||||
want: map[string]any{"keyword": "周报", "startTime": float64(1785513600000), "endTime": float64(1785600000000), "limit": 100, "cursor": "0"},
|
||||
},
|
||||
{
|
||||
name: "list-favorites",
|
||||
args: []string{"message", "list-favorites"},
|
||||
server: "im",
|
||||
tool: "list_message_favorites",
|
||||
want: map[string]any{"cursor": int64(0), "size": "20"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{}
|
||||
args := append([]string{}, tt.args...)
|
||||
args = append(args, "--page-limit", "2", "--max-items", "1", "--page-delay", "0")
|
||||
_, err := executeChatMessagePaginationCommand(t, caller, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %#v, want one fallback call", caller.calls)
|
||||
}
|
||||
got := caller.calls[0]
|
||||
if got.server != tt.server || got.tool != tt.tool || !argsEqual(got.args, tt.want) {
|
||||
t.Fatalf("call = %#v, want server=%s tool=%s args=%#v", got, tt.server, tt.tool, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessagePaginationPageAllAggregatesSevenCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
server string
|
||||
tool string
|
||||
itemPath string
|
||||
cursorOne any
|
||||
cursorTwo any
|
||||
pageOne string
|
||||
pageTwo string
|
||||
}{
|
||||
{
|
||||
name: "list-all", args: []string{"message", "list-all", "--start", "2026-08-01 00:00:00", "--end", "2026-08-02 00:00:00"},
|
||||
server: "chat", tool: "search_messages_by_time_range", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "list-by-sender", args: []string{"message", "list-by-sender", "--sender-user-id", "u1", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "chat", tool: "search_messages_by_sender", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"洄川","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "list-mentions", args: []string{"message", "list-mentions", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "chat", tool: "search_at_me_message", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "list-focused", args: []string{"message", "list-focused"},
|
||||
server: "chat", tool: "list_special_focus_messages", itemPath: "messages", cursorOne: nil, cursorTwo: int64(2),
|
||||
pageOne: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":2}}`,
|
||||
pageTwo: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":0}}`,
|
||||
},
|
||||
{
|
||||
name: "search", args: []string{"message", "search", "--query", "发布", "--start", "2026-08-01T00:00:00+08:00", "--end", "2026-08-02T00:00:00+08:00"},
|
||||
server: "chat", tool: "search_messages_by_keyword", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "search-advanced", args: []string{"message", "search-advanced", "--query", "周报"},
|
||||
server: "im", tool: "search_messages", itemPath: "conversationMessagesList", cursorOne: "0", cursorTwo: "c2",
|
||||
pageOne: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
pageTwo: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`,
|
||||
},
|
||||
{
|
||||
name: "list-favorites", args: []string{"message", "list-favorites"},
|
||||
server: "im", tool: "list_message_favorites", itemPath: "items", cursorOne: int64(0), cursorTwo: int64(20),
|
||||
pageOne: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`,
|
||||
pageTwo: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessagePaginationCaller{steps: []scriptedToolStep{{text: tt.pageOne}, {text: tt.pageTwo}}}
|
||||
args := append([]string{}, tt.args...)
|
||||
args = append(args, "--page-all", "--page-delay", "0")
|
||||
got, err := executeChatMessagePaginationCommand(t, caller, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := got["result"].(map[string]any)[tt.itemPath].([]any)
|
||||
if tt.itemPath == "conversationMessagesList" {
|
||||
messages := items[0].(map[string]any)["messages"].([]any)
|
||||
if len(items) != 1 || len(messages) != 2 {
|
||||
t.Fatalf("conversation items = %#v", items)
|
||||
}
|
||||
} else if len(items) != 2 {
|
||||
t.Fatalf("items = %#v", items)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %#v, want two pages", caller.calls)
|
||||
}
|
||||
if caller.calls[0].server != tt.server || caller.calls[0].tool != tt.tool {
|
||||
t.Fatalf("first call = %#v", caller.calls[0])
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[0].args["cursor"], tt.cursorOne) {
|
||||
t.Fatalf("first cursor = %#v, want %#v", caller.calls[0].args["cursor"], tt.cursorOne)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[1].args["cursor"], tt.cursorTwo) {
|
||||
t.Fatalf("second cursor = %#v, want %#v", caller.calls[1].args["cursor"], tt.cursorTwo)
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["pages"].(float64) != 2 || paging["total"].(float64) != 2 || paging["truncated"] != false {
|
||||
t.Fatalf("paging = %#v", paging)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func argsEqual(got, want map[string]any) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
}
|
||||
for key, wantValue := range want {
|
||||
gotValue, ok := got[key]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
switch w := wantValue.(type) {
|
||||
case float64:
|
||||
g, ok := gotValue.(int64)
|
||||
if !ok || float64(g) != w {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
if !reflect.DeepEqual(gotValue, wantValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -16,6 +16,7 @@ package helpers
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -23,8 +24,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type chatMessageSearchCall struct {
|
||||
@@ -34,12 +37,41 @@ type chatMessageSearchCall struct {
|
||||
}
|
||||
|
||||
type chatMessageSearchCaller struct {
|
||||
calls []chatMessageSearchCall
|
||||
calls []chatMessageSearchCall
|
||||
searchResponse string
|
||||
searchResponses []string
|
||||
searchCalls int
|
||||
searchError error
|
||||
failPreflight bool
|
||||
preflightError error
|
||||
}
|
||||
|
||||
func (c *chatMessageSearchCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, chatMessageSearchCall{productID: productID, toolName: toolName, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{}`}}}, nil
|
||||
text := `{}`
|
||||
if toolName == "get_conversation_info" {
|
||||
if c.preflightError != nil {
|
||||
return nil, c.preflightError
|
||||
}
|
||||
if c.failPreflight {
|
||||
return nil, errors.New("conversation not found")
|
||||
}
|
||||
text = `{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`
|
||||
}
|
||||
if toolName == "search_messages_by_keyword" || toolName == "search_messages" {
|
||||
if c.searchError != nil {
|
||||
c.searchCalls++
|
||||
return nil, c.searchError
|
||||
}
|
||||
text = `{"result":{"messages":[],"hasMore":false}}`
|
||||
if c.searchCalls < len(c.searchResponses) {
|
||||
text = c.searchResponses[c.searchCalls]
|
||||
} else if c.searchResponse != "" {
|
||||
text = c.searchResponse
|
||||
}
|
||||
c.searchCalls++
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (*chatMessageSearchCaller) Format() string { return "json" }
|
||||
@@ -72,6 +104,7 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
productID string
|
||||
toolName string
|
||||
wantToolArg map[string]any
|
||||
preflight []string
|
||||
}{
|
||||
{
|
||||
name: "keyword search",
|
||||
@@ -79,13 +112,13 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
productID: "chat",
|
||||
toolName: "search_messages_by_keyword",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationId": "cid-1",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
"keyword": "categoryName",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
preflight: []string{"cid-1"},
|
||||
},
|
||||
{
|
||||
name: "advanced search",
|
||||
@@ -93,16 +126,16 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
productID: "im",
|
||||
toolName: "search_messages",
|
||||
wantToolArg: map[string]any{
|
||||
"keyword": "categoryName",
|
||||
"openConversationIds": []string{"cid-1", "cid-2"},
|
||||
"messageType": "text",
|
||||
"onlyRobotMessages": true,
|
||||
"searchConvType": "group",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
"keyword": "categoryName",
|
||||
"messageType": "text",
|
||||
"onlyRobotMessages": true,
|
||||
"searchConvType": "group",
|
||||
"startTime": startTime.UnixMilli(),
|
||||
"endTime": endTime.UnixMilli(),
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
preflight: []string{"cid-1", "cid-2"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -116,14 +149,21 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetArgs(tt.args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("chat search returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool call count = %d, want 1", len(caller.calls))
|
||||
if len(caller.calls) != len(tt.preflight)+1 {
|
||||
t.Fatalf("tool calls = %#v", caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
for index, conversationID := range tt.preflight {
|
||||
call := caller.calls[index]
|
||||
if call.productID != "chat" || call.toolName != "get_conversation_info" || call.args["openConversationId"] != conversationID {
|
||||
t.Fatalf("preflight[%d] = %#v", index, call)
|
||||
}
|
||||
}
|
||||
call := caller.calls[len(caller.calls)-1]
|
||||
if call.productID != tt.productID || call.toolName != tt.toolName {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tt.productID, tt.toolName)
|
||||
}
|
||||
@@ -134,6 +174,507 @@ func TestCrossPlatformCoverageChatMessageSearchUsesMCPContracts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func executeNativeScopedSearch(t *testing.T, caller *chatMessageSearchCaller, args ...string) (map[string]any, error) {
|
||||
t.Helper()
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newChatCommand()
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
var output strings.Builder
|
||||
cmd.SetOut(&output)
|
||||
cmd.SetArgs(args)
|
||||
if err := cmd.Execute(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchFiltersGlobalResultsForBothEntries(t *testing.T) {
|
||||
start := "2026-07-09T00:00:00+08:00"
|
||||
end := "2026-07-11T00:00:00+08:00"
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
scopeParam string
|
||||
}{
|
||||
{
|
||||
name: "keyword search",
|
||||
args: []string{"message", "search", "--query", "周报", "--group", "cid-target", "--start", start, "--end", end},
|
||||
tool: "search_messages_by_keyword",
|
||||
scopeParam: "openConversationId",
|
||||
},
|
||||
{
|
||||
name: "advanced search",
|
||||
args: []string{"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target"},
|
||||
tool: "search_messages",
|
||||
scopeParam: "openConversationIds",
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"conversationMessagesList": [
|
||||
{"openConversationId":"cid-target","title":"目标群","messages":[{"openMessageId":"m-target","content":"目标"}]},
|
||||
{"openConversationId":"cid-other","title":"其他群","messages":[{"openMessageId":"m-other","content":"越界"}]}
|
||||
],
|
||||
"hasMore": false
|
||||
}
|
||||
}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller, tt.args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
groups, _ := result["conversationMessagesList"].([]any)
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
group, _ := groups[0].(map[string]any)
|
||||
if group["openConversationId"] != "cid-target" {
|
||||
t.Fatalf("group = %#v", group)
|
||||
}
|
||||
scope, _ := payload["scope"].(map[string]any)
|
||||
if scope["targetsValidated"] != true || scope["resultsWithinScope"] != true || scope["filterMode"] != "client" {
|
||||
t.Fatalf("scope = %#v", scope)
|
||||
}
|
||||
searchCall := caller.calls[len(caller.calls)-1]
|
||||
if searchCall.toolName != tt.tool {
|
||||
t.Fatalf("search call = %#v", searchCall)
|
||||
}
|
||||
if _, exists := searchCall.args[tt.scopeParam]; exists {
|
||||
t.Fatalf("global fallback unexpectedly forwarded %s: %#v", tt.scopeParam, searchCall.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchInvalidCIDStopsBeforeSearch(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{failPreflight: true}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-invalid")
|
||||
if err == nil {
|
||||
t.Fatal("invalid CID unexpectedly succeeded")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_invalid" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchPreservesPreflightAuthError(t *testing.T) {
|
||||
want := &CLIError{Code: CodeAuthNotConfigured, Message: "当前未登录"}
|
||||
caller := &chatMessageSearchCaller{preflightError: want}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err == nil {
|
||||
t.Fatal("auth failure unexpectedly succeeded")
|
||||
}
|
||||
var cliErr *CLIError
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeAuthNotConfigured {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeScopedSearchPreservesAmbiguousMCPToolErrors(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
want *CLIError
|
||||
}{
|
||||
{
|
||||
name: "rate limited",
|
||||
want: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
want: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{preflightError: test.want}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err != test.want {
|
||||
t.Fatalf("error = %#v, want original %#v", err, test.want)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "get_conversation_info" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchScansUntilTargetConversationAppears(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponses: []string{
|
||||
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-other","messages":[{"openMessageId":"m-other"}]}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
`{"result":{"conversationMessagesList":[{"openConversationId":"cid-target","messages":[{"openMessageId":"m-target"}]}],"hasMore":false}}`,
|
||||
}}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
if result["pagesFetched"] != float64(2) || result["complete"] != true {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
groups, _ := result["conversationMessagesList"].([]any)
|
||||
if len(groups) != 1 {
|
||||
t.Fatalf("groups = %#v", groups)
|
||||
}
|
||||
searchCalls := make([]chatMessageSearchCall, 0, 2)
|
||||
for _, call := range caller.calls {
|
||||
if call.toolName == "search_messages" {
|
||||
searchCalls = append(searchCalls, call)
|
||||
}
|
||||
}
|
||||
if len(searchCalls) != 2 || searchCalls[1].args["cursor"] != "c2" {
|
||||
t.Fatalf("search calls = %#v", searchCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeScopedSearchPageAllOptions(t *testing.T) {
|
||||
t.Run("page limit preserves continuation", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"conversationMessagesList": [
|
||||
{"openConversationId":"cid-target","messages":[{"openMessageId":"m1"}]}
|
||||
],
|
||||
"hasMore": true,
|
||||
"nextCursor": "c2"
|
||||
}
|
||||
}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
|
||||
"--page-all", "--page-limit", "1", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paging, _ := payload["paging"].(map[string]any)
|
||||
if paging["pages"] != float64(1) || paging["total"] != float64(1) || paging["truncated"] != true {
|
||||
t.Fatalf("paging = %#v", paging)
|
||||
}
|
||||
if caller.searchCalls != 1 {
|
||||
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("max items truncates within filtered page", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"conversationMessagesList": [
|
||||
{"openConversationId":"cid-target","messages":[
|
||||
{"openMessageId":"m1"},
|
||||
{"openMessageId":"m2"}
|
||||
]}
|
||||
],
|
||||
"hasMore": false
|
||||
}
|
||||
}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target",
|
||||
"--page-all", "--max-items", "1", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
groups, _ := result["conversationMessagesList"].([]any)
|
||||
group, _ := groups[0].(map[string]any)
|
||||
messages, _ := group["messages"].([]any)
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("messages = %#v", messages)
|
||||
}
|
||||
paging, _ := payload["paging"].(map[string]any)
|
||||
if paging["total"] != float64(1) || paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
|
||||
t.Fatalf("paging = %#v", paging)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchMissingConversationIdentityFailsClosed(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":false}}`}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err == nil {
|
||||
t.Fatal("unverifiable scoped result unexpectedly succeeded")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeScopedSearchValidEmptyResultIsComplete(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-empty")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
if result["complete"] != true || result["hasMore"] != false {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
scope, _ := payload["scope"].(map[string]any)
|
||||
if scope["targetsValidated"] != true || scope["sourceComplete"] != true {
|
||||
t.Fatalf("scope = %#v", scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeScopedSearchFailureAndPaginationBranches(t *testing.T) {
|
||||
t.Run("empty scope uses the native search call", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
err := runConversationScopedMessageSearch(
|
||||
cmd,
|
||||
"im",
|
||||
"search_messages",
|
||||
"openConversationIds",
|
||||
map[string]any{"keyword": "周报"},
|
||||
[]string{"", " "},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].toolName != "search_messages" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid page options fail before preflight", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
AddPagedMCPFlags(cmd)
|
||||
if err := cmd.Flags().Set("page-all", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("page-limit", "0"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := runConversationScopedMessageSearch(
|
||||
cmd,
|
||||
"im",
|
||||
"search_messages",
|
||||
"openConversationIds",
|
||||
map[string]any{"keyword": "周报"},
|
||||
[]string{"cid-target"},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "--page-limit must be between 1 and 500") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid paging made calls: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cancelled context interrupts page delay", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
|
||||
"hasMore": true,
|
||||
"nextCursor": "c2"
|
||||
}
|
||||
}`}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
AddPagedMCPFlags(cmd)
|
||||
if err := cmd.Flags().Set("page-all", "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.Flags().Set("page-delay", "60000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd.SetContext(ctx)
|
||||
err := runConversationScopedMessageSearch(
|
||||
cmd,
|
||||
"im",
|
||||
"search_messages",
|
||||
"openConversationIds",
|
||||
map[string]any{"keyword": "周报", "limit": 100, "cursor": "0"},
|
||||
[]string{"cid-target"},
|
||||
)
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context canceled", err)
|
||||
}
|
||||
if caller.searchCalls != 1 {
|
||||
t.Fatalf("search calls = %d, want 1", caller.searchCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("lower search error", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchError: errors.New("search unavailable")}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err == nil {
|
||||
t.Fatal("lower search error was ignored")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stalled cursor fails closed", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
|
||||
"hasMore": true
|
||||
}
|
||||
}`}
|
||||
_, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_cursor_stalled" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("result limit preserves continuation", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponse: `{
|
||||
"result": {
|
||||
"messages": [{"openMessageId":"m1","openConversationId":"cid-target"}],
|
||||
"hasMore": true,
|
||||
"nextCursor": "c2"
|
||||
}
|
||||
}`}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target", "--limit", "1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
if result["complete"] != false || result["hasMore"] != true || result["nextCursor"] != "c2" {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate message ids are removed across pages", func(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{searchResponses: []string{
|
||||
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"}],"hasMore":true,"nextCursor":"c2"}}`,
|
||||
`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target"},{"openMessageId":"m2","openConversationId":"cid-target"}],"hasMore":false}}`,
|
||||
}}
|
||||
payload, err := executeNativeScopedSearch(t, caller,
|
||||
"message", "search-advanced", "--query", "周报", "--conversation-ids", "cid-target")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, _ := payload["result"].(map[string]any)
|
||||
groups, _ := result["conversationMessagesList"].([]any)
|
||||
group, _ := groups[0].(map[string]any)
|
||||
messages, _ := group["messages"].([]any)
|
||||
if len(messages) != 2 {
|
||||
t.Fatalf("deduplicated messages = %#v", messages)
|
||||
}
|
||||
})
|
||||
|
||||
if got := uniqueNonEmptyStrings([]string{" cid ", "", "cid"}); !reflect.DeepEqual(got, []string{"cid"}) {
|
||||
t.Fatalf("uniqueNonEmptyStrings = %#v", got)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
value any
|
||||
want int
|
||||
}{
|
||||
{value: int64(7), want: 7},
|
||||
{value: json.Number("8"), want: 8},
|
||||
{value: float64(9), want: 9},
|
||||
{value: int64(0), want: 11},
|
||||
} {
|
||||
if got := positiveSearchLimit(test.value, 11); got != test.want {
|
||||
t.Errorf("positiveSearchLimit(%#v) = %d, want %d", test.value, got, test.want)
|
||||
}
|
||||
}
|
||||
if cleanSearchCursor(nil) != "" || cleanSearchCursor(" null ") != "" || cleanSearchCursor(" c2 ") != "c2" {
|
||||
t.Fatal("cleanSearchCursor did not normalize sentinel values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNativeScopedSearchDryRunShowsCompositePlanWithoutCallingTools(t *testing.T) {
|
||||
caller := &chatMessageSearchCaller{}
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
InitDeps(caller)
|
||||
cmd := &cobra.Command{Use: "search"}
|
||||
cmd.Flags().Bool("dry-run", true, "")
|
||||
AddPagedMCPFlags(cmd)
|
||||
for name, value := range map[string]string{
|
||||
"page-all": "true",
|
||||
"page-limit": "7",
|
||||
"max-items": "9",
|
||||
"page-delay": "11",
|
||||
} {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
var output strings.Builder
|
||||
cmd.SetOut(&output)
|
||||
err := runConversationScopedMessageSearch(
|
||||
cmd,
|
||||
"im",
|
||||
"search_messages",
|
||||
"openConversationIds",
|
||||
map[string]any{
|
||||
"keyword": "周报",
|
||||
"openConversationIds": []string{"cid-target"},
|
||||
"limit": 100,
|
||||
"cursor": "0",
|
||||
},
|
||||
[]string{"cid-target"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("dry-run made calls: %#v", caller.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(output.String()), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, _ := payload["plan"].([]any)
|
||||
if payload["dry_run"] != true || payload["executed"] != false || len(plan) != 3 {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
searchStage, _ := plan[1].(map[string]any)
|
||||
arguments, _ := searchStage["arguments"].(map[string]any)
|
||||
if _, exists := arguments["openConversationIds"]; exists {
|
||||
t.Fatalf("dry-run global search still carries scope: %#v", searchStage)
|
||||
}
|
||||
if searchStage["pageAll"] != true ||
|
||||
searchStage["pageLimit"] != float64(7) ||
|
||||
searchStage["maxItems"] != float64(9) ||
|
||||
searchStage["pageDelay"] != float64(11) {
|
||||
t.Fatalf("dry-run paging = %#v", searchStage)
|
||||
}
|
||||
}
|
||||
|
||||
type chatChangedContractCaller struct {
|
||||
calls []chatMessageSearchCall
|
||||
resolveUsers bool
|
||||
@@ -160,9 +701,12 @@ func executeChatChangedContract(t *testing.T, caller *chatChangedContractCaller,
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
cmd := newChatCommand()
|
||||
if cmd.PersistentFlags().Lookup("yes") == nil {
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
}
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetArgs(append(append([]string(nil), args...), "--yes"))
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// NormalizeSearchConversationScopeError maps only errors that prove the
|
||||
// requested conversation does not exist or that openConversationId itself is
|
||||
// invalid. Unknown MCP tool failures must remain unchanged: the legacy
|
||||
// CodeMCPToolError bucket also carries permission, throttling, and transient
|
||||
// backend failures, none of which proves that the caller supplied a bad CID.
|
||||
func NormalizeSearchConversationScopeError(conversationID string, err error) error {
|
||||
if err == nil || !isDefinitiveInvalidSearchConversationError(err) {
|
||||
return err
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("无法验证会话 CID %q;已停止搜索,避免过滤失效后返回其他会话消息", conversationID),
|
||||
apperrors.WithReason("search_conversation_scope_invalid"),
|
||||
apperrors.WithDetails(map[string]any{"conversationId": conversationID}),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("确认 openConversationId 存在且当前账号可访问后重试"),
|
||||
apperrors.WithCause(err),
|
||||
)
|
||||
}
|
||||
|
||||
func isDefinitiveInvalidSearchConversationError(err error) bool {
|
||||
var cliErr *CLIError
|
||||
if errors.As(err, &cliErr) {
|
||||
switch cliErr.Code {
|
||||
case CodeResourceNotFound, CodeInvalidParam:
|
||||
return true
|
||||
}
|
||||
code, message := searchConversationErrorFacts(cliErr.Message)
|
||||
if isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Transport errors can survive below a legacy CLIError in the cause chain.
|
||||
// Inspect their structured diagnostics, but require conversation-specific
|
||||
// evidence before treating a generic PARAM_ERROR as an invalid CID.
|
||||
var appErr *apperrors.Error
|
||||
if errors.As(err, &appErr) {
|
||||
code := strings.TrimSpace(appErr.ServerDiag.ServerErrorCode)
|
||||
message := strings.Join([]string{
|
||||
appErr.Message,
|
||||
appErr.ServerDiag.TechnicalDetail,
|
||||
appErr.Reason,
|
||||
appErr.FailureStage,
|
||||
}, " ")
|
||||
return isExplicitInvalidConversationCode(code) || isConversationParameterError(code, message)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func searchConversationErrorFacts(raw string) (string, string) {
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(raw), &body) != nil {
|
||||
return "", raw
|
||||
}
|
||||
code := firstSearchConversationErrorString(body, "errorCode", "error_code", "code")
|
||||
message := firstSearchConversationErrorString(body, "errorMsg", "error_msg", "message", "error")
|
||||
return code, message
|
||||
}
|
||||
|
||||
func firstSearchConversationErrorString(body map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := body[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isExplicitInvalidConversationCode(code string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(code)) {
|
||||
case "INVALID_OPEN_CONVERSATION_ID", "OPEN_CONVERSATION_NOT_FOUND", "CONVERSATION_NOT_FOUND":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isConversationParameterError(code, message string) bool {
|
||||
normalizedCode := strings.ToUpper(strings.TrimSpace(code))
|
||||
if normalizedCode != "PARAM_ERROR" && normalizedCode != "PARAMETER_ERROR" && normalizedCode != "INVALID_ARGUMENT" {
|
||||
return false
|
||||
}
|
||||
normalizedMessage := strings.ToLower(strings.TrimSpace(message))
|
||||
mentionsConversationID := strings.Contains(normalizedMessage, "openconversationid") ||
|
||||
strings.Contains(normalizedMessage, "open conversation id") ||
|
||||
strings.Contains(normalizedMessage, "conversation id") ||
|
||||
strings.Contains(normalizedMessage, "cid")
|
||||
if !mentionsConversationID {
|
||||
return false
|
||||
}
|
||||
for _, marker := range []string{
|
||||
"invalid", "illegal", "malformed", "required", "missing", "not found", "不存在", "无效", "非法", "缺少", "必填",
|
||||
} {
|
||||
if strings.Contains(normalizedMessage, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageNormalizeSearchConversationScopeError(t *testing.T) {
|
||||
if got := NormalizeSearchConversationScopeError("cid", nil); got != nil {
|
||||
t.Fatalf("nil error normalized to %#v", got)
|
||||
}
|
||||
|
||||
invalidCases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "classified resource not found",
|
||||
err: &CLIError{Code: CodeResourceNotFound, Message: "conversation not found"},
|
||||
},
|
||||
{
|
||||
name: "explicit conversation error code",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"INVALID_OPEN_CONVERSATION_ID","errorMsg":"invalid conversation"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "conversation specific parameter error",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId is invalid"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transport diagnostic proves invalid conversation",
|
||||
err: apperrors.NewAPI(
|
||||
"conversation validation failed",
|
||||
apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
ServerErrorCode: "INVALID_OPEN_CONVERSATION_ID",
|
||||
}),
|
||||
),
|
||||
},
|
||||
}
|
||||
for _, test := range invalidCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := NormalizeSearchConversationScopeError("cid-invalid", test.err)
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(got, &typed) || typed.Reason != "search_conversation_scope_invalid" {
|
||||
t.Fatalf("normalized error = %#v", got)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("retryable = (%t, set=%t), want false and set", typed.Retryable, typed.RetryableSet)
|
||||
}
|
||||
if typed.Details["conversationId"] != "cid-invalid" || !errors.Is(got, test.err) {
|
||||
t.Fatalf("normalized error lost details or cause: %#v", typed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
preservedCases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "rate limit",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down","retryable":true}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generic parameter error without CID evidence",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"未找到指定工具"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "structured error without recognized facts",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"retryable":true}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parameter error mentions CID without invalid evidence",
|
||||
err: &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"PARAM_ERROR","errorMsg":"openConversationId could not be processed"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range preservedCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := NormalizeSearchConversationScopeError("cid-target", test.err); got != test.err {
|
||||
t.Fatalf("error = %#v, want original %#v", got, test.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestCrossPlatformCoverageChatMessageSendLocation(t *testing.T) {
|
||||
caller = &guardedMutationCaller{}
|
||||
err = executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"message", "send", "--group", "cid1", "--msg-type", "location",
|
||||
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1")
|
||||
"--latitude", "39.9", "--longitude", "116.4", "--location-name", "国贸", "--map-thumbnail-url", "@media1", "--yes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func TestCrossPlatformCoverageChatMessageSendProfile(t *testing.T) {
|
||||
|
||||
caller = &guardedMutationCaller{}
|
||||
err = executeGuardedMutationCommand(t, caller, newChatCommand,
|
||||
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123")
|
||||
"message", "send", "--group", "cid1", "--msg-type", "profile", "--contact-id", "od123", "--yes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ func newDevAppCommand(runner executor.Runner) *cobra.Command {
|
||||
"请求涉及企业内部应用的查询、创建、配置、成员权限、机器人、事件订阅或版本管理",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
|
||||
"个人 IM/OA 实时事件监听使用 event;开放平台接口文档搜索使用 devdoc;普通钉钉业务数据使用对应产品命令",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -293,7 +293,7 @@ func newDevAppEventListCommand(runner executor.Runner) *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出或搜索应用可订阅的事件",
|
||||
UseWhen: []string{"需要查事件码、事件名称或当前事件列表时"},
|
||||
AvoidWhen: []string{"订阅或退订事件使用对应写命令"},
|
||||
AvoidWhen: []string{"订阅或退订应用回调事件使用对应写命令;监听当前用户个人 IM/OA 事件使用 event"},
|
||||
Examples: []string{`dws dev app event list --unified-app-id <unifiedAppId> --keyword "审批" --page-size 20`},
|
||||
},
|
||||
},
|
||||
@@ -329,7 +329,7 @@ func newDevAppEventSubscribeCommand(runner executor.Runner) *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为应用订阅指定事件码",
|
||||
UseWhen: []string{"已确认事件码并需要新增事件订阅时"},
|
||||
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list"},
|
||||
AvoidWhen: []string{"查询事件码或已有订阅时先使用 dev app event list;个人 IM/OA 事件长连接监听使用 event"},
|
||||
Examples: []string{"dws dev app event subscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
|
||||
},
|
||||
},
|
||||
@@ -365,7 +365,7 @@ func newDevAppEventUnsubscribeCommand(runner executor.Runner) *cobra.Command {
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "取消应用的指定事件订阅",
|
||||
UseWhen: []string{"需要停止接收一个或多个已订阅事件时"},
|
||||
AvoidWhen: []string{"只是查看事件订阅时使用 dev app event list"},
|
||||
AvoidWhen: []string{"只是查看应用事件订阅时使用 dev app event list;停止个人事件监听使用 event stop"},
|
||||
Examples: []string{"dws dev app event unsubscribe --unified-app-id <unifiedAppId> --event-codes bpms_task_change --dry-run"},
|
||||
},
|
||||
},
|
||||
|
||||
+33
-5
@@ -3668,6 +3668,17 @@ CLI 内部自动完成全部流程:
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "doc_export",
|
||||
"nodeId": node,
|
||||
"exportFormat": format,
|
||||
"savedPath": outputPath,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "导出文档(提交+轮询+下载)")
|
||||
deps.Out.PrintKeyValue("文档", node)
|
||||
deps.Out.PrintKeyValue("输出", outputPath)
|
||||
@@ -3678,7 +3689,7 @@ CLI 内部自动完成全部流程:
|
||||
ctx := context.Background()
|
||||
|
||||
// ── Step 1: 提交导出任务 ──
|
||||
deps.Out.PrintInfo("[1/3] 提交导出任务...")
|
||||
printJSONSafeInfo("[1/3] 提交导出任务...")
|
||||
submitText, err := callMCPToolReturnText(ctx, "submit_export_job", submitArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("提交导出任务失败: %w", err)
|
||||
@@ -3693,10 +3704,10 @@ CLI 内部自动完成全部流程:
|
||||
deps.Out.PrintRaw(submitText)
|
||||
return fmt.Errorf("提交导出任务成功但未返回 jobId")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID))
|
||||
printJSONSafeInfo(fmt.Sprintf(" 任务已提交,jobId: %s", jobID))
|
||||
|
||||
// ── Step 2: 渐进式退避轮询 ──
|
||||
deps.Out.PrintInfo("[2/3] 等待导出完成...")
|
||||
printJSONSafeInfo("[2/3] 等待导出完成...")
|
||||
downloadURL, err := pollDocExportJob(ctx, jobID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -3715,11 +3726,28 @@ CLI 内部自动完成全部流程:
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath))
|
||||
printJSONSafeInfo(fmt.Sprintf("[3/3] 下载文件到 %s ...", outputPath))
|
||||
if err := httpGetFile(ctx, downloadURL, nil, outputPath); err != nil {
|
||||
return fmt.Errorf("文件下载失败 (jobId=%s): %w", jobID, err)
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取导出产物信息失败 (jobId=%s): %w", jobID, err)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"nodeId": node,
|
||||
"exportFormat": format,
|
||||
"jobId": jobID,
|
||||
"taskId": jobID,
|
||||
"status": "SUCCESS",
|
||||
"savedPath": outputPath,
|
||||
"sizeBytes": info.Size(),
|
||||
})
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导出完成: %s", outputPath))
|
||||
return nil
|
||||
},
|
||||
@@ -4657,7 +4685,7 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er
|
||||
|
||||
for attempt := 1; attempt <= maxPolls; attempt++ {
|
||||
interval := pollInterval(attempt)
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
|
||||
printJSONSafeInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -619,10 +619,20 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
dlOpts.logf = func(format string, a ...any) {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
|
||||
printJSONSafeInfo(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "drive_download",
|
||||
"nodeId": fileID,
|
||||
"savedPath": outputPath,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "下载钉盘文件")
|
||||
deps.Out.PrintKeyValue("文件ID", fileID)
|
||||
deps.Out.PrintKeyValue("输出", outputPath)
|
||||
@@ -632,7 +642,7 @@ func newDriveCommand() *cobra.Command {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Step 1: 获取下载 URL 和签名请求头
|
||||
deps.Out.PrintInfo("[1/2] 获取下载链接...")
|
||||
printJSONSafeInfo("[1/2] 获取下载链接...")
|
||||
text, err := callMCPToolReturnText(ctx, "download_file", argsMap)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -654,7 +664,7 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
// Step 2: 分片下载(自动分派 + 401/403 凭证刷新重试)
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
dlOpts.knownSize = parseDownloadFileSize(text)
|
||||
dlOpts.nodeID = fileID
|
||||
dlOpts.version = parseDownloadFileVersion(text)
|
||||
@@ -684,6 +694,19 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取下载产物信息失败: %w", err)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"nodeId": fileID,
|
||||
"version": dlOpts.version,
|
||||
"savedPath": outputPath,
|
||||
"sizeBytes": info.Size(),
|
||||
})
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||||
return nil
|
||||
},
|
||||
@@ -772,10 +795,21 @@ func newDriveCommand() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
dlOpts.logf = func(format string, a ...any) {
|
||||
deps.Out.PrintInfo(fmt.Sprintf(format, a...))
|
||||
printJSONSafeInfo(fmt.Sprintf(format, a...))
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"operation": "drive_download_version",
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
"savedPath": outputPath,
|
||||
})
|
||||
}
|
||||
deps.Out.PrintKeyValue("操作", "下载文件历史版本")
|
||||
deps.Out.PrintKeyValue("节点ID", fileID)
|
||||
deps.Out.PrintKeyValue("版本号", fmt.Sprintf("%d", versionNum))
|
||||
@@ -784,7 +818,7 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
deps.Out.PrintInfo("[1/2] 获取历史版本下载链接...")
|
||||
printJSONSafeInfo("[1/2] 获取历史版本下载链接...")
|
||||
dlArgsMap := map[string]any{
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
@@ -804,7 +838,7 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
printJSONSafeInfo(fmt.Sprintf("[2/2] 下载文件到 %s ...", outputPath))
|
||||
dlOpts.knownSize = parseDownloadFileSize(text)
|
||||
dlOpts.nodeID = fileID
|
||||
dlOpts.version = versionNum
|
||||
@@ -833,6 +867,19 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
info, err := os.Stat(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取下载产物信息失败: %w", err)
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"nodeId": fileID,
|
||||
"version": versionNum,
|
||||
"savedPath": outputPath,
|
||||
"sizeBytes": info.Size(),
|
||||
})
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf("下载完成: %s", outputPath))
|
||||
return nil
|
||||
},
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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 helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func executeJSONOutputContractCommand(t *testing.T, caller *scriptedToolCaller, build func() *cobra.Command, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &deps)
|
||||
testseam.Protect(t, &os.Args)
|
||||
InitDeps(caller)
|
||||
var stdout, stderr bytes.Buffer
|
||||
deps.Out.w = &stdout
|
||||
deps.Out.errW = &stderr
|
||||
|
||||
root := build()
|
||||
installExampleGlobalFlags(root)
|
||||
os.Args = append([]string{"dws", root.Name()}, args...)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetArgs(args)
|
||||
if err := root.Execute(); err != nil {
|
||||
return stdout.String(), stderr.String(), err
|
||||
}
|
||||
return stdout.String(), stderr.String(), nil
|
||||
}
|
||||
|
||||
func assertJSONOutputPayload(t *testing.T, stdout string) map[string]any {
|
||||
t.Helper()
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(stdout), &payload); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageJSONOutputContractForCompletedFileTransfers(t *testing.T) {
|
||||
testseam.Swap(t, &httpGetFile, func(_ context.Context, _ string, _ map[string]string, destination string) error {
|
||||
return os.WriteFile(destination, []byte("payload"), 0o600)
|
||||
})
|
||||
|
||||
t.Run("drive latest download", func(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "latest.txt")
|
||||
stdout, stderr, err := executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}}},
|
||||
newDriveCommand,
|
||||
"download", "--node", "node-latest", "--output", outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := assertJSONOutputPayload(t, stdout)
|
||||
if payload["nodeId"] != "node-latest" || payload["savedPath"] != outputPath || payload["sizeBytes"] != float64(7) || payload["version"] != float64(9) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if !strings.Contains(stderr, "下载完成") && !strings.Contains(stderr, "下载文件到") {
|
||||
t.Fatalf("expected progress on stderr, got %q", stderr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("drive historical download through compatibility flag", func(t *testing.T) {
|
||||
outputPath := filepath.Join(t.TempDir(), "versioned.txt")
|
||||
stdout, _, err := executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}}},
|
||||
newDriveCommand,
|
||||
"download", "--node", "node-versioned", "--version", "4", "--output", outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := assertJSONOutputPayload(t, stdout)
|
||||
if payload["nodeId"] != "node-versioned" || payload["version"] != float64(4) || payload["sizeBytes"] != float64(7) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("doc export", func(t *testing.T) {
|
||||
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
||||
ch := make(chan time.Time, 1)
|
||||
ch <- time.Now()
|
||||
return ch
|
||||
})
|
||||
outputPath := filepath.Join(t.TempDir(), "export.md")
|
||||
stdout, stderr, err := executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", steps: []scriptedToolStep{
|
||||
{text: `{"jobId":"export-job-1"}`},
|
||||
{text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`},
|
||||
}},
|
||||
newDocCommand,
|
||||
"export", "--node", "doc-node", "--export-format", "markdown", "--output", outputPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := assertJSONOutputPayload(t, stdout)
|
||||
if payload["nodeId"] != "doc-node" || payload["exportFormat"] != "markdown" || payload["jobId"] != "export-job-1" || payload["taskId"] != "export-job-1" || payload["status"] != "SUCCESS" || payload["sizeBytes"] != float64(7) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if !strings.Contains(stderr, "提交导出任务") {
|
||||
t.Fatalf("expected export progress on stderr, got %q", stderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageJSONOutputContractDryRunIsMachineReadable(t *testing.T) {
|
||||
stdout, _, err := executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", dry: true},
|
||||
newDriveCommand,
|
||||
"download", "--node", "node-dry-run", "--output", filepath.Join(t.TempDir(), "out.txt"), "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload := assertJSONOutputPayload(t, stdout)
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "node-dry-run" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
|
||||
stdout, _, err = executeJSONOutputContractCommand(t,
|
||||
&scriptedToolCaller{format: "json", dry: true},
|
||||
newDocCommand,
|
||||
"export", "--node", "doc-dry-run", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md"), "--dry-run")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
payload = assertJSONOutputPayload(t, stdout)
|
||||
if payload["dry_run"] != true || payload["executed"] != false || payload["nodeId"] != "doc-dry-run" || payload["operation"] != "doc_export" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageJSONOutputContractReportsMissingLocalArtifact(t *testing.T) {
|
||||
testseam.Swap(t, &httpGetFile, func(context.Context, string, map[string]string, string) error {
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
||||
ch := make(chan time.Time, 1)
|
||||
ch <- time.Now()
|
||||
return ch
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
build func() *cobra.Command
|
||||
args []string
|
||||
steps []scriptedToolStep
|
||||
}{
|
||||
{
|
||||
name: "latest drive download",
|
||||
build: newDriveCommand,
|
||||
args: []string{"download", "--node", "node-latest", "--output", filepath.Join(t.TempDir(), "latest.txt")},
|
||||
steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/latest.txt","fileSize":7,"version":9}`}},
|
||||
},
|
||||
{
|
||||
name: "versioned drive download",
|
||||
build: newDriveCommand,
|
||||
args: []string{"download", "--node", "node-versioned", "--version", "4", "--output", filepath.Join(t.TempDir(), "versioned.txt")},
|
||||
steps: []scriptedToolStep{{text: `{"downloadUrl":"https://example.test/versioned.txt","fileSize":7}`}},
|
||||
},
|
||||
{
|
||||
name: "doc export",
|
||||
build: newDocCommand,
|
||||
args: []string{"export", "--node", "doc-node", "--export-format", "markdown", "--output", filepath.Join(t.TempDir(), "export.md")},
|
||||
steps: []scriptedToolStep{
|
||||
{text: `{"jobId":"export-job-1"}`},
|
||||
{text: `{"status":"SUCCESS","downloadUrl":"https://example.test/export.md"}`},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, _, err := executeJSONOutputContractCommand(t, &scriptedToolCaller{format: "json", steps: tt.steps}, tt.build, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "读取") {
|
||||
t.Fatalf("expected missing local artifact error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1346,7 +1346,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
Short: "批量删除个人热词",
|
||||
Long: `批量删除听记个人热词。
|
||||
支持一次删除多个热词(逗号分隔)。删除后对应热词不再参与后续语音识别优化。`,
|
||||
Example: ` dws minutes hot-word delete --words "钉钉"
|
||||
Example: ` dws minutes hot-word delete --words "天气"
|
||||
dws minutes hot-word delete --words "OKR,钉钉,Copilot"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "words"); err != nil {
|
||||
@@ -1384,7 +1384,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
"不确定现有热词时先用 hot-word list",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws minutes hot-word delete --words \"钉钉\"",
|
||||
"dws minutes hot-word delete --words \"天气\"",
|
||||
"dws minutes hot-word delete --words \"OKR,钉钉,Copilot\"",
|
||||
},
|
||||
},
|
||||
@@ -1883,11 +1883,12 @@ func newMinutesCommand() *cobra.Command {
|
||||
if err := validateRequiredFlagWithAliases(cmd, "id", "url", "task-uuid", "uuid"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "policy"); err != nil {
|
||||
return err
|
||||
// 手动校验必填参数(避免 validateRequiredFlags 对 Int 的误判)
|
||||
if !cmd.Flags().Changed("policy") {
|
||||
return fmt.Errorf("missing required flag --policy")
|
||||
}
|
||||
|
||||
policyID, err := strconv.ParseInt(mustGetFlag(cmd, "policy"), 10, 64)
|
||||
policyID, err := cmd.Flags().GetInt("policy")
|
||||
if err != nil || policyID < 2 || policyID > 4 {
|
||||
return fmt.Errorf("flag --policy must be an integer between 2 and 4 (2=可编辑, 3=可查看/下载, 4=仅查看)")
|
||||
}
|
||||
@@ -1942,7 +1943,7 @@ func newMinutesCommand() *cobra.Command {
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("task-uuid")
|
||||
permissionApplyCmd.Flags().String("uuid", "", "--id 的别名")
|
||||
_ = permissionApplyCmd.Flags().MarkHidden("uuid")
|
||||
permissionApplyCmd.Flags().String("policy", "", "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
|
||||
permissionApplyCmd.Flags().Int("policy", 0, "权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)")
|
||||
|
||||
permissionCmd.AddCommand(permissionAddCmd, permissionRemoveCmd, permissionApplyCmd)
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func newOaCommand() *cobra.Command {
|
||||
"查看待审、已办、已发起或抄送审批,并执行同意、拒绝、撤销、转交等审批动作时",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"不要用于普通待办任务或工作日志;需要创建审批实例时先确认当前命令面是否支持",
|
||||
"不要用于普通待办任务或工作日志;需要实时监听未来的审批任务/实例事件时使用 event consume",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -151,6 +151,17 @@ func (f *Formatter) PrintInfo(msg string) { fmt.Fprintf(f.w, "[INFO] %s\n",
|
||||
func (f *Formatter) PrintProgress(msg string) { fmt.Fprintf(f.errW, "%s\n", msg) }
|
||||
func (f *Formatter) PrintDim(msg string) { fmt.Fprintf(f.w, " %s\n", msg) }
|
||||
|
||||
// printJSONSafeInfo keeps command progress out of stdout when the caller
|
||||
// requested JSON. A successful JSON command must leave stdout parseable; its
|
||||
// human-readable progress belongs on stderr instead.
|
||||
func printJSONSafeInfo(msg string) {
|
||||
if deps != nil && deps.Caller != nil && strings.EqualFold(strings.TrimSpace(deps.Caller.Format()), "json") {
|
||||
deps.Out.PrintProgress("[INFO] " + msg)
|
||||
return
|
||||
}
|
||||
deps.Out.PrintInfo(msg)
|
||||
}
|
||||
|
||||
func (f *Formatter) PrintKeyValue(key, value string) {
|
||||
fmt.Fprintf(f.w, "%-16s%s\n", key+":", value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPagedCommandPageLimit = 50
|
||||
maxPagedCommandPageLimit = 500
|
||||
defaultPagedCommandDelayMS = 200
|
||||
)
|
||||
|
||||
type PagedCursorKind int
|
||||
|
||||
const (
|
||||
PagedCursorString PagedCursorKind = iota
|
||||
PagedCursorInt64
|
||||
)
|
||||
|
||||
type PagedAggregationMode int
|
||||
|
||||
const (
|
||||
PagedAggregationArray PagedAggregationMode = iota
|
||||
PagedAggregationConversationMessages
|
||||
)
|
||||
|
||||
type PagedMCPCommandConfig struct {
|
||||
ServerID string
|
||||
ToolName string
|
||||
ItemPath string
|
||||
CursorPath string
|
||||
HasMorePath string
|
||||
CursorArg string
|
||||
CursorKind PagedCursorKind
|
||||
AggregationMode PagedAggregationMode
|
||||
BuildArgs func(*cobra.Command) (map[string]any, error)
|
||||
Fallback func(map[string]any) error
|
||||
}
|
||||
|
||||
type pagedCommandOptions struct {
|
||||
pageAll bool
|
||||
pageLimit int
|
||||
maxItems int
|
||||
delayMS int
|
||||
}
|
||||
|
||||
func AddPagedMCPFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().Bool("page-all", false, "自动按 nextCursor 拉取所有分页;未设置时保持单页调用")
|
||||
cmd.Flags().Int("page-limit", defaultPagedCommandPageLimit, "自动翻页最多请求页数(默认 50,范围 1-500;仅 --page-all 生效)")
|
||||
cmd.Flags().Int("max-items", 0, "自动翻页最多返回条数(默认 0 表示不限制;仅 --page-all 生效)")
|
||||
cmd.Flags().Int("page-delay", defaultPagedCommandDelayMS, "自动翻页每页之间等待毫秒数(默认 200;0 表示不等待;仅 --page-all 生效)")
|
||||
}
|
||||
|
||||
func RunPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig) error {
|
||||
args, err := cfg.BuildArgs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts, err := readPagedCommandOptions(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !opts.pageAll {
|
||||
return cfg.Fallback(args)
|
||||
}
|
||||
if err := validatePagedConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true,
|
||||
"request": map[string]any{
|
||||
"server": cfg.ServerID,
|
||||
"name": cfg.ToolName,
|
||||
"args": args,
|
||||
},
|
||||
"paging": map[string]any{
|
||||
"pageAll": true,
|
||||
"pageLimit": opts.pageLimit,
|
||||
"maxItems": opts.maxItems,
|
||||
"pageDelay": opts.delayMS,
|
||||
},
|
||||
})
|
||||
}
|
||||
return runPagedMCPCommand(cmd, cfg, opts, args)
|
||||
}
|
||||
|
||||
func readPagedCommandOptions(cmd *cobra.Command) (pagedCommandOptions, error) {
|
||||
pageAll, _ := cmd.Flags().GetBool("page-all")
|
||||
opts := pagedCommandOptions{pageAll: pageAll}
|
||||
if !pageAll {
|
||||
return opts, nil
|
||||
}
|
||||
opts.pageLimit, _ = cmd.Flags().GetInt("page-limit")
|
||||
if opts.pageLimit < 1 || opts.pageLimit > maxPagedCommandPageLimit {
|
||||
return opts, fmt.Errorf("--page-limit must be between 1 and 500")
|
||||
}
|
||||
opts.maxItems, _ = cmd.Flags().GetInt("max-items")
|
||||
if opts.maxItems < 0 {
|
||||
return opts, fmt.Errorf("--max-items must be greater than or equal to 0")
|
||||
}
|
||||
opts.delayMS, _ = cmd.Flags().GetInt("page-delay")
|
||||
if opts.delayMS < 0 {
|
||||
return opts, fmt.Errorf("--page-delay must be greater than or equal to 0")
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func validatePagedConfig(cfg PagedMCPCommandConfig) error {
|
||||
switch {
|
||||
case strings.TrimSpace(cfg.ServerID) == "":
|
||||
return fmt.Errorf("paged command server is required")
|
||||
case strings.TrimSpace(cfg.ToolName) == "":
|
||||
return fmt.Errorf("paged command tool is required")
|
||||
case strings.TrimSpace(cfg.ItemPath) == "":
|
||||
return fmt.Errorf("paged command item path is required")
|
||||
case strings.TrimSpace(cfg.CursorPath) == "":
|
||||
return fmt.Errorf("paged command cursor path is required")
|
||||
case strings.TrimSpace(cfg.HasMorePath) == "":
|
||||
return fmt.Errorf("paged command hasMore path is required")
|
||||
case strings.TrimSpace(cfg.CursorArg) == "":
|
||||
return fmt.Errorf("paged command cursor arg is required")
|
||||
case cfg.BuildArgs == nil || cfg.Fallback == nil:
|
||||
return fmt.Errorf("paged command callbacks are required")
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func runPagedMCPCommand(cmd *cobra.Command, cfg PagedMCPCommandConfig, opts pagedCommandOptions, args map[string]any) error {
|
||||
var envelope map[string]any
|
||||
ctx := cmd.Context()
|
||||
items := newPagedCollection(cfg)
|
||||
seenCursors := map[string]bool{}
|
||||
currentCursor := cursorValueKey(args[cfg.CursorArg], cfg.CursorKind)
|
||||
lastCursor := args[cfg.CursorArg]
|
||||
hasMore := true
|
||||
|
||||
for page := 1; page <= opts.pageLimit && hasMore; page++ {
|
||||
pageCursor := args[cfg.CursorArg]
|
||||
seenCursors[currentCursor] = true
|
||||
text, err := callMCPToolReturnTextOnServer(ctx, cfg.ServerID, cfg.ToolName, args)
|
||||
if err != nil {
|
||||
return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err)
|
||||
}
|
||||
parsed, pageItems, nextCursor, more, err := parsePagedCommandPage(text, cfg)
|
||||
if err != nil {
|
||||
return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err)
|
||||
}
|
||||
if envelope == nil {
|
||||
envelope = parsed
|
||||
}
|
||||
if err := items.Add(pageItems); err != nil {
|
||||
return handlePagedCommandError(cmd, envelope, cfg, items, page, currentCursor, err)
|
||||
}
|
||||
hasMore = more
|
||||
|
||||
if opts.maxItems > 0 && items.Total() > opts.maxItems {
|
||||
items.Truncate(opts.maxItems)
|
||||
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
|
||||
Truncated: true,
|
||||
HasMore: true,
|
||||
LastCursor: pageCursor,
|
||||
Pages: page,
|
||||
Total: items.Total(),
|
||||
TruncatedWithinPage: true,
|
||||
})
|
||||
}
|
||||
lastCursor = nextCursor
|
||||
if opts.maxItems > 0 && items.Total() == opts.maxItems && hasMore {
|
||||
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
|
||||
Truncated: true,
|
||||
HasMore: true,
|
||||
LastCursor: lastCursor,
|
||||
Pages: page,
|
||||
Total: items.Total(),
|
||||
})
|
||||
}
|
||||
if !hasMore {
|
||||
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
|
||||
Truncated: false,
|
||||
HasMore: false,
|
||||
LastCursor: lastCursor,
|
||||
Pages: page,
|
||||
Total: items.Total(),
|
||||
})
|
||||
}
|
||||
nextKey := cursorValueKey(nextCursor, cfg.CursorKind)
|
||||
if nextKey == "" || nextKey == currentCursor || seenCursors[nextKey] {
|
||||
err := fmt.Errorf("pagination cursor did not advance: %s", nextKey)
|
||||
return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err)
|
||||
}
|
||||
normalizedCursor, err := normalizeCursorArg(nextCursor, cfg.CursorKind)
|
||||
if err != nil {
|
||||
return handlePagedCommandError(cmd, envelope, cfg, items, page+1, nextKey, err)
|
||||
}
|
||||
currentCursor = nextKey
|
||||
args[cfg.CursorArg] = normalizedCursor
|
||||
if opts.delayMS > 0 {
|
||||
if err := sleepPagedCommandDelay(ctx, time.Duration(opts.delayMS)*time.Millisecond); err != nil {
|
||||
return handlePagedCommandError(cmd, envelope, cfg, items, page+1, currentCursor, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return writePagedCommandResult(envelope, cfg, items, pagingMetadata{
|
||||
Truncated: hasMore,
|
||||
HasMore: hasMore,
|
||||
LastCursor: lastCursor,
|
||||
Pages: opts.pageLimit,
|
||||
Total: items.Total(),
|
||||
})
|
||||
}
|
||||
|
||||
func parsePagedCommandPage(text string, cfg PagedMCPCommandConfig) (map[string]any, []any, any, bool, error) {
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &parsed); err != nil {
|
||||
return nil, nil, nil, false, fmt.Errorf("parse paged response JSON: %w", err)
|
||||
}
|
||||
rawHasMore, ok := getJSONPath(parsed, cfg.HasMorePath)
|
||||
if !ok {
|
||||
return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.HasMorePath)
|
||||
}
|
||||
hasMore, ok := rawHasMore.(bool)
|
||||
if !ok {
|
||||
return nil, nil, nil, false, fmt.Errorf("paged response %s must be boolean", cfg.HasMorePath)
|
||||
}
|
||||
rawItems, ok := getJSONPath(parsed, cfg.ItemPath)
|
||||
if !ok && cfg.AggregationMode == PagedAggregationConversationMessages && !hasMore {
|
||||
rawItems = []any{}
|
||||
ok = true
|
||||
}
|
||||
if !ok {
|
||||
return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.ItemPath)
|
||||
}
|
||||
items, ok := rawItems.([]any)
|
||||
if !ok {
|
||||
return nil, nil, nil, false, fmt.Errorf("paged response %s must be array", cfg.ItemPath)
|
||||
}
|
||||
nextCursor, ok := getJSONPath(parsed, cfg.CursorPath)
|
||||
if hasMore && !ok {
|
||||
return nil, nil, nil, false, fmt.Errorf("paged response missing %s", cfg.CursorPath)
|
||||
}
|
||||
return parsed, items, nextCursor, hasMore, nil
|
||||
}
|
||||
|
||||
type pagingMetadata struct {
|
||||
Truncated bool
|
||||
HasMore bool
|
||||
LastCursor any
|
||||
Pages int
|
||||
Total int
|
||||
TruncatedWithinPage bool
|
||||
Partial bool
|
||||
FailedPage int
|
||||
FailedCursor string
|
||||
PagesFetched int
|
||||
ItemsFetched int
|
||||
Error string
|
||||
}
|
||||
|
||||
func handlePagedCommandError(cmd *cobra.Command, envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, failedPage int, failedCursor string, err error) error {
|
||||
if envelope == nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.ErrOrStderr(), "pagination stopped at page %d: %v\n", failedPage, err)
|
||||
if outputErr := writePagedCommandResult(envelope, cfg, items, pagingMetadata{
|
||||
Truncated: true,
|
||||
HasMore: true,
|
||||
LastCursor: failedCursor,
|
||||
Pages: failedPage - 1,
|
||||
Total: items.Total(),
|
||||
Partial: true,
|
||||
FailedPage: failedPage,
|
||||
FailedCursor: failedCursor,
|
||||
PagesFetched: failedPage - 1,
|
||||
ItemsFetched: items.Total(),
|
||||
Error: err.Error(),
|
||||
}); outputErr != nil {
|
||||
return errors.Join(err, outputErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func writePagedCommandResult(envelope map[string]any, cfg PagedMCPCommandConfig, items *pagedCollection, meta pagingMetadata) error {
|
||||
_ = setJSONPath(envelope, cfg.ItemPath, items.Values())
|
||||
_ = setJSONPath(envelope, cfg.HasMorePath, meta.HasMore)
|
||||
_ = setJSONPath(envelope, cfg.CursorPath, meta.LastCursor)
|
||||
paging := map[string]any{
|
||||
"truncated": meta.Truncated,
|
||||
"hasMore": meta.HasMore,
|
||||
"lastCursor": meta.LastCursor,
|
||||
"pages": meta.Pages,
|
||||
"total": meta.Total,
|
||||
}
|
||||
if meta.Partial {
|
||||
paging["partial"] = true
|
||||
paging["failedPage"] = meta.FailedPage
|
||||
paging["failedCursor"] = meta.FailedCursor
|
||||
paging["pagesFetched"] = meta.PagesFetched
|
||||
paging["itemsFetched"] = meta.ItemsFetched
|
||||
paging["error"] = meta.Error
|
||||
}
|
||||
if meta.TruncatedWithinPage {
|
||||
paging["truncatedWithinPage"] = true
|
||||
paging["resumeCursorReliable"] = false
|
||||
}
|
||||
envelope["paging"] = paging
|
||||
return deps.Out.PrintJSON(envelope)
|
||||
}
|
||||
|
||||
func sleepPagedCommandDelay(ctx context.Context, delay time.Duration) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-helperAfter(delay):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
type pagedCollection struct {
|
||||
mode PagedAggregationMode
|
||||
items []any
|
||||
conversationIndex map[string]int
|
||||
total int
|
||||
}
|
||||
|
||||
func newPagedCollection(cfg PagedMCPCommandConfig) *pagedCollection {
|
||||
return &pagedCollection{
|
||||
mode: cfg.AggregationMode,
|
||||
conversationIndex: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *pagedCollection) Add(items []any) error {
|
||||
if c.mode != PagedAggregationConversationMessages {
|
||||
c.items = append(c.items, items...)
|
||||
c.total = len(c.items)
|
||||
return nil
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := c.addConversation(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *pagedCollection) Values() []any {
|
||||
if c.items == nil {
|
||||
return []any{}
|
||||
}
|
||||
return c.items
|
||||
}
|
||||
|
||||
func (c *pagedCollection) Total() int {
|
||||
return c.total
|
||||
}
|
||||
|
||||
func (c *pagedCollection) Truncate(maxItems int) bool {
|
||||
if maxItems <= 0 || c.total <= maxItems {
|
||||
return false
|
||||
}
|
||||
if c.mode != PagedAggregationConversationMessages {
|
||||
c.items = c.items[:maxItems]
|
||||
c.total = len(c.items)
|
||||
return true
|
||||
}
|
||||
c.truncateConversationMessages(maxItems)
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *pagedCollection) addConversation(item any) error {
|
||||
conversation, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("paged response conversation item must be object")
|
||||
}
|
||||
key, _ := conversation["openConversationId"].(string)
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("paged response conversation item missing openConversationId")
|
||||
}
|
||||
messages, err := conversationMessages(conversation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if idx, ok := c.conversationIndex[key]; ok {
|
||||
existing := c.items[idx].(map[string]any)
|
||||
existingMessages, err := conversationMessages(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing["messages"] = append(existingMessages, messages...)
|
||||
c.total += len(messages)
|
||||
return nil
|
||||
}
|
||||
c.conversationIndex[key] = len(c.items)
|
||||
c.items = append(c.items, conversation)
|
||||
c.total += len(messages)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *pagedCollection) truncateConversationMessages(maxItems int) {
|
||||
remaining := maxItems
|
||||
for i, item := range c.items {
|
||||
conversation := item.(map[string]any)
|
||||
messages, _ := conversationMessages(conversation)
|
||||
if remaining >= len(messages) {
|
||||
remaining -= len(messages)
|
||||
continue
|
||||
}
|
||||
if remaining == 0 {
|
||||
c.items = c.items[:i]
|
||||
c.total = maxItems
|
||||
return
|
||||
}
|
||||
conversation["messages"] = messages[:remaining]
|
||||
c.items = c.items[:i+1]
|
||||
c.total = maxItems
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func conversationMessages(conversation map[string]any) ([]any, error) {
|
||||
raw, ok := conversation["messages"]
|
||||
if !ok {
|
||||
return []any{}, nil
|
||||
}
|
||||
messages, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("paged response conversation messages must be array")
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
func getJSONPath(root map[string]any, path string) (any, bool) {
|
||||
var current any = root
|
||||
for _, part := range strings.Split(path, ".") {
|
||||
obj, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = obj[part]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
func setJSONPath(root map[string]any, path string, value any) bool {
|
||||
parts := strings.Split(path, ".")
|
||||
current := root
|
||||
for _, part := range parts[:len(parts)-1] {
|
||||
next, ok := current[part].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
current = next
|
||||
}
|
||||
current[parts[len(parts)-1]] = value
|
||||
return true
|
||||
}
|
||||
|
||||
func cursorValueKey(value any, kind PagedCursorKind) string {
|
||||
switch kind {
|
||||
case PagedCursorInt64:
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
return strconv.FormatInt(v, 10)
|
||||
case int:
|
||||
return strconv.Itoa(v)
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
case string:
|
||||
return strings.TrimSpace(v)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
default:
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCursorArg(value any, kind PagedCursorKind) (any, error) {
|
||||
if kind != PagedCursorInt64 {
|
||||
if value == nil {
|
||||
return "", nil
|
||||
}
|
||||
return fmt.Sprint(value), nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case int64:
|
||||
return v, nil
|
||||
case int:
|
||||
return int64(v), nil
|
||||
case float64:
|
||||
converted := int64(v)
|
||||
if float64(converted) != v {
|
||||
return nil, fmt.Errorf("paged response cursor must be an integer, got %v", v)
|
||||
}
|
||||
return converted, nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
if err == nil {
|
||||
return parsed, nil
|
||||
}
|
||||
return nil, fmt.Errorf("paged response cursor must be a base-10 int64 string, got %q", v)
|
||||
}
|
||||
return nil, fmt.Errorf("paged response cursor must be int64-compatible, got %T", value)
|
||||
}
|
||||
@@ -0,0 +1,973 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type pagedCommandCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
type pagedCommandCaller struct {
|
||||
steps []scriptedToolStep
|
||||
calls []pagedCommandCall
|
||||
format string
|
||||
dry bool
|
||||
}
|
||||
|
||||
func (c *pagedCommandCaller) CallTool(ctx context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := map[string]any{}
|
||||
for k, v := range args {
|
||||
copied[k] = v
|
||||
}
|
||||
c.calls = append(c.calls, pagedCommandCall{server: serverID, tool: toolName, args: copied, ctx: ctx})
|
||||
if len(c.steps) == 0 {
|
||||
return textToolResult(`{"result":{"messages":[],"hasMore":false}}`), nil
|
||||
}
|
||||
step := c.steps[len(c.calls)-1]
|
||||
if step.err != nil {
|
||||
return nil, step.err
|
||||
}
|
||||
return textToolResult(step.text), nil
|
||||
}
|
||||
|
||||
func (c *pagedCommandCaller) Format() string { return c.format }
|
||||
func (c *pagedCommandCaller) DryRun() bool { return c.dry }
|
||||
func (*pagedCommandCaller) Fields() string { return "" }
|
||||
func (*pagedCommandCaller) JQ() string { return "" }
|
||||
|
||||
func runPagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, args ...string) (map[string]any, string, error) {
|
||||
t.Helper()
|
||||
return runPagedCommandTestWithSleep(t, caller, cfg, func(time.Duration) {}, args...)
|
||||
}
|
||||
|
||||
func runPagedCommandTestWithSleep(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), args ...string) (map[string]any, string, error) {
|
||||
t.Helper()
|
||||
out, stderr, err := executePagedCommandTest(t, caller, cfg, sleep, &bytes.Buffer{}, args...)
|
||||
if strings.TrimSpace(out) == "" {
|
||||
return nil, stderr, err
|
||||
}
|
||||
var parsed map[string]any
|
||||
if unmarshalErr := json.Unmarshal([]byte(out), &parsed); unmarshalErr != nil {
|
||||
t.Fatalf("stdout JSON = %q, err = %v", out, unmarshalErr)
|
||||
}
|
||||
return parsed, stderr, err
|
||||
}
|
||||
|
||||
func executePagedCommandTest(t *testing.T, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
return executePagedCommandTestWithContext(t, context.Background(), caller, cfg, sleep, stdout, args...)
|
||||
}
|
||||
|
||||
func executePagedCommandTestWithContext(t *testing.T, ctx context.Context, caller *pagedCommandCaller, cfg PagedMCPCommandConfig, sleep func(time.Duration), stdout io.Writer, args ...string) (string, string, error) {
|
||||
t.Helper()
|
||||
oldDeps := deps
|
||||
oldSleep := helperSleep
|
||||
oldAfter := helperAfter
|
||||
t.Cleanup(func() {
|
||||
deps = oldDeps
|
||||
helperSleep = oldSleep
|
||||
helperAfter = oldAfter
|
||||
})
|
||||
InitDeps(caller)
|
||||
out := stdout
|
||||
errOut := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
deps.Out.errW = errOut
|
||||
if sleep != nil {
|
||||
helperSleep = sleep
|
||||
helperAfter = func(d time.Duration) <-chan time.Time {
|
||||
sleep(d)
|
||||
ch := make(chan time.Time, 1)
|
||||
ch <- time.Now()
|
||||
return ch
|
||||
}
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "paged",
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return RunPagedMCPCommand(cmd, cfg)
|
||||
},
|
||||
}
|
||||
cmd.SetContext(ctx)
|
||||
cmd.Flags().String("cursor", "0", "")
|
||||
AddPagedMCPFlags(cmd)
|
||||
cmd.SetErr(errOut)
|
||||
cmd.SetArgs(args)
|
||||
err := cmd.Execute()
|
||||
if buf, ok := out.(*bytes.Buffer); ok {
|
||||
return buf.String(), errOut.String(), err
|
||||
}
|
||||
return "", errOut.String(), err
|
||||
}
|
||||
|
||||
func pagedCommandMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig {
|
||||
if fallback == nil {
|
||||
fallback = func(map[string]any) error { return nil }
|
||||
}
|
||||
return PagedMCPCommandConfig{
|
||||
ServerID: "chat",
|
||||
ToolName: "search_messages_by_time_range",
|
||||
ItemPath: "result.messages",
|
||||
CursorPath: "result.nextCursor",
|
||||
HasMorePath: "result.hasMore",
|
||||
CursorArg: "cursor",
|
||||
CursorKind: PagedCursorString,
|
||||
BuildArgs: func(cmd *cobra.Command) (map[string]any, error) {
|
||||
cursor, _ := cmd.Flags().GetString("cursor")
|
||||
return map[string]any{"cursor": cursor, "limit": 2}, nil
|
||||
},
|
||||
Fallback: fallback,
|
||||
}
|
||||
}
|
||||
|
||||
func pagedCommandConversationMessagesConfig(fallback func(map[string]any) error) PagedMCPCommandConfig {
|
||||
cfg := pagedCommandMessagesConfig(fallback)
|
||||
cfg.ItemPath = "result.conversationMessagesList"
|
||||
cfg.AggregationMode = PagedAggregationConversationMessages
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandDefaultUsesFallbackOnly(t *testing.T) {
|
||||
caller := &pagedCommandCaller{}
|
||||
fallbackCalls := 0
|
||||
cfg := pagedCommandMessagesConfig(func(args map[string]any) error {
|
||||
fallbackCalls++
|
||||
if args["cursor"] != "0" {
|
||||
t.Fatalf("fallback args = %#v", args)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
_, _, err := runPagedCommandTest(t, caller, cfg, "--page-limit", "2", "--max-items", "1", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fallbackCalls != 1 || len(caller.calls) != 0 {
|
||||
t.Fatalf("fallback=%d remote=%d, want fallback only", fallbackCalls, len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandRejectsInvalidConfigWhenPageAll(t *testing.T) {
|
||||
caller := &pagedCommandCaller{}
|
||||
cfg := pagedCommandMessagesConfig(nil)
|
||||
cfg.ServerID = " "
|
||||
|
||||
got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all")
|
||||
if err == nil || !strings.Contains(err.Error(), "server is required") {
|
||||
t.Fatalf("result=%#v err=%v, want config error", got, err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("result=%#v, want no stdout", got)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("calls=%#v, want no remote call", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandDryRunPrintsRequestAndSkipsRemote(t *testing.T) {
|
||||
caller := &pagedCommandCaller{dry: true}
|
||||
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "3", "--max-items", "7", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("calls=%#v, want no remote call", caller.calls)
|
||||
}
|
||||
if got["dry_run"] != true {
|
||||
t.Fatalf("dry_run=%#v, want true", got["dry_run"])
|
||||
}
|
||||
request := got["request"].(map[string]any)
|
||||
if request["server"] != "chat" || request["name"] != "search_messages_by_time_range" {
|
||||
t.Fatalf("request=%#v", request)
|
||||
}
|
||||
args := request["args"].(map[string]any)
|
||||
if args["cursor"] != "0" || args["limit"].(float64) != 2 {
|
||||
t.Fatalf("args=%#v", args)
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["pageAll"] != true || paging["pageLimit"].(float64) != 3 || paging["maxItems"].(float64) != 7 || paging["pageDelay"].(float64) != 0 {
|
||||
t.Fatalf("paging=%#v", paging)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandValidateConfigRejectsMissingRequiredFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
edit func(*PagedMCPCommandConfig)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "server",
|
||||
edit: func(cfg *PagedMCPCommandConfig) {
|
||||
cfg.ServerID = ""
|
||||
},
|
||||
want: "server is required",
|
||||
},
|
||||
{
|
||||
name: "tool",
|
||||
edit: func(cfg *PagedMCPCommandConfig) {
|
||||
cfg.ToolName = ""
|
||||
},
|
||||
want: "tool is required",
|
||||
},
|
||||
{
|
||||
name: "item path",
|
||||
edit: func(cfg *PagedMCPCommandConfig) {
|
||||
cfg.ItemPath = ""
|
||||
},
|
||||
want: "item path is required",
|
||||
},
|
||||
{
|
||||
name: "cursor path",
|
||||
edit: func(cfg *PagedMCPCommandConfig) {
|
||||
cfg.CursorPath = ""
|
||||
},
|
||||
want: "cursor path is required",
|
||||
},
|
||||
{
|
||||
name: "hasMore path",
|
||||
edit: func(cfg *PagedMCPCommandConfig) {
|
||||
cfg.HasMorePath = ""
|
||||
},
|
||||
want: "hasMore path is required",
|
||||
},
|
||||
{
|
||||
name: "cursor arg",
|
||||
edit: func(cfg *PagedMCPCommandConfig) {
|
||||
cfg.CursorArg = ""
|
||||
},
|
||||
want: "cursor arg is required",
|
||||
},
|
||||
{
|
||||
name: "build args callback",
|
||||
edit: func(cfg *PagedMCPCommandConfig) {
|
||||
cfg.BuildArgs = nil
|
||||
},
|
||||
want: "callbacks are required",
|
||||
},
|
||||
{
|
||||
name: "fallback callback",
|
||||
edit: func(cfg *PagedMCPCommandConfig) {
|
||||
cfg.Fallback = nil
|
||||
},
|
||||
want: "callbacks are required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := pagedCommandMessagesConfig(nil)
|
||||
tt.edit(&cfg)
|
||||
err := validatePagedConfig(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("err=%v, want %q", err, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandStringCursorAggregatesAndPageLimit(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":true,"nextCursor":"c3"}}`},
|
||||
}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-limit", "2", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := got["result"].(map[string]any)["messages"].([]any)
|
||||
result := got["result"].(map[string]any)
|
||||
paging := got["paging"].(map[string]any)
|
||||
if len(items) != 2 || paging["truncated"] != true || paging["pages"].(float64) != 2 {
|
||||
t.Fatalf("result = %#v", got)
|
||||
}
|
||||
if result["hasMore"] != true || result["nextCursor"] != "c3" {
|
||||
t.Fatalf("result=%#v, want final page-limit cursor state", result)
|
||||
}
|
||||
if caller.calls[0].args["cursor"] != "0" || caller.calls[1].args["cursor"] != "c2" {
|
||||
t.Fatalf("call args = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandStringCursorAggregatesAndSyncsCompletionFields(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`},
|
||||
}}
|
||||
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := got["result"].(map[string]any)
|
||||
items := result["messages"].([]any)
|
||||
if len(items) != 2 || result["hasMore"] != false || result["nextCursor"] != "" {
|
||||
t.Fatalf("result=%#v, want complete aggregate with final cursor state", result)
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["truncated"] != false || paging["hasMore"] != false || paging["lastCursor"] != "" {
|
||||
t.Fatalf("paging=%#v, want complete pagination metadata", paging)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesMergeSameConversation(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"群1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","title":"ignored","messages":[{"id":"m2"}]}],"hasMore":false,"nextCursor":""}}`},
|
||||
}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
|
||||
if len(conversations) != 1 {
|
||||
t.Fatalf("conversations=%#v, want one merged conversation", conversations)
|
||||
}
|
||||
conversation := conversations[0].(map[string]any)
|
||||
messages := conversation["messages"].([]any)
|
||||
if conversation["title"] != "群1" || len(messages) != 2 {
|
||||
t.Fatalf("conversation=%#v, want preserved title and two messages", conversation)
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["total"].(float64) != 2 {
|
||||
t.Fatalf("paging=%#v, want total message count 2", paging)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesPreserveFirstConversationOrder(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid2","messages":[{"id":"m2"}]},{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid3","messages":[{"id":"m3"}]}],"hasMore":false,"nextCursor":""}}`},
|
||||
}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
|
||||
gotIDs := []string{
|
||||
conversations[0].(map[string]any)["openConversationId"].(string),
|
||||
conversations[1].(map[string]any)["openConversationId"].(string),
|
||||
conversations[2].(map[string]any)["openConversationId"].(string),
|
||||
}
|
||||
if strings.Join(gotIDs, ",") != "cid2,cid1,cid3" {
|
||||
t.Fatalf("conversation order=%v", gotIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesMessages(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "3", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
|
||||
if len(conversations) != 2 {
|
||||
t.Fatalf("conversations=%#v, want two conversations", conversations)
|
||||
}
|
||||
secondMessages := conversations[1].(map[string]any)["messages"].([]any)
|
||||
paging := got["paging"].(map[string]any)
|
||||
if len(secondMessages) != 1 || paging["total"].(float64) != 3 || paging["truncated"] != true {
|
||||
t.Fatalf("result=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesMissingListOnFinalPageIsEmpty(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"hasMore":false,"nextCursor":""}}`},
|
||||
}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
|
||||
if len(conversations) != 0 {
|
||||
t.Fatalf("conversations=%#v, want empty", conversations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesLaterFailureOutputsPartial(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"}]}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{err: errors.New("page failed")},
|
||||
}}
|
||||
got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
|
||||
if err == nil || !strings.Contains(stderr, "pagination stopped") {
|
||||
t.Fatalf("err=%v stderr=%q", err, stderr)
|
||||
}
|
||||
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
|
||||
paging := got["paging"].(map[string]any)
|
||||
if len(conversations) != 1 || paging["partial"] != true || paging["itemsFetched"].(float64) != 1 {
|
||||
t.Fatalf("result=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesAddErrorsOutputPartial(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "conversation item not object",
|
||||
response: `{"result":{"conversationMessagesList":["bad"],"hasMore":false,"nextCursor":""}}`,
|
||||
want: "conversation item must be object",
|
||||
},
|
||||
{
|
||||
name: "conversation missing openConversationId",
|
||||
response: `{"result":{"conversationMessagesList":[{"messages":[]}],"hasMore":false,"nextCursor":""}}`,
|
||||
want: "missing openConversationId",
|
||||
},
|
||||
{
|
||||
name: "conversation messages not array",
|
||||
response: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":"bad"}],"hasMore":false,"nextCursor":""}}`,
|
||||
want: "conversation messages must be array",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}}
|
||||
got, stderr, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--page-delay", "0")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want)
|
||||
}
|
||||
if !strings.Contains(stderr, "pagination stopped at page 1") {
|
||||
t.Fatalf("stderr=%q", stderr)
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["partial"] != true || paging["itemsFetched"].(float64) != 0 {
|
||||
t.Fatalf("paging=%#v", paging)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesMaxItemsTruncatesAtConversationBoundary(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]},{"openConversationId":"cid2","messages":[{"id":"m3"},{"id":"m4"}]}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
|
||||
paging := got["paging"].(map[string]any)
|
||||
if len(conversations) != 1 || paging["total"].(float64) != 2 || paging["truncated"] != true {
|
||||
t.Fatalf("result=%#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesAcceptsMissingMessages(t *testing.T) {
|
||||
messages, err := conversationMessages(map[string]any{"openConversationId": "cid1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(messages) != 0 {
|
||||
t.Fatalf("messages=%#v, want empty", messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesRejectsCorruptExistingMessages(t *testing.T) {
|
||||
collection := newPagedCollection(PagedMCPCommandConfig{AggregationMode: PagedAggregationConversationMessages})
|
||||
collection.items = []any{map[string]any{"openConversationId": "cid1", "messages": "bad"}}
|
||||
collection.conversationIndex["cid1"] = 0
|
||||
|
||||
err := collection.Add([]any{map[string]any{"openConversationId": "cid1", "messages": []any{map[string]any{"id": "m2"}}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "conversation messages must be array") {
|
||||
t.Fatalf("err=%v, want corrupt existing messages error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandResponseShapeErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "items not array",
|
||||
response: `{"result":{"messages":"bad","hasMore":false}}`,
|
||||
want: "result.messages must be array",
|
||||
},
|
||||
{
|
||||
name: "missing hasMore",
|
||||
response: `{"result":{"messages":[]}}`,
|
||||
want: "missing result.hasMore",
|
||||
},
|
||||
{
|
||||
name: "hasMore not bool",
|
||||
response: `{"result":{"messages":[],"hasMore":"yes"}}`,
|
||||
want: "result.hasMore must be boolean",
|
||||
},
|
||||
{
|
||||
name: "missing next cursor",
|
||||
response: `{"result":{"messages":[{"id":"m1"}],"hasMore":true}}`,
|
||||
want: "missing result.nextCursor",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{{text: tt.response}}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("result=%#v err=%v, want %q", got, err, tt.want)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("result=%#v, want no partial stdout", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandPageDelayControlsSleep(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
delay string
|
||||
wantSleeps []time.Duration
|
||||
}{
|
||||
{
|
||||
name: "non zero delay sleeps between pages",
|
||||
delay: "200",
|
||||
wantSleeps: []time.Duration{200 * time.Millisecond},
|
||||
},
|
||||
{
|
||||
name: "zero delay skips sleep",
|
||||
delay: "0",
|
||||
wantSleeps: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`},
|
||||
}}
|
||||
var sleeps []time.Duration
|
||||
got, _, err := runPagedCommandTestWithSleep(t, caller, pagedCommandMessagesConfig(nil), func(d time.Duration) {
|
||||
sleeps = append(sleeps, d)
|
||||
}, "--page-all", "--page-delay", tt.delay)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := got["result"].(map[string]any)["messages"].([]any)
|
||||
if len(items) != 2 || len(caller.calls) != 2 {
|
||||
t.Fatalf("items=%#v calls=%#v", items, caller.calls)
|
||||
}
|
||||
if len(sleeps) != len(tt.wantSleeps) {
|
||||
t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps)
|
||||
}
|
||||
for i := range tt.wantSleeps {
|
||||
if sleeps[i] != tt.wantSleeps[i] {
|
||||
t.Fatalf("sleeps=%v, want %v", sleeps, tt.wantSleeps)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandMaxItemsTruncatesPrecisely(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := got["result"].(map[string]any)["messages"].([]any)
|
||||
paging := got["paging"].(map[string]any)
|
||||
if len(items) != 1 || paging["total"].(float64) != 1 || paging["truncated"] != true {
|
||||
t.Fatalf("result = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandMaxItemsStopsWhenPageExactlyReachesLimit(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{err: errors.New("second page should not run")},
|
||||
}}
|
||||
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "2", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := got["result"].(map[string]any)["messages"].([]any)
|
||||
result := got["result"].(map[string]any)
|
||||
paging := got["paging"].(map[string]any)
|
||||
if len(caller.calls) != 1 || len(items) != 2 {
|
||||
t.Fatalf("calls=%#v items=%#v, want one full page", caller.calls, items)
|
||||
}
|
||||
if paging["truncated"] != true || paging["hasMore"] != true || paging["lastCursor"] != "c2" {
|
||||
t.Fatalf("paging=%#v, want safe page-boundary cursor", paging)
|
||||
}
|
||||
if result["hasMore"] != true || result["nextCursor"] != "c2" {
|
||||
t.Fatalf("result=%#v, want safe page-boundary cursor fields", result)
|
||||
}
|
||||
if _, ok := paging["truncatedWithinPage"]; ok {
|
||||
t.Fatalf("paging=%#v, want no within-page truncation marker", paging)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
}}
|
||||
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := got["result"].(map[string]any)["messages"].([]any)
|
||||
result := got["result"].(map[string]any)
|
||||
paging := got["paging"].(map[string]any)
|
||||
if len(items) != 1 || paging["lastCursor"] != "0" {
|
||||
t.Fatalf("result=%#v, want current-page cursor after within-page truncation", got)
|
||||
}
|
||||
if result["hasMore"] != true || result["nextCursor"] != "0" {
|
||||
t.Fatalf("result=%#v, want unreliable current-page cursor fields", result)
|
||||
}
|
||||
if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
|
||||
t.Fatalf("paging=%#v, want unreliable resume marker", paging)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedCollectionTruncateReturnsFalseWhenLimitDoesNotTrim(t *testing.T) {
|
||||
collection := newPagedCollection(PagedMCPCommandConfig{})
|
||||
if err := collection.Add([]any{map[string]any{"id": "m1"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if collection.Truncate(0) {
|
||||
t.Fatal("Truncate(0) should not trim")
|
||||
}
|
||||
if collection.Truncate(1) {
|
||||
t.Fatal("Truncate(total) should not trim")
|
||||
}
|
||||
if collection.Total() != 1 || len(collection.Values()) != 1 {
|
||||
t.Fatalf("collection=%#v, want unchanged single item", collection.Values())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandConversationMessagesMaxItemsWithinPageKeepsCurrentCursor(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"conversationMessagesList":[{"openConversationId":"cid1","messages":[{"id":"m1"},{"id":"m2"}]}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
}}
|
||||
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandConversationMessagesConfig(nil), "--page-all", "--max-items", "1", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversations := got["result"].(map[string]any)["conversationMessagesList"].([]any)
|
||||
messages := conversations[0].(map[string]any)["messages"].([]any)
|
||||
paging := got["paging"].(map[string]any)
|
||||
if len(messages) != 1 || paging["lastCursor"] != "0" {
|
||||
t.Fatalf("result=%#v, want truncated conversation with current-page cursor", got)
|
||||
}
|
||||
if paging["truncatedWithinPage"] != true || paging["resumeCursorReliable"] != false {
|
||||
t.Fatalf("paging=%#v, want unreliable resume marker", paging)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandPassesCommandContextToCaller(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`},
|
||||
}}
|
||||
|
||||
out, _, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, &bytes.Buffer{}, "--page-all", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(out) == "" || len(caller.calls) != 1 {
|
||||
t.Fatalf("stdout=%q calls=%#v, want one successful call", out, caller.calls)
|
||||
}
|
||||
if caller.calls[0].ctx != ctx || caller.calls[0].ctx.Err() != context.Canceled {
|
||||
t.Fatalf("call ctx=%#v err=%v, want canceled command context", caller.calls[0].ctx, caller.calls[0].ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandPageDelayStopsWhenContextCanceled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{text: `{"result":{"messages":[{"id":"m2"}],"hasMore":false,"nextCursor":""}}`},
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
|
||||
stdout, stderr, err := executePagedCommandTestWithContext(t, ctx, caller, pagedCommandMessagesConfig(nil), nil, &out, "--page-all", "--page-delay", "10")
|
||||
if strings.TrimSpace(stdout) == "" {
|
||||
t.Fatal("stdout is empty, want partial pagination JSON")
|
||||
}
|
||||
if err == nil || !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("err=%v, want context canceled", err)
|
||||
}
|
||||
if !strings.Contains(stderr, "pagination stopped at page 2") || len(caller.calls) != 1 {
|
||||
t.Fatalf("stderr=%q calls=%#v, want cancellation before second call", stderr, caller.calls)
|
||||
}
|
||||
var got map[string]any
|
||||
if unmarshalErr := json.Unmarshal([]byte(stdout), &got); unmarshalErr != nil {
|
||||
t.Fatalf("stdout JSON = %q, err = %v", stdout, unmarshalErr)
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 {
|
||||
t.Fatalf("paging=%#v, want partial cancellation metadata", paging)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandPropagatesAggregatedOutputErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
steps []scriptedToolStep
|
||||
args []string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "normal end",
|
||||
steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":false,"nextCursor":""}}`},
|
||||
},
|
||||
args: []string{"--page-all", "--page-delay", "0"},
|
||||
},
|
||||
{
|
||||
name: "max items truncation",
|
||||
steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"},{"id":"m2"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
},
|
||||
args: []string{"--page-all", "--max-items", "1", "--page-delay", "0"},
|
||||
},
|
||||
{
|
||||
name: "page limit truncation",
|
||||
steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
},
|
||||
args: []string{"--page-all", "--page-limit", "1", "--page-delay", "0"},
|
||||
},
|
||||
{
|
||||
name: "partial result after later failure",
|
||||
steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{err: errors.New("page failed")},
|
||||
},
|
||||
args: []string{"--page-all", "--page-delay", "0"},
|
||||
wantStderr: "pagination stopped at page 2",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// TC-output-error: aggregate stdout write failures must fail the command.
|
||||
caller := &pagedCommandCaller{steps: tt.steps}
|
||||
_, stderr, err := executePagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), func(time.Duration) {}, failingWriter{}, tt.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("err=%v, want propagated write failure", err)
|
||||
}
|
||||
if tt.wantStderr != "" && !strings.Contains(stderr, tt.wantStderr) {
|
||||
t.Fatalf("stderr=%q, want %q", stderr, tt.wantStderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandInt64CursorAndItemsPath(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":20}}`},
|
||||
{text: `{"result":{"items":[{"id":"f2"}],"hasMore":false,"nextCursor":0}}`},
|
||||
}}
|
||||
cfg := PagedMCPCommandConfig{
|
||||
ServerID: "im",
|
||||
ToolName: "list_message_favorites",
|
||||
ItemPath: "result.items",
|
||||
CursorPath: "result.nextCursor",
|
||||
HasMorePath: "result.hasMore",
|
||||
CursorArg: "cursor",
|
||||
CursorKind: PagedCursorInt64,
|
||||
BuildArgs: func(cmd *cobra.Command) (map[string]any, error) {
|
||||
return map[string]any{"cursor": int64(0), "size": "20"}, nil
|
||||
},
|
||||
Fallback: func(map[string]any) error { return nil },
|
||||
}
|
||||
got, _, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items := got["result"].(map[string]any)["items"].([]any)
|
||||
if len(items) != 2 || caller.calls[1].args["cursor"] != int64(20) {
|
||||
t.Fatalf("items=%#v calls=%#v", items, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandInt64CursorRejectsNonNumericNextCursor(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"items":[{"id":"f1"}],"hasMore":true,"nextCursor":"not-a-number"}}`},
|
||||
}}
|
||||
cfg := PagedMCPCommandConfig{
|
||||
ServerID: "im",
|
||||
ToolName: "list_message_favorites",
|
||||
ItemPath: "result.items",
|
||||
CursorPath: "result.nextCursor",
|
||||
HasMorePath: "result.hasMore",
|
||||
CursorArg: "cursor",
|
||||
CursorKind: PagedCursorInt64,
|
||||
BuildArgs: func(cmd *cobra.Command) (map[string]any, error) {
|
||||
return map[string]any{"cursor": int64(0), "size": "20"}, nil
|
||||
},
|
||||
Fallback: func(map[string]any) error { return nil },
|
||||
}
|
||||
got, stderr, err := runPagedCommandTest(t, caller, cfg, "--page-all", "--page-delay", "0")
|
||||
if err == nil || !strings.Contains(err.Error(), "base-10 int64 string") {
|
||||
t.Fatalf("err=%v, want invalid int64 cursor error", err)
|
||||
}
|
||||
if !strings.Contains(stderr, "pagination stopped at page 2") {
|
||||
t.Fatalf("stderr=%q", stderr)
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["partial"] != true || paging["failedCursor"] != "not-a-number" || paging["pagesFetched"].(float64) != 1 {
|
||||
t.Fatalf("paging = %#v", paging)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls=%#v, want no second call with cursor 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandFirstPageFailureReturnsNoPartial(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{{err: errors.New("boom")}}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all")
|
||||
if err == nil || got != nil {
|
||||
t.Fatalf("result=%#v err=%v, want first-page error without stdout", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandLaterFailureOutputsPartial(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"c2"}}`},
|
||||
{err: errors.New("page failed")},
|
||||
}}
|
||||
got, stderr, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
|
||||
if err == nil || !strings.Contains(stderr, "pagination stopped") {
|
||||
t.Fatalf("err=%v stderr=%q", err, stderr)
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["partial"] != true || paging["failedPage"].(float64) != 2 || paging["itemsFetched"].(float64) != 1 {
|
||||
t.Fatalf("paging = %#v", paging)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandCursorCycleOutputsPartial(t *testing.T) {
|
||||
caller := &pagedCommandCaller{steps: []scriptedToolStep{
|
||||
{text: `{"result":{"messages":[{"id":"m1"}],"hasMore":true,"nextCursor":"0"}}`},
|
||||
}}
|
||||
got, _, err := runPagedCommandTest(t, caller, pagedCommandMessagesConfig(nil), "--page-all", "--page-delay", "0")
|
||||
if err == nil {
|
||||
t.Fatal("cursor cycle should return error")
|
||||
}
|
||||
paging := got["paging"].(map[string]any)
|
||||
if paging["partial"] != true || paging["pagesFetched"].(float64) != 1 {
|
||||
t.Fatalf("paging = %#v", paging)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandSetJSONPathRejectsNonObjectIntermediate(t *testing.T) {
|
||||
root := map[string]any{"result": "not-object"}
|
||||
|
||||
if setJSONPath(root, "result.messages", []any{}) {
|
||||
t.Fatal("setJSONPath should reject a non-object intermediate")
|
||||
}
|
||||
if root["result"] != "not-object" {
|
||||
t.Fatalf("root=%#v, want original intermediate preserved", root)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandCursorValueKeyCoversBoundaryKinds(t *testing.T) {
|
||||
if got := cursorValueKey(7, PagedCursorInt64); got != "7" {
|
||||
t.Fatalf("int cursor key=%q, want 7", got)
|
||||
}
|
||||
if got := cursorValueKey(nil, PagedCursorString); got != "" {
|
||||
t.Fatalf("nil string cursor key=%q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagedMCPCommandNormalizeCursorArgCoversBoundaryKinds(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value any
|
||||
kind PagedCursorKind
|
||||
want any
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil string cursor",
|
||||
value: nil,
|
||||
kind: PagedCursorString,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "int64 cursor",
|
||||
value: int64(9),
|
||||
kind: PagedCursorInt64,
|
||||
want: int64(9),
|
||||
},
|
||||
{
|
||||
name: "int cursor",
|
||||
value: 10,
|
||||
kind: PagedCursorInt64,
|
||||
want: int64(10),
|
||||
},
|
||||
{
|
||||
name: "numeric string cursor",
|
||||
value: " 11 ",
|
||||
kind: PagedCursorInt64,
|
||||
want: int64(11),
|
||||
},
|
||||
{
|
||||
name: "fractional float cursor",
|
||||
value: 1.5,
|
||||
kind: PagedCursorInt64,
|
||||
wantErr: "must be an integer",
|
||||
},
|
||||
{
|
||||
name: "unsupported cursor type",
|
||||
value: []string{"bad"},
|
||||
kind: PagedCursorInt64,
|
||||
wantErr: "int64-compatible",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeCursorArg(tt.value, tt.kind)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("value=%#v err=%v, want %q", tt.value, err, tt.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("value=%#v got=%#v, want %#v", tt.value, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,17 @@ func executePR868Command(t *testing.T, root *cobra.Command, args ...string) erro
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
|
||||
t.Run("permission add requires explicit policy", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newMinutesCommand(), "permission", "add", "--ids", "task-1", "--member-uids", "user-1")
|
||||
if err == nil || !strings.Contains(err.Error(), "--policy") {
|
||||
t.Fatalf("permission add without --policy error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("permission add called MCP %d times before required policy validation", caller.calls)
|
||||
}
|
||||
})
|
||||
t.Run("hot-word delete dry-run", func(t *testing.T) {
|
||||
installScriptedCaller(t, &scriptedToolCaller{dry: true, format: "json"})
|
||||
if err := executePR868Command(t, newMinutesCommand(), "hot-word", "delete", "--words", "钉钉,OKR"); err != nil {
|
||||
@@ -91,6 +102,21 @@ func TestCrossPlatformCoverageMinutesNewSurfaces(t *testing.T) {
|
||||
t.Fatal("expected missing policy")
|
||||
}
|
||||
})
|
||||
t.Run("permission apply policy flag is int", func(t *testing.T) {
|
||||
// 数值参数声明为 int 类型 flag;必填校验走 cmd.Flags().Changed,
|
||||
// 不能用 validateRequiredFlags(它把 int 零值当成未传)。
|
||||
cmd, _, err := newMinutesCommand().Find([]string{"permission", "apply"})
|
||||
if err != nil {
|
||||
t.Fatalf("find permission apply: %v", err)
|
||||
}
|
||||
flag := cmd.Flags().Lookup("policy")
|
||||
if flag == nil {
|
||||
t.Fatal("flag --policy not found")
|
||||
}
|
||||
if flag.Value.Type() != "int" {
|
||||
t.Fatalf("flag --policy type = %q, want %q", flag.Value.Type(), "int")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("audio-memo list default", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"items":[]}`}}}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
@@ -157,7 +158,15 @@ func TestCrossPlatformCoverageProductCommandExamplesAreExecutableContracts(t *te
|
||||
deps.Out.errW = io.Discard
|
||||
// Product examples execute real RunE paths; whiteboard insert retries must
|
||||
// not burn the suite timeout on real sleep (race CI uses a 12m package cap).
|
||||
// Sheet export uses helperAfter for progressive polling, so keep that clock
|
||||
// deterministic too; context deadlines alone still accumulate heavily under
|
||||
// Windows coverage instrumentation.
|
||||
whiteboardSleep = func(time.Duration) {}
|
||||
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
||||
ch := make(chan time.Time, 1)
|
||||
ch <- time.Now()
|
||||
return ch
|
||||
})
|
||||
httpPutFile = func(context.Context, string, map[string]string, string, int64) error { return nil }
|
||||
httpGetFile = func(_ context.Context, _ string, _ map[string]string, destPath string) error {
|
||||
if destPath == "" {
|
||||
|
||||
@@ -15,6 +15,7 @@ package chat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
@@ -1081,6 +1082,10 @@ func DownloadMessageResources(
|
||||
continue
|
||||
}
|
||||
preferredName := resourceDownloadPreferredName(data)
|
||||
if preferredName == "" {
|
||||
preferredName, _ = resource["name"].(string)
|
||||
preferredName = strings.TrimSpace(preferredName)
|
||||
}
|
||||
filename := resourceDownloadFilename(resourceURL, preferredName)
|
||||
filename = disambiguateResourceDownloadFilename(filename, downloadedNames)
|
||||
output := filepath.Join(outputDir, filename)
|
||||
@@ -1203,9 +1208,10 @@ func uniqueShortcutStrings(values []string) []string {
|
||||
var MessagesQuerySendStatus = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+messages-query-send-status",
|
||||
Aliases: []string{"+messages-send-status"},
|
||||
Product: "im",
|
||||
Description: "查询消息发送状态",
|
||||
Intent: "当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。",
|
||||
Description: "查询消息投递状态并衔接后续消息操作",
|
||||
Intent: "当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -1218,17 +1224,18 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
|
||||
CanonicalPath: "chat.shortcut_messages_query_send_status",
|
||||
CLIPath: "chat +messages-query-send-status",
|
||||
PrimaryCLIPath: "chat +messages-query-send-status",
|
||||
Aliases: []string{"chat +messages-send-status"},
|
||||
},
|
||||
Description: "查询消息发送状态",
|
||||
Description: "查询消息投递状态并衔接后续消息操作",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询消息发送状态",
|
||||
UseWhen: []string{"当你发消息后拿到 openTaskId、想确认这条消息是否发送成功时使用;只读返回发送状态,需传 --open-task-id。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
AgentSummary: "查询消息投递状态并衔接后续消息操作",
|
||||
UseWhen: []string{"当你发消息后拿到 openTaskId、想确认投递结果,或后续 edit/recall/read-status 需要取得 openMessageId 和 openConversationId 时使用;openTaskId 不是消息 ID。结果会保留下层响应,并追加版本化 messageRef 与结构化 nextActions。"},
|
||||
AvoidWhen: []string{"没有 openTaskId、已经有消息 ID,或只需查历史消息内容时不要使用"},
|
||||
Examples: []string{"dws chat +messages-query-send-status --open-task-id <openTaskId>"},
|
||||
},
|
||||
},
|
||||
@@ -1237,7 +1244,12 @@ var MessagesQuerySendStatus = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws chat +messages-query-send-status --open-task-id <openTaskId>`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("query_message_send_status", map[string]any{"openTaskId": rt.Str("open-task-id")})
|
||||
taskID := rt.Str("open-task-id")
|
||||
data, err := rt.CallMCPData("im", "query_message_send_status", map[string]any{"openTaskId": taskID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(chatmsg.ProjectMessageSendStatus(data, taskID))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1541,7 +1553,28 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
}
|
||||
content := rt.Str("content")
|
||||
if content == "" {
|
||||
return rt.CallMCP("create_and_send_card", params)
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"contractVersion": chatmsg.StreamingCardContractVersion,
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"preview_kind": "plan",
|
||||
"actionCount": 1,
|
||||
"actions": []map[string]any{{
|
||||
"tool": "create_and_send_card",
|
||||
"arguments": params,
|
||||
}},
|
||||
})
|
||||
}
|
||||
created, err := rt.CallMCPWriteData("im", "create_and_send_card", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bizID := findCardBizID(created)
|
||||
if bizID == "" {
|
||||
return cardCreateMissingBizIDError(created)
|
||||
}
|
||||
return rt.Output(chatmsg.ProjectStreamingCardReceipt(created, bizID))
|
||||
}
|
||||
status := rt.Int("flow-status")
|
||||
if rt.DryRun() {
|
||||
@@ -1592,14 +1625,14 @@ var MessagesSendCard = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新失败: %w", bizID, err)
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"contractVersion": currentCardWorkflowContract.Version,
|
||||
"ok": true,
|
||||
"bizId": bizID,
|
||||
"flowStatus": status,
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
})
|
||||
if _, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated); err != nil {
|
||||
return fmt.Errorf("卡片已创建(bizId=%s),但自动更新结果不可信: %w", bizID, cardUpdateVerificationError(bizID, err))
|
||||
}
|
||||
payload := chatmsg.ProjectStreamingCardReceipt(created, bizID)
|
||||
payload["bizId"] = bizID
|
||||
payload["flowStatus"] = status
|
||||
payload["updated"] = updated
|
||||
return rt.Output(payload)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1694,7 +1727,7 @@ var MessagesUpdateCard = shortcut.Shortcut{
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "流式更新卡片内容(最后一次 --flow-status 应为 3)",
|
||||
UseWhen: []string{"当你要向已发送的流式文本卡片持续追加/更新内容时使用;会实际更新卡片,需传 send-card 返回的 bizId、新内容及 flowStatus 1-5(最后一次应为 3 表示完成)。当前不支持 Card JSON 组件或 action callback。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
AvoidWhen: []string{"需要底层原始响应、未公开参数,或由调用方自行管理确认与更新节奏时,改用 chat message update-card"},
|
||||
Examples: []string{"dws chat +messages-update-card --biz-id <bizId> --content \"内容\" --flow-status 3"},
|
||||
},
|
||||
},
|
||||
@@ -1708,20 +1741,91 @@ var MessagesUpdateCard = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws chat +messages-update-card --biz-id <bizId> --content "内容" --flow-status 3`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if _, err := chatmsg.NormalizeCardBizID(rt.Str("biz-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validCardFlowStatus(rt.Int("flow-status")) {
|
||||
return fmt.Errorf("--flow-status 必须在 1-5 之间")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("update_streaming_card", map[string]any{
|
||||
"bizId": rt.Str("biz-id"),
|
||||
// Validate has already normalized and rejected empty, placeholder, and
|
||||
// whitespace-containing values before Execute is entered.
|
||||
bizID, _ := chatmsg.NormalizeCardBizID(rt.Str("biz-id"))
|
||||
params := map[string]any{
|
||||
"bizId": bizID,
|
||||
"msgContent": rt.Str("content"),
|
||||
"flowStatus": rt.Int("flow-status"),
|
||||
})
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{
|
||||
"dry_run": true,
|
||||
"executed": false,
|
||||
"verified": false,
|
||||
"action": map[string]any{
|
||||
"product": "im",
|
||||
"tool": "update_streaming_card",
|
||||
"arguments": params,
|
||||
},
|
||||
})
|
||||
}
|
||||
updated, err := rt.CallMCPWriteData("im", "update_streaming_card", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
proof, err := chatmsg.VerifyStreamingCardUpdate(bizID, updated)
|
||||
if err != nil {
|
||||
return cardUpdateVerificationError(bizID, err)
|
||||
}
|
||||
return rt.Output(chatmsg.ProjectStreamingCardUpdate(updated, bizID, proof))
|
||||
},
|
||||
}
|
||||
|
||||
func cardCreateMissingBizIDError(created map[string]any) error {
|
||||
return apperrors.NewAPI(
|
||||
"卡片可能已经创建,但服务端未返回后续更新所需的 bizId;CLI 无法确认卡片工作流可继续",
|
||||
apperrors.WithOperation("create_and_send_card"),
|
||||
apperrors.WithServerKey("im"),
|
||||
apperrors.WithOrigin("client_postcondition"),
|
||||
apperrors.WithFailureStage("verify_card_reference"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason("streaming_card_reference_missing"),
|
||||
apperrors.WithHint("不要盲目重试创建;请保留 trace_id 并推动服务端返回 bizId、openMessageId 和 openConversationId"),
|
||||
apperrors.WithDetails(map[string]any{"created": created}),
|
||||
)
|
||||
}
|
||||
|
||||
func cardUpdateVerificationError(bizID string, verifyErr error) error {
|
||||
reason := "streaming_card_update_unverified"
|
||||
message := "服务端未返回卡片实际更新的证据;为避免假成功,CLI 已将本次操作判为失败"
|
||||
hint := "请检查服务端是否返回 updated=true、affectedCount>0 或等价的明确更新结果"
|
||||
switch {
|
||||
case errors.Is(verifyErr, chatmsg.ErrCardUpdateNotApplied):
|
||||
reason = "streaming_card_update_not_applied"
|
||||
message = "服务端明确表示流式卡片没有被更新"
|
||||
hint = "请确认 bizId 来自 send-card、当前账号有权限且卡片仍允许该状态转换"
|
||||
case errors.Is(verifyErr, chatmsg.ErrCardUpdateBizIDDrift):
|
||||
reason = "streaming_card_update_biz_id_mismatch"
|
||||
message = "服务端返回的 bizId 与本次请求不一致;无法确认目标卡片已更新"
|
||||
hint = "请保留 trace_id 并检查 update_streaming_card 的响应映射"
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
message,
|
||||
apperrors.WithOperation("update_streaming_card"),
|
||||
apperrors.WithServerKey("im"),
|
||||
apperrors.WithOrigin("client_postcondition"),
|
||||
apperrors.WithFailureStage("verify_update_result"),
|
||||
apperrors.WithExecutionStarted(true),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithHint(hint),
|
||||
apperrors.WithDetails(map[string]any{"bizId": bizID}),
|
||||
apperrors.WithCause(verifyErr),
|
||||
)
|
||||
}
|
||||
|
||||
// MessagesResourceURL gets a message resource download url (get_resource_download_url, im).
|
||||
var MessagesResourceURL = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
|
||||
@@ -360,7 +360,9 @@ func TestCrossPlatformCoverageRecallCardAndLedgerBoundaries(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
helpers.InitDeps(&larkAlignmentCaller{})
|
||||
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"result":{"updated":true}}`,
|
||||
}})
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+messages-update-card", "--biz-id", "b", "--content", "x", "--flow-status", "3", "--yes"})
|
||||
if err := root.Execute(); err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
)
|
||||
|
||||
type chatOutputErrorWriter struct {
|
||||
@@ -124,6 +125,159 @@ func TestCrossPlatformCoverageIMWorkflowContractsPublishRealPositiveAndNegativeB
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendStatusAliasPublishesWorkflowReceipt(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/query_message_send_status": `{"result":{"status":"SUCCESS","openTaskId":"task-1","openMessageId":"msg-1","openConversationId":"cid-1"}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{"chat", "+messages-send-status", "--open-task-id", "task-1"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "query_message_send_status" || fake.calls[0].args["openTaskId"] != "task-1" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["contractVersion"] != chatmsg.MessageSendStatusContractVersion || payload["readyForMessageActions"] != true {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
ref, _ := payload["messageRef"].(map[string]any)
|
||||
if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
|
||||
t.Fatalf("messageRef = %#v", ref)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessageWorkflowFailureAndPreviewBranches(t *testing.T) {
|
||||
t.Run("send status lower error", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{failProductTool: "im/query_message_send_status"}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+messages-query-send-status", "--open-task-id", "task-1"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "fixture lower call failed") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create only dry run", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send-card", "--group", "cid", "--dry-run", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("dry-run made calls: %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["actionCount"] != float64(1) || payload["executed"] != false {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
fake *larkAlignmentCaller
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "create only lower error",
|
||||
fake: &larkAlignmentCaller{failProductTool: "im/create_and_send_card"},
|
||||
wantError: "fixture lower call failed",
|
||||
},
|
||||
{
|
||||
name: "create only missing biz id",
|
||||
fake: &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"result":{"created":true}}`,
|
||||
}},
|
||||
wantError: "未返回后续更新所需的 bizId",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
helpers.InitDeps(test.fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{"chat", "+messages-send-card", "--group", "cid", "--yes"})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("error = %v, want substring %q", err, test.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("update card lower error", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{failProductTool: "im/update_streaming_card"}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-update-card",
|
||||
"--biz-id", "biz-1", "--content", "完成", "--flow-status", "3", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "fixture lower call failed") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
createErr := cardCreateMissingBizIDError(map[string]any{"created": true})
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(createErr, &typed) || typed.Reason != "streaming_card_reference_missing" {
|
||||
t.Fatalf("create error = %#v", createErr)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
cause error
|
||||
wantReason string
|
||||
}{
|
||||
{cause: chatmsg.ErrCardUpdateNotApplied, wantReason: "streaming_card_update_not_applied"},
|
||||
{cause: chatmsg.ErrCardUpdateBizIDDrift, wantReason: "streaming_card_update_biz_id_mismatch"},
|
||||
} {
|
||||
typed = nil
|
||||
err := cardUpdateVerificationError("biz-1", test.cause)
|
||||
if !errors.As(err, &typed) || typed.Reason != test.wantReason {
|
||||
t.Errorf("cardUpdateVerificationError(%v) = %#v", test.cause, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendPublishesStatusQueryReceipt(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"chat/send_personal_message": `{"result":{"openTaskId":"task-send-1"}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-send", "--as", "user", "--chat-id", "cid-1",
|
||||
"--text", "hello", "--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, _ := payload["sendReceipt"].(map[string]any)
|
||||
if receipt["contractVersion"] != chatmsg.MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-send-1" {
|
||||
t.Fatalf("sendReceipt = %#v", receipt)
|
||||
}
|
||||
actions, _ := receipt["nextActions"].([]any)
|
||||
if len(actions) != 1 {
|
||||
t.Fatalf("nextActions = %#v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesSendBotMultiGroupPublishesPerTargetLedger(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
@@ -895,6 +1049,14 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
},
|
||||
wantError: "biz-preserved",
|
||||
},
|
||||
{
|
||||
name: "unverified update preserves id",
|
||||
fake: &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/create_and_send_card": `{"bizId":"biz-unverified"}`,
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}},
|
||||
wantError: "biz-unverified",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(tc.fake)
|
||||
@@ -929,6 +1091,120 @@ func TestCrossPlatformCoverageMessagesSendCardDryRunAndFailureBoundaries(t *test
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMessagesUpdateCardRejectsFalseSuccess(t *testing.T) {
|
||||
t.Run("agent shortcut owns confirmation boundary", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"result":{"bizId":"biz-confirm","updated":true}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-update-card",
|
||||
"--biz-id", "biz-confirm",
|
||||
"--content", "高层更新",
|
||||
"--flow-status", "3",
|
||||
})
|
||||
err := root.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "confirmation_required" {
|
||||
t.Fatalf("error = %#v, want confirmation_required", err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("unconfirmed shortcut reached MCP: %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic success is unverified", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"success":true,"errorCode":null}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-update-card",
|
||||
"--biz-id", "中文乱串",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
"--yes",
|
||||
})
|
||||
err := root.Execute()
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "streaming_card_update_unverified" {
|
||||
t.Fatalf("error = %#v, want streaming_card_update_unverified", err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "update_streaming_card" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit update evidence succeeds", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{responses: map[string]string{
|
||||
"im/update_streaming_card": `{"result":{"bizId":"biz-verified","updated":true}}`,
|
||||
}}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-update-card",
|
||||
"--biz-id", "biz-verified",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("placeholder fails before write", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-update-card",
|
||||
"--biz-id", "<bizId>",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "占位符") {
|
||||
t.Fatalf("error = %v, want placeholder validation", err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("invalid placeholder made calls: %#v", fake.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run only publishes plan", func(t *testing.T) {
|
||||
fake := &larkAlignmentCaller{}
|
||||
helpers.InitDeps(fake)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs([]string{
|
||||
"chat", "+messages-update-card",
|
||||
"--biz-id", "biz-preview",
|
||||
"--content", "完成",
|
||||
"--flow-status", "3",
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("dry-run made calls: %#v", fake.calls)
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["executed"] != false || payload["verified"] != false {
|
||||
t.Fatalf("dry-run payload = %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFindCardBizIDResponseShapes(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
value any
|
||||
@@ -1090,6 +1366,9 @@ func TestCrossPlatformCoverageMessageFileResourceDownloadUsesDriveAndPreservesNa
|
||||
fake.calls[0].args["fileId"] != "drive-file" {
|
||||
t.Fatalf("drive call = %#v", fake.calls)
|
||||
}
|
||||
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
|
||||
"drive/download_file": `{"result":{"downloadUrl":"https://download.dingtalk.com/opaque"}}`,
|
||||
}})
|
||||
|
||||
var ledger map[string]any
|
||||
shortcut.Register(shortcut.Shortcut{
|
||||
|
||||
@@ -75,6 +75,8 @@ func (f *larkAlignmentCaller) CallTool(_ context.Context, product, tool string,
|
||||
text = string(payload)
|
||||
case "im/create_group_conversation":
|
||||
text = `{"result":{"cid":"internal-cid","openCid":"open-cid"}}`
|
||||
case "im/create_and_send_card":
|
||||
text = `{"result":{"bizId":"biz-created"}}`
|
||||
case "im/list_messages_by_ids":
|
||||
text = `{"result":[{"openMessageId":"msg","openConversationId":"cid","senderOpenDingTalkId":"D-inferred","content":"{\"mediaId\":\"@image\"}"}]}`
|
||||
case "im/list_conversations_by_category":
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
)
|
||||
|
||||
@@ -372,12 +373,16 @@ func executeUnifiedMessageWrite(rt *shortcut.RuntimeContext, product, tool strin
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
payload := map[string]any{
|
||||
"ok": true,
|
||||
"identity": messagesSendIdentity(rt),
|
||||
"tool": tool,
|
||||
"result": data,
|
||||
})
|
||||
}
|
||||
if messagesSendIdentity(rt) == "user" && tool == "send_personal_message" {
|
||||
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
|
||||
}
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func messagesSendIdentity(rt *shortcut.RuntimeContext) string {
|
||||
@@ -612,7 +617,7 @@ func executeMessagesSendUserFile(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
payload := map[string]any{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"tool": "send_personal_message",
|
||||
@@ -624,7 +629,9 @@ func executeMessagesSendUserFile(
|
||||
"sizeBytes": meta.FileSize,
|
||||
},
|
||||
"result": data,
|
||||
})
|
||||
}
|
||||
payload["sendReceipt"] = chatmsg.ProjectMessageSendReceipt(data)
|
||||
return rt.Output(payload)
|
||||
}
|
||||
|
||||
func addMessagesSendUserTarget(params map[string]any, group, openID string) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
package chatmsg
|
||||
|
||||
import "strings"
|
||||
|
||||
// StreamingCardContractVersion identifies the additive card receipt emitted by
|
||||
// high-level card shortcuts.
|
||||
const StreamingCardContractVersion = "im.streaming-card.v1"
|
||||
|
||||
// ProjectStreamingCardReceipt publishes every server-returned identifier in a
|
||||
// single cardRef. referencePairAvailable means this response contained both
|
||||
// the update identifier and the visible message identifiers; it does not claim
|
||||
// that older messages can be resolved without server-side mapping support.
|
||||
func ProjectStreamingCardReceipt(created map[string]any, bizID string) map[string]any {
|
||||
bizID = strings.TrimSpace(bizID)
|
||||
messageID := firstSendStatusString(created, "openMessageId", "messageId", "msgId")
|
||||
conversationID := firstSendStatusString(created, "openConversationId", "conversationId", "openCid")
|
||||
cardRef := map[string]any{}
|
||||
if bizID != "" {
|
||||
cardRef["bizId"] = bizID
|
||||
}
|
||||
if messageID != "" {
|
||||
cardRef["openMessageId"] = messageID
|
||||
}
|
||||
if conversationID != "" {
|
||||
cardRef["openConversationId"] = conversationID
|
||||
}
|
||||
pairAvailable := bizID != "" && messageID != "" && conversationID != ""
|
||||
payload := map[string]any{
|
||||
"contractVersion": StreamingCardContractVersion,
|
||||
"ok": true,
|
||||
"cardRef": cardRef,
|
||||
"referencePairAvailable": pairAvailable,
|
||||
"created": created,
|
||||
"nextActions": []map[string]any{},
|
||||
}
|
||||
if bizID != "" {
|
||||
payload["nextActions"] = []map[string]any{{
|
||||
"cliPath": "chat +messages-update-card",
|
||||
"arguments": map[string]any{
|
||||
"biz-id": bizID,
|
||||
},
|
||||
"requiredArguments": []string{"content", "flow-status"},
|
||||
"ready": false,
|
||||
}}
|
||||
}
|
||||
if !pairAvailable {
|
||||
payload["capabilityGap"] = "服务端尚未同时返回 bizId、openMessageId 和 openConversationId;CLI 只能保留本次响应,不能据此承诺从历史消息反向恢复 bizId"
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
// ProjectStreamingCardUpdate preserves the lower response while making the
|
||||
// verified target explicit for downstream consumers.
|
||||
func ProjectStreamingCardUpdate(updated map[string]any, bizID, proof string) map[string]any {
|
||||
payload := cloneSendStatusMap(updated)
|
||||
payload["contractVersion"] = StreamingCardContractVersion
|
||||
payload["cardRef"] = map[string]any{"bizId": strings.TrimSpace(bizID)}
|
||||
payload["verified"] = true
|
||||
payload["verificationEvidence"] = proof
|
||||
return payload
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 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.
|
||||
|
||||
package chatmsg
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageProjectStreamingCardReceipt(t *testing.T) {
|
||||
complete := ProjectStreamingCardReceipt(map[string]any{
|
||||
"result": map[string]any{
|
||||
"bizId": "biz-1",
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
},
|
||||
}, "biz-1")
|
||||
if complete["contractVersion"] != StreamingCardContractVersion || complete["referencePairAvailable"] != true {
|
||||
t.Fatalf("complete receipt = %#v", complete)
|
||||
}
|
||||
ref, _ := complete["cardRef"].(map[string]any)
|
||||
if ref["bizId"] != "biz-1" || ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
|
||||
t.Fatalf("cardRef = %#v", ref)
|
||||
}
|
||||
if _, exists := complete["capabilityGap"]; exists {
|
||||
t.Fatalf("complete receipt has capability gap: %#v", complete)
|
||||
}
|
||||
|
||||
partial := ProjectStreamingCardReceipt(map[string]any{"result": map[string]any{"bizId": "biz-2"}}, "biz-2")
|
||||
if partial["referencePairAvailable"] != false || partial["capabilityGap"] == "" {
|
||||
t.Fatalf("partial receipt = %#v", partial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectStreamingCardUpdate(t *testing.T) {
|
||||
payload := ProjectStreamingCardUpdate(map[string]any{"result": map[string]any{"updated": true}}, "biz-1", "updated=true")
|
||||
if payload["contractVersion"] != StreamingCardContractVersion || payload["verified"] != true || payload["verificationEvidence"] != "updated=true" {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if _, exists := payload["result"]; !exists {
|
||||
t.Fatal("lower response was not preserved")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// 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 chatmsg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCardUpdateNotApplied = errors.New("streaming card update was not applied")
|
||||
ErrCardUpdateUnverified = errors.New("streaming card update could not be verified")
|
||||
ErrCardUpdateBizIDDrift = errors.New("streaming card update returned a different bizId")
|
||||
)
|
||||
|
||||
// NormalizeCardBizID performs only format-independent checks. bizId is an
|
||||
// opaque server-issued identifier; a stricter character or prefix contract
|
||||
// must not be invented by the CLI without an authoritative API declaration.
|
||||
func NormalizeCardBizID(raw string) (string, error) {
|
||||
bizID := strings.TrimSpace(raw)
|
||||
if bizID == "" {
|
||||
return "", fmt.Errorf("--biz-id 不能为空")
|
||||
}
|
||||
for _, r := range bizID {
|
||||
if unicode.IsControl(r) || unicode.IsSpace(r) {
|
||||
return "", fmt.Errorf("--biz-id 必须是 send-card 返回的单个不透明 ID,不能包含空白或控制字符")
|
||||
}
|
||||
}
|
||||
if isCardBizIDPlaceholder(bizID) {
|
||||
return "", fmt.Errorf("--biz-id 仍是占位符 %q;请传入 send-card 实际返回的 bizId", bizID)
|
||||
}
|
||||
return bizID, nil
|
||||
}
|
||||
|
||||
func isCardBizIDPlaceholder(value string) bool {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "bizid", "biz-id", "your-biz-id", "your_biz_id",
|
||||
"<bizid>", "<biz-id>", "<your-biz-id>",
|
||||
"{bizid}", "{biz-id}", "${bizid}", "${biz-id}":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// VerifyStreamingCardUpdate requires affirmative evidence that the requested
|
||||
// write took effect. A transport-level {success:true,errorCode:null} only says
|
||||
// that the RPC returned normally and is deliberately not accepted as proof of
|
||||
// a card update.
|
||||
func VerifyStreamingCardUpdate(requestedBizID string, response map[string]any) (string, error) {
|
||||
requestedBizID = strings.TrimSpace(requestedBizID)
|
||||
observation := cardUpdateObservation{bizIDs: map[string]struct{}{}}
|
||||
observeCardUpdate(response, &observation)
|
||||
|
||||
for responseBizID := range observation.bizIDs {
|
||||
if requestedBizID != "" && responseBizID != requestedBizID {
|
||||
return "", fmt.Errorf("%w: requested %q, response %q", ErrCardUpdateBizIDDrift, requestedBizID, responseBizID)
|
||||
}
|
||||
}
|
||||
if observation.positiveEvidence != "" && observation.negativeEvidence != "" {
|
||||
return "", fmt.Errorf("%w: conflicting evidence %s and %s", ErrCardUpdateUnverified, observation.positiveEvidence, observation.negativeEvidence)
|
||||
}
|
||||
if observation.positiveEvidence != "" {
|
||||
return observation.positiveEvidence, nil
|
||||
}
|
||||
if observation.negativeEvidence != "" {
|
||||
return "", fmt.Errorf("%w: %s", ErrCardUpdateNotApplied, observation.negativeEvidence)
|
||||
}
|
||||
return "", ErrCardUpdateUnverified
|
||||
}
|
||||
|
||||
type cardUpdateObservation struct {
|
||||
bizIDs map[string]struct{}
|
||||
positiveEvidence string
|
||||
negativeEvidence string
|
||||
}
|
||||
|
||||
func observeCardUpdate(value any, observation *cardUpdateObservation) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
observeCardUpdateMap(typed, observation)
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
observeCardUpdate(child, observation)
|
||||
}
|
||||
case bool:
|
||||
if typed {
|
||||
setPositiveCardUpdateEvidence(observation, "result=true")
|
||||
} else {
|
||||
setNegativeCardUpdateEvidence(observation, "result=false")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func observeCardUpdateMap(value map[string]any, observation *cardUpdateObservation) {
|
||||
for _, key := range []string{"bizId", "bizID", "biz_id"} {
|
||||
if candidate, ok := value[key].(string); ok && strings.TrimSpace(candidate) != "" {
|
||||
observation.bizIDs[strings.TrimSpace(candidate)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range []string{"updated", "applied"} {
|
||||
if applied, ok := value[key].(bool); ok {
|
||||
if applied {
|
||||
setPositiveCardUpdateEvidence(observation, key+"=true")
|
||||
} else {
|
||||
setNegativeCardUpdateEvidence(observation, key+"=false")
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"affectedCount", "updatedCount", "modifiedCount"} {
|
||||
if count, ok := cardUpdateCount(value[key]); ok {
|
||||
if count > 0 {
|
||||
setPositiveCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count))
|
||||
} else if count == 0 {
|
||||
setNegativeCardUpdateEvidence(observation, fmt.Sprintf("%s=%d", key, count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only documented response envelopes are traversed. This prevents an
|
||||
// unrelated extension field containing "updated":true from proving the
|
||||
// business write.
|
||||
for _, key := range []string{"result", "data", "response", "card"} {
|
||||
if child, exists := value[key]; exists {
|
||||
observeCardUpdate(child, observation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setPositiveCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
|
||||
if observation.positiveEvidence == "" {
|
||||
observation.positiveEvidence = evidence
|
||||
}
|
||||
}
|
||||
|
||||
func setNegativeCardUpdateEvidence(observation *cardUpdateObservation, evidence string) {
|
||||
if observation.negativeEvidence == "" {
|
||||
observation.negativeEvidence = evidence
|
||||
}
|
||||
}
|
||||
|
||||
func cardUpdateCount(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case int32:
|
||||
return int64(typed), true
|
||||
case int64:
|
||||
return typed, true
|
||||
case float32:
|
||||
return int64(typed), float32(int64(typed)) == typed
|
||||
case float64:
|
||||
return int64(typed), float64(int64(typed)) == typed
|
||||
case json.Number:
|
||||
count, err := typed.Int64()
|
||||
return count, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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 chatmsg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageNormalizeCardBizID(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "opaque id", raw: " card-token-1 ", want: "card-token-1"},
|
||||
{name: "opaque unicode remains server owned", raw: "中文乱串", want: "中文乱串"},
|
||||
{name: "empty", raw: " ", wantErr: true},
|
||||
{name: "placeholder", raw: "<bizId>", wantErr: true},
|
||||
{name: "internal space", raw: "card token", wantErr: true},
|
||||
{name: "control", raw: "card\ntoken", wantErr: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := NormalizeCardBizID(test.raw)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeCardBizID(%q) unexpectedly succeeded", test.raw)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || got != test.want {
|
||||
t.Fatalf("NormalizeCardBizID(%q) = %q, %v; want %q", test.raw, got, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageVerifyStreamingCardUpdate(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
response map[string]any
|
||||
wantProof string
|
||||
wantErrIs error
|
||||
}{
|
||||
{name: "updated", response: map[string]any{"result": map[string]any{"updated": true}}, wantProof: "updated=true"},
|
||||
{name: "affected", response: map[string]any{"data": map[string]any{"affectedCount": float64(1)}}, wantProof: "affectedCount=1"},
|
||||
{name: "boolean result", response: map[string]any{"result": true}, wantProof: "result=true"},
|
||||
{name: "boolean false result", response: map[string]any{"result": false}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "matching id", response: map[string]any{"result": map[string]any{"bizId": "biz-1", "applied": true}}, wantProof: "applied=true"},
|
||||
{name: "conflicting evidence", response: map[string]any{"updated": true, "applied": false}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "zero affected", response: map[string]any{"affectedCount": 0}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "false success has no write proof", response: map[string]any{"success": true, "errorCode": nil}, wantErrIs: ErrCardUpdateUnverified},
|
||||
{name: "explicitly not updated", response: map[string]any{"result": map[string]any{"updated": false}}, wantErrIs: ErrCardUpdateNotApplied},
|
||||
{name: "mismatched id", response: map[string]any{"result": map[string]any{"bizId": "biz-2", "updated": true}}, wantErrIs: ErrCardUpdateBizIDDrift},
|
||||
{name: "unrelated extension ignored", response: map[string]any{"extension": map[string]any{"updated": true}}, wantErrIs: ErrCardUpdateUnverified},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
proof, err := VerifyStreamingCardUpdate("biz-1", test.response)
|
||||
if test.wantErrIs != nil {
|
||||
if !errors.Is(err, test.wantErrIs) {
|
||||
t.Fatalf("VerifyStreamingCardUpdate error = %v, want errors.Is(_, %v)", err, test.wantErrIs)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || proof != test.wantProof {
|
||||
t.Fatalf("VerifyStreamingCardUpdate = %q, %v; want %q", proof, err, test.wantProof)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCardUpdateCountScalarVariants(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
value any
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{value: int(1), want: 1, ok: true},
|
||||
{value: int32(2), want: 2, ok: true},
|
||||
{value: int64(3), want: 3, ok: true},
|
||||
{value: float32(4), want: 4, ok: true},
|
||||
{value: float32(4.5), want: 4, ok: false},
|
||||
{value: float64(5), want: 5, ok: true},
|
||||
{value: float64(5.5), want: 5, ok: false},
|
||||
{value: json.Number("6"), want: 6, ok: true},
|
||||
{value: json.Number("6.5"), ok: false},
|
||||
{value: "7", ok: false},
|
||||
} {
|
||||
got, ok := cardUpdateCount(test.value)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Errorf("cardUpdateCount(%#v) = (%d, %v), want (%d, %v)", test.value, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ var messageResultContractV1 = MessageResultContract{
|
||||
"failedCount",
|
||||
"failures",
|
||||
"partial",
|
||||
"scope",
|
||||
"resourceDownloads",
|
||||
},
|
||||
}
|
||||
@@ -283,9 +284,10 @@ func SenderType(m map[string]any) any {
|
||||
// legacy aliases such as time or msgType, but the underlying identity,
|
||||
// context, reaction, quote, forward, and resource semantics come from here.
|
||||
func ProjectMessageV1(m map[string]any, includeReactions bool) map[string]any {
|
||||
ownedResources := Resources(m)
|
||||
row := map[string]any{
|
||||
"sender": Sender(m),
|
||||
"text": Text(m),
|
||||
"text": projectedResourceText(m, ownedResources),
|
||||
"createTime": CreateTime(m),
|
||||
}
|
||||
if value := MessageID(m); value != nil {
|
||||
@@ -357,7 +359,8 @@ func QuotedMessage(m map[string]any) map[string]any {
|
||||
if value := Sender(quoted); value != nil {
|
||||
out["sender"] = value
|
||||
}
|
||||
if value := Text(quoted); value != nil {
|
||||
resources := Resources(quoted)
|
||||
if value := projectedResourceText(quoted, resources); value != nil {
|
||||
out["text"] = value
|
||||
}
|
||||
if value := CreateTime(quoted); value != nil {
|
||||
@@ -366,7 +369,7 @@ func QuotedMessage(m map[string]any) map[string]any {
|
||||
if value := MessageType(quoted); value != nil {
|
||||
out["messageType"] = value
|
||||
}
|
||||
if resources := Resources(quoted); len(resources) > 0 {
|
||||
if len(resources) > 0 {
|
||||
out["resourceRefs"] = resources
|
||||
}
|
||||
return out
|
||||
@@ -400,10 +403,14 @@ func Resources(m map[string]any) []map[string]any {
|
||||
collectResourceIDs(m, "mediaid", mediaIDTextRE, &mediaIDs)
|
||||
mediaIDs = uniqueResourceIDs(mediaIDs)
|
||||
sort.Strings(mediaIDs)
|
||||
mediaNames := make(map[string]resourceNameCandidate)
|
||||
collectResourceNames(m, "mediaid", mediaNames)
|
||||
fileIDs := make([]string, 0)
|
||||
collectResourceIDs(m, "fileid", fileIDTextRE, &fileIDs)
|
||||
fileIDs = uniqueResourceIDs(fileIDs)
|
||||
sort.Strings(fileIDs)
|
||||
fileNames := make(map[string]resourceNameCandidate)
|
||||
collectResourceNames(m, "fileid", fileNames)
|
||||
if len(mediaIDs) == 0 && len(fileIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -434,7 +441,7 @@ func Resources(m map[string]any) []map[string]any {
|
||||
} else {
|
||||
missing = append(missing, "open-conversation-id")
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
resource := map[string]any{
|
||||
"type": "mediaId",
|
||||
"resourceId": id,
|
||||
"download": map[string]any{
|
||||
@@ -443,10 +450,14 @@ func Resources(m map[string]any) []map[string]any {
|
||||
"ready": len(missing) == 0,
|
||||
"missing": missing,
|
||||
},
|
||||
})
|
||||
}
|
||||
if candidate, ok := mediaNames[id]; ok {
|
||||
resource["name"] = candidate.name
|
||||
}
|
||||
out = append(out, resource)
|
||||
}
|
||||
for _, id := range fileIDs {
|
||||
out = append(out, map[string]any{
|
||||
resource := map[string]any{
|
||||
"type": "fileId",
|
||||
"resourceId": id,
|
||||
"download": map[string]any{
|
||||
@@ -458,7 +469,11 @@ func Resources(m map[string]any) []map[string]any {
|
||||
"ready": true,
|
||||
"missing": []string{},
|
||||
},
|
||||
})
|
||||
}
|
||||
if candidate, ok := fileNames[id]; ok {
|
||||
resource["name"] = candidate.name
|
||||
}
|
||||
out = append(out, resource)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -503,6 +518,158 @@ func resourcesDeep(m map[string]any, inheritedConversationID string, depth int)
|
||||
|
||||
var mediaIDTextRE = regexp.MustCompile(`(?i)\bmedia[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
|
||||
var fileIDTextRE = regexp.MustCompile(`(?i)\bfile[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
|
||||
var fileNameAndIDTextRE = regexp.MustCompile(`(?i)\[文件\]\s*([^\r\n]*?)\s+file[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
|
||||
var legacyResourceDownloadHintRE = regexp.MustCompile(`\s*注意:如需下载使用dws\s+(?:chat message download-media|drive download)命令下载\s*`)
|
||||
|
||||
// projectedResourceText removes only the exact, machine-generated download
|
||||
// hint emitted by older IM APIs. The readable resource marker and ID remain in
|
||||
// text, while resourceRefs publishes the current executable download command.
|
||||
// Text without an owned mediaId/fileId is left byte-for-byte unchanged so an
|
||||
// ordinary user sentence mentioning a command can never be rewritten.
|
||||
func projectedResourceText(m map[string]any, resources []map[string]any) any {
|
||||
value := Text(m)
|
||||
text, ok := value.(string)
|
||||
if !ok || len(resources) == 0 ||
|
||||
(!mediaIDTextRE.MatchString(text) && !fileIDTextRE.MatchString(text)) {
|
||||
return value
|
||||
}
|
||||
return strings.TrimSpace(legacyResourceDownloadHintRE.ReplaceAllString(text, ""))
|
||||
}
|
||||
|
||||
type resourceNameCandidate struct {
|
||||
name string
|
||||
priority int
|
||||
}
|
||||
|
||||
const (
|
||||
resourceNamePriorityText = 1
|
||||
resourceNamePriorityStructured = 2
|
||||
)
|
||||
|
||||
// collectResourceNames keeps a resource ID paired with a name only when both
|
||||
// are present in the same structured object or in the legacy, machine-shaped
|
||||
// "[文件] name fileId: id" text. This deliberately does not borrow a generic
|
||||
// message title or sender name: an unknown resource name is safer than a
|
||||
// plausible but incorrect one.
|
||||
func collectResourceNames(value any, targetKey string, out map[string]resourceNameCandidate) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
directIDs := directResourceIDs(typed, targetKey)
|
||||
if len(directIDs) == 1 {
|
||||
if name := directResourceName(typed, targetKey); name != "" {
|
||||
recordResourceName(out, directIDs[0], name, resourceNamePriorityStructured)
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if isNestedMessageBoundaryKey(normalizeMessageKey(key)) {
|
||||
continue
|
||||
}
|
||||
collectResourceNames(typed[key], targetKey, out)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
collectResourceNames(child, targetKey, out)
|
||||
}
|
||||
case []map[string]any:
|
||||
for _, child := range typed {
|
||||
collectResourceNames(child, targetKey, out)
|
||||
}
|
||||
case string:
|
||||
if targetKey == "fileid" {
|
||||
for _, match := range fileNameAndIDTextRE.FindAllStringSubmatch(typed, -1) {
|
||||
name := strings.TrimSpace(match[1])
|
||||
id := resourceIDScalar(match[2])
|
||||
if name != "" && id != "" {
|
||||
recordResourceName(out, id, name, resourceNamePriorityText)
|
||||
}
|
||||
}
|
||||
}
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||
var decoded any
|
||||
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
|
||||
collectResourceNames(decoded, targetKey, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func directResourceIDs(value map[string]any, targetKey string) []string {
|
||||
resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint(
|
||||
firstMessageValue(value, "resourceType", "resource_type"))))
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
ids := make([]string, 0, 1)
|
||||
for _, key := range keys {
|
||||
normalizedKey := normalizeMessageKey(key)
|
||||
if normalizedKey != targetKey &&
|
||||
!(normalizedKey == "resourceid" && resourceType == targetKey) {
|
||||
continue
|
||||
}
|
||||
if id := resourceIDScalar(value[key]); id != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return uniqueResourceIDs(ids)
|
||||
}
|
||||
|
||||
func directResourceName(value map[string]any, targetKey string) string {
|
||||
for _, wanted := range []string{"filename", "resourcename", "originalfilename"} {
|
||||
if name := directResourceString(value, wanted); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
// A bare "name" is accepted only inside an explicit resource envelope.
|
||||
// Message rows also commonly contain a sender/group name, which must never
|
||||
// become the attachment filename merely because the row has a resource ID.
|
||||
resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint(
|
||||
firstMessageValue(value, "resourceType", "resource_type"))))
|
||||
if resourceType == targetKey && directResourceString(value, "resourceid") != "" {
|
||||
return directResourceString(value, "name")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func directResourceString(value map[string]any, wanted string) string {
|
||||
keys := make([]string, 0, len(value))
|
||||
for key := range value {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
if normalizeMessageKey(key) != wanted {
|
||||
continue
|
||||
}
|
||||
if text, ok := value[key].(string); ok {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func recordResourceName(
|
||||
out map[string]resourceNameCandidate,
|
||||
id, name string,
|
||||
priority int,
|
||||
) {
|
||||
id = resourceIDScalar(id)
|
||||
name = strings.TrimSpace(name)
|
||||
if id == "" || name == "" {
|
||||
return
|
||||
}
|
||||
if current, ok := out[id]; ok && current.priority >= priority {
|
||||
return
|
||||
}
|
||||
out[id] = resourceNameCandidate{name: name, priority: priority}
|
||||
}
|
||||
|
||||
func collectResourceIDs(value any, targetKey string, textPattern *regexp.Regexp, out *[]string) {
|
||||
switch typed := value.(type) {
|
||||
|
||||
@@ -540,6 +540,28 @@ func TestCrossPlatformCoverageResourcesBuildsActionableDownloadReferences(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResourcesKeepsNestedResourceNamesWithTheirOwner(t *testing.T) {
|
||||
message := map[string]any{
|
||||
"openMessageId": "parent-message",
|
||||
"openConversationId": "cid-1",
|
||||
"content": `{"fileId":"parent-file","fileName":"parent.pdf"}`,
|
||||
"quotedMessage": map[string]any{
|
||||
"openMessageId": "quoted-message",
|
||||
"content": `{"fileId":"quoted-file","file_name":"quoted.pdf"}`,
|
||||
},
|
||||
}
|
||||
resources := ResourcesDeep(message)
|
||||
if len(resources) != 2 {
|
||||
t.Fatalf("resources = %#v", resources)
|
||||
}
|
||||
if resources[0]["resourceId"] != "parent-file" || resources[0]["name"] != "parent.pdf" {
|
||||
t.Fatalf("parent resource = %#v", resources[0])
|
||||
}
|
||||
if resources[1]["resourceId"] != "quoted-file" || resources[1]["name"] != "quoted.pdf" {
|
||||
t.Fatalf("quoted resource = %#v", resources[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResourcesReportsMissingDownloadContext(t *testing.T) {
|
||||
resources := Resources(map[string]any{"content": `{"mediaId":"@image-a"}`})
|
||||
if len(resources) != 1 {
|
||||
|
||||
@@ -28,16 +28,18 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
|
||||
}
|
||||
resources := Resources(map[string]any{
|
||||
"attachments": []map[string]any{
|
||||
{"resourceType": "mediaId", "resourceId": "@file-a"},
|
||||
{"resourceType": "fileId", "resourceId": "drive-file"},
|
||||
{"resourceType": "mediaId", "resourceId": "@file-a", "name": "photo.png"},
|
||||
{"resourceType": "fileId", "resourceId": "drive-file", "fileName": "canonical-report.txt"},
|
||||
{"mediaId": 42, "fileId": 42},
|
||||
},
|
||||
"content": `[文件] report.txt fileId: drive-file`,
|
||||
})
|
||||
if len(resources) != 2 ||
|
||||
resources[0]["resourceId"] != "@file-a" ||
|
||||
resources[0]["name"] != "photo.png" ||
|
||||
resources[1]["resourceId"] != "drive-file" ||
|
||||
resources[1]["type"] != "fileId" {
|
||||
resources[1]["type"] != "fileId" ||
|
||||
resources[1]["name"] != "canonical-report.txt" {
|
||||
t.Fatalf("resources = %#v", resources)
|
||||
}
|
||||
fileDownload := resources[1]["download"].(map[string]any)
|
||||
@@ -53,6 +55,65 @@ func TestCrossPlatformCoverageQuotedResourcesAndScalarVariants(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResourcesExtractsLegacyFileNameWithoutGuessing(t *testing.T) {
|
||||
resources := Resources(map[string]any{
|
||||
"name": "sender-name-must-not-leak",
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
"content": `[文件] 项目最终报告 2026.pdf fileId: drive-file 注意:如需下载使用旧命令`,
|
||||
"unrelatedAttachment": map[string]any{"mediaId": "@image-without-name"},
|
||||
})
|
||||
if len(resources) != 2 {
|
||||
t.Fatalf("resources = %#v", resources)
|
||||
}
|
||||
if resources[0]["resourceId"] != "@image-without-name" {
|
||||
t.Fatalf("first resource = %#v", resources[0])
|
||||
}
|
||||
if _, leaked := resources[0]["name"]; leaked {
|
||||
t.Fatalf("message sender name leaked into media resource: %#v", resources[0])
|
||||
}
|
||||
if resources[1]["resourceId"] != "drive-file" ||
|
||||
resources[1]["name"] != "项目最终报告 2026.pdf" {
|
||||
t.Fatalf("file resource = %#v", resources[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResourceNameRejectsIncompletePairs(t *testing.T) {
|
||||
names := map[string]resourceNameCandidate{}
|
||||
recordResourceName(names, "", "report.pdf", resourceNamePriorityStructured)
|
||||
recordResourceName(names, "file-1", "", resourceNamePriorityStructured)
|
||||
if len(names) != 0 {
|
||||
t.Fatalf("incomplete resource-name pairs were retained: %#v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectionRemovesOnlyLegacyResourceDownloadHint(t *testing.T) {
|
||||
legacy := `[文件] 项目最终报告 2026.pdf fileId: drive-file 注意:如需下载使用dws drive download命令下载`
|
||||
row := ProjectMessageV1(map[string]any{"content": legacy}, false)
|
||||
if row["text"] != `[文件] 项目最终报告 2026.pdf fileId: drive-file` {
|
||||
t.Fatalf("projected text = %#v", row["text"])
|
||||
}
|
||||
resources := row["resourceRefs"].([]map[string]any)
|
||||
if len(resources) != 1 ||
|
||||
resources[0]["name"] != "项目最终报告 2026.pdf" ||
|
||||
resources[0]["download"].(map[string]any)["shortcut"] != "+messages-resource-download" {
|
||||
t.Fatalf("projected resources = %#v", resources)
|
||||
}
|
||||
mediaRow := ProjectMessageV1(map[string]any{
|
||||
"openMessageId": "msg-media",
|
||||
"openConversationId": "cid-media",
|
||||
"content": `[图片消息](mediaId=@media) 注意:如需下载使用dws chat message download-media命令下载`,
|
||||
}, false)
|
||||
if mediaRow["text"] != `[图片消息](mediaId=@media)` {
|
||||
t.Fatalf("projected media text = %#v", mediaRow["text"])
|
||||
}
|
||||
|
||||
ordinary := `团队规范:注意:如需下载使用dws drive download命令下载`
|
||||
if got := ProjectMessageV1(map[string]any{"content": ordinary}, false)["text"]; got != ordinary {
|
||||
t.Fatalf("ordinary text was rewritten: got %#v, want %q", got, ordinary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReactionShapeVariants(t *testing.T) {
|
||||
got := Reactions(map[string]any{
|
||||
"reactions": []map[string]any{
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// 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 chatmsg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SearchItems locates and flattens the message list returned by the two
|
||||
// DingTalk message-search interfaces. Grouped search responses carry the
|
||||
// conversation identity on the group rather than each message, so the
|
||||
// flattener copies that identity onto every returned message before callers
|
||||
// perform scope checks.
|
||||
func SearchItems(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
for _, root := range []map[string]any{data, childMap(data, "result")} {
|
||||
if root == nil {
|
||||
continue
|
||||
}
|
||||
if groups, ok := root["conversationMessagesList"].([]any); ok {
|
||||
return flattenSearchGroups(groups)
|
||||
}
|
||||
}
|
||||
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
|
||||
for _, key := range keys {
|
||||
if arr, ok := data[key].([]any); ok {
|
||||
return searchMaps(arr)
|
||||
}
|
||||
if inner, ok := data[key].(map[string]any); ok {
|
||||
for _, innerKey := range []string{"list", "messages", "messageList", "items", "data", "records"} {
|
||||
if arr, ok := inner[innerKey].([]any); ok {
|
||||
return searchMaps(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func childMap(data map[string]any, key string) map[string]any {
|
||||
if value, ok := data[key].(map[string]any); ok {
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func flattenSearchGroups(groups []any) []map[string]any {
|
||||
out := make([]map[string]any, 0)
|
||||
for _, rawGroup := range groups {
|
||||
group, ok := rawGroup.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
messages, ok := group["messages"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
conversationID := cleanSearchScalar(group["openConversationId"])
|
||||
conversationTitle := cleanSearchScalar(group["title"])
|
||||
singleChat, hasSingleChat := group["singleChat"]
|
||||
for _, rawMessage := range messages {
|
||||
message, ok := rawMessage.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := make(map[string]any, len(message)+3)
|
||||
for key, value := range message {
|
||||
item[key] = value
|
||||
}
|
||||
if _, exists := item["openConversationId"]; !exists && conversationID != "" {
|
||||
item["openConversationId"] = conversationID
|
||||
}
|
||||
if _, exists := item["conversationTitle"]; !exists && conversationTitle != "" {
|
||||
item["conversationTitle"] = conversationTitle
|
||||
}
|
||||
if _, exists := item["singleChat"]; !exists && hasSingleChat {
|
||||
item["singleChat"] = singleChat
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func searchMaps(items []any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if message, ok := item.(map[string]any); ok {
|
||||
out = append(out, message)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterConversationScope keeps only messages belonging to the explicitly
|
||||
// requested conversations. A message without a conversation identity is
|
||||
// reported as unverifiable rather than treated as in-scope.
|
||||
func FilterConversationScope(messages []map[string]any, conversationIDs []string) (matched []map[string]any, unverifiableMessageIDs []string) {
|
||||
requested := make(map[string]struct{}, len(conversationIDs))
|
||||
for _, conversationID := range conversationIDs {
|
||||
if value := strings.TrimSpace(conversationID); value != "" {
|
||||
requested[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
matched = make([]map[string]any, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
conversationID := cleanSearchScalar(ConversationID(message))
|
||||
if conversationID == "" {
|
||||
messageID := cleanSearchScalar(MessageID(message))
|
||||
if messageID == "" {
|
||||
messageID = "<unknown>"
|
||||
}
|
||||
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
|
||||
continue
|
||||
}
|
||||
if _, ok := requested[conversationID]; ok {
|
||||
matched = append(matched, message)
|
||||
}
|
||||
}
|
||||
return matched, uniqueStrings(unverifiableMessageIDs)
|
||||
}
|
||||
|
||||
// GroupSearchMessages restores the established typed search envelope after a
|
||||
// client-side scoped scan. Group order follows first occurrence in the search
|
||||
// result, and each message retains its original fields.
|
||||
func GroupSearchMessages(messages []map[string]any) []map[string]any {
|
||||
groups := make([]map[string]any, 0)
|
||||
index := make(map[string]int)
|
||||
for _, message := range messages {
|
||||
conversationID := cleanSearchScalar(ConversationID(message))
|
||||
if conversationID == "" {
|
||||
continue
|
||||
}
|
||||
groupIndex, ok := index[conversationID]
|
||||
if !ok {
|
||||
group := map[string]any{
|
||||
"openConversationId": conversationID,
|
||||
"messages": []map[string]any{},
|
||||
}
|
||||
if title := cleanSearchScalar(message["conversationTitle"]); title != "" {
|
||||
group["title"] = title
|
||||
}
|
||||
if singleChat, exists := message["singleChat"]; exists {
|
||||
group["singleChat"] = singleChat
|
||||
}
|
||||
groups = append(groups, group)
|
||||
groupIndex = len(groups) - 1
|
||||
index[conversationID] = groupIndex
|
||||
}
|
||||
groupMessages := groups[groupIndex]["messages"].([]map[string]any)
|
||||
groups[groupIndex]["messages"] = append(groupMessages, message)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func cleanSearchScalar(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
text := strings.TrimSpace(fmt.Sprint(value))
|
||||
if text == "" || text == "<nil>" || strings.EqualFold(text, "null") {
|
||||
return ""
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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 chatmsg
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSearchItemsCarriesGroupedConversationIdentity(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"result": map[string]any{
|
||||
"conversationMessagesList": []any{
|
||||
map[string]any{
|
||||
"openConversationId": "cid-group",
|
||||
"title": "项目群",
|
||||
"singleChat": false,
|
||||
"messages": []any{
|
||||
map[string]any{"openMessageId": "m1", "content": "hello"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items := SearchItems(data)
|
||||
if len(items) != 1 || items[0]["openConversationId"] != "cid-group" ||
|
||||
items[0]["conversationTitle"] != "项目群" || items[0]["singleChat"] != false {
|
||||
t.Fatalf("items = %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterConversationScopeDropsOtherConversationsAndRejectsMissingIdentity(t *testing.T) {
|
||||
messages := []map[string]any{
|
||||
{"openMessageId": "m1", "openConversationId": "cid-target"},
|
||||
{"openMessageId": "m2", "openConversationId": "cid-other"},
|
||||
{"openMessageId": "m3"},
|
||||
}
|
||||
|
||||
matched, missing := FilterConversationScope(messages, []string{"cid-target"})
|
||||
if len(matched) != 1 || matched[0]["openMessageId"] != "m1" {
|
||||
t.Fatalf("matched = %#v", matched)
|
||||
}
|
||||
if !reflect.DeepEqual(missing, []string{"m3"}) {
|
||||
t.Fatalf("missing = %#v", missing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupSearchMessagesPreservesFirstSeenOrder(t *testing.T) {
|
||||
messages := []map[string]any{
|
||||
{"openMessageId": "m1", "openConversationId": "cid-b", "conversationTitle": "B"},
|
||||
{"openMessageId": "m2", "openConversationId": "cid-a", "conversationTitle": "A"},
|
||||
{"openMessageId": "m3", "openConversationId": "cid-b", "conversationTitle": "B"},
|
||||
}
|
||||
|
||||
groups := GroupSearchMessages(messages)
|
||||
if len(groups) != 2 || groups[0]["openConversationId"] != "cid-b" || groups[1]["openConversationId"] != "cid-a" {
|
||||
t.Fatalf("groups = %#v", groups)
|
||||
}
|
||||
groupMessages, _ := groups[0]["messages"].([]map[string]any)
|
||||
if len(groupMessages) != 2 || groupMessages[1]["openMessageId"] != "m3" {
|
||||
t.Fatalf("group messages = %#v", groupMessages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchProjectionEdgeBranches(t *testing.T) {
|
||||
if SearchItems(nil) != nil {
|
||||
t.Fatal("nil search response returned messages")
|
||||
}
|
||||
|
||||
matched, missing := FilterConversationScope(
|
||||
[]map[string]any{{}},
|
||||
[]string{"", "cid-target"},
|
||||
)
|
||||
if len(matched) != 0 || !reflect.DeepEqual(missing, []string{"<unknown>"}) {
|
||||
t.Fatalf("scope result = matched:%#v missing:%#v", matched, missing)
|
||||
}
|
||||
|
||||
groups := GroupSearchMessages([]map[string]any{
|
||||
{"openMessageId": "missing-scope"},
|
||||
{"openMessageId": "m1", "openConversationId": "cid-1", "singleChat": true},
|
||||
})
|
||||
if len(groups) != 1 || groups[0]["singleChat"] != true {
|
||||
t.Fatalf("groups = %#v", groups)
|
||||
}
|
||||
|
||||
if cleanSearchScalar(nil) != "" || cleanSearchScalar(" null ") != "" || cleanSearchScalar(" value ") != "value" {
|
||||
t.Fatal("cleanSearchScalar did not normalize sentinel values")
|
||||
}
|
||||
if got := uniqueStrings([]string{"m1", "m1", "m2"}); !reflect.DeepEqual(got, []string{"m1", "m2"}) {
|
||||
t.Fatalf("uniqueStrings = %#v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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.
|
||||
|
||||
package chatmsg
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MessageSendStatusContractVersion identifies the additive workflow fields
|
||||
// projected by the high-level send-status shortcut. The lower response fields
|
||||
// remain at their original locations for compatibility.
|
||||
const MessageSendStatusContractVersion = "im.message-send-status.v1"
|
||||
|
||||
// MessageSendReceiptContractVersion identifies the additive receipt attached
|
||||
// to high-level current-user send results.
|
||||
const MessageSendReceiptContractVersion = "im.message-send-receipt.v1"
|
||||
|
||||
// ProjectMessageSendReceipt connects a send result to its asynchronous status
|
||||
// query without treating openTaskId as a message identifier.
|
||||
func ProjectMessageSendReceipt(raw map[string]any) map[string]any {
|
||||
taskID := firstSendStatusString(raw, "openTaskId", "taskId")
|
||||
messageID := firstSendStatusString(raw, "openMessageId", "messageId", "msgId")
|
||||
conversationID := firstSendStatusString(raw, "openConversationId", "conversationId", "openCid")
|
||||
ready := messageID != "" && conversationID != ""
|
||||
receipt := map[string]any{
|
||||
"contractVersion": MessageSendReceiptContractVersion,
|
||||
"openTaskId": taskID,
|
||||
"readyForMessageActions": ready,
|
||||
"nextActions": []map[string]any{},
|
||||
}
|
||||
if messageID != "" || conversationID != "" {
|
||||
messageRef := map[string]any{}
|
||||
if messageID != "" {
|
||||
messageRef["openMessageId"] = messageID
|
||||
}
|
||||
if conversationID != "" {
|
||||
messageRef["openConversationId"] = conversationID
|
||||
}
|
||||
receipt["messageRef"] = messageRef
|
||||
}
|
||||
switch {
|
||||
case ready:
|
||||
receipt["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, true)
|
||||
case taskID != "":
|
||||
receipt["nextActions"] = []map[string]any{{
|
||||
"cliPath": "chat +messages-query-send-status",
|
||||
"arguments": map[string]any{
|
||||
"open-task-id": taskID,
|
||||
},
|
||||
"ready": true,
|
||||
"when": "需要确认投递结果或取得真实消息 ID 时",
|
||||
}}
|
||||
default:
|
||||
receipt["capabilityGap"] = "下层发送响应未返回 openTaskId 或完整 messageRef,CLI 无法生成后续状态查询"
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
// ProjectMessageSendStatus preserves the lower response and adds a stable
|
||||
// receipt that connects openTaskId to the message identifiers required by
|
||||
// edit, recall, and read-status. It never manufactures a message reference:
|
||||
// downstream actions are marked ready only when both IDs are actually present.
|
||||
func ProjectMessageSendStatus(raw map[string]any, requestedTaskID string) map[string]any {
|
||||
payload := cloneSendStatusMap(raw)
|
||||
taskID := firstSendStatusString(payload, "openTaskId", "taskId")
|
||||
if taskID == "" {
|
||||
taskID = strings.TrimSpace(requestedTaskID)
|
||||
}
|
||||
messageID := firstSendStatusString(payload, "openMessageId", "messageId", "msgId")
|
||||
conversationID := firstSendStatusString(payload, "openConversationId", "conversationId", "openCid")
|
||||
|
||||
payload["contractVersion"] = MessageSendStatusContractVersion
|
||||
payload["openTaskId"] = taskID
|
||||
messageRef := map[string]any{}
|
||||
if messageID != "" {
|
||||
messageRef["openMessageId"] = messageID
|
||||
}
|
||||
if conversationID != "" {
|
||||
messageRef["openConversationId"] = conversationID
|
||||
}
|
||||
if len(messageRef) > 0 {
|
||||
payload["messageRef"] = messageRef
|
||||
}
|
||||
ready := messageID != "" && conversationID != ""
|
||||
payload["readyForMessageActions"] = ready
|
||||
payload["nextActions"] = sendStatusNextActions(taskID, messageID, conversationID, ready)
|
||||
return payload
|
||||
}
|
||||
|
||||
func sendStatusNextActions(taskID, messageID, conversationID string, ready bool) []map[string]any {
|
||||
if !ready {
|
||||
return []map[string]any{{
|
||||
"cliPath": "chat message query-send-status",
|
||||
"arguments": map[string]any{
|
||||
"open-task-id": taskID,
|
||||
},
|
||||
"ready": false,
|
||||
"when": "投递任务尚未返回 openMessageId 和 openConversationId 时稍后重查",
|
||||
}}
|
||||
}
|
||||
messageArgs := map[string]any{
|
||||
"conversation-id": conversationID,
|
||||
"msg-id": messageID,
|
||||
}
|
||||
return []map[string]any{
|
||||
{
|
||||
"cliPath": "chat message recall",
|
||||
"arguments": cloneSendStatusMap(messageArgs),
|
||||
"ready": true,
|
||||
},
|
||||
{
|
||||
"cliPath": "chat message edit",
|
||||
"arguments": map[string]any{
|
||||
"conversation-id": conversationID,
|
||||
"msg-id": messageID,
|
||||
},
|
||||
"requiredArguments": []string{"text 或 content"},
|
||||
"ready": false,
|
||||
},
|
||||
{
|
||||
"cliPath": "chat message read-status",
|
||||
"arguments": map[string]any{
|
||||
"conversation-id": conversationID,
|
||||
"message-id": messageID,
|
||||
},
|
||||
"ready": true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func firstSendStatusString(value any, keys ...string) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for _, key := range keys {
|
||||
if candidate, ok := typed[key].(string); ok && strings.TrimSpace(candidate) != "" {
|
||||
return strings.TrimSpace(candidate)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"result", "data", "response", "content", "message"} {
|
||||
if candidate := firstSendStatusString(typed[key], keys...); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
if candidate := firstSendStatusString(item, keys...); candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneSendStatusMap(source map[string]any) map[string]any {
|
||||
clone := make(map[string]any, len(source)+5)
|
||||
for key, value := range source {
|
||||
clone[key] = value
|
||||
}
|
||||
return clone
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// 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.
|
||||
|
||||
package chatmsg
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageProjectMessageSendReceiptLinksStatusQuery(t *testing.T) {
|
||||
receipt := ProjectMessageSendReceipt(map[string]any{
|
||||
"result": map[string]any{"openTaskId": "task-1"},
|
||||
})
|
||||
if receipt["contractVersion"] != MessageSendReceiptContractVersion || receipt["openTaskId"] != "task-1" || receipt["readyForMessageActions"] != false {
|
||||
t.Fatalf("receipt = %#v", receipt)
|
||||
}
|
||||
actions, _ := receipt["nextActions"].([]map[string]any)
|
||||
if len(actions) != 1 || actions[0]["cliPath"] != "chat +messages-query-send-status" || actions[0]["ready"] != true {
|
||||
t.Fatalf("nextActions = %#v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectMessageSendReceiptReadyWorkflow(t *testing.T) {
|
||||
receipt := ProjectMessageSendReceipt(map[string]any{
|
||||
"openTaskId": "task-ready",
|
||||
"openMessageId": "msg-ready",
|
||||
"openConversationId": "cid-ready",
|
||||
})
|
||||
if receipt["readyForMessageActions"] != true {
|
||||
t.Fatalf("receipt = %#v", receipt)
|
||||
}
|
||||
ref, _ := receipt["messageRef"].(map[string]any)
|
||||
if ref["openMessageId"] != "msg-ready" || ref["openConversationId"] != "cid-ready" {
|
||||
t.Fatalf("messageRef = %#v", ref)
|
||||
}
|
||||
actions, _ := receipt["nextActions"].([]map[string]any)
|
||||
if len(actions) != 3 || actions[0]["ready"] != true {
|
||||
t.Fatalf("nextActions = %#v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectMessageSendStatusReadyWorkflow(t *testing.T) {
|
||||
raw := map[string]any{
|
||||
"result": map[string]any{
|
||||
"openTaskId": "task-1",
|
||||
"openMessageId": "msg-1",
|
||||
"openConversationId": "cid-1",
|
||||
"status": "SUCCESS",
|
||||
},
|
||||
}
|
||||
payload := ProjectMessageSendStatus(raw, "ignored")
|
||||
if payload["contractVersion"] != MessageSendStatusContractVersion ||
|
||||
payload["openTaskId"] != "task-1" || payload["readyForMessageActions"] != true {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
ref, _ := payload["messageRef"].(map[string]any)
|
||||
if ref["openMessageId"] != "msg-1" || ref["openConversationId"] != "cid-1" {
|
||||
t.Fatalf("messageRef = %#v", ref)
|
||||
}
|
||||
actions, _ := payload["nextActions"].([]map[string]any)
|
||||
if len(actions) != 3 || actions[0]["cliPath"] != "chat message recall" || actions[2]["cliPath"] != "chat message read-status" {
|
||||
t.Fatalf("nextActions = %#v", actions)
|
||||
}
|
||||
if _, ok := payload["result"]; !ok {
|
||||
t.Fatal("raw response field was not preserved")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProjectMessageSendStatusPendingDoesNotInventMessageRef(t *testing.T) {
|
||||
payload := ProjectMessageSendStatus(map[string]any{
|
||||
"result": map[string]any{"status": "PENDING"},
|
||||
}, "task-pending")
|
||||
if payload["openTaskId"] != "task-pending" || payload["readyForMessageActions"] != false {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
if _, exists := payload["messageRef"]; exists {
|
||||
t.Fatalf("pending payload invented messageRef: %#v", payload)
|
||||
}
|
||||
actions, _ := payload["nextActions"].([]map[string]any)
|
||||
if len(actions) != 1 || actions[0]["ready"] != false {
|
||||
t.Fatalf("nextActions = %#v", actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFirstSendStatusStringTraversesArrays(t *testing.T) {
|
||||
value := []any{
|
||||
nil,
|
||||
map[string]any{"result": []any{
|
||||
map[string]any{"openTaskId": " task-from-array "},
|
||||
}},
|
||||
}
|
||||
if got := firstSendStatusString(value, "openTaskId"); got != "task-from-array" {
|
||||
t.Fatalf("firstSendStatusString() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -72,8 +72,9 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
|
||||
text = f.chatMessagesResult
|
||||
}
|
||||
case "im/search_messages":
|
||||
if f.searchMessagesResult != "" {
|
||||
text = f.searchMessagesResult
|
||||
text = f.searchMessagesResult
|
||||
if text == "" {
|
||||
text = `{"result":{"messages":[],"hasMore":false}}`
|
||||
}
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
@@ -405,7 +406,8 @@ func TestCrossPlatformCoverageCompatibilityAliases(t *testing.T) {
|
||||
argv: []string{"chat", "+search-msg", "--id", "cid-1", "--keyword", "树莓派", "--no-enrich", "--yes"},
|
||||
wantProduct: "im",
|
||||
wantTool: "search_messages",
|
||||
wantArgs: map[string]any{"openConversationIds": []string{"cid-1"}, "keyword": "树莓派"},
|
||||
wantArgs: map[string]any{"keyword": "树莓派"},
|
||||
wantAbsent: []string{"openConversationIds"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -323,15 +323,18 @@ func TestCrossPlatformCoverageSearchMsgResolvesNaturalChatAndSenderBeforeSearch(
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 3 {
|
||||
t.Fatalf("calls = %#v, want chat resolve + user resolve + search", fake.calls)
|
||||
if len(fake.calls) != 4 {
|
||||
t.Fatalf("calls = %#v, want chat resolve + user resolve + scope validation + search", fake.calls)
|
||||
}
|
||||
search := fake.calls[2]
|
||||
if preflight := fake.calls[2]; preflight.product != "chat" || preflight.tool != "get_conversation_info" || preflight.args["openConversationId"] != "cid-1" {
|
||||
t.Fatalf("scope preflight = %#v", preflight)
|
||||
}
|
||||
search := fake.calls[3]
|
||||
if search.product != "im" || search.tool != "search_messages" {
|
||||
t.Fatalf("search = %#v", search)
|
||||
}
|
||||
if got, want := search.args["openConversationIds"], []string{"cid-1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("openConversationIds = %#v, want %#v", got, want)
|
||||
if _, exists := search.args["openConversationIds"]; exists {
|
||||
t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", search.args)
|
||||
}
|
||||
if got, want := search.args["senderOpenDingTakIds"], []string{"D1"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("senderOpenDingTakIds = %#v, want %#v", got, want)
|
||||
@@ -392,14 +395,17 @@ func TestCrossPlatformCoverageSearchMsgAcceptsStableIDInChatQuery(t *testing.T)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fake.calls) != 1 || fake.calls[0].tool != "search_messages" {
|
||||
if len(fake.calls) != 2 || fake.calls[0].tool != "get_conversation_info" || fake.calls[1].tool != "search_messages" {
|
||||
t.Fatalf("calls = %#v", fake.calls)
|
||||
}
|
||||
if got, want := fake.calls[0].args["openConversationIds"], []string{"cid-fixture-chat-0002"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("openConversationIds = %#v, want %#v", got, want)
|
||||
if fake.calls[0].args["openConversationId"] != "cid-fixture-chat-0002" {
|
||||
t.Fatalf("scope preflight = %#v", fake.calls[0])
|
||||
}
|
||||
if fake.calls[0].args["keyword"] != "评测" {
|
||||
t.Fatalf("keyword = %#v", fake.calls[0].args["keyword"])
|
||||
if _, exists := fake.calls[1].args["openConversationIds"]; exists {
|
||||
t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", fake.calls[1].args)
|
||||
}
|
||||
if fake.calls[1].args["keyword"] != "评测" {
|
||||
t.Fatalf("keyword = %#v", fake.calls[1].args["keyword"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
||||
@@ -34,15 +35,18 @@ import (
|
||||
// search hits through list_messages_by_ids in chunks of 50. A later-page or
|
||||
// enrichment failure never turns a partial result into a false success: the
|
||||
// output carries an explicit failure ledger and complete=false.
|
||||
const searchMsgIntent = "当你要按关键词、发送者、@对象、消息类型、机器人来源或会话范围组合搜索 IM 消息时使用;可搜索单个、多个或全部会话,会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间和输出顺序。" +
|
||||
"显式指定会话时会先验证 CID,再执行有界全局扫描并在本地精确过滤,避免下层忽略非法 CID 或群聊 CID。" +
|
||||
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
|
||||
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"
|
||||
|
||||
var SearchMsg = shortcut.Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+search-msg",
|
||||
Product: "im",
|
||||
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
|
||||
Intent: "当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。" +
|
||||
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
|
||||
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Description: "按稳定 ID、内容、时间等条件搜索消息,可校验会话范围、全量翻页并批量富化",
|
||||
Intent: searchMsgIntent,
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -55,15 +59,15 @@ var SearchMsg = shortcut.Shortcut{
|
||||
CLIPath: "chat +search-msg",
|
||||
PrimaryCLIPath: "chat +search-msg",
|
||||
},
|
||||
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
|
||||
Description: "按稳定 ID、内容、时间等条件搜索消息,可校验会话范围、全量翻页并批量富化",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
|
||||
UseWhen: []string{"当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"},
|
||||
AgentSummary: "按稳定 ID、内容、时间等条件搜索消息,可校验会话范围、全量翻页并批量富化",
|
||||
UseWhen: []string{searchMsgIntent},
|
||||
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
|
||||
Examples: []string{
|
||||
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
|
||||
@@ -104,7 +108,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
|
||||
{Name: "page-token", Type: shortcut.FlagString, Desc: "--cursor 的 lark-cli 对齐别名"},
|
||||
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动连续拉取所有游标页"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 的最大页数(1-40)", Default: "20"},
|
||||
{Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 或显式会话范围本地扫描的最大页数(1-40)", Default: "20"},
|
||||
{Name: "no-enrich", Type: shortcut.FlagBool, Desc: "不再按消息 ID 批量查询完整详情"},
|
||||
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出命中消息的 reaction(默认输出)"},
|
||||
}, chatshortcut.MessageResourceDownloadFlags()...),
|
||||
@@ -145,9 +149,21 @@ var SearchMsg = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestedConversationIDs, _ := params["openConversationIds"].([]string)
|
||||
scopedSearch := len(requestedConversationIDs) > 0
|
||||
if scopedSearch {
|
||||
if err := validateSearchConversationScope(rt, requestedConversationIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
// The downstream search currently drops invalid CID filters and does
|
||||
// not return group-scoped hits reliably. Scan the same filtered global
|
||||
// stream and apply the already-validated CID set locally instead.
|
||||
delete(params, "openConversationIds")
|
||||
}
|
||||
|
||||
pageLimit := 1
|
||||
if rt.Bool("page-all") {
|
||||
scanAllPages := rt.Bool("page-all") || scopedSearch
|
||||
if scanAllPages {
|
||||
pageLimit = rt.Int("page-limit")
|
||||
}
|
||||
cursor := rt.StrFirst("page-token", "cursor")
|
||||
@@ -176,7 +192,15 @@ var SearchMsg = shortcut.Shortcut{
|
||||
break
|
||||
}
|
||||
pagesFetched++
|
||||
for _, message := range searchMsgItems(data) {
|
||||
pageMessages := searchMsgItems(data)
|
||||
if scopedSearch {
|
||||
var unverifiableMessageIDs []string
|
||||
pageMessages, unverifiableMessageIDs = chatmsg.FilterConversationScope(pageMessages, requestedConversationIDs)
|
||||
if len(unverifiableMessageIDs) > 0 {
|
||||
return searchScopeUnverifiedError(requestedConversationIDs, unverifiableMessageIDs)
|
||||
}
|
||||
}
|
||||
for _, message := range pageMessages {
|
||||
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
|
||||
if messageID != "" && messageID != "<nil>" {
|
||||
if seen[messageID] {
|
||||
@@ -204,7 +228,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
}
|
||||
}
|
||||
hasMore = hasMoreValue
|
||||
if !rt.Bool("page-all") || !hasMore {
|
||||
if !scanAllPages || !hasMore {
|
||||
complete = !hasMore
|
||||
break
|
||||
}
|
||||
@@ -218,7 +242,7 @@ var SearchMsg = shortcut.Shortcut{
|
||||
}
|
||||
cursor = nextCursor
|
||||
}
|
||||
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
|
||||
if scanAllPages && hasMore && pagesFetched == pageLimit {
|
||||
failures = append(failures, map[string]any{
|
||||
"stage": "search-page-limit",
|
||||
"error": fmt.Sprintf("达到 --page-limit=%d,仍有更多结果", pageLimit),
|
||||
@@ -235,6 +259,16 @@ var SearchMsg = shortcut.Shortcut{
|
||||
complete = false
|
||||
}
|
||||
}
|
||||
if scopedSearch {
|
||||
validatedMessages, unverifiableMessageIDs := chatmsg.FilterConversationScope(messages, requestedConversationIDs)
|
||||
if len(unverifiableMessageIDs) > 0 {
|
||||
return searchScopeUnverifiedError(requestedConversationIDs, unverifiableMessageIDs)
|
||||
}
|
||||
if len(validatedMessages) != len(messages) {
|
||||
return searchScopeViolationError(requestedConversationIDs, messages)
|
||||
}
|
||||
messages = validatedMessages
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
|
||||
if order == "" {
|
||||
@@ -262,6 +296,9 @@ var SearchMsg = shortcut.Shortcut{
|
||||
if len(resolvedFilters.Senders) > 0 {
|
||||
payload["resolvedFilters"] = resolvedFilters
|
||||
}
|
||||
if scopedSearch {
|
||||
payload["scope"] = searchScopePayload(requestedConversationIDs, paginationKnown && !hasMore)
|
||||
}
|
||||
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
|
||||
payload["nextCursor"] = nextCursor
|
||||
}
|
||||
@@ -432,6 +469,63 @@ func uniqueSearchStrings(values []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func validateSearchConversationScope(rt *shortcut.RuntimeContext, conversationIDs []string) error {
|
||||
for _, conversationID := range conversationIDs {
|
||||
_, err := rt.CallMCPData("chat", "get_conversation_info", map[string]any{
|
||||
"openConversationId": conversationID,
|
||||
})
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
return helpers.NormalizeSearchConversationScopeError(conversationID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchScopeUnverifiedError(conversationIDs, messageIDs []string) error {
|
||||
return apperrors.NewAPI(
|
||||
"搜索结果缺少 conversationId,无法证明会话过滤范围;已停止输出",
|
||||
apperrors.WithReason("search_conversation_scope_unverified"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"requestedConversationIds": conversationIDs,
|
||||
"unverifiableMessageIds": messageIDs,
|
||||
}),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithHint("请保留 trace_id 并检查 IM 搜索服务是否返回 openConversationId"),
|
||||
)
|
||||
}
|
||||
|
||||
func searchScopeViolationError(conversationIDs []string, messages []map[string]any) error {
|
||||
observed := make([]string, 0, len(messages))
|
||||
for _, message := range messages {
|
||||
conversationID := strings.TrimSpace(fmt.Sprint(chatmsg.ConversationID(message)))
|
||||
if conversationID == "" || conversationID == "<nil>" {
|
||||
continue
|
||||
}
|
||||
observed = append(observed, conversationID)
|
||||
}
|
||||
return apperrors.NewAPI(
|
||||
"消息富化结果超出请求的会话范围;已停止输出",
|
||||
apperrors.WithReason("search_conversation_scope_violation"),
|
||||
apperrors.WithDetails(map[string]any{
|
||||
"requestedConversationIds": conversationIDs,
|
||||
"observedConversationIds": uniqueSearchStrings(observed),
|
||||
}),
|
||||
apperrors.WithRetryable(false),
|
||||
)
|
||||
}
|
||||
|
||||
func searchScopePayload(conversationIDs []string, sourceComplete bool) map[string]any {
|
||||
return map[string]any{
|
||||
"requestedConversationIds": append([]string(nil), conversationIDs...),
|
||||
"targetsValidated": true,
|
||||
"filterApplied": true,
|
||||
"filterMode": "client",
|
||||
"resultsWithinScope": true,
|
||||
"sourceComplete": sourceComplete,
|
||||
}
|
||||
}
|
||||
|
||||
func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) {
|
||||
detailsByID := map[string]map[string]any{}
|
||||
failures := make([]map[string]any, 0)
|
||||
@@ -505,31 +599,7 @@ func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any
|
||||
// response, probing common container keys at the top level and nested under
|
||||
// "result". Returns nil when no list is found.
|
||||
func searchMsgItems(data map[string]any) []map[string]any {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
for _, root := range []map[string]any{data, searchMsgChildMap(data, "result")} {
|
||||
if root == nil {
|
||||
continue
|
||||
}
|
||||
if groups, ok := root["conversationMessagesList"].([]any); ok {
|
||||
return searchMsgFlattenGroups(groups)
|
||||
}
|
||||
}
|
||||
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
|
||||
for _, key := range keys {
|
||||
if arr, ok := data[key].([]any); ok {
|
||||
return searchMsgToMaps(arr)
|
||||
}
|
||||
if inner, ok := data[key].(map[string]any); ok {
|
||||
for _, k2 := range []string{"list", "messages", "messageList", "items", "data", "records"} {
|
||||
if arr, ok := inner[k2].([]any); ok {
|
||||
return searchMsgToMaps(arr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return chatmsg.SearchItems(data)
|
||||
}
|
||||
|
||||
func searchMsgChildMap(data map[string]any, key string) map[string]any {
|
||||
@@ -539,56 +609,6 @@ func searchMsgChildMap(data map[string]any, key string) map[string]any {
|
||||
return nil
|
||||
}
|
||||
|
||||
func searchMsgFlattenGroups(groups []any) []map[string]any {
|
||||
out := make([]map[string]any, 0)
|
||||
for _, rawGroup := range groups {
|
||||
group, ok := rawGroup.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
messages, ok := group["messages"].([]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
|
||||
conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"]))
|
||||
singleChat, hasSingleChat := group["singleChat"]
|
||||
for _, rawMessage := range messages {
|
||||
message, ok := rawMessage.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item := make(map[string]any, len(message)+3)
|
||||
for key, value := range message {
|
||||
item[key] = value
|
||||
}
|
||||
if _, exists := item["openConversationId"]; !exists &&
|
||||
conversationID != "" && conversationID != "<nil>" {
|
||||
item["openConversationId"] = conversationID
|
||||
}
|
||||
if _, exists := item["conversationTitle"]; !exists &&
|
||||
conversationTitle != "" && conversationTitle != "<nil>" {
|
||||
item["conversationTitle"] = conversationTitle
|
||||
}
|
||||
if _, exists := item["singleChat"]; !exists && hasSingleChat {
|
||||
item["singleChat"] = singleChat
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func searchMsgToMaps(arr []any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// searchMsgProject reshapes one matched message into {sender, time, text,
|
||||
// messageId}, running text through the shared chatmsg cleaning (card/auto-reply
|
||||
// JSON → readable, ciphertext → marker) and recursively expanding any forwarded
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
@@ -23,34 +24,57 @@ type searchMsgExecutionCaller struct {
|
||||
failEnrichment bool
|
||||
omitPagination bool
|
||||
omitMgetItem bool
|
||||
failPreflight bool
|
||||
preflightError error
|
||||
searchResponse string
|
||||
wrongMgetScope bool
|
||||
missingMgetCID bool
|
||||
firstResponse string
|
||||
mgetResponse string
|
||||
}
|
||||
|
||||
func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
f.calls = append(f.calls, platformCoverageCall{product: product, tool: tool, args: args})
|
||||
if product == "chat" && tool == "get_conversation_info" {
|
||||
if f.preflightError != nil {
|
||||
return nil, f.preflightError
|
||||
}
|
||||
if f.failPreflight {
|
||||
return nil, errors.New("conversation not found")
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"openConversationId":"` + args["openConversationId"].(string) + `"}}`), nil
|
||||
}
|
||||
if product != "im" {
|
||||
return nil, errors.New("unexpected product")
|
||||
}
|
||||
switch tool {
|
||||
case "search_messages":
|
||||
if f.searchResponse != "" {
|
||||
return searchMsgToolResult(f.searchResponse), nil
|
||||
}
|
||||
if f.omitPagination {
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}]}}`), nil
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}]}}`), nil
|
||||
}
|
||||
if args["cursor"] == "c2" {
|
||||
if f.failSecondPage {
|
||||
return nil, errors.New("second page unavailable")
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","content":"sparse-2"}],"hasMore":false}}`), nil
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","openConversationId":"cid-2","content":"sparse-2"}],"hasMore":false}}`), nil
|
||||
}
|
||||
if f.firstResponse != "" {
|
||||
return searchMsgToolResult(f.firstResponse), nil
|
||||
}
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
|
||||
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
|
||||
case "list_messages_by_ids":
|
||||
if f.failEnrichment {
|
||||
return nil, errors.New("mget unavailable")
|
||||
}
|
||||
if f.wrongMgetScope {
|
||||
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","openConversationId":"cid-other","content":"detail-1"}]}`), nil
|
||||
}
|
||||
if f.missingMgetCID {
|
||||
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","openConversationId":null,"content":"detail-1"}]}`), nil
|
||||
}
|
||||
if f.omitMgetItem {
|
||||
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"}]}`), nil
|
||||
}
|
||||
@@ -78,19 +102,27 @@ func searchMsgToolResult(text string) *edition.ToolResult {
|
||||
|
||||
func executeSearchMsg(t *testing.T, caller *searchMsgExecutionCaller, args ...string) map[string]any {
|
||||
t.Helper()
|
||||
payload, err := executeSearchMsgResult(caller, args...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func executeSearchMsgResult(caller *searchMsgExecutionCaller, args ...string) (map[string]any, error) {
|
||||
helpers.InitDeps(caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
var output bytes.Buffer
|
||||
root.SetOut(&output)
|
||||
root.SetArgs(append([]string{"chat", "+search-msg", "--yes"}, args...))
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
return nil, err
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode output: %v\n%s", err, output.String())
|
||||
return nil, err
|
||||
}
|
||||
return payload
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
|
||||
@@ -112,15 +144,18 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te
|
||||
"--page-limit", "3",
|
||||
)
|
||||
|
||||
if len(caller.calls) != 3 {
|
||||
if len(caller.calls) != 5 {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
first := caller.calls[0]
|
||||
if caller.calls[0].tool != "get_conversation_info" || caller.calls[0].args["openConversationId"] != "cid-1" ||
|
||||
caller.calls[1].tool != "get_conversation_info" || caller.calls[1].args["openConversationId"] != "cid-2" {
|
||||
t.Fatalf("scope preflight calls = %#v", caller.calls[:2])
|
||||
}
|
||||
first := caller.calls[2]
|
||||
if first.product != "im" || first.tool != "search_messages" {
|
||||
t.Fatalf("first call = %#v", first)
|
||||
}
|
||||
for key, want := range map[string]any{
|
||||
"openConversationIds": []string{"cid-1", "cid-2"},
|
||||
"senderUserIds": []string{"42"},
|
||||
"senderOpenDingTakIds": []string{"Dsender"},
|
||||
"atUserIds": []string{"43"},
|
||||
@@ -133,10 +168,13 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te
|
||||
t.Errorf("%s = %#v, want %#v", key, first.args[key], want)
|
||||
}
|
||||
}
|
||||
if caller.calls[1].args["cursor"] != "c2" {
|
||||
t.Fatalf("second cursor = %#v", caller.calls[1].args["cursor"])
|
||||
if _, exists := first.args["openConversationIds"]; exists {
|
||||
t.Fatalf("global fallback unexpectedly forwarded openConversationIds: %#v", first.args)
|
||||
}
|
||||
if ids := caller.calls[2].args["openMsgIds"]; !reflect.DeepEqual(ids, []string{"m1", "m2"}) {
|
||||
if caller.calls[3].args["cursor"] != "c2" {
|
||||
t.Fatalf("second cursor = %#v", caller.calls[3].args["cursor"])
|
||||
}
|
||||
if ids := caller.calls[4].args["openMsgIds"]; !reflect.DeepEqual(ids, []string{"m1", "m2"}) {
|
||||
t.Fatalf("mget ids = %#v", ids)
|
||||
}
|
||||
if payload["complete"] != true || payload["count"] != float64(2) ||
|
||||
@@ -149,6 +187,10 @@ func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *te
|
||||
if firstMessage["text"] != "detail-1" {
|
||||
t.Fatalf("enriched message = %#v", firstMessage)
|
||||
}
|
||||
scope, _ := payload["scope"].(map[string]any)
|
||||
if scope["targetsValidated"] != true || scope["filterMode"] != "client" || scope["resultsWithinScope"] != true {
|
||||
t.Fatalf("scope = %#v", scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
|
||||
@@ -209,6 +251,175 @@ func TestCrossPlatformCoverageSearchMsgMissingMgetItemPublishesFailureLedger(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgScopedFallbackFiltersGlobalResults(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{
|
||||
"result": {
|
||||
"conversationMessagesList": [
|
||||
{"openConversationId":"cid-target","title":"目标群","messages":[{"openMessageId":"m-target","content":"目标"}]},
|
||||
{"openConversationId":"cid-other","title":"其他群","messages":[{"openMessageId":"m-other","content":"越界"}]}
|
||||
],
|
||||
"hasMore": false
|
||||
}
|
||||
}`}
|
||||
payload := executeSearchMsg(t, caller, "--group", "cid-target", "--query", "目标", "--no-enrich")
|
||||
if payload["count"] != float64(1) || payload["complete"] != true {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
messages, _ := payload["messages"].([]any)
|
||||
message, _ := messages[0].(map[string]any)
|
||||
if message["conversationId"] != "cid-target" || message["messageId"] != "m-target" {
|
||||
t.Fatalf("messages = %#v", messages)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "get_conversation_info" || caller.calls[1].tool != "search_messages" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
if _, exists := caller.calls[1].args["openConversationIds"]; exists {
|
||||
t.Fatalf("global fallback unexpectedly forwarded scope: %#v", caller.calls[1].args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgScopedValidEmptyResultIsComplete(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[],"hasMore":false}}`}
|
||||
payload := executeSearchMsg(t, caller, "--group", "cid-empty", "--query", "不存在", "--no-enrich")
|
||||
if payload["count"] != float64(0) || payload["complete"] != true {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
scope, _ := payload["scope"].(map[string]any)
|
||||
if scope["targetsValidated"] != true || scope["sourceComplete"] != true {
|
||||
t.Fatalf("scope = %#v", scope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgScopedEmptyPartialScanCannotClaimComplete(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{
|
||||
"result": {
|
||||
"conversationMessagesList": [
|
||||
{"openConversationId":"cid-other","messages":[{"openMessageId":"m-other"}]}
|
||||
],
|
||||
"hasMore": true,
|
||||
"nextCursor": "c2"
|
||||
}
|
||||
}`}
|
||||
payload := executeSearchMsg(t, caller,
|
||||
"--group", "cid-target", "--query", "周报", "--no-enrich", "--page-limit", "1")
|
||||
if payload["count"] != float64(0) || payload["complete"] != false || payload["failedCount"] != float64(1) {
|
||||
t.Fatalf("payload = %#v", payload)
|
||||
}
|
||||
scope, _ := payload["scope"].(map[string]any)
|
||||
if scope["sourceComplete"] != false {
|
||||
t.Fatalf("scope = %#v", scope)
|
||||
}
|
||||
failures, _ := payload["failures"].([]any)
|
||||
failure, _ := failures[0].(map[string]any)
|
||||
if failure["stage"] != "search-page-limit" {
|
||||
t.Fatalf("failure = %#v", failure)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgInvalidCIDStopsBeforeGlobalSearch(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{failPreflight: true}
|
||||
_, err := executeSearchMsgResult(caller, "--group", "cid-invalid", "--query", "周报", "--no-enrich")
|
||||
if err == nil {
|
||||
t.Fatal("invalid CID unexpectedly succeeded")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_invalid" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_conversation_info" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgPreservesAmbiguousPreflightMCPToolErrors(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
want *helpers.CLIError
|
||||
}{
|
||||
{
|
||||
name: "rate limited",
|
||||
want: &helpers.CLIError{
|
||||
Code: helpers.CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"invalidRequest.rateLimited","errorMsg":"slow down"}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "permission denied",
|
||||
want: &helpers.CLIError{
|
||||
Code: helpers.CodeMCPToolError,
|
||||
Message: `{"success":false,"errorCode":"forbidden.noPermission","errorMsg":"permission denied"}`,
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{preflightError: test.want}
|
||||
_, err := executeSearchMsgResult(caller,
|
||||
"--group", "cid-target", "--query", "周报", "--no-enrich")
|
||||
if err != test.want {
|
||||
t.Fatalf("error = %#v, want original %#v", err, test.want)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_conversation_info" {
|
||||
t.Fatalf("calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgMissingConversationIdentityFailsClosed(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{searchResponse: `{"result":{"messages":[{"openMessageId":"m1","content":"unknown"}],"hasMore":false}}`}
|
||||
_, err := executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报", "--no-enrich")
|
||||
if err == nil {
|
||||
t.Fatal("unverifiable scoped result unexpectedly succeeded")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgScopeFailureBranches(t *testing.T) {
|
||||
want := errors.New("preflight unavailable")
|
||||
caller := &searchMsgExecutionCaller{preflightError: want}
|
||||
_, err := executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报", "--no-enrich")
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("preflight error = %v, want %v", err, want)
|
||||
}
|
||||
|
||||
caller = &searchMsgExecutionCaller{
|
||||
searchResponse: `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target","content":"sparse"}],"hasMore":false}}`,
|
||||
missingMgetCID: true,
|
||||
}
|
||||
_, err = executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报")
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_unverified" {
|
||||
t.Fatalf("enrichment scope error = %#v", err)
|
||||
}
|
||||
|
||||
err = searchScopeViolationError(
|
||||
[]string{"cid-target"},
|
||||
[]map[string]any{{"openMessageId": "m-empty"}, {"openMessageId": "m-other", "openConversationId": "cid-other"}},
|
||||
)
|
||||
typed = nil
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_violation" {
|
||||
t.Fatalf("scope violation error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchMsgEnrichmentCannotMoveMessageOutsideScope(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{
|
||||
searchResponse: `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-target","content":"sparse"}],"hasMore":false}}`,
|
||||
wrongMgetScope: true,
|
||||
}
|
||||
_, err := executeSearchMsgResult(caller, "--group", "cid-target", "--query", "周报")
|
||||
if err == nil {
|
||||
t.Fatal("out-of-scope enrichment unexpectedly succeeded")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "search_conversation_scope_violation" {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMsgLarkTimeAliasesAndAscendingOrder(t *testing.T) {
|
||||
caller := &searchMsgExecutionCaller{
|
||||
firstResponse: `{"result":{"messages":[{"openMessageId":"m2","createTime":1782892800000,"content":"later"},{"openMessageId":"m1","createTime":1782806400000,"content":"earlier"}],"hasMore":false}}`,
|
||||
|
||||
@@ -1047,6 +1047,7 @@ func authActions(snapshotPath string) []string {
|
||||
func runtimeActions(snapshotPath string) []string {
|
||||
actions := []string{
|
||||
i18n.T("检查认证、权限和参数后重试原命令"),
|
||||
i18n.T("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
|
||||
}
|
||||
_ = snapshotPath
|
||||
return actions
|
||||
@@ -1056,6 +1057,7 @@ func networkActions(snapshotPath string) []string {
|
||||
actions := []string{
|
||||
i18n.T("检查网络、代理和 DNS 配置后重试原命令"),
|
||||
i18n.T("确认 MCP 服务可访问;若持续失败请稍后重试"),
|
||||
i18n.T("运行 dws doctor 获取可共享的环境诊断结果"),
|
||||
}
|
||||
_ = snapshotPath
|
||||
return actions
|
||||
|
||||
@@ -397,13 +397,13 @@ func TestCrossPlatformCoverageHTTPStatusAndDiagnosticsEdges(t *testing.T) {
|
||||
t.Fatalf("HTTP %d returned nil", tc.code)
|
||||
}
|
||||
}
|
||||
if got := networkActions("snap"); len(got) != 2 {
|
||||
if got := networkActions("snap"); len(got) != 3 {
|
||||
t.Fatalf("network actions = %#v", got)
|
||||
}
|
||||
if got := authActions("snap"); len(got) != 2 {
|
||||
t.Fatalf("auth actions = %#v", got)
|
||||
}
|
||||
if got := runtimeActions("snap"); len(got) != 1 {
|
||||
if got := runtimeActions("snap"); len(got) != 2 {
|
||||
t.Fatalf("runtime actions = %#v", got)
|
||||
}
|
||||
if got := actionsForMethod("tools/call", "snap"); len(got) == 0 {
|
||||
|
||||
@@ -42,18 +42,48 @@ func ExtractServerDiagnosticsFromMap(content map[string]any) apperrors.ServerDia
|
||||
return apperrors.ServerDiagnostics{}
|
||||
}
|
||||
diag := apperrors.ServerDiagnostics{
|
||||
TraceID: stringFromMap(content, "trace_id", "traceId"),
|
||||
TraceID: stringFromMapRecursive(content, 0, "trace_id", "traceId", "request_id", "requestId"),
|
||||
ServerErrorCode: serverErrorCodeFromMap(content, 0),
|
||||
TechnicalDetail: stringFromMap(content, "technical_detail"),
|
||||
TechnicalDetail: stringFromMapRecursive(content, 0, "technical_detail", "technicalDetail"),
|
||||
FriendlyHint: stringFromMapRecursive(content, 0, "friendly_hint", "friendlyHint"),
|
||||
ActionURL: stringFromMapRecursive(content, 0, "action_url", "actionUrl"),
|
||||
}
|
||||
if v, ok := content["retryable"].(bool); ok {
|
||||
if v, ok := boolFromMapRecursive(content, 0, "retryable", "server_retryable", "serverRetryable"); ok {
|
||||
diag.ServerRetryable = &v
|
||||
}
|
||||
return diag
|
||||
}
|
||||
|
||||
func boolFromMapRecursive(content map[string]any, depth int, keys ...string) (bool, bool) {
|
||||
if content == nil || depth > 8 {
|
||||
return false, false
|
||||
}
|
||||
for _, key := range keys {
|
||||
if value, ok := content[key].(bool); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"content", "result", "data"} {
|
||||
switch child := content[key].(type) {
|
||||
case map[string]any:
|
||||
if value, ok := boolFromMapRecursive(child, depth+1, keys...); ok {
|
||||
return value, true
|
||||
}
|
||||
case []any:
|
||||
for _, item := range child {
|
||||
childMap, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if value, ok := boolFromMapRecursive(childMap, depth+1, keys...); ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
func stringFromMapRecursive(content map[string]any, depth int, keys ...string) string {
|
||||
if content == nil || depth > 8 {
|
||||
return ""
|
||||
|
||||
@@ -110,6 +110,44 @@ func TestExtractServerDiagnosticsFromMapNestedBusinessCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractServerDiagnosticsFromMapNestedDiagnosticEnvelope(t *testing.T) {
|
||||
t.Parallel()
|
||||
content := map[string]any{
|
||||
"result": map[string]any{
|
||||
"data": map[string]any{
|
||||
"requestId": "request-nested",
|
||||
"technicalDetail": "nested detail",
|
||||
"serverRetryable": false,
|
||||
},
|
||||
},
|
||||
}
|
||||
diag := ExtractServerDiagnosticsFromMap(content)
|
||||
if diag.TraceID != "request-nested" || diag.TechnicalDetail != "nested detail" ||
|
||||
diag.ServerRetryable == nil || *diag.ServerRetryable {
|
||||
t.Fatalf("nested diagnostics = %#v", diag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBoolDiagnosticsRecursiveEdges(t *testing.T) {
|
||||
t.Parallel()
|
||||
if value, ok := boolFromMapRecursive(nil, 0, "retryable"); ok || value {
|
||||
t.Fatalf("nil recursive bool = (%v, %v)", value, ok)
|
||||
}
|
||||
if value, ok := boolFromMapRecursive(map[string]any{"retryable": true}, 9, "retryable"); ok || value {
|
||||
t.Fatalf("over-depth recursive bool = (%v, %v)", value, ok)
|
||||
}
|
||||
content := map[string]any{
|
||||
"result": []any{
|
||||
"ignore",
|
||||
map[string]any{"data": map[string]any{"serverRetryable": true}},
|
||||
},
|
||||
}
|
||||
value, ok := boolFromMapRecursive(content, 0, "serverRetryable")
|
||||
if !ok || !value {
|
||||
t.Fatalf("nested array recursive bool = (%v, %v)", value, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractServerDiagnosticsFromMap_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
diag := ExtractServerDiagnosticsFromMap(nil)
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestStdioClientCallBeforeStart(t *testing.T) {
|
||||
func TestStdioClientEnsureInitializedIsIdempotent(t *testing.T) {
|
||||
client := NewStdioClient(buildTestHelper(t), nil, nil)
|
||||
defer client.Stop()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := client.EnsureInitialized(ctx); err != nil {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
#
|
||||
# One-command installer for dws personal events.
|
||||
# Downloads the official dws binary and installs:
|
||||
# - multi skill: dingtalk-misc (hosts personal IM event docs)
|
||||
# - multi skill: dingtalk-event (personal IM/OA event routing)
|
||||
# - multi prerequisites: dingtalk-shared + clean dingtalk-misc
|
||||
# - mono skill: dws
|
||||
#
|
||||
# Usage:
|
||||
@@ -25,7 +26,9 @@ INSTALL_DIR="${DWS_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
NO_SKILLS="${DWS_NO_SKILLS:-0}"
|
||||
SKILLS_ONLY="${DWS_SKILLS_ONLY:-0}"
|
||||
BIN_NAME="dws"
|
||||
EVENT_SKILL_NAME="dingtalk-misc"
|
||||
EVENT_SKILL_NAME="dingtalk-event"
|
||||
SHARED_SKILL_NAME="dingtalk-shared"
|
||||
MISC_SKILL_NAME="dingtalk-misc"
|
||||
MONO_SKILL_NAME="dws"
|
||||
|
||||
if [ "$EVENT_VERSION" = "latest" ]; then
|
||||
@@ -123,12 +126,13 @@ install_skill_to_homes() {
|
||||
printf '%s\n' "$installed"
|
||||
}
|
||||
|
||||
find_event_skill_src() {
|
||||
find_multi_skill_src() {
|
||||
bundle="$1"
|
||||
skill_name="$2"
|
||||
for c in \
|
||||
"$bundle/multi/$EVENT_SKILL_NAME" \
|
||||
"$bundle/skills/multi/$EVENT_SKILL_NAME" \
|
||||
"$bundle/$EVENT_SKILL_NAME"
|
||||
"$bundle/multi/$skill_name" \
|
||||
"$bundle/skills/multi/$skill_name" \
|
||||
"$bundle/$skill_name"
|
||||
do
|
||||
if [ -f "$c/SKILL.md" ]; then
|
||||
printf '%s\n' "$c"
|
||||
@@ -157,23 +161,40 @@ find_mono_skill_src() {
|
||||
install_skills_from_bundle() {
|
||||
bundle="$1"
|
||||
|
||||
event_src="$(find_event_skill_src "$bundle" || true)"
|
||||
# Resolve the complete, version-matched set before touching any installed or
|
||||
# cached skill. A malformed release therefore cannot leave a half-migrated
|
||||
# event/misc pair behind.
|
||||
event_src="$(find_multi_skill_src "$bundle" "$EVENT_SKILL_NAME" || true)"
|
||||
[ -n "$event_src" ] || err "${EVENT_SKILL_NAME} not found in dws-skills.zip"
|
||||
shared_src="$(find_multi_skill_src "$bundle" "$SHARED_SKILL_NAME" || true)"
|
||||
[ -n "$shared_src" ] || err "${SHARED_SKILL_NAME} not found in dws-skills.zip"
|
||||
misc_src="$(find_multi_skill_src "$bundle" "$MISC_SKILL_NAME" || true)"
|
||||
[ -n "$misc_src" ] || err "${MISC_SKILL_NAME} not found in dws-skills.zip"
|
||||
mono_src="$(find_mono_skill_src "$bundle" || true)"
|
||||
[ -n "$mono_src" ] || err "mono ${MONO_SKILL_NAME} skill not found in dws-skills.zip"
|
||||
|
||||
event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME"
|
||||
shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME"
|
||||
misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME"
|
||||
copy_tree "$event_src" "$event_cache"
|
||||
copy_tree "$shared_src" "$shared_cache"
|
||||
copy_tree "$misc_src" "$misc_cache"
|
||||
|
||||
mono_cache="$HOME/.dws/skills/mono"
|
||||
copy_tree "$mono_src" "$mono_cache"
|
||||
|
||||
event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")"
|
||||
shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")"
|
||||
misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")"
|
||||
mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")"
|
||||
|
||||
say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)"
|
||||
say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)"
|
||||
say "Skill ${MISC_SKILL_NAME} -> ${misc_installed} agent dir(s)"
|
||||
say "Skill ${MONO_SKILL_NAME} -> ${mono_installed} agent dir(s)"
|
||||
say "Cached ${EVENT_SKILL_NAME} -> ${event_cache}"
|
||||
say "Cached ${SHARED_SKILL_NAME} -> ${shared_cache}"
|
||||
say "Cached ${MISC_SKILL_NAME} -> ${misc_cache}"
|
||||
say "Cached mono ${MONO_SKILL_NAME} -> ${mono_cache}"
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ python3 scripts/gen_skill_shortcut_sections.py --check
|
||||
|
||||
chat_skill="skills/multi/dingtalk-chat/SKILL.md"
|
||||
doc_skill="skills/multi/dingtalk-doc/SKILL.md"
|
||||
event_skill="skills/multi/dingtalk-misc/references/event.md"
|
||||
event_skill="skills/multi/dingtalk-event/SKILL.md"
|
||||
mono_skill="skills/mono/SKILL.md"
|
||||
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
|
||||
chat_target_bytes=10000
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
// Command multi-im-skill-chain verifies that reviewed high-frequency IM
|
||||
// intents keep one default route across Skill references and Agent selection.
|
||||
// Command multi-im-skill-chain verifies that reviewed high-frequency IM and
|
||||
// personal-event intents keep one default route across Skill references and
|
||||
// Agent selection.
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -96,13 +96,13 @@ func TestCrossPlatformCoverageValidateManifestAcceptsExactReviewedRoute(t *testi
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat +send` | <!-- dws-intent: chat.send -->\n")
|
||||
writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md")
|
||||
writeEventHandoffReference(t, root, "skills/multi/dingtalk-misc/references/event-im-output.md")
|
||||
writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md")
|
||||
manifest := routeManifest{
|
||||
Version: 3,
|
||||
MarkerRoots: []string{"skills/multi/dingtalk-chat"},
|
||||
RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"},
|
||||
ContractReference: "skills/multi/dingtalk-chat/references/contracts.md",
|
||||
HandoffReference: "skills/multi/dingtalk-misc/references/event-im-output.md",
|
||||
HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md",
|
||||
Intents: []intentRoute{{
|
||||
ID: "chat.send",
|
||||
PreferredTool: "chat.shortcut_send",
|
||||
@@ -123,13 +123,13 @@ func TestCrossPlatformCoverageValidateManifestRejectsWrongMarkerAndSafetyDowngra
|
||||
root := t.TempDir()
|
||||
writeTestFile(t, root, "skills/multi/dingtalk-chat/SKILL.md", "| 发消息 | `dws chat message send` | <!-- dws-intent: chat.send -->\n")
|
||||
writeTypedContractReference(t, root, "skills/multi/dingtalk-chat/references/contracts.md")
|
||||
writeEventHandoffReference(t, root, "skills/multi/dingtalk-misc/references/event-im-output.md")
|
||||
writeEventHandoffReference(t, root, "skills/multi/dingtalk-event/references/event-im-output.md")
|
||||
manifest := routeManifest{
|
||||
Version: 3,
|
||||
MarkerRoots: []string{"skills/multi/dingtalk-chat"},
|
||||
RetiredScripts: []string{"skills/multi/dingtalk-chat/scripts/retired.py"},
|
||||
ContractReference: "skills/multi/dingtalk-chat/references/contracts.md",
|
||||
HandoffReference: "skills/multi/dingtalk-misc/references/event-im-output.md",
|
||||
HandoffReference: "skills/multi/dingtalk-event/references/event-im-output.md",
|
||||
Intents: []intentRoute{{
|
||||
ID: "chat.send",
|
||||
PreferredTool: "chat.shortcut_send",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 3,
|
||||
"marker_roots": [
|
||||
"skills/multi/dingtalk-chat",
|
||||
"skills/multi/dingtalk-misc/references"
|
||||
"skills/multi/dingtalk-event"
|
||||
],
|
||||
"retired_scripts": [
|
||||
"skills/multi/dingtalk-chat/scripts/_chat_message_export_common.py",
|
||||
@@ -15,7 +15,7 @@
|
||||
"skills/mono/scripts/bot_broadcast.py"
|
||||
],
|
||||
"contract_reference": "skills/multi/dingtalk-chat/references/contracts.md",
|
||||
"handoff_reference": "skills/multi/dingtalk-misc/references/event-im-output.md",
|
||||
"handoff_reference": "skills/multi/dingtalk-event/references/event-im-output.md",
|
||||
"intents": [
|
||||
{
|
||||
"id": "chat.send.dm",
|
||||
@@ -148,8 +148,19 @@
|
||||
"event.consume"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-misc/references/event.md",
|
||||
"skills/multi/dingtalk-misc/references/event-im.md"
|
||||
"skills/multi/dingtalk-event/SKILL.md",
|
||||
"skills/multi/dingtalk-event/references/event-im.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "event.listen.oa",
|
||||
"preferred_tool": "event.consume",
|
||||
"forbidden_default_tools": [
|
||||
"event.listen_im"
|
||||
],
|
||||
"references": [
|
||||
"skills/multi/dingtalk-event/SKILL.md",
|
||||
"skills/multi/dingtalk-event/references/event-oa.md"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -40,9 +40,10 @@ coverage:
|
||||
- mono: drive
|
||||
multi_skill: dingtalk-drive
|
||||
- mono: event
|
||||
multi_skill: dingtalk-misc
|
||||
multi_skill: dingtalk-event
|
||||
multi_refs:
|
||||
- references/event.md
|
||||
- references/event-im.md
|
||||
- references/event-oa.md
|
||||
- mono: hrbrain
|
||||
multi_skill: dingtalk-misc
|
||||
multi_refs:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dws
|
||||
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件、实时监听群成员加入、群成员退出、群改名和群解散时使用。
|
||||
description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/终止/完成,以及审批任务创建/完成/转交时使用。
|
||||
cli_version: ">=1.0.15"
|
||||
---
|
||||
|
||||
@@ -22,7 +22,7 @@ cli_version: ">=1.0.15"
|
||||
- 单次批量操作不超过 30 条记录
|
||||
- 所有命令必须**严格遵循**对应产品参考文档里面规定的参数格式(如:如果有参数值,则参数和参数值之间至少用一个空格隔开)
|
||||
- **脚本只用于明确覆盖的复合任务**:[scripts/](./scripts/) 下的脚本可封装 AI 表格批量导入导出、AI 应用创建轮询、文档创建后写内容、钉盘目录树等流程;当公开 `+` Shortcut 已提供目标唯一解析、分页/部分失败 ledger 和确认语义时,优先 Shortcut。Chat 历史导出与机器人广播已完全下沉 Runtime,不再发布兼容脚本
|
||||
- **实时个人消息事件例外**:用户要监听消息、订阅事件、自动回复消息或事件驱动 Agent 时,默认走 `dws event +listen-im ...`;只有明确需要多个原始 EventKey、Filter DSL、subscribe_id 或原始 envelope 时才用 `dws event consume ... --flatten`,不要写脚本轮询消息历史
|
||||
- **实时个人事件例外**:普通 IM 消息、reaction、已读和撤回默认走 `dws event +listen-im ...`;OA 审批、群生命周期、明确的原始 EventKey、Filter DSL、subscribe_id 或原始 envelope 使用 `dws event consume ... --flatten`。不要写脚本轮询消息历史或审批列表
|
||||
|
||||
## Shortcut 与原子命令的使用原则
|
||||
|
||||
@@ -94,7 +94,7 @@ cli_version: ">=1.0.15"
|
||||
| `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) |
|
||||
| `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) |
|
||||
| `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) |
|
||||
| `event` | 个人 IM 事件:监听消息接收、指定发送人、已读、撤回、表情回应,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
|
||||
| `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) |
|
||||
|
||||
## 意图判断决策树
|
||||
|
||||
@@ -122,6 +122,7 @@ cli_version: ">=1.0.15"
|
||||
用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki`
|
||||
用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert`
|
||||
用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume`
|
||||
用户提到"监听待我审批的任务/监听审批任务创建、完成或转交/监听审批单发起或终止/监听我发起的审批完成/监听审批实例完成/订阅 OA 事件/event consume user_oa_approval_*" → `event consume`
|
||||
|
||||
普通消息、reaction、已读、撤回监听优先由一个 `dws event +listen-im` 进程表达目标;不同用户、不同群或不同过滤条件拆成独立进程。只有高级事件控制才生成 `dws event consume <event_key> [event_key...] --flatten`。
|
||||
|
||||
@@ -131,6 +132,7 @@ cli_version: ">=1.0.15"
|
||||
关键区分: doc(在线富文本文档/adoc) vs markdown(原生 .md 纯文本文件) vs drive(通用文件存储与传输)
|
||||
关键区分: contact(基础通讯录档案:学历/家庭/银行卡/紧急联系人/合同/部门角色) vs aisearch person(通用语义找人:谁负责/上级/下级/多维度模糊搜索) vs hrbrain(人才池/员工档案专项模块数据/职业历程/绩效/结构化高级人才搜索)
|
||||
关键区分: oa tasks(审批 taskId,审批/拒绝用) vs oa list-pending(收件箱 processInstanceId,查看用)
|
||||
关键区分: oa(查询或操作审批) vs event user_oa_approval_*(当前用户审批事件长连接监听)
|
||||
|
||||
|
||||
> 更多易混淆场景见 [intent-guide.md](./references/intent-guide.md)
|
||||
|
||||
@@ -112,8 +112,10 @@
|
||||
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
|
||||
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
|
||||
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
|
||||
| `workflow run` | 立即执行工作流(需确认) | `--base-id` `--workflow-id`;记录触发另需 `--table-id` `--record-ids` | 返回 `executionId`;不确定时先用 history 核对,避免重复执行 |
|
||||
| `workflow history` | 查询工作流执行历史 | `--base-id` `--workflow-id` | 支持 status、Unix 毫秒时间范围和 page/size;`instanceId` 对应 run 的 `executionId` |
|
||||
|
||||
> 当前支持创建、更新、查询和启停;删除、运行历史及手动触发仍未开放。
|
||||
> 当前支持创建、更新、查询、启停、手动执行和执行历史;删除工作流仍未开放。
|
||||
|
||||
### dashboard & chart → 详见 [aitable-dashboard-chart.md](./aitable/aitable-dashboard-chart.md)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# workflow — 自动化工作流管理
|
||||
|
||||
创建 / 更新 / 启停 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。
|
||||
适用场景:用户要求创建自动化、修改流程、停掉流程、查询已有流程或恢复运行。
|
||||
创建 / 更新 / 启停 / 手动执行 / 查询执行历史 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。
|
||||
适用场景:用户要求创建自动化、修改流程、停掉或恢复流程、立即执行流程、核对执行结果或查询已有流程。
|
||||
|
||||
## 命令一览
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
| `workflow get` | 获取单个工作流详情(含 flowSchema 完整节点定义) |
|
||||
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
|
||||
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
|
||||
| `workflow run` | 立即执行指定工作流(会产生真实副作用,需确认) |
|
||||
| `workflow history` | 按状态、时间和分页条件查询工作流执行历史 |
|
||||
|
||||
> `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
|
||||
|
||||
@@ -104,6 +106,29 @@ dws aitable workflow update \
|
||||
|
||||
update 返回结构与 create 相同,并会对瞬态错误重试。成功时 `data.flowId` 应与传入的工作流 ID 一致;随后用 `workflow get/list` 验证发布结果和运行状态。
|
||||
|
||||
### workflow run — 立即执行工作流
|
||||
|
||||
```bash
|
||||
# 记录类触发器
|
||||
dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID \
|
||||
--table-id TABLE_ID --record-ids RECORD_ID_1,RECORD_ID_2
|
||||
|
||||
# 定时触发器不传 table-id / record-ids
|
||||
dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID
|
||||
```
|
||||
|
||||
记录类触发器必须同时传 `--table-id` 和 `--record-ids`;记录 ID 限 1–5 个且不可重复。`run` 会启动真实异步执行并产生工作流配置的副作用,必须先取得用户确认。返回的 `executionId` 可与 `workflow history` 项目的 `instanceId` 匹配;网络结果不确定时先查历史,不要直接重复执行。
|
||||
|
||||
### workflow history — 查询执行历史
|
||||
|
||||
```bash
|
||||
dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID \
|
||||
--status failed --after-time 1786000000000 --before-time 1787000000000 \
|
||||
--page 0 --size 50
|
||||
```
|
||||
|
||||
`--status` 接受 `success` / `failed` / `running` / `break` / `untrigger`;时间参数是 Unix 毫秒且 after 必须小于 before;`--page` 从 0 开始,`--size` 默认 20、最大 100。返回 `totalCount` 与 `list`,其中 `running` 为非终态。
|
||||
|
||||
### workflow list — 列出工作流
|
||||
|
||||
```bash
|
||||
@@ -211,9 +236,9 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes -
|
||||
| 列出工作流 | ✅ |
|
||||
| 看工作流详情(含 flowSchema) | ✅ |
|
||||
| 启用/禁用 | ✅ |
|
||||
| 查看运行历史/执行日志 | ✅ `workflow history` |
|
||||
| 手动触发/单次运行 | ✅ `workflow run`(需确认) |
|
||||
| 删除工作流 | ❌ 暂未开放 |
|
||||
| 查看运行历史/执行日志 | ❌ 暂未开放 |
|
||||
| 手动触发/单次运行 | ❌ 暂未开放 |
|
||||
|
||||
## 错误码速查
|
||||
|
||||
@@ -282,4 +307,5 @@ done
|
||||
- create 不自动重试;update 仅对网络、5xx、`retryable:true` 等瞬态错误自动重试。
|
||||
- enable / disable 出参里的 `enabled` / `disabled` 是 **动作确认 flag**,不是当前状态字段。要确认真生效请走 `workflow list` 查 `status`。
|
||||
- `workflow get` 的 `flowSchema` 结构随触发器/动作类型变化,不要假设固定字段。
|
||||
- 删除、运行历史和手动触发当前仍未开放。
|
||||
- `workflow run` 不自动重试;结果不确定时用 `workflow history` 按 executionId / instanceId 核对。
|
||||
- 删除工作流当前仍未开放。
|
||||
|
||||
@@ -836,6 +836,8 @@ Flags:
|
||||
#### 拉取指定时间范围内当前用户的所有会话消息 — 分页拉取当前登录用户在指定时间范围内的所有会话消息
|
||||
|
||||
--start 和 --end 限定时间范围,--limit 指定每页数量,--cursor 传分页游标(首页传 "0",后续从响应中的 nextCursor 获取)。服务端按 cursor 分页返回,hasMore=true 时用返回的 nextCursor 值作为下次 --cursor 继续翻页。若当前账号没有消息搜索权益,CLI 会透传服务端的友好提示与开通入口。
|
||||
|
||||
需要自动遍历全部分页时显式加 `--page-all`。`--page-limit` 控制最多请求页数(默认 50,范围 1-500),`--max-items` 控制最多输出条数(默认 0 不限制,精确截断并输出 `paging.truncated=true`),`--page-delay` 控制页间等待毫秒数(默认 200,0 表示不等待)。只传这些分页控制参数但不传 `--page-all` 时仍保持原单页调用。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-all [flags]
|
||||
@@ -847,11 +849,16 @@ Flags:
|
||||
--end string 结束时间,格式: yyyy-MM-dd HH:mm:ss (必填)
|
||||
--limit int 每页返回数量(默认 50)
|
||||
--cursor string 分页游标(首页传 "0",后续从响应中的 nextCursor 获取)
|
||||
--page-all 自动按 nextCursor 拉取所有分页
|
||||
--page-limit int 自动翻页最多请求页数(默认 50,范围 1-500)
|
||||
--max-items int 自动翻页最多返回条数(默认 0 表示不限制)
|
||||
--page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待)
|
||||
|
||||
注意:
|
||||
- 四个参数每次请求都会传递给服务端,cursor 首页传 "0"
|
||||
- 与 chat message list 的区别:list 拉取指定单个会话(群聊或单聊)的消息,list-all 拉取当前用户所有会话的消息
|
||||
- 翻页:hasMore=true 时,用响应中的 nextCursor 值作为下次 --cursor 参数继续翻页
|
||||
- 自动翻页:`--page-all` 会保留并合并 `result.conversationMessagesList`,同一会话跨页合并 messages,并在顶层输出 `paging` 元数据;shortcut 命令不属于本 typed fallback 小节
|
||||
- 时间格式统一为 yyyy-MM-dd HH:mm:ss
|
||||
- 权限/权益错误不是空结果;应把返回的 friendly_hint 与 action_url 展示给用户,不要继续盲目翻页
|
||||
```
|
||||
@@ -861,6 +868,8 @@ Flags:
|
||||
> 推荐优先使用 `chat message search-advanced --user/--users`(userId)或 `--sender-ids`(openDingTalkId),它还能叠加关键词/群/at 等过滤条件。本命令保留给需要旧 list-by-sender 返回结构的场景。
|
||||
|
||||
搜索特定人发送给我的消息,返回结果包含单聊和群聊标识。--sender-user-id 指定发送者 userId,--sender-open-dingtalk-id 指定发送者 openDingTalkId,二者互斥。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
|
||||
|
||||
自动翻页同样使用 `--page-all` 触发,复用发送者、时间范围和 limit 条件,只替换每页 `cursor`;输出保留并合并 `result.conversationMessagesList`,控制参数为 `--page-limit`、`--max-items`、`--page-delay`。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-by-sender [flags]
|
||||
@@ -876,6 +885,10 @@ Flags:
|
||||
--end string 结束时间,ISO-8601 格式 (必填)
|
||||
--limit int 每页返回数量(默认 50)
|
||||
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
|
||||
--page-all 自动按 nextCursor 拉取所有分页
|
||||
--page-limit int 自动翻页最多请求页数(默认 50,范围 1-500)
|
||||
--max-items int 自动翻页最多返回条数(默认 0 表示不限制)
|
||||
--page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待)
|
||||
|
||||
注意:
|
||||
- --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一:
|
||||
@@ -892,6 +905,8 @@ Flags:
|
||||
> 推荐使用 `chat message search-advanced --at-me`,它还能叠加关键词/群/发送者等过滤条件。本命令适用于仅需拉取 @我 消息的简单场景。
|
||||
|
||||
搜索时间范围内 @我 的消息,可选指定群聊。返回结果包含单聊和群聊标识。分页参数 --limit(默认 50)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
|
||||
|
||||
自动翻页同样使用 `--page-all` 触发,复用时间范围和 group 过滤条件,只替换每页 `cursor`;输出保留并合并 `result.conversationMessagesList`,控制参数为 `--page-limit`、`--max-items`、`--page-delay`。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-mentions [flags]
|
||||
@@ -906,6 +921,10 @@ Flags:
|
||||
--end string 结束时间,ISO-8601 格式 (必填)
|
||||
--limit int 每页返回数量(默认 50)
|
||||
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
|
||||
--page-all 自动按 nextCursor 拉取所有分页
|
||||
--page-limit int 自动翻页最多请求页数(默认 50,范围 1-500)
|
||||
--max-items int 自动翻页最多返回条数(默认 0 表示不限制)
|
||||
--page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待)
|
||||
|
||||
注意:
|
||||
- --group 可选,不传则查询所有会话中 @我 的消息;传入则只查指定群聊
|
||||
@@ -917,6 +936,8 @@ Flags:
|
||||
#### 拉取特别关注人的消息
|
||||
|
||||
拉取当前用户特别关注人的消息。分页参数 --limit 指定每页数量,--cursor 传分页游标(首次不传或传 0)。返回结果中 hasMore=true 时用 nextCursor 作为下次 --cursor 继续翻页。
|
||||
|
||||
自动翻页使用 `--page-all` 触发,按 int64 cursor 注入下一页游标并聚合 `result.messages`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-focused [flags]
|
||||
@@ -926,6 +947,10 @@ Example:
|
||||
Flags:
|
||||
--limit int 每页返回数量(默认 50)
|
||||
--cursor int64 分页游标(首次不传或传 0,翻页传 nextCursor)
|
||||
--page-all 自动按 nextCursor 拉取所有分页
|
||||
--page-limit int 自动翻页最多请求页数(默认 50,范围 1-500)
|
||||
--max-items int 自动翻页最多返回条数(默认 0 表示不限制)
|
||||
--page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待)
|
||||
|
||||
注意:
|
||||
- 首次调用不传 --cursor 或传 0,后续翻页传 nextCursor
|
||||
@@ -977,6 +1002,8 @@ Flags:
|
||||
> 推荐优先使用 `chat message search-advanced`,它是本命令的严格超集:query 可选(非必填)、支持多个会话(非单个)、还能叠加发送者/at 等维度过滤。
|
||||
|
||||
按关键词搜索消息内容。--query 指定搜索关键词(必填)。可选 --group 限定搜索某个会话,不传则搜索所有会话。时间参数 --start/--end(ISO-8601)限定搜索时间范围。分页参数 --limit(默认 100)和 --cursor(默认 "0")始终传递;hasMore=true 时用返回的 nextCursor 作为下次 --cursor 继续翻页。
|
||||
|
||||
自动翻页同样使用 `--page-all` 触发,复用 query、时间范围和 group 条件,只替换每页 `cursor`;输出保留并合并 `result.conversationMessagesList`,控制参数为 `--page-limit`、`--max-items`、`--page-delay`。
|
||||
```
|
||||
Usage:
|
||||
dws chat message search [flags]
|
||||
@@ -991,6 +1018,10 @@ Flags:
|
||||
--end string 结束时间,ISO-8601 格式 (必填)
|
||||
--limit int 每页返回数量(默认 100)
|
||||
--cursor string 分页游标(默认 "0",翻页传 nextCursor)
|
||||
--page-all 自动按 nextCursor 拉取所有分页
|
||||
--page-limit int 自动翻页最多请求页数(默认 50,范围 1-500)
|
||||
--max-items int 自动翻页最多返回条数(默认 0 表示不限制)
|
||||
--page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待)
|
||||
|
||||
注意:
|
||||
- --group 可选,不传则搜索所有会话中的消息;传入则只搜索指定会话
|
||||
@@ -1004,6 +1035,8 @@ Flags:
|
||||
> 推荐:这是消息搜索的首选接口。它可以完全替代 `chat message search`(query 可选 vs 必填,支持多个会话 vs 单个),大部分替代 `chat message list-by-sender`(通过 --user/--users 按 userId 搜索发送者,或通过 --sender-ids 按 openDingTalkId 搜索)和 `chat message list-mentions`(通过 --at-me 搜索@我的消息)。仅在拉取「特别关注人」消息时需要退回 `list-focused`。
|
||||
|
||||
支持按关键词、发送者、@我、@指定人、指定会话、时间范围等多维度搜索消息。发送者 userId 使用 --user/--users;发送者或 @ 人的 openDingTalkId 使用 --sender-ids/--at-ids。所有参数均为可选,至少指定一个搜索条件。
|
||||
|
||||
自动翻页使用 `--page-all` 触发,复用所有高级过滤参数,只替换每页 `cursor`,保留并合并 `result.conversationMessagesList`;控制参数为 `--page-limit`、`--max-items`、`--page-delay`。
|
||||
```
|
||||
Usage:
|
||||
dws chat message search-advanced [flags]
|
||||
@@ -1030,6 +1063,10 @@ Flags:
|
||||
--end string 结束时间,ISO-8601 格式(可选)
|
||||
--cursor string 分页游标(默认 "0")
|
||||
--limit int 每页返回数量(默认 100)
|
||||
--page-all 自动按 nextCursor 拉取所有分页
|
||||
--page-limit int 自动翻页最多请求页数(默认 50,范围 1-500)
|
||||
--max-items int 自动翻页最多返回条数(默认 0 表示不限制)
|
||||
--page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待)
|
||||
--conversation-ids 的别名: --groups
|
||||
|
||||
注意:
|
||||
@@ -1423,6 +1460,8 @@ Flags:
|
||||
```
|
||||
|
||||
#### 查询收藏消息 — 分页查询当前用户收藏的消息
|
||||
|
||||
自动翻页使用 `--page-all` 触发,按 int64 cursor 注入下一页游标并聚合 `result.items`(不是 `result.messages`)。`--size` 仍保持 1-30 的 Open 服务范围;`--page-limit` 只控制最多请求页数。
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-favorites [flags]
|
||||
@@ -1433,10 +1472,15 @@ Example:
|
||||
Flags:
|
||||
--cursor int 数字分页游标,默认 0;翻页时传上次返回的 nextCursor
|
||||
--size int 一次拉取的收藏数量,默认 20,范围 1-30
|
||||
--page-all 自动按 nextCursor 拉取所有分页
|
||||
--page-limit int 自动翻页最多请求页数(默认 50,范围 1-500)
|
||||
--max-items int 自动翻页最多返回条数(默认 0 表示不限制)
|
||||
--page-delay int 自动翻页每页之间等待毫秒数(默认 200;0 表示不等待)
|
||||
|
||||
注意:
|
||||
- 首次请求可省略分页参数,CLI 会自动向 Open 服务传入 cursor=0、size="20"
|
||||
- hasMore=true 时,将 nextCursor 作为下一次的 --cursor
|
||||
- 自动翻页输出顶层 `paging` 元数据,达到 `--page-limit` 或 `--max-items` 时 `truncated=true`
|
||||
```
|
||||
|
||||
### bot (机器人管理)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# dws event — 个人 IM 事件
|
||||
# dws event — 个人 IM 与 OA 审批事件
|
||||
|
||||
通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回、表情回应和群生命周期事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。普通 IM 监听默认使用 `dws event +listen-im`;群生命周期、显式 EventKey、Filter DSL、subscribe_id 复用或原始 envelope 才使用 `dws event consume`。不要写脚本轮询消息历史。
|
||||
通过个人 Stream 长连接监听当前用户的钉钉消息接收、已读、撤回、表情回应、群生命周期,以及审批任务和审批实例事件,NDJSON 输出到 stdout,用于驱动事件触发的 Agent。普通 IM 监听默认使用 `dws event +listen-im`;OA 审批、群生命周期、显式 EventKey、Filter DSL、subscribe_id 复用或原始 envelope 使用 `dws event consume`。不要写脚本轮询消息历史或审批列表。
|
||||
|
||||
## 运行方式
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `dws event +listen-im --kind ... [flags]` | 普通 message/reaction/read/recall 监听;支持自然姓名/群名唯一解析 |
|
||||
| `dws event list --category oa` | 查看当前公开的 OA 个人事件目录 |
|
||||
| `dws event schema <event_key> --flatten` | 查看 Agent 使用的顶层业务字段 schema |
|
||||
| `dws event consume <event_key> [event_key...] --flatten [flags]` | 高级入口:阻塞消费显式 EventKey,事件写到 stdout,用 `-f ndjson` |
|
||||
| `dws event status --event <event_key>` | 查看个人订阅、bus、本地 consume |
|
||||
@@ -42,8 +43,14 @@
|
||||
| `user_im_group_member_added` | 指定群聊有成员加入 | `--group` |
|
||||
| `user_im_group_member_exited` | 指定群聊有成员退出 | `--group` |
|
||||
| `user_im_group_disbanded` | 指定群聊被解散 | `--group` |
|
||||
| `user_oa_approval_task_created` | 审批任务创建,发送给审批人 | 无 |
|
||||
| `user_oa_approval_task_finished` | 审批任务已完成 | 无 |
|
||||
| `user_oa_approval_task_redirected` | 审批任务已转交 | 无 |
|
||||
| `user_oa_approval_instance_started` | 审批实例已发起 | 无 |
|
||||
| `user_oa_approval_instance_terminated` | 审批实例已终止 | 无 |
|
||||
| `user_oa_approval_instance_finished` | 审批实例完成,发送给审批单发起人 | 无 |
|
||||
|
||||
只承认上表 16 个事件码。默认身份就是当前用户,不要额外加身份切换 flag。
|
||||
只承认上表 22 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。六个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。
|
||||
|
||||
## Intent mapping
|
||||
|
||||
@@ -65,20 +72,27 @@
|
||||
| "监听并自动回复某人的单聊消息" | `+listen-im --kind sender --user-query "<姓名>"`;事件到达后把稳定 ID 交给 `chat +messages-send` |
|
||||
| "同时监听同一人的消息、已读和撤回" | 一个 `+listen-im --kind sender --events message,read,recall` 生命周期 |
|
||||
| "同时监听同一群的消息、改名和解散" | 生命周期事件不在 Shortcut 范围;一个高级 consume 放入 3 个 EventKey,共享 `--group` |
|
||||
| "查看个人消息事件 schema" | `dws event schema <event_key> --flatten` |
|
||||
| "监听新的待我审批任务" / "有审批任务创建时通知我" | `event consume`,事件码 `user_oa_approval_task_created`,参数 `--flatten -f ndjson` |
|
||||
| "审批任务完成时通知我" | `event consume`,事件码 `user_oa_approval_task_finished`,参数 `--flatten -f ndjson` |
|
||||
| "审批任务被转交时通知我" | `event consume`,事件码 `user_oa_approval_task_redirected`,参数 `--flatten -f ndjson` |
|
||||
| "有审批单发起时通知我" | `event consume`,事件码 `user_oa_approval_instance_started`,参数 `--flatten -f ndjson` |
|
||||
| "有审批单终止时通知我" | `event consume`,事件码 `user_oa_approval_instance_terminated`,参数 `--flatten -f ndjson` |
|
||||
| "监听我发起的审批何时完成" / "审批实例完成时通知我" | `event consume`,事件码 `user_oa_approval_instance_finished`,参数 `--flatten -f ndjson` |
|
||||
| "同时监听全部已公开 OA 事件" | 一个 consume 放入六个 OA event key,不加目标或消息过滤参数 |
|
||||
| "查看个人事件 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`,后者覆盖该发送人的单聊和群聊消息。只有明确说“所有”时才使用 `receive_o2o_all/receive_group_all`,指定对象仍使用范围更小的事件。用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。
|
||||
“我和某人的单聊”使用 `receive_o2o`;“某人发给我的消息/某人发送的消息”使用 `receive_user`,后者覆盖该发送人的单聊和群聊消息。只有明确说“所有”时才使用 `receive_o2o_all/receive_group_all`,指定对象仍使用范围更小的事件。用户要求执行“撤回消息”时走 `dws chat`;只有“监听/订阅消息撤回”才走 `dws event`。“贴标签”表示给消息贴表情时,对应 `reaction` 表情回应事件。OA 事件不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。
|
||||
|
||||
## Call flow
|
||||
|
||||
1. 普通 IM 意图选择 `+listen-im` 的 `--kind` 与 `--events`;人名/群名直接用 `--user-query`/`--chat-query` 唯一解析。高级事件控制才手选 EventKey。
|
||||
1. 普通 IM 意图选择 `+listen-im` 的 `--kind` 与 `--events`;人名/群名直接用 `--user-query`/`--chat-query` 唯一解析。OA 审批与高级事件控制手选 EventKey。
|
||||
2. 需要了解字段时运行 `dws event schema <event_key> --flatten`,读取 `schema.properties`;此模式的 `jq_root_path` 为 `.`。
|
||||
3. 普通任务启动 `dws event +listen-im ... -f ndjson`;高级任务启动 `dws event consume <event_key> [event_key...] ... --flatten -f ndjson`。等待真实 ready marker;多事件记录每条 subscription,再等待整体 ready。不要用 `sleep` 猜测。
|
||||
4. stdout 每行是一个扁平事件 JSON;消息、动作及群成员加入/退出事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。
|
||||
3. 普通 IM 启动 `dws event +listen-im ... -f ndjson`;OA 或高级任务启动 `dws event consume <event_key> [event_key...] ... --flatten -f ndjson`。等待真实 ready marker;多事件记录每条 subscription,再等待整体 ready。不要用 `sleep` 猜测。
|
||||
4. stdout 每行是一个扁平事件 JSON;消息、动作、群成员加入/退出及 OA 审批事件读取顶层业务字段。群标题变更和群解散只读取公共字段和 `payload` 中实际存在的字段。
|
||||
5. 需要确认监听状态时运行 `dws event status --event <event_key>`,查看 `Subscriptions` 和 `Consumers`。
|
||||
6. 任务完成后优雅结束 consume;本次新建的订阅会自动取消。复用已有订阅或需要从外部主动取消时,先运行 `dws event stop <subscribe_id> --dry-run`,向用户确认后再以 `--yes` 执行;自测可在 consume 加 `--max-events` 或 `--duration` 自动退出。
|
||||
|
||||
@@ -112,6 +126,12 @@ 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
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
dws event schema user_oa_approval_task_finished --flatten
|
||||
dws event schema user_oa_approval_task_redirected --flatten
|
||||
dws event schema user_oa_approval_instance_started --flatten
|
||||
dws event schema user_oa_approval_instance_terminated --flatten
|
||||
dws event schema user_oa_approval_instance_finished --flatten
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -133,6 +153,12 @@ dws event consume user_im_group_updated --group <openConversationId> --flatten -
|
||||
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_oa_approval_task_created --flatten -f ndjson
|
||||
dws event consume user_oa_approval_task_finished --flatten -f ndjson
|
||||
dws event consume user_oa_approval_task_redirected --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_started --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_finished --flatten -f ndjson
|
||||
```
|
||||
|
||||
同一目标、同一过滤条件的兼容事件优先使用一个多事件命令:
|
||||
@@ -153,9 +179,19 @@ dws event consume \
|
||||
--group <openConversationId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是消息接收事件。
|
||||
用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。六个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。
|
||||
|
||||
上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user <userId>` 替换为 `--open-dingtalk-id <openDingtalkId>`,但两个参数不能同时使用。
|
||||
|
||||
@@ -180,7 +216,7 @@ dws event stop --all --yes
|
||||
|
||||
## 订阅创建失败与重试预算
|
||||
|
||||
以下约束适用于上表全部 16 个事件以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
|
||||
以下约束适用于上表全部 22 个公开个人事件(16 个 IM + 6 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。
|
||||
|
||||
- `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。
|
||||
- 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。
|
||||
@@ -210,6 +246,7 @@ dws event stop --all --yes
|
||||
- 表情回应事件直接读取 `operator/operator_open_dingtalk_id/reaction_name/reaction_text/operation_type/operation_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` 为准,不猜测群标题、操作者等字段。
|
||||
- OA 事件读取顶层 `process_instance_id/process_code/title/status/create_time/event_time`;任务事件另有 `task_id`,完成、转交或终止事件按对应 schema 提供 `finish_time`,任务完成、任务转交和实例完成还提供 `result`。`status/result` 保留服务端实际值,不推断完整枚举;缺少稳定 ID 或 payload 非法时 stderr 会输出 warning,stdout 回退为原始 transport envelope。
|
||||
- 图片、文件等媒体消息的 `content` 可能是可读描述;合并转发媒体的下载定位信息位于对应 `forward_messages[].content`。需要实际媒体文件时调用 `dws chat message download-media`。
|
||||
- 正常动作事件输出不含内部 `payload/uid/corpid/clientId/filterSubId/bizid`;原始排查才使用 `-f raw` 或 `--debug-raw-events`。
|
||||
- 自己发的消息不作为事件回来(`isSelfLoop` 过滤);自发验证会看到 0 事件,测试投递使用别人或机器人发消息。
|
||||
@@ -225,6 +262,6 @@ dws event stop --all --yes
|
||||
|
||||
## Full reference
|
||||
|
||||
- multi skill: `skills/multi/dingtalk-misc/SKILL.md`(产品索引 → event)
|
||||
- IM reference: `skills/multi/dingtalk-misc/references/event.md`
|
||||
- IM task index: `skills/multi/dingtalk-misc/references/event-im.md`
|
||||
- multi skill: `skills/multi/dingtalk-event/SKILL.md`
|
||||
- IM task index: `skills/multi/dingtalk-event/references/event-im.md`
|
||||
- OA reference: `skills/multi/dingtalk-event/references/event-oa.md`
|
||||
|
||||
@@ -57,9 +57,10 @@ dws minutes
|
||||
│ ├── create --file-name "..." --file-size <n> # 创建上传会话,获取上传地址
|
||||
│ ├── complete --session-id <sid> # 确认上传完成
|
||||
│ └── cancel --session-id <sid> # 取消上传会话
|
||||
└── permission <subcommand> # 权限管理(添加/移除听记成员权限)
|
||||
└── permission <subcommand> # 权限管理(添加/移除听记成员权限、为自己申请权限)
|
||||
├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限
|
||||
└── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2> # 移除成员权限
|
||||
├── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2> # 移除成员权限
|
||||
└── apply --id <uuid> --policy <2-4> # 为当前用户申请听记权限
|
||||
```
|
||||
|
||||
**高频错误命令 vs 正确命令对照(真实 badcase 提炼):**
|
||||
@@ -308,7 +309,8 @@ dws minutes
|
||||
│ └── cancel --session-id <sid>
|
||||
├── permission <subcommand>
|
||||
│ ├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."]
|
||||
│ └── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>
|
||||
│ ├── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>
|
||||
│ └── apply --id <uuid> --policy <2-4>
|
||||
└── tag <subcommand> # 听记标签/分组管理(标签在听记页面手动创建)
|
||||
├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称)
|
||||
└── query --tag-id <tagId> [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表
|
||||
@@ -1211,6 +1213,38 @@ Flags:
|
||||
- 如果成员是听记的创建者(所有者),无法通过此命令移除其权限
|
||||
- 建议在移除前先确认成员的当前权限,避免误操作
|
||||
|
||||
### 为当前用户申请听记权限
|
||||
```
|
||||
Usage:
|
||||
dws minutes permission apply [flags]
|
||||
Example:
|
||||
dws minutes permission apply --id <taskUuid> --policy 4
|
||||
dws minutes permission apply --id <taskUuid> --policy 2
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填)
|
||||
--policy int 权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)
|
||||
```
|
||||
|
||||
为当前登录用户申请指定听记的权限,无需传入用户 ID,系统自动识别当前用户身份。
|
||||
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
|
||||
|
||||
**权限类型说明:**
|
||||
|
||||
| --policy 值 | 含义 | 说明 |
|
||||
|------------|------|------|
|
||||
| 2 | 可编辑 | 可编辑听记的纪要、待办等内容 |
|
||||
| 3 | 可查看/下载 | 可查看和下载听记内容,不可编辑 |
|
||||
| 4 | 仅查看 | 仅可查看听记内容,不可下载 |
|
||||
|
||||
**与 permission add 的区别:**
|
||||
- `permission add` 是听记所有者/管理员**给别人授权**,需传 `--member-uids`
|
||||
- `permission apply` 是当前用户**为自己申请权限**,不需要任何用户 ID,不支持申请管理员/所有者权限(0/1)
|
||||
|
||||
**典型使用场景:**
|
||||
- 打开同事分享的听记链接提示无权限,申请查看权限
|
||||
- 需要编辑某篇只读听记的纪要,申请可编辑权限
|
||||
- 需要下载听记音频但当前仅有查看权限,申请可查看/下载权限
|
||||
|
||||
### 查询我的听记标签/分组列表
|
||||
```
|
||||
Usage:
|
||||
@@ -1595,6 +1629,7 @@ tagId 可通过 `dws minutes tag list` 获取。
|
||||
用户说"把某人加到这个听记中/共享听记给某人/给某人添加听记权限/把听记分享给同事/让某人也能看这个听记" → `permission add`
|
||||
用户说"添加参会者/批量添加参会者/添加成员/批量添加成员/加参会人/加入成员" → `permission add`(**注意:听记模块没有独立的"添加参会者/成员"命令,统一走权限接口**)
|
||||
用户说"移除某人的权限/取消共享/不让某人看这个听记了" → `permission remove`
|
||||
用户说"我没权限看这个听记/帮我申请这个听记的权限/申请查看权限/申请编辑权限" → `permission apply`(**注意:为当前用户自己申请,不是给别人授权**)
|
||||
|
||||
**自然语言示例:**
|
||||
- "把张三加到这个听记中"
|
||||
@@ -1611,6 +1646,9 @@ tagId 可通过 `dws minutes tag list` 获取。
|
||||
- "批量添加成员到这个听记"
|
||||
- "把张三加为参会人"
|
||||
- "移除张三对这个听记的权限"
|
||||
- "我打不开这个听记,帮我申请一下权限"
|
||||
- "帮我申请这个听记的查看权限"
|
||||
- "申请这篇听记的编辑权限"
|
||||
- "取消小王对这个听记的访问"
|
||||
- "不让某人看这个听记了"
|
||||
|
||||
@@ -1618,8 +1656,9 @@ tagId 可通过 `dws minutes tag list` 获取。
|
||||
- 用户提到"加/添加/共享/分享/让...看/让...访问"等增加权限语义 → `permission add`
|
||||
- 用户提到"添加参会者/加参会者/批量添加参会者/添加成员/加成员/批量添加成员/加参会人/添加参会人"等成员语义 → `permission add`(听记没有独立的"添加成员"接口,**统一走权限接口**)
|
||||
- 用户提到"移除/取消/删除/不让...看"等移除权限语义 → `permission remove`
|
||||
- 如果用户未指定权限类型,默认使用 `--policy 4`(可查看/下载/编辑)
|
||||
- 如果用户未提供 member-uids,需要先引导用户提供目标成员的钉钉 UID(可通过 `dws contact user search --query "姓名"` 查询)
|
||||
- 用户提到"我没权限/打不开/帮我申请/申请查看权限/申请编辑权限"等**为自己申请**语义 → `permission apply`(只需 `--id` + `--policy`;**不需要也不接受 `--member-uids`**,当前用户身份由系统自动识别;未说明权限等级时先与用户确认要 2(可编辑)/ 3(可查看下载)/ 4(仅查看))
|
||||
- `permission add` 的 `--policy` 是必填参数,没有默认值。用户未指定权限类型时,先确认要 0(管理员)/ 1(所有者)/ 2(可编辑)/ 3(可查看下载)/ 4(仅查看);即使确认选择 4,命令中仍必须显式传入 `--policy 4`
|
||||
- `permission add` / `permission remove` 需要目标成员的钉钉 UID:如果用户未提供 member-uids,需要先引导用户提供(可通过 `dws contact user search --query "姓名"` 查询);`permission apply` 不适用这一条
|
||||
|
||||
**典型执行链路:**
|
||||
1. 用户说"把张三加到这个听记中" → AI 需获取张三的 UID
|
||||
|
||||
@@ -141,6 +141,8 @@ Flags:
|
||||
| `workflow get` | 获取单个工作流详情(含 flowSchema) | `--base-id` `--workflow-id` | `--workflow-id` 接受 list 里的 `flowId`(同值) |
|
||||
| `workflow enable` | 启用工作流 | `--base-id` `--workflow-id` | 返回 `{enabled: true}` 是动作确认;要确认真启用看 list 的 `status` |
|
||||
| `workflow disable` | 禁用工作流(高危) | `--base-id` `--workflow-id` `--yes` | 影响业务自动化,建议二次确认;status 变 STOP |
|
||||
| `workflow run` | 立即执行工作流(需确认) | `--base-id` `--workflow-id`;记录触发另需 `--table-id` `--record-ids` | 返回 `executionId`;不确定时先用 history 核对,避免重复执行 |
|
||||
| `workflow history` | 查询工作流执行历史 | `--base-id` `--workflow-id` | 支持 status、Unix 毫秒时间范围和 page/size;`instanceId` 对应 run 的 `executionId` |
|
||||
|
||||
> 创建/更新的 `--dsl` 使用钉钉 AI 表格 `workflow-dsl/v1`;完整格式和最小 Demo 见 [aitable-workflow.md](./aitable/aitable-workflow.md)。删除工作流暂未开放。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# workflow — 自动化工作流管理
|
||||
|
||||
创建 / 更新 / 启停 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。
|
||||
适用场景:用户要求创建自动化、修改流程、停掉流程、查询已有流程或恢复运行。
|
||||
创建 / 更新 / 启停 / 手动执行 / 查询执行历史 / 查看 / 列出 Base 下的自动化工作流("当 X 时自动 Y" 流程)。
|
||||
适用场景:用户要求创建自动化、修改流程、停掉或恢复流程、立即执行流程、核对执行结果或查询已有流程。
|
||||
|
||||
## 命令一览
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
| `workflow get` | 获取单个工作流详情(含 flowSchema 完整节点定义) |
|
||||
| `workflow enable` | 启用指定工作流(按配置的触发条件自动执行) |
|
||||
| `workflow disable` | 禁用指定工作流(高危,建议 `--yes` 二次确认) |
|
||||
| `workflow run` | 立即执行指定工作流(会产生真实副作用,需确认) |
|
||||
| `workflow history` | 按状态、时间和分页条件查询工作流执行历史 |
|
||||
|
||||
> `workflow edit-example` 无参数;其他子命令的 `--base-id` 必填(可用隐藏别名 `--base`)。
|
||||
|
||||
@@ -155,6 +157,46 @@ dws aitable workflow update \
|
||||
|
||||
返回结构与 create 相同,成功时 `flowId` 应为目标工作流。update 使用 AI 表格瞬态错误重试;最终仍必须检查 `data.valid` 和 `issues`,并用 `workflow get/list` 验证发布结果与运行状态。
|
||||
|
||||
### workflow run — 立即执行工作流
|
||||
|
||||
```bash
|
||||
# 记录类触发器
|
||||
dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID \
|
||||
--table-id TABLE_ID --record-ids RECORD_ID_1,RECORD_ID_2
|
||||
|
||||
# 定时触发器不传 table-id / record-ids
|
||||
dws aitable workflow run --base-id BASE_ID --workflow-id WORKFLOW_ID
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 所属 Base ID |
|
||||
| `--workflow-id` | 是 | 目标工作流 ID |
|
||||
| `--table-id` | 条件必填 | 记录类触发器绑定的数据表;必须与触发器配置一致 |
|
||||
| `--record-ids` | 条件必填 | 记录类触发器的记录 ID,1–5 个、逗号分隔且不可重复 |
|
||||
|
||||
`run` 启动真实异步执行,工作流中的发消息、写记录等动作会实际发生;执行前必须取得用户确认。返回项中的 `executionId` 是本次执行标识,可与 `workflow history` 项目的 `instanceId` 匹配。网络结果不确定时不要直接重复执行,先按该标识查询历史。
|
||||
|
||||
### workflow history — 查询执行历史
|
||||
|
||||
```bash
|
||||
dws aitable workflow history --base-id BASE_ID --workflow-id WORKFLOW_ID \
|
||||
--status failed --after-time 1786000000000 --before-time 1787000000000 \
|
||||
--page 0 --size 50
|
||||
```
|
||||
|
||||
| flag | 说明 |
|
||||
|------|------|
|
||||
| `--base-id` | 必填 |
|
||||
| `--workflow-id` | 必填;CLI 会映射为 MCP 的 `flowId` |
|
||||
| `--status` | 可选:`success` / `failed` / `running` / `break` / `untrigger` |
|
||||
| `--after-time` | 可选,Unix 毫秒开始时间 |
|
||||
| `--before-time` | 可选,Unix 毫秒结束时间;与 after-time 同传时必须更大 |
|
||||
| `--page` | 可选,从 0 开始,默认 0 |
|
||||
| `--size` | 可选,默认 20,范围 `[1, 100]` |
|
||||
|
||||
返回 `totalCount` 和 `list`。`running` 是非终态;`success`、`failed`、`break`、`untrigger` 是终态。
|
||||
|
||||
### workflow list — 列出工作流
|
||||
|
||||
```bash
|
||||
@@ -262,9 +304,9 @@ dws aitable workflow disable --base-id BASE_ID --workflow-id WORKFLOW_ID --yes -
|
||||
| 列出工作流 | ✅ |
|
||||
| 看工作流详情(含 flowSchema) | ✅ |
|
||||
| 启用/禁用 | ✅ |
|
||||
| 查看运行历史/执行日志 | ✅ `workflow history` |
|
||||
| 手动触发/单次运行 | ✅ `workflow run`(需确认) |
|
||||
| 删除工作流 | ❌ 暂未开放 |
|
||||
| 查看运行历史/执行日志 | ❌ 暂未开放 |
|
||||
| 手动触发/单次运行 | ❌ 暂未开放 |
|
||||
|
||||
## 错误码速查
|
||||
|
||||
@@ -338,4 +380,5 @@ done
|
||||
- create 不自动重试;update 仅对网络/5xx/`retryable:true` 瞬态错误自动重试。
|
||||
- enable / disable 出参里的 `enabled` / `disabled` 是 **动作确认 flag**,不是当前状态字段。要确认真生效请走 `workflow list` 查 `status`。
|
||||
- `workflow get` 的 `flowSchema` 结构随触发器/动作类型变化,不要假设固定字段。
|
||||
- 删除、运行历史和手动触发当前仍未开放。
|
||||
- `workflow run` 不自动重试;结果不确定时用 `workflow history` 按 executionId / instanceId 核对。
|
||||
- 删除工作流当前仍未开放。
|
||||
|
||||
@@ -52,7 +52,7 @@ metadata:
|
||||
| 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` |
|
||||
| 读取并下载消息资源 | 查询命令加 `--download-resources` | 不另起手工下载循环;下载失败项保留在结果中 |
|
||||
| <!-- dws-intent: chat.conversation.list-top -->查看置顶会话 | `dws chat +conversation-list-top` | 会话 Top 与消息 Pin、消息 Top、Favorite 不同 |
|
||||
| 监听 IM 事件 | 切换 `dingtalk-misc` → [event.md](../dingtalk-misc/references/event.md) | 由事件参考选择确定的 EventKey 和生命周期 |
|
||||
| 监听未来 IM 事件 | [`dingtalk-event`](../dingtalk-event/SKILL.md) | 常规监听走 `+listen-im`;生命周期/高级控制走 `consume` |
|
||||
|
||||
以下次级入口在意图明确时直接使用,不需要先加载完整 Catalog:
|
||||
|
||||
|
||||
@@ -148,14 +148,16 @@ dws chat +chat-messages --group "项目群" --page-all --format json \
|
||||
| 命令 | 用途 | 示例与要点 |
|
||||
|------|------|------------|
|
||||
| `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group <cid> --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` |
|
||||
| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start <ISO> --end <ISO> --limit 100 --cursor 0`;四个参数每次请求都传,翻页用 `nextCursor` |
|
||||
| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊 |
|
||||
| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部 |
|
||||
| `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor` |
|
||||
| `message list-all` | 时间范围内全部会话消息 | `dws chat message list-all --start <ISO> --end <ISO> --limit 100 --cursor 0`;默认一页,完整遍历加 `--page-all`,保留并合并 `result.conversationMessagesList` |
|
||||
| `message list-by-sender` | 查指定发送者消息 | `--sender-user-id` 与 `--sender-open-dingtalk-id` 二选一,跨单聊+群聊;完整遍历加 `--page-all`,同一会话跨页合并 messages |
|
||||
| `message list-mentions` | 查 @ 我的消息 | 可传 `--group` 限定群,不传查全部;完整遍历加 `--page-all`,同一会话跨页合并 messages |
|
||||
| `message list-focused` | 查特别关注人消息 | 零参数可用,可加 `--limit` / `--cursor`;完整遍历加 `--page-all`,cursor 为 int64 |
|
||||
| `message list-unread-conversations` | 未读会话列表 | 可选 `--count` |
|
||||
| `message list-topic-replies` | 拉取话题回复 | `--group <openConversationId> --topic-id <openConvThreadId>` |
|
||||
| `message list-by-ids` | 按消息 ID 批量查询 | `--msg-ids msgId1,msgId2`,最多 50 条 |
|
||||
|
||||
Typed `chat message` 自动翻页只由 `--page-all` 触发;只传 `--page-limit`、`--max-items` 或 `--page-delay` 仍保持默认单页 fallback。`conversationMessagesList` 结构会保留并按 `openConversationId` 合并 messages。分页元数据输出到顶层 `paging`,包含 `truncated`、`hasMore`、`lastCursor`、`pages`、`total`;非第一页失败时会输出 partial 结果和 `failedPage` / `failedCursor` / `pagesFetched` / `itemsFetched`。
|
||||
|
||||
`message list` 注意事项:
|
||||
|
||||
- `--group`、`--user`、`--open-dingtalk-id` 互斥且必须指定一个。
|
||||
@@ -194,6 +196,7 @@ dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+0
|
||||
dws chat message search-advanced --user <userId> --start <ISO> --end <ISO>
|
||||
dws chat message search-advanced --at-me --start <ISO> --end <ISO>
|
||||
dws chat message search-advanced --conversation-ids <cid1>,<cid2> --query "合同" --limit 50 --cursor 0
|
||||
dws chat message search-advanced --query "周报" --start <ISO> --end <ISO> --page-all --max-items 200
|
||||
dws chat message search-advanced --message-type file --search-conv-type group_chat --query "附件"
|
||||
dws chat message search-advanced --only-robot-messages --query "通知"
|
||||
```
|
||||
@@ -210,12 +213,14 @@ dws chat message search-advanced --only-robot-messages --query "通知"
|
||||
| `--only-robot-messages` | 只搜索机器人消息 |
|
||||
| `--start` / `--end` | ISO-8601 时间范围 |
|
||||
| `--cursor` / `--limit` | 分页,翻页用 `nextCursor` |
|
||||
| `--page-all` / `--page-limit` / `--max-items` / `--page-delay` | 自动翻页;`--page-all` 是唯一触发开关 |
|
||||
|
||||
仅简单关键词搜索时可用:
|
||||
|
||||
```bash
|
||||
dws chat message search --query "changefree" --start <ISO> --end <ISO> --limit 50 --cursor 0
|
||||
dws chat message search --query "codereview" --group <openConversationId> --start <ISO> --end <ISO>
|
||||
dws chat message search --query "发布计划" --start <ISO> --end <ISO> --page-all --page-delay 0
|
||||
```
|
||||
|
||||
### 消息状态与撤回
|
||||
@@ -307,9 +312,10 @@ Runtime 会把创建响应的 `atTag` 自动放在正文前;不要自行写 ID
|
||||
| `message add-favorite` | 收藏消息 | `--open-message-id` `--open-conversation-id` |
|
||||
| `message remove-favorite` | 取消收藏消息 | `--open-message-id` `--open-conversation-id` |
|
||||
| `+flag-list` | 查询收藏消息列表的默认 Shortcut | 默认一页;要求全部时加 `--page-all`;`--page-size/--size` 范围为 1-30,可用 `--page-token` 或兼容的 `--cursor` 续页,检查 `complete` / `failures` |
|
||||
| `message list-favorites` | 原始单页收藏结果 fallback | 可选 `--cursor` `--size` |
|
||||
| `message list-favorites` | typed 收藏消息列表 | 默认一页;完整遍历加 `--page-all`,数字 cursor,聚合 `result.items`;可选 `--cursor` `--size` |
|
||||
|
||||
`+flag-list` 查询钉钉 message favorite,底层使用数字 cursor;它与消息 Pin、消息 Top 和会话置顶属于不同对象层级。
|
||||
`message list-favorites --page-all` 仍遵守 `--size` 1-30;`--page-limit` 只控制最多请求页数,`--max-items` 可精确截断输出。
|
||||
消息置顶 `set-top-msg` 与会话置顶 `chat set-top` 不同:前者置顶会话内消息,后者置顶整个会话。
|
||||
|
||||
### 表情回应
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->
|
||||
- `version`: `im.message-list.v1`
|
||||
- `message_fields`: `messageId`, `conversationId`, `threadId`, `sender`, `senderId`, `senderType`, `messageType`, `text`, `createTime`, `updateTime`, `reactions`, `quotedMessage`, `forwarded`, `resourceRefs`
|
||||
- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `resourceDownloads`
|
||||
- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads`
|
||||
<!-- DWS_MESSAGE_RESULT_CONTRACT_END -->
|
||||
|
||||
当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary;
|
||||
|
||||
+36
-13
@@ -1,13 +1,28 @@
|
||||
# 钉钉个人 IM 事件
|
||||
---
|
||||
name: dingtalk-event
|
||||
description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/终止/完成。命令前缀:dws event。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
requires:
|
||||
bins:
|
||||
- dws
|
||||
---
|
||||
|
||||
> 本文件为 `dingtalk-misc` 内个人 IM 事件产品入口。命令前缀:`dws event`。命中后先读本页 Golden Route;子 reference 按需加载,不要预加载全部。
|
||||
# 钉钉个人 IM 与 OA 审批事件
|
||||
|
||||
实时监听必须使用事件长连接,不写轮询脚本,不用历史消息查询模拟事件。高频 IM 意图优先交给 `dws event +listen-im`;它在 CLI 内解析自然目标、选择 EventKey,并复用现有订阅与 bus 生命周期。
|
||||
> **前置:执行 `dws` 前必须完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**Shared references 仅按需加载。
|
||||
|
||||
本 Skill 只负责未来个人 IM/OA 实时事件;发送和历史消息走 `dingtalk-chat`,审批查询与处理走 `dingtalk-misc` 的 OA,开放平台应用事件配置走其 DevApp。子 reference 按需加载。
|
||||
|
||||
实时监听必须使用事件长连接,不写轮询脚本,不用历史消息或审批列表查询模拟事件。高频 IM 意图优先交给 `dws event +listen-im`;它在 CLI 内解析自然目标、选择 EventKey,并复用现有订阅与 bus 生命周期。OA 审批事件使用显式 `dws event consume`。
|
||||
|
||||
<!-- dws-intent: event.listen.im -->消息、reaction、已读和撤回的默认监听入口是 `dws event +listen-im`;
|
||||
只有群生命周期、Filter DSL、原始 envelope 或底层订阅控制才使用
|
||||
`event consume` fallback。
|
||||
|
||||
<!-- dws-intent: event.listen.oa -->OA 审批任务与审批实例的实时变化使用 `dws event consume`;查询或操作已有审批走 `dws oa`,不要用轮询模拟事件。
|
||||
|
||||
## Golden Route
|
||||
|
||||
| 用户意图 | 唯一推荐入口 |
|
||||
@@ -17,7 +32,9 @@
|
||||
| 监听指定群消息 | `dws event +listen-im --kind group --chat-query <群名>` |
|
||||
| 同一人/群的消息、表情、已读或撤回 | `dws event +listen-im --kind <sender|group> --events message,reaction,read,recall ...` |
|
||||
| 监听全部单聊或全部群消息 | `dws event +listen-im --kind <all-direct|all-group>`;只有用户明确要求“全部”时使用 |
|
||||
| 群改名、成员进退、群解散 | 读取 [EventKey 索引](./event-im-keys.md),使用精确 `event consume` EventKey |
|
||||
| 群改名、成员进退、群解散 | 读取 [EventKey 索引](references/event-im-keys.md),使用精确 `event consume` EventKey |
|
||||
| OA 审批任务或实例事件 | 读取 [OA 事件参考](references/event-oa.md),使用精确 `event consume` EventKey |
|
||||
| 查看 OA 事件目录 | `dws event list --category oa` |
|
||||
| 已知 EventKey 或需要底层订阅控制 | `dws event consume`;参数与约束以 leaf Schema 为准 |
|
||||
| 查看状态 / 停止 | `dws event status` / `dws event stop <subscribe_id> --dry-run`,确认后再 `--yes` |
|
||||
|
||||
@@ -28,6 +45,8 @@
|
||||
- `group` 必须且只能传 `--chat-id` 或 `--chat-query` 之一。
|
||||
- `--query` 只用于纯 `message` 监听;混入 reaction/read/recall 时不得使用。
|
||||
|
||||
OA 事件不进入 `+listen-im`。六个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。六项可放入同一个 consume,每项建立独立订阅并共享 bus。
|
||||
|
||||
自然姓名和群名由 CLI 内部唯一解析:零命中或多候选返回结构化失败,在创建任何订阅前停止。`--dry-run` 走同一解析链。解析、监听、状态和停止必须使用同一个 `--profile`,不得跨组织搬运 ID。
|
||||
|
||||
### 兼容 EventKey 索引
|
||||
@@ -46,6 +65,8 @@ user_im_group_member_added user_im_group_member_exited
|
||||
user_im_group_disbanded
|
||||
```
|
||||
|
||||
六个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。
|
||||
|
||||
用户类事件传 `--user` 或 `--open-dingtalk-id`,群类事件传 `--group`。群生命周期输出可含 `operator_open_dingtalk_id` 和 `members`;成员项使用 `open_dingtalk_id`。精确组合、兼容性和 Filter 规则见 reference。
|
||||
|
||||
## 公开层与内部统一边界
|
||||
@@ -59,7 +80,7 @@ kind + events + target
|
||||
→ 一次 event consume 生命周期
|
||||
```
|
||||
|
||||
订阅创建/复用、单 bus、多 consumer、ready marker、扁平 NDJSON、超时/取消、部分失败回滚和退出清理全部复用现有 Runtime。低频 EventKey、群生命周期、Filter DSL、原始 envelope、复用 subscribe_id 等仍由 `event consume` 承担。
|
||||
订阅创建/复用、单 bus、多 consumer、ready marker、扁平 NDJSON、超时/取消、部分失败回滚和退出清理全部复用现有 Runtime。低频 EventKey、群生命周期、OA 审批、Filter DSL、原始 envelope、复用 subscribe_id 等仍由 `event consume` 承担。
|
||||
|
||||
## 运行与结果契约
|
||||
|
||||
@@ -69,16 +90,17 @@ kind + events + target
|
||||
- 有界任务使用 `--max-events N` 或 `--duration 10m`;无界任务需要宿主管理进程并持续读取 stdout。
|
||||
- 干净退出会取消本次新建的订阅;使用 SIGTERM、关闭符合条件的管道 stdin,或 Runtime 的 bounded exit。不要 `kill -9`。
|
||||
- 当前用户自己发送的消息会被 self-loop 过滤;自测事件应由另一用户或机器人发送。
|
||||
- 事件只负责监听;需要回复时按 [输出与 Chat 交接](./event-im-output.md) 把真实 `conversation_id` 或 `sender_open_dingtalk_id` 交给 `dws chat +messages-send`,不要从显示名猜 ID。
|
||||
- 事件只负责监听;需要回复时按 [输出与 Chat 交接](references/event-im-output.md) 把真实 `conversation_id` 或 `sender_open_dingtalk_id` 交给 `dws chat +messages-send`,不要从显示名猜 ID。
|
||||
- 扁平消息/动作字段按事件类型读取:已读为 `reader_open_dingtalk_id`,撤回为 `recaller_open_dingtalk_id`,回应为 `reaction_name`、`operation_type`。媒体优先通过聊天读取命令加 `--download-resources`;已知消息 ID 的底层降级入口是 `dws chat message download-media`。
|
||||
- OA 扁平事件提供审批实例、任务和状态字段;字段差异、原始回退条件及与 OA 命令的稳定 ID 交接以 [OA 事件参考](references/event-oa.md) 为准。
|
||||
|
||||
## 安全与失败处理
|
||||
|
||||
- `event stop` 会取消订阅并影响本地 consumer:先 `--dry-run`,用户确认后再加 `--yes`。
|
||||
- 多事件属于一次原始操作;任一订阅启动失败时 Runtime 回滚本次已创建项,不拆成新命令绕过重试预算。
|
||||
- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于 16 个 EventKey:`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。
|
||||
- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 22 个公开个人 EventKey(16 个 IM + 6 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。
|
||||
- 重试必须遵守 `retry_after_seconds` / `next_retry_at`。遇到 `in_flight`、`cooldown`、`terminal_hold` 不并发或递归重启同一逻辑订阅,也不换 `subscribe_id` / `trace_id` 绕过保护。
|
||||
- 认证、profile、订阅保护状态和 bus 排障按失败类型读取 [订阅运维](./event-im-operations.md),不要在正常路径预加载完整运维手册。
|
||||
- 认证、profile、订阅保护状态和 bus 排障按失败类型读取 [订阅运维](references/event-im-operations.md),不要在正常路径预加载完整运维手册。
|
||||
|
||||
### 本地订阅保护契约
|
||||
|
||||
@@ -98,8 +120,9 @@ kind + events + target
|
||||
|
||||
| Topic | Reference | 何时读取 |
|
||||
|---|---|---|
|
||||
| 任务索引 | [event-im.md](./event-im.md) | 还不能判断应该加载哪一个子 reference |
|
||||
| EventKey、目标规则与底层 consume | [event-im-keys.md](./event-im-keys.md) | 群生命周期、显式 EventKey 或多事件组合 |
|
||||
| ready、bounded consume 与退出清理 | [event-im-lifecycle.md](./event-im-lifecycle.md) | 启动/托管/关闭 consumer |
|
||||
| 扁平字段与事件到 Chat 交接 | [event-im-output.md](./event-im-output.md) | 解析事件或自动回复 |
|
||||
| Filter、status/stop、重试与排障 | [event-im-operations.md](./event-im-operations.md) | 订阅控制或失败恢复 |
|
||||
| 任务索引 | [event-im.md](references/event-im.md) | 还不能判断应该加载哪一个子 reference |
|
||||
| EventKey、目标规则与底层 consume | [event-im-keys.md](references/event-im-keys.md) | 群生命周期、显式 EventKey 或多事件组合 |
|
||||
| ready、bounded consume 与退出清理 | [event-im-lifecycle.md](references/event-im-lifecycle.md) | 启动/托管/关闭 consumer |
|
||||
| 扁平字段与事件到 Chat 交接 | [event-im-output.md](references/event-im-output.md) | 解析事件或自动回复 |
|
||||
| Filter、status/stop、重试与排障 | [event-im-operations.md](references/event-im-operations.md) | 订阅控制或失败恢复 |
|
||||
| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择六个 OA EventKey、组合消费或解析审批字段 |
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# IM 事件任务路由
|
||||
|
||||
本页是任务索引,不再混放 16 个 EventKey、输出字段和运维细节。先用上层
|
||||
[event.md](./event.md) 的 Golden Route;只加载当前任务对应的一份子 reference。
|
||||
[SKILL.md](../SKILL.md) 的 Golden Route;只加载当前任务对应的一份子 reference。
|
||||
|
||||
<!-- dws-intent: event.listen.im -->消息、reaction、已读和撤回默认使用 `dws event +listen-im` 长连接;
|
||||
群生命周期、显式 EventKey、Filter DSL、原始 envelope 或底层订阅控制才使用
|
||||
@@ -0,0 +1,149 @@
|
||||
# OA 个人审批事件
|
||||
|
||||
先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的六个 OA 个人事件:审批实例发起、终止和完成,以及审批任务创建、完成和转交。
|
||||
|
||||
<!-- dws-intent: event.listen.oa -->实时监听审批事件必须使用 `dws event consume` 长连接,不要轮询 OA 待办或审批实例列表来模拟事件。
|
||||
|
||||
## Prerequisite
|
||||
|
||||
OA 个人事件使用当前用户 OAuth 登录态。未登录或 token 失效时,先执行:
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
非默认组织使用全局 `--profile <corpId 或 profile 名>`。事件范围始终是该 OAuth 用户相关的全部 OA 事件,不需要也不接受审批人、发起人或审批模板选择参数。
|
||||
|
||||
## Event catalog
|
||||
|
||||
| 事件码 | 订阅规则 | 接收语义 | 必填参数 |
|
||||
|---|---|---|---|
|
||||
| `user_oa_approval_task_created` | `all` | 审批任务创建,发送给审批人 | 无 |
|
||||
| `user_oa_approval_task_finished` | `all` | 审批任务已完成 | 无 |
|
||||
| `user_oa_approval_task_redirected` | `all` | 审批任务已转交 | 无 |
|
||||
| `user_oa_approval_instance_started` | `all` | 审批实例已发起 | 无 |
|
||||
| `user_oa_approval_instance_terminated` | `all` | 审批实例已终止 | 无 |
|
||||
| `user_oa_approval_instance_finished` | `all` | 审批实例完成,发送给审批单发起人 | 无 |
|
||||
|
||||
只承认上表 6 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。
|
||||
|
||||
## Intent mapping
|
||||
|
||||
| 用户说 | 下一步 |
|
||||
|---|---|
|
||||
| “监听新的待我审批任务” / “有审批任务创建时通知我” | `dws event consume user_oa_approval_task_created --flatten -f ndjson` |
|
||||
| “审批任务完成时通知我” | `dws event consume user_oa_approval_task_finished --flatten -f ndjson` |
|
||||
| “审批任务被转交时通知我” | `dws event consume user_oa_approval_task_redirected --flatten -f ndjson` |
|
||||
| “有审批单发起时通知我” | `dws event consume user_oa_approval_instance_started --flatten -f ndjson` |
|
||||
| “有审批单终止时通知我” | `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` |
|
||||
| “监听我发起的审批何时完成” / “审批实例完成时通知我” | `dws event consume user_oa_approval_instance_finished --flatten -f ndjson` |
|
||||
| “同时监听全部已公开 OA 事件” | 一个 consume 放入六个 OA event key,不加目标或过滤参数 |
|
||||
| “查看 OA 事件目录” | `dws event list --category oa` |
|
||||
| “查看 OA 事件输出字段” | 对对应事件运行 `dws event schema <event_key> --flatten` |
|
||||
|
||||
三个审批任务事件分别表达任务已创建、已完成和已转交;三个审批实例事件分别表达实例已发起、已终止和已完成。扁平字段来自六类事件的预发联调样本;`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。
|
||||
|
||||
## Commands
|
||||
|
||||
查看稳定的扁平输出 schema:
|
||||
|
||||
```bash
|
||||
dws event schema user_oa_approval_task_created --flatten
|
||||
dws event schema user_oa_approval_task_finished --flatten
|
||||
dws event schema user_oa_approval_task_redirected --flatten
|
||||
dws event schema user_oa_approval_instance_started --flatten
|
||||
dws event schema user_oa_approval_instance_terminated --flatten
|
||||
dws event schema user_oa_approval_instance_finished --flatten
|
||||
```
|
||||
|
||||
单独监听一种事件:
|
||||
|
||||
```bash
|
||||
dws event consume user_oa_approval_task_created --flatten -f ndjson
|
||||
dws event consume user_oa_approval_task_finished --flatten -f ndjson
|
||||
dws event consume user_oa_approval_task_redirected --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_started --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_terminated --flatten -f ndjson
|
||||
dws event consume user_oa_approval_instance_finished --flatten -f ndjson
|
||||
```
|
||||
|
||||
同时监听六种事件:
|
||||
|
||||
```bash
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
```
|
||||
|
||||
多事件 consume 会为六个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。
|
||||
|
||||
## Output contract
|
||||
|
||||
`--flatten` 模式的所有 OA 事件都包含以下顶层字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "user_oa_approval_task_created",
|
||||
"event_id": "...",
|
||||
"timestamp": 0,
|
||||
"subscribe_id": "...",
|
||||
"process_instance_id": "...",
|
||||
"process_code": "...",
|
||||
"title": "...",
|
||||
"status": "RUNNING",
|
||||
"create_time": 0,
|
||||
"event_time": 0
|
||||
}
|
||||
```
|
||||
|
||||
- `type` 是当前 event key;`event_id` 可用于去重;`timestamp` 是 transport 事件发生时间;`subscribe_id` 标识对应的独立订阅。
|
||||
- `process_instance_id` 是审批实例 ID,可传给 OA 审批命令的 `--instance-id`;`process_code` 是审批流程模板编码。
|
||||
- `create_time`、`finish_time` 和 `event_time` 都是毫秒时间戳。`event_time` 是审批业务事件时间,`timestamp` 是 transport 事件时间。
|
||||
- 六类事件的额外字段如下;具体事件始终以 `dws event schema <event_key> --flatten` 为准。
|
||||
|
||||
| 事件 | 额外顶层字段 |
|
||||
|---|---|
|
||||
| `user_oa_approval_task_created` | `task_id` |
|
||||
| `user_oa_approval_task_finished` | `task_id`、`result`、`finish_time` |
|
||||
| `user_oa_approval_task_redirected` | `task_id`、`result`、`finish_time` |
|
||||
| `user_oa_approval_instance_started` | 无 |
|
||||
| `user_oa_approval_instance_terminated` | `finish_time` |
|
||||
| `user_oa_approval_instance_finished` | `result`、`finish_time` |
|
||||
|
||||
任务完成事件示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "user_oa_approval_task_finished",
|
||||
"event_id": "...",
|
||||
"timestamp": 0,
|
||||
"subscribe_id": "...",
|
||||
"process_instance_id": "...",
|
||||
"process_code": "...",
|
||||
"task_id": "...",
|
||||
"title": "测试审批",
|
||||
"status": "FINISHED",
|
||||
"result": "agree",
|
||||
"create_time": 0,
|
||||
"finish_time": 0,
|
||||
"event_time": 0
|
||||
}
|
||||
```
|
||||
|
||||
- `task_id` 是当前审批任务 ID,可传给接受任务 ID 的 OA 审批命令。
|
||||
- `status` 和 `result` 是服务端字符串;不要只根据当前样本把 `RUNNING/FINISHED/TERMINATED` 或 `agree/redirect` 写成封闭枚举。
|
||||
- payload 缺失、为空、缺少对应事件的稳定 ID 或无法解析时,consume 会在 stderr 记录 warning,并把原始 transport envelope 写到 stdout,保证事件不被静默丢弃。
|
||||
- 不传 `--flatten` 时保持兼容 transport envelope,业务 payload 位于 `.data | fromjson`。需要联调完整原始协议时使用不带 `--flatten` 的 `-f raw` 或 `--debug-raw-events`。
|
||||
|
||||
## Lifecycle
|
||||
|
||||
- 单事件等待 `[event] ready event_key=<key> bus_pid=<pid> subscribe_id=<id>`。
|
||||
- 六事件先保存六条 `[event] subscription event_key=<key> subscribe_id=<id>`,再等待 `[event] ready event_count=6 bus_pid=<pid>`。
|
||||
- 临时验证使用 `--max-events 1` 或 `--duration 10m`;任务完成后优雅结束 consume,本次新建的订阅会自动取消。
|
||||
- 外部停止已有订阅时先运行 `dws event stop <subscribe_id> --dry-run`,确认后再加 `--yes`。不要 `kill -9`,否则会跳过自动退订。
|
||||
@@ -66,9 +66,10 @@ dws minutes
|
||||
│ ├── create --file-name "..." --file-size <n> # 创建上传会话,获取上传地址
|
||||
│ ├── complete --session-id <sid> # 确认上传完成
|
||||
│ └── cancel --session-id <sid> # 取消上传会话
|
||||
├── permission <subcommand> # 权限管理(添加/移除听记成员权限)
|
||||
├── permission <subcommand> # 权限管理(添加/移除听记成员权限、为自己申请权限)
|
||||
│ ├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."] # 添加成员权限
|
||||
│ └── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2> # 移除成员权限
|
||||
│ ├── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2> # 移除成员权限
|
||||
│ └── apply --id <uuid> --policy <2-4> # 为当前用户申请听记权限
|
||||
└── tag <subcommand> # 听记标签/分组管理(标签在听记页面手动创建)
|
||||
├── list # 查询我的听记标签/分组列表(返回 tagId 和标签名称)
|
||||
└── query --tag-id <tagId> [--limit] [--cursor] # 根据标签ID查询该标签下的听记列表
|
||||
@@ -417,7 +418,8 @@ dws minutes
|
||||
│ └── cancel --session-id <sid>
|
||||
├── permission <subcommand>
|
||||
│ ├── add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy <0-4> [--cover] [--sub-resources "..."]
|
||||
│ └── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>
|
||||
│ ├── remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>
|
||||
│ └── apply --id <uuid> --policy <2-4>
|
||||
└── tag <subcommand>
|
||||
├── list
|
||||
└── query --tag-id <tagId> [--limit] [--cursor]
|
||||
@@ -1324,6 +1326,38 @@ Flags:
|
||||
- 如果成员是听记的创建者(所有者),无法通过此命令移除其权限
|
||||
- 建议在移除前先确认成员的当前权限,避免误操作
|
||||
|
||||
### 为当前用户申请听记权限
|
||||
```
|
||||
Usage:
|
||||
dws minutes permission apply [flags]
|
||||
Example:
|
||||
dws minutes permission apply --id <taskUuid> --policy 4
|
||||
dws minutes permission apply --id <taskUuid> --policy 2
|
||||
Flags:
|
||||
--id string 听记 taskUuid (必填)
|
||||
--policy int 权限类型: 2=可编辑, 3=可查看/下载, 4=仅查看 (必填)
|
||||
```
|
||||
|
||||
为当前登录用户申请指定听记的权限,无需传入用户 ID,系统自动识别当前用户身份。
|
||||
适用于用户无权限访问某听记(如打开分享链接提示无权限)时,主动向听记所有者发起权限申请。
|
||||
|
||||
**权限类型说明:**
|
||||
|
||||
| --policy 值 | 含义 | 说明 |
|
||||
|------------|------|------|
|
||||
| 2 | 可编辑 | 可编辑听记的纪要、待办等内容 |
|
||||
| 3 | 可查看/下载 | 可查看和下载听记内容,不可编辑 |
|
||||
| 4 | 仅查看 | 仅可查看听记内容,不可下载 |
|
||||
|
||||
**与 permission add 的区别:**
|
||||
- `permission add` 是听记所有者/管理员**给别人授权**,需传 `--member-uids`
|
||||
- `permission apply` 是当前用户**为自己申请权限**,不需要任何用户 ID,不支持申请管理员/所有者权限(0/1)
|
||||
|
||||
**典型使用场景:**
|
||||
- 打开同事分享的听记链接提示无权限,申请查看权限
|
||||
- 需要编辑某篇只读听记的纪要,申请可编辑权限
|
||||
- 需要下载听记音频但当前仅有查看权限,申请可查看/下载权限
|
||||
|
||||
### 查询我的听记标签/分组列表
|
||||
```
|
||||
Usage:
|
||||
@@ -1708,6 +1742,7 @@ tagId 可通过 `dws minutes tag list` 获取。
|
||||
用户说"把某人加到这个听记中/共享听记给某人/给某人添加听记权限/把听记分享给同事/让某人也能看这个听记" → `permission add`
|
||||
用户说"添加参会者/批量添加参会者/添加成员/批量添加成员/加参会人/加入成员" → `permission add`(**注意:听记模块没有独立的"添加参会者/成员"命令,统一走权限接口**)
|
||||
用户说"移除某人的权限/取消共享/不让某人看这个听记了" → `permission remove`
|
||||
用户说"我没权限看这个听记/帮我申请这个听记的权限/申请查看权限/申请编辑权限" → `permission apply`(**注意:为当前用户自己申请,不是给别人授权**)
|
||||
|
||||
**自然语言示例:**
|
||||
- "把张三加到这个听记中"
|
||||
@@ -1724,6 +1759,9 @@ tagId 可通过 `dws minutes tag list` 获取。
|
||||
- "批量添加成员到这个听记"
|
||||
- "把张三加为参会人"
|
||||
- "移除张三对这个听记的权限"
|
||||
- "我打不开这个听记,帮我申请一下权限"
|
||||
- "帮我申请这个听记的查看权限"
|
||||
- "申请这篇听记的编辑权限"
|
||||
- "取消小王对这个听记的访问"
|
||||
- "不让某人看这个听记了"
|
||||
|
||||
@@ -1731,8 +1769,9 @@ tagId 可通过 `dws minutes tag list` 获取。
|
||||
- 用户提到"加/添加/共享/分享/让...看/让...访问"等增加权限语义 → `permission add`
|
||||
- 用户提到"添加参会者/加参会者/批量添加参会者/添加成员/加成员/批量添加成员/加参会人/添加参会人"等成员语义 → `permission add`(听记没有独立的"添加成员"接口,**统一走权限接口**)
|
||||
- 用户提到"移除/取消/删除/不让...看"等移除权限语义 → `permission remove`
|
||||
- 如果用户未指定权限类型,默认使用 `--policy 4`(可查看/下载/编辑)
|
||||
- 如果用户未提供 member-uids,需要先引导用户提供目标成员的钉钉 UID(可通过 `dws aisearch person --keyword "姓名" --dimension name` 查询)
|
||||
- 用户提到"我没权限/打不开/帮我申请/申请查看权限/申请编辑权限"等**为自己申请**语义 → `permission apply`(只需 `--id` + `--policy`;**不需要也不接受 `--member-uids`**,当前用户身份由系统自动识别;未说明权限等级时先与用户确认要 2(可编辑)/ 3(可查看下载)/ 4(仅查看))
|
||||
- `permission add` 的 `--policy` 是必填参数,没有默认值。用户未指定权限类型时,先确认要 0(管理员)/ 1(所有者)/ 2(可编辑)/ 3(可查看下载)/ 4(仅查看);即使确认选择 4,命令中仍必须显式传入 `--policy 4`
|
||||
- `permission add` / `permission remove` 需要目标成员的钉钉 UID:如果用户未提供 member-uids,需要先引导用户提供(可通过 `dws aisearch person --keyword "姓名" --dimension name` 查询);`permission apply` 不适用这一条
|
||||
|
||||
**典型执行链路:**
|
||||
1. 用户说"把张三加到这个听记中" → AI 需获取张三的 UID
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user