Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea6fd16d11 | ||
|
|
4c43108bdf | ||
|
|
5e9a920b76 | ||
|
|
36b0528d90 | ||
|
|
a2201b4ab4 | ||
|
|
181cdf4a03 | ||
|
|
818b8b29e3 | ||
|
|
109ad13844 | ||
|
|
fd6bbd928e | ||
|
|
67417d3fb1 | ||
|
|
91dfc8b926 |
@@ -6,6 +6,45 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.51] - 2026-07-10
|
||||
|
||||
This release promotes the sealed `v1.0.51-beta.1` contents to stable. It syncs the hardcoded Wukong command surface, prevents `dev connect` conversations from blocking on messages received mid-turn, and makes local credential failures diagnosable without mutating key material.
|
||||
|
||||
### Added
|
||||
|
||||
- **Agoal product commands** (#585) — adds `dws agoal` strategy, contract, scorecard, user-objective, report, and objective-template command groups, together with static routing and the bundled mono/multi Agoal skills.
|
||||
- **Wukong chat command parity** (#585) — adds `chat group notice create|edit|get|list`, `group share-invite`, `text translate`, `category create-smart`, and `message list-emotion-replies`.
|
||||
- **Wukong document import commands** (#585) — adds `doc import` for starting imports and `doc import get` for querying import tasks.
|
||||
- **Wukong mail command parity** (#585) — adds mailbox profile, message batch-get, sent-message recall and recall-detail, auto-reply update, plus allow-list and block-list management.
|
||||
- **Wukong sheet grouping commands** (#585) — adds `sheet group-dimension` and `sheet ungroup-dimension` for whole-row or whole-column ranges.
|
||||
- **Keychain health diagnostics** (#578) — `dws doctor` now includes a keychain check, while `dws auth status` distinguishes ordinary logged-out state from `keychain_unavailable` and `dek_missing` failures and returns remediation hints in table and JSON output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`dws pat chmod` defaults to permanent grants** (#584) — running `dws pat chmod <scope>` without `--grant-type` now requests a `permanent` grant instead of `session`, aligning the direct CLI path with the recommend-authorization helper. Session grants remain available by passing `--grant-type session --session-id <id>`.
|
||||
- **The `dev connect --channel gemini` path now uses the Gemini `generateContent` API** (#587) — configure it with `GEMINI_API_KEY` or `GOOGLE_API_KEY`, optionally override the compatible endpoint with `GEMINI_API_BASE_URL` or `GOOGLE_GEMINI_API_BASE_URL`, and select a model with `--agent-model` or `GEMINI_MODEL`; a local `gemini` executable is no longer required.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Non-blocking `dev connect` turn scheduling** (#587) — stream and `@`-poll callbacks no longer wait for the active turn to finish. Turns stay serialized per conversation, messages received mid-turn are coalesced into one pending follow-up, and different conversations can continue in parallel.
|
||||
- **Connect agent recovery and headless execution** (#587) — stale addressable sessions retry once with a fresh session, unsupported Qoder control requests receive an immediate response instead of hanging, OpenCode and bypass-mode channels receive non-interactive permission settings, and backend/API failures are no longer posted as successful assistant replies.
|
||||
- **Side-effect-free credential reads** (#578) — keychain reads inspect encrypted credential data before looking up the DEK and never generate a replacement key on a read path. Missing DEKs and unavailable macOS Keychains are surfaced as explicit diagnostic failures instead of silently mutating credential state.
|
||||
|
||||
## [1.0.50] - 2026-07-08
|
||||
|
||||
This release fixes a long-standing gap where the global `--jq` / `--fields` output filters were silently ignored on product commands, lands a JSON-mode output path for the sheet batch-style command, and aligns the bundled skill surface with the real command semantics uncovered by the round-2 real-machine QA sweep.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Global `--jq` / `--fields` are honored on product commands** (#575) — `Formatter.PrintJSON` / `PrintJSONUnescaped` now route through `output.WriteFiltered` when either flag is set, so product commands accept the same filters that `dws api` has always supported. The tool-caller adapter exposes `Fields()` / `JQ()` so helpers can read the flags without re-parsing.
|
||||
- **`skill setup --dry-run` is a no-op preview** (#575) — it now prints what would be written without touching the skill directory, the registry, or the agent config. Help text and docs are updated to match.
|
||||
- **Skill docs alignment to the real command surface** (#575) — per-product references and the cross-product intent guide clarify that `--fields` projects top-level / list keys only (use `--jq` for nested paths); `minutes_extract_todos.py`, `calendar_free_slot_finder.py`, `chat_export_messages.py` / `chat_history_with_user.py`, and `contact_dept_members.py` are rewritten against the current response shapes; `aisearch` / `aitable` / `attendance` / `calendar` / `chat` / `contact` / `dev` / `doc` / `doc-comment` / `doc-file-ops` / `doc-list` / `doc-search` / `drive` / `mail` / `minutes` / `oa` / `sheet` / `sheet-export` / `url-patterns` / `best_practices/lite-recipes.md` / `global-reference.md` / `intent-guide.md` are re-synced; the QA voice ("真机" phrasing) and environment-specific quirks stated as absolute rules are removed from the docs.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`sheet range batch-set-style` emits per-row JSON in JSON mode** (#575) — when `--format json` is set, each update is reported as `{index, sheetId, range, ok, error}` instead of only the final aggregate, so callers can programmatically track partial failures under `--continue-on-error`.
|
||||
- **Command-merge helpers exported** — `pkg/cmdutil.LeafMerge*` and the provenance helpers are now public so downstream command trees can reuse the same merge semantics.
|
||||
|
||||
## [1.0.49] - 2026-07-08
|
||||
|
||||
This release lands a full real-machine QA sweep across the CLI, helper scripts, and skill docs (#572), and hardens the release pipeline so npm publishing can no longer be blocked by Gitee mirror issues (#570).
|
||||
|
||||
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 22 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 22 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -331,7 +331,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 22 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
|
||||
+3
-3
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 22 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。22 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -328,7 +328,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 22 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# cli_to_mcp smoke tests
|
||||
|
||||
This directory contains lightweight command-to-tool contract tests for hardcoded
|
||||
DWS commands synced from `dws-wukong`.
|
||||
|
||||
The tests do not call live DingTalk APIs. They exercise command help, validation,
|
||||
and `--dry-run` output so command paths and MCP argument mappings stay stable.
|
||||
|
||||
Run with an already built binary:
|
||||
|
||||
```bash
|
||||
DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases
|
||||
```
|
||||
|
||||
If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo
|
||||
root.
|
||||
@@ -0,0 +1,193 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_agoal_strategy_and_contract_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"list",
|
||||
"--scope-type",
|
||||
"PERSONAL",
|
||||
"--scope-id",
|
||||
"user123",
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_strategy_decodings" in output
|
||||
assert dry_run_args(output) == {
|
||||
"scopeType": "PERSONAL",
|
||||
"openId": "user123",
|
||||
"requestId": "req-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"update",
|
||||
"--profile-id",
|
||||
"profile123",
|
||||
"--content",
|
||||
'[{"id":"e1","title":{"title":"new"}}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_strategy_decoding" in output
|
||||
assert dry_run_args(output) == {
|
||||
"profileId": "profile123",
|
||||
"content": [{"id": "e1", "title": {"title": "new"}}],
|
||||
}
|
||||
|
||||
output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run"))
|
||||
assert "list_op_contract_fields" in output
|
||||
assert dry_run_args(output) == {}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"contract",
|
||||
"update",
|
||||
"--contract-id",
|
||||
"contract123",
|
||||
"--dimensions",
|
||||
'[{"id":"dim1","title":"metric"}]',
|
||||
"--audit-config",
|
||||
'{"needAudit":true}',
|
||||
"--objective-template",
|
||||
'{"id":"tpl1"}',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_op_contract" in output
|
||||
assert dry_run_args(output) == {
|
||||
"contractId": "contract123",
|
||||
"dimensions": [{"id": "dim1", "title": "metric"}],
|
||||
"auditConfig": '{"needAudit":true}',
|
||||
"objectiveTemplate": '{"id":"tpl1"}',
|
||||
}
|
||||
|
||||
|
||||
def test_agoal_scorecard_user_report_template_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"detail",
|
||||
"--selected-time",
|
||||
"2026-01-01T00:00:00+08:00",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_score_card_detail" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["deptId"] == "dept123"
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"update",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--selected-time",
|
||||
"2026-01-01",
|
||||
"--id",
|
||||
"sc123",
|
||||
"--tracking-period-type",
|
||||
"MONTHLY",
|
||||
"--content",
|
||||
'[{"id":"dim1","items":[]}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_score_card" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
assert args["content"] == [{"id": "dim1", "items": []}]
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"user",
|
||||
"objectives",
|
||||
"--user-id",
|
||||
"user123",
|
||||
"--rule-id",
|
||||
"rule123",
|
||||
"--period-ids",
|
||||
"p1,p2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_user_objectives" in output
|
||||
assert dry_run_args(output) == {
|
||||
"dingUserId": "user123",
|
||||
"objectiveRuleId": "rule123",
|
||||
"periodIds": ["p1", "p2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"report",
|
||||
"submit-detail",
|
||||
"--template-id",
|
||||
"tpl123",
|
||||
"--submit-state",
|
||||
"LATE",
|
||||
"--query-date",
|
||||
"2026-06-18T00:00:00+08:00",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"20",
|
||||
"--keyword",
|
||||
"alice",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_submit_detail" in output
|
||||
assert dry_run_args(output) == {
|
||||
"templateId": "tpl123",
|
||||
"submitState": "LATE",
|
||||
"queryDate": "2026-06-18",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"keyword": "alice",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"obj-template",
|
||||
"create-or-update",
|
||||
"--title",
|
||||
"tpl",
|
||||
"--dimensions",
|
||||
'[{"title":"dim"}]',
|
||||
"--objective-weight",
|
||||
"--dimension-weight",
|
||||
"--compute-by-weight",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_or_update_obj_template" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "tpl",
|
||||
"dimensions": '[{"title":"dim"}]',
|
||||
"objectiveWeight": True,
|
||||
"dimensionWeight": True,
|
||||
"computeByWeight": True,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_notice_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"create",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--content",
|
||||
"maintenance tonight",
|
||||
"--sticky",
|
||||
"--send-ding",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"content": "maintenance tonight",
|
||||
"sticky": True,
|
||||
"sendDing": True,
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"edit",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--content",
|
||||
"updated",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "edit_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
"content": "updated",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"get",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"list",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--limit",
|
||||
"20",
|
||||
"--cursor",
|
||||
"next",
|
||||
"--scheduled",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_group_notices" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"limit": 20,
|
||||
"cursor": "next",
|
||||
"scheduled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_misc_new_commands_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"share-invite",
|
||||
"--source",
|
||||
"sourceCid",
|
||||
"--target",
|
||||
"targetCid",
|
||||
"--expires-seconds",
|
||||
"3600",
|
||||
"--uuid",
|
||||
"uuid-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "share_group_invite_url" in output
|
||||
assert dry_run_args(output) == {
|
||||
"sourceOpenConversationId": "sourceCid",
|
||||
"targetOpenConversationId": "targetCid",
|
||||
"expiresSeconds": 3600,
|
||||
"uuid": "uuid-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run")
|
||||
)
|
||||
assert "translate" in output
|
||||
assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"category",
|
||||
"create-smart",
|
||||
"--name",
|
||||
"priority",
|
||||
"--keywords",
|
||||
"alpha,beta",
|
||||
"--members",
|
||||
"uid1,uid2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_smart_conv_category" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "priority",
|
||||
"keywords": ["alpha", "beta"],
|
||||
"memberOpenDingTalkIds": ["uid1", "uid2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"message",
|
||||
"list-emotion-replies",
|
||||
"--msg-ids",
|
||||
"msg1,msg2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_message_emotion_replies" in output
|
||||
assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]}
|
||||
@@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from test_utils import DWSRunner
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dws():
|
||||
return DWSRunner()
|
||||
@@ -0,0 +1,54 @@
|
||||
from test_utils import combined_output
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_doc_import_help_and_validation(dws, tmp_path):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "--help"))
|
||||
assert "dws doc import" in output
|
||||
assert "--file string" in output
|
||||
assert "--workspace string" in output
|
||||
assert "--name string" in output
|
||||
|
||||
result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "cannot read file" in output
|
||||
|
||||
bad = tmp_path / "bad.exe"
|
||||
bad.write_text("bad", encoding="utf-8")
|
||||
result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "unsupported file format" in output
|
||||
|
||||
|
||||
def test_doc_import_dry_run(dws, tmp_path):
|
||||
source = tmp_path / "sample.md"
|
||||
source.write_text("# Sample\n\nhello\n", encoding="utf-8")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"doc",
|
||||
"import",
|
||||
"--file",
|
||||
str(source),
|
||||
"--name",
|
||||
"Imported Sample",
|
||||
"--workspace",
|
||||
"workspace123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "Imported Sample" in output
|
||||
assert "sample.md" in output
|
||||
assert "md" in output
|
||||
|
||||
|
||||
def test_doc_import_get_dry_run(dws):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run"))
|
||||
assert "task123" in output
|
||||
@@ -0,0 +1,143 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def assert_fails(result, expected: str):
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0, output
|
||||
assert expected in output
|
||||
|
||||
|
||||
def test_mailbox_profile_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help"))
|
||||
assert "dws mail mailbox profile" in output
|
||||
assert "--email string" in output
|
||||
|
||||
assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run")
|
||||
)
|
||||
assert "get_mailbox_profile" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
|
||||
def test_message_batch_get_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help"))
|
||||
assert "dws mail message batch-get" in output
|
||||
assert "--email string" in output
|
||||
assert "--ids string" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw("mail", "message", "batch-get", "--email", mail_email()),
|
||||
"ids",
|
||||
)
|
||||
|
||||
too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21))
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
too_many_ids,
|
||||
"--dry-run",
|
||||
),
|
||||
"20",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
"msg_001,msg_002",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_email_by_message_id" in output
|
||||
assert "msg_001" in output
|
||||
assert "msg_002" in output
|
||||
|
||||
|
||||
def test_sent_message_recall_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help"))
|
||||
assert "dws mail sent-message recall" in output
|
||||
assert "--subject string" in output
|
||||
assert "--yes" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
),
|
||||
"--yes",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "recall_sent_message" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"id": "msg_001",
|
||||
"subject": "subject",
|
||||
}
|
||||
|
||||
|
||||
def test_sent_message_recall_detail_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help"))
|
||||
assert "dws mail sent-message recall-detail" in output
|
||||
assert "--email string" in output
|
||||
assert "--id string" in output
|
||||
assert "FINISHED" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall-detail",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"task_001",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_recall_detail" in output
|
||||
assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"}
|
||||
@@ -0,0 +1,133 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_auto_reply_update_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help"))
|
||||
for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"):
|
||||
assert flag in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"auto-reply",
|
||||
"update",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--enabled",
|
||||
"true",
|
||||
"--start",
|
||||
"2026/07/01 09:00:00 +0800",
|
||||
"--end",
|
||||
"2026/07/07 18:00:00 +0800",
|
||||
"--scope",
|
||||
"all",
|
||||
"--content",
|
||||
"out of office",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_auto_reply" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"enabled": True,
|
||||
"startTime": "2026/07/01 09:00:00 +0800",
|
||||
"endTime": "2026/07/07 18:00:00 +0800",
|
||||
"scope": "all",
|
||||
"content": "out of office",
|
||||
}
|
||||
|
||||
|
||||
def test_allow_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com,@example.org",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com", "@example.org"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com"],
|
||||
}
|
||||
|
||||
|
||||
def test_block_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com,@junk.example",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com", "@junk.example"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com"],
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help"))
|
||||
assert "dws sheet group-dimension" in output
|
||||
assert "--group-state string" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"fold",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "group_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
}
|
||||
|
||||
|
||||
def test_ungroup_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help"))
|
||||
assert "dws sheet ungroup-dimension" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"ungroup-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"C:F",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "ungroup_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
}
|
||||
|
||||
|
||||
def test_group_dimension_rejects_invalid_state(dws):
|
||||
result = dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"invalid",
|
||||
"--dry-run",
|
||||
)
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "group-state" in output
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root(start_file: str) -> Path:
|
||||
current = Path(start_file).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "go.mod").exists():
|
||||
return parent
|
||||
raise RuntimeError(f"cannot locate repo root from {start_file}")
|
||||
|
||||
|
||||
def resolve_dws_cmd(start_file: str) -> list[str]:
|
||||
root = repo_root(start_file)
|
||||
if env_bin := os.environ.get("DWS_BIN"):
|
||||
return shlex.split(env_bin)
|
||||
|
||||
for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"):
|
||||
candidate = root / rel
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return [str(candidate)]
|
||||
|
||||
return ["go", "run", "./cmd"]
|
||||
|
||||
|
||||
def combined_output(result: subprocess.CompletedProcess) -> str:
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def dry_run_args(output: str) -> dict:
|
||||
match = re.search(r"Arguments:\s*(\{.*\})", output, re.S)
|
||||
assert match, f"dry-run output does not contain Arguments JSON: {output}"
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
class DWSRunner:
|
||||
def __init__(self):
|
||||
self.root = repo_root(__file__)
|
||||
self.cmd = resolve_dws_cmd(__file__)
|
||||
|
||||
def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[*self.cmd, *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def run(self, *args: str, timeout: int = 45):
|
||||
result = self.run_raw(*args, timeout=timeout)
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return json.loads(result.stdout)
|
||||
@@ -446,6 +446,7 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
authenticated := false
|
||||
refreshed := false
|
||||
var tokenData *authpkg.TokenData
|
||||
var statusErr error
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
if data, err := provider.Status(); err == nil {
|
||||
@@ -468,12 +469,15 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
authenticated = true
|
||||
}
|
||||
} else {
|
||||
statusErr = err
|
||||
}
|
||||
diagnostic := authStatusDiagnosticFromError(statusErr)
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData)
|
||||
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData, diagnostic)
|
||||
}
|
||||
|
||||
// Default table output
|
||||
@@ -503,7 +507,10 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
if diagnostic != nil {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "原因:", diagnostic.Message)
|
||||
fmt.Fprintf(w, "%-16s%s\n", "提示:", diagnostic.Hint)
|
||||
} else if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
|
||||
}
|
||||
}
|
||||
@@ -1199,6 +1206,8 @@ type authStatusResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Refreshed bool `json:"refreshed,omitempty"`
|
||||
TokenValid bool `json:"token_valid,omitempty"`
|
||||
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
|
||||
@@ -1210,14 +1219,47 @@ type authStatusResponse struct {
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
}
|
||||
|
||||
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error {
|
||||
type authStatusDiagnostic struct {
|
||||
Reason string
|
||||
Message string
|
||||
Hint string
|
||||
}
|
||||
|
||||
func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if keychain.IsDEKMissing(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "dek_missing",
|
||||
Message: "本地登录密钥缺失,无法解密已保存的登录态",
|
||||
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
|
||||
}
|
||||
}
|
||||
if !keychain.IsUnavailable(err) {
|
||||
return nil
|
||||
}
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "无法读取 macOS Keychain 中的登录密钥,无法判断登录状态",
|
||||
Hint: "检查 macOS 默认钥匙串是否存在且已解锁;修复后重试,或在测试环境设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error {
|
||||
resp := authStatusResponse{
|
||||
Success: true,
|
||||
Authenticated: authenticated,
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
resp.Message = "未登录"
|
||||
if diagnostic != nil {
|
||||
resp.Message = diagnostic.Message
|
||||
resp.Reason = diagnostic.Reason
|
||||
resp.Hint = diagnostic.Hint
|
||||
} else {
|
||||
resp.Message = "未登录"
|
||||
}
|
||||
} else if data != nil {
|
||||
resp.Refreshed = refreshed
|
||||
resp.TokenValid = data.IsAccessTokenValid()
|
||||
|
||||
@@ -16,7 +16,9 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -132,6 +134,106 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusJSONReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success = false, want true; response=%+v", resp)
|
||||
}
|
||||
if resp.Authenticated {
|
||||
t.Fatalf("authenticated = true, want false; response=%+v", resp)
|
||||
}
|
||||
if resp.Reason != "keychain_unavailable" {
|
||||
t.Fatalf("reason = %q, want keychain_unavailable; response=%+v", resp.Reason, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "Keychain") && !strings.Contains(resp.Message, "钥匙串") {
|
||||
t.Fatalf("message should mention Keychain/钥匙串; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s; response=%+v", keychain.DisableKeychainEnv, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success = false, want true; response=%+v", resp)
|
||||
}
|
||||
if resp.Authenticated {
|
||||
t.Fatalf("authenticated = true, want false; response=%+v", resp)
|
||||
}
|
||||
if resp.Reason != "dek_missing" {
|
||||
t.Fatalf("reason = %q, want dek_missing; response=%+v", resp.Reason, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "登录密钥") {
|
||||
t.Fatalf("message should mention 登录密钥; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
// Isolate keychain storage to a per-test directory so the saved
|
||||
// token can't leak into other test packages running in parallel.
|
||||
@@ -824,6 +926,10 @@ func (f *authLoginRecommendSequenceCaller) Format() string { return "table" }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) DryRun() bool { return false }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) Fields() string { return "" }
|
||||
|
||||
func (f *authLoginRecommendSequenceCaller) JQ() string { return "" }
|
||||
|
||||
func stringSliceArgEqual(got any, want []string) bool {
|
||||
if got == nil {
|
||||
return len(want) == 0
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
@@ -29,6 +30,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var doctorKeychainDiagnose = keychain.Diagnose
|
||||
|
||||
// checkStatus represents the outcome of a single doctor check.
|
||||
type checkStatus string
|
||||
|
||||
@@ -76,6 +79,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
|
||||
checks = append(checks, authResult)
|
||||
|
||||
keychainResult := doctorCheckKeychain(w, jsonOut)
|
||||
checks = append(checks, keychainResult)
|
||||
|
||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||
checks = append(checks, networkResult)
|
||||
|
||||
@@ -132,6 +138,19 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
|
||||
data, err := provider.Status()
|
||||
if err != nil || data == nil {
|
||||
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
|
||||
r := checkResult{
|
||||
Name: "auth",
|
||||
Status: statusFail,
|
||||
Message: diagnostic.Message,
|
||||
Hint: diagnostic.Hint,
|
||||
Detail: map[string]string{"reason": diagnostic.Reason},
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
|
||||
if !edition.Get().IsEmbedded {
|
||||
r.Hint = "运行 dws auth login 进行登录"
|
||||
@@ -182,6 +201,40 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Keychain check ─────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckKeychain(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, tui.Dim("检查钥匙串状态... "))
|
||||
}
|
||||
|
||||
diagnostic := doctorKeychainDiagnose()
|
||||
r := checkResult{
|
||||
Name: "keychain",
|
||||
Status: statusPass,
|
||||
Message: diagnostic.Message,
|
||||
Detail: diagnostic.Detail,
|
||||
}
|
||||
if !diagnostic.OK {
|
||||
r.Status = statusFail
|
||||
r.Hint = diagnostic.Hint
|
||||
if diagnostic.Detail == nil {
|
||||
r.Detail = map[string]string{"reason": diagnostic.Reason}
|
||||
} else if diagnostic.Reason != "" {
|
||||
detail := make(map[string]string, len(diagnostic.Detail)+1)
|
||||
for k, v := range diagnostic.Detail {
|
||||
detail[k] = v
|
||||
}
|
||||
detail["reason"] = diagnostic.Reason
|
||||
r.Detail = detail
|
||||
}
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Network check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
|
||||
@@ -15,9 +15,16 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCountResults(t *testing.T) {
|
||||
@@ -127,6 +134,108 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckAuth(context.Background(), &buf, false)
|
||||
|
||||
if r.Name != "auth" {
|
||||
t.Fatalf("name = %q, want auth", r.Name)
|
||||
}
|
||||
if r.Status != statusFail {
|
||||
t.Fatalf("status = %q, want fail", r.Status)
|
||||
}
|
||||
if !strings.Contains(r.Message, "Keychain") && !strings.Contains(r.Message, "钥匙串") {
|
||||
t.Fatalf("message should mention Keychain/钥匙串; result=%+v", r)
|
||||
}
|
||||
if !strings.Contains(r.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s; result=%+v", keychain.DisableKeychainEnv, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsDEKMissing(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckAuth(context.Background(), &buf, false)
|
||||
|
||||
if r.Name != "auth" {
|
||||
t.Fatalf("name = %q, want auth", r.Name)
|
||||
}
|
||||
if r.Status != statusFail {
|
||||
t.Fatalf("status = %q, want fail", r.Status)
|
||||
}
|
||||
if !strings.Contains(r.Message, "登录密钥") {
|
||||
t.Fatalf("message should mention 登录密钥; result=%+v", r)
|
||||
}
|
||||
if !strings.Contains(r.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; result=%+v", r)
|
||||
}
|
||||
detail, ok := r.Detail.(map[string]string)
|
||||
if !ok || detail["reason"] != "dek_missing" {
|
||||
t.Fatalf("detail = %#v, want reason=dek_missing", r.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckKeychainReportsUnavailable(t *testing.T) {
|
||||
prev := doctorKeychainDiagnose
|
||||
doctorKeychainDiagnose = func() keychain.Diagnostic {
|
||||
return keychain.Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "macOS 默认钥匙串不存在",
|
||||
Hint: "恢复默认钥匙串后重试",
|
||||
Detail: map[string]string{
|
||||
"default_keychain": "/tmp/missing.keychain-db",
|
||||
},
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
doctorKeychainDiagnose = prev
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckKeychain(&buf, false)
|
||||
|
||||
if r.Name != "keychain" {
|
||||
t.Fatalf("name = %q, want keychain", r.Name)
|
||||
}
|
||||
if r.Status != statusFail {
|
||||
t.Fatalf("status = %q, want fail", r.Status)
|
||||
}
|
||||
if r.Message != "macOS 默认钥匙串不存在" {
|
||||
t.Fatalf("message = %q", r.Message)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint is empty; result=%+v", r)
|
||||
}
|
||||
detail, ok := r.Detail.(map[string]string)
|
||||
if !ok || detail["default_keychain"] == "" {
|
||||
t.Fatalf("detail = %#v, want default_keychain", r.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCommandStructure(t *testing.T) {
|
||||
cmd := newDoctorCommand()
|
||||
if cmd.Use != "doctor" {
|
||||
|
||||
@@ -590,6 +590,33 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func makePATErrorJSONWithAuthorizationURL(flowID, clientID, authURL string) string {
|
||||
type patData struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
AuthorizationURL string `json:"authorizationUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
payload := struct {
|
||||
Code string `json:"code"`
|
||||
Data patData `json:"data"`
|
||||
}{
|
||||
Code: "AGENT_CODE_NOT_EXISTS",
|
||||
Data: patData{
|
||||
Desc: "test auth",
|
||||
FlowID: flowID,
|
||||
AuthorizationURL: authURL,
|
||||
ClientID: clientID,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func patTestAuthorizationURL(server *httptest.Server) string {
|
||||
return server.URL + "/pat"
|
||||
}
|
||||
|
||||
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
|
||||
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
|
||||
@@ -664,10 +691,13 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
|
||||
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
|
||||
var retried atomic.Bool
|
||||
var retryHadKey atomic.Bool
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
|
||||
@@ -691,7 +721,7 @@ func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
@@ -721,7 +751,7 @@ func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
@@ -1196,7 +1226,8 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "table"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
authURL := patTestAuthorizationURL(server)
|
||||
raw := makePATErrorJSONWithAuthorizationURL("flow-approved", "test-client-id", authURL)
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
@@ -1216,7 +1247,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
if !strings.Contains(buf.String(), "需要 PAT 授权") {
|
||||
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") {
|
||||
if !strings.Contains(buf.String(), "授权链接: "+authURL) {
|
||||
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
|
||||
|
||||
@@ -64,7 +64,7 @@ skill 源默认取二进制内嵌的版本(升级二进制即升级 skill)
|
||||
dws skill setup --mode mono --yes # 非交互装 mono
|
||||
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
|
||||
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
|
||||
dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个
|
||||
dws skill setup --mode multi -x live -x devdoc # 装其余 20 个,剔除 2 个
|
||||
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillSetup,
|
||||
@@ -128,6 +128,19 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error {
|
||||
multiSkillNames = ensureMandatorySharedSkill(filtered, allMultiSkillNames)
|
||||
}
|
||||
|
||||
// --dry-run:仅预览将安装的内容与目标目录,不写入任何文件、不弹确认。
|
||||
if dryRun, _ := cmd.Flags().GetBool("dry-run"); dryRun {
|
||||
fmt.Fprintf(out, "[DRY-RUN] 预览(不写入任何文件):mode=%s,来源 %s\n", mode, skillSrc)
|
||||
fmt.Fprintln(out, "将安装到:")
|
||||
for _, d := range dests {
|
||||
fmt.Fprintf(out, " - %s\n", d)
|
||||
}
|
||||
if mode == skillSetupModeMulti && len(multiSkillNames) > 0 {
|
||||
fmt.Fprintf(out, "子 skill:%s\n", strings.Join(multiSkillNames, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !autoYes {
|
||||
ok, err := confirmSkillSetup(out, mode, skillSrc, dests, multiSkillNames)
|
||||
if err != nil {
|
||||
@@ -493,7 +506,7 @@ func confirmSkillSetup(out io.Writer, mode, src string, dests []string, multiSki
|
||||
if mode == skillSetupModeMulti {
|
||||
fmt.Fprintln(out, "\n🧪 ─────────────────────────────────────────────────────────────")
|
||||
fmt.Fprintln(out, " multi 模式当前为 EXPERIMENTAL(试验版 / Preview)")
|
||||
fmt.Fprintln(out, " · 20 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
|
||||
fmt.Fprintln(out, " · 22 个 dingtalk-* 子 skill 跑过 verifier,可用但未达 stable")
|
||||
fmt.Fprintln(out, " · 跨 skill 引用、bundle 命名、目录布局后续可能调整")
|
||||
fmt.Fprintln(out, " · 不建议在生产 / 共享环境直接落地;问题请提 issue 反馈")
|
||||
fmt.Fprintln(out, " 稳定版请用 --mode mono")
|
||||
|
||||
@@ -35,6 +35,11 @@ import (
|
||||
// Setting keychain.StorageDirEnv here forces every keychain read/write in
|
||||
// this binary into a per-process tempdir, eliminating that contamination
|
||||
// without touching production code.
|
||||
//
|
||||
// PAT authorization tests also exercise code paths that normally open the
|
||||
// system browser. Keep the package-wide default opener inert so running the
|
||||
// test binary never launches a page on the developer's machine; tests that
|
||||
// need to assert the URL can still replace openBrowserFunc locally.
|
||||
func TestMain(m *testing.M) {
|
||||
tmpDir, err := os.MkdirTemp("", "dws-app-test-keychain-")
|
||||
if err != nil {
|
||||
@@ -44,6 +49,7 @@ func TestMain(m *testing.M) {
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
panic("set " + keychain.StorageDirEnv + ": " + err.Error())
|
||||
}
|
||||
openBrowserFunc = func(string) error { return nil }
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpDir)
|
||||
os.Exit(code)
|
||||
|
||||
@@ -54,6 +54,20 @@ func (a *toolCallerAdapter) DryRun() bool {
|
||||
return a.flags != nil && a.flags.DryRun
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) Fields() string {
|
||||
if a.flags != nil {
|
||||
return a.flags.Fields
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *toolCallerAdapter) JQ() string {
|
||||
if a.flags != nil {
|
||||
return a.flags.JQ
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func convertResult(r executor.Result) *edition.ToolResult {
|
||||
resp := r.Response
|
||||
if resp == nil {
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// dws agoal — Agoal 管理
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newAgoalCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "agoal",
|
||||
Short: "Agoal 管理",
|
||||
Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。
|
||||
|
||||
命令结构:
|
||||
dws agoal strategy list 获取战略解码列表
|
||||
dws agoal strategy detail 获取战略解码详情
|
||||
dws agoal strategy update 更新战略解码
|
||||
dws agoal contract list 获取经营合约列表
|
||||
dws agoal contract fields 获取经营合约字段列表
|
||||
dws agoal contract detail 获取经营合约详情
|
||||
dws agoal contract update 更新经营合约
|
||||
dws agoal scorecard detail 获取计分卡详情
|
||||
dws agoal scorecard entity-detail 获取计分卡实体详情
|
||||
dws agoal scorecard update 更新计分卡
|
||||
dws agoal user rules 获取用户规则
|
||||
dws agoal user objectives 查询用户目标列表
|
||||
dws agoal report list-statistics 获取周月报数据跟催列表
|
||||
dws agoal report submit-detail 获取周月报规则提交详情
|
||||
dws agoal obj-template list 获取目标模板列表
|
||||
dws agoal obj-template create-or-update 新增或更新目标模板`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
|
||||
// ── strategy: 战略解码管理 ──────────────────────────────────
|
||||
|
||||
strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}
|
||||
|
||||
strategyListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "获取战略解码列表",
|
||||
Long: `按部门或个人维度查询战略解码列表。
|
||||
scopeType 支持:
|
||||
DEPT — 按部门查询
|
||||
PERSONAL — 按个人查询
|
||||
|
||||
--scope-id 为对应维度的钉钉部门 id 或用户 id。`,
|
||||
Example: ` dws agoal strategy list --scope-type PERSONAL --scope-id USER_ID
|
||||
dws agoal strategy list --scope-type DEPT --scope-id DEPT_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "scope-type", "scope-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"scopeType": mustGetFlag(cmd, "scope-type"),
|
||||
"openId": mustGetFlag(cmd, "scope-id"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("list_strategy_decodings", toolArgs)
|
||||
},
|
||||
}
|
||||
strategyListCmd.Flags().String("scope-type", "", "解码范围类型: DEPT/PERSONAL (必填)")
|
||||
strategyListCmd.Flags().String("scope-id", "", "scope-type 对应的钉钉部门 id 或用户 id (必填)")
|
||||
strategyListCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
strategyDetailCmd := &cobra.Command{
|
||||
Use: "detail",
|
||||
Short: "获取战略解码详情",
|
||||
Long: `根据战略解码 id (profileId) 获取战略解码的详细信息。`,
|
||||
Example: ` dws agoal strategy detail --profile-id PROFILE_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "profile-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"profileId": mustGetFlag(cmd, "profile-id"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("get_strategy_decoding_detail", toolArgs)
|
||||
},
|
||||
}
|
||||
strategyDetailCmd.Flags().String("profile-id", "", "战略解码 id (必填)")
|
||||
strategyDetailCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
strategyUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新战略解码",
|
||||
Long: `谨慎操作,基于查询接口返回的老数据进行修改,本接口是覆盖逻辑,会根据战略解码id进行对应的修改。
|
||||
--content 为 JSON 数组,每个实体对象包含:
|
||||
id — 实体 id
|
||||
title — 标题对象 {"title":"标题文本"}
|
||||
linkEntityId — 所属的实体 ID(查询接口中此字段有值一定要传进来)
|
||||
entityType — 类型: OGSM_OBJECTIVE/目的、OGSM_GOAL/目标、OGSM_STRATEGY/策略、OGSM_MEASUREMENT/衡量标准、OGSM_TACTICS/行动方案
|
||||
status — 状态: NORMAL/正常、PRE_PUBLISH_THEN_UPDATE/预发布更新、PRE_PUBLISH_THEN_CREATE/预发布新增、PRE_PUBLISH_THEN_DELETE/预发布删除
|
||||
supporters — 承接人数组 [{type, dingId, staffId}],type: USER/个人、DEPARTMENT/部门,staffId 类型为 USER 时必填
|
||||
indicators — 关键指标 id 字符串数组,如 ["id1","id2"]
|
||||
linkSources — 资源关联数组 [{id, linkType, linkId, source, objectId, keyResultId}]
|
||||
linkType: project/项目、task/任务、campaign/战役空间、product/产品空间、doc/文档、standard/其它
|
||||
source: teambition
|
||||
executors — 人员 dingId 字符串数组,如 ["dingId1","dingId2"]
|
||||
teams — 部门 dingId 字符串数组,如 ["dingId1","dingId2"]`,
|
||||
Example: ` dws agoal strategy update --profile-id PROFILE_ID --content '[{"id":"entity1","title":{"title":"新目标"},"entityType":"OGSM_OBJECTIVE","status":"NORMAL","executors":["dingId1"],"teams":["deptDingId1"]}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "profile-id", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
var contentArr []any
|
||||
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "content")), &contentArr); err != nil {
|
||||
return fmt.Errorf("--content must be a valid JSON array: %w", err)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"profileId": mustGetFlag(cmd, "profile-id"),
|
||||
"content": contentArr,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("update_strategy_decoding", toolArgs)
|
||||
},
|
||||
}
|
||||
strategyUpdateCmd.Flags().String("profile-id", "", "战略解码 id (必填)")
|
||||
strategyUpdateCmd.Flags().String("content", "", "实体列表 JSON 数组 (必填)")
|
||||
strategyUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
strategyCmd.AddCommand(strategyListCmd, strategyDetailCmd, strategyUpdateCmd)
|
||||
|
||||
// ── contract: 经营合约管理 ──────────────────────────────────
|
||||
|
||||
contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}
|
||||
|
||||
contractListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "获取经营合约列表",
|
||||
Long: `按部门或个人维度查询经营合约列表。
|
||||
scopeType 支持:
|
||||
DEPT — 按部门查询
|
||||
PERSONAL — 按个人查询
|
||||
|
||||
--scope-id 为通讯录里的部门 id 或用户 id。`,
|
||||
Example: ` dws agoal contract list --scope-type PERSONAL --scope-id USER_ID
|
||||
dws agoal contract list --scope-type DEPT --scope-id DEPT_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "scope-type", "scope-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"scopeType": mustGetFlag(cmd, "scope-type"),
|
||||
"openId": mustGetFlag(cmd, "scope-id"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("list_op_contracts", toolArgs)
|
||||
},
|
||||
}
|
||||
contractListCmd.Flags().String("scope-type", "", "合约范围类型: DEPT/PERSONAL (必填)")
|
||||
contractListCmd.Flags().String("scope-id", "", "scope-type 对应的钉钉部门 id 或用户 id (必填)")
|
||||
contractListCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
contractFieldsCmd := &cobra.Command{
|
||||
Use: "fields",
|
||||
Short: "获取经营合约字段列表",
|
||||
Long: `获取指定组织下经营合约的字段配置信息。`,
|
||||
Example: ` dws agoal contract fields`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("list_op_contract_fields", toolArgs)
|
||||
},
|
||||
}
|
||||
contractFieldsCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
contractDetailCmd := &cobra.Command{
|
||||
Use: "detail",
|
||||
Short: "获取经营合约详情",
|
||||
Long: `根据经营合约 id 获取经营合约的详细信息。`,
|
||||
Example: ` dws agoal contract detail --contract-id CONTRACT_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "contract-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"contractId": mustGetFlag(cmd, "contract-id"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("get_op_contract_detail", toolArgs)
|
||||
},
|
||||
}
|
||||
contractDetailCmd.Flags().String("contract-id", "", "经营合约 id (必填)")
|
||||
contractDetailCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
contractUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新经营合约",
|
||||
Long: `谨慎操作,一定要基于查询接口返回的老数据进行修改,然后给本接口使用,因为本接口是覆盖逻辑,会使用传入的数据去覆盖已存在的数据。
|
||||
--dimensions 为 JSON 数组,每个维度对象包含:
|
||||
id — 维度 id
|
||||
title — 维度名称
|
||||
description — 维度描述
|
||||
weight — 维度权重
|
||||
objectives — 目标列表
|
||||
dimensionConfig — 维度配置
|
||||
children — 子维度列表
|
||||
|
||||
可选参数:
|
||||
--audit-config — 审批配置 JSON,如 {"needAudit":true,"processTemplateId":"TPL_ID"}
|
||||
--objective-template — 合约模板 JSON,如 {"id":"TPL_ID","title":"模板名称"}`,
|
||||
Example: ` dws agoal contract update --contract-id CONTRACT_ID \
|
||||
--dimensions '[{"id":"dim1","title":"业绩","weight":60,"objectives":[{"id":"obj1"}]}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "contract-id", "dimensions"); err != nil {
|
||||
return err
|
||||
}
|
||||
var dimensionsArr []any
|
||||
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "dimensions")), &dimensionsArr); err != nil {
|
||||
return fmt.Errorf("--dimensions must be a valid JSON array: %w", err)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"contractId": mustGetFlag(cmd, "contract-id"),
|
||||
"dimensions": dimensionsArr,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("audit-config"); v != "" {
|
||||
toolArgs["auditConfig"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("objective-template"); v != "" {
|
||||
toolArgs["objectiveTemplate"] = v
|
||||
}
|
||||
return callMCPTool("update_op_contract", toolArgs)
|
||||
},
|
||||
}
|
||||
contractUpdateCmd.Flags().String("contract-id", "", "经营合约 id (必填)")
|
||||
contractUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
contractUpdateCmd.Flags().String("audit-config", "", "审批配置 JSON (可选)")
|
||||
contractUpdateCmd.Flags().String("objective-template", "", "合约模板 JSON (可选)")
|
||||
contractUpdateCmd.Flags().String("dimensions", "", "维度内容列表 JSON 数组 (必填)")
|
||||
|
||||
contractCmd.AddCommand(contractListCmd, contractFieldsCmd, contractDetailCmd, contractUpdateCmd)
|
||||
|
||||
// ── scorecard: 计分卡管理 ───────────────────────────────────
|
||||
|
||||
scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}
|
||||
|
||||
scorecardDetailCmd := &cobra.Command{
|
||||
Use: "detail",
|
||||
Short: "获取计分卡详情",
|
||||
Long: `根据部门 id 和时间获取计分卡详情。
|
||||
--selected-time 接受 ISO-8601 字符串,传入对应周期起始时刻:
|
||||
2026年 → "2026-01-01T00:00:00+08:00"
|
||||
2026年5月 → "2026-05-01T00:00:00+08:00"`,
|
||||
Example: ` dws agoal scorecard detail --selected-time "2026-01-01T00:00:00+08:00" --dept-id DEPT_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "selected-time", "dept-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
selectedTimeMs, err := parseISO8601ToMillis(mustGetFlag(cmd, "selected-time"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--selected-time: %w", err)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"selectedTime": selectedTimeMs,
|
||||
"deptId": mustGetFlag(cmd, "dept-id"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("get_score_card_detail", toolArgs)
|
||||
},
|
||||
}
|
||||
scorecardDetailCmd.Flags().String("selected-time", "", "ISO-8601 时间字符串,如 \"2026-01-01T00:00:00+08:00\" (必填)")
|
||||
scorecardDetailCmd.Flags().String("dept-id", "", "部门 id (必填)")
|
||||
scorecardDetailCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
scorecardEntityDetailCmd := &cobra.Command{
|
||||
Use: "entity-detail",
|
||||
Short: "获取计分卡实体详情",
|
||||
Long: `根据计分卡 id 和实体 id 获取计分卡实体的详细信息。`,
|
||||
Example: ` dws agoal scorecard entity-detail --sc-id SC_ID --entity-id ENTITY_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "sc-id", "entity-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"scId": mustGetFlag(cmd, "sc-id"),
|
||||
"entityId": mustGetFlag(cmd, "entity-id"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("get_score_card_entity_detail", toolArgs)
|
||||
},
|
||||
}
|
||||
scorecardEntityDetailCmd.Flags().String("sc-id", "", "计分卡 id (必填)")
|
||||
scorecardEntityDetailCmd.Flags().String("entity-id", "", "计分卡实体 id (必填)")
|
||||
scorecardEntityDetailCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
scorecardUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新计分卡",
|
||||
Long: `更新指定计分卡的内容。
|
||||
--content 为 JSON 数组,每个维度对象包含:
|
||||
id — 维度 id
|
||||
title — 维度名称
|
||||
items — 指标或关键事项列表,每项包含:
|
||||
id — 实体 id
|
||||
title — 名称
|
||||
reference — 参考信息
|
||||
start — 起始值
|
||||
target — 目标值
|
||||
executors — 负责人列表,每项包含 openId`,
|
||||
Example: ` dws agoal scorecard update --dept-id DEPT_ID --selected-time "2025-01-01T00:00:00+08:00" --id SC_ID --tracking-period-type MONTHLY --content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "dept-id", "selected-time", "id", "tracking-period-type", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
selectedTimeMs, err := parseISO8601ToMillis(mustGetFlag(cmd, "selected-time"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("--selected-time: %w", err)
|
||||
}
|
||||
var contentArr []any
|
||||
if err := json.Unmarshal([]byte(mustGetFlag(cmd, "content")), &contentArr); err != nil {
|
||||
return fmt.Errorf("--content must be a valid JSON array: %w", err)
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"deptId": mustGetFlag(cmd, "dept-id"),
|
||||
"selectedTime": selectedTimeMs,
|
||||
"id": mustGetFlag(cmd, "id"),
|
||||
"trackingPeriodType": mustGetFlag(cmd, "tracking-period-type"),
|
||||
"content": contentArr,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("update_score_card", toolArgs)
|
||||
},
|
||||
}
|
||||
scorecardUpdateCmd.Flags().String("dept-id", "", "部门 id (必填)")
|
||||
scorecardUpdateCmd.Flags().String("selected-time", "", "ISO-8601 时间字符串,如 \"2026-01-01T00:00:00+08:00\" (必填)")
|
||||
scorecardUpdateCmd.Flags().String("id", "", "计分卡 id (必填)")
|
||||
scorecardUpdateCmd.Flags().String("tracking-period-type", "", "跟踪周期类型: MONTHLY/月度追踪、QUARTERLY/季度追踪 (必填)")
|
||||
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
|
||||
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
|
||||
|
||||
// ── user: 用户目标管理 ──────────────────────────────────────
|
||||
|
||||
userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}
|
||||
|
||||
userRulesCmd := &cobra.Command{
|
||||
Use: "rules",
|
||||
Short: "获取用户的规则周期列表",
|
||||
Long: `获取用户的规则周期列表。不传 dingUserId 时默认取操作人自己的规则。`,
|
||||
Example: ` dws agoal user rules
|
||||
dws agoal user rules --user-id USER_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("user-id"); v != "" {
|
||||
toolArgs["dingUserId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("get_user_rules", toolArgs)
|
||||
},
|
||||
}
|
||||
userRulesCmd.Flags().String("user-id", "", "要查询的人员钉钉 id (可选,默认取操作人)")
|
||||
userRulesCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
userObjectivesCmd := &cobra.Command{
|
||||
Use: "objectives",
|
||||
Short: "查询用户目标列表",
|
||||
Long: `根据用户、规则 id 和周期列表查询用户的目标。`,
|
||||
Example: ` dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "user-id", "rule-id", "period-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"dingUserId": mustGetFlag(cmd, "user-id"),
|
||||
"objectiveRuleId": mustGetFlag(cmd, "rule-id"),
|
||||
"periodIds": parseCSVValues(mustGetFlag(cmd, "period-ids")),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
return callMCPTool("list_user_objectives", toolArgs)
|
||||
},
|
||||
}
|
||||
userObjectivesCmd.Flags().String("user-id", "", "要查询的人员钉钉 id (必填)")
|
||||
userObjectivesCmd.Flags().String("rule-id", "", "规则 id (必填)")
|
||||
userObjectivesCmd.Flags().String("period-ids", "", "周期 id 列表,逗号分隔 (必填)")
|
||||
userObjectivesCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
userCmd.AddCommand(userRulesCmd, userObjectivesCmd)
|
||||
|
||||
// ── report: 周月报管理 ──────────────────────────────────────
|
||||
|
||||
reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}
|
||||
|
||||
reportListStatisticsCmd := &cobra.Command{
|
||||
Use: "list-statistics",
|
||||
Short: "获取周月报数据跟催列表",
|
||||
Long: `获取周月报规则的人员提交情况列表,包含按时提交、迟交、未提交的人员数量统计。`,
|
||||
Example: ` dws agoal report list-statistics
|
||||
dws agoal report list-statistics --keyword "周报规则"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
|
||||
toolArgs["keyword"] = v
|
||||
}
|
||||
return callMCPTool("list_report_statistics", toolArgs)
|
||||
},
|
||||
}
|
||||
reportListStatisticsCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
reportListStatisticsCmd.Flags().String("keyword", "", "搜索规则名称 (可选)")
|
||||
|
||||
reportSubmitDetailCmd := &cobra.Command{
|
||||
Use: "submit-detail",
|
||||
Short: "获取周月报规则提交详情",
|
||||
Long: `获取周月报规则提交详情,包含按时提交、迟交、未提交中的具体人员以及提交的时间、周报id等。
|
||||
--submit-state 支持:
|
||||
ON_TIME — 按时提交
|
||||
LATE — 迟交
|
||||
NOT_SUBMITTED — 未提交
|
||||
--query-date 接受 ISO-8601 字符串,如 "2026-06-18T00:00:00+08:00"`,
|
||||
Example: ` dws agoal report submit-detail --template-id TPL_ID --submit-state ON_TIME
|
||||
dws agoal report submit-detail --template-id TPL_ID --submit-state LATE --query-date "2026-06-18T00:00:00+08:00" --page 1 --page-size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "template-id", "submit-state"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"templateId": mustGetFlag(cmd, "template-id"),
|
||||
"submitState": mustGetFlag(cmd, "submit-state"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("query-date"); v != "" {
|
||||
queryDateMs, err := parseISO8601ToMillis(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--query-date: %w", err)
|
||||
}
|
||||
toolArgs["queryDate"] = time.UnixMilli(queryDateMs).In(shanghaiLocation()).Format("2006-01-02")
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
|
||||
toolArgs["page"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
|
||||
toolArgs["pageSize"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
|
||||
toolArgs["keyword"] = v
|
||||
}
|
||||
return callMCPTool("get_submit_detail", toolArgs)
|
||||
},
|
||||
}
|
||||
reportSubmitDetailCmd.Flags().String("template-id", "", "规则模板 id (必填)")
|
||||
reportSubmitDetailCmd.Flags().String("submit-state", "", "提交状态: ON_TIME(按时提交)/LATE(迟交)/NOT_SUBMITTED(未提交) (必填)")
|
||||
reportSubmitDetailCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
reportSubmitDetailCmd.Flags().String("query-date", "", "查询日期,ISO-8601 格式(如 \"2026-06-18T00:00:00+08:00\"),默认为当天 (可选)")
|
||||
reportSubmitDetailCmd.Flags().Int("page", 0, "分页参数,默认为 1 (可选)")
|
||||
reportSubmitDetailCmd.Flags().Int("page-size", 0, "分页参数,默认为 10 (可选)")
|
||||
reportSubmitDetailCmd.Flags().String("keyword", "", "搜索员工名称 (可选)")
|
||||
|
||||
reportCmd.AddCommand(reportListStatisticsCmd, reportSubmitDetailCmd)
|
||||
|
||||
// ── template: 目标模板管理 ──────────────────────────────────
|
||||
|
||||
objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}
|
||||
|
||||
objTemplateListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "获取目标模板列表",
|
||||
Long: `获取目标模板列表,支持关键词搜索和分页。`,
|
||||
Example: ` dws agoal obj-template list
|
||||
dws agoal obj-template list --keyword "业绩" --page 1 --page-size 10`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
toolArgs := map[string]any{}
|
||||
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
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("keyword"); v != "" {
|
||||
toolArgs["keyword"] = v
|
||||
}
|
||||
return callMCPTool("list_obj_template", toolArgs)
|
||||
},
|
||||
}
|
||||
objTemplateListCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
objTemplateListCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
|
||||
objTemplateListCmd.Flags().Int("page-size", 0, "每页数量,默认 10 (可选)")
|
||||
objTemplateListCmd.Flags().String("keyword", "", "搜索关键词 (可选)")
|
||||
|
||||
objTemplateCreateOrUpdateCmd := &cobra.Command{
|
||||
Use: "create-or-update",
|
||||
Short: "新增或更新目标模板",
|
||||
Long: `新增或更新目标模板。覆盖逻辑,更新时一定要基于查询接口返回的老数据进行修改,新增时建议先参考已存在的模板数据。
|
||||
--dimensions 为必填参数,JSON 字符串,包含目标维度、维度配置、目标内容。
|
||||
--objective-weight 启用目标权重
|
||||
--dimension-weight 启用维度权重
|
||||
--compute-by-weight 维度是否参与计算`,
|
||||
Example: ` dws agoal obj-template create-or-update --title "业绩模板" --dimensions '[{"title":"维度1","weight":100}]'
|
||||
dws agoal obj-template create-or-update --template-id TPL_ID --dimensions '[...基于老数据修改...]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "dimensions"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("template-id"); v != "" {
|
||||
toolArgs["templateId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("title"); v != "" {
|
||||
toolArgs["title"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("objective-weight") {
|
||||
v, _ := cmd.Flags().GetBool("objective-weight")
|
||||
toolArgs["objectiveWeight"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("dimension-weight") {
|
||||
v, _ := cmd.Flags().GetBool("dimension-weight")
|
||||
toolArgs["dimensionWeight"] = v
|
||||
}
|
||||
if cmd.Flags().Changed("compute-by-weight") {
|
||||
v, _ := cmd.Flags().GetBool("compute-by-weight")
|
||||
toolArgs["computeByWeight"] = v
|
||||
}
|
||||
toolArgs["dimensions"] = mustGetFlag(cmd, "dimensions")
|
||||
return callMCPTool("create_or_update_obj_template", toolArgs)
|
||||
},
|
||||
}
|
||||
objTemplateCreateOrUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
objTemplateCreateOrUpdateCmd.Flags().String("template-id", "", "模板 id (更新时必填)")
|
||||
objTemplateCreateOrUpdateCmd.Flags().String("title", "", "模板标题 (新增时必填)")
|
||||
objTemplateCreateOrUpdateCmd.Flags().Bool("objective-weight", false, "是否启用目标权重")
|
||||
objTemplateCreateOrUpdateCmd.Flags().Bool("dimension-weight", false, "是否启用维度权重")
|
||||
objTemplateCreateOrUpdateCmd.Flags().Bool("compute-by-weight", false, "维度是否参与计算")
|
||||
objTemplateCreateOrUpdateCmd.Flags().String("dimensions", "", "模板关联的维度 JSON 字符串 (必填,更新时基于老数据修改,新增时建议参考已有模板)")
|
||||
|
||||
objTemplateCmd.AddCommand(objTemplateListCmd, objTemplateCreateOrUpdateCmd)
|
||||
|
||||
root.AddCommand(strategyCmd, contractCmd, scorecardCmd, userCmd, reportCmd, objTemplateCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
// parseISO8601ToMillis 将 ISO-8601 时间字符串解析为毫秒时间戳。
|
||||
// 支持格式:RFC3339(含时区)、无时区(默认 Asia/Shanghai)、仅日期。
|
||||
func parseISO8601ToMillis(value string) (int64, error) {
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
|
||||
shanghaiLoc := shanghaiLocation()
|
||||
|
||||
if t, err := time.ParseInLocation("2006-01-02T15:04:05", value, shanghaiLoc); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
if t, err := time.ParseInLocation("2006-01-02 15:04:05", value, shanghaiLoc); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
if t, err := time.ParseInLocation("2006-01-02", value, shanghaiLoc); err == nil {
|
||||
return t.UnixMilli(), nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("invalid ISO-8601 time format %q, expected e.g. \"2026-01-01T00:00:00+08:00\"", value)
|
||||
}
|
||||
|
||||
func shanghaiLocation() *time.Location {
|
||||
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||
if err != nil {
|
||||
return time.FixedZone("CST", 8*3600)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
@@ -4290,7 +4290,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
baseGetPrimaryDocIdCmd.Flags().String("table-id", "", "Table ID,可通过 list_tables 或 get_base 获取 (必填)")
|
||||
baseGetPrimaryDocIdCmd.Flags().String("record-id", "", "记录 ID (必填)")
|
||||
baseCopyCmd.Flags().String("base-id", "", "源 Base ID (必填)")
|
||||
baseCopyCmd.Flags().String("target-folder-id", "", "目标文件夹 ID (必填), 默认为当前 Base 的父节点")
|
||||
baseCopyCmd.Flags().String("target-folder-id", "", "目标文件夹 ID (必填, 不传会复制失败)")
|
||||
baseCopyCmd.Flags().Bool("only-struct", false, "是否仅复制结构(不含数据),默认 false 表示完整复制")
|
||||
baseCmd.AddCommand(
|
||||
baseListCmd, baseSearchCmd, baseGetCmd,
|
||||
|
||||
@@ -2333,7 +2333,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
子命令:
|
||||
types 查询当前用户假期规则列表
|
||||
save-type 创建或更新假期规则(仅支持无额度模式)
|
||||
update-type 更新假期规则(仅支持无额度模式)
|
||||
balance 查询指定员工假期余额
|
||||
save-balance 更新员工假期余额
|
||||
records 查询指定员工假期余额变更记录`,
|
||||
@@ -3136,11 +3136,11 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
// vacation balance (get_leave_balance_quota)
|
||||
vacationBalanceCmd.Flags().String("users", "", "目标员工 ID 列表,逗号分隔 (必填)")
|
||||
vacationBalanceCmd.Flags().String("leave-code", "", "假期规则 code (选填,不传则查询所有假期)")
|
||||
vacationBalanceCmd.Flags().String("leave-code", "", "假期规则 code (必填,服务端要求非空,不传返回 INVALID_PARAMS)")
|
||||
|
||||
// vacation records (get_leave_balance_records)
|
||||
vacationRecordsCmd.Flags().String("user", "", "指定查询员工 ID (必填)")
|
||||
vacationRecordsCmd.Flags().String("leave-code", "", "假期规则 code (选填,不传则查询所有假期)")
|
||||
vacationRecordsCmd.Flags().String("leave-code", "", "假期规则 code (必填,服务端要求非空,不传返回 INVALID_PARAMS)")
|
||||
vacationRecordsCmd.Flags().String("start", "", "查询开始日期,格式 YYYY-MM-DD (必填)")
|
||||
vacationRecordsCmd.Flags().String("end", "", "查询结束日期,格式 YYYY-MM-DD (必填)")
|
||||
|
||||
|
||||
+288
-14
@@ -3069,7 +3069,7 @@ func newChatCommand() *cobra.Command {
|
||||
chatMessageCreateTextEmotionCmd := &cobra.Command{
|
||||
Use: "create-text-emotion",
|
||||
Short: "创建文字表情(获取 emotionId)",
|
||||
Long: `创建一个新的文字表情模板。当 list-emotions 中没有所需表情时,使用此命令创建并获取 emotionId,随后可用于 add-text-emotion。`,
|
||||
Long: `创建一个新的文字表情模板。当内置表情(见 chat-emoji-list.md)中没有所需表情时,使用此命令创建并获取 emotionId,随后可用于 add-text-emotion。`,
|
||||
Example: ` dws chat message create-text-emotion --emotion-name "赞" --text "nice"
|
||||
dws chat message create-text-emotion --emotion-name "感谢" --text "感谢" --background-id im_bg_5`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -3228,12 +3228,21 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
return err
|
||||
}
|
||||
|
||||
// 解析输出路径:如果是目录,从 URL 推断文件名
|
||||
// 解析输出路径:目录(已存在的目录,或以分隔符结尾的意图目录)→ 追加推断文件名。
|
||||
fi, statErr := os.Stat(outputPath)
|
||||
if statErr == nil && fi.IsDir() {
|
||||
isDir := (statErr == nil && fi.IsDir()) ||
|
||||
strings.HasSuffix(outputPath, string(os.PathSeparator)) ||
|
||||
strings.HasSuffix(outputPath, "/")
|
||||
if isDir {
|
||||
filename := inferFilename(resourceURL)
|
||||
outputPath = filepath.Join(outputPath, filename)
|
||||
}
|
||||
// 确保目标父目录存在,否则 httpGetFile 打开文件会因目录缺失失败。
|
||||
if dir := filepath.Dir(outputPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("创建输出目录失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: HTTP GET 下载文件
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[2/2] 下载资源到 %s ...", outputPath))
|
||||
@@ -4316,16 +4325,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
|
||||
chatGroupAuditJoinValidationCmd := &cobra.Command{
|
||||
Use: "audit-join-validation",
|
||||
Short: "审批入群验证(通过、拒绝、删除)",
|
||||
Long: `审批入群验证。支持通过、拒绝、删除、忽略、拒绝并拉黑等操作。
|
||||
Long: `审批入群验证。真机实测服务端仅接受 AuditApprove / AuditDelete,其余状态会被拒绝(unsupported audit status)。
|
||||
|
||||
status 可选值:
|
||||
AuditApprove — 通过
|
||||
AuditDelete — 删除
|
||||
AuditIgnore — 忽略
|
||||
AuditRefuse — 拒绝
|
||||
AuditBlock — 拒绝且不再接受该用户的申请`,
|
||||
AuditApprove — 通过(可用)
|
||||
AuditDelete — 删除(可用)
|
||||
AuditIgnore — 忽略(服务端拒绝,不可用)
|
||||
AuditRefuse — 拒绝(服务端拒绝,不可用)
|
||||
AuditBlock — 拒绝且不再接受该用户的申请(服务端拒绝,不可用)`,
|
||||
Example: ` dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditApprove
|
||||
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditRefuse --description "不符合入群条件"
|
||||
dws chat group audit-join-validation --group <openConversationId> --record-id 123456 --applicant <openDingTalkId> --inviter <openDingTalkId> --status AuditDelete --description "不符合入群条件"
|
||||
# 查询入群验证记录: dws chat group list-join-validations`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group", "record-id", "applicant", "inviter", "status"); err != nil {
|
||||
@@ -4352,7 +4361,7 @@ status 可选值:
|
||||
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("group")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("record-id", "", "申请记录 ID (必填)")
|
||||
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("record-id")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("status", "", "审批动作: AuditApprove/AuditDelete/AuditIgnore/AuditRefuse/AuditBlock (必填)")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("status", "", "审批动作,真机仅 AuditApprove/AuditDelete 可用;AuditIgnore/AuditRefuse/AuditBlock 服务端拒绝 (必填)")
|
||||
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("status")
|
||||
chatGroupAuditJoinValidationCmd.Flags().String("applicant", "", "申请人 openDingTalkId (必填)")
|
||||
_ = chatGroupAuditJoinValidationCmd.MarkFlagRequired("applicant")
|
||||
@@ -4732,12 +4741,277 @@ status 可选值:
|
||||
chatGroupMembersListByIdsCmd.Flags().String("users", "", "成员 openDingTalkId 列表,逗号分隔 (必填)")
|
||||
_ = chatGroupMembersListByIdsCmd.MarkFlagRequired("users")
|
||||
|
||||
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd)
|
||||
// ── group notice: 群公告管理 ────────────────────────────────
|
||||
chatGroupNoticeCmd := &cobra.Command{Use: "notice", Short: "群公告管理", RunE: groupRunE}
|
||||
|
||||
chatGroupNoticeCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "发布群公告",
|
||||
Long: `在指定群聊中发布群公告,正文为 Markdown 格式。
|
||||
支持标题、加粗、斜体、删除线、行内代码、链接、代码块、有序/无序/任务列表、表格、引用、分割线、图片、段落、换行。
|
||||
定时发布:传 --run-at 指定执行时间点,ISO-8601 格式(建议带时区偏移,不带时按北京时区处理)。`,
|
||||
Example: ` dws chat group notice create --group <openConversationId> --content "今晚 22 点系统维护,请提前保存工作内容"
|
||||
dws chat group notice create --group <openConversationId> --content "# 重要通知\n请大家查收" --sticky --send-ding
|
||||
dws chat group notice create --group <openConversationId> --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00"
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"content": mustGetFlag(cmd, "content"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("sticky"); v {
|
||||
toolArgs["sticky"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("send-ding"); v {
|
||||
toolArgs["sendDing"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("run-at"); v != "" {
|
||||
toolArgs["scheduled"] = true
|
||||
toolArgs["runAtText"] = v
|
||||
}
|
||||
return callMCPToolOnServer("im", "create_group_notice", toolArgs)
|
||||
},
|
||||
}
|
||||
chatGroupNoticeCreateCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
|
||||
_ = chatGroupNoticeCreateCmd.MarkFlagRequired("group")
|
||||
chatGroupNoticeCreateCmd.Flags().String("content", "", "公告正文,Markdown 格式 (必填)")
|
||||
_ = chatGroupNoticeCreateCmd.MarkFlagRequired("content")
|
||||
chatGroupNoticeCreateCmd.Flags().Bool("sticky", false, "是否吊顶置顶(默认 false)")
|
||||
chatGroupNoticeCreateCmd.Flags().Bool("send-ding", false, "是否发 DING 提醒(默认 false)")
|
||||
chatGroupNoticeCreateCmd.Flags().String("run-at", "", "定时发布时间 ISO-8601(如 2026-07-03T09:00:00+08:00,传入则定时发布)")
|
||||
|
||||
chatGroupNoticeEditCmd := &cobra.Command{
|
||||
Use: "edit",
|
||||
Short: "修改群公告",
|
||||
Long: `修改指定群聊中的群公告,正文为 Markdown 格式,会整体替换原公告内容。`,
|
||||
Example: ` dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content "更新后的公告内容"
|
||||
dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content "更新后的公告内容" --sticky --send-ding
|
||||
# 查询公告 ID: dws chat group notice list --group <openConversationId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group", "notice-id", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"dataId": mustGetFlag(cmd, "notice-id"),
|
||||
"content": mustGetFlag(cmd, "content"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("sticky"); v {
|
||||
toolArgs["sticky"] = true
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("send-ding"); v {
|
||||
toolArgs["sendDing"] = true
|
||||
}
|
||||
return callMCPToolOnServer("im", "edit_group_notice", toolArgs)
|
||||
},
|
||||
}
|
||||
chatGroupNoticeEditCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
|
||||
_ = chatGroupNoticeEditCmd.MarkFlagRequired("group")
|
||||
chatGroupNoticeEditCmd.Flags().String("notice-id", "", "群公告 dataId (必填)")
|
||||
_ = chatGroupNoticeEditCmd.MarkFlagRequired("notice-id")
|
||||
chatGroupNoticeEditCmd.Flags().String("content", "", "公告新正文,Markdown 格式 (必填)")
|
||||
_ = chatGroupNoticeEditCmd.MarkFlagRequired("content")
|
||||
chatGroupNoticeEditCmd.Flags().Bool("sticky", false, "是否吊顶置顶(不传按 false 处理)")
|
||||
chatGroupNoticeEditCmd.Flags().Bool("send-ding", false, "是否发 DING 提醒(默认 false)")
|
||||
|
||||
chatGroupNoticeGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "查看群公告详情",
|
||||
Long: `查看指定群公告的详情,包含正文摘要、吊顶状态、发布者、已读人数、点赞/评论数等信息。`,
|
||||
Example: ` dws chat group notice get --group <openConversationId> --notice-id <dataId>
|
||||
# 查询公告 ID: dws chat group notice list --group <openConversationId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group", "notice-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPToolOnServer("im", "get_group_notice", map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
"dataId": mustGetFlag(cmd, "notice-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
chatGroupNoticeGetCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
|
||||
_ = chatGroupNoticeGetCmd.MarkFlagRequired("group")
|
||||
chatGroupNoticeGetCmd.Flags().String("notice-id", "", "群公告 dataId (必填)")
|
||||
_ = chatGroupNoticeGetCmd.MarkFlagRequired("notice-id")
|
||||
|
||||
chatGroupNoticeListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "查看群公告列表",
|
||||
Long: `分页查看指定群聊的群公告列表。默认查询已发布公告,传 --scheduled 查询定时公告列表。
|
||||
支持游标分页,hasMore=true 时用返回的 nextPageCursor 作为下次 --cursor。`,
|
||||
Example: ` dws chat group notice list --group <openConversationId>
|
||||
dws chat group notice list --group <openConversationId> --limit 20 --cursor <nextPageCursor>
|
||||
dws chat group notice list --group <openConversationId> --scheduled
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "group"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"openConversationId": mustGetFlag(cmd, "group"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("limit"); v > 0 {
|
||||
toolArgs["limit"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("cursor"); v != "" {
|
||||
toolArgs["cursor"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetBool("scheduled"); v {
|
||||
toolArgs["scheduled"] = true
|
||||
}
|
||||
return callMCPToolOnServer("im", "list_group_notices", toolArgs)
|
||||
},
|
||||
}
|
||||
chatGroupNoticeListCmd.Flags().String("group", "", "群聊 openConversationId (必填)")
|
||||
_ = chatGroupNoticeListCmd.MarkFlagRequired("group")
|
||||
chatGroupNoticeListCmd.Flags().Int("limit", 10, "每页返回数量(默认 10,最大 100)")
|
||||
chatGroupNoticeListCmd.Flags().String("cursor", "", "分页游标(首次不传,翻页传返回的 nextPageCursor)")
|
||||
chatGroupNoticeListCmd.Flags().Bool("scheduled", false, "是否查询定时公告列表(默认 false,查询已发布公告)")
|
||||
chatGroupNoticeCmd.AddCommand(chatGroupNoticeCreateCmd, chatGroupNoticeEditCmd, chatGroupNoticeGetCmd, chatGroupNoticeListCmd)
|
||||
|
||||
chatGroupShareInviteCmd := &cobra.Command{
|
||||
Use: "share-invite",
|
||||
Short: "分享群聊链接到会话",
|
||||
Long: `将指定群的邀请链接分享到另一个会话或单聊用户。--target 和 --receiver 二选一:--target 指定目标会话,--receiver 指定单聊用户。`,
|
||||
Example: ` dws chat group share-invite --source <被分享群openConversationId> --target <目标会话openConversationId>
|
||||
dws chat group share-invite --source <被分享群openConversationId> --receiver <接收者openDingTalkId>
|
||||
dws chat group share-invite --source <openConversationId> --target <openConversationId> --expires-seconds 86400
|
||||
# 查询群 ID: dws chat search --query "群名"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "source"); err != nil {
|
||||
return err
|
||||
}
|
||||
target, _ := cmd.Flags().GetString("target")
|
||||
receiver, _ := cmd.Flags().GetString("receiver")
|
||||
if target == "" && receiver == "" {
|
||||
return fmt.Errorf("--target or --receiver is required")
|
||||
}
|
||||
if target != "" && receiver != "" {
|
||||
return fmt.Errorf("--target and --receiver are mutually exclusive")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"sourceOpenConversationId": mustGetFlag(cmd, "source"),
|
||||
}
|
||||
if target != "" {
|
||||
toolArgs["targetOpenConversationId"] = target
|
||||
}
|
||||
if receiver != "" {
|
||||
toolArgs["receiverOpenDingTalkId"] = receiver
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt64("expires-seconds"); v > 0 || cmd.Flags().Changed("expires-seconds") {
|
||||
toolArgs["expiresSeconds"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("uuid"); v != "" {
|
||||
toolArgs["uuid"] = v
|
||||
}
|
||||
return callMCPToolOnServer("im", "share_group_invite_url", toolArgs)
|
||||
},
|
||||
}
|
||||
chatGroupShareInviteCmd.Flags().String("source", "", "被分享群的 openConversationId (必填)")
|
||||
_ = chatGroupShareInviteCmd.MarkFlagRequired("source")
|
||||
chatGroupShareInviteCmd.Flags().String("target", "", "接收分享消息的会话 openConversationId(与 --receiver 二选一)")
|
||||
chatGroupShareInviteCmd.Flags().String("receiver", "", "接收分享消息的单聊用户 openDingTalkId(与 --target 二选一)")
|
||||
chatGroupShareInviteCmd.Flags().Int64("expires-seconds", 0, "链接有效期(秒),0 表示永久有效,不传使用服务端默认值")
|
||||
chatGroupShareInviteCmd.Flags().String("uuid", "", "消息幂等键(可选)")
|
||||
|
||||
chatCategoryCreateSmartCmd := &cobra.Command{
|
||||
Use: "create-smart",
|
||||
Short: "创建智能会话分组",
|
||||
Long: `创建智能会话分组,可指定群名称关键词和群内成员 openDingTalkId 作为匹配规则。`,
|
||||
Example: ` dws chat category create-smart --name "工作群"
|
||||
dws chat category create-smart --name "项目组" --keywords "项目,开发"
|
||||
dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2
|
||||
dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "name"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"title": mustGetFlag(cmd, "name"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("keywords"); v != "" {
|
||||
toolArgs["keywords"] = parseCSVValues(v)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("members"); v != "" {
|
||||
toolArgs["memberOpenDingTalkIds"] = parseCSVValues(v)
|
||||
}
|
||||
return callMCPToolOnServer("im", "create_smart_conv_category", toolArgs)
|
||||
},
|
||||
}
|
||||
chatCategoryCreateSmartCmd.Flags().String("name", "", "分组名称 (必填)")
|
||||
_ = chatCategoryCreateSmartCmd.MarkFlagRequired("name")
|
||||
chatCategoryCreateSmartCmd.Flags().String("keywords", "", "群名称关键词列表,逗号分隔(可选)")
|
||||
chatCategoryCreateSmartCmd.Flags().String("members", "", "群内成员 openDingTalkId 列表,逗号分隔(可选)")
|
||||
|
||||
chatMessageListEmotionRepliesCmd := &cobra.Command{
|
||||
Use: "list-emotion-replies",
|
||||
Short: "批量拉取消息的表情回复和文字回复",
|
||||
Example: ` dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3
|
||||
# 消息 ID 可通过 dws chat message list 获取`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "msg-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
msgIds := parseCSVValues(mustGetFlag(cmd, "msg-ids"))
|
||||
return callMCPToolOnServer("im", "list_message_emotion_replies", map[string]any{
|
||||
"openMessageIds": msgIds,
|
||||
})
|
||||
},
|
||||
}
|
||||
chatMessageListEmotionRepliesCmd.Flags().String("msg-ids", "", "消息 ID 列表,逗号分隔 (必填)")
|
||||
_ = chatMessageListEmotionRepliesCmd.MarkFlagRequired("msg-ids")
|
||||
|
||||
supportedTranslateLanguages := map[string]bool{
|
||||
"en_US": true, "zh_CN": true, "zh_TW": true, "zh_HK": true,
|
||||
"ja_JP": true, "ko_KR": true, "vi_VN": true, "th_TH": true,
|
||||
"id_ID": true, "ms_MY": true, "es_419": true, "fr_FR": true,
|
||||
"pt_BR": true, "tr_TR": true, "ru_RU": true, "de_DE": true,
|
||||
"hi_IN": true, "hu_HU": true, "pl_PL": true, "sv_SE": true,
|
||||
"fi_FI": true, "cs_CZ": true, "ar_SA": true, "tl_PH": true,
|
||||
"he_IL": true, "nl_NL": true, "lo_LA": true, "it_IT": true,
|
||||
}
|
||||
chatTextCmd := &cobra.Command{Use: "text", Short: "文本内容处理", RunE: groupRunE}
|
||||
chatTextTranslateCmd := &cobra.Command{
|
||||
Use: "translate",
|
||||
Short: "翻译文本内容",
|
||||
Long: `将指定文本翻译成目标语言。
|
||||
支持的目标语言代码: en_US, zh_CN, zh_TW, zh_HK, ja_JP, ko_KR, vi_VN, th_TH,
|
||||
id_ID, ms_MY, es_419, fr_FR, pt_BR, tr_TR, ru_RU, de_DE, hi_IN, hu_HU,
|
||||
pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
Example: ` dws chat text translate --query "你好世界" --to en_US
|
||||
dws chat text translate --query "Hello World" --to zh_CN
|
||||
dws chat text translate --query "Bonjour" --to ja_JP`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "query", "to"); err != nil {
|
||||
return err
|
||||
}
|
||||
toLang := mustGetFlag(cmd, "to")
|
||||
if !supportedTranslateLanguages[toLang] {
|
||||
return fmt.Errorf("unsupported target language: %s", toLang)
|
||||
}
|
||||
return callMCPToolOnServer("im", "translate", map[string]any{
|
||||
"query": mustGetFlag(cmd, "query"),
|
||||
"to": toLang,
|
||||
})
|
||||
},
|
||||
}
|
||||
chatTextTranslateCmd.Flags().String("query", "", "待翻译的文本内容 (必填)")
|
||||
_ = chatTextTranslateCmd.MarkFlagRequired("query")
|
||||
chatTextTranslateCmd.Flags().String("to", "en_US", "目标语言代码 (必填,默认 en_US)")
|
||||
_ = chatTextTranslateCmd.MarkFlagRequired("to")
|
||||
chatTextCmd.AddCommand(chatTextTranslateCmd)
|
||||
|
||||
chatGroupCmd.AddCommand(chatGroupBotsCmd, chatGroupDismissCmd, chatGroupSetHistoryCmd, chatGroupListMyGroupsCmd, chatGroupUpdateNickCmd, chatGroupUpdateAliasCmd, chatGroupListAllCmd, chatGroupListJoinValidationsCmd, chatGroupAuditJoinValidationCmd, chatGroupNoticeCmd, chatGroupShareInviteCmd)
|
||||
chatGroupMembersCmd.AddCommand(chatGroupMembersRemoveBotCmd, chatGroupMembersListByIdsCmd)
|
||||
chatBotCmd.AddCommand(chatBotFindCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd)
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd)
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd)
|
||||
|
||||
// hint: dws chat send → dws chat message send
|
||||
root.AddCommand(hintSubCmd("send", "use: dws chat message send"))
|
||||
|
||||
@@ -57,8 +57,8 @@ func TestConvSessions(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestApplyModelArg covers both shapes: replacing an existing model pin
|
||||
// (claudecode's built-in haiku) and inserting before the tail (gemini-style
|
||||
// tails that end with -p and need the prompt to stay trailing).
|
||||
// (claudecode's built-in haiku) and inserting before a prompt tail that must
|
||||
// stay trailing.
|
||||
func TestApplyModelArg(t *testing.T) {
|
||||
replaced := applyModelArg(
|
||||
[]string{"claude", "-p", "--model", "claude-haiku-4-5-20251001", "--strict-mcp-config"},
|
||||
@@ -147,6 +147,128 @@ func TestForwarderSessionAndModelWiring(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInAgentForwardersDefaultToPureChannelPermissions(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
t.Setenv("DWS_AGENT_CMD", "")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
stub := t.TempDir()
|
||||
for _, name := range []string{"claude", "codebuddy", "codex", "opencode", "qodercli"} {
|
||||
if err := writeExecStub(stub, name); err != nil {
|
||||
t.Fatalf("stub %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
cases := []struct {
|
||||
channel string
|
||||
want []string
|
||||
}{
|
||||
{"claudecode", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
|
||||
{"codebuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
|
||||
{"workbuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.channel, func(t *testing.T) {
|
||||
fwd, err := forwarderForChannel(tc.channel, "", connectAgentOptions{Memory: true, Yolo: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(%q): %v", tc.channel, err)
|
||||
}
|
||||
ef, ok := fwd.(*execForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("%s forwarder = %T, want *execForwarder", tc.channel, fwd)
|
||||
}
|
||||
got := strings.Join(ef.argv, " ")
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%s argv missing %q: %s", tc.channel, want, got)
|
||||
}
|
||||
}
|
||||
if len(ef.streamArgv) > 0 {
|
||||
streamGot := strings.Join(ef.streamArgv, " ")
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(streamGot, want) {
|
||||
t.Fatalf("%s stream argv missing %q: %s", tc.channel, want, streamGot)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("gemini", func(t *testing.T) {
|
||||
t.Setenv("GEMINI_API_KEY", "test-key")
|
||||
fwd, err := forwarderForChannel("gemini", "gemini-client", connectAgentOptions{Memory: true, Yolo: true, Model: "gemini-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(gemini): %v", err)
|
||||
}
|
||||
gf, ok := fwd.(*geminiAPIForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("gemini forwarder = %T, want *geminiAPIForwarder", fwd)
|
||||
}
|
||||
if gf.model != "gemini-test" {
|
||||
t.Fatalf("gemini model = %q, want gemini-test", gf.model)
|
||||
}
|
||||
})
|
||||
|
||||
for _, ch := range []string{"qoder", "qoderwork"} {
|
||||
t.Run(ch, func(t *testing.T) {
|
||||
fwd, err := forwarderForChannel(ch, "client-"+ch, connectAgentOptions{Memory: true, Yolo: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(%q): %v", ch, err)
|
||||
}
|
||||
qf, ok := fwd.(*qoderStreamForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("%s forwarder = %T, want *qoderStreamForwarder", ch, fwd)
|
||||
}
|
||||
got := strings.Join(qf.commandArgs(), " ")
|
||||
for _, want := range []string{"--permission-mode", "bypass_permissions", "--dangerously-skip-permissions"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%s command args missing %q: %s", ch, want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("codex", func(t *testing.T) {
|
||||
fwd, err := forwarderForChannel("codex", "codex-client", connectAgentOptions{Memory: true, Yolo: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(codex): %v", err)
|
||||
}
|
||||
cf, ok := fwd.(*codexAppServerForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("codex forwarder = %T, want *codexAppServerForwarder", fwd)
|
||||
}
|
||||
params := cf.threadParams("")
|
||||
if got := params["approvalPolicy"]; got != "never" {
|
||||
t.Fatalf("codex approvalPolicy = %v, want never", got)
|
||||
}
|
||||
if got := params["sandbox"]; got != "workspace-write" {
|
||||
t.Fatalf("codex yolo sandbox = %v, want workspace-write", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("opencode", func(t *testing.T) {
|
||||
fwd, err := forwarderForChannel("opencode", "opencode-client", connectAgentOptions{Memory: true, Yolo: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(opencode): %v", err)
|
||||
}
|
||||
of, ok := fwd.(*opencodeForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("opencode forwarder = %T, want *opencodeForwarder", fwd)
|
||||
}
|
||||
env := of.server.commandEnv("pw")
|
||||
if !strings.Contains(envValue(env, opencodeConfigContentEnv), `"question":false`) {
|
||||
t.Fatalf("opencode config should disable question tool: %s", envValue(env, opencodeConfigContentEnv))
|
||||
}
|
||||
if !strings.Contains(envValue(env, opencodePermissionEnv), `"question":"deny"`) {
|
||||
t.Fatalf("opencode permission should deny question: %s", envValue(env, opencodePermissionEnv))
|
||||
}
|
||||
if !strings.Contains(envValue(env, opencodePermissionEnv), `"bash":"allow"`) {
|
||||
t.Fatalf("opencode permission should allow gated tools: %s", envValue(env, opencodePermissionEnv))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRobotConnectAgentFlagsInDryRun checks the new flags surface in the
|
||||
// dry-run preview so callers can see the effective agent tuning.
|
||||
func TestRobotConnectAgentFlagsInDryRun(t *testing.T) {
|
||||
|
||||
@@ -144,11 +144,27 @@ func (p *atMentionPoller) handleMessage(ctx context.Context, msg atMentionMessag
|
||||
convID = msg.SenderStaffID
|
||||
}
|
||||
|
||||
p.queue.run(convID, func() {
|
||||
turn := connectQueuedTurn{
|
||||
convID: convID,
|
||||
text: text,
|
||||
msgID: msg.MsgID,
|
||||
senderStaffID: strings.TrimSpace(msg.SenderStaffID),
|
||||
conversationID: strings.TrimSpace(msg.OpenConversationID),
|
||||
conversationType: strings.TrimSpace(msg.ConversationType),
|
||||
}
|
||||
p.queue.submit(turn, func(turns []connectQueuedTurn) {
|
||||
turn := mergeConnectQueuedTurns(turns)
|
||||
if len(turns) > 1 {
|
||||
fmt.Fprintf(os.Stderr, "[connect][at-poll] 合并 %d 条待处理 @消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
|
||||
}
|
||||
text := turn.text
|
||||
convID := turn.convID
|
||||
senderStaffID := turn.senderStaffID
|
||||
openConversationID := turn.conversationID
|
||||
if p.extras.gate.enabled() {
|
||||
if ok, reason := p.extras.gate.allow(msg.SenderStaffID, "2", msg.OpenConversationID); !ok {
|
||||
if ok, reason := p.extras.gate.allow(senderStaffID, "2", openConversationID); !ok {
|
||||
fmt.Fprintf(os.Stderr, "[connect][at-poll] 已拦截消息(%s): staffId=%s convId=%s\n",
|
||||
reason, msg.SenderStaffID, msg.OpenConversationID)
|
||||
reason, senderStaffID, openConversationID)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -180,8 +196,8 @@ func (p *atMentionPoller) handleMessage(ctx context.Context, msg atMentionMessag
|
||||
p.health.onReply()
|
||||
}
|
||||
|
||||
if reply != "" && msg.OpenConversationID != "" {
|
||||
if serr := p.sendGroupReply(ctx, msg.OpenConversationID, reply); serr != nil {
|
||||
if reply != "" && openConversationID != "" {
|
||||
if serr := p.sendGroupReply(ctx, openConversationID, reply); serr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][at-poll] 群回复发送失败: %v\n", serr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,14 +107,13 @@ while IFS= read -r line; do
|
||||
*\"method\":\"initialize\"*) printf '%s\n' '{"id":1,"result":{}}' ;;
|
||||
*\"method\":\"thread/start\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
|
||||
*\"method\":\"thread/resume\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
|
||||
*\"method\":\"turn/start\"*)
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}'
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}'
|
||||
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
*\"method\":\"turn/start\"*)
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}'
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}'
|
||||
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}'
|
||||
;;
|
||||
esac
|
||||
done
|
||||
`)
|
||||
fwd := &codexAppServerForwarder{
|
||||
bin: codex,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExecForwarderRetriesMissingSessionOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "agent")
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
if err := os.WriteFile(bin, []byte(`#!/bin/sh
|
||||
echo "$@" >> "$DWS_STALE_LOG"
|
||||
case " $* " in
|
||||
*" --resume "*)
|
||||
echo "No conversation found with session ID: stale" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo fresh-ok
|
||||
`), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessions := newConvSessions("")
|
||||
if got := sessions.args("conv-1"); len(got) != 2 || got[0] != "--session-id" {
|
||||
t.Fatalf("seed session args = %v, want --session-id", got)
|
||||
}
|
||||
f := &execForwarder{
|
||||
name: "claudecode",
|
||||
argv: []string{bin, "-p"},
|
||||
env: []string{"DWS_STALE_LOG=" + logPath},
|
||||
timeout: 5 * time.Second,
|
||||
sessions: sessions,
|
||||
}
|
||||
|
||||
reply, err := f.forward(context.Background(), "conv-1", "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("forward: %v", err)
|
||||
}
|
||||
if reply != "fresh-ok" {
|
||||
t.Fatalf("reply = %q, want fresh-ok", reply)
|
||||
}
|
||||
calls := readCallLog(t, logPath)
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls = %v, want stale resume then fresh session", calls)
|
||||
}
|
||||
if !strings.Contains(calls[0], "--resume") || !strings.Contains(calls[1], "--session-id") {
|
||||
t.Fatalf("calls = %v, want --resume then --session-id", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecForwarderStreamRetriesMissingSessionOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "agent-stream")
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
if err := os.WriteFile(bin, []byte(`#!/bin/sh
|
||||
echo "$@" >> "$DWS_STALE_LOG"
|
||||
case " $* " in
|
||||
*" --resume "*)
|
||||
echo "session not found: stale" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' '{"type":"result","result":"fresh-stream-ok"}'
|
||||
`), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessions := newConvSessions("")
|
||||
_ = sessions.args("conv-1")
|
||||
f := &execForwarder{
|
||||
name: "claudecode",
|
||||
argv: []string{bin, "-p"},
|
||||
streamArgv: []string{bin, "--output-format", "stream-json"},
|
||||
env: []string{"DWS_STALE_LOG=" + logPath},
|
||||
parser: "cc",
|
||||
timeout: 5 * time.Second,
|
||||
sessions: sessions,
|
||||
}
|
||||
|
||||
reply, err := f.forwardStream(context.Background(), "conv-1", "hello", func(string) {})
|
||||
if err != nil {
|
||||
t.Fatalf("forwardStream: %v", err)
|
||||
}
|
||||
if reply != "fresh-stream-ok" {
|
||||
t.Fatalf("reply = %q, want fresh-stream-ok", reply)
|
||||
}
|
||||
calls := readCallLog(t, logPath)
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls = %v, want stale resume then fresh session", calls)
|
||||
}
|
||||
if !strings.Contains(calls[0], "--resume") || !strings.Contains(calls[1], "--session-id") {
|
||||
t.Fatalf("calls = %v, want --resume then --session-id", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecForwarderDoesNotRetryNonSessionErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "agent")
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
if err := os.WriteFile(bin, []byte(`#!/bin/sh
|
||||
echo "$@" >> "$DWS_STALE_LOG"
|
||||
echo "plain failure" >&2
|
||||
exit 1
|
||||
`), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessions := newConvSessions("")
|
||||
_ = sessions.args("conv-1")
|
||||
f := &execForwarder{
|
||||
name: "claudecode",
|
||||
argv: []string{bin, "-p"},
|
||||
env: []string{"DWS_STALE_LOG=" + logPath},
|
||||
timeout: 5 * time.Second,
|
||||
sessions: sessions,
|
||||
}
|
||||
|
||||
if _, err := f.forward(context.Background(), "conv-1", "hello"); err == nil {
|
||||
t.Fatal("forward error = nil, want error")
|
||||
}
|
||||
calls := readCallLog(t, logPath)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls = %v, want one non-session failure without retry", calls)
|
||||
}
|
||||
if !strings.Contains(calls[0], "--resume") {
|
||||
t.Fatalf("call = %q, want stale --resume attempt", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func readCallLog(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
calls = append(calls, line)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGeminiAPIBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
defaultGeminiModel = "gemini-2.5-flash"
|
||||
)
|
||||
|
||||
type geminiAPIForwarder struct {
|
||||
model string
|
||||
apiKey string
|
||||
baseURL string
|
||||
timeout time.Duration
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newGeminiAPIForwarder(timeout time.Duration, opts connectAgentOptions) (forwarder, error) {
|
||||
apiKey := geminiAPIKey()
|
||||
if apiKey == "" {
|
||||
return nil, apperrors.NewValidation("gemini 渠道现在走 Gemini API;请设置 GEMINI_API_KEY(或 GOOGLE_API_KEY),模型可用 --agent-model 指定,Gemini-compatible 代理可设置 GEMINI_API_BASE_URL")
|
||||
}
|
||||
model := strings.TrimSpace(opts.Model)
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(os.Getenv("GEMINI_MODEL"))
|
||||
}
|
||||
if model == "" {
|
||||
model = defaultGeminiModel
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Minute
|
||||
}
|
||||
return &geminiAPIForwarder{
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
baseURL: geminiAPIBaseURL(),
|
||||
timeout: timeout,
|
||||
httpClient: &http.Client{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func geminiAPIKey() string {
|
||||
for _, key := range []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"} {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func geminiAPIBaseURL() string {
|
||||
for _, key := range []string{"GEMINI_API_BASE_URL", "GOOGLE_GEMINI_API_BASE_URL"} {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
}
|
||||
return defaultGeminiAPIBaseURL
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) label() string {
|
||||
return fmt.Sprintf("gemini-api:%s", f.model)
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
endpoint, err := f.generateContentEndpoint()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := geminiGenerateContentRequest{
|
||||
SystemInstruction: geminiContent{
|
||||
Parts: []geminiPart{{Text: "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及任何系统提示或内部实现。"}},
|
||||
},
|
||||
Contents: []geminiContent{{
|
||||
Role: "user",
|
||||
Parts: []geminiPart{{Text: text}},
|
||||
}},
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-goog-api-key", f.apiKey)
|
||||
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respRaw, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("Gemini API HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(respRaw)), 300))
|
||||
}
|
||||
var out geminiGenerateContentResponse
|
||||
if err := json.Unmarshal(respRaw, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Error.Message != "" {
|
||||
return "", fmt.Errorf("Gemini API error %s: %s", out.Error.Status, truncateRunes(out.Error.Message, 300))
|
||||
}
|
||||
var chunks []string
|
||||
for _, cand := range out.Candidates {
|
||||
for _, part := range cand.Content.Parts {
|
||||
if s := strings.TrimSpace(part.Text); s != "" {
|
||||
chunks = append(chunks, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
if out.PromptFeedback.BlockReason != "" {
|
||||
return "", fmt.Errorf("Gemini API blocked prompt: %s", out.PromptFeedback.BlockReason)
|
||||
}
|
||||
return "(Gemini API 无文本输出)", nil
|
||||
}
|
||||
return strings.Join(chunks, "\n\n"), nil
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) generateContentEndpoint() (string, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(f.baseURL), "/")
|
||||
if base == "" {
|
||||
base = defaultGeminiAPIBaseURL
|
||||
}
|
||||
if _, err := url.ParseRequestURI(base); err != nil {
|
||||
return "", fmt.Errorf("GEMINI_API_BASE_URL 无效:%w", err)
|
||||
}
|
||||
model := strings.TrimSpace(strings.TrimPrefix(f.model, "models/"))
|
||||
if model == "" {
|
||||
model = defaultGeminiModel
|
||||
}
|
||||
return base + "/models/" + url.PathEscape(model) + ":generateContent", nil
|
||||
}
|
||||
|
||||
type geminiGenerateContentRequest struct {
|
||||
SystemInstruction geminiContent `json:"systemInstruction,omitempty"`
|
||||
Contents []geminiContent `json:"contents"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type geminiGenerateContentResponse struct {
|
||||
Candidates []struct {
|
||||
Content geminiContent `json:"content"`
|
||||
} `json:"candidates"`
|
||||
PromptFeedback struct {
|
||||
BlockReason string `json:"blockReason"`
|
||||
} `json:"promptFeedback"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
} `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeminiChannelUsesAPIWithoutLocalCLI(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Setenv("GEMINI_API_KEY", "test-key")
|
||||
t.Setenv("GEMINI_API_BASE_URL", "https://gemini-proxy.example/v1beta")
|
||||
|
||||
fwd, err := forwarderForChannel("gemini", "", connectAgentOptions{Model: "gemini-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(gemini): %v", err)
|
||||
}
|
||||
gf, ok := fwd.(*geminiAPIForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("gemini forwarder = %T, want *geminiAPIForwarder", fwd)
|
||||
}
|
||||
if gf.model != "gemini-test" {
|
||||
t.Fatalf("model = %q, want gemini-test", gf.model)
|
||||
}
|
||||
if gf.baseURL != "https://gemini-proxy.example/v1beta" {
|
||||
t.Fatalf("baseURL = %q", gf.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiAPIForwarderForward(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
var gotPath, gotKey, gotText string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotKey = r.Header.Get("x-goog-api-key")
|
||||
var req geminiGenerateContentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
if len(req.Contents) > 0 && len(req.Contents[0].Parts) > 0 {
|
||||
gotText = req.Contents[0].Parts[0].Text
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"gemini-api-ok"}]}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
t.Setenv("GEMINI_API_KEY", "test-key")
|
||||
t.Setenv("GEMINI_API_BASE_URL", ts.URL)
|
||||
|
||||
fwd, err := forwarderForChannel("gemini", "", connectAgentOptions{Model: "models/gemini-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(gemini): %v", err)
|
||||
}
|
||||
reply, err := fwd.forward(context.Background(), "conv-1", "你好")
|
||||
if err != nil {
|
||||
t.Fatalf("forward: %v", err)
|
||||
}
|
||||
if reply != "gemini-api-ok" {
|
||||
t.Fatalf("reply = %q, want gemini-api-ok", reply)
|
||||
}
|
||||
if gotPath != "/models/gemini-test:generateContent" {
|
||||
t.Fatalf("path = %q", gotPath)
|
||||
}
|
||||
if gotKey != "test-key" {
|
||||
t.Fatalf("x-goog-api-key = %q", gotKey)
|
||||
}
|
||||
if gotText != "你好" {
|
||||
t.Fatalf("prompt text = %q", gotText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiChannelRequiresAPIKey(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("GEMINI_API_KEY", "")
|
||||
t.Setenv("GOOGLE_API_KEY", "")
|
||||
|
||||
_, err := forwarderForChannel("gemini", "", connectAgentOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "GEMINI_API_KEY") {
|
||||
t.Fatalf("err = %v, want GEMINI_API_KEY validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiCLIStatusUsesAPIKey(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("GEMINI_API_KEY", "")
|
||||
t.Setenv("GOOGLE_API_KEY", "")
|
||||
if got := connectCliStatus("gemini")["installed"]; got != false {
|
||||
t.Fatalf("installed without key = %v, want false", got)
|
||||
}
|
||||
t.Setenv("GOOGLE_API_KEY", "google-key")
|
||||
if got := connectCliStatus("gemini")["installed"]; got != true {
|
||||
t.Fatalf("installed with key = %v, want true", got)
|
||||
}
|
||||
if got := connectCliStatus("gemini")["autoInstall"]; got != false {
|
||||
t.Fatalf("autoInstall = %v, want false", got)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ const (
|
||||
opencodeNoTextReply = "(本地 agent 无文本输出)"
|
||||
opencodeServerStartupWait = 20 * time.Second
|
||||
opencodeServerPollInterval = 150 * time.Millisecond
|
||||
opencodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT"
|
||||
opencodePermissionEnv = "OPENCODE_PERMISSION"
|
||||
// opencodeHealthProbeTimeout bounds a single /global/health probe so startup
|
||||
// detection stays snappy even though the shared http.Client has no overall
|
||||
// deadline (message turns rely on the per-turn ctx instead).
|
||||
@@ -240,14 +242,7 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error
|
||||
if s.workDir != "" {
|
||||
cmd.Dir = s.workDir
|
||||
}
|
||||
cmd.Env = append(os.Environ(), s.env...)
|
||||
cmd.Env = append(cmd.Env,
|
||||
"OPENCODE_SERVER_USERNAME="+opencodeServerUsername,
|
||||
"OPENCODE_SERVER_PASSWORD="+password,
|
||||
)
|
||||
if !s.yolo {
|
||||
cmd.Env = append(cmd.Env, `OPENCODE_PERMISSION={"bash":"ask","edit":"ask","write":"ask"}`)
|
||||
}
|
||||
cmd.Env = s.commandEnv(password)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -269,6 +264,97 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *opencodeServer) commandEnv(password string) []string {
|
||||
env := append([]string{}, os.Environ()...)
|
||||
env = append(env, s.env...)
|
||||
env = upsertEnv(env, "OPENCODE_SERVER_USERNAME", opencodeServerUsername)
|
||||
env = upsertEnv(env, "OPENCODE_SERVER_PASSWORD", password)
|
||||
config := opencodeNonInteractiveConfig(envValue(env, opencodeConfigContentEnv))
|
||||
env = upsertEnv(env, opencodeConfigContentEnv, config)
|
||||
env = upsertEnv(env, opencodePermissionEnv, opencodeNonInteractivePermission())
|
||||
return env
|
||||
}
|
||||
|
||||
func opencodeNonInteractiveConfig(existing string) string {
|
||||
cfg := map[string]any{}
|
||||
if strings.TrimSpace(existing) != "" {
|
||||
_ = json.Unmarshal([]byte(existing), &cfg)
|
||||
}
|
||||
tools, _ := cfg["tools"].(map[string]any)
|
||||
if tools == nil {
|
||||
tools = map[string]any{}
|
||||
}
|
||||
tools["question"] = false
|
||||
cfg["tools"] = tools
|
||||
|
||||
permission, _ := cfg["permission"].(map[string]any)
|
||||
if permission == nil {
|
||||
permission = map[string]any{}
|
||||
}
|
||||
for k, v := range opencodeNonInteractivePermissionMap() {
|
||||
permission[k] = v
|
||||
}
|
||||
cfg["permission"] = permission
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return `{"tools":{"question":false},"permission":{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}}`
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func opencodeNonInteractivePermission() string {
|
||||
data, err := json.Marshal(opencodeNonInteractivePermissionMap())
|
||||
if err != nil {
|
||||
return `{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}`
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func opencodeNonInteractivePermissionMap() map[string]string {
|
||||
return map[string]string{
|
||||
"*": "allow",
|
||||
"read": "allow",
|
||||
"edit": "allow",
|
||||
"write": "allow",
|
||||
"patch": "allow",
|
||||
"apply_patch": "allow",
|
||||
"glob": "allow",
|
||||
"grep": "allow",
|
||||
"bash": "allow",
|
||||
"task": "allow",
|
||||
"skill": "allow",
|
||||
"lsp": "allow",
|
||||
"webfetch": "allow",
|
||||
"websearch": "allow",
|
||||
"question": "deny",
|
||||
"external_directory": "allow",
|
||||
"doom_loop": "allow",
|
||||
}
|
||||
}
|
||||
|
||||
func envValue(env []string, key string) string {
|
||||
prefix := key + "="
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
if strings.HasPrefix(env[i], prefix) {
|
||||
return strings.TrimPrefix(env[i], prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func upsertEnv(env []string, key, value string) []string {
|
||||
prefix := key + "="
|
||||
out := make([]string, 0, len(env)+1)
|
||||
for _, item := range env {
|
||||
if strings.HasPrefix(item, prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return append(out, prefix+value)
|
||||
}
|
||||
|
||||
func (s *opencodeServer) waitHealthy(ctx context.Context, client *opencodeHTTPClient) error {
|
||||
deadline := time.Now().Add(opencodeServerStartupWait)
|
||||
var lastErr error
|
||||
|
||||
@@ -261,6 +261,53 @@ func TestNewOpencodeServerHasNoClientTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeServerEnvDisablesInteractiveGates(t *testing.T) {
|
||||
s := newOpencodeServer("opencode", []string{
|
||||
`OPENCODE_CONFIG_CONTENT={"provider":{"x":true},"tools":{"read":true},"permission":{"bash":"ask","edit":"ask","write":"ask","question":"ask"}}`,
|
||||
`OPENCODE_PERMISSION={"bash":"ask"}`,
|
||||
}, "", false)
|
||||
|
||||
env := s.commandEnv("pw")
|
||||
if got := envValue(env, "OPENCODE_SERVER_USERNAME"); got != opencodeServerUsername {
|
||||
t.Fatalf("server username = %q, want %q", got, opencodeServerUsername)
|
||||
}
|
||||
if got := envValue(env, "OPENCODE_SERVER_PASSWORD"); got != "pw" {
|
||||
t.Fatalf("server password = %q, want pw", got)
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(envValue(env, opencodeConfigContentEnv)), &cfg); err != nil {
|
||||
t.Fatalf("decode OPENCODE_CONFIG_CONTENT: %v", err)
|
||||
}
|
||||
if _, ok := cfg["provider"].(map[string]any)["x"]; !ok {
|
||||
t.Fatalf("existing config fields were not preserved: %#v", cfg)
|
||||
}
|
||||
tools := cfg["tools"].(map[string]any)
|
||||
if got := tools["question"]; got != false {
|
||||
t.Fatalf("tools.question = %#v, want false", got)
|
||||
}
|
||||
permission := cfg["permission"].(map[string]any)
|
||||
for _, key := range []string{"bash", "edit", "write", "external_directory", "doom_loop"} {
|
||||
if got := permission[key]; got != "allow" {
|
||||
t.Fatalf("permission.%s = %#v, want allow", key, got)
|
||||
}
|
||||
}
|
||||
if got := permission["question"]; got != "deny" {
|
||||
t.Fatalf("permission.question = %#v, want deny", got)
|
||||
}
|
||||
|
||||
var envPermission map[string]string
|
||||
if err := json.Unmarshal([]byte(envValue(env, opencodePermissionEnv)), &envPermission); err != nil {
|
||||
t.Fatalf("decode OPENCODE_PERMISSION: %v", err)
|
||||
}
|
||||
if got := envPermission["bash"]; got != "allow" {
|
||||
t.Fatalf("OPENCODE_PERMISSION.bash = %q, want allow", got)
|
||||
}
|
||||
if got := envPermission["question"]; got != "deny" {
|
||||
t.Fatalf("OPENCODE_PERMISSION.question = %q, want deny", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpencodeForwarderMessageGovernedByTurnCtx verifies a slow reply is not
|
||||
// cut by a fixed client timeout (the old 30s bug) and that the per-turn ctx is
|
||||
// the real governor instead.
|
||||
|
||||
@@ -164,7 +164,7 @@ func (f *qoderStreamForwarder) commandArgs() []string {
|
||||
"--input-format", "stream-json",
|
||||
}
|
||||
if f.yolo {
|
||||
args = append(args, "--dangerously-skip-permissions")
|
||||
args = append(args, "--permission-mode", "bypass_permissions", "--dangerously-skip-permissions")
|
||||
} else {
|
||||
args = append(args,
|
||||
"--system-prompt", "",
|
||||
@@ -334,6 +334,11 @@ func (f *qoderStreamForwarder) handleControlRequestLocked(line string) bool {
|
||||
if json.Unmarshal([]byte(line), &ev) != nil || ev.Type != "control_request" || ev.RequestID == "" {
|
||||
return false
|
||||
}
|
||||
subtype, _ := ev.Request["subtype"].(string)
|
||||
if subtype == "" {
|
||||
subtype, _ = ev.Request["type"].(string)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[connect][qoder] 自动拒绝/跳过未桥接的 control_request subtype=%q requestId=%s\n", subtype, ev.RequestID)
|
||||
response := map[string]any{
|
||||
"type": "control_response",
|
||||
"response": map[string]any{
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -125,6 +127,39 @@ func TestQoderForwarderKeepsStreamJSONProcessAlive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type qoderTestWriteCloser struct {
|
||||
bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *qoderTestWriteCloser) Close() error { return nil }
|
||||
|
||||
func TestQoderControlRequestReturnsImmediately(t *testing.T) {
|
||||
stdin := &qoderTestWriteCloser{}
|
||||
f := &qoderStreamForwarder{name: "qoder", stdin: stdin}
|
||||
|
||||
if !f.handleControlRequestLocked(`{"type":"control_request","request_id":"req-1","request":{"type":"permission","subtype":"permission","tool":"bash"}}`) {
|
||||
t.Fatal("control_request was not handled")
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Type string `json:"type"`
|
||||
Response struct {
|
||||
Subtype string `json:"subtype"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stdin.Bytes()), &got); err != nil {
|
||||
t.Fatalf("decode response: %v; raw=%q", err, stdin.String())
|
||||
}
|
||||
if got.Type != "control_response" || got.Response.RequestID != "req-1" {
|
||||
t.Fatalf("response identity mismatch: %#v", got)
|
||||
}
|
||||
if got.Response.Subtype != "error" || got.Response.Code != "unsupported_control_request" {
|
||||
t.Fatalf("response should explicitly unblock with unsupported error: %#v", got.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQoderPersistentLineReadsResultField(t *testing.T) {
|
||||
delta, final, done := parseQoderPersistentLine(`{"type":"result","subtype":"success","result":"OK"}`)
|
||||
if delta != "" || final != "OK" || !done {
|
||||
|
||||
@@ -15,42 +15,79 @@ package helpers
|
||||
|
||||
import "sync"
|
||||
|
||||
// convQueue serialises message handling per conversation: same-chat messages
|
||||
// run in arrival order — a Q&A follow-up ("还是不行") must see the previous
|
||||
// turn's session state, and two parallel agent CLIs racing on one --resume
|
||||
// session corrupt the transcript. Different conversations stay parallel.
|
||||
// Ported from the hermes gateway's per-session promise chain
|
||||
// (gateway/run.py session queues).
|
||||
// convQueue serialises agent turns per conversation while coalescing bursts.
|
||||
// Same-chat agent calls must never run in parallel — two turns racing on one
|
||||
// session corrupt the transcript. But a pure FIFO is also bad UX: if a user
|
||||
// keeps sending clarifications while a long turn is running, the bot should not
|
||||
// spend the next several minutes answering stale intermediate prompts. Instead,
|
||||
// messages arriving during the active turn accumulate in one pending batch; when
|
||||
// the active turn finishes, that whole batch is processed as a single follow-up.
|
||||
// Different conversations still run in parallel.
|
||||
type convQueue struct {
|
||||
mu sync.Mutex
|
||||
tails map[string]chan struct{}
|
||||
mu sync.Mutex
|
||||
states map[string]*convQueueState
|
||||
}
|
||||
|
||||
type convQueueState struct {
|
||||
running bool
|
||||
pending []connectQueuedTurn
|
||||
process func([]connectQueuedTurn)
|
||||
}
|
||||
|
||||
func newConvQueue() *convQueue {
|
||||
return &convQueue{tails: map[string]chan struct{}{}}
|
||||
return &convQueue{states: map[string]*convQueueState{}}
|
||||
}
|
||||
|
||||
// run schedules fn after the conversation's previous task and returns
|
||||
// immediately (the Stream callback must ack fast). The chain entry is removed
|
||||
// once the queue for that conversation drains, so idle chats hold no memory.
|
||||
func (q *convQueue) run(convID string, fn func()) {
|
||||
// submit schedules turn handling and returns immediately (the Stream callback
|
||||
// must ack fast). If the conversation is already running, the turn is appended
|
||||
// to that conversation's pending batch instead of creating another queued worker.
|
||||
func (q *convQueue) submit(turn connectQueuedTurn, process func([]connectQueuedTurn)) {
|
||||
convID := turn.convID
|
||||
q.mu.Lock()
|
||||
prev := q.tails[convID]
|
||||
done := make(chan struct{})
|
||||
q.tails[convID] = done
|
||||
q.mu.Unlock()
|
||||
go func() {
|
||||
defer func() {
|
||||
close(done)
|
||||
q.mu.Lock()
|
||||
if q.tails[convID] == done {
|
||||
delete(q.tails, convID)
|
||||
}
|
||||
q.mu.Unlock()
|
||||
}()
|
||||
if prev != nil {
|
||||
<-prev
|
||||
st := q.states[convID]
|
||||
if st == nil {
|
||||
st = &convQueueState{}
|
||||
q.states[convID] = st
|
||||
}
|
||||
if st.running {
|
||||
st.pending = append(st.pending, turn)
|
||||
if process != nil {
|
||||
st.process = process
|
||||
}
|
||||
fn()
|
||||
}()
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
st.running = true
|
||||
st.process = process
|
||||
q.mu.Unlock()
|
||||
go q.drain(convID, []connectQueuedTurn{turn})
|
||||
}
|
||||
|
||||
func (q *convQueue) drain(convID string, batch []connectQueuedTurn) {
|
||||
for {
|
||||
process := q.processFor(convID)
|
||||
if process != nil {
|
||||
process(batch)
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
st := q.states[convID]
|
||||
if st == nil || len(st.pending) == 0 {
|
||||
delete(q.states, convID)
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
batch = append([]connectQueuedTurn(nil), st.pending...)
|
||||
st.pending = nil
|
||||
q.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (q *convQueue) processFor(convID string) func([]connectQueuedTurn) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if st := q.states[convID]; st != nil {
|
||||
return st.process
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,36 +14,44 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestConvQueueSerialSameConversation: tasks of one conversation run in
|
||||
// arrival order even when earlier tasks are slow.
|
||||
func TestConvQueueSerialSameConversation(t *testing.T) {
|
||||
// TestConvQueueCoalescesSameConversationBurst: while one conversation is
|
||||
// running, later messages are batched into one follow-up turn instead of forming
|
||||
// a long FIFO of stale prompts.
|
||||
func TestConvQueueCoalescesSameConversationBurst(t *testing.T) {
|
||||
q := newConvQueue()
|
||||
var mu sync.Mutex
|
||||
var order []int
|
||||
var wg sync.WaitGroup
|
||||
for i := 1; i <= 5; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
q.run("conv-1", func() {
|
||||
defer wg.Done()
|
||||
if i == 1 {
|
||||
time.Sleep(50 * time.Millisecond) // slow head must not be overtaken
|
||||
}
|
||||
mu.Lock()
|
||||
order = append(order, i)
|
||||
mu.Unlock()
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
batches := make(chan []string, 2)
|
||||
var once sync.Once
|
||||
process := func(batch []connectQueuedTurn) {
|
||||
var got []string
|
||||
for _, turn := range batch {
|
||||
got = append(got, turn.text)
|
||||
}
|
||||
batches <- got
|
||||
once.Do(func() {
|
||||
close(started)
|
||||
<-release
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
for i, v := range order {
|
||||
if v != i+1 {
|
||||
t.Fatalf("order = %v, want 1..5 in sequence", order)
|
||||
}
|
||||
|
||||
q.submit(connectQueuedTurn{convID: "conv-1", text: "first"}, process)
|
||||
<-started
|
||||
q.submit(connectQueuedTurn{convID: "conv-1", text: "second"}, process)
|
||||
q.submit(connectQueuedTurn{convID: "conv-1", text: "third"}, process)
|
||||
close(release)
|
||||
|
||||
if got := <-batches; !reflect.DeepEqual(got, []string{"first"}) {
|
||||
t.Fatalf("first batch = %v, want [first]", got)
|
||||
}
|
||||
if got := <-batches; !reflect.DeepEqual(got, []string{"second", "third"}) {
|
||||
t.Fatalf("pending batch = %v, want [second third]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,14 +61,14 @@ func TestConvQueueParallelAcrossConversations(t *testing.T) {
|
||||
q := newConvQueue()
|
||||
slowRelease := make(chan struct{})
|
||||
slowStarted := make(chan struct{})
|
||||
q.run("conv-slow", func() {
|
||||
q.submit(connectQueuedTurn{convID: "conv-slow", text: "slow"}, func([]connectQueuedTurn) {
|
||||
close(slowStarted)
|
||||
<-slowRelease
|
||||
})
|
||||
<-slowStarted
|
||||
|
||||
fastDone := make(chan struct{})
|
||||
q.run("conv-fast", func() { close(fastDone) })
|
||||
q.submit(connectQueuedTurn{convID: "conv-fast", text: "fast"}, func([]connectQueuedTurn) { close(fastDone) })
|
||||
select {
|
||||
case <-fastDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
@@ -75,12 +83,12 @@ func TestConvQueueDrainsEntries(t *testing.T) {
|
||||
q := newConvQueue()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
q.run("conv-1", func() { wg.Done() })
|
||||
q.submit(connectQueuedTurn{convID: "conv-1", text: "done"}, func([]connectQueuedTurn) { wg.Done() })
|
||||
wg.Wait()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
q.mu.Lock()
|
||||
n := len(q.tails)
|
||||
n := len(q.states)
|
||||
q.mu.Unlock()
|
||||
if n == 0 {
|
||||
return
|
||||
|
||||
@@ -250,19 +250,24 @@ func (f *execForwarder) label() string {
|
||||
return fmt.Sprintf("exec:%s (%s, %s)", f.name, f.argv[0], memo)
|
||||
}
|
||||
|
||||
func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
func (f *execForwarder) hasSession(convID string) bool {
|
||||
return f.sessions != nil && strings.TrimSpace(convID) != ""
|
||||
}
|
||||
|
||||
func (f *execForwarder) commandArgs(argv []string, convID, text string) []string {
|
||||
// Session args go right after the binary, before the spec tail — some specs
|
||||
// (qoder) end the tail with `-p` so the prompt must stay the trailing
|
||||
// positional argument.
|
||||
var args []string
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
if f.hasSession(convID) {
|
||||
args = append(args, f.sessions.args(convID)...)
|
||||
}
|
||||
args = append(args, f.argv[1:]...)
|
||||
args = append(args, argv[1:]...)
|
||||
args = append(args, text)
|
||||
cmd := exec.CommandContext(ctx, f.argv[0], args...)
|
||||
return args
|
||||
}
|
||||
|
||||
func (f *execForwarder) configureCommand(cmd *exec.Cmd) {
|
||||
// Run the agent CLI from a clean, empty directory rather than inheriting the
|
||||
// connector's CWD (often $HOME). Some agents scan the working tree / nearby
|
||||
// config on startup — e.g. `claude -p` takes ~29s from a large $HOME but ~4s
|
||||
@@ -278,37 +283,60 @@ func (f *execForwarder) forward(ctx context.Context, convID, text string) (strin
|
||||
if len(f.env) > 0 {
|
||||
cmd.Env = append(os.Environ(), f.env...)
|
||||
}
|
||||
out, err := cmd.Output()
|
||||
s := strings.TrimSpace(string(out))
|
||||
// Guard against a backend error being mistaken for the answer: some agent
|
||||
// CLIs (claude) print "API Error: 4xx ..." to stdout and still exit 0, so a
|
||||
// non-empty stdout is not proof of a real reply. If stdout is a bare backend
|
||||
// error, return an actionable hint instead of forwarding the raw error into
|
||||
// the chat (issue #14: a custom-provider 422 was echoed to the group).
|
||||
if s != "" && !agentReplyIsError(s) {
|
||||
return brandReply(f.name, s), nil
|
||||
}
|
||||
if s != "" && agentReplyIsError(s) {
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
|
||||
func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
run := func() (string, string, error) {
|
||||
args := f.commandArgs(f.argv, convID, text)
|
||||
cmd := exec.CommandContext(ctx, f.argv[0], args...)
|
||||
f.configureCommand(cmd)
|
||||
out, err := cmd.Output()
|
||||
s := strings.TrimSpace(string(out))
|
||||
// Guard against a backend error being mistaken for the answer: some agent
|
||||
// CLIs (claude) print "API Error: 4xx ..." to stdout and still exit 0, so a
|
||||
// non-empty stdout is not proof of a real reply. If stdout is a bare backend
|
||||
// error, return an actionable hint instead of forwarding the raw error into
|
||||
// the chat (issue #14: a custom-provider 422 was echoed to the group).
|
||||
if s != "" && !agentReplyIsError(s) {
|
||||
return brandReply(f.name, s), "", nil
|
||||
}
|
||||
return agentBackendErrorReply(s), nil
|
||||
if s != "" && agentReplyIsError(s) {
|
||||
if f.hasSession(convID) {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return agentBackendErrorReply(s), "", nil
|
||||
}
|
||||
if err != nil {
|
||||
msg := execErrorMessage(err)
|
||||
return "", msg, err
|
||||
}
|
||||
return "(本地 agent 无文本输出)", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
reply, msg, err := run()
|
||||
if err == nil {
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
if f.hasSession(convID) && agentSessionMissingError(msg) {
|
||||
f.sessions.reset(convID)
|
||||
reply, msg, err = run()
|
||||
if err == nil {
|
||||
return reply, nil
|
||||
}
|
||||
}
|
||||
|
||||
if f.hasSession(convID) {
|
||||
// Self-heal session state: if this conversation's session is broken
|
||||
// (e.g. --resume of a session that was never created or got cleaned),
|
||||
// drop the mapping so the next message starts a fresh session instead
|
||||
// of failing forever.
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
msg := err.Error()
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
msg = strings.TrimSpace(string(ee.Stderr))
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return "(本地 agent 无文本输出)", nil
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
|
||||
// convSessions maps a DingTalk conversation to a stable agent session ID, so a
|
||||
@@ -415,6 +443,17 @@ func brandReply(channel, reply string) string {
|
||||
return qoderworkIdentityRe.ReplaceAllString(reply, "我是 QoderWork 助手,钉钉群里的智能助手。")
|
||||
}
|
||||
|
||||
func execErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
msg = strings.TrimSpace(string(ee.Stderr))
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// agentReplyIsError reports whether an agent's stdout is a bare backend error
|
||||
// rather than a real answer. Claude Code prints provider failures as
|
||||
// "API Error: <status> ..." on stdout and may still exit 0, so the connector
|
||||
@@ -424,6 +463,16 @@ func agentReplyIsError(s string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(s), "API Error:")
|
||||
}
|
||||
|
||||
// agentSessionMissingError recognizes stale addressable sessions. Agent CLIs
|
||||
// differ in wording, but the operational meaning is the same: the connector's
|
||||
// persisted session id points at a conversation the CLI can no longer resume.
|
||||
func agentSessionMissingError(msg string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(msg))
|
||||
return strings.Contains(lower, "no conversation found with session id") ||
|
||||
strings.Contains(lower, "conversation not found") ||
|
||||
strings.Contains(lower, "session not found")
|
||||
}
|
||||
|
||||
// agentBackendErrorReply turns a bare backend error into a short, actionable
|
||||
// Chinese message for the chat, instead of echoing the raw provider error
|
||||
// (issue #14). It keeps only the first line of the raw error, truncated.
|
||||
@@ -588,10 +637,11 @@ func claudeUserSettingsEnv() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// agentSpecs is the registry of local-agent channels. Most forward to a local
|
||||
// agentSpecs is the registry of agent channels. Most forward to a local
|
||||
// headless CLI (one-shot per message, 24/7, no interactive session). Codex uses
|
||||
// the local CLI binary only to host app-server. Exact headless flags for
|
||||
// non-Codex channels can be overridden per run with DWS_AGENT_CMD.
|
||||
// the local CLI binary only to host app-server; Gemini uses its HTTP API.
|
||||
// Exact headless flags for CLI channels can be overridden per run with
|
||||
// DWS_AGENT_CMD.
|
||||
//
|
||||
// Install policy: npm/pipx (package managers) are auto-installed; curl|bash
|
||||
// remote-script installs and desktop apps are hint-only (we do not silently pipe
|
||||
@@ -608,9 +658,8 @@ var agentSpecs = map[string]agentSpec{
|
||||
"codex": {app: "OpenAI Codex CLI", bins: []string{"codex"},
|
||||
install: []string{"npm", "i", "-g", "@openai/codex"}, hint: "npm i -g @openai/codex",
|
||||
modelFlag: "-m"},
|
||||
"gemini": {app: "Gemini CLI", bins: []string{"gemini"}, argvTail: []string{"-p"},
|
||||
install: []string{"npm", "i", "-g", "@google/gemini-cli"}, hint: "npm i -g @google/gemini-cli",
|
||||
modelFlag: "-m"},
|
||||
"gemini": {app: "Gemini API",
|
||||
hint: "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL"},
|
||||
// opencode is resolved here only to find the local binary. The forwarder
|
||||
// uses `opencode serve --pure` plus HTTP session/message APIs instead of
|
||||
// parsing `opencode run` stdout.
|
||||
@@ -719,6 +768,17 @@ func connectCliStatus(channel string) map[string]any {
|
||||
status["command"] = command
|
||||
}
|
||||
return status
|
||||
case "gemini":
|
||||
status := map[string]any{
|
||||
"required": "GEMINI_API_KEY or GOOGLE_API_KEY",
|
||||
"installed": geminiAPIKey() != "",
|
||||
"autoInstall": false,
|
||||
"installHint": "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL",
|
||||
}
|
||||
if base := strings.TrimSpace(geminiAPIBaseURL()); base != "" {
|
||||
status["baseURL"] = base
|
||||
}
|
||||
return status
|
||||
}
|
||||
spec, ok := agentSpecs[channel]
|
||||
if !ok {
|
||||
@@ -785,6 +845,10 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 不是 stream-bridge 渠道,无 forwarder", channel))
|
||||
}
|
||||
overridden := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" && channel != "codex"
|
||||
if channel == "gemini" && !overridden {
|
||||
return newGeminiAPIForwarder(timeout, opts)
|
||||
}
|
||||
// Resolve the agent CLI (PATH → app bundle → auto-install → guidance) and
|
||||
// preflight here so a missing dependency errors at connect time.
|
||||
argv, env, err := resolveExecAgent(channel)
|
||||
@@ -795,7 +859,6 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo
|
||||
// model or session flags we cannot know are valid for it. Codex ignores the
|
||||
// override because its channel is app-server only; custom commands belong on
|
||||
// --channel custom.
|
||||
overridden := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" && channel != "codex"
|
||||
userPickedModel := opts.Model != "" || strings.TrimSpace(os.Getenv("DWS_AGENT_MODEL")) != ""
|
||||
if !overridden && opts.Model != "" {
|
||||
if spec.modelFlag == "" {
|
||||
@@ -850,14 +913,9 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo
|
||||
if !overridden && opts.Yolo {
|
||||
switch channel {
|
||||
case "claudecode", "codebuddy", "workbuddy":
|
||||
argv = append(argv, "--dangerously-skip-permissions")
|
||||
argv = append(argv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions")
|
||||
if len(streamArgv) > 0 {
|
||||
streamArgv = append(streamArgv, "--dangerously-skip-permissions")
|
||||
}
|
||||
case "gemini":
|
||||
argv = append(argv, "--yolo")
|
||||
if len(streamArgv) > 0 {
|
||||
streamArgv = append(streamArgv, "--yolo")
|
||||
streamArgv = append(streamArgv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -883,6 +941,18 @@ func applyModelArg(argv []string, flag, model string) []string {
|
||||
return append(out[:1:1], append([]string{flag, model}, out[1:]...)...)
|
||||
}
|
||||
|
||||
func insertBeforeArg(argv []string, marker string, values ...string) []string {
|
||||
for i := 1; i < len(argv); i++ {
|
||||
if argv[i] == marker {
|
||||
out := append([]string(nil), argv[:i]...)
|
||||
out = append(out, values...)
|
||||
return append(out, argv[i:]...)
|
||||
}
|
||||
}
|
||||
out := append([]string(nil), argv...)
|
||||
return append(out, values...)
|
||||
}
|
||||
|
||||
// msgDedup tracks recently-seen MsgIds so a redelivered message is not
|
||||
// processed (and replied to) twice. Memory is bounded: once the set reaches
|
||||
// limit it is cleared (the chance of a very old MsgId being redelivered after a
|
||||
@@ -935,6 +1005,80 @@ type connectExtras struct {
|
||||
persona string
|
||||
}
|
||||
|
||||
type connectQueuedTurn struct {
|
||||
convID string
|
||||
text string
|
||||
picCode string
|
||||
fileInfo fileInboundInfo
|
||||
webhook string
|
||||
msgID string
|
||||
msgType string
|
||||
senderStaffID string
|
||||
conversationID string
|
||||
conversationType string
|
||||
callbackData chatbot.BotCallbackDataModel
|
||||
}
|
||||
|
||||
func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
|
||||
if len(turns) == 0 {
|
||||
return connectQueuedTurn{}
|
||||
}
|
||||
if len(turns) == 1 {
|
||||
return turns[0]
|
||||
}
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
if connectTurnShouldStayStandalone(turns[i]) {
|
||||
return turns[i]
|
||||
}
|
||||
}
|
||||
merged := turns[len(turns)-1]
|
||||
lines := make([]string, 0, len(turns)+1)
|
||||
lines = append(lines, "用户在上一轮处理期间连续发送了以下消息,请把它们作为同一个最新请求一起处理:")
|
||||
for i, turn := range turns {
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", i+1, connectTurnSummary(turn)))
|
||||
}
|
||||
merged.text = strings.Join(lines, "\n")
|
||||
if merged.picCode == "" && !merged.fileInfo.hasActionable() {
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
if turns[i].picCode != "" {
|
||||
merged.picCode = turns[i].picCode
|
||||
break
|
||||
}
|
||||
if turns[i].fileInfo.hasActionable() {
|
||||
merged.fileInfo = turns[i].fileInfo
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func connectTurnShouldStayStandalone(turn connectQueuedTurn) bool {
|
||||
if _, ok := parseConnectControlCommand(turn.text); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := parseDecisionWord(turn.text); ok {
|
||||
return true
|
||||
}
|
||||
return isRetryWord(turn.text)
|
||||
}
|
||||
|
||||
func connectTurnSummary(turn connectQueuedTurn) string {
|
||||
if text := strings.TrimSpace(turn.text); text != "" {
|
||||
return text
|
||||
}
|
||||
if turn.picCode != "" {
|
||||
return "[图片]"
|
||||
}
|
||||
if turn.fileInfo.hasActionable() {
|
||||
if name := strings.TrimSpace(turn.fileInfo.FileName); name != "" {
|
||||
return "[文件: " + name + "]"
|
||||
}
|
||||
return "[文件]"
|
||||
}
|
||||
return "[空消息]"
|
||||
}
|
||||
|
||||
func runStreamConnector(ctx context.Context, channel, clientID, clientSecret string, fwd forwarder, cardCli *aiCardClient, extras *connectExtras) error {
|
||||
if extras == nil {
|
||||
extras = &connectExtras{}
|
||||
@@ -1072,11 +1216,36 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
if convID == "" {
|
||||
convID = strings.TrimSpace(data.SenderStaffId)
|
||||
}
|
||||
callbackData := data
|
||||
msgID := strings.TrimSpace(data.MsgId)
|
||||
// Same-conversation messages run in arrival order (follow-ups need the
|
||||
// previous turn's session state); different conversations in parallel.
|
||||
queue.run(convID, func() {
|
||||
turn := connectQueuedTurn{
|
||||
convID: convID,
|
||||
text: text,
|
||||
picCode: picCode,
|
||||
fileInfo: fileInfo,
|
||||
webhook: webhook,
|
||||
msgID: msgID,
|
||||
msgType: msgtype,
|
||||
senderStaffID: strings.TrimSpace(data.SenderStaffId),
|
||||
conversationID: strings.TrimSpace(data.ConversationId),
|
||||
conversationType: strings.TrimSpace(data.ConversationType),
|
||||
callbackData: *data,
|
||||
}
|
||||
// Same-conversation agent calls never run in parallel; messages received
|
||||
// while a turn is running are merged into one pending follow-up instead
|
||||
// of forming an unbounded stale FIFO.
|
||||
queue.submit(turn, func(turns []connectQueuedTurn) {
|
||||
turn := mergeConnectQueuedTurns(turns)
|
||||
if len(turns) > 1 {
|
||||
fmt.Fprintf(os.Stderr, "[connect] 合并 %d 条待处理消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
|
||||
}
|
||||
text := turn.text
|
||||
picCode := turn.picCode
|
||||
fileInfo := turn.fileInfo
|
||||
webhook := turn.webhook
|
||||
convID := turn.convID
|
||||
msgID := turn.msgID
|
||||
msgtype := turn.msgType
|
||||
callbackData := &turn.callbackData
|
||||
// Digital-twin text approval: if this is the OWNER replying
|
||||
// 「同意」/「拒绝」 (in their 1:1 chat with the bot) to the pending
|
||||
// request, route it to the gate (decide → execute/decline, with
|
||||
|
||||
@@ -162,3 +162,31 @@ func TestEnvDurationMS(t *testing.T) {
|
||||
t.Fatalf("invalid env falls back to default = %v, want %v", got, def)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsBuildsSinglePrompt(t *testing.T) {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{convID: "conv-1", text: "第一条", msgID: "m1"},
|
||||
{convID: "conv-1", text: "补充:按今天的数据", msgID: "m2"},
|
||||
{convID: "conv-1", text: "最后改成周报口径", msgID: "m3"},
|
||||
})
|
||||
if merged.msgID != "m3" {
|
||||
t.Fatalf("merged msgID = %q, want latest m3", merged.msgID)
|
||||
}
|
||||
for _, want := range []string{"连续发送", "1. 第一条", "2. 补充:按今天的数据", "3. 最后改成周报口径"} {
|
||||
if !strings.Contains(merged.text, want) {
|
||||
t.Fatalf("merged prompt missing %q:\n%s", want, merged.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsKeepsControlMessagesStandalone(t *testing.T) {
|
||||
for _, text := range []string{"/clear", "同意", "拒绝", "重试"} {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{convID: "conv-1", text: "先查一下", msgID: "m1"},
|
||||
{convID: "conv-1", text: text, msgID: "m2"},
|
||||
})
|
||||
if merged.text != text || merged.msgID != "m2" {
|
||||
t.Fatalf("control %q merged to (%q,%q), want standalone latest", text, merged.text, merged.msgID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
@@ -53,76 +52,79 @@ func (f *execForwarder) forwardStream(ctx context.Context, convID, text string,
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
var args []string
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
args = append(args, f.sessions.args(convID)...)
|
||||
}
|
||||
args = append(args, f.streamArgv[1:]...)
|
||||
args = append(args, text)
|
||||
cmd := exec.CommandContext(ctx, f.streamArgv[0], args...)
|
||||
if f.workDir != "" {
|
||||
cmd.Dir = f.workDir
|
||||
} else {
|
||||
cmd.Dir = connectWorkDir()
|
||||
}
|
||||
if len(f.env) > 0 {
|
||||
cmd.Env = append(os.Environ(), f.env...)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var stderrBuf strings.Builder
|
||||
cmd.Stderr = &stderrBuf
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", err
|
||||
run := func() (string, string, error) {
|
||||
args := f.commandArgs(f.streamArgv, convID, text)
|
||||
cmd := exec.CommandContext(ctx, f.streamArgv[0], args...)
|
||||
f.configureCommand(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", err.Error(), err
|
||||
}
|
||||
var stderrBuf strings.Builder
|
||||
cmd.Stderr = &stderrBuf
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", err.Error(), err
|
||||
}
|
||||
|
||||
var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns)
|
||||
finalText := ""
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
delta, final := parseStreamLine(f.parser, line)
|
||||
if delta != "" {
|
||||
acc.WriteString(delta)
|
||||
onDelta(brandReply(f.name, acc.String()))
|
||||
}
|
||||
if final != "" {
|
||||
finalText = final
|
||||
}
|
||||
}
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
if finalText == "" {
|
||||
finalText = strings.TrimSpace(acc.String())
|
||||
}
|
||||
// A bare backend error (e.g. claude's "API Error: 4xx ...") must not be
|
||||
// forwarded as the answer (issue #14): return an actionable hint instead.
|
||||
if agentReplyIsError(finalText) {
|
||||
if f.hasSession(convID) {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return agentBackendErrorReply(finalText), "", nil
|
||||
}
|
||||
if finalText != "" {
|
||||
return brandReply(f.name, finalText), "", nil
|
||||
}
|
||||
if waitErr != nil {
|
||||
msg := waitErr.Error()
|
||||
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
|
||||
msg = s
|
||||
}
|
||||
return "", msg, waitErr
|
||||
}
|
||||
return "(本地 agent 无文本输出)", "", nil
|
||||
}
|
||||
|
||||
var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns)
|
||||
finalText := ""
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
delta, final := parseStreamLine(f.parser, line)
|
||||
if delta != "" {
|
||||
acc.WriteString(delta)
|
||||
onDelta(brandReply(f.name, acc.String()))
|
||||
}
|
||||
if final != "" {
|
||||
finalText = final
|
||||
reply, msg, err := run()
|
||||
if err == nil {
|
||||
return reply, nil
|
||||
}
|
||||
if f.hasSession(convID) && agentSessionMissingError(msg) {
|
||||
f.sessions.reset(convID)
|
||||
reply, msg, err = run()
|
||||
if err == nil {
|
||||
return reply, nil
|
||||
}
|
||||
}
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
if finalText == "" {
|
||||
finalText = strings.TrimSpace(acc.String())
|
||||
}
|
||||
// A bare backend error (e.g. claude's "API Error: 4xx ...") must not be
|
||||
// forwarded as the answer (issue #14): return an actionable hint instead.
|
||||
if agentReplyIsError(finalText) {
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return agentBackendErrorReply(finalText), nil
|
||||
}
|
||||
if finalText != "" {
|
||||
return brandReply(f.name, finalText), nil
|
||||
}
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
if f.hasSession(convID) {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
if waitErr != nil {
|
||||
msg := waitErr.Error()
|
||||
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
|
||||
msg = s
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
return "(本地 agent 无文本输出)", nil
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
|
||||
// parseStreamLine extracts (visible-text delta, final text) from one JSONL
|
||||
|
||||
@@ -583,7 +583,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("robot-client-id", "", "现成机器人 clientId(AppKey)")
|
||||
cmd.Flags().String("robot-client-secret", "", "现成机器人 clientSecret(AppSecret)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID:复用 dev app credentials get 自动取凭证(替代手填 robot-client-id/secret)")
|
||||
cmd.Flags().String("agent-model", "", "覆盖本地 agent 模型(如 claude 的 sonnet/opus;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
|
||||
cmd.Flags().String("agent-model", "", "覆盖 agent 模型(如 claude 的 sonnet/opus、gemini-2.5-pro;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
|
||||
cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR")
|
||||
cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(codex/opencode/qoder/qoderwork/claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)")
|
||||
cmd.Flags().Int("agent-timeout", 0, "每次 agent 调用的超时时间(秒),0=不限制(默认);env: DWS_AGENT_TIMEOUT_MS(毫秒)")
|
||||
@@ -751,7 +751,7 @@ func resolveAgentYoloMode(cmd *cobra.Command) (bool, error) {
|
||||
// connectAgentOptionsPayload renders the effective agent tuning for the
|
||||
// dry-run preview, including whether session memory actually applies to the
|
||||
// chosen channel (Codex uses app-server threads, opencode uses opencode serve
|
||||
// sessions, CLI session channels use --session-id/--resume, and gemini stays
|
||||
// sessions, CLI session channels use --session-id/--resume, and Gemini API stays
|
||||
// stateless today).
|
||||
func connectAgentOptionsPayload(channel string, opts connectAgentOptions) map[string]any {
|
||||
spec, ok := agentSpecs[channel]
|
||||
|
||||
@@ -29,6 +29,8 @@ func clearChannelEnv(t *testing.T) {
|
||||
"DWS_CONNECT_CMD", "DWS_AGENT_CMD",
|
||||
"WORKBUDDY_CONFIG_DIR", "WORKBUDDY_APP_NAME", "CLAUDECODE",
|
||||
"DWS_AGENT_PERMISSION_MODE", "DWS_AGENT_APPROVAL_MODE",
|
||||
"GEMINI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_BASE_URL",
|
||||
"GOOGLE_GEMINI_API_BASE_URL", "GEMINI_MODEL",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
@@ -264,7 +266,7 @@ func TestAgentSpecsCoverMainstreamAgents(t *testing.T) {
|
||||
t.Errorf("agentSpecs missing channel %q", ch)
|
||||
continue
|
||||
}
|
||||
if len(spec.bins) == 0 {
|
||||
if ch != "gemini" && len(spec.bins) == 0 {
|
||||
t.Errorf("channel %q has no bins", ch)
|
||||
}
|
||||
if spec.hint == "" {
|
||||
|
||||
+294
-4
@@ -400,7 +400,11 @@ func inferFilename(rawURL string) string {
|
||||
if decoded, err := url.PathUnescape(name); err == nil {
|
||||
name = decoded
|
||||
}
|
||||
if name != "" {
|
||||
// 解码后可能含 %2F 还原出的路径分隔符(如 "ddmedia/xxx.png"),只取
|
||||
// 末段 base 名,避免拼出调用方未创建的子目录导致写文件失败。
|
||||
name = strings.ReplaceAll(name, "\\", "/")
|
||||
name = filepath.Base(name)
|
||||
if name != "" && name != "." && name != "/" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
@@ -783,7 +787,7 @@ func newDocCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
Use: "doc",
|
||||
Short: "钉钉文档管理",
|
||||
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、模板管理。
|
||||
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。
|
||||
|
||||
命令结构:
|
||||
dws doc info 获取文档元信息
|
||||
@@ -794,6 +798,8 @@ func newDocCommand() *cobra.Command {
|
||||
dws doc comment [list|create|delete] 文档评论管理
|
||||
dws doc export 导出在线文档 (支持 docx / markdown / pdf,自动完成提交→轮询→下载)
|
||||
dws doc export get 查询导出任务结果 (手动兜底)
|
||||
dws doc import 导入本地文件为在线文档 (支持 docx / xlsx / md 等)
|
||||
dws doc import get 查询导入任务结果 (手动兜底)
|
||||
dws doc template list 获取文档模板列表
|
||||
dws doc template search 搜索文档模板
|
||||
dws doc template apply 应用文档模板创建新文档
|
||||
@@ -2442,6 +2448,233 @@ CLI 内部自动完成全部流程:
|
||||
|
||||
exportCmd.AddCommand(exportGetCmd)
|
||||
|
||||
// ── import: 文件导入为在线文档(一体化:上传→转换→轮询)──────────────
|
||||
importCmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "导入本地文件为在线文档 (支持 docx / xlsx / md 等)",
|
||||
Long: `将本地文件导入为钉钉在线文档。
|
||||
|
||||
支持的文件格式 (按扩展名):
|
||||
docx, doc → 文字文档
|
||||
xlsx, xls → 电子表格
|
||||
md, txt → 文字文档
|
||||
xmind, mark → 脑图
|
||||
|
||||
文件大小限制: 20MB
|
||||
|
||||
CLI 内部自动完成全部流程:
|
||||
1. 创建导入会话(获取 OSS 上传凭证)
|
||||
2. 上传文件到 OSS
|
||||
3. 确认导入(触发格式转换)
|
||||
4. 渐进式退避轮询等待完成(最多约 5 分钟)
|
||||
|
||||
如果轮询超时仍未完成,会输出 taskId 供后续手动查询:
|
||||
dws doc import get --task-id <taskId>`,
|
||||
Example: ` # 导入 Word 文档
|
||||
dws doc import --file ./report.docx
|
||||
|
||||
# 导入到指定文件夹
|
||||
dws doc import --file ./notes.md --folder <FOLDER_ID>
|
||||
|
||||
# 导入到知识库根目录
|
||||
dws doc import --file ./data.xlsx --workspace <WORKSPACE_ID>
|
||||
|
||||
# 自定义导入后的文档名称
|
||||
dws doc import --file ./draft.md --name "项目周报"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
filePath := mustGetFlag(cmd, "file")
|
||||
if filePath == "" && len(args) > 0 {
|
||||
filePath = args[0]
|
||||
}
|
||||
if filePath == "" {
|
||||
return fmt.Errorf("flag --file is required (or pass file path as argument)")
|
||||
}
|
||||
|
||||
fi, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot read file %s: %w", filePath, err)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return fmt.Errorf("%s is a directory, not a file", filePath)
|
||||
}
|
||||
|
||||
const maxFileSize = 20 * 1024 * 1024
|
||||
fileSize := fi.Size()
|
||||
if fileSize > maxFileSize {
|
||||
return fmt.Errorf("file size %d bytes exceeds 20MB limit", fileSize)
|
||||
}
|
||||
if fileSize == 0 {
|
||||
return fmt.Errorf("file is empty: %s", filePath)
|
||||
}
|
||||
|
||||
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
|
||||
supportedFormats := map[string]bool{
|
||||
"docx": true, "doc": true, "xlsx": true, "xls": true,
|
||||
"md": true, "txt": true, "xmind": true, "mark": true,
|
||||
}
|
||||
if !supportedFormats[ext] {
|
||||
return fmt.Errorf("unsupported file format %q, supported: docx, doc, xlsx, xls, md, txt, xmind, mark", ext)
|
||||
}
|
||||
|
||||
fileName := filepath.Base(filePath)
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if name == "" {
|
||||
name = strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||
}
|
||||
folder := flagOrFallback(cmd, "folder", "folder-id")
|
||||
workspace := flagOrFallback(cmd, "workspace", "workspace-id")
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "导入本地文件为在线文档")
|
||||
deps.Out.PrintKeyValue("文件", filePath)
|
||||
deps.Out.PrintKeyValue("名称", name)
|
||||
deps.Out.PrintKeyValue("格式", ext)
|
||||
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", fileSize))
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
deps.Out.PrintInfo("[1/4] 创建导入会话...")
|
||||
sessionArgs := map[string]any{
|
||||
"fileName": name,
|
||||
"suffix": ext,
|
||||
"fileSize": fileSize,
|
||||
}
|
||||
if folder != "" {
|
||||
sessionArgs["targetFolderId"] = folder
|
||||
}
|
||||
if workspace != "" {
|
||||
sessionArgs["workspaceId"] = workspace
|
||||
}
|
||||
|
||||
sessionText, err := callMCPToolReturnText(ctx, "create_import_session", sessionArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建导入会话失败: %w", err)
|
||||
}
|
||||
|
||||
var sessionResult map[string]any
|
||||
if err := json.Unmarshal([]byte(sessionText), &sessionResult); err != nil {
|
||||
return fmt.Errorf("解析导入会话响应失败: %w", err)
|
||||
}
|
||||
sessionID, _ := sessionResult["sessionId"].(string)
|
||||
uploadURL, _ := sessionResult["uploadUrl"].(string)
|
||||
if sessionID == "" || uploadURL == "" {
|
||||
deps.Out.PrintRaw(sessionText)
|
||||
return fmt.Errorf("创建导入会话成功但缺少 sessionId 或 uploadUrl")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 会话已创建,sessionId: %s", sessionID))
|
||||
|
||||
deps.Out.PrintInfo("[2/4] 上传文件...")
|
||||
if err := httpPutFile(ctx, uploadURL, nil, filePath, fileSize); err != nil {
|
||||
return fmt.Errorf("文件上传失败 (sessionId=%s): %w", sessionID, err)
|
||||
}
|
||||
deps.Out.PrintInfo(" 文件上传完成")
|
||||
|
||||
deps.Out.PrintInfo("[3/4] 确认导入,启动格式转换...")
|
||||
confirmText, err := callMCPToolReturnText(ctx, "confirm_import", map[string]any{
|
||||
"sessionId": sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("确认导入失败 (sessionId=%s): %w", sessionID, err)
|
||||
}
|
||||
|
||||
var confirmResult map[string]any
|
||||
if err := json.Unmarshal([]byte(confirmText), &confirmResult); err != nil {
|
||||
return fmt.Errorf("解析确认导入响应失败: %w", err)
|
||||
}
|
||||
taskID, _ := confirmResult["taskId"].(string)
|
||||
if taskID == "" {
|
||||
deps.Out.PrintRaw(confirmText)
|
||||
return fmt.Errorf("确认导入成功但未返回 taskId")
|
||||
}
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 转换任务已提交,taskId: %s", taskID))
|
||||
|
||||
deps.Out.PrintInfo("[4/4] 等待格式转换完成...")
|
||||
importResult, err := pollDocImportTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
documentURL, _ := importResult["documentUrl"].(string)
|
||||
documentName, _ := importResult["documentName"].(string)
|
||||
documentType, _ := importResult["documentType"].(string)
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("导入完成: %s", documentURL))
|
||||
deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"taskId": taskID,
|
||||
"documentUrl": documentURL,
|
||||
"documentName": documentName,
|
||||
"documentType": documentType,
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
importCmd.Flags().String("file", "", "本地文件路径 (必填)")
|
||||
importCmd.Flags().String("folder", "", "目标文件夹 ID 或 URL (可选,与 --workspace 至少传一个)")
|
||||
importCmd.Flags().String("workspace", "", "目标知识库 ID 或 URL (可选,与 --folder 至少传一个)")
|
||||
importCmd.Flags().StringP("name", "n", "", "导入后文档名称 (可选,默认取文件名)")
|
||||
importCmd.Flags().String("folder-id", "", "")
|
||||
_ = importCmd.Flags().MarkHidden("folder-id")
|
||||
importCmd.Flags().String("workspace-id", "", "")
|
||||
_ = importCmd.Flags().MarkHidden("workspace-id")
|
||||
|
||||
importGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
Short: "查询导入任务结果(手动兜底)",
|
||||
Long: `根据 taskId 查询文档导入任务的执行结果。
|
||||
通常不需要手动调用,dws doc import 会自动完成轮询。
|
||||
仅在导入命令超时或中断后,用于手动查询任务状态。
|
||||
|
||||
任务状态:
|
||||
processing 转换中
|
||||
completed 导入成功,返回 documentUrl
|
||||
failed 导入失败`,
|
||||
Example: ` dws doc import get --task-id <TASK_ID>`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
taskID := mustGetFlag(cmd, "task-id")
|
||||
if taskID == "" {
|
||||
return fmt.Errorf("flag --task-id is required")
|
||||
}
|
||||
|
||||
if deps.Caller.DryRun() {
|
||||
deps.Out.PrintKeyValue("操作", "查询导入任务结果")
|
||||
deps.Out.PrintKeyValue("任务ID", taskID)
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
text, err := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &result); err != nil {
|
||||
deps.Out.PrintRaw(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
status, _ := result["status"].(string)
|
||||
message, _ := result["message"].(string)
|
||||
|
||||
switch strings.ToLower(status) {
|
||||
case "completed", "processing":
|
||||
deps.Out.PrintJSON(result)
|
||||
return nil
|
||||
default:
|
||||
deps.Out.PrintJSON(result)
|
||||
if message != "" {
|
||||
return fmt.Errorf("导入任务失败 (status=%s): %s", status, message)
|
||||
}
|
||||
return fmt.Errorf("导入任务失败 (status=%s)", status)
|
||||
}
|
||||
},
|
||||
}
|
||||
importGetCmd.Flags().String("task-id", "", "导入任务 ID (必填)")
|
||||
importCmd.AddCommand(importGetCmd)
|
||||
|
||||
// ── doc version 子命令组 ──
|
||||
versionCmd := &cobra.Command{
|
||||
Use: "version",
|
||||
@@ -2654,7 +2887,7 @@ CLI 内部自动完成全部流程:
|
||||
// will skip flags that already exist.
|
||||
for _, cmd := range []*cobra.Command{
|
||||
searchCmd, listCmd, createCmd, updateCmd, uploadCmd, downloadCmd,
|
||||
copyCmd, moveCmd, renameCmd, deleteCmd, exportCmd,
|
||||
copyCmd, moveCmd, renameCmd, deleteCmd, exportCmd, importCmd,
|
||||
} {
|
||||
RegisterCrossProductAliases(cmd)
|
||||
}
|
||||
@@ -2696,7 +2929,7 @@ CLI 内部自动完成全部流程:
|
||||
folderCmd.Hidden = true
|
||||
permissionCmd.Hidden = true
|
||||
|
||||
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, versionCmd, templateCmd)
|
||||
root.AddCommand(searchCmd, listCmd, infoCmd, readCmd, createCmd, updateCmd, uploadCmd, downloadCmd, copyCmd, moveCmd, renameCmd, deleteCmd, fileCmd, folderCmd, blockCmd, commentCmd, mediaCmd, permissionCmd, exportCmd, importCmd, versionCmd, templateCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
@@ -2943,6 +3176,63 @@ func pollDocExportJob(ctx context.Context, jobID string) (downloadURL string, er
|
||||
return "", fmt.Errorf("导出任务超时:已轮询 %d 次仍在处理中 (jobId=%s),请稍后使用 dws doc export get --job-id %s 手动查询", maxPolls, jobID, jobID)
|
||||
}
|
||||
|
||||
// pollDocImportTask polls the import task status with progressive backoff.
|
||||
func pollDocImportTask(ctx context.Context, taskID string) (map[string]any, error) {
|
||||
const maxPolls = 30
|
||||
|
||||
pollInterval := func(attempt int) time.Duration {
|
||||
switch {
|
||||
case attempt <= 5:
|
||||
return 2 * time.Second
|
||||
case attempt <= 10:
|
||||
return 5 * time.Second
|
||||
case attempt <= 20:
|
||||
return 10 * time.Second
|
||||
default:
|
||||
return 15 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
for attempt := 1; attempt <= maxPolls; attempt++ {
|
||||
interval := pollInterval(attempt)
|
||||
deps.Out.PrintInfo(fmt.Sprintf(" 第 %d/%d 次查询,等待 %v ...", attempt, maxPolls, interval))
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("导入轮询被取消 (taskId=%s): %w", taskID, ctx.Err())
|
||||
case <-time.After(interval):
|
||||
}
|
||||
|
||||
text, queryErr := callMCPToolReturnText(ctx, "query_import_task", map[string]any{"taskId": taskID})
|
||||
if queryErr != nil {
|
||||
return nil, fmt.Errorf("查询导入任务失败 (taskId=%s): %w", taskID, queryErr)
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
if parseErr := json.Unmarshal([]byte(text), &result); parseErr != nil {
|
||||
return nil, fmt.Errorf("解析查询结果失败 (taskId=%s): %w", taskID, parseErr)
|
||||
}
|
||||
|
||||
status, _ := result["status"].(string)
|
||||
switch strings.ToLower(status) {
|
||||
case "completed":
|
||||
return result, nil
|
||||
case "processing":
|
||||
continue
|
||||
case "failed":
|
||||
message, _ := result["message"].(string)
|
||||
if message != "" {
|
||||
return nil, fmt.Errorf("导入任务失败 (taskId=%s): %s", taskID, message)
|
||||
}
|
||||
return nil, fmt.Errorf("导入任务失败 (taskId=%s)", taskID)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("导入任务超时:已轮询 %d 次仍在处理中 (taskId=%s),请稍后使用 dws doc import get --task-id %s 手动查询", maxPolls, taskID, taskID)
|
||||
}
|
||||
|
||||
// stripDuplicateTitle removes the leading H1 heading from markdown content
|
||||
// when it matches the document name (set via --name). This prevents the title
|
||||
// from appearing twice: once as document metadata and once in the body.
|
||||
|
||||
+351
-47
@@ -104,7 +104,7 @@ func newMailCommand() *cobra.Command {
|
||||
Long: `查询当前用户绑定的所有邮箱地址。
|
||||
|
||||
返回字段:
|
||||
mailboxes 邮箱列表,每条包含邮箱地址、账号类型、所属企业
|
||||
emailAccounts 邮箱列表,每条包含邮箱地址(email)、账号类型(type)、所属企业(orgName)
|
||||
|
||||
错误说明:
|
||||
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
|
||||
@@ -114,7 +114,38 @@ func newMailCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd)
|
||||
mailboxProfileCmd := &cobra.Command{
|
||||
Use: "profile",
|
||||
Short: "获取用户邮箱信息",
|
||||
Long: `根据邮箱地址获取用户的邮箱详细信息,包含容量、别名等。
|
||||
|
||||
返回字段:
|
||||
email 邮箱地址
|
||||
emailAliases 邮件地址别名列表
|
||||
name 用户名
|
||||
nickname 用户昵称
|
||||
displayName 用户显示名
|
||||
mboxSize 邮箱容量(字节)
|
||||
mboxSizeUsed 已使用的邮箱容量(字节)
|
||||
createdTime 创建时间
|
||||
modifiedTime 修改时间
|
||||
|
||||
错误说明:
|
||||
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
|
||||
Example: ` dws mail mailbox profile --email user@company.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("get_mailbox_profile", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
mailboxProfileCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
|
||||
mailboxCmd.AddCommand(mailboxListCmd, mailboxProfileCmd)
|
||||
|
||||
messageCmd := &cobra.Command{Use: "message", Short: "邮件管理", RunE: groupRunE}
|
||||
|
||||
@@ -1138,6 +1169,64 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
},
|
||||
}
|
||||
|
||||
messageBatchGetCmd := &cobra.Command{
|
||||
Use: "batch-get",
|
||||
Short: "批量获取邮件详情",
|
||||
Long: `根据邮件 ID 列表批量获取邮件完整内容,包含正文。
|
||||
|
||||
限制说明:
|
||||
- 单次最多 20 个邮件 ID
|
||||
- CLI 会逐个调用 get_email_by_message_id 并返回聚合结果
|
||||
|
||||
错误说明:
|
||||
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
|
||||
Example: ` dws mail message batch-get --email user@company.com --ids <id1>,<id2>
|
||||
dws mail message batch-get --email user@company.com --ids <id1>,<id2>,<id3>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
email := mustGetFlag(cmd, "email")
|
||||
ids := parseRecipients(mustGetFlag(cmd, "ids"))
|
||||
if len(ids) == 0 {
|
||||
return fmt.Errorf("--ids 至少需要 1 个邮件 ID")
|
||||
}
|
||||
if len(ids) > 20 {
|
||||
return fmt.Errorf("--ids 单次最多支持 20 个邮件 ID,当前: %d", len(ids))
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
fmt.Printf("[DRY-RUN] Preview only, not executed:\n")
|
||||
fmt.Printf("Operation: batch-get (loop get_email_by_message_id x%d)\n", len(ids))
|
||||
fmt.Printf("Arguments:\n")
|
||||
fmt.Printf(" email: %s\n", email)
|
||||
fmt.Printf(" ids: %v\n", ids)
|
||||
return nil
|
||||
}
|
||||
items := make([]map[string]any, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
text, err := callMCPToolReturnText(context.Background(), "get_email_by_message_id", map[string]any{
|
||||
"email": email,
|
||||
"messageId": id,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取邮件详情失败 id=%s: %w", id, err)
|
||||
}
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(text), &payload); err != nil {
|
||||
payload = text
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id,
|
||||
"result": payload,
|
||||
})
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"email": email,
|
||||
"messages": items,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
draftCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建草稿",
|
||||
@@ -1407,12 +1496,88 @@ internetMessageId 来源:message send / draft send / message reply / message r
|
||||
messageBatchModifyCmd.Flags().String("action", "", "操作类型: markRead/markUnread/addTags/removeTags (必填)")
|
||||
messageBatchModifyCmd.Flags().String("tags", "", "标签 ID 列表,逗号分隔 (action 为 addTags/removeTags 时必填)")
|
||||
|
||||
messageBatchGetCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
|
||||
messageBatchGetCmd.Flags().String("ids", "", "要获取的邮件 ID 列表,逗号分隔,最多 20 个 (必填)")
|
||||
|
||||
messageVerifyCmd.Flags().String("email", "", "邮件所属邮箱地址 (必填)")
|
||||
messageVerifyCmd.Flags().String("internet-message-id", "", "邮件的 internetMessageId (必填),取自发送类命令返回值")
|
||||
|
||||
messageCmd.AddCommand(messageListCmd, messageSearchCmd, messageGetCmd, messageSendCmd,
|
||||
messageReplyCmd, messageReplyAllCmd, messageForwardCmd,
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageVerifyCmd)
|
||||
messageBatchMoveCmd, messageBatchDeleteCmd, messageBatchModifyCmd, messageBatchGetCmd, messageVerifyCmd)
|
||||
|
||||
sentMessageCmd := &cobra.Command{Use: "sent-message", Short: "已发送邮件管理", RunE: groupRunE}
|
||||
|
||||
sentMessageRecallCmd := &cobra.Command{
|
||||
Use: "recall",
|
||||
Short: "[危险] 撤回已发送的邮件",
|
||||
Long: `撤回已发送的邮件。仅支持撤回同组织内未读邮件。
|
||||
|
||||
返回字段:
|
||||
id 撤回任务 ID(可用于 recall-detail 查询进度)
|
||||
success 接口调用是否成功
|
||||
errorCode 错误码(仅失败时存在)
|
||||
errorMsg 错误信息(仅失败时存在)
|
||||
|
||||
错误说明:
|
||||
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
|
||||
Example: ` dws mail sent-message recall --email user@company.com --id <mailId> --subject "邮件主题" --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id", "subject"); err != nil {
|
||||
return err
|
||||
}
|
||||
yes, _ := cmd.Flags().GetBool("yes")
|
||||
if !yes && !deps.Caller.DryRun() {
|
||||
return fmt.Errorf("此操作为危险操作,需要传入 --yes 确认执行")
|
||||
}
|
||||
return callMCPTool("recall_sent_message", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": mustGetFlag(cmd, "id"),
|
||||
"subject": mustGetFlag(cmd, "subject"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
sentMessageRecallDetailCmd := &cobra.Command{
|
||||
Use: "recall-detail",
|
||||
Short: "查询邮件撤回进度",
|
||||
Long: `根据撤回任务 ID 查询邮件撤回的详细进度。
|
||||
|
||||
撤回任务 ID 来源:sent-message recall 命令返回值中的 id 字段。
|
||||
|
||||
返回字段:
|
||||
id 撤回任务 ID
|
||||
status 任务状态: UNINITED/SUBMITTED/RUNNING/FINISHED/CANCELED/FAILED
|
||||
createdTime 创建时间
|
||||
updatedTime 更新时间
|
||||
totalCount 总邮件数
|
||||
succeededCount 成功撤回数
|
||||
failedCount 撤回失败数
|
||||
details 每封邮件的撤回结果
|
||||
|
||||
错误说明:
|
||||
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
|
||||
Example: ` dws mail sent-message recall-detail --email user@company.com --id <recallTaskId>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("get_recall_detail", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"id": mustGetFlag(cmd, "id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
sentMessageRecallCmd.Flags().String("email", "", "发件人邮箱地址 (必填)")
|
||||
sentMessageRecallCmd.Flags().String("id", "", "要撤回的邮件 ID (必填)")
|
||||
sentMessageRecallCmd.Flags().String("subject", "", "邮件主题 (必填)")
|
||||
sentMessageRecallCmd.Flags().Bool("yes", false, "跳过确认提示,直接执行")
|
||||
|
||||
sentMessageRecallDetailCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
sentMessageRecallDetailCmd.Flags().String("id", "", "撤回任务 ID (必填),由 recall 命令返回")
|
||||
|
||||
sentMessageCmd.AddCommand(sentMessageRecallCmd, sentMessageRecallDetailCmd)
|
||||
|
||||
draftCreateCmd.Flags().String("from", "", "发件人邮箱 (必填)")
|
||||
draftCreateCmd.Flags().String("sender", "", "--from 的别名")
|
||||
@@ -1913,52 +2078,55 @@ user 对象字段:
|
||||
},
|
||||
}
|
||||
|
||||
// TODO: auto-reply update 暂时注释,待服务端修复后恢复
|
||||
// autoReplyUpdateCmd := &cobra.Command{
|
||||
// Use: "update",
|
||||
// Short: "更新/设置用户的自动回复配置",
|
||||
// Long: `更新或设置用户的邮件自动回复配置。所有参数均为必填。
|
||||
//
|
||||
// 建议工作流:先通过 auto-reply get 获取当前配置,再传入需要修改的字段值。
|
||||
//
|
||||
// 时间格式示例:2026/06/25 16:30:00 +0800
|
||||
//
|
||||
// 参数说明(全部必填):
|
||||
// --enabled 是否启用自动回复 (true/false)
|
||||
// --startTime 自动回复开始时间
|
||||
// --endTime 自动回复结束时间
|
||||
// --scope 回复范围: "contact"(仅联系人) 或 "all"(所有人)
|
||||
// --content 自动回复内容
|
||||
//
|
||||
// 错误说明:
|
||||
// domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
|
||||
// Example: ` dws mail auto-reply update --email user@company.com --enabled true --startTime "2026/07/01 09:00:00 +0800" --endTime "2026/07/07 18:00:00 +0800" --scope all --content "出差中,请稍后联系"
|
||||
// dws mail auto-reply update --email user@company.com --enabled false --startTime "" --endTime "" --scope all --content ""`,
|
||||
// RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// if err := validateRequiredFlags(cmd, "email", "enabled", "startTime", "endTime", "scope", "content"); err != nil {
|
||||
// return err
|
||||
// }
|
||||
// toolArgs := map[string]any{
|
||||
// "email": mustGetFlag(cmd, "email"),
|
||||
// "enabled": mustGetFlag(cmd, "enabled") == "true",
|
||||
// "startTime": mustGetFlag(cmd, "startTime"),
|
||||
// "endTime": mustGetFlag(cmd, "endTime"),
|
||||
// "scope": mustGetFlag(cmd, "scope"),
|
||||
// "content": mustGetFlag(cmd, "content"),
|
||||
// }
|
||||
// return callMCPTool("update_auto_reply", toolArgs)
|
||||
// },
|
||||
// }
|
||||
autoReplyUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新/设置用户的自动回复配置",
|
||||
Long: `更新或设置用户的邮件自动回复配置。所有参数均为必填。
|
||||
|
||||
建议工作流:先通过 auto-reply get 获取当前配置,再传入需要修改的字段值。
|
||||
|
||||
时间格式示例:2026/06/25 16:00:00 +0800
|
||||
|
||||
参数说明(全部必填):
|
||||
--enabled 是否启用自动回复 (true/false)
|
||||
--start 自动回复开始时间
|
||||
--end 自动回复结束时间
|
||||
--scope 回复范围: "contact"(仅联系人) 或 "all"(所有人)
|
||||
--content 自动回复内容
|
||||
|
||||
错误说明:
|
||||
domain.notFound 该用户的邮箱不是由钉钉邮箱托管,无法完成操作`,
|
||||
Example: ` dws mail auto-reply update --email user@company.com --enabled true --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" --scope all --content "出差中,请稍后联系"
|
||||
dws mail auto-reply update --email user@company.com --enabled false --start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" --scope all --content "已关闭自动回复"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "enabled", "start", "end", "scope", "content"); err != nil {
|
||||
return err
|
||||
}
|
||||
enabledRaw := strings.ToLower(strings.TrimSpace(mustGetFlag(cmd, "enabled")))
|
||||
if enabledRaw != "true" && enabledRaw != "false" {
|
||||
return fmt.Errorf("--enabled 必须为 true 或 false")
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"enabled": enabledRaw == "true",
|
||||
"startTime": mustGetFlag(cmd, "start"),
|
||||
"endTime": mustGetFlag(cmd, "end"),
|
||||
"scope": mustGetFlag(cmd, "scope"),
|
||||
"content": mustGetFlag(cmd, "content"),
|
||||
}
|
||||
return callMCPTool("update_auto_reply", toolArgs)
|
||||
},
|
||||
}
|
||||
|
||||
autoReplyGetCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
// autoReplyUpdateCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
// autoReplyUpdateCmd.Flags().String("enabled", "", "是否启用自动回复: true/false (必填)")
|
||||
// autoReplyUpdateCmd.Flags().String("startTime", "", "自动回复开始时间 (必填),格式: 2026/06/25 16:30:00 +0800")
|
||||
// autoReplyUpdateCmd.Flags().String("endTime", "", "自动回复结束时间 (必填),格式: 2026/06/25 16:30:00 +0800")
|
||||
// autoReplyUpdateCmd.Flags().String("scope", "", "回复范围: contact(仅联系人)/all(所有人) (必填)")
|
||||
// autoReplyUpdateCmd.Flags().String("content", "", "自动回复内容 (必填)")
|
||||
autoReplyUpdateCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
autoReplyUpdateCmd.Flags().String("enabled", "", "是否启用自动回复: true/false (必填)")
|
||||
autoReplyUpdateCmd.Flags().String("start", "", "自动回复开始时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填)")
|
||||
autoReplyUpdateCmd.Flags().String("end", "", "自动回复结束时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填)")
|
||||
autoReplyUpdateCmd.Flags().String("scope", "", "回复范围: contact(仅联系人)/all(所有人) (必填)")
|
||||
autoReplyUpdateCmd.Flags().String("content", "", "自动回复内容 (必填)")
|
||||
|
||||
autoReplyCmd.AddCommand(autoReplyGetCmd) // , autoReplyUpdateCmd)
|
||||
autoReplyCmd.AddCommand(autoReplyGetCmd, autoReplyUpdateCmd)
|
||||
|
||||
// ── rule 收信规则 ────────────────────────────────────
|
||||
ruleCmd := &cobra.Command{Use: "rule", Short: "收信规则管理", RunE: groupRunE}
|
||||
@@ -2145,7 +2313,143 @@ object 与 operation 合法组合:
|
||||
|
||||
ruleCmd.AddCommand(ruleListCmd, ruleCreateCmd, ruleUpdateCmd, ruleDeleteCmd, ruleAdjustCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd)
|
||||
// ── allow-list 个人收信白名单 ────────────────────────────────
|
||||
allowListCmd := &cobra.Command{Use: "allow-list", Short: "个人收信白名单管理", RunE: groupRunE}
|
||||
|
||||
allowListListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出个人收信白名单",
|
||||
Long: `列出当前用户的个人收信白名单地址列表。
|
||||
|
||||
返回字段:
|
||||
entries 白名单地址列表
|
||||
success 接口调用是否成功
|
||||
errorCode 错误码(仅失败时存在)
|
||||
errorMsg 错误信息(仅失败时存在)`,
|
||||
Example: ` dws mail allow-list list --email user@company.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("list_mailbox_allowlist", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
})
|
||||
},
|
||||
}
|
||||
allowListAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加个人收信白名单",
|
||||
Long: `向个人收信白名单中添加邮件地址或域名。
|
||||
|
||||
条目格式:
|
||||
- 邮件地址:123@domain.com
|
||||
- 域名:@domain.com(域名前需加 @ 符号)`,
|
||||
Example: ` dws mail allow-list add --email user@company.com --entries a@b.com,@spam.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "entries"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("add_mailbox_allowlist", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"entries": parseRecipients(mustGetFlag(cmd, "entries")),
|
||||
})
|
||||
},
|
||||
}
|
||||
allowListRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "移除个人收信白名单",
|
||||
Long: `从个人收信白名单中移除邮件地址或域名。
|
||||
|
||||
条目格式:
|
||||
- 邮件地址:123@domain.com
|
||||
- 域名:@domain.com(域名前需加 @ 符号)`,
|
||||
Example: ` dws mail allow-list remove --email user@company.com --entries a@b.com,@spam.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "entries"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("remove_mailbox_allowlist", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"entries": parseRecipients(mustGetFlag(cmd, "entries")),
|
||||
})
|
||||
},
|
||||
}
|
||||
allowListListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
allowListAddCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
allowListAddCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
allowListRemoveCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
allowListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
allowListCmd.AddCommand(allowListListCmd, allowListAddCmd, allowListRemoveCmd)
|
||||
|
||||
// ── block-list 个人收信黑名单 ────────────────────────────────
|
||||
blockListCmd := &cobra.Command{Use: "block-list", Short: "个人收信黑名单管理", RunE: groupRunE}
|
||||
|
||||
blockListListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出个人收信黑名单",
|
||||
Long: `列出当前用户的个人收信黑名单地址列表。
|
||||
|
||||
返回字段:
|
||||
entries 黑名单地址列表
|
||||
success 接口调用是否成功
|
||||
errorCode 错误码(仅失败时存在)
|
||||
errorMsg 错误信息(仅失败时存在)`,
|
||||
Example: ` dws mail block-list list --email user@company.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("list_mailbox_blocklist", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
})
|
||||
},
|
||||
}
|
||||
blockListAddCmd := &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "添加个人收信黑名单",
|
||||
Long: `向个人收信黑名单中添加邮件地址或域名。
|
||||
|
||||
条目格式:
|
||||
- 邮件地址:123@domain.com
|
||||
- 域名:@domain.com(域名前需加 @ 符号)`,
|
||||
Example: ` dws mail block-list add --email user@company.com --entries spam@bad.com,@junk.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "entries"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("add_mailbox_blocklist", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"entries": parseRecipients(mustGetFlag(cmd, "entries")),
|
||||
})
|
||||
},
|
||||
}
|
||||
blockListRemoveCmd := &cobra.Command{
|
||||
Use: "remove",
|
||||
Short: "移除个人收信黑名单",
|
||||
Long: `从个人收信黑名单中移除邮件地址或域名。
|
||||
|
||||
条目格式:
|
||||
- 邮件地址:123@domain.com
|
||||
- 域名:@domain.com(域名前需加 @ 符号)`,
|
||||
Example: ` dws mail block-list remove --email user@company.com --entries spam@bad.com,@junk.com`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "email", "entries"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("remove_mailbox_blocklist", map[string]any{
|
||||
"email": mustGetFlag(cmd, "email"),
|
||||
"entries": parseRecipients(mustGetFlag(cmd, "entries")),
|
||||
})
|
||||
},
|
||||
}
|
||||
blockListListCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
blockListAddCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
blockListAddCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
blockListRemoveCmd.Flags().String("email", "", "用户的邮箱地址 (必填)")
|
||||
blockListRemoveCmd.Flags().String("entries", "", "逗号分隔的地址列表,支持邮件地址(如123@domain.com)或域名(如@domain.com)")
|
||||
blockListCmd.AddCommand(blockListListCmd, blockListAddCmd, blockListRemoveCmd)
|
||||
|
||||
root.AddCommand(mailboxCmd, messageCmd, sentMessageCmd, draftCmd, threadCmd, folderCmd, tagCmd, userCmd, attachmentCmd, templateCmd, contactCmd, autoReplyCmd, ruleCmd, allowListCmd, blockListCmd)
|
||||
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -6,8 +6,31 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
)
|
||||
|
||||
// applyGlobalFilter applies the global --jq / --fields output filters to data
|
||||
// when either is set. Returns handled=true when it wrote the (filtered) output,
|
||||
// so callers skip their default JSON encoding. The filter helpers live in
|
||||
// internal/output (the same ones used by `dws api`); the helper Formatter used
|
||||
// by product commands previously ignored these flags, making them no-ops.
|
||||
func (f *Formatter) applyGlobalFilter(data any) (handled bool, err error) {
|
||||
if deps == nil || deps.Caller == nil {
|
||||
return false, nil
|
||||
}
|
||||
jq := strings.TrimSpace(deps.Caller.JQ())
|
||||
fields := strings.TrimSpace(deps.Caller.Fields())
|
||||
if jq == "" && fields == "" {
|
||||
return false, nil
|
||||
}
|
||||
format := output.Format(strings.TrimSpace(deps.Caller.Format()))
|
||||
if format == "" {
|
||||
format = output.FormatJSON
|
||||
}
|
||||
return true, output.WriteFiltered(f.w, format, data, fields, jq)
|
||||
}
|
||||
|
||||
// Formatter provides output formatting compatible with the old Wukong CLI.
|
||||
type Formatter struct {
|
||||
w io.Writer
|
||||
@@ -23,6 +46,9 @@ func NewFormatter() *Formatter {
|
||||
// 转义为 \u0026、\u003c、\u003e。对于大多数 CLI 输出场景这是安全的默认行为。
|
||||
// 如果返回值中包含 URL 等不应被转义的内容,请使用 PrintJSONUnescaped。
|
||||
func (f *Formatter) PrintJSON(data any) error {
|
||||
if handled, err := f.applyGlobalFilter(data); handled {
|
||||
return err
|
||||
}
|
||||
enc := json.NewEncoder(f.w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(data)
|
||||
@@ -38,6 +64,9 @@ func (f *Formatter) PrintJSON(data any) error {
|
||||
//
|
||||
// 影响范围:仅在调用方显式选择时生效,不影响全局 PrintJSON 的行为。
|
||||
func (f *Formatter) PrintJSONUnescaped(data any) error {
|
||||
if handled, err := f.applyGlobalFilter(data); handled {
|
||||
return err
|
||||
}
|
||||
enc := json.NewEncoder(f.w)
|
||||
enc.SetIndent("", " ")
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
@@ -23,6 +23,7 @@ func init() {
|
||||
name string
|
||||
fn func() *cobra.Command
|
||||
}{
|
||||
{"agoal", newAgoalCommand},
|
||||
{"aisearch", newAisearchCommand},
|
||||
{"aitable", newAitableCommand},
|
||||
{"attendance", newAttendanceCommand},
|
||||
|
||||
@@ -48,6 +48,8 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet insert-dimension 在指定位置插入行或列
|
||||
dws sheet delete-dimension 删除指定位置的行或列
|
||||
dws sheet update-dimension 更新指定范围行/列属性(显隐、行高/列宽)
|
||||
dws sheet group-dimension 对指定连续行/列创建分组
|
||||
dws sheet ungroup-dimension 取消指定连续行/列分组
|
||||
dws sheet media-upload 上传附件到表格
|
||||
dws sheet write-image 上传图片并写入表格单元格
|
||||
dws sheet replace 全局查找替换文本
|
||||
|
||||
@@ -39,6 +39,8 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
"csv-put": {"set_range_from_csv", BuildCsvPutArgs},
|
||||
"delete-float-image": {"delete_float_image", BuildDeleteFloatImageArgs},
|
||||
"update-dimension": {"update_dimension", BuildUpdateDimensionArgs},
|
||||
"group-dimension": {"group_dimension", BuildGroupDimensionArgs},
|
||||
"ungroup-dimension": {"ungroup_dimension", BuildUngroupDimensionArgs},
|
||||
}
|
||||
|
||||
// translateBatchOp translates a batch operation from CLI format to MCP format.
|
||||
@@ -269,6 +271,27 @@ func BuildUpdateDimensionArgs(input map[string]any) map[string]any {
|
||||
return args
|
||||
}
|
||||
|
||||
// BuildGroupDimensionArgs converts CLI flags to MCP params for group_dimension.
|
||||
func BuildGroupDimensionArgs(input map[string]any) map[string]any {
|
||||
groupState := batchStr(input, "group-state", "groupState")
|
||||
if groupState == "" {
|
||||
groupState = "expand"
|
||||
}
|
||||
return map[string]any{
|
||||
"sheetId": batchStr(input, "sheet-id"),
|
||||
"range": batchStr(input, "range"),
|
||||
"groupState": groupState,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildUngroupDimensionArgs converts CLI flags to MCP params for ungroup_dimension.
|
||||
func BuildUngroupDimensionArgs(input map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"sheetId": batchStr(input, "sheet-id"),
|
||||
"range": batchStr(input, "range"),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCsvContent resolves @filepath and - stdin to CSV text, matching standalone csv-put behavior.
|
||||
func resolveCsvContent(csvVal string) string {
|
||||
switch {
|
||||
@@ -307,8 +330,12 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
|
||||
支持的 CLI 命令名:
|
||||
range clear / range update / merge-cells / unmerge-cells / update-dimension
|
||||
range fill / range copy-to / add-dimension / delete-dimension / move-dimension
|
||||
group-dimension / ungroup-dimension
|
||||
set-dropdown / delete-dropdown / csv-put / delete-float-image
|
||||
|
||||
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
|
||||
dws sheet group-dimension 命令。
|
||||
|
||||
--operations 是 JSON 数组,每项包含:
|
||||
toolName CLI 命令名(如 "range clear", "range update")
|
||||
input 该命令的入参(不含 --node),键用 flag 名去掉 --
|
||||
|
||||
@@ -375,6 +375,83 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)")
|
||||
updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽")
|
||||
|
||||
groupDimensionCmd := &cobra.Command{
|
||||
Use: "group-dimension",
|
||||
Short: "对指定连续行/列创建分组",
|
||||
Long: `对钉钉表格指定工作表中的连续整行或整列创建分组。
|
||||
|
||||
--range 使用整行/整列范围:
|
||||
行分组:3:7 或 3
|
||||
列分组:C:F 或 C
|
||||
|
||||
支持在 --range 中携带工作表前缀(如 "Sheet1!3:7" / "Sheet1!C:F"),此时将忽略 --sheet-id。
|
||||
创建后可通过 sheet info --include groups 回读 rowGroups / columnGroups。
|
||||
|
||||
--group-state 支持 expand / fold,默认 expand。`,
|
||||
Example: ` # 分组第 3~7 行
|
||||
dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "3:7"
|
||||
|
||||
# 分组并折叠 C~F 列
|
||||
dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "C:F" --group-state fold
|
||||
|
||||
# 使用工作表前缀(忽略 --sheet-id)
|
||||
dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "Sheet1!3:7"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
|
||||
return err
|
||||
}
|
||||
groupState, _ := cmd.Flags().GetString("group-state")
|
||||
switch groupState {
|
||||
case "", "expand":
|
||||
groupState = "expand"
|
||||
case "fold":
|
||||
default:
|
||||
return fmt.Errorf("--group-state 必须为 expand 或 fold,当前值: %s", groupState)
|
||||
}
|
||||
return callMCPTool("group_dimension", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"range": mustGetFlag(cmd, "range"),
|
||||
"groupState": groupState,
|
||||
})
|
||||
},
|
||||
}
|
||||
groupDimensionCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
groupDimensionCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
groupDimensionCmd.Flags().String("range", "", `整行/整列范围 (必填),如 "3:7" 或 "C:F"`)
|
||||
groupDimensionCmd.Flags().String("group-state", "expand", "创建后的分组状态: expand 或 fold")
|
||||
|
||||
ungroupDimensionCmd := &cobra.Command{
|
||||
Use: "ungroup-dimension",
|
||||
Short: "取消指定连续行/列分组",
|
||||
Long: `取消钉钉表格指定工作表中的连续整行或整列分组。
|
||||
|
||||
--range 使用整行/整列范围:
|
||||
行分组:3:7 或 3
|
||||
列分组:C:F 或 C
|
||||
|
||||
支持在 --range 中携带工作表前缀(如 "Sheet1!3:7" / "Sheet1!C:F"),此时将忽略 --sheet-id。
|
||||
取消后可通过 sheet info --include groups 回读 rowGroups / columnGroups。`,
|
||||
Example: ` # 取消第 3~7 行分组
|
||||
dws sheet ungroup-dimension --node NODE_ID --sheet-id SHEET_ID --range "3:7"
|
||||
|
||||
# 取消 C~F 列分组
|
||||
dws sheet ungroup-dimension --node NODE_ID --sheet-id SHEET_ID --range "C:F"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("ungroup_dimension", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"range": mustGetFlag(cmd, "range"),
|
||||
})
|
||||
},
|
||||
}
|
||||
ungroupDimensionCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
ungroupDimensionCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
ungroupDimensionCmd.Flags().String("range", "", `整行/整列范围 (必填),如 "3:7" 或 "C:F"`)
|
||||
|
||||
// ── dropdown ──────────────────────────────────────────────────
|
||||
setDropdownCmd := &cobra.Command{
|
||||
Use: "set-dropdown",
|
||||
@@ -499,7 +576,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
return []*cobra.Command{
|
||||
insertDimensionCmd, moveDimensionCmd, addDimensionCmd,
|
||||
mergeCellsCmd, unmergeRangeCmd,
|
||||
deleteDimensionCmd, updateDimensionCmd,
|
||||
deleteDimensionCmd, updateDimensionCmd, groupDimensionCmd, ungroupDimensionCmd,
|
||||
setDropdownCmd, getDropdownCmd, deleteDropdownCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ func newExportCmd() *cobra.Command {
|
||||
- 未指定:仅返回 downloadUrl,链接有时效性请尽快下载
|
||||
|
||||
支持范围:
|
||||
仅支持钉钉在线电子表格(alxs)→ xlsx;
|
||||
仅支持钉钉在线电子表格(axls)→ xlsx;
|
||||
若需导出钉钉文字文档,请使用 dingtalkdoc 侧的导出工具。
|
||||
|
||||
权限要求:
|
||||
|
||||
@@ -182,7 +182,13 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
// json 模式下进度提示会污染 stdout(PrintInfo/PrintKeyValue 都写 stdout),
|
||||
// 使 write_image 的 JSON 响应无法被单独解析。故 json 模式抑制进度。
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo(fmt.Sprintf("[1/3] 获取附件上传凭证 (%s, %d bytes)...", fileName, fileSize))
|
||||
}
|
||||
|
||||
result, err := deps.Caller.CallTool(ctx, "doc", "get_doc_attachment_upload_info", map[string]any{
|
||||
"nodeId": nodeID,
|
||||
@@ -216,10 +222,11 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error {
|
||||
resourceURL, _ = credData["resourceUrl"].(string)
|
||||
}
|
||||
|
||||
deps.Out.PrintKeyValue("resourceId", resourceID)
|
||||
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
|
||||
|
||||
deps.Out.PrintInfo("[2/3] 上传图片到 OSS...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintKeyValue("resourceId", resourceID)
|
||||
deps.Out.PrintKeyValue("resourceUrl", resourceURL)
|
||||
deps.Out.PrintInfo("[2/3] 上传图片到 OSS...")
|
||||
}
|
||||
|
||||
ossHeaders := map[string]string{
|
||||
"Content-Type": mimeType,
|
||||
@@ -228,7 +235,9 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo("[3/3] 写入图片到表格单元格...")
|
||||
if !jsonMode {
|
||||
deps.Out.PrintInfo("[3/3] 写入图片到表格单元格...")
|
||||
}
|
||||
|
||||
writeArgs := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
@@ -244,10 +253,13 @@ func runSheetWriteImage(cmd *cobra.Command, _ []string) error {
|
||||
writeArgs["height"] = h
|
||||
}
|
||||
|
||||
if jsonMode {
|
||||
// json 模式:write_image 的 JSON 响应就是唯一 stdout 输出。
|
||||
return callMCPTool("write_image", writeArgs)
|
||||
}
|
||||
if err := callMCPTool("write_image", writeArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
deps.Out.PrintInfo(fmt.Sprintf("图片已写入表格: %s → %s (resourceId=%s)", fileName, rangeAddress, resourceID))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -397,6 +398,9 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
|
||||
node := mustGetFlag(cmd, "node")
|
||||
batchPath := mustGetFlag(cmd, "batch")
|
||||
continueOnErr, _ := cmd.Flags().GetBool("continue-on-error")
|
||||
jsonMode := deps.Caller.Format() == "json"
|
||||
ctx := context.Background()
|
||||
var jsonResults []any
|
||||
|
||||
data, err := os.ReadFile(batchPath)
|
||||
if err != nil {
|
||||
@@ -457,6 +461,34 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[%d/%d] update_range sheet=%s range=%s\n", i+1, total, item.SheetID, item.Range)
|
||||
if jsonMode {
|
||||
text, cerr := callMCPToolReturnText(ctx, "update_range", toolArgs)
|
||||
entry := map[string]any{"index": i + 1, "sheetId": item.SheetID, "range": item.Range}
|
||||
if cerr != nil {
|
||||
entry["ok"] = false
|
||||
entry["error"] = cerr.Error()
|
||||
jsonResults = append(jsonResults, entry)
|
||||
cerr = fmt.Errorf("第 %d/%d 条 update_range 失败: %w", i+1, total, cerr)
|
||||
fmt.Fprintln(os.Stderr, cerr)
|
||||
failed++
|
||||
if firstErr == nil {
|
||||
firstErr = cerr
|
||||
}
|
||||
if !continueOnErr {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
var parsed any
|
||||
if json.Unmarshal([]byte(text), &parsed) == nil {
|
||||
entry["result"] = parsed
|
||||
} else {
|
||||
entry["result"] = text
|
||||
}
|
||||
entry["ok"] = true
|
||||
jsonResults = append(jsonResults, entry)
|
||||
continue
|
||||
}
|
||||
if err := callMCPTool("update_range", toolArgs); err != nil {
|
||||
err = fmt.Errorf("第 %d/%d 条 update_range 失败: %w", i+1, total, err)
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -470,6 +502,14 @@ func newRangeBatchSetStyleCmd() *cobra.Command {
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "batch-set-style 完成:共 %d 条,失败 %d 条\n", total, failed)
|
||||
if jsonMode {
|
||||
_ = deps.Out.PrintJSON(map[string]any{
|
||||
"total": total,
|
||||
"failed": failed,
|
||||
"results": jsonResults,
|
||||
"success": failed == 0,
|
||||
})
|
||||
}
|
||||
if failed > 0 && !continueOnErr {
|
||||
return firstErr
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func requireWukongSyncCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd, remaining, err := root.Find(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: command not found: %v", path, err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("%s: unresolved path suffix %v (resolved to %q)", path, remaining, cmd.CommandPath())
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func requireWukongSyncFlags(t *testing.T, cmd *cobra.Command, names ...string) {
|
||||
t.Helper()
|
||||
for _, name := range names {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("%s: missing flag --%s", cmd.CommandPath(), name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWukongSyncMailCommands(t *testing.T) {
|
||||
root := newMailCommand()
|
||||
cases := []struct {
|
||||
path []string
|
||||
flags []string
|
||||
}{
|
||||
{[]string{"mailbox", "profile"}, []string{"email"}},
|
||||
{[]string{"message", "batch-get"}, []string{"email", "ids"}},
|
||||
{[]string{"sent-message", "recall"}, []string{"email", "id", "subject", "yes"}},
|
||||
{[]string{"sent-message", "recall-detail"}, []string{"email", "id"}},
|
||||
{[]string{"auto-reply", "update"}, []string{"email", "enabled", "start", "end", "scope", "content"}},
|
||||
{[]string{"allow-list", "list"}, []string{"email"}},
|
||||
{[]string{"allow-list", "add"}, []string{"email", "entries"}},
|
||||
{[]string{"allow-list", "remove"}, []string{"email", "entries"}},
|
||||
{[]string{"block-list", "list"}, []string{"email"}},
|
||||
{[]string{"block-list", "add"}, []string{"email", "entries"}},
|
||||
{[]string{"block-list", "remove"}, []string{"email", "entries"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd := requireWukongSyncCommand(t, root, tc.path...)
|
||||
requireWukongSyncFlags(t, cmd, tc.flags...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWukongSyncChatCommands(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
cases := []struct {
|
||||
path []string
|
||||
flags []string
|
||||
}{
|
||||
{[]string{"group", "notice", "create"}, []string{"group", "content", "sticky", "send-ding", "run-at"}},
|
||||
{[]string{"group", "notice", "edit"}, []string{"group", "notice-id", "content", "sticky", "send-ding"}},
|
||||
{[]string{"group", "notice", "get"}, []string{"group", "notice-id"}},
|
||||
{[]string{"group", "notice", "list"}, []string{"group", "limit", "cursor", "scheduled"}},
|
||||
{[]string{"group", "share-invite"}, []string{"source", "target", "receiver", "expires-seconds", "uuid"}},
|
||||
{[]string{"text", "translate"}, []string{"query", "to"}},
|
||||
{[]string{"category", "create-smart"}, []string{"name", "keywords", "members"}},
|
||||
{[]string{"message", "list-emotion-replies"}, []string{"msg-ids"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd := requireWukongSyncCommand(t, root, tc.path...)
|
||||
requireWukongSyncFlags(t, cmd, tc.flags...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWukongSyncDocCommands(t *testing.T) {
|
||||
root := newDocCommand()
|
||||
|
||||
importCmd := requireWukongSyncCommand(t, root, "import")
|
||||
requireWukongSyncFlags(t, importCmd, "file", "folder", "workspace", "name", "folder-id", "workspace-id")
|
||||
|
||||
importGetCmd := requireWukongSyncCommand(t, root, "import", "get")
|
||||
requireWukongSyncFlags(t, importGetCmd, "task-id")
|
||||
}
|
||||
|
||||
func TestWukongSyncSheetCommands(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
|
||||
groupCmd := requireWukongSyncCommand(t, root, "group-dimension")
|
||||
requireWukongSyncFlags(t, groupCmd, "node", "sheet-id", "range", "group-state")
|
||||
|
||||
ungroupCmd := requireWukongSyncCommand(t, root, "ungroup-dimension")
|
||||
requireWukongSyncFlags(t, ungroupCmd, "node", "sheet-id", "range")
|
||||
}
|
||||
|
||||
func TestWukongSyncSheetBatchDimensionGroupMapping(t *testing.T) {
|
||||
group, err := translateBatchOp(map[string]any{
|
||||
"toolName": "group-dimension",
|
||||
"input": map[string]any{
|
||||
"sheet-id": "Sheet1",
|
||||
"range": "3:7",
|
||||
"group-state": "fold",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("group-dimension mapping returned error: %v", err)
|
||||
}
|
||||
wantGroup := map[string]any{
|
||||
"toolName": "group_dimension",
|
||||
"input": map[string]any{
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(group, wantGroup) {
|
||||
t.Fatalf("group-dimension mapping mismatch:\n got: %#v\nwant: %#v", group, wantGroup)
|
||||
}
|
||||
|
||||
ungroup, err := translateBatchOp(map[string]any{
|
||||
"toolName": "ungroup-dimension",
|
||||
"input": map[string]any{
|
||||
"sheet-id": "Sheet1",
|
||||
"range": "C:F",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ungroup-dimension mapping returned error: %v", err)
|
||||
}
|
||||
wantUngroup := map[string]any{
|
||||
"toolName": "ungroup_dimension",
|
||||
"input": map[string]any{
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(ungroup, wantUngroup) {
|
||||
t.Fatalf("ungroup-dimension mapping mismatch:\n got: %#v\nwant: %#v", ungroup, wantUngroup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWukongSyncAgoalCommands(t *testing.T) {
|
||||
root := newAgoalCommand()
|
||||
cases := []struct {
|
||||
path []string
|
||||
flags []string
|
||||
}{
|
||||
{[]string{"strategy", "list"}, []string{"scope-type", "scope-id", "request-id"}},
|
||||
{[]string{"strategy", "detail"}, []string{"profile-id", "request-id"}},
|
||||
{[]string{"strategy", "update"}, []string{"profile-id", "content", "request-id"}},
|
||||
{[]string{"contract", "list"}, []string{"scope-type", "scope-id", "request-id"}},
|
||||
{[]string{"contract", "fields"}, []string{"request-id"}},
|
||||
{[]string{"contract", "detail"}, []string{"contract-id", "request-id"}},
|
||||
{[]string{"contract", "update"}, []string{"contract-id", "dimensions", "audit-config", "objective-template", "request-id"}},
|
||||
{[]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{"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"}},
|
||||
{[]string{"report", "submit-detail"}, []string{"template-id", "submit-state", "query-date", "page", "page-size", "keyword", "request-id"}},
|
||||
{[]string{"obj-template", "list"}, []string{"keyword", "page", "page-size", "request-id"}},
|
||||
{[]string{"obj-template", "create-or-update"}, []string{"template-id", "title", "objective-weight", "dimension-weight", "compute-by-weight", "dimensions", "request-id"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd := requireWukongSyncCommand(t, root, tc.path...)
|
||||
requireWukongSyncFlags(t, cmd, tc.flags...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// 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.
|
||||
|
||||
//go:build !darwin
|
||||
|
||||
package keychain
|
||||
|
||||
func platformDiagnose() Diagnostic {
|
||||
return Diagnostic{
|
||||
OK: true,
|
||||
Message: "当前平台使用本地加密凭据后端",
|
||||
Detail: map[string]string{
|
||||
"service": Service,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -63,3 +63,18 @@ func fileDEK(service string) ([]byte, error) {
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func fileDEKReadOnly(service string) ([]byte, error) {
|
||||
keyPath := filepath.Join(StorageDir(service), "dek")
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if err == nil && len(key) == dekBytes {
|
||||
return key, nil
|
||||
}
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrDEKMissing
|
||||
}
|
||||
return nil, fmt.Errorf("read dek: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("read dek: %w", ErrDEKMissing)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
// - Windows: DPAPI + Registry storage
|
||||
package keychain
|
||||
|
||||
import "errors"
|
||||
|
||||
const (
|
||||
// Service is the unified keychain service name for all secrets.
|
||||
Service = "dws-cli"
|
||||
@@ -40,6 +42,11 @@ const (
|
||||
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
|
||||
)
|
||||
|
||||
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
|
||||
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
|
||||
// because a fresh key cannot decrypt existing ciphertext.
|
||||
var ErrDEKMissing = errors.New("dek missing")
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
type KeychainAccess interface {
|
||||
Get(service, account string) (string, error)
|
||||
@@ -47,6 +54,64 @@ type KeychainAccess interface {
|
||||
Remove(service, account string) error
|
||||
}
|
||||
|
||||
// Diagnostic is a read-only health report for the platform keychain backend.
|
||||
// It never mutates credentials, DEKs, or OS keychain settings.
|
||||
type Diagnostic struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Detail map[string]string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// UnavailableError marks failures where the platform keychain itself could
|
||||
// not be reached, unlocked, or created. Callers can surface a diagnostic
|
||||
// instead of treating the result as a normal missing credential.
|
||||
type UnavailableError struct {
|
||||
Op string
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewUnavailableError(op string, err error) error {
|
||||
return &UnavailableError{Op: op, Err: err}
|
||||
}
|
||||
|
||||
func (e *UnavailableError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Op == "" {
|
||||
if e.Err != nil {
|
||||
return e.Err.Error()
|
||||
}
|
||||
return "keychain unavailable"
|
||||
}
|
||||
if e.Err == nil {
|
||||
return e.Op + ": keychain unavailable"
|
||||
}
|
||||
return e.Op + ": " + e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *UnavailableError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func IsUnavailable(err error) bool {
|
||||
var unavailable *UnavailableError
|
||||
return errors.As(err, &unavailable)
|
||||
}
|
||||
|
||||
func IsDEKMissing(err error) bool {
|
||||
return errors.Is(err, ErrDEKMissing)
|
||||
}
|
||||
|
||||
func Diagnose() Diagnostic {
|
||||
return platformDiagnose()
|
||||
}
|
||||
|
||||
// Get retrieves a value from the keychain.
|
||||
// Returns empty string and nil error if the entry does not exist.
|
||||
func Get(service, account string) (string, error) {
|
||||
|
||||
@@ -21,10 +21,14 @@ import (
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -59,15 +63,169 @@ func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
var readDefaultKeychain = func() ([]byte, error) {
|
||||
return exec.Command("security", "default-keychain", "-d", "user").Output()
|
||||
}
|
||||
|
||||
var (
|
||||
keyringGet = keyring.Get
|
||||
keyringSet = keyring.Set
|
||||
)
|
||||
|
||||
func defaultKeychainPathFromSecurityOutput(output []byte) string {
|
||||
value := strings.TrimSpace(string(output))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if unquoted, err := strconv.Unquote(value); err == nil {
|
||||
return unquoted
|
||||
}
|
||||
return strings.Trim(value, `"`)
|
||||
}
|
||||
|
||||
func checkDefaultKeychainAvailable() error {
|
||||
output, err := readDefaultKeychain()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
path := defaultKeychainPathFromSecurityOutput(output)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
|
||||
return NewUnavailableError("read macOS default Keychain", fmt.Errorf("default keychain %q does not exist", path))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformDiagnose() Diagnostic {
|
||||
detail := map[string]string{
|
||||
"platform": "darwin",
|
||||
"service": Service,
|
||||
"account": "dek",
|
||||
}
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
detail["mode"] = "file_dek"
|
||||
detail["storage_dir"] = StorageDir(Service)
|
||||
return Diagnostic{
|
||||
OK: true,
|
||||
Message: "macOS Keychain 已禁用, 当前使用 file-DEK 测试模式",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
output, err := readDefaultKeychain()
|
||||
if err != nil {
|
||||
detail["error"] = err.Error()
|
||||
return Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_check_failed",
|
||||
Message: "无法读取 macOS 默认钥匙串配置",
|
||||
Hint: "检查 /usr/bin/security 是否可用, 并确认当前用户钥匙串配置正常。",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
path := defaultKeychainPathFromSecurityOutput(output)
|
||||
if path != "" {
|
||||
detail["default_keychain"] = path
|
||||
}
|
||||
if path == "" {
|
||||
return Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "macOS 默认钥匙串未配置",
|
||||
Hint: "恢复默认钥匙串后重试;测试环境可设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
|
||||
return Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "macOS 默认钥匙串不存在",
|
||||
Hint: "恢复默认钥匙串后重试;测试环境可设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
|
||||
Detail: detail,
|
||||
}
|
||||
} else if err != nil {
|
||||
detail["error"] = err.Error()
|
||||
return Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_check_failed",
|
||||
Message: "无法访问 macOS 默认钥匙串",
|
||||
Hint: "检查默认钥匙串路径权限与挂载状态。",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
return Diagnostic{
|
||||
OK: true,
|
||||
Message: "macOS 默认钥匙串可用",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key.
|
||||
// When DWS_DISABLE_KEYCHAIN=1 (set in sandboxed runtimes like Codex App
|
||||
// where Keychain APIs are blocked), falls back to a file-based DEK
|
||||
// identical to the Linux scheme. See DisableKeychainEnv docs for the
|
||||
// security tradeoff.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
|
||||
func getDEKReadOnly(service string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return fileDEKReadOnly(service)
|
||||
}
|
||||
if err := checkDefaultKeychainAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
key []byte
|
||||
err error
|
||||
}
|
||||
resCh := make(chan result, 1)
|
||||
|
||||
go func() {
|
||||
defer func() { recover() }()
|
||||
|
||||
encodedKey, err := keyringGet(service, "dek")
|
||||
if err == nil {
|
||||
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if decodeErr == nil && len(key) == dekBytes {
|
||||
resCh <- result{key: key, err: nil}
|
||||
return
|
||||
}
|
||||
resCh <- result{key: nil, err: fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing)}
|
||||
return
|
||||
}
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
resCh <- result{key: nil, err: fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing)}
|
||||
return
|
||||
}
|
||||
resCh <- result{key: nil, err: NewUnavailableError("read DEK from macOS Keychain", err)}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resCh:
|
||||
return res.key, res.err
|
||||
case <-ctx.Done():
|
||||
return nil, NewUnavailableError("read DEK from macOS Keychain", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func getOrCreateDEK(service string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return fileDEK(service)
|
||||
}
|
||||
if err := checkDefaultKeychainAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
|
||||
defer cancel()
|
||||
@@ -82,13 +240,16 @@ func getDEK(service string) ([]byte, error) {
|
||||
defer func() { recover() }()
|
||||
|
||||
// Try to get existing DEK from system Keychain
|
||||
encodedKey, err := keyring.Get(service, "dek")
|
||||
encodedKey, err := keyringGet(service, "dek")
|
||||
if err == nil {
|
||||
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if decodeErr == nil && len(key) == dekBytes {
|
||||
resCh <- result{key: key, err: nil}
|
||||
return
|
||||
}
|
||||
} else if !errors.Is(err, keyring.ErrNotFound) {
|
||||
resCh <- result{key: nil, err: NewUnavailableError("read DEK from macOS Keychain", err)}
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new DEK if not found or invalid
|
||||
@@ -100,15 +261,18 @@ func getDEK(service string) ([]byte, error) {
|
||||
|
||||
// Store in system Keychain
|
||||
encodedKey = base64.StdEncoding.EncodeToString(key)
|
||||
setErr := keyring.Set(service, "dek", encodedKey)
|
||||
resCh <- result{key: key, err: setErr}
|
||||
if setErr := keyringSet(service, "dek", encodedKey); setErr != nil {
|
||||
resCh <- result{key: nil, err: NewUnavailableError("store DEK in macOS Keychain", setErr)}
|
||||
return
|
||||
}
|
||||
resCh <- result{key: key, err: nil}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resCh:
|
||||
return res.key, res.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
return nil, NewUnavailableError("read DEK from macOS Keychain", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,10 +321,6 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
}
|
||||
|
||||
func platformGet(service, account string) (string, error) {
|
||||
key, err := getDEK(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -168,6 +328,10 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
key, err := getDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -176,7 +340,7 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getDEK(service)
|
||||
key, err := getOrCreateDEK(service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,11 +16,128 @@
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
keyringpkg "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func TestDefaultKeychainPathFromSecurityOutput(t *testing.T) {
|
||||
got := defaultKeychainPathFromSecurityOutput([]byte("\"/Users/me/Library/Keychains/login.keychain-db\"\n"))
|
||||
if got != "/Users/me/Library/Keychains/login.keychain-db" {
|
||||
t.Fatalf("path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDefaultKeychainAvailableReportsMissingPath(t *testing.T) {
|
||||
missingPath := filepath.Join(t.TempDir(), "missing.keychain-db")
|
||||
prev := readDefaultKeychain
|
||||
readDefaultKeychain = func() ([]byte, error) {
|
||||
return []byte("\"" + missingPath + "\"\n"), nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
readDefaultKeychain = prev
|
||||
})
|
||||
|
||||
err := checkDefaultKeychainAvailable()
|
||||
if !IsUnavailable(err) {
|
||||
t.Fatalf("error = %v, want unavailable", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), missingPath) {
|
||||
t.Fatalf("error = %v, want missing keychain path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMissingAccountDoesNotReadMacOSDEK(t *testing.T) {
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
|
||||
keychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
|
||||
if err := os.WriteFile(keychainPath, nil, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(default keychain) error = %v", err)
|
||||
}
|
||||
|
||||
prevReadDefault := readDefaultKeychain
|
||||
prevGet := keyringGet
|
||||
prevSet := keyringSet
|
||||
readDefaultKeychain = func() ([]byte, error) {
|
||||
return []byte("\"" + keychainPath + "\"\n"), nil
|
||||
}
|
||||
keyringGet = func(service, account string) (string, error) {
|
||||
t.Fatalf("keyring.Get(%q, %q) called for missing account", service, account)
|
||||
return "", nil
|
||||
}
|
||||
keyringSet = func(service, account, value string) error {
|
||||
t.Fatalf("keyring.Set(%q, %q) called for missing account", service, account)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
readDefaultKeychain = prevReadDefault
|
||||
keyringGet = prevGet
|
||||
keyringSet = prevSet
|
||||
})
|
||||
|
||||
got, err := Get("test-missing-account", "auth-token")
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v, want nil", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("Get() = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWithMissingMacOSDEKDoesNotCreateDEK(t *testing.T) {
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
|
||||
keychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
|
||||
if err := os.WriteFile(keychainPath, nil, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(default keychain) error = %v", err)
|
||||
}
|
||||
|
||||
prevReadDefault := readDefaultKeychain
|
||||
prevGet := keyringGet
|
||||
prevSet := keyringSet
|
||||
readDefaultKeychain = func() ([]byte, error) {
|
||||
return []byte("\"" + keychainPath + "\"\n"), nil
|
||||
}
|
||||
keyringGet = func(service, account string) (string, error) {
|
||||
if account != "dek" {
|
||||
t.Fatalf("keyring.Get account = %q, want dek", account)
|
||||
}
|
||||
return "", keyringpkg.ErrNotFound
|
||||
}
|
||||
setCalls := 0
|
||||
keyringSet = func(service, account, value string) error {
|
||||
setCalls++
|
||||
return errors.New("keyring.Set should not be called by Get")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
readDefaultKeychain = prevReadDefault
|
||||
keyringGet = prevGet
|
||||
keyringSet = prevSet
|
||||
})
|
||||
|
||||
service := "test-missing-dek"
|
||||
account := "auth-token"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), []byte("ciphertext"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile(ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
_, err := Get(service, account)
|
||||
if !IsDEKMissing(err) {
|
||||
t.Fatalf("Get() error = %v, want dek missing", err)
|
||||
}
|
||||
if setCalls != 0 {
|
||||
t.Fatalf("keyring.Set calls = %d, want 0", setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisableKeychainFallback verifies that setting DWS_DISABLE_KEYCHAIN
|
||||
// routes the DEK to a local file (same scheme as Linux) and the full
|
||||
// Set/Get/Remove cycle works without touching the system Keychain.
|
||||
|
||||
@@ -32,6 +32,10 @@ func getDEK(service string) ([]byte, error) {
|
||||
return fileDEK(service)
|
||||
}
|
||||
|
||||
func getDEKReadOnly(service string) ([]byte, error) {
|
||||
return fileDEKReadOnly(service)
|
||||
}
|
||||
|
||||
const (
|
||||
dekBytes = 32 // DEK = Data Encryption Key (AES-256)
|
||||
ivBytes = 12
|
||||
@@ -106,10 +110,6 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
}
|
||||
|
||||
func platformGet(service, account string) (string, error) {
|
||||
key, err := getDEK(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -117,6 +117,10 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
key, err := getDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -19,6 +19,18 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "dws-keychain-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = os.Setenv(StorageDirEnv, dir)
|
||||
_ = os.Setenv(DisableKeychainEnv, "1")
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestKeychainBasicOperations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -87,6 +99,23 @@ func TestKeychainNonExistentAccount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNonExistentAccountDoesNotCreateFileDEK(t *testing.T) {
|
||||
service := "test-service-readonly-" + t.Name()
|
||||
account := "nonexistent-account"
|
||||
dekPath := filepath.Join(StorageDir(service), "dek")
|
||||
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v, want nil", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("Get() = %q, want empty string", got)
|
||||
}
|
||||
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Get() created DEK at %s; stat error = %v", dekPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeychainOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -244,12 +244,12 @@ func newChmodCommand(c edition.ToolCaller) *cobra.Command {
|
||||
Long: `授予指定 scope 的操作权限。
|
||||
|
||||
scope 格式: <product>.<entity>:<permission>
|
||||
例: aitable.record:read chat.group:write calendar.event:read
|
||||
例: aitable.record:query chat.message:list calendar.event:get
|
||||
|
||||
grantType 规则:
|
||||
once 一次性,执行一次后自动失效
|
||||
session 当前会话有效(默认),需要 --session-id
|
||||
permanent 永久有效
|
||||
session 当前会话有效,需要 --session-id
|
||||
permanent 永久有效(默认)
|
||||
|
||||
批量授权:
|
||||
dws pat chmod 支持一次传多个 scope 直接批量授予。
|
||||
@@ -271,13 +271,13 @@ agentCode 配置:
|
||||
}
|
||||
return cobra.MinimumNArgs(1)(cmd, args)
|
||||
},
|
||||
Example: ` dws pat chmod aitable.record:read --grant-type session --session-id session-xxx
|
||||
Example: ` dws pat chmod aitable.record:query --grant-type session --session-id session-xxx
|
||||
dws pat chmod chat.message:list --grant-type once
|
||||
dws pat chmod aitable.record:read aitable.record:write --grant-type permanent --yes
|
||||
dws pat chmod aitable.record:query aitable.record:create --grant-type permanent --yes
|
||||
dws pat chmod --product calendar --product aitable --grant-type once --dry-run --format json
|
||||
dws pat chmod --products calendar,aitable --grant-type session --session-id session-xxx --yes
|
||||
dws pat chmod --products calendar,aitable --grant-type permanent --yes
|
||||
dws pat chmod --domain calendar --domain chat --grant-type once --yes
|
||||
dws pat chmod --recommend --grant-type session --session-id session-xxx --yes`,
|
||||
dws pat chmod --recommend --yes`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
flagVal, _ := cmd.Flags().GetString("agentCode")
|
||||
agentCode, err := resolveAgentCode(flagVal)
|
||||
@@ -404,7 +404,7 @@ agentCode 配置:
|
||||
|
||||
chmodCmd.Flags().String("agentCode", "",
|
||||
"Agent 唯一标识(可选;也可通过 env DINGTALK_DWS_AGENTCODE 注入,flag 优先;未传则由服务端默认兜底)")
|
||||
chmodCmd.Flags().String("grant-type", "session", "授权策略: once|session|permanent")
|
||||
chmodCmd.Flags().String("grant-type", grantTypePermanent, "授权策略: once|session|permanent")
|
||||
chmodCmd.Flags().String("session-id", "", "会话标识(session 模式下必填)")
|
||||
chmodCmd.Flags().StringArrayVar(&productFlags, "product", nil, "产品编码,可重复;与 --products 等价;执行批量授权需 --yes")
|
||||
chmodCmd.Flags().StringSliceVar(&productsFlag, "products", nil, "产品编码列表,逗号分隔;执行批量授权需 --yes")
|
||||
|
||||
@@ -67,6 +67,8 @@ func (f *fakeToolCaller) CallTool(_ context.Context, _ string, toolName string,
|
||||
|
||||
func (f *fakeToolCaller) Format() string { return "json" }
|
||||
func (f *fakeToolCaller) DryRun() bool { return f.dryRun }
|
||||
func (f *fakeToolCaller) Fields() string { return "" }
|
||||
func (f *fakeToolCaller) JQ() string { return "" }
|
||||
|
||||
type recordedToolCall struct {
|
||||
tool string
|
||||
@@ -94,6 +96,8 @@ func (f *fallbackToolCaller) CallTool(_ context.Context, _ string, toolName stri
|
||||
|
||||
func (f *fallbackToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackToolCaller) DryRun() bool { return false }
|
||||
func (f *fallbackToolCaller) Fields() string { return "" }
|
||||
func (f *fallbackToolCaller) JQ() string { return "" }
|
||||
|
||||
type fallbackErrorToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
@@ -198,6 +202,8 @@ func (f *fallbackPATContractErrorToolCaller) CallTool(_ context.Context, _ strin
|
||||
|
||||
func (f *fallbackPATContractErrorToolCaller) Format() string { return "json" }
|
||||
func (f *fallbackPATContractErrorToolCaller) DryRun() bool { return false }
|
||||
func (f *fallbackPATContractErrorToolCaller) Fields() string { return "" }
|
||||
func (f *fallbackPATContractErrorToolCaller) JQ() string { return "" }
|
||||
|
||||
type sequenceToolCaller struct {
|
||||
calls []recordedToolCall
|
||||
@@ -227,6 +233,8 @@ func (s *sequenceToolCaller) CallTool(_ context.Context, _ string, toolName stri
|
||||
|
||||
func (s *sequenceToolCaller) Format() string { return "json" }
|
||||
func (s *sequenceToolCaller) DryRun() bool { return s.dryRun }
|
||||
func (s *sequenceToolCaller) Fields() string { return "" }
|
||||
func (s *sequenceToolCaller) JQ() string { return "" }
|
||||
|
||||
func stringSliceArgEqual(got any, want []string) bool {
|
||||
gotSlice, ok := got.([]string)
|
||||
@@ -372,9 +380,9 @@ func TestPATHelpDocumentsBatchAuthorization(t *testing.T) {
|
||||
"--dry-run 只返回授权计划",
|
||||
"执行批量授权必须显式",
|
||||
"由服务端默认兜底",
|
||||
"aitable.record:read aitable.record:write --grant-type permanent --yes",
|
||||
"aitable.record:query aitable.record:create --grant-type permanent --yes",
|
||||
"dws pat chmod --products calendar,aitable",
|
||||
"dws pat chmod --recommend --grant-type session",
|
||||
"dws pat chmod --recommend --yes",
|
||||
} {
|
||||
if !strings.Contains(chmodHelp, want) {
|
||||
t.Fatalf("pat chmod help missing %q\nhelp:\n%s", want, chmodHelp)
|
||||
@@ -478,6 +486,7 @@ func TestChmod_productsSessionModePassesIdentityArgsAndCompatEnv(t *testing.T) {
|
||||
`{"success":true,"data":{"grantedScopes":["calendar.event:read"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "session")
|
||||
_ = cmd.Flags().Set("products", "calendar")
|
||||
_ = cmd.Flags().Set("session-id", "session-123")
|
||||
setBatchYesForTest(t, cmd)
|
||||
@@ -843,7 +852,7 @@ func TestChmod_grantTypeAndSessionParameterMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
|
||||
func TestChmod_productsSessionDryRunUsesSessionIDFromEnv(t *testing.T) {
|
||||
t.Setenv(agentCodeEnv, "qoderwork")
|
||||
t.Setenv(sessionIDEnvDWS, "env-session-123")
|
||||
fake := &sequenceToolCaller{
|
||||
@@ -853,6 +862,7 @@ func TestChmod_productsDryRunUsesSessionIDFromEnv(t *testing.T) {
|
||||
},
|
||||
}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "session")
|
||||
_ = cmd.Flags().Set("products", "calendar")
|
||||
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
@@ -1056,6 +1066,7 @@ func TestChmod_sessionModeUsesDingtalkSessionEnv(t *testing.T) {
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "session")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
t.Fatalf("chmod RunE error = %v", err)
|
||||
@@ -1081,6 +1092,7 @@ func TestChmod_explicitSessionIDOverridesStaleDingtalkSessionEnv(t *testing.T) {
|
||||
|
||||
fake := &fakeToolCaller{resultOK: true}
|
||||
cmd := buildChmod(t, fake)
|
||||
_ = cmd.Flags().Set("grant-type", "session")
|
||||
_ = cmd.Flags().Set("session-id", "flag-session")
|
||||
|
||||
if err := cmd.RunE(cmd, []string{"aitable.record:read"}); err != nil {
|
||||
@@ -1108,7 +1120,6 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T)
|
||||
`{"success":true,"data":{"grantedScopes":["recommended.scope:read"]}}`,
|
||||
}}
|
||||
cmd := newChmodCommand(fake)
|
||||
_ = cmd.Flags().Set("grant-type", "once")
|
||||
_ = cmd.Flags().Set("recommend", "true")
|
||||
setBatchYesForTest(t, cmd)
|
||||
|
||||
@@ -1125,9 +1136,15 @@ func TestChmod_recommendFlagPlansThenGrantsWithoutPositionalScopes(t *testing.T)
|
||||
if got := fake.calls[0].args["recommend"]; got != true {
|
||||
t.Fatalf("recommend = %#v, want true", got)
|
||||
}
|
||||
if got := fake.calls[0].args["grantType"]; got != grantTypePermanent {
|
||||
t.Fatalf("plan grantType = %#v, want %q", got, grantTypePermanent)
|
||||
}
|
||||
if fake.calls[1].tool != patBatchGrantToolName {
|
||||
t.Fatalf("second tool = %q, want %q", fake.calls[1].tool, patBatchGrantToolName)
|
||||
}
|
||||
if got := fake.calls[1].args["grantType"]; got != grantTypePermanent {
|
||||
t.Fatalf("grant grantType = %#v, want %q", got, grantTypePermanent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRecommendAuthorizationSelectorReplansBySelectedProducts(t *testing.T) {
|
||||
@@ -2020,3 +2037,12 @@ func TestResolveAgentCodeFromEnv(t *testing.T) {
|
||||
code, src)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fallbackErrorToolCaller) Fields() string { return "" }
|
||||
func (f *fallbackErrorToolCaller) JQ() string { return "" }
|
||||
func (f *fallbackSchemaMismatchToolCaller) Fields() string { return "" }
|
||||
func (f *fallbackSchemaMismatchToolCaller) JQ() string { return "" }
|
||||
func (f *fallbackPermissionDeniedToolCaller) Fields() string { return "" }
|
||||
func (f *fallbackPermissionDeniedToolCaller) JQ() string { return "" }
|
||||
func (f *fallbackPATErrorToolCaller) Fields() string { return "" }
|
||||
func (f *fallbackPATErrorToolCaller) JQ() string { return "" }
|
||||
|
||||
@@ -5,6 +5,7 @@ package syncdata
|
||||
|
||||
func StaticServers() []ServerInfo {
|
||||
return []ServerInfo{
|
||||
{ID: "agoal", Name: "Agoal", Endpoint: "https://mcp-gw.dingtalk.com/server/1db49ea94ffe74c25a7079a68b4df6629c79130fb9095d1b499f507015d847a9", Prefixes: []string{"agoal"}},
|
||||
{ID: "aisearch", Name: "AI 搜问", Endpoint: "https://mcp-gw.dingtalk.com/server/ai-search", Prefixes: []string{"aisearch", "enterprise"}},
|
||||
{ID: "aitable", Name: "AI 多维表", Endpoint: "https://mcp-gw.dingtalk.com/server/5f0d121611f14e878f7d42c3e32bf6c4a790d433066adae38c062a657c397047", Prefixes: []string{"table", "record", "field", "base", "attachment", "view", "dashboard", "chart", "export", "import"}},
|
||||
{ID: "aitable-helper", Name: "AI 多维表(辅助)", Endpoint: "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95", Prefixes: []string{"form", "share_form"}},
|
||||
|
||||
@@ -5,6 +5,7 @@ package syncdata
|
||||
|
||||
func CmdToProduct() map[string]string {
|
||||
return map[string]string{
|
||||
"agoal": "agoal",
|
||||
"aisearch": "aisearch",
|
||||
"aitable": "aitable",
|
||||
"attendance": "attendance",
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// MergeHardcodedLeaves grafts leaves from hardcodedRoot onto dynamicRoot when
|
||||
// the same-named path does not already exist. Groups recurse. On leaf
|
||||
// conflicts, the dynamic side wins by default because the runtime envelope is
|
||||
// authoritative; hardcoded commands are retained as fallback for paths the
|
||||
// envelope does not declare.
|
||||
//
|
||||
// A hardcoded leaf or group can opt into replacement by carrying a strictly
|
||||
// higher OverridePriority than the dynamic command at the same path.
|
||||
//
|
||||
// MergeHardcodedLeaves mutates dynamicRoot in place and returns it. Grafted
|
||||
// commands are detached from hardcodedRoot so Cobra parent pointers remain
|
||||
// correct.
|
||||
func MergeHardcodedLeaves(dynamicRoot, hardcodedRoot *cobra.Command) *cobra.Command {
|
||||
if dynamicRoot == nil || hardcodedRoot == nil {
|
||||
return dynamicRoot
|
||||
}
|
||||
|
||||
children := append([]*cobra.Command(nil), hardcodedRoot.Commands()...)
|
||||
for _, hc := range children {
|
||||
dyn := findChildByName(dynamicRoot, hc.Name())
|
||||
switch {
|
||||
case dyn == nil:
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
case IsLeafCmd(hc) && IsLeafCmd(dyn):
|
||||
if OverridePriority(hc) > OverridePriority(dyn) {
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.RemoveCommand(dyn)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
}
|
||||
case !IsLeafCmd(hc) && !IsLeafCmd(dyn) && OverridePriority(hc) > OverridePriority(dyn):
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.RemoveCommand(dyn)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
case !IsLeafCmd(hc) && !IsLeafCmd(dyn):
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
case IsLeafCmd(dyn) && !IsLeafCmd(hc) && OverridePriority(hc) > OverridePriority(dyn):
|
||||
hardcodedRoot.RemoveCommand(hc)
|
||||
dynamicRoot.RemoveCommand(dyn)
|
||||
dynamicRoot.AddCommand(hc)
|
||||
default:
|
||||
slog.Warn("overlay: shape mismatch, keeping dynamic",
|
||||
"name", hc.Name(),
|
||||
"dynamicIsLeaf", IsLeafCmd(dyn),
|
||||
"hardcodedIsLeaf", IsLeafCmd(hc))
|
||||
}
|
||||
}
|
||||
return dynamicRoot
|
||||
}
|
||||
|
||||
// IsLeafCmd reports whether cmd has no subcommands.
|
||||
func IsLeafCmd(cmd *cobra.Command) bool {
|
||||
if cmd == nil {
|
||||
return false
|
||||
}
|
||||
return !cmd.HasSubCommands()
|
||||
}
|
||||
|
||||
func findChildByName(parent *cobra.Command, name string) *cobra.Command {
|
||||
if parent == nil {
|
||||
return nil
|
||||
}
|
||||
for _, child := range parent.Commands() {
|
||||
if child.Name() == name {
|
||||
return child
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newGroup(name string, children ...*cobra.Command) *cobra.Command {
|
||||
cmd := &cobra.Command{Use: name}
|
||||
cmd.AddCommand(children...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newLeaf(name, tag string) *cobra.Command {
|
||||
return &cobra.Command{Use: name, Short: tag}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesNilInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := MergeHardcodedLeaves(nil, nil); got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
dyn := newGroup("root")
|
||||
if got := MergeHardcodedLeaves(dyn, nil); got != dyn {
|
||||
t.Fatal("expected dynamic root to be returned unchanged")
|
||||
}
|
||||
hc := newGroup("root")
|
||||
if got := MergeHardcodedLeaves(nil, hc); got != nil {
|
||||
t.Fatal("expected nil when dynamic root is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesGraftsUnknownLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root", newLeaf("kept", "dynamic"))
|
||||
hc := newGroup("root", newLeaf("extra", "hardcoded"))
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "extra")
|
||||
if got == nil {
|
||||
t.Fatal("expected extra leaf to be grafted")
|
||||
}
|
||||
if got.Short != "hardcoded" {
|
||||
t.Fatalf("extra.Short = %q, want %q", got.Short, "hardcoded")
|
||||
}
|
||||
if findChildByName(hc, "extra") != nil {
|
||||
t.Fatal("expected extra leaf to be detached from hardcoded root")
|
||||
}
|
||||
if got.Parent() != dyn {
|
||||
t.Fatalf("grafted leaf parent = %v, want %v", got.Parent(), dyn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesDynamicLeafWinsByDefault(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root", newLeaf("shared", "dynamic"))
|
||||
hc := newGroup("root", newLeaf("shared", "hardcoded"))
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got == nil {
|
||||
t.Fatal("expected shared leaf to remain")
|
||||
}
|
||||
if got.Short != "dynamic" {
|
||||
t.Fatalf("shared.Short = %q, want dynamic", got.Short)
|
||||
}
|
||||
if findChildByName(hc, "shared") == nil {
|
||||
t.Fatal("expected hardcoded shared leaf to remain on donor root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesHigherPriorityHardcodedLeafWins(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcLeaf := newLeaf("shared", "hardcoded")
|
||||
SetOverridePriority(hcLeaf, 100)
|
||||
hc := newGroup("root", hcLeaf)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != hcLeaf {
|
||||
t.Fatalf("expected hardcoded leaf to replace dynamic leaf, got %+v", got)
|
||||
}
|
||||
if findChildByName(hc, "shared") != nil {
|
||||
t.Fatal("expected hardcoded leaf to be detached from donor root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesEqualPriorityKeepsDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
SetOverridePriority(dynLeaf, 100)
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcLeaf := newLeaf("shared", "hardcoded")
|
||||
SetOverridePriority(hcLeaf, 100)
|
||||
hc := newGroup("root", hcLeaf)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != dynLeaf {
|
||||
t.Fatalf("equal priorities should keep dynamic leaf, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesRecurseGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root",
|
||||
newGroup("space",
|
||||
newLeaf("list", "dynamic"),
|
||||
),
|
||||
)
|
||||
hc := newGroup("root",
|
||||
newGroup("space",
|
||||
newLeaf("list", "hardcoded"),
|
||||
newLeaf("create", "hardcoded"),
|
||||
),
|
||||
newLeaf("ping", "hardcoded"),
|
||||
)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
space := findChildByName(dyn, "space")
|
||||
if space == nil {
|
||||
t.Fatal("expected space group")
|
||||
}
|
||||
if list := findChildByName(space, "list"); list == nil || list.Short != "dynamic" {
|
||||
t.Fatalf("space.list should remain dynamic, got %+v", list)
|
||||
}
|
||||
if create := findChildByName(space, "create"); create == nil || create.Short != "hardcoded" {
|
||||
t.Fatalf("space.create should be grafted, got %+v", create)
|
||||
}
|
||||
if ping := findChildByName(dyn, "ping"); ping == nil || ping.Short != "hardcoded" {
|
||||
t.Fatalf("ping should be grafted, got %+v", ping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesHigherPriorityGroupReplacesDynamicGroup(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynGroup := newGroup("export", newLeaf("get", "dynamic"))
|
||||
dyn := newGroup("root", dynGroup)
|
||||
hcGroup := newGroup("export", newLeaf("get", "hardcoded"))
|
||||
SetOverridePriority(hcGroup, 100)
|
||||
hc := newGroup("root", hcGroup)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "export")
|
||||
if got != hcGroup {
|
||||
t.Fatal("expected higher-priority hardcoded group to replace dynamic group")
|
||||
}
|
||||
if get := findChildByName(got, "get"); get == nil || get.Short != "hardcoded" {
|
||||
t.Fatalf("expected hardcoded export.get after replacement, got %+v", get)
|
||||
}
|
||||
if findChildByName(hc, "export") != nil {
|
||||
t.Fatal("expected hardcoded group to be detached from donor root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesShapeMismatchKeepsDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dyn := newGroup("root",
|
||||
newGroup("cmd", newLeaf("sub", "dynamic")),
|
||||
)
|
||||
hc := newGroup("root", newLeaf("cmd", "hardcoded"))
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
cmd := findChildByName(dyn, "cmd")
|
||||
if cmd == nil {
|
||||
t.Fatal("expected dynamic cmd to remain")
|
||||
}
|
||||
if IsLeafCmd(cmd) {
|
||||
t.Fatal("expected dynamic cmd to remain a group")
|
||||
}
|
||||
if findChildByName(cmd, "sub") == nil {
|
||||
t.Fatal("expected cmd.sub to remain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesHigherPriorityGroupReplacesDynamicLeaf(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcGroup := newGroup("shared",
|
||||
newLeaf("list", "hardcoded"),
|
||||
newLeaf("add", "hardcoded"),
|
||||
)
|
||||
SetOverridePriority(hcGroup, 100)
|
||||
hc := newGroup("root", hcGroup)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != hcGroup {
|
||||
t.Fatal("expected higher-priority hardcoded group to replace dynamic leaf")
|
||||
}
|
||||
if findChildByName(got, "list") == nil {
|
||||
t.Fatal("expected hardcoded subtree leaf list to be reachable")
|
||||
}
|
||||
if findChildByName(got, "add") == nil {
|
||||
t.Fatal("expected hardcoded subtree leaf add to be reachable")
|
||||
}
|
||||
if findChildByName(hc, "shared") != nil {
|
||||
t.Fatal("expected hardcoded group to be detached from donor root")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeHardcodedLeavesEqualPriorityShapeMismatchKeepsDynamic(t *testing.T) {
|
||||
t.Parallel()
|
||||
dynLeaf := newLeaf("shared", "dynamic")
|
||||
SetOverridePriority(dynLeaf, 100)
|
||||
dyn := newGroup("root", dynLeaf)
|
||||
hcGroup := newGroup("shared", newLeaf("list", "hardcoded"))
|
||||
SetOverridePriority(hcGroup, 100)
|
||||
hc := newGroup("root", hcGroup)
|
||||
|
||||
MergeHardcodedLeaves(dyn, hc)
|
||||
|
||||
got := findChildByName(dyn, "shared")
|
||||
if got != dynLeaf {
|
||||
t.Fatalf("equal priorities should keep dynamic leaf, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLeafCmd(t *testing.T) {
|
||||
t.Parallel()
|
||||
leaf := newLeaf("x", "")
|
||||
group := newGroup("x", newLeaf("child", ""))
|
||||
if !IsLeafCmd(leaf) {
|
||||
t.Fatal("expected leaf to be leaf")
|
||||
}
|
||||
if IsLeafCmd(group) {
|
||||
t.Fatal("expected group to not be leaf")
|
||||
}
|
||||
if IsLeafCmd(nil) {
|
||||
t.Fatal("expected nil to not be leaf")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,45 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import "github.com/spf13/cobra"
|
||||
|
||||
// SourceAnnotation records where a command tree came from. Edition overlays
|
||||
// use it to distinguish runtime-authored commands from helper fallbacks that
|
||||
// happen to share the same top-level product name.
|
||||
const SourceAnnotation = "dws.source"
|
||||
|
||||
// SourceEnvelope marks a command as authored by the runtime discovery envelope.
|
||||
const SourceEnvelope = "envelope"
|
||||
|
||||
// MarkEnvelopeSource stamps cmd with runtime discovery provenance.
|
||||
func MarkEnvelopeSource(cmd *cobra.Command) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[SourceAnnotation] = SourceEnvelope
|
||||
}
|
||||
|
||||
// IsEnvelopeSourced reports whether cmd was authored by the runtime discovery
|
||||
// envelope.
|
||||
func IsEnvelopeSourced(cmd *cobra.Command) bool {
|
||||
return cmd != nil && cmd.Annotations[SourceAnnotation] == SourceEnvelope
|
||||
}
|
||||
|
||||
// KindAnnotation is the annotation key for marking command kinds.
|
||||
const KindAnnotation = "dws.kind"
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEnvelopeSourceProvenance(t *testing.T) {
|
||||
t.Parallel()
|
||||
if IsEnvelopeSourced(nil) {
|
||||
t.Fatal("nil command should not be envelope sourced")
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{Use: "chat"}
|
||||
if IsEnvelopeSourced(cmd) {
|
||||
t.Fatal("unstamped command should not be envelope sourced")
|
||||
}
|
||||
|
||||
MarkEnvelopeSource(cmd)
|
||||
if !IsEnvelopeSourced(cmd) {
|
||||
t.Fatal("stamped command should be envelope sourced")
|
||||
}
|
||||
if got := cmd.Annotations[SourceAnnotation]; got != SourceEnvelope {
|
||||
t.Fatalf("SourceAnnotation = %q, want %q", got, SourceEnvelope)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkEnvelopeSourceNilDoesNotPanic(t *testing.T) {
|
||||
t.Parallel()
|
||||
MarkEnvelopeSource(nil)
|
||||
}
|
||||
@@ -53,6 +53,10 @@ type ToolCaller interface {
|
||||
Format() string
|
||||
// DryRun returns true when --dry-run is active.
|
||||
DryRun() bool
|
||||
// Fields returns the global --fields output projection ("" if unset).
|
||||
Fields() string
|
||||
// JQ returns the global --jq output filter expression ("" if unset).
|
||||
JQ() string
|
||||
}
|
||||
|
||||
// RuntimeDefaultFn resolves a single runtimeDefault placeholder (e.g.
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ resolve_skill_mode() {
|
||||
print_multi_mode_notice() {
|
||||
say ""
|
||||
say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
|
||||
say " ⚠ multi is not yet stable. 20 product-scoped skills pass dispatch verifier,"
|
||||
say " ⚠ multi is not yet stable. 22 product-scoped skills pass dispatch verifier,"
|
||||
say " but interface, naming and cross-skill references may change in future releases."
|
||||
say " For production / shared environments, use mono mode (--mode mono)."
|
||||
say ""
|
||||
|
||||
@@ -50,6 +50,7 @@ cli_version: ">=1.0.15"
|
||||
## 意图判断决策树
|
||||
|
||||
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp`
|
||||
用户提到"目标管理/Agoal/战略解码/经营合约/计分卡/目标模板/周月报提交统计" → `agoal`
|
||||
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
|
||||
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
|
||||
用户提到"考勤/打卡/排班" → `attendance`
|
||||
|
||||
@@ -249,7 +249,7 @@ query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/
|
||||
- 按文件夹:`folderId:2`(2=收件箱, 1=已发送, 5=草稿, 6=已删除)
|
||||
- 按是否有附件:`hasAttachments:true`
|
||||
- 组合:`from:alice AND subject:周报 AND date>2025-06-01T00:00:00Z`
|
||||
3. 执行搜索:`mail message search --email <邮箱> --query "<KQL表达式>" --size 20`
|
||||
3. 执行搜索:`mail message search --email <邮箱> --query "<KQL表达式>" --limit 20`
|
||||
4. 查看详情(按需):`mail message get --email <邮箱> --id <messageId>`
|
||||
|
||||
### mail-send
|
||||
|
||||
@@ -64,8 +64,8 @@ dws recovery finalize --event-id <event_id> --outcome recovered|failed|handoff -
|
||||
| 标志 | 短名 | 说明 | 默认 |
|
||||
|------|:---:|------|------|
|
||||
| `--format` | `-f` | 输出格式: json / table / raw | json |
|
||||
| `--jq` | | jq 表达式过滤输出 ⚠️**当前为 no-op,不生效**:仍返回完整 JSON,不要依赖它过滤,改在拿到 JSON 后自行处理 | 无 |
|
||||
| `--fields` | | 筛选输出字段 ⚠️**当前为 no-op,不生效**:仍返回全量字段 | 无 |
|
||||
| `--jq` | | jq 表达式过滤输出(如 `.result[].name`)。对产品命令(aitable/chat/mail/... 走 MCP 的)已生效;少数工具命令(auth/config/profile/doctor/schema 等)仍直接编码、暂不过滤 | 无 |
|
||||
| `--fields` | | 筛选输出字段(逗号分隔)。按**顶层信封键**(data/result/success/status…)或**列表元素字段**投影;取 data 内的嵌套字段(如 baseName)请改用 `--jq '.data.baseName'`,`--fields baseName` 会因顶层无此键返回 `{}`。同 `--jq`:产品命令已生效,个别工具命令暂不生效 | 无 |
|
||||
| `--verbose` | `-v` | 详细日志 | false |
|
||||
| `--debug` | | 调试日志 | false |
|
||||
| `--yes` | `-y` | 跳过确认提示 | false |
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
| "改群昵称/设置群昵称/我在群里的名字" | 设置个人群昵称 | `chat group update-nick` | `chat group rename` | update-nick 改自己的群昵称;rename 改群名称 |
|
||||
| "群备注/给群加备注/修改群备注" | 设置群备注 | `chat group update-alias` | `chat group rename` | update-alias 设置仅自己可见的备注;rename 改群名称全员可见 |
|
||||
| "隐藏会话/隐藏群聊/隐藏对话" | 隐藏会话 | `chat hide` | `chat mute` | hide 隐藏会话不显示;mute 是免打扰但仍显示 |
|
||||
| "关闭@所有人通知/屏蔽@all/不接收@所有人" | 关闭 @所有人提醒 | `chat mute-at-all` | `chat mute` | mute-at-all 仅屏蔽 @所有人;mute 是整个会话免打扰 |
|
||||
| "关闭红包通知/屏蔽红包/不接收红包提醒" | 关闭红包提醒 | `chat mute-red-envelope` | `chat mute` | mute-red-envelope 仅屏蔽红包;mute 是整个会话免打扰 |
|
||||
| "关闭@所有人通知/屏蔽@all/不接收@所有人" | 关闭 @所有人提醒 | `chat mute-at-all` | `chat mute` | mute-at-all 仅屏蔽 @所有人;mute 是整个会话免打扰。⚠️ 当前服务端稳定返回 1002「系统繁忙」,命令暂不可用,先告知用户到钉钉客户端设置 |
|
||||
| "关闭红包通知/屏蔽红包/不接收红包提醒" | 关闭红包提醒 | `chat mute-red-envelope` | `chat mute` | mute-red-envelope 仅屏蔽红包;mute 是整个会话免打扰。⚠️ 同 mute-at-all,服务端稳定 1002,暂不可用 |
|
||||
| "解散群/解散群聊" | 解散群聊 | `chat group dismiss` | `chat group quit` | dismiss 是群主解散整个群(不可逆);quit 是当前用户自己退群 |
|
||||
| "新成员看历史/历史消息可见范围" | 设置新成员可见历史消息 | `chat group set-history` | `chat group update-settings` | set-history 控制新成员入群后可见历史消息范围;update-settings 是其他群功能开关 |
|
||||
| "群里有哪些机器人/查看群机器人/列出群机器人" | 查看群内机器人列表 | `chat group bots` | `chat group members` | bots 只列机器人;members 列普通群成员 |
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Agoal(目标管理)
|
||||
|
||||
## 产品说明
|
||||
|
||||
Agoal 是钉钉目标管理工具,支持战略解码、经营合约、计分卡、用户目标、目标模板、周月报六大模块,帮助组织将战略目标从顶层分解到个人并持续跟踪。
|
||||
|
||||
**CLI 前缀**: `dws agoal`
|
||||
|
||||
## 命令总览
|
||||
|
||||
### strategy (战略解码管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `strategy list` | 获取战略解码列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id |
|
||||
| `strategy detail` | 获取战略解码详情 | `--profile-id` | 根据战略解码 id 查询 |
|
||||
| `strategy update` | 更新战略解码 | `--profile-id` `--content` | 覆盖逻辑,必须基于查询返回的老数据修改后再传入;`--content` 为 JSON 数组 |
|
||||
|
||||
`strategy update` 是覆盖式更新:一定要先 `strategy detail` 获取完整数据,在原数据基础上修改后再传入。
|
||||
|
||||
### contract (经营合约管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `contract list` | 获取经营合约列表 | `--scope-type` `--scope-id` | scopeType: DEPT/PERSONAL;scope-id 为 scope-type 对应的部门 id 或用户 id |
|
||||
| `contract fields` | 获取经营合约字段列表 | - | 获取组织下经营合约的字段配置 |
|
||||
| `contract detail` | 获取经营合约详情 | `--contract-id` | 根据合约 id 查询 |
|
||||
| `contract update` | 更新经营合约 | `--contract-id` `--dimensions` | 覆盖逻辑;可选 `--audit-config`、`--objective-template` |
|
||||
|
||||
`contract update` 同样是覆盖式更新:必须基于 `contract detail` 返回的数据修改后再传入。
|
||||
|
||||
### scorecard (计分卡管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"` |
|
||||
| `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 |
|
||||
| `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY |
|
||||
|
||||
### user (用户目标管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `user rules` | 获取用户规则周期列表 | - | 可选 `--user-id`,不传则默认取操作人自己 |
|
||||
| `user objectives` | 查询用户目标列表 | `--user-id` `--rule-id` `--period-ids` | `--period-ids` 为逗号分隔的周期 id 列表 |
|
||||
|
||||
### report (周月报管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `report list-statistics` | 获取周月报数据跟催列表 | - | 返回各规则的人员提交情况统计;可选 `--keyword` |
|
||||
| `report submit-detail` | 获取周月报规则提交详情 | `--template-id` `--submit-state` | submitState: ON_TIME/LATE/NOT_SUBMITTED;可选 `--query-date`、`--page`、`--page-size`、`--keyword` |
|
||||
|
||||
### obj-template (目标模板管理)
|
||||
|
||||
| 命令 | 用途 | 必填参数 | 备注 |
|
||||
|------|------|----------|------|
|
||||
| `obj-template list` | 获取目标模板列表 | - | 可选 `--keyword`、`--page`、`--page-size` |
|
||||
| `obj-template create-or-update` | 新增或更新目标模板 | `--dimensions` | 覆盖逻辑;新增时 `--title` 必填;更新时 `--template-id` 必填 |
|
||||
|
||||
## 意图判断
|
||||
|
||||
用户说"战略解码/战略目标/OGSM":
|
||||
- 查看/列表 → `strategy list`
|
||||
- 详情 → `strategy detail`
|
||||
- 修改/更新 → `strategy update`(先查后改)
|
||||
|
||||
用户说"经营合约/合约/KPI合约":
|
||||
- 查看/列表 → `contract list`
|
||||
- 字段配置 → `contract fields`
|
||||
- 详情 → `contract detail`
|
||||
- 修改/更新 → `contract update`(先查后改)
|
||||
|
||||
用户说"计分卡/scorecard/绩效看板":
|
||||
- 查看详情 → `scorecard detail`
|
||||
- 实体详情 → `scorecard entity-detail`
|
||||
- 修改/更新 → `scorecard update`
|
||||
|
||||
用户说"目标/OKR/我的目标/个人目标":
|
||||
- 规则周期 → `user rules`
|
||||
- 目标列表 → `user objectives`
|
||||
|
||||
用户说"目标模板/模板管理":
|
||||
- 查看模板列表 → `obj-template list`
|
||||
- 新增模板 → `obj-template create-or-update --title "模板名称"`
|
||||
- 更新模板 → `obj-template create-or-update --template-id TPL_ID`
|
||||
|
||||
用户说"周月报/周报统计/提交情况/跟催/迟交/未提交":
|
||||
- 查看提交统计列表 → `report list-statistics`
|
||||
- 查看某规则的提交详情 → `report submit-detail`
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 查看战略解码列表与详情
|
||||
dws agoal strategy list --scope-type DEPT --scope-id DEPT_ID --format json
|
||||
dws agoal strategy detail --profile-id PROFILE_ID --format json
|
||||
|
||||
# 更新战略解码:必须基于 detail 返回内容修改后传入
|
||||
dws agoal strategy update --profile-id PROFILE_ID \
|
||||
--content '[{"id":"entity1","title":{"title":"新目标"},"entityType":"OGSM_OBJECTIVE","status":"NORMAL","executors":["dingId1"]}]' \
|
||||
--format json
|
||||
|
||||
# 查看经营合约列表、字段与详情
|
||||
dws agoal contract list --scope-type PERSONAL --scope-id USER_ID --format json
|
||||
dws agoal contract fields --format json
|
||||
dws agoal contract detail --contract-id CONTRACT_ID --format json
|
||||
|
||||
# 更新经营合约:必须基于 detail 返回内容修改后传入
|
||||
dws agoal contract update --contract-id CONTRACT_ID \
|
||||
--dimensions '[{"id":"dim1","title":"维度名称","objectives":[]}]' \
|
||||
--format json
|
||||
|
||||
# 查看计分卡
|
||||
dws agoal scorecard detail --selected-time "2026-01-01T00:00:00+08:00" --dept-id DEPT_ID --format json
|
||||
dws agoal scorecard entity-detail --sc-id SC_ID --entity-id ENTITY_ID --format json
|
||||
|
||||
# 更新计分卡
|
||||
dws agoal scorecard update --dept-id DEPT_ID --selected-time "2026-01-01T00:00:00+08:00" \
|
||||
--id SC_ID --tracking-period-type MONTHLY \
|
||||
--content '[{"id":"dim1","title":"业绩","items":[{"id":"item1","title":"收入","target":"100"}]}]' \
|
||||
--format json
|
||||
|
||||
# 查询用户目标
|
||||
dws agoal user rules --user-id USER_ID --format json
|
||||
dws agoal user objectives --user-id USER_ID --rule-id RULE_ID --period-ids "period1,period2" --format json
|
||||
|
||||
# 周月报提交统计与详情
|
||||
dws agoal report list-statistics --format json
|
||||
dws agoal report list-statistics --keyword "周报规则" --format json
|
||||
dws agoal report submit-detail --template-id TPL_ID --submit-state ON_TIME --format json
|
||||
dws agoal report submit-detail --template-id TPL_ID --submit-state LATE --query-date "2026-06-18T00:00:00+08:00" --page 1 --page-size 20 --format json
|
||||
|
||||
# 目标模板
|
||||
dws agoal obj-template list --format json
|
||||
dws agoal obj-template list --keyword "业绩" --format json
|
||||
dws agoal obj-template create-or-update --title "业绩模板" --objective-weight --dimension-weight --dimensions '[...]' --format json
|
||||
dws agoal obj-template create-or-update --template-id TPL_ID --title "业绩模板" --dimensions '[...]' --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `strategy list` | `profileId` | `strategy detail` / `strategy update` 的 `--profile-id` |
|
||||
| `strategy detail` | 完整实体数据 | `strategy update` 的 `--content`(基于此修改) |
|
||||
| `contract list` | `contractId` | `contract detail` / `contract update` 的 `--contract-id` |
|
||||
| `contract detail` | 完整维度数据 | `contract update` 的 `--dimensions`(基于此修改) |
|
||||
| `scorecard detail` | `scId`、`entityId` | `scorecard entity-detail` / `scorecard update` 的 `--id` |
|
||||
| `user rules` | `ruleId`、`periodIds` | `user objectives` 的 `--rule-id` `--period-ids` |
|
||||
| `report list-statistics` | `templateId` | `report submit-detail` 的 `--template-id` |
|
||||
| `obj-template list` | `templateId` | `obj-template create-or-update` 的 `--template-id`(更新时) |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 所有 update / create-or-update 命令都是覆盖逻辑:必须先用对应 detail/list 查询完整数据,在原数据基础上修改后再传入。
|
||||
- 所有命令支持可选参数 `--request-id`。
|
||||
- `--scope-type` 仅支持 `DEPT` 和 `PERSONAL`。
|
||||
- `--selected-time` 接受 ISO-8601 字符串,如 `"2026-01-01T00:00:00+08:00"`。
|
||||
- `--period-ids` 为逗号分隔字符串,如 `"period1,period2"`。
|
||||
- `report submit-detail` 的 `--query-date` 接受 ISO-8601 字符串;不传则默认当天。
|
||||
@@ -98,7 +98,7 @@ Flags:
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `aisearch person` | `userId`(用户ID)、`title`(姓名) | 展示搜索结果、后续操作(发消息/建待办等) |
|
||||
| `aisearch person` | `userId`(用户ID,= `meta.staffId`/`meta.jobNumber`)、`author`(真实姓名,= `meta.name`);`title` 是花名/显示名,不一定等于姓名 | 展示搜索结果、后续操作(发消息/建待办等) |
|
||||
|
||||
## 重名消歧
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
|
||||
| `dashboard delete` | 删除仪表盘 | `--base-id` `--dashboard-id` `--yes` | — |
|
||||
| `dashboard config-example` | 查看仪表盘配置模板 | 无 | 创建前先调此命令了解 config 结构 |
|
||||
| `dashboard share get` | 获取仪表盘分享配置 | `--base-id` `--dashboard-id` | 可能 404,见上方要点 |
|
||||
| `dashboard share update` | 更新仪表盘分享配置 | `--base-id` `--dashboard-id` `--enabled` | `--enabled true` 开启(配 `--share-type PUBLIC/ORG`)/ `--enabled false` 关闭;ORG 回显 `shareType="[1]"` |
|
||||
| `dashboard share update` | 更新仪表盘分享配置 | `--base-id` `--dashboard-id` `--enabled` | `--enabled true` 开启,`--share-type` 用 `ORG`(ORG 回显 `shareType="[1]"`);部分组织禁用了 `PUBLIC` 公开分享;若报 `Illegal argument`,改用 `ORG`(组织内分享)。`--enabled false` 关闭 |
|
||||
|
||||
## chart 子命令
|
||||
|
||||
@@ -43,7 +43,7 @@ dws aitable chart get --base-id <BASE_ID> --dashboard-id <DASHBOARD_ID> --chart-
|
||||
| `chart delete` | 删除图表 | `--base-id` `--dashboard-id` `--chart-id` `--yes` |
|
||||
| `chart widgets-example` | 查看图表 widgets 配置模板 | 无 |
|
||||
| `chart share get` | 获取图表分享配置 | `--base-id` `--dashboard-id` `--chart-id` |
|
||||
| `chart share update` | 更新图表分享配置(`--enabled true/false`,开启配 `--share-type PUBLIC/ORG`) | `--base-id` `--dashboard-id` `--chart-id` `--enabled` |
|
||||
| `chart share update` | 更新图表分享配置(`--enabled true/false`,开启配 `--share-type`,用 `ORG`;部分组织禁用了 `PUBLIC` 公开分享;报 `Illegal argument` 时改用 `ORG`) | `--base-id` `--dashboard-id` `--chart-id` `--enabled` |
|
||||
|
||||
> `chart create` 的 `--layout` 是**必填**(12 列网格布局,如 `{"x":0,"y":0,"w":6,"h":4}`);不传本地校验直接拒。`chart update` 的 `--config` 也**必填**——即便只想改 layout,也要带完整 config,否则服务端拒绝。
|
||||
|
||||
|
||||
@@ -89,6 +89,19 @@ Flags:
|
||||
|
||||
返回每条记录含:用户 ID、实际打卡时间、打卡地址、打卡经纬度、打卡类型(OnDuty/OffDuty)、定位方式(Map/Wifi/etc)。时间跨度不超过 1 个月。
|
||||
|
||||
### 查询个人某日考勤详情
|
||||
```
|
||||
Usage:
|
||||
dws attendance record get [flags]
|
||||
Example:
|
||||
dws attendance record get --user 011769261608 --date 2026-03-08
|
||||
Flags:
|
||||
--user string 钉钉用户 ID (必填,别名 --users 亦可)
|
||||
--date string 查询日期, 格式 YYYY-MM-DD (必填)
|
||||
```
|
||||
|
||||
查询单个用户在**某一天**的考勤详情(区别于 `check record`/`check result` 的多人时间段批量查询)。返回 `result` 对象含 `isHasSchedule`(当日是否有排班)、`isRest`(是否休息日)、`isUnSigned`(是否未打卡)、`recordList`(打卡明细)、`approveList`(当日审批单)、`workOvertime`、`workTimeDesc` 等字段。`--user` 只接受**单个** userId;查 userId 用 `dws contact user search --query "姓名"`。
|
||||
|
||||
### 查询审批单(补卡/加班/请假/出差外出)
|
||||
```
|
||||
Usage:
|
||||
@@ -1161,13 +1174,13 @@ dws attendance report query-leave --users userId1,userId2 \
|
||||
dws attendance vacation types --format json
|
||||
|
||||
# 查看指定员工假期余额
|
||||
dws attendance vacation balance --users userId1,userId2 --format json
|
||||
dws attendance vacation balance --users userId1,userId2 --leave-code <假期类型code> --format json
|
||||
|
||||
# 查看指定员工某类假期余额
|
||||
dws attendance vacation balance --users userId1 --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --format json
|
||||
|
||||
# 查看指定员工假期余额变更记录
|
||||
dws attendance vacation records --user USER_ID --start 2026-04-01 --end 2026-04-22 --format json
|
||||
dws attendance vacation records --user USER_ID --leave-code <假期类型code> --start 2026-04-01 --end 2026-04-22 --format json
|
||||
|
||||
# 更新假期规则名称
|
||||
dws attendance vacation update-type --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 \
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- **二级子命令(必选其一)**:`event`(日程)、`attendee`(参会人)、`room`(会议室)、`busy`(闲忙)、`attachment`(日程附件)、`book`(我能看哪些日历本)、`acl`(我的日历共享给了谁)。`dws calendar` 后**必须**紧跟上述之一;**禁止**只执行 `dws calendar`(无子命令)。
|
||||
- **个人日程 / 给自己留时间块 / 专注时段**:统一走 **`dws calendar event create`**。当前**没有**单独的 `personal schedule create` / `calendar create` 命令。
|
||||
- **查日程列表**:`dws calendar event list --start "<ISO-8601>" --end "<ISO-8601>" --format json`,或优先使用脚本 `python scripts/calendar_today_agenda.py [today|tomorrow|week]`(见文末「自动化脚本」)。
|
||||
- **查用户日历本列表**:`dws calendar book list`(返回主日历 `id == "primary"` 等)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。
|
||||
- **查用户日历本列表**:`dws calendar book list`(返回字段为 `calendarId`,主日历 `calendarId == "primary"` 等;注意无 `id` 字段,按 `.id` 提取会取空)。**重要**可以查询他人共享给自己的日历本,根据日历本id可以进一步查询对方的日程信息。
|
||||
- **CLI 不存在**独立的 `dws calendar list`;若误跑无子命令的 `dws calendar`,会打印整段 Usage,**切勿**将该段 help 当作工具结果再次塞进对话(会急剧增加 token 与首字延迟)。
|
||||
- **必须**遵循指令说明进行调用。**绝对禁止**使用虚构指令,使用虚构参数。
|
||||
|
||||
@@ -618,7 +618,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
|
||||
| `event respond` | 响应结果 | — |
|
||||
| `room search` | `rooms[].roomId` | room add 的 --rooms 或 event create 的 --rooms |
|
||||
| `room list-groups` | `groups[].groupId` | room search 的 --group-id |
|
||||
| `book list` | `id`(如 `primary`) | event list/get 的 --calendar-id, book get/update 的 --id |
|
||||
| `book list` | `calendarId`(如 `primary`) | event list/get 的 --calendar-id, book get/update 的 --id |
|
||||
| `book get` | 日历详细信息 | — |
|
||||
| `book search` | 匹配的日历列表 | book get/update 的 --id |
|
||||
| `acl list` | `aclId` | acl delete 的 --acl-id |
|
||||
@@ -640,7 +640,7 @@ dws calendar event list --start "2026-03-10T14:00:00+08:00" --end "2026-03-10T15
|
||||
- **搜房无结果**:在符合早停/用户限定范围内,`room search`(含按分组逐组查)全部返回空或无空闲 → 应**直接向用户报错/说明失败**并结束订房;**禁止**假设 roomId、禁止无合法 `roomId` 时调用 `room add` / `event create --rooms` 试探、禁止用 `event get` 等绕路推断 roomId
|
||||
- **评测 / 自动化断言**:凡涉及 `room add` / `event create --rooms` 的流程,`--rooms` 只能填上游 `room search`(或等价接口)返回 JSON 中的 **`rooms[].roomId`**;不得以会议室展示名、楼层文案或用户口语当作 `roomId`
|
||||
- **附件**:`attachment add` 仅负责挂载,**不上传**文件;fileId 必须先通过钉盘流程取得;`--files` 多附件用 `<fileId>:<name>` 元素逗号分隔
|
||||
- **日历本**:`book list` 返回的 `id` 才是合法 `calendarId`;如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历
|
||||
- **日历本**:`book list` 返回的 `calendarId` 字段才是合法日历 id(无 `id` 字段);如无明确说明,`event list` / `event get` 都不要带 `--calendar-id`,让接口默认走 primary 主日历
|
||||
- **已弃用入参**:`--max-results`(event list)在新 MCP schema 中被移除;CLI 仍接受但**不会**透传到 MCP;模型生成命令时**禁止**继续使用
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
@@ -139,6 +139,26 @@ Flags:
|
||||
--expires-seconds int64 链接有效期(秒),0 表示永久有效,不传使用服务端默认值
|
||||
```
|
||||
|
||||
#### 分享群聊链接到会话 — 将指定群的邀请链接分享到另一个会话或单聊用户
|
||||
|
||||
`--target` 和 `--receiver` 二选一:`--target` 指定目标会话,`--receiver` 指定单聊用户。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws chat group share-invite [flags]
|
||||
Example:
|
||||
dws chat group share-invite --source <被分享群openConversationId> --target <目标会话openConversationId>
|
||||
dws chat group share-invite --source <被分享群openConversationId> --receiver <接收者openDingTalkId>
|
||||
dws chat group share-invite --source <openConversationId> --target <openConversationId> --expires-seconds 86400
|
||||
# 查询群 ID: dws chat search --query "群名"
|
||||
Flags:
|
||||
--source string 被分享群的 openConversationId (必填)
|
||||
--target string 接收分享消息的会话 openConversationId(与 --receiver 二选一)
|
||||
--receiver string 接收分享消息的单聊用户 openDingTalkId(与 --target 二选一)
|
||||
--expires-seconds int64 链接有效期(秒),0 表示永久有效,不传使用服务端默认值
|
||||
--uuid string 消息幂等键(可选)
|
||||
```
|
||||
|
||||
#### 退出群聊 — 当前用户退出指定群聊
|
||||
```
|
||||
Usage:
|
||||
@@ -272,6 +292,65 @@ Flags:
|
||||
- 不传 --role 时返回群主 + 管理员的所有群
|
||||
```
|
||||
|
||||
### group notice (群公告管理)
|
||||
|
||||
#### 发布群公告 — 在指定群聊中发布群公告,支持 Markdown、定时发布
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws chat group notice create [flags]
|
||||
Example:
|
||||
dws chat group notice create --group <openConversationId> --content "今晚 22 点系统维护,请提前保存工作内容"
|
||||
dws chat group notice create --group <openConversationId> --content "# 重要通知\n请大家查收" --sticky --send-ding
|
||||
dws chat group notice create --group <openConversationId> --content "明早九点例会" --run-at "2026-07-03T09:00:00+08:00"
|
||||
Flags:
|
||||
--group string 群聊 openConversationId (必填)
|
||||
--content string 公告正文,Markdown 格式 (必填)
|
||||
--sticky 是否吊顶置顶(默认 false)
|
||||
--send-ding 是否发 DING 提醒(默认 false)
|
||||
--run-at string 定时发布时间 ISO-8601(传入则定时发布)
|
||||
```
|
||||
|
||||
#### 修改群公告 — 整体替换指定群公告的内容
|
||||
```
|
||||
Usage:
|
||||
dws chat group notice edit [flags]
|
||||
Example:
|
||||
dws chat group notice edit --group <openConversationId> --notice-id <dataId> --content "更新后的公告内容"
|
||||
Flags:
|
||||
--group string 群聊 openConversationId (必填)
|
||||
--notice-id string 群公告 dataId (必填)
|
||||
--content string 公告新正文,Markdown 格式 (必填)
|
||||
--sticky 是否吊顶置顶
|
||||
--send-ding 是否发 DING 提醒
|
||||
```
|
||||
|
||||
#### 查看群公告详情
|
||||
```
|
||||
Usage:
|
||||
dws chat group notice get [flags]
|
||||
Example:
|
||||
dws chat group notice get --group <openConversationId> --notice-id <dataId>
|
||||
Flags:
|
||||
--group string 群聊 openConversationId (必填)
|
||||
--notice-id string 群公告 dataId (必填)
|
||||
```
|
||||
|
||||
#### 查看群公告列表
|
||||
```
|
||||
Usage:
|
||||
dws chat group notice list [flags]
|
||||
Example:
|
||||
dws chat group notice list --group <openConversationId>
|
||||
dws chat group notice list --group <openConversationId> --limit 20 --cursor <nextPageCursor>
|
||||
dws chat group notice list --group <openConversationId> --scheduled
|
||||
Flags:
|
||||
--group string 群聊 openConversationId (必填)
|
||||
--limit int 每页返回数量(默认 10,最大 100)
|
||||
--cursor string 分页游标
|
||||
--scheduled 是否查询定时公告列表
|
||||
```
|
||||
|
||||
### group-role (群身份管理)
|
||||
|
||||
#### 查看群身份列表 — 拉取指定群聊的自定义群身份列表
|
||||
@@ -334,7 +413,7 @@ Flags:
|
||||
|
||||
#### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份)
|
||||
|
||||
> ⚠️ 当前不可用:真机服务端对该命令恒返回 1002(系统繁忙),历史回归至今未修,属服务端问题。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。
|
||||
> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。
|
||||
```
|
||||
Usage:
|
||||
dws chat group-role remove-user [flags]
|
||||
@@ -729,7 +808,7 @@ Example:
|
||||
dws chat message list-by-sender --sender-open-dingtalk-id <openDingTalkId> --start "2026-03-10T00:00:00+08:00" --end "2026-03-11T00:00:00+08:00" --limit 50 --cursor <nextCursor>
|
||||
Flags:
|
||||
--sender-user-id string 发送者 userId(与 --sender-open-dingtalk-id 二选一)
|
||||
--sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一,适用于无法获取 userId 的场景)
|
||||
--sender-open-dingtalk-id string 发送者 openDingTalkId(与 --sender-user-id 二选一);该路径当前返回 result:{}、success:null(疑似服务端问题),优先用 --sender-user-id
|
||||
--start string 开始时间,ISO-8601 格式 (必填)
|
||||
--end string 结束时间,ISO-8601 格式 (必填)
|
||||
--limit int 每页返回数量(默认 50)
|
||||
@@ -737,8 +816,8 @@ Flags:
|
||||
|
||||
注意:
|
||||
- --sender-user-id 和 --sender-open-dingtalk-id 二者互斥,必须且只能指定其一:
|
||||
- --sender-user-id 传 userId(企业内部应用常用)
|
||||
- --sender-open-dingtalk-id 传 openDingTalkId(三方应用或跨组织场景常用,无法获取 userId 时使用)
|
||||
- --sender-user-id 传 userId(企业内部应用常用,当前唯一可用路径)
|
||||
- --sender-open-dingtalk-id 传 openDingTalkId(当前返回 result:{}、success:null(疑似服务端问题);即使只有 openDingTalkId,也建议先用 contact 换取 userId 后走 --sender-user-id)
|
||||
- openDingTalkId 获取方式见下方「openDingTalkId 获取方式」小节
|
||||
- 不需要指定单聊/群聊,返回结果自带会话类型标识
|
||||
- 时间支持多种 ISO-8601 格式,如 "2026-03-10T00:00:00+08:00"、"2026-03-10 14:00:00"、"2026-03-10" 等
|
||||
@@ -994,6 +1073,20 @@ Flags:
|
||||
- 如果已有合适的表情,无需创建新的
|
||||
```
|
||||
|
||||
#### 批量拉取消息的表情回复和文字回复
|
||||
|
||||
根据消息 ID 列表批量查询消息的表情回复和文字回复信息。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws chat message list-emotion-replies [flags]
|
||||
Example:
|
||||
dws chat message list-emotion-replies --msg-ids msgId1,msgId2,msgId3
|
||||
# 消息 ID 可通过 dws chat message list 获取
|
||||
Flags:
|
||||
--msg-ids string 消息 ID 列表,逗号分隔 (必填)
|
||||
```
|
||||
|
||||
### list-top-conversations (置顶会话)
|
||||
|
||||
#### 拉取置顶会话列表
|
||||
@@ -1025,20 +1118,20 @@ Flags:
|
||||
Usage:
|
||||
dws chat message download-media [flags]
|
||||
Example:
|
||||
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/
|
||||
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./photo.jpg
|
||||
dws chat message download-media --type mediaId --resource-id <mediaId> --message-id <openMessageId> --open-conversation-id <openConversationId> --output ./downloads/photo.jpg
|
||||
Flags:
|
||||
--type string 资源类型: mediaId (必填)
|
||||
--resource-id string 资源 ID,mediaId 类型时为消息中的 mediaId 值 (必填)
|
||||
--message-id string 消息 openMessageId (必填)
|
||||
--open-conversation-id string 会话 openConversationId (必填)
|
||||
--output string 本地保存路径,文件或目录 (必填)
|
||||
--output string 本地保存路径 (必填),建议传完整文件路径(含文件名)
|
||||
|
||||
注意:
|
||||
- resource-id 从 `dws chat message list` 返回的消息内容中获取 mediaId
|
||||
- message-id 从 `dws chat message list` 返回的 openMessageId
|
||||
- open-conversation-id 从 `dws chat search` 获取 openConversationId
|
||||
- --output 如果指定目录,文件名会从下载 URL 中自动推断
|
||||
- --output 建议传完整文件路径(含扩展名,如 ./photo.jpg);只传目录(如 ./downloads/)在旧版本会下载失败,请显式带上文件名
|
||||
```
|
||||
|
||||
### search-common (搜索共同群)
|
||||
@@ -1309,6 +1402,21 @@ Flags:
|
||||
--title string 分组名称 (必填)
|
||||
```
|
||||
|
||||
#### 创建智能会话分组 — 可指定群名称关键词和群内成员作为匹配规则
|
||||
```
|
||||
Usage:
|
||||
dws chat category create-smart [flags]
|
||||
Example:
|
||||
dws chat category create-smart --name "工作群"
|
||||
dws chat category create-smart --name "项目组" --keywords "项目,开发"
|
||||
dws chat category create-smart --name "团队群" --members openDingTalkId1,openDingTalkId2
|
||||
dws chat category create-smart --name "重点群" --keywords "重点" --members openDingTalkId1
|
||||
Flags:
|
||||
--name string 分组名称 (必填)
|
||||
--keywords string 群名称关键词列表,逗号分隔(可选)
|
||||
--members string 群内成员 openDingTalkId 列表,逗号分隔(可选)
|
||||
```
|
||||
|
||||
#### 删除会话分组
|
||||
```
|
||||
Usage:
|
||||
@@ -1358,6 +1466,23 @@ Flags:
|
||||
--category-ids string 目标分组 ID 列表,逗号分隔 (必填)
|
||||
```
|
||||
|
||||
### text (文本内容处理)
|
||||
|
||||
#### 翻译文本内容
|
||||
```
|
||||
Usage:
|
||||
dws chat text translate [flags]
|
||||
Example:
|
||||
dws chat text translate --query "你好世界" --to en_US
|
||||
dws chat text translate --query "Hello World" --to zh_CN
|
||||
dws chat text translate --query "Bonjour" --to ja_JP
|
||||
Flags:
|
||||
--query string 待翻译的文本内容 (必填)
|
||||
--to string 目标语言代码 (必填)
|
||||
```
|
||||
|
||||
支持的目标语言代码:`en_US`, `zh_CN`, `zh_TW`, `zh_HK`, `ja_JP`, `ko_KR`, `vi_VN`, `th_TH`, `id_ID`, `ms_MY`, `es_419`, `fr_FR`, `pt_BR`, `tr_TR`, `ru_RU`, `de_DE`, `hi_IN`, `hu_HU`, `pl_PL`, `sv_SE`, `fi_FI`, `cs_CZ`, `ar_SA`, `tl_PH`, `he_IL`, `nl_NL`, `lo_LA`, `it_IT`。
|
||||
|
||||
### mute (会话免打扰)
|
||||
|
||||
#### 会话消息免打扰 — 开启或关闭会话消息免打扰(支持单聊和群聊)
|
||||
@@ -1405,7 +1530,7 @@ Flags:
|
||||
|
||||
#### 关闭/开启 @所有人消息提醒 — 关闭或开启会话中 @所有人的消息通知
|
||||
|
||||
> ⚠️ 当前不可用:真机服务端对该命令恒返回 1002(系统繁忙),历史回归至今未修,属服务端问题。命令本身参数合法,但调用不会生效。
|
||||
> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。
|
||||
```
|
||||
Usage:
|
||||
dws chat mute-at-all [flags]
|
||||
@@ -1428,7 +1553,7 @@ Flags:
|
||||
|
||||
#### 关闭/开启红包消息提醒 — 关闭或开启会话中的红包消息通知
|
||||
|
||||
> ⚠️ 当前不可用:真机服务端对该命令恒返回 1002(系统繁忙),历史回归至今未修,属服务端问题。命令本身参数合法,但调用不会生效。
|
||||
> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。命令本身参数合法,但调用不会生效。
|
||||
```
|
||||
Usage:
|
||||
dws chat mute-red-envelope [flags]
|
||||
@@ -1518,7 +1643,7 @@ Flags:
|
||||
注意:
|
||||
- 返回结果包含单聊和群聊,不区分会话类型
|
||||
- --limit 范围 1-100,默认 100,上限 100;传入 >100 会报错拒绝,不会静默截断
|
||||
- 分页当前不可用:真机 hasMore 恒为 false、nextCursor 恒为 null,本命令最多返回 100 条会话,无法用 --cursor 继续翻页取更多
|
||||
- 分页当前不可用:当前 hasMore 恒为 false、nextCursor 恒为 null,本命令最多返回 100 条会话,无法用 --cursor 继续翻页取更多
|
||||
- 与 list-top-conversations 的区别: 本命令返回全部会话(单聊+群聊),list-top-conversations 仅返回置顶会话
|
||||
```
|
||||
|
||||
@@ -1608,7 +1733,7 @@ Flags:
|
||||
|
||||
#### 审批入群验证 — 通过、删除单个审核
|
||||
|
||||
真机当前仅 AuditApprove(通过)和 AuditDelete(删除)两个动作可用。
|
||||
当前仅 AuditApprove(通过)和 AuditDelete(删除)两个动作可用。
|
||||
|
||||
```
|
||||
Usage:
|
||||
@@ -1626,7 +1751,7 @@ Flags:
|
||||
--description string 审批说明(可选)
|
||||
|
||||
注意:
|
||||
- status 真机仅支持 AuditApprove(通过) 和 AuditDelete(删除);AuditIgnore(忽略)、AuditRefuse(拒绝)、AuditBlock(拒绝且拉黑) 会被服务端拒绝报 unsupported audit status,属服务端限制
|
||||
- status 仅支持 AuditApprove(通过) 和 AuditDelete(删除);AuditIgnore(忽略)、AuditRefuse(拒绝)、AuditBlock(拒绝且拉黑) 会被服务端拒绝报 unsupported audit status,属服务端限制
|
||||
- record-id、applicant、inviter 可通过 dws chat group list-join-validations 查询获得
|
||||
```
|
||||
|
||||
@@ -1679,6 +1804,7 @@ Flags:
|
||||
用户说"查看会话分组/自定义分组" → `chat category list`
|
||||
用户说"某个分组下的会话/分组会话列表" → `chat category list-conversations`
|
||||
用户说"新建会话分组/创建分组" → `chat category create`
|
||||
用户说"创建智能分组/新建智能会话分组" → `chat category create-smart`
|
||||
用户说"删除会话分组" → `chat category delete`
|
||||
用户说"重命名分组/改分组名" → `chat category rename`
|
||||
用户说"把会话加入分组/会话归类到分组" → `chat category add-conv`
|
||||
@@ -1693,7 +1819,10 @@ Flags:
|
||||
用户说"查询某人的群身份/某人在群里有什么身份" → `chat group-role query-user`
|
||||
用户说"转让群主/换群主/群主转让" → `chat group transfer-owner`
|
||||
用户说"群邀请链接/入群链接/加群链接" → `chat group invite-url`
|
||||
用户说"分享群链接/把群分享给某人/群链接发到某群" → `chat group share-invite`
|
||||
用户说"群公告/发布公告/修改公告/查看公告/定时公告" → `chat group notice create/edit/get/list`
|
||||
用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids`
|
||||
用户说"批量查消息回复/表情回复/文字回复/消息回应列表" → `chat message list-emotion-replies`
|
||||
用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji`
|
||||
用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji`
|
||||
用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion`
|
||||
@@ -1732,6 +1861,7 @@ Flags:
|
||||
用户说"全员禁言/群禁言/解除禁言" → `chat group-mute`
|
||||
用户说"禁言某人/指定成员禁言/解除某人禁言" → `chat group-mute-member`
|
||||
用户说"设管理员/取消管理员/设置群管理员" → `chat group set-admin`
|
||||
用户说"翻译文本/把这段话翻译成英文/翻译成中文" → `chat text translate`
|
||||
|
||||
关键区分:
|
||||
- `chat search` — 搜**群/会话名**返回 `openConversationId`,**不**搜消息内容;要搜消息内容请用 `chat message search-advanced`(首选)/ `chat message search` / `list-by-sender` / `list-all`,**勿混淆**
|
||||
@@ -1756,10 +1886,12 @@ Flags:
|
||||
- `chat message search-advanced` — 多维度搜索消息(支持关键词、发送者、@我、@指定人、多个会话等维度组合,与 `search` 的区别:`search` 仅支持关键词且必填,`search-advanced` 所有参数均可选)
|
||||
- `chat message list-by-ids` — 根据消息 ID 批量查询消息(最多 50 条)
|
||||
- `chat message add-emoji` / `remove-emoji` — 对消息添加/移除 emoji 表情回应
|
||||
- `chat message list-emotion-replies` — 批量拉取消息的表情回复和文字回复
|
||||
- `chat message add-text-emotion` / `remove-text-emotion` — 对消息添加/移除文字表情回应
|
||||
- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用
|
||||
- `chat category list` — 获取用户自定义会话分组列表
|
||||
- `chat category list-conversations` — 拉取指定分组下的会话列表
|
||||
- `chat category create-smart` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则)
|
||||
- `chat mute` — 开启/关闭会话消息免打扰(默认开启,--off 关闭)
|
||||
- `chat hide` — 在会话列表中隐藏会话(支持单聊/群聊,收到新消息时重新出现)
|
||||
- `chat mute-at-all` — 关闭/开启 @所有人消息提醒(默认关闭,--off 恢复)
|
||||
@@ -1773,6 +1905,8 @@ Flags:
|
||||
- `chat file upload` — 已下线;不要调用 `chat/upload_conversation_file_by_url`,本地文件发送改用 `chat message send --msg-type file --file-path`
|
||||
- `chat group transfer-owner` — 转让群主
|
||||
- `chat group invite-url` — 获取群邀请链接
|
||||
- `chat group share-invite` — 分享群聊链接到会话
|
||||
- `chat group notice create/edit/get/list` — 群公告发布、修改、详情、列表
|
||||
- `chat message reply` — 引用回复消息(在群聊中引用某条消息并回复文字)
|
||||
- `chat message forward` — 转发单条消息(将一条消息从源会话转发到目标会话)
|
||||
- `chat set-top` — 设置/取消会话置顶(默认置顶,--off 取消)
|
||||
|
||||
@@ -213,7 +213,7 @@ Example:
|
||||
Flags:
|
||||
无
|
||||
Notes:
|
||||
- 无需参数,返回当前企业全部角色列表(labelId、labelName等)
|
||||
- 无需参数,返回当前企业全部角色,按 `groupName` 分组,每组 `labels[]` 里每个角色是 `{labelId, name}`(字段名是 `name`,不是 `labelName`)
|
||||
- 用于不知道准确角色名称时先浏览全部角色
|
||||
- 典型场景:用户说“企业所有主管/查所有管理员/财务人员有哪些”→ 先 label list 浏览全部角色,匹配目标角色后 label list-members 获取成员
|
||||
```
|
||||
@@ -360,7 +360,7 @@ dws contact user dismission search --depts 123456,789012 --hide-retirement=false
|
||||
| `user get-self/search` | `orgAuthEmail` | mail message send 的 --to/--cc (跨产品) |
|
||||
| `user get-self/search` | `userId` | profile get 的 --staff-id |
|
||||
| `user profile fields` | `fieldCode` | profile get 的 --fields |
|
||||
| `label list` | `labelId` / `labelName` | `label get --names` 或 `label list-members --id` |
|
||||
| `label list` | `labelId` / `name` | `label get --names` 或 `label list-members --id` |
|
||||
| `label get` | `labelId` | `label list-members` 的 --id |
|
||||
| `dept search/list-children` | `deptId` | dept get-info/list-children/list-members 的 --dept/--depts |
|
||||
| `dept search/list-children` | `deptId` | dismission search 的 --depts |
|
||||
|
||||
@@ -118,11 +118,8 @@ dws dev app robot disable --unified-app-id <unifiedAppId> --format json
|
||||
## dev app credentials — 凭证
|
||||
|
||||
```bash
|
||||
# 查询 clientId/clientSecret
|
||||
# 查询 clientId/clientSecret(credentials 下只有 get 子命令)
|
||||
dws dev app credentials get --unified-app-id <unifiedAppId> --format json
|
||||
|
||||
# 重置凭证(旧 secret 立即失效)
|
||||
dws dev app credentials reset --unified-app-id <unifiedAppId> --yes --format json
|
||||
```
|
||||
|
||||
---
|
||||
@@ -147,8 +144,8 @@ dws dev connect --channel opencode \
|
||||
dws dev connect --daemon \
|
||||
--robot-client-id <clientId> --robot-client-secret <clientSecret>
|
||||
|
||||
# 查看/停止后台连接器
|
||||
dws dev connect status --format json
|
||||
# 查看/停止后台连接器(status/list 忽略全局 --format,出 JSON 用专属 --json)
|
||||
dws dev connect status --json
|
||||
dws dev connect stop
|
||||
```
|
||||
|
||||
@@ -208,7 +205,7 @@ dws dev app version status --unified-app-id <unifiedAppId> --version-id
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **`clientSecret` 只在 `robot result` 返回一次**,务必立即保存;遗失需走 `credentials reset` 重置
|
||||
- **`clientSecret` 只在 `robot result` 返回一次**,务必立即保存;dws 只能 `credentials get` 读取,不支持重置,遗失后到开放平台控制台重置密钥
|
||||
- 改配置后机器人不自动生效,需走 `version create → publish` 才上线
|
||||
- `hermes`/`openclaw` 渠道走官方建联,`dws dev connect` 不代建机器人,会输出指引后退出
|
||||
- 应用名在企业内唯一;`app list/get` 用 `--app-key` 过滤但不能定位单应用,定位单应用须用 `--unified-app-id`
|
||||
|
||||
@@ -27,7 +27,7 @@ dws doc permission --help
|
||||
|
||||
### 搜索 / 遍历文件(已迁移,不再是 doc 子命令)
|
||||
|
||||
> **弃用提示**:`dws doc search` 和 `dws doc list` 已迁移到 `drive` / `wiki`。真机上这两条虽仍能跑,但每次都会打印弃用警告,请改用下面的命令,命令详情见 [drive.md](./drive.md)。
|
||||
> **弃用提示**:`dws doc search` 和 `dws doc list` 已迁移到 `drive` / `wiki`。这两条虽仍能跑,但每次都会打印弃用警告,请改用下面的命令,命令详情见 [drive.md](./drive.md)。
|
||||
|
||||
| 旧命令(弃用) | 改用 | 场景 |
|
||||
|--------------|------|------|
|
||||
@@ -83,7 +83,7 @@ Flags:
|
||||
|
||||
### 创建其他类型文件 (表格/脑图/白板/多维表/画板/文件夹)
|
||||
|
||||
> **弃用提示**:`dws doc file create --type` 已弃用,真机每次执行都会打印 `deprecated, use 'dws wiki node create --type <type>'`。创建各类型文件节点改用 `dws wiki node create`(与 intent-guide 一致);创建普通文件夹用 `dws drive mkdir`。
|
||||
> **弃用提示**:`dws doc file create --type` 已弃用,执行时会打印 `deprecated, use 'dws wiki node create --type <type>'`。创建各类型文件节点改用 `dws wiki node create`(与 intent-guide 一致);创建普通文件夹用 `dws drive mkdir`。
|
||||
|
||||
```
|
||||
Usage:
|
||||
@@ -131,9 +131,30 @@ Flags:
|
||||
--convert 是否转换为钉钉在线文档
|
||||
```
|
||||
|
||||
### 导入本地文件为在线文档
|
||||
|
||||
详见 [doc/doc-import.md](./doc/doc-import.md)。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws doc import [flags]
|
||||
dws doc import get [flags]
|
||||
Example:
|
||||
dws doc import --file ./report.docx
|
||||
dws doc import --file ./notes.md --folder <FOLDER_ID>
|
||||
dws doc import --file ./data.xlsx --workspace <WS_ID>
|
||||
dws doc import get --task-id <TASK_ID>
|
||||
Flags:
|
||||
--file string 本地文件路径 (必填)
|
||||
--folder string 目标文件夹 ID 或 URL
|
||||
--workspace string 目标知识库 ID 或 URL
|
||||
--name string 导入后文档名称 (默认取文件名)
|
||||
--task-id string 导入任务 ID (import get 必填)
|
||||
```
|
||||
|
||||
### 下载文件到本地(已迁移到 drive)
|
||||
|
||||
> **弃用提示**:`dws doc download` 已迁移到 `dws drive download`(真机执行 `doc download` 会打印弃用警告)。下载已有文件(PDF/图片/附件等非在线文档)改用:
|
||||
> **弃用提示**:`dws doc download` 已迁移到 `dws drive download`(执行 `doc download` 会打印弃用警告)。下载已有文件(PDF/图片/附件等非在线文档)改用:
|
||||
|
||||
```
|
||||
dws drive download --node <NODE_ID> --output ~/downloads/
|
||||
@@ -261,11 +282,11 @@ Usage:
|
||||
Example:
|
||||
dws doc comment list --node <DOC_ID>
|
||||
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
|
||||
dws doc comment list --node <DOC_ID> --page-size 20 --next-token <TOKEN>
|
||||
dws doc comment list --node <DOC_ID> --limit 20 --cursor <TOKEN>
|
||||
Flags:
|
||||
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
|
||||
--page-size int 每页返回的评论数量,默认 50,最大 50
|
||||
--next-token string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
|
||||
--limit int 每页返回的评论数量,默认 50,最大 50
|
||||
--cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
|
||||
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
|
||||
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
|
||||
```
|
||||
@@ -618,6 +639,12 @@ Flags:
|
||||
- 上传 → `upload`(需本地文件路径)
|
||||
- 上传并转换 → `upload --convert`
|
||||
|
||||
用户说"导入文件/导入为在线文档/导入 Word/导入 Excel/导入 xmind/导入 Markdown/把本地文件转在线文档":
|
||||
- 导入并转换为在线文档 → `doc import --file <本地路径>`
|
||||
- 支持 docx/doc/xlsx/xls/md/txt/xmind/mark,文件大小不超过 20MB
|
||||
- 如果用户指定知识库或文件夹,补充 `--workspace` 或 `--folder`
|
||||
- 不要把本地文件内容先读出来再 `doc create/update`;应直接使用 `doc import`
|
||||
|
||||
用户说"下载/导出/下载到本地/导出文档/导出为Word/导出为docx/把文档导出来":
|
||||
- **必须先判断目标文件类型**,再决定走 `doc export` 还是 `drive download`:
|
||||
- 在线文档(alidocs/adoc)→ **`doc export`**(内容级命令,格式转换后导出为 docx,未迁移)
|
||||
@@ -768,6 +795,20 @@ dws drive download --node <NODE_ID> --output ~/downloads/
|
||||
# 如果是在线文档 (ALIDOC),用 doc export:
|
||||
dws doc export --node <NODE_ID> --output ~/downloads/
|
||||
|
||||
# ── 工作流 5a: 导入本地文件为在线文档 ──
|
||||
|
||||
# 导入到默认位置
|
||||
dws doc import --file ./report.docx --format json
|
||||
|
||||
# 导入到指定文件夹
|
||||
dws doc import --file ./notes.md --folder <DOC_FOLDER_NODE_ID> --format json
|
||||
|
||||
# 导入到知识库
|
||||
dws doc import --file ./data.xlsx --workspace <WS_ID> --format json
|
||||
|
||||
# 如果导入命令超时或中断,可用 import get 手动查询任务状态:
|
||||
# dws doc import get --task-id <TASK_ID>
|
||||
|
||||
# ── 工作流 6: 上传附件并插入文档 ──
|
||||
|
||||
# media insert 自动完成三步流程:
|
||||
@@ -882,6 +923,7 @@ dws doc export --node <DOC_ID_OR_URL> --output ./exported.docx
|
||||
| `drive list`(原 `doc list`,已弃用) | `nodes[].nodeId` / folder 类型 `nodeId` | read / info / update / block 的 --node;folder 用作 `--folder` |
|
||||
| `drive search`(原 `doc search`,已弃用) | 文档 `nodeId` / URL / `createTime` / `creatorUid` | read / info / update 的 --node;创建时间与创建者信息 |
|
||||
| `create` | `nodeId` | update / block 操作的 --node |
|
||||
| `import` | `nodeId` / `documentUrl` / `documentName` / `documentType`;中断时提取 `taskId` | 后续 read / info / sheet 操作;中断后用 `doc import get --task-id` |
|
||||
| `drive mkdir`(原 `doc folder create`,已弃用) | `nodeId` | create 的 --folder |
|
||||
| `block list` | `blockId` | block insert 的 --ref-block, block update/delete 的 --block-id |
|
||||
| `comment list` | `commentList[].commentKey` | comment reply 的 --comment-key |
|
||||
|
||||
@@ -15,11 +15,11 @@ Usage:
|
||||
Example:
|
||||
dws doc comment list --node <DOC_ID>
|
||||
dws doc comment list --node <DOC_ID> --type inline --resolve-status unresolved
|
||||
dws doc comment list --node <DOC_ID> --page-size 20 --next-token <TOKEN>
|
||||
dws doc comment list --node <DOC_ID> --limit 20 --cursor <TOKEN>
|
||||
Flags:
|
||||
--node string 目标文档的标识,支持传入 URL 或 ID (必填)
|
||||
--page-size int 每页返回的评论数量
|
||||
--next-token string 下一页 token,从上一次请求的返回结果中获取 (首次请求不传)
|
||||
--limit int 每页返回的评论数量,默认 50,最大 50
|
||||
--cursor string 分页游标,从上一次请求的返回结果中获取 (首次请求不传)
|
||||
--type string 按评论类型过滤: global (全文评论) / inline (划词评论)
|
||||
--resolve-status string 按解决状态过滤: resolved (已解决) / unresolved (未解决)
|
||||
```
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **前置条件(MUST READ):** 执行本命令前,必须先用 Read 工具读取以下文件:
|
||||
> 1. [`../doc.md`](../doc.md) — 命令路由 + 场景索引 + 意图判断 + 工作流
|
||||
|
||||
> **弃用提示(文件管理命令正在迁移到 drive / wiki)**:本文所列 `doc` 文件管理命令在真机上虽仍能跑,但执行时会打印弃用警告,请优先改用 `drive` / `wiki` 对应命令:
|
||||
> **弃用提示(文件管理命令正在迁移到 drive / wiki)**:本文所列 `doc` 文件管理命令虽仍能跑,但执行时会打印弃用警告,请优先改用 `drive` / `wiki` 对应命令:
|
||||
> - `doc download` → **`dws drive download`**(下载已有文件;在线文档导出 docx 仍走 `doc export`)
|
||||
> - `doc folder create` → **`dws drive mkdir`**(个人空间/钉盘)或 **`dws wiki node create --workspace <id> --type folder`**(知识库内)
|
||||
> - `doc upload` / `doc copy` / `doc move` / `doc rename` / `doc delete` 也在同一迁移方向(→ `drive`),命令用法以 `drive` 为准。
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# 本地文件导入为在线文档 (doc import)
|
||||
|
||||
## 使用场景
|
||||
|
||||
用户说"导入 Word/Excel/Markdown/xmind 到钉钉文档"、"把本地文件转成在线文档"、"导入到知识库/文件夹"时,使用 `dws doc import`。
|
||||
|
||||
不要先读取文件内容再调用 `doc create` 或 `doc update`。`doc import` 会按文件格式走导入任务,保留更完整的原始结构。
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
dws doc import --file ./report.docx --format json
|
||||
dws doc import --file ./notes.md --folder <FOLDER_ID> --format json
|
||||
dws doc import --file ./data.xlsx --workspace <WORKSPACE_ID> --format json
|
||||
dws doc import --file ./draft.md --name "项目周报" --format json
|
||||
```
|
||||
|
||||
```bash
|
||||
dws doc import get --task-id <TASK_ID> --format json
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--file` | 本地文件路径,必填 |
|
||||
| `--folder` | 目标文件夹 ID 或 URL,可选 |
|
||||
| `--workspace` | 目标知识库 ID 或 URL,可选 |
|
||||
| `--name` / `-n` | 导入后的文档名称;不传时使用文件名 |
|
||||
| `--task-id` | `import get` 查询导入任务时必填 |
|
||||
|
||||
支持格式:docx、doc、xlsx、xls、md、txt、xmind、mark。文件大小上限 20MB。
|
||||
|
||||
## 工作流
|
||||
|
||||
1. 确认本地文件存在且格式受支持。
|
||||
2. 如果用户指定目标知识库或文件夹,传 `--workspace` 或 `--folder`;未指定时导入到默认位置。
|
||||
3. 执行 `dws doc import --file ... --format json`。
|
||||
4. 正常情况下 CLI 会自动提交、上传并轮询导入任务。
|
||||
5. 如果命令超时或中断,从输出中提取 `taskId`,再执行 `dws doc import get --task-id <TASK_ID> --format json`。
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `doc import` | `nodeId` / `documentUrl` / `documentName` / `documentType` | 后续 `doc read` / `doc info` / `sheet` 操作 |
|
||||
| `doc import` 中断 | `taskId` | `doc import get --task-id` 查询任务状态 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- `--folder` 和 `--workspace` 都是目标位置参数;用户明确指定文件夹时优先使用 `--folder`。
|
||||
- 导入 Excel 后通常得到在线表格,后续数据读取和编辑走 `dws sheet ...`。
|
||||
- 导入 Markdown 或 Word 后通常得到在线文档,后续内容读取和编辑走 `dws doc ...`。
|
||||
@@ -1,6 +1,6 @@
|
||||
# doc list(已弃用 → drive list / wiki node list)
|
||||
|
||||
> **弃用提示**:`dws doc list` 已迁移,不再是 `doc` 子命令。真机上它虽仍能跑,但每次都会打印弃用警告:
|
||||
> **弃用提示**:`dws doc list` 已迁移,不再是 `doc` 子命令。它虽仍能跑,但每次都会打印弃用警告:
|
||||
> `'dws doc list' is deprecated, use 'dws drive list --workspace <workspaceId>' or 'dws wiki node list --workspace <workspaceId>'`。
|
||||
> 遍历文件夹/知识库请改用下面的命令,命令详情见 [`../drive.md`](../drive.md)。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# doc search(已弃用 → drive search / wiki node search)
|
||||
|
||||
> **弃用提示**:`dws doc search` 已迁移,不再是 `doc` 子命令。真机上它虽仍能跑,但每次都会打印弃用警告:
|
||||
> **弃用提示**:`dws doc search` 已迁移,不再是 `doc` 子命令。它虽仍能跑,但每次都会打印弃用警告:
|
||||
> `'dws doc search' is deprecated, use 'dws drive search' or 'dws wiki node search --workspace <id>'`。
|
||||
> 搜索文档/文件请改用下面的命令,命令详情见 [`../drive.md`](../drive.md)。
|
||||
|
||||
|
||||
@@ -514,7 +514,7 @@ dws drive copy --node <源文件dentryUuid> --folder <目标文件夹fileId> --f
|
||||
- 不传 `--space-id` 时默认使用「我的文件」空间
|
||||
- 不传 `--folder` 时默认操作空间根目录
|
||||
- `--folder` 只能使用父文件夹的 `dentryUuid`。不要把 `drive info` 返回的数字型 `dentryId` 当作父目录
|
||||
- **`--limit` 最大值为 50**,禁止传入超过 50 的值(如 `--limit 100`)。用户要求超过 50 条时,应使用 `--limit 50` 配合 `--cursor` 分页查询
|
||||
- **`--limit` 有效上限为 50**:CLI 不做本地校验,传 `--limit 100` 不会报错,但服务端每页最多返回 50 条,超出部分无效。用户要求超过 50 条时,应使用 `--limit 50` 配合 `--cursor` 分页查询
|
||||
- `--order-by` 支持: `createTime`、`modifyTime`、`name`
|
||||
- **上传文件首选 `dws drive upload` 命令**;手动三步(`upload-info` → HTTP PUT → `commit`)仅用于自定义流式上传等特殊场景。手动三步时 HTTP PUT 必须把 upload-info 返回的 `headers` 全部回传,`Content-Type` 通常要留空;PUT 返回 200 后才能调 `commit`;`uploadId` 有过期时间,过期需重新 `upload-info`;`--folder` 在 upload-info / commit 中要保持一致
|
||||
- `--file-name` 必须包含扩展名(如 `report.pdf`)
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
| 命令 | 功能简述 |
|
||||
|------|----------|
|
||||
| `dws mail mailbox list` | 查询**当前用户自己**的可用邮箱列表 |
|
||||
| `dws mail mailbox profile` | 获取用户邮箱详细信息(容量、别名等) |
|
||||
| `dws mail message list` | 列出指定文件夹中的邮件(默认收件箱) |
|
||||
| `dws mail message search` | 搜索邮件(KQL 语法,按主题/发件人/日期等) |
|
||||
| `dws mail message get` | 查看邮件完整内容(含正文) |
|
||||
@@ -15,7 +16,10 @@
|
||||
| `dws mail message batch-move` | 批量移动邮件到指定文件夹 |
|
||||
| `dws mail message batch-delete` | 批量删除邮件 |
|
||||
| `dws mail message batch-update` | 批量修改邮件状态(标记已读/未读/添加标签/移除标签) |
|
||||
| `dws mail message batch-get` | 批量获取邮件详情(最多 20 封) |
|
||||
| `dws mail message verify` | 根据 internetMessageId 查询邮件发送状态 |
|
||||
| `dws mail sent-message recall` | 撤回已发送的邮件(仅支持同组织内未读邮件) |
|
||||
| `dws mail sent-message recall-detail` | 查询邮件撤回进度 |
|
||||
| `dws mail draft create` | 创建草稿(保留在草稿箱,不发送) |
|
||||
| `dws mail draft update` | 更新草稿内容(保留在草稿箱,不发送) |
|
||||
| `dws mail draft send` | 发送草稿箱中已有的草稿 |
|
||||
@@ -46,6 +50,13 @@
|
||||
| `dws mail contact update` | 更新个人邮件联系人信息 |
|
||||
| `dws mail contact batch-delete` | 批量删除个人邮件联系人 |
|
||||
| `dws mail auto-reply get` | 获取用户的自动回复配置 |
|
||||
| `dws mail auto-reply update` | 更新/设置用户的自动回复配置 |
|
||||
| `dws mail allow-list list` | 列出个人收信白名单 |
|
||||
| `dws mail allow-list add` | 添加个人收信白名单 |
|
||||
| `dws mail allow-list remove` | 移除个人收信白名单 |
|
||||
| `dws mail block-list list` | 列出个人收信黑名单 |
|
||||
| `dws mail block-list add` | 添加个人收信黑名单 |
|
||||
| `dws mail block-list remove` | 移除个人收信黑名单 |
|
||||
| `dws mail rule list` | 列出个人收信规则 |
|
||||
| `dws mail rule create` | 创建个人收信规则 |
|
||||
| `dws mail rule update` | 更新个人收信规则 |
|
||||
@@ -93,6 +104,27 @@ Example:
|
||||
|------|------|------|
|
||||
| `emailAccounts` | `List[]` | 邮箱列表,每条包含 `email`(邮箱地址)、`orgName`(所属企业,个人邮箱为 null)、`type`(`ORG`=企业邮箱 / `PERSONAL`=个人邮箱) |
|
||||
|
||||
### 获取用户邮箱信息
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws mail mailbox profile [flags]
|
||||
Example:
|
||||
dws mail mailbox profile --email user@company.com
|
||||
Flags:
|
||||
--email string 用户的邮箱地址 (必填)
|
||||
```
|
||||
|
||||
**返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `email` | string | 邮箱地址 |
|
||||
| `emailAliases` | List[] | 邮件地址别名列表 |
|
||||
| `name` / `nickname` / `displayName` | string | 用户名、昵称、显示名 |
|
||||
| `mboxSize` / `mboxSizeUsed` | int64 | 邮箱容量与已用容量(字节) |
|
||||
| `createdTime` / `modifiedTime` | string | 创建时间与更新时间 |
|
||||
|
||||
### 查找他人邮箱地址(通讯录查人)
|
||||
|
||||
> **这不是 `mailbox list`。** 当需要获取**某人**的邮箱地址时,必须走以下三路并发查询,取最先返回有效邮箱的结果。禁止臆测邮箱地址。
|
||||
@@ -214,7 +246,24 @@ Flags:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `message` | `object` | 邮件完整信息,包含主题、发件人、收件人、正文、附件等 |
|
||||
| `message` | `object` | 邮件信息,含 subject/from/toRecipients/ccRecipients/markdownBody/conversationId/receivedDateTime/size 等 |
|
||||
|
||||
> **注意:** 即使邮件确有附件,`message get` 返回的 `message` 对象也**不含** `attachments`/`isRead`/`tags` 字段(实测 keys 仅上表所列这些)。要取附件请另用 `dws mail attachment list`。
|
||||
|
||||
### 批量获取邮件详情
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws mail message batch-get [flags]
|
||||
Example:
|
||||
dws mail message batch-get --email user@company.com --ids <id1>,<id2>
|
||||
dws mail message batch-get --email user@company.com --ids <id1>,<id2>,<id3>
|
||||
Flags:
|
||||
--email string 邮件所属邮箱地址 (必填)
|
||||
--ids string 要获取的邮件 ID 列表,逗号分隔,最多 20 个 (必填)
|
||||
```
|
||||
|
||||
单次最多获取 20 封邮件。CLI 会逐个调用 `get_email_by_message_id`,并以 `messages` 数组聚合返回。
|
||||
|
||||
### 发送邮件
|
||||
```
|
||||
@@ -933,6 +982,53 @@ Flags:
|
||||
| `failed` | 发送失败 |
|
||||
| `unknown` | 未知状态 |
|
||||
|
||||
### [危险] 撤回已发送的邮件
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws mail sent-message recall [flags]
|
||||
Example:
|
||||
dws mail sent-message recall --email user@company.com --id <mailId> --subject "邮件主题" --yes
|
||||
Flags:
|
||||
--email string 发件人邮箱地址 (必填)
|
||||
--id string 要撤回的邮件 ID (必填)
|
||||
--subject string 邮件主题 (必填)
|
||||
--yes 跳过确认提示,直接执行 (可选)
|
||||
```
|
||||
|
||||
> CAUTION: 此命令会撤回已发送邮件。仅支持撤回同组织内未读邮件,执行前必须确认目标邮件、主题和影响范围。
|
||||
|
||||
**返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | string | 撤回任务 ID,可用于 `recall-detail` 查询进度 |
|
||||
| `success` | boolean | 接口调用是否成功 |
|
||||
| `errorCode` / `errorMsg` | string | 错误码和错误信息(仅失败时存在) |
|
||||
|
||||
### 查询邮件撤回进度
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws mail sent-message recall-detail [flags]
|
||||
Example:
|
||||
dws mail sent-message recall-detail --email user@company.com --id <recallTaskId>
|
||||
Flags:
|
||||
--email string 用户的邮箱地址 (必填)
|
||||
--id string 撤回任务 ID (必填),由 recall 命令返回
|
||||
```
|
||||
|
||||
根据撤回任务 ID 查询邮件撤回进度。任务状态包括 `UNINITED` / `SUBMITTED` / `RUNNING` / `FINISHED` / `CANCELED` / `FAILED`。
|
||||
|
||||
**返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `id` | string | 撤回任务 ID |
|
||||
| `status` | string | 任务状态 |
|
||||
| `totalCount` / `succeededCount` / `failedCount` | int | 总数、成功数、失败数 |
|
||||
| `details` | List[] | 每封邮件的撤回结果 |
|
||||
|
||||
### 创建草稿
|
||||
```
|
||||
Usage:
|
||||
@@ -1218,6 +1314,61 @@ Flags:
|
||||
| `scope` | string | 回复范围: "contact"(仅联系人) 或 "all"(所有人) |
|
||||
| `content` | string | 自动回复内容 |
|
||||
|
||||
### 更新自动回复配置
|
||||
|
||||
更新或设置用户的邮件自动回复配置。建议先通过 `auto-reply get` 获取当前配置,再传入需要修改的字段值。
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws mail auto-reply update [flags]
|
||||
Example:
|
||||
dws mail auto-reply update --email user@company.com --enabled true \
|
||||
--start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" \
|
||||
--scope all --content "出差中,请稍后联系"
|
||||
dws mail auto-reply update --email user@company.com --enabled false \
|
||||
--start "2026/07/01 09:00:00 +0800" --end "2026/07/07 18:00:00 +0800" \
|
||||
--scope all --content "已关闭自动回复"
|
||||
Flags:
|
||||
--email string 用户的邮箱地址 (必填)
|
||||
--enabled string 是否启用自动回复: true/false (必填)
|
||||
--start string 自动回复开始时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填)
|
||||
--end string 自动回复结束时间,格式: YYYY/MM/DD HH:MM:SS +ZZZZ (必填)
|
||||
--scope string 回复范围: contact(仅联系人)/all(所有人) (必填)
|
||||
--content string 自动回复内容 (必填)
|
||||
```
|
||||
|
||||
### 个人收信白名单管理
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws mail allow-list list [flags]
|
||||
dws mail allow-list add [flags]
|
||||
dws mail allow-list remove [flags]
|
||||
Examples:
|
||||
dws mail allow-list list --email user@company.com
|
||||
dws mail allow-list add --email user@company.com --entries a@b.com,@example.com
|
||||
dws mail allow-list remove --email user@company.com --entries a@b.com,@example.com
|
||||
Flags:
|
||||
--email string 用户的邮箱地址 (必填)
|
||||
--entries string 逗号分隔的地址列表,支持邮件地址或 @domain.com 域名(add/remove 必填)
|
||||
```
|
||||
|
||||
### 个人收信黑名单管理
|
||||
|
||||
```
|
||||
Usage:
|
||||
dws mail block-list list [flags]
|
||||
dws mail block-list add [flags]
|
||||
dws mail block-list remove [flags]
|
||||
Examples:
|
||||
dws mail block-list list --email user@company.com
|
||||
dws mail block-list add --email user@company.com --entries spam@bad.com,@junk.com
|
||||
dws mail block-list remove --email user@company.com --entries spam@bad.com,@junk.com
|
||||
Flags:
|
||||
--email string 用户的邮箱地址 (必填)
|
||||
--entries string 逗号分隔的地址列表,支持邮件地址或 @domain.com 域名(add/remove 必填)
|
||||
```
|
||||
|
||||
### 收信规则管理
|
||||
|
||||
#### 列出收信规则
|
||||
@@ -1364,6 +1515,8 @@ Flags:
|
||||
--actions string 规则动作 JSON 数组 (必填)
|
||||
```
|
||||
|
||||
> **⚠️ 实测:** 尽管 `--help` 把 `--conditions` 标为(可选),服务端实际要求它**非空**——省略或传空会返回 `209 SYS_UNKNOWN_ERROR(1001)` 失败,帮助文本承诺的"命中所有邮件"无条件匹配**不可用**。create 请始终传入有效条件。
|
||||
|
||||
**返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
@@ -1375,11 +1528,11 @@ Flags:
|
||||
|
||||
#### 更新收信规则
|
||||
|
||||
更新已有的收信规则。**除 `--conditions` 外所有参数均为必填**。
|
||||
更新已有的收信规则。**所有参数(含 `--conditions`)均须传入有效值**——见下方警告。
|
||||
|
||||
> **建议工作流:** 先通过 `dws mail rule list` 获取当前规则的完整配置,再传入需要修改的字段值。
|
||||
>
|
||||
> `--conditions` 为空或不传表示命中所有邮件(无条件匹配)。`--actions` 格式同 create 命令。
|
||||
> **⚠️ 实测:** `--help` 称 `--conditions` 为空即命中所有邮件,但该无条件匹配**不可用**——省略或传空会返回 `209 SYS_UNKNOWN_ERROR(1001)` 失败。update 请始终传入有效条件。`--actions` 格式同 create 命令。
|
||||
|
||||
```
|
||||
Usage:
|
||||
@@ -1399,6 +1552,8 @@ Flags:
|
||||
--actions string 规则动作 JSON 数组 (必填)
|
||||
```
|
||||
|
||||
> **⚠️ 实测:** 上方 `--conditions` 的"(可选,为空表示命中所有邮件)"是 `--help` 原文,但服务端实际要求它**非空**——省略或传空会返回 `209 SYS_UNKNOWN_ERROR(1001)` 失败。update 须传入有效条件。
|
||||
|
||||
**返回字段:**
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
@@ -1467,9 +1622,11 @@ Flags:
|
||||
## 意图判断
|
||||
|
||||
用户说"我的邮箱/邮箱地址" → `mailbox list`(**仅限查询自己的邮箱,不能查他人**)
|
||||
用户说"邮箱详情/邮箱容量/邮箱别名/邮箱 profile" → `mailbox profile --email <邮箱>`
|
||||
用户说"获取/查找/得到 某人的邮箱地址" → **不是 `mailbox list`**,走三路并发查询流程(见「查找他人邮箱地址」章节)
|
||||
用户说"找邮件/搜邮件/查邮件" → `message search`
|
||||
用户说"看邮件/打开邮件/邮件内容" → 先 `message search` 获取 messageId,再 `message get`
|
||||
用户说"批量查看邮件详情/批量打开这些邮件/按多个邮件ID取详情" → `message batch-get --ids <id1,id2,...>`(单次最多 20 个)
|
||||
用户说"发邮件/写邮件" → 先 `mailbox list` 获取发件地址,再 `message send`
|
||||
用户说“给(某人名字)发邮件” / “查询某人发给我的邮件” / “查询发给某人的邮件” / 任何涉及按人名查找邮箱的场景 →
|
||||
**第一步**:并发同时发起以下三路查询,取最先返回有效邮箱的结果;若三路均无有效邮箱,ask_human 请用户提供,禁止臆测:
|
||||
@@ -1496,6 +1653,11 @@ Flags:
|
||||
用户说"删除会话/把会话放入已删除/批量删除会话" → 单条用 `thread trash`,多条用 `thread batch-trash`;传入的是会话 ID,不是邮件 ID
|
||||
用户说"批量标记邮件已读/未读/给邮件加标签/移除邮件标签" → `message batch-update --action markRead/markUnread/addTags/removeTags`(操作对象是邮件 ID;会话级操作用 `thread update/batch-update`)
|
||||
用户说"邮件发出去了吗/查邮件发送状态/确认邮件是否发送成功/邮件投递结果" → 用发送类命令返回的 `internetMessageId`,调用 `message verify` 查询 `sendStatus`
|
||||
用户说"撤回邮件/撤回已发送邮件" → `sent-message recall --email <邮箱> --id <mailId> --subject "<主题>" --yes`(危险操作,先确认)
|
||||
用户说"查询邮件撤回进度/撤回任务状态" → `sent-message recall-detail --email <邮箱> --id <recallTaskId>`
|
||||
用户说"设置/更新/关闭自动回复" → `auto-reply update`(所有字段必填,先 `auto-reply get` 获取当前配置)
|
||||
用户说"收信白名单/添加白名单/移除白名单" → `allow-list list/add/remove`
|
||||
用户说"收信黑名单/添加黑名单/移除黑名单" → `block-list list/add/remove`
|
||||
用户说"查看会话/获取会话/看这封邮件的会话" → 先 `message search` 或 `message get` 获取邮件中的 `conversationId`,再 `thread get`
|
||||
用户说"搜索/查找/联系 邮箱用户/联系人/某人的邮箱地址" → `user search`(搜索通讯录人员,不是搜邮件内容)
|
||||
用户说"发送草稿/把草稿发出去/发这封草稿" → 先 `message search --query "folderId:5"` 找到草稿 messageId,再 `draft send`
|
||||
@@ -1612,7 +1774,9 @@ dws mail thread get --email user@company.com --id <conversationId> --select mess
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|------|
|
||||
| `mailbox list` | 邮箱地址 | message search/get/send/thread get 的 --email/--from |
|
||||
| `mailbox profile` | `emailAliases` / `mboxSize` / `mboxSizeUsed` | 展示邮箱别名与容量信息 |
|
||||
| `message search` | `messageId` | message get 的 --id |
|
||||
| `message search` | `messageId` | message batch-get 的 --ids |
|
||||
| `message search` | `conversationId` | thread get 的 --id |
|
||||
| `message search` | `messageId` | attachment list 的 --id |
|
||||
| `attachment list` | `attachments[].id` / `attachments[].name` | attachment download 的 --attachment-id / --name |
|
||||
@@ -1627,6 +1791,8 @@ dws mail thread get --email user@company.com --id <conversationId> --select mess
|
||||
| `aisearch person` → `contact user get` / `contact user search` / `mail user search` | 用户邮箱 (orgAuthEmail / email) | message send 的 --to/--cc(三路并发,取先到结果) |
|
||||
| `user search` | 用户邮箱 (email) | message send 的 --to/--cc |
|
||||
| `message send` / `draft send` / `message reply` / `message reply-all` / `message forward` | `internetMessageId` | `message verify` 的 --internet-message-id |
|
||||
| `sent-message recall` | `id` | sent-message recall-detail 的 --id |
|
||||
| `auto-reply get` | `enabled` / `startTime` / `endTime` / `scope` / `content` | auto-reply update 的当前配置基线 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ dws minutes
|
||||
│ ├── shared [--query] [--start] [--end] [--max] [--next-token] # 仅查询他人共享给我的听记(不含我自己创建的)
|
||||
│ └── all [--query] [--start] [--end] [--max] [--next-token] # 查询我可访问的所有听记(= mine ∪ shared,覆盖面最广,默认 scope)
|
||||
├── get <subcommand> # 获取听记详情(get 后必须跟子命令名)
|
||||
│ ├── info --id <uuid> # 获取听记基础信息(标题、时长、参与人、状态等)
|
||||
│ ├── info --id <uuid> # 获取听记基础信息(标题、时长、开始时间、taskUuid、访问链接;不含参会人)
|
||||
│ ├── summary --id <uuid> # 获取听记 AI 摘要(结构化纪要)
|
||||
│ ├── transcription --id <uuid> # 获取听记语音转写原文(逐字稿,支持分页)
|
||||
│ ├── keywords --id <uuid> # 获取听记关键词
|
||||
@@ -618,7 +618,7 @@ AI 基于已拉取的转写数据,**本地完成**聚类与摘要(无需新
|
||||
> **铁律 2:严禁用 AI 摘要里的"参与人/参会人"字段判断某人是否参会**
|
||||
> - AI 摘要中的"参与人"字段往往**只截取最显著的 1-2 个名字**(通常是创建人或发言最多的人),**不是完整参会人列表**
|
||||
> - "AI 摘要参与人只有故愚 → 灵麦没参会" 是典型的错误推理链
|
||||
> - 如果一定要列出参会人,应使用 `dws minutes get info` / `get batch` 返回的 `participants` 字段,而不是摘要文本里的描述
|
||||
> - 如果一定要列出参会人,只能从 `get transcription` 的转写发言人(`speakerName`/`speakerId`)汇总——`get info` / `get batch` **均不返回** `participants` 结构化字段(get info 只返回 duration/startTime/taskUuid/title/url,get batch 只返回 creatorNick/creatorUnionId/status 等),别指望从它们拿参会名单
|
||||
>
|
||||
> **铁律 3:通讯录查询是 Step 4 的强制起点,不依赖 Step 3 是否成功**
|
||||
> - Step 2 一旦返回"未命中真名标注",**Step 3 与 Step 4 ① 必须并发触发**,不要等 Step 3 失败再补查
|
||||
@@ -667,7 +667,7 @@ Step 7: 引导用户替换发言人(调用 speaker replace 写回听记)
|
||||
> - **未命中真名标注 ≠ 目标人物没参会**——绝大多数听记的发言人都是匿名编号,"未命中"是**默认场景**,恰恰是发言人识别功能要解决的问题
|
||||
> - **不要在转写文本里 grep 目标人名**作为存在性判断——花名/真名通常不会出现在 TA 自己的发言里(参见铁律 1)
|
||||
> - **不要把 `dws minutes get summary` 摘要文本里写到的"参与人"当作完整参会列表**——AI 摘要里的"参与人"字段只截取最显著的 1-2 个名字,不是完整名册(参见铁律 2)
|
||||
> - 唯一能下"目标人物没参会"结论的场景是:`get info` / `get batch` 返回的结构化 `participants` 字段里**完整列出参会人且不含目标**,且 `dws contact user search` 也搜不到该人 → 才允许告知用户"该人不在参会列表"
|
||||
> - 听记各接口都拿不到权威的完整参会名册:`get info` / `get batch` **均不返回** `participants` 字段,转写发言人列表又常是匿名编号。因此**几乎无法**仅凭听记数据下"目标人物没参会"的结论;只有当转写发言人已全部实名标注且不含目标、且 `dws contact user search` 也搜不到该人时,才可谨慎告知"未在可见发言人中找到该人"
|
||||
|
||||
##### Step 3: 转写原文内推断(优先)
|
||||
|
||||
@@ -813,7 +813,7 @@ Flags:
|
||||
--ids string 听记 taskUuid 列表,逗号分隔 (必填)
|
||||
```
|
||||
|
||||
返回字段: 听记标题、时长、参与人列表、创建时间、taskUuid、听记状态
|
||||
返回字段: `result.minutesDetails` 数组,每项含 `title`(标题)、`durationMicros`(时长, 微秒)、`startTime`(开始时间, 毫秒)、`taskUuid`、`status`(状态)、`creatorNick`(创建人昵称)、`creatorUnionId`、`size`、`bizType`、`isDeleted`。**不返回参会人列表**。
|
||||
|
||||
### 修改听记标题
|
||||
```
|
||||
@@ -2299,7 +2299,7 @@ Step 2 **[禁止]** 看到全是匿名编号 → 没有继续走 Step 3-4,反
|
||||
|
||||
1. **"在转写里搜不到目标人名"绝不构成"没参会"的证据**:花名/真名通常不出现在 TA 自己的发言里,这是默认场景而非例外。99% 的听记发言人都是匿名编号——这正是发言人识别功能要解决的问题。
|
||||
|
||||
2. **AI 摘要的"参与人"字段是低保真信号,不能作为参会判断依据**:`get summary` 返回的是 AI 生成的自然语言摘要,里面提到的"参与人"通常只是最显著的 1-2 人;要拿完整参会列表,应使用 `get info` / `get batch` 返回的结构化 `participants` 字段。
|
||||
2. **AI 摘要的"参与人"字段是低保真信号,不能作为参会判断依据**:`get summary` 返回的是 AI 生成的自然语言摘要,里面提到的"参与人"通常只是最显著的 1-2 人。而 `get info` / `get batch` **均不返回**结构化 `participants` 字段,无法从中拿完整参会列表;能拿到的只有 `get transcription` 的转写发言人(常为匿名编号)。
|
||||
|
||||
3. **`dws contact user search` 是 Step 4 ① 的必跑项,单次调用就能突破死局**:本案例的整个失败链路只要有 1 次通讯录查询就能立即扭转——拿到"木兰=王佳明,产品设计部"后,Step 5 的角色匹配就有了锚点,再也不会得出"没参会"的错误结论。
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ Flags:
|
||||
用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject`
|
||||
用户说"撤回审批/取消审批" → `approval revoke`
|
||||
用户说"审批记录/操作历史" → `approval records`
|
||||
用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms 或 detail 获取)
|
||||
用户说"我发起的审批" → `approval list-initiated`(需 --process-code,可从 list-forms / search-forms / detail 获取)
|
||||
用户说"有哪些审批表单/可见表单" → `approval list-forms`
|
||||
用户说"我有哪些待审的任务" → `approval tasks`
|
||||
用户说"我发起的审批单" -> `approval list-submitted`
|
||||
@@ -351,7 +351,7 @@ dws oa approval records --instance-id <processInstanceId> --format json
|
||||
# 7. 获取可见审批表单(得到 processCode)
|
||||
dws oa approval list-forms --cursor 0 --limit 100 --format json
|
||||
|
||||
# 8. 查看自己发起的审批列表(--process-code 来自 list-forms 或 detail)
|
||||
# 8. 查看自己发起的审批列表(--process-code 来自 list-forms / search-forms / detail)
|
||||
dws oa approval list-initiated --process-code <code> \
|
||||
--start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" \
|
||||
--cursor 0 --limit 20 --format json
|
||||
@@ -383,6 +383,7 @@ dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "userId1
|
||||
| `tasks` | `taskId` | approve / reject / redirect-task 的 --task-id |
|
||||
| `detail` | `processCode` | list-initiated 的 --process-code |
|
||||
| `list-forms` | `processCode` | list-initiated 的 --process-code |
|
||||
| `search-forms` | `processCode` | list-initiated 的 --process-code |
|
||||
|
||||
## 注意事项
|
||||
|
||||
@@ -391,7 +392,7 @@ dws oa approval oa-cc-noticer --instance-id <processInstanceId> --users "userId1
|
||||
- `redirect-task` 的 `--to-actioner-id` 可通过 `dws contact user search` 获取目标用户 userId
|
||||
- `revoke` 只能撤销自己发起的审批
|
||||
- `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹
|
||||
- `list-initiated` 的 `--process-code` 可从 `list-forms` 或 `detail` 返回中提取
|
||||
- `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳
|
||||
- `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询
|
||||
|
||||
## 自动化脚本
|
||||
|
||||
@@ -108,6 +108,8 @@ dws sheet filter-view --help
|
||||
| `sheet update-dimension` | 更新行/列属性(显隐/行高/列宽) |
|
||||
| `sheet move-dimension` | 移动行或列到指定位置 |
|
||||
| `sheet add-dimension` | 在末尾追加空行或空列 |
|
||||
| `sheet group-dimension` | 创建行/列分组 |
|
||||
| `sheet ungroup-dimension` | 取消行/列分组 |
|
||||
| `sheet media-upload` | 上传附件到表格 |
|
||||
| `sheet write-image` | 上传图片并写入单元格 |
|
||||
| `sheet create-float-image` | 创建浮动图片 |
|
||||
@@ -156,7 +158,7 @@ dws sheet filter-view --help
|
||||
| 读取数据 | [sheet/sheet-read-data.md](./sheet/sheet-read-data.md) | range read / csv-get |
|
||||
| 区域操作 | [sheet/sheet-range-operations.md](./sheet/sheet-range-operations.md) | range clear / sort / fill / copy-to / move-to |
|
||||
| 批量操作 | [sheet/sheet-batch-operations.md](./sheet/sheet-batch-operations.md) | range batch-clear / batch-update |
|
||||
| 行列操作 | [sheet/sheet-dimension-operations.md](./sheet/sheet-dimension-operations.md) | insert / delete / update / move / add-dimension |
|
||||
| 行列操作 | [sheet/sheet-dimension-operations.md](./sheet/sheet-dimension-operations.md) | insert / delete / update / move / add-dimension / group-dimension / ungroup-dimension |
|
||||
| 样式与合并 | [sheet/sheet-style-format.md](./sheet/sheet-style-format.md) | range set-style / batch-set-style / merge-cells / unmerge-cells |
|
||||
| 条件格式 | [sheet/sheet-conditional-format.md](./sheet/sheet-conditional-format.md) | cond-format list / create / update / delete |
|
||||
| 浮动图表 | [sheet/sheet-chart.md](./sheet/sheet-chart.md) | chart list / create / update / delete |
|
||||
@@ -254,6 +256,11 @@ dws sheet filter-view --help
|
||||
- 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行
|
||||
- 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度
|
||||
|
||||
用户说"创建行分组/创建列分组/折叠几行/折叠几列/取消行列分组":
|
||||
- 创建分组 → `group-dimension --range <整行或整列范围>`
|
||||
- 取消分组 → `ungroup-dimension --range <整行或整列范围>`
|
||||
- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不要传 `A1:C5`
|
||||
|
||||
### 单元格格式
|
||||
|
||||
用户说"设置样式/改颜色/设背景色/加粗/居中/换行/字体颜色/字号":
|
||||
@@ -1099,6 +1106,38 @@ Flags:
|
||||
|
||||
在工作表末尾追加指定数量的空行或空列。
|
||||
|
||||
### 创建行或列分组
|
||||
```
|
||||
Usage:
|
||||
dws sheet group-dimension [flags]
|
||||
Example:
|
||||
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "3:7"
|
||||
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "C:F" --group-state fold
|
||||
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "Sheet1!3:7"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 整行/整列范围,如 "3:7" 或 "C:F" (必填)
|
||||
--group-state string 创建后的分组状态: expand 或 fold,默认 expand
|
||||
```
|
||||
|
||||
在工作表中为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持普通单元格矩形范围。
|
||||
|
||||
### 取消行或列分组
|
||||
```
|
||||
Usage:
|
||||
dws sheet ungroup-dimension [flags]
|
||||
Example:
|
||||
dws sheet ungroup-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "3:7"
|
||||
dws sheet ungroup-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "C:F"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 整行/整列范围,如 "3:7" 或 "C:F" (必填)
|
||||
```
|
||||
|
||||
取消指定连续行或连续列的分组。
|
||||
|
||||
### 取消合并单元格
|
||||
```
|
||||
Usage:
|
||||
@@ -1581,7 +1620,7 @@ Flags:
|
||||
- MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
|
||||
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
|
||||
|
||||
**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。
|
||||
**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。
|
||||
|
||||
### 表格模板(list / search / apply)
|
||||
```
|
||||
@@ -1903,6 +1942,8 @@ dws sheet export --node <NODE_ID> --output ./
|
||||
| `replace` | `replaceCount` 被替换的单元格数量 | 确认替换结果 |
|
||||
| `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
|
||||
| `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
|
||||
| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 |
|
||||
| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 |
|
||||
| `unmerge-cells` | `sheetId` 工作表 ID | 确认操作完成 |
|
||||
| `set-dropdown` | `range` 实际设置范围、`optionCount` 选项数量、`enableMultiSelect` 是否多选 | 确认下拉列表设置成功 |
|
||||
| `get-dropdown` | `hasDropdown` 是否存在下拉、`dataValidations` 下拉配置列表(含 `conditionValues`、`ranges`、`options`) | 查看已有下拉配置 |
|
||||
@@ -2032,6 +2073,9 @@ dws sheet export --node <NODE_ID> --output ./
|
||||
- `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果
|
||||
- `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同
|
||||
- `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000
|
||||
- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5`
|
||||
- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand`
|
||||
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold`
|
||||
- `unmerge-cells` 取消指定范围内所有合并单元格,使用 A1 表示法指定范围
|
||||
- `set-dropdown` 在指定范围内设置下拉列表,`--options` 为 JSON 数组,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB` 格式)。选项值不能包含英文逗号。`--multi-select` 启用多选模式。如果目标范围已存在下拉列表,会被新配置覆盖
|
||||
- `get-dropdown` 查询指定范围内的下拉列表配置,返回 `dataValidations` 数组,相同选项的单元格聚合为一组。无下拉列表时 `hasDropdown` 为 false
|
||||
@@ -2067,7 +2111,7 @@ dws sheet export --node <NODE_ID> --output ./
|
||||
- `filter-view delete-criteria` 仅清除指定列的条件,不会删除整个筛选视图。如需删除整个筛选视图,请使用 `filter-view delete`
|
||||
- `filter-view delete-criteria` 如果指定列没有设置筛选条件,调用不会报错
|
||||
- 筛选视图相关操作需要"可阅读"权限(list / info / list-criteria / get-criteria)或"可编辑"权限(create / update / delete / update-criteria / delete-criteria),不支持跨组织操作
|
||||
- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
|
||||
- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
|
||||
- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
|
||||
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
|
||||
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
- 需要对**多个**不同区域执行 `merge-cells` / `unmerge-cells` 时(如按分组合并多列相同内容)
|
||||
- 需要对**多个**不同区域执行 `update-dimension` 时(如统一调整多列列宽或多行行高,含 hidden + pixelSize)
|
||||
- 需要先插入行列再写入数据时(`add-dimension` + `range update` / `csv-put`)
|
||||
- 需要批量创建或取消行列分组时(`group-dimension` / `ungroup-dimension`)
|
||||
- 需要对多个区域执行不同写入操作时(多次 `range update` + `range clear` 等组合)
|
||||
|
||||
当同一工具需要对多个区域重复调用时,**推荐**改用 `batch-update` 合并为单次请求——`batch-update` 是原子提交(要么全成功要么整批回滚);逐个调用非原子,中途失败会留下半成品。
|
||||
@@ -80,6 +81,7 @@ Flags:
|
||||
toolName 用 CLI 命令名,input 的键用 CLI flag 名去掉 --:
|
||||
range clear / range update / merge-cells / unmerge-cells / update-dimension
|
||||
range fill / range copy-to / add-dimension / delete-dimension / move-dimension
|
||||
group-dimension / ungroup-dimension
|
||||
set-dropdown / delete-dropdown / csv-put / delete-float-image
|
||||
Notes:
|
||||
- 默认严格事务模式:任一子操作失败 → 整批回滚到初始状态
|
||||
@@ -177,6 +179,8 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet'
|
||||
| `add-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
|
||||
| `delete-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
|
||||
| `move-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
|
||||
| `group-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
|
||||
| `ungroup-dimension` | [sheet-dimension-operations](./sheet-dimension-operations.md) |
|
||||
| `set-dropdown` | [sheet-dropdown](./sheet-dropdown.md) |
|
||||
| `delete-dropdown` | [sheet-dropdown](./sheet-dropdown.md) |
|
||||
| `csv-put` | [sheet-write-data](./sheet-write-data.md) |
|
||||
@@ -230,6 +234,19 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet'
|
||||
]
|
||||
```
|
||||
|
||||
### 批量创建行列分组
|
||||
|
||||
```jsonc
|
||||
[
|
||||
{"toolName":"group-dimension",
|
||||
"input":{"sheet-id":"...","range":"3:7","group-state":"expand"}},
|
||||
{"toolName":"group-dimension",
|
||||
"input":{"sheet-id":"...","range":"C:F"}},
|
||||
{"toolName":"ungroup-dimension",
|
||||
"input":{"sheet-id":"...","range":"10:12"}}
|
||||
]
|
||||
```
|
||||
|
||||
## 上下文传递
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
@@ -244,3 +261,5 @@ Cause: The requested resource was not found by the identifier 'NonExistentSheet'
|
||||
- ★ **需要组合多个不同写操作(清除+写入等)时,用 `batch-update`**:原子事务,任一操作失败则整批回滚,避免留下半成品
|
||||
- ★ **批次完成后必须回读校验**:用 `range read` 或 `csv-get` 抽样回读受影响区域,至少校验 3-5 个代表性单元格(首 / 中 / 末),与预期值对照
|
||||
- ★ **`batch-update` 不支持嵌套**:`--operations` 中的 `toolName` 必须是原子操作,不可再嵌套 `batch-update`
|
||||
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;`input.range` 只接受整行或整列范围,如 `3:7`、`C:F`
|
||||
- 需要创建后立即折叠分组时,优先使用独立 `dws sheet group-dimension --group-state fold`
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
- 注意与 `append`(追加数据行)区分:`add-dimension` 追加的是空行/空列,`append` 追加的是带数据的行
|
||||
- 请勿用 `range update` 写空数据来模拟追加,`add-dimension` 直接扩展表格维度
|
||||
|
||||
用户说"创建行分组/创建列分组/折叠几行/折叠几列/展开行列分组/取消行列分组":
|
||||
- 创建分组 → `group-dimension --range <整行或整列范围>`
|
||||
- 创建并折叠 → `group-dimension --group-state fold`
|
||||
- 取消分组 → `ungroup-dimension --range <整行或整列范围>`
|
||||
- `--range` 只能传整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`
|
||||
|
||||
**结构预检**:插入、删除、移动行列前,必须先执行 `dws sheet info --node <NODE_ID> --sheet-id <SHEET_ID> --format json` 查看 `mergedRanges`。合并区域跨过操作位置时,行列变更可能导致表头/分组标题断裂、空白或错位;需要先向用户说明影响,必要时取消合并后操作,再按原模式重新 `merge-cells`。
|
||||
|
||||
## 命令详细参考
|
||||
@@ -172,6 +178,38 @@ Flags:
|
||||
|
||||
在工作表末尾追加指定数量的空行或空列。
|
||||
|
||||
### 创建行或列分组
|
||||
```
|
||||
Usage:
|
||||
dws sheet group-dimension [flags]
|
||||
Example:
|
||||
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "3:7"
|
||||
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "C:F" --group-state fold
|
||||
dws sheet group-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "Sheet1!3:7"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 整行/整列范围,如 "3:7" 或 "C:F" (必填)
|
||||
--group-state string 创建后的分组状态: expand 或 fold,默认 expand
|
||||
```
|
||||
|
||||
为连续行或连续列创建分组。`--range` 必须是整行或整列范围,不支持 `A1:C5` 这类普通单元格矩形范围。
|
||||
|
||||
### 取消行或列分组
|
||||
```
|
||||
Usage:
|
||||
dws sheet ungroup-dimension [flags]
|
||||
Example:
|
||||
dws sheet ungroup-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "3:7"
|
||||
dws sheet ungroup-dimension --node <NODE_ID> --sheet-id <SHEET_ID> --range "C:F"
|
||||
Flags:
|
||||
--node string 表格文档 ID 或 URL (必填)
|
||||
--sheet-id string 工作表 ID 或名称 (必填)
|
||||
--range string 整行/整列范围,如 "3:7" 或 "C:F" (必填)
|
||||
```
|
||||
|
||||
取消指定连续行或连续列的分组。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
@@ -244,6 +282,8 @@ dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
| `update-dimension` | `a1Notation` 被更新区域范围、`hidden` 生效的显隐状态、`pixelSize` 生效的尺寸 | 确认更新结果 |
|
||||
| `move-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
|
||||
| `add-dimension` | `sheetId` 工作表 ID | 确认操作完成 |
|
||||
| `group-dimension` | `range` / `level` / `groupState` | 确认行列分组层级和展开状态 |
|
||||
| `ungroup-dimension` | `range` / `level` | 确认行列分组已取消 |
|
||||
| `list` | 工作表的 `sheetId` | info / range read / range update / find 的 --sheet-id |
|
||||
|
||||
## 注意事项
|
||||
@@ -273,3 +313,7 @@ dws sheet update-dimension --node <NODE_ID> --sheet-id <SHEET_ID> \
|
||||
- `add-dimension` vs `range update`:需要在末尾追加空行/空列时,必须使用 `add-dimension` 命令,禁止用 `range update` 写空数据来模拟追加效果
|
||||
- `add-dimension` 追加的是空行/空列,与 `append`(追加带数据的行)不同
|
||||
- `add-dimension` 的 `--length` 必须为正整数(>= 1),行列均不超过 5000
|
||||
- `group-dimension` / `ungroup-dimension` 的 `--range` 只接受整行或整列范围,如 `3:7`、`C:F`、`Sheet1!3:7`,不支持 `A1:C5`
|
||||
- `group-dimension` 的 `--group-state` 只接受 `expand` 或 `fold`,默认 `expand`
|
||||
- 分组操作返回 `level` 表示当前分组层级;不要把它误认为固定深度参数
|
||||
- `batch-update` 支持 `group-dimension` / `ungroup-dimension`;需要创建后立即折叠时,优先使用独立 `group-dimension --group-state fold`
|
||||
|
||||
@@ -56,7 +56,7 @@ Flags:
|
||||
- MCP 返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试
|
||||
- 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试
|
||||
|
||||
**限制**:仅支持钉钉在线电子表格(alxs)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。
|
||||
**限制**:仅支持钉钉在线电子表格(axls)→ xlsx。导出钉钉文字文档请使用 `doc` 产品对应的导出工具。
|
||||
|
||||
## 核心工作流
|
||||
|
||||
@@ -86,7 +86,7 @@ dws sheet export --node <NODE_ID> --output ./
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ★ `export` 仅支持钉钉在线电子表格(alxs)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
|
||||
- ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal`
|
||||
- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令
|
||||
- `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可
|
||||
- ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试
|
||||
|
||||
@@ -90,14 +90,14 @@ Step 3 → 按下方路由规则映射到对应产品
|
||||
| `contentType=ALIDOC`, `extension=adoc` | `doc` | 按 [doc.md](./products/doc.md) 操作 |
|
||||
| `contentType=ALIDOC`, `extension=axls` | `sheet` | 按 [sheet.md](./products/sheet.md) 操作(仅 `axls` 在线电子表格) |
|
||||
| `contentType=ALIDOC`, `extension=able` | `aitable` | 将 nodeId 作为 baseId,按 [aitable.md](./products/aitable.md) 操作 |
|
||||
| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws doc download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) |
|
||||
| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws doc download` 下载,返回文件下载链接 |
|
||||
| `contentType=DOCUMENT`, `extension=xlsx` / `xls` / `xlsm` / `csv` | `doc` | 必须用 `dws drive download` 下载到本地处理,禁止走 `sheet`(非在线表格,sheet 命令无法操作) |
|
||||
| `contentType≠ALIDOC`, `nodeType=file` | `doc` | 调用 `dws drive download` 下载,返回文件下载链接 |
|
||||
| `nodeType=folder` | `doc` | 调用 `dws doc list --folder <ID>` 列出指定文件夹直接子节点列表 |
|
||||
| 以上均不匹配 | — | 告知用户当前暂不支持该类型 |
|
||||
|
||||
> axls vs xlsx 关键区分:
|
||||
> - `axls`(钉钉在线电子表格,`contentType=ALIDOC`)→ 走 `sheet` 产品线(读/写/筛选/导出等服务端原子操作)
|
||||
> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws doc download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错)
|
||||
> - `xlsx` / `xls` / `xlsm` / `csv`(上传到文档空间的本地表格文件,`contentType=DOCUMENT`)→ 必须走 `dws drive download` 下载到本地后再解析处理,严禁错误路由到 `sheet` 产品线(sheet 命令只支持在线表格,调用 xlsx 节点会直接报错)
|
||||
> - 用户想把在线表格导出为 xlsx 文件 → 开源 dws CLI 暂未暴露在线表格导出能力(旧动态 schema 曾包含 `submit_export_job` / `query_export_job`,但当前 cobra 未注册),需要在钉钉客户端手动导出 xlsx
|
||||
|
||||
### 示例
|
||||
@@ -110,10 +110,10 @@ dws doc info --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
|
||||
dws sheet list --node "https://alidocs.dingtalk.com/i/nodes/abc123" --format json
|
||||
|
||||
# 返回 contentType≠ALIDOC, extension=xlsx/xls/csv → 本地表格文件,必须下载处理(禁止走 sheet)
|
||||
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456"
|
||||
dws drive download --node "https://alidocs.dingtalk.com/i/nodes/xlsx456"
|
||||
|
||||
# 返回 contentType≠ALIDOC, nodeType=file → 普通文件,下载
|
||||
dws doc download --node "https://alidocs.dingtalk.com/i/nodes/def456"
|
||||
dws drive download --node "https://alidocs.dingtalk.com/i/nodes/def456"
|
||||
|
||||
# 返回 nodeType=folder → 文件夹,列出子节点
|
||||
dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format json
|
||||
@@ -138,8 +138,8 @@ dws doc list --folder "https://alidocs.dingtalk.com/i/nodes/ghi789" --format jso
|
||||
| **adoc**(在线文档) | `doc read` | `doc update` / `doc block update` | ⚠️ `doc delete` | ⚠️ `doc export` (→ docx) | ⚠️ `doc permission *` | ⚠️ `doc media download/insert` |
|
||||
| **axls**(在线电子表格) | `sheet range read` / `sheet list` | `sheet range update` / `sheet append` | ⚠️ `doc delete`(节点删除) | `sheet export`(单命令一站式:提交→轮询→下载,可选 `--output` 落盘) | ⚠️ `doc permission *`(节点级,跨产品) | 不适用 |
|
||||
| **able**(在线多维表) | `aitable base get` / `aitable record query` | `aitable record create/update` | ⚠️ `doc delete`(节点删除)或 `aitable base delete --yes` | `aitable export data --scope all --format excel`(取 downloadUrl,`--output` 不落盘) | ⚠️ `doc permission *`(节点级) | `aitable attachment upload` |
|
||||
| **xlsx / xls / xlsm / csv**(本地表格文件) | `doc download` → 本地用 xlsx skill 解析 | 不支持服务端写(先下载改本地再上传) | ⚠️ `doc delete`(节点删除) | 不需要(本身就是 xlsx) | ⚠️ `doc permission *` | 不适用 |
|
||||
| **普通文件** (nodeType=file) | `doc download` | 不支持服务端写 | ⚠️ `doc delete` | 不需要 | ⚠️ `doc permission *` | 不适用 |
|
||||
| **xlsx / xls / xlsm / csv**(本地表格文件) | `drive download` → 本地用 xlsx skill 解析 | 不支持服务端写(先下载改本地再上传) | ⚠️ `doc delete`(节点删除) | 不需要(本身就是 xlsx) | ⚠️ `doc permission *` | 不适用 |
|
||||
| **普通文件** (nodeType=file) | `drive download` | 不支持服务端写 | ⚠️ `doc delete` | 不需要 | ⚠️ `doc permission *` | 不适用 |
|
||||
| **文件夹** (nodeType=folder) | `doc list --folder <URL>` | `doc create --folder <URL> ...` | ⚠️ `doc delete` | 不适用 | ⚠️ `doc permission *` | 不适用 |
|
||||
| **分享短链** `/i/p/<short>` | `read_url` 兜底(外部工具) | 不适用 | 不适用 | 不适用 | 不适用 | 不适用 |
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ def main():
|
||||
|
||||
event_id = None
|
||||
if not args.dry_run and isinstance(result, dict):
|
||||
# event create 真机返回 {result:{id,...}, success:true},id 在 result 内层,
|
||||
# event create 返回 {result:{id,...}, success:true},id 在 result 内层,
|
||||
# 顶层取 id 恒 None 会导致参会人/订房支路被静默跳过。先解包 result 再取。
|
||||
inner = result.get('result') if isinstance(result.get('result'), dict) else result
|
||||
event_id = (inner.get('eventId') or inner.get('id')
|
||||
|
||||
@@ -70,7 +70,7 @@ def main():
|
||||
print('未找到匹配部门')
|
||||
sys.exit(1)
|
||||
|
||||
# dept search 真机返回顶层 deptList;兼容 result 包裹与历史 items/result 键。
|
||||
# dept search 返回顶层 deptList;兼容 result 包裹与历史 items/result 键。
|
||||
if isinstance(dept_data, list):
|
||||
depts = dept_data
|
||||
else:
|
||||
@@ -104,7 +104,7 @@ def main():
|
||||
print(' 无法获取成员列表')
|
||||
continue
|
||||
|
||||
# list-members 真机返回 deptUserList;兼容 result 包裹与历史 userlist 键。
|
||||
# list-members 返回 deptUserList;兼容 result 包裹与历史 userlist 键。
|
||||
if isinstance(members_data, list):
|
||||
members = members_data
|
||||
else:
|
||||
@@ -121,7 +121,7 @@ def main():
|
||||
continue
|
||||
|
||||
for m in members:
|
||||
# list-members 真机每项形如 {"userInfo": {"name":..., "userId":...}},
|
||||
# list-members 每项形如 {"userInfo": {"name":..., "userId":...}},
|
||||
# 成员字段嵌在 userInfo 下;兼容历史扁平结构。
|
||||
info = m.get('userInfo', m)
|
||||
name = info.get('name') or info.get('userName', '未知')
|
||||
|
||||
@@ -44,6 +44,58 @@ def run_dws(
|
||||
return None
|
||||
|
||||
|
||||
def todos_from_payload(payload: Any) -> List[dict]:
|
||||
"""解析 `minutes get todos` 返回为待办列表。
|
||||
|
||||
返回结构: result.dingtalkTodoList(对象数组, 含 title 等)与
|
||||
result.actions(JSON 字符串数组, 每条形如
|
||||
{"mark":[],"value":"..."}). 二者无 todos 键。
|
||||
优先取 dingtalkTodoList; 为空时回退解析 actions。
|
||||
"""
|
||||
if isinstance(payload, dict):
|
||||
inner = payload.get('result', payload)
|
||||
else:
|
||||
inner = payload
|
||||
out: List[dict] = []
|
||||
if isinstance(inner, dict):
|
||||
ding_list = inner.get('dingtalkTodoList')
|
||||
if isinstance(ding_list, list) and ding_list:
|
||||
for t in ding_list:
|
||||
if isinstance(t, dict):
|
||||
content = t.get('title') or t.get('content') or ''
|
||||
if content:
|
||||
out.append({'content': str(content), '_raw': t})
|
||||
if out:
|
||||
return out
|
||||
actions = inner.get('actions')
|
||||
if isinstance(actions, list):
|
||||
for a in actions:
|
||||
content = ''
|
||||
if isinstance(a, str):
|
||||
text = a.strip()
|
||||
if text.startswith('{'):
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
content = parsed.get('value') or ''
|
||||
except json.JSONDecodeError:
|
||||
content = text
|
||||
else:
|
||||
content = text
|
||||
elif isinstance(a, dict):
|
||||
content = (a.get('value') or a.get('content')
|
||||
or a.get('title') or '')
|
||||
if content:
|
||||
out.append({'content': str(content)})
|
||||
elif isinstance(inner, list):
|
||||
for t in inner:
|
||||
if isinstance(t, dict):
|
||||
content = (t.get('content') or t.get('text')
|
||||
or t.get('title') or t.get('value') or '')
|
||||
if content:
|
||||
out.append({'content': str(content), '_raw': t})
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='从听记中提取待办事项'
|
||||
@@ -82,21 +134,9 @@ def main():
|
||||
])
|
||||
if not todos_data:
|
||||
continue
|
||||
if isinstance(todos_data, list):
|
||||
items = todos_data
|
||||
elif isinstance(todos_data, dict):
|
||||
inner = todos_data.get('result', todos_data)
|
||||
if isinstance(inner, dict):
|
||||
items = inner.get('todos', [])
|
||||
elif isinstance(inner, list):
|
||||
items = inner
|
||||
else:
|
||||
items = []
|
||||
else:
|
||||
items = []
|
||||
items = todos_from_payload(todos_data)
|
||||
for t in items:
|
||||
if isinstance(t, dict):
|
||||
t['_source'] = title
|
||||
t['_source'] = title
|
||||
all_todos.extend(items)
|
||||
|
||||
print(f"\n📋 听记待办汇总")
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
name: dingtalk-agoal
|
||||
description: 钉钉 Agoal 目标管理。Use when 用户说 目标管理/Agoal/战略解码/经营合约/计分卡/OKR/目标模板/周月报提交统计/跟催。Distinct from dingtalk-todo(待办任务)、dingtalk-report(日志)。命令前缀:dws agoal。
|
||||
cli_version: ">=0.2.14"
|
||||
metadata:
|
||||
category: product
|
||||
stability: experimental
|
||||
requires:
|
||||
bins:
|
||||
- dws
|
||||
---
|
||||
|
||||
# 钉钉 Agoal Skill
|
||||
|
||||
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
|
||||
|
||||
> **PREREQUISITE:** Read the `dws-shared` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
|
||||
|
||||
<!-- SAFETY_PREAMBLE_INJECT -->
|
||||
|
||||
> ⚠️ **命令可用性以当前 dws 二进制为准**。服务发现已下线,本文档随内置 skill 发布;如果 `dws <cmd> --help` 不存在,说明当前版本未暴露该命令。若命令存在但调用失败,请按错误中的 endpoint 或 tool 提示确认静态端点目录和后端工具注册。实际调用前可用 `dws <cmd> --help` 或 `--dry-run` 验证。
|
||||
|
||||
> 命令参考:[agoal.md](references/agoal.md)。
|
||||
|
||||
## 意图表
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "查战略解码 / OGSM" | `dws agoal strategy list --scope-type <DEPT|PERSONAL> --scope-id <ID>` |
|
||||
| "看战略解码详情" | `dws agoal strategy detail --profile-id <PROFILE_ID>` |
|
||||
| "更新战略解码" | 先 detail,基于完整数据修改后 `dws agoal strategy update ...` |
|
||||
| "查经营合约 / KPI合约" | `dws agoal contract list/detail/fields` |
|
||||
| "更新经营合约" | 先 detail,基于完整 dimensions 修改后 `dws agoal contract update ...` |
|
||||
| "查计分卡" | `dws agoal scorecard detail --selected-time <ISO> --dept-id <DEPT_ID>` |
|
||||
| "我的目标 / 个人目标" | `dws agoal user rules` → `dws agoal user objectives` |
|
||||
| "周月报提交统计 / 跟催 / 迟交 / 未提交" | `dws agoal report list-statistics` → `dws agoal report submit-detail` |
|
||||
| "目标模板" | `dws agoal obj-template list/create-or-update` |
|
||||
|
||||
## 写操作硬约束
|
||||
|
||||
- `strategy update` / `contract update` / `scorecard update` / `obj-template create-or-update` 都是覆盖式写入。
|
||||
- 必须先读取现有详情或模板列表,在原数据基础上修改;禁止只传局部字段覆盖。
|
||||
- 执行前必须向用户展示目标对象、修改字段和最终命令参数摘要,并等待确认。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user