Compare commits

..
11 Commits
Author SHA1 Message Date
修雨 ea6fd16d11 chore(changelog): prepare v1.0.51 stable (#595) 2026-07-10 17:35:12 +08:00
修雨 4c43108bdf sync wukong hardcoded command additions
sync wukong hardcoded command additions

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:57 +08:00
修雨 5e9a920b76 fix(connect): prevent agent mid-turn blocking
fix(connect): prevent agent mid-turn blocking

Co-authored-by: 修雨 <47820304+PeterGuy326@users.noreply.github.com>
2026-07-09 21:28:35 +08:00
xuanandshangguanxuan.sgx 36b0528d90 fix: default pat chmod grants to permanent (#584)
* fix: default pat chmod grants to permanent

* docs(changelog): note pat chmod permanent default

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:45 +08:00
xuanandshangguanxuan.sgx a2201b4ab4 test(pat): remove external example auth URL trigger (#583)
* test(pat): remove external example auth URL trigger

* test(app): prevent browser launches during tests

* test(pat): build auth URL fixture as JSON

---------

Co-authored-by: shangguanxuan.sgx <shangguanxuan.sgx@alibaba-inc.com>
2026-07-09 11:28:40 +08:00
修雨 181cdf4a03 Merge pull request #578 from LastdianXuan/fix/dek-readonly-keychain-status
fix: keep keychain reads side-effect free
2026-07-08 18:46:33 +08:00
修雨 818b8b29e3 chore(changelog): add v1.0.50 release notes (#580)
Covers PR #575 (global --jq/--fields honored on product commands,
skill --dry-run preview, sheet batch-style JSON mode, skill docs
alignment) and the exported cmdutil leaf-merge / provenance helpers.
2026-07-08 14:49:25 +08:00
Ari 109ad13844 fix: honor global --jq/--fields on product commands; round-2 QA fixes (#575)
* fix: honor global --jq/--fields on product commands; round-2 QA fixes

Make the global --jq / --fields output filters actually work for the
product (MCP) commands. The helper Formatter used by every product
command ignored them, so they were silent no-ops there (they already
worked for `dws api`). Expose Fields()/JQ() on the ToolCaller interface
and apply the existing output.WriteFiltered path in the helper
Formatter's PrintJSON. The handful of bespoke utility commands
(auth/config/profile/...) still encode directly and are documented as
such.

Additional CLI fixes surfaced by the second real-machine QA pass:
- sheet write-image: emit clean JSON under --format json (suppress the
  progress lines that leaked onto stdout, same as media-upload/export)
- sheet range batch-set-style: under --format json, collect per-item
  results into a single JSON object instead of printing N separate ones
- chat download-media: create the output directory when missing and
  strip URL-encoded path separators from the inferred filename so the
  file actually lands instead of failing on a missing subdirectory
- pat chmod, aitable, sheet, chat, attendance: correct --help text
  (real scope names, non-existent subcommands, flag requiredness,
  alxs -> axls typo)

Helper scripts (mono and multi):
- minutes_extract_todos: parse dingtalkTodoList/actions (there is no
  todos key), so todos are no longer silently dropped
- sync the multi copies of chat_export_messages / chat_history_with_user
  (were crashing with AttributeError), minutes_list_parse /
  minutes_recent_summary, and calendar_free_slot_finder to the fixed
  mono versions

Skill docs (mono and multi): correct return-structure keys, flag names,
deprecated command routing (doc download -> drive download), enum values
and server-side limitations across products; update the global
reference to note --jq/--fields now apply to product commands.

* fix(skill): make skill setup --dry-run a no-op preview; doc/help fixups

skill setup ignored the global --dry-run flag and always wrote the skill
files (overwriting an existing install). Short-circuit into a preview
that lists the source, target dirs and selected sub-skills without
touching the filesystem.

Also correct a few doc/help mismatches found in the round-3 health check:
- attendance vacation balance/records quick-reference examples were
  missing the required --leave-code flag
- mail mailbox list --help described the returned field as "mailboxes"
  but the real field is "emailAccounts"

* docs: clarify --fields projects top-level/list keys, use --jq for nested

* docs: drop QA voice ("真机") and don't state env-specific quirks as absolute rules

The QA-driven doc/comment edits leaked test-process narration ("真机实测")
and this environment/account's quirks stated as universal rules into the
skill files, which are general-purpose instructions for any org/account.
Strip the "真机" narration everywhere; reword environment-specific findings
(PUBLIC sharing disabled by org policy, transient 1002, sender-open-dingtalk-id
behaviour) from absolute bans into conditional hints; keep genuinely
universal command behaviour, just without the QA voice.
2026-07-08 14:06:57 +08:00
张卓澎 fd6bbd928e fix: keep keychain reads side-effect free 2026-07-08 11:23:44 +08:00
张卓澎 67417d3fb1 fix: diagnose macos keychain auth failures 2026-07-08 11:23:44 +08:00
修雨 91dfc8b926 fix: export command merge helpers 2026-07-08 10:47:31 +08:00
154 changed files with 7059 additions and 576 deletions
+39
View File
@@ -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).
+3 -3
View File
@@ -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
View File
@@ -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 工具就能通过自然语言直接操作钉钉:
+16
View File
@@ -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)
+46 -4
View File
@@ -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()
+106
View File
@@ -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
+53
View File
@@ -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 {
+109
View File
@@ -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" {
+37 -6
View File
@@ -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=") {
+15 -2
View File
@@ -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")
+6
View File
@@ -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)
+14
View File
@@ -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 {
+603
View File
@@ -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
}
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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
View File
@@ -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"))
+124 -2
View File
@@ -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) {
+21 -5
View File
@@ -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
}
+192
View File
@@ -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"`
}
+114
View File
@@ -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)
}
}
+94 -8
View File
@@ -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
+47
View File
@@ -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.
+6 -1
View File
@@ -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 {
+67 -30
View File
@@ -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
}
+34 -26
View File
@@ -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
+217 -48
View File
@@ -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
+28
View File
@@ -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)
}
}
}
+67 -65
View File
@@ -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
+2 -2
View File
@@ -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]
+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+29
View File
@@ -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)
+1
View File
@@ -23,6 +23,7 @@ func init() {
name string
fn func() *cobra.Command
}{
{"agoal", newAgoalCommand},
{"aisearch", newAisearchCommand},
{"aitable", newAitableCommand},
{"attendance", newAttendanceCommand},
+2
View File
@@ -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 全局查找替换文本
+27
View File
@@ -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 名去掉 --
+78 -1
View File
@@ -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,
}
}
+1 -1
View File
@@ -268,7 +268,7 @@ func newExportCmd() *cobra.Command {
- 未指定:仅返回 downloadUrl,链接有时效性请尽快下载
支持范围:
仅支持钉钉在线电子表格(alxs)→ xlsx;
仅支持钉钉在线电子表格(axls)→ xlsx;
若需导出钉钉文字文档,请使用 dingtalkdoc 侧的导出工具。
权限要求:
+19 -7
View File
@@ -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
}
+40
View File
@@ -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...)
}
}
+26
View File
@@ -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,
},
}
}
+15
View File
@@ -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)
}
+65
View File
@@ -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) {
+173 -9
View File
@@ -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
}
+117
View File
@@ -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.
+8 -4
View File
@@ -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
+29
View File
@@ -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()
+8 -8
View File
@@ -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")
+30 -4
View File
@@ -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 "" }
+1
View File
@@ -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"}},
+1
View File
@@ -5,6 +5,7 @@ package syncdata
func CmdToProduct() map[string]string {
return map[string]string{
"agoal": "agoal",
"aisearch": "aisearch",
"aitable": "aitable",
"attendance": "attendance",
+90
View File
@@ -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
}
+257
View File
@@ -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")
}
}
+38
View File
@@ -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"
+45
View File
@@ -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)
}
+4
View File
@@ -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
View File
@@ -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 ""
+1
View File
@@ -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
+2 -2
View File
@@ -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 |
+2 -2
View File
@@ -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 列普通群成员 |
+161
View File
@@ -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 字符串;不传则默认当天。
+1 -1
View File
@@ -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,否则服务端拒绝。
+15 -2
View File
@@ -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 \
+3 -3
View File
@@ -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;模型生成命令时**禁止**继续使用
## 自动化脚本
+146 -12
View File
@@ -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 取消)
+2 -2
View File
@@ -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 |
+4 -7
View File
@@ -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`
+48 -6
View File
@@ -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)。
+1 -1
View File
@@ -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`)
+169 -3
View File
@@ -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 的当前配置基线 |
## 注意事项
+5 -5
View File
@@ -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 的角色匹配就有了锚点,再也不会得出"没参会"的错误结论。
+4 -3
View File
@@ -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: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询
## 自动化脚本
+47 -3
View File
@@ -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`**;直接告知用户导出失败并建议稍后再试
+7 -7
View File
@@ -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')
+3 -3
View File
@@ -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', '未知')
+54 -14
View File
@@ -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📋 听记待办汇总")
+43
View File
@@ -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