Compare commits

...
Author SHA1 Message Date
修雨 c9ca0f99f0 chore(changelog): prepare v1.0.51 stable 2026-07-10 17:01:55 +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
张卓澎 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
94 changed files with 6001 additions and 310 deletions
+24
View File
@@ -6,6 +6,30 @@ 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.
+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()
+102
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.
+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=") {
+2 -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,
@@ -506,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)
+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
}
+268 -3
View File
@@ -4741,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 == "" {
+289 -3
View File
@@ -787,7 +787,7 @@ func newDocCommand() *cobra.Command {
root := &cobra.Command{
Use: "doc",
Short: "钉钉文档管理",
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、模板管理。
Long: `管理钉钉文档:浏览、读写、块级编辑、导出、导入、模板管理。
命令结构:
dws doc info 获取文档元信息
@@ -798,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 应用文档模板创建新文档
@@ -2446,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",
@@ -2658,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)
}
@@ -2700,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
}
@@ -2947,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.
+350 -46
View File
@@ -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
}
+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,
}
}
@@ -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()
+5 -5
View File
@@ -248,8 +248,8 @@ scope 格式: <product>.<entity>:<permission>
grantType 规则:
once 一次性,执行一次后自动失效
session 当前会话有效(默认),需要 --session-id
permanent 永久有效
session 当前会话有效,需要 --session-id
permanent 永久有效(默认)
批量授权:
dws pat chmod 支持一次传多个 scope 直接批量授予。
@@ -275,9 +275,9 @@ agentCode 配置:
dws pat chmod chat.message:list --grant-type once
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")
+12 -3
View File
@@ -382,7 +382,7 @@ func TestPATHelpDocumentsBatchAuthorization(t *testing.T) {
"由服务端默认兜底",
"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)
@@ -486,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)
@@ -851,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{
@@ -861,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 {
@@ -1064,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)
@@ -1089,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 {
@@ -1116,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)
@@ -1133,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) {
+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",
+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`
+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 字符串;不传则默认当天。
+134
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 (群身份管理)
#### 查看群身份列表 — 拉取指定群聊的自定义群身份列表
@@ -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 (置顶会话)
#### 拉取置顶会话列表
@@ -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 (会话免打扰)
#### 会话消息免打扰 — 开启或关闭会话消息免打扰(支持单聊和群聊)
@@ -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 取消)
+42
View File
@@ -131,6 +131,27 @@ 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/图片/附件等非在线文档)改用:
@@ -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 |
@@ -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 ...`。
+160
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`。** 当需要获取**某人**的邮箱地址时,必须走以下三路并发查询,取最先返回有效邮箱的结果。禁止臆测邮箱地址。
@@ -218,6 +250,21 @@ Flags:
> **注意:** 即使邮件确有附件,`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` 数组聚合返回。
### 发送邮件
```
Usage:
@@ -935,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:
@@ -1220,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 必填)
```
### 收信规则管理
#### 列出收信规则
@@ -1473,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 请用户提供,禁止臆测:
@@ -1502,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`
@@ -1618,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 |
@@ -1633,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 的当前配置基线 |
## 注意事项
+45 -1
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:
@@ -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
@@ -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`
+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` 都是覆盖式写入。
- 必须先读取现有详情或模板列表,在原数据基础上修改;禁止只传局部字段覆盖。
- 执行前必须向用户展示目标对象、修改字段和最终命令参数摘要,并等待确认。
@@ -0,0 +1,77 @@
# 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 数组 |
### 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` |
### scorecard (计分卡管理)
| 命令 | 用途 | 必填参数 | 备注 |
|------|------|----------|------|
| `scorecard detail` | 获取计分卡详情 | `--selected-time` `--dept-id` | selectedTime 为 ISO-8601 字符串 |
| `scorecard entity-detail` | 获取计分卡实体详情 | `--sc-id` `--entity-id` | 根据计分卡 id 和实体 id 查询 |
| `scorecard update` | 更新计分卡 | `--dept-id` `--selected-time` `--id` `--tracking-period-type` `--content` | trackingPeriodType: MONTHLY/QUARTERLY |
### user / report / obj-template
| 命令 | 用途 | 必填参数 |
|------|------|----------|
| `user rules` | 获取用户规则周期列表 | - |
| `user objectives` | 查询用户目标列表 | `--user-id` `--rule-id` `--period-ids` |
| `report list-statistics` | 获取周月报数据跟催列表 | - |
| `report submit-detail` | 获取周月报规则提交详情 | `--template-id` `--submit-state` |
| `obj-template list` | 获取目标模板列表 | - |
| `obj-template create-or-update` | 新增或更新目标模板 | `--dimensions` |
## 意图判断
- "战略解码/战略目标/OGSM" → `strategy list/detail/update`
- "经营合约/合约/KPI合约" → `contract list/fields/detail/update`
- "计分卡/scorecard/绩效看板" → `scorecard detail/entity-detail/update`
- "目标/OKR/我的目标/个人目标" → `user rules/objectives`
- "目标模板/模板管理" → `obj-template list/create-or-update`
- "周月报/周报统计/提交情况/跟催/迟交/未提交" → `report list-statistics/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
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
dws agoal scorecard detail --selected-time "2026-01-01T00:00:00+08:00" --dept-id DEPT_ID --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 submit-detail --template-id TPL_ID --submit-state ON_TIME --format json
dws agoal obj-template list --keyword "业绩" --format json
```
## 安全规则
- 所有 update / create-or-update 命令都是覆盖逻辑:必须先用对应 detail/list 查询完整数据,在原数据基础上修改后再传入。
- 写入类命令执行前必须展示变更摘要并等待用户确认。
- `--selected-time` / `--query-date` 接受 ISO-8601 字符串。
- `--period-ids` 为逗号分隔字符串。
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉 AI 搜问(搜人)Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉 AI 表格 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。22 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> **PREREQUISITE:** Read the root `dws` skill first for auth, global flags, product routing, URL preflight, error codes, and safety rules. The `dws` binary must be on PATH.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉考勤 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉日历 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉群聊 / 消息 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
@@ -140,6 +140,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:
@@ -274,6 +294,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 (群身份管理)
#### 查看群身份列表 — 拉取指定群聊的自定义群身份列表
@@ -959,6 +1038,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 (置顶会话)
#### 拉取置顶会话列表
@@ -1274,6 +1367,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:
@@ -1323,6 +1431,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 (会话免打扰)
#### 会话消息免打扰 — 开启或关闭会话消息免打扰(支持单聊和群聊)
@@ -1644,6 +1769,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`
@@ -1658,7 +1784,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`
@@ -1697,6 +1826,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`,**勿混淆**
@@ -1721,13 +1851,17 @@ 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 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 取消)
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉通讯录 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉开放平台文档 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉 DING 紧急消息 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉文档 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
@@ -131,6 +131,27 @@ 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/图片/附件等非在线文档)改用:
@@ -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 |
@@ -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 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉盘 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉直播 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉邮箱 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
@@ -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`。** 当需要获取**某人**的邮箱地址时,必须走以下三路并发查询,取最先返回有效邮箱的结果。禁止臆测邮箱地址。
@@ -218,6 +250,21 @@ Flags:
> **注意:** 即使邮件确有附件,`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` 数组聚合返回。
### 发送邮件
```
Usage:
@@ -935,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:
@@ -1220,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 必填)
```
### 收信规则管理
#### 列出收信规则
@@ -1473,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 请用户提供,禁止臆测:
@@ -1502,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`
@@ -1618,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 |
@@ -1633,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 的当前配置基线 |
## 注意事项
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉 AI 听记 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉 OA 审批 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉日志 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉电子表格 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
@@ -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:
@@ -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
@@ -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`
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 悟空技能管理 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉待办 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
# 钉钉知识库 Skill
> 🧪 **EXPERIMENTAL · 试验版 / Preview** — multi 模式当前未达 stable 标准。20 个 dingtalk-* skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整;生产 / 共享环境请优先使用 mono 模式(`dws skill setup --mode mono`)。问题请提 issue 反馈。
> 🧪 **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.