Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
758b0f875e | ||
|
|
19be571574 | ||
|
|
b5a287ae71 | ||
|
|
da6f867dfa | ||
|
|
82dc2b5e5e | ||
|
|
9265fd4cb8 | ||
|
|
e324ef9d4a | ||
|
|
fb1847d62e | ||
|
|
331681e82b | ||
|
|
1633888290 | ||
|
|
206f33ae1c | ||
|
|
9259477372 | ||
|
|
137151b38c | ||
|
|
081220f15e | ||
|
|
b8216380de | ||
|
|
83bc213b7f | ||
|
|
804b9a3142 | ||
|
|
cfdb0d0556 | ||
|
|
d8686122ab | ||
|
|
222a0230ae | ||
|
|
f7befc7943 | ||
|
|
1dc924af1b | ||
|
|
0e8d6e00cf | ||
|
|
0028ed1570 | ||
|
|
8b5d9a59b0 | ||
|
|
01d663f597 | ||
|
|
2bf314c625 | ||
|
|
8c98d1abe4 | ||
|
|
5e4a65513b | ||
|
|
4ae0e0ffc3 | ||
|
|
1c9a977d08 | ||
|
|
78fabec4bb | ||
|
|
6d6404993a | ||
|
|
008d50bb3d | ||
|
|
f871689960 | ||
|
|
b15a21de93 | ||
|
|
58e8e35948 | ||
|
|
a8b0d8895b | ||
|
|
546c2d2eb2 | ||
|
|
5bd0ea7c53 | ||
|
|
4833b39071 | ||
|
|
8a4e49dbf2 | ||
|
|
7c924c54ca | ||
|
|
59d0b6dd75 | ||
|
|
74f7bbc980 | ||
|
|
2b7d5a2c5f | ||
|
|
fcfead71cb | ||
|
|
c7510cd1a1 | ||
|
|
1c3477c087 | ||
|
|
93d450a9bf | ||
|
|
cf39768095 | ||
|
|
c096258b0f | ||
|
|
5ba8ac6775 | ||
|
|
66fee5ef5f | ||
|
|
d9b9c5c7da | ||
|
|
684411e54e | ||
|
|
de5ba029d4 | ||
|
|
15f139e32d | ||
|
|
768c1ce494 | ||
|
|
966fd60e2f | ||
|
|
7825c3c7e0 | ||
|
|
10d44615d1 | ||
|
|
1e88612e43 | ||
|
|
5934ccac7f | ||
|
|
d9365f3fff | ||
|
|
15d93698bd | ||
|
|
7e69a3d6fe | ||
|
|
94b4958038 | ||
|
|
97a99ba04f | ||
|
|
5e1e5cbb84 | ||
|
|
e04e886c50 | ||
|
|
1acde9b766 | ||
|
|
852efe56aa | ||
|
|
6c287bcb4d | ||
|
|
df24d53886 | ||
|
|
49cecabb12 | ||
|
|
09993ad82c | ||
|
|
0efaf6c82f | ||
|
|
26002637f4 | ||
|
|
d552c59d11 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Permission error guidance and error rendering** (#1085) —
|
||||
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
|
||||
instead of a generic business-error rendering; document/wiki-specific errors
|
||||
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
|
||||
or the role-threshold wording like
|
||||
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
|
||||
(`dws drive permission apply-info` / `dws drive permission apply`), while
|
||||
permission failures carrying only generic code names (`FORBIDDEN`,
|
||||
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
|
||||
or other products' wording keep their product-specific or
|
||||
product-neutral suggestion instead of a misleading document-permission hint;
|
||||
member-validation failures such as
|
||||
“用户不存在/不属于当前组织” are classified as tool errors with a
|
||||
`--members`-with-`corpId` suggestion instead of a misleading
|
||||
resource-not-found error; business error output now surfaces the backend
|
||||
message with `code`/`logId` appended for traceability; and the
|
||||
`update_permission` / `remove_permission` / `update_member` /
|
||||
`remove_member` tools — whose servers return a literal `null` on successful
|
||||
no-payload writes — now render `{}` so downstream JSON consumers do not fail
|
||||
parsing `null`; other tools keep raw `null` output unchanged.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Permission and member list pagination** (#1085) — `drive/doc permission
|
||||
list` and `wiki member list` now accept `--next-token` to follow the
|
||||
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
|
||||
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
|
||||
200` path; `permission add/update/remove` and `wiki member add/update/remove`
|
||||
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
|
||||
grantee types. The optional `--notify` defaults to `false` and is omitted from
|
||||
the server request unless passed explicitly, so member grants no longer notify
|
||||
recipients by default. These commands also declare cursor pagination
|
||||
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
|
||||
change. Because a single batch remove can revoke access for up to 30
|
||||
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
|
||||
groups can indirectly affect many more users — `drive/doc permission
|
||||
remove` and `wiki member remove` now declare
|
||||
`confirmation=user_required` and gate the actual tool call behind user
|
||||
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
|
||||
confirmation-gate failure now also passes through verbatim instead of being
|
||||
reclassified as a permission-denied or unclassified error.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
|
||||
@@ -544,7 +544,8 @@ jobs:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
@@ -692,7 +693,8 @@ jobs:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c-a-l
|
||||
- app-c-m-r
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
|
||||
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -20,6 +20,7 @@ help:
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
|
||||
@printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@@ -62,6 +63,9 @@ test-auth-legacy-compat:
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
|
||||
|
||||
shortcut-public-e2e-proof: build
|
||||
@GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
@@ -84,7 +88,7 @@ fmt:
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy: test-auth-legacy-compat
|
||||
policy: test-auth-legacy-compat shortcut-public-e2e-proof
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# AI 表格数据源指令使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。
|
||||
|
||||
所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。
|
||||
|
||||
## 指令速览
|
||||
|
||||
| 指令 | 用途 | 读写 | 风险 |
|
||||
|------|------|------|------|
|
||||
| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low |
|
||||
| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low |
|
||||
| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium |
|
||||
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium |
|
||||
| `+datasource-sync` | 手动触发一次同步 | 写 | medium |
|
||||
| `+datasource-sync-status` | 查询同步任务状态 | 读 | low |
|
||||
| `+datasource-get-config` | 查看数据源表配置 | 读 | low |
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **登录认证**:执行 `dws auth login` 确保已登录
|
||||
2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID
|
||||
|
||||
---
|
||||
|
||||
## 1. 列出数据源可用来源
|
||||
|
||||
```
|
||||
dws aitable +datasource-list-sources [flags]
|
||||
```
|
||||
|
||||
列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode)
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回 `sources` 数组,每个条目包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` |
|
||||
| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) |
|
||||
| `sourceUrl` | 数据源访问链接,可选 |
|
||||
|
||||
`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 获取数据源可同步字段
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-fields [flags]
|
||||
```
|
||||
|
||||
获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 获取某审批模板的可同步字段
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 3. 创建数据源表
|
||||
|
||||
```
|
||||
dws aitable +datasource-create [flags]
|
||||
```
|
||||
|
||||
为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
|
||||
|
||||
### source-config 格式(审批类)
|
||||
|
||||
审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` |
|
||||
| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` |
|
||||
| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` |
|
||||
| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` |
|
||||
| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` |
|
||||
| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false |
|
||||
|
||||
> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。
|
||||
|
||||
按 `dataType` 选择对应的时间参数组合:
|
||||
|
||||
| dataType | 需要的时间字段 | 说明 |
|
||||
|----------|----------------|------|
|
||||
| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) |
|
||||
| `start_time` | `startDate` | 同步从某日期至今的数据 |
|
||||
| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 |
|
||||
|
||||
### auto-sync-setting 格式
|
||||
|
||||
`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
|
||||
| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 |
|
||||
| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` |
|
||||
| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` |
|
||||
| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 |
|
||||
| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
|
||||
| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false |
|
||||
|
||||
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 基本创建——同步近 30 天审批数据
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 指定日期范围创建并开启自动同步
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--auto
|
||||
|
||||
# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--field-ids fldAAA,fldBBB,fldCCC
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。
|
||||
|
||||
---
|
||||
|
||||
## 4. 更新数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-update [flags]
|
||||
```
|
||||
|
||||
更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) |
|
||||
| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 更换审批模板并调整时间范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 更新同步字段范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--field-ids fldAAA,fldDDD
|
||||
```
|
||||
|
||||
> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。
|
||||
|
||||
---
|
||||
|
||||
## 5. 触发手动同步
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync [flags]
|
||||
```
|
||||
|
||||
对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 同步单张表
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1
|
||||
|
||||
# 批量同步多张表(逗号分隔,最多 5 个)
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1,TBL2,TBL3
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。
|
||||
|
||||
---
|
||||
|
||||
## 6. 查询同步状态
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync-status [flags]
|
||||
```
|
||||
|
||||
按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 按任务 ID 查询(批量,最多 5 个)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK1,TASK2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 获取数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-config [flags]
|
||||
```
|
||||
|
||||
获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 场景一:从零接入审批数据
|
||||
|
||||
```bash
|
||||
# 0. 获取 Base ID
|
||||
dws aitable +base-search --query "我的项目表"
|
||||
|
||||
# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX
|
||||
|
||||
# 2. 查看可同步字段(可选,用于指定 field-ids)
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 3. 创建数据源表(创建后自动触发首次同步)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
# → 返回 tableId=TBL456, taskId=TASK001
|
||||
|
||||
# 4. 查询首次同步是否完成
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
|
||||
# 5. 确认配置
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
### 场景二:更换审批模板后重新同步
|
||||
|
||||
```bash
|
||||
# 1. 更新源配置(更新后自动触发一次同步)
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 2. 查询同步状态(更新后会返回新的 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK002
|
||||
```
|
||||
|
||||
### 场景三:手动触发日常同步
|
||||
|
||||
```bash
|
||||
# 仅触发同步,不修改配置
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL456
|
||||
|
||||
# 查询结果(sync 会返回 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
```
|
||||
|
||||
### 场景四:开启自动同步后确认
|
||||
|
||||
```bash
|
||||
# 1. 更新配置,开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 2. 确认配置已更新
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
# → 返回中应显示 auto=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通用选项
|
||||
|
||||
以下全局选项可在所有指令中使用:
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv |
|
||||
| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) |
|
||||
| `--fields` | 筛选输出字段(逗号分隔) |
|
||||
| `--dry-run` | 预览操作内容,不实际执行 |
|
||||
| `--profile` | 指定组织或账号 |
|
||||
| `--timeout` | HTTP 请求超时时间(秒,默认 30) |
|
||||
| `--debug` | 显示调试日志 |
|
||||
| `-v, --verbose` | 显示详细日志 |
|
||||
|
||||
### 输出过滤示例
|
||||
|
||||
```bash
|
||||
# 只取 tableId
|
||||
dws aitable +datasource-create ... --jq '.tableId'
|
||||
|
||||
# 只取同步状态
|
||||
dws aitable +datasource-sync-status ... --jq '.status'
|
||||
|
||||
# table 格式查看
|
||||
dws aitable +datasource-get-config ... -f table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。
|
||||
|
||||
2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。
|
||||
|
||||
3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。
|
||||
|
||||
4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。
|
||||
|
||||
5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。
|
||||
|
||||
6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。
|
||||
|
||||
7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。
|
||||
@@ -1,7 +1,17 @@
|
||||
{
|
||||
"generated_at": "2026-08-21T10:07:48.251313",
|
||||
"count": 418,
|
||||
"generated_at": "2026-08-24T12:22:05.108140",
|
||||
"count": 426,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aisearch",
|
||||
"command": "+search-person",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -272,6 +282,76 @@
|
||||
"semantic_delta": "更新仪表盘配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-config",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-fields",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-list-sources",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync-status",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -2263,102 +2343,134 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+by-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-followings",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-role-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-roles",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-sub-depts",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+lookup",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+me",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+org",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+resolve-dept",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-user",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+team",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
@@ -3593,6 +3705,16 @@
|
||||
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "pat",
|
||||
"command": "+browser-policy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 417
|
||||
publicShortcutCount = 425
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 455
|
||||
schemaPublishedShortcutCount = 482
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 417
|
||||
publiclyDeliveredShortcutCount = 425
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -271,6 +271,60 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"}
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(declared.Command, "+datasource-") {
|
||||
continue
|
||||
}
|
||||
if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
if !strings.Contains(example, "--source-config") {
|
||||
continue
|
||||
}
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
sourceConfig := schemaExampleFlagValue(argv, "source-config")
|
||||
if sourceConfig == "" {
|
||||
t.Errorf("%s example %q contains --source-config but has no value", canonical, example)
|
||||
continue
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil {
|
||||
t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err)
|
||||
continue
|
||||
}
|
||||
for _, member := range requiredSourceConfigMembers {
|
||||
if _, ok := cfg[member]; !ok {
|
||||
t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 3 {
|
||||
t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
@@ -282,6 +336,25 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func schemaExampleFlagValue(argv []string, name string) string {
|
||||
prefix := "--" + name + "="
|
||||
for _, argument := range argv {
|
||||
if argument == "--"+name {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(argument, prefix) {
|
||||
return strings.TrimPrefix(argument, prefix)
|
||||
}
|
||||
}
|
||||
// Value may be in the next argv entry: `--flag value` form.
|
||||
for i := 0; i < len(argv)-1; i++ {
|
||||
if argv[i] == "--"+name {
|
||||
return argv[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
|
||||
@@ -5723,7 +5723,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"top": "limit",
|
||||
"wiki-workspace-id": "workspace",
|
||||
},
|
||||
Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "page", "permission", "role", "space", "space-id", "storage-space-id"},
|
||||
Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "page", "permission", "role", "space", "space-id", "storage-space-id"},
|
||||
},
|
||||
{
|
||||
CLIPath: "drive permission remove",
|
||||
@@ -7423,7 +7423,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"top": "limit",
|
||||
"wiki-workspace-id": "workspace",
|
||||
},
|
||||
Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"},
|
||||
Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"},
|
||||
Ambiguous: []string{"id"},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -423,7 +423,7 @@
|
||||
"wiki +member-remove": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."},
|
||||
"wiki member remove": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."},
|
||||
"wiki +member-list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"This command lists members and can filter by a role list. It does not accept user filters or cursor auto-pagination, and workspace/user IDs must not be conflated."},
|
||||
"wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. The endpoint has no user filter or cursor and returns at most 50 members."}
|
||||
"wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace aliases remain native. Pagination is now cursor-based via the real --next-token (auto-aliased --cursor/--page-token) with pageSize max 50; user filters are still unsupported."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
|
||||
@@ -408,6 +408,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.insert_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
|
||||
"doc.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
|
||||
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -476,6 +477,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
|
||||
"drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -664,6 +666,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
|
||||
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
|
||||
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
|
||||
"wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches",
|
||||
|
||||
@@ -151,6 +151,24 @@ func WithOperation(operation string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
|
||||
// typed framework confirmation-gate failure carrying reason
|
||||
// confirmation_required. Downstream classifiers must pass such errors through
|
||||
// verbatim: the "re-run with --yes" semantics can only be carried by the
|
||||
// machine-readable reason, while message-text classification actively
|
||||
// misroutes them (a command path containing "permission" would be reported as
|
||||
// an auth failure, and any other wording degrades to an unclassified error).
|
||||
func IsConfirmationRequired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
return strings.TrimSpace(typed.Reason) == "confirmation_required"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WithServerKey records the server identifier associated with the failure.
|
||||
func WithServerKey(serverKey string) Option {
|
||||
return func(err *Error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -464,3 +465,34 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
t.Fatalf("normal mode should not show RPC Code, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if IsConfirmationRequired(nil) {
|
||||
t.Fatal("nil error must not report confirmation_required")
|
||||
}
|
||||
if IsConfirmationRequired(NewValidation("missing required flag")) {
|
||||
t.Fatal("plain validation error must not report confirmation_required")
|
||||
}
|
||||
plain := stderrors.New("需要用户确认")
|
||||
if IsConfirmationRequired(plain) {
|
||||
t.Fatal("message text alone must not report confirmation_required")
|
||||
}
|
||||
confirmation := NewValidation(
|
||||
"blocked",
|
||||
WithReason("confirmation_required"),
|
||||
)
|
||||
if !IsConfirmationRequired(confirmation) {
|
||||
t.Fatal("typed confirmation error must report confirmation_required")
|
||||
}
|
||||
// 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。
|
||||
wrapped := fmt.Errorf("call tool: %w", confirmation)
|
||||
if !IsConfirmationRequired(wrapped) {
|
||||
t.Fatal("wrapped confirmation error must report confirmation_required")
|
||||
}
|
||||
otherReason := NewValidation("rate limited", WithReason("rate_limit"))
|
||||
if IsConfirmationRequired(otherReason) {
|
||||
t.Fatal("other reasons must not report confirmation_required")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8422,6 +8422,416 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
sectionMoveNodeCmd,
|
||||
)
|
||||
|
||||
// ── datasource: 数据源同步管理 ──────────────────────────────
|
||||
|
||||
datasourceCmd := &cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE}
|
||||
|
||||
datasourceGetConfigCmd := &cobra.Command{
|
||||
Use: "get-config",
|
||||
Short: "获取数据源表同步配置",
|
||||
Example: ` dws aitable datasource get-config --base-id BASE_ID --table-id TABLE_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_config", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetConfigCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_config",
|
||||
CanonicalPath: "aitable.datasource_get_config",
|
||||
CLIPath: "aitable datasource get-config",
|
||||
PrimaryCLIPath: "aitable datasource get-config",
|
||||
},
|
||||
Description: "获取数据源表的同步配置信息。",
|
||||
Interface: aitableMCPInterface("get_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取数据源表的同步配置信息。",
|
||||
UseWhen: []string{"查看已有数据源表的配置详情时"},
|
||||
AvoidWhen: []string{"更新配置用 datasource update;查询同步状态用 datasource sync-status"},
|
||||
Examples: []string{"dws aitable datasource get-config --base-id <BASE_ID> --table-id <TABLE_ID>"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetConfigCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetConfigCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
|
||||
datasourceListSourcesCmd := &cobra.Command{
|
||||
Use: "list-sources",
|
||||
Short: "列出可用数据源来源",
|
||||
Example: ` dws aitable datasource list-sources --base-id BASE_ID --datasource-type OA`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("list_datasource_sources", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceListSourcesCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_list_sources",
|
||||
CanonicalPath: "aitable.datasource_list_sources",
|
||||
CLIPath: "aitable datasource list-sources",
|
||||
PrimaryCLIPath: "aitable datasource list-sources",
|
||||
},
|
||||
Description: "列出指定 Base 下可用的数据源条目。",
|
||||
Interface: aitableMCPInterface("list_datasource_sources"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出指定 Base 下可用的数据源条目(OA 审批模板等)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可用来源时"},
|
||||
AvoidWhen: []string{"获取字段结构用 datasource get-fields"},
|
||||
Examples: []string{"dws aitable datasource list-sources --base-id <BASE_ID> --datasource-type OA"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceListSourcesCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceListSourcesCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
|
||||
validateJSONObject := func(flag, raw string) error {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
return fmt.Errorf("--%s must be a valid JSON object: %w", flag, err)
|
||||
}
|
||||
if _, ok := v.(map[string]any); !ok {
|
||||
return fmt.Errorf("--%s must be a JSON object, got %T", flag, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
validateAutoSyncSetting := func(raw string) error {
|
||||
return validateJSONObject("auto-sync-setting", raw)
|
||||
}
|
||||
|
||||
datasourceGetFieldsCmd := &cobra.Command{
|
||||
Use: "get-fields",
|
||||
Short: "获取数据源可同步字段列表",
|
||||
Example: ` dws aitable datasource get-fields --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_fields", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetFieldsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_fields",
|
||||
CanonicalPath: "aitable.datasource_get_fields",
|
||||
CLIPath: "aitable datasource get-fields",
|
||||
PrimaryCLIPath: "aitable datasource get-fields",
|
||||
},
|
||||
Description: "获取指定数据源来源的可同步字段列表。",
|
||||
Interface: aitableMCPInterface("get_datasource_fields"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可同步字段以决定 field-ids 时"},
|
||||
AvoidWhen: []string{"列出可用来源用 datasource list-sources"},
|
||||
Examples: []string{`dws aitable datasource get-fields --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetFieldsCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("source-config", "", "源配置 JSON 字符串,需含 processCode、name、iconUrl、url、dataType 及对应时间字段 (必填)")
|
||||
|
||||
datasourceCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建数据源表并触发首次同步",
|
||||
Example: ` dws aitable datasource create --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
"auto": auto,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("auto-sync-setting"); v != "" {
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
return callAitableTool("create_datasource", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceCreateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_create",
|
||||
CanonicalPath: "aitable.datasource_create",
|
||||
CLIPath: "aitable datasource create",
|
||||
PrimaryCLIPath: "aitable datasource create",
|
||||
},
|
||||
Description: "为指定 Base 创建数据源表并触发首次全量同步。",
|
||||
Interface: aitableMCPInterface("create_datasource"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
UseWhen: []string{"需要将外部数据源接入 AI 表格、创建新的数据源表时"},
|
||||
AvoidWhen: []string{"已有数据源表改配置用 datasource update;仅触发同步用 datasource sync"},
|
||||
Examples: []string{`dws aitable datasource create --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "datasource-type", Property: "datasourceType", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig", Required: boolPtr(true)},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceCreateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceCreateCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceCreateCmd.Flags().String("source-config", "", "源配置 JSON 字符串,须从 list-sources 原样透传 processCode/name/iconUrl/url,并设置 dataType 及对应时间字段 (必填)")
|
||||
datasourceCreateCmd.Flags().Bool("auto", false, "是否开启自动同步,默认 false;创建新数据源表时始终下发给下游")
|
||||
datasourceCreateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时同步全部字段")
|
||||
datasourceCreateCmd.Flags().String("auto-sync-setting", "", "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新数据源表同步配置并触发同步",
|
||||
Example: ` dws aitable datasource update --base-id BASE_ID --table-id TABLE_ID --auto`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
}
|
||||
if cmd.Flags().Changed("source-config") {
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["sourceConfig"] = mustGetFlag(cmd, "source-config")
|
||||
}
|
||||
if cmd.Flags().Changed("auto") {
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs["auto"] = auto
|
||||
}
|
||||
if cmd.Flags().Changed("field-ids") {
|
||||
v := mustGetFlag(cmd, "field-ids")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if cmd.Flags().Changed("auto-sync-setting") {
|
||||
v := mustGetFlag(cmd, "auto-sync-setting")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
if !cmd.Flags().Changed("source-config") && !cmd.Flags().Changed("auto") && !cmd.Flags().Changed("field-ids") && !cmd.Flags().Changed("auto-sync-setting") {
|
||||
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 datasource sync")
|
||||
}
|
||||
return callAitableTool("update_datasource_config", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceUpdateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_update",
|
||||
CanonicalPath: "aitable.datasource_update",
|
||||
CLIPath: "aitable datasource update",
|
||||
PrimaryCLIPath: "aitable datasource update",
|
||||
},
|
||||
Description: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
Interface: aitableMCPInterface("update_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
UseWhen: []string{"需要修改已有数据源表的配置(更换模板、调整字段、开关自动同步)时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;仅触发同步用 datasource sync"},
|
||||
Examples: []string{
|
||||
"dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --auto",
|
||||
`dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig"},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceUpdateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("source-config", "", "可选。新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖,须含 processCode、name、iconUrl、url、dataType 及对应时间字段")
|
||||
datasourceUpdateCmd.Flags().Bool("auto", false, "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原设置")
|
||||
datasourceUpdateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时保持现有配置(创建时默认为全部字段)")
|
||||
datasourceUpdateCmd.Flags().String("auto-sync-setting", "", "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceSyncCmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "触发数据源表手动同步",
|
||||
Example: ` dws aitable datasource sync --base-id BASE_ID --table-ids TBL1,TBL2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableIDs := parseCSVValues(mustGetFlag(cmd, "table-ids"))
|
||||
if len(tableIDs) < 1 || len(tableIDs) > 5 {
|
||||
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
|
||||
}
|
||||
return callAitableTool("run_datasource_sync", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableIds": tableIDs,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync",
|
||||
CanonicalPath: "aitable.datasource_sync",
|
||||
CLIPath: "aitable datasource sync",
|
||||
PrimaryCLIPath: "aitable datasource sync",
|
||||
},
|
||||
Description: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回。",
|
||||
Interface: aitableMCPInterface("run_datasource_sync"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
UseWhen: []string{"需要手动触发已有数据源表的数据同步时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;更新配置用 datasource update"},
|
||||
Examples: []string{"dws aitable datasource sync --base-id <BASE_ID> --table-ids TBL1,TBL2"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-ids", Property: "tableIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncCmd.Flags().String("table-ids", "", "待触发同步的数据源表 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceSyncStatusCmd := &cobra.Command{
|
||||
Use: "sync-status",
|
||||
Short: "按任务 ID 查询数据源同步任务状态",
|
||||
Example: ` dws aitable datasource sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "task-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := parseCSVValues(mustGetFlag(cmd, "task-ids"))
|
||||
if len(ids) < 1 || len(ids) > 5 {
|
||||
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(ids))
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"taskIds": ids,
|
||||
}
|
||||
return callAitableTool("get_datasource_sync_status", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncStatusCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync_status",
|
||||
CanonicalPath: "aitable.datasource_sync_status",
|
||||
CLIPath: "aitable datasource sync-status",
|
||||
PrimaryCLIPath: "aitable datasource sync-status",
|
||||
},
|
||||
Description: "按任务 ID 查询数据源同步任务状态(RUNNING/FINISHED/FAILED)。",
|
||||
Interface: aitableMCPInterface("get_datasource_sync_status"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按任务 ID 批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
UseWhen: []string{"触发同步后需要按任务 ID 查询任务是否完成时"},
|
||||
AvoidWhen: []string{"触发同步用 datasource sync"},
|
||||
Examples: []string{"dws aitable datasource sync-status --base-id <BASE_ID> --table-id <TABLE_ID> --task-ids TASK1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "task-ids", Property: "taskIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncStatusCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("task-ids", "", "待查询的同步任务 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceCmd.AddCommand(
|
||||
datasourceGetConfigCmd, datasourceListSourcesCmd, datasourceGetFieldsCmd,
|
||||
datasourceCreateCmd, datasourceUpdateCmd,
|
||||
datasourceSyncCmd, datasourceSyncStatusCmd,
|
||||
)
|
||||
|
||||
// 组装 aitable 命令树
|
||||
root.AddCommand(
|
||||
baseCmd, tableCmd, fieldCmd,
|
||||
@@ -8432,6 +8842,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
attachmentCmd, templateCmd,
|
||||
advpermCmd,
|
||||
sectionCmd,
|
||||
datasourceCmd,
|
||||
)
|
||||
|
||||
// 批量注册 --base 作为 --base-id 的隐藏别名
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type aitableDatasourceCaller struct {
|
||||
calls []aitableTestCall
|
||||
}
|
||||
|
||||
func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableDatasourceCaller) Format() string { return "json" }
|
||||
func (*aitableDatasourceCaller) DryRun() bool { return false }
|
||||
func (*aitableDatasourceCaller) Fields() string { return "" }
|
||||
func (*aitableDatasourceCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableDatasourceCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "datasource"}, args...)
|
||||
|
||||
root := newAitableCommand()
|
||||
root.SetArgs(append([]string{"datasource"}, args...))
|
||||
return caller, root.Execute()
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5,T6")
|
||||
if err == nil || !strings.Contains(err.Error(), "1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 table-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1")
|
||||
if err != nil {
|
||||
t.Fatalf("single table-id should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires 1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 task-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "task-ids") {
|
||||
t.Fatalf("error = %v, want task-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-config",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err != nil {
|
||||
t.Fatalf("get-config should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["tableId"] != "TBL456" {
|
||||
t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "list-sources",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err != nil {
|
||||
t.Fatalf("list-sources should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "datasource-type") {
|
||||
t.Fatalf("error = %v, want datasource-type required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("get-fields should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false when not provided", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithAuto(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("create with --auto should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
|
||||
t.Fatalf("error = %v, want at least one config change required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when no changes provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto only should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with source-config should succeed: %v", err)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
if _, ok := caller.calls[0].args["auto"]; ok {
|
||||
t.Fatalf("auto should not be sent when --auto is omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto=false")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto=false should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty field-ids is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -39,7 +41,16 @@ func TestMain(m *testing.M) {
|
||||
if os.Getenv(helpersShellStubEnv) == "1" {
|
||||
os.Exit(runHelpersShellStub())
|
||||
}
|
||||
// Cobra's Windows pre-exec hook walks the process table on every Execute*
|
||||
// call to detect Explorer launches. Helpers tests execute command trees
|
||||
// thousands of times, and none of those in-process invocations can be an
|
||||
// Explorer launch, so keep that production-only check out of the test
|
||||
// process. This also prevents every new exhaustive harness from having to
|
||||
// remember a test-local override.
|
||||
originalMousetrapHelpText := cobra.MousetrapHelpText
|
||||
cobra.MousetrapHelpText = ""
|
||||
code := m.Run()
|
||||
cobra.MousetrapHelpText = originalMousetrapHelpText
|
||||
if helpersShellStubBaseDir != "" {
|
||||
_ = os.RemoveAll(helpersShellStubBaseDir)
|
||||
}
|
||||
|
||||
@@ -504,3 +504,147 @@ func TestSheetConfirmationGuardCoversEveryProtectedLeaf(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// executeGuardedPermissionRemoveCommand mirrors executeGuardedMutationCommand
|
||||
// but additionally rewrites os.Args so resolveProductID routes doc/wiki
|
||||
// auto-routed tools to their real MCP server (doc → doc, wiki → wiki).
|
||||
func executeGuardedPermissionRemoveCommand(t *testing.T, caller *guardedMutationCaller, build func() *cobra.Command, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
root := build()
|
||||
os.Args = append([]string{"dws", root.Name()}, args...)
|
||||
previousDeps := deps
|
||||
t.Cleanup(func() { deps = previousDeps })
|
||||
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
if root.PersistentFlags().Lookup("yes") == nil {
|
||||
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
|
||||
}
|
||||
if root.PersistentFlags().Lookup("dry-run") == nil {
|
||||
root.PersistentFlags().Bool("dry-run", false, "preview without executing")
|
||||
}
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
if root.InOrStdin() == os.Stdin {
|
||||
root.SetIn(strings.NewReader(""))
|
||||
}
|
||||
root.SetArgs(args)
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall pins the
|
||||
// destructive confirmation gate on the three batch remove entry points
|
||||
// (drive/doc permission remove, wiki member remove). Removing up to 30
|
||||
// USER/DEPT/CONVERSATION/TAG members in one call can revoke access for whole
|
||||
// departments, chats, or role groups, so every invocation — new --members
|
||||
// format and legacy --users alike — must confirm first: without --yes the
|
||||
// command fails with the typed confirmation_required error and performs zero
|
||||
// MCP calls; with --yes it dispatches exactly one call with the complete,
|
||||
// precise tool arguments; --dry-run previews without any call.
|
||||
func TestPermissionMemberRemoveRequiresConfirmationBeforeToolCall(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
build func() *cobra.Command
|
||||
args []string
|
||||
product string
|
||||
tool string
|
||||
wantArgs map[string]any
|
||||
}{
|
||||
{
|
||||
name: "drive permission remove --members",
|
||||
build: newDriveCommand,
|
||||
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"CONVERSATION","id":"cid1"},{"type":"TAG","id":"t1","corpId":"c1"}]`},
|
||||
product: "doc",
|
||||
tool: "remove_permission",
|
||||
wantArgs: map[string]any{
|
||||
"nodeId": "n1",
|
||||
"members": []map[string]any{
|
||||
{"type": "CONVERSATION", "id": "cid1"},
|
||||
{"type": "TAG", "id": "t1", "corpId": "c1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "drive permission remove --users",
|
||||
build: newDriveCommand,
|
||||
args: []string{"permission", "remove", "--node", "n1", "--users", "uid1,uid2"},
|
||||
product: "doc",
|
||||
tool: "remove_permission",
|
||||
wantArgs: map[string]any{
|
||||
"nodeId": "n1",
|
||||
"userIds": []string{"uid1", "uid2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "doc permission remove --members",
|
||||
build: newDocCommand,
|
||||
args: []string{"permission", "remove", "--node", "n1", "--members", `[{"type":"USER","id":"u1","corpId":"c1"}]`},
|
||||
product: "doc",
|
||||
tool: "remove_permission",
|
||||
wantArgs: map[string]any{
|
||||
"nodeId": "n1",
|
||||
"members": []map[string]any{{"type": "USER", "id": "u1", "corpId": "c1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wiki member remove --members",
|
||||
build: newWikiCommand,
|
||||
args: []string{"member", "remove", "--workspace", "ws1", "--members", `[{"type":"DEPT","id":"d1","corpId":"c1"}]`},
|
||||
product: "wiki",
|
||||
tool: "remove_member",
|
||||
wantArgs: map[string]any{
|
||||
"workspaceId": "ws1",
|
||||
"members": []map[string]any{{"type": "DEPT", "id": "d1", "corpId": "c1"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wiki member remove --users",
|
||||
build: newWikiCommand,
|
||||
args: []string{"member", "remove", "--workspace", "ws1", "--users", "uid1"},
|
||||
product: "wiki",
|
||||
tool: "remove_member",
|
||||
wantArgs: map[string]any{
|
||||
"workspaceId": "ws1",
|
||||
"userIds": []string{"uid1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// 1) 未确认:typed confirmation_required 错误 + 零 MCP 调用。
|
||||
caller := &guardedMutationCaller{}
|
||||
err := executeGuardedPermissionRemoveCommand(t, caller, tc.build, tc.args...)
|
||||
requireTypedConfirmationError(t, err)
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls before confirmation = %#v, want none", caller.calls)
|
||||
}
|
||||
|
||||
// 2) 明确确认(--yes):恰好一次调用,参数完整且精确。
|
||||
confirmed := &guardedMutationCaller{}
|
||||
if err := executeGuardedPermissionRemoveCommand(t, confirmed, tc.build, append(append([]string(nil), tc.args...), "--yes")...); err != nil {
|
||||
t.Fatalf("confirmed remove returned error: %v", err)
|
||||
}
|
||||
if len(confirmed.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want exactly 1: %+v", len(confirmed.calls), confirmed.calls)
|
||||
}
|
||||
call := confirmed.calls[0]
|
||||
if call.productID != tc.product || call.toolName != tc.tool {
|
||||
t.Fatalf("tool call = %s/%s, want %s/%s", call.productID, call.toolName, tc.product, tc.tool)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, tc.wantArgs) {
|
||||
t.Fatalf("tool args = %#v, want %#v", call.args, tc.wantArgs)
|
||||
}
|
||||
|
||||
// 3) --dry-run 预览:不触发任何 MCP 调用。
|
||||
dryRun := &guardedMutationCaller{dryRun: true}
|
||||
if err := executeGuardedPermissionRemoveCommand(t, dryRun, tc.build, append(append([]string(nil), tc.args...), "--dry-run")...); err != nil {
|
||||
t.Fatalf("dry-run remove returned error: %v", err)
|
||||
}
|
||||
if len(dryRun.calls) != 0 {
|
||||
t.Fatalf("dry-run tool calls = %#v, want none", dryRun.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+225
-49
@@ -3556,11 +3556,20 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
permissionAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加文档协作者",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为指定文档(或文件夹/文件)添加一个或多个协作成员,并授予指定角色。
|
||||
|
||||
通过 --user 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色(大小写不敏感):
|
||||
MANAGER 管理员,可读写、管理成员
|
||||
EDITOR 编辑者,可查看、编辑、上传内容
|
||||
DOWNLOADER 查看下载者,可查看并下载内容
|
||||
@@ -3568,29 +3577,46 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口添加。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。
|
||||
|
||||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||||
dws contact user search --keyword "姓名"`,
|
||||
Example: ` dws doc permission add --node DOC_ID --users uid1 --role READER
|
||||
dws doc permission add --node DOC_ID --users uid1,uid2,uid3 --role EDITOR
|
||||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID`,
|
||||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID
|
||||
dws doc permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws doc permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3627,7 +3653,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Examples: []string{"dws doc permission add --node <DOC_ID> --users uid1 --role READER --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -3636,18 +3664,31 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
})
|
||||
|
||||
permissionAddCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
permissionAddCmd.Flags().String("users", "", "被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
permissionAddCmd.Flags().String("user", "", "")
|
||||
_ = permissionAddCmd.Flags().MarkHidden("user")
|
||||
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
permissionAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
permissionAddCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
permissionAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
permissionAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
|
||||
|
||||
permissionUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新文档协作者权限",
|
||||
Long: `更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新指定节点已有协作者的权限角色。
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
支持的角色 (--role)(大小写不敏感):
|
||||
MANAGER 管理员
|
||||
EDITOR 编辑者
|
||||
DOWNLOADER 查看下载者
|
||||
@@ -3657,26 +3698,43 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
- OWNER 角色不可通过此接口变更。
|
||||
- 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。
|
||||
- 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
|
||||
|
||||
仅可更新已存在协作关系的用户,新增协作者请使用 dws doc permission add。`,
|
||||
Example: ` dws doc permission update --node DOC_ID --users uid1 --role EDITOR
|
||||
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER`,
|
||||
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER
|
||||
dws doc permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws doc permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3710,7 +3768,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Examples: []string{"dws doc permission update --node <DOC_ID> --users uid1 --role EDITOR --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -3719,11 +3779,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
})
|
||||
|
||||
permissionUpdateCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
permissionUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
permissionUpdateCmd.Flags().String("user", "", "")
|
||||
_ = permissionUpdateCmd.Flags().MarkHidden("user")
|
||||
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
permissionUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
permissionUpdateCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
permissionUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
permissionUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
|
||||
|
||||
permissionListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -3731,11 +3793,14 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Short: "查询文档协作者列表",
|
||||
Long: `查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。
|
||||
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
|
||||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围。`,
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
|
||||
操作者需满足该节点配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。`,
|
||||
Example: ` dws doc permission list --node DOC_ID
|
||||
dws doc permission list --node DOC_ID --limit 100
|
||||
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`,
|
||||
dws doc permission list --node DOC_ID --limit 50
|
||||
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR
|
||||
dws doc permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -3744,14 +3809,13 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
}
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -3789,50 +3853,75 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
|
||||
},
|
||||
})
|
||||
|
||||
permissionListCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
|
||||
permissionListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = permissionListCmd.Flags().MarkHidden("max-results")
|
||||
permissionListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
permissionListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
|
||||
permissionListCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
|
||||
permissionRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "移除文档协作者权限",
|
||||
Long: `从指定节点移除一个或多个协作成员的权限(仅支持 USER 类型)。
|
||||
Long: `从指定节点移除一个或多个协作成员的权限。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
移除后相关用户将无法通过该节点的直接授权访问内容(若有父节点继承权限则仍可通过继承权限访问)。
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口移除。
|
||||
- 操作者需在该节点具备 EDITOR 及以上角色(OWNER / MANAGER / EDITOR)。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。
|
||||
|
||||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||||
dws contact user search --keyword "姓名"`,
|
||||
Example: ` dws doc permission remove --node DOC_ID --users uid1
|
||||
dws doc permission remove --node DOC_ID --users uid1,uid2,uid3
|
||||
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1`,
|
||||
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1
|
||||
dws doc permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"userIds": userIds,
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, true)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
} else {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3842,8 +3931,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
}
|
||||
DeclareLeafMetadata(permissionRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
|
||||
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
|
||||
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -3866,6 +3958,7 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Examples: []string{"dws doc permission remove --node <DOC_ID> --users uid1 --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -3873,9 +3966,10 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
},
|
||||
})
|
||||
permissionRemoveCmd.Flags().String("node", "", "目标节点的标识(文档/文件夹/文件),支持传入 URL 或 ID (必填)")
|
||||
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
permissionRemoveCmd.Flags().String("users", "", "被移除权限的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
permissionRemoveCmd.Flags().String("user", "", "")
|
||||
_ = permissionRemoveCmd.Flags().MarkHidden("user")
|
||||
permissionRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
|
||||
permissionRemoveCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL(选填,仅用于辅助构造返回的 docUrl)")
|
||||
|
||||
// permission 子命令的 --node 隐藏别名
|
||||
@@ -5199,3 +5293,85 @@ func collectUserIDs(cmd *cobra.Command) ([]string, error) {
|
||||
}
|
||||
return userIds, nil
|
||||
}
|
||||
|
||||
// collectMembers parses the --members JSON array flag (new format), returning
|
||||
// a members list ready to embed in MCP tool args. Supports USER/DEPT/CONVERSATION/TAG
|
||||
// member types, each carrying an independent roleId.
|
||||
//
|
||||
// When onlyTypeID is true (remove operations), roleId is not required —
|
||||
// only type and id are needed.
|
||||
func collectMembers(cmd *cobra.Command, onlyTypeID bool) ([]map[string]any, error) {
|
||||
raw := mustGetFlag(cmd, "members")
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var members []map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &members); err != nil {
|
||||
return nil, fmt.Errorf("--members JSON 解析失败: %w", err)
|
||||
}
|
||||
if len(members) == 0 {
|
||||
return nil, fmt.Errorf("--members 不能为空数组")
|
||||
}
|
||||
if len(members) > 30 {
|
||||
return nil, fmt.Errorf("--members 单次最多 30 个成员,超出请分批调用")
|
||||
}
|
||||
for i, m := range members {
|
||||
mt, ok := m["type"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("--members[%d] 缺少必填字段 type", i)
|
||||
}
|
||||
if _, ok := m["id"].(string); !ok {
|
||||
return nil, fmt.Errorf("--members[%d] 缺少必填字段 id", i)
|
||||
}
|
||||
// USER/DEPT/TAG 类型需携带 corpId 用于确定成员所属组织,CONVERSATION 类型选填
|
||||
if (mt == "USER" || mt == "DEPT" || mt == "TAG") && m["corpId"] == nil {
|
||||
return nil, fmt.Errorf("--members[%d] 类型 %s 需携带 corpId 以确定所属组织", i, mt)
|
||||
}
|
||||
if !onlyTypeID {
|
||||
if _, ok := m["roleId"].(string); !ok {
|
||||
return nil, fmt.Errorf("--members[%d] 缺少必填字段 roleId", i)
|
||||
}
|
||||
if r, ok := m["roleId"].(string); ok {
|
||||
m["roleId"] = normalizePermissionRole(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
return members, nil
|
||||
}
|
||||
|
||||
// validateMembersExclusivity ensures --members (new format) and --users (legacy
|
||||
// format) are not used simultaneously. Exactly one must be provided.
|
||||
func validateMembersExclusivity(cmd *cobra.Command) error {
|
||||
hasMembers := mustGetFlag(cmd, "members") != ""
|
||||
hasUsers := flagOrFallback(cmd, "users", "user") != ""
|
||||
if hasMembers && hasUsers {
|
||||
return fmt.Errorf("--members 与 --users 互斥,不可同时传递")
|
||||
}
|
||||
if !hasMembers && !hasUsers {
|
||||
return fmt.Errorf("必须指定 --members(新格式)或 --users(旧格式)之一")
|
||||
}
|
||||
if hasMembers && mustGetFlag(cmd, "role") != "" {
|
||||
return fmt.Errorf("--members 新格式下不需要 --role,每个 member 携带独立 roleId")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// permissionPageSizeFromFlags resolves and validates the page size for the
|
||||
// permission / member list commands. Both --limit and the hidden
|
||||
// --max-results alias map to the server pageSize, whose accepted range is
|
||||
// 1..50 (the backend rejects pageSize > 50 with
|
||||
// invalidRequest.inputArgs.invalid, and non-positive values are invalid).
|
||||
// It returns (size, true, nil) when a page size was explicitly provided.
|
||||
func permissionPageSizeFromFlags(cmd *cobra.Command) (int, bool, error) {
|
||||
for _, name := range []string{"limit", "max-results"} {
|
||||
if !cmd.Flags().Changed(name) {
|
||||
continue
|
||||
}
|
||||
size, _ := cmd.Flags().GetInt(name)
|
||||
if size < 1 || size > 50 {
|
||||
return 0, false, fmt.Errorf("--%s 取值范围为 1..50(服务端 pageSize 上限 50),当前值 %d", name, size)
|
||||
}
|
||||
return size, true, nil
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
+141
-44
@@ -2042,27 +2042,55 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加协作者",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为文档空间节点添加协作成员并授予指定角色。
|
||||
|
||||
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
|
||||
省略 --notify 时 CLI 不向服务端发送该字段,服务端按不通知处理;需要通知请显式传 --notify。`,
|
||||
Example: ` dws drive permission add --node DOC_ID --users uid1 --role READER
|
||||
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR`,
|
||||
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR
|
||||
dws drive permission add --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"}]' --notify
|
||||
dws drive permission add --node DOC_ID --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2103,7 +2131,9 @@ func newDriveCommand() *cobra.Command {
|
||||
Examples: []string{"dws drive permission add --node <ID> --users uid1,uid2 --role READER --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -2111,35 +2141,64 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
drivePermAddCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
drivePermAddCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
|
||||
drivePermAddCmd.Flags().String("user", "", "")
|
||||
_ = drivePermAddCmd.Flags().MarkHidden("user")
|
||||
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
|
||||
drivePermAddCmd.Flags().String("role", "", "角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
|
||||
drivePermAddCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
drivePermAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
drivePermAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
|
||||
|
||||
drivePermUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新协作者权限",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新文档空间节点已有协作者的权限角色。
|
||||
|
||||
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
|
||||
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR`,
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
支持的角色: MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。`,
|
||||
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR
|
||||
dws drive permission update --node DOC_ID --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws drive permission update --node DOC_ID --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2176,7 +2235,9 @@ func newDriveCommand() *cobra.Command {
|
||||
Examples: []string{"dws drive permission update --node <ID> --users uid1 --role EDITOR --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -2184,33 +2245,39 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
drivePermUpdateCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
drivePermUpdateCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
|
||||
drivePermUpdateCmd.Flags().String("user", "", "")
|
||||
_ = drivePermUpdateCmd.Flags().MarkHidden("user")
|
||||
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (必填)")
|
||||
drivePermUpdateCmd.Flags().String("role", "", "新角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)")
|
||||
drivePermUpdateCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
drivePermUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
drivePermUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
|
||||
|
||||
drivePermListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Aliases: []string{"ls"},
|
||||
Short: "查询协作者列表",
|
||||
Long: `查询文档空间节点的协作者列表。`,
|
||||
Long: `查询文档空间节点的协作者列表,支持分页和角色过滤。
|
||||
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount、hasMore 和 nextToken。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。`,
|
||||
Example: ` dws drive permission list --node DOC_ID
|
||||
dws drive permission list --node DOC_ID --limit 100 --filter-role MANAGER,EDITOR`,
|
||||
dws drive permission list --node DOC_ID --limit 50 --filter-role MANAGER,EDITOR
|
||||
dws drive permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
}
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -2251,38 +2318,63 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
|
||||
},
|
||||
})
|
||||
drivePermListCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
|
||||
drivePermListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = drivePermListCmd.Flags().MarkHidden("max-results")
|
||||
drivePermListCmd.Flags().String("filter-role", "", "按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
drivePermListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
|
||||
drivePermListCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
drivePermRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Aliases: []string{"rm"},
|
||||
Short: "移除协作者权限",
|
||||
Long: `从文档空间节点移除协作成员的权限。`,
|
||||
Long: `从文档空间节点移除协作成员的权限。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId`,
|
||||
Example: ` dws drive permission remove --node DOC_ID --users uid1
|
||||
dws drive permission remove --node DOC_ID --users uid1,uid2`,
|
||||
dws drive permission remove --node DOC_ID --users uid1,uid2
|
||||
dws drive permission remove --node DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"userIds": userIds,
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, true)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
} else {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2292,8 +2384,11 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
DeclareLeafMetadata(drivePermRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
|
||||
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
|
||||
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -2319,6 +2414,7 @@ func newDriveCommand() *cobra.Command {
|
||||
Examples: []string{"dws drive permission remove --node <ID> --users uid1 --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "node", Property: "nodeId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -2326,9 +2422,10 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
})
|
||||
drivePermRemoveCmd.Flags().String("node", "", "目标节点 ID 或 URL (必填)")
|
||||
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (必填)")
|
||||
drivePermRemoveCmd.Flags().String("users", "", "用户 userId 列表,逗号分隔 (旧格式)")
|
||||
drivePermRemoveCmd.Flags().String("user", "", "")
|
||||
_ = drivePermRemoveCmd.Flags().MarkHidden("user")
|
||||
drivePermRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
|
||||
drivePermRemoveCmd.Flags().String("workspace", "", "知识库 ID (选填)")
|
||||
|
||||
// permission 子命令 --node 隐藏别名(保持与迁移前 doc 命令一致)
|
||||
|
||||
+145
-2
@@ -4,6 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -134,6 +136,13 @@ func WrapErrorWithOperation(err error, operation string) error {
|
||||
if _, ok := err.(*PATError); ok {
|
||||
return err
|
||||
}
|
||||
// 框架确认门禁错误(deferred ConfirmSafety 从 CallTool 返回)必须原样透传:
|
||||
// “加 --yes 重试”的语义只能由 reason=confirmation_required 表达,文本分类会
|
||||
// 误路由(commandPath 含 "permission" 的命令会被判成 AUTH_PERMISSION_DENIED,
|
||||
// 其余则丢失 reason 退化为 UNCLASSIFIED)。
|
||||
if apperrors.IsConfirmationRequired(err) {
|
||||
return err
|
||||
}
|
||||
msg := err.Error()
|
||||
|
||||
// File lock timeout (cross-process lock)
|
||||
@@ -254,6 +263,24 @@ func WrapErrorWithOperation(err error, operation string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端返回的用户/成员参数校验错误(如“用户不存在”、“不属于当前组织”),
|
||||
// 文本含“不存在”但并非文档资源缺失,需在 RESOURCE_NOT_FOUND 分类之前拦截,
|
||||
// 透传服务端原始错误信息。
|
||||
if errContainsAny(msg, "用户不存在", "不属于当前组织", "成员不存在") {
|
||||
errMsg := msg
|
||||
var body map[string]any
|
||||
if json.Unmarshal([]byte(msg), &body) == nil {
|
||||
errMsg = businessErrorDisplayMessage(body, msg)
|
||||
}
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: errMsg,
|
||||
Suggestion: "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId",
|
||||
Operation: operation,
|
||||
Cause: err,
|
||||
}
|
||||
}
|
||||
|
||||
// Resource not found
|
||||
if errContainsAny(msg, "not found", "不存在", "资源不存在") {
|
||||
suggestion := "Check if the resource exists or if your account has permission"
|
||||
@@ -379,21 +406,98 @@ func suggestForBusinessErrorText(text string) string {
|
||||
return "请确认邮箱地址正确,查看可用邮箱: dws mail mailbox list"
|
||||
case strings.Contains(text, "频率超限") || strings.Contains(text, "rate limit"):
|
||||
return "API rate limit exceeded, wait a moment and retry"
|
||||
case strings.Contains(text, "用户不存在") || strings.Contains(text, "不属于当前组织") ||
|
||||
strings.Contains(text, "成员不存在"):
|
||||
return "请检查用户 ID 或组织信息是否正确,跨组织用户需通过 --members 格式携带 corpId"
|
||||
case strings.Contains(text, "未找到指定工具") || strings.Contains(text, "MCP不存在"):
|
||||
return "后端工具未注册或已下线;这不是参数格式问题。请升级到包含该工具注册的后端/静态端点版本,或改用当前可用替代命令。"
|
||||
case strings.Contains(text, "参数错误") || strings.Contains(text, "param error"):
|
||||
return "Check input parameters. Use --help for available flags"
|
||||
case strings.Contains(text, "无权限访问") || strings.Contains(text, "没有访问权限") ||
|
||||
strings.Contains(text, "没有权限") || strings.Contains(text, "权限不足") ||
|
||||
strings.Contains(text, "no permission") || strings.Contains(text, "permission denied") ||
|
||||
strings.Contains(text, "FORBIDDEN"):
|
||||
return permissionDeniedSuggestion(text)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// documentPermissionServerCodes are permission-denied codes that are
|
||||
// demonstrably drive-specific (their identity carries the forbidden.* domain
|
||||
// prefix). Only these (plus the document/wiki role-threshold message wording)
|
||||
// justify the dws drive permission apply-* guidance. Generic code names such
|
||||
// as NO_PERMISSION and FORBIDDEN are deliberately excluded: attendance
|
||||
// (get-self-setting) and event-subscription tools have been observed returning
|
||||
// NO_PERMISSION, so keying guidance on it would mislead non-document products
|
||||
// exactly the way this classification tries to avoid.
|
||||
var documentPermissionServerCodes = map[string]bool{
|
||||
"forbidden.no.auth": true,
|
||||
"forbidden.accessDenied": true,
|
||||
}
|
||||
|
||||
// documentPermissionErrorText reports whether a permission-denied message is
|
||||
// specific to document/wiki permission (drive/doc/wiki), e.g. the role
|
||||
// threshold wording emitted by the member-management APIs or the document
|
||||
// permission error codes embedded in the message text.
|
||||
func documentPermissionErrorText(text string) bool {
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(text)
|
||||
return strings.Contains(lower, "forbidden.no.auth") ||
|
||||
strings.Contains(lower, "forbidden.accessdenied") ||
|
||||
strings.Contains(text, "需要您具备") || strings.Contains(text, "及以上角色")
|
||||
}
|
||||
|
||||
// isDocumentPermissionError reports whether a permission-denied body is
|
||||
// specific to document/wiki access, where applying for access via
|
||||
// dws drive permission apply-* is the actionable next step.
|
||||
func isDocumentPermissionError(body map[string]any) bool {
|
||||
for _, key := range []string{"code", "errorCode", "server_error_code"} {
|
||||
if code, ok := body[key].(string); ok && documentPermissionServerCodes[code] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return documentPermissionErrorText(businessErrorMessage(body))
|
||||
}
|
||||
|
||||
// genericPermissionSuggestion is the product-neutral suggestion for
|
||||
// permission-denied errors that are not specific to document/wiki access.
|
||||
const genericPermissionSuggestion = "Verify your account has permission for this resource"
|
||||
|
||||
// permissionDeniedSuggestion picks between document apply-permission guidance
|
||||
// and a product-neutral hint: only document/wiki-specific errors point at
|
||||
// dws drive permission apply-*, so other products never get a misleading
|
||||
// document-permission suggestion.
|
||||
func permissionDeniedSuggestion(text string) string {
|
||||
if documentPermissionErrorText(text) {
|
||||
return permissionApplyGuidance
|
||||
}
|
||||
return genericPermissionSuggestion
|
||||
}
|
||||
|
||||
// permissionDeniedSuggestionFor resolves the suggestion for a permission-denied
|
||||
// body: document/wiki-specific errors get the apply-permission guidance; other
|
||||
// products keep their product-specific suggestion (e.g. the mail mailbox hint)
|
||||
// or fall back to the product-neutral hint.
|
||||
func permissionDeniedSuggestionFor(body map[string]any) string {
|
||||
if isDocumentPermissionError(body) {
|
||||
return permissionApplyGuidance
|
||||
}
|
||||
if suggestion := suggestForBusinessError(body); suggestion != "" {
|
||||
return suggestion
|
||||
}
|
||||
return genericPermissionSuggestion
|
||||
}
|
||||
|
||||
// ClassifyToolResultContent checks a raw MCP tool result content map for
|
||||
// DWS gateway auth errors and PAT permission error codes. This is used as the
|
||||
// edition.Hooks.ClassifyToolResult callback so the framework's runner returns
|
||||
// a typed error before its generic business-error classification.
|
||||
//
|
||||
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission.
|
||||
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission >
|
||||
// no-permission guidance.
|
||||
func ClassifyToolResultContent(content map[string]any) error {
|
||||
if _, ok := getDWSGatewayErrorCode(content); ok {
|
||||
raw, _ := json.Marshal(content)
|
||||
@@ -408,6 +512,20 @@ func ClassifyToolResultContent(content map[string]any) error {
|
||||
return &PATError{RawJSON: cleanPATJSON(content, code)}
|
||||
}
|
||||
}
|
||||
|
||||
// Permission-denied business errors: surface apply-permission guidance before
|
||||
// the framework's generic rendering swallows it. Guidance is limited to
|
||||
// document/wiki-specific signals (forbidden.* codes or role-threshold
|
||||
// wording); other products keep the product-neutral suggestion.
|
||||
if isNoPermissionError(content) {
|
||||
suggestion := permissionDeniedSuggestionFor(content)
|
||||
raw, _ := json.Marshal(content)
|
||||
return &CLIError{
|
||||
Code: CodeAuthPermission,
|
||||
Message: businessErrorDisplayMessage(content, string(raw)),
|
||||
Suggestion: suggestion,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -419,6 +537,23 @@ var patNoPermissionCodes = map[string]bool{
|
||||
"PAT_HIGH_RISK_NO_PERMISSION": true,
|
||||
}
|
||||
|
||||
// noPermissionServerCodes are business-level permission-denied codes (from the
|
||||
// MCP server / gateway) that should surface apply-permission guidance instead of
|
||||
// being swallowed by the framework's generic business-error rendering.
|
||||
var noPermissionServerCodes = map[string]bool{
|
||||
"NO_PERMISSION": true,
|
||||
"FORBIDDEN": true,
|
||||
"forbidden.no.auth": true,
|
||||
"forbidden.accessDenied": true,
|
||||
}
|
||||
|
||||
// permissionApplySteps lists the two-step commands to apply for document access.
|
||||
const permissionApplySteps = " 1) 查可申请角色与审批人: dws drive permission apply-info --node <节点ID或URL>\n" +
|
||||
" 2) 选定角色与审批人后发起申请: dws drive permission apply --node <节点ID或URL> --role <EDITOR|DOWNLOADER|READER> --users <审批人userId>"
|
||||
|
||||
// permissionApplyGuidance is the standard suggestion for permission-denied errors.
|
||||
const permissionApplyGuidance = "若你对该文档/知识库暂无访问权限,可发起权限申请:\n" + permissionApplySteps
|
||||
|
||||
// ClassifyMCPResponseText classifies a text response returned by an MCP tool call.
|
||||
// Returns a typed error for known gateway auth failures, PAT interceptions,
|
||||
// and business-level errors embedded in HTTP-200 JSON bodies.
|
||||
@@ -452,10 +587,18 @@ func ClassifyMCPResponseText(text string) error {
|
||||
}
|
||||
}
|
||||
|
||||
if isNoPermissionError(body) {
|
||||
return &CLIError{
|
||||
Code: CodeAuthPermission,
|
||||
Message: text,
|
||||
Suggestion: permissionDeniedSuggestionFor(body),
|
||||
}
|
||||
}
|
||||
|
||||
if isBusinessError(body) {
|
||||
return &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Message: text,
|
||||
Message: businessErrorDisplayMessage(body, text),
|
||||
Suggestion: suggestForBusinessError(body),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +123,19 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
|
||||
"User has no permission to access this email": "mailbox list",
|
||||
"频率超限": "rate limit",
|
||||
"rate limit": "rate limit",
|
||||
"用户不存在": "--members",
|
||||
"不属于当前组织": "--members",
|
||||
"成员不存在": "--members",
|
||||
"未找到指定工具": "未注册",
|
||||
"MCP不存在": "未注册",
|
||||
"参数错误": "input parameters",
|
||||
"param error": "input parameters",
|
||||
// 非文档类权限文本不再给出 drive permission apply 指引。
|
||||
"无权限访问": "Verify your account",
|
||||
"权限不足": "Verify your account",
|
||||
// 权限关键词叠加文档特征(文档权限码/角色门槛)时保留 apply 指引。
|
||||
"您没有权限访问该文档 (forbidden.no.auth)": "权限申请",
|
||||
"无权限访问:需要您具备 MANAGER 及以上角色": "权限申请",
|
||||
}
|
||||
for input, want := range suggestions {
|
||||
if got := suggestForBusinessErrorText(input); !strings.Contains(got, want) {
|
||||
@@ -152,17 +161,76 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
|
||||
t.Errorf("PAT key %s classified as %#v", key, err)
|
||||
}
|
||||
}
|
||||
// forbidden.accessDenied(新接口权限不足错误码)应分类为 AUTH_PERMISSION_DENIED
|
||||
// 并携带权限申请指引,而不是被 generic business-error 渲染吞掉。
|
||||
for _, key := range []string{"code", "errorCode", "server_error_code"} {
|
||||
err := ClassifyToolResultContent(map[string]any{key: "forbidden.accessDenied", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
|
||||
cli, ok := err.(*CLIError)
|
||||
if !ok || cli.Code != CodeAuthPermission {
|
||||
t.Errorf("accessDenied key %s classified as %#v, want AUTH_PERMISSION_DENIED", key, err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cli.Message, "需要您具备") || !strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("accessDenied guidance missing: message=%q suggestion=%q", cli.Message, cli.Suggestion)
|
||||
}
|
||||
}
|
||||
// 带 forbidden.* 域名的权限码是 drive 专属,单独出现即可拿到 apply 指引。
|
||||
for _, code := range []string{"forbidden.no.auth"} {
|
||||
err := ClassifyToolResultContent(map[string]any{"code": code, "success": false, "errorMsg": "no permission"})
|
||||
cli, ok := err.(*CLIError)
|
||||
if !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("document code %s classified as %#v, want AUTH_PERMISSION_DENIED with apply guidance", code, err)
|
||||
}
|
||||
}
|
||||
// 通用码名 NO_PERMISSION 不再单独触发文档 apply 指引(回归测试):
|
||||
// 考勤 get-self-setting、事件订阅等非文档工具也会返回该码。
|
||||
nonDoc := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "no permission"})
|
||||
if cli, ok := nonDoc.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
|
||||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("NO_PERMISSION classified as %#v, want product-neutral suggestion without apply guidance", nonDoc)
|
||||
}
|
||||
// NO_PERMISSION 叠加文档特征文本(角色门槛)时仍保留 apply 指引。
|
||||
docCombo := ClassifyToolResultContent(map[string]any{"code": "NO_PERMISSION", "success": false, "errorMsg": "需要您具备 MANAGER 及以上角色"})
|
||||
if cli, ok := docCombo.(*CLIError); !ok || cli.Code != CodeAuthPermission || !strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("NO_PERMISSION with document wording classified as %#v, want apply guidance", docCombo)
|
||||
}
|
||||
// 非文档产品的权限错误保留产品专属建议(邮件)或通用提示,
|
||||
// 不得被改写为 drive 文档权限申请指引。
|
||||
mail := ClassifyToolResultContent(map[string]any{"success": false, "errorMsg": "User has no permission to access this email"})
|
||||
if cli, ok := mail.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
|
||||
!strings.Contains(cli.Suggestion, "mailbox list") || strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("mail permission error classified as %#v, want mailbox hint without apply guidance", mail)
|
||||
}
|
||||
generic := ClassifyToolResultContent(map[string]any{"success": false, "code": "FORBIDDEN", "errorMsg": "群权限不足"})
|
||||
if cli, ok := generic.(*CLIError); !ok || cli.Code != CodeAuthPermission ||
|
||||
!strings.Contains(cli.Suggestion, "Verify your account") || strings.Contains(cli.Suggestion, "permission apply") {
|
||||
t.Errorf("generic permission error classified as %#v, want product-neutral suggestion", generic)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
kind string
|
||||
text string
|
||||
kind string
|
||||
suggHas string
|
||||
}{
|
||||
{"not-json", "nil"},
|
||||
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli"},
|
||||
{`{"error":"Missing service_id or access_key"}`, "cli"},
|
||||
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat"},
|
||||
{`{"success":false,"errorMsg":"rate limit"}`, "cli"},
|
||||
{`{"success":true}`, "nil"},
|
||||
{"not-json", "nil", ""},
|
||||
{"null", "nil", ""},
|
||||
{`{"errorCode":"USER_TOKEN_ILLEGAL"}`, "cli", ""},
|
||||
{`{"error":"Missing service_id or access_key"}`, "cli", ""},
|
||||
{`{"code":"PAT_HIGH_RISK_NO_PERMISSION","extra":{"class":"x","keep":1}}`, "pat", ""},
|
||||
{`{"success":false,"errorMsg":"rate limit"}`, "cli", ""},
|
||||
{`{"success":false,"code":"forbidden.accessDenied","errorMsg":"需要您具备 MANAGER 及以上角色","logId":"abc123"}`, "authperm", "permission apply"},
|
||||
// 非文档产品的权限错误:分类为 AUTH_PERMISSION_DENIED,但建议保持
|
||||
// 产品专属(邮件 mailbox)或通用提示,不带文档 apply 指引。
|
||||
{`{"success":false,"errorMsg":"User has no permission to access this email"}`, "authperm", "mailbox list"},
|
||||
{`{"success":false,"code":"FORBIDDEN","errorMsg":"群权限不足"}`, "authperm", "Verify your account"},
|
||||
{`{"success":false,"code":"FORBIDDEN"}`, "authperm", "Verify your account"},
|
||||
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"no permission"}`, "authperm", "Verify your account"},
|
||||
// 文档/知识库角色门槛文案与文档权限码文本:apply 指引保留。
|
||||
{`{"success":false,"errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
|
||||
{`{"success":false,"errorMsg":"forbidden.no.auth: 你无权访问该文档"}`, "authperm", "permission apply"},
|
||||
{`{"success":false,"code":"NO_PERMISSION","errorMsg":"需要您具备 MANAGER 及以上角色"}`, "authperm", "permission apply"},
|
||||
{`{"success":false,"errorMsg":"members[0].id 对应的用户不存在"}`, "cli", ""},
|
||||
{`{"success":true}`, "nil", ""},
|
||||
} {
|
||||
err := ClassifyMCPResponseText(tc.text)
|
||||
switch tc.kind {
|
||||
@@ -174,6 +242,15 @@ func TestCrossPlatformCoverageBusinessSuggestionsAndResponseClassification(t *te
|
||||
if _, ok := err.(*CLIError); !ok {
|
||||
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
|
||||
}
|
||||
case "authperm":
|
||||
cli, ok := err.(*CLIError)
|
||||
if !ok || cli.Code != CodeAuthPermission {
|
||||
t.Errorf("ClassifyMCPResponseText(%s) = %#v, want AUTH_PERMISSION_DENIED", tc.text, err)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(cli.Suggestion, tc.suggHas) {
|
||||
t.Errorf("ClassifyMCPResponseText(%s) suggestion = %q, want it to contain %q", tc.text, cli.Suggestion, tc.suggHas)
|
||||
}
|
||||
case "pat":
|
||||
if _, ok := err.(*PATError); !ok {
|
||||
t.Errorf("ClassifyMCPResponseText(%s) = %#v", tc.text, err)
|
||||
@@ -235,3 +312,82 @@ func TestCrossPlatformCoveragePATCleanupAndMatchingHelpers(t *testing.T) {
|
||||
t.Fatalf("stripClassFields slice = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageBusinessErrorMessagePriority 同步自内部 MR 28965577:
|
||||
// errorMessage 是新接口返回的用户/成员校验错误的字段,优先级介于 errorMsg 与 message 之间。
|
||||
func TestCrossPlatformCoverageBusinessErrorMessagePriority(t *testing.T) {
|
||||
if msg := businessErrorMessage(map[string]any{"errorMsg": "first", "errorMessage": "second", "message": "third", "error": "fourth"}); msg != "first" {
|
||||
t.Errorf("businessErrorMessage = %q, want %q (errorMsg has priority)", msg, "first")
|
||||
}
|
||||
want := "members[0].id 对应的用户不存在"
|
||||
if msg := businessErrorMessage(map[string]any{"errorMessage": want, "message": "third", "error": "fourth"}); msg != want {
|
||||
t.Errorf("businessErrorMessage = %q, want %q (errorMessage fallback)", msg, want)
|
||||
}
|
||||
if msg := businessErrorMessage(map[string]any{"message": "second", "error": "third"}); msg != "second" {
|
||||
t.Errorf("businessErrorMessage = %q, want %q (message fallback)", msg, "second")
|
||||
}
|
||||
if msg := businessErrorMessage(map[string]any{}); msg != "" {
|
||||
t.Errorf("businessErrorMessage(empty) = %q, want empty", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageBusinessErrorDisplayMessage 验证 code/logId 附加以便排查。
|
||||
func TestCrossPlatformCoverageBusinessErrorDisplayMessage(t *testing.T) {
|
||||
got := businessErrorDisplayMessage(map[string]any{"errorMessage": "权限不足", "logId": "abc123"}, "raw")
|
||||
if got != "权限不足 (logId: abc123)" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want logId appended", got)
|
||||
}
|
||||
// errorCode/code 存在时附加在后,保证后端错误码可见
|
||||
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败", "errorCode": "forbidden.document.sizeOverLimit"}, "raw")
|
||||
if got != "失败 (code: forbidden.document.sizeOverLimit)" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want code appended", got)
|
||||
}
|
||||
got = businessErrorDisplayMessage(map[string]any{"message": "失败", "code": "forbidden.accessDenied", "logId": "l1"}, "raw")
|
||||
if got != "失败 (code: forbidden.accessDenied, logId: l1)" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want code+logId appended", got)
|
||||
}
|
||||
// logId 已包含在 message 中时不重复追加
|
||||
got = businessErrorDisplayMessage(map[string]any{"errorMessage": "失败 logId:abc123", "logId": "abc123"}, "raw")
|
||||
if got != "失败 logId:abc123" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want no duplicate logId", got)
|
||||
}
|
||||
// 无任何 message 字段时回退 rawText
|
||||
if got := businessErrorDisplayMessage(map[string]any{"success": false}, "raw-fallback"); got != "raw-fallback" {
|
||||
t.Errorf("businessErrorDisplayMessage = %q, want rawText fallback", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageUserNotInOrgErrors 验证「用户不存在/不属于当前组织/成员不存在」
|
||||
// 在 RESOURCE_NOT_FOUND 之前被拦截为 MCP_TOOL_ERROR 并携带 --members corpId 建议。
|
||||
func TestCrossPlatformCoverageUserNotInOrgErrors(t *testing.T) {
|
||||
suggestion := suggestForBusinessError(map[string]any{"errorMessage": "members[0].id 对应的用户不存在或不属于当前组织(实际值:209499),请检查后重试。"})
|
||||
if !strings.Contains(suggestion, "--members") {
|
||||
t.Errorf("suggestForBusinessError should mention --members for user/org error, got: %q", suggestion)
|
||||
}
|
||||
|
||||
for _, msg := range []string{
|
||||
"用户不存在",
|
||||
"不属于当前组织",
|
||||
"成员不存在",
|
||||
} {
|
||||
cli, ok := WrapErrorWithOperation(errors.New(msg), "wiki/add_member").(*CLIError)
|
||||
if !ok {
|
||||
t.Fatalf("WrapErrorWithOperation(%q) not a CLIError", msg)
|
||||
}
|
||||
if cli.Code != CodeMCPToolError {
|
||||
t.Errorf("WrapErrorWithOperation(%q).Code = %s, want %s (not RESOURCE_NOT_FOUND)", msg, cli.Code, CodeMCPToolError)
|
||||
}
|
||||
if !strings.Contains(cli.Suggestion, "corpId") {
|
||||
t.Errorf("WrapErrorWithOperation(%q).Suggestion should mention corpId, got: %q", msg, cli.Suggestion)
|
||||
}
|
||||
}
|
||||
|
||||
// JSON 体形式:透传 errorMessage 且附 logId
|
||||
cli, ok := WrapErrorWithOperation(errors.New(`{"errorMessage":"用户不存在","logId":"lid-1"}`), "wiki/add_member").(*CLIError)
|
||||
if !ok {
|
||||
t.Fatal("WrapErrorWithOperation(json) not a CLIError")
|
||||
}
|
||||
if cli.Message != "用户不存在 (logId: lid-1)" {
|
||||
t.Errorf("WrapErrorWithOperation(json).Message = %q, want errorMessage + logId", cli.Message)
|
||||
}
|
||||
}
|
||||
|
||||
+89
-10
@@ -255,7 +255,11 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
|
||||
}
|
||||
if isBusinessError(errBody) {
|
||||
return "", &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
Code: CodeMCPToolError,
|
||||
// 注意:这里必须保留原始响应 JSON。此路径是数据编排层
|
||||
// (如 drive list --depth 的限流重试)的输入,下游会从
|
||||
// Message 反解析 errorCode;人话提取(含 code/logId 附加)
|
||||
// 只用于 callMCPToolInternalOptsContext 的终端展示路径。
|
||||
Message: c.Text,
|
||||
Suggestion: suggestForBusinessError(errBody),
|
||||
}
|
||||
@@ -440,6 +444,20 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
|
||||
// 尝试将返回文本解析为 JSON,进行错误分类
|
||||
var errBody map[string]any
|
||||
if json.Unmarshal([]byte(c.Text), &errBody) == nil {
|
||||
// errBody == nil 表示文本为 "null",服务端返回了空响应。
|
||||
// 仅对已确认“空响应=写成功”契约的 permission/member update/remove
|
||||
// 工具适配为空对象 {},避免消费方解析 null 时出错;其它工具的
|
||||
// 合法 null 保持原样输出,不改变未版本化的机器输出契约。
|
||||
if errBody == nil {
|
||||
if nullOnSuccessTools[toolName] {
|
||||
if deps.Caller.Format() == "json" {
|
||||
return printJSON(map[string]any{})
|
||||
}
|
||||
deps.Out.PrintRaw("{}")
|
||||
return nil
|
||||
}
|
||||
return renderLegacyMCPText(toolName, c.Text, unescapeHTML)
|
||||
}
|
||||
// 网关层错误(如 token 过期)
|
||||
if _, ok := getDWSGatewayErrorCode(errBody); ok {
|
||||
return &CLIError{Code: CodeAuthTokenExpired, Message: c.Text, Suggestion: authExpiredSuggestion()}
|
||||
@@ -454,7 +472,7 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
|
||||
}
|
||||
// 业务逻辑错误
|
||||
if isBusinessError(errBody) {
|
||||
return &CLIError{Code: CodeMCPToolError, Message: c.Text, Suggestion: suggestForBusinessError(errBody)}
|
||||
return &CLIError{Code: CodeMCPToolError, Message: businessErrorDisplayMessage(errBody, c.Text), Suggestion: suggestForBusinessError(errBody)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,6 +483,18 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
|
||||
return printJSON(result)
|
||||
}
|
||||
|
||||
// nullOnSuccessTools lists MCP tools whose server contract is confirmed to
|
||||
// return a literal JSON null for successful no-payload writes (permission /
|
||||
// member update/remove). Only these get the null→{} adaptation; every other
|
||||
// tool keeps its raw null output so the shared machine-output contract stays
|
||||
// unchanged.
|
||||
var nullOnSuccessTools = map[string]bool{
|
||||
"update_permission": true,
|
||||
"remove_permission": true,
|
||||
"update_member": true,
|
||||
"remove_member": true,
|
||||
}
|
||||
|
||||
// RenderLegacyMCPText renders an already-fetched MCP text response through the
|
||||
// exact legacy formatter. It lets dual validation execute the business request
|
||||
// once, validate a shadow unified result, and still preserve legacy bytes.
|
||||
@@ -765,15 +795,64 @@ func getDWSGatewayErrorCode(errBody map[string]any) (string, bool) {
|
||||
// suggestForBusinessError returns a user-facing suggestion for known business
|
||||
// error patterns in a parsed JSON body, or "" if no specific suggestion applies.
|
||||
func suggestForBusinessError(body map[string]any) string {
|
||||
msg := ""
|
||||
if v, ok := body["errorMsg"].(string); ok {
|
||||
msg = v
|
||||
} else if v, ok := body["message"].(string); ok {
|
||||
msg = v
|
||||
} else if v, ok := body["error"].(string); ok {
|
||||
msg = v
|
||||
return suggestForBusinessErrorText(businessErrorMessage(body))
|
||||
}
|
||||
|
||||
// businessErrorMessage extracts the human-readable message from a parsed error
|
||||
// body, checking errorMsg > errorMessage > message > error. Returns "" if none present.
|
||||
func businessErrorMessage(body map[string]any) string {
|
||||
for _, k := range []string{"errorMsg", "errorMessage", "message", "error"} {
|
||||
if v, ok := body[k].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return suggestForBusinessErrorText(msg)
|
||||
return ""
|
||||
}
|
||||
|
||||
// businessErrorDisplayMessage extracts the human-readable message from a parsed
|
||||
// error body, appends the backend error code and logId if present (for
|
||||
// traceability), and falls back to rawText if no message field is found.
|
||||
func businessErrorDisplayMessage(body map[string]any, rawText string) string {
|
||||
msg := businessErrorMessage(body)
|
||||
if msg == "" {
|
||||
return rawText
|
||||
}
|
||||
var extras []string
|
||||
for _, k := range []string{"errorCode", "error_code", "code"} {
|
||||
if code, ok := body[k].(string); ok && code != "" && !strings.Contains(msg, code) {
|
||||
extras = append(extras, "code: "+code)
|
||||
break
|
||||
}
|
||||
}
|
||||
if logId, ok := body["logId"].(string); ok && logId != "" && !strings.Contains(msg, logId) {
|
||||
extras = append(extras, "logId: "+logId)
|
||||
}
|
||||
if len(extras) > 0 {
|
||||
msg = msg + " (" + strings.Join(extras, ", ") + ")"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// isNoPermissionError reports whether a parsed error body represents a
|
||||
// permission-denied error, by known server codes or message text. Used to
|
||||
// surface apply-permission guidance before the framework's generic rendering.
|
||||
func isNoPermissionError(body map[string]any) bool {
|
||||
for _, key := range []string{"code", "errorCode", "server_error_code"} {
|
||||
if code, ok := body[key].(string); ok && noPermissionServerCodes[code] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
msg := businessErrorMessage(body)
|
||||
if msg == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
return strings.Contains(msg, "无权限访问") || strings.Contains(msg, "没有访问权限") ||
|
||||
strings.Contains(msg, "没有权限") || strings.Contains(msg, "权限不足") ||
|
||||
strings.Contains(lower, "no permission") || strings.Contains(lower, "permission denied") ||
|
||||
strings.Contains(lower, "forbidden.no.auth") ||
|
||||
strings.Contains(lower, "forbidden.accessdenied") ||
|
||||
strings.Contains(msg, "需要您具备") || strings.Contains(msg, "及以上角色")
|
||||
}
|
||||
|
||||
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
|
||||
|
||||
@@ -0,0 +1,738 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// 同步自闭源 MR 28965577(知识库/节点权限增删改查改造):
|
||||
// 权限/成员 add/update/remove 支持 --members 新格式(USER/DEPT/CONVERSATION/TAG),
|
||||
// list 支持 nextToken 翻页。以下用例覆盖 CLI → MCP 参数装配契约。
|
||||
|
||||
func TestCollectMembersParsesNewFormat(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
|
||||
"--members", `[{"type":"USER","id":"u1","roleId":"reader","corpId":"c1"},{"type":"TAG","id":"t1","roleId":"editor","corpId":"c1"}]`,
|
||||
"--notify=false"); err != nil {
|
||||
t.Fatalf("permission add --members: %v", err)
|
||||
}
|
||||
if caller.tool != "add_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
members, ok := caller.args["members"].([]map[string]any)
|
||||
if !ok || len(members) != 2 {
|
||||
t.Fatalf("members=%#v", caller.args["members"])
|
||||
}
|
||||
if members[0]["roleId"] != "READER" || members[1]["roleId"] != "EDITOR" {
|
||||
t.Fatalf("roleId normalize failed: %#v", members)
|
||||
}
|
||||
if notify, ok := caller.args["notify"].(bool); !ok || notify {
|
||||
t.Fatalf("notify should be false when --notify=false: %#v", caller.args["notify"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMembersValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
members string
|
||||
wantError string
|
||||
}{
|
||||
{"invalid json", "[{", "JSON 解析失败"},
|
||||
{"empty array", "[]", "不能为空数组"},
|
||||
{"missing type", `[{"id":"u1","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 type"},
|
||||
{"missing id", `[{"type":"USER","roleId":"READER","corpId":"c1"}]`, "缺少必填字段 id"},
|
||||
{"missing corpId", `[{"type":"USER","id":"u1","roleId":"READER"}]`, "需携带 corpId"},
|
||||
{"missing roleId", `[{"type":"USER","id":"u1","corpId":"c1"}]`, "缺少必填字段 roleId"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1", "--members", tc.members)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("expected error containing %q, got %v", tc.wantError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateMembersExclusivity(t *testing.T) {
|
||||
t.Run("members and users are mutually exclusive", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
|
||||
"--users", "u1", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "互斥") {
|
||||
t.Fatalf("expected mutual exclusion error, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("one of members or users is required", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1")
|
||||
if err == nil || !strings.Contains(err.Error(), "之一") {
|
||||
t.Fatalf("expected required error, got %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("role is redundant with members", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newDriveCommand(), "permission", "add", "--node", "n1",
|
||||
"--role", "READER", "--members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "不需要 --role") {
|
||||
t.Fatalf("expected no-role error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionListPagination(t *testing.T) {
|
||||
t.Run("drive permission list passes nextToken and pageSize", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1",
|
||||
"--limit", "50", "--next-token", "50"); err != nil {
|
||||
t.Fatalf("permission list: %v", err)
|
||||
}
|
||||
if caller.tool != "list_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["pageSize"] != 50 {
|
||||
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
|
||||
}
|
||||
if caller.args["nextToken"] != "50" {
|
||||
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
|
||||
}
|
||||
})
|
||||
t.Run("doc permission list passes nextToken", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDocCommand(), "permission", "list", "--node", "n1",
|
||||
"--next-token", "30"); err != nil {
|
||||
t.Fatalf("doc permission list: %v", err)
|
||||
}
|
||||
if caller.args["nextToken"] != "30" {
|
||||
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
|
||||
}
|
||||
})
|
||||
t.Run("wiki member list passes nextToken and pageSize", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newWikiCommand(), "member", "list", "--workspace", "ws1",
|
||||
"--limit", "50", "--next-token", "50"); err != nil {
|
||||
t.Fatalf("wiki member list: %v", err)
|
||||
}
|
||||
if caller.tool != "list_member" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
if caller.args["pageSize"] != 50 || caller.args["nextToken"] != "50" {
|
||||
t.Fatalf("args=%#v", caller.args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionRemoveWithMembers(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
// remove 是 destructive 入口:Safety confirmation=user_required,测试里
|
||||
// 注入根级 --yes 后直接确认执行(参数装配本身由下方断言验证)。
|
||||
root := newDriveCommand()
|
||||
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
|
||||
if err := executePR868Command(t, root, "permission", "remove", "--node", "n1",
|
||||
"--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes"); err != nil {
|
||||
t.Fatalf("permission remove --members: %v", err)
|
||||
}
|
||||
if caller.tool != "remove_permission" {
|
||||
t.Fatalf("tool=%q", caller.tool)
|
||||
}
|
||||
members, ok := caller.args["members"].([]map[string]any)
|
||||
if !ok || len(members) != 1 || members[0]["id"] != "cid1" {
|
||||
t.Fatalf("members=%#v", caller.args["members"])
|
||||
}
|
||||
// remove 语义下 roleId 不应被要求
|
||||
if _, hasRole := members[0]["roleId"]; hasRole {
|
||||
t.Fatalf("remove members should not require roleId: %#v", members[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWikiMemberAddWithMembersRejectsOwner(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
err := executePR868Command(t, newWikiCommand(), "member", "add", "--workspace", "ws1",
|
||||
"--members", `[{"type":"USER","id":"u1","roleId":"OWNER","corpId":"c1"}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "OWNER") {
|
||||
t.Fatalf("expected OWNER rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// collectMembers / validateMembersExclusivity 单元级用例
|
||||
// 同步自内部 MR 28965577 补齐的覆盖:四类型混传、remove 语义、
|
||||
// 超过 30 个、corpId 按类型校验、--user 别名。
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
// newMembersCmd 构造带 --members/--users/--user/--role 的最小命令,
|
||||
// 与生产命令的 flag 注册保持一致。
|
||||
func newMembersCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().String("members", "", "")
|
||||
cmd.Flags().String("users", "", "")
|
||||
cmd.Flags().String("user", "", "")
|
||||
cmd.Flags().String("role", "", "")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestCollectMembersMixedTypes(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"MANAGER","corpId":"c1"},{"type":"DEPT","id":"d1","roleId":"editor","corpId":"c1"},{"type":"CONVERSATION","id":"cid1","roleId":"READER"},{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`)
|
||||
got, err := collectMembers(cmd, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("expected 4 members, got %d", len(got))
|
||||
}
|
||||
if got[1]["roleId"] != "EDITOR" {
|
||||
t.Errorf("member[1] roleId should be normalized to EDITOR, got %v", got[1]["roleId"])
|
||||
}
|
||||
if got[0]["corpId"] != "c1" {
|
||||
t.Errorf("corpId should be preserved, got %v", got[0]["corpId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMembersRemoveSemantics(t *testing.T) {
|
||||
t.Run("remove only needs type and id", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"},{"type":"DEPT","id":"d1","corpId":"c1"}]`)
|
||||
got, err := collectMembers(cmd, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("expected 2 members, got %d", len(got))
|
||||
}
|
||||
})
|
||||
t.Run("add still requires roleId", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","corpId":"c1"}]`)
|
||||
if _, err := collectMembers(cmd, false); err == nil {
|
||||
t.Fatal("expected error for missing roleId, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCollectMembersOver30(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
members := "["
|
||||
for i := 0; i < 31; i++ {
|
||||
if i > 0 {
|
||||
members += ","
|
||||
}
|
||||
members += fmt.Sprintf(`{"type":"USER","id":"u%d","roleId":"READER","corpId":"c"}`, i)
|
||||
}
|
||||
members += "]"
|
||||
_ = cmd.Flags().Set("members", members)
|
||||
if _, err := collectMembers(cmd, false); err == nil {
|
||||
t.Fatal("expected error for >30 members, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectMembersCorpIDByType(t *testing.T) {
|
||||
t.Run("DEPT requires corpId", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"DEPT","id":"d1","roleId":"EDITOR"}]`)
|
||||
if _, err := collectMembers(cmd, false); err == nil {
|
||||
t.Fatal("expected error for DEPT missing corpId, got nil")
|
||||
}
|
||||
})
|
||||
t.Run("TAG requires corpId", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER"}]`)
|
||||
if _, err := collectMembers(cmd, false); err == nil {
|
||||
t.Fatal("expected error for TAG missing corpId, got nil")
|
||||
}
|
||||
})
|
||||
t.Run("CONVERSATION works without corpId", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"CONVERSATION","id":"cid1","roleId":"READER"}]`)
|
||||
got, err := collectMembers(cmd, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error for CONVERSATION without corpId: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 member, got %d", len(got))
|
||||
}
|
||||
})
|
||||
t.Run("TAG with corpId passes", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("members", `[{"type":"TAG","id":"t1","roleId":"DOWNLOADER","corpId":"c1"}]`)
|
||||
got, err := collectMembers(cmd, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected 1 member, got %d", len(got))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateMembersExclusivityUserAlias(t *testing.T) {
|
||||
t.Run("--user alias counts as users", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("user", "u1")
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
t.Errorf("unexpected error for --user alias: %v", err)
|
||||
}
|
||||
})
|
||||
t.Run("--user alias conflicts with --members", func(t *testing.T) {
|
||||
cmd := newMembersCmd()
|
||||
_ = cmd.Flags().Set("user", "u1")
|
||||
_ = cmd.Flags().Set("members", `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`)
|
||||
if err := validateMembersExclusivity(cmd); err == nil {
|
||||
t.Error("expected error when both --user and --members set, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// isNoPermissionError — 新接口 forbidden.accessDenied 错误码
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func TestIsNoPermissionErrorAccessDenied(t *testing.T) {
|
||||
t.Run("server code forbidden.accessDenied", func(t *testing.T) {
|
||||
for _, key := range []string{"code", "errorCode", "server_error_code"} {
|
||||
body := map[string]any{key: "forbidden.accessDenied"}
|
||||
if !isNoPermissionError(body) {
|
||||
t.Errorf("isNoPermissionError(%s=forbidden.accessDenied) = false, want true", key)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("access-denied message text", func(t *testing.T) {
|
||||
for _, msg := range []string{
|
||||
"需要您具备 EDITOR 及以上角色",
|
||||
"需要您具备 MANAGER 及以上角色才能执行此操作",
|
||||
"forbidden.accessDenied",
|
||||
"Forbidden.AccessDenied",
|
||||
} {
|
||||
body := map[string]any{"errorMsg": msg}
|
||||
if !isNoPermissionError(body) {
|
||||
t.Errorf("isNoPermissionError(msg=%q) = false, want true", msg)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("normal body is not permission error", func(t *testing.T) {
|
||||
body := map[string]any{"success": true, "errorMsg": ""}
|
||||
if isNoPermissionError(body) {
|
||||
t.Error("isNoPermissionError(success body) = true, want false")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// --notify bool flag 解析(NoArgs 防御 `--notify false` 空格形式)
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
// newNotifyCmd 构造与生产 add/update 命令一致的最小命令:
|
||||
// --notify bool flag + Args: cobra.NoArgs。
|
||||
func newNotifyCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "test",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error { return nil },
|
||||
}
|
||||
cmd.Flags().Bool("notify", false, "")
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestNotifyFlagParsing(t *testing.T) {
|
||||
t.Run("equals form sets false", func(t *testing.T) {
|
||||
cmd := newNotifyCmd()
|
||||
cmd.SetArgs([]string{"--notify=false"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetBool("notify"); got {
|
||||
t.Error("--notify=false: got true, want false")
|
||||
}
|
||||
if !cmd.Flags().Changed("notify") {
|
||||
t.Error("--notify=false should mark flag as changed")
|
||||
}
|
||||
})
|
||||
t.Run("bare flag defaults true via NoOptDefVal", func(t *testing.T) {
|
||||
cmd := newNotifyCmd()
|
||||
cmd.SetArgs([]string{"--notify"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, _ := cmd.Flags().GetBool("notify"); !got {
|
||||
t.Error("--notify (no value): got false, want true (NoOptDefVal)")
|
||||
}
|
||||
if !cmd.Flags().Changed("notify") {
|
||||
t.Error("--notify should mark flag as changed")
|
||||
}
|
||||
})
|
||||
t.Run("space form rejected by NoArgs", func(t *testing.T) {
|
||||
cmd := newNotifyCmd()
|
||||
// "--notify false" 被 pflag 解析为 notify=true(NoOptDefVal)+ 位置参数 "false"。
|
||||
// Args: cobra.NoArgs 拒绝位置参数,防止用户误以为传了 false。
|
||||
cmd.SetArgs([]string{"--notify", "false"})
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("expected error for '--notify false' (space form) with NoArgs, got nil")
|
||||
}
|
||||
if got, _ := cmd.Flags().GetBool("notify"); !got {
|
||||
t.Error("pflag should set notify=true via NoOptDefVal for bare --notify, got false")
|
||||
}
|
||||
})
|
||||
t.Run("omitted stays unchanged", func(t *testing.T) {
|
||||
cmd := newNotifyCmd()
|
||||
cmd.SetArgs([]string{})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cmd.Flags().Changed("notify") {
|
||||
t.Error("notify should not be marked changed when omitted")
|
||||
}
|
||||
if got, _ := cmd.Flags().GetBool("notify"); got {
|
||||
t.Error("omitted notify: got true, want false (default)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// pageSize 1..50 运行时边界(P2 意见:服务端 pageSize 上限 50)
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func newPageSizeCmd(changed string, value int) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("limit", 30, "")
|
||||
cmd.Flags().Int("max-results", 0, "")
|
||||
_ = cmd.Flags().Set(changed, fmt.Sprintf("%d", value))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func TestPermissionPageSizeFromFlagsBoundaries(t *testing.T) {
|
||||
t.Run("rejects out-of-range values", func(t *testing.T) {
|
||||
for _, flag := range []string{"limit", "max-results"} {
|
||||
for _, size := range []int{-1, 0, 51, 100} {
|
||||
if _, _, err := permissionPageSizeFromFlags(newPageSizeCmd(flag, size)); err == nil {
|
||||
t.Errorf("--%s %d: expected error, got nil", flag, size)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("accepts in-range values", func(t *testing.T) {
|
||||
for _, size := range []int{1, 30, 50} {
|
||||
got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", size))
|
||||
if err != nil {
|
||||
t.Errorf("--limit %d: unexpected error %v", size, err)
|
||||
continue
|
||||
}
|
||||
if !ok || got != size {
|
||||
t.Errorf("--limit %d: got (%d, %v)", size, got, ok)
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("omitted returns ok=false", func(t *testing.T) {
|
||||
// newPageSizeCmd 强制 Set(changed),先验证 Set 后 Changed 生效,
|
||||
// 再用未设置任何 flag 的命令验证默认路径。
|
||||
if got, ok, err := permissionPageSizeFromFlags(newPageSizeCmd("limit", 30)); err != nil || !ok || got != 30 {
|
||||
t.Fatalf("setup sanity failed: got (%d, %v, %v), want (30, true, nil)", got, ok, err)
|
||||
}
|
||||
fresh := &cobra.Command{Use: "test"}
|
||||
fresh.Flags().Int("limit", 30, "")
|
||||
fresh.Flags().Int("max-results", 0, "")
|
||||
if got, ok, err := permissionPageSizeFromFlags(fresh); err != nil || ok || got != 0 {
|
||||
t.Errorf("omitted flags: got (%d, %v, %v), want (0, false, nil)", got, ok, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPermissionListPageSizeRuntimeGuard(t *testing.T) {
|
||||
type listCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
nodeID []string
|
||||
}
|
||||
cases := []listCase{
|
||||
{"drive", newDriveCommand, []string{"permission", "list"}, []string{"--node", "n1"}},
|
||||
{"doc", newDocCommand, []string{"permission", "list"}, []string{"--node", "n1"}},
|
||||
{"wiki", newWikiCommand, []string{"member", "list"}, []string{"--workspace", "ws1"}},
|
||||
}
|
||||
for _, lc := range cases {
|
||||
t.Run(lc.name+" rejects --limit 51", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, lc.path...), lc.nodeID...)
|
||||
args = append(args, "--limit", "51")
|
||||
err := executePR868Command(t, lc.root(), args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "1..50") {
|
||||
t.Fatalf("expected 1..50 validation error, got %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("list should not call MCP with invalid pageSize, called %d times", caller.calls)
|
||||
}
|
||||
})
|
||||
t.Run(lc.name+" accepts --limit 50", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, lc.path...), lc.nodeID...)
|
||||
args = append(args, "--limit", "50")
|
||||
if err := executePR868Command(t, lc.root(), args...); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if caller.args["pageSize"] != 50 {
|
||||
t.Fatalf("pageSize=%#v, want 50", caller.args["pageSize"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// add 的 --notify 声明默认值必须与实际透传行为一致
|
||||
//
|
||||
// 三个 add 命令都用 `if Flags().Changed("notify")` 守卫来决定是否把 notify
|
||||
// 写入 toolArgs,所以省略该 flag 时字段根本不会发给服务端,实测服务端按
|
||||
// 不通知处理。声明默认值因此必须是 false —— 若有人把它改回 true,help 会
|
||||
// 承诺一个从未透传的默认值(曾经的真实缺陷)。
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func TestPermissionAddNotifyDefaultMatchesWireBehavior(t *testing.T) {
|
||||
type addCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
target []string
|
||||
}
|
||||
cases := []addCase{
|
||||
{"drive", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}},
|
||||
{"doc", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}},
|
||||
{"wiki", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}},
|
||||
}
|
||||
const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`
|
||||
|
||||
runAdd := func(t *testing.T, lc addCase, extra ...string) *scriptedToolCaller {
|
||||
t.Helper()
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, lc.path...), lc.target...)
|
||||
args = append(args, "--members", members)
|
||||
args = append(args, extra...)
|
||||
if err := executePR868Command(t, lc.root(), args...); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
return caller
|
||||
}
|
||||
|
||||
for _, lc := range cases {
|
||||
t.Run(lc.name+" omitted sends no notify field", func(t *testing.T) {
|
||||
caller := runAdd(t, lc)
|
||||
if v, present := caller.args["notify"]; present {
|
||||
t.Fatalf("notify must be absent from toolArgs when --notify is omitted, got %#v", v)
|
||||
}
|
||||
})
|
||||
t.Run(lc.name+" declared default is false", func(t *testing.T) {
|
||||
// 声明默认值必须与上面的透传行为一致,否则 help 在说谎。
|
||||
root := lc.root()
|
||||
leaf, _, err := root.Find(lc.path)
|
||||
if err != nil {
|
||||
t.Fatalf("find %v: %v", lc.path, err)
|
||||
}
|
||||
flag := leaf.Flags().Lookup("notify")
|
||||
if flag == nil {
|
||||
t.Fatal("notify flag not declared")
|
||||
}
|
||||
if flag.DefValue != "false" {
|
||||
t.Errorf("notify DefValue=%q, want \"false\": 省略时不透传该字段,声明 true 会承诺一个未生效的默认值", flag.DefValue)
|
||||
}
|
||||
})
|
||||
t.Run(lc.name+" bare --notify opts in", func(t *testing.T) {
|
||||
caller := runAdd(t, lc, "--notify")
|
||||
if notify, ok := caller.args["notify"].(bool); !ok || !notify {
|
||||
t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"])
|
||||
}
|
||||
})
|
||||
t.Run(lc.name+" --notify=false is sent explicitly", func(t *testing.T) {
|
||||
caller := runAdd(t, lc, "--notify=false")
|
||||
notify, ok := caller.args["notify"].(bool)
|
||||
if !ok || notify {
|
||||
t.Fatalf("notify=%#v, want explicit false", caller.args["notify"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// 服务端对已确认“空响应=写成功”契约的 permission/member update/remove
|
||||
// 工具可能返回字面 "null"(操作成功但无返回数据)。CLI 仅对这几个
|
||||
// 工具将其渲染为空对象 {},避免 Agent/下游把 null 当作对象解析时失败;
|
||||
// 其它工具的合法 null 保持原样输出,公共渲染器的机器输出契约不变。
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageNullToolResponseRendersEmptyObject(t *testing.T) {
|
||||
// nullOnSuccessTools 集合内:null 适配为 {}
|
||||
for _, format := range []string{"json", "raw"} {
|
||||
t.Run("adapted_"+format, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format}
|
||||
installScriptedCaller(t, caller)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_permission", nil, false); err != nil {
|
||||
t.Fatalf("null tool response should render as {}: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "{}" {
|
||||
t.Errorf("format %s renders %q, want {}", format, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 集合外的工具:null 原样输出,不再被公共渲染路径改写为 {}
|
||||
for _, format := range []string{"json", "raw"} {
|
||||
t.Run("passthrough_"+format, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: "null"}}, format: format}
|
||||
installScriptedCaller(t, caller)
|
||||
out := &bytes.Buffer{}
|
||||
deps.Out.w = out
|
||||
if err := callMCPToolInternalOptsContext(context.Background(), "drive", "update_node_permission", nil, false); err != nil {
|
||||
t.Fatalf("null passthrough should not error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(out.String()); got != "null" {
|
||||
t.Errorf("format %s renders %q, want null unchanged", format, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// update / remove 的 --members 装配与 --users 解析失败分支
|
||||
//
|
||||
// add 的 members+notify 透传已有
|
||||
// TestPermissionAddNotifyDefaultMatchesWireBehavior 覆盖;这里补齐
|
||||
// update(members + notify)与 doc/wiki remove(members)的同构分支,
|
||||
// 以及各产品 add/update/remove 中 collectUserIDs 的错误路径:
|
||||
// --users 传纯空白时 flagOrFallback 视为已提供(非空字符串),
|
||||
// 但 parseCommentMentionIds 过滤空白后为空,collectUserIDs 必须报错
|
||||
// 而不是向服务端发送空 userIds。
|
||||
// ──────────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoveragePermissionUpdateWithMembers(t *testing.T) {
|
||||
type updateCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
target []string
|
||||
tool string
|
||||
}
|
||||
cases := []updateCase{
|
||||
{"drive", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"},
|
||||
{"doc", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, "update_permission"},
|
||||
{"wiki", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, "update_member"},
|
||||
}
|
||||
const members = `[{"type":"USER","id":"u1","roleId":"READER","corpId":"c1"}]`
|
||||
for _, uc := range cases {
|
||||
t.Run(uc.name+" passes members and notify", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, uc.path...), uc.target...)
|
||||
args = append(args, "--members", members, "--notify")
|
||||
if err := executePR868Command(t, uc.root(), args...); err != nil {
|
||||
t.Fatalf("update --members: %v", err)
|
||||
}
|
||||
if caller.tool != uc.tool {
|
||||
t.Fatalf("tool=%q, want %q", caller.tool, uc.tool)
|
||||
}
|
||||
got, ok := caller.args["members"].([]map[string]any)
|
||||
if !ok || len(got) != 1 || got[0]["id"] != "u1" || got[0]["roleId"] != "READER" {
|
||||
t.Fatalf("members=%#v", caller.args["members"])
|
||||
}
|
||||
if notify, ok := caller.args["notify"].(bool); !ok || !notify {
|
||||
t.Fatalf("notify=%#v, want true for bare --notify", caller.args["notify"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePermissionRemoveWithMembersProducts(t *testing.T) {
|
||||
// drive remove 已由 TestPermissionRemoveWithMembers 覆盖;这里补 doc/wiki。
|
||||
type removeCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
target []string
|
||||
tool string
|
||||
}
|
||||
cases := []removeCase{
|
||||
{"doc", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, "remove_permission"},
|
||||
{"wiki", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, "remove_member"},
|
||||
}
|
||||
for _, rc := range cases {
|
||||
t.Run(rc.name+" passes members", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
// remove 需用户确认(confirmation=user_required):注入根级 --yes。
|
||||
root := rc.root()
|
||||
root.PersistentFlags().Bool("yes", false, "confirm high-risk operation")
|
||||
args := append(append([]string{}, rc.path...), rc.target...)
|
||||
args = append(args, "--members", `[{"type":"CONVERSATION","id":"cid1"}]`, "--yes")
|
||||
if err := executePR868Command(t, root, args...); err != nil {
|
||||
t.Fatalf("remove --members: %v", err)
|
||||
}
|
||||
if caller.tool != rc.tool {
|
||||
t.Fatalf("tool=%q, want %q", caller.tool, rc.tool)
|
||||
}
|
||||
got, ok := caller.args["members"].([]map[string]any)
|
||||
if !ok || len(got) != 1 || got[0]["id"] != "cid1" {
|
||||
t.Fatalf("members=%#v", caller.args["members"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePermissionUsersBlankParseError(t *testing.T) {
|
||||
type blankCase struct {
|
||||
name string
|
||||
root func() *cobra.Command
|
||||
path []string
|
||||
target []string
|
||||
needRole bool
|
||||
}
|
||||
cases := []blankCase{
|
||||
{"drive add", newDriveCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true},
|
||||
{"drive update", newDriveCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true},
|
||||
{"drive remove", newDriveCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false},
|
||||
{"doc add", newDocCommand, []string{"permission", "add"}, []string{"--node", "n1"}, true},
|
||||
{"doc update", newDocCommand, []string{"permission", "update"}, []string{"--node", "n1"}, true},
|
||||
{"doc remove", newDocCommand, []string{"permission", "remove"}, []string{"--node", "n1"}, false},
|
||||
{"wiki add", newWikiCommand, []string{"member", "add"}, []string{"--workspace", "ws1"}, true},
|
||||
{"wiki update", newWikiCommand, []string{"member", "update"}, []string{"--workspace", "ws1"}, true},
|
||||
{"wiki remove", newWikiCommand, []string{"member", "remove"}, []string{"--workspace", "ws1"}, false},
|
||||
}
|
||||
for _, bc := range cases {
|
||||
t.Run(bc.name+" rejects blank --users", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
args := append(append([]string{}, bc.path...), bc.target...)
|
||||
args = append(args, "--users", " ")
|
||||
if bc.needRole {
|
||||
// add/update 的旧格式分支在 collectUserIDs 之前校验必填 --role。
|
||||
args = append(args, "--role", "READER")
|
||||
}
|
||||
err := executePR868Command(t, bc.root(), args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "--users is required") {
|
||||
t.Fatalf("expected --users is required error, got %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("MCP must not be called when --users resolves empty, called %d times", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// P2 评审要求(PR #1085):三个游标列表命令(drive/doc permission list、
|
||||
// wiki member list)必须声明 Contract.Pagination(cursor 分页,游标 flag 为
|
||||
// next-token),且 next-token 是该叶的 declared parameter。
|
||||
//
|
||||
// wire 上的 pagination 投影由 output rollout 门控制(schema_runtime_registry
|
||||
// 对未迁移统一结果 envelope 的命令不发布 result/pagination,避免 Schema 与
|
||||
// runtime 输出不一致)。本测试固化「声明已就绪」与「当前仍为 legacy 输出」
|
||||
// 两个事实;命令迁移 unified envelope 后 wire 投影自动生效,届时应更新此
|
||||
// 断言并用 dws schema 验证最终投影。
|
||||
// 命名携带 TestCrossPlatformCoverage 前缀:本测试覆盖本次改动的
|
||||
// Contract.Pagination 声明行,需在 macOS/Windows 平台覆盖率门禁上执行。
|
||||
func TestCrossPlatformCoveragePermissionListPaginationDeclaration(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
root *cobra.Command
|
||||
path []string
|
||||
}{
|
||||
{"drive permission list", newDriveCommand(), []string{"permission", "list"}},
|
||||
{"doc permission list", newDocCommand(), []string{"permission", "list"}},
|
||||
{"wiki member list", newWikiCommand(), []string{"member", "list"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, _, err := tc.root.Find(tc.path)
|
||||
if err != nil || cmd == nil || cmd.Name() != tc.path[len(tc.path)-1] {
|
||||
t.Fatalf("find leaf %v: cmd=%v err=%v", tc.path, cmd, err)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(cmd)
|
||||
if !ok {
|
||||
t.Fatalf("missing RuntimeContractFinal on %v", tc.path)
|
||||
}
|
||||
pg := final.Pagination
|
||||
if pg == nil {
|
||||
t.Fatalf("Contract.Pagination not declared on %v", tc.path)
|
||||
}
|
||||
if pg.Kind != contract.PaginationKindCursor || pg.CursorParameter != "next-token" {
|
||||
t.Fatalf("pagination = %+v, want cursor/next-token", pg)
|
||||
}
|
||||
if pg.MetaPath != contract.PaginationMetaPath ||
|
||||
pg.NextTokenPath != contract.PaginationNextTokenPath ||
|
||||
pg.EndpointExhaustedPath != contract.PaginationExhaustedPath {
|
||||
t.Fatalf("pagination framework-owned paths not normalized: %+v", pg)
|
||||
}
|
||||
declared := false
|
||||
for _, p := range final.Parameters {
|
||||
if p.Name == "next-token" {
|
||||
declared = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !declared {
|
||||
t.Fatalf("cursor flag next-token must be a declared parameter on %v", tc.path)
|
||||
}
|
||||
if output.ActiveContract(cmd) != output.ContractLegacy {
|
||||
t.Fatalf("%v migrated to unified result; update this test and verify wire projection via dws schema", tc.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -206,8 +206,18 @@ func TestCrossPlatformCoverageDriveAliasAndDownloadVersion(t *testing.T) {
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--max-results", "10"); err != nil {
|
||||
t.Fatalf("permission list: %v", err)
|
||||
}
|
||||
if caller.args["maxResults"] != 10 {
|
||||
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
|
||||
if caller.args["pageSize"] != 10 {
|
||||
t.Fatalf("pageSize=%#v", caller.args["pageSize"])
|
||||
}
|
||||
})
|
||||
t.Run("permission list next-token pagination", func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{{text: `{"result":[]}`}}}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executePR868Command(t, newDriveCommand(), "permission", "list", "--node", "n1", "--next-token", "50"); err != nil {
|
||||
t.Fatalf("permission list --next-token: %v", err)
|
||||
}
|
||||
if caller.args["nextToken"] != "50" {
|
||||
t.Fatalf("nextToken=%#v", caller.args["nextToken"])
|
||||
}
|
||||
})
|
||||
t.Run("cover file-id alias", func(t *testing.T) {
|
||||
|
||||
@@ -144,17 +144,25 @@ func TestSheetCellInfosDirectPassthroughPreservesCompletionMetadata(t *testing.T
|
||||
// Large-CP failure is not a pageable success. Both the raw csv-get path and
|
||||
// both get_cell_infos paths must retain the backend code and safe user message
|
||||
// exactly, matching the generic get_all_sheets error-classification path.
|
||||
//
|
||||
// 两条路径的 Message 形态不同:csv-get / cell-infos-direct 走终端展示路径
|
||||
// (callMCPToolInternalOptsContext),业务错误展示为提取的 errorMessage 加
|
||||
// "(code: ...)" 后缀——后端错误码必须保持可见;range read 走数据编排路径
|
||||
// (parseMCPToolTextResult),下游(如 drive list --depth 的限流重试)需要
|
||||
// 从 Message 反解析 errorCode,因此必须保留原始 JSON payload。
|
||||
func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) {
|
||||
const response = `{"success":false,"errorCode":"forbidden.document.sizeOverLimit","errorMessage":"The workbook data is too large to process. Use a smaller copy or split the workbook, then try again."}`
|
||||
const wantDisplay = "The workbook data is too large to process. Use a smaller copy or split the workbook, then try again. (code: forbidden.document.sizeOverLimit)"
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
tool string
|
||||
direct bool
|
||||
want string
|
||||
}{
|
||||
{name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv"},
|
||||
{name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos"},
|
||||
{name: "cell-infos-direct", tool: "get_cell_infos", direct: true},
|
||||
{name: "csv-get", args: []string{"csv-get", "--node", "NODE"}, tool: "get_range_as_csv", want: wantDisplay},
|
||||
{name: "range-read", args: []string{"range", "read", "--node", "NODE"}, tool: "get_cell_infos", want: response},
|
||||
{name: "cell-infos-direct", tool: "get_cell_infos", direct: true, want: wantDisplay},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -180,8 +188,8 @@ func TestSheetReadPreservesWorkbookSizeOverLimitError(t *testing.T) {
|
||||
if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError {
|
||||
t.Fatalf("error = %T %v, want MCP tool error", err, err)
|
||||
}
|
||||
if cliErr.Message != response {
|
||||
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, response)
|
||||
if cliErr.Message != test.want {
|
||||
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, test.want)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"forbidden.document.sizeOverLimit",
|
||||
|
||||
+158
-61
@@ -529,11 +529,20 @@ func newWikiCommand() *cobra.Command {
|
||||
memberAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加知识库成员",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为指定知识库添加一个或多个成员,并授予指定角色。
|
||||
|
||||
通过 --users 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色(大小写不敏感):
|
||||
MANAGER 管理员,可读写、管理成员
|
||||
EDITOR 编辑者,可查看、编辑、上传内容
|
||||
DOWNLOADER 查看下载者,可查看并下载内容
|
||||
@@ -541,36 +550,58 @@ func newWikiCommand() *cobra.Command {
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口添加,知识库创建者默认为所有者。
|
||||
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时 CLI 不向服务端发送该字段,服务端按不通知处理,需要通知请显式传 --notify。
|
||||
|
||||
支持通过 --workspace 传入知识库 ID 或知识库 URL,系统自动识别。
|
||||
用户 uid 可通过「钉钉通讯录」相关命令检索,如:
|
||||
dws contact user search --keyword "姓名"`,
|
||||
Example: ` dws wiki member add --workspace <workspaceId> --users uid1 --role READER
|
||||
dws wiki member add --workspace <workspaceId> --users uid1,uid2,uid3 --role EDITOR
|
||||
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --users uid1 --role MANAGER`,
|
||||
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --users uid1 --role MANAGER
|
||||
dws wiki member add --workspace <workspaceId> --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws wiki member add --workspace <workspaceId> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
if role == "OWNER" {
|
||||
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
|
||||
toolArgs := map[string]any{"workspaceId": workspaceID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
if len(members) > 0 {
|
||||
for _, m := range members {
|
||||
if r, _ := m["roleId"].(string); r == "OWNER" {
|
||||
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
|
||||
}
|
||||
}
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
if role == "OWNER" {
|
||||
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = role
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
return callMCPTool("add_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"roleId": role,
|
||||
"userIds": userIds,
|
||||
})
|
||||
return callMCPTool("add_member", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(memberAddCmd, LeafSpec{
|
||||
@@ -605,6 +636,8 @@ func newWikiCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -613,17 +646,30 @@ func newWikiCommand() *cobra.Command {
|
||||
})
|
||||
|
||||
memberAddCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberAddCmd.Flags().String("users", "", "被添加的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
memberAddCmd.Flags().String("users", "", "被添加的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
memberAddCmd.Flags().String("user", "", "")
|
||||
_ = memberAddCmd.Flags().MarkHidden("user")
|
||||
memberAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
memberAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
memberAddCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
memberAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
|
||||
|
||||
memberUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新知识库成员权限",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新指定知识库已有成员的角色。
|
||||
|
||||
支持的角色 (--role)(必须大写):
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
支持的角色(大小写不敏感):
|
||||
MANAGER 管理员
|
||||
EDITOR 编辑者
|
||||
DOWNLOADER 查看下载者
|
||||
@@ -632,28 +678,45 @@ func newWikiCommand() *cobra.Command {
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口变更。
|
||||
- 同一成员在同一知识库只能拥有一个角色,变更后旧角色自动替换。
|
||||
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
|
||||
|
||||
仅可更新已存在成员关系的成员,新增成员请使用 dws wiki member add。`,
|
||||
Example: ` dws wiki member update --workspace <workspaceId> --users uid1 --role EDITOR
|
||||
dws wiki member update --workspace <workspaceId> --users uid1,uid2 --role READER`,
|
||||
dws wiki member update --workspace <workspaceId> --users uid1,uid2 --role READER
|
||||
dws wiki member update --workspace <workspaceId> --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws wiki member update --workspace <workspaceId> --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
toolArgs := map[string]any{"workspaceId": workspaceID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
return callMCPTool("update_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
})
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
if cmd.Flags().Changed("notify") {
|
||||
notify, _ := cmd.Flags().GetBool("notify")
|
||||
toolArgs["notify"] = notify
|
||||
}
|
||||
} else {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["roleId"] = normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
return callMCPTool("update_member", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(memberUpdateCmd, LeafSpec{
|
||||
@@ -682,6 +745,8 @@ func newWikiCommand() *cobra.Command {
|
||||
Examples: []string{"dws wiki member update --workspace <WS_ID> --users uid1 --role EDITOR --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "notify", Property: "notify"},
|
||||
{Name: "role", Property: "roleId"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
@@ -690,10 +755,12 @@ func newWikiCommand() *cobra.Command {
|
||||
})
|
||||
|
||||
memberUpdateCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
memberUpdateCmd.Flags().String("users", "", "被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
memberUpdateCmd.Flags().String("user", "", "")
|
||||
_ = memberUpdateCmd.Flags().MarkHidden("user")
|
||||
memberUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
memberUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填,大小写不敏感)")
|
||||
memberUpdateCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
memberUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
|
||||
|
||||
memberListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -701,12 +768,15 @@ func newWikiCommand() *cobra.Command {
|
||||
Short: "查询知识库成员列表",
|
||||
Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。
|
||||
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。
|
||||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围;
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
|
||||
操作者需满足该知识库配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。
|
||||
ORG 类型授权不会出现在查询结果中。`,
|
||||
Example: ` dws wiki member list --workspace <workspaceId>
|
||||
dws wiki member list --workspace <workspaceId> --limit 100
|
||||
dws wiki member list --workspace <workspaceId> --filter-role MANAGER,EDITOR`,
|
||||
dws wiki member list --workspace <workspaceId> --limit 50
|
||||
dws wiki member list --workspace <workspaceId> --filter-role MANAGER,EDITOR
|
||||
dws wiki member list --workspace <workspaceId> --next-token <上次返回的 nextToken>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
@@ -715,17 +785,13 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
toolArgs := map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
}
|
||||
limit := 0
|
||||
if cmd.Flags().Changed("limit") {
|
||||
limit, _ = cmd.Flags().GetInt("limit")
|
||||
} else if cmd.Flags().Changed("max-results") {
|
||||
limit, _ = cmd.Flags().GetInt("max-results")
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
}
|
||||
if limit > 0 {
|
||||
if limit > 50 {
|
||||
return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页")
|
||||
}
|
||||
toolArgs["maxResults"] = limit
|
||||
if v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -763,50 +829,79 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
Pagination: &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "next-token"},
|
||||
},
|
||||
})
|
||||
|
||||
memberListCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50;底层不支持游标续页")
|
||||
memberListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 50")
|
||||
memberListCmd.Flags().Int("max-results", 0, "")
|
||||
_ = memberListCmd.Flags().MarkHidden("max-results")
|
||||
memberListCmd.Flags().String("filter-role", "", "按角色过滤(逗号分隔):OWNER / MANAGER / EDITOR / DOWNLOADER / READER")
|
||||
memberListCmd.Flags().String("next-token", "", "分页游标,首次不传,后续传入上一次返回的 nextToken")
|
||||
|
||||
memberRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "移除知识库成员",
|
||||
Long: `从指定知识库中移除一个或多个成员(仅支持 USER 类型)。
|
||||
Long: `从指定知识库中移除一个或多个成员。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,只需 type 和 id(USER/DEPT/TAG 还需 corpId)
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId
|
||||
|
||||
移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口移除。
|
||||
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 单次请求最多 30 个成员,超出请分批调用。`,
|
||||
Example: ` dws wiki member remove --workspace <workspaceId> --users uid1
|
||||
dws wiki member remove --workspace <workspaceId> --users uid1,uid2,uid3`,
|
||||
dws wiki member remove --workspace <workspaceId> --users uid1,uid2,uid3
|
||||
dws wiki member remove --workspace <workspaceId> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("remove_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"userIds": userIds,
|
||||
})
|
||||
toolArgs := map[string]any{"workspaceId": workspaceID}
|
||||
members, mErr := collectMembers(cmd, true)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
}
|
||||
if len(members) > 0 {
|
||||
toolArgs["members"] = members
|
||||
} else {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["userIds"] = userIds
|
||||
}
|
||||
return callMCPTool("remove_member", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(memberRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销整个
|
||||
// 知识库容器级别的成员访问,部门/群聊/角色组还可能间接影响大量用户,
|
||||
// 与删除同级的 destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -816,14 +911,14 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
CLIPath: "wiki member remove",
|
||||
PrimaryCLIPath: "wiki member remove",
|
||||
},
|
||||
Description: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)",
|
||||
Description: "从指定知识库中移除一个或多个成员",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "wiki", RPCName: "remove_member"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)",
|
||||
AgentSummary: "从指定知识库中移除一个或多个成员",
|
||||
UseWhen: []string{"从知识库移除成员访问(离职/清理)时"},
|
||||
AvoidWhen: []string{
|
||||
"改角色用 update;节点级权限用 drive permission remove",
|
||||
@@ -832,15 +927,17 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
Examples: []string{"dws wiki member remove --workspace <WS_ID> --users uid1 --format json"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "members", Property: "members"},
|
||||
{Name: "users", Property: "userIds"},
|
||||
{Name: "workspace", Property: "workspaceId"},
|
||||
},
|
||||
},
|
||||
})
|
||||
memberRemoveCmd.Flags().String("workspace", "", "知识库 ID 或 URL (必填)")
|
||||
memberRemoveCmd.Flags().String("users", "", "被移除的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)")
|
||||
memberRemoveCmd.Flags().String("users", "", "被移除的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)")
|
||||
memberRemoveCmd.Flags().String("user", "", "")
|
||||
_ = memberRemoveCmd.Flags().MarkHidden("user")
|
||||
memberRemoveCmd.Flags().String("members", "", "成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥")
|
||||
|
||||
// member 子命令的 --workspace-id 隐藏别名(LLMs derive from API field "workspaceId")
|
||||
memberAliasCmds := []*cobra.Command{memberAddCmd, memberUpdateCmd, memberListCmd, memberRemoveCmd}
|
||||
|
||||
@@ -71,6 +71,13 @@ func patConfigDir() string {
|
||||
return filepath.Join(homeDir, ".dws")
|
||||
}
|
||||
|
||||
// BrowserPolicyConfigDir returns the exact configuration directory used by
|
||||
// the native PAT command. Shortcut adapters use this narrow seam so their
|
||||
// write/readback cycle cannot drift to a different policy file.
|
||||
func BrowserPolicyConfigDir() string {
|
||||
return patConfigDir()
|
||||
}
|
||||
|
||||
func LoadBrowserPolicy(configDir string) (*BrowserPolicy, error) {
|
||||
path := patPolicyPath(configDir)
|
||||
data, err := patPolicyReadFile(path)
|
||||
@@ -203,6 +210,32 @@ func SetBrowserPolicy(configDir, explicitAgentCode string, enabled bool) (Browse
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReadStoredBrowserPolicy reads the exact default or explicitly named agent
|
||||
// entry without consulting the process agent-code environment. This is the
|
||||
// verification companion to SetBrowserPolicy: callers can prove the same
|
||||
// target was persisted rather than observing an environment-selected fallback.
|
||||
func ReadStoredBrowserPolicy(configDir, explicitAgentCode string) (BrowserPolicySelection, error) {
|
||||
agentCode, err := resolveBrowserPolicyWriteAgentCode(explicitAgentCode)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
policy, err := LoadBrowserPolicy(configDir)
|
||||
if err != nil {
|
||||
return BrowserPolicySelection{}, err
|
||||
}
|
||||
if agentCode != "" {
|
||||
entry, ok := policy.Agents[agentCode]
|
||||
if !ok {
|
||||
return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy agent entry was not persisted")
|
||||
}
|
||||
return BrowserPolicySelection{Scope: "agent", AgentCode: agentCode, OpenBrowser: entry.OpenBrowser, Source: "agent"}, nil
|
||||
}
|
||||
if policy.Default == nil {
|
||||
return BrowserPolicySelection{}, fmt.Errorf("PAT browser policy default entry was not persisted")
|
||||
}
|
||||
return BrowserPolicySelection{Scope: "default", OpenBrowser: policy.Default.OpenBrowser, Source: "default"}, nil
|
||||
}
|
||||
|
||||
func newBrowserPolicyCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "browser-policy",
|
||||
|
||||
@@ -105,6 +105,9 @@ func TestCrossPlatformCoverageBrowserPolicyInjectedEdges(t *testing.T) {
|
||||
if _, err := LoadBrowserPolicy("x"); !errors.Is(err, failure) {
|
||||
t.Fatalf("read error = %v", err)
|
||||
}
|
||||
if _, err := ReadStoredBrowserPolicy("x", ""); !errors.Is(err, failure) {
|
||||
t.Fatalf("readback error = %v", err)
|
||||
}
|
||||
patPolicyReadFile = func(string) ([]byte, error) { return []byte(`{`), nil }
|
||||
if _, err := LoadBrowserPolicy("x"); err == nil {
|
||||
t.Fatal("invalid policy decoded")
|
||||
@@ -179,6 +182,53 @@ func TestCrossPlatformCoverageBrowserPolicyCommandSetError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBrowserPolicyReadbackAndSelectionEdges(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if got := BrowserPolicyConfigDir(); strings.TrimSpace(got) == "" {
|
||||
t.Fatal("browser policy config directory is empty")
|
||||
}
|
||||
if _, err := ReadStoredBrowserPolicy(configDir, "bad code"); err == nil {
|
||||
t.Fatal("invalid readback agent code succeeded")
|
||||
}
|
||||
if _, err := ReadStoredBrowserPolicy(configDir, "missing"); err == nil {
|
||||
t.Fatal("missing agent readback succeeded")
|
||||
}
|
||||
if _, err := ReadStoredBrowserPolicy(configDir, ""); err == nil {
|
||||
t.Fatal("missing default readback succeeded")
|
||||
}
|
||||
if _, err := SetBrowserPolicy(configDir, "agent", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
agent, err := ReadStoredBrowserPolicy(configDir, "agent")
|
||||
if err != nil || agent.Scope != "agent" || agent.AgentCode != "agent" || agent.OpenBrowser {
|
||||
t.Fatalf("agent readback = %#v, %v", agent, err)
|
||||
}
|
||||
resolvedAgent, err := ResolveBrowserPolicy(configDir, "agent")
|
||||
if err != nil || resolvedAgent.Source != "agent" || resolvedAgent.OpenBrowser {
|
||||
t.Fatalf("agent selection = %#v, %v", resolvedAgent, err)
|
||||
}
|
||||
if _, err := SetBrowserPolicy(configDir, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defaultPolicy, err := ReadStoredBrowserPolicy(configDir, "")
|
||||
if err != nil || defaultPolicy.Scope != "default" || !defaultPolicy.OpenBrowser {
|
||||
t.Fatalf("default readback = %#v, %v", defaultPolicy, err)
|
||||
}
|
||||
resolvedDefault, err := ResolveBrowserPolicy(configDir, "unknown")
|
||||
if err != nil || resolvedDefault.Source != "default" || !resolvedDefault.OpenBrowser {
|
||||
t.Fatalf("default selection = %#v, %v", resolvedDefault, err)
|
||||
}
|
||||
empty, err := ResolveBrowserPolicy(t.TempDir(), "")
|
||||
if err != nil || empty.Source == "" || !empty.OpenBrowser {
|
||||
t.Fatalf("built-in selection = %#v, %v", empty, err)
|
||||
}
|
||||
|
||||
cmd := newBrowserPolicyCommand()
|
||||
if err := cmd.Execute(); err == nil {
|
||||
t.Fatal("browser policy command accepted missing --enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATPureHelperEdges(t *testing.T) {
|
||||
if got := collectChmodProductCodes([]string{"", "a,a", " b "}, []string{"b", "c"}); strings.Join(got, ",") != "a,b,c" {
|
||||
t.Fatalf("product codes = %#v", got)
|
||||
|
||||
@@ -18,6 +18,7 @@ package pat
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
@@ -91,5 +92,17 @@ DWS_CHANNEL 只用于上游 channelCode。`,
|
||||
|
||||
patCmd.AddCommand(newChmodCommand(c))
|
||||
patCmd.AddCommand(newBrowserPolicyCommand())
|
||||
// Shortcuts are mounted before this open-source PAT tree in the app. Fold
|
||||
// that pre-existing service group into the native PAT parent so Cobra and
|
||||
// Schema both see exactly one top-level path while preserving PAT's group
|
||||
// behavior and adding every +leaf alongside chmod/browser-policy.
|
||||
for _, existing := range root.Commands() {
|
||||
if existing.Name() != patCmd.Name() {
|
||||
continue
|
||||
}
|
||||
cobracmd.MergeCommandTree(patCmd, existing)
|
||||
root.RemoveCommand(existing)
|
||||
break
|
||||
}
|
||||
root.AddCommand(patCmd)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRegisterCommandsMergesExistingPATShortcutGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
shortcutGroup := &cobra.Command{Use: "pat", Short: "pat shortcuts"}
|
||||
shortcutGroup.AddCommand(&cobra.Command{Use: "+fixture"})
|
||||
root.AddCommand(&cobra.Command{Use: "aaa-other"})
|
||||
root.AddCommand(shortcutGroup)
|
||||
|
||||
RegisterCommands(root, &fakeToolCaller{})
|
||||
|
||||
var patGroups []*cobra.Command
|
||||
for _, command := range root.Commands() {
|
||||
if command.Name() == "pat" {
|
||||
patGroups = append(patGroups, command)
|
||||
}
|
||||
}
|
||||
if len(patGroups) != 1 {
|
||||
t.Fatalf("top-level PAT groups = %d, want 1", len(patGroups))
|
||||
}
|
||||
for _, child := range []string{"+fixture", "chmod", "browser-policy"} {
|
||||
if found, _, err := patGroups[0].Find([]string{child}); err != nil || found == nil || found.Name() != child {
|
||||
t.Errorf("merged PAT child %q missing: found=%v err=%v", child, found, err)
|
||||
}
|
||||
}
|
||||
if patGroups[0].Short != "行为授权管理" {
|
||||
t.Fatalf("native PAT group metadata was not preserved: %q", patGroups[0].Short)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package aisearch implements strict, task-oriented Shortcut projections over
|
||||
// DingTalk's enterprise search APIs.
|
||||
package aisearch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
const (
|
||||
aisearchCompositeReason = "Reviewed AiSearch Shortcut adapter: the executable CLI owns strict success, collection, item, stable-identity, and output validation; the task contract is stronger than a direct MCP passthrough."
|
||||
aisearchZeroGapReason = "Live exact-leaf probes with a random zero-match marker still returned non-empty business objects, so the Shortcut cannot prove query relevance or a legitimate zero result."
|
||||
)
|
||||
|
||||
var aisearchReadSafety = contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
}
|
||||
|
||||
func aisearchResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中通过严格校验的命中数量"},"matches":{"type":"array","description":"通过 success、集合、元素和稳定身份校验的企业人员命中;该接口不发布分页或全局完整性承诺","items":{"type":"object","description":"带稳定身份且来源类型固定为 person 的企业人员命中","properties":{"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"openDingTalkId":{"type":"string","minLength":1,"description":"稳定开放用户 ID"},"url":{"type":"string","minLength":1,"description":"稳定资源 URL"},"sourceType":{"type":"string","enum":["person"],"description":"已审核的企业人员来源类型"}},"required":["sourceType"],"anyOf":[{"required":["userId"]},{"required":["openDingTalkId"]},{"required":["url"]}],"additionalProperties":true}}},"required":["count","matches"],"additionalProperties":false}`,
|
||||
description,
|
||||
)),
|
||||
SensitivePaths: []string{
|
||||
"matches.userId", "matches.openDingTalkId", "matches.url", "matches.name",
|
||||
"matches.mobile", "matches.phone", "matches.jobNumber", "matches.email",
|
||||
"matches.department", "matches.position", "matches.duty",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func aisearchContract(command, description, intent string, available bool, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
availability := contract.InterfaceAvailable
|
||||
reason := aisearchCompositeReason
|
||||
if !available {
|
||||
availability = contract.InterfaceUnavailable
|
||||
reason = aisearchZeroGapReason
|
||||
}
|
||||
decl := corecmd.ContractDecl{
|
||||
Description: description,
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aisearch", Name: name, CanonicalPath: "aisearch." + name,
|
||||
CLIPath: "aisearch " + command, PrimaryCLIPath: "aisearch " + command,
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{intent},
|
||||
AvoidWhen: []string{
|
||||
"已有稳定资源 ID 时使用对应产品详情命令;不要把搜索命中当作资源写入回执",
|
||||
"需要全局完结或可续页枚举时不要使用;下层未发布分页与终止证据",
|
||||
},
|
||||
Examples: examples,
|
||||
},
|
||||
Parameters: flags,
|
||||
}
|
||||
if available {
|
||||
decl.Result = aisearchResult(description)
|
||||
}
|
||||
return decl
|
||||
}
|
||||
|
||||
// SearchPerson is the only public AiSearch Shortcut. Exact live evidence
|
||||
// proves both a known non-empty user fixture and an explicit zero hit in the
|
||||
// phone dimension using a value longer than any valid phone number.
|
||||
var SearchPerson = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "aisearch", Command: "+search-person", Product: "aisearch",
|
||||
Description: "按姓名、部门、职责或组织关系语义搜索企业人员",
|
||||
Intent: "只知道人名、部门、职责、手机号、工号或上下级关系,需要获得可用于后续任务的人员稳定身份时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: aisearchReadSafety,
|
||||
Contract: aisearchContract(
|
||||
"+search-person",
|
||||
"按姓名、部门、职责或组织关系语义搜索企业人员",
|
||||
"只知道人名、部门、职责、手机号、工号或上下级关系,需要获得可用于后续任务的人员稳定身份时使用。",
|
||||
true,
|
||||
[]contract.ParamDecl{{Name: "query", Property: "keyword"}, {Name: "dimensions", Property: "dimension"}},
|
||||
`dws aisearch +search-person --query "张三" --dimensions name --format json`,
|
||||
`dws aisearch +search-person --query "产品" --dimensions duty,department --format json`,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "人员搜索关键词;--query 不能为空白"},
|
||||
{Name: "dimensions", Type: shortcut.FlagStringSlice, Default: "all", Desc: "搜索维度;--dimensions 只能包含 all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,all 不能与其他维度并用"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空白"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"dimensions"}, Description: "--dimensions 只能包含 all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,all 不能与其他维度并用"},
|
||||
},
|
||||
Validate: validatePerson,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return executeSearchForSource(rt, "enterprise_person_search", map[string]any{
|
||||
"keyword": rt.Str("query"), "dimension": rt.StrSlice("dimensions"),
|
||||
}, []string{"userId", "openDingTalkId", "url"}, "person")
|
||||
},
|
||||
}
|
||||
|
||||
// SearchEnterprise remains registered but unavailable. The IM slice can return
|
||||
// stable identities for a known fixture, but a fresh random marker also
|
||||
// returned a business object, so the service cannot prove a legitimate zero
|
||||
// result or strict query relevance.
|
||||
var SearchEnterprise = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutLegacyOnly,
|
||||
Service: "aisearch", Command: "+search-enterprise", Product: "aisearch",
|
||||
Description: "按主题搜索企业知识、消息、邮件与协作内容",
|
||||
Intent: "需要按关键词搜索企业即时消息时使用;当前因随机查询仍返回业务对象,无法证明严格相关性与合法零结果,保持不可用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: aisearchReadSafety,
|
||||
Contract: aisearchContract(
|
||||
"+search-enterprise",
|
||||
"按主题搜索企业知识、消息、邮件与协作内容",
|
||||
"需要按关键词搜索企业即时消息时使用;当前因随机查询仍返回业务对象,无法证明严格相关性与合法零结果,保持不可用。",
|
||||
false,
|
||||
[]contract.ParamDecl{{Name: "queries"}, {Name: "types", Property: "searchTypes"}, {Name: "time-range", Property: "timeRange"}},
|
||||
`dws aisearch +search-enterprise --queries "发布方案" --types im --format json`,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "queries", Type: shortcut.FlagStringSlice, Required: true, Desc: "即时消息关键词列表"},
|
||||
{Name: "types", Type: shortcut.FlagStringSlice, Default: "im", Desc: "已审核内容类型;--types 当前必须且只能为 im"},
|
||||
{Name: "time-range", Type: shortcut.FlagString, Desc: "服务端自然语言时间范围,如今天、本周或过去一月"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{
|
||||
Kind: shortcut.ConstraintCustom, Flags: []string{"types"},
|
||||
Description: "--types 当前必须且只能为 im",
|
||||
}},
|
||||
Validate: validateEnterprise,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return unavailableSearch("aisearch/search_enterprise")
|
||||
},
|
||||
}
|
||||
|
||||
// SearchBehavior remains registered but unavailable for the same zero-match
|
||||
// relevance gap as SearchEnterprise.
|
||||
var SearchBehavior = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutLegacyOnly,
|
||||
Service: "aisearch", Command: "+search-behavior", Product: "aisearch",
|
||||
Description: "搜索发送、创建、分享、编辑或接收等企业行为记录",
|
||||
Intent: "用户明确询问谁发送、创建、分享、编辑或接收过什么时使用;当前因零命中相关性合同不足保持不可用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: aisearchReadSafety,
|
||||
Contract: aisearchContract(
|
||||
"+search-behavior",
|
||||
"搜索发送、创建、分享、编辑或接收等企业行为记录",
|
||||
"用户明确询问谁发送、创建、分享、编辑或接收过什么时使用;当前因零命中相关性合同不足保持不可用。",
|
||||
false,
|
||||
[]contract.ParamDecl{
|
||||
{Name: "queries"}, {Name: "types", Property: "searchTypes"},
|
||||
{Name: "behavior", Property: "behaviorType"}, {Name: "chat-scope", Property: "chatScope"},
|
||||
{Name: "time-range", Property: "timeRange"}, {Name: "direction"},
|
||||
},
|
||||
`dws aisearch +search-behavior --types mail --behavior send --direction "我->同事" --format json`,
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "queries", Type: shortcut.FlagStringSlice, Desc: "内容关键词列表;汇总场景可留空"},
|
||||
{Name: "types", Type: shortcut.FlagStringSlice, Default: "all", Desc: "内容类型:all/document/im/calendar/todo/minute/report/image/link/notable/baike/mail"},
|
||||
{Name: "behavior", Type: shortcut.FlagString, Default: "all", Enum: []string{"all", "send", "create", "share", "edit", "receive"}, Desc: "行为类型"},
|
||||
{Name: "chat-scope", Type: shortcut.FlagString, Desc: "仅 IM 搜索时的会话或群范围"},
|
||||
{Name: "time-range", Type: shortcut.FlagString, Desc: "服务端自然语言时间范围"},
|
||||
{Name: "direction", Type: shortcut.FlagString, Desc: "交互方向,如我->同事"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{
|
||||
Kind: shortcut.ConstraintCustom, Flags: []string{"types"},
|
||||
Description: "--types 只能包含已审核内容类型,all 不能与其他类型并用;--chat-scope 仅能与 im 类型组合",
|
||||
}},
|
||||
Validate: validateBehavior,
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return unavailableSearch("aisearch/search_enterprise_behavior")
|
||||
},
|
||||
}
|
||||
|
||||
func unavailableSearch(operation string) error {
|
||||
return responsecheck.Error(operation, "capability_unavailable", aisearchZeroGapReason)
|
||||
}
|
||||
|
||||
func validatePerson(rt *shortcut.RuntimeContext) error {
|
||||
if strings.TrimSpace(rt.Str("query")) == "" {
|
||||
return responsecheck.Error("aisearch/enterprise_person_search", "empty_query", "--query 不能为空白")
|
||||
}
|
||||
return validateValues("--dimensions", rt.StrSlice("dimensions"), map[string]bool{
|
||||
"all": true, "name": true, "department": true, "position": true, "duty": true,
|
||||
"supervisor": true, "subordinate": true, "phone": true, "jobNumber": true,
|
||||
})
|
||||
}
|
||||
|
||||
func validateEnterprise(rt *shortcut.RuntimeContext) error {
|
||||
queries := rt.StrSlice("queries")
|
||||
if len(queries) == 0 {
|
||||
return responsecheck.Error("aisearch/search_enterprise", "empty_query", "--queries 至少需要一个非空即时消息关键词")
|
||||
}
|
||||
for _, query := range queries {
|
||||
if strings.TrimSpace(query) == "" {
|
||||
return responsecheck.Error("aisearch/search_enterprise", "empty_query", "--queries 不能包含空关键词")
|
||||
}
|
||||
}
|
||||
types := rt.StrSlice("types")
|
||||
if len(types) != 1 || strings.TrimSpace(types[0]) != "im" {
|
||||
return responsecheck.Error("aisearch/search_enterprise", "unsupported_search_type", "--types 当前必须且只能为 im;其他类型请使用 aisearch enterprise 原子命令")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBehavior(rt *shortcut.RuntimeContext) error {
|
||||
types := rt.StrSlice("types")
|
||||
if err := validateSearchTypes(types); err != nil {
|
||||
return err
|
||||
}
|
||||
if rt.Str("chat-scope") != "" && !contains(types, "im") {
|
||||
return responsecheck.Error("aisearch/search_enterprise_behavior", "invalid_chat_scope", "--chat-scope 仅能与 --types im 组合")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSearchTypes(values []string) error {
|
||||
return validateValues("--types", values, map[string]bool{
|
||||
"all": true, "document": true, "im": true, "calendar": true, "todo": true,
|
||||
"minute": true, "report": true, "image": true, "link": true, "notable": true,
|
||||
"baike": true, "mail": true,
|
||||
})
|
||||
}
|
||||
|
||||
func validateValues(flag string, values []string, allowed map[string]bool) error {
|
||||
if len(values) == 0 {
|
||||
return responsecheck.Error("aisearch/validate", "empty_selector", flag+" 不能为空")
|
||||
}
|
||||
seen := make(map[string]bool, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || !allowed[value] {
|
||||
return responsecheck.Error("aisearch/validate", "invalid_selector", fmt.Sprintf("%s 包含未支持值 %q", flag, value))
|
||||
}
|
||||
if seen[value] {
|
||||
return responsecheck.Error("aisearch/validate", "duplicate_selector", fmt.Sprintf("%s 包含重复值 %q", flag, value))
|
||||
}
|
||||
seen[value] = true
|
||||
}
|
||||
if len(values) > 1 && seen["all"] {
|
||||
return responsecheck.Error("aisearch/validate", "conflicting_selector", flag+" 中 all 不能与其他值并用")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contains(values []string, expected string) bool {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func executeSearchForSource(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys []string, expectedSource string) error {
|
||||
operation := "aisearch/" + tool
|
||||
data, err := rt.CallMCPReadData("aisearch", tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
matches, err := projectSearch(data, operation, identityKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for index, item := range matches {
|
||||
if source := firstNonEmptyString(item, "sourceType"); source != expectedSource {
|
||||
return responsecheck.Error(operation, "unexpected_item_source", fmt.Sprintf("响应 result[%d] 来源 %q 与已审核类型 %q 不一致", index, source, expectedSource))
|
||||
}
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(matches), "matches": matches})
|
||||
}
|
||||
|
||||
func projectSearch(data map[string]any, operation string, identityKeys []string) ([]map[string]any, error) {
|
||||
if hasConflictingError(data) {
|
||||
return nil, responsecheck.Error(operation, "conflicting_error_fields", "响应同时声明 success=true 和非空错误字段")
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(data, operation, "result")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for index, item := range items {
|
||||
projected := make(map[string]any, len(item))
|
||||
for key, value := range item {
|
||||
projected[key] = value
|
||||
}
|
||||
source, ok := projected["sourceType"].(string)
|
||||
source = strings.TrimSpace(source)
|
||||
if !ok || source == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_item_source", fmt.Sprintf("响应 result[%d] 缺少非空 sourceType", index))
|
||||
}
|
||||
projected["sourceType"] = source
|
||||
candidateKeys := append([]string(nil), identityKeys...)
|
||||
for _, key := range []string{"userId", "openDingTalkId", "url", "openConversationId"} {
|
||||
if !contains(candidateKeys, key) {
|
||||
candidateKeys = append(candidateKeys, key)
|
||||
}
|
||||
}
|
||||
for _, key := range candidateKeys {
|
||||
raw, present := projected[key]
|
||||
if !present || raw == nil {
|
||||
delete(projected, key)
|
||||
continue
|
||||
}
|
||||
value, valid := raw.(string)
|
||||
if !valid {
|
||||
return nil, responsecheck.Error(operation, "malformed_item_identity", fmt.Sprintf("响应 result[%d].%s 必须是字符串", index, key))
|
||||
}
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
delete(projected, key)
|
||||
continue
|
||||
}
|
||||
projected[key] = value
|
||||
}
|
||||
identity := firstNonEmptyString(projected, identityKeys...)
|
||||
if identity == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("响应 result[%d] 缺少稳定身份", index))
|
||||
}
|
||||
if seen[identity] {
|
||||
return nil, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("响应 result[%d] 重复稳定身份", index))
|
||||
}
|
||||
seen[identity] = true
|
||||
out = append(out, projected)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hasConflictingError(data map[string]any) bool {
|
||||
envelope := data
|
||||
if content, ok := data["content"].(map[string]any); ok {
|
||||
envelope = content
|
||||
}
|
||||
success, ok := envelope["success"].(bool)
|
||||
if !ok || !success {
|
||||
return false
|
||||
}
|
||||
if message := firstNonEmptyString(envelope, "errorMsg", "errorMessage"); message != "" {
|
||||
return true
|
||||
}
|
||||
if code, present := envelope["errorCode"]; present && code != nil {
|
||||
switch typed := code.(type) {
|
||||
case string:
|
||||
return strings.TrimSpace(typed) != "" && strings.TrimSpace(typed) != "0"
|
||||
case float64:
|
||||
return typed != 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func firstNonEmptyString(object map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(SearchPerson, SearchEnterprise, SearchBehavior)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package aisearch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type aisearchCaller struct {
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
payload string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (caller *aisearchCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
caller.product, caller.tool, caller.args = product, tool, args
|
||||
caller.calls++
|
||||
if caller.err != nil {
|
||||
return nil, caller.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.payload}}}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAiSearchValidationAndExecutionBranches(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
values []string
|
||||
wantErr bool
|
||||
}{
|
||||
{nil, true},
|
||||
{[]string{"unknown"}, true},
|
||||
{[]string{"name", "name"}, true},
|
||||
{[]string{"all", "name"}, true},
|
||||
{[]string{"name", "duty"}, false},
|
||||
} {
|
||||
if err := validateValues("--dimensions", test.values, map[string]bool{"all": true, "name": true, "duty": true}); (err != nil) != test.wantErr {
|
||||
t.Errorf("validateValues(%v) err=%v wantErr=%v", test.values, err, test.wantErr)
|
||||
}
|
||||
}
|
||||
|
||||
enterprise := func(queries, types []string) error {
|
||||
cmd := &cobra.Command{Use: "+search-enterprise"}
|
||||
cmd.Flags().StringSlice("queries", queries, "")
|
||||
cmd.Flags().StringSlice("types", types, "")
|
||||
return validateEnterprise(shortcut.RuntimeContextForTest(cmd, SearchEnterprise))
|
||||
}
|
||||
if enterprise(nil, []string{"im"}) == nil || enterprise([]string{" "}, []string{"im"}) == nil || enterprise([]string{"fixture"}, []string{"document"}) == nil || enterprise([]string{"fixture"}, []string{"im"}) != nil {
|
||||
t.Fatal("enterprise validation branch matrix drift")
|
||||
}
|
||||
behavior := &cobra.Command{Use: "+search-behavior"}
|
||||
behavior.Flags().StringSlice("types", []string{"im"}, "")
|
||||
behavior.Flags().String("chat-scope", "fixture", "")
|
||||
if err := validateBehavior(shortcut.RuntimeContextForTest(behavior, SearchBehavior)); err != nil {
|
||||
t.Fatalf("valid IM behavior selector: %v", err)
|
||||
}
|
||||
|
||||
caller := &aisearchCaller{payload: `{"success":true,"result":[]}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
declaration := SearchPerson
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
command := &cobra.Command{Use: "+search-person"}
|
||||
command.Flags().String("query", "fixture", "")
|
||||
command.Flags().StringSlice("dimensions", []string{"name"}, "")
|
||||
runtime := shortcut.RuntimeContextForTest(command, declaration)
|
||||
caller.err = errors.New("transport")
|
||||
if err := executeSearchForSource(runtime, "enterprise_person_search", map[string]any{"keyword": "fixture"}, []string{"userId"}, "person"); err == nil {
|
||||
t.Fatal("transport failure returned success")
|
||||
}
|
||||
caller.err = nil
|
||||
caller.payload = `{"success":true}`
|
||||
if err := executeSearchForSource(runtime, "enterprise_person_search", map[string]any{"keyword": "fixture"}, []string{"userId"}, "person"); err == nil {
|
||||
t.Fatal("projection failure returned success")
|
||||
}
|
||||
caller.err = errors.New("transport")
|
||||
if err := executeSearchForSource(runtime, "search_enterprise", nil, []string{"openConversationId"}, "im"); err == nil {
|
||||
t.Fatal("source transport failure returned success")
|
||||
}
|
||||
caller.err = nil
|
||||
caller.payload = `{"success":true}`
|
||||
if err := executeSearchForSource(runtime, "search_enterprise", nil, []string{"openConversationId"}, "im"); err == nil {
|
||||
t.Fatal("source projection failure returned success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAiSearchConflictingEvidenceAndIdentityNormalization(t *testing.T) {
|
||||
for index, data := range []map[string]any{
|
||||
{"content": map[string]any{"success": true, "errorCode": float64(1), "result": []any{}}},
|
||||
{"success": true, "errorCode": map[string]any{}, "result": []any{}},
|
||||
} {
|
||||
if !hasConflictingError(data) {
|
||||
t.Errorf("conflicting evidence %d not detected", index)
|
||||
}
|
||||
}
|
||||
for index, data := range []map[string]any{
|
||||
{"success": false, "errorCode": "FAILED"},
|
||||
{"success": true, "errorCode": float64(0)},
|
||||
{"success": true, "errorCode": "0"},
|
||||
} {
|
||||
if hasConflictingError(data) {
|
||||
t.Errorf("non-conflicting evidence %d rejected", index)
|
||||
}
|
||||
}
|
||||
items, err := projectSearch(map[string]any{"success": true, "result": []any{map[string]any{
|
||||
"sourceType": " person ", "userId": " stable ", "openDingTalkId": " ", "url": nil,
|
||||
}}}, "aisearch/test", []string{"userId"})
|
||||
if err != nil || len(items) != 1 || items[0]["sourceType"] != "person" || items[0]["userId"] != "stable" {
|
||||
t.Fatalf("identity normalization failed: items=%v err=%v", items, err)
|
||||
}
|
||||
if _, present := items[0]["openDingTalkId"]; present {
|
||||
t.Fatal("blank optional identity was not omitted")
|
||||
}
|
||||
if _, err := projectSearch(map[string]any{"success": true, "result": []any{map[string]any{"sourceType": " ", "userId": "stable"}}}, "aisearch/test", []string{"userId"}); err == nil {
|
||||
t.Fatal("blank sourceType returned success")
|
||||
}
|
||||
}
|
||||
|
||||
func (*aisearchCaller) Format() string { return "json" }
|
||||
func (*aisearchCaller) DryRun() bool { return false }
|
||||
func (*aisearchCaller) Fields() string { return "" }
|
||||
func (*aisearchCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageAiSearchRejectsFalseSuccessAndBadCollections(t *testing.T) {
|
||||
for identity, value := range map[string]string{
|
||||
"userId": "user-fixture", "openDingTalkId": "open-fixture", "url": "https://example.invalid/user-fixture",
|
||||
} {
|
||||
valid := map[string]any{"success": true, "result": []any{map[string]any{"sourceType": "person", identity: value}}}
|
||||
items, err := projectSearch(valid, "aisearch/test", []string{"userId", "openDingTalkId", "url"})
|
||||
if err != nil || len(items) != 1 || items[0][identity] != value {
|
||||
t.Fatalf("valid %s identity rejected: items=%v err=%v", identity, items, err)
|
||||
}
|
||||
}
|
||||
explicitEmpty := map[string]any{"success": true, "result": []any{}}
|
||||
items, err := projectSearch(explicitEmpty, "aisearch/test", []string{"userId"})
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("explicit empty rejected: items=%v err=%v", items, err)
|
||||
}
|
||||
|
||||
broken := []map[string]any{
|
||||
{},
|
||||
{"result": []any{}},
|
||||
{"success": "true", "result": []any{}},
|
||||
{"success": false, "result": []any{}},
|
||||
{"success": true},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": []any{"bad"}},
|
||||
{"success": true, "result": []any{map[string]any{}}},
|
||||
{"success": true, "result": []any{map[string]any{"sourceType": "person"}}},
|
||||
{"success": true, "result": []any{map[string]any{"sourceType": "person", "userId": 1}}},
|
||||
{"success": true, "result": []any{map[string]any{"sourceType": "person", "userId": "same"}, map[string]any{"sourceType": "person", "userId": "same"}}},
|
||||
{"success": true, "result": []any{map[string]any{"sourceType": "person", "userId": "good", "openDingTalkId": map[string]any{}}}},
|
||||
{"success": true, "errorCode": "REMOTE_ERROR", "result": []any{}},
|
||||
{"success": true, "errorMsg": "conflict", "result": []any{}},
|
||||
}
|
||||
for index, data := range broken {
|
||||
if got, projectErr := projectSearch(data, "aisearch/test", []string{"userId"}); projectErr == nil {
|
||||
t.Errorf("broken response %d returned success: %v", index, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAiSearchSelectorsFailBeforeRemoteCall(t *testing.T) {
|
||||
caller := &aisearchCaller{payload: `{"success":true,"result":[]}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := &cobra.Command{Use: "+search-person"}
|
||||
cmd.Flags().StringSlice("dimensions", []string{"all", "name"}, "")
|
||||
if err := validatePerson(shortcut.RuntimeContextForTest(cmd, SearchPerson)); err == nil {
|
||||
t.Fatal("all plus another dimension must fail")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid selector made %d remote calls", caller.calls)
|
||||
}
|
||||
blank := &cobra.Command{Use: "+search-person"}
|
||||
blank.Flags().String("query", " ", "")
|
||||
blank.Flags().StringSlice("dimensions", []string{"name"}, "")
|
||||
if err := validatePerson(shortcut.RuntimeContextForTest(blank, SearchPerson)); err == nil {
|
||||
t.Fatal("blank person query must fail")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("blank person query made %d remote calls", caller.calls)
|
||||
}
|
||||
|
||||
behavior := &cobra.Command{Use: "+search-behavior"}
|
||||
behavior.Flags().StringSlice("types", []string{"document"}, "")
|
||||
behavior.Flags().String("chat-scope", "fixture-chat", "")
|
||||
if err := validateBehavior(shortcut.RuntimeContextForTest(behavior, SearchBehavior)); err == nil {
|
||||
t.Fatal("chat scope without im must fail")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid behavior selector made %d remote calls", caller.calls)
|
||||
}
|
||||
|
||||
enterprise := &cobra.Command{Use: "+search-enterprise"}
|
||||
enterprise.Flags().StringSlice("queries", []string{""}, "")
|
||||
enterprise.Flags().StringSlice("types", []string{"im"}, "")
|
||||
if err := validateEnterprise(shortcut.RuntimeContextForTest(enterprise, SearchEnterprise)); err == nil {
|
||||
t.Fatal("empty enterprise query must fail")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("empty enterprise query made %d remote calls", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnavailableAiSearchMakesNoRemoteCall(t *testing.T) {
|
||||
caller := &aisearchCaller{payload: `{"success":true,"result":[]}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
for _, declaration := range []shortcut.Shortcut{SearchEnterprise, SearchBehavior} {
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, declaration)); err == nil || !strings.Contains(err.Error(), "cannot prove query relevance") {
|
||||
t.Errorf("%s unavailable error = %v", declaration.Command, err)
|
||||
}
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("unavailable searches made %d remote calls", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAiSearchExactShortcutMapping(t *testing.T) {
|
||||
caller := &aisearchCaller{payload: `{"success":true,"errorCode":null,"errorMsg":"","result":[{"sourceType":"person","userId":"stable-user"}]}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
|
||||
declaration := SearchPerson
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
service := &cobra.Command{Use: "aisearch"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"aisearch", "+search-person", "--query", "fixture", "--dimensions", "name,duty"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("exact shortcut execution: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.product != "aisearch" || caller.tool != "enterprise_person_search" {
|
||||
t.Fatalf("call = count:%d product:%q tool:%q", caller.calls, caller.product, caller.tool)
|
||||
}
|
||||
if caller.args["keyword"] != "fixture" {
|
||||
t.Fatalf("keyword = %#v", caller.args["keyword"])
|
||||
}
|
||||
dimensions, ok := caller.args["dimension"].([]string)
|
||||
if !ok || strings.Join(dimensions, ",") != "name,duty" {
|
||||
t.Fatalf("dimensions = %#v", caller.args["dimension"])
|
||||
}
|
||||
|
||||
caller.calls = 0
|
||||
caller.payload = `{"success":true,"result":[{"sourceType":"im","url":"https://example.invalid/non-person-resource"}]}`
|
||||
driftCommand := &cobra.Command{Use: "+search-person"}
|
||||
driftCommand.Flags().String("query", "fixture", "")
|
||||
driftCommand.Flags().StringSlice("dimensions", []string{"name"}, "")
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(driftCommand, declaration)); err == nil || !strings.Contains(err.Error(), "来源") {
|
||||
t.Fatalf("non-person source was not rejected: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.tool != "enterprise_person_search" {
|
||||
t.Fatalf("source drift call = count:%d tool:%q", caller.calls, caller.tool)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAiSearchEnterpriseHardenedMapperSourceGuard(t *testing.T) {
|
||||
caller := &aisearchCaller{payload: `{"success":true,"result":[{"sourceType":"im","openConversationId":"cid-fixture"}]}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
|
||||
declaration := SearchEnterprise
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
command := &cobra.Command{Use: "+search-enterprise"}
|
||||
command.Flags().StringSlice("queries", []string{"fixture"}, "")
|
||||
command.Flags().StringSlice("types", []string{"im"}, "")
|
||||
command.Flags().String("time-range", "过去一周", "")
|
||||
runtime := shortcut.RuntimeContextForTest(command, declaration)
|
||||
if err := executeSearchForSource(runtime, "search_enterprise", map[string]any{
|
||||
"queries": []string{"fixture"}, "searchTypes": []string{"im"}, "timeRange": "过去一周",
|
||||
}, []string{"openConversationId", "url"}, "im"); err != nil {
|
||||
t.Fatalf("hardened enterprise mapper: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.product != "aisearch" || caller.tool != "search_enterprise" {
|
||||
t.Fatalf("call = count:%d product:%q tool:%q", caller.calls, caller.product, caller.tool)
|
||||
}
|
||||
if got := caller.args["searchTypes"]; len(got.([]string)) != 1 || got.([]string)[0] != "im" {
|
||||
t.Fatalf("searchTypes = %#v", got)
|
||||
}
|
||||
if caller.args["timeRange"] != "过去一周" {
|
||||
t.Fatalf("timeRange = %#v", caller.args["timeRange"])
|
||||
}
|
||||
|
||||
caller.payload = `{"success":true,"result":[{"sourceType":"minute","openConversationId":"cid-fixture"}]}`
|
||||
badCommand := &cobra.Command{Use: "+search-enterprise"}
|
||||
badCommand.Flags().StringSlice("queries", []string{"fixture"}, "")
|
||||
badCommand.Flags().StringSlice("types", []string{"im"}, "")
|
||||
badCommand.Flags().String("time-range", "", "")
|
||||
if err := executeSearchForSource(
|
||||
shortcut.RuntimeContextForTest(badCommand, declaration),
|
||||
"search_enterprise",
|
||||
map[string]any{"queries": []string{"fixture"}, "searchTypes": []string{"im"}},
|
||||
[]string{"openConversationId", "url"},
|
||||
"im",
|
||||
); err == nil || !strings.Contains(err.Error(), "来源") {
|
||||
t.Fatalf("source drift error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAiSearchCatalogAndContracts(t *testing.T) {
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "aisearch" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != 3 {
|
||||
t.Fatalf("registered AiSearch shortcuts = %d, want 3", len(registered))
|
||||
}
|
||||
for _, command := range []string{"+search-person", "+search-enterprise", "+search-behavior"} {
|
||||
item := registered[command]
|
||||
if item.Command == "" {
|
||||
t.Fatalf("missing %s", command)
|
||||
}
|
||||
if strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Errorf("%s lacks Safety", command)
|
||||
}
|
||||
}
|
||||
public := registered["+search-person"]
|
||||
if public.OutputRollout != output.RolloutUnifiedActive || public.Contract.Result == nil || len(public.Contract.Result.SensitivePaths) == 0 {
|
||||
t.Fatalf("search-person lacks unified typed sensitive Result: %#v", public)
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(public.Contract.Result.DataSchema, &schema); err != nil || schema["type"] != "object" {
|
||||
t.Fatalf("search-person result schema invalid: schema=%v err=%v", schema, err)
|
||||
}
|
||||
matches := schema["properties"].(map[string]any)["matches"].(map[string]any)
|
||||
items := matches["items"].(map[string]any)
|
||||
sourceType := items["properties"].(map[string]any)["sourceType"].(map[string]any)
|
||||
if got, ok := sourceType["enum"].([]any); !ok || len(got) != 1 || got[0] != "person" {
|
||||
t.Fatalf("search-person sourceType enum = %#v", sourceType["enum"])
|
||||
}
|
||||
identityAlternatives, ok := items["anyOf"].([]any)
|
||||
if !ok || len(identityAlternatives) != 3 {
|
||||
t.Fatalf("search-person stable identity anyOf = %#v", items["anyOf"])
|
||||
}
|
||||
for index, identity := range []string{"userId", "openDingTalkId", "url"} {
|
||||
branch, branchOK := identityAlternatives[index].(map[string]any)
|
||||
required, requiredOK := branch["required"].([]any)
|
||||
if !branchOK || !requiredOK || len(required) != 1 || required[0] != identity {
|
||||
t.Fatalf("search-person stable identity anyOf[%d] = %#v, want required %q", index, identityAlternatives[index], identity)
|
||||
}
|
||||
}
|
||||
if registered["+search-person"].Hidden || registered["+search-person"].Availability != shortcut.AvailabilityAvailable {
|
||||
t.Fatalf("search-person visibility/availability = hidden:%v availability:%q", registered["+search-person"].Hidden, registered["+search-person"].Availability)
|
||||
}
|
||||
for _, command := range []string{"+search-enterprise", "+search-behavior"} {
|
||||
item := registered[command]
|
||||
if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable || item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil {
|
||||
t.Errorf("%s must remain hidden/unavailable without Result: hidden=%v availability=%q rollout=%q result=%v", command, item.Hidden, item.Availability, item.OutputRollout, item.Contract.Result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ package aitable
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
@@ -59,6 +60,19 @@ func parseJSONObject(flag, s string) (map[string]any, error) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// trimNonEmpty returns a copy of ss with each element trimmed of surrounding
|
||||
// whitespace and all empty entries dropped. Used to sanitize string-slice
|
||||
// flags (e.g. --field-ids) before they reach the downstream MCP tool.
|
||||
func trimNonEmpty(ss []string) []string {
|
||||
out := ss[:0]
|
||||
for _, s := range ss {
|
||||
if t := strings.TrimSpace(s); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// resolveNamedList finds a list in a response envelope without conflating a
|
||||
// missing/malformed collection with a legitimate empty array. Some AITable MCP
|
||||
// versions use a direct collection key while others wrap it one level below
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
// 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.
|
||||
|
||||
// This file declares the 7 datasource shortcuts for the aitable service:
|
||||
// create / update / sync / sync-status / get-config / list-sources /
|
||||
// get-fields. Each maps 1:1 onto a datasource MCP tool on the "aitable"
|
||||
// server. datasource-type is passed through without CLI-side enum
|
||||
// validation; source-config is validated as a JSON object but passed
|
||||
// through as a raw string (MCP types it as string).
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// datasource: 数据源同步管理(server: aitable)
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
// DatasourceCreate 为指定 AI 表格创建数据源同步配置(create_datasource)。
|
||||
var DatasourceCreate = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-create",
|
||||
Product: serverMain,
|
||||
Description: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。",
|
||||
Intent: "当用户需要将外部数据源(如审批数据)接入 AI 表格时使用。创建后返回的表 ID 可用于后续同步、更新配置或查询状态。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_create",
|
||||
CanonicalPath: "aitable.shortcut_datasource_create",
|
||||
CLIPath: "aitable +datasource-create",
|
||||
PrimaryCLIPath: "aitable +datasource-create",
|
||||
},
|
||||
Description: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。",
|
||||
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: "为指定 AI 表格创建数据源同步配置,创建一张数据源表并触发首次全量同步。返回新建数据源表 ID 和同步任务 ID。",
|
||||
UseWhen: []string{"当用户需要将外部数据源(如审批数据)接入 AI 表格时使用。创建后返回的表 ID 可用于后续同步、更新配置或查询状态。"},
|
||||
AvoidWhen: []string{
|
||||
"目标 Base 已有数据源表且仅需更新配置时(改用 +datasource-update)",
|
||||
"仅需触发已有数据源表的同步时(改用 +datasource-sync)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' --auto`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID(通过 +base-list / +base-search 获取)", Required: true},
|
||||
{Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true},
|
||||
{Name: "source-config", Type: shortcut.FlagString, Desc: "源配置 JSON 字符串。字段分为两类:须从 +datasource-list-sources 结果原样透传的字段(必填):processCode(审批流程编码)、name(展示名称)、iconUrl(图标 URL)、url(跳转链接);调用方自行设置的字段:dataType(必填,time_range/start_time/recent_time)、recentDays(dataType=recent_time 时有效,7d/30d/1y,默认 30d)、startDate(dataType=time_range/start_time 时有效,yyyy-MM-dd,默认 30 天前)、endDate(dataType=time_range 时有效,yyyy-MM-dd,默认当天)、keepRemovedFields(是否保留已删除字段,默认 false)。约定:syncAll 固定为 true;splitParentTableField 与 enableDataSyncOaDetailList 为下游内部字段,无需传入", Required: true},
|
||||
{Name: "auto", Type: shortcut.FlagBool, Desc: "是否开启自动同步,默认 false;创建新数据源表时该字段始终下发给下游"},
|
||||
{Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "需要同步的字段 ID 列表,不传时保持现有配置(创建时默认为全部字段)"},
|
||||
{Name: "auto-sync-setting", Type: shortcut.FlagString, Desc: "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly=按小时间隔,scheduled=定时触发)、hourlyInterval(syncType=hourly 时必填,正整数小时)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(syncType=scheduled 时必填,HH:mm)、selectedMonthDays(scheduleType=monthly 时必填,每月几号触发,1-31)、selectedWeekdays(scheduleType=weekly 时必填,每周哪几天触发,1=周一…7=周日)、skipNonWorkingDay(可选,默认 false)。不传时使用下游默认自动同步策略"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
`dws aitable +datasource-create --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' --auto`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"datasourceType": rt.Str("datasource-type"),
|
||||
"sourceConfig": rt.Str("source-config"),
|
||||
}
|
||||
params["auto"] = rt.Bool("auto")
|
||||
if rt.Changed("field-ids") {
|
||||
raw := rt.StrSlice("field-ids")
|
||||
cleaned := trimNonEmpty(raw)
|
||||
if len(cleaned) == 0 {
|
||||
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
params["fieldIds"] = cleaned
|
||||
}
|
||||
if rt.Changed("auto-sync-setting") {
|
||||
v := rt.Str("auto-sync-setting")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
if _, err := parseJSONObject("auto-sync-setting", v); err != nil {
|
||||
return err
|
||||
}
|
||||
params["autoSyncSetting"] = v
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "create_datasource", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceUpdate 更新已有数据源表的同步配置(update_datasource_config)。
|
||||
var DatasourceUpdate = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-update",
|
||||
Product: serverMain,
|
||||
Description: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。",
|
||||
Intent: "当用户需要修改已有数据源表的配置(如更换审批模板、调整同步字段、开关自动同步)时使用。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_update",
|
||||
CanonicalPath: "aitable.shortcut_datasource_update",
|
||||
CLIPath: "aitable +datasource-update",
|
||||
PrimaryCLIPath: "aitable +datasource-update",
|
||||
},
|
||||
Description: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。",
|
||||
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: "更新指定 AI 表格中已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后触发一次同步。仅适用于数据源表。",
|
||||
UseWhen: []string{"当用户需要修改已有数据源表的配置(如更换审批模板、调整同步字段、开关自动同步)时使用。"},
|
||||
AvoidWhen: []string{
|
||||
"需要创建新数据源表时(改用 +datasource-create)",
|
||||
"仅需触发同步不改配置时(改用 +datasource-sync)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --auto`,
|
||||
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "已存在的数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的数据源表)", Required: true},
|
||||
{Name: "source-config", Type: shortcut.FlagString, Desc: "可选。新的源配置 JSON 字符串。不传时保持原有配置不变;传入时整体覆盖。字段分为两类:须从 +datasource-list-sources 结果原样透传的字段(必填):processCode(审批流程编码)、name(展示名称)、iconUrl(图标 URL)、url(跳转链接);调用方自行设置的字段:dataType(必填,time_range/start_time/recent_time)、recentDays(dataType=recent_time 时有效,7d/30d/1y,默认 30d)、startDate(dataType=time_range/start_time 时有效,yyyy-MM-dd,默认 30 天前)、endDate(dataType=time_range 时有效,yyyy-MM-dd,默认当天)、keepRemovedFields(默认 false)。约定:syncAll 固定为 true;splitParentTableField 与 enableDataSyncOaDetailList 为下游内部字段,无需传入"},
|
||||
{Name: "auto", Type: shortcut.FlagBool, Desc: "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变"},
|
||||
{Name: "field-ids", Type: shortcut.FlagStringSlice, Desc: "需要同步的字段 ID 列表,不传时保持现有配置(创建时默认为全部字段)"},
|
||||
{Name: "auto-sync-setting", Type: shortcut.FlagString, Desc: "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly=按小时间隔,scheduled=定时触发)、hourlyInterval(syncType=hourly 时必填,正整数小时)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(syncType=scheduled 时必填,HH:mm)、selectedMonthDays(scheduleType=monthly 时必填,每月几号触发,1-31)、selectedWeekdays(scheduleType=weekly 时必填,每周哪几天触发,1=周一…7=周日)、skipNonWorkingDay(可选,默认 false)"},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --auto`,
|
||||
`dws aitable +datasource-update --base-id BASE123 --table-id TBL456 --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
}
|
||||
if rt.Changed("source-config") {
|
||||
if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
params["sourceConfig"] = rt.Str("source-config")
|
||||
}
|
||||
if rt.Changed("auto") {
|
||||
params["auto"] = rt.Bool("auto")
|
||||
}
|
||||
if rt.Changed("field-ids") {
|
||||
raw := rt.StrSlice("field-ids")
|
||||
cleaned := trimNonEmpty(raw)
|
||||
if len(cleaned) == 0 {
|
||||
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
params["fieldIds"] = cleaned
|
||||
}
|
||||
if rt.Changed("auto-sync-setting") {
|
||||
v := rt.Str("auto-sync-setting")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
if _, err := parseJSONObject("auto-sync-setting", v); err != nil {
|
||||
return err
|
||||
}
|
||||
params["autoSyncSetting"] = v
|
||||
}
|
||||
if !rt.Changed("source-config") && !rt.Changed("auto") && !rt.Changed("field-ids") && !rt.Changed("auto-sync-setting") {
|
||||
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 +datasource-sync")
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "update_datasource_config", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceSync 对数据源表触发一次手动同步(run_datasource_sync)。
|
||||
var DatasourceSync = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-sync",
|
||||
Product: serverMain,
|
||||
Description: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。",
|
||||
Intent: "当用户需要手动触发已有数据源表的同步(而非创建或更新配置)时使用。同步任务 ID 可通过 +datasource-sync-status 查询结果。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_sync",
|
||||
CanonicalPath: "aitable.shortcut_datasource_sync",
|
||||
CLIPath: "aitable +datasource-sync",
|
||||
PrimaryCLIPath: "aitable +datasource-sync",
|
||||
},
|
||||
Description: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。",
|
||||
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: "对指定 AI 表格中的数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。该工具仅触发任务即返回,不会等待同步完成。返回结果包含文档链接,用户可打开文档查看同步进度与最终数据。同步运行中(errorCode=4014)属于幂等冲突,会被标记为 failed 并允许调用方稍后重试。非数据源表(sync=false)不能用此工具触发同步,会以参数错误返回。",
|
||||
UseWhen: []string{"当用户需要手动触发已有数据源表的同步(而非创建或更新配置)时使用。同步任务 ID 可通过 +datasource-sync-status 查询结果。"},
|
||||
AvoidWhen: []string{
|
||||
"需要创建新数据源表时(改用 +datasource-create)",
|
||||
"需要更新配置时(改用 +datasource-update)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1,TBL2`,
|
||||
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "table-ids", Type: shortcut.FlagStringSlice, Desc: "待触发同步的数据源表 ID 列表(通过 +base-get / +table-list 获取,仅允许 sync=true 的表,1-5 个)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1,TBL2`,
|
||||
`dws aitable +datasource-sync --base-id BASE123 --table-ids TBL1`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
rawTableIDs := rt.StrSlice("table-ids")
|
||||
tableIDs := trimNonEmpty(rawTableIDs)
|
||||
if len(tableIDs) < 1 || len(tableIDs) > 5 {
|
||||
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableIds": tableIDs,
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "run_datasource_sync", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceSyncStatus 查询数据源表同步任务状态(get_datasource_sync_status)。
|
||||
var DatasourceSyncStatus = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-sync-status",
|
||||
Product: serverMain,
|
||||
Description: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。",
|
||||
Intent: "当用户触发同步后需要按 taskId 查询同步是否完成、成功或失败时使用。支持批量查询(单次最多 5 个任务 ID)。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_sync_status",
|
||||
CanonicalPath: "aitable.shortcut_datasource_sync_status",
|
||||
CLIPath: "aitable +datasource-sync-status",
|
||||
PrimaryCLIPath: "aitable +datasource-sync-status",
|
||||
},
|
||||
Description: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。",
|
||||
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: "按任务 ID 查询指定数据源表的同步任务状态。与 +datasource-sync / +datasource-create / +datasource-update 配对使用:这些指令触发同步后返回 taskId,本指令通过 taskId 查询最终结果。支持批量查询(单次最多 5 个 taskId),整体仍返回 success;需遍历 tasks 数组按单条 status 判断真实结果。任务状态包括:RUNNING(同步进行中)、FINISHED(同步完成)、FAILED(同步失败)。失败时会返回 errorCode 和 errorMessage 供排查。",
|
||||
UseWhen: []string{"当用户触发同步后需要按 taskId 查询同步是否完成、成功或失败时使用。支持批量查询(单次最多 5 个任务 ID)。"},
|
||||
AvoidWhen: []string{
|
||||
"需要触发同步时(改用 +datasource-sync)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-sync-status --base-id BASE123 --table-id TBL456 --task-ids TASK1,TASK2`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的表)", Required: true},
|
||||
{Name: "task-ids", Type: shortcut.FlagStringSlice, Desc: "待查询的同步任务 ID 列表(由 +datasource-sync / +datasource-create / +datasource-update 返回)。单次最多 5 个,超出请拆分多次调用。", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-sync-status --base-id BASE123 --table-id TBL456 --task-ids TASK1,TASK2`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
}
|
||||
rawTaskIDs := rt.StrSlice("task-ids")
|
||||
taskIDs := trimNonEmpty(rawTaskIDs)
|
||||
if len(taskIDs) < 1 || len(taskIDs) > 5 {
|
||||
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(taskIDs))
|
||||
}
|
||||
params["taskIds"] = taskIDs
|
||||
data, err := rt.CallMCPData(serverMain, "get_datasource_sync_status", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceGetConfig 获取数据源表的同步配置信息(get_datasource_config)。
|
||||
var DatasourceGetConfig = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-get-config",
|
||||
Product: serverMain,
|
||||
Description: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。",
|
||||
Intent: "当用户需要查看已有数据源表的配置详情(如确认当前同步的审批模板、字段选择、自动同步状态)时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_get_config",
|
||||
CanonicalPath: "aitable.shortcut_datasource_get_config",
|
||||
CLIPath: "aitable +datasource-get-config",
|
||||
PrimaryCLIPath: "aitable +datasource-get-config",
|
||||
},
|
||||
Description: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。",
|
||||
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: "获取指定数据源表的同步配置信息,包括源配置、是否全量同步、是否自动同步、同步状态等。仅适用于数据源表(sync=true),普通表会返回错误。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。返回的 sourceConfig 包含数据源连接信息(如审批模板 ID、源表 ID 等)。",
|
||||
UseWhen: []string{"当用户需要查看已有数据源表的配置详情(如确认当前同步的审批模板、字段选择、自动同步状态)时使用。"},
|
||||
AvoidWhen: []string{
|
||||
"需要更新配置时(改用 +datasource-update)",
|
||||
"需要查询同步任务状态时(改用 +datasource-sync-status)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-get-config --base-id BASE123 --table-id TBL456`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "table-id", Type: shortcut.FlagString, Desc: "数据源表 ID(通过 +base-get / +table-list 获取,仅允许传入 sync=true 的表)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-get-config --base-id BASE123 --table-id TBL456`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "get_datasource_config", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceListSources 列出指定数据源类型可用的来源(list_datasource_sources)。
|
||||
var DatasourceListSources = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-list-sources",
|
||||
Product: serverMain,
|
||||
Description: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。",
|
||||
Intent: "当用户需要查看某类数据源(如审批)的可用来源信息、获取 result/processCode 以便创建或更新数据源配置时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_list_sources",
|
||||
CanonicalPath: "aitable.shortcut_datasource_list_sources",
|
||||
CLIPath: "aitable +datasource-list-sources",
|
||||
PrimaryCLIPath: "aitable +datasource-list-sources",
|
||||
},
|
||||
Description: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。",
|
||||
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: "列出指定 Base 下可用的数据源条目。仅支持 OA 审批数据源(datasourceType=OA)。返回的每条条目包含 result 字段(下游原始 JSON 字符串)和 sourceType 字段(OA 审批对应 2,仅供参考)。OA 审批场景下 result 为包含 approvals 数组的 JSON 字符串,每个 approval 包含 processCode、name、iconUrl、url、keepRemovedFields、splitParentTableField 等字段。须原样透传至 sourceConfig 的字段(仅以下 4 个):processCode、name、iconUrl、url;调用方自行设置的字段(即使 result 中有值也不透传):keepRemovedFields、splitParentTableField;enableDataSyncOaDetailList 为下游内部字段,无需传入 sourceConfig。调用方应自行解析 result,提取目标模板字段后构造 sourceConfig 传入 +datasource-create。",
|
||||
UseWhen: []string{"当用户需要查看某类数据源(如审批)的可用来源信息、获取 result/processCode 以便创建或更新数据源配置时使用。"},
|
||||
AvoidWhen: []string{
|
||||
"需要创建数据源表时(改用 +datasource-create)",
|
||||
"需要获取数据源字段结构时(改用 +datasource-get-fields)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-list-sources --base-id BASE123 --datasource-type OA`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-list-sources --base-id BASE123 --datasource-type OA`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"datasourceType": rt.Str("datasource-type"),
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "list_datasource_sources", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
// DatasourceGetFields 获取指定数据源来源的可同步字段列表(get_datasource_fields)。
|
||||
var DatasourceGetFields = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+datasource-get-fields",
|
||||
Product: serverMain,
|
||||
Description: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。",
|
||||
Intent: "当用户需要查看某数据源来源有哪些可同步字段、以便在创建或更新数据源时指定 field-ids 时使用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "shortcut_datasource_get_fields",
|
||||
CanonicalPath: "aitable.shortcut_datasource_get_fields",
|
||||
CLIPath: "aitable +datasource-get-fields",
|
||||
PrimaryCLIPath: "aitable +datasource-get-fields",
|
||||
},
|
||||
Description: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。",
|
||||
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: "获取指定数据源下可供同步的字段列表,用于在 +datasource-create / +datasource-update 中决定同步哪些字段。传入从 +datasource-list-sources 获取的 sourceConfig。仅支持 OA 审批数据源(datasourceType=OA),其他数据源类型暂不支持,待后续开放。",
|
||||
UseWhen: []string{"当用户需要查看某数据源来源有哪些可同步字段、以便在创建或更新数据源时指定 field-ids 时使用。"},
|
||||
AvoidWhen: []string{
|
||||
"需要列出可用来源时(改用 +datasource-list-sources)",
|
||||
"需要创建数据源表时(改用 +datasource-create)",
|
||||
},
|
||||
Examples: []string{
|
||||
`dws aitable +datasource-get-fields --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "base-id", Type: shortcut.FlagString, Desc: "目标 Base ID", Required: true},
|
||||
{Name: "datasource-type", Type: shortcut.FlagString, Desc: "数据源类型,目前支持审批(OA)", Required: true},
|
||||
{Name: "source-config", Type: shortcut.FlagString, Desc: "源配置 JSON 字符串。结构同 +datasource-create 的 --source-config,需含 processCode、name、iconUrl、url、dataType 及对应时间字段", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws aitable +datasource-get-fields --base-id BASE123 --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if _, err := parseJSONObject("source-config", rt.Str("source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"datasourceType": rt.Str("datasource-type"),
|
||||
"sourceConfig": rt.Str("source-config"),
|
||||
}
|
||||
data, err := rt.CallMCPData(serverMain, "get_datasource_fields", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(withReviewedAITableShortcutContracts(
|
||||
DatasourceCreate,
|
||||
DatasourceUpdate,
|
||||
DatasourceSync,
|
||||
DatasourceSyncStatus,
|
||||
DatasourceGetConfig,
|
||||
DatasourceListSources,
|
||||
DatasourceGetFields,
|
||||
)...)
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type datasourceCoverageCaller struct {
|
||||
err error
|
||||
resp string
|
||||
argLog []map[string]any
|
||||
}
|
||||
|
||||
func (c *datasourceCoverageCaller) CallTool(_ context.Context, _, _ string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.argLog = append(c.argLog, args)
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
text := c.resp
|
||||
if text == "" {
|
||||
text = `{"status":"success","data":{}}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
func (c *datasourceCoverageCaller) Format() string { return "json" }
|
||||
func (c *datasourceCoverageCaller) DryRun() bool { return false }
|
||||
func (c *datasourceCoverageCaller) Fields() string { return "" }
|
||||
func (c *datasourceCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runDatasourceShortcutCLI(t *testing.T, caller *datasourceCoverageCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := newPlatformCoverageRoot()
|
||||
root.SetArgs(append([]string{"aitable"}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
// ── DatasourceCreate error paths ─────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--auto-sync-setting", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreatePropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsEmptyFieldIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--field-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty field-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsWhitespaceOnlyFieldIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--field-ids", " , , ")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when whitespace-only field-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreateRejectsEmptyAutoSyncSetting(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--auto-sync-setting", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceCreatePassesFieldIDsThrough(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-create", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`,
|
||||
"--field-ids", "fld1,fld2")
|
||||
if err != nil {
|
||||
t.Fatalf("create with valid --field-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.argLog) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
|
||||
}
|
||||
got, _ := caller.argLog[0]["fieldIds"].([]string)
|
||||
if len(got) != 2 || got[0] != "fld1" || got[1] != "fld2" {
|
||||
t.Fatalf("fieldIds = %#v, want [fld1 fld2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceUpdate error paths ─────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--source-config", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--auto-sync-setting", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsNoChanges(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1")
|
||||
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
|
||||
t.Fatalf("error = %v, want no-changes error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdatePropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1", "--auto")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsEmptyFieldIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--field-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty field-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsWhitespaceOnlyFieldIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--field-ids", " , ")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when whitespace-only field-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdateRejectsEmptyAutoSyncSetting(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--auto-sync-setting", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceUpdatePassesFieldIDsThrough(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-update", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--field-ids", "fldA,fldB,fldC")
|
||||
if err != nil {
|
||||
t.Fatalf("update with valid --field-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.argLog) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
|
||||
}
|
||||
got, _ := caller.argLog[0]["fieldIds"].([]string)
|
||||
if len(got) != 3 || got[0] != "fldA" || got[1] != "fldB" || got[2] != "fldC" {
|
||||
t.Fatalf("fieldIds = %#v, want [fldA fldB fldC]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceSync error paths ────────────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncShortcutRejectsTooManyTableIDs(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-sync", "--base-id", "BASE1", "--table-ids", "T1,T2,T3,T4,T5,T6")
|
||||
if err == nil || !strings.Contains(err.Error(), "1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-sync", "--base-id", "BASE1", "--table-ids", "TBL1")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceSyncStatus error paths ─────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncStatusShortcutRejectsTooManyTaskIDs(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--task-ids", "T1,T2,T3,T4,T5,T6")
|
||||
if err == nil || !strings.Contains(err.Error(), "1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncStatusShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--task-ids", "TASK1")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceSync whitespace trimming ──────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncRejectsWhitespaceOnlyTableIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-sync", "--base-id", "BASE1", "--table-ids", " , ")
|
||||
if err == nil || (!strings.Contains(err.Error(), "table-ids") && !strings.Contains(err.Error(), "1-5")) {
|
||||
t.Fatalf("error = %v, want table-ids rejection", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when whitespace-only table-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncTrimsWhitespaceFromTableIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-sync", "--base-id", "BASE1", "--table-ids", " TBL1 , TBL2 ")
|
||||
if err != nil {
|
||||
t.Fatalf("sync with whitespace-padded --table-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.argLog) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
|
||||
}
|
||||
got, _ := caller.argLog[0]["tableIds"].([]string)
|
||||
if len(got) != 2 || got[0] != "TBL1" || got[1] != "TBL2" {
|
||||
t.Fatalf("tableIds = %#v, want [TBL1 TBL2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceSyncStatus whitespace trimming ───────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncStatusRejectsWhitespaceOnlyTaskIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--task-ids", " , ")
|
||||
if err == nil || (!strings.Contains(err.Error(), "task-ids") && !strings.Contains(err.Error(), "1-5")) {
|
||||
t.Fatalf("error = %v, want task-ids rejection", err)
|
||||
}
|
||||
if len(caller.argLog) != 0 {
|
||||
t.Fatalf("MCP should not be called when whitespace-only task-ids is rejected, got %d calls", len(caller.argLog))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceSyncStatusTrimsWhitespaceFromTaskIDs(t *testing.T) {
|
||||
caller := &datasourceCoverageCaller{}
|
||||
err := runDatasourceShortcutCLI(t, caller,
|
||||
"+datasource-sync-status", "--base-id", "BASE1", "--table-id", "TBL1",
|
||||
"--task-ids", " TASK1 , TASK2 ")
|
||||
if err != nil {
|
||||
t.Fatalf("sync-status with whitespace-padded --task-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.argLog) != 1 {
|
||||
t.Fatalf("expected exactly 1 MCP call, got %d", len(caller.argLog))
|
||||
}
|
||||
got, _ := caller.argLog[0]["taskIds"].([]string)
|
||||
if len(got) != 2 || got[0] != "TASK1" || got[1] != "TASK2" {
|
||||
t.Fatalf("taskIds = %#v, want [TASK1 TASK2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceGetConfig error path ───────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceGetConfigShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-get-config", "--base-id", "BASE1", "--table-id", "TBL1")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceListSources error path ─────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceListSourcesShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-list-sources", "--base-id", "BASE1", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── DatasourceGetFields error paths ──────────────────────────────────────────
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceGetFieldsShortcutRejectsInvalidSourceConfig(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{},
|
||||
"+datasource-get-fields", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", "not-json")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config parse error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDatasourceGetFieldsShortcutPropagatesCallMCPError(t *testing.T) {
|
||||
err := runDatasourceShortcutCLI(t, &datasourceCoverageCaller{err: errors.New("mcp-error")},
|
||||
"+datasource-get-fields", "--base-id", "BASE1", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "mcp-error") {
|
||||
t.Fatalf("error = %v, want mcp-error", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAiSearchSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_aisearch.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "aisearch" {
|
||||
t.Fatalf("service = %q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "aisearch" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != 3 || len(source.Shortcuts) != 3 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 3/3", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
public, unavailable := 0, 0
|
||||
for command, record := range source.Shortcuts {
|
||||
item, ok := registered[command]
|
||||
if !ok {
|
||||
t.Errorf("catalog contains stale %s", command)
|
||||
continue
|
||||
}
|
||||
availability := record.Availability
|
||||
if availability == "" {
|
||||
availability = source.Availability
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || item.SemanticDelta != record.SemanticDelta || item.Risk != record.Risk {
|
||||
t.Errorf("%s semantic facts drifted", command)
|
||||
}
|
||||
if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Errorf("%s lacks Contract/Safety", command)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("aisearch", command) || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil {
|
||||
t.Errorf("%s public availability drift", command)
|
||||
}
|
||||
} else {
|
||||
unavailable++
|
||||
if availability != shortcut.AvailabilityUnavailable || !item.Hidden || shortcut.InPublicCatalog("aisearch", command) || item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil {
|
||||
t.Errorf("%s unavailable visibility drift", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
if public != 1 || unavailable != 2 {
|
||||
t.Fatalf("public/unavailable = %d/%d, want 1/2", public, unavailable)
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,8 @@ func TestCrossPlatformCoverageAITableSemanticCatalogExactlyCoversRegisteredSurfa
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
if len(registered) != 93 || len(source.Shortcuts) != 93 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 93/93", len(registered), len(source.Shortcuts))
|
||||
if len(registered) != 100 || len(source.Shortcuts) != 100 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 100/100", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
|
||||
var missing, stale []string
|
||||
|
||||
@@ -25,18 +25,23 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
|
||||
// Service packages — each registers its shortcuts from init().
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aisearch"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/aitable"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/attendance"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendar"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/contact"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devapp"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/devdoc"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/ding"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/doc"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/drive"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/hrbrain"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/live"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/mail"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/minutes"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/oa"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/pat"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/report"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/sheet"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/smart"
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageContactSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_contact.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "contact" {
|
||||
t.Fatalf("service = %q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "contact" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != 16 || len(source.Shortcuts) != 16 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 16/16", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
wantCompatibilityVisible := map[string]bool{"+list-roles": true}
|
||||
public, unavailable := 0, 0
|
||||
for command, record := range source.Shortcuts {
|
||||
item, ok := registered[command]
|
||||
if !ok {
|
||||
t.Errorf("catalog contains stale %s", command)
|
||||
continue
|
||||
}
|
||||
availability := record.Availability
|
||||
if availability == "" {
|
||||
availability = source.Availability
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || item.SemanticDelta != record.SemanticDelta || item.Risk != record.Risk {
|
||||
t.Errorf("%s semantic facts drifted", command)
|
||||
}
|
||||
if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Errorf("%s lacks Contract/Safety", command)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("contact", command) || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil {
|
||||
t.Errorf("%s public availability drift", command)
|
||||
}
|
||||
} else {
|
||||
unavailable++
|
||||
if availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("contact", command) || item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil {
|
||||
t.Errorf("%s unavailable visibility drift", command)
|
||||
}
|
||||
if record.CompatibilityVisible {
|
||||
if item.Hidden || !item.CompatibilityVisible {
|
||||
t.Errorf("%s compatibility visibility drift", command)
|
||||
}
|
||||
} else if !item.Hidden || item.CompatibilityVisible {
|
||||
t.Errorf("%s hidden unavailable visibility drift", command)
|
||||
}
|
||||
}
|
||||
if record.CompatibilityVisible != wantCompatibilityVisible[command] {
|
||||
t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command])
|
||||
}
|
||||
if command == "+search-mobile" || command == "+by-mobile" {
|
||||
if !strings.Contains(record.SemanticDelta, "专用手机号精确查询接口") {
|
||||
t.Errorf("%s must describe the dedicated exact mobile interface", command)
|
||||
}
|
||||
if strings.Contains(record.SemanticDelta, "关键词能力") || strings.Contains(record.SemanticDelta, "关键词数组") {
|
||||
t.Errorf("%s must not describe the retired keyword-search route", command)
|
||||
}
|
||||
}
|
||||
if command == "+list-roster-fields" || command == "+get-roster" {
|
||||
if !strings.Contains(record.SemanticDelta, "历史 CLI 继续透传原 MCP 调用与真实错误") {
|
||||
t.Errorf("%s must preserve the historical direct CLI execution path", command)
|
||||
}
|
||||
if strings.Contains(record.SemanticDelta, "0 次远程调用") {
|
||||
t.Errorf("%s must not claim a zero-call unavailable runtime", command)
|
||||
}
|
||||
}
|
||||
if command == "+list-roles" {
|
||||
if !strings.Contains(record.SemanticDelta, "历史 CLI 继续执行严格 get_org_labels 适配") || !strings.Contains(record.SemanticDelta, "整批拒绝") {
|
||||
t.Errorf("%s must preserve strict historical CLI execution while remaining Agent unavailable", command)
|
||||
}
|
||||
if strings.Contains(record.SemanticDelta, "0 次远程调用") {
|
||||
t.Errorf("%s must not claim a zero-call unavailable runtime", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
if public != 13 || unavailable != 3 {
|
||||
t.Fatalf("public/unavailable = %d/%d, want 13/3", public, unavailable)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageDevdocSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_devdoc.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "devdoc" || len(source.Shortcuts) != 1 {
|
||||
t.Fatalf("service/count = %q/%d", source.Service, len(source.Shortcuts))
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "devdoc" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if len(registered) != 1 {
|
||||
t.Fatalf("registered Devdoc shortcuts = %d, want 1", len(registered))
|
||||
}
|
||||
for command, record := range source.Shortcuts {
|
||||
item, ok := registered[command]
|
||||
if !ok {
|
||||
t.Fatalf("catalog entry %s is not registered", command)
|
||||
}
|
||||
if !record.Reviewed || record.Public || record.Availability != shortcut.AvailabilityUnavailable {
|
||||
t.Fatalf("catalog entry is not reviewed private unavailable: %#v", record)
|
||||
}
|
||||
if !item.Hidden || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Fatalf("runtime semantic facts drifted: %#v", item)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || item.OutputRollout != output.RolloutUnifiedActive || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Fatal("unavailable Devdoc Shortcut lacks Contract/Result/Safety/unified output")
|
||||
}
|
||||
if item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" || !strings.Contains(string(item.Contract.Result.DataSchema), "zeroMatchProven") {
|
||||
t.Fatal("unavailable Devdoc Shortcut does not publish the semantic zero-match boundary")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_hrbrain.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "hrbrain" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if source.Service != "hrbrain" || len(source.Shortcuts) != 11 || len(registered) != 11 {
|
||||
t.Fatalf("service/catalog/registered = %q/%d/%d", source.Service, len(source.Shortcuts), len(registered))
|
||||
}
|
||||
var missing, stale []string
|
||||
blockerCounts := map[string]int{}
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
if record.Public || record.Availability != "" || !record.Reviewed || !item.SemanticReviewed || item.Availability != shortcut.AvailabilityUnavailable || !item.Hidden {
|
||||
t.Errorf("%s availability/review drift", command)
|
||||
}
|
||||
if strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s semantic delta drift", command)
|
||||
}
|
||||
if item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Errorf("%s lacks Contract/Result/Safety/unified output", command)
|
||||
}
|
||||
if item.Contract.Interface == nil || strings.Contains(item.Contract.Interface.Reason, "shortcut_defect") {
|
||||
t.Errorf("%s lacks a reviewed non-Shortcut blocker", command)
|
||||
} else if strings.Contains(item.Contract.Interface.Reason, "classified=adapter_business_service") {
|
||||
blockerCounts["adapter_business_service"]++
|
||||
} else if strings.Contains(item.Contract.Interface.Reason, "classified=tenant_fixture") {
|
||||
blockerCounts["tenant_fixture"]++
|
||||
} else {
|
||||
t.Errorf("%s has unknown blocker classification", command)
|
||||
}
|
||||
}
|
||||
for command := range source.Shortcuts {
|
||||
if _, ok := registered[command]; !ok {
|
||||
stale = append(stale, command)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
sort.Strings(stale)
|
||||
if len(missing) > 0 || len(stale) > 0 {
|
||||
t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale)
|
||||
}
|
||||
if blockerCounts["adapter_business_service"] != 9 || blockerCounts["tenant_fixture"] != 2 {
|
||||
t.Fatalf("blocker counts = %#v", blockerCounts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageLiveSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_live.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "live" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if source.Service != "live" || len(registered) != 1 || len(source.Shortcuts) != 1 {
|
||||
t.Fatalf("service/registered/catalog = %q/%d/%d, want live/1/1", source.Service, len(registered), len(source.Shortcuts))
|
||||
}
|
||||
record := source.Shortcuts["+list-my-lives"]
|
||||
item, ok := registered["+list-my-lives"]
|
||||
if !ok {
|
||||
t.Fatal("registered Live shortcut missing")
|
||||
}
|
||||
if !record.Reviewed || record.Public || record.Availability != shortcut.AvailabilityUnavailable {
|
||||
t.Fatalf("semantic record = %#v", record)
|
||||
}
|
||||
if !item.SemanticReviewed || item.SemanticDelta != record.SemanticDelta || item.Risk != record.Risk {
|
||||
t.Fatal("Live semantic facts drifted")
|
||||
}
|
||||
if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable || shortcut.InPublicCatalog("live", item.Command) {
|
||||
t.Fatalf("Live visibility drift: hidden=%v availability=%q", item.Hidden, item.Availability)
|
||||
}
|
||||
if item.Contract.Empty() || strings.TrimSpace(item.Safety.Effect) == "" || item.Contract.Result != nil || item.OutputRollout != output.RolloutLegacyOnly {
|
||||
t.Fatal("Live unavailable shortcut contract drift")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package builtin_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoveragePATSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_pat.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "pat" {
|
||||
registered[item.Command] = item
|
||||
}
|
||||
}
|
||||
if source.Service != "pat" || len(source.Shortcuts) != 2 || len(registered) != 2 {
|
||||
t.Fatalf("service/catalog/registered = %q/%d/%d", source.Service, len(source.Shortcuts), len(registered))
|
||||
}
|
||||
public := 0
|
||||
for command, record := range source.Shortcuts {
|
||||
item, ok := registered[command]
|
||||
if !ok || !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Fatalf("%s semantic review drift", command)
|
||||
}
|
||||
if record.Public {
|
||||
public++
|
||||
if item.Hidden || item.Availability != shortcut.AvailabilityAvailable || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Fatalf("%s public contract drift", command)
|
||||
}
|
||||
} else if !item.Hidden || item.Availability != shortcut.AvailabilityUnavailable {
|
||||
t.Fatalf("%s unavailable visibility drift", command)
|
||||
}
|
||||
if command == "+authorize" {
|
||||
if record.Disposition != shortcut.DispositionAliasInternal || record.Primary != "pat chmod" || item.PrimaryCommand != "pat chmod" || !strings.Contains(item.SemanticDelta, "classified=routed") {
|
||||
t.Fatalf("%s must route to the reviewed pat chmod execution chain", command)
|
||||
}
|
||||
if item.Contract.Interface == nil || !strings.Contains(item.Contract.Interface.Reason, "blocked_fixture") || !strings.Contains(item.Contract.Interface.Reason, "classified=routed") {
|
||||
t.Fatalf("%s must separate routed implementation from unproved live write", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
if public != 1 {
|
||||
t.Fatalf("public PAT shortcuts = %d, want 1", public)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
const (
|
||||
contactCompositeReason = "Reviewed Contact Shortcut adapter: the executable CLI owns strict success, collection, item, stable-identity, and unified-output validation."
|
||||
contactCompatibilityInterfaceReason = "Historical CLI and full-Schema compatibility: this executable shortcut remains callable with its published interface shape, but semantic catalog policy keeps it unavailable to Agents until strict live evidence is complete."
|
||||
contactUnavailableReason = "Exact live-leaf probes across multiple authorized profiles could not safely produce a guaranteed zero-result fixture, so empty-result truth cannot be proved without guessing."
|
||||
contactRosterGapReason = "Exact raw roster probes are blocked by the current authorization or platform capability before a typed business response is available."
|
||||
)
|
||||
|
||||
var contactReadSafety = contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
}
|
||||
|
||||
func markContactCompatibilityOnly(declaration *shortcut.Shortcut) {
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
declaration.Contract.Result = nil
|
||||
declaration.Contract.Pagination = nil
|
||||
declaration.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite,
|
||||
Availability: contract.InterfaceAvailable,
|
||||
Reason: contactCompatibilityInterfaceReason,
|
||||
}
|
||||
}
|
||||
|
||||
func contactCollectionResult(collection, description string) *contract.ResultSpec {
|
||||
itemSchema := `{"type":"object","description":"带稳定身份的已校验项目","additionalProperties":true}`
|
||||
sensitive := []string{}
|
||||
switch collection {
|
||||
case "followings":
|
||||
itemSchema = `{"type":"object","description":"带稳定开放用户身份的特别关注联系人","properties":{"openDingTalkId":{"type":"string","minLength":1,"description":"稳定开放用户 ID"}},"required":["openDingTalkId"],"additionalProperties":false}`
|
||||
sensitive = []string{"followings.openDingTalkId"}
|
||||
case "users":
|
||||
itemSchema = `{"type":"object","description":"至少带 userId 或 openDingTalkId 之一的通讯录用户","properties":{"name":{"type":"string","description":"用户姓名"},"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"flowerName":{"type":"string","description":"用户花名"},"openDingTalkId":{"type":"string","minLength":1,"description":"稳定开放用户 ID"},"title":{"type":"string","description":"职位名称"}},"additionalProperties":false}`
|
||||
sensitive = []string{"users.name", "users.userId", "users.flowerName", "users.openDingTalkId", "users.title"}
|
||||
case "members":
|
||||
itemSchema = `{"type":"object","description":"带稳定用户身份的成员","properties":{"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"name":{"type":"string","description":"成员姓名"}},"required":["userId"],"additionalProperties":false}`
|
||||
sensitive = []string{"members.userId", "members.name"}
|
||||
case "depts":
|
||||
itemSchema = `{"type":"object","description":"带稳定部门身份的直属子部门","properties":{"deptId":{"type":"integer","minimum":1,"description":"稳定部门 ID"},"deptName":{"type":"string","description":"部门名称"}},"required":["deptId"],"additionalProperties":false}`
|
||||
sensitive = []string{"depts.deptName"}
|
||||
}
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中通过严格校验的项目数量"},%q:{"type":"array","description":%q,"items":%s}},"required":["count",%q],"additionalProperties":false}`,
|
||||
description, collection, description, itemSchema, collection,
|
||||
)),
|
||||
SensitivePaths: sensitive,
|
||||
}
|
||||
}
|
||||
|
||||
func contactObjectResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, description)),
|
||||
}
|
||||
}
|
||||
|
||||
func finalizeContactShortcut(item *shortcut.Shortcut, result *contract.ResultSpec, available bool) {
|
||||
item.OutputRollout = output.RolloutUnifiedActive
|
||||
item.Safety = contactReadSafety
|
||||
if item.Contract.Identity.Name == "" {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(item.Command, "+"), "-", "_")
|
||||
examples := append([]string(nil), item.Tips...)
|
||||
parameters := make([]contract.ParamDecl, 0, len(item.Flags))
|
||||
for _, flag := range item.Flags {
|
||||
parameters = append(parameters, contract.ParamDecl{Name: flag.Name})
|
||||
}
|
||||
item.Contract = corecmd.ContractDecl{
|
||||
Description: item.Description,
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "contact", Name: name, CanonicalPath: "contact." + name,
|
||||
CLIPath: "contact " + item.Command, PrimaryCLIPath: "contact " + item.Command,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: item.Description, UseWhen: []string{item.Intent},
|
||||
AvoidWhen: []string{"该能力未公开;使用已审核的 Contact Shortcut 或对应原子命令"},
|
||||
Examples: examples,
|
||||
},
|
||||
Parameters: parameters,
|
||||
}
|
||||
}
|
||||
item.Contract.Result = result
|
||||
availability := contract.InterfaceAvailable
|
||||
reason := contactCompositeReason
|
||||
if !available {
|
||||
availability = contract.InterfaceUnavailable
|
||||
reason = contactUnavailableReason
|
||||
item.OutputRollout = output.RolloutLegacyOnly
|
||||
item.Contract.Result = nil
|
||||
}
|
||||
item.Contract.Interface = &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func validateContactNonBlank(rt *shortcut.RuntimeContext, operation string, flags ...string) error {
|
||||
for _, flag := range flags {
|
||||
if strings.TrimSpace(rt.Str(flag)) == "" {
|
||||
return responsecheck.Error(operation, "empty_parameter", "--"+flag+" 不能为空白")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateContactMobile(rt *shortcut.RuntimeContext, operation, flag string) error {
|
||||
if _, err := normalizeContactMobile(rt.Str(flag)); err != nil {
|
||||
return responsecheck.Error(operation, "invalid_mobile", "--"+flag+" 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateContactOptionalNonBlank(rt *shortcut.RuntimeContext, operation, flag string) error {
|
||||
if rt.Changed(flag) && strings.TrimSpace(rt.Str(flag)) == "" {
|
||||
return responsecheck.Error(operation, "empty_parameter", "--"+flag+" 显式传入时不能为空白")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateContactPositiveStringID(rt *shortcut.RuntimeContext, operation, flag string) error {
|
||||
value := strings.TrimSpace(rt.Str(flag))
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return responsecheck.Error(operation, "invalid_parameter", "--"+flag+" 必须为正整数")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateContactPositiveInt(rt *shortcut.RuntimeContext, operation, flag string) error {
|
||||
if rt.Int(flag) <= 0 {
|
||||
return responsecheck.Error(operation, "invalid_parameter", "--"+flag+" 必须大于 0")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateContactPositiveIDList(rt *shortcut.RuntimeContext, operation, flag string) error {
|
||||
values := rt.StrSlice(flag)
|
||||
if len(values) == 0 {
|
||||
return responsecheck.Error(operation, "empty_parameter", "--"+flag+" 至少需要一个正整数 ID")
|
||||
}
|
||||
seen := make(map[int64]bool, len(values))
|
||||
for _, value := range values {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return responsecheck.Error(operation, "invalid_parameter", "--"+flag+" 每项都必须为正整数")
|
||||
}
|
||||
if seen[parsed] {
|
||||
return responsecheck.Error(operation, "duplicate_parameter", "--"+flag+" 不能包含重复 ID")
|
||||
}
|
||||
seen[parsed] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizedContactIDList(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
out = append(out, strings.TrimSpace(value))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contactEnvelope(data map[string]any, operation string) (map[string]any, error) {
|
||||
envelope, err := responsecheck.RequireSuccess(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range []string{"errorCode", "errorMsg", "errorMessage", "error"} {
|
||||
if contactFailureValue(envelope[key]) {
|
||||
return nil, responsecheck.Error(operation, "conflicting_failure_evidence", "success=true 响应同时携带失败字段 "+key)
|
||||
}
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
func contactFailureValue(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
return trimmed != "" && trimmed != "0" && !strings.EqualFold(trimmed, "success")
|
||||
case float64:
|
||||
return typed != 0
|
||||
case int:
|
||||
return typed != 0
|
||||
case bool:
|
||||
return typed
|
||||
case map[string]any:
|
||||
return len(typed) != 0
|
||||
case []any:
|
||||
return len(typed) != 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func strictUserSearch(data map[string]any, operation string, allowMissingResult bool) ([]map[string]any, error) {
|
||||
envelope, err := contactEnvelope(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, present := envelope["result"]; !present && allowMissingResult {
|
||||
return nil, responsecheck.Error(operation, "missing_result", "手机号搜索未返回显式 result;不能把缺失详情当作成功的空结果")
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(envelope, operation, "result")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return projectUsers(items, operation)
|
||||
}
|
||||
|
||||
// strictMobileLookup decodes the dedicated exact-mobile interface. The live
|
||||
// service returns one result object for a match and omits result for a
|
||||
// successful no-match. That omission is reviewed only for this exact lookup;
|
||||
// null, empty objects, arrays, and malformed identities remain failures.
|
||||
func strictMobileLookup(data map[string]any, operation string) (map[string]any, bool, error) {
|
||||
envelope, err := contactEnvelope(data, operation)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
raw, present := envelope["result"]
|
||||
if !present {
|
||||
return nil, false, nil
|
||||
}
|
||||
result, ok := raw.(map[string]any)
|
||||
if !ok || len(result) == 0 {
|
||||
return nil, false, responsecheck.Error(operation, "malformed_result", "手机号精确查询 result 必须是非空对象")
|
||||
}
|
||||
userID := contactString(result, "userId")
|
||||
if userID == "" {
|
||||
return nil, false, responsecheck.Error(operation, "missing_stable_identity", "手机号精确查询结果缺少非空 userId")
|
||||
}
|
||||
row := map[string]any{"userId": userID}
|
||||
if name, present, valid := contactOptionalString(result, "orgUserName"); !valid {
|
||||
return nil, false, responsecheck.Error(operation, "malformed_optional_field", "手机号精确查询 orgUserName 必须是字符串")
|
||||
} else if present && name != "" {
|
||||
row["name"] = name
|
||||
}
|
||||
return row, true, nil
|
||||
}
|
||||
|
||||
func normalizeContactMobile(value string) (string, error) {
|
||||
return normalizeContactMobilePart(value, 6)
|
||||
}
|
||||
|
||||
func normalizeContactMobilePart(value string, minimumDigits int) (string, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("empty mobile")
|
||||
}
|
||||
var digits strings.Builder
|
||||
for index, r := range trimmed {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
digits.WriteRune(r)
|
||||
case r == '+' && index == 0:
|
||||
case r == ' ' || r == '-' || r == '(' || r == ')':
|
||||
default:
|
||||
return "", fmt.Errorf("invalid mobile character")
|
||||
}
|
||||
}
|
||||
normalized := digits.String()
|
||||
if strings.HasPrefix(normalized, "00") {
|
||||
normalized = strings.TrimPrefix(normalized, "00")
|
||||
}
|
||||
if len(normalized) < minimumDigits {
|
||||
return "", fmt.Errorf("mobile too short")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func strictFollowings(data map[string]any, operation, expectedOpenID string) ([]map[string]any, error) {
|
||||
envelope, err := contactEnvelope(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, ok := envelope["result"].(map[string]any)
|
||||
if !ok || result == nil {
|
||||
return nil, responsecheck.Error(operation, "malformed_result", fmt.Sprintf("响应 result 应为对象,实际为 %T", envelope["result"]))
|
||||
}
|
||||
raw, present := result["models"]
|
||||
if !present {
|
||||
return nil, responsecheck.Error(operation, "missing_collection", "响应缺少显式 result.models 集合")
|
||||
}
|
||||
items, ok := raw.([]any)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result.models 应为数组,实际为 %T", raw))
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for index, rawItem := range items {
|
||||
item, ok := rawItem.(map[string]any)
|
||||
if !ok || item == nil {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("响应 result.models[%d] 应为对象,实际为 %T", index, rawItem))
|
||||
}
|
||||
openID := contactString(item, "openDingTalkId")
|
||||
if openID == "" {
|
||||
openID = contactString(item, "openDingtalkId")
|
||||
}
|
||||
if openID == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("响应 result.models[%d] 缺少 openDingTalkId", index))
|
||||
}
|
||||
if seen[openID] {
|
||||
return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("响应包含重复 openDingTalkId(索引 %d)", index))
|
||||
}
|
||||
seen[openID] = true
|
||||
if expectedOpenID == "" || openID == expectedOpenID {
|
||||
out = append(out, map[string]any{"openDingTalkId": openID})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func strictRoles(data map[string]any, operation string) ([]map[string]any, error) {
|
||||
envelope, err := contactEnvelope(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawGroups, present := envelope["result"]
|
||||
if !present {
|
||||
return nil, responsecheck.Error(operation, "missing_collection", "响应缺少显式 result 角色分组集合")
|
||||
}
|
||||
groups, ok := rawGroups.([]any)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result 应为数组,实际为 %T", rawGroups))
|
||||
}
|
||||
out := make([]map[string]any, 0)
|
||||
seen := map[int64]bool{}
|
||||
for groupIndex, rawGroup := range groups {
|
||||
group, ok := rawGroup.(map[string]any)
|
||||
if !ok || group == nil {
|
||||
return nil, responsecheck.Error(operation, "malformed_group", fmt.Sprintf("响应 result[%d] 应为对象,实际为 %T", groupIndex, rawGroup))
|
||||
}
|
||||
groupName := contactString(group, "groupName")
|
||||
if groupName == "" {
|
||||
return nil, responsecheck.Error(operation, "malformed_group", fmt.Sprintf("响应 result[%d] 缺少 groupName", groupIndex))
|
||||
}
|
||||
rawLabels, present := group["labels"]
|
||||
if !present {
|
||||
return nil, responsecheck.Error(operation, "missing_collection", fmt.Sprintf("响应 result[%d] 缺少 labels 集合", groupIndex))
|
||||
}
|
||||
labels, ok := rawLabels.([]any)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result[%d].labels 应为数组,实际为 %T", groupIndex, rawLabels))
|
||||
}
|
||||
for labelIndex, rawLabel := range labels {
|
||||
label, ok := rawLabel.(map[string]any)
|
||||
if !ok || label == nil {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("响应 result[%d].labels[%d] 应为对象,实际为 %T", groupIndex, labelIndex, rawLabel))
|
||||
}
|
||||
rawID, idPresent := label["labelId"]
|
||||
rawName, namePresent := label["name"]
|
||||
name, nameIsString := rawName.(string)
|
||||
name = strings.TrimSpace(name)
|
||||
// The legacy get_org_labels response can contain one reviewed null
|
||||
// placeholder inside an otherwise valid group. Preserve that row exactly
|
||||
// for CLI compatibility instead of rejecting every valid role in the
|
||||
// response. This exception is intentionally confined to the hidden,
|
||||
// legacy-only +list-roles path; public role-based shortcuts remain
|
||||
// fail-closed on malformed identities.
|
||||
if idPresent && rawID == nil && namePresent && rawName == nil {
|
||||
out = append(out, map[string]any{"labelId": nil, "labelName": rawName})
|
||||
continue
|
||||
}
|
||||
id, idOK := contactInt64(rawID)
|
||||
if !idOK || id <= 0 || !namePresent || !nameIsString || name == "" {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("响应 result[%d].labels[%d] 缺少有效 labelId/name", groupIndex, labelIndex))
|
||||
}
|
||||
if seen[id] {
|
||||
return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("响应包含重复 labelId(分组 %d,索引 %d)", groupIndex, labelIndex))
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, map[string]any{"labelId": id, "labelName": name})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func projectUsers(items []map[string]any, operation string) ([]map[string]any, error) {
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for index, item := range items {
|
||||
userID := contactString(item, "userId")
|
||||
openID := contactString(item, "openDingTalkId")
|
||||
if userID == "" && openID == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("用户结果第 %d 项缺少 userId/openDingTalkId", index))
|
||||
}
|
||||
identity := userID
|
||||
if identity == "" {
|
||||
identity = openID
|
||||
}
|
||||
if seen[identity] {
|
||||
return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("用户结果第 %d 项重复稳定身份", index))
|
||||
}
|
||||
seen[identity] = true
|
||||
row := map[string]any{}
|
||||
for _, key := range []string{"name", "userId", "flowerName", "openDingTalkId", "title"} {
|
||||
value, present, valid := contactOptionalString(item, key)
|
||||
if !valid {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("用户结果第 %d 项的 %s 必须是字符串", index, key))
|
||||
}
|
||||
if present && value != "" {
|
||||
row[key] = value
|
||||
}
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func strictMembers(data map[string]any, operation, path string) ([]map[string]any, error) {
|
||||
if _, err := contactEnvelope(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(data, operation, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for index, item := range items {
|
||||
user, ok := item["userInfo"].(map[string]any)
|
||||
if !ok || len(user) == 0 {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("%s[%d].userInfo 应为非空对象", path, index))
|
||||
}
|
||||
id := contactString(user, "userId")
|
||||
if id == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("%s[%d] 缺少 userId", path, index))
|
||||
}
|
||||
if seen[id] {
|
||||
return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("%s[%d] 重复 userId", path, index))
|
||||
}
|
||||
seen[id] = true
|
||||
row := map[string]any{"userId": id}
|
||||
name, _, valid := contactOptionalString(user, "name")
|
||||
if !valid {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("%s[%d].userInfo.name 必须是字符串", path, index))
|
||||
}
|
||||
if name != "" {
|
||||
row["name"] = name
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func strictSubDepts(data map[string]any, operation string) ([]map[string]any, error) {
|
||||
if _, err := contactEnvelope(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(data, operation, "result")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[int64]bool, len(items))
|
||||
for index, item := range items {
|
||||
id, ok := contactInt64(item["deptId"])
|
||||
if !ok || id <= 0 {
|
||||
return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("部门结果第 %d 项缺少有效 deptId", index))
|
||||
}
|
||||
if seen[id] {
|
||||
return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("部门结果第 %d 项重复 deptId", index))
|
||||
}
|
||||
seen[id] = true
|
||||
row := map[string]any{"deptId": id}
|
||||
name, _, valid := contactOptionalString(item, "deptName")
|
||||
if !valid {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("部门结果第 %d 项的 deptName 必须是字符串", index))
|
||||
}
|
||||
if name != "" {
|
||||
row["deptName"] = name
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func contactString(object map[string]any, key string) string {
|
||||
value, _ := object[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func contactOptionalString(object map[string]any, key string) (string, bool, bool) {
|
||||
raw, present := object[key]
|
||||
if !present || raw == nil {
|
||||
return "", present, true
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", true, false
|
||||
}
|
||||
return strings.TrimSpace(value), true, true
|
||||
}
|
||||
|
||||
func contactInt64(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed != float64(int64(typed)) {
|
||||
return 0, false
|
||||
}
|
||||
return int64(typed), true
|
||||
case int64:
|
||||
return typed, true
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
// GetSelf 获取当前登录用户信息(我是谁 / 本人)。
|
||||
@@ -56,18 +57,27 @@ var ListFollowings = shortcut.Shortcut{
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws contact +list-followings"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{{Name: "open-id"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "open-id", Type: shortcut.FlagString, Desc: "可选;仅保留 openDingTalkId 精确匹配的特别关注,用于确定性存在性检查;显式传入时不能为空白"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"open-id"}, Description: "--open-id 显式传入时不能为空白"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return validateContactOptionalNonBlank(rt, "contact/list_my_followings", "open-id")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +list-followings`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Project the raw {arguments,result:{models:[…]}} envelope down to a
|
||||
// clean {count, followings:[{openDingTalkId}]} list.
|
||||
data, err := rt.CallMCPData("contact", "list_my_followings", nil)
|
||||
data, err := rt.CallMCPData("contact", "list_my_followings", map[string]any{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
followings, err := strictFollowings(data, "contact/list_my_followings", rt.Str("open-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
followings := listFollowingsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(followings), "followings": followings})
|
||||
},
|
||||
}
|
||||
@@ -138,9 +148,16 @@ var SearchUser = shortcut.Shortcut{
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws contact +search-user --query \"张三\""},
|
||||
},
|
||||
// Keep the published merge-base property name. Execute remains a reviewed
|
||||
// composite adapter from --query to the downstream keyword field.
|
||||
Parameters: []contract.ParamDecl{{Name: "query", Property: "query"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true},
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词;--query 不能为空白", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空白"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return validateContactNonBlank(rt, "contact/search_contact_by_key_word", "query")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +search-user --query "张三"`,
|
||||
@@ -152,7 +169,10 @@ var SearchUser = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := searchUserProject(data)
|
||||
users, err := strictUserSearch(data, "contact/search_contact_by_key_word", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(users), "users": users})
|
||||
},
|
||||
}
|
||||
@@ -222,22 +242,42 @@ var SearchMobile = shortcut.Shortcut{
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws contact +search-mobile --mobile 13800138000"},
|
||||
},
|
||||
// Keep the merge-base Schema property. Execute uses the dedicated exact
|
||||
// mobile interface and returns its reviewed stable-identity projection.
|
||||
Parameters: []contract.ParamDecl{{Name: "mobile", Property: "mobile"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "mobile", Type: shortcut.FlagString, Desc: "手机号", Required: true},
|
||||
{Name: "mobile", Type: shortcut.FlagString, Desc: "手机号;--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"mobile"}, Description: "--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return validateContactMobile(rt, "contact/search_user_by_mobile", "mobile")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +search-mobile --mobile 13800138000`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := rt.RequireAll("mobile"); err != nil {
|
||||
return err
|
||||
}
|
||||
mobile, err := normalizeContactMobile(rt.Str("mobile"))
|
||||
if err != nil {
|
||||
return responsecheck.Error("contact/search_user_by_mobile", "invalid_mobile", "--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号")
|
||||
}
|
||||
data, err := rt.CallMCPData("contact", "search_user_by_mobile", map[string]any{
|
||||
"mobile": rt.Str("mobile"),
|
||||
"mobile": mobile,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := searchUserProject(data)
|
||||
return rt.Output(map[string]any{"count": len(users), "users": users})
|
||||
user, found, err := strictMobileLookup(data, "contact/search_user_by_mobile")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return rt.Output(map[string]any{"count": 0, "users": []map[string]any{}})
|
||||
}
|
||||
return rt.Output(map[string]any{"count": 1, "users": []map[string]any{user}})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -283,7 +323,10 @@ var ListRoles = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roles := listRolesProject(data)
|
||||
roles, err := strictRoles(data, "contact/get_org_labels")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(roles), "roles": roles})
|
||||
},
|
||||
}
|
||||
@@ -390,7 +433,7 @@ var ListRoleMembers = shortcut.Shortcut{
|
||||
Command: "+list-role-members",
|
||||
Product: "contact",
|
||||
Description: "查询角色下的成员列表",
|
||||
Intent: "当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表,通常先用 +search-role 拿到角色 ID 再调用。",
|
||||
Intent: "当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表;按名称找角色时先用 contact label get 原子命令取得角色 ID。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -412,13 +455,18 @@ var ListRoleMembers = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询角色下的成员列表",
|
||||
UseWhen: []string{"当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表,通常先用 +search-role 拿到角色 ID 再调用。"},
|
||||
UseWhen: []string{"当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表;按名称找角色时先用 contact label get 原子命令取得角色 ID。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws contact +list-role-members --id 12345"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{{Name: "id", Property: "id"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "角色 ID", Required: true},
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "角色 ID;--id 必须为正整数", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "--id 必须为正整数"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return validateContactPositiveStringID(rt, "contact/get_label_members_by_labelId", "id")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +list-role-members --id 12345`,
|
||||
@@ -430,7 +478,10 @@ var ListRoleMembers = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members := memberListProject(data)
|
||||
members, err := strictMembers(data, "contact/get_label_members_by_labelId", "labelUserList")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(members), "members": members})
|
||||
},
|
||||
}
|
||||
@@ -540,9 +591,14 @@ var ListSubDepts = shortcut.Shortcut{
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws contact +list-sub-depts --dept 1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{{Name: "dept", Property: "dept"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "dept", Type: shortcut.FlagInt, Desc: "部门 ID(钉钉根部门为 1)", Required: true},
|
||||
{Name: "dept", Type: shortcut.FlagInt, Desc: "部门 ID(钉钉根部门为 1);--dept 必须大于 0", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"dept"}, Description: "--dept 必须大于 0"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return validateContactPositiveInt(rt, "contact/get_sub_depts_by_dept_id", "dept")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +list-sub-depts --dept 1`,
|
||||
@@ -554,7 +610,10 @@ var ListSubDepts = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
depts := listSubDeptsProject(data)
|
||||
depts, err := strictSubDepts(data, "contact/get_sub_depts_by_dept_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(depts), "depts": depts})
|
||||
},
|
||||
}
|
||||
@@ -655,21 +714,29 @@ var ListDeptMembers = shortcut.Shortcut{
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws contact +list-dept-members --depts 12345,67890"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{{Name: "depts", Property: "depts"}},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "depts", Type: shortcut.FlagStringSlice, Desc: "部门 ID 列表,逗号分隔", Required: true},
|
||||
{Name: "depts", Type: shortcut.FlagStringSlice, Desc: "部门 ID 列表,逗号分隔;--depts 每项都必须为正整数且不能重复", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"depts"}, Description: "--depts 每项都必须为正整数且不能重复"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return validateContactPositiveIDList(rt, "contact/get_dept_members_by_deptId", "depts")
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +list-dept-members --depts 12345,67890`,
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{
|
||||
"deptIds": rt.StrSlice("depts"),
|
||||
"deptIds": normalizedContactIDList(rt.StrSlice("depts")),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members := memberListProject(data)
|
||||
members, err := strictMembers(data, "contact/get_dept_members_by_deptId", "deptUserList")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(members), "members": members})
|
||||
},
|
||||
}
|
||||
@@ -719,6 +786,18 @@ var GetRoster = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactShortcut(&ListFollowings, contactCollectionResult("followings", ListFollowings.Description), true)
|
||||
finalizeContactShortcut(&SearchUser, contactCollectionResult("users", SearchUser.Description), true)
|
||||
finalizeContactShortcut(&SearchMobile, contactCollectionResult("users", SearchMobile.Description), true)
|
||||
finalizeContactShortcut(&ListRoles, contactCollectionResult("roles", ListRoles.Description), false)
|
||||
markContactCompatibilityOnly(&ListRoles)
|
||||
finalizeContactShortcut(&ListRoleMembers, contactCollectionResult("members", ListRoleMembers.Description), true)
|
||||
finalizeContactShortcut(&ListSubDepts, contactCollectionResult("depts", ListSubDepts.Description), true)
|
||||
finalizeContactShortcut(&ListDeptMembers, contactCollectionResult("members", ListDeptMembers.Description), true)
|
||||
finalizeContactShortcut(&ListRosterFields, contactCollectionResult("fields", ListRosterFields.Description), false)
|
||||
finalizeContactShortcut(&GetRoster, contactObjectResult(GetRoster.Description), false)
|
||||
ListRosterFields.Contract.Interface.Reason = contactRosterGapReason
|
||||
GetRoster.Contract.Interface.Reason = contactRosterGapReason
|
||||
shortcut.Register(
|
||||
ListFollowings,
|
||||
SearchUser,
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package contact
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type contactCaller struct {
|
||||
payload string
|
||||
payloads map[string]string
|
||||
err error
|
||||
errors map[string]error
|
||||
calls int
|
||||
product string
|
||||
tool string
|
||||
args map[string]any
|
||||
history []contactCall
|
||||
}
|
||||
|
||||
type contactCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
func (caller *contactCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
caller.product, caller.tool, caller.args = product, tool, args
|
||||
caller.history = append(caller.history, contactCall{tool: tool, args: args})
|
||||
if caller.err != nil {
|
||||
return nil, caller.err
|
||||
}
|
||||
if err := caller.errors[tool]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := caller.payload
|
||||
if toolPayload, ok := caller.payloads[tool]; ok {
|
||||
payload = toolPayload
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: payload}}}, nil
|
||||
}
|
||||
func (*contactCaller) Format() string { return "json" }
|
||||
func (*contactCaller) DryRun() bool { return false }
|
||||
func (*contactCaller) Fields() string { return "" }
|
||||
func (*contactCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageContactStrictSearchAndCollections(t *testing.T) {
|
||||
users, err := strictUserSearch(map[string]any{
|
||||
"success": true, "result": []any{map[string]any{"userId": "stable-user", "name": "Fixture"}},
|
||||
}, "contact/test", false)
|
||||
if err != nil || len(users) != 1 {
|
||||
t.Fatalf("valid users rejected: users=%v err=%v", users, err)
|
||||
}
|
||||
users, err = strictUserSearch(map[string]any{"success": true, "result": []any{}}, "contact/test", false)
|
||||
if err != nil || len(users) != 0 {
|
||||
t.Fatalf("explicit zero rejected: users=%v err=%v", users, err)
|
||||
}
|
||||
|
||||
broken := []map[string]any{
|
||||
{},
|
||||
{"result": []any{}},
|
||||
{"success": "true", "result": []any{}},
|
||||
{"success": false, "result": []any{}},
|
||||
{"success": true},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": []any{"bad"}},
|
||||
{"success": true, "result": []any{map[string]any{}}},
|
||||
{"success": true, "result": []any{map[string]any{"name": "no-id"}}},
|
||||
{"success": true, "result": []any{map[string]any{"userId": "same"}, map[string]any{"userId": "same"}}},
|
||||
{"success": true, "result": []any{map[string]any{"userId": "good", "name": float64(1)}}},
|
||||
{"success": true, "errorCode": "FAILED", "result": []any{}},
|
||||
}
|
||||
for index, data := range broken {
|
||||
if got, projectErr := strictUserSearch(data, "contact/test", false); projectErr == nil {
|
||||
t.Errorf("broken response %d returned success: %v", index, got)
|
||||
}
|
||||
}
|
||||
|
||||
if got, projectErr := strictMembers(map[string]any{
|
||||
"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "stable-user"}}},
|
||||
}, "contact/members", "deptUserList"); projectErr != nil || len(got) != 1 {
|
||||
t.Fatalf("valid members rejected: got=%v err=%v", got, projectErr)
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true},
|
||||
{"success": true, "deptUserList": map[string]any{}},
|
||||
{"success": true, "deptUserList": []any{map[string]any{"unexpected": true}}},
|
||||
{"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"name": "no-id"}}}},
|
||||
{"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "same"}}, map[string]any{"userInfo": map[string]any{"userId": "same"}}}},
|
||||
{"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "good", "name": true}}}},
|
||||
} {
|
||||
if got, projectErr := strictMembers(data, "contact/members", "deptUserList"); projectErr == nil {
|
||||
t.Errorf("broken members returned success: %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactSubDepartmentsRejectBadIdentityAndShape(t *testing.T) {
|
||||
depts, err := strictSubDepts(map[string]any{
|
||||
"success": true,
|
||||
"result": []any{map[string]any{"deptId": float64(1), "deptName": "Fixture"}},
|
||||
}, "contact/sub-depts")
|
||||
if err != nil || len(depts) != 1 || depts[0]["deptId"] != int64(1) {
|
||||
t.Fatalf("valid sub departments rejected: depts=%v err=%v", depts, err)
|
||||
}
|
||||
for _, broken := range []map[string]any{
|
||||
{"success": true},
|
||||
{"success": true, "result": []any{map[string]any{"deptId": float64(0)}}},
|
||||
{"success": true, "result": []any{map[string]any{"deptId": float64(1)}, map[string]any{"deptId": float64(1)}}},
|
||||
{"success": true, "result": []any{map[string]any{"deptId": float64(1), "deptName": map[string]any{}}}},
|
||||
} {
|
||||
if got, projectErr := strictSubDepts(broken, "contact/sub-depts"); projectErr == nil {
|
||||
t.Errorf("broken sub departments returned success: %v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactSearchMobileUsesReviewedObjectShape(t *testing.T) {
|
||||
caller := &contactCaller{payloads: map[string]string{
|
||||
"search_user_by_mobile": `{"success":true,"result":{"userId":"stable-user","orgUserName":"Fixture"}}`,
|
||||
}, errors: map[string]error{"get_user_info_by_user_ids": errors.New("detail permission unavailable")}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
cmd := &cobra.Command{Use: "+search-mobile"}
|
||||
cmd.Flags().String("mobile", "", "")
|
||||
if err := cmd.Flags().Set("mobile", "+86 138-0013-8000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declaration := SearchMobile
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
if err := declaration.Validate(shortcut.RuntimeContextForTest(cmd, declaration)); err != nil {
|
||||
t.Fatalf("formatted mobile validation: %v", err)
|
||||
}
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err != nil {
|
||||
t.Fatalf("search-mobile: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.product != "contact" || caller.history[0].tool != "search_user_by_mobile" || caller.history[0].args["mobile"] != "8613800138000" {
|
||||
t.Fatalf("mapping = calls:%d product:%q history:%#v", caller.calls, caller.product, caller.history)
|
||||
}
|
||||
caller.errors = nil
|
||||
|
||||
caller.calls, caller.history = 0, nil
|
||||
caller.errors = map[string]error{"search_user_by_mobile": errors.New("lookup unavailable")}
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err == nil {
|
||||
t.Fatal("search-mobile swallowed the exact lookup transport error")
|
||||
}
|
||||
if caller.calls != 1 || caller.history[0].args["mobile"] != "8613800138000" {
|
||||
t.Fatalf("transport failure mapping = calls:%d history:%#v", caller.calls, caller.history)
|
||||
}
|
||||
caller.errors = nil
|
||||
|
||||
caller.calls, caller.history = 0, nil
|
||||
caller.payloads["search_user_by_mobile"] = `{"success":true}`
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err != nil {
|
||||
t.Fatalf("reviewed exact-mobile zero rejected: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.history[0].tool != "search_user_by_mobile" {
|
||||
t.Fatalf("zero match must stop after exact lookup: %#v", caller.history)
|
||||
}
|
||||
caller.payloads["search_user_by_mobile"] = `{"success":true,"result":null}`
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err == nil {
|
||||
t.Fatal("null result must not become a successful empty search")
|
||||
}
|
||||
|
||||
caller.payloads["search_user_by_mobile"] = `{"success":true,"result":{"openDingTalkId":"open-only"}}`
|
||||
caller.calls, caller.history = 0, nil
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(cmd, declaration)); err == nil {
|
||||
t.Fatal("mobile candidate without userId returned success")
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("missing userId calls=%d history=%#v", caller.calls, caller.history)
|
||||
}
|
||||
invalidCommand := &cobra.Command{Use: "+search-mobile"}
|
||||
invalidCommand.Flags().String("mobile", "", "")
|
||||
if err := invalidCommand.Flags().Set("mobile", "not-a-phone"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller.calls, caller.history = 0, nil
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(invalidCommand, declaration)); err == nil {
|
||||
t.Fatal("invalid mobile reached execution")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid mobile made calls: %#v", caller.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactMobileProofHelperMatrix(t *testing.T) {
|
||||
user, found, err := strictMobileLookup(map[string]any{
|
||||
"success": true, "result": map[string]any{"userId": "stable-user", "orgUserName": "Fixture"},
|
||||
}, "contact/mobile")
|
||||
if err != nil || !found || user["userId"] != "stable-user" || user["name"] != "Fixture" {
|
||||
t.Fatalf("exact mobile result rejected: user=%#v found=%v err=%v", user, found, err)
|
||||
}
|
||||
if user, found, err := strictMobileLookup(map[string]any{"success": true}, "contact/mobile"); err != nil || found || user != nil {
|
||||
t.Fatalf("reviewed no-match rejected: user=%#v found=%v err=%v", user, found, err)
|
||||
}
|
||||
for _, broken := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true, "result": nil},
|
||||
{"success": true, "result": []any{}},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"orgUserName": "no-id"}},
|
||||
{"success": true, "result": map[string]any{"userId": "stable-user", "orgUserName": 7}},
|
||||
} {
|
||||
if user, found, err := strictMobileLookup(broken, "contact/mobile"); err == nil {
|
||||
t.Errorf("broken mobile lookup returned success: user=%#v found=%v data=%#v", user, found, broken)
|
||||
}
|
||||
}
|
||||
for input, want := range map[string]string{
|
||||
"+86 138-0013-8000": "8613800138000",
|
||||
"008613800138000": "8613800138000",
|
||||
"(138) 0013-8000": "13800138000",
|
||||
} {
|
||||
if got, err := normalizeContactMobile(input); err != nil || got != want {
|
||||
t.Errorf("normalize %q = %q, %v; want %q", input, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"", "123", "1+3800138000", "13800x138000"} {
|
||||
if got, err := normalizeContactMobile(input); err == nil {
|
||||
t.Errorf("invalid mobile %q normalized to %q", input, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactRequiredSearchInputsFailBeforeRemoteCall(t *testing.T) {
|
||||
caller := &contactCaller{payload: `{"success":true,"result":[]}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
mobileCommand := &cobra.Command{Use: "+search-mobile"}
|
||||
mobileCommand.Flags().String("mobile", "", "")
|
||||
if err := SearchMobile.Execute(shortcut.RuntimeContextForTest(mobileCommand, SearchMobile)); err == nil {
|
||||
t.Fatal("missing mobile unexpectedly reached execution")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("missing mobile made %d remote calls", caller.calls)
|
||||
}
|
||||
|
||||
userCommand := &cobra.Command{Use: "+search-user"}
|
||||
userCommand.Flags().String("query", "fixture", "")
|
||||
declaration := SearchUser
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(userCommand, declaration)); err != nil {
|
||||
t.Fatalf("search-user regressed: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.args["keyword"] != "fixture" {
|
||||
t.Fatalf("search-user mapping = calls:%d args:%#v", caller.calls, caller.args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactInputConstraintsFailBeforeRemoteCall(t *testing.T) {
|
||||
caller := &contactCaller{payload: `{"success":true,"result":[]}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
tests := []struct {
|
||||
declaration shortcut.Shortcut
|
||||
flag string
|
||||
value string
|
||||
}{
|
||||
{SearchUser, "query", " "},
|
||||
{SearchMobile, "mobile", " "},
|
||||
{SearchMobile, "mobile", "not-a-phone"},
|
||||
{ListRoleMembers, "id", "0"},
|
||||
{ListRoleMembers, "id", "not-a-number"},
|
||||
{ListSubDepts, "dept", "0"},
|
||||
{ListDeptMembers, "depts", "1,1"},
|
||||
{ListDeptMembers, "depts", "1,invalid"},
|
||||
{ListFollowings, "open-id", " "},
|
||||
}
|
||||
for _, test := range tests {
|
||||
command := &cobra.Command{Use: test.declaration.Command}
|
||||
switch test.declaration.Command {
|
||||
case "+list-sub-depts":
|
||||
command.Flags().Int(test.flag, 0, "")
|
||||
case "+list-dept-members":
|
||||
command.Flags().StringSlice(test.flag, nil, "")
|
||||
default:
|
||||
command.Flags().String(test.flag, "", "")
|
||||
}
|
||||
if err := command.Flags().Set(test.flag, test.value); err != nil {
|
||||
t.Fatalf("set %s %s: %v", test.declaration.Command, test.flag, err)
|
||||
}
|
||||
if err := test.declaration.Validate(shortcut.RuntimeContextForTest(command, test.declaration)); err == nil {
|
||||
t.Errorf("%s accepted invalid %s=%q", test.declaration.Command, test.flag, test.value)
|
||||
}
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid inputs made %d remote calls", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListRolesCompatibilityUsesStrictLegacyMCP(t *testing.T) {
|
||||
caller := &contactCaller{payload: `{"success":true,"result":[{"groupName":"Fixture group","labels":[{"labelId":1,"name":"Fixture role"},{"labelId":null,"name":null}]}]}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
declaration := ListRoles
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
command := &cobra.Command{Use: declaration.Command}
|
||||
var stdout bytes.Buffer
|
||||
command.SetOut(&stdout)
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err != nil {
|
||||
t.Fatalf("list roles compatibility call: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.product != "contact" || caller.tool != "get_org_labels" || len(caller.args) != 0 {
|
||||
t.Fatalf("list roles mapping = calls:%d product:%q tool:%q args:%#v", caller.calls, caller.product, caller.tool, caller.args)
|
||||
}
|
||||
var payload struct {
|
||||
Count int `json:"count"`
|
||||
Roles []map[string]any `json:"roles"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode list roles output: %v", err)
|
||||
}
|
||||
if payload.Count != 2 || len(payload.Roles) != 2 || payload.Roles[0]["labelName"] != "Fixture role" {
|
||||
t.Fatalf("legacy role output drift: %#v", payload)
|
||||
}
|
||||
if value, present := payload.Roles[1]["labelId"]; !present || value != nil || payload.Roles[1]["labelName"] != nil {
|
||||
t.Fatalf("reviewed placeholder was not preserved: %#v", payload.Roles[1])
|
||||
}
|
||||
|
||||
caller.calls = 0
|
||||
caller.payload = `{"success":true,"result":[{"groupName":"Fixture group","labels":[{"labelId":1,"name":"Fixture role"},{"name":"Missing identity"}]}]}`
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, declaration)); err == nil {
|
||||
t.Fatal("list roles accepted a malformed role element")
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("malformed response calls = %d, want 1", caller.calls)
|
||||
}
|
||||
|
||||
caller.calls = 0
|
||||
caller.err = errors.New("permission denied")
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, declaration)); err == nil {
|
||||
t.Fatal("list roles swallowed downstream error")
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("downstream error calls = %d, want 1", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRosterCompatibilityDelegatesToLegacyMCP(t *testing.T) {
|
||||
caller := &contactCaller{payload: `{"success":true,"result":[]}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
|
||||
listDeclaration := ListRosterFields
|
||||
listDeclaration.OutputRollout = output.RolloutLegacyOnly
|
||||
if err := listDeclaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: listDeclaration.Command}, listDeclaration)); err != nil {
|
||||
t.Fatalf("list roster fields compatibility call: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.product != "hrmregister" || caller.tool != "list_authorized_roster_fields" || len(caller.args) != 0 {
|
||||
t.Fatalf("list roster fields mapping = calls:%d product:%q tool:%q args:%#v", caller.calls, caller.product, caller.tool, caller.args)
|
||||
}
|
||||
|
||||
caller.calls, caller.history = 0, nil
|
||||
getCommand := &cobra.Command{Use: GetRoster.Command}
|
||||
getCommand.Flags().String("staff-id", "", "")
|
||||
getCommand.Flags().StringSlice("fields", nil, "")
|
||||
if err := getCommand.Flags().Set("staff-id", "fixture-staff"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := getCommand.Flags().Set("fields", "field-a,field-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
getDeclaration := GetRoster
|
||||
getDeclaration.OutputRollout = output.RolloutLegacyOnly
|
||||
if err := getDeclaration.Execute(shortcut.RuntimeContextForTest(getCommand, getDeclaration)); err != nil {
|
||||
t.Fatalf("get roster compatibility call: %v", err)
|
||||
}
|
||||
fields, ok := caller.args["fieldCodeList"].([]string)
|
||||
if caller.calls != 1 || caller.product != "hrmregister" || caller.tool != "get_authorized_emp_rosterInfo" || caller.args["staffId"] != "fixture-staff" || !ok || len(fields) != 2 || fields[0] != "field-a" || fields[1] != "field-b" {
|
||||
t.Fatalf("get roster mapping = calls:%d product:%q tool:%q args:%#v", caller.calls, caller.product, caller.tool, caller.args)
|
||||
}
|
||||
|
||||
caller.err = errors.New("permission denied")
|
||||
caller.calls = 0
|
||||
if err := listDeclaration.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: listDeclaration.Command}, listDeclaration)); err == nil {
|
||||
t.Fatal("list roster fields swallowed downstream error")
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("downstream error calls = %d, want 1", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactFollowingsAndRolesRejectBadElements(t *testing.T) {
|
||||
followings, err := strictFollowings(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"models": []any{
|
||||
map[string]any{"openDingTalkId": "open-1"},
|
||||
map[string]any{"openDingTalkId": "open-2"},
|
||||
}},
|
||||
}, "contact/followings", "open-2")
|
||||
if err != nil || len(followings) != 1 || followings[0]["openDingTalkId"] != "open-2" {
|
||||
t.Fatalf("strict followings = %#v, err=%v", followings, err)
|
||||
}
|
||||
followings, err = strictFollowings(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"models": []any{map[string]any{"openDingTalkId": "open-1"}}},
|
||||
}, "contact/followings", "guaranteed-missing")
|
||||
if err != nil || len(followings) != 0 {
|
||||
t.Fatalf("filtered explicit zero = %#v, err=%v", followings, err)
|
||||
}
|
||||
caller := &contactCaller{payload: `{"success":true,"result":{"models":[{"openDingTalkId":"open-1"}]}}`}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
command := &cobra.Command{Use: "+list-followings"}
|
||||
command.Flags().String("open-id", "guaranteed-missing", "")
|
||||
declaration := ListFollowings
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
if executeErr := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); executeErr != nil {
|
||||
t.Fatalf("exact followings mapping: %v", executeErr)
|
||||
}
|
||||
if caller.calls != 1 || caller.product != "contact" || caller.tool != "list_my_followings" {
|
||||
t.Fatalf("followings mapping = calls:%d product:%q tool:%q", caller.calls, caller.product, caller.tool)
|
||||
}
|
||||
for _, broken := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"models": map[string]any{}}},
|
||||
{"success": true, "result": map[string]any{"models": []any{"bad"}}},
|
||||
{"success": true, "result": map[string]any{"models": []any{map[string]any{}}}},
|
||||
{"success": true, "result": map[string]any{"models": []any{map[string]any{"openDingTalkId": "same"}, map[string]any{"openDingTalkId": "same"}}}},
|
||||
} {
|
||||
if got, parseErr := strictFollowings(broken, "contact/followings", ""); parseErr == nil {
|
||||
t.Errorf("broken followings returned success: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
roles, err := strictRoles(map[string]any{
|
||||
"success": true,
|
||||
"result": []any{map[string]any{
|
||||
"groupName": "Fixture group",
|
||||
"labels": []any{
|
||||
map[string]any{"labelId": float64(1), "name": "Fixture role"},
|
||||
map[string]any{"labelId": nil, "name": nil},
|
||||
},
|
||||
}},
|
||||
}, "contact/roles")
|
||||
if err != nil || len(roles) != 2 || roles[0]["labelId"] != int64(1) || roles[0]["labelName"] != "Fixture role" || roles[1]["labelId"] != nil || roles[1]["labelName"] != nil {
|
||||
t.Fatalf("strict roles = %#v, err=%v", roles, err)
|
||||
}
|
||||
for _, broken := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": []any{"bad"}},
|
||||
{"success": true, "result": []any{map[string]any{"labels": []any{}}}},
|
||||
{"success": true, "result": []any{map[string]any{"groupName": "Fixture"}}},
|
||||
{"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": map[string]any{}}}},
|
||||
{"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": []any{"bad"}}}},
|
||||
{"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": []any{map[string]any{"labelId": nil}}}}},
|
||||
{"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": []any{map[string]any{"labelId": nil, "name": ""}}}}},
|
||||
{"success": true, "result": []any{map[string]any{"groupName": "Fixture", "labels": []any{map[string]any{"labelId": float64(1), "name": "One"}, map[string]any{"labelId": float64(1), "name": "Duplicate"}}}}},
|
||||
} {
|
||||
if got, parseErr := strictRoles(broken, "contact/roles"); parseErr == nil {
|
||||
t.Errorf("broken roles returned success: %#v", got)
|
||||
}
|
||||
}
|
||||
if users, err := projectUsers([]map[string]any{{"openDingTalkId": "open-only"}}, "contact/users"); err != nil || users[0]["openDingTalkId"] != "open-only" {
|
||||
t.Fatalf("open-ID-only user rejected: %#v err=%v", users, err)
|
||||
}
|
||||
if members, err := strictMembers(map[string]any{"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "u1", "name": "Fixture"}}}}, "contact/members", "deptUserList"); err != nil || members[0]["name"] != "Fixture" {
|
||||
t.Fatalf("named member rejected: %#v err=%v", members, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactDirectContracts(t *testing.T) {
|
||||
available := []*shortcut.Shortcut{
|
||||
&ListFollowings, &SearchUser, &SearchMobile, &ListRoleMembers, &ListSubDepts, &ListDeptMembers,
|
||||
}
|
||||
for _, item := range available {
|
||||
if item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil || len(item.Contract.Result.SensitivePaths) == 0 || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Errorf("%s lacks Contract/Result/Safety/unified output", item.Command)
|
||||
continue
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(item.Contract.Result.DataSchema, &schema); err != nil || schema["type"] != "object" {
|
||||
t.Errorf("%s invalid Result schema: schema=%v err=%v", item.Command, schema, err)
|
||||
}
|
||||
}
|
||||
for item, want := range map[*shortcut.Shortcut]map[string]string{
|
||||
&SearchMobile: {"mobile": "mobile"},
|
||||
&ListRoleMembers: {"id": "id"},
|
||||
&ListSubDepts: {"dept": "dept"},
|
||||
&ListDeptMembers: {"depts": "depts"},
|
||||
} {
|
||||
for _, parameter := range item.Contract.Parameters {
|
||||
if property, ok := want[parameter.Name]; ok {
|
||||
if parameter.Property != property {
|
||||
t.Errorf("%s parameter %s property=%q want=%q", item.Command, parameter.Name, parameter.Property, property)
|
||||
}
|
||||
delete(want, parameter.Name)
|
||||
}
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Errorf("%s missing compatibility parameters: %#v", item.Command, want)
|
||||
}
|
||||
}
|
||||
if ListRoles.OutputRollout != output.RolloutLegacyOnly || ListRoles.Contract.Result != nil || ListRoles.Contract.Interface == nil || ListRoles.Contract.Interface.Availability != "available" || ListRoles.Contract.Interface.Reason != contactCompatibilityInterfaceReason {
|
||||
t.Errorf("+list-roles compatibility delivery drift: rollout=%q result=%v interface=%#v", ListRoles.OutputRollout, ListRoles.Contract.Result, ListRoles.Contract.Interface)
|
||||
}
|
||||
for _, item := range []*shortcut.Shortcut{&ListRosterFields, &GetRoster} {
|
||||
if item.OutputRollout != output.RolloutLegacyOnly || item.Contract.Result != nil || item.Contract.Interface == nil || item.Contract.Interface.Availability != "unavailable" {
|
||||
t.Errorf("%s unavailable delivery drift: rollout=%q result=%v interface=%#v", item.Command, item.OutputRollout, item.Contract.Result, item.Contract.Interface)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactPrimitiveMatrices(t *testing.T) {
|
||||
for index, test := range []struct {
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{nil, false}, {"", false}, {"0", false}, {"SUCCESS", false}, {"bad", true},
|
||||
{float64(0), false}, {float64(1), true}, {0, false}, {1, true}, {false, false}, {true, true},
|
||||
{map[string]any{}, false}, {map[string]any{"x": 1}, true}, {[]any{}, false}, {[]any{1}, true}, {struct{}{}, true},
|
||||
} {
|
||||
if got := contactFailureValue(test.value); got != test.want {
|
||||
t.Errorf("failure matrix %d = %v, want %v", index, got, test.want)
|
||||
}
|
||||
}
|
||||
for index, test := range []struct {
|
||||
value any
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{float64(7), 7, true}, {float64(7.5), 0, false}, {int64(8), 8, true}, {9, 9, true},
|
||||
{json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {" 11 ", 11, true}, {"bad", 0, false}, {true, 0, false},
|
||||
} {
|
||||
got, ok := contactInt64(test.value)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Errorf("integer matrix %d = (%d,%v), want (%d,%v)", index, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
if got := normalizedContactIDList([]string{" 1 ", "2"}); len(got) != 2 || got[0] != "1" || got[1] != "2" {
|
||||
t.Fatalf("normalized IDs = %#v", got)
|
||||
}
|
||||
if _, err := strictUserSearch(map[string]any{"success": true}, "contact/test", true); err == nil {
|
||||
t.Fatal("missing optional-result evidence returned success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactValidatorsAcceptCanonicalInputs(t *testing.T) {
|
||||
command := &cobra.Command{Use: "contact"}
|
||||
command.Flags().String("required", " value ", "")
|
||||
command.Flags().String("optional", " value ", "")
|
||||
command.Flags().String("id", " 7 ", "")
|
||||
command.Flags().Int("dept", 7, "")
|
||||
command.Flags().StringSlice("depts", []string{" 7 ", "8"}, "")
|
||||
rt := shortcut.RuntimeContextForTest(command, SearchUser)
|
||||
for _, err := range []error{
|
||||
validateContactNonBlank(rt, "contact/test", "required"),
|
||||
validateContactOptionalNonBlank(rt, "contact/test", "optional"),
|
||||
validateContactPositiveStringID(rt, "contact/test", "id"),
|
||||
validateContactPositiveInt(rt, "contact/test", "dept"),
|
||||
validateContactPositiveIDList(rt, "contact/test", "depts"),
|
||||
} {
|
||||
if err != nil {
|
||||
t.Fatalf("canonical validation failed: %v", err)
|
||||
}
|
||||
}
|
||||
command.Flags().StringSlice("empty-list", nil, "")
|
||||
if err := validateContactPositiveIDList(rt, "contact/test", "empty-list"); err == nil {
|
||||
t.Fatal("empty ID list returned success")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactDirectExecutors(t *testing.T) {
|
||||
tests := []struct {
|
||||
declaration shortcut.Shortcut
|
||||
flag string
|
||||
value string
|
||||
payload string
|
||||
tool string
|
||||
}{
|
||||
{ListFollowings, "open-id", "open-1", `{"success":true,"result":{"models":[{"openDingTalkId":"open-1"}]}}`, "list_my_followings"},
|
||||
{SearchUser, "query", "fixture", `{"success":true,"result":[{"userId":"user-1"}]}`, "search_contact_by_key_word"},
|
||||
{ListRoleMembers, "id", "7", `{"success":true,"labelUserList":[{"userInfo":{"userId":"user-1"}}]}`, "get_label_members_by_labelId"},
|
||||
{ListSubDepts, "dept", "7", `{"success":true,"result":[{"deptId":8}]}`, "get_sub_depts_by_dept_id"},
|
||||
{ListDeptMembers, "depts", " 7 ,8", `{"success":true,"deptUserList":[{"userInfo":{"userId":"user-1"}}]}`, "get_dept_members_by_deptId"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.declaration.Command, func(t *testing.T) {
|
||||
caller := &contactCaller{payload: test.payload}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
declaration := test.declaration
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
command := &cobra.Command{Use: declaration.Command}
|
||||
switch declaration.Command {
|
||||
case "+list-sub-depts":
|
||||
command.Flags().Int(test.flag, 0, "")
|
||||
case "+list-dept-members":
|
||||
command.Flags().StringSlice(test.flag, nil, "")
|
||||
default:
|
||||
command.Flags().String(test.flag, "", "")
|
||||
}
|
||||
if err := command.Flags().Set(test.flag, test.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rt := shortcut.RuntimeContextForTest(command, declaration)
|
||||
if err := declaration.Execute(rt); err != nil {
|
||||
t.Fatalf("success execution: %v", err)
|
||||
}
|
||||
if caller.tool != test.tool {
|
||||
t.Fatalf("tool=%q want=%q", caller.tool, test.tool)
|
||||
}
|
||||
caller.err = errors.New("transport")
|
||||
if err := declaration.Execute(rt); err == nil {
|
||||
t.Fatal("transport error returned success")
|
||||
}
|
||||
caller.err = nil
|
||||
caller.payload = `{"success":true}`
|
||||
if err := declaration.Execute(rt); err == nil {
|
||||
t.Fatal("projection error returned success")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package devdoc registers strict Shortcut adapters for DingTalk Open Platform
|
||||
// documentation discovery.
|
||||
package devdoc
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
const devdocCompositeReason = "Unavailable after exact Shortcut plus raw isolation: the downstream interface performs semantic recall and returned nonempty neighbors for runtime-random zero-match candidates, so no guaranteed zero-match query can satisfy the public list/search proof contract."
|
||||
|
||||
type devdocPageEvidence struct {
|
||||
CurrentPage int
|
||||
PageSize int
|
||||
TotalCount int
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
func devdocSearchResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"严格校验但未满足公开 zero-match 证明的语义文档搜索页","properties":{"count":{"type":"integer","description":"当前页文档数量"},"documents":{"type":"array","description":"当前页开放平台文档","items":{"type":"object","description":"带稳定 URL 的开放平台文档","properties":{"title":{"type":"string","description":"文档标题"},"url":{"type":"string","description":"文档稳定 URL"},"description":{"type":"string","description":"文档摘要"}},"required":["title","url"],"additionalProperties":false}},"currentPage":{"type":"integer","description":"服务端确认的当前页码"},"pageSize":{"type":"integer","description":"服务端确认的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的语义召回总数"},"complete":{"type":"boolean","description":"是否到达语义召回末页;不表示 query zero-match 已证明"},"matchSemantics":{"type":"string","description":"下游搜索匹配语义","enum":["semantic"]},"zeroMatchProven":{"type":"boolean","description":"是否通过保证零匹配 query 证明公开投影;当前固定为 false"}},"required":["count","documents","currentPage","pageSize","totalCount","complete","matchSemantics","zeroMatchProven"],"additionalProperties":false}`),
|
||||
}
|
||||
}
|
||||
|
||||
func devdocPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor,
|
||||
CursorParameter: "page",
|
||||
MetaPath: contract.PaginationMetaPath,
|
||||
EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
var SearchDocs = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "devdoc",
|
||||
Command: "+search-docs",
|
||||
Product: "devdoc",
|
||||
Description: "审计钉钉开放平台语义文档搜索结果",
|
||||
Intent: "需要语义搜索钉钉开放平台文档且明确接受无法证明 query zero-match 时才使用;当前 Shortcut 保持 unavailable,请路由到 raw `devdoc article search`。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Hidden: true,
|
||||
Availability: shortcut.AvailabilityUnavailable,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "devdoc", Name: "shortcut_search_docs", CanonicalPath: "devdoc.shortcut_search_docs",
|
||||
CLIPath: "devdoc +search-docs", PrimaryCLIPath: "devdoc +search-docs",
|
||||
},
|
||||
Description: "审计钉钉开放平台语义文档搜索结果",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: devdocCompositeReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "审计钉钉开放平台语义文档搜索结果",
|
||||
UseWhen: []string{"需要语义搜索钉钉开放平台文档且明确接受无法证明 query zero-match 时才使用;当前 Shortcut 保持 unavailable,请路由到 raw `devdoc article search`。"},
|
||||
AvoidWhen: []string{"需要精确匹配或保证零命中时不要使用;搜索用户业务文档时使用 drive/wiki/doc"},
|
||||
Examples: []string{`dws devdoc +search-docs --query "OAuth2 接入" --size 10 --format json`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "query", Property: "keyword"},
|
||||
{Name: "page", Property: "page", InterfaceType: "number"},
|
||||
{Name: "size", Property: "size", InterfaceType: "number"},
|
||||
},
|
||||
Result: devdocSearchResult(),
|
||||
Pagination: devdocPagination(),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "query", Type: shortcut.FlagString, Required: true, Desc: "搜索关键词去除空白后必须非空"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "size", Type: shortcut.FlagInt, Default: "10", Desc: "每页数量必须在 1 到 50 之间"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "搜索关键词去除空白后必须非空"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "每页数量必须在 1 到 50 之间"},
|
||||
},
|
||||
Tips: []string{`dws devdoc +search-docs --query "OAuth2 接入" --size 10 --format json`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if strings.TrimSpace(rt.Str("query")) == "" {
|
||||
return apperrors.NewValidation("--query 不能为空")
|
||||
}
|
||||
if rt.Int("page") < 1 {
|
||||
return apperrors.NewValidation("--page 必须大于 0")
|
||||
}
|
||||
if size := rt.Int("size"); size < 1 || size > 50 {
|
||||
return apperrors.NewValidation("--size 必须在 1 到 50 之间")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: executeSearchDocs,
|
||||
}
|
||||
|
||||
func executeSearchDocs(rt *shortcut.RuntimeContext) error {
|
||||
page, size := rt.Int("page"), rt.Int("size")
|
||||
data, err := rt.CallMCPData("devdoc", "search_open_platform_docs", map[string]any{
|
||||
"keyword": strings.TrimSpace(rt.Str("query")),
|
||||
"page": page,
|
||||
"size": size,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
documents, evidence, err := devdocProjectSearch(data, page, size)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(documents),
|
||||
"documents": documents,
|
||||
"currentPage": evidence.CurrentPage,
|
||||
"pageSize": evidence.PageSize,
|
||||
"totalCount": evidence.TotalCount,
|
||||
"complete": !evidence.HasMore,
|
||||
"matchSemantics": "semantic",
|
||||
"zeroMatchProven": false,
|
||||
}
|
||||
next := ""
|
||||
if evidence.HasMore {
|
||||
next = strconv.Itoa(evidence.CurrentPage + 1)
|
||||
}
|
||||
// Strict page evidence guarantees the only two valid framework states:
|
||||
// terminal+empty-next or continuing+positive next page.
|
||||
pagination, _ := output.NewPagination(!evidence.HasMore, next)
|
||||
meta := &output.Meta{Count: output.NewCount(len(documents)), Pagination: pagination}
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta)))
|
||||
}
|
||||
|
||||
func devdocProjectSearch(data map[string]any, requestedPage, requestedSize int) ([]map[string]any, devdocPageEvidence, error) {
|
||||
const operation = "devdoc/search_open_platform_docs"
|
||||
items, err := responsecheck.RequireObjectCollection(data, operation, "result.items")
|
||||
if err != nil {
|
||||
return nil, devdocPageEvidence{}, err
|
||||
}
|
||||
// RequireObjectCollection already proved that result is an object while
|
||||
// resolving result.items, so a second fallible shape check is unreachable.
|
||||
result, _ := data["result"].(map[string]any)
|
||||
evidence, err := devdocParsePage(result, requestedPage, requestedSize, len(items))
|
||||
if err != nil {
|
||||
return nil, devdocPageEvidence{}, err
|
||||
}
|
||||
documents := make([]map[string]any, 0, len(items))
|
||||
seenURLs := make(map[string]struct{}, len(items))
|
||||
for index, item := range items {
|
||||
title, titleOK := devdocNonEmptyString(item["title"])
|
||||
url, urlOK := devdocNonEmptyString(item["url"])
|
||||
if !titleOK || !urlOK {
|
||||
return nil, devdocPageEvidence{}, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("文档结果第 %d 项缺少非空 title 或 url", index))
|
||||
}
|
||||
if _, duplicate := seenURLs[url]; duplicate {
|
||||
return nil, devdocPageEvidence{}, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("文档结果第 %d 项 URL 重复", index))
|
||||
}
|
||||
seenURLs[url] = struct{}{}
|
||||
document := map[string]any{"title": title, "url": url}
|
||||
if description, ok := devdocOptionalString(item["desc"]); ok {
|
||||
document["description"] = description
|
||||
}
|
||||
documents = append(documents, document)
|
||||
}
|
||||
return documents, evidence, nil
|
||||
}
|
||||
|
||||
func devdocParsePage(result map[string]any, requestedPage, requestedSize, itemCount int) (devdocPageEvidence, error) {
|
||||
const operation = "devdoc/search_open_platform_docs"
|
||||
currentPage, ok := devdocInteger(result["currentPage"])
|
||||
if !ok || currentPage < 1 || currentPage != requestedPage {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "pagination_page_mismatch", "响应 currentPage 缺失、错型或与请求页码不一致")
|
||||
}
|
||||
pageSize, ok := devdocInteger(result["pageSize"])
|
||||
if !ok || pageSize < 1 || pageSize != requestedSize {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "pagination_size_mismatch", "响应 pageSize 缺失、错型或与请求分页大小不一致")
|
||||
}
|
||||
totalCount, ok := devdocInteger(result["totalCount"])
|
||||
if !ok || totalCount < 0 || totalCount < itemCount {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "invalid_total_count", "响应 totalCount 缺失、错型、为负数或小于当前页条数")
|
||||
}
|
||||
if itemCount > pageSize {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "page_size_exceeded", "当前页结果数量超过服务端 pageSize")
|
||||
}
|
||||
hasMore, ok := result["hasMore"].(bool)
|
||||
if !ok {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "malformed_pagination", "响应 hasMore 缺失或不是布尔值")
|
||||
}
|
||||
pageEnd := currentPage * pageSize
|
||||
if hasMore && pageEnd >= totalCount {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore=true 但 currentPage/pageSize/totalCount 已到达末页")
|
||||
}
|
||||
if !hasMore && pageEnd < totalCount {
|
||||
return devdocPageEvidence{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore=false 但 totalCount 表明仍有下一页")
|
||||
}
|
||||
return devdocPageEvidence{CurrentPage: currentPage, PageSize: pageSize, TotalCount: totalCount, HasMore: hasMore}, nil
|
||||
}
|
||||
|
||||
func devdocInteger(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed != math.Trunc(typed) || typed > float64(math.MaxInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
case int:
|
||||
return typed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func devdocNonEmptyString(value any) (string, bool) {
|
||||
text, ok := value.(string)
|
||||
text = strings.TrimSpace(text)
|
||||
return text, ok && text != ""
|
||||
}
|
||||
|
||||
func devdocOptionalString(value any) (string, bool) {
|
||||
if value == nil {
|
||||
return "", false
|
||||
}
|
||||
return devdocNonEmptyString(value)
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(SearchDocs)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package devdoc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type devdocCoverageCaller struct {
|
||||
response string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (caller *devdocCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
if caller.err != nil {
|
||||
return nil, caller.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.response}}}, nil
|
||||
}
|
||||
func (*devdocCoverageCaller) Format() string { return "json" }
|
||||
func (*devdocCoverageCaller) DryRun() bool { return false }
|
||||
func (*devdocCoverageCaller) Fields() string { return "" }
|
||||
func (*devdocCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runDevdocCoverage(t *testing.T, caller *devdocCoverageCaller, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
service := &cobra.Command{Use: "devdoc"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(SearchDocs)))
|
||||
root.AddCommand(service)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(append([]string{"devdoc", SearchDocs.Command}, args...))
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil {
|
||||
_, _, err = output.EmitStoredResult(executed)
|
||||
}
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocUnavailableContractIsUnifiedTypedAndTruthful(t *testing.T) {
|
||||
if SearchDocs.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Fatalf("rollout = %q", SearchDocs.OutputRollout)
|
||||
}
|
||||
if !SearchDocs.Hidden || SearchDocs.Availability != "unavailable" || SearchDocs.Contract.Interface == nil || SearchDocs.Contract.Interface.Availability != "unavailable" {
|
||||
t.Fatal("Devdoc semantic search must remain hidden/unavailable until a guaranteed zero-match query is provable")
|
||||
}
|
||||
if SearchDocs.Contract.Empty() || SearchDocs.Contract.Result == nil || SearchDocs.Contract.Pagination == nil {
|
||||
t.Fatal("Devdoc Shortcut must retain Contract, Result, and Pagination for audit")
|
||||
}
|
||||
resultSchema := string(SearchDocs.Contract.Result.DataSchema)
|
||||
if !strings.Contains(resultSchema, "matchSemantics") || !strings.Contains(resultSchema, "zeroMatchProven") || strings.TrimSpace(SearchDocs.Safety.Effect) == "" {
|
||||
t.Fatal("Devdoc Result must declare semantic matching and the unproven zero-match boundary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocSearchRejectsBrokenResponses(t *testing.T) {
|
||||
explicitEmpty := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"items": []any{}, "currentPage": float64(3), "pageSize": float64(10),
|
||||
"totalCount": float64(2), "hasMore": false,
|
||||
},
|
||||
}
|
||||
documents, page, err := devdocProjectSearch(explicitEmpty, 3, 10)
|
||||
if err != nil || len(documents) != 0 || page.HasMore {
|
||||
t.Fatalf("explicit empty page: documents=%v page=%+v err=%v", documents, page, err)
|
||||
}
|
||||
// This proves only that an explicitly empty terminal page is parsed
|
||||
// truthfully. It is deliberately not evidence for a zero-match query.
|
||||
|
||||
broken := map[string]map[string]any{
|
||||
"empty response": {},
|
||||
"missing success": {"result": map[string]any{"items": []any{}}},
|
||||
"success false": {"success": false, "result": map[string]any{"items": []any{}}},
|
||||
"wrong success type": {"success": "true", "result": map[string]any{"items": []any{}}},
|
||||
"missing collection": {"success": true, "result": map[string]any{"hasMore": false}},
|
||||
"wrong collection type": {"success": true, "result": map[string]any{"items": map[string]any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(0), "hasMore": false}},
|
||||
"bad item": {"success": true, "result": map[string]any{"items": []any{"bad"}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(1), "hasMore": false}},
|
||||
"missing item identity": {"success": true, "result": map[string]any{"items": []any{map[string]any{"title": "doc"}}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(1), "hasMore": false}},
|
||||
"duplicate identity": {"success": true, "result": map[string]any{"items": []any{map[string]any{"title": "one", "url": "https://example.invalid/doc"}, map[string]any{"title": "two", "url": "https://example.invalid/doc"}}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(2), "hasMore": false}},
|
||||
"missing pagination": {"success": true, "result": map[string]any{"items": []any{}}},
|
||||
"mismatched page": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "pageSize": float64(10), "totalCount": float64(0), "hasMore": false}},
|
||||
"wrong hasMore type": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(0), "hasMore": "false"}},
|
||||
"false completion": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(11), "hasMore": false}},
|
||||
"false continuation": {"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(10), "totalCount": float64(10), "hasMore": true}},
|
||||
}
|
||||
for name, payload := range broken {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got, _, err := devdocProjectSearch(payload, 1, 10); err == nil {
|
||||
t.Fatalf("broken response accepted: %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocSearchProjectsKnownNonemptyPage(t *testing.T) {
|
||||
payload := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"title": "Authentication", "url": "https://example.invalid/auth", "desc": "Guide"},
|
||||
map[string]any{"title": "Errors", "url": "https://example.invalid/errors", "desc": nil},
|
||||
},
|
||||
"currentPage": float64(1), "pageSize": float64(2), "totalCount": float64(3), "hasMore": true,
|
||||
},
|
||||
}
|
||||
documents, page, err := devdocProjectSearch(payload, 1, 2)
|
||||
if err != nil || len(documents) != 2 || !page.HasMore {
|
||||
t.Fatalf("documents=%#v page=%+v err=%v", documents, page, err)
|
||||
}
|
||||
if documents[0]["description"] != "Guide" {
|
||||
t.Fatalf("first document=%#v", documents[0])
|
||||
}
|
||||
if _, exists := documents[1]["description"]; exists {
|
||||
t.Fatalf("nil description must be omitted: %#v", documents[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocExecuteValidationAndPagination(t *testing.T) {
|
||||
blankCommand := corecmd.New(shortcut.FromShortcut(SearchDocs))
|
||||
if err := blankCommand.Flags().Set("query", " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SearchDocs.Validate(shortcut.RuntimeContextForTest(blankCommand, SearchDocs)); err == nil {
|
||||
t.Fatal("blank query validation succeeded")
|
||||
}
|
||||
for name, args := range map[string][]string{
|
||||
"blank query": {"--query", " "},
|
||||
"zero page": {"--query", "query", "--page", "0"},
|
||||
"zero size": {"--query", "query", "--size", "0"},
|
||||
"large size": {"--query", "query", "--size", "51"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &devdocCoverageCaller{}
|
||||
if _, err := runDevdocCoverage(t, caller, args...); err == nil {
|
||||
t.Fatal("invalid input succeeded")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote calls before validation = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for name, response := range map[string]string{
|
||||
"terminal": `{"success":true,"result":{"items":[{"title":"Doc","url":"https://example.invalid/doc"}],"currentPage":1,"pageSize":10,"totalCount":1,"hasMore":false}}`,
|
||||
"continuing": `{"success":true,"result":{"items":[{"title":"Doc","url":"https://example.invalid/doc"}],"currentPage":1,"pageSize":1,"totalCount":2,"hasMore":true}}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &devdocCoverageCaller{response: response}
|
||||
args := []string{"--query", " query "}
|
||||
if name == "continuing" {
|
||||
args = append(args, "--size", "1")
|
||||
}
|
||||
if _, err := runDevdocCoverage(t, caller, args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
caller := &devdocCoverageCaller{err: errors.New("transport")}
|
||||
if _, err := runDevdocCoverage(t, caller, "--query", "query"); err == nil {
|
||||
t.Fatal("transport failure succeeded")
|
||||
}
|
||||
malformed := &devdocCoverageCaller{response: `{"success":true,"result":{"items":"bad"}}`}
|
||||
if _, err := runDevdocCoverage(t, malformed, "--query", "query"); err == nil {
|
||||
t.Fatal("malformed projection succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDevdocPageAndScalarEdges(t *testing.T) {
|
||||
base := func() map[string]any {
|
||||
return map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"items": []any{}, "currentPage": float64(1), "pageSize": float64(2),
|
||||
"totalCount": float64(0), "hasMore": false,
|
||||
},
|
||||
}
|
||||
}
|
||||
for name, mutate := range map[string]func(map[string]any){
|
||||
"page size mismatch": func(result map[string]any) { result["pageSize"] = float64(3) },
|
||||
"negative total": func(result map[string]any) { result["totalCount"] = float64(-1) },
|
||||
"total below items": func(result map[string]any) {
|
||||
result["items"] = []any{map[string]any{"title": "one", "url": "https://example.invalid/one"}}
|
||||
result["totalCount"] = float64(0)
|
||||
},
|
||||
"items exceed size": func(result map[string]any) {
|
||||
result["items"] = []any{
|
||||
map[string]any{"title": "one", "url": "https://example.invalid/one"},
|
||||
map[string]any{"title": "two", "url": "https://example.invalid/two"},
|
||||
map[string]any{"title": "three", "url": "https://example.invalid/three"},
|
||||
}
|
||||
result["totalCount"] = float64(3)
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
payload := base()
|
||||
mutate(payload["result"].(map[string]any))
|
||||
if _, _, err := devdocProjectSearch(payload, 1, 2); err == nil {
|
||||
t.Fatal("invalid page succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(math.MaxInt) * 2, "1"} {
|
||||
if _, ok := devdocInteger(value); ok {
|
||||
t.Fatalf("invalid integer accepted: %#v", value)
|
||||
}
|
||||
}
|
||||
if got, ok := devdocInteger(7); !ok || got != 7 {
|
||||
t.Fatalf("int projection = %d, %v", got, ok)
|
||||
}
|
||||
if _, ok := devdocOptionalString(7); ok {
|
||||
t.Fatal("non-string optional value accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package hrbrain registers fail-closed Shortcut adapters for DingTalk
|
||||
// Organization Brain reads. Every declaration remains unavailable until the
|
||||
// lower service supplies a verifiable non-null result and safe live fixtures.
|
||||
package hrbrain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
const (
|
||||
hrbrainBlockerAdapterBusiness = "adapter_business_service"
|
||||
hrbrainBlockerTenantFixture = "tenant_fixture"
|
||||
)
|
||||
|
||||
func hrbrainUnavailableReason(command string) string {
|
||||
switch command {
|
||||
case "+get-pool", "+list-pool-employees":
|
||||
return "classified=" + hrbrainBlockerTenantFixture + "; exact Shortcut and raw atomic calls reach the same remote operation and upstream error, but no safe nonempty pool fixture exists to prove identity and pagination."
|
||||
case "+list-pools", "+profile-labels":
|
||||
return "classified=" + hrbrainBlockerAdapterBusiness + "; raw atomic calls return success=true with result=null and the exact Shortcut rejects the response, so the business response contract cannot prove a collection or legitimate empty result."
|
||||
case "+profile-metadata", "+query-profile", "+profile-career", "+profile-performance", "+search-employees", "+search-employees-structured", "+search-fields":
|
||||
return "classified=" + hrbrainBlockerAdapterBusiness + "; exact Shortcut and raw atomic calls reach the same remote server, operation, and upstream error, so no Shortcut-layer defect can be repaired without downstream capability or business-service recovery."
|
||||
default:
|
||||
panic("missing reviewed HRbrain blocker classification for " + command)
|
||||
}
|
||||
}
|
||||
|
||||
type hrbrainPage struct {
|
||||
CurrentPage int
|
||||
PageSize int
|
||||
TotalCount int
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
func hrbrainReadSafety() contract.SafetySpec {
|
||||
return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"}
|
||||
}
|
||||
|
||||
func hrbrainObjectResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的 HRbrain 业务对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`,
|
||||
description,
|
||||
)),
|
||||
SensitivePaths: []string{"value.name", "value.mobile", "value.email", "value.workNo", "value.userId"},
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainCollectionResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效记录数量"},"items":{"type":"array","description":%q,"items":{"type":"object","description":"严格校验且带稳定身份的 HRbrain 业务记录","additionalProperties":true}}},"required":["count","items"],"additionalProperties":false}`,
|
||||
description, description,
|
||||
)),
|
||||
SensitivePaths: []string{"items.name", "items.mobile", "items.email", "items.workNo", "items.userId"},
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainPageResult(description string) *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(fmt.Sprintf(
|
||||
`{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页有效记录数量"},"items":{"type":"array","description":%q,"items":{"type":"object","description":"严格校验且带稳定身份的 HRbrain 业务记录","additionalProperties":true}},"currentPage":{"type":"integer","description":"服务端确认的当前页码"},"pageSize":{"type":"integer","description":"服务端确认的分页大小"},"totalCount":{"type":"integer","description":"服务端报告的总记录数"},"complete":{"type":"boolean","description":"服务端证据是否证明到达末页"}},"required":["count","items","currentPage","pageSize","totalCount","complete"],"additionalProperties":false}`,
|
||||
description, description,
|
||||
)),
|
||||
SensitivePaths: []string{"items.name", "items.mobile", "items.email", "items.workNo", "items.userId"},
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainPagination() *contract.PaginationSpec {
|
||||
return &contract.PaginationSpec{
|
||||
Kind: contract.PaginationKindCursor, CursorParameter: "page",
|
||||
MetaPath: contract.PaginationMetaPath, EndpointExhaustedPath: contract.PaginationExhaustedPath,
|
||||
NextTokenPath: contract.PaginationNextTokenPath,
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl {
|
||||
name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_")
|
||||
path := "hrbrain " + command
|
||||
return corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "hrbrain", Name: name, CanonicalPath: "hrbrain." + name,
|
||||
CLIPath: path, PrimaryCLIPath: path,
|
||||
},
|
||||
Description: description,
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: hrbrainUnavailableReason(command),
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: description,
|
||||
UseWhen: []string{intent},
|
||||
AvoidWhen: []string{"该 HRbrain Shortcut 当前 unavailable;不要把 null、业务错误或缺少安全 fixture 当作成功,基础通讯录查询改用 contact"},
|
||||
Examples: examples,
|
||||
},
|
||||
Parameters: params,
|
||||
Result: result,
|
||||
Pagination: pagination,
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainBase(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, flags []shortcut.Flag, params []contract.ParamDecl, examples ...string) shortcut.Shortcut {
|
||||
return shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "hrbrain",
|
||||
Command: command,
|
||||
Product: "hrbrain",
|
||||
Description: description,
|
||||
Intent: intent,
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: hrbrainReadSafety(),
|
||||
Contract: hrbrainContract(command, description, intent, result, pagination, params, examples...),
|
||||
Flags: flags,
|
||||
Tips: examples,
|
||||
Hidden: true,
|
||||
Availability: shortcut.AvailabilityUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
var ListPools = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+list-pools", "查询组织大脑人才池列表", "需要按名称、类型、创建人或标签发现人才池时使用;当前必须保持 unavailable,直到下游返回可验证分页数组。",
|
||||
hrbrainPageResult("严格校验的人才池搜索页"), hrbrainPagination(),
|
||||
[]shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "人才池名称关键词"},
|
||||
{Name: "pool-type", Type: shortcut.FlagString, Desc: "人才池类型"},
|
||||
{Name: "creator", Type: shortcut.FlagString, Desc: "创建人稳定身份"},
|
||||
{Name: "labels", Type: shortcut.FlagStringSlice, Desc: "标签列表"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "keyword", Property: "keyword"}, {Name: "pool-type", Property: "poolType"},
|
||||
{Name: "creator", Property: "creator"}, {Name: "labels", Property: "labels"},
|
||||
{Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"},
|
||||
},
|
||||
`dws hrbrain +list-pools --page 1 --page-size 20 --format json`,
|
||||
)
|
||||
declaration.Constraints = hrbrainPageConstraints()
|
||||
declaration.Validate = hrbrainValidatePage
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size")}
|
||||
hrbrainAddOptional(params, rt, "keyword", "keyword")
|
||||
hrbrainAddOptional(params, rt, "pool-type", "poolType")
|
||||
hrbrainAddOptional(params, rt, "creator", "creator")
|
||||
if rt.Changed("labels") {
|
||||
params["labels"] = hrbrainCleanValues(rt.StrSlice("labels"))
|
||||
}
|
||||
return hrbrainCallPage(rt, "list_talent_pools", params, "poolCode")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var GetPool = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+get-pool", "根据 poolCode 查询人才池详情", "已知稳定 poolCode 并需要读取同一人才池详情时使用;没有安全非空 fixture 前保持 unavailable。",
|
||||
hrbrainObjectResult("身份匹配的人才池详情"), nil,
|
||||
[]shortcut.Flag{{Name: "pool-code", Type: shortcut.FlagString, Required: true, Desc: "人才池稳定 poolCode"}},
|
||||
[]contract.ParamDecl{{Name: "pool-code", Property: "poolCode"}},
|
||||
`dws hrbrain +get-pool --pool-code <POOL_CODE> --format json`,
|
||||
)
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return hrbrainCallObject(rt, "get_talent_pool_detail", map[string]any{"poolCode": rt.Str("pool-code")}, rt.Str("pool-code"), "poolCode")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var ListPoolEmployees = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+list-pool-employees", "查询指定人才池内的员工列表", "已知稳定 poolCode 并需要分页读取池内员工时使用;缺少安全人才池与员工 fixture 前保持 unavailable。",
|
||||
hrbrainPageResult("严格校验的人才池员工页"), hrbrainPagination(),
|
||||
[]shortcut.Flag{
|
||||
{Name: "pool-code", Type: shortcut.FlagString, Required: true, Desc: "人才池稳定 poolCode"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "pool-code", Property: "poolCode"}, {Name: "page", Property: "currentPage", InterfaceType: "number"},
|
||||
{Name: "page-size", Property: "pageSize", InterfaceType: "number"},
|
||||
},
|
||||
`dws hrbrain +list-pool-employees --pool-code <POOL_CODE> --page 1 --page-size 20 --format json`,
|
||||
)
|
||||
declaration.Constraints = hrbrainPageConstraints()
|
||||
declaration.Validate = hrbrainValidatePage
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return hrbrainCallPage(rt, "list_pool_employees", map[string]any{
|
||||
"poolCode": rt.Str("pool-code"), "currentPage": rt.Int("page"), "pageSize": rt.Int("page-size"),
|
||||
}, "workNo", "staffId", "userId")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var ProfileMetadata = hrbrainCollectionShortcut(
|
||||
"+profile-metadata", "查询员工档案元数据结构", "已知工号并需要发现可查询档案模块与字段时使用;下游工具当前不可验证,保持 unavailable。",
|
||||
"get_profile_metadata", "work-no", "workNo", "档案元数据", []string{"modelCode", "fieldCode", "code"},
|
||||
)
|
||||
|
||||
var QueryProfile = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+query-profile", "按模块批量查询员工档案数据", "已知工号、模块与字段编码并需要批量读取档案时使用;下游可用性和结果合同未通过 live 验证。",
|
||||
hrbrainCollectionResult("严格校验的员工档案模块结果"), nil,
|
||||
[]shortcut.Flag{
|
||||
{Name: "work-no", Type: shortcut.FlagString, Required: true, Desc: "员工稳定工号"},
|
||||
{Name: "data-queries", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 数组的模块查询条件"},
|
||||
},
|
||||
[]contract.ParamDecl{{Name: "work-no", Property: "workNo"}, {Name: "data-queries", Property: "dataQueries"}},
|
||||
`dws hrbrain +query-profile --work-no <WORK_NO> --data-queries '[{"modelCode":"basic","fields":["name"]}]' --format json`,
|
||||
)
|
||||
declaration.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"data-queries"}, Description: "必须是非空 JSON 数组的模块查询条件"}}
|
||||
declaration.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
_, err := hrbrainJSONArray(rt.Str("data-queries"), "--data-queries")
|
||||
return err
|
||||
}
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
// Validate already established the exact non-empty object-array shape.
|
||||
queries, _ := hrbrainJSONArray(rt.Str("data-queries"), "--data-queries")
|
||||
return hrbrainCallCollection(rt, "query_profile_data", map[string]any{"workNo": rt.Str("work-no"), "dataQueries": queries}, "modelCode", "moduleCode")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var ProfileLabels = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+profile-labels", "批量查询员工档案标签", "已知一组稳定工号并需要读取标签时使用;下游当前 success=true 但 result=null,保持 unavailable。",
|
||||
hrbrainCollectionResult("严格校验的员工标签结果"), nil,
|
||||
[]shortcut.Flag{
|
||||
{Name: "staff-ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "一个或多个非空员工工号"},
|
||||
{Name: "all-label", Type: shortcut.FlagBool, Desc: "是否返回全部标签"},
|
||||
},
|
||||
[]contract.ParamDecl{{Name: "staff-ids", Property: "staffIds"}, {Name: "all-label", Property: "allLabel"}},
|
||||
`dws hrbrain +profile-labels --staff-ids <WORK_NO> --format json`,
|
||||
)
|
||||
declaration.Constraints = []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"staff-ids"}, Description: "必须包含一个或多个非空员工工号"}}
|
||||
declaration.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if len(hrbrainCleanValues(rt.StrSlice("staff-ids"))) == 0 {
|
||||
return apperrors.NewValidation("--staff-ids 必须包含一个或多个非空员工工号")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"staffIds": hrbrainCleanValues(rt.StrSlice("staff-ids"))}
|
||||
if rt.Changed("all-label") {
|
||||
params["allLabel"] = rt.Bool("all-label")
|
||||
}
|
||||
return hrbrainCallCollection(rt, "get_profile_label", params, "workNo", "staffId", "userId")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var ProfileCareer = hrbrainCollectionShortcut(
|
||||
"+profile-career", "查询员工公司内职业历程", "已知稳定工号并需要读取岗位或职级变动历史时使用;下游未通过 live 可用性验证。",
|
||||
"get_employee_career", "work-no", "workNo", "职业历程", []string{"careerId", "id", "workNo"},
|
||||
)
|
||||
|
||||
var ProfilePerformance = hrbrainCollectionShortcut(
|
||||
"+profile-performance", "查询员工历史绩效记录", "已知稳定工号并需要读取历史绩效时使用;下游未通过 live 可用性验证。",
|
||||
"get_employee_performance", "work-no", "workNo", "绩效记录", []string{"performanceId", "id", "workNo"},
|
||||
)
|
||||
|
||||
var SearchEmployees = func() shortcut.Shortcut {
|
||||
declaration := hrbrainEmployeeSearchBase(
|
||||
"+search-employees", "按关键词、部门、职务、职级或人才池搜索员工", "需要按至少一个基础过滤条件搜索员工时使用;当前下游返回业务错误且缺少安全非空 fixture。",
|
||||
"search_employees",
|
||||
)
|
||||
declaration.Constraints = append(hrbrainPageConstraints(), shortcut.Constraint{
|
||||
Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "dept-name", "position-name", "job-level", "pool-code"},
|
||||
Description: "至少提供一个员工搜索过滤条件,禁止无条件枚举组织人员",
|
||||
})
|
||||
declaration.Validate = hrbrainValidatePage
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size")}
|
||||
for _, pair := range [][2]string{{"keyword", "keyword"}, {"dept-name", "deptName"}, {"position-name", "positionName"}, {"job-level", "jobLevel"}, {"pool-code", "poolCode"}} {
|
||||
hrbrainAddOptional(params, rt, pair[0], pair[1])
|
||||
}
|
||||
return hrbrainCallPage(rt, "search_employees", params, "workNo", "staffId", "userId")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var SearchEmployeesStructured = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+search-employees-structured", "使用高级条件表达式搜索员工", "已先取得有权限字段,并需要组合过滤表达式搜索员工时使用;下游工具当前不可用。",
|
||||
hrbrainPageResult("严格校验的高级员工搜索页"), hrbrainPagination(),
|
||||
[]shortcut.Flag{
|
||||
{Name: "origin-json", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 对象的搜索表达式"},
|
||||
{Name: "fields", Type: shortcut.FlagString, Required: true, Desc: "必须是非空 JSON 数组的返回字段"},
|
||||
{Name: "order-by", Type: shortcut.FlagStringSlice, Desc: "排序字段列表"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "origin-json", Property: "originJson"}, {Name: "fields", Property: "fields"}, {Name: "order-by", Property: "orderByClauses"},
|
||||
{Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"},
|
||||
},
|
||||
`dws hrbrain +search-employees-structured --origin-json '{"rules":[],"combinator":"and"}' --fields '[{"label":"name","value":"name"}]' --format json`,
|
||||
)
|
||||
declaration.Constraints = append(hrbrainPageConstraints(),
|
||||
shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{"origin-json"}, Description: "必须是非空 JSON 对象的搜索表达式"},
|
||||
shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{"fields"}, Description: "必须是非空 JSON 数组的返回字段"},
|
||||
)
|
||||
declaration.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if err := hrbrainValidatePage(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := hrbrainJSONObject(rt.Str("origin-json"), "--origin-json"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := hrbrainJSONArray(rt.Str("fields"), "--fields")
|
||||
return err
|
||||
}
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
// Validate already established both structured input shapes.
|
||||
origin, _ := hrbrainJSONObject(rt.Str("origin-json"), "--origin-json")
|
||||
fields, _ := hrbrainJSONArray(rt.Str("fields"), "--fields")
|
||||
params := map[string]any{
|
||||
"originJson": string(mustJSON(origin)), "fields": fields,
|
||||
"currentPage": rt.Int("page"), "pageSize": rt.Int("page-size"),
|
||||
}
|
||||
if rt.Changed("order-by") {
|
||||
params["orderByClauses"] = hrbrainCleanValues(rt.StrSlice("order-by"))
|
||||
}
|
||||
return hrbrainCallPage(rt, "search_employees_structured", params, "workNo", "staffId", "userId")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
var SearchFields = func() shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
"+search-fields", "获取当前身份可用的高级人才搜索字段", "构造高级员工搜索表达式前发现字段与操作符时使用;下游工具当前返回业务错误,保持 unavailable。",
|
||||
hrbrainCollectionResult("严格校验的高级搜索字段"), nil, nil, nil,
|
||||
`dws hrbrain +search-fields --format json`,
|
||||
)
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return hrbrainCallCollection(rt, "get_search_fields", nil, "fieldCode", "code", "value")
|
||||
}
|
||||
return declaration
|
||||
}()
|
||||
|
||||
func hrbrainCollectionShortcut(command, description, intent, tool, flag, property, resultDescription string, identityKeys []string) shortcut.Shortcut {
|
||||
declaration := hrbrainBase(
|
||||
command, description, intent, hrbrainCollectionResult("严格校验的"+resultDescription), nil,
|
||||
[]shortcut.Flag{{Name: flag, Type: shortcut.FlagString, Required: true, Desc: "员工稳定工号"}},
|
||||
[]contract.ParamDecl{{Name: flag, Property: property}},
|
||||
fmt.Sprintf("dws hrbrain %s --%s <WORK_NO> --format json", command, flag),
|
||||
)
|
||||
declaration.Execute = func(rt *shortcut.RuntimeContext) error {
|
||||
return hrbrainCallCollection(rt, tool, map[string]any{property: rt.Str(flag)}, identityKeys...)
|
||||
}
|
||||
return declaration
|
||||
}
|
||||
|
||||
func hrbrainEmployeeSearchBase(command, description, intent, _ string) shortcut.Shortcut {
|
||||
return hrbrainBase(
|
||||
command, description, intent, hrbrainPageResult("严格校验的员工搜索页"), hrbrainPagination(),
|
||||
[]shortcut.Flag{
|
||||
{Name: "keyword", Type: shortcut.FlagString, Desc: "姓名或工号关键词"},
|
||||
{Name: "dept-name", Type: shortcut.FlagString, Desc: "部门名称"},
|
||||
{Name: "position-name", Type: shortcut.FlagString, Desc: "职务名称"},
|
||||
{Name: "job-level", Type: shortcut.FlagString, Desc: "职级"},
|
||||
{Name: "pool-code", Type: shortcut.FlagString, Desc: "人才池稳定 poolCode"},
|
||||
{Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码必须大于 0"},
|
||||
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量必须在 1 到 100 之间"},
|
||||
},
|
||||
[]contract.ParamDecl{
|
||||
{Name: "keyword", Property: "keyword"}, {Name: "dept-name", Property: "deptName"}, {Name: "position-name", Property: "positionName"},
|
||||
{Name: "job-level", Property: "jobLevel"}, {Name: "pool-code", Property: "poolCode"},
|
||||
{Name: "page", Property: "currentPage", InterfaceType: "number"}, {Name: "page-size", Property: "pageSize", InterfaceType: "number"},
|
||||
},
|
||||
`dws hrbrain +search-employees --keyword <QUERY> --page 1 --page-size 20 --format json`,
|
||||
)
|
||||
}
|
||||
|
||||
func hrbrainPageConstraints() []shortcut.Constraint {
|
||||
return []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "页码必须大于 0"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size"}, Description: "每页数量必须在 1 到 100 之间"},
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainValidatePage(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Int("page") < 1 {
|
||||
return apperrors.NewValidation("--page 必须大于 0")
|
||||
}
|
||||
if size := rt.Int("page-size"); size < 1 || size > 100 {
|
||||
return apperrors.NewValidation("--page-size 必须在 1 到 100 之间")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hrbrainCallObject(rt *shortcut.RuntimeContext, tool string, params map[string]any, expected string, identityKeys ...string) error {
|
||||
data, err := rt.CallMCPData("hrbrain", tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := responsecheck.RequireSingleObjectResult(data, "hrbrain/"+tool)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity := hrbrainIdentity(value, identityKeys...)
|
||||
if identity == "" {
|
||||
return responsecheck.Error("hrbrain/"+tool, "missing_stable_id", "HRbrain 详情对象缺少稳定身份")
|
||||
}
|
||||
if expected != "" && identity != strings.TrimSpace(expected) {
|
||||
return responsecheck.Error("hrbrain/"+tool, "identity_mismatch", "HRbrain 详情对象身份与请求目标不一致")
|
||||
}
|
||||
return rt.Output(map[string]any{"value": value})
|
||||
}
|
||||
|
||||
func hrbrainCallCollection(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys ...string) error {
|
||||
data, err := rt.CallMCPData("hrbrain", tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := hrbrainRequireCollectionResult(data, "hrbrain/"+tool, identityKeys...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(items), "items": items})
|
||||
}
|
||||
|
||||
func hrbrainCallPage(rt *shortcut.RuntimeContext, tool string, params map[string]any, identityKeys ...string) error {
|
||||
page, size := rt.Int("page"), rt.Int("page-size")
|
||||
data, err := rt.CallMCPData("hrbrain", tool, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items, evidence, err := hrbrainProjectPage(data, "hrbrain/"+tool, page, size, identityKeys...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"count": len(items), "items": items, "currentPage": evidence.CurrentPage,
|
||||
"pageSize": evidence.PageSize, "totalCount": evidence.TotalCount, "complete": !evidence.HasMore,
|
||||
}
|
||||
next := ""
|
||||
if evidence.HasMore {
|
||||
next = strconv.Itoa(evidence.CurrentPage + 1)
|
||||
}
|
||||
// The strict page evidence guarantees either terminal+empty-next or
|
||||
// continuing+positive next page, the two valid framework states.
|
||||
pagination, _ := output.NewPagination(!evidence.HasMore, next)
|
||||
return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(&output.Meta{
|
||||
Count: output.NewCount(len(items)), Pagination: pagination,
|
||||
})))
|
||||
}
|
||||
|
||||
func hrbrainRequireCollectionResult(data map[string]any, operation string, identityKeys ...string) ([]map[string]any, error) {
|
||||
result, err := responsecheck.RequireResult(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, ok := result.([]any)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result 应为数组,实际为 %T", result))
|
||||
}
|
||||
return hrbrainValidateItems(raw, operation, identityKeys...)
|
||||
}
|
||||
|
||||
func hrbrainProjectPage(data map[string]any, operation string, requestedPage, requestedSize int, identityKeys ...string) ([]map[string]any, hrbrainPage, error) {
|
||||
result, err := responsecheck.RequireObjectResult(data, operation)
|
||||
if err != nil {
|
||||
return nil, hrbrainPage{}, err
|
||||
}
|
||||
raw, ok := result["items"].([]any)
|
||||
if !ok {
|
||||
if _, present := result["items"]; !present {
|
||||
return nil, hrbrainPage{}, responsecheck.Error(operation, "missing_collection", "成功响应缺少 result.items 数组")
|
||||
}
|
||||
return nil, hrbrainPage{}, responsecheck.Error(operation, "malformed_collection", fmt.Sprintf("响应 result.items 应为数组,实际为 %T", result["items"]))
|
||||
}
|
||||
items, err := hrbrainValidateItems(raw, operation, identityKeys...)
|
||||
if err != nil {
|
||||
return nil, hrbrainPage{}, err
|
||||
}
|
||||
evidence, err := hrbrainParsePage(result, operation, requestedPage, requestedSize, len(items))
|
||||
if err != nil {
|
||||
return nil, hrbrainPage{}, err
|
||||
}
|
||||
return items, evidence, nil
|
||||
}
|
||||
|
||||
func hrbrainValidateItems(raw []any, operation string, identityKeys ...string) ([]map[string]any, error) {
|
||||
items := make([]map[string]any, 0, len(raw))
|
||||
seen := make(map[string]struct{}, len(raw))
|
||||
for index, item := range raw {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("结果第 %d 项不是非空对象", index))
|
||||
}
|
||||
identity := hrbrainIdentity(object, identityKeys...)
|
||||
if identity == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定身份", index))
|
||||
}
|
||||
if _, duplicate := seen[identity]; duplicate {
|
||||
return nil, responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("结果第 %d 项稳定身份重复", index))
|
||||
}
|
||||
seen[identity] = struct{}{}
|
||||
items = append(items, object)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func hrbrainParsePage(result map[string]any, operation string, requestedPage, requestedSize, itemCount int) (hrbrainPage, error) {
|
||||
currentPage, ok := hrbrainInteger(result["currentPage"])
|
||||
if !ok || currentPage != requestedPage || currentPage < 1 {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "pagination_page_mismatch", "currentPage 缺失、错型或与请求页码不一致")
|
||||
}
|
||||
pageSize, ok := hrbrainInteger(result["pageSize"])
|
||||
if !ok || pageSize != requestedSize || pageSize < 1 || itemCount > pageSize {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "pagination_size_mismatch", "pageSize 缺失、错型、与请求不一致或小于结果条数")
|
||||
}
|
||||
totalCount, ok := hrbrainInteger(result["totalCount"])
|
||||
if !ok || totalCount < 0 || totalCount < itemCount {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "invalid_total_count", "totalCount 缺失、错型、为负数或小于当前页条数")
|
||||
}
|
||||
hasMore, ok := result["hasMore"].(bool)
|
||||
if !ok {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "malformed_pagination", "hasMore 缺失或不是布尔值")
|
||||
}
|
||||
pageEnd := currentPage * pageSize
|
||||
if hasMore && pageEnd >= totalCount || !hasMore && pageEnd < totalCount {
|
||||
return hrbrainPage{}, responsecheck.Error(operation, "conflicting_pagination", "hasMore 与 currentPage/pageSize/totalCount 相互矛盾")
|
||||
}
|
||||
return hrbrainPage{CurrentPage: currentPage, PageSize: pageSize, TotalCount: totalCount, HasMore: hasMore}, nil
|
||||
}
|
||||
|
||||
func hrbrainIdentity(object map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
value := object[key]
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if text := strings.TrimSpace(typed); text != "" {
|
||||
return text
|
||||
}
|
||||
case float64:
|
||||
if typed == math.Trunc(typed) && !math.IsNaN(typed) && !math.IsInf(typed, 0) {
|
||||
return strconv.FormatInt(int64(typed), 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hrbrainInteger(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed, true
|
||||
case float64:
|
||||
if typed != math.Trunc(typed) || math.IsNaN(typed) || math.IsInf(typed, 0) || typed > float64(math.MaxInt) || typed < float64(math.MinInt) {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainAddOptional(params map[string]any, rt *shortcut.RuntimeContext, flag, property string) {
|
||||
if value := strings.TrimSpace(rt.Str(flag)); value != "" {
|
||||
params[property] = value
|
||||
}
|
||||
}
|
||||
|
||||
func hrbrainCleanValues(values []string) []string {
|
||||
cleaned := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
if value = strings.TrimSpace(value); value != "" {
|
||||
cleaned = append(cleaned, value)
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func hrbrainJSONArray(raw, flag string) ([]any, error) {
|
||||
var value []any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是有效 JSON 数组: %v", flag, err))
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return nil, apperrors.NewValidation(flag + " 必须是非空 JSON 数组")
|
||||
}
|
||||
for index, item := range value {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok || len(object) == 0 {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("%s 第 %d 项必须是非空对象", flag, index))
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func hrbrainJSONObject(raw, flag string) (map[string]any, error) {
|
||||
var value map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("%s 必须是有效 JSON 对象: %v", flag, err))
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return nil, apperrors.NewValidation(flag + " 必须是非空 JSON 对象")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func mustJSON(value any) []byte {
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(
|
||||
ListPools, GetPool, ListPoolEmployees,
|
||||
ProfileMetadata, QueryProfile, ProfileLabels, ProfileCareer, ProfilePerformance,
|
||||
SearchEmployees, SearchEmployeesStructured, SearchFields,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package hrbrain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type hrbrainCoverageCaller struct{ calls int }
|
||||
|
||||
func (caller *hrbrainCoverageCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":[]}`}}}, nil
|
||||
}
|
||||
func (*hrbrainCoverageCaller) Format() string { return "json" }
|
||||
func (*hrbrainCoverageCaller) DryRun() bool { return false }
|
||||
func (*hrbrainCoverageCaller) Fields() string { return "" }
|
||||
func (*hrbrainCoverageCaller) JQ() string { return "" }
|
||||
|
||||
type hrbrainParityCaller struct {
|
||||
calls int
|
||||
server string
|
||||
tool string
|
||||
params map[string]any
|
||||
response string
|
||||
err error
|
||||
}
|
||||
|
||||
func (caller *hrbrainParityCaller) CallTool(_ context.Context, server, tool string, params map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
caller.server = server
|
||||
caller.tool = tool
|
||||
caller.params = params
|
||||
if caller.err != nil {
|
||||
return nil, caller.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.response}}}, nil
|
||||
}
|
||||
func (*hrbrainParityCaller) Format() string { return "json" }
|
||||
func (*hrbrainParityCaller) DryRun() bool { return false }
|
||||
func (*hrbrainParityCaller) Fields() string { return "" }
|
||||
func (*hrbrainParityCaller) JQ() string { return "" }
|
||||
|
||||
func runHRbrainCoverage(t *testing.T, declaration shortcut.Shortcut, caller edition.ToolCaller, args ...string) error {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
service := &cobra.Command{Use: "hrbrain"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
root.SetArgs(append([]string{"hrbrain", declaration.Command}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainDeclarationsStayUnavailableAndTyped(t *testing.T) {
|
||||
declarations := []shortcut.Shortcut{
|
||||
ListPools, GetPool, ListPoolEmployees, ProfileMetadata, QueryProfile, ProfileLabels,
|
||||
ProfileCareer, ProfilePerformance, SearchEmployees, SearchEmployeesStructured, SearchFields,
|
||||
}
|
||||
if len(declarations) != 11 {
|
||||
t.Fatalf("declarations = %d", len(declarations))
|
||||
}
|
||||
for _, declaration := range declarations {
|
||||
if !declaration.Hidden || declaration.Availability != shortcut.AvailabilityUnavailable {
|
||||
t.Errorf("%s must remain hidden/unavailable", declaration.Command)
|
||||
}
|
||||
if declaration.OutputRollout != output.RolloutUnifiedActive || declaration.Contract.Empty() || declaration.Contract.Result == nil {
|
||||
t.Errorf("%s lacks unified Contract/Result", declaration.Command)
|
||||
}
|
||||
if strings.TrimSpace(declaration.Safety.Effect) == "" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" {
|
||||
t.Errorf("%s lacks unavailable interface and Safety", declaration.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainBlockersAreClassifiedWithoutShortcutDefects(t *testing.T) {
|
||||
commands := []string{
|
||||
ListPools.Command, GetPool.Command, ListPoolEmployees.Command, ProfileMetadata.Command,
|
||||
QueryProfile.Command, ProfileLabels.Command, ProfileCareer.Command, ProfilePerformance.Command,
|
||||
SearchEmployees.Command, SearchEmployeesStructured.Command, SearchFields.Command,
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for _, command := range commands {
|
||||
reason := hrbrainUnavailableReason(command)
|
||||
if strings.Contains(reason, "shortcut_defect") || !strings.Contains(reason, "classified=") {
|
||||
t.Fatalf("%s has unreviewed blocker reason %q", command, reason)
|
||||
}
|
||||
switch {
|
||||
case strings.Contains(reason, "classified="+hrbrainBlockerAdapterBusiness):
|
||||
counts[hrbrainBlockerAdapterBusiness]++
|
||||
case strings.Contains(reason, "classified="+hrbrainBlockerTenantFixture):
|
||||
counts[hrbrainBlockerTenantFixture]++
|
||||
default:
|
||||
t.Fatalf("%s has unknown blocker classification %q", command, reason)
|
||||
}
|
||||
}
|
||||
if counts[hrbrainBlockerAdapterBusiness] != 9 || counts[hrbrainBlockerTenantFixture] != 2 {
|
||||
t.Fatalf("blocker counts = %#v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainExactParameterProjectionForAllElevenShortcuts(t *testing.T) {
|
||||
pageResponse := `{"success":true,"result":{"items":[{"poolCode":"pool"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`
|
||||
employeePageResponse := `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`
|
||||
queryItems := []any{map[string]any{"modelCode": "model", "fields": []any{"field"}}}
|
||||
structuredFields := []any{map[string]any{"label": "field", "value": "field"}}
|
||||
cases := []struct {
|
||||
name string
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
tool string
|
||||
params map[string]any
|
||||
response string
|
||||
}{
|
||||
{name: "list pools", declaration: ListPools, tool: "list_talent_pools", params: map[string]any{"currentPage": 1, "pageSize": 20}, response: pageResponse},
|
||||
{name: "get pool", declaration: GetPool, args: []string{"--pool-code", "pool"}, tool: "get_talent_pool_detail", params: map[string]any{"poolCode": "pool"}, response: `{"success":true,"result":{"poolCode":"pool"}}`},
|
||||
{name: "list pool employees", declaration: ListPoolEmployees, args: []string{"--pool-code", "pool"}, tool: "list_pool_employees", params: map[string]any{"poolCode": "pool", "currentPage": 1, "pageSize": 20}, response: employeePageResponse},
|
||||
{name: "profile metadata", declaration: ProfileMetadata, args: []string{"--work-no", "worker"}, tool: "get_profile_metadata", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"modelCode":"model"}]}`},
|
||||
{name: "query profile", declaration: QueryProfile, args: []string{"--work-no", "worker", "--data-queries", `[{"modelCode":"model","fields":["field"]}]`}, tool: "query_profile_data", params: map[string]any{"workNo": "worker", "dataQueries": queryItems}, response: `{"success":true,"result":[{"modelCode":"model"}]}`},
|
||||
{name: "profile labels", declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, tool: "get_profile_label", params: map[string]any{"staffIds": []string{"worker"}}, response: `{"success":true,"result":[{"workNo":"worker"}]}`},
|
||||
{name: "profile career", declaration: ProfileCareer, args: []string{"--work-no", "worker"}, tool: "get_employee_career", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"careerId":"career"}]}`},
|
||||
{name: "profile performance", declaration: ProfilePerformance, args: []string{"--work-no", "worker"}, tool: "get_employee_performance", params: map[string]any{"workNo": "worker"}, response: `{"success":true,"result":[{"performanceId":"performance"}]}`},
|
||||
{name: "search employees", declaration: SearchEmployees, args: []string{"--keyword", "worker"}, tool: "search_employees", params: map[string]any{"keyword": "worker", "currentPage": 1, "pageSize": 20}, response: employeePageResponse},
|
||||
{name: "search employees structured", declaration: SearchEmployeesStructured, args: []string{"--origin-json", `{"rules":[{"field":"field"}]}`, "--fields", `[{"label":"field","value":"field"}]`}, tool: "search_employees_structured", params: map[string]any{"originJson": `{"rules":[{"field":"field"}]}`, "fields": structuredFields, "currentPage": 1, "pageSize": 20}, response: employeePageResponse},
|
||||
{name: "search fields", declaration: SearchFields, tool: "get_search_fields", params: map[string]any{}, response: `{"success":true,"result":[{"fieldCode":"field"}]}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &hrbrainParityCaller{response: tc.response}
|
||||
if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err != nil {
|
||||
t.Fatalf("exact Shortcut failed against valid synthetic response: %v", err)
|
||||
}
|
||||
if caller.calls != 1 || caller.server != "hrbrain" || caller.tool != tc.tool {
|
||||
t.Fatalf("remote route = calls:%d server:%q tool:%q, want 1/hrbrain/%s", caller.calls, caller.server, caller.tool, tc.tool)
|
||||
}
|
||||
if !reflect.DeepEqual(caller.params, tc.params) {
|
||||
t.Fatalf("params = %#v, want %#v", caller.params, tc.params)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainRejectsNullWrongCollectionsAndBadItems(t *testing.T) {
|
||||
brokenCollections := []map[string]any{
|
||||
{},
|
||||
{"success": false, "result": []any{}},
|
||||
{"success": true, "result": nil},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": []any{"bad"}},
|
||||
{"success": true, "result": []any{map[string]any{"name": "missing-id"}}},
|
||||
{"success": true, "result": []any{map[string]any{"poolCode": "same"}, map[string]any{"poolCode": "same"}}},
|
||||
}
|
||||
for index, payload := range brokenCollections {
|
||||
if got, err := hrbrainRequireCollectionResult(payload, "hrbrain/test", "poolCode"); err == nil {
|
||||
t.Errorf("broken collection %d accepted: %#v", index, got)
|
||||
}
|
||||
}
|
||||
explicitEmpty := map[string]any{"success": true, "result": []any{}}
|
||||
if got, err := hrbrainRequireCollectionResult(explicitEmpty, "hrbrain/test", "poolCode"); err != nil || len(got) != 0 {
|
||||
t.Fatalf("explicit empty collection: got=%#v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainPaginationFailsClosed(t *testing.T) {
|
||||
valid := map[string]any{"success": true, "result": map[string]any{
|
||||
"items": []any{map[string]any{"poolCode": "pool-1"}},
|
||||
"currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(2), "hasMore": true,
|
||||
}}
|
||||
items, page, err := hrbrainProjectPage(valid, "hrbrain/test", 1, 1, "poolCode")
|
||||
if err != nil || len(items) != 1 || !page.HasMore {
|
||||
t.Fatalf("valid page: items=%#v page=%+v err=%v", items, page, err)
|
||||
}
|
||||
broken := []map[string]any{
|
||||
{"success": true, "result": map[string]any{"items": []any{}}},
|
||||
{"success": true, "result": map[string]any{"items": "bad", "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(0), "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "pageSize": float64(20), "totalCount": float64(0), "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(21), "hasMore": false}},
|
||||
{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(20), "hasMore": true}},
|
||||
{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(1), "pageSize": float64(20), "totalCount": float64(0), "hasMore": "false"}},
|
||||
}
|
||||
for index, payload := range broken {
|
||||
if got, evidence, err := hrbrainProjectPage(payload, "hrbrain/test", 1, 20, "poolCode"); err == nil {
|
||||
t.Errorf("broken page %d accepted: items=%#v evidence=%+v", index, got, evidence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainStructuredInputsRejectBadShapes(t *testing.T) {
|
||||
for _, raw := range []string{"", "{}", "[]", "[1]", "[{}]"} {
|
||||
if _, err := hrbrainJSONArray(raw, "--items"); err == nil {
|
||||
t.Errorf("bad array accepted: %q", raw)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"", "[]", "{}"} {
|
||||
if _, err := hrbrainJSONObject(raw, "--object"); err == nil {
|
||||
t.Errorf("bad object accepted: %q", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainInvalidInputMakesZeroRemoteCalls(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
}{
|
||||
"unfiltered employee search": {declaration: SearchEmployees},
|
||||
"invalid page": {declaration: ListPools, args: []string{"--page", "0"}},
|
||||
"invalid page size": {declaration: ListPoolEmployees, args: []string{"--pool-code", "fixture", "--page-size", "101"}},
|
||||
"bad query json": {declaration: QueryProfile, args: []string{"--work-no", "fixture", "--data-queries", `{}`}},
|
||||
"bad structured json": {declaration: SearchEmployeesStructured, args: []string{"--origin-json", `{}`, "--fields", `[]`}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &hrbrainCoverageCaller{}
|
||||
if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid input unexpectedly succeeded")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote calls before validation = %d, want 0", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainExecutionFailuresAndContinuation(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
declaration shortcut.Shortcut
|
||||
args []string
|
||||
response string
|
||||
err error
|
||||
}{
|
||||
"object transport": {declaration: GetPool, args: []string{"--pool-code", "pool"}, err: errors.New("transport")},
|
||||
"object malformed": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":null}`},
|
||||
"object missing id": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":{"name":"pool"}}`},
|
||||
"object wrong id": {declaration: GetPool, args: []string{"--pool-code", "pool"}, response: `{"success":true,"result":{"poolCode":"other"}}`},
|
||||
"collection transport": {declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, err: errors.New("transport")},
|
||||
"collection malformed": {declaration: ProfileLabels, args: []string{"--staff-ids", "worker"}, response: `{"success":true,"result":null}`},
|
||||
"page transport": {declaration: SearchEmployees, args: []string{"--keyword", "worker"}, err: errors.New("transport")},
|
||||
"page malformed": {declaration: SearchEmployees, args: []string{"--keyword", "worker"}, response: `{"success":true,"result":null}`},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &hrbrainParityCaller{response: tc.response, err: tc.err}
|
||||
if err := runHRbrainCoverage(t, tc.declaration, caller, tc.args...); err == nil {
|
||||
t.Fatal("invalid remote result succeeded")
|
||||
}
|
||||
if caller.calls != 1 {
|
||||
t.Fatalf("calls = %d", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
continuing := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":1,"totalCount":2,"hasMore":true}}`}
|
||||
if err := runHRbrainCoverage(t, SearchEmployees, continuing, "--keyword", "worker", "--page-size", "1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if continuing.calls != 1 {
|
||||
t.Fatalf("continuing calls = %d", continuing.calls)
|
||||
}
|
||||
|
||||
optionalPool := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"poolCode":"pool"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`}
|
||||
if err := runHRbrainCoverage(t, ListPools, optionalPool,
|
||||
"--keyword", "pool", "--pool-type", "type", "--creator", "creator", "--labels", "one,two"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if labels, ok := optionalPool.params["labels"].([]string); !ok || len(labels) != 2 {
|
||||
t.Fatalf("labels = %#v", optionalPool.params["labels"])
|
||||
}
|
||||
|
||||
labelsCaller := &hrbrainParityCaller{response: `{"success":true,"result":[{"workNo":"worker"}]}`}
|
||||
if err := runHRbrainCoverage(t, ProfileLabels, labelsCaller, "--staff-ids", "worker", "--all-label"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if labelsCaller.params["allLabel"] != true {
|
||||
t.Fatalf("allLabel = %#v", labelsCaller.params["allLabel"])
|
||||
}
|
||||
|
||||
structured := &hrbrainParityCaller{response: `{"success":true,"result":{"items":[{"workNo":"worker"}],"currentPage":1,"pageSize":20,"totalCount":1,"hasMore":false}}`}
|
||||
if err := runHRbrainCoverage(t, SearchEmployeesStructured, structured,
|
||||
"--origin-json", `{"rules":[{"field":"field"}]}`, "--fields", `[{"label":"field","value":"field"}]`, "--order-by", "field"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if order, ok := structured.params["orderByClauses"].([]string); !ok || len(order) != 1 {
|
||||
t.Fatalf("orderByClauses = %#v", structured.params["orderByClauses"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainRemainingValidationAndScalarEdges(t *testing.T) {
|
||||
t.Run("unknown blocker", func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("unknown blocker classification did not panic")
|
||||
}
|
||||
}()
|
||||
_ = hrbrainUnavailableReason("+unknown")
|
||||
})
|
||||
|
||||
profileLabelsCommand := corecmd.New(shortcut.FromShortcut(ProfileLabels))
|
||||
if err := profileLabelsCommand.Flags().Set("staff-ids", ","); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ProfileLabels.Validate(shortcut.RuntimeContextForTest(profileLabelsCommand, ProfileLabels)); err == nil {
|
||||
t.Fatal("blank staff IDs succeeded")
|
||||
}
|
||||
badPage := &hrbrainCoverageCaller{}
|
||||
if err := runHRbrainCoverage(t, SearchEmployeesStructured, badPage,
|
||||
"--origin-json", `{"rule":"value"}`, "--fields", `[{"field":"value"}]`, "--page", "0"); err == nil {
|
||||
t.Fatal("structured search invalid page succeeded")
|
||||
}
|
||||
if badPage.calls != 0 {
|
||||
t.Fatalf("structured invalid page remote calls = %d", badPage.calls)
|
||||
}
|
||||
|
||||
if got := hrbrainIdentity(map[string]any{"id": float64(7)}, "id"); got != "7" {
|
||||
t.Fatalf("numeric identity = %q", got)
|
||||
}
|
||||
for _, value := range []any{math.NaN(), math.Inf(1), 1.5} {
|
||||
if got := hrbrainIdentity(map[string]any{"id": value}, "id"); got != "" {
|
||||
t.Fatalf("invalid identity = %q", got)
|
||||
}
|
||||
}
|
||||
if got, ok := hrbrainInteger(7); !ok || got != 7 {
|
||||
t.Fatalf("integer = %d, %v", got, ok)
|
||||
}
|
||||
for _, value := range []any{math.NaN(), math.Inf(1), 1.5, float64(math.MaxInt) * 2, "7"} {
|
||||
if _, ok := hrbrainInteger(value); ok {
|
||||
t.Fatalf("invalid integer accepted: %#v", value)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("marshal panic", func(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("mustJSON did not panic for unsupported value")
|
||||
}
|
||||
}()
|
||||
_ = mustJSON(make(chan int))
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHRbrainPageSizeAndTotalContradictions(t *testing.T) {
|
||||
missing := map[string]any{"success": true, "result": map[string]any{}}
|
||||
if _, _, err := hrbrainProjectPage(missing, "hrbrain/test", 1, 1, "workNo"); err == nil {
|
||||
t.Fatal("missing page collection succeeded")
|
||||
}
|
||||
badItem := map[string]any{"success": true, "result": map[string]any{
|
||||
"items": []any{"bad"}, "currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(1), "hasMore": false,
|
||||
}}
|
||||
if _, _, err := hrbrainProjectPage(badItem, "hrbrain/test", 1, 1, "workNo"); err == nil {
|
||||
t.Fatal("bad page item succeeded")
|
||||
}
|
||||
tooMany := map[string]any{"success": true, "result": map[string]any{
|
||||
"items": []any{map[string]any{"workNo": "one"}, map[string]any{"workNo": "two"}},
|
||||
"currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(2), "hasMore": true,
|
||||
}}
|
||||
if _, _, err := hrbrainProjectPage(tooMany, "hrbrain/test", 1, 1, "workNo"); err == nil {
|
||||
t.Fatal("page larger than pageSize succeeded")
|
||||
}
|
||||
badTotal := map[string]any{"success": true, "result": map[string]any{
|
||||
"items": []any{map[string]any{"workNo": "one"}},
|
||||
"currentPage": float64(1), "pageSize": float64(1), "totalCount": float64(0), "hasMore": false,
|
||||
}}
|
||||
if _, _, err := hrbrainProjectPage(badTotal, "hrbrain/test", 1, 1, "workNo"); err == nil {
|
||||
t.Fatal("total below item count succeeded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package live defines strict task-oriented Shortcut candidates for DingTalk
|
||||
// live broadcasts. The current list capability remains fail-closed because the
|
||||
// downstream response can contradict its own completion and total fields.
|
||||
package live
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
const liveListGapReason = "Exact bounded live probes returned total greater than zero together with hasFinish=true and an empty liveDetailModelList; the response cannot prove either a complete list or a legitimate empty result."
|
||||
|
||||
var ListMyLives = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutLegacyOnly,
|
||||
Service: "live", Command: "+list-my-lives", Product: "live",
|
||||
Description: "查看当前用户发起的直播列表与基础统计",
|
||||
Intent: "需要查看本人发起的直播、状态或观看统计时使用;当前因下游列表数量与完成状态矛盾保持不可用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Description: "查看当前用户发起的直播列表与基础统计",
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "live", Name: "shortcut_list_my_lives", CanonicalPath: "live.shortcut_list_my_lives",
|
||||
CLIPath: "live +list-my-lives", PrimaryCLIPath: "live +list-my-lives",
|
||||
},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable, Reason: liveListGapReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查看当前用户发起的直播列表与基础统计",
|
||||
UseWhen: []string{"需要查看本人发起的直播、状态或观看统计时使用;当前因下游列表数量与完成状态矛盾保持不可用。"},
|
||||
AvoidWhen: []string{
|
||||
"需要创建、开播、结束、观看或控制直播时不要使用;当前 DWS 原子面没有这些能力",
|
||||
"下游列表总数、完成状态和项目数组未自洽前不要把空数组当作没有直播",
|
||||
},
|
||||
Examples: []string{"dws live +list-my-lives --format json"},
|
||||
},
|
||||
},
|
||||
Execute: func(*shortcut.RuntimeContext) error {
|
||||
return responsecheck.Error("live/get_my_lives", "capability_unavailable", liveListGapReason)
|
||||
},
|
||||
}
|
||||
|
||||
// projectLiveList is retained as the reviewed activation gate. It accepts only
|
||||
// an explicit complete, unpaginated list whose total exactly equals its items.
|
||||
func projectLiveList(data map[string]any) (map[string]any, error) {
|
||||
operation := "live/get_my_lives"
|
||||
envelope, err := responsecheck.RequireSuccess(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range []string{"errorCode", "errorMsg", "errorMessage", "error"} {
|
||||
if liveFailureValue(envelope[key]) {
|
||||
return nil, responsecheck.Error(operation, "conflicting_failure_evidence", "success=true 响应同时携带失败字段 "+key)
|
||||
}
|
||||
}
|
||||
result, err := responsecheck.RequireObjectResult(envelope, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
total, ok := liveNonNegativeInt(result["total"])
|
||||
if !ok {
|
||||
return nil, responsecheck.Error(operation, "malformed_total", "响应 result.total 必须是非负整数")
|
||||
}
|
||||
complete, ok := result["hasFinish"].(bool)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error(operation, "malformed_completion", "响应 result.hasFinish 必须是布尔值")
|
||||
}
|
||||
if !complete {
|
||||
return nil, responsecheck.Error(operation, "unpageable_incomplete_result", "响应未完结但接口没有 cursor 或下一页参数,不能声称列表成功")
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(envelope, operation, "result.liveDetailModelList")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if total != len(items) {
|
||||
return nil, responsecheck.Error(operation, "inconsistent_total", fmt.Sprintf("响应 total=%d、项目数=%d 且 hasFinish=true,不能证明完整列表", total, len(items)))
|
||||
}
|
||||
seen := make(map[string]bool, len(items))
|
||||
for index, item := range items {
|
||||
id := liveStableID(item)
|
||||
if id == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("直播结果第 %d 项缺少稳定直播 ID", index))
|
||||
}
|
||||
if seen[id] {
|
||||
return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("直播结果第 %d 项重复稳定直播 ID", index))
|
||||
}
|
||||
seen[id] = true
|
||||
}
|
||||
return map[string]any{"count": total, "lives": items, "complete": true}, nil
|
||||
}
|
||||
|
||||
func liveFailureValue(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
return trimmed != "" && trimmed != "0" && !strings.EqualFold(trimmed, "success")
|
||||
case float64:
|
||||
return typed != 0
|
||||
case int:
|
||||
return typed != 0
|
||||
case bool:
|
||||
return typed
|
||||
case map[string]any:
|
||||
return len(typed) != 0
|
||||
case []any:
|
||||
return len(typed) != 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func liveNonNegativeInt(value any) (int, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed < 0 || typed != float64(int(typed)) {
|
||||
return 0, false
|
||||
}
|
||||
return int(typed), true
|
||||
case int:
|
||||
return typed, typed >= 0
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
return int(parsed), err == nil && parsed >= 0
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func liveStableID(item map[string]any) string {
|
||||
for _, key := range []string{"liveId", "liveUuid", "uuid", "id"} {
|
||||
if value, ok := item[key].(string); ok && strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(ListMyLives)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package live
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type liveCaller struct{ calls int }
|
||||
|
||||
func (caller *liveCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":{"total":0,"hasFinish":true,"liveDetailModelList":[]}}`}}}, nil
|
||||
}
|
||||
func (*liveCaller) Format() string { return "json" }
|
||||
func (*liveCaller) DryRun() bool { return false }
|
||||
func (*liveCaller) Fields() string { return "" }
|
||||
func (*liveCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageLiveListStrictProjection(t *testing.T) {
|
||||
valid := map[string]any{"success": true, "result": map[string]any{"total": float64(1), "hasFinish": true, "liveDetailModelList": []any{map[string]any{"liveId": "stable-live"}}}}
|
||||
projected, err := projectLiveList(valid)
|
||||
if err != nil || projected["count"] != 1 {
|
||||
t.Fatalf("valid list rejected: projected=%v err=%v", projected, err)
|
||||
}
|
||||
empty, err := projectLiveList(map[string]any{"success": true, "result": map[string]any{"total": float64(0), "hasFinish": true, "liveDetailModelList": []any{}}})
|
||||
if err != nil || empty["count"] != 0 {
|
||||
t.Fatalf("explicit zero rejected: projected=%v err=%v", empty, err)
|
||||
}
|
||||
|
||||
broken := []map[string]any{
|
||||
{},
|
||||
{"result": map[string]any{}},
|
||||
{"success": false, "result": map[string]any{}},
|
||||
{"success": true, "errorCode": "FAILED", "result": map[string]any{"total": float64(0), "hasFinish": true, "liveDetailModelList": []any{}}},
|
||||
{"success": true},
|
||||
{"success": true, "result": []any{}},
|
||||
{"success": true, "result": map[string]any{"total": float64(0), "hasFinish": true}},
|
||||
{"success": true, "result": map[string]any{"total": "0", "hasFinish": true, "liveDetailModelList": []any{}}},
|
||||
{"success": true, "result": map[string]any{"total": float64(0), "hasFinish": "true", "liveDetailModelList": []any{}}},
|
||||
{"success": true, "result": map[string]any{"total": float64(0), "hasFinish": false, "liveDetailModelList": []any{}}},
|
||||
{"success": true, "result": map[string]any{"total": float64(4), "hasFinish": true, "liveDetailModelList": []any{}}},
|
||||
{"success": true, "result": map[string]any{"total": float64(1), "hasFinish": true, "liveDetailModelList": []any{"bad"}}},
|
||||
{"success": true, "result": map[string]any{"total": float64(1), "hasFinish": true, "liveDetailModelList": []any{map[string]any{"title": "no-id"}}}},
|
||||
{"success": true, "result": map[string]any{"total": float64(2), "hasFinish": true, "liveDetailModelList": []any{map[string]any{"liveId": "duplicate"}, map[string]any{"liveId": "duplicate"}}}},
|
||||
}
|
||||
for index, data := range broken {
|
||||
if got, projectErr := projectLiveList(data); projectErr == nil {
|
||||
t.Errorf("broken response %d returned success: %v", index, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageUnavailableLiveMakesNoRemoteCall(t *testing.T) {
|
||||
caller := &liveCaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
err := ListMyLives.Execute(shortcut.RuntimeContextForTest(&cobra.Command{Use: ListMyLives.Command}, ListMyLives))
|
||||
if err == nil || !strings.Contains(err.Error(), "cannot prove") {
|
||||
t.Fatalf("unavailable error = %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("unavailable Live shortcut made %d remote calls", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLiveContract(t *testing.T) {
|
||||
if ListMyLives.OutputRollout != output.RolloutLegacyOnly || ListMyLives.Contract.Result != nil || strings.TrimSpace(ListMyLives.Safety.Effect) == "" || ListMyLives.Contract.Interface == nil || ListMyLives.Contract.Interface.Availability != "unavailable" {
|
||||
t.Fatalf("Live unavailable contract drift: rollout=%q result=%v interface=%#v", ListMyLives.OutputRollout, ListMyLives.Contract.Result, ListMyLives.Contract.Interface)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLivePrimitiveMatrices(t *testing.T) {
|
||||
for index, test := range []struct {
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{nil, false}, {"", false}, {"0", false}, {"success", false}, {"bad", true},
|
||||
{float64(0), false}, {float64(1), true}, {0, false}, {1, true}, {false, false}, {true, true},
|
||||
{map[string]any{}, false}, {map[string]any{"x": 1}, true}, {[]any{}, false}, {[]any{1}, true}, {struct{}{}, true},
|
||||
} {
|
||||
if got := liveFailureValue(test.value); got != test.want {
|
||||
t.Errorf("failure matrix %d = %v, want %v", index, got, test.want)
|
||||
}
|
||||
}
|
||||
for index, test := range []struct {
|
||||
value any
|
||||
want int
|
||||
ok bool
|
||||
}{
|
||||
{float64(7), 7, true}, {float64(-1), 0, false}, {float64(7.5), 0, false},
|
||||
{8, 8, true}, {-1, -1, false}, {json.Number("9"), 9, true}, {json.Number("bad"), 0, false}, {"9", 0, false},
|
||||
} {
|
||||
got, ok := liveNonNegativeInt(test.value)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Errorf("integer matrix %d = (%d,%v), want (%d,%v)", index, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
for _, field := range []string{"liveId", "liveUuid", "uuid", "id"} {
|
||||
if got := liveStableID(map[string]any{field: " stable "}); got != "stable" {
|
||||
t.Errorf("stable ID field %s = %q", field, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package pat registers safe Shortcut routing for PAT authorization tasks.
|
||||
package pat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"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/output"
|
||||
patcore "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
var safeAgentCodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||||
|
||||
var (
|
||||
patBrowserPolicyConfigDir = patcore.BrowserPolicyConfigDir
|
||||
patSetBrowserPolicy = patcore.SetBrowserPolicy
|
||||
patReadBrowserPolicy = patcore.ReadStoredBrowserPolicy
|
||||
)
|
||||
|
||||
func browserPolicyResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"本地 PAT 浏览器策略预览或已验证写入结果","properties":{"scope":{"type":"string","description":"写入范围:default 或 agent","enum":["default","agent"]},"openBrowser":{"type":"boolean","description":"策略是否允许打开浏览器"},"executed":{"type":"boolean","description":"本次是否实际写入本地策略"},"verified":{"type":"boolean","description":"是否对同一目标完成磁盘读回验证"}},"required":["scope","openBrowser","executed","verified"],"additionalProperties":false}`),
|
||||
}
|
||||
}
|
||||
|
||||
func authorizeResult() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomePending, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"由 routed 原子命令 pat chmod 持有的 PAT 授权计划或逐 scope 终态账本","properties":{"selected":{"type":"integer","description":"计划选择的 scope 数量"},"skipped":{"type":"integer","description":"计划跳过的 scope 数量"},"pending":{"type":"integer","description":"仍待授权的 scope 数量"},"verified":{"type":"boolean","description":"授权身份与逐 scope 终态是否已验证"}},"required":["selected","skipped","pending","verified"],"additionalProperties":false}`),
|
||||
}
|
||||
}
|
||||
|
||||
var BrowserPolicy = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "pat",
|
||||
Command: "+browser-policy",
|
||||
Product: "pat",
|
||||
Description: "安全配置 PAT 授权时是否允许打开本地浏览器",
|
||||
Intent: "需要为默认策略或一个明确 Agent 配置 PAT 撞墙时是否允许打开本地浏览器时使用;支持无写入预览,实际写入需确认并对同一策略项完成磁盘读回。",
|
||||
Risk: shortcut.RiskWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "pat", Name: "shortcut_browser_policy", CanonicalPath: "pat.shortcut_browser_policy",
|
||||
CLIPath: "pat +browser-policy", PrimaryCLIPath: "pat +browser-policy",
|
||||
},
|
||||
Description: "安全配置 PAT 授权时是否允许打开本地浏览器",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable,
|
||||
Reason: "The Shortcut writes only the local PAT policy file, requires confirmation, supports a no-write request preview, omits agent identity from output, and verifies the exact persisted target.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "安全配置 PAT 授权时是否允许打开本地浏览器",
|
||||
UseWhen: []string{"需要为默认策略或一个明确 Agent 配置 PAT 撞墙时是否允许打开本地浏览器时使用;支持无写入预览,实际写入需确认并对同一策略项完成磁盘读回。"},
|
||||
AvoidWhen: []string{"需要预览或授予行为 scope 时使用原子命令 pat chmod;普通 OAuth 登录使用 auth"},
|
||||
Examples: []string{`dws pat +browser-policy --enabled=false --dry-run --format json`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "enabled"}, {Name: "agent-code"},
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest},
|
||||
Result: browserPolicyResult(),
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "enabled", Type: shortcut.FlagBool, Required: true, Desc: "是否允许打开本地浏览器;必须显式传 true 或 false"},
|
||||
{Name: "agent-code", Type: shortcut.FlagString, Desc: "可选 Agent 标识必须为 1 到 64 位字母、数字、下划线或连字符;只用于定位本地策略项且不会出现在结果中"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"agent-code"}, Description: "可选 Agent 标识必须为 1 到 64 位字母、数字、下划线或连字符"},
|
||||
},
|
||||
Tips: []string{`dws pat +browser-policy --enabled=false --dry-run --format json`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
value := strings.TrimSpace(rt.Str("agent-code"))
|
||||
if value != "" && !safeAgentCodePattern.MatchString(value) {
|
||||
return apperrors.NewValidation("--agent-code 格式无效;只允许 1 到 64 位字母、数字、下划线或连字符")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
scope := "default"
|
||||
agentCode := strings.TrimSpace(rt.Str("agent-code"))
|
||||
if agentCode != "" {
|
||||
scope = "agent"
|
||||
}
|
||||
enabled := rt.Bool("enabled")
|
||||
if rt.DryRun() {
|
||||
return rt.Output(map[string]any{"scope": scope, "openBrowser": enabled, "executed": false, "verified": false})
|
||||
}
|
||||
configDir := patBrowserPolicyConfigDir()
|
||||
written, err := patSetBrowserPolicy(configDir, agentCode, enabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored, err := patReadBrowserPolicy(configDir, agentCode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written.Scope != scope || stored.Scope != scope || written.OpenBrowser != enabled || stored.OpenBrowser != enabled || written.AgentCode != stored.AgentCode {
|
||||
return apperrors.NewInternal("PAT 浏览器策略写后读回不一致;结果未验证")
|
||||
}
|
||||
return rt.Output(map[string]any{"scope": scope, "openBrowser": enabled, "executed": true, "verified": true})
|
||||
},
|
||||
}
|
||||
|
||||
var Authorize = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "pat",
|
||||
Command: "+authorize",
|
||||
Product: "pat",
|
||||
Description: "将 PAT 行为 scope 授权路由到原子命令 pat chmod",
|
||||
Intent: "命令提示缺少 PAT 行为授权时 classified=routed:先使用 pat chmod --dry-run 审核计划,用户确认后再由同一原子命令执行;+authorize 本身保持 unavailable。",
|
||||
Risk: shortcut.RiskHighWrite,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: corecmd.ContractDecl{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "pat", Name: "shortcut_authorize", CanonicalPath: "pat.shortcut_authorize",
|
||||
CLIPath: "pat +authorize", PrimaryCLIPath: "pat +authorize",
|
||||
},
|
||||
Description: "将 PAT 行为 scope 授权路由到原子命令 pat chmod",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceUnavailable,
|
||||
Reason: "classified=routed; pat chmod already owns the reviewed plan, fallback, pending, per-scope ledger, identity, and confirmation chain. The Shortcut runtime cannot reuse that Cobra/ToolCaller closure without duplicating safety semantics, and a live write remains blocked_fixture, so +authorize stays unavailable while routing to pat chmod.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "将 PAT 行为 scope 授权路由到原子命令 pat chmod",
|
||||
UseWhen: []string{"命令提示缺少 PAT 行为授权时 classified=routed:先使用 pat chmod --dry-run 审核计划,用户确认后再由同一原子命令执行;+authorize 本身保持 unavailable。"},
|
||||
AvoidWhen: []string{"不要向此 unavailable Shortcut 传入任何真实身份、会话、scope 或授权材料;本地浏览器策略使用 pat +browser-policy"},
|
||||
Examples: []string{`dws pat +authorize --format json`},
|
||||
},
|
||||
Result: authorizeResult(),
|
||||
},
|
||||
Hidden: true,
|
||||
Availability: shortcut.AvailabilityUnavailable,
|
||||
Execute: func(*shortcut.RuntimeContext) error {
|
||||
return apperrors.NewValidation("PAT 授权任务 classified=routed;+authorize 当前 unavailable,请使用审核后的原子命令 pat chmod,并先执行 --dry-run")
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
shortcut.Register(BrowserPolicy, Authorize)
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package pat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
patcore "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type patShortcutCaller struct{ calls int }
|
||||
|
||||
func (caller *patShortcutCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls++
|
||||
return nil, nil
|
||||
}
|
||||
func (*patShortcutCaller) Format() string { return "json" }
|
||||
func (*patShortcutCaller) DryRun() bool { return false }
|
||||
func (*patShortcutCaller) Fields() string { return "" }
|
||||
func (*patShortcutCaller) JQ() string { return "" }
|
||||
|
||||
type patAtomicCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type patAtomicFixtureCaller struct {
|
||||
dryRun bool
|
||||
responses []string
|
||||
calls []patAtomicCall
|
||||
}
|
||||
|
||||
func (caller *patAtomicFixtureCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
caller.calls = append(caller.calls, patAtomicCall{tool: tool, args: copied})
|
||||
response := `{"success":true,"code":"OK","data":{}}`
|
||||
if index := len(caller.calls) - 1; index < len(caller.responses) {
|
||||
response = caller.responses[index]
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}}, nil
|
||||
}
|
||||
func (*patAtomicFixtureCaller) Format() string { return "json" }
|
||||
func (caller *patAtomicFixtureCaller) DryRun() bool { return caller.dryRun }
|
||||
func (*patAtomicFixtureCaller) Fields() string { return "" }
|
||||
func (*patAtomicFixtureCaller) JQ() string { return "" }
|
||||
|
||||
func runPATShortcut(t *testing.T, declaration shortcut.Shortcut, caller *patShortcutCaller, input string, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
service := &cobra.Command{Use: "pat"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(input))
|
||||
root.SetArgs(append([]string{"pat", declaration.Command}, args...))
|
||||
executed, err := root.ExecuteC()
|
||||
if err == nil {
|
||||
_, _, err = output.EmitStoredResult(executed)
|
||||
}
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func runPATAtomicRoute(t *testing.T, caller *patAtomicFixtureCaller, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
patcore.RegisterCommands(root, caller)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(append([]string{"pat", "chmod"}, args...))
|
||||
|
||||
oldStdout := os.Stdout
|
||||
readPipe, writePipe, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = writePipe
|
||||
_, runErr := root.ExecuteC()
|
||||
_ = writePipe.Close()
|
||||
os.Stdout = oldStdout
|
||||
outputBytes, readErr := io.ReadAll(readPipe)
|
||||
_ = readPipe.Close()
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
return string(outputBytes), runErr
|
||||
}
|
||||
|
||||
func runPATNativeBrowserPolicy(t *testing.T, caller *patAtomicFixtureCaller, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.PersistentFlags().Bool("verbose", false, "")
|
||||
patcore.RegisterCommands(root, caller)
|
||||
var stdout bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetIn(strings.NewReader(""))
|
||||
root.SetArgs(append([]string{"pat", "browser-policy"}, args...))
|
||||
_, err := root.ExecuteC()
|
||||
return stdout.String(), err
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATAuthorizeRouteUsesAtomicPlanFallbackPendingAndLedger(t *testing.T) {
|
||||
plan := `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","disposition":"selected"}],"selectedScopes":["synthetic.item:read"],"skippedScopes":[],"pendingScopes":[]}}`
|
||||
pending := `{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","status":"pending"}],"selectedScopes":["synthetic.item:read"],"grantedScopes":[],"skippedScopes":[],"pendingScopes":["synthetic.item:read"]}}`
|
||||
|
||||
preconfirm := &patAtomicFixtureCaller{responses: []string{plan, pending}}
|
||||
if _, err := runPATAtomicRoute(t, preconfirm, "--product", "synthetic", "--grant-type", "once", "--format", "json"); err == nil {
|
||||
t.Fatal("unconfirmed atomic authorization unexpectedly succeeded")
|
||||
}
|
||||
if len(preconfirm.calls) != 0 {
|
||||
t.Fatalf("atomic calls before confirmation = %d, want 0", len(preconfirm.calls))
|
||||
}
|
||||
|
||||
dryRun := &patAtomicFixtureCaller{dryRun: true, responses: []string{plan}}
|
||||
dryOutput, err := runPATAtomicRoute(t, dryRun, "--product", "synthetic", "--grant-type", "once", "--dry-run", "--format", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("atomic plan dry-run: %v", err)
|
||||
}
|
||||
if len(dryRun.calls) != 1 || dryRun.calls[0].tool != "pat.batch_plan" || dryRun.calls[0].args["dryRun"] != true {
|
||||
t.Fatalf("dry-run route = %#v", dryRun.calls)
|
||||
}
|
||||
if !strings.Contains(dryOutput, `"items"`) || !strings.Contains(dryOutput, `"selectedScopes"`) || !strings.Contains(dryOutput, `"pendingScopes"`) {
|
||||
t.Fatal("dry-run did not preserve the synthetic per-scope plan ledger")
|
||||
}
|
||||
|
||||
confirmed := &patAtomicFixtureCaller{responses: []string{plan, pending}}
|
||||
confirmedOutput, err := runPATAtomicRoute(t, confirmed, "--product", "synthetic", "--grant-type", "once", "--yes", "--format", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed atomic route: %v", err)
|
||||
}
|
||||
if len(confirmed.calls) != 2 || confirmed.calls[0].tool != "pat.batch_plan" || confirmed.calls[1].tool != "pat.batch_grant" {
|
||||
t.Fatalf("confirmed route = %#v", confirmed.calls)
|
||||
}
|
||||
if scopes, ok := confirmed.calls[1].args["scopes"].([]string); !ok || len(scopes) != 1 || scopes[0] != "synthetic.item:read" {
|
||||
t.Fatalf("grant scopes = %#v", confirmed.calls[1].args["scopes"])
|
||||
}
|
||||
for _, key := range []string{"agentCode", "sessionId", "orgId", "uid"} {
|
||||
if _, present := confirmed.calls[1].args[key]; present {
|
||||
t.Fatalf("synthetic fixture unexpectedly carried identity key %s", key)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(confirmedOutput, `"items"`) || !strings.Contains(confirmedOutput, `"pendingScopes"`) || !strings.Contains(confirmedOutput, `"status":"pending"`) {
|
||||
t.Fatal("confirmed route did not preserve the synthetic pending per-scope ledger")
|
||||
}
|
||||
|
||||
fallback := &patAtomicFixtureCaller{responses: []string{
|
||||
`{"success":false,"errorCode":"PAT_BATCH_SCOPE_NOT_DECLARED","data":{}}`,
|
||||
`{"success":true,"code":"OK","data":{"items":[{"scope":"synthetic.item:read","status":"granted"}],"grantedScopes":["synthetic.item:read"],"pendingScopes":[]}}`,
|
||||
}}
|
||||
if _, err := runPATAtomicRoute(t, fallback, "synthetic.item:read", "--grant-type", "once", "--yes", "--format", "json"); err != nil {
|
||||
t.Fatalf("atomic fallback route: %v", err)
|
||||
}
|
||||
if len(fallback.calls) != 2 || fallback.calls[0].tool != "pat.batch_grant" || fallback.calls[1].tool != "pat.grant" {
|
||||
t.Fatalf("fallback route = %#v", fallback.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATBrowserPolicyConfirmationDryRunReadbackAndCleanup(t *testing.T) {
|
||||
proofRoot := t.TempDir()
|
||||
exactDir := filepath.Join(proofRoot, "exact")
|
||||
if err := os.Mkdir(exactDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", exactDir)
|
||||
policyPath := filepath.Join(exactDir, "pat_policy.json")
|
||||
caller := &patShortcutCaller{}
|
||||
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true"); err == nil {
|
||||
t.Fatal("unconfirmed write unexpectedly succeeded")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("remote calls before confirmation = %d", caller.calls)
|
||||
}
|
||||
if _, err := os.Stat(policyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("policy file exists before confirmation: %v", err)
|
||||
}
|
||||
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true", "--dry-run"); err != nil {
|
||||
t.Fatalf("dry-run: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(policyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("dry-run created policy file: %v", err)
|
||||
}
|
||||
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, caller, "yes\n", "--enabled=true"); err != nil {
|
||||
t.Fatalf("confirmed write: %v", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("local policy Shortcut made %d remote calls", caller.calls)
|
||||
}
|
||||
stored, err := os.ReadFile(policyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read policy: %v", err)
|
||||
}
|
||||
if bytes.Contains(stored, []byte("agentCode")) || !bytes.Contains(stored, []byte(`"openBrowser": true`)) {
|
||||
t.Fatal("default policy did not persist the expected redacted setting")
|
||||
}
|
||||
if err := os.Remove(policyPath); err != nil {
|
||||
t.Fatalf("cleanup policy: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(policyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("policy cleanup left residue: %v", err)
|
||||
}
|
||||
|
||||
rawDir := filepath.Join(proofRoot, "raw")
|
||||
if err := os.Mkdir(rawDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", rawDir)
|
||||
rawPolicyPath := filepath.Join(rawDir, "pat_policy.json")
|
||||
rawCaller := &patAtomicFixtureCaller{}
|
||||
rawOutput, err := runPATNativeBrowserPolicy(t, rawCaller, "--enabled=false", "--format", "json")
|
||||
if err != nil {
|
||||
t.Fatalf("raw browser policy write: %v", err)
|
||||
}
|
||||
if len(rawCaller.calls) != 0 {
|
||||
t.Fatalf("raw local policy command made %d remote calls", len(rawCaller.calls))
|
||||
}
|
||||
var rawReceipt patcore.BrowserPolicySelection
|
||||
if err := json.Unmarshal([]byte(rawOutput), &rawReceipt); err != nil {
|
||||
t.Fatalf("parse raw browser policy receipt: %v", err)
|
||||
}
|
||||
if rawReceipt.Scope != "default" || rawReceipt.Source != "default" || rawReceipt.OpenBrowser || rawReceipt.AgentCode != "" {
|
||||
t.Fatal("raw browser policy did not return a terminal receipt for the requested target")
|
||||
}
|
||||
rawStored, err := os.ReadFile(rawPolicyPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read raw policy: %v", err)
|
||||
}
|
||||
if bytes.Contains(rawStored, []byte("agentCode")) || !bytes.Contains(rawStored, []byte(`"openBrowser": false`)) {
|
||||
t.Fatal("raw default policy did not persist the expected redacted setting")
|
||||
}
|
||||
if err := os.Remove(rawPolicyPath); err != nil {
|
||||
t.Fatalf("cleanup raw policy: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(rawPolicyPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("raw policy cleanup left residue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATBrowserPolicyOutputOmitsAgentIdentity(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
caller := &patShortcutCaller{}
|
||||
outputText, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=false", "--agent-code", "DWS_TEST_AGENT", "--yes")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(outputText, "DWS_TEST_AGENT") || strings.Contains(outputText, "agentCode") || strings.Contains(outputText, "agent-code") {
|
||||
t.Fatal("public PAT Shortcut output exposed agent identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATBrowserPolicyWriteReadbackFailures(t *testing.T) {
|
||||
failure := errors.New("fixture failure")
|
||||
t.Run("write failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() })
|
||||
testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{}, failure
|
||||
})
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); !errors.Is(err, failure) {
|
||||
t.Fatalf("write error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read failure", func(t *testing.T) {
|
||||
testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() })
|
||||
testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: true}, nil
|
||||
})
|
||||
testseam.Swap(t, &patReadBrowserPolicy, func(string, string) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{}, failure
|
||||
})
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); !errors.Is(err, failure) {
|
||||
t.Fatalf("read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("mismatched readback", func(t *testing.T) {
|
||||
testseam.Swap(t, &patBrowserPolicyConfigDir, func() string { return t.TempDir() })
|
||||
testseam.Swap(t, &patSetBrowserPolicy, func(string, string, bool) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: true}, nil
|
||||
})
|
||||
testseam.Swap(t, &patReadBrowserPolicy, func(string, string) (patcore.BrowserPolicySelection, error) {
|
||||
return patcore.BrowserPolicySelection{Scope: "default", OpenBrowser: false}, nil
|
||||
})
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, &patShortcutCaller{}, "", "--enabled=true", "--yes"); err == nil {
|
||||
t.Fatal("mismatched readback succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATInvalidAndUnavailablePathsMakeZeroRemoteCalls(t *testing.T) {
|
||||
caller := &patShortcutCaller{}
|
||||
if _, err := runPATShortcut(t, BrowserPolicy, caller, "", "--enabled=true", "--agent-code", "invalid value", "--yes"); err == nil {
|
||||
t.Fatal("invalid agent code unexpectedly succeeded")
|
||||
}
|
||||
if _, err := runPATShortcut(t, Authorize, caller, "", "--yes"); err == nil {
|
||||
t.Fatal("unavailable authorization Shortcut unexpectedly succeeded")
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("invalid/unavailable PAT paths made %d remote calls", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePATShortcutContracts(t *testing.T) {
|
||||
if BrowserPolicy.Hidden || BrowserPolicy.Availability == shortcut.AvailabilityUnavailable {
|
||||
t.Fatal("browser policy must be public/available before semantic decoration")
|
||||
}
|
||||
if BrowserPolicy.OutputRollout != output.RolloutUnifiedActive || BrowserPolicy.Contract.Result == nil || BrowserPolicy.Contract.DryRun == nil || BrowserPolicy.Contract.DryRun.PreviewKind != "request" || strings.TrimSpace(BrowserPolicy.Safety.Effect) == "" {
|
||||
t.Fatal("public browser policy lacks Result/Safety/DryRun/unified output")
|
||||
}
|
||||
if !Authorize.Hidden || Authorize.Availability != shortcut.AvailabilityUnavailable || Authorize.Contract.Interface.Availability != "unavailable" {
|
||||
t.Fatal("PAT authorization Shortcut must stay hidden/unavailable")
|
||||
}
|
||||
if !strings.Contains(Authorize.Contract.Interface.Reason, "classified=routed") || !strings.Contains(Authorize.Contract.Interface.Reason, "blocked_fixture") {
|
||||
t.Fatal("PAT authorization must separate the routed implementation from unproved live write evidence")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -24,8 +25,9 @@ func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T)
|
||||
var catalog struct {
|
||||
Count int `json:"count"`
|
||||
Results []struct {
|
||||
Service string `json:"service"`
|
||||
Command string `json:"command"`
|
||||
Service string `json:"service"`
|
||||
Command string `json:"command"`
|
||||
SemanticDelta string `json:"semantic_delta"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &catalog); err != nil {
|
||||
@@ -50,6 +52,14 @@ func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T)
|
||||
if _, generated := publicShortcutCatalog[key]; !generated {
|
||||
t.Fatalf("public catalog result %d is missing from generated Go keys: %q", i, key)
|
||||
}
|
||||
if row.Service == "contact" && (row.Command == "+search-mobile" || row.Command == "+by-mobile") {
|
||||
if !strings.Contains(row.SemanticDelta, "专用手机号精确查询接口") {
|
||||
t.Fatalf("public catalog result %d %q has stale mobile lookup semantics", i, key)
|
||||
}
|
||||
if strings.Contains(row.SemanticDelta, "关键词能力") || strings.Contains(row.SemanticDelta, "关键词数组") {
|
||||
t.Fatalf("public catalog result %d %q references the retired keyword-search route", i, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for key := range publicShortcutCatalog {
|
||||
if _, documented := seen[key]; !documented {
|
||||
|
||||
@@ -6,6 +6,7 @@ package shortcut
|
||||
// command discovery and skill generation.
|
||||
func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
return map[string]struct{}{
|
||||
"aisearch\u0000+search-person": {},
|
||||
"aitable\u0000+advperm-disable": {},
|
||||
"aitable\u0000+advperm-enable": {},
|
||||
"aitable\u0000+attachment-put": {},
|
||||
@@ -33,6 +34,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"aitable\u0000+dashboard-share-get": {},
|
||||
"aitable\u0000+dashboard-share-update": {},
|
||||
"aitable\u0000+dashboard-update": {},
|
||||
"aitable\u0000+datasource-create": {},
|
||||
"aitable\u0000+datasource-get-config": {},
|
||||
"aitable\u0000+datasource-get-fields": {},
|
||||
"aitable\u0000+datasource-list-sources": {},
|
||||
"aitable\u0000+datasource-sync": {},
|
||||
"aitable\u0000+datasource-sync-status": {},
|
||||
"aitable\u0000+datasource-update": {},
|
||||
"aitable\u0000+export-data": {},
|
||||
"aitable\u0000+field-delete": {},
|
||||
"aitable\u0000+field-get": {},
|
||||
@@ -237,7 +245,6 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"contact\u0000+list-dept-members": {},
|
||||
"contact\u0000+list-followings": {},
|
||||
"contact\u0000+list-role-members": {},
|
||||
"contact\u0000+list-roles": {},
|
||||
"contact\u0000+list-sub-depts": {},
|
||||
"contact\u0000+lookup": {},
|
||||
"contact\u0000+me": {},
|
||||
@@ -375,6 +382,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"minutes\u0000+upload": {},
|
||||
"minutes\u0000+upload-and-analyze": {},
|
||||
"oa\u0000+search-forms": {},
|
||||
"pat\u0000+browser-policy": {},
|
||||
"report\u0000+inbox-list": {},
|
||||
"report\u0000+outbox-list": {},
|
||||
"report\u0000+report-latest": {},
|
||||
|
||||
@@ -40,6 +40,15 @@ var attendanceSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_mail.json
|
||||
var mailSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_aisearch.json
|
||||
var aisearchSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_contact.json
|
||||
var contactSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_live.json
|
||||
var liveSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_oa.json
|
||||
var oaSemanticCatalogJSON []byte
|
||||
|
||||
@@ -55,6 +64,15 @@ var sheetSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_whiteboard.json
|
||||
var whiteboardSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_devdoc.json
|
||||
var devdocSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_hrbrain.json
|
||||
var hrbrainSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_pat.json
|
||||
var patSemanticCatalogJSON []byte
|
||||
|
||||
type semanticCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Service string `json:"service"`
|
||||
@@ -84,11 +102,17 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
todoSemanticCatalogJSON,
|
||||
attendanceSemanticCatalogJSON,
|
||||
mailSemanticCatalogJSON,
|
||||
aisearchSemanticCatalogJSON,
|
||||
contactSemanticCatalogJSON,
|
||||
liveSemanticCatalogJSON,
|
||||
oaSemanticCatalogJSON,
|
||||
dingSemanticCatalogJSON,
|
||||
reportSemanticCatalogJSON,
|
||||
sheetSemanticCatalogJSON,
|
||||
whiteboardSemanticCatalogJSON,
|
||||
devdocSemanticCatalogJSON,
|
||||
hrbrainSemanticCatalogJSON,
|
||||
patSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "aisearch",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+search-person": {
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。",
|
||||
"risk": "read",
|
||||
"availability": "available",
|
||||
"public": true,
|
||||
"reviewed": true
|
||||
},
|
||||
"+search-enterprise": {
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "IM 搜索可返回稳定会话身份,但 fresh 随机查询仍返回业务对象,无法证明严格相关性或合法零结果;保持 unavailable,其他内容类型继续路由到原子命令。",
|
||||
"risk": "read",
|
||||
"availability": "unavailable",
|
||||
"public": false,
|
||||
"reviewed": true
|
||||
},
|
||||
"+search-behavior": {
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验行为搜索响应,但随机零命中标记仍返回非空完整业务对象,当前无法证明相关性或合法空结果。",
|
||||
"risk": "read",
|
||||
"availability": "unavailable",
|
||||
"public": false,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,14 @@
|
||||
"+list-tables": {"disposition":"semantic_adapter","semantic_delta":"把 Base 目录稳定投影为 tableId/tableName 列表,隐藏非表节点。","risk":"read","public":true,"reviewed":true},
|
||||
"+record-share-links": {"disposition":"primary_smart","semantic_delta":"对超过 20 条 recordId 去重分片调用并合并分享链接及逐批结果。","risk":"read","public":true,"reviewed":true},
|
||||
"+resolve-base": {"disposition":"primary_smart","semantic_delta":"按名称搜索并要求唯一匹配,0 或多结果时显式失败而不猜 baseId。","risk":"read","public":true,"reviewed":true},
|
||||
"+resolve-table": {"disposition":"primary_smart","semantic_delta":"在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。","risk":"read","public":true,"reviewed":true}
|
||||
"+resolve-table": {"disposition":"primary_smart","semantic_delta":"在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。","risk":"read","public":true,"reviewed":true},
|
||||
|
||||
"+datasource-create": {"disposition":"schema_leaf","semantic_delta":"为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。","risk":"write","public":true,"reviewed":true},
|
||||
"+datasource-update": {"disposition":"schema_leaf","semantic_delta":"更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。","risk":"write","public":true,"reviewed":true},
|
||||
"+datasource-sync": {"disposition":"schema_leaf","semantic_delta":"对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。","risk":"write","public":true,"reviewed":true},
|
||||
"+datasource-sync-status": {"disposition":"schema_leaf","semantic_delta":"批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。","risk":"read","public":true,"reviewed":true},
|
||||
"+datasource-get-config": {"disposition":"schema_leaf","semantic_delta":"读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。","risk":"read","public":true,"reviewed":true},
|
||||
"+datasource-list-sources": {"disposition":"schema_leaf","semantic_delta":"列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。","risk":"read","public":true,"reviewed":true},
|
||||
"+datasource-get-fields": {"disposition":"schema_leaf","semantic_delta":"获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。","risk":"read","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "contact",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+list-followings": {"disposition":"semantic_adapter","semantic_delta":"严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。","risk":"read","availability":"available","public":true,"reviewed":true},
|
||||
"+search-user": {"disposition":"semantic_adapter","semantic_delta":"按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+search-mobile": {"disposition":"semantic_adapter","semantic_delta":"使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。","risk":"read","public":true,"reviewed":true},
|
||||
"+list-roles": {"disposition":"semantic_adapter","semantic_delta":"历史 CLI 继续执行严格 get_org_labels 适配:校验角色分组、每个角色元素、正整数 labelId、非空名称与唯一身份,并传播真实下游错误;当前 exact raw 仍含坏元素,整批拒绝,因此保持 Agent/public unavailable。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true},
|
||||
"+list-role-members": {"disposition":"semantic_adapter","semantic_delta":"按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+list-sub-depts": {"disposition":"semantic_adapter","semantic_delta":"按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+list-dept-members": {"disposition":"semantic_adapter","semantic_delta":"按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。","risk":"read","public":true,"reviewed":true},
|
||||
"+list-roster-fields": {"disposition":"semantic_adapter","semantic_delta":"exact raw 在当前授权/平台层被阻断,未获得可校验的业务字段集合;Agent/public 保持 unavailable,历史 CLI 继续透传原 MCP 调用与真实错误。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+get-roster": {"disposition":"semantic_adapter","semantic_delta":"exact raw 使用随机员工夹具仍在当前授权/平台层被阻断,无法安全获得业务详情合同;Agent/public 保持 unavailable,历史 CLI 继续透传原 MCP 调用与真实错误。","risk":"read","availability":"unavailable","public":false,"reviewed":true},
|
||||
"+by-mobile": {"disposition":"primary_smart","semantic_delta":"使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。","risk":"read","public":true,"reviewed":true},
|
||||
"+dept-members": {"disposition":"primary_smart","semantic_delta":"按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。","risk":"read","public":true,"reviewed":true},
|
||||
"+lookup": {"disposition":"primary_smart","semantic_delta":"按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。","risk":"read","public":true,"reviewed":true},
|
||||
"+org": {"disposition":"primary_smart","semantic_delta":"按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。","risk":"read","public":true,"reviewed":true},
|
||||
"+resolve-dept": {"disposition":"primary_smart","semantic_delta":"按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。","risk":"read","public":true,"reviewed":true},
|
||||
"+team": {"disposition":"primary_smart","semantic_delta":"按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。","risk":"read","public":true,"reviewed":true},
|
||||
"+me": {"disposition":"primary_smart","semantic_delta":"读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。","risk":"read","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "devdoc",
|
||||
"default_availability": "unavailable",
|
||||
"shortcuts": {
|
||||
"+search-docs": {
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "严格验证 success、result.items、文档 URL 身份和分页一致性;但 exact 与 raw 对运行时随机 zero-match 候选都返回语义近邻,无法满足 public list/search 的保证零命中证明,因此保持 unavailable。",
|
||||
"risk": "read",
|
||||
"availability": "unavailable",
|
||||
"public": false,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "hrbrain",
|
||||
"default_availability": "unavailable",
|
||||
"shortcuts": {
|
||||
"+list-pools": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;raw 明确 success=true/result=null,exact fail-closed,无法证明列表成功或合法空。","risk":"read","public":false,"reviewed":true},
|
||||
"+get-pool": {"disposition":"schema_leaf","semantic_delta":"classified=tenant_fixture;exact/raw 到达同一远端操作与错误,但缺少安全非空 pool fixture,不能验证同 ID 详情。","risk":"read","public":false,"reviewed":true},
|
||||
"+list-pool-employees": {"disposition":"semantic_adapter","semantic_delta":"classified=tenant_fixture;exact/raw 到达同一远端操作与错误,但缺少安全非空人才池与员工 fixture,分页合同未获 live 证明。","risk":"read","public":false,"reviewed":true},
|
||||
"+profile-metadata": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,档案元数据能力仍未解锁。","risk":"read","public":false,"reviewed":true},
|
||||
"+query-profile": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,档案查询业务链仍未解锁。","risk":"read","public":false,"reviewed":true},
|
||||
"+profile-labels": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;raw 明确 success=true/result=null,exact fail-closed,不能把未知结果投成空标签。","risk":"read","public":false,"reviewed":true},
|
||||
"+profile-career": {"disposition":"schema_leaf","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,职业历程读取仍未解锁。","risk":"read","public":false,"reviewed":true},
|
||||
"+profile-performance": {"disposition":"schema_leaf","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,且敏感绩效数据缺少受控 live 证明。","risk":"read","public":false,"reviewed":true},
|
||||
"+search-employees": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,无法完成已知非空与保证零命中证明。","risk":"read","public":false,"reviewed":true},
|
||||
"+search-employees-structured": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;exact/raw 的远端 server、operation、upstream code 一致,字段发现与结构化搜索链仍未解锁。","risk":"read","public":false,"reviewed":true},
|
||||
"+search-fields": {"disposition":"semantic_adapter","semantic_delta":"classified=adapter_business_service;无资源输入的 exact/raw 仍返回同一远端错误,字段发现能力未解锁。","risk":"read","public":false,"reviewed":true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "live",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+list-my-lives": {
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验 success、result 对象、total、hasFinish、liveDetailModelList、坏元素、稳定且不重复的直播 ID;live MCP 元数据没有筛选或分页参数,有界 exact raw 仍出现 total>0、hasFinish=true 与空数组冲突,保持不可用。",
|
||||
"risk": "read",
|
||||
"availability": "unavailable",
|
||||
"public": false,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "pat",
|
||||
"default_availability": "unavailable",
|
||||
"shortcuts": {
|
||||
"+browser-policy": {
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
|
||||
"risk": "write",
|
||||
"availability": "available",
|
||||
"public": true,
|
||||
"reviewed": true
|
||||
},
|
||||
"+authorize": {
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "classified=routed;pat chmod 已持有 plan、fallback、pending、逐 scope ledger、身份校验与确认链,+authorize 不复制执行语义;真实授权写仍因 blocked_fixture 未获证明。",
|
||||
"risk": "high-risk-write",
|
||||
"primary": "pat chmod",
|
||||
"public": false,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,23 +14,19 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"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/shortcut"
|
||||
)
|
||||
|
||||
// ByMobile: find a person by phone number and return their full profile in one
|
||||
// step.
|
||||
//
|
||||
// Steps: look the mobile up via search_user_by_mobile → resolve to a userId
|
||||
// (error clearly if nobody is bound to that number) → fetch full detail via
|
||||
// get_user_info_by_user_ids. Replaces `contact user search-mobile` (copy the
|
||||
// userId) → `contact user get --ids <id>`.
|
||||
// Steps: resolve the number through the dedicated exact-mobile interface →
|
||||
// fetch the same stable userId's full detail. The detail endpoint does not
|
||||
// expose mobile under every permission profile, so identity is bound to the
|
||||
// dedicated lookup result rather than inferred from keyword search.
|
||||
//
|
||||
// dws contact +by-mobile --mobile 13800138000
|
||||
var ByMobile = shortcut.Shortcut{
|
||||
@@ -67,7 +63,11 @@ var ByMobile = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "mobile", Type: shortcut.FlagString, Desc: "手机号", Required: true},
|
||||
{Name: "mobile", Type: shortcut.FlagString, Desc: "手机号;--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号", Required: true},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"mobile"}, Description: "--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号"}},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
return validateContactSmartMobile(rt, "contact/by-mobile", "mobile")
|
||||
},
|
||||
Tips: []string{`dws contact +by-mobile --mobile 13800138000`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
@@ -76,83 +76,16 @@ var ByMobile = shortcut.Shortcut{
|
||||
}
|
||||
mobile := rt.Str("mobile")
|
||||
|
||||
// Step 1 — look up the userId bound to this mobile number.
|
||||
// search_user_by_mobile takes {mobile} (see helpers.contact
|
||||
// search-mobile). The gateway shape is not guaranteed, so probe common
|
||||
// containers/field names defensively.
|
||||
data, err := rt.CallMCPData("contact", "search_user_by_mobile", map[string]any{
|
||||
"mobile": mobile,
|
||||
})
|
||||
// Step 1 — resolve the mobile through the dedicated exact interface.
|
||||
_, profile, err := strictResolveContactUserByMobile(rt, mobile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userID := byMobileExtractUserID(data)
|
||||
if userID == "" {
|
||||
return apperrors.NewValidation(
|
||||
fmt.Sprintf("没找到绑定手机号 %q 的用户;确认号码正确、且该人在当前组织通讯录中。", mobile))
|
||||
}
|
||||
|
||||
// Step 2 — fetch and print the full profile of the resolved user.
|
||||
// get_user_info_by_user_ids takes {user_id_list} (see helpers.contact
|
||||
// user get).
|
||||
return rt.CallMCP("get_user_info_by_user_ids", map[string]any{
|
||||
"user_id_list": []string{userID},
|
||||
})
|
||||
return rt.Output(map[string]any{"profile": profile})
|
||||
},
|
||||
}
|
||||
|
||||
// byMobileExtractUserID pulls a single userId out of a search_user_by_mobile
|
||||
// response. The exact shape is not contractually fixed, so it probes a few
|
||||
// likely containers (top-level, result, data, user) and field names
|
||||
// (userId/userid/user_id), tolerating both string and numeric encodings.
|
||||
func byMobileExtractUserID(data map[string]any) string {
|
||||
if data == nil {
|
||||
return ""
|
||||
}
|
||||
if id := byMobileUserIDFromMap(data); id != "" {
|
||||
return id
|
||||
}
|
||||
for _, key := range []string{"result", "data", "user", "userInfo"} {
|
||||
switch v := data[key].(type) {
|
||||
case map[string]any:
|
||||
if id := byMobileUserIDFromMap(v); id != "" {
|
||||
return id
|
||||
}
|
||||
case []any:
|
||||
for _, it := range v {
|
||||
if m, ok := it.(map[string]any); ok {
|
||||
if id := byMobileUserIDFromMap(m); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
case string:
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
case float64:
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func byMobileUserIDFromMap(m map[string]any) string {
|
||||
for _, key := range []string{"userId", "userid", "user_id", "userID", "id"} {
|
||||
switch v := m[key].(type) {
|
||||
case string:
|
||||
if v != "" && v != "0" {
|
||||
return v
|
||||
}
|
||||
case float64:
|
||||
if v != 0 {
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&ByMobile)
|
||||
shortcut.Register(ByMobile)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck"
|
||||
)
|
||||
|
||||
func contactSmartResult(item *shortcut.Shortcut) *contract.ResultSpec {
|
||||
result := &contract.ResultSpec{Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}}
|
||||
switch item.Command {
|
||||
case "+by-mobile", "+lookup":
|
||||
result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"profile":{"type":"object","description":"与请求稳定身份精确绑定的用户详情","properties":{"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"orgUserId":{"type":"string","minLength":1,"description":"稳定组织用户 ID"}},"additionalProperties":true}},"required":["profile"],"additionalProperties":false}`, item.Description))
|
||||
result.SensitivePaths = []string{"profile.userId", "profile.orgUserId", "profile.orgUserName", "profile.orgUserMobile", "profile.orgAuthEmail", "profile.orgEmail", "profile.depts.deptName"}
|
||||
case "+dept-members", "+team":
|
||||
result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"严格校验的直属成员数量"},"members":{"type":"array","description":"带稳定 userId 的直属成员","items":{"type":"object","description":"带稳定用户身份的直属成员","properties":{"userId":{"type":"string","minLength":1,"description":"稳定用户 ID"},"name":{"type":"string","description":"成员姓名"}},"required":["userId"],"additionalProperties":false}}},"required":["count","members"],"additionalProperties":false}`, item.Description))
|
||||
result.SensitivePaths = []string{"members.userId", "members.name"}
|
||||
case "+org":
|
||||
result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"department":{"type":"object","description":"与用户主部门 ID 精确绑定的部门详情","properties":{"deptId":{"type":"integer","minimum":1,"description":"稳定部门 ID"},"deptName":{"type":"string","description":"部门名称"}},"required":["deptId"],"additionalProperties":true}},"required":["department"],"additionalProperties":false}`, item.Description))
|
||||
result.SensitivePaths = []string{"department.deptName"}
|
||||
case "+resolve-dept":
|
||||
result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"resolved":{"type":"boolean","description":"是否唯一解析为一个部门"},"deptId":{"type":"string","minLength":1,"description":"唯一命中时的稳定部门 ID"},"name":{"type":"string","description":"唯一命中时的部门名称"},"count":{"type":"integer","minimum":0,"description":"多命中时的候选数量"},"candidates":{"type":"array","description":"多命中时供消歧的部门候选","items":{"type":"object","description":"带稳定部门身份的候选","properties":{"deptId":{"type":"string","minLength":1,"description":"稳定部门 ID"},"name":{"type":"string","description":"部门名称"}},"required":["deptId","name"],"additionalProperties":false}}},"required":["resolved"],"additionalProperties":false}`, item.Description))
|
||||
result.SensitivePaths = []string{"name", "candidates.name"}
|
||||
case "+me":
|
||||
result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"userId":{"type":"string","minLength":1,"description":"当前用户稳定 ID"},"name":{"type":"string","description":"当前用户姓名"},"mobile":{"type":"string","description":"当前用户手机号"},"email":{"type":"string","description":"当前用户邮箱"},"org":{"type":"string","description":"当前用户组织名称"},"dept":{"type":"string","description":"当前用户主部门名称"}},"required":["userId"],"additionalProperties":false}`, item.Description))
|
||||
result.SensitivePaths = []string{"userId", "name", "mobile", "email", "org", "dept"}
|
||||
default:
|
||||
result.DataSchema = json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"additionalProperties":true}`, item.Description))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func finalizeContactSmart(item *shortcut.Shortcut) {
|
||||
item.OutputRollout = output.RolloutUnifiedActive
|
||||
item.Contract.Result = contactSmartResult(item)
|
||||
operation := "contact/" + strings.TrimPrefix(item.Command, "+")
|
||||
requiredStrings := make([]string, 0, len(item.Flags))
|
||||
for index := range item.Flags {
|
||||
flag := &item.Flags[index]
|
||||
if flag.Required && flag.Type == shortcut.FlagString {
|
||||
requiredStrings = append(requiredStrings, flag.Name)
|
||||
fact := "--" + flag.Name + " 不能为空白"
|
||||
if !strings.Contains(flag.Desc, fact) {
|
||||
flag.Desc += ";" + fact
|
||||
}
|
||||
item.Constraints = append(item.Constraints, shortcut.Constraint{Kind: shortcut.ConstraintCustom, Flags: []string{flag.Name}, Description: fact})
|
||||
}
|
||||
}
|
||||
previousValidate := item.Validate
|
||||
if previousValidate != nil || len(requiredStrings) > 0 {
|
||||
item.Validate = func(rt *shortcut.RuntimeContext) error {
|
||||
if previousValidate != nil {
|
||||
if err := previousValidate(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return validateContactSmartNonBlank(rt, operation, requiredStrings...)
|
||||
}
|
||||
}
|
||||
switch item.Command {
|
||||
case "+by-mobile":
|
||||
item.Contract.Parameters = []contract.ParamDecl{{Name: "mobile", Property: "mobile"}}
|
||||
case "+dept-members":
|
||||
item.Contract.Parameters = []contract.ParamDecl{{Name: "dept", Property: "dept"}}
|
||||
case "+lookup", "+org", "+team":
|
||||
item.Contract.Parameters = []contract.ParamDecl{{Name: "name", Property: "name"}}
|
||||
case "+resolve-dept":
|
||||
item.Contract.Parameters = []contract.ParamDecl{{Name: "name", Property: "name"}}
|
||||
}
|
||||
}
|
||||
|
||||
func validateContactSmartNonBlank(rt *shortcut.RuntimeContext, operation string, flags ...string) error {
|
||||
for _, flag := range flags {
|
||||
if strings.TrimSpace(rt.Str(flag)) == "" {
|
||||
return responsecheck.Error(operation, "empty_parameter", "--"+flag+" 不能为空白")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateContactSmartMobile(rt *shortcut.RuntimeContext, operation, flag string) error {
|
||||
if _, err := normalizeContactSmartMobile(rt.Str(flag)); err != nil {
|
||||
return responsecheck.Error(operation, "invalid_mobile", "--"+flag+" 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func strictContactEnvelope(data map[string]any, operation string) (map[string]any, error) {
|
||||
envelope, err := responsecheck.RequireSuccess(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, key := range []string{"errorCode", "errorMsg", "errorMessage", "error"} {
|
||||
if strictContactFailure(envelope[key]) {
|
||||
return nil, responsecheck.Error(operation, "conflicting_failure_evidence", "success=true 响应同时携带失败字段 "+key)
|
||||
}
|
||||
}
|
||||
return envelope, nil
|
||||
}
|
||||
|
||||
func strictContactFailure(value any) bool {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return false
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(typed)
|
||||
return trimmed != "" && trimmed != "0" && !strings.EqualFold(trimmed, "success")
|
||||
case float64:
|
||||
return typed != 0
|
||||
case int:
|
||||
return typed != 0
|
||||
case bool:
|
||||
return typed
|
||||
case map[string]any:
|
||||
return len(typed) != 0
|
||||
case []any:
|
||||
return len(typed) != 0
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func strictResolveContactUser(rt *shortcut.RuntimeContext, name string) (contactUser, error) {
|
||||
data, err := rt.CallMCPData("contact", "search_contact_by_key_word", map[string]any{"keyword": name})
|
||||
if err != nil {
|
||||
return contactUser{}, err
|
||||
}
|
||||
if _, err := strictContactEnvelope(data, "contact/search_contact_by_key_word"); err != nil {
|
||||
return contactUser{}, err
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(data, "contact/search_contact_by_key_word", "result")
|
||||
if err != nil {
|
||||
return contactUser{}, err
|
||||
}
|
||||
users := make([]contactUser, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for index, item := range items {
|
||||
user := contactUser{
|
||||
userID: strictContactString(item, "userId"),
|
||||
openDingTalkID: strictContactString(item, "openDingTalkId"),
|
||||
name: strictContactString(item, "name"),
|
||||
}
|
||||
if user.userID == "" {
|
||||
return contactUser{}, responsecheck.Error("contact/search_contact_by_key_word", "missing_stable_identity", fmt.Sprintf("用户结果第 %d 项缺少 userId", index))
|
||||
}
|
||||
if seen[user.userID] {
|
||||
return contactUser{}, responsecheck.Error("contact/search_contact_by_key_word", "duplicate_stable_identity", fmt.Sprintf("用户结果第 %d 项重复 userId", index))
|
||||
}
|
||||
seen[user.userID] = true
|
||||
users = append(users, user)
|
||||
}
|
||||
switch len(users) {
|
||||
case 0:
|
||||
return contactUser{}, responsecheck.Error("contact/search_contact_by_key_word", "not_found", "未找到匹配的通讯录用户")
|
||||
case 1:
|
||||
return users[0], nil
|
||||
default:
|
||||
return contactUser{}, responsecheck.Error("contact/search_contact_by_key_word", "ambiguous_match", fmt.Sprintf("匹配到 %d 个用户;请提供更精确的姓名", len(users)))
|
||||
}
|
||||
}
|
||||
|
||||
func strictResolveContactUserByMobile(rt *shortcut.RuntimeContext, mobile string) (contactUser, map[string]any, error) {
|
||||
normalizedMobile, err := normalizeContactSmartMobile(mobile)
|
||||
if err != nil {
|
||||
return contactUser{}, nil, responsecheck.Error("contact/by-mobile", "invalid_mobile", "--mobile 必须是至少 6 位数字的手机号,可包含国家码、空格、连字符或括号")
|
||||
}
|
||||
lookup, err := rt.CallMCPData("contact", "search_user_by_mobile", map[string]any{"mobile": normalizedMobile})
|
||||
if err != nil {
|
||||
return contactUser{}, nil, err
|
||||
}
|
||||
user, found, err := strictContactMobileLookup(lookup, "contact/search_user_by_mobile")
|
||||
if err != nil {
|
||||
return contactUser{}, nil, err
|
||||
}
|
||||
if !found {
|
||||
return contactUser{}, nil, responsecheck.Error("contact/search_user_by_mobile", "not_found", "未找到绑定该手机号的通讯录用户")
|
||||
}
|
||||
data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{
|
||||
"user_id_list": []string{user.userID},
|
||||
})
|
||||
if err != nil {
|
||||
return contactUser{}, nil, err
|
||||
}
|
||||
profile, err := strictUserDetail(data, user.userID, "contact/get_user_info_by_user_ids")
|
||||
if err != nil {
|
||||
return contactUser{}, nil, err
|
||||
}
|
||||
return user, profile, nil
|
||||
}
|
||||
|
||||
// strictContactMobileLookup accepts the dedicated exact-mobile wire only. A
|
||||
// successful response with no result is the reviewed no-match encoding seen
|
||||
// from this interface; null and every malformed present result fail closed.
|
||||
func strictContactMobileLookup(data map[string]any, operation string) (contactUser, bool, error) {
|
||||
envelope, err := strictContactEnvelope(data, operation)
|
||||
if err != nil {
|
||||
return contactUser{}, false, err
|
||||
}
|
||||
raw, present := envelope["result"]
|
||||
if !present {
|
||||
return contactUser{}, false, nil
|
||||
}
|
||||
result, ok := raw.(map[string]any)
|
||||
if !ok || len(result) == 0 {
|
||||
return contactUser{}, false, responsecheck.Error(operation, "malformed_result", "手机号精确查询 result 必须是非空对象")
|
||||
}
|
||||
user := contactUser{
|
||||
userID: strictContactString(result, "userId"),
|
||||
openDingTalkID: strictContactString(result, "openDingTalkId"),
|
||||
name: strictContactString(result, "orgUserName"),
|
||||
}
|
||||
if user.userID == "" {
|
||||
return contactUser{}, false, responsecheck.Error(operation, "missing_stable_identity", "手机号精确查询结果缺少非空 userId")
|
||||
}
|
||||
for _, key := range []string{"orgUserName", "openDingTalkId"} {
|
||||
if _, _, valid := strictContactOptionalString(result, key); !valid {
|
||||
return contactUser{}, false, responsecheck.Error(operation, "malformed_optional_field", "手机号精确查询 "+key+" 必须是字符串")
|
||||
}
|
||||
}
|
||||
return user, true, nil
|
||||
}
|
||||
|
||||
func normalizeContactSmartMobile(value string) (string, error) {
|
||||
return normalizeContactSmartMobilePart(value, 6)
|
||||
}
|
||||
|
||||
func normalizeContactSmartMobilePart(value string, minimumDigits int) (string, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("empty mobile")
|
||||
}
|
||||
var digits strings.Builder
|
||||
for index, r := range trimmed {
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
digits.WriteRune(r)
|
||||
case r == '+' && index == 0:
|
||||
case r == ' ' || r == '-' || r == '(' || r == ')':
|
||||
default:
|
||||
return "", fmt.Errorf("invalid mobile character")
|
||||
}
|
||||
}
|
||||
normalized := digits.String()
|
||||
if strings.HasPrefix(normalized, "00") {
|
||||
normalized = strings.TrimPrefix(normalized, "00")
|
||||
}
|
||||
if len(normalized) < minimumDigits {
|
||||
return "", fmt.Errorf("mobile too short")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func strictUserDetail(data map[string]any, expectedUserID, operation string) (map[string]any, error) {
|
||||
if _, err := strictContactEnvelope(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(data, operation, "result")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) != 1 {
|
||||
return nil, responsecheck.Error(operation, "unexpected_detail_count", fmt.Sprintf("用户详情应唯一,实际返回 %d 项", len(items)))
|
||||
}
|
||||
model, ok := items[0]["orgEmployeeModel"].(map[string]any)
|
||||
if !ok || len(model) == 0 {
|
||||
return nil, responsecheck.Error(operation, "malformed_result", "用户详情缺少非空 orgEmployeeModel")
|
||||
}
|
||||
actual := strictContactString(model, "orgUserId")
|
||||
if actual == "" {
|
||||
actual = strictContactString(model, "userId")
|
||||
}
|
||||
if actual == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_stable_identity", "用户详情缺少 userId/orgUserId")
|
||||
}
|
||||
if expectedUserID != "" && actual != expectedUserID {
|
||||
return nil, responsecheck.Error(operation, "identity_mismatch", "用户详情稳定身份与请求不一致")
|
||||
}
|
||||
return model, nil
|
||||
}
|
||||
|
||||
func strictDeptCandidates(data map[string]any, operation string) ([]deptMembersDept, error) {
|
||||
if _, err := strictContactEnvelope(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(data, operation, "deptList")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]deptMembersDept, 0, len(items))
|
||||
seen := map[int64]bool{}
|
||||
for index, item := range items {
|
||||
id, ok := strictContactInt64(item["deptId"])
|
||||
if !ok || id <= 0 {
|
||||
return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("部门结果第 %d 项缺少有效 deptId", index))
|
||||
}
|
||||
if seen[id] {
|
||||
return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("部门结果包含重复 deptId %d", id))
|
||||
}
|
||||
seen[id] = true
|
||||
name := stripHighlightTags(strictContactString(item, "deptName"))
|
||||
if name == "" {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("部门结果第 %d 项缺少 deptName", index))
|
||||
}
|
||||
out = append(out, deptMembersDept{id: id, name: name})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func strictContactMembers(data map[string]any, operation string) ([]map[string]any, error) {
|
||||
if _, err := strictContactEnvelope(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := responsecheck.RequireObjectCollection(data, operation, "deptUserList")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
seen := make(map[string]bool, len(items))
|
||||
for index, item := range items {
|
||||
user, ok := item["userInfo"].(map[string]any)
|
||||
if !ok || len(user) == 0 {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("deptUserList[%d].userInfo 应为非空对象", index))
|
||||
}
|
||||
id := strictContactString(user, "userId")
|
||||
if id == "" {
|
||||
return nil, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("部门成员第 %d 项缺少 userId", index))
|
||||
}
|
||||
if seen[id] {
|
||||
return nil, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("部门成员第 %d 项重复 userId", index))
|
||||
}
|
||||
seen[id] = true
|
||||
row := map[string]any{"userId": id}
|
||||
name, _, valid := strictContactOptionalString(user, "name")
|
||||
if !valid {
|
||||
return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("部门成员第 %d 项的 name 必须是字符串", index))
|
||||
}
|
||||
if name != "" {
|
||||
row["name"] = name
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func strictDeptDetail(data map[string]any, expectedID int64, operation string) (map[string]any, error) {
|
||||
if _, err := strictContactEnvelope(data, operation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail, err := responsecheck.RequireObjectResult(data, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
id, ok := strictContactInt64(detail["deptId"])
|
||||
if !ok || id <= 0 {
|
||||
return nil, responsecheck.Error(operation, "missing_stable_identity", "部门详情缺少有效 deptId")
|
||||
}
|
||||
if expectedID > 0 && id != expectedID {
|
||||
return nil, responsecheck.Error(operation, "identity_mismatch", "部门详情稳定身份与请求不一致")
|
||||
}
|
||||
if _, _, valid := strictContactOptionalString(detail, "deptName"); !valid {
|
||||
return nil, responsecheck.Error(operation, "malformed_result", "部门详情 deptName 必须是字符串")
|
||||
}
|
||||
detail["deptId"] = id
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func strictPrimaryDeptID(model map[string]any, operation string) (int64, error) {
|
||||
raw, ok := model["depts"].([]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return 0, responsecheck.Error(operation, "missing_collection", "用户详情缺少非空 depts 数组")
|
||||
}
|
||||
seen := make(map[int64]bool, len(raw))
|
||||
var primary int64
|
||||
for index, item := range raw {
|
||||
dept, ok := item.(map[string]any)
|
||||
if !ok || len(dept) == 0 {
|
||||
return 0, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("depts[%d] 不是非空对象", index))
|
||||
}
|
||||
id, ok := strictContactInt64(dept["deptId"])
|
||||
if !ok || id <= 0 {
|
||||
return 0, responsecheck.Error(operation, "missing_stable_identity", fmt.Sprintf("depts[%d] 缺少有效 deptId", index))
|
||||
}
|
||||
if seen[id] {
|
||||
return 0, responsecheck.Error(operation, "duplicate_stable_identity", fmt.Sprintf("depts[%d] 重复 deptId", index))
|
||||
}
|
||||
seen[id] = true
|
||||
if index == 0 {
|
||||
primary = id
|
||||
}
|
||||
}
|
||||
return primary, nil
|
||||
}
|
||||
|
||||
func strictWhoami(data map[string]any) (map[string]any, error) {
|
||||
model, err := strictUserDetail(data, "", "contact/get_current_user_profile")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]any{"userId": strictContactFirst(model, "userId", "orgUserId")}
|
||||
for outputKey, candidates := range map[string][]string{
|
||||
"name": {"orgUserName"}, "mobile": {"orgUserMobile"}, "email": {"orgAuthEmail", "orgEmail"}, "org": {"orgName"},
|
||||
} {
|
||||
if value := strictContactFirst(model, candidates...); value != "" {
|
||||
out[outputKey] = value
|
||||
}
|
||||
}
|
||||
if depts, ok := model["depts"].([]any); ok && len(depts) > 0 {
|
||||
if dept, ok := depts[0].(map[string]any); ok {
|
||||
if name := strictContactString(dept, "deptName"); name != "" {
|
||||
out["dept"] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func strictContactFirst(object map[string]any, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strictContactString(object, key); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func strictContactString(object map[string]any, key string) string {
|
||||
value, _ := object[key].(string)
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func strictContactOptionalString(object map[string]any, key string) (string, bool, bool) {
|
||||
raw, present := object[key]
|
||||
if !present || raw == nil {
|
||||
return "", present, true
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
if !ok {
|
||||
return "", true, false
|
||||
}
|
||||
return strings.TrimSpace(value), true, true
|
||||
}
|
||||
|
||||
func strictContactInt64(value any) (int64, bool) {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
if typed != float64(int64(typed)) {
|
||||
return 0, false
|
||||
}
|
||||
return int64(typed), true
|
||||
case int64:
|
||||
return typed, true
|
||||
case int:
|
||||
return int64(typed), true
|
||||
case json.Number:
|
||||
parsed, err := typed.Int64()
|
||||
return parsed, err == nil
|
||||
case string:
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
|
||||
return parsed, err == nil
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,587 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package smart
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type contactSmartStrictCall struct {
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type contactSmartStrictCaller struct {
|
||||
calls []contactSmartStrictCall
|
||||
searchZero bool
|
||||
payloads map[string]string
|
||||
errors map[string]error
|
||||
}
|
||||
|
||||
func (caller *contactSmartStrictCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
caller.calls = append(caller.calls, contactSmartStrictCall{tool: tool, args: args})
|
||||
if err := caller.errors[tool]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload, ok := caller.payloads[tool]; ok {
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: payload}}}, nil
|
||||
}
|
||||
payload := `{"success":true,"result":[]}`
|
||||
switch tool {
|
||||
case "search_user_by_mobile":
|
||||
if caller.searchZero {
|
||||
payload = `{"success":true}`
|
||||
} else {
|
||||
payload = `{"success":true,"result":{"userId":"stable-user","orgUserName":"Fixture"}}`
|
||||
}
|
||||
case "search_contact_by_key_word":
|
||||
payload = `{"success":true,"result":[{"userId":"stable-user","openDingTalkId":"stable-open"}]}`
|
||||
case "get_user_info_by_user_ids":
|
||||
payload = `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"stable-user"}}]}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: payload}}}, nil
|
||||
}
|
||||
|
||||
func (*contactSmartStrictCaller) Format() string { return "json" }
|
||||
func (*contactSmartStrictCaller) DryRun() bool { return false }
|
||||
func (*contactSmartStrictCaller) Fields() string { return "" }
|
||||
func (*contactSmartStrictCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageContactSmartStrictDecoders(t *testing.T) {
|
||||
profile, err := strictUserDetail(map[string]any{
|
||||
"success": true,
|
||||
"result": []any{map[string]any{"orgEmployeeModel": map[string]any{"orgUserId": "stable-user"}}},
|
||||
}, "stable-user", "contact/detail")
|
||||
if err != nil || profile["orgUserId"] != "stable-user" {
|
||||
t.Fatalf("valid detail rejected: profile=%v err=%v", profile, err)
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": true, "result": []any{}},
|
||||
{"success": true, "result": []any{map[string]any{}}},
|
||||
{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"orgUserId": "other"}}}},
|
||||
{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"orgUserId": "stable-user"}}, map[string]any{"orgEmployeeModel": map[string]any{"orgUserId": "stable-user"}}}},
|
||||
} {
|
||||
if got, decodeErr := strictUserDetail(data, "stable-user", "contact/detail"); decodeErr == nil {
|
||||
t.Errorf("broken detail returned success: %v", got)
|
||||
}
|
||||
}
|
||||
if _, err := strictContactMembers(map[string]any{
|
||||
"success": true,
|
||||
"deptUserList": []any{
|
||||
map[string]any{"userInfo": map[string]any{"userId": "same"}},
|
||||
map[string]any{"userInfo": map[string]any{"userId": "same"}},
|
||||
},
|
||||
}, "contact/members"); err == nil {
|
||||
t.Fatal("duplicate smart member identity returned success")
|
||||
}
|
||||
if _, err := strictContactMembers(map[string]any{
|
||||
"success": true,
|
||||
"deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "stable", "name": true}}},
|
||||
}, "contact/members"); err == nil {
|
||||
t.Fatal("malformed optional smart member name returned success")
|
||||
}
|
||||
department, err := strictDeptDetail(map[string]any{
|
||||
"success": true, "result": map[string]any{"deptId": "7", "deptName": "Fixture"},
|
||||
}, 7, "contact/dept")
|
||||
if err != nil || department["deptId"] != int64(7) {
|
||||
t.Fatalf("department ID was not normalized: department=%v err=%v", department, err)
|
||||
}
|
||||
if _, err := strictPrimaryDeptID(map[string]any{"depts": []any{
|
||||
map[string]any{"deptId": float64(1)}, map[string]any{"deptId": float64(1)},
|
||||
}}, "contact/detail"); err == nil {
|
||||
t.Fatal("duplicate profile department identity returned success")
|
||||
}
|
||||
if _, err := strictPrimaryDeptID(map[string]any{"depts": []any{
|
||||
map[string]any{"deptId": float64(1)}, map[string]any{"deptName": "missing-id"},
|
||||
}}, "contact/detail"); err == nil {
|
||||
t.Fatal("malformed later profile department was silently ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactSmartContracts(t *testing.T) {
|
||||
items := []*shortcut.Shortcut{&ByMobile, &DeptMembers, &Lookup, &Org, &ResolveDept, &Team, &Whoami}
|
||||
for _, item := range items {
|
||||
if item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Result == nil || len(item.Contract.Result.SensitivePaths) == 0 || strings.TrimSpace(item.Safety.Effect) == "" {
|
||||
t.Errorf("%s lacks Contract/Result/Safety/unified output", item.Command)
|
||||
continue
|
||||
}
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(item.Contract.Result.DataSchema, &schema); err != nil || schema["type"] != "object" {
|
||||
t.Errorf("%s invalid Result schema: schema=%v err=%v", item.Command, schema, err)
|
||||
}
|
||||
}
|
||||
for item, want := range map[*shortcut.Shortcut]map[string]string{
|
||||
&ByMobile: {"mobile": "mobile"},
|
||||
&DeptMembers: {"dept": "dept"},
|
||||
&Lookup: {"name": "name"},
|
||||
&Org: {"name": "name"},
|
||||
&ResolveDept: {"name": "name"},
|
||||
&Team: {"name": "name"},
|
||||
} {
|
||||
for _, parameter := range item.Contract.Parameters {
|
||||
if property, ok := want[parameter.Name]; ok {
|
||||
if parameter.Property != property {
|
||||
t.Errorf("%s parameter %s property=%q want=%q", item.Command, parameter.Name, parameter.Property, property)
|
||||
}
|
||||
delete(want, parameter.Name)
|
||||
}
|
||||
}
|
||||
if len(want) != 0 {
|
||||
t.Errorf("%s missing compatibility parameters: %#v", item.Command, want)
|
||||
}
|
||||
}
|
||||
if Whoami.Validate != nil {
|
||||
t.Fatal("parameterless +me must not publish an empty runtime validator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactSmartBlankInputsFailBeforeRemoteCall(t *testing.T) {
|
||||
caller := &contactSmartStrictCaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
tests := []struct {
|
||||
declaration shortcut.Shortcut
|
||||
flag string
|
||||
}{
|
||||
{ByMobile, "mobile"},
|
||||
{DeptMembers, "dept"},
|
||||
{Lookup, "name"},
|
||||
{Org, "name"},
|
||||
{ResolveDept, "name"},
|
||||
{Team, "name"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
command := &cobra.Command{Use: test.declaration.Command}
|
||||
command.Flags().String(test.flag, "", "")
|
||||
if err := command.Flags().Set(test.flag, " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := test.declaration.Validate(shortcut.RuntimeContextForTest(command, test.declaration)); err == nil {
|
||||
t.Errorf("%s accepted blank --%s", test.declaration.Command, test.flag)
|
||||
}
|
||||
}
|
||||
invalidMobile := &cobra.Command{Use: "+by-mobile"}
|
||||
invalidMobile.Flags().String("mobile", "", "")
|
||||
if err := invalidMobile.Flags().Set("mobile", "not-a-phone"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ByMobile.Validate(shortcut.RuntimeContextForTest(invalidMobile, ByMobile)); err == nil {
|
||||
t.Fatal("+by-mobile accepted a non-phone value")
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("blank smart inputs made remote calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageByMobileUsesDedicatedExactLookupBeforeDetail(t *testing.T) {
|
||||
caller := &contactSmartStrictCaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
command := &cobra.Command{Use: "+by-mobile"}
|
||||
command.Flags().String("mobile", "", "")
|
||||
if err := command.Flags().Set("mobile", "+86 138-0013-8000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declaration := ByMobile
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err != nil {
|
||||
t.Fatalf("by-mobile known execution: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 2 || caller.calls[0].tool != "search_user_by_mobile" || caller.calls[0].args["mobile"] != "8613800138000" || caller.calls[1].tool != "get_user_info_by_user_ids" {
|
||||
t.Fatalf("by-mobile calls = %#v", caller.calls)
|
||||
}
|
||||
|
||||
caller.calls = nil
|
||||
caller.searchZero = true
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err == nil {
|
||||
t.Fatal("explicit zero unexpectedly became a successful detail")
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "search_user_by_mobile" {
|
||||
t.Fatalf("zero-match calls = %#v", caller.calls)
|
||||
}
|
||||
|
||||
caller.searchZero = false
|
||||
caller.calls = nil
|
||||
caller.payloads = map[string]string{"search_user_by_mobile": `{"success":true,"result":null}`}
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err == nil {
|
||||
t.Fatal("malformed exact-mobile result returned success")
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "search_user_by_mobile" {
|
||||
t.Fatalf("malformed lookup calls = %#v", caller.calls)
|
||||
}
|
||||
for name, detail := range map[string]string{
|
||||
"missing model": `{"success":true,"result":[{}]}`,
|
||||
"wrong identity": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"other"}}]}`,
|
||||
"missing detail": `{"success":true,"result":[]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller.calls = nil
|
||||
caller.payloads = map[string]string{
|
||||
"search_user_by_mobile": `{"success":true,"result":{"userId":"stable-user"}}`,
|
||||
"get_user_info_by_user_ids": detail,
|
||||
}
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err == nil {
|
||||
t.Fatal("unverified mobile profile returned success")
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("verification calls = %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactSmartMobileProofHelperMatrix(t *testing.T) {
|
||||
invalidCaller := &contactSmartStrictCaller{}
|
||||
helpers.InitDepsForTest(t, invalidCaller)
|
||||
if _, _, err := strictResolveContactUserByMobile(shortcut.RuntimeContextForTest(&cobra.Command{Use: "+by-mobile"}, ByMobile), "not-a-phone"); err == nil {
|
||||
t.Fatal("invalid mobile resolver returned success")
|
||||
}
|
||||
if len(invalidCaller.calls) != 0 {
|
||||
t.Fatalf("invalid mobile resolver made calls: %#v", invalidCaller.calls)
|
||||
}
|
||||
command := &cobra.Command{Use: "+by-mobile"}
|
||||
command.Flags().String("mobile", "", "")
|
||||
if err := command.Flags().Set("mobile", "+86 138-0013-8000"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ByMobile.Validate(shortcut.RuntimeContextForTest(command, ByMobile)); err != nil {
|
||||
t.Fatalf("valid formatted mobile rejected: %v", err)
|
||||
}
|
||||
for input, want := range map[string]string{
|
||||
"+86 138-0013-8000": "8613800138000",
|
||||
"008613800138000": "8613800138000",
|
||||
"(138) 0013-8000": "13800138000",
|
||||
} {
|
||||
if got, err := normalizeContactSmartMobile(input); err != nil || got != want {
|
||||
t.Errorf("normalize %q = %q, %v; want %q", input, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, input := range []string{"", "123", "1+3800138000", "13800x138000"} {
|
||||
if got, err := normalizeContactSmartMobile(input); err == nil {
|
||||
t.Errorf("invalid mobile %q normalized to %q", input, got)
|
||||
}
|
||||
}
|
||||
user, found, err := strictContactMobileLookup(map[string]any{
|
||||
"success": true, "result": map[string]any{"userId": "stable-user", "orgUserName": "Fixture", "openDingTalkId": "open"},
|
||||
}, "contact/mobile")
|
||||
if err != nil || !found || user.userID != "stable-user" || user.name != "Fixture" || user.openDingTalkID != "open" {
|
||||
t.Fatalf("mobile lookup=(%#v,%v,%v)", user, found, err)
|
||||
}
|
||||
if user, found, err := strictContactMobileLookup(map[string]any{"success": true}, "contact/mobile"); err != nil || found || user.userID != "" {
|
||||
t.Fatalf("reviewed no-match=(%#v,%v,%v)", user, found, err)
|
||||
}
|
||||
for _, broken := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true, "result": nil},
|
||||
{"success": true, "result": []any{}},
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"orgUserName": "missing-id"}},
|
||||
{"success": true, "result": map[string]any{"userId": "stable-user", "orgUserName": 7}},
|
||||
{"success": true, "result": map[string]any{"userId": "stable-user", "openDingTalkId": true}},
|
||||
} {
|
||||
if user, found, err := strictContactMobileLookup(broken, "contact/mobile"); err == nil {
|
||||
t.Errorf("broken lookup succeeded: user=%#v found=%v data=%#v", user, found, broken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactSmartPrimitiveMatrices(t *testing.T) {
|
||||
for index, test := range []struct {
|
||||
value any
|
||||
want bool
|
||||
}{
|
||||
{nil, false}, {"", false}, {"0", false}, {"SUCCESS", false}, {"bad", true},
|
||||
{float64(0), false}, {float64(1), true}, {0, false}, {1, true}, {false, false}, {true, true},
|
||||
{map[string]any{}, false}, {map[string]any{"x": 1}, true}, {[]any{}, false}, {[]any{1}, true}, {struct{}{}, true},
|
||||
} {
|
||||
if got := strictContactFailure(test.value); got != test.want {
|
||||
t.Errorf("failure matrix %d = %v, want %v", index, got, test.want)
|
||||
}
|
||||
}
|
||||
for index, test := range []struct {
|
||||
value any
|
||||
want int64
|
||||
ok bool
|
||||
}{
|
||||
{float64(7), 7, true}, {float64(7.5), 0, false}, {int64(8), 8, true}, {9, 9, true},
|
||||
{json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {" 11 ", 11, true}, {"bad", 0, false}, {true, 0, false},
|
||||
} {
|
||||
got, ok := strictContactInt64(test.value)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Errorf("integer matrix %d = (%d,%v), want (%d,%v)", index, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
if value, present, valid := strictContactOptionalString(map[string]any{"x": nil}, "x"); value != "" || !present || !valid {
|
||||
t.Fatalf("nil optional string = (%q,%v,%v)", value, present, valid)
|
||||
}
|
||||
if value, present, valid := strictContactOptionalString(map[string]any{"x": true}, "x"); value != "" || !present || valid {
|
||||
t.Fatalf("bad optional string = (%q,%v,%v)", value, present, valid)
|
||||
}
|
||||
if got := strictContactFirst(map[string]any{"a": " ", "b": " value "}, "a", "b"); got != "value" {
|
||||
t.Fatalf("first string = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactSmartStrictHelperBranches(t *testing.T) {
|
||||
dummy := shortcut.Shortcut{Command: "+dummy", Description: "Dummy", Flags: []shortcut.Flag{{Name: "name", Type: shortcut.FlagString, Required: true}}, Validate: func(*shortcut.RuntimeContext) error { return errors.New("prior") }}
|
||||
if result := contactSmartResult(&dummy); result == nil || len(result.DataSchema) == 0 {
|
||||
t.Fatal("default smart result missing")
|
||||
}
|
||||
finalizeContactSmart(&dummy)
|
||||
dummyCommand := &cobra.Command{Use: "+dummy"}
|
||||
dummyCommand.Flags().String("name", "value", "")
|
||||
if err := dummy.Validate(shortcut.RuntimeContextForTest(dummyCommand, dummy)); err == nil {
|
||||
t.Fatal("previous validator failure was discarded")
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{}, {"success": false}, {"success": true, "errorCode": "FAILED"},
|
||||
} {
|
||||
if _, err := strictContactEnvelope(data, "contact/test"); err == nil {
|
||||
t.Errorf("bad envelope returned success: %#v", data)
|
||||
}
|
||||
}
|
||||
caller := &contactSmartStrictCaller{payloads: map[string]string{}, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
resolverCommand := &cobra.Command{Use: "+lookup"}
|
||||
resolverCommand.Flags().String("name", "fixture", "")
|
||||
resolverRuntime := shortcut.RuntimeContextForTest(resolverCommand, Lookup)
|
||||
for _, payload := range []string{
|
||||
`{"success":true}`,
|
||||
`{"success":true,"result":[]}`,
|
||||
`{"success":true,"result":[{"name":"missing-id"}]}`,
|
||||
`{"success":true,"result":[{"userId":"same"},{"userId":"same"}]}`,
|
||||
`{"success":true,"result":[{"userId":"one"},{"userId":"two"}]}`,
|
||||
} {
|
||||
caller.payloads["search_contact_by_key_word"] = payload
|
||||
if _, err := strictResolveContactUser(resolverRuntime, "fixture"); err == nil {
|
||||
t.Errorf("bad resolver payload returned success: %s", payload)
|
||||
}
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": true},
|
||||
{"success": true, "deptList": []any{}},
|
||||
{"success": true, "deptList": []any{map[string]any{"deptName": "missing-id"}}},
|
||||
{"success": true, "deptList": []any{map[string]any{"deptId": 1, "deptName": ""}}},
|
||||
{"success": true, "deptList": []any{map[string]any{"deptId": 1, "deptName": "One"}, map[string]any{"deptId": 1, "deptName": "Two"}}},
|
||||
} {
|
||||
got, err := strictDeptCandidates(data, "contact/dept")
|
||||
if list, ok := data["deptList"].([]any); ok && len(list) == 0 {
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Errorf("explicit empty departments rejected: got=%#v err=%v", got, err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Errorf("bad department candidates returned success: %#v", data)
|
||||
}
|
||||
}
|
||||
candidates, err := strictDeptCandidates(map[string]any{"success": true, "deptList": []any{map[string]any{"deptId": "7", "deptName": "<red>Fixture</red>"}}}, "contact/dept")
|
||||
if err != nil || len(candidates) != 1 || candidates[0].id != 7 || candidates[0].name != "Fixture" {
|
||||
t.Fatalf("valid candidates = %#v err=%v", candidates, err)
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true},
|
||||
{"success": true, "deptUserList": []any{map[string]any{"x": 1}}},
|
||||
{"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"name": "no-id"}}}},
|
||||
} {
|
||||
if _, err := strictContactMembers(data, "contact/members"); err == nil {
|
||||
t.Errorf("bad members returned success: %#v", data)
|
||||
}
|
||||
}
|
||||
members, err := strictContactMembers(map[string]any{"success": true, "deptUserList": []any{map[string]any{"userInfo": map[string]any{"userId": "u1", "name": "Fixture"}}}}, "contact/members")
|
||||
if err != nil || len(members) != 1 {
|
||||
t.Fatalf("valid members=%#v err=%v", members, err)
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": false},
|
||||
{"success": true, "result": []any{}},
|
||||
{"success": true, "result": map[string]any{"deptId": 0}},
|
||||
{"success": true, "result": map[string]any{"deptId": 8}},
|
||||
{"success": true, "result": map[string]any{"deptId": 7, "deptName": true}},
|
||||
} {
|
||||
if _, err := strictDeptDetail(data, 7, "contact/dept"); err == nil {
|
||||
t.Errorf("bad detail returned success: %#v", data)
|
||||
}
|
||||
}
|
||||
for _, data := range []map[string]any{
|
||||
{"success": true, "result": []any{map[string]any{"x": 1}}},
|
||||
{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"name": "no-id"}}}},
|
||||
} {
|
||||
if _, err := strictUserDetail(data, "u1", "contact/detail"); err == nil {
|
||||
t.Errorf("bad user detail returned success: %#v", data)
|
||||
}
|
||||
}
|
||||
for _, model := range []map[string]any{
|
||||
{}, {"depts": []any{}}, {"depts": []any{"bad"}}, {"depts": []any{map[string]any{}}},
|
||||
} {
|
||||
if _, err := strictPrimaryDeptID(model, "contact/detail"); err == nil {
|
||||
t.Errorf("bad primary department returned success: %#v", model)
|
||||
}
|
||||
}
|
||||
if id, err := strictPrimaryDeptID(map[string]any{"depts": []any{map[string]any{"deptId": 7}, map[string]any{"deptId": 8}}}, "contact/detail"); err != nil || id != 7 {
|
||||
t.Fatalf("primary dept=(%d,%v)", id, err)
|
||||
}
|
||||
who, err := strictWhoami(map[string]any{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{
|
||||
"userId": "u1", "orgUserName": "Fixture", "orgUserMobile": "m", "orgAuthEmail": "e", "orgName": "o", "depts": []any{map[string]any{"deptName": "d"}},
|
||||
}}}})
|
||||
if err != nil || who["userId"] != "u1" || who["dept"] != "d" {
|
||||
t.Fatalf("whoami=%#v err=%v", who, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactSmartExecutors(t *testing.T) {
|
||||
basePayloads := map[string]string{
|
||||
"search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1","openDingTalkId":"o1"}]}`,
|
||||
"search_user_by_mobile": `{"success":true,"result":{"userId":"u1","orgUserName":"Fixture"}}`,
|
||||
"get_user_info_by_user_ids": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"u1","depts":[{"deptId":7}]}}]}`,
|
||||
"search_dept_by_keyword": `{"success":true,"deptList":[{"deptId":7,"deptName":"Fixture"}]}`,
|
||||
"get_dept_members_by_deptId": `{"success":true,"deptUserList":[{"userInfo":{"userId":"u1","name":"Fixture"}}]}`,
|
||||
"get_dept_info_by_dept_id": `{"success":true,"result":{"deptId":7,"deptName":"Fixture"}}`,
|
||||
"get_current_user_profile": `{"success":true,"result":[{"orgEmployeeModel":{"userId":"u1","orgUserName":"Fixture"}}]}`,
|
||||
}
|
||||
tests := []struct {
|
||||
declaration shortcut.Shortcut
|
||||
flag string
|
||||
value string
|
||||
}{
|
||||
{ByMobile, "mobile", "13800138000"}, {DeptMembers, "dept", "fixture"}, {Lookup, "name", "fixture"},
|
||||
{Org, "name", "fixture"}, {ResolveDept, "name", "fixture"}, {Team, "name", "fixture"}, {Whoami, "", ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.declaration.Command, func(t *testing.T) {
|
||||
caller := &contactSmartStrictCaller{payloads: basePayloads, errors: map[string]error{}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
declaration := test.declaration
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
command := &cobra.Command{Use: declaration.Command}
|
||||
if test.flag != "" {
|
||||
command.Flags().String(test.flag, "", "")
|
||||
if err := command.Flags().Set(test.flag, test.value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rt := shortcut.RuntimeContextForTest(command, declaration)
|
||||
if err := declaration.Execute(rt); err != nil {
|
||||
t.Fatalf("success execution: %v; calls=%#v", err, caller.calls)
|
||||
}
|
||||
caller.calls = nil
|
||||
firstTool := map[string]string{
|
||||
"+by-mobile": "search_user_by_mobile", "+dept-members": "search_dept_by_keyword", "+resolve-dept": "search_dept_by_keyword", "+me": "get_current_user_profile",
|
||||
}[declaration.Command]
|
||||
if firstTool == "" {
|
||||
firstTool = "search_contact_by_key_word"
|
||||
}
|
||||
caller.errors[firstTool] = errors.New("transport")
|
||||
if err := declaration.Execute(rt); err == nil {
|
||||
t.Fatal("first transport error returned success")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, declaration := range []shortcut.Shortcut{ByMobile, Lookup, Org, Team} {
|
||||
caller := &contactSmartStrictCaller{payloads: basePayloads, errors: map[string]error{"get_user_info_by_user_ids": errors.New("transport")}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
copy := declaration
|
||||
copy.OutputRollout = output.RolloutLegacyOnly
|
||||
command := &cobra.Command{Use: copy.Command}
|
||||
flag := "name"
|
||||
if copy.Command == "+by-mobile" {
|
||||
flag = "mobile"
|
||||
}
|
||||
command.Flags().String(flag, "", "")
|
||||
value := "fixture"
|
||||
if copy.Command == "+by-mobile" {
|
||||
value = "13800138000"
|
||||
}
|
||||
if err := command.Flags().Set(flag, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copy.Execute(shortcut.RuntimeContextForTest(command, copy)); err == nil {
|
||||
t.Errorf("%s second transport error returned success", copy.Command)
|
||||
}
|
||||
}
|
||||
for _, declaration := range []shortcut.Shortcut{DeptMembers, Team} {
|
||||
caller := &contactSmartStrictCaller{payloads: basePayloads, errors: map[string]error{"get_dept_members_by_deptId": errors.New("transport")}}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
copy := declaration
|
||||
copy.OutputRollout = output.RolloutLegacyOnly
|
||||
command := &cobra.Command{Use: copy.Command}
|
||||
flag := "name"
|
||||
if copy.Command == "+dept-members" {
|
||||
flag = "dept"
|
||||
}
|
||||
command.Flags().String(flag, "", "")
|
||||
if err := command.Flags().Set(flag, "fixture"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copy.Execute(shortcut.RuntimeContextForTest(command, copy)); err == nil {
|
||||
t.Errorf("%s final transport error returned success", copy.Command)
|
||||
}
|
||||
}
|
||||
|
||||
runFailure := func(t *testing.T, declaration shortcut.Shortcut, flag string, payloads map[string]string, errorsByTool map[string]error) {
|
||||
t.Helper()
|
||||
caller := &contactSmartStrictCaller{payloads: payloads, errors: errorsByTool}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
declaration.OutputRollout = output.RolloutLegacyOnly
|
||||
command := &cobra.Command{Use: declaration.Command}
|
||||
if flag != "" {
|
||||
command.Flags().String(flag, "", "")
|
||||
value := "fixture"
|
||||
if declaration.Command == "+by-mobile" {
|
||||
value = "13800138000"
|
||||
}
|
||||
if err := command.Flags().Set(flag, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := declaration.Execute(shortcut.RuntimeContextForTest(command, declaration)); err == nil {
|
||||
t.Fatalf("%s malformed downstream response returned success", declaration.Command)
|
||||
}
|
||||
}
|
||||
badProfile := map[string]string{
|
||||
"search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`,
|
||||
"search_user_by_mobile": `{"success":true,"result":{"userId":"u1"}}`,
|
||||
"get_user_info_by_user_ids": `{"success":true,"result":[{"x":1}]}`,
|
||||
}
|
||||
runFailure(t, ByMobile, "mobile", badProfile, map[string]error{})
|
||||
runFailure(t, Lookup, "name", badProfile, map[string]error{})
|
||||
badMembers := map[string]string{
|
||||
"search_dept_by_keyword": `{"success":true,"deptList":[{"deptId":7,"deptName":"Fixture"}]}`,
|
||||
"get_dept_members_by_deptId": `{"success":true}`,
|
||||
}
|
||||
runFailure(t, DeptMembers, "dept", badMembers, map[string]error{})
|
||||
badOrgProfile := map[string]string{
|
||||
"search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`,
|
||||
"get_user_info_by_user_ids": `{"success":true,"result":[{"x":1}]}`,
|
||||
}
|
||||
runFailure(t, Org, "name", badOrgProfile, map[string]error{})
|
||||
runFailure(t, Team, "name", badOrgProfile, map[string]error{})
|
||||
missingDept := map[string]string{
|
||||
"search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`,
|
||||
"get_user_info_by_user_ids": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"u1"}}]}`,
|
||||
}
|
||||
runFailure(t, Org, "name", missingDept, map[string]error{})
|
||||
runFailure(t, Team, "name", missingDept, map[string]error{})
|
||||
validOrg := map[string]string{
|
||||
"search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`,
|
||||
"get_user_info_by_user_ids": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"u1","depts":[{"deptId":7}]}}]}`,
|
||||
"get_dept_info_by_dept_id": `{"success":true}`,
|
||||
}
|
||||
runFailure(t, Org, "name", validOrg, map[string]error{})
|
||||
runFailure(t, Org, "name", validOrg, map[string]error{"get_dept_info_by_dept_id": errors.New("transport")})
|
||||
validTeam := map[string]string{
|
||||
"search_contact_by_key_word": `{"success":true,"result":[{"userId":"u1"}]}`,
|
||||
"get_user_info_by_user_ids": `{"success":true,"result":[{"orgEmployeeModel":{"orgUserId":"u1","depts":[{"deptId":7}]}}]}`,
|
||||
"get_dept_members_by_deptId": `{"success":true}`,
|
||||
}
|
||||
runFailure(t, Team, "name", validTeam, map[string]error{})
|
||||
}
|
||||
@@ -98,7 +98,10 @@ var DeptMembers = shortcut.Shortcut{
|
||||
|
||||
// Step 2 — defensively pull the matching departments out of the
|
||||
// response and require exactly one, otherwise disambiguate.
|
||||
depts := deptMembersExtractDepts(data)
|
||||
depts, err := strictDeptCandidates(data, "contact/search_dept_by_keyword")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch {
|
||||
case len(depts) == 0:
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
@@ -112,9 +115,17 @@ var DeptMembers = shortcut.Shortcut{
|
||||
|
||||
// Step 3 — list that department's direct members.
|
||||
// get_dept_members_by_deptId expects deptIds as a string list.
|
||||
return rt.CallMCP("get_dept_members_by_deptId", map[string]any{
|
||||
data, err = rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{
|
||||
"deptIds": []string{strconv.FormatInt(depts[0].id, 10)},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members, err := strictContactMembers(data, "contact/get_dept_members_by_deptId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(members), "members": members})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -218,5 +229,6 @@ func deptMembersLabels(depts []deptMembersDept) []string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&DeptMembers)
|
||||
shortcut.Register(DeptMembers)
|
||||
}
|
||||
|
||||
@@ -65,18 +65,27 @@ var Lookup = shortcut.Shortcut{
|
||||
Tips: []string{`dws contact +lookup --name 张三`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — resolve the name to a unique userId.
|
||||
user, err := resolveUser(rt, rt.Str("name"))
|
||||
user, err := strictResolveContactUser(rt, rt.Str("name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — fetch and print the full profile of the resolved user.
|
||||
return rt.CallMCP("get_user_info_by_user_ids", map[string]any{
|
||||
data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{
|
||||
"user_id_list": []string{user.userID},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profile, err := strictUserDetail(data, user.userID, "contact/get_user_info_by_user_ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"profile": profile})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&Lookup)
|
||||
shortcut.Register(Lookup)
|
||||
}
|
||||
|
||||
@@ -15,13 +15,11 @@ package smart
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"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/shortcut"
|
||||
)
|
||||
|
||||
@@ -75,7 +73,7 @@ var Org = shortcut.Shortcut{
|
||||
Tips: []string{`dws contact +org --name 张三`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — resolve the person's name to a unique userId.
|
||||
user, err := resolveUser(rt, rt.Str("name"))
|
||||
user, err := strictResolveContactUser(rt, rt.Str("name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -91,16 +89,25 @@ var Org = shortcut.Shortcut{
|
||||
|
||||
// Step 3 — defensively pull the primary deptId out of the response,
|
||||
// then print that department's detail.
|
||||
deptID, ok := shortcutOrgExtractDeptID(data)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"没能从 %s(%s) 的组织信息里解析出所在部门 deptId;"+
|
||||
"TA 可能没有归属部门,或返回结构与预期不符。",
|
||||
user.name, user.userID))
|
||||
profile, err := strictUserDetail(data, user.userID, "contact/get_user_info_by_user_ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.CallMCP("get_dept_info_by_dept_id", map[string]any{
|
||||
deptID, err := strictPrimaryDeptID(profile, "contact/get_user_info_by_user_ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err = rt.CallMCPData("contact", "get_dept_info_by_dept_id", map[string]any{
|
||||
"deptId": deptID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dept, err := strictDeptDetail(data, deptID, "contact/get_dept_info_by_dept_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"department": dept})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -179,5 +186,6 @@ func shortcutOrgToInt64(v any) (int64, bool) {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&Org)
|
||||
shortcut.Register(Org)
|
||||
}
|
||||
|
||||
@@ -95,12 +95,15 @@ var ResolveDept = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
// Project candidates to {deptId, name}, defensively unwrapping the list.
|
||||
items := resolveDeptItems(data)
|
||||
items, err := strictDeptCandidates(data, "contact/search_dept_by_keyword")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
candidates := make([]map[string]any, 0, len(items))
|
||||
for _, d := range items {
|
||||
candidates = append(candidates, map[string]any{
|
||||
"deptId": resolveDeptID(d),
|
||||
"name": resolveDeptName(d),
|
||||
"deptId": strconv.FormatInt(d.id, 10),
|
||||
"name": d.name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -197,5 +200,6 @@ func stripHighlightTags(s string) string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&ResolveDept)
|
||||
shortcut.Register(ResolveDept)
|
||||
}
|
||||
|
||||
@@ -14,13 +14,11 @@
|
||||
package smart
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
"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/shortcut"
|
||||
)
|
||||
|
||||
@@ -77,7 +75,7 @@ var Team = shortcut.Shortcut{
|
||||
Tips: []string{`dws contact +team --name 张三`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
// Step 1 — resolve the person's name to a unique userId.
|
||||
user, err := resolveUser(rt, rt.Str("name"))
|
||||
user, err := strictResolveContactUser(rt, rt.Str("name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -94,21 +92,31 @@ var Team = shortcut.Shortcut{
|
||||
// Step 3 — defensively pull the primary deptId out of the response
|
||||
// (reuses the same extractor as +org), then list that department's
|
||||
// direct members.
|
||||
deptID, ok := shortcutOrgExtractDeptID(data)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"没能从 %s(%s) 的组织信息里解析出所在部门 deptId;"+
|
||||
"TA 可能没有归属部门,或返回结构与预期不符。",
|
||||
user.name, user.userID))
|
||||
profile, err := strictUserDetail(data, user.userID, "contact/get_user_info_by_user_ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
deptID, err := strictPrimaryDeptID(profile, "contact/get_user_info_by_user_ids")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get_dept_members_by_deptId expects deptIds as a string list.
|
||||
return rt.CallMCP("get_dept_members_by_deptId", map[string]any{
|
||||
data, err = rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{
|
||||
"deptIds": []string{strconv.FormatInt(deptID, 10)},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members, err := strictContactMembers(data, "contact/get_dept_members_by_deptId")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"count": len(members), "members": members})
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&Team)
|
||||
shortcut.Register(Team)
|
||||
}
|
||||
|
||||
@@ -70,7 +70,11 @@ var Whoami = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(whoamiProject(data))
|
||||
profile, err := strictWhoami(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(profile)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -159,5 +163,6 @@ func whoamiStr(m map[string]any, keys ...string) string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&Whoami)
|
||||
shortcut.Register(Whoami)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ usage() {
|
||||
# Single source of truth for the partition set. CI runs one job per partition and
|
||||
# pins its shard names to this list, so a name that appears here without a
|
||||
# dispatch entry below fails closed rather than silently skipping tests.
|
||||
APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz'
|
||||
APP_PARTITIONS='schema a-b c-a-l c-m-o c-p-r c-s-z c-other d-r s-z-example-fuzz'
|
||||
|
||||
mode="${1:-}"
|
||||
partition=""
|
||||
@@ -67,7 +67,8 @@ fi
|
||||
schema_count=0
|
||||
ab_count=0
|
||||
cal_count=0
|
||||
cmr_count=0
|
||||
cmo_count=0
|
||||
cpr_count=0
|
||||
csz_count=0
|
||||
cother_count=0
|
||||
dr_count=0
|
||||
@@ -79,7 +80,8 @@ while IFS= read -r test_name; do
|
||||
Test*Schema*) schema_count=$((schema_count + 1)) ;;
|
||||
Test[A-B]*) ab_count=$((ab_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[M-O]*) cmo_count=$((cmo_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[P-R]*) cpr_count=$((cpr_count + 1)) ;;
|
||||
TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;;
|
||||
TestC*) cother_count=$((cother_count + 1)) ;;
|
||||
Test[D-R]*) dr_count=$((dr_count + 1)) ;;
|
||||
@@ -103,7 +105,8 @@ for spec in \
|
||||
"schema:$schema_count" \
|
||||
"a-b:$ab_count" \
|
||||
"c-a-l:$cal_count" \
|
||||
"c-m-r:$cmr_count" \
|
||||
"c-m-o:$cmo_count" \
|
||||
"c-p-r:$cpr_count" \
|
||||
"c-s-z:$csz_count" \
|
||||
"c-other:$cother_count" \
|
||||
"d-r:$dr_count" \
|
||||
@@ -142,14 +145,14 @@ for name in $classified; do
|
||||
done
|
||||
|
||||
total_count="$(wc -l < "$tests" | tr -d ' ')"
|
||||
assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count))
|
||||
assigned_count=$((schema_count + ab_count + cal_count + cmo_count + cpr_count + csz_count + cother_count + dr_count + sz_count))
|
||||
if [ "$assigned_count" -ne "$total_count" ]; then
|
||||
printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
|
||||
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
|
||||
printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-o=%s c-p-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \
|
||||
"$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmo_count" "$cpr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count"
|
||||
|
||||
if [ "$mode" = "verify" ]; then
|
||||
exit 0
|
||||
@@ -208,7 +211,8 @@ run_named_partition() {
|
||||
schema) run_partition schema no-race "$schema_pattern" ;;
|
||||
a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;;
|
||||
c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;;
|
||||
c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;;
|
||||
c-m-o) run_partition c-m-o race '^TestCrossPlatformCoverage[M-O]' "$schema_pattern" ;;
|
||||
c-p-r) run_partition c-p-r race '^TestCrossPlatformCoverage[P-R]' "$schema_pattern" ;;
|
||||
c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;;
|
||||
c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;;
|
||||
d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;;
|
||||
|
||||
@@ -29,11 +29,17 @@ SEMANTIC_PATHS = [
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_aisearch.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_contact.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_live.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_report.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_sheet.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_whiteboard.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_devdoc.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_hrbrain.json",
|
||||
ROOT / "internal" / "shortcut" / "semantic_catalog_pat.json",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ SERVICE_TO_SKILL = {
|
||||
"mail": ROOT / "skills" / "multi" / "dingtalk-mail" / "SKILL.md",
|
||||
"minutes": ROOT / "skills" / "multi" / "dingtalk-minutes" / "SKILL.md",
|
||||
"oa": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "oa.md",
|
||||
"pat": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "pat.md",
|
||||
"report": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "report.md",
|
||||
"sheet": ROOT / "skills" / "multi" / "dingtalk-misc" / "references" / "sheet.md",
|
||||
"todo": ROOT / "skills" / "multi" / "dingtalk-todo" / "SKILL.md",
|
||||
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
MANIFEST="$ROOT/scripts/policy/shortcut-public-e2e-proof.json"
|
||||
BINARY="${DWS_SHORTCUT_PROOF_BINARY:-$ROOT/dws}"
|
||||
GO_BINARY="${GO:-go}"
|
||||
PROOF_ROOT=""
|
||||
|
||||
fail() {
|
||||
printf 'shortcut public E2E proof failed: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$PROOF_ROOT" ] && [ -d "$PROOF_ROOT" ]; then
|
||||
find "$PROOF_ROOT" -type f -delete 2>/dev/null || true
|
||||
find "$PROOF_ROOT" -depth -type d -exec rmdir -- {} \; 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT HUP INT TERM
|
||||
|
||||
command -v jq >/dev/null 2>&1 || fail "jq is required"
|
||||
command -v "$GO_BINARY" >/dev/null 2>&1 || fail "Go is required"
|
||||
[ -x "$BINARY" ] || fail "current source binary is missing; run make build"
|
||||
[ -f "$MANIFEST" ] || fail "reviewed proof manifest is missing"
|
||||
|
||||
if [ "${DWS_SHORTCUT_PROOF_ALLOW_DIRTY:-0}" != "1" ] &&
|
||||
[ -n "$(git -C "$ROOT" status --porcelain --untracked-files=all)" ]; then
|
||||
fail "worktree must be clean so evidence is bound to current HEAD"
|
||||
fi
|
||||
|
||||
HEAD_SHA="$(git -C "$ROOT" rev-parse HEAD)"
|
||||
[ -n "$HEAD_SHA" ] || fail "cannot resolve current HEAD"
|
||||
|
||||
jq -e '
|
||||
.version == 1 and
|
||||
(.products | type == "array" and length > 0) and
|
||||
(.public_shortcuts | type == "array") and
|
||||
(.products | group_by(.service) | all(.[]; length == 1)) and
|
||||
(.public_shortcuts | group_by([.service, .command]) | all(.[]; length == 1)) and
|
||||
(.products | all(.[];
|
||||
(.service | type == "string" and length > 0) and
|
||||
(.source_total | type == "number") and
|
||||
(.public | type == "number") and
|
||||
(.unavailable | type == "number") and
|
||||
(.source_total == (.public + .unavailable)))) and
|
||||
(.public_shortcuts | all(.[];
|
||||
(.service | type == "string" and length > 0) and
|
||||
(.command | type == "string" and startswith("+")) and
|
||||
(.owning_atomic | type == "string" and length > 0) and
|
||||
(.proof_class | IN("list", "search", "read", "write")) and
|
||||
(.proof_case | type == "string" and length > 0) and
|
||||
(.stable_target | type == "string" and length > 0) and
|
||||
(.pass_labels | type == "array" and length > 0 and all(.[]; type == "string" and endswith("-PASS")))))
|
||||
' "$MANIFEST" >/dev/null || fail "reviewed proof manifest is malformed"
|
||||
|
||||
SOURCE_TOTAL=0
|
||||
PUBLIC_TOTAL=0
|
||||
UNAVAILABLE_TOTAL=0
|
||||
SEMANTIC_PUBLIC=""
|
||||
RUNTIME_PUBLIC=""
|
||||
|
||||
for service in $(jq -r '.products[].service' "$MANIFEST"); do
|
||||
catalog="$ROOT/internal/shortcut/semantic_catalog_${service}.json"
|
||||
[ -f "$catalog" ] || fail "semantic catalog is missing for $service"
|
||||
|
||||
jq -e --arg service "$service" '
|
||||
. as $catalog |
|
||||
.service == $service and
|
||||
.default_availability == "unavailable" and
|
||||
(.shortcuts | type == "object") and
|
||||
(.shortcuts | to_entries | all(.[];
|
||||
(.key | type == "string" and startswith("+")) and
|
||||
(.value | type == "object") and
|
||||
(.value.reviewed == true) and
|
||||
((.value.semantic_delta | type) == "string" and (.value.semantic_delta | length) > 0) and
|
||||
(if (.value.public // false)
|
||||
then ((.value.availability // $catalog.default_availability) == "available")
|
||||
else ((.value.availability // $catalog.default_availability) == "unavailable")
|
||||
end)))
|
||||
' "$catalog" >/dev/null || fail "$service semantic catalog is not truthfully reviewed"
|
||||
|
||||
source_count="$(jq '.shortcuts | length' "$catalog")"
|
||||
public_count="$(jq '[. as $catalog | .shortcuts | to_entries[] | select((.value.public // false) and ((.value.availability // $catalog.default_availability) == "available"))] | length' "$catalog")"
|
||||
unavailable_count=$((source_count - public_count))
|
||||
expected_source="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .source_total' "$MANIFEST")"
|
||||
expected_public="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .public' "$MANIFEST")"
|
||||
expected_unavailable="$(jq -r --arg service "$service" '.products[] | select(.service == $service) | .unavailable' "$MANIFEST")"
|
||||
[ "$source_count" -eq "$expected_source" ] || fail "$service source count drifted"
|
||||
[ "$public_count" -eq "$expected_public" ] || fail "$service public count drifted"
|
||||
[ "$unavailable_count" -eq "$expected_unavailable" ] || fail "$service unavailable count drifted"
|
||||
|
||||
semantic_lines="$(jq -r --arg service "$service" '. as $catalog | .shortcuts | to_entries[] | select((.value.public // false) and ((.value.availability // $catalog.default_availability) == "available")) | [$service, .key] | @tsv' "$catalog")"
|
||||
if [ -n "$semantic_lines" ]; then
|
||||
SEMANTIC_PUBLIC="${SEMANTIC_PUBLIC}${semantic_lines}"$'\n'
|
||||
fi
|
||||
|
||||
runtime_output="$($BINARY shortcut list --service "$service" --format json)"
|
||||
printf '%s' "$runtime_output" | jq -e --arg service "$service" '
|
||||
type == "object" and
|
||||
(.count | type == "number") and
|
||||
(.shortcuts | type == "array") and
|
||||
(.count == (.shortcuts | length)) and
|
||||
(.shortcuts | all(.[];
|
||||
type == "object" and
|
||||
.service == $service and
|
||||
(.command | type == "string" and startswith("+")) and
|
||||
.public == true and
|
||||
.reviewed == true and
|
||||
.availability == "available"))
|
||||
' >/dev/null || fail "$service runtime public inventory is malformed"
|
||||
runtime_count="$(printf '%s' "$runtime_output" | jq '.shortcuts | length')"
|
||||
[ "$runtime_count" -eq "$public_count" ] || fail "$service runtime public count does not match its semantic catalog"
|
||||
runtime_lines="$(printf '%s' "$runtime_output" | jq -r '.shortcuts[] | [.service, .command] | @tsv')"
|
||||
if [ -n "$runtime_lines" ]; then
|
||||
RUNTIME_PUBLIC="${RUNTIME_PUBLIC}${runtime_lines}"$'\n'
|
||||
fi
|
||||
|
||||
SOURCE_TOTAL=$((SOURCE_TOTAL + source_count))
|
||||
PUBLIC_TOTAL=$((PUBLIC_TOTAL + public_count))
|
||||
UNAVAILABLE_TOTAL=$((UNAVAILABLE_TOTAL + unavailable_count))
|
||||
done
|
||||
|
||||
SEMANTIC_PUBLIC="$(printf '%s' "$SEMANTIC_PUBLIC" | sed '/^$/d' | LC_ALL=C sort)"
|
||||
RUNTIME_PUBLIC="$(printf '%s' "$RUNTIME_PUBLIC" | sed '/^$/d' | LC_ALL=C sort)"
|
||||
EXPECTED_PUBLIC="$(jq -r '.public_shortcuts[] | [.service, .command] | @tsv' "$MANIFEST" | LC_ALL=C sort)"
|
||||
[ "$SEMANTIC_PUBLIC" = "$RUNTIME_PUBLIC" ] || fail "semantic and runtime public surfaces differ"
|
||||
[ "$RUNTIME_PUBLIC" = "$EXPECTED_PUBLIC" ] || fail "a public shortcut lacks a reviewed exact plus raw proof case"
|
||||
|
||||
printf 'CURRENT-HEAD-PUBLIC-SURFACE-PASS head=%s source=%d public=%d unavailable=%d\n' "$HEAD_SHA" "$SOURCE_TOTAL" "$PUBLIC_TOTAL" "$UNAVAILABLE_TOTAL"
|
||||
|
||||
(cd "$ROOT" && DWS_PACKAGE_VERSION="${DWS_PACKAGE_VERSION:-0.0.0-test}" "$GO_BINARY" test -count=1 ./internal/shortcut/pat -run '^TestCrossPlatformCoveragePATBrowserPolicyConfirmationDryRunReadbackAndCleanup$' >/dev/null) ||
|
||||
fail "PAT exact/raw zero-call structural proof failed"
|
||||
|
||||
run_pat_browser_policy_default() {
|
||||
schema_output="$($BINARY schema --cli-path 'pat +browser-policy' --compact --format json)"
|
||||
printf '%s' "$schema_output" | jq -e '
|
||||
type == "object" and
|
||||
.cli_path == "pat +browser-policy" and
|
||||
.effect == "write" and
|
||||
.risk == "medium" and
|
||||
.confirmation == "user_required" and
|
||||
.idempotency == "idempotent" and
|
||||
.availability == "available" and
|
||||
.interface_mode == "composite" and
|
||||
(.result | type == "object") and
|
||||
(.result.outcomes | type == "array" and index("success") != null and index("failure") != null) and
|
||||
(.result.data_schema.required | sort == ["executed", "openBrowser", "scope", "verified"])
|
||||
' >/dev/null || fail "pat +browser-policy compact contract is incomplete"
|
||||
|
||||
PROOF_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-pat-public-proof.XXXXXX")"
|
||||
exact_dir="$PROOF_ROOT/exact"
|
||||
raw_dir="$PROOF_ROOT/raw"
|
||||
mkdir -p "$exact_dir" "$raw_dir"
|
||||
exact_path="$exact_dir/pat_policy.json"
|
||||
raw_path="$raw_dir/pat_policy.json"
|
||||
|
||||
if env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \
|
||||
DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \
|
||||
"$BINARY" pat +browser-policy --enabled=false --format json </dev/null >/dev/null 2>&1; then
|
||||
fail "pat +browser-policy wrote without confirmation"
|
||||
fi
|
||||
[ ! -e "$exact_path" ] || fail "pat +browser-policy left state before confirmation"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-PRECONFIRM-ZERO-CALL-PASS'
|
||||
|
||||
exact_dry_run="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \
|
||||
DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \
|
||||
"$BINARY" pat +browser-policy --enabled=false --dry-run --format json)"
|
||||
printf '%s' "$exact_dry_run" | jq -e '
|
||||
type == "object" and .ok == true and .outcome == "success" and
|
||||
(.data | type == "object") and .data.scope == "default" and
|
||||
.data.openBrowser == false and .data.executed == false and .data.verified == false
|
||||
' >/dev/null || fail "pat +browser-policy dry-run result is malformed"
|
||||
[ ! -e "$exact_path" ] || fail "pat +browser-policy dry-run wrote policy state"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-DRY-RUN-ZERO-WRITE-PASS'
|
||||
|
||||
exact_output="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \
|
||||
DWS_CONFIG_DIR="$exact_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \
|
||||
"$BINARY" pat +browser-policy --enabled=false --yes --format json)"
|
||||
printf '%s' "$exact_output" | jq -e '
|
||||
type == "object" and .ok == true and .outcome == "success" and
|
||||
(.data | type == "object") and .data.scope == "default" and
|
||||
.data.openBrowser == false and .data.executed == true and .data.verified == true
|
||||
' >/dev/null || fail "pat +browser-policy terminal receipt is malformed"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-EXACT-TERMINAL-RECEIPT-PASS'
|
||||
|
||||
jq -e '
|
||||
type == "object" and (.default | type == "object") and
|
||||
.default.openBrowser == false and ((has("agents") | not) or .agents == {})
|
||||
' "$exact_path" >/dev/null || fail "pat +browser-policy exact target readback failed"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-EXACT-READBACK-PASS'
|
||||
|
||||
raw_output="$(env -u DINGTALK_DWS_AGENTCODE -u DINGTALK_DWS_SESSIONID -u DINGTALK_DWS_ORGID -u DINGTALK_DWS_UID \
|
||||
DWS_CONFIG_DIR="$raw_dir" HTTPS_PROXY=http://127.0.0.1:1 HTTP_PROXY=http://127.0.0.1:1 \
|
||||
"$BINARY" pat browser-policy --enabled=false --format json)"
|
||||
printf '%s' "$raw_output" | jq -e '
|
||||
type == "object" and .scope == "default" and .source == "default" and
|
||||
.openBrowser == false and (has("agentCode") | not)
|
||||
' >/dev/null || fail "pat browser-policy terminal receipt is malformed"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-RAW-TERMINAL-RECEIPT-PASS'
|
||||
|
||||
jq -e '
|
||||
type == "object" and (.default | type == "object") and
|
||||
.default.openBrowser == false and ((has("agents") | not) or .agents == {})
|
||||
' "$raw_path" >/dev/null || fail "pat browser-policy raw target readback failed"
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-RAW-READBACK-PASS'
|
||||
|
||||
unlink "$exact_path"
|
||||
unlink "$raw_path"
|
||||
[ ! -e "$exact_path" ] || fail "pat +browser-policy cleanup left exact residue"
|
||||
[ ! -e "$raw_path" ] || fail "pat browser-policy cleanup left raw residue"
|
||||
find "$exact_dir" "$raw_dir" -type f -delete
|
||||
[ -z "$(find "$PROOF_ROOT" -type f -print -quit)" ] || fail "PAT browser policy proof left file residue"
|
||||
find "$PROOF_ROOT" -depth -type d -exec rmdir -- {} \;
|
||||
[ ! -e "$PROOF_ROOT" ] || fail "PAT browser policy proof left directory residue"
|
||||
PROOF_ROOT=""
|
||||
printf '%s\n' 'PAT-BROWSER-POLICY-CLEANUP-ABSENCE-PASS'
|
||||
}
|
||||
|
||||
while IFS=$'\t' read -r service command proof_case; do
|
||||
[ -n "$service" ] || continue
|
||||
case "$service $command $proof_case" in
|
||||
'pat +browser-policy pat_browser_policy_default')
|
||||
proof_output="$(run_pat_browser_policy_default)"
|
||||
;;
|
||||
*)
|
||||
fail "$service $command has no executable proof implementation"
|
||||
;;
|
||||
esac
|
||||
actual_labels="$(printf '%s\n' "$proof_output" | sed '/^$/d' | LC_ALL=C sort)"
|
||||
expected_labels="$(jq -r --arg service "$service" --arg command "$command" '.public_shortcuts[] | select(.service == $service and .command == $command) | .pass_labels[]' "$MANIFEST" | LC_ALL=C sort)"
|
||||
[ "$actual_labels" = "$expected_labels" ] || fail "$service $command did not emit every reviewed PASS label"
|
||||
printf '%s\n' "$proof_output"
|
||||
done < <(jq -r '.public_shortcuts[] | [.service, .command, .proof_case] | @tsv' "$MANIFEST")
|
||||
|
||||
printf 'EVERY-PUBLIC-SHORTCUT-DOUBLE-LAYER-E2E-PASS total=%d\n' "$PUBLIC_TOTAL"
|
||||
@@ -79,6 +79,38 @@ type parameterSchema struct {
|
||||
Enum []string `json:"enum,omitempty"`
|
||||
}
|
||||
|
||||
type reviewedCompatibilityException struct {
|
||||
Field string
|
||||
Old string
|
||||
New string
|
||||
}
|
||||
|
||||
// reviewedCompatibilityExceptions is intentionally exact: safety fixes may
|
||||
// need to tighten a historical contract, but that must not turn arbitrary
|
||||
// confirmation drift into a compatible change.
|
||||
var reviewedCompatibilityExceptions = map[string]reviewedCompatibilityException{
|
||||
// PR #1085: batch permission/member remove is destructive at container
|
||||
// scope — one call can revoke access for up to 30 USER / DEPT /
|
||||
// CONVERSATION / TAG members, and departments, chats, and role groups
|
||||
// can indirectly affect many more users. The review therefore asked for
|
||||
// the same user confirmation gate as other destructive removes.
|
||||
"doc/doc.remove_permission": {
|
||||
Field: "confirmation",
|
||||
Old: "not_required",
|
||||
New: "user_required",
|
||||
},
|
||||
"drive/drive.permission_remove": {
|
||||
Field: "confirmation",
|
||||
Old: "not_required",
|
||||
New: "user_required",
|
||||
},
|
||||
"wiki/wiki.remove_member": {
|
||||
Field: "confirmation",
|
||||
Old: "not_required",
|
||||
New: "user_required",
|
||||
},
|
||||
}
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
@@ -708,7 +740,7 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
||||
{name: "confirmation", old: oldTool.Confirmation, new: newTool.Confirmation},
|
||||
{name: "idempotency", old: oldTool.Idempotency, new: newTool.Idempotency},
|
||||
} {
|
||||
if field.old != field.new {
|
||||
if field.old != field.new && !isReviewedCompatibilityException(toolPath, field.name, field.old, field.new) {
|
||||
failures = append(failures, fmt.Sprintf("schema tool %q changed %s", toolPath, field.name))
|
||||
}
|
||||
}
|
||||
@@ -744,6 +776,11 @@ func checkToolCompatibility(toolPath string, oldTool, newTool toolSchema) []stri
|
||||
return failures
|
||||
}
|
||||
|
||||
func isReviewedCompatibilityException(toolPath, field, oldValue, newValue string) bool {
|
||||
exception, ok := reviewedCompatibilityExceptions[toolPath]
|
||||
return ok && exception.Field == field && exception.Old == oldValue && exception.New == newValue
|
||||
}
|
||||
|
||||
// reviewedInterfaceRefRedirect enumerates the exact, individually reviewed
|
||||
// backend RPC migrations this gate accepts. Schema shape alone cannot prove two
|
||||
// RPCs share business semantics, permissions, error behaviour, or side effects,
|
||||
|
||||
@@ -415,6 +415,39 @@ func TestSchemaCompatibilityRejectsContractDrift(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaCompatibilityAcceptsReviewedRemoveConfirmationHardening(t *testing.T) {
|
||||
oldTool := baselineContract().Products["doc"].Tools["doc.create"]
|
||||
newTool := oldTool
|
||||
newTool.Confirmation = "user_required"
|
||||
|
||||
for _, toolPath := range []string{
|
||||
"doc/doc.remove_permission",
|
||||
"drive/drive.permission_remove",
|
||||
"wiki/wiki.remove_member",
|
||||
} {
|
||||
if failures := checkToolCompatibility(toolPath, oldTool, newTool); len(failures) != 0 {
|
||||
t.Fatalf("reviewed confirmation hardening for %s failures = %v", toolPath, failures)
|
||||
}
|
||||
}
|
||||
if failures := checkToolCompatibility("doc/doc.other_remove", oldTool, newTool); len(failures) == 0 {
|
||||
t.Fatal("unreviewed confirmation hardening unexpectedly passed")
|
||||
}
|
||||
|
||||
// A reviewed tool is only exempt for the exact reviewed transition: an
|
||||
// unrelated field drift on the same tool must still be reported.
|
||||
riskTool := oldTool
|
||||
riskTool.Risk = "high"
|
||||
if failures := checkToolCompatibility("doc/doc.remove_permission", oldTool, riskTool); len(failures) == 0 {
|
||||
t.Fatal("reviewed tool risk drift unexpectedly passed")
|
||||
}
|
||||
|
||||
oldTool.Confirmation = "user_required"
|
||||
newTool.Confirmation = "not_required"
|
||||
if failures := checkToolCompatibility("doc/doc.remove_permission", oldTool, newTool); len(failures) == 0 {
|
||||
t.Fatal("reviewed tool confirmation weakening unexpectedly passed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeContracts(t *testing.T) {
|
||||
historical := baselineContract()
|
||||
current := cloneContract(historical)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"version": 1,
|
||||
"products": [
|
||||
{
|
||||
"service": "devdoc",
|
||||
"source_total": 1,
|
||||
"public": 0,
|
||||
"unavailable": 1
|
||||
},
|
||||
{
|
||||
"service": "hrbrain",
|
||||
"source_total": 11,
|
||||
"public": 0,
|
||||
"unavailable": 11
|
||||
},
|
||||
{
|
||||
"service": "pat",
|
||||
"source_total": 2,
|
||||
"public": 1,
|
||||
"unavailable": 1
|
||||
}
|
||||
],
|
||||
"public_shortcuts": [
|
||||
{
|
||||
"service": "pat",
|
||||
"command": "+browser-policy",
|
||||
"owning_atomic": "pat browser-policy",
|
||||
"proof_class": "write",
|
||||
"proof_case": "pat_browser_policy_default",
|
||||
"stable_target": "default-policy",
|
||||
"pass_labels": [
|
||||
"PAT-BROWSER-POLICY-PRECONFIRM-ZERO-CALL-PASS",
|
||||
"PAT-BROWSER-POLICY-DRY-RUN-ZERO-WRITE-PASS",
|
||||
"PAT-BROWSER-POLICY-EXACT-TERMINAL-RECEIPT-PASS",
|
||||
"PAT-BROWSER-POLICY-RAW-TERMINAL-RECEIPT-PASS",
|
||||
"PAT-BROWSER-POLICY-EXACT-READBACK-PASS",
|
||||
"PAT-BROWSER-POLICY-RAW-READBACK-PASS",
|
||||
"PAT-BROWSER-POLICY-CLEANUP-ABSENCE-PASS"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -43,11 +43,12 @@ cli_version: ">=1.0.15"
|
||||
|
||||
| 服务 | shortcut 数 | multi skill |
|
||||
|---|---:|---|
|
||||
| `aitable` | 93 | `dingtalk-aitable` |
|
||||
| `aisearch` | 1 | `—` |
|
||||
| `aitable` | 100 | `dingtalk-aitable` |
|
||||
| `attendance` | 8 | `dingtalk-misc` |
|
||||
| `calendar` | 27 | `dingtalk-calendar` |
|
||||
| `chat` | 98 | `dingtalk-chat` |
|
||||
| `contact` | 14 | `dingtalk-contact` |
|
||||
| `contact` | 13 | `dingtalk-contact` |
|
||||
| `devapp` | 19 | `dingtalk-misc` |
|
||||
| `ding` | 1 | `dingtalk-misc` |
|
||||
| `doc` | 45 | `dingtalk-doc` |
|
||||
@@ -55,6 +56,7 @@ cli_version: ">=1.0.15"
|
||||
| `mail` | 8 | `dingtalk-mail` |
|
||||
| `minutes` | 27 | `dingtalk-minutes` |
|
||||
| `oa` | 1 | `dingtalk-misc` |
|
||||
| `pat` | 1 | `dingtalk-misc` |
|
||||
| `report` | 4 | `dingtalk-misc` |
|
||||
| `sheet` | 2 | `dingtalk-misc` |
|
||||
| `todo` | 21 | `dingtalk-todo` |
|
||||
|
||||
@@ -521,17 +521,32 @@ Example:
|
||||
dws doc permission add --node <DOC_ID> --users uid1 --role READER
|
||||
dws doc permission add --node <DOC_ID> --users uid1,uid2 --role EDITOR
|
||||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>" --users uid1 --role MANAGER
|
||||
dws doc permission add --node <DOC_ID> --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws doc permission add --node <DOC_ID> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--node string 目标文档/文件夹的 ID 或 URL (必填)
|
||||
--users strings 被授权的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 授予的角色 (必填,大小写敏感,必须全大写): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
|
||||
--users string 被授权的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--role string 授予的角色 (旧格式必填,大小写不敏感): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被添加的成员 (仅 --members 新格式时生效,默认 false)
|
||||
--workspace string 所属知识库 ID (选填,仅用于辅助构造返回的 docUrl,业务实际依赖 nodeId)
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,支持 USER/DEPT/CONVERSATION/TAG 四种成员类型,每个成员携带独立 `roleId`
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`(标识用户所属组织)
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`(标识部门所属组织)
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
>
|
||||
> **重要约束**:
|
||||
> - 仅支持 USER 类型授权。
|
||||
> - 角色枚举严格大写:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加)。
|
||||
> - 操作者需在该节点具备「可编辑(EDITOR)」及以上角色(OWNER / MANAGER / EDITOR)。
|
||||
> - 角色枚举(大小写不敏感):MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加)。
|
||||
> - 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时不会向服务端发送该字段,需要通知请显式传 `--notify`。
|
||||
> - 授权对象是文档节点本身,不需要也不应该用 `wiki member add`(那个是知识库容器级授权)。
|
||||
|
||||
### 修改文档权限(节点级)
|
||||
@@ -541,13 +556,36 @@ Usage:
|
||||
Example:
|
||||
dws doc permission update --node <DOC_ID> --users uid1 --role EDITOR
|
||||
dws doc permission update --node <DOC_ID> --users uid1,uid2 --role READER
|
||||
dws doc permission update --node <DOC_ID> --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws doc permission update --node <DOC_ID> --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--node string 目标文档/文件夹的 ID 或 URL (必填)
|
||||
--users strings 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 新角色 (必填,大小写敏感,必须全大写): MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--users string 被更新的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--role string 新角色 (旧格式必填,大小写不敏感): MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被变更的成员 (仅 --members 新格式时生效,默认 false)
|
||||
--workspace string 所属知识库 ID (选填)
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,支持 USER/DEPT/CONVERSATION/TAG 四种成员类型,每个成员携带独立 `roleId`
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`
|
||||
>
|
||||
> **重要约束**:
|
||||
> - OWNER 角色不可通过此接口变更。
|
||||
> - 同一成员在同一节点只能拥有一个角色,变更后旧角色自动替换。
|
||||
> - 若成员的角色来自父节点的权限继承(PASS_ON),且继承角色高于目标角色,接口会拒绝操作。
|
||||
> - 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
|
||||
> - 仅可更新已存在协作关系的成员,新增协作者请使用 `dws doc permission add`。
|
||||
|
||||
### 列出文档权限(节点级)
|
||||
```
|
||||
Usage:
|
||||
@@ -556,14 +594,16 @@ Example:
|
||||
dws doc permission list --node <DOC_ID>
|
||||
dws doc permission list --node <DOC_ID> --limit 50
|
||||
dws doc permission list --node <DOC_ID> --filter-role EDITOR
|
||||
dws doc permission list --node <DOC_ID> --next-token <上次返回的 nextToken>
|
||||
Flags:
|
||||
--node string 目标文档/文件夹的 ID 或 URL (必填)
|
||||
--workspace string 所属知识库 ID (选填)
|
||||
--limit int 返回数量上限,最大 200 (默认 50)
|
||||
--limit int 返回数量上限,最大 50 (默认 30)
|
||||
--filter-role string 按角色过滤: MANAGER / EDITOR / DOWNLOADER / READER (选填)
|
||||
--next-token string 分页游标,首次不传,后续传入上一次返回的 nextToken (选填)
|
||||
```
|
||||
|
||||
> 接口不支持游标分页,使用 `--limit` 一次性拉取。
|
||||
> 底层一次性返回全量成员后在内存中按 pageSize 分页;当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页。
|
||||
|
||||
### 导出在线文档为 docx(一体化命令)
|
||||
```
|
||||
@@ -716,9 +756,17 @@ Flags:
|
||||
- 删除 → `block delete`
|
||||
|
||||
用户说"给某人开权限/分享给某人/授权某文档/把这篇文档给 xxx 看":
|
||||
- 新增权限 → `permission add`(需 `--node` + `--user` + `--role`)
|
||||
- 新增权限 → `permission add`(需 `--node` + `--user` + `--role`,或 `--node` + `--members`)
|
||||
- 修改权限 → `permission update`
|
||||
- 查看谁有权限 → `permission list`
|
||||
- 授权后要告知对方("并通知他/告知一下/提醒对方/让他知道")→ 走 `--members` 并追加 `--notify`
|
||||
|
||||
> **通知意图 → `--notify`**(默认不通知,省略时 CLI 不向服务端发送该字段):
|
||||
> - 用户明确要求“通知 / 告知 / 提醒对方 / 让他知道” → 追加 `--notify`
|
||||
> - 用户明确要求“不要通知 / 别提醒 / 悄悄加 / 不要打扰” → 追加 `--notify=false`
|
||||
> - 用户没提通知需求 → **不传该 flag**,保持不通知;不要自行补上 `--notify`
|
||||
> - `--notify` 仅在 `--members` 新格式下生效;旧格式 `--users` 下传了也不会生效,有通知需求必须改用 `--members`
|
||||
> - 仅 USER 和 CONVERSATION 类型成员会收到通知;被授权对象是 DEPT / TAG 时通知不会送达,**需主动向用户说明这一点**,不要默不作声
|
||||
|
||||
> **关键区分**:
|
||||
> - "把**某篇文档**授权给某人" → `doc permission add`(节点级,包括「我的文档」下的文档都支持)
|
||||
|
||||
@@ -594,6 +594,7 @@ Flags:
|
||||
用户说"回收站/查看回收站/回收站列表/回收站里有什么" → `recycle list`
|
||||
用户说"恢复文件/还原删除的文件/从回收站恢复/还原回收站文件" → `recycle restore`
|
||||
用户说"给文档授权/分享权限" → `permission add`
|
||||
用户说"授权并通知对方/加权限后告知他/通知一下被授权的人" → `permission add --members ... --notify`(未提通知需求时不传 `--notify`)
|
||||
用户说"公开文件/互联网公开/设置公开/让互联网所有人可访问" → `publish set`
|
||||
用户说"关闭公开/取消公开/取消互联网访问" → `publish unset`
|
||||
用户说"查看公开状态/是否公开/发布状态" → `publish get`
|
||||
@@ -610,6 +611,13 @@ Flags:
|
||||
|
||||
**drive permission vs wiki member**: "给某篇文档/文件授权" → `drive permission add`(节点级);"给某个知识库整体加成员" → `wiki member add`(空间级)
|
||||
|
||||
**通知意图 → `--notify`**(默认不通知,省略时 CLI 不向服务端发送该字段):
|
||||
- 用户明确要求“通知 / 告知 / 提醒对方 / 让他知道” → 追加 `--notify`
|
||||
- 用户明确要求“不要通知 / 别提醒 / 悄悄加 / 不要打扰” → 追加 `--notify=false`
|
||||
- 用户没提通知需求 → **不传该 flag**,保持不通知;不要自行补上 `--notify`
|
||||
- `--notify` 仅在 `--members` 新格式下生效;旧格式 `--users` 下传了也不会生效,有通知需求必须改用 `--members`
|
||||
- 仅 USER 和 CONVERSATION 类型成员会收到通知;被授权对象是 DEPT / TAG 时通知不会送达,**需主动向用户说明这一点**,不要默不作声
|
||||
|
||||
**创建在线文档/表格/脑图**: drive 不支持创建文件,需走 `wiki node create --type <type>`(创建空节点)或 `doc create`(创建并写入内容)。
|
||||
|
||||
**导出文档/导出为Word**: 导出是内容层操作,走 `doc export`,不属于 drive。
|
||||
@@ -710,18 +718,42 @@ dws wiki node create --type folder --name "文件夹名" --workspace <WORKSPACE_
|
||||
```
|
||||
Usage:
|
||||
dws drive permission add --node <ID> --users uid1,uid2 --role READER
|
||||
dws drive permission add --node <ID> --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws drive permission update --node <ID> --users uid1 --role EDITOR
|
||||
dws drive permission update --node <ID> --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws drive permission update --node <ID> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"}]'
|
||||
dws drive permission list --node <ID>
|
||||
dws drive permission list --node <ID> --limit 50 --next-token <上次返回的 nextToken>
|
||||
dws drive permission remove --node <ID> --users uid1
|
||||
dws drive permission remove --node <ID> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--node string 目标节点 ID 或 URL (必填)
|
||||
--users string 用户 userId 列表,逗号分隔 (add/update/remove 必填)
|
||||
--role string 角色: MANAGER / EDITOR / DOWNLOADER / READER (add/update 必填)
|
||||
--workspace string 知识库 ID (选填)
|
||||
--limit int 返回成员数上限 (仅 list,默认 30,最大 200)
|
||||
--filter-role string 按角色过滤: OWNER / MANAGER / EDITOR / DOWNLOADER / READER (仅 list)
|
||||
--node string 目标节点 ID 或 URL (必填)
|
||||
--users string 用户 userId 列表,逗号分隔 (旧格式)
|
||||
--role string 角色: MANAGER / EDITOR / DOWNLOADER / READER (旧格式必填)
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被添加/变更的成员 (仅 --members 新格式时生效,add / update 均默认 false)
|
||||
--limit int 返回成员数上限 (仅 list,默认 30,最大 50)
|
||||
--filter-role string 按角色过滤 (仅 list)
|
||||
--next-token string 分页游标,首次不传,后续传入上一次返回的 nextToken (仅 list)
|
||||
--workspace string 知识库 ID (选填)
|
||||
```
|
||||
|
||||
> **add / update / remove 支持两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,支持 USER/DEPT/CONVERSATION/TAG 四种成员类型,每个 member 携带独立 `roleId`(remove 只需 type 和 id,但 USER/DEPT/TAG 仍需 corpId)
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`(标识用户所属组织)
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`(标识部门所属组织)
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
>
|
||||
> **重要约束**:
|
||||
> - `--notify` 仅在新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),add / update 均默认 false;省略时不会向服务端发送该字段,需要通知请显式传 `--notify`
|
||||
> - 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`
|
||||
> - 单次请求最多 30 个成员,超出请分批调用
|
||||
> - list 命令底层一次性返回全量成员后在内存中按 pageSize 分页,当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页
|
||||
|
||||
### 文件互联网公开发布
|
||||
|
||||
管理文件的互联网公开发布状态。公开后任何人通过链接即可访问,无需登录钉钉。操作者需要是文件的管理员或拥有者。
|
||||
|
||||
@@ -120,16 +120,31 @@ Example:
|
||||
dws wiki member add --workspace <WS_ID> --users uid1 --role READER
|
||||
dws wiki member add --workspace <WS_ID> --users uid1,uid2 --role EDITOR
|
||||
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/<WS_ID>/overview" --users uid1 --role MANAGER
|
||||
dws wiki member add --workspace <WS_ID> --members '[{"type":"USER","id":"uid1","roleId":"READER","corpId":"xxx"},{"type":"DEPT","id":"deptId1","roleId":"EDITOR","corpId":"xxx"}]' --notify
|
||||
dws wiki member add --workspace <WS_ID> --members '[{"type":"CONVERSATION","id":"cidXXX","roleId":"READER"},{"type":"TAG","id":"tagId1","roleId":"EDITOR","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--workspace string 目标知识库 ID 或 URL (必填)
|
||||
--users string 被加入的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 授予的角色 (必填,大小写不敏感): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
|
||||
--users string 被加入的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--role string 授予的角色 (旧格式必填,大小写不敏感): MANAGER (管理者) / EDITOR (可编辑) / DOWNLOADER (可下载) / READER (可阅读)
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被添加的成员 (仅 --members 新格式时生效,默认 false)
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,每个成员包含 `type`(USER/DEPT/CONVERSATION/TAG)、`id`、`roleId`(建议全大写),支持混合类型
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`(标识用户所属组织)
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`(标识部门所属组织)
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
>
|
||||
> **❗ 重要约束**:
|
||||
> - 仅支持 USER 类型。
|
||||
> - 角色枚举:MANAGER / EDITOR / DOWNLOADER / READER(OWNER 不可通过此接口添加,知识库创建者默认为所有者)。
|
||||
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
> - 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false;省略时不会向服务端发送该字段,需要通知请显式传 `--notify`。旧格式始终通知。
|
||||
> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**;后端会直接拒绝。如果你的目标只是把某篇文档分享给别人,请改用 `dws drive permission add` 在节点级别授权。
|
||||
|
||||
### 移除知识库成员
|
||||
@@ -139,15 +154,28 @@ Usage:
|
||||
Example:
|
||||
dws wiki member remove --workspace <WS_ID> --users uid1
|
||||
dws wiki member remove --workspace <WS_ID> --users uid1,uid2
|
||||
dws wiki member remove --workspace <WS_ID> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--workspace string 目标知识库 ID 或 URL (必填)
|
||||
--users string 被移除的用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--users string 被移除的用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--members string 成员列表 JSON 数组(新格式),只需 type 和 id(USER/DEPT/TAG 还需 corpId),与 --users 互斥
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,只需 `type` 和 `id`(USER/DEPT/TAG 还需 `corpId`),支持 USER/DEPT/CONVERSATION/TAG
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`
|
||||
>
|
||||
> **重要约束**:
|
||||
> - OWNER 角色不可通过此接口移除。
|
||||
> - 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
> - 移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。
|
||||
> - 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - 移除后相关成员将无法访问该知识库下的内容(除非通过节点级权限另行授权)。
|
||||
> - 「我的文档」(myWikiSpace) 是个人空间,**不支持容器级成员管理**。
|
||||
|
||||
### 修改知识库成员角色
|
||||
@@ -157,27 +185,50 @@ Usage:
|
||||
Example:
|
||||
dws wiki member update --workspace <WS_ID> --users uid1 --role EDITOR
|
||||
dws wiki member update --workspace <WS_ID> --users uid1,uid2 --role READER
|
||||
dws wiki member update --workspace <WS_ID> --members '[{"type":"USER","id":"uid1","roleId":"EDITOR","corpId":"xxx"}]' --notify=false
|
||||
dws wiki member update --workspace <WS_ID> --members '[{"type":"TAG","id":"tagId1","roleId":"READER","corpId":"xxx"}]'
|
||||
Flags:
|
||||
--workspace string 目标知识库 ID 或 URL (必填)
|
||||
--users string 目标用户 userId 列表,逗号分隔 (必填,单次最多 30 个)
|
||||
--role string 新角色 (必填,大小写不敏感): MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--users string 目标用户 userId 列表,逗号分隔 (旧格式,单次最多 30 个)
|
||||
--role string 新角色 (旧格式必填,大小写不敏感): MANAGER / EDITOR / DOWNLOADER / READER
|
||||
--members string 成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥
|
||||
--notify bool 是否通知被变更的成员 (仅 --members 新格式时生效,默认 false)
|
||||
```
|
||||
|
||||
> **两种传参方式(互斥)**:
|
||||
> - 旧格式:`--users` 传入逗号分隔的 userId 列表 + `--role` 指定统一角色(仅 USER 类型)
|
||||
> - 新格式:`--members` 传入 JSON 数组,每个成员携带独立 `roleId`
|
||||
>
|
||||
> **成员类型说明**:
|
||||
> - `USER` — 用户,id 为用户 userId,需携带 `corpId`
|
||||
> - `DEPT` — 部门,id 为部门 ID,需携带 `corpId`
|
||||
> - `CONVERSATION` — 群聊,id 为群聊 conversationId(cid 开头),无需 `corpId`
|
||||
> - `TAG` — 角色标签(也称角色组),id 为角色标签 ID,需携带 `corpId`
|
||||
>
|
||||
> **重要约束**:
|
||||
> - OWNER 角色不可通过此接口变更。
|
||||
> - 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 `forbidden.accessDenied`。
|
||||
> - 单次请求最多 30 个成员,超出请分批调用。
|
||||
> - `--notify` 仅在新格式(`--members`)时生效,仅对 USER 和 CONVERSATION 类型成员发送通知(DEPT 和 TAG 不通知),默认 false。
|
||||
|
||||
### 列出知识库成员
|
||||
```
|
||||
Usage:
|
||||
dws wiki member list [flags]
|
||||
Example:
|
||||
dws wiki member list --workspace <WS_ID>
|
||||
dws wiki member list --workspace <WS_ID> --limit 100
|
||||
dws wiki member list --workspace <WS_ID> --limit 50
|
||||
dws wiki member list --workspace <WS_ID> --filter-role EDITOR
|
||||
dws wiki member list --workspace <WS_ID> --next-token <上次返回的 nextToken>
|
||||
Flags:
|
||||
--workspace string 目标知识库 ID 或 URL (必填)
|
||||
--limit int 返回成员数上限,默认 30,最大 200
|
||||
--limit int 返回成员数上限,默认 30,最大 50
|
||||
--filter-role string 按角色过滤,逗号分隔: OWNER / MANAGER / EDITOR / DOWNLOADER / READER (选填)
|
||||
--next-token string 分页游标,首次不传,后续传入上一次返回的 nextToken (选填)
|
||||
```
|
||||
|
||||
> 接口不支持游标分页,使用 `--limit` 一次性拉取。
|
||||
> 底层一次性返回全量成员后在内存中按 pageSize 分页;当 `hasMore` 为 true 时,传入 `--next-token` 即可获取下一页。
|
||||
> 操作者须满足该知识库配置的权限管理最低角色要求,权限不足返回 `forbidden.accessDenied`。
|
||||
|
||||
> ⚠️ **返回字段限制**:`member list` 每条只返回 `name` / `role` / `type` 三个字段,**不含 userId**(服务端不返回)。因此**无法**从 `member list` 拿到 userId 再去串联 `member update` / `member remove`。要对某人改角色 / 移除,需另行拿到其 userId(例如用 `dws contact user search --query "<姓名>"` 按姓名反查)。
|
||||
|
||||
@@ -338,7 +389,8 @@ Flags:
|
||||
- 用户说"复制知识库里的文档" → `node copy`(需 `--workspace` + `--node`)
|
||||
- 用户说"移动知识库里的文档" → `node move`(需 `--workspace` + `--node`)
|
||||
- 用户说"删除知识库里的文档/节点" → `node delete`(需 `--workspace` + `--node`)
|
||||
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--users` + `--role`)
|
||||
- 用户说"把知识库分享给某人/给某人加入知识库/邀请进知识库" → `member add`(需 `--workspace` + `--users` + `--role`,或 `--workspace` + `--members`)
|
||||
- 用户说"加成员并通知他/邀请后告知对方" → `member add --members ... --notify`;用户说"加进去但不要通知/悄悄加" → `member add --members ... --notify=false`
|
||||
- 用户说"修改某人在知识库的权限/调整成员角色" → `member update`
|
||||
- 用户说"移除知识库成员/把某人从知识库移除/删除知识库成员" → `member remove`(需 `--workspace` + `--users`)
|
||||
- 用户说"知识库有哪些成员/查看知识库成员" → `member list`
|
||||
@@ -350,6 +402,12 @@ Flags:
|
||||
> **重要 — `--exclude-file` 使用规则**:
|
||||
> 当用户意图排除文件类动态(上传文件、更新 office 文件等)或只看文档操作时,**必须在命令中带上 `--exclude-file`**,由后端完成过滤。禁止先拉全量数据再用 Python/jq 等工具在客户端过滤,那样既浪费带宽又可能因分页遗漏数据。
|
||||
|
||||
> **通知意图 → `--notify`**(`--members` 新格式默认不通知,省略时 CLI 不向服务端发送该字段):
|
||||
> - 用户明确要求“通知 / 告知 / 提醒对方 / 让他知道” → 追加 `--notify`
|
||||
> - 用户明确要求“不要通知 / 别提醒 / 悄悄加 / 不要打扰” → **必须改用 `--members` 并传 `--notify=false`**;旧格式 `--users` 始终通知且无法关闭,此时继续用旧格式会违背用户意图
|
||||
> - 用户没提通知需求 → 走 `--members` 时**不传该 flag**,保持不通知;不要自行补上 `--notify`
|
||||
> - 仅 USER 和 CONVERSATION 类型成员会收到通知;被授权对象是 DEPT / TAG 时通知不会送达,**需主动向用户说明这一点**,不要默不作声
|
||||
|
||||
> **跨产品路由说明**:知识库节点的**内容操作**(读取/编辑/块级操作)仍由 `dws doc` 承担:
|
||||
>- 用户说"读某个知识库里的某篇文档" → 先 `node list` 拿到 nodeId,再走 **`dws doc read --node <nodeId>`**
|
||||
>- 用户说"搜文件"(不指定空间) → 走 **`dws drive search`**(全局聚合搜索)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-aitable
|
||||
description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表。不做电子表格单元格读写(走 dingtalk-misc)、文档编辑(走 dingtalk-doc);听记待办入表先用 dingtalk-minutes 提取,再由本 skill 写入。命令前缀:dws aitable。
|
||||
description: 钉钉 AI 表格(多维表)。Use when 用户说 AI表格/多维表/数据表/base/table/建表/查记录/写数据/字段/记录增删改查/筛选/排序/公式/模板搜索/批量导入CSV或JSON/导出/仪表盘/图表/上传附件到表格/按字段类型建表/数据源/创建数据源/更新数据源配置/触发数据源同步/按任务 ID 查询同步状态/获取数据源配置/列出数据源可用来源/获取数据源可同步字段/审批数据同步。不做电子表格单元格读写(走 dingtalk-misc)、文档编辑(走 dingtalk-doc);听记待办入表先用 dingtalk-minutes 提取,再由本 skill 写入。命令前缀:dws aitable。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -28,7 +28,7 @@ metadata:
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcut 发现(按需)
|
||||
|
||||
`aitable` 当前有 93 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
|
||||
`aitable` 当前有 100 条公开 shortcut,完整清单保留在 Runtime Catalog 与 Schema,不在高频产品根 Skill 中重复展开。已知意图直接使用下方的优先路由、意图表或任务 reference;命令已选中时直接执行,只在参数/安全语义不确定时读取 leaf Schema,在当前 Cobra flags 不确定时读取 leaf Help。
|
||||
|
||||
仅当现有路由和 reference 都无法定位低频能力时,才执行 `dws shortcut list --service aitable --format json` 做最后回退;不要为已知高频意图加载完整 Shortcut Catalog 或产品级 Schema。
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
@@ -60,10 +60,11 @@ metadata:
|
||||
| 调整视图列顺序 | `dws aitable view update visible-fields --base-id <ID> --table-id <ID> --view-id <ID> --field-ids <完整有序IDs>` | 先读取字段和当前完整列数组,固定主字段在首位,写后回读精确校验 |
|
||||
| 创建/修改图表前取配置 | `dws aitable +chart-widgets-example` | 命令返回所有图表类型示例;已有合法 config 时直接 create/update |
|
||||
| Base 内 Section/节点移动 | `dws aitable +section-*` | Table/Dashboard/Section 是 Base 内 nsheet 节点,不是独立 Drive 节点 |
|
||||
| 接入外部数据源(审批等) | `dws aitable +datasource-list-sources --base-id <ID> --datasource-type OA` → 解析 result 构造 sourceConfig → `dws aitable +datasource-create --base-id <ID> --datasource-type OA --source-config '<JSON>'` | 当前仅支持 OA 审批;sourceConfig 中 processCode/name/iconUrl/url 须从 list-sources 原样透传;创建后用 `+datasource-sync-status` 查同步结果 |
|
||||
|
||||
### 常用 leaf 直达
|
||||
|
||||
参数已知时直接执行,不探测 Help/Catalog:Base 查看/改名用 `+base-get` / `+base-update`;模板搜索用 `+template-search`,再把真实 templateId 交给 `base create --template-id`;Table 查看/更新用 `+table-get` / `+table-update`;视图创建/复制用 `view create` / `+view-duplicate`;仪表盘创建/更新/读回用 `dashboard create` / `+dashboard-update` / `+dashboard-get`;表单分享用 `+form-share-update` / `+form-share-get`;查看自动化用 `+workflow-list`。
|
||||
参数已知时直接执行,不探测 Help/Catalog:Base 查看/改名用 `+base-get` / `+base-update`;模板搜索用 `+template-search`,再把真实 templateId 交给 `base create --template-id`;Table 查看/更新用 `+table-get` / `+table-update`;视图创建/复制用 `view create` / `+view-duplicate`;仪表盘创建/更新/读回用 `dashboard create` / `+dashboard-update` / `+dashboard-get`;表单分享用 `+form-share-update` / `+form-share-get`;查看自动化用 `+workflow-list`;数据源查看来源用 `+datasource-list-sources`,获取字段用 `+datasource-get-fields`,创建/更新/同步/查状态/查配置用 `+datasource-create` / `+datasource-update` / `+datasource-sync` / `+datasource-sync-status` / `+datasource-get-config`。
|
||||
|
||||
### 低频入口
|
||||
|
||||
@@ -77,6 +78,7 @@ metadata:
|
||||
- 单产品线性任务直接执行,不创建 TodoWrite;只有跨产品或多个独立分支的长任务才建计划,并且只在阶段切换时更新,不在每条 CLI 后刷新状态。
|
||||
- 用户要求资源名带当前时间戳时只取一次并在 Base、Table、Dashboard 等名称中复用同一值;不要为每个资源分别取时间。
|
||||
- JSON 已返回所需字段时立即复用;不得为寻找同一字段改用 `--verbose`、`raw`、`pretty` 重复请求。
|
||||
- 数据源创建前必须先 `+datasource-list-sources` 获取 processCode 等透传字段,不要凭记忆或猜测构造 sourceConfig。
|
||||
|
||||
## 记录输入与结果
|
||||
|
||||
@@ -91,6 +93,7 @@ metadata:
|
||||
## 安全边界
|
||||
|
||||
- 删除不可逆,按 Runtime confirmation 核对真实目标;`base list` 只是最近访问。字段零/多候选、类型不明时停止;多批写保留已完成批次和续跑位置。
|
||||
- 数据源 `+datasource-create` / `+datasource-update` 会触发真实数据同步(全量),执行前确认目标 Base 和 sourceConfig 无误。`+datasource-sync` 同理,单次最多 5 张表。
|
||||
|
||||
## 按需加载
|
||||
|
||||
@@ -107,6 +110,7 @@ metadata:
|
||||
| Base 内 Section/节点移动或清理 | [section](references/aitable-section.md) |
|
||||
| 图表配置 | [dashboard-chart](references/aitable/aitable-dashboard-chart.md) |
|
||||
| 附件、表单、工作流 | 读取 `references/aitable/` 下对应的一个精确文件 |
|
||||
| 数据源接入、同步管理、sourceConfig 构造、同步审批数据到 AI 表格 | [datasource](references/aitable/aitable-datasource.md) |
|
||||
| 产品边界不明确 | [intent-guide](references/intent-guide.md) |
|
||||
|
||||
通用 `references/aitable.md` 仅保留为兼容索引,不是默认入口;正常 Case 不预读。低频能力按意图选择一个最精确的 Reference,禁止连读。
|
||||
@@ -116,6 +120,7 @@ metadata:
|
||||
1. 零/多候选、字段歧义或分页不完整:停止并返回证据;需要后续页时只透传真实 `nextCursor`。
|
||||
2. 类型错误只复核目标字段,不删字段或丢输入;`partial_success` 从 checkpoint 续跑,未知写入先回读。
|
||||
3. 错误包含 `actions` / `available_flags` 时只执行其中的 `next_command`;同一操作最多做一次有证据的参数修正。`retryable=false` 或目标 ID 类型不符时停止,不把 Drive/Wiki/Space/子节点 ID 轮流代入试错。
|
||||
4. 数据源同步 `errorCode=4014` 为幂等冲突(同步运行中重复触发),标记 FAILED 但可稍后重试;非数据源表(sync=false)触发 sync 会返回参数错误,先用 `+base-get` 确认 sync=true。
|
||||
|
||||
## 跨产品边界
|
||||
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# datasource — 数据源同步管理
|
||||
|
||||
将外部数据源(当前仅支持 OA 审批)同步到 AI 表格。完整链路:list-sources → (OA) 选择模板 → get-fields → create → sync-status → sync / update / get-config。
|
||||
|
||||
## 命令一览
|
||||
|
||||
| 命令 | 用途 | 读/写 |
|
||||
|------|------|-------|
|
||||
| `+datasource-list-sources` | 列出可用数据源条目,获取 processCode/name/iconUrl/url | 读 |
|
||||
| `+datasource-get-fields` | 获取可同步字段列表,用于决定 field-ids | 读 |
|
||||
| `+datasource-create` | 创建数据源表并触发首次全量同步 | 写 |
|
||||
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 |
|
||||
| `+datasource-sync` | 手动触发一次同步(最多 5 张表) | 写 |
|
||||
| `+datasource-sync-status` | 查询同步任务状态(RUNNING/FINISHED/FAILED) | 读 |
|
||||
| `+datasource-get-config` | 获取数据源表当前同步配置 | 读 |
|
||||
|
||||
## 典型工作流
|
||||
|
||||
```
|
||||
Step 0 列出可用来源 +datasource-list-sources --base-id <B> --datasource-type OA
|
||||
→ 返回 approvals 数组(通常含多个审批模板)
|
||||
|
||||
Step 0.5 (OA 审批) 选择模板 list-sources 返回的 approvals 数组通常含多个审批模板,需确定目标:
|
||||
- 用户已指定名称(如"采购申请")→ 按 name 精确/模糊匹配
|
||||
· 唯一命中 → 提取该条的 processCode/name/iconUrl/url,继续
|
||||
· 多候选 → 列出匹配项让用户消歧
|
||||
· 零命中 → 停止,提示用户确认名称或从完整列表选择
|
||||
- 用户未指定 → 列出候选模板清单(name + processCode),等用户选择
|
||||
- 禁止:未匹配直接选第一项、或凭记忆猜 processCode
|
||||
注:此步骤针对 OA 审批数据源(多模板场景);其他数据源类型的
|
||||
list-sources 返回结构可能不同,按实际 result 解析即可,
|
||||
不一定需要选择步骤。
|
||||
|
||||
Step 1 (可选) 获取可同步字段 +datasource-get-fields --base-id <B> --datasource-type OA --source-config '<JSON>'
|
||||
→ 决定需要同步哪些字段,得到 field-ids
|
||||
|
||||
Step 2 创建数据源 +datasource-create --base-id <B> --datasource-type OA --source-config '<JSON>'
|
||||
→ sourceConfig 中的 processCode/name/iconUrl/url 来自 Step 0.5 选中的模板
|
||||
→ 返回 tableId + taskId
|
||||
|
||||
Step 3 查询同步结果 +datasource-sync-status --base-id <B> --table-id <T> --task-ids <TASK_ID>
|
||||
→ FINISHED=完成,FAILED=看 errorCode 排查,RUNNING=轮询
|
||||
|
||||
Step 4 (后续) 手动触发同步 +datasource-sync --base-id <B> --table-ids <T1>,<T2>
|
||||
→ 返回新 taskId,再用 sync-status 查结果
|
||||
|
||||
Step 5 (后续) 更新配置 +datasource-update --base-id <B> --table-id <T> --source-config '<JSON>' --auto
|
||||
→ 更新后自动触发一次同步
|
||||
|
||||
查看当前配置 +datasource-get-config --base-id <B> --table-id <T>
|
||||
```
|
||||
|
||||
## sourceConfig 字段协议
|
||||
|
||||
以下字段协议仅适用于 OA 审批数据源(datasourceType=OA),其他数据源类型待后续开放。
|
||||
|
||||
### 须从 list-sources 原样透传(必填)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| processCode | String | OA 审批流程编码 |
|
||||
| name | String | 展示名称 |
|
||||
| iconUrl | String | 图标 URL |
|
||||
| url | String | 跳转链接 |
|
||||
|
||||
### 调用方自行设置
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| dataType | String | 是 | 数据范围类型:`time_range` / `start_time` / `recent_time` |
|
||||
| recentDays | String | 条件 | dataType=recent_time 时有效,取值 `7d`/`30d`/`1y`,默认 `30d` |
|
||||
| startDate | String | 条件 | dataType=time_range 或 start_time 时有效,`yyyy-MM-dd`,默认 30 天前 |
|
||||
| endDate | String | 条件 | dataType=time_range 时有效,`yyyy-MM-dd`,默认当天 |
|
||||
| keepRemovedFields | Boolean | 否 | 是否保留已删除字段,默认 false |
|
||||
| splitParentTableField | Boolean | 否 | 是否拆分父表字段 |
|
||||
|
||||
> list-sources 返回的 keepRemovedFields / splitParentTableField / enableDataSyncOaDetailList 不要透传,由调用方按需设置。
|
||||
|
||||
### 三种 dataType 最小示例
|
||||
|
||||
```json
|
||||
// recent_time — 最近一段时间
|
||||
{"processCode":"PROC-xxxx","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}
|
||||
|
||||
// time_range — 指定起止日期
|
||||
{"processCode":"PROC-xxxx","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"...","url":"..."}
|
||||
|
||||
// start_time — 从某日期至今
|
||||
{"processCode":"PROC-xxxx","name":"采购申请","dataType":"start_time","startDate":"2025-06-01","iconUrl":"...","url":"..."}
|
||||
```
|
||||
|
||||
## autoSyncSetting 频率配置
|
||||
|
||||
仅在 `--auto=true` 时生效。不传时使用下游默认自动同步策略。
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| syncType | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
|
||||
| hourlyInterval | hourly 时 | 正整数,小时间隔 |
|
||||
| scheduleType | scheduled 时 | `daily` / `weekly` / `monthly` |
|
||||
| timeValue | scheduled 时 | `HH:mm` 触发时间 |
|
||||
| selectedMonthDays | monthly 时必填 | 每月几号触发,1-31 |
|
||||
| selectedWeekdays | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
|
||||
| skipNonWorkingDay | 否 | 是否跳过非工作日,默认 false |
|
||||
|
||||
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
|
||||
|
||||
## 命令详情
|
||||
|
||||
### +datasource-list-sources — 列出可用数据源条目
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-list-sources --base-id BASE_ID --datasource-type OA --format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | 是 | 数据源类型,当前仅支持 `OA` |
|
||||
|
||||
返回每条条目包含 `result`(下游原始 JSON 字符串)和 `sourceType`(OA 审批对应 2)。OA 审批场景下 result 为 approvals 数组:
|
||||
|
||||
```json
|
||||
{
|
||||
"approvals": [
|
||||
{
|
||||
"processCode": "PROC-xxxx",
|
||||
"name": "采购申请",
|
||||
"iconUrl": "https://...",
|
||||
"url": "https://...",
|
||||
"keepRemovedFields": false,
|
||||
"splitParentTableField": false,
|
||||
"enableDataSyncOaDetailList": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
调用方应自行解析 result,提取目标模板字段后构造 sourceConfig。
|
||||
|
||||
### +datasource-get-fields — 获取可同步字段列表
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-get-fields --base-id BASE_ID --datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-xxxx","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | 是 | 数据源类型,当前仅支持 `OA` |
|
||||
| `--source-config` | 是 | 源配置 JSON 字符串,结构同 create 的 --source-config |
|
||||
|
||||
返回字段列表(字段 ID、名称、类型等),用于在 create/update 中指定 `--field-ids`。
|
||||
|
||||
### +datasource-create — 创建数据源表
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-create --base-id BASE_ID --datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-xxxx","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}' \
|
||||
--format json
|
||||
|
||||
# 开启自动同步 + 自定义频率
|
||||
dws aitable +datasource-create --base-id BASE_ID --datasource-type OA \
|
||||
--source-config '...' --auto \
|
||||
--auto-sync-setting '{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | 是 | 数据源类型,当前仅支持 `OA` |
|
||||
| `--source-config` | 是 | 源配置 JSON 字符串(见上方字段协议) |
|
||||
| `--auto` | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
|
||||
| `--auto-sync-setting` | 否 | 自动同步频率配置 JSON 字符串,仅 --auto=true 时生效 |
|
||||
| `--field-ids` | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
|
||||
|
||||
返回新建数据源表 tableId 和同步任务 taskId。创建后自动触发一次全量同步,需用 `+datasource-sync-status` 查最终结果。
|
||||
|
||||
### +datasource-update — 更新数据源配置
|
||||
|
||||
```bash
|
||||
# 仅开启自动同步
|
||||
dws aitable +datasource-update --base-id BASE_ID --table-id TABLE_ID --auto --format json
|
||||
|
||||
# 更新源配置
|
||||
dws aitable +datasource-update --base-id BASE_ID --table-id TABLE_ID \
|
||||
--source-config '{"processCode":"PROC-yyyy","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"...","url":"..."}' \
|
||||
--format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--table-id` | 是 | 已有数据源表 ID(sync=true) |
|
||||
| `--source-config` | 否 | 新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖 |
|
||||
| `--auto` | 否 | 是否开启自动同步,不传时保持原设置 |
|
||||
| `--auto-sync-setting` | 否 | 自动同步频率配置 JSON 字符串,仅 --auto=true 时生效;不传时保持原频率配置 |
|
||||
| `--field-ids` | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
|
||||
|
||||
更新后自动触发一次全量同步,返回新 taskId。
|
||||
|
||||
### +datasource-sync — 手动触发同步
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-sync --base-id BASE_ID --table-ids TBL1,TBL2 --format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--table-ids` | 是 | 待同步的数据源表 ID 列表(sync=true),1-5 个 |
|
||||
|
||||
返回结果包含文档链接,可打开查看同步进度。每张表独立提交,部分失败不影响其他表。
|
||||
|
||||
### +datasource-sync-status — 按任务 ID 查询同步状态
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2 --format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--table-id` | 是 | 数据源表 ID(sync=true) |
|
||||
| `--task-ids` | 是 | 同步任务 ID 列表(由 create/update/sync 返回),1-5 个 |
|
||||
|
||||
任务状态:`RUNNING`(进行中)、`FINISHED`(完成)、`FAILED`(失败,含 errorCode + errorMessage)。
|
||||
|
||||
### +datasource-get-config — 获取数据源配置
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-get-config --base-id BASE_ID --table-id TABLE_ID --format json
|
||||
```
|
||||
|
||||
| flag | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--base-id` | 是 | 目标 Base ID |
|
||||
| `--table-id` | 是 | 数据源表 ID(sync=true) |
|
||||
|
||||
返回当前同步配置详情(sourceConfig、是否自动同步、同步状态等)。仅适用于数据源表,普通表会报错。
|
||||
|
||||
## 错误码与排查
|
||||
|
||||
| 场景 | 表现 | 排查 |
|
||||
|------|------|------|
|
||||
| 同步运行中重复触发 | errorCode=4014,status=FAILED | 幂等冲突,稍后重试即可 |
|
||||
| 非数据源表触发 sync | 参数错误返回 | 确认 table 的 sync=true,用 `+base-get` / `+table-list` 检查 |
|
||||
| sourceConfig 缺必填字段 | 创建/更新失败 | 检查 processCode/name/iconUrl/url 是否从 list-sources 原样透传 |
|
||||
| dataType 与时间字段不匹配 | 创建失败 | recent_time 需 recentDays;time_range 需 startDate+endDate;start_time 需 startDate |
|
||||
|
||||
## 能力边界
|
||||
|
||||
| 能力 | 状态 |
|
||||
|------|------|
|
||||
| OA 审批数据源 | 已支持 |
|
||||
| 其他数据源类型 | 待后续开放 |
|
||||
| 全量同步 | 已支持 |
|
||||
| 增量同步 | 待后续开放 |
|
||||
| 自动同步 | 已支持(--auto + autoSyncSetting) |
|
||||
| 删除数据源表 | 走普通表删除,不走 datasource 命令 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- sourceConfig 是 **JSON 字符串**(不是 JSON 对象),CLI flag 传入时需要用单引号包裹
|
||||
- list-sources 返回的 keepRemovedFields / splitParentTableField / enableDataSyncOaDetailList 不要透传,由调用方按需设置
|
||||
- create/update 后自动触发一次同步,返回 taskId;用 sync-status 查最终结果
|
||||
- sync 单次最多 5 张表,超出拆分多次调用
|
||||
- sync-status 单次最多 5 个 taskId,超出拆分多次调用
|
||||
- get-config 仅适用于数据源表(sync=true),普通表会报错
|
||||
@@ -29,7 +29,6 @@ metadata:
|
||||
| `dws contact +list-dept-members` | read | 查看部门成员(仅本部门,不含下级) |
|
||||
| `dws contact +list-followings` | read | 获取当前用户的特别关注列表 |
|
||||
| `dws contact +list-role-members` | read | 查询角色下的成员列表 |
|
||||
| `dws contact +list-roles` | read | 获取企业所有角色(标签)列表 |
|
||||
| `dws contact +list-sub-depts` | read | 查看指定部门的子部门 |
|
||||
| `dws contact +lookup` | read | 按姓名查询某人的完整资料(自动解析 userId 后取详情) |
|
||||
| `dws contact +me` | read | 查看我自己的通讯录资料(姓名/userId/手机/部门/组织,干净投影) |
|
||||
|
||||
@@ -4,6 +4,16 @@
|
||||
|
||||
`dws pat` 管理 Agent 的行为授权。它不管理开放平台应用权限;应用权限使用 `dws dev app permission`(见 [devapp.md](./devapp.md))。
|
||||
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcuts(无专用脚本/recipe 时优先)
|
||||
|
||||
以下 shortcut 同时进入公开 catalog 与 Runtime Schema。先按本 skill 的意图表、脚本和 recipe 路由:存在精确覆盖该场景的专用脚本/recipe 时按其执行;否则用户意图命中时,shortcut 优先于手写原子命令。命令已选中时直接执行;只在参数或安全语义不确定时读取 Agent leaf Schema(例如 `dws schema --cli-path "pat +<shortcut>" --compact --format json`),在当前 Cobra flags 不确定时读取 `dws pat <shortcut> --help`。只有参数映射、接口绑定或 provenance 审计才省略 `--compact`。仅当现有路由和 reference 都无法定位低频能力时,才用 `dws shortcut list --service pat --format json` 批量发现。
|
||||
|
||||
| Shortcut | 风险 | 适用场景 |
|
||||
|---|---|---|
|
||||
| `dws pat +browser-policy` | write | 安全配置 PAT 授权时是否允许打开本地浏览器 |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 命令总览
|
||||
|
||||
### 配置浏览器策略
|
||||
|
||||
@@ -18,7 +18,12 @@ dws wiki +member-remove --workspace <workspaceId> --users <userId> --format json
|
||||
|
||||
## 列表完整性
|
||||
|
||||
`+member-list` 的服务端单次上限为 50,且没有可续游标。结果能证明“本次返回的成员”,不能据此断言大型知识库的完整成员全集。不要伪造 `--page-all` 或不断提高 limit。
|
||||
成员列表服务端单次上限为 50(`--limit` / pageSize 最大 50)。快捷命令 `+member-list` 适合单页列表,但**不支持游标翻页**;超过 50 人必须改用原生 `dws wiki member list` 的 `--next-token` 翻页:首次调用不传,后续把上一次响应中的 `nextToken` 传入 `--next-token`,直到 `hasMore` 为 false。出参 `totalCount` 为全量成员总数,可用于核对是否拉全。不要伪造 `--page-all` 或不断提高 limit 超过 50。
|
||||
|
||||
```bash
|
||||
dws wiki member list --workspace <workspaceId> --limit 50 --format json
|
||||
dws wiki member list --workspace <workspaceId> --next-token <上次返回的 nextToken> --format json
|
||||
```
|
||||
|
||||
## 写入验证
|
||||
|
||||
|
||||
Reference in New Issue
Block a user