Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e675d353bb | ||
|
|
78ed8f27b1 |
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
@@ -14,12 +14,9 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const webhooks = [
|
||||
process.env.DINGTALK_WEBHOOK,
|
||||
process.env.DINGTALK_WEBHOOK_SECONDARY
|
||||
].filter(Boolean);
|
||||
if (webhooks.length === 0) {
|
||||
console.log('⚠️ No DingTalk webhook configured, skipping notification');
|
||||
const webhook = process.env.DINGTALK_WEBHOOK;
|
||||
if (!webhook) {
|
||||
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,15 +39,12 @@ jobs:
|
||||
}
|
||||
};
|
||||
|
||||
await Promise.all(webhooks.map(webhook =>
|
||||
fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
})
|
||||
));
|
||||
await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
|
||||
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
|
||||
console.log('✅ DingTalk notification sent');
|
||||
env:
|
||||
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
|
||||
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
|
||||
|
||||
@@ -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 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
|
||||
.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
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -20,7 +20,6 @@ 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"
|
||||
@@ -63,9 +62,6 @@ 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
|
||||
|
||||
@@ -88,7 +84,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 shortcut-public-e2e-proof
|
||||
policy: test-auth-legacy-compat
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
+5
-63
@@ -13,71 +13,13 @@ if (!fs.existsSync(binaryPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Interactive commands must remain in the terminal's foreground session so
|
||||
// prompts can use /dev/tty. Non-interactive launches use a separate process
|
||||
// group, allowing a signal sent only to this wrapper to reach the full vendor
|
||||
// process tree exactly once.
|
||||
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
|
||||
|
||||
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
|
||||
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
detached: isolateVendorProcessGroup,
|
||||
});
|
||||
|
||||
let spawnFailed = false;
|
||||
let forwardedSignal = null;
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM"];
|
||||
|
||||
function forwardSignal(signal) {
|
||||
forwardedSignal = signal;
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
if (process.platform === "win32") {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (!isolateVendorProcessGroup) {
|
||||
// Ctrl-C is generated for the whole foreground process group, including
|
||||
// the vendor. SIGTERM is not terminal-generated and still needs an
|
||||
// explicit handoff when a process manager targets only this wrapper.
|
||||
if (signal === "SIGTERM") {
|
||||
child.kill(signal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// detached makes the vendor PID the leader of its POSIX process group.
|
||||
// Signal the whole group so any subprocesses inherit the same shutdown.
|
||||
process.kill(-child.pid, signal);
|
||||
} catch (error) {
|
||||
// The group may have completed between the state check and kill.
|
||||
if (error.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const signalHandlers = new Map(
|
||||
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
|
||||
);
|
||||
for (const signal of forwardedSignals) {
|
||||
process.on(signal, signalHandlers.get(signal));
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
spawnFailed = true;
|
||||
console.error(error.message);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
for (const forwarded of forwardedSignals) {
|
||||
process.removeListener(forwarded, signalHandlers.get(forwarded));
|
||||
}
|
||||
|
||||
const exitSignal = forwardedSignal || signal;
|
||||
if (exitSignal && process.platform !== "win32") {
|
||||
process.kill(process.pid, exitSignal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = spawnFailed || code === null ? 1 : code;
|
||||
});
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
# 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` 手动触发。
|
||||
@@ -0,0 +1,310 @@
|
||||
# Attendance Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:Lark CLI 1.0.87
|
||||
> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。
|
||||
- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。
|
||||
- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。
|
||||
- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。
|
||||
- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` |
|
||||
| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` |
|
||||
| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 |
|
||||
| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut |
|
||||
| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut |
|
||||
| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` |
|
||||
| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` |
|
||||
| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` |
|
||||
| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable |
|
||||
| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable |
|
||||
| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 |
|
||||
| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable |
|
||||
| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog |
|
||||
| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable |
|
||||
| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 |
|
||||
| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog |
|
||||
| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。
|
||||
- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。
|
||||
- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。
|
||||
- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。
|
||||
- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。
|
||||
- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。
|
||||
- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。
|
||||
- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。
|
||||
- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。
|
||||
- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。
|
||||
- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。
|
||||
2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。
|
||||
3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。
|
||||
4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。
|
||||
|
||||
### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。
|
||||
- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。
|
||||
- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。
|
||||
- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。
|
||||
- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。
|
||||
- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。
|
||||
- 安全证据句柄:`ATT-REPORT-NULL-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。
|
||||
- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。
|
||||
- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。
|
||||
- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。
|
||||
- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。
|
||||
- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。
|
||||
2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。
|
||||
3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。
|
||||
4. 假期报表至少覆盖已知非空、合法空和未知假期类型。
|
||||
5. 分页/partial 分支和远端零残留通过。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。
|
||||
|
||||
### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。
|
||||
- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。
|
||||
- 安全证据句柄:`ATT-CHECK-PAGE-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。
|
||||
- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。
|
||||
- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。
|
||||
- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。
|
||||
- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。
|
||||
|
||||
### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。
|
||||
- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。
|
||||
- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。
|
||||
- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。
|
||||
- 安全证据句柄:`ATT-FIXTURE-GAP-01`。
|
||||
|
||||
#### B. 需要的测试基础设施与合同
|
||||
|
||||
- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。
|
||||
- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。
|
||||
- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。
|
||||
- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。
|
||||
- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。
|
||||
2. create→get→update→restore/delete 覆盖班次、考勤组与排班。
|
||||
3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。
|
||||
4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。
|
||||
5. 测试结束远端和本地均零残留。
|
||||
|
||||
在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。
|
||||
|
||||
### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。
|
||||
- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。
|
||||
- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。
|
||||
- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。
|
||||
- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。
|
||||
- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。
|
||||
- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。
|
||||
- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。
|
||||
2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。
|
||||
3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。
|
||||
|
||||
在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。
|
||||
|
||||
### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。
|
||||
- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。
|
||||
- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。
|
||||
- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。
|
||||
- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。
|
||||
- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。
|
||||
- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。
|
||||
- 改动需 additive/versioned;现有详情业务字段保持兼容。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。
|
||||
2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。
|
||||
3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。
|
||||
|
||||
下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。
|
||||
|
||||
### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。
|
||||
- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。
|
||||
- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。
|
||||
- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。
|
||||
- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。
|
||||
- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。
|
||||
- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。
|
||||
2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。
|
||||
3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。
|
||||
4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。
|
||||
|
||||
能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。
|
||||
|
||||
### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。
|
||||
- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。
|
||||
- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。
|
||||
- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。
|
||||
- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。
|
||||
- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。
|
||||
- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。
|
||||
- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。
|
||||
2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。
|
||||
3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。
|
||||
4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。
|
||||
|
||||
下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|---|
|
||||
| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 |
|
||||
|
||||
无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 |
|
||||
| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 |
|
||||
| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 |
|
||||
| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 |
|
||||
| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 |
|
||||
| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 |
|
||||
| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 |
|
||||
| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 |
|
||||
| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS |
|
||||
| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS |
|
||||
|
||||
### 6.1 clean-HEAD live 发布门状态
|
||||
|
||||
| 叶子 | clean executable HEAD 双层证据 | 发布状态 |
|
||||
|---|---|---|
|
||||
| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS |
|
||||
| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS |
|
||||
|
||||
pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。
|
||||
|
||||
上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。
|
||||
- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。
|
||||
- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。
|
||||
- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -0,0 +1,198 @@
|
||||
# Mail Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:lark-cli 1.0.87
|
||||
> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。
|
||||
|
||||
上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。
|
||||
|
||||
仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
|
||||
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
|
||||
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
|
||||
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
|
||||
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
|
||||
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
|
||||
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
|
||||
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
|
||||
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
|
||||
| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 |
|
||||
| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable |
|
||||
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
|
||||
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable |
|
||||
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable |
|
||||
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
|
||||
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
|
||||
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
|
||||
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
|
||||
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
|
||||
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
|
||||
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
|
||||
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
|
||||
| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-Mail-001` — 可验证的发送生命周期
|
||||
|
||||
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
|
||||
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
|
||||
- 所需接口合同:
|
||||
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
|
||||
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
|
||||
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
|
||||
- 支持幂等键,或明确 unknown commit 不可自动重试。
|
||||
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
|
||||
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
|
||||
|
||||
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
|
||||
|
||||
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
|
||||
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
|
||||
- 所需接口合同:
|
||||
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
|
||||
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
|
||||
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
|
||||
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
|
||||
|
||||
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
|
||||
|
||||
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
|
||||
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
|
||||
- 所需接口合同:
|
||||
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
|
||||
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
|
||||
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
|
||||
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
|
||||
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
|
||||
|
||||
### `DS-Mail-004` — 已读回执与签名
|
||||
|
||||
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
|
||||
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
|
||||
- 所需接口合同:
|
||||
- 消息详情公开稳定回执请求状态和请求者身份类型。
|
||||
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
|
||||
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
|
||||
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
|
||||
|
||||
### `DS-Mail-005` — 新邮件监听的订阅生命周期
|
||||
|
||||
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
|
||||
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
|
||||
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
|
||||
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
|
||||
|
||||
### `DS-Mail-006` — 模板附件与内联图片事务
|
||||
|
||||
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
|
||||
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
|
||||
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
|
||||
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
|
||||
|
||||
### `DS-Mail-007` — 联系人写操作的稳定身份
|
||||
|
||||
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
|
||||
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
|
||||
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
|
||||
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
|
||||
|
||||
### `DS-Mail-008` — 统一成功、空结果与分页协议
|
||||
|
||||
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
|
||||
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。
|
||||
- 所需接口合同:
|
||||
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
|
||||
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
|
||||
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
|
||||
- 保留兼容期,但提供 capability/version 让上游安全切换。
|
||||
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
|
||||
|
||||
### `DS-Mail-009` — 安全租户与真实 E2E fixture
|
||||
|
||||
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
|
||||
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
|
||||
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
|
||||
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
|
||||
|
||||
### `DS-Mail-010` — 草稿/模板可证明的清理终态
|
||||
|
||||
- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。
|
||||
- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。
|
||||
- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。
|
||||
- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。
|
||||
- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|
|
||||
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
|
||||
| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable |
|
||||
| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable |
|
||||
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
|
||||
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
|
||||
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
|
||||
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
|
||||
| `+signature` | no | 缺签名读取资源 | platform unavailable |
|
||||
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
|
||||
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
|
||||
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|
|
||||
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
|
||||
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
|
||||
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
|
||||
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
|
||||
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 |
|
||||
| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 |
|
||||
| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 |
|
||||
| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 |
|
||||
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` |
|
||||
| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS |
|
||||
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 |
|
||||
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED |
|
||||
|
||||
### 6.1 clean executable HEAD 双层证据
|
||||
|
||||
| 公开入口 | exact Shortcut + owning raw 证据 | 状态 |
|
||||
|---|---|---|
|
||||
| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 |
|
||||
| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
|
||||
8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
|
||||
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
|
||||
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
|
||||
- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。
|
||||
- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。
|
||||
- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -1,17 +1,7 @@
|
||||
{
|
||||
"generated_at": "2026-08-24T12:22:05.108140",
|
||||
"count": 426,
|
||||
"generated_at": "2026-08-20T11:51:44.156046",
|
||||
"count": 416,
|
||||
"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",
|
||||
@@ -282,76 +272,6 @@
|
||||
"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",
|
||||
@@ -2343,134 +2263,102 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+by-mobile",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+dept-members",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-dept-members",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-followings",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-role-members",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-roles",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-sub-depts",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+lookup",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+me",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+org",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+resolve-dept",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+search-mobile",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+search-user",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+team",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
@@ -3705,16 +3593,6 @@
|
||||
"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",
|
||||
@@ -3756,24 +3634,18 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "sheet",
|
||||
"command": "+list-sheets",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"suite": "read",
|
||||
"service": "sheet",
|
||||
"command": "+read",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
|
||||
"availability": "available"
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3985,26 +3857,6 @@
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+query",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+update",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
|
||||
@@ -10,7 +10,6 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
|
||||
@@ -1295,17 +1295,6 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
}
|
||||
|
||||
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
|
||||
hasTarget := false
|
||||
for _, id := range subscribeIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
hasTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTarget {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
|
||||
|
||||
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
oldStop := personalStopConsumers
|
||||
oldQuery := personalQueryStatus
|
||||
oldFind := personalFindProcess
|
||||
@@ -746,18 +746,6 @@ func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallb
|
||||
personalSignalProcess = oldSignal
|
||||
}()
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
t.Fatal("targeted stop called without a subscribe_id")
|
||||
return transport.ConsumerStopResp{}, nil
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy status queried without a subscribe_id")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
|
||||
t.Fatalf("empty target stop = %v", err)
|
||||
}
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
|
||||
}
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
|
||||
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
|
||||
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
|
||||
}
|
||||
|
||||
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
registered := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "markdown" {
|
||||
registered++
|
||||
}
|
||||
}
|
||||
if registered != 0 {
|
||||
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
|
||||
}
|
||||
|
||||
type route struct {
|
||||
canonical string
|
||||
confirmation string
|
||||
flags []string
|
||||
}
|
||||
routes := map[string]route{
|
||||
"create": {
|
||||
canonical: "markdown.create", confirmation: "not_required",
|
||||
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
|
||||
},
|
||||
"fetch": {
|
||||
canonical: "markdown.fetch", confirmation: "not_required",
|
||||
flags: []string{"node", "output", "space-id", "workspace"},
|
||||
},
|
||||
"overwrite": {
|
||||
canonical: "markdown.overwrite", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
|
||||
},
|
||||
"patch": {
|
||||
canonical: "markdown.patch", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
|
||||
},
|
||||
"diff": {
|
||||
canonical: "markdown.diff", confirmation: "not_required",
|
||||
flags: []string{"context", "file", "node", "version", "version2"},
|
||||
},
|
||||
}
|
||||
|
||||
group := mustFindCommand(t, root, "markdown")
|
||||
children := map[string]bool{}
|
||||
for _, child := range group.Commands() {
|
||||
children[child.Name()] = true
|
||||
}
|
||||
if len(children) != len(routes) {
|
||||
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
|
||||
}
|
||||
|
||||
for name, want := range routes {
|
||||
leaf := mustFindCommand(t, root, "markdown", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !children[name] {
|
||||
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
|
||||
}
|
||||
for _, flag := range want.flags {
|
||||
if leaf.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
|
||||
}
|
||||
}
|
||||
if shortcut.InPublicCatalog("markdown", "+"+name) {
|
||||
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
|
||||
}
|
||||
|
||||
meta, ok := cli.ResolveMeta("markdown " + name)
|
||||
if !ok {
|
||||
t.Errorf("markdown %s missing from assembled Schema", name)
|
||||
continue
|
||||
}
|
||||
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
|
||||
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
|
||||
}
|
||||
if meta.Safety.Confirmation != want.confirmation {
|
||||
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
|
||||
}
|
||||
|
||||
tool := tools[want.canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("markdown %s missing from full delivery Schema", name)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("markdown %s availability=%q, want available", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
driveShortcuts := map[string]string{
|
||||
"+copy": "drive.shortcut_copy",
|
||||
"+delete": "drive.shortcut_delete",
|
||||
"+find-file": "drive.shortcut_find_file",
|
||||
"+list": "drive.shortcut_list",
|
||||
"+move": "drive.shortcut_move",
|
||||
"+publish-get": "drive.shortcut_publish_get",
|
||||
"+recycle-restore": "drive.shortcut_recycle_restore",
|
||||
"+rename": "drive.shortcut_rename",
|
||||
"+version-download": "drive.shortcut_version_download",
|
||||
"+version-get": "drive.shortcut_version_get",
|
||||
"+version-history": "drive.shortcut_version_history",
|
||||
"+version-revert": "drive.shortcut_version_revert",
|
||||
}
|
||||
for name, canonical := range driveShortcuts {
|
||||
leaf := mustFindCommand(t, root, "drive", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !shortcut.InPublicCatalog("drive", name) {
|
||||
t.Errorf("drive %s is not in the public Shortcut catalog", name)
|
||||
}
|
||||
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
|
||||
}
|
||||
|
||||
ordinaryRoutes := map[string]string{
|
||||
"drive permission list": "drive.list_permission",
|
||||
"drive pull": "drive.folder_pull",
|
||||
"drive push": "drive.folder_push",
|
||||
"drive status": "drive.folder_status",
|
||||
"drive sync": "drive.folder_sync",
|
||||
"wiki node list": "wiki.list_nodes",
|
||||
}
|
||||
for cliPath, canonical := range ordinaryRoutes {
|
||||
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
|
||||
t.Helper()
|
||||
meta, ok := cli.ResolveMeta(cliPath)
|
||||
if !ok {
|
||||
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
|
||||
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 425
|
||||
publicShortcutCount = 415
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 482
|
||||
schemaPublishedShortcutCount = 453
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 425
|
||||
publiclyDeliveredShortcutCount = 415
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -271,60 +271,6 @@ 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 {
|
||||
@@ -336,25 +282,6 @@ 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,
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
for canonical, command := range map[string]string{
|
||||
"whiteboard.shortcut_query": "+query",
|
||||
"whiteboard.shortcut_update": "+update",
|
||||
} {
|
||||
leaf, _, err := root.Find([]string{"whiteboard", command})
|
||||
if err != nil || leaf == nil || leaf.Name() != command {
|
||||
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
|
||||
} else if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("public %s missing from delivery Schema surface", canonical)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("%s availability=%q, want available", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("%s missing composite adapter reason", canonical)
|
||||
}
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4243,7 +4243,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"start-block": "start-block-id",
|
||||
"url": "node",
|
||||
},
|
||||
Blocked: []string{"comment-id", "comment-key", "content-format", "dentry-id", "doc-format", "folder", "folder-id", "id", "job-id", "name", "parent-id", "range", "role", "space-id", "task-id", "template-id", "workspace", "workspace-id"},
|
||||
Blocked: []string{"comment-id", "comment-key", "content-format", "dentry-id", "doc-format", "folder", "folder-id", "id", "job-id", "name", "parent-id", "range", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Ambiguous: []string{"block-id"},
|
||||
},
|
||||
{
|
||||
@@ -4859,7 +4859,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"output-path": "output",
|
||||
"save-path": "output",
|
||||
},
|
||||
Blocked: []string{"before-block-id", "comment-id", "comment-key", "content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "job-id", "name", "parent-block-id", "parent-id", "ref-block-id", "reference-block-id", "revision", "role", "space-id", "task-id", "template-id", "workspace", "workspace-id"},
|
||||
Blocked: []string{"before-block-id", "comment-id", "comment-key", "content-file", "dentry-id", "file", "file-path", "folder", "folder-id", "job-id", "name", "parent-block-id", "parent-id", "ref-block-id", "reference-block-id", "revision", "role", "space-id", "task-id", "template-id", "version", "workspace", "workspace-id"},
|
||||
Ambiguous: []string{"block-id"},
|
||||
},
|
||||
{
|
||||
@@ -5723,7 +5723,7 @@ var generatedParamAliases = []ParamAliasEntry{
|
||||
"top": "limit",
|
||||
"wiki-workspace-id": "workspace",
|
||||
},
|
||||
Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "page", "permission", "role", "space", "space-id", "storage-space-id"},
|
||||
Blocked: []string{"count", "cursor", "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", "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", "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"},
|
||||
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":["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."}
|
||||
"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."}
|
||||
},
|
||||
|
||||
"validation_fixture": {
|
||||
|
||||
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
|
||||
}
|
||||
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
|
||||
if parameters[flagName]["required"] != false {
|
||||
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
|
||||
}
|
||||
@@ -315,15 +315,6 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
if parameters["max-depth"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
|
||||
}
|
||||
if got := parameters["version"]["property"]; got != "historyVersion" {
|
||||
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
|
||||
}
|
||||
if parameters["version"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
|
||||
}
|
||||
if got := parameters["password"]["property"]; got != "password" {
|
||||
t.Fatalf("doc read --password property = %#v, want password", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
|
||||
@@ -76,7 +76,6 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"agoal scorecard detail",
|
||||
"agoal scorecard entity-detail",
|
||||
"agoal scorecard update",
|
||||
"agoal scorecard search-entities",
|
||||
"agoal strategy detail",
|
||||
"agoal strategy list",
|
||||
"agoal strategy update",
|
||||
|
||||
@@ -408,7 +408,6 @@ 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.",
|
||||
@@ -477,7 +476,6 @@ 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.",
|
||||
@@ -666,7 +664,6 @@ 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,24 +151,6 @@ 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,7 +15,6 @@ package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -465,34 +464,3 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ func newAgoalCommand() *cobra.Command {
|
||||
dws agoal scorecard detail 获取计分卡详情
|
||||
dws agoal scorecard entity-detail 获取计分卡实体详情
|
||||
dws agoal scorecard update 更新计分卡
|
||||
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
|
||||
dws agoal user rules 获取用户规则
|
||||
dws agoal user objectives 查询用户目标列表
|
||||
dws agoal report list-statistics 获取周月报数据跟催列表
|
||||
@@ -360,37 +359,7 @@ scopeType 支持:
|
||||
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
|
||||
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
scorecardSearchContentCmd := &cobra.Command{
|
||||
Use: "search-entities",
|
||||
Short: "搜索计分卡指标与关键事项",
|
||||
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
|
||||
Example: ` dws agoal scorecard search-entities --keyword "业绩"
|
||||
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"keyword": mustGetFlag(cmd, "keyword"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
|
||||
toolArgs["page"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
|
||||
toolArgs["pageSize"] = v
|
||||
}
|
||||
return callMCPTool("search_score_card_entities", toolArgs)
|
||||
},
|
||||
}
|
||||
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
|
||||
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
|
||||
|
||||
// ── user: 用户目标管理 ──────────────────────────────────────
|
||||
|
||||
|
||||
@@ -8422,416 +8422,6 @@ 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,
|
||||
@@ -8842,7 +8432,6 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
attachmentCmd, templateCmd,
|
||||
advpermCmd,
|
||||
sectionCmd,
|
||||
datasourceCmd,
|
||||
)
|
||||
|
||||
// 批量注册 --base 作为 --base-id 的隐藏别名
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -504,147 +504,3 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+59
-273
@@ -1429,14 +1429,9 @@ func newDocCommand() *cobra.Command {
|
||||
readCmd := &cobra.Command{
|
||||
Use: "read",
|
||||
Short: "读取文档内容 (Markdown)",
|
||||
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。
|
||||
|
||||
互联网公开文档(含开启密码保护的)可传入公开链接;设置了访问密码时通过 --password 提供。
|
||||
--version 读取指定历史版本内容(版本号从 dws doc version list 获取,0 表示文档初始版本,需要文档编辑权限);缺省读最新版。`,
|
||||
Long: `获取文档内容,以 Markdown 格式返回。支持传入文档 URL 或 ID。`,
|
||||
Example: ` dws doc read --node DOC_ID
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"
|
||||
dws doc read --node PUBLIC_URL --password <ACCESS_PASSWORD>
|
||||
dws doc read --node DOC_ID --version 3`,
|
||||
dws doc read --node "https://alidocs.dingtalk.com/i/nodes/<DOC_UUID>"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -1446,15 +1441,6 @@ func newDocCommand() *cobra.Command {
|
||||
"dws doc read --node DOC_ID --content-format jsonml"); err != nil {
|
||||
return err
|
||||
}
|
||||
password, _ := cmd.Flags().GetString("password")
|
||||
historyVersion := 0
|
||||
historyVersionSet := cmd.Flags().Changed("version")
|
||||
if historyVersionSet {
|
||||
historyVersion, _ = cmd.Flags().GetInt("version")
|
||||
if historyVersion < 0 {
|
||||
return fmt.Errorf("--version 必须为非负整数历史版本号(0 表示初始版本,版本号从 dws doc version list 获取),当前值: %d", historyVersion)
|
||||
}
|
||||
}
|
||||
format, _ := cmd.Flags().GetString("content-format")
|
||||
scope, _ := cmd.Flags().GetString("scope")
|
||||
tags, _ := cmd.Flags().GetString("tags")
|
||||
@@ -1495,18 +1481,15 @@ func newDocCommand() *cobra.Command {
|
||||
startBlockID,
|
||||
endBlockID,
|
||||
outputPath,
|
||||
password,
|
||||
historyVersion,
|
||||
historyVersionSet,
|
||||
)
|
||||
}
|
||||
if format == "jsonml" {
|
||||
outputPath, _ := cmd.Flags().GetString("output")
|
||||
return runDocReadJsonML(nodeID, outputPath, password, historyVersion, historyVersionSet)
|
||||
return runDocReadJsonML(cmd, nodeID, outputPath)
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
|
||||
return callMCPTool("get_document_content", toolArgs)
|
||||
return callMCPTool("get_document_content", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(readCmd, LeafSpec{
|
||||
@@ -1533,8 +1516,6 @@ func newDocCommand() *cobra.Command {
|
||||
UseWhen: []string{
|
||||
"用户要读取钉钉在线文字文档(adoc)正文(Markdown)时",
|
||||
"用户直接粘贴文档 URL 且无其他指令时(默认读内容)",
|
||||
"互联网公开文档(含设置密码保护的公开链接)时配合 --password 提供访问密码",
|
||||
"要读取指定历史版本内容时用 --version(版本号来自 doc version list,0 表示初始版本,需要编辑权限)",
|
||||
"只需标题大纲、指定块区间/单块或特定 JSONML tags 时使用 --content-format jsonml 与 --scope",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
@@ -1552,11 +1533,9 @@ func newDocCommand() *cobra.Command {
|
||||
{Name: "content-format", Property: "format", Required: boolPtr(false)},
|
||||
{Name: "end-block-id", Required: boolPtr(false)},
|
||||
{Name: "max-depth", Required: boolPtr(false), InterfaceType: "integer"},
|
||||
{Name: "password", Property: "password", Required: boolPtr(false)},
|
||||
{Name: "scope", Required: boolPtr(false)},
|
||||
{Name: "start-block-id", Required: boolPtr(false), RequiredWhen: "--scope=range or --scope=section"},
|
||||
{Name: "tags", Required: boolPtr(false), RequiredWhen: "--scope=tags"},
|
||||
{Name: "version", Property: "historyVersion", Required: boolPtr(false), InterfaceType: "integer"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -2701,8 +2680,6 @@ WARNING: --mode overwrite 为破坏性写入,会清空原文档全部内容。
|
||||
readCmd.Flags().Int("max-depth", 0, "筛选遍历最大深度, 0 表示不限(仅 --scope 时生效)")
|
||||
readCmd.Flags().String("start-block-id", "", "range/section 起始块 ID(节点 uuid); scope=range/section 时必填")
|
||||
readCmd.Flags().String("end-block-id", "", "range 结束块 ID(节点 uuid); \"-1\"或空=到文档末尾(仅 scope=range 生效)")
|
||||
readCmd.Flags().String("password", "", "互联网公开文档开启密码保护时的访问密码;普通文档无需传入")
|
||||
readCmd.Flags().Int("version", 0, "读取指定历史版本内容(版本号从 doc version list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版")
|
||||
cli.AnnotateRuntimeFlagEnum(readCmd, "scope", "outline", "range", "section", "tags")
|
||||
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "tags", "--scope=tags")
|
||||
cli.AnnotateRuntimeFlagRequiredWhen(readCmd, "start-block-id", "--scope=range or --scope=section")
|
||||
@@ -3556,20 +3533,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
permissionAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加文档协作者",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为指定文档(或文件夹/文件)添加一个或多个协作成员,并授予指定角色。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
通过 --user 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色(大小写不敏感):
|
||||
支持的角色 (--role)(必须大写):
|
||||
MANAGER 管理员,可读写、管理成员
|
||||
EDITOR 编辑者,可查看、编辑、上传内容
|
||||
DOWNLOADER 查看下载者,可查看并下载内容
|
||||
@@ -3577,46 +3545,29 @@ 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 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"}]'`,
|
||||
dws doc permission add --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1 --role MANAGER --workspace WS_ID`,
|
||||
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 := validateMembersExclusivity(cmd); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3653,9 +3604,7 @@ 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"},
|
||||
@@ -3664,31 +3613,18 @@ 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: "更新文档协作者权限",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新指定节点已有协作者的权限角色。
|
||||
Long: `更新指定节点已有协作者的权限角色(仅支持 USER 类型成员)。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--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)(大小写不敏感):
|
||||
支持的角色 (--role)(必须大写):
|
||||
MANAGER 管理员
|
||||
EDITOR 编辑者
|
||||
DOWNLOADER 查看下载者
|
||||
@@ -3698,43 +3634,26 @@ 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 --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"}]'`,
|
||||
dws doc permission update --node DOC_ID --users uid1,uid2 --role READER`,
|
||||
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 := validateMembersExclusivity(cmd); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3768,9 +3687,7 @@ 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"},
|
||||
@@ -3779,13 +3696,11 @@ 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",
|
||||
@@ -3793,14 +3708,11 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
Short: "查询文档协作者列表",
|
||||
Long: `查询指定节点的协作者列表,返回每位成员的 userId、姓名、角色等信息。
|
||||
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
|
||||
操作者需满足该节点配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。`,
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 200)。
|
||||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围。`,
|
||||
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 MANAGER,EDITOR
|
||||
dws doc permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
|
||||
dws doc permission list --node DOC_ID --limit 100
|
||||
dws doc permission list --node DOC_ID --filter-role MANAGER,EDITOR`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id")
|
||||
if err != nil {
|
||||
@@ -3809,13 +3721,14 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
}
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
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 v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -3853,75 +3766,50 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
{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,最大 50")
|
||||
permissionListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
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: `从指定节点移除一个或多个协作成员的权限。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--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
|
||||
Long: `从指定节点移除一个或多个协作成员的权限(仅支持 USER 类型)。
|
||||
|
||||
移除后相关用户将无法通过该节点的直接授权访问内容(若有父节点继承权限则仍可通过继承权限访问)。
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口移除。
|
||||
- 操作者须满足该节点配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 操作者需在该节点具备 EDITOR 及以上角色(OWNER / MANAGER / EDITOR)。
|
||||
- 单次请求最多 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 DOC_ID --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
|
||||
dws doc permission remove --node "https://alidocs.dingtalk.com/i/nodes/xxx" --users uid1`,
|
||||
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 := validateMembersExclusivity(cmd); err != nil {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"userIds": userIds,
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -3931,11 +3819,8 @@ 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: "user_required", Idempotency: "unknown",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -3958,7 +3843,6 @@ 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"},
|
||||
@@ -3966,10 +3850,9 @@ 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 隐藏别名
|
||||
@@ -4869,30 +4752,16 @@ func wrapDocDeprecatedToTarget(cmd *cobra.Command, targetCmd string) {
|
||||
}
|
||||
}
|
||||
|
||||
// applyDocReadAccessParams 把 doc read 的访问参数(互联网公开文档密码、历史版本号)
|
||||
// 附加到 get_document_content 请求上;空密码或未显式设置版本时不发送对应字段,
|
||||
// 显式 --version 0 表示读取文档初始版本。
|
||||
func applyDocReadAccessParams(args map[string]any, password string, historyVersion int, historyVersionSet bool) {
|
||||
if password != "" {
|
||||
args["password"] = password
|
||||
}
|
||||
if historyVersionSet && historyVersion >= 0 {
|
||||
args["historyVersion"] = historyVersion
|
||||
}
|
||||
}
|
||||
|
||||
// resolveContentFromFlags 从 --content-file / --content-path / --content / --markdown 获取文档内容。
|
||||
// 优先级:--content-file/--content-path > --content > --markdown(已弃用别名,向后兼容)。
|
||||
func runDocReadJsonML(nodeID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
|
||||
func runDocReadJsonML(_ *cobra.Command, nodeID string, outputPath string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
toolArgs := map[string]any{
|
||||
resultText, err := callMCPToolReturnText(ctx, "get_document_content", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"format": "jsonml",
|
||||
}
|
||||
applyDocReadAccessParams(toolArgs, password, historyVersion, historyVersionSet)
|
||||
resultText, err := callMCPToolReturnText(ctx, "get_document_content", toolArgs)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -4944,7 +4813,7 @@ func runDocReadJsonML(nodeID, outputPath, password string, historyVersion int, h
|
||||
|
||||
// runDocReadScope calls get_document_content with JSONML filtering parameters
|
||||
// and preserves the returned read-only fragment container.
|
||||
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath, password string, historyVersion int, historyVersionSet bool) error {
|
||||
func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool, startBlockID, endBlockID, outputPath string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -4964,7 +4833,6 @@ func runDocReadScope(nodeID, scope, tags string, maxDepth int, maxDepthSet bool,
|
||||
if endBlockID != "" && scope == "range" {
|
||||
args["endBlockId"] = endBlockID
|
||||
}
|
||||
applyDocReadAccessParams(args, password, historyVersion, historyVersionSet)
|
||||
|
||||
resultText, err := callMCPToolReturnTextOnServer(ctx, "doc", "get_document_content", args)
|
||||
if err != nil {
|
||||
@@ -5293,85 +5161,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ func TestCrossPlatformCoverageRunDocReadJSONMLCoverage(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{steps: []scriptedToolStep{tc.step}}
|
||||
installScriptedCaller(t, caller)
|
||||
err := runDocReadJsonML("node", tc.output, "", 0, false)
|
||||
err := runDocReadJsonML(&cobra.Command{}, "node", tc.output)
|
||||
if tc.wantFail && err == nil {
|
||||
t.Fatal("expected failure")
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestCrossPlatformCoverageDocReadScopeFlagsAndValidation(t *testing.T) {
|
||||
if err != nil || len(remaining) != 0 {
|
||||
t.Fatalf("find read: remaining=%v err=%v", remaining, err)
|
||||
}
|
||||
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "password", "version"} {
|
||||
for _, name := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
|
||||
if read.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("doc read missing --%s", name)
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := installDocReadScopeCaller(t, tt.caller)
|
||||
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output, "", 0, false)
|
||||
err := runDocReadScope("doc-1", "", "", 0, false, "", "ignored", tt.output)
|
||||
if tt.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, tt.wantErr)
|
||||
@@ -277,7 +277,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`}
|
||||
stdout := installDocReadScopeCaller(t, caller)
|
||||
outputPath := filepath.Join(t.TempDir(), "fragment.json")
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
|
||||
t.Fatalf("runDocReadScope: %v", err)
|
||||
}
|
||||
var receipt map[string]any
|
||||
@@ -292,7 +292,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
t.Run("human output reports missing fragment", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{}`, format: "raw"}
|
||||
stdout := installDocReadScopeCaller(t, caller)
|
||||
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", "", "", 0, false); err != nil {
|
||||
if err := runDocReadScope("doc-1", "", "", 0, false, "", "", ""); err != nil {
|
||||
t.Fatalf("runDocReadScope: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "未匹配到节点") {
|
||||
@@ -304,7 +304,7 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{}]"}`, format: "raw"}
|
||||
stdout := installDocReadScopeCaller(t, caller)
|
||||
outputPath := filepath.Join(t.TempDir(), "fragment.json")
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath, "", 0, false); err != nil {
|
||||
if err := runDocReadScope("doc-1", "outline", "", 0, false, "", "", outputPath); err != nil {
|
||||
t.Fatalf("runDocReadScope: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "JSONML fragment 已写入 "+outputPath) {
|
||||
@@ -312,93 +312,3 @@ func TestCrossPlatformCoverageRunDocReadScopeResponseBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageDocReadAccessParamsForwarding 验证 doc read 三条读取路径
|
||||
// 都把 --password / --version 透传到 get_document_content,且非法版本号在
|
||||
// 本地校验阶段被拒绝、不产生远端调用。
|
||||
func TestCrossPlatformCoverageDocReadAccessParamsForwarding(t *testing.T) {
|
||||
t.Run("markdown path forwards password and history version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--password", "pw", "--version", "7"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "password": "pw", "historyVersion": 7}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jsonml path forwards password and history version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"root\",{}]","revision":7}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--content-format", "jsonml", "--password", "pw", "--version", "7"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "password": "pw", "historyVersion": 7}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scope path forwards password and history version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"jsonml":"[\"fragment\",{},[\"h1\",{},\"t\"]]"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller,
|
||||
"read", "--node", "doc-1", "--content-format", "jsonml", "--scope", "outline",
|
||||
"--password", "pw", "--version", "7"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "format": "jsonml", "scope": "outline", "password": "pw", "historyVersion": 7}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("negative history version is rejected locally", func(t *testing.T) {
|
||||
for _, raw := range []string{"-1", "-3"} {
|
||||
caller := &docReadScopeCaller{}
|
||||
err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "--version 必须为非负整数") {
|
||||
t.Fatalf("--version %s error = %v, want invalid --version", raw, err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("--version %s produced %d remote calls, want 0", raw, len(caller.calls))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit zero history version forwards initial version", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1", "--version", "0"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1", "historyVersion": 0}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unset history version is not forwarded", func(t *testing.T) {
|
||||
caller := &docReadScopeCaller{text: `{"markdown":"body"}`}
|
||||
if err := executeDocReadScopeCommand(t, caller, "read", "--node", "doc-1"); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
want := map[string]any{"nodeId": "doc-1"}
|
||||
if caller.calls[0].tool != "get_document_content" || !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("call = %#v, want get_document_content %#v", caller.calls[0], want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+44
-141
@@ -2042,55 +2042,27 @@ func newDriveCommand() *cobra.Command {
|
||||
drivePermAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加协作者",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为文档空间节点添加协作成员并授予指定角色。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--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。`,
|
||||
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
|
||||
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 --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"}]'`,
|
||||
dws drive permission add --node DOC_ID --users uid1,uid2 --role EDITOR`,
|
||||
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 := validateMembersExclusivity(cmd); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2131,9 +2103,7 @@ 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"},
|
||||
@@ -2141,64 +2111,35 @@ 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: `更新文档空间节点已有协作者的权限角色。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--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"}]'`,
|
||||
支持的角色 (--role): MANAGER / EDITOR / DOWNLOADER / READER`,
|
||||
Example: ` dws drive permission update --node DOC_ID --users uid1 --role EDITOR`,
|
||||
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 := validateMembersExclusivity(cmd); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"nodeId": nodeID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2235,9 +2176,7 @@ 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"},
|
||||
@@ -2245,39 +2184,33 @@ 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: `查询文档空间节点的协作者列表,支持分页和角色过滤。
|
||||
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount、hasMore 和 nextToken。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。`,
|
||||
Long: `查询文档空间节点的协作者列表。`,
|
||||
Example: ` dws drive permission list --node DOC_ID
|
||||
dws drive permission list --node DOC_ID --limit 50 --filter-role MANAGER,EDITOR
|
||||
dws drive permission list --node DOC_ID --next-token <上次返回的 nextToken>`,
|
||||
dws drive permission list --node DOC_ID --limit 100 --filter-role MANAGER,EDITOR`,
|
||||
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}
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
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 v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
if limit > 0 {
|
||||
toolArgs["maxResults"] = limit
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -2318,63 +2251,38 @@ func newDriveCommand() *cobra.Command {
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
{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,最大 50")
|
||||
drivePermListCmd.Flags().Int("limit", 30, "返回成员数上限,默认 30,最大 200")
|
||||
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: `从文档空间节点移除协作成员的权限。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--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`,
|
||||
Long: `从文档空间节点移除协作成员的权限。`,
|
||||
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 --members '[{"type":"USER","id":"uid1","corpId":"xxx"}]'`,
|
||||
dws drive permission remove --node DOC_ID --users uid1,uid2`,
|
||||
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 := validateMembersExclusivity(cmd); err != nil {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
toolArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"userIds": userIds,
|
||||
}
|
||||
if v := flagOrFallback(cmd, "workspace", "workspace-id"); v != "" {
|
||||
toolArgs["workspaceId"] = v
|
||||
@@ -2384,11 +2292,8 @@ func newDriveCommand() *cobra.Command {
|
||||
}
|
||||
DeclareLeafMetadata(drivePermRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销多个
|
||||
// 成员的访问,部门/群聊/角色组还可能间接影响大量用户,与删除同级的
|
||||
// destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -2414,7 +2319,6 @@ 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"},
|
||||
@@ -2422,10 +2326,9 @@ 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 命令一致)
|
||||
|
||||
+2
-145
@@ -4,8 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -136,13 +134,6 @@ 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)
|
||||
@@ -263,24 +254,6 @@ 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"
|
||||
@@ -406,98 +379,21 @@ 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 >
|
||||
// no-permission guidance.
|
||||
// Check order matches ClassifyMCPResponseText: DWS gateway > PAT permission.
|
||||
func ClassifyToolResultContent(content map[string]any) error {
|
||||
if _, ok := getDWSGatewayErrorCode(content); ok {
|
||||
raw, _ := json.Marshal(content)
|
||||
@@ -512,20 +408,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -537,23 +419,6 @@ 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.
|
||||
@@ -587,18 +452,10 @@ 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: businessErrorDisplayMessage(body, text),
|
||||
Message: text,
|
||||
Suggestion: suggestForBusinessError(body),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,19 +123,10 @@ 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) {
|
||||
@@ -161,76 +152,17 @@ 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
|
||||
suggHas string
|
||||
text string
|
||||
kind string
|
||||
}{
|
||||
{"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", ""},
|
||||
{"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"},
|
||||
} {
|
||||
err := ClassifyMCPResponseText(tc.text)
|
||||
switch tc.kind {
|
||||
@@ -242,15 +174,6 @@ 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)
|
||||
@@ -312,82 +235,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-89
@@ -255,11 +255,7 @@ func parseMCPToolTextResult(serverID, toolName string, result *edition.ToolResul
|
||||
}
|
||||
if isBusinessError(errBody) {
|
||||
return "", &CLIError{
|
||||
Code: CodeMCPToolError,
|
||||
// 注意:这里必须保留原始响应 JSON。此路径是数据编排层
|
||||
// (如 drive list --depth 的限流重试)的输入,下游会从
|
||||
// Message 反解析 errorCode;人话提取(含 code/logId 附加)
|
||||
// 只用于 callMCPToolInternalOptsContext 的终端展示路径。
|
||||
Code: CodeMCPToolError,
|
||||
Message: c.Text,
|
||||
Suggestion: suggestForBusinessError(errBody),
|
||||
}
|
||||
@@ -444,20 +440,6 @@ 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()}
|
||||
@@ -472,7 +454,7 @@ func callMCPToolInternalOptsContext(ctx context.Context, explicitServerID, toolN
|
||||
}
|
||||
// 业务逻辑错误
|
||||
if isBusinessError(errBody) {
|
||||
return &CLIError{Code: CodeMCPToolError, Message: businessErrorDisplayMessage(errBody, c.Text), Suggestion: suggestForBusinessError(errBody)}
|
||||
return &CLIError{Code: CodeMCPToolError, Message: c.Text, Suggestion: suggestForBusinessError(errBody)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -483,18 +465,6 @@ 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.
|
||||
@@ -795,64 +765,15 @@ 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 {
|
||||
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
|
||||
}
|
||||
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 ""
|
||||
}
|
||||
|
||||
// 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, "及以上角色")
|
||||
return suggestForBusinessErrorText(msg)
|
||||
}
|
||||
|
||||
// confirmDelete is a convenience wrapper around cmdutil.ConfirmDelete that
|
||||
|
||||
@@ -147,9 +147,9 @@ func TestCrossPlatformCoverageRunDocReadJsonMLCoverage(t *testing.T) {
|
||||
`{"jsonml":"{\"type\":\"doc\"}","revision":"bad"}`,
|
||||
} {
|
||||
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}
|
||||
_ = runDocReadJsonML("node", "", "", 0, false)
|
||||
_ = runDocReadJsonML(nil, "node", "")
|
||||
}
|
||||
caller.result = &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"jsonml":"{\"type\":\"doc\"}"}`}}}
|
||||
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "out.json"), "", 0, false)
|
||||
_ = runDocReadJsonML("node", filepath.Join(t.TempDir(), "missing", "out.json"), "pw", 3, true)
|
||||
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "out.json"))
|
||||
_ = runDocReadJsonML(nil, "node", filepath.Join(t.TempDir(), "missing", "out.json"))
|
||||
}
|
||||
|
||||
@@ -1,738 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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,18 +206,8 @@ 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["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"])
|
||||
if caller.args["maxResults"] != 10 {
|
||||
t.Fatalf("maxResults=%#v", caller.args["maxResults"])
|
||||
}
|
||||
})
|
||||
t.Run("cover file-id alias", func(t *testing.T) {
|
||||
|
||||
@@ -144,25 +144,17 @@ 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", 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},
|
||||
{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},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -188,8 +180,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 != test.want {
|
||||
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, test.want)
|
||||
if cliErr.Message != response {
|
||||
t.Fatalf("backend error payload changed:\n got: %s\nwant: %s", cliErr.Message, response)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"forbidden.document.sizeOverLimit",
|
||||
|
||||
+61
-158
@@ -529,20 +529,11 @@ func newWikiCommand() *cobra.Command {
|
||||
memberAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加知识库成员",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `为指定知识库添加一个或多个成员,并授予指定角色。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--users 传入逗号分隔的 userId 列表 + --role 指定统一角色(仅 USER 类型)
|
||||
新格式:--members 传入 JSON 数组,支持四种成员类型,每个 member 携带独立 roleId
|
||||
通过 --users 传入逗号分隔的 userId 列表,多个用户将被授予同一角色。
|
||||
|
||||
成员类型说明:
|
||||
USER 用户,id 为用户 userId,需携带 corpId(标识用户所属组织)
|
||||
DEPT 部门,id 为部门 ID,需携带 corpId(标识部门所属组织)
|
||||
CONVERSATION 群聊,id 为群聊 conversationId(cid 开头),无需 corpId
|
||||
TAG 角色标签(也称角色组),id 为角色标签 ID,需携带 corpId。当用户要求"添加角色组"或"添加角色标签"时使用此类型
|
||||
|
||||
支持的角色(大小写不敏感):
|
||||
支持的角色 (--role)(必须大写):
|
||||
MANAGER 管理员,可读写、管理成员
|
||||
EDITOR 编辑者,可查看、编辑、上传内容
|
||||
DOWNLOADER 查看下载者,可查看并下载内容
|
||||
@@ -550,58 +541,36 @@ func newWikiCommand() *cobra.Command {
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口添加,知识库创建者默认为所有者。
|
||||
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
- 单次请求最多 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 <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"}]'`,
|
||||
dws wiki member add --workspace "https://alidocs.dingtalk.com/i/spaces/xxx/overview" --users uid1 --role MANAGER`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{"workspaceId": workspaceID}
|
||||
members, mErr := collectMembers(cmd, false)
|
||||
if mErr != nil {
|
||||
return mErr
|
||||
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
if role == "OWNER" {
|
||||
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
|
||||
}
|
||||
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
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("add_member", toolArgs)
|
||||
return callMCPTool("add_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"roleId": role,
|
||||
"userIds": userIds,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(memberAddCmd, LeafSpec{
|
||||
@@ -636,8 +605,6 @@ 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"},
|
||||
@@ -646,30 +613,17 @@ 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("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
memberAddCmd.Flags().Bool("notify", false, "是否通知被添加的成员(仅 --members 新格式时生效,需显式传入才通知)")
|
||||
memberAddCmd.Flags().String("role", "", "权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
|
||||
memberUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新知识库成员权限",
|
||||
Args: cobra.NoArgs,
|
||||
Long: `更新指定知识库已有成员的角色。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--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 查看下载者
|
||||
@@ -678,45 +632,28 @@ func newWikiCommand() *cobra.Command {
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口变更。
|
||||
- 同一成员在同一知识库只能拥有一个角色,变更后旧角色自动替换。
|
||||
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- --notify 仅在 --members 新格式时生效,仅对 USER 和 CONVERSATION 类型成员发送通知,默认 false。
|
||||
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
|
||||
仅可更新已存在成员关系的成员,新增成员请使用 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> --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"}]'`,
|
||||
dws wiki member update --workspace <workspaceId> --users uid1,uid2 --role READER`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
return callMCPTool("update_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"userIds": userIds,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(memberUpdateCmd, LeafSpec{
|
||||
@@ -745,8 +682,6 @@ 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"},
|
||||
@@ -755,12 +690,10 @@ 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("members", "", "成员列表 JSON 数组(新格式),支持 USER/DEPT/CONVERSATION/TAG 类型(TAG=角色组),与 --users 互斥")
|
||||
memberUpdateCmd.Flags().Bool("notify", false, "是否通知被变更的成员(仅 --members 新格式时生效)")
|
||||
memberUpdateCmd.Flags().String("role", "", "新权限角色: MANAGER / EDITOR / DOWNLOADER / READER (必填,大小写不敏感)")
|
||||
|
||||
memberListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -768,15 +701,12 @@ func newWikiCommand() *cobra.Command {
|
||||
Short: "查询知识库成员列表",
|
||||
Long: `查询指定知识库的成员列表,返回每位成员的 userId、姓名、角色等信息。
|
||||
|
||||
底层一次性返回全量成员后在内存中按 pageSize 分页,支持通过 nextToken 翻页。
|
||||
出参包含 totalCount(全量成员总数)、hasMore(是否还有下一页)和 nextToken(下一页游标)。
|
||||
当 hasMore 为 true 时,传入下一次请求的 --next-token 即可获取下一页。
|
||||
操作者需满足该知识库配置的权限管理最低角色要求,权限不足返回 forbidden.accessDenied。
|
||||
注意:底层不支持游标分页,--limit 仅控制单次返回的最大条数(最大 50)。
|
||||
若结果被截断(出参 truncated=true),可通过 --filter-role 收窄查询范围;
|
||||
ORG 类型授权不会出现在查询结果中。`,
|
||||
Example: ` dws wiki member list --workspace <workspaceId>
|
||||
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>`,
|
||||
dws wiki member list --workspace <workspaceId> --limit 100
|
||||
dws wiki member list --workspace <workspaceId> --filter-role MANAGER,EDITOR`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
@@ -785,13 +715,17 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
toolArgs := map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
}
|
||||
if size, ok, err := permissionPageSizeFromFlags(cmd); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
toolArgs["pageSize"] = size
|
||||
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 v := flagOrFallback(cmd, "next-token", "cursor", "page-token"); v != "" {
|
||||
toolArgs["nextToken"] = v
|
||||
if limit > 0 {
|
||||
if limit > 50 {
|
||||
return fmt.Errorf("--limit 不能超过 50;底层成员接口不提供游标续页")
|
||||
}
|
||||
toolArgs["maxResults"] = limit
|
||||
}
|
||||
if v := mustGetFlag(cmd, "filter-role"); v != "" {
|
||||
toolArgs["filterRoleIds"] = parseRoleList(v)
|
||||
@@ -829,79 +763,50 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "filter-role", Property: "filterRoleIds"},
|
||||
// limit 不声明 Property:运行时经 cap 校验(1-50)转换为 pageSize,
|
||||
// 属 CLI 分页输入而非 1:1 RPC property(reviewed mapping exclusion)。
|
||||
{Name: "limit"},
|
||||
{Name: "next-token", Property: "nextToken"},
|
||||
{Name: "limit", Property: "maxResults"},
|
||||
{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: `从指定知识库中移除一个或多个成员。
|
||||
|
||||
两种传参方式(互斥):
|
||||
旧格式:--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
|
||||
Long: `从指定知识库中移除一个或多个成员(仅支持 USER 类型)。
|
||||
|
||||
移除后相关用户将无法访问该知识库下的内容(除非通过节点级权限另行授权)。
|
||||
|
||||
注意:
|
||||
- OWNER 角色不可通过此接口移除。
|
||||
- 操作者须满足该知识库配置的权限管理最低角色要求(默认 MANAGER,可配置为 EDITOR 等),权限不足返回 forbidden.accessDenied。
|
||||
- 操作者需具备知识库的 OWNER 或 MANAGER 权限。
|
||||
- 单次请求最多 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> --members '[{"type":"USER","id":"uid1","corpId":"xxx"},{"type":"DEPT","id":"deptId1","corpId":"xxx"}]'`,
|
||||
dws wiki member remove --workspace <workspaceId> --users uid1,uid2,uid3`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
workspaceID, err := mustFlagOrFallback(cmd, "workspace", "workspace-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateMembersExclusivity(cmd); err != nil {
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
return callMCPTool("remove_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"userIds": userIds,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(memberRemoveCmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
// 批量移除(最多 30 个 USER/DEPT/CONVERSATION/TAG)会一次性撤销整个
|
||||
// 知识库容器级别的成员访问,部门/群聊/角色组还可能间接影响大量用户,
|
||||
// 与删除同级的 destructive 入口,必须经过用户确认(--yes 或交互 yes)。
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
@@ -911,14 +816,14 @@ ORG 类型授权不会出现在查询结果中。`,
|
||||
CLIPath: "wiki member remove",
|
||||
PrimaryCLIPath: "wiki member remove",
|
||||
},
|
||||
Description: "从指定知识库中移除一个或多个成员",
|
||||
Description: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "mcp",
|
||||
Availability: "available",
|
||||
Ref: &contract.InterfaceRefSpec{ProductID: "wiki", RPCName: "remove_member"},
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "从指定知识库中移除一个或多个成员",
|
||||
AgentSummary: "从指定知识库中移除一个或多个成员(仅支持 USER 类型)",
|
||||
UseWhen: []string{"从知识库移除成员访问(离职/清理)时"},
|
||||
AvoidWhen: []string{
|
||||
"改角色用 update;节点级权限用 drive permission remove",
|
||||
@@ -927,17 +832,15 @@ 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}
|
||||
|
||||
@@ -179,7 +179,6 @@ func TestWukongSyncAgoalCommands(t *testing.T) {
|
||||
{[]string{"scorecard", "detail"}, []string{"selected-time", "dept-id", "request-id"}},
|
||||
{[]string{"scorecard", "entity-detail"}, []string{"sc-id", "entity-id", "request-id"}},
|
||||
{[]string{"scorecard", "update"}, []string{"dept-id", "selected-time", "id", "tracking-period-type", "content", "request-id"}},
|
||||
{[]string{"scorecard", "search-entities"}, []string{"keyword", "page", "page-size", "request-id"}},
|
||||
{[]string{"user", "rules"}, []string{"user-id", "request-id"}},
|
||||
{[]string{"user", "objectives"}, []string{"user-id", "rule-id", "period-ids", "request-id"}},
|
||||
{[]string{"report", "list-statistics"}, []string{"keyword", "request-id"}},
|
||||
|
||||
@@ -71,13 +71,6 @@ 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)
|
||||
@@ -210,32 +203,6 @@ 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,9 +105,6 @@ 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")
|
||||
@@ -182,53 +179,6 @@ 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,7 +18,6 @@ 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"
|
||||
@@ -92,17 +91,5 @@ 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)
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,369 +0,0 @@
|
||||
// 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,7 +24,6 @@ package aitable
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
|
||||
@@ -60,19 +59,6 @@ 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
|
||||
|
||||
@@ -1,534 +0,0 @@
|
||||
// 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,
|
||||
)...)
|
||||
}
|
||||
@@ -1,367 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// 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) != 100 || len(source.Shortcuts) != 100 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 100/100", len(registered), len(source.Shortcuts))
|
||||
if len(registered) != 93 || len(source.Shortcuts) != 93 {
|
||||
t.Fatalf("registered/catalog = %d/%d, want 93/93", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
|
||||
var missing, stale []string
|
||||
|
||||
@@ -25,28 +25,22 @@ 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"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/todo"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/whiteboard"
|
||||
_ "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/wiki"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// 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 TestCrossPlatformCoverageSheetSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_sheet.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "sheet" {
|
||||
t.Fatalf("service=%q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != "sheet" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := registered[item.Command]; duplicate {
|
||||
t.Fatalf("duplicate Sheet Shortcut %s", item.Command)
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
if len(registered) != 2 || len(source.Shortcuts) != 2 {
|
||||
t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s semantic review facts drifted", command)
|
||||
}
|
||||
if !record.Public || item.Hidden || !shortcut.InPublicCatalog("sheet", command) {
|
||||
t.Errorf("%s is not delivered as reviewed public", command)
|
||||
}
|
||||
if item.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk {
|
||||
t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", 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)
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// 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 TestCrossPlatformCoverageWhiteboardSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) {
|
||||
raw, err := os.ReadFile("../semantic_catalog_whiteboard.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var source chatSemanticCatalogFixture
|
||||
if err := json.Unmarshal(raw, &source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if source.Service != "whiteboard" {
|
||||
t.Fatalf("service=%q", source.Service)
|
||||
}
|
||||
registered := map[string]shortcut.Shortcut{}
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service != "whiteboard" {
|
||||
continue
|
||||
}
|
||||
if _, duplicate := registered[item.Command]; duplicate {
|
||||
t.Fatalf("duplicate Whiteboard Shortcut %s", item.Command)
|
||||
}
|
||||
registered[item.Command] = item
|
||||
}
|
||||
if len(registered) != 2 || len(source.Shortcuts) != 2 {
|
||||
t.Fatalf("registered/catalog=%d/%d, want 2/2", len(registered), len(source.Shortcuts))
|
||||
}
|
||||
public, unavailable, hidden := 0, 0, 0
|
||||
var missing, stale []string
|
||||
for command, item := range registered {
|
||||
record, ok := source.Shortcuts[command]
|
||||
if !ok {
|
||||
missing = append(missing, command)
|
||||
continue
|
||||
}
|
||||
if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta {
|
||||
t.Errorf("%s semantic review facts drifted", command)
|
||||
}
|
||||
if !record.Public || item.Hidden || !shortcut.InPublicCatalog("whiteboard", command) {
|
||||
t.Errorf("%s did not enter the public surface", command)
|
||||
}
|
||||
if !record.Public {
|
||||
hidden++
|
||||
} else {
|
||||
public++
|
||||
}
|
||||
if item.Availability == shortcut.AvailabilityUnavailable {
|
||||
unavailable++
|
||||
}
|
||||
if item.Availability != shortcut.AvailabilityAvailable || record.Availability != shortcut.AvailabilityAvailable || item.Risk != record.Risk {
|
||||
t.Errorf("%s availability/risk=%q/%q", command, item.Availability, item.Risk)
|
||||
}
|
||||
if item.Contract.Empty() || item.Contract.Result == nil || item.Contract.Pagination != nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive || item.Contract.Interface == nil || item.Contract.Interface.Availability != "available" || strings.TrimSpace(item.Contract.Interface.Reason) == "" {
|
||||
t.Errorf("%s lacks Result/Safety/unified output or publishes false pagination", command)
|
||||
}
|
||||
}
|
||||
if public != 2 || unavailable != 0 || hidden != 0 {
|
||||
t.Fatalf("Whiteboard source/public/unavailable/hidden=2/%d/%d/%d, want 2/2/0/0", public, unavailable, hidden)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
// 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,7 +21,6 @@ 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 获取当前登录用户信息(我是谁 / 本人)。
|
||||
@@ -57,27 +56,18 @@ 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 {
|
||||
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"))
|
||||
// Project the raw {arguments,result:{models:[…]}} envelope down to a
|
||||
// clean {count, followings:[{openDingTalkId}]} list.
|
||||
data, err := rt.CallMCPData("contact", "list_my_followings", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
followings := listFollowingsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(followings), "followings": followings})
|
||||
},
|
||||
}
|
||||
@@ -148,16 +138,9 @@ 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: "搜索关键词;--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")
|
||||
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +search-user --query "张三"`,
|
||||
@@ -169,10 +152,7 @@ var SearchUser = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users, err := strictUserSearch(data, "contact/search_contact_by_key_word", false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
users := searchUserProject(data)
|
||||
return rt.Output(map[string]any{"count": len(users), "users": users})
|
||||
},
|
||||
}
|
||||
@@ -242,42 +222,22 @@ 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: "手机号;--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")
|
||||
{Name: "mobile", Type: shortcut.FlagString, Desc: "手机号", Required: true},
|
||||
},
|
||||
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": mobile,
|
||||
"mobile": rt.Str("mobile"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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}})
|
||||
users := searchUserProject(data)
|
||||
return rt.Output(map[string]any{"count": len(users), "users": users})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -323,10 +283,7 @@ var ListRoles = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roles, err := strictRoles(data, "contact/get_org_labels")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
roles := listRolesProject(data)
|
||||
return rt.Output(map[string]any{"count": len(roles), "roles": roles})
|
||||
},
|
||||
}
|
||||
@@ -433,7 +390,7 @@ var ListRoleMembers = shortcut.Shortcut{
|
||||
Command: "+list-role-members",
|
||||
Product: "contact",
|
||||
Description: "查询角色下的成员列表",
|
||||
Intent: "当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表;按名称找角色时先用 contact label get 原子命令取得角色 ID。",
|
||||
Intent: "当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表,通常先用 +search-role 拿到角色 ID 再调用。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -455,18 +412,13 @@ var ListRoleMembers = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询角色下的成员列表",
|
||||
UseWhen: []string{"当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表;按名称找角色时先用 contact label get 原子命令取得角色 ID。"},
|
||||
UseWhen: []string{"当你已知某个角色 ID、想列出该角色(标签)下的全部成员以便群发通知或统计人群时使用;输入角色 ID(--id),返回该角色下的用户列表,通常先用 +search-role 拿到角色 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;--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")
|
||||
{Name: "id", Type: shortcut.FlagString, Desc: "角色 ID", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +list-role-members --id 12345`,
|
||||
@@ -478,10 +430,7 @@ var ListRoleMembers = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members, err := strictMembers(data, "contact/get_label_members_by_labelId", "labelUserList")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members := memberListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(members), "members": members})
|
||||
},
|
||||
}
|
||||
@@ -591,14 +540,9 @@ 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);--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")
|
||||
{Name: "dept", Type: shortcut.FlagInt, Desc: "部门 ID(钉钉根部门为 1)", Required: true},
|
||||
},
|
||||
Tips: []string{
|
||||
`dws contact +list-sub-depts --dept 1`,
|
||||
@@ -610,10 +554,7 @@ var ListSubDepts = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
depts, err := strictSubDepts(data, "contact/get_sub_depts_by_dept_id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
depts := listSubDeptsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(depts), "depts": depts})
|
||||
},
|
||||
}
|
||||
@@ -714,29 +655,21 @@ 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 列表,逗号分隔;--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")
|
||||
{Name: "depts", Type: shortcut.FlagStringSlice, Desc: "部门 ID 列表,逗号分隔", Required: true},
|
||||
},
|
||||
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": normalizedContactIDList(rt.StrSlice("depts")),
|
||||
"deptIds": rt.StrSlice("depts"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members, err := strictMembers(data, "contact/get_dept_members_by_deptId", "deptUserList")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
members := memberListProject(data)
|
||||
return rt.Output(map[string]any{"count": len(members), "members": members})
|
||||
},
|
||||
}
|
||||
@@ -786,18 +719,6 @@ 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,
|
||||
|
||||
@@ -1,624 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,240 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -176,17 +176,15 @@ var Fetch = shortcut.Shortcut{
|
||||
Command: "+fetch",
|
||||
Product: productDoc,
|
||||
Description: "读取完整或局部文档内容,并按 detail 控制保真度",
|
||||
Intent: "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论;互联网公开文档(含密码保护)用 --password 提供访问密码,读历史版本用 --version 指定版本号(0 表示初始版本)。",
|
||||
Intent: "当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"},
|
||||
Contract: docContract(
|
||||
"+fetch", "读取完整或局部文档内容,并按 detail 控制保真度",
|
||||
"当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论;互联网公开文档(含密码保护)用 --password 提供访问密码,读历史版本用 --version 指定版本号(0 表示初始版本)。",
|
||||
"当用户要按 node/URL 直接读取在线文字文档,或只知道唯一标题并希望一次完成解析和读取时使用;支持 outline/range/section/keyword/tags 局部内容用于精确编辑和评论。",
|
||||
[]string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`},
|
||||
contract.ParamDecl{Name: "node", Property: "nodeId"},
|
||||
contract.ParamDecl{Name: "query", Property: "keyword"},
|
||||
contract.ParamDecl{Name: "password", Property: "password"},
|
||||
contract.ParamDecl{Name: "version", Property: "historyVersion"},
|
||||
),
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "文档 ID 或 URL;" + fetchTargetConstraint},
|
||||
@@ -200,17 +198,12 @@ var Fetch = shortcut.Shortcut{
|
||||
{Name: "context-before", Type: shortcut.FlagInt, Desc: "关键词命中前的上下文字符数"},
|
||||
{Name: "context-after", Type: shortcut.FlagInt, Desc: "关键词命中后的上下文字符数"},
|
||||
{Name: "max-depth", Type: shortcut.FlagInt, Desc: "outline/section 最大深度"},
|
||||
{Name: "password", Type: shortcut.FlagString, Desc: "互联网公开文档开启密码保护时的访问密码;普通文档无需传入"},
|
||||
{Name: "revision", Type: shortcut.FlagInt, Desc: "不支持;revision 是文档编辑版本号(JSONML 读取响应返回、供 +update --expected-revision 条件写使用),不是历史版本号"},
|
||||
{Name: "version", Type: shortcut.FlagInt, Desc: "读取指定历史版本(版本号从 doc +version-list 获取, 0 表示初始版本, 需要文档编辑权限);缺省读最新版"},
|
||||
{Name: "revision", Type: shortcut.FlagInt, Desc: "只接受当前最新版;历史 revision 暂不支持"},
|
||||
},
|
||||
Tips: []string{`dws doc +fetch --node <DOC_ID>`, `dws doc +fetch --query "项目周报" --scope keyword --keyword "结论"`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if rt.Changed("revision") {
|
||||
return apperrors.NewValidation("--revision 不支持:revision 是文档编辑版本号(doc read --content-format jsonml 响应返回,供 doc +update --expected-revision 条件写使用),不是历史版本号;读历史版本请用 --version")
|
||||
}
|
||||
if rt.Changed("version") && rt.Int("version") < 0 {
|
||||
return apperrors.NewValidation("--version 必须为非负整数历史版本号(0 表示初始版本,从 doc +version-list 获取)")
|
||||
return apperrors.NewValidation("HISTORICAL_READ_UNSUPPORTED: 当前接口不能读取指定历史 revision")
|
||||
}
|
||||
if rt.Str("scope") == "keyword" && rt.Str("keyword") == "" {
|
||||
return apperrors.NewValidation("--scope keyword 时必须提供 --keyword")
|
||||
@@ -247,12 +240,6 @@ var Fetch = shortcut.Shortcut{
|
||||
if rt.Changed("max-depth") {
|
||||
params["maxDepth"] = rt.Int("max-depth")
|
||||
}
|
||||
if rt.Changed("version") {
|
||||
params["historyVersion"] = rt.Int("version")
|
||||
}
|
||||
if value := rt.Str("password"); value != "" {
|
||||
params["password"] = value
|
||||
}
|
||||
data, err := rt.CallMCPData(productDoc, "get_document_content", params)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1097,8 +1097,7 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
|
||||
{Create, []string{"--name", "n", "--content", `{}`, "--doc-format", "jsonml"}},
|
||||
{Create, []string{"--name", "n", "--content", `[]`, "--doc-format", "jsonml"}},
|
||||
{Create, []string{"--name", "n", "--content", `[["p",{},"x"]]`, "--doc-format", "jsonml"}},
|
||||
{Fetch, []string{"--node", "n", "--revision", "7"}},
|
||||
{Fetch, []string{"--node", "n", "--version", "-2"}},
|
||||
{Fetch, []string{"--node", "n", "--revision", "1"}},
|
||||
{Fetch, []string{"--node", "n", "--scope", "keyword"}},
|
||||
{Update, []string{"--node", "n"}},
|
||||
{Update, []string{"--command", "append", "--content", "x", "--yes"}},
|
||||
@@ -1129,27 +1128,6 @@ func TestCrossPlatformCoverageDocContentValidationAndPureHelpers(t *testing.T) {
|
||||
t.Fatalf("expected revision was not forwarded atomically: %#v", revision.history)
|
||||
}
|
||||
|
||||
fetchAccess := &docCoverageCaller{}
|
||||
if err := runDocCoverage(t, Fetch, fetchAccess, "--node", "n", "--version", "7", "--password", "pw"); err != nil {
|
||||
t.Fatalf("fetch with access params failed: %v", err)
|
||||
}
|
||||
if len(fetchAccess.history) != 1 ||
|
||||
fetchAccess.history[0].tool != "get_document_content" ||
|
||||
fetchAccess.history[0].params["historyVersion"] != 7 ||
|
||||
fetchAccess.history[0].params["password"] != "pw" {
|
||||
t.Fatalf("fetch access params were not forwarded: %#v", fetchAccess.history)
|
||||
}
|
||||
|
||||
fetchZero := &docCoverageCaller{}
|
||||
if err := runDocCoverage(t, Fetch, fetchZero, "--node", "n", "--version", "0"); err != nil {
|
||||
t.Fatalf("fetch with zero version failed: %v", err)
|
||||
}
|
||||
if len(fetchZero.history) != 1 ||
|
||||
fetchZero.history[0].tool != "get_document_content" ||
|
||||
fetchZero.history[0].params["historyVersion"] != 0 {
|
||||
t.Fatalf("fetch zero version (initial version) was not forwarded: %#v", fetchZero.history)
|
||||
}
|
||||
|
||||
for _, value := range []any{
|
||||
map[string]any{"revision": 2.5}, map[string]any{"revision": json.Number("bad")}, map[string]any{"revision": "bad"},
|
||||
map[string]any{"data": []any{map[string]any{"versionNumber": "3"}}}, []any{map[string]any{"version": 4.0}}, "none",
|
||||
|
||||
@@ -1,632 +0,0 @@
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,353 +0,0 @@
|
||||
// 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,7 +8,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -25,9 +24,8 @@ func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T)
|
||||
var catalog struct {
|
||||
Count int `json:"count"`
|
||||
Results []struct {
|
||||
Service string `json:"service"`
|
||||
Command string `json:"command"`
|
||||
SemanticDelta string `json:"semantic_delta"`
|
||||
Service string `json:"service"`
|
||||
Command string `json:"command"`
|
||||
} `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &catalog); err != nil {
|
||||
@@ -52,14 +50,6 @@ 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,7 +6,6 @@ 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": {},
|
||||
@@ -34,13 +33,6 @@ 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": {},
|
||||
@@ -245,6 +237,7 @@ 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": {},
|
||||
@@ -382,7 +375,6 @@ 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": {},
|
||||
@@ -410,8 +402,6 @@ func generatedPublicShortcutCatalog() map[string]struct{} {
|
||||
"todo\u0000+search": {},
|
||||
"todo\u0000+todo-done": {},
|
||||
"todo\u0000+update": {},
|
||||
"whiteboard\u0000+query": {},
|
||||
"whiteboard\u0000+update": {},
|
||||
"wiki\u0000+delete-space": {},
|
||||
"wiki\u0000+feed-list": {},
|
||||
"wiki\u0000+member-add": {},
|
||||
|
||||
@@ -40,15 +40,6 @@ 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
|
||||
|
||||
@@ -58,21 +49,6 @@ var dingSemanticCatalogJSON []byte
|
||||
//go:embed semantic_catalog_report.json
|
||||
var reportSemanticCatalogJSON []byte
|
||||
|
||||
//go:embed semantic_catalog_sheet.json
|
||||
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"`
|
||||
@@ -102,17 +78,9 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs(
|
||||
todoSemanticCatalogJSON,
|
||||
attendanceSemanticCatalogJSON,
|
||||
mailSemanticCatalogJSON,
|
||||
aisearchSemanticCatalogJSON,
|
||||
contactSemanticCatalogJSON,
|
||||
liveSemanticCatalogJSON,
|
||||
oaSemanticCatalogJSON,
|
||||
dingSemanticCatalogJSON,
|
||||
reportSemanticCatalogJSON,
|
||||
sheetSemanticCatalogJSON,
|
||||
whiteboardSemanticCatalogJSON,
|
||||
devdocSemanticCatalogJSON,
|
||||
hrbrainSemanticCatalogJSON,
|
||||
patSemanticCatalogJSON,
|
||||
)
|
||||
|
||||
func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
{
|
||||
"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,14 +108,6 @@
|
||||
"+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},
|
||||
|
||||
"+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}
|
||||
"+resolve-table": {"disposition":"primary_smart","semantic_delta":"在 Base 内按名称要求唯一匹配,0 或多结果时显式失败而不猜 tableId。","risk":"read","public":true,"reviewed":true}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"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}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "sheet",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+list-sheets": {
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
|
||||
"risk": "read",
|
||||
"public": true,
|
||||
"reviewed": true
|
||||
},
|
||||
"+read": {
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
|
||||
"risk": "read",
|
||||
"public": true,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"service": "whiteboard",
|
||||
"default_availability": "available",
|
||||
"shortcuts": {
|
||||
"+query": {
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
|
||||
"risk": "read",
|
||||
"availability": "available",
|
||||
"public": true,
|
||||
"reviewed": true
|
||||
},
|
||||
"+update": {
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
|
||||
"risk": "high-risk-write",
|
||||
"availability": "available",
|
||||
"public": true,
|
||||
"reviewed": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,9 @@
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"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"
|
||||
)
|
||||
|
||||
// ── workbook & worksheet ──────────────────────────────────────────────
|
||||
@@ -35,13 +27,12 @@ import (
|
||||
// Create creates a new DingTalk online spreadsheet document.
|
||||
// ListSheets lists all worksheets in a spreadsheet document.
|
||||
var ListSheets = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "sheet",
|
||||
Command: "+list-sheets",
|
||||
Product: "sheet",
|
||||
Description: "严格列出在线电子表格的工作表,并可按完整标题精确筛选",
|
||||
Intent: "需要发现一个在线电子表格中的工作表与稳定 sheetId,或验证某个完整工作表标题是否存在时",
|
||||
Risk: shortcut.RiskRead,
|
||||
Service: "sheet",
|
||||
Command: "+list-sheets",
|
||||
Product: "sheet",
|
||||
Description: "获取表格文档中全部工作表列表",
|
||||
Intent: "当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -54,95 +45,107 @@ var ListSheets = shortcut.Shortcut{
|
||||
CLIPath: "sheet +list-sheets",
|
||||
PrimaryCLIPath: "sheet +list-sheets",
|
||||
},
|
||||
Description: "严格列出在线电子表格的工作表",
|
||||
Description: "获取表格文档中全部工作表列表",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "严格列出在线电子表格的工作表,并可按完整标题精确筛选",
|
||||
UseWhen: []string{"需要发现一个在线电子表格中的工作表与稳定 sheetId,或验证某个完整工作表标题是否存在时"},
|
||||
AvoidWhen: []string{"需要管理 AITable/Base 的数据表或记录时改用 aitable;需要原始 get_all_sheets 响应时改用 sheet list"},
|
||||
AgentSummary: "获取表格文档中全部工作表列表",
|
||||
UseWhen: []string{"当你拿到一个表格文档、想先了解它里面有哪些工作表(sheet)以及各自的 sheetId 时使用,通常作为读写具体数据前的第一步;传入表格文档 ID 或 URL,返回工作表清单。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws sheet +list-sheets --node NODE_ID"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "node"},
|
||||
{Name: "title", Property: "title"},
|
||||
},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"经过严格校验的工作表列表","properties":{"count":{"type":"integer","description":"精确筛选后的工作表数量"},"sheets":{"type":"array","description":"工作表条目;显式空数组表示合法零命中","items":{"type":"object","description":"带稳定身份的工作表","properties":{"sheetId":{"type":"string","description":"稳定工作表 ID"},"title":{"type":"string","description":"工作表完整标题"},"index":{"type":"integer","description":"可选工作表顺序"},"visibility":{"description":"可选工作表可见性"},"rowCount":{"type":"integer","description":"可选行数"},"columnCount":{"type":"integer","description":"可选列数"}},"required":["sheetId","title"],"additionalProperties":false}}},"required":["count","sheets"],"additionalProperties":false}`),
|
||||
SensitivePaths: []string{"sheets.sheetId", "sheets.title"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL;--node 去除空白后不能为空", Required: true},
|
||||
{Name: "title", Type: shortcut.FlagString, Desc: "按完整工作表标题精确筛选(区分大小写);显式传入时去除空白后不能为空"},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"title"}, Description: "--title 显式传入时去除空白后不能为空"},
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true},
|
||||
},
|
||||
Tips: []string{`dws sheet +list-sheets --node NODE_ID`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateSheetNode(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateOptionalSheetString(rt, "title")
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
data, err := rt.CallMCPData("sheet", "get_all_sheets", map[string]any{"nodeId": rt.Str("node")})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheets, err := projectSheetList(data, rt.Str("title"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sheets := listSheetsProject(data)
|
||||
return rt.Output(map[string]any{"count": len(sheets), "sheets": sheets})
|
||||
},
|
||||
}
|
||||
|
||||
func projectSheetList(data map[string]any, exactTitle string) ([]map[string]any, error) {
|
||||
items, err := responsecheck.RequireObjectCollection(data, "sheet/get_all_sheets", "sheets")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
sheetID, err := requireSheetString(item, index, "sheetId")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, duplicate := seen[sheetID]; duplicate {
|
||||
return nil, responsecheck.Error("sheet/get_all_sheets", "duplicate_sheet_id", fmt.Sprintf("响应 sheets[%d] 的 sheetId 重复", index))
|
||||
}
|
||||
seen[sheetID] = struct{}{}
|
||||
title, err := requireSheetStringAliases(item, index, "name", "title")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exactTitle != "" && title != exactTitle {
|
||||
// listSheetsProject reshapes the raw get_all_sheets response into a clean
|
||||
// worksheet list (sheetId/title/index/visibility/rowCount/columnCount) — the
|
||||
// the clean output projection applied to every list command. Both
|
||||
// the list container and the per-item field names are probed defensively across
|
||||
// candidate keys, so an empty/unknown shape yields an empty list rather than a
|
||||
// crash or fabricated data.
|
||||
func listSheetsProject(data map[string]any) []map[string]any {
|
||||
raw := listSheetsResolveList(data)
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
m, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
row := map[string]any{"sheetId": sheetID, "title": title}
|
||||
if err := copySheetOptionalNumber(row, item, index, "index", "index", "sheetIndex"); err != nil {
|
||||
return nil, err
|
||||
row := map[string]any{}
|
||||
if v, ok := listSheetsFirst(m, "sheetId", "sheet_id", "id"); ok {
|
||||
row["sheetId"] = v
|
||||
}
|
||||
if err := copySheetOptionalVisibility(row, item, index, "visibility", "visibility", "hidden", "visible"); err != nil {
|
||||
return nil, err
|
||||
if v, ok := listSheetsFirst(m, "title", "name", "sheetName", "sheet_name"); ok {
|
||||
row["title"] = v
|
||||
}
|
||||
if err := copySheetOptionalNumber(row, item, index, "rowCount", "rowCount", "row_count", "rows"); err != nil {
|
||||
return nil, err
|
||||
if v, ok := listSheetsFirst(m, "index", "sheetIndex"); ok {
|
||||
row["index"] = v
|
||||
}
|
||||
if err := copySheetOptionalNumber(row, item, index, "columnCount", "columnCount", "column_count", "columns", "colCount"); err != nil {
|
||||
return nil, err
|
||||
if v, ok := listSheetsFirst(m, "visibility", "hidden", "visible"); ok {
|
||||
row["visibility"] = v
|
||||
}
|
||||
if v, ok := listSheetsFirst(m, "rowCount", "row_count", "rows"); ok {
|
||||
row["rowCount"] = v
|
||||
}
|
||||
if v, ok := listSheetsFirst(m, "columnCount", "column_count", "columns", "colCount"); ok {
|
||||
row["columnCount"] = v
|
||||
}
|
||||
if len(row) > 0 {
|
||||
out = append(out, row)
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
return out
|
||||
}
|
||||
|
||||
// listSheetsResolveList locates the worksheet array inside the response,
|
||||
// tolerating a bare top-level array container or nesting one level deeper under
|
||||
// a common envelope key.
|
||||
func listSheetsResolveList(data map[string]any) []any {
|
||||
if data == nil {
|
||||
return []any{}
|
||||
}
|
||||
for _, key := range []string{"sheets", "result", "data", "list", "items", "worksheets"} {
|
||||
v, ok := data[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr
|
||||
}
|
||||
if inner, ok := v.(map[string]any); ok {
|
||||
for _, ik := range []string{"sheets", "list", "items", "worksheets", "result", "data"} {
|
||||
if arr, ok := inner[ik].([]any); ok {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return []any{}
|
||||
}
|
||||
|
||||
// listSheetsFirst returns the first present candidate key's value.
|
||||
func listSheetsFirst(m map[string]any, keys ...string) (any, bool) {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// SheetInfo returns the detail of a single worksheet.
|
||||
@@ -154,13 +157,12 @@ func projectSheetList(data map[string]any, exactTitle string) ([]map[string]any,
|
||||
|
||||
// Read reads structured per-cell data from a worksheet range.
|
||||
var Read = shortcut.Shortcut{
|
||||
OutputRollout: output.RolloutUnifiedActive,
|
||||
Service: "sheet",
|
||||
Command: "+read",
|
||||
Product: "sheet",
|
||||
Description: "完整读取并严格校验在线电子表格范围;截断结果失败关闭",
|
||||
Intent: "需要逐格读取在线电子表格的值、公式或格式化值,并要求结果未被服务端截断时",
|
||||
Risk: shortcut.RiskRead,
|
||||
Service: "sheet",
|
||||
Command: "+read",
|
||||
Product: "sheet",
|
||||
Description: "读取工作表指定范围的结构化单元格数据",
|
||||
Intent: "当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
@@ -173,57 +175,26 @@ var Read = shortcut.Shortcut{
|
||||
CLIPath: "sheet +read",
|
||||
PrimaryCLIPath: "sheet +read",
|
||||
},
|
||||
Description: "完整读取并严格校验工作表范围",
|
||||
Description: "读取工作表指定范围的结构化单元格数据",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "完整读取并严格校验在线电子表格范围;截断结果失败关闭",
|
||||
UseWhen: []string{"需要逐格读取在线电子表格的值、公式或格式化值,并要求结果未被服务端截断时"},
|
||||
AvoidWhen: []string{"需要纯 CSV 时用 sheet csv-get;需要 AITable/Base 结构化记录时用 aitable;超大范围应拆小后重试,不能把截断结果当完整成功"},
|
||||
AgentSummary: "读取工作表指定范围的结构化单元格数据",
|
||||
UseWhen: []string{"当你需要按单元格逐格获取数据(含类型、公式或格式化值等结构化信息)以便程序处理时使用;传入表格与可选范围(A1 表示法,不传则全部),可指定取格式化值/原始值/公式,返回结构化单元格数组。若只想要纯文本可改用 +csv-get。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{"dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range \"A1:D10\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "node", Property: "node"},
|
||||
{Name: "sheet-id", Property: "sheetId"},
|
||||
{Name: "range", Property: "range"},
|
||||
{Name: "value-render-option", Property: "valueRenderOption"},
|
||||
},
|
||||
DryRun: &contract.DryRunSpec{
|
||||
PreviewKind: contract.DryRunPreviewRequest,
|
||||
RemoteReads: false,
|
||||
},
|
||||
Result: &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure},
|
||||
DataSchema: json.RawMessage(`{"type":"object","description":"完整且未截断的单元格范围","properties":{"cells":{"type":"array","description":"与行列坐标对齐的二维单元格对象数组","items":{"type":"array","description":"一行单元格对象","items":{"type":"object","description":"单元格结构化数据","additionalProperties":true}}},"colIndices":{"type":"array","description":"与 cells 列对齐的 A1 列标","items":{"type":"string"}},"rowIndices":{"type":"array","description":"与 cells 行对齐的一基行号","items":{"type":"integer"}},"complete":{"type":"boolean","description":"结果已证明完整;成功结果恒为 true"},"hasMore":{"type":"boolean","description":"服务端截断标记;成功结果恒为 false"},"truncationReasons":{"type":"array","description":"截断原因;成功结果恒为空数组","items":{"type":"string"}},"resolvedRange":{"type":"string","description":"服务解析后的请求范围"},"returnedRange":{"type":"string","description":"服务实际返回的范围"},"message":{"type":"string","description":"可选服务说明"}},"required":["cells","colIndices","rowIndices","complete","hasMore","truncationReasons"],"additionalProperties":false}`),
|
||||
SensitivePaths: []string{"cells"},
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL;--node 去除空白后不能为空", Required: true},
|
||||
{Name: "sheet-id", Type: shortcut.FlagString, Desc: "工作表 ID 或名称 (不传则第一个工作表);显式传入时去除空白后不能为空"},
|
||||
{Name: "range", Type: shortcut.FlagString, Desc: "读取范围,A1 表示法 (不传则全部数据);显式传入时去除空白后不能为空"},
|
||||
{Name: "node", Type: shortcut.FlagString, Desc: "表格文档 ID 或 URL", Required: true},
|
||||
{Name: "sheet-id", Type: shortcut.FlagString, Desc: "工作表 ID 或名称 (不传则第一个工作表)"},
|
||||
{Name: "range", Type: shortcut.FlagString, Desc: "读取范围,A1 表示法 (不传则全部数据)"},
|
||||
{Name: "value-render-option", Type: shortcut.FlagString, Desc: "取值模式", Enum: []string{"formatted_value", "raw_value", "formula"}},
|
||||
},
|
||||
Constraints: []shortcut.Constraint{
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"node"}, Description: "--node 去除空白后不能为空"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"sheet-id"}, Description: "--sheet-id 显式传入时去除空白后不能为空"},
|
||||
{Kind: shortcut.ConstraintCustom, Flags: []string{"range"}, Description: "--range 显式传入时去除空白后不能为空"},
|
||||
},
|
||||
Tips: []string{`dws sheet +read --node NODE_ID --sheet-id SHEET_ID --range "A1:D10"`},
|
||||
Validate: func(rt *shortcut.RuntimeContext) error {
|
||||
if err := validateSheetNode(rt); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range []string{"sheet-id", "range"} {
|
||||
if err := validateOptionalSheetString(rt, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
params := map[string]any{"nodeId": rt.Str("node")}
|
||||
if rt.Changed("sheet-id") {
|
||||
@@ -235,204 +206,10 @@ var Read = shortcut.Shortcut{
|
||||
if rt.Changed("value-render-option") {
|
||||
params["valueRenderOption"] = rt.Str("value-render-option")
|
||||
}
|
||||
if rt.DryRun() {
|
||||
return rt.CallMCP("get_cell_infos", params)
|
||||
}
|
||||
data, err := rt.CallMCPData("sheet", "get_cell_infos", params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
projected, err := projectSheetRead(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(projected)
|
||||
return rt.CallMCP("get_cell_infos", params)
|
||||
},
|
||||
}
|
||||
|
||||
func validateSheetNode(rt *shortcut.RuntimeContext) error {
|
||||
if strings.TrimSpace(rt.Str("node")) == "" {
|
||||
return apperrors.NewValidation("--node 去除空白后不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateOptionalSheetString(rt *shortcut.RuntimeContext, name string) error {
|
||||
if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" {
|
||||
return apperrors.NewValidation(fmt.Sprintf("--%s 显式传入时去除空白后不能为空", name))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireSheetString(item map[string]any, index int, key string) (string, error) {
|
||||
value, present := item[key]
|
||||
if !present {
|
||||
return "", responsecheck.Error("sheet/get_all_sheets", "missing_sheet_identity", fmt.Sprintf("响应 sheets[%d] 缺少 %s", index, key))
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok || strings.TrimSpace(text) == "" {
|
||||
return "", responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_identity", fmt.Sprintf("响应 sheets[%d].%s 必须是非空字符串", index, key))
|
||||
}
|
||||
return strings.TrimSpace(text), nil
|
||||
}
|
||||
|
||||
func requireSheetStringAliases(item map[string]any, index int, keys ...string) (string, error) {
|
||||
for _, key := range keys {
|
||||
if _, present := item[key]; present {
|
||||
return requireSheetString(item, index, key)
|
||||
}
|
||||
}
|
||||
return "", responsecheck.Error("sheet/get_all_sheets", "missing_sheet_title", fmt.Sprintf("响应 sheets[%d] 缺少工作表标题", index))
|
||||
}
|
||||
|
||||
func copySheetOptionalNumber(target, source map[string]any, itemIndex int, targetKey string, sourceKeys ...string) error {
|
||||
for _, key := range sourceKeys {
|
||||
if value, present := source[key]; present {
|
||||
number, ok := value.(float64)
|
||||
if !ok || number < 0 || math.Trunc(number) != number {
|
||||
return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 必须是非负整数", itemIndex, key))
|
||||
}
|
||||
target[targetKey] = value
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copySheetOptionalVisibility(target, source map[string]any, itemIndex int, targetKey string, sourceKeys ...string) error {
|
||||
for _, key := range sourceKeys {
|
||||
value, present := source[key]
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case bool:
|
||||
target[targetKey] = typed
|
||||
case string:
|
||||
if strings.TrimSpace(typed) == "" {
|
||||
return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 不能为空字符串", itemIndex, key))
|
||||
}
|
||||
target[targetKey] = strings.TrimSpace(typed)
|
||||
default:
|
||||
return responsecheck.Error("sheet/get_all_sheets", "malformed_sheet_field", fmt.Sprintf("响应 sheets[%d].%s 必须是布尔值或字符串", itemIndex, key))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func projectSheetRead(data map[string]any) (map[string]any, error) {
|
||||
envelope, err := responsecheck.RequireSuccess(data, "sheet/get_cell_infos")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cellsRaw, err := requireSheetArray(envelope, "cells")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columnsRaw, err := requireSheetArray(envelope, "colIndices")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rowsRaw, err := requireSheetArray(envelope, "rowIndices")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
columns := make([]any, len(columnsRaw))
|
||||
for index, value := range columnsRaw {
|
||||
column, ok := value.(string)
|
||||
if !ok || strings.TrimSpace(column) == "" {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_column_index", fmt.Sprintf("响应 colIndices[%d] 必须是非空字符串", index))
|
||||
}
|
||||
columns[index] = strings.TrimSpace(column)
|
||||
}
|
||||
rows := make([]any, len(rowsRaw))
|
||||
for index, value := range rowsRaw {
|
||||
number, ok := value.(float64)
|
||||
if !ok || number < 1 || math.Trunc(number) != number {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_row_index", fmt.Sprintf("响应 rowIndices[%d] 必须是正整数", index))
|
||||
}
|
||||
rows[index] = int(number)
|
||||
}
|
||||
if len(cellsRaw) != len(rows) {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "row_count_mismatch", "响应 cells 行数与 rowIndices 数量不一致")
|
||||
}
|
||||
cells := make([]any, len(cellsRaw))
|
||||
for rowIndex, rowValue := range cellsRaw {
|
||||
row, ok := rowValue.([]any)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_cell_row", fmt.Sprintf("响应 cells[%d] 必须是数组", rowIndex))
|
||||
}
|
||||
if len(row) != len(columns) {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "column_count_mismatch", fmt.Sprintf("响应 cells[%d] 列数与 colIndices 数量不一致", rowIndex))
|
||||
}
|
||||
clean := make([]any, len(row))
|
||||
for columnIndex, cellValue := range row {
|
||||
cell, ok := cellValue.(map[string]any)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_cell", fmt.Sprintf("响应 cells[%d][%d] 必须是对象", rowIndex, columnIndex))
|
||||
}
|
||||
clean[columnIndex] = cell
|
||||
}
|
||||
cells[rowIndex] = clean
|
||||
}
|
||||
hasMoreValue, present := envelope["hasMore"]
|
||||
if !present {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "missing_completion_evidence", "响应缺少 hasMore,无法证明读取完整")
|
||||
}
|
||||
hasMore, ok := hasMoreValue.(bool)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_completion_evidence", "响应 hasMore 必须是布尔值")
|
||||
}
|
||||
reasonsRaw, err := requireSheetArray(envelope, "truncationReasons")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reasons := make([]any, len(reasonsRaw))
|
||||
for index, value := range reasonsRaw {
|
||||
reason, ok := value.(string)
|
||||
if !ok || strings.TrimSpace(reason) == "" {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_truncation_reason", fmt.Sprintf("响应 truncationReasons[%d] 必须是非空字符串", index))
|
||||
}
|
||||
reasons[index] = strings.TrimSpace(reason)
|
||||
}
|
||||
if hasMore {
|
||||
if len(reasons) == 0 {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "missing_truncation_reason", "响应 hasMore=true 但缺少截断原因")
|
||||
}
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "incomplete_result", "工作表读取结果被服务端截断;请缩小 --range 后重试,不能把部分数据当作完整成功")
|
||||
}
|
||||
if len(reasons) != 0 {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "conflicting_completion_evidence", "响应 hasMore=false 但仍声明截断原因")
|
||||
}
|
||||
out := map[string]any{
|
||||
"cells": cells, "colIndices": columns, "rowIndices": rows,
|
||||
"complete": true, "hasMore": false, "truncationReasons": reasons,
|
||||
}
|
||||
for _, key := range []string{"resolvedRange", "returnedRange", "message"} {
|
||||
if value, present := envelope[key]; present {
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_optional_field", fmt.Sprintf("响应 %s 必须是字符串", key))
|
||||
}
|
||||
out[key] = text
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func requireSheetArray(object map[string]any, key string) ([]any, error) {
|
||||
value, present := object[key]
|
||||
if !present {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "missing_"+key, fmt.Sprintf("响应缺少 %s 数组", key))
|
||||
}
|
||||
array, ok := value.([]any)
|
||||
if !ok {
|
||||
return nil, responsecheck.Error("sheet/get_cell_infos", "malformed_"+key, fmt.Sprintf("响应 %s 必须是数组", key))
|
||||
}
|
||||
return array, nil
|
||||
}
|
||||
|
||||
// Write updates a worksheet range with a 2D JSON array of cell objects.
|
||||
// Append appends rows to the end of a worksheet.
|
||||
// CSVGet reads worksheet data as RFC 4180 CSV.
|
||||
|
||||
@@ -1,342 +0,0 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package sheet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/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 sheetCoverageCaller struct {
|
||||
responses map[string][]string
|
||||
failures map[string]error
|
||||
history []string
|
||||
arguments []map[string]any
|
||||
}
|
||||
|
||||
func (caller *sheetCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) {
|
||||
caller.history = append(caller.history, tool)
|
||||
caller.arguments = append(caller.arguments, arguments)
|
||||
if err := caller.failures[tool]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
queue := caller.responses[tool]
|
||||
if len(queue) == 0 {
|
||||
return nil, errors.New("missing fake response for " + tool)
|
||||
}
|
||||
caller.responses[tool] = queue[1:]
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil
|
||||
}
|
||||
|
||||
func (*sheetCoverageCaller) Format() string { return "json" }
|
||||
func (*sheetCoverageCaller) DryRun() bool { return false }
|
||||
func (*sheetCoverageCaller) Fields() string { return "" }
|
||||
func (*sheetCoverageCaller) JQ() string { return "" }
|
||||
|
||||
func runSheetCoverage(t *testing.T, declaration shortcut.Shortcut, caller *sheetCoverageCaller, 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", "")
|
||||
ctx, _ := output.WithResultStore(context.Background())
|
||||
root.SetContext(ctx)
|
||||
service := &cobra.Command{Use: "sheet"}
|
||||
service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration)))
|
||||
root.AddCommand(service)
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(append([]string{"sheet", declaration.Command}, args...))
|
||||
return root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetListRejectsFalseEmptySuccess(t *testing.T) {
|
||||
for name, payload := range map[string]map[string]any{
|
||||
"empty response": {},
|
||||
"missing success": {"sheets": []any{}},
|
||||
"success false": {"success": false, "errorMessage": "denied"},
|
||||
"missing collection": {"success": true},
|
||||
"wrong collection type": {"success": true, "sheets": map[string]any{}},
|
||||
"bad element": {"success": true, "sheets": []any{"bad"}},
|
||||
"empty element": {"success": true, "sheets": []any{map[string]any{}}},
|
||||
"missing stable id": {"success": true, "sheets": []any{map[string]any{"name": "Sheet1"}}},
|
||||
"wrong stable id type": {"success": true, "sheets": []any{map[string]any{"sheetId": 1, "name": "Sheet1"}}},
|
||||
"missing title": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1"}}},
|
||||
"duplicate stable id": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One"}, map[string]any{"sheetId": "s1", "name": "Two"}}},
|
||||
"wrong optional index": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "sheetIndex": -1.0}}},
|
||||
"wrong optional count": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "rowCount": "many"}}},
|
||||
"wrong column count": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "colCount": 1.5}}},
|
||||
"empty visibility": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "visibility": " "}}},
|
||||
"wrong visibility": {"success": true, "sheets": []any{map[string]any{"sheetId": "s1", "name": "One", "hidden": 1}}},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := projectSheetList(payload, ""); err == nil {
|
||||
t.Fatalf("payload unexpectedly accepted: %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
payload := map[string]any{"success": true, "sheets": []any{
|
||||
map[string]any{"sheetId": "s1", "name": "Alpha"},
|
||||
map[string]any{"sheetId": "s2", "name": "Beta"},
|
||||
}}
|
||||
nonempty, err := projectSheetList(payload, "Alpha")
|
||||
if err != nil || len(nonempty) != 1 || nonempty[0]["sheetId"] != "s1" {
|
||||
t.Fatalf("known nonempty projection = %#v, err=%v", nonempty, err)
|
||||
}
|
||||
empty, err := projectSheetList(payload, "guaranteed-missing")
|
||||
if err != nil || len(empty) != 0 {
|
||||
t.Fatalf("guaranteed empty projection = %#v, err=%v", empty, err)
|
||||
}
|
||||
optionalPayload := map[string]any{"success": true, "sheets": []any{
|
||||
map[string]any{
|
||||
"sheetId": "s1", "title": "Alpha", "index": float64(0),
|
||||
"hidden": true, "row_count": float64(5), "colCount": float64(2),
|
||||
},
|
||||
map[string]any{"sheetId": "s2", "title": "Beta", "visibility": " visible "},
|
||||
}}
|
||||
optional, err := projectSheetList(optionalPayload, "")
|
||||
if err != nil || len(optional) != 2 {
|
||||
t.Fatalf("optional field projection=%#v err=%v", optional, err)
|
||||
}
|
||||
if optional[0]["index"] != float64(0) || optional[0]["visibility"] != true || optional[0]["rowCount"] != float64(5) || optional[0]["columnCount"] != float64(2) {
|
||||
t.Fatalf("numeric/bool optional projection=%#v", optional[0])
|
||||
}
|
||||
if optional[1]["visibility"] != "visible" {
|
||||
t.Fatalf("string visibility projection=%#v", optional[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetReadRejectsMalformedAndIncompleteResults(t *testing.T) {
|
||||
valid := func() map[string]any {
|
||||
return map[string]any{
|
||||
"success": true, "cells": []any{[]any{map[string]any{"value": "x"}}},
|
||||
"colIndices": []any{"A"}, "rowIndices": []any{float64(1)},
|
||||
"hasMore": false, "truncationReasons": []any{},
|
||||
"resolvedRange": "A1:A1", "returnedRange": "A1:A1",
|
||||
}
|
||||
}
|
||||
for name, mutate := range map[string]func(map[string]any){
|
||||
"missing success": func(v map[string]any) { delete(v, "success") },
|
||||
"success false": func(v map[string]any) { v["success"] = false },
|
||||
"missing cells": func(v map[string]any) { delete(v, "cells") },
|
||||
"wrong cells type": func(v map[string]any) { v["cells"] = map[string]any{} },
|
||||
"bad row": func(v map[string]any) { v["cells"] = []any{"bad"} },
|
||||
"bad cell": func(v map[string]any) { v["cells"] = []any{[]any{"bad"}} },
|
||||
"missing columns": func(v map[string]any) { delete(v, "colIndices") },
|
||||
"bad column": func(v map[string]any) { v["colIndices"] = []any{1} },
|
||||
"missing rows": func(v map[string]any) { delete(v, "rowIndices") },
|
||||
"bad row index": func(v map[string]any) { v["rowIndices"] = []any{1.5} },
|
||||
"row count mismatch": func(v map[string]any) { v["rowIndices"] = []any{} },
|
||||
"column count mismatch": func(v map[string]any) { v["cells"] = []any{[]any{}} },
|
||||
"missing hasMore": func(v map[string]any) { delete(v, "hasMore") },
|
||||
"wrong hasMore type": func(v map[string]any) { v["hasMore"] = "false" },
|
||||
"truncated": func(v map[string]any) {
|
||||
v["hasMore"] = true
|
||||
v["truncationReasons"] = []any{"max_cells"}
|
||||
},
|
||||
"truncated without reason": func(v map[string]any) {
|
||||
v["hasMore"] = true
|
||||
v["truncationReasons"] = []any{}
|
||||
},
|
||||
"missing truncation reasons": func(v map[string]any) { delete(v, "truncationReasons") },
|
||||
"wrong truncation reason": func(v map[string]any) { v["truncationReasons"] = []any{1} },
|
||||
"contradictory completion": func(v map[string]any) { v["truncationReasons"] = []any{"max_cells"} },
|
||||
"wrong optional field": func(v map[string]any) { v["message"] = true },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
payload := valid()
|
||||
mutate(payload)
|
||||
if _, err := projectSheetRead(payload); err == nil {
|
||||
t.Fatalf("payload unexpectedly accepted: %#v", payload)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
projected, err := projectSheetRead(valid())
|
||||
if err != nil || projected["complete"] != true || projected["hasMore"] != false {
|
||||
t.Fatalf("valid projection=%#v err=%v", projected, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetShortcutsValidateBeforeCallsAndUseExactTools(t *testing.T) {
|
||||
noCall := &sheetCoverageCaller{responses: map[string][]string{}}
|
||||
if err := runSheetCoverage(t, ListSheets, noCall); err == nil || len(noCall.history) != 0 {
|
||||
t.Fatalf("missing node err=%v calls=%v", err, noCall.history)
|
||||
}
|
||||
for _, declaration := range []shortcut.Shortcut{ListSheets, Read} {
|
||||
t.Run(declaration.Command+" blank node", func(t *testing.T) {
|
||||
blank := &sheetCoverageCaller{responses: map[string][]string{}}
|
||||
err := runSheetCoverage(t, declaration, blank, "--node", " ")
|
||||
if err == nil || !strings.Contains(err.Error(), "--node") || len(blank.history) != 0 {
|
||||
t.Fatalf("blank node err=%v calls=%v", err, blank.history)
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, declaration := range []shortcut.Shortcut{ListSheets, Read} {
|
||||
blankCommand := corecmd.New(shortcut.FromShortcut(declaration))
|
||||
if err := blankCommand.Flags().Set("node", " "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := declaration.Validate(shortcut.RuntimeContextForTest(blankCommand, declaration))
|
||||
if err == nil || !strings.Contains(err.Error(), "--node") {
|
||||
t.Fatalf("%s direct blank node validation=%v", declaration.Command, err)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
flag string
|
||||
}{
|
||||
{declaration: ListSheets, flag: "title"},
|
||||
{declaration: Read, flag: "sheet-id"},
|
||||
{declaration: Read, flag: "range"},
|
||||
} {
|
||||
t.Run(test.declaration.Command+" blank "+test.flag, func(t *testing.T) {
|
||||
caller := &sheetCoverageCaller{responses: map[string][]string{}}
|
||||
err := runSheetCoverage(t, test.declaration, caller, "--node", "node", "--"+test.flag, " ")
|
||||
if err == nil || !strings.Contains(err.Error(), "--"+test.flag) || len(caller.history) != 0 {
|
||||
t.Fatalf("blank --%s err=%v calls=%v", test.flag, err, caller.history)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
listCaller := &sheetCoverageCaller{responses: map[string][]string{
|
||||
"get_all_sheets": {`{"success":true,"sheets":[{"sheetId":"s1","name":"Alpha"}]}`},
|
||||
}}
|
||||
if err := runSheetCoverage(t, ListSheets, listCaller, "--node", "node", "--title", "Alpha"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(listCaller.history, ","); got != "get_all_sheets" {
|
||||
t.Fatalf("list history=%q", got)
|
||||
}
|
||||
|
||||
readCaller := &sheetCoverageCaller{responses: map[string][]string{
|
||||
"get_cell_infos": {`{"success":true,"cells":[[{"value":"x"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":false,"truncationReasons":[]}`},
|
||||
}}
|
||||
if err := runSheetCoverage(t, Read, readCaller,
|
||||
"--node", "node", "--sheet-id", "sheet-1", "--range", "A1:A1", "--value-render-option", "raw_value"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(readCaller.history, ","); got != "get_cell_infos" {
|
||||
t.Fatalf("read history=%q", got)
|
||||
}
|
||||
if len(readCaller.arguments) != 1 || readCaller.arguments[0]["nodeId"] != "node" || readCaller.arguments[0]["sheetId"] != "sheet-1" || readCaller.arguments[0]["range"] != "A1:A1" || readCaller.arguments[0]["valueRenderOption"] != "raw_value" {
|
||||
t.Fatalf("read arguments=%#v", readCaller.arguments)
|
||||
}
|
||||
|
||||
dryRun := &sheetCoverageCaller{responses: map[string][]string{}}
|
||||
if err := runSheetCoverage(t, Read, dryRun,
|
||||
"--node", "node", "--sheet-id", "sheet-1", "--range", "A1:A1", "--value-render-option", "raw_value", "--dry-run"); err != nil {
|
||||
t.Fatalf("read dry-run: %v", err)
|
||||
}
|
||||
if len(dryRun.history) != 0 {
|
||||
t.Fatalf("read dry-run reached remote calls=%v", dryRun.history)
|
||||
}
|
||||
|
||||
readFailure := &sheetCoverageCaller{
|
||||
responses: map[string][]string{},
|
||||
failures: map[string]error{"get_cell_infos": errors.New("fixture transport failure")},
|
||||
}
|
||||
if err := runSheetCoverage(t, Read, readFailure, "--node", "node"); err == nil || len(readFailure.history) != 1 {
|
||||
t.Fatalf("read transport err=%v calls=%v", err, readFailure.history)
|
||||
}
|
||||
|
||||
incomplete := &sheetCoverageCaller{responses: map[string][]string{
|
||||
"get_cell_infos": {`{"success":true,"cells":[[{"value":"x"}]],"colIndices":["A"],"rowIndices":[1],"hasMore":true,"truncationReasons":["max_cells"],"resolvedRange":"A1:A2","returnedRange":"A1:A1"}`},
|
||||
}}
|
||||
err := runSheetCoverage(t, Read, incomplete, "--node", "node")
|
||||
if err == nil || !strings.Contains(err.Error(), "截断") || len(incomplete.history) != 1 {
|
||||
t.Fatalf("incomplete err=%v calls=%v", err, incomplete.history)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetRuntimeRejectsEmptyMalformedAndWrongCollection(t *testing.T) {
|
||||
for name, payload := range map[string]string{
|
||||
"empty body": "",
|
||||
"malformed json": "{",
|
||||
"success string": `{"success":"true","sheets":[]}`,
|
||||
"missing array": `{"success":true}`,
|
||||
"wrong array type": `{"success":true,"sheets":{}}`,
|
||||
"bad item": `{"success":true,"sheets":["bad"]}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
caller := &sheetCoverageCaller{responses: map[string][]string{"get_all_sheets": {payload}}}
|
||||
err := runSheetCoverage(t, ListSheets, caller, "--node", "node")
|
||||
if err == nil || len(caller.history) != 1 {
|
||||
t.Fatalf("payload=%q err=%v calls=%v", payload, err, caller.history)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSheetPublicContractsAreReviewedAndUnified(t *testing.T) {
|
||||
for _, declaration := range []shortcut.Shortcut{ListSheets, Read} {
|
||||
if declaration.Contract.Empty() || declaration.Contract.Result == nil {
|
||||
t.Errorf("%s missing Contract.Result", declaration.Command)
|
||||
}
|
||||
if declaration.Contract.Pagination != nil {
|
||||
t.Errorf("%s publishes unsupported pagination", declaration.Command)
|
||||
}
|
||||
if declaration.OutputRollout != output.RolloutUnifiedActive {
|
||||
t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout)
|
||||
}
|
||||
if declaration.Safety.Confirmation != "not_required" || declaration.Safety.Effect != "read" {
|
||||
t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety)
|
||||
}
|
||||
}
|
||||
if Read.Contract.DryRun == nil || Read.Contract.DryRun.PreviewKind != contract.DryRunPreviewRequest || Read.Contract.DryRun.RemoteReads {
|
||||
t.Errorf("read dry-run contract=%+v", Read.Contract.DryRun)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
wantProperties map[string]string
|
||||
}{
|
||||
{declaration: ListSheets, wantProperties: map[string]string{"node": "node", "title": "title"}},
|
||||
{declaration: Read, wantProperties: map[string]string{"node": "node", "sheet-id": "sheetId", "range": "range", "value-render-option": "valueRenderOption"}},
|
||||
} {
|
||||
got := make(map[string]string, len(test.declaration.Contract.Parameters))
|
||||
for _, parameter := range test.declaration.Contract.Parameters {
|
||||
got[parameter.Name] = parameter.Property
|
||||
}
|
||||
if !reflect.DeepEqual(got, test.wantProperties) {
|
||||
t.Errorf("%s parameter properties=%v, want %v", test.declaration.Command, got, test.wantProperties)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
declaration shortcut.Shortcut
|
||||
sensitive []string
|
||||
constraints []string
|
||||
}{
|
||||
{declaration: ListSheets, sensitive: []string{"sheets.sheetId", "sheets.title"}, constraints: []string{"node", "title"}},
|
||||
{declaration: Read, sensitive: []string{"cells"}, constraints: []string{"node", "sheet-id", "range"}},
|
||||
} {
|
||||
if !reflect.DeepEqual(test.declaration.Contract.Result.SensitivePaths, test.sensitive) {
|
||||
t.Errorf("%s sensitive paths=%v, want %v", test.declaration.Command, test.declaration.Contract.Result.SensitivePaths, test.sensitive)
|
||||
}
|
||||
gotConstraints := make([]string, 0, len(test.declaration.Constraints))
|
||||
for _, constraint := range test.declaration.Constraints {
|
||||
if constraint.Kind != shortcut.ConstraintCustom || len(constraint.Flags) != 1 || !strings.Contains(constraint.Description, "--"+constraint.Flags[0]) {
|
||||
t.Errorf("%s malformed custom constraint=%+v", test.declaration.Command, constraint)
|
||||
continue
|
||||
}
|
||||
gotConstraints = append(gotConstraints, constraint.Flags[0])
|
||||
}
|
||||
if !reflect.DeepEqual(gotConstraints, test.constraints) {
|
||||
t.Errorf("%s custom constraint flags=%v, want %v", test.declaration.Command, gotConstraints, test.constraints)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,19 +14,23 @@
|
||||
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: 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.
|
||||
// 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>`.
|
||||
//
|
||||
// dws contact +by-mobile --mobile 13800138000
|
||||
var ByMobile = shortcut.Shortcut{
|
||||
@@ -63,11 +67,7 @@ var ByMobile = shortcut.Shortcut{
|
||||
},
|
||||
},
|
||||
Flags: []shortcut.Flag{
|
||||
{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")
|
||||
{Name: "mobile", Type: shortcut.FlagString, Desc: "手机号", Required: true},
|
||||
},
|
||||
Tips: []string{`dws contact +by-mobile --mobile 13800138000`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
@@ -76,16 +76,83 @@ var ByMobile = shortcut.Shortcut{
|
||||
}
|
||||
mobile := rt.Str("mobile")
|
||||
|
||||
// Step 1 — resolve the mobile through the dedicated exact interface.
|
||||
_, profile, err := strictResolveContactUserByMobile(rt, 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,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(map[string]any{"profile": profile})
|
||||
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},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
// 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,10 +98,7 @@ var DeptMembers = shortcut.Shortcut{
|
||||
|
||||
// Step 2 — defensively pull the matching departments out of the
|
||||
// response and require exactly one, otherwise disambiguate.
|
||||
depts, err := strictDeptCandidates(data, "contact/search_dept_by_keyword")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
depts := deptMembersExtractDepts(data)
|
||||
switch {
|
||||
case len(depts) == 0:
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
@@ -115,17 +112,9 @@ var DeptMembers = shortcut.Shortcut{
|
||||
|
||||
// Step 3 — list that department's direct members.
|
||||
// get_dept_members_by_deptId expects deptIds as a string list.
|
||||
data, err = rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{
|
||||
return rt.CallMCP("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})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -229,6 +218,5 @@ func deptMembersLabels(depts []deptMembersDept) []string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&DeptMembers)
|
||||
shortcut.Register(DeptMembers)
|
||||
}
|
||||
|
||||
@@ -65,27 +65,18 @@ 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 := strictResolveContactUser(rt, rt.Str("name"))
|
||||
user, err := resolveUser(rt, rt.Str("name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Step 2 — fetch and print the full profile of the resolved user.
|
||||
data, err := rt.CallMCPData("contact", "get_user_info_by_user_ids", map[string]any{
|
||||
return rt.CallMCP("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,11 +15,13 @@ 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"
|
||||
)
|
||||
|
||||
@@ -73,7 +75,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 := strictResolveContactUser(rt, rt.Str("name"))
|
||||
user, err := resolveUser(rt, rt.Str("name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -89,25 +91,16 @@ var Org = shortcut.Shortcut{
|
||||
|
||||
// Step 3 — defensively pull the primary deptId out of the response,
|
||||
// then print that department's detail.
|
||||
profile, err := strictUserDetail(data, user.userID, "contact/get_user_info_by_user_ids")
|
||||
if err != nil {
|
||||
return err
|
||||
deptID, ok := shortcutOrgExtractDeptID(data)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"没能从 %s(%s) 的组织信息里解析出所在部门 deptId;"+
|
||||
"TA 可能没有归属部门,或返回结构与预期不符。",
|
||||
user.name, user.userID))
|
||||
}
|
||||
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{
|
||||
return rt.CallMCP("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})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -186,6 +179,5 @@ func shortcutOrgToInt64(v any) (int64, bool) {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&Org)
|
||||
shortcut.Register(Org)
|
||||
}
|
||||
|
||||
@@ -95,15 +95,12 @@ var ResolveDept = shortcut.Shortcut{
|
||||
}
|
||||
|
||||
// Project candidates to {deptId, name}, defensively unwrapping the list.
|
||||
items, err := strictDeptCandidates(data, "contact/search_dept_by_keyword")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items := resolveDeptItems(data)
|
||||
candidates := make([]map[string]any, 0, len(items))
|
||||
for _, d := range items {
|
||||
candidates = append(candidates, map[string]any{
|
||||
"deptId": strconv.FormatInt(d.id, 10),
|
||||
"name": d.name,
|
||||
"deptId": resolveDeptID(d),
|
||||
"name": resolveDeptName(d),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -200,6 +197,5 @@ func stripHighlightTags(s string) string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&ResolveDept)
|
||||
shortcut.Register(ResolveDept)
|
||||
}
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -75,7 +77,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 := strictResolveContactUser(rt, rt.Str("name"))
|
||||
user, err := resolveUser(rt, rt.Str("name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -92,31 +94,21 @@ 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.
|
||||
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
|
||||
deptID, ok := shortcutOrgExtractDeptID(data)
|
||||
if !ok {
|
||||
return apperrors.NewValidation(fmt.Sprintf(
|
||||
"没能从 %s(%s) 的组织信息里解析出所在部门 deptId;"+
|
||||
"TA 可能没有归属部门,或返回结构与预期不符。",
|
||||
user.name, user.userID))
|
||||
}
|
||||
|
||||
// get_dept_members_by_deptId expects deptIds as a string list.
|
||||
data, err = rt.CallMCPData("contact", "get_dept_members_by_deptId", map[string]any{
|
||||
return rt.CallMCP("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,11 +70,7 @@ var Whoami = shortcut.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
profile, err := strictWhoami(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return rt.Output(profile)
|
||||
return rt.Output(whoamiProject(data))
|
||||
},
|
||||
}
|
||||
|
||||
@@ -163,6 +159,5 @@ func whoamiStr(m map[string]any, keys ...string) string {
|
||||
}
|
||||
|
||||
func init() {
|
||||
finalizeContactSmart(&Whoami)
|
||||
shortcut.Register(Whoami)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user