Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9ca0f99f0 | ||
|
|
4c43108bdf | ||
|
|
5e9a920b76 | ||
|
|
36b0528d90 | ||
|
|
a2201b4ab4 | ||
|
|
181cdf4a03 | ||
|
|
fd6bbd928e | ||
|
|
67417d3fb1 |
@@ -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.
|
||||
|
||||
@@ -71,9 +71,9 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 18 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | 22 per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 18 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** 22 product-scoped skills all pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -331,7 +331,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
The repo ships a complete Agent Skill system under `skills/`, now organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 20 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ... 22 products in total), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
|
||||
+3
-3
@@ -71,9 +71,9 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 20 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 22 个独立产品 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。20 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。22 个独立 skill 全部通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -328,7 +328,7 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),目前重组为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 18 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ... 共 22 个),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# cli_to_mcp smoke tests
|
||||
|
||||
This directory contains lightweight command-to-tool contract tests for hardcoded
|
||||
DWS commands synced from `dws-wukong`.
|
||||
|
||||
The tests do not call live DingTalk APIs. They exercise command help, validation,
|
||||
and `--dry-run` output so command paths and MCP argument mappings stay stable.
|
||||
|
||||
Run with an already built binary:
|
||||
|
||||
```bash
|
||||
DWS_BIN=/path/to/dws pytest auto-test/cli_to_mcp/testcases
|
||||
```
|
||||
|
||||
If `DWS_BIN` is not set, the runner falls back to `go run ./cmd` from the repo
|
||||
root.
|
||||
@@ -0,0 +1,193 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_agoal_strategy_and_contract_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"list",
|
||||
"--scope-type",
|
||||
"PERSONAL",
|
||||
"--scope-id",
|
||||
"user123",
|
||||
"--request-id",
|
||||
"req-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_strategy_decodings" in output
|
||||
assert dry_run_args(output) == {
|
||||
"scopeType": "PERSONAL",
|
||||
"openId": "user123",
|
||||
"requestId": "req-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"strategy",
|
||||
"update",
|
||||
"--profile-id",
|
||||
"profile123",
|
||||
"--content",
|
||||
'[{"id":"e1","title":{"title":"new"}}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_strategy_decoding" in output
|
||||
assert dry_run_args(output) == {
|
||||
"profileId": "profile123",
|
||||
"content": [{"id": "e1", "title": {"title": "new"}}],
|
||||
}
|
||||
|
||||
output = assert_ok(dws.run_raw("agoal", "contract", "fields", "--dry-run"))
|
||||
assert "list_op_contract_fields" in output
|
||||
assert dry_run_args(output) == {}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"contract",
|
||||
"update",
|
||||
"--contract-id",
|
||||
"contract123",
|
||||
"--dimensions",
|
||||
'[{"id":"dim1","title":"metric"}]',
|
||||
"--audit-config",
|
||||
'{"needAudit":true}',
|
||||
"--objective-template",
|
||||
'{"id":"tpl1"}',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_op_contract" in output
|
||||
assert dry_run_args(output) == {
|
||||
"contractId": "contract123",
|
||||
"dimensions": [{"id": "dim1", "title": "metric"}],
|
||||
"auditConfig": '{"needAudit":true}',
|
||||
"objectiveTemplate": '{"id":"tpl1"}',
|
||||
}
|
||||
|
||||
|
||||
def test_agoal_scorecard_user_report_template_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"detail",
|
||||
"--selected-time",
|
||||
"2026-01-01T00:00:00+08:00",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_score_card_detail" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["deptId"] == "dept123"
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"scorecard",
|
||||
"update",
|
||||
"--dept-id",
|
||||
"dept123",
|
||||
"--selected-time",
|
||||
"2026-01-01",
|
||||
"--id",
|
||||
"sc123",
|
||||
"--tracking-period-type",
|
||||
"MONTHLY",
|
||||
"--content",
|
||||
'[{"id":"dim1","items":[]}]',
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_score_card" in output
|
||||
args = dry_run_args(output)
|
||||
assert args["selectedTime"] == 1767196800000
|
||||
assert args["content"] == [{"id": "dim1", "items": []}]
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"user",
|
||||
"objectives",
|
||||
"--user-id",
|
||||
"user123",
|
||||
"--rule-id",
|
||||
"rule123",
|
||||
"--period-ids",
|
||||
"p1,p2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_user_objectives" in output
|
||||
assert dry_run_args(output) == {
|
||||
"dingUserId": "user123",
|
||||
"objectiveRuleId": "rule123",
|
||||
"periodIds": ["p1", "p2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"report",
|
||||
"submit-detail",
|
||||
"--template-id",
|
||||
"tpl123",
|
||||
"--submit-state",
|
||||
"LATE",
|
||||
"--query-date",
|
||||
"2026-06-18T00:00:00+08:00",
|
||||
"--page",
|
||||
"1",
|
||||
"--page-size",
|
||||
"20",
|
||||
"--keyword",
|
||||
"alice",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_submit_detail" in output
|
||||
assert dry_run_args(output) == {
|
||||
"templateId": "tpl123",
|
||||
"submitState": "LATE",
|
||||
"queryDate": "2026-06-18",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"keyword": "alice",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"agoal",
|
||||
"obj-template",
|
||||
"create-or-update",
|
||||
"--title",
|
||||
"tpl",
|
||||
"--dimensions",
|
||||
'[{"title":"dim"}]',
|
||||
"--objective-weight",
|
||||
"--dimension-weight",
|
||||
"--compute-by-weight",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_or_update_obj_template" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "tpl",
|
||||
"dimensions": '[{"title":"dim"}]',
|
||||
"objectiveWeight": True,
|
||||
"dimensionWeight": True,
|
||||
"computeByWeight": True,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_notice_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"create",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--content",
|
||||
"maintenance tonight",
|
||||
"--sticky",
|
||||
"--send-ding",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"content": "maintenance tonight",
|
||||
"sticky": True,
|
||||
"sendDing": True,
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"edit",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--content",
|
||||
"updated",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "edit_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
"content": "updated",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"get",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--notice-id",
|
||||
"notice123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_group_notice" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"dataId": "notice123",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"notice",
|
||||
"list",
|
||||
"--group",
|
||||
"cid123",
|
||||
"--limit",
|
||||
"20",
|
||||
"--cursor",
|
||||
"next",
|
||||
"--scheduled",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_group_notices" in output
|
||||
assert dry_run_args(output) == {
|
||||
"openConversationId": "cid123",
|
||||
"limit": 20,
|
||||
"cursor": "next",
|
||||
"scheduled": True,
|
||||
}
|
||||
|
||||
|
||||
def test_chat_misc_new_commands_cli_to_mcp(dws):
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"group",
|
||||
"share-invite",
|
||||
"--source",
|
||||
"sourceCid",
|
||||
"--target",
|
||||
"targetCid",
|
||||
"--expires-seconds",
|
||||
"3600",
|
||||
"--uuid",
|
||||
"uuid-1",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "share_group_invite_url" in output
|
||||
assert dry_run_args(output) == {
|
||||
"sourceOpenConversationId": "sourceCid",
|
||||
"targetOpenConversationId": "targetCid",
|
||||
"expiresSeconds": 3600,
|
||||
"uuid": "uuid-1",
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("chat", "text", "translate", "--query", "hello", "--to", "zh_CN", "--dry-run")
|
||||
)
|
||||
assert "translate" in output
|
||||
assert dry_run_args(output) == {"query": "hello", "to": "zh_CN"}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"category",
|
||||
"create-smart",
|
||||
"--name",
|
||||
"priority",
|
||||
"--keywords",
|
||||
"alpha,beta",
|
||||
"--members",
|
||||
"uid1,uid2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "create_smart_conv_category" in output
|
||||
assert dry_run_args(output) == {
|
||||
"title": "priority",
|
||||
"keywords": ["alpha", "beta"],
|
||||
"memberOpenDingTalkIds": ["uid1", "uid2"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"chat",
|
||||
"message",
|
||||
"list-emotion-replies",
|
||||
"--msg-ids",
|
||||
"msg1,msg2",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "list_message_emotion_replies" in output
|
||||
assert dry_run_args(output) == {"openMessageIds": ["msg1", "msg2"]}
|
||||
@@ -0,0 +1,8 @@
|
||||
import pytest
|
||||
|
||||
from test_utils import DWSRunner
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def dws():
|
||||
return DWSRunner()
|
||||
@@ -0,0 +1,54 @@
|
||||
from test_utils import combined_output
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_doc_import_help_and_validation(dws, tmp_path):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "--help"))
|
||||
assert "dws doc import" in output
|
||||
assert "--file string" in output
|
||||
assert "--workspace string" in output
|
||||
assert "--name string" in output
|
||||
|
||||
result = dws.run_raw("doc", "import", "--file", str(tmp_path / "missing.md"), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "cannot read file" in output
|
||||
|
||||
bad = tmp_path / "bad.exe"
|
||||
bad.write_text("bad", encoding="utf-8")
|
||||
result = dws.run_raw("doc", "import", "--file", str(bad), "--dry-run")
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "unsupported file format" in output
|
||||
|
||||
|
||||
def test_doc_import_dry_run(dws, tmp_path):
|
||||
source = tmp_path / "sample.md"
|
||||
source.write_text("# Sample\n\nhello\n", encoding="utf-8")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"doc",
|
||||
"import",
|
||||
"--file",
|
||||
str(source),
|
||||
"--name",
|
||||
"Imported Sample",
|
||||
"--workspace",
|
||||
"workspace123",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "Imported Sample" in output
|
||||
assert "sample.md" in output
|
||||
assert "md" in output
|
||||
|
||||
|
||||
def test_doc_import_get_dry_run(dws):
|
||||
output = assert_ok(dws.run_raw("doc", "import", "get", "--task-id", "task123", "--dry-run"))
|
||||
assert "task123" in output
|
||||
@@ -0,0 +1,143 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def assert_fails(result, expected: str):
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0, output
|
||||
assert expected in output
|
||||
|
||||
|
||||
def test_mailbox_profile_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "mailbox", "profile", "--help"))
|
||||
assert "dws mail mailbox profile" in output
|
||||
assert "--email string" in output
|
||||
|
||||
assert_fails(dws.run_raw("mail", "mailbox", "profile"), "email")
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw("mail", "mailbox", "profile", "--email", mail_email(), "--dry-run")
|
||||
)
|
||||
assert "get_mailbox_profile" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
|
||||
def test_message_batch_get_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "message", "batch-get", "--help"))
|
||||
assert "dws mail message batch-get" in output
|
||||
assert "--email string" in output
|
||||
assert "--ids string" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw("mail", "message", "batch-get", "--email", mail_email()),
|
||||
"ids",
|
||||
)
|
||||
|
||||
too_many_ids = ",".join(f"msg_{i:02d}" for i in range(21))
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
too_many_ids,
|
||||
"--dry-run",
|
||||
),
|
||||
"20",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"message",
|
||||
"batch-get",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--ids",
|
||||
"msg_001,msg_002",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_email_by_message_id" in output
|
||||
assert "msg_001" in output
|
||||
assert "msg_002" in output
|
||||
|
||||
|
||||
def test_sent_message_recall_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall", "--help"))
|
||||
assert "dws mail sent-message recall" in output
|
||||
assert "--subject string" in output
|
||||
assert "--yes" in output
|
||||
|
||||
assert_fails(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
),
|
||||
"--yes",
|
||||
)
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"msg_001",
|
||||
"--subject",
|
||||
"subject",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "recall_sent_message" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"id": "msg_001",
|
||||
"subject": "subject",
|
||||
}
|
||||
|
||||
|
||||
def test_sent_message_recall_detail_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "sent-message", "recall-detail", "--help"))
|
||||
assert "dws mail sent-message recall-detail" in output
|
||||
assert "--email string" in output
|
||||
assert "--id string" in output
|
||||
assert "FINISHED" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"sent-message",
|
||||
"recall-detail",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--id",
|
||||
"task_001",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "get_recall_detail" in output
|
||||
assert dry_run_args(output) == {"email": mail_email(), "id": "task_001"}
|
||||
@@ -0,0 +1,133 @@
|
||||
import os
|
||||
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def mail_email() -> str:
|
||||
return os.environ.get("DINGTALK_MAIL_EMAIL", "user@example.com")
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_auto_reply_update_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "auto-reply", "update", "--help"))
|
||||
for flag in ("--email string", "--enabled string", "--start string", "--end string", "--scope string", "--content string"):
|
||||
assert flag in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"auto-reply",
|
||||
"update",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--enabled",
|
||||
"true",
|
||||
"--start",
|
||||
"2026/07/01 09:00:00 +0800",
|
||||
"--end",
|
||||
"2026/07/07 18:00:00 +0800",
|
||||
"--scope",
|
||||
"all",
|
||||
"--content",
|
||||
"out of office",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "update_auto_reply" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"enabled": True,
|
||||
"startTime": "2026/07/01 09:00:00 +0800",
|
||||
"endTime": "2026/07/07 18:00:00 +0800",
|
||||
"scope": "all",
|
||||
"content": "out of office",
|
||||
}
|
||||
|
||||
|
||||
def test_allow_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "allow-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com,@example.org",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com", "@example.org"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"allow-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"partner@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_allowlist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["partner@example.com"],
|
||||
}
|
||||
|
||||
|
||||
def test_block_list_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("mail", "block-list", "list", "--email", mail_email(), "--dry-run"))
|
||||
assert "list_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {"email": mail_email()}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"add",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com,@junk.example",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "add_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com", "@junk.example"],
|
||||
}
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"mail",
|
||||
"block-list",
|
||||
"remove",
|
||||
"--email",
|
||||
mail_email(),
|
||||
"--entries",
|
||||
"spam@example.com",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "remove_mailbox_blocklist" in output
|
||||
assert dry_run_args(output) == {
|
||||
"email": mail_email(),
|
||||
"entries": ["spam@example.com"],
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
from test_utils import combined_output, dry_run_args
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return output
|
||||
|
||||
|
||||
def test_group_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "group-dimension", "--help"))
|
||||
assert "dws sheet group-dimension" in output
|
||||
assert "--group-state string" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"fold",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "group_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
}
|
||||
|
||||
|
||||
def test_ungroup_dimension_cli_to_mcp(dws):
|
||||
output = assert_ok(dws.run_raw("sheet", "ungroup-dimension", "--help"))
|
||||
assert "dws sheet ungroup-dimension" in output
|
||||
|
||||
output = assert_ok(
|
||||
dws.run_raw(
|
||||
"sheet",
|
||||
"ungroup-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"C:F",
|
||||
"--dry-run",
|
||||
)
|
||||
)
|
||||
assert "ungroup_dimension" in output
|
||||
assert dry_run_args(output) == {
|
||||
"nodeId": "node123",
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
}
|
||||
|
||||
|
||||
def test_group_dimension_rejects_invalid_state(dws):
|
||||
result = dws.run_raw(
|
||||
"sheet",
|
||||
"group-dimension",
|
||||
"--node",
|
||||
"node123",
|
||||
"--sheet-id",
|
||||
"Sheet1",
|
||||
"--range",
|
||||
"3:7",
|
||||
"--group-state",
|
||||
"invalid",
|
||||
"--dry-run",
|
||||
)
|
||||
output = combined_output(result)
|
||||
assert result.returncode != 0
|
||||
assert "group-state" in output
|
||||
@@ -0,0 +1,58 @@
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def repo_root(start_file: str) -> Path:
|
||||
current = Path(start_file).resolve()
|
||||
for parent in [current, *current.parents]:
|
||||
if (parent / "go.mod").exists():
|
||||
return parent
|
||||
raise RuntimeError(f"cannot locate repo root from {start_file}")
|
||||
|
||||
|
||||
def resolve_dws_cmd(start_file: str) -> list[str]:
|
||||
root = repo_root(start_file)
|
||||
if env_bin := os.environ.get("DWS_BIN"):
|
||||
return shlex.split(env_bin)
|
||||
|
||||
for rel in ("dws", "build/dws", "bin/dws", "dingtalk-workspace-cli"):
|
||||
candidate = root / rel
|
||||
if candidate.exists() and os.access(candidate, os.X_OK):
|
||||
return [str(candidate)]
|
||||
|
||||
return ["go", "run", "./cmd"]
|
||||
|
||||
|
||||
def combined_output(result: subprocess.CompletedProcess) -> str:
|
||||
return (result.stdout or "") + (result.stderr or "")
|
||||
|
||||
|
||||
def dry_run_args(output: str) -> dict:
|
||||
match = re.search(r"Arguments:\s*(\{.*\})", output, re.S)
|
||||
assert match, f"dry-run output does not contain Arguments JSON: {output}"
|
||||
return json.loads(match.group(1))
|
||||
|
||||
|
||||
class DWSRunner:
|
||||
def __init__(self):
|
||||
self.root = repo_root(__file__)
|
||||
self.cmd = resolve_dws_cmd(__file__)
|
||||
|
||||
def run_raw(self, *args: str, timeout: int = 45) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[*self.cmd, *args],
|
||||
cwd=self.root,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def run(self, *args: str, timeout: int = 45):
|
||||
result = self.run_raw(*args, timeout=timeout)
|
||||
output = combined_output(result)
|
||||
assert result.returncode == 0, output
|
||||
return json.loads(result.stdout)
|
||||
@@ -446,6 +446,7 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
authenticated := false
|
||||
refreshed := false
|
||||
var tokenData *authpkg.TokenData
|
||||
var statusErr error
|
||||
provider := authpkg.NewOAuthProvider(configDir, nil)
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
if data, err := provider.Status(); err == nil {
|
||||
@@ -468,12 +469,15 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
if authStatusAuthenticated(tokenData) {
|
||||
authenticated = true
|
||||
}
|
||||
} else {
|
||||
statusErr = err
|
||||
}
|
||||
diagnostic := authStatusDiagnosticFromError(statusErr)
|
||||
|
||||
// Check if JSON output is requested
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
if strings.EqualFold(strings.TrimSpace(format), "json") {
|
||||
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData)
|
||||
return writeAuthStatusJSON(cmd.OutOrStdout(), authenticated, refreshed, tokenData, diagnostic)
|
||||
}
|
||||
|
||||
// Default table output
|
||||
@@ -503,7 +507,10 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "状态:", "未登录")
|
||||
if !edition.Get().IsEmbedded {
|
||||
if diagnostic != nil {
|
||||
fmt.Fprintf(w, "%-16s%s\n", "原因:", diagnostic.Message)
|
||||
fmt.Fprintf(w, "%-16s%s\n", "提示:", diagnostic.Hint)
|
||||
} else if !edition.Get().IsEmbedded {
|
||||
fmt.Fprintln(w, "运行 dws auth login --recommend 进行登录")
|
||||
}
|
||||
}
|
||||
@@ -1199,6 +1206,8 @@ type authStatusResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Refreshed bool `json:"refreshed,omitempty"`
|
||||
TokenValid bool `json:"token_valid,omitempty"`
|
||||
RefreshTokenValid bool `json:"refresh_token_valid,omitempty"`
|
||||
@@ -1210,14 +1219,47 @@ type authStatusResponse struct {
|
||||
UserName string `json:"user_name,omitempty"`
|
||||
}
|
||||
|
||||
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData) error {
|
||||
type authStatusDiagnostic struct {
|
||||
Reason string
|
||||
Message string
|
||||
Hint string
|
||||
}
|
||||
|
||||
func authStatusDiagnosticFromError(err error) *authStatusDiagnostic {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if keychain.IsDEKMissing(err) {
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "dek_missing",
|
||||
Message: "本地登录密钥缺失,无法解密已保存的登录态",
|
||||
Hint: "重新登录以生成新的本地登录密钥;如仍异常,可先清理本地登录态后再登录。",
|
||||
}
|
||||
}
|
||||
if !keychain.IsUnavailable(err) {
|
||||
return nil
|
||||
}
|
||||
return &authStatusDiagnostic{
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "无法读取 macOS Keychain 中的登录密钥,无法判断登录状态",
|
||||
Hint: "检查 macOS 默认钥匙串是否存在且已解锁;修复后重试,或在测试环境设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
|
||||
}
|
||||
}
|
||||
|
||||
func writeAuthStatusJSON(w io.Writer, authenticated, refreshed bool, data *authpkg.TokenData, diagnostic *authStatusDiagnostic) error {
|
||||
resp := authStatusResponse{
|
||||
Success: true,
|
||||
Authenticated: authenticated,
|
||||
}
|
||||
|
||||
if !authenticated {
|
||||
resp.Message = "未登录"
|
||||
if diagnostic != nil {
|
||||
resp.Message = diagnostic.Message
|
||||
resp.Reason = diagnostic.Reason
|
||||
resp.Hint = diagnostic.Hint
|
||||
} else {
|
||||
resp.Message = "未登录"
|
||||
}
|
||||
} else if data != nil {
|
||||
resp.Refreshed = refreshed
|
||||
resp.TokenValid = data.IsAccessTokenValid()
|
||||
|
||||
@@ -16,7 +16,9 @@ package app
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -132,6 +134,106 @@ func TestAuthImportRequiresForceWhenPopulated(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusJSONReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success = false, want true; response=%+v", resp)
|
||||
}
|
||||
if resp.Authenticated {
|
||||
t.Fatalf("authenticated = true, want false; response=%+v", resp)
|
||||
}
|
||||
if resp.Reason != "keychain_unavailable" {
|
||||
t.Fatalf("reason = %q, want keychain_unavailable; response=%+v", resp.Reason, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "Keychain") && !strings.Contains(resp.Message, "钥匙串") {
|
||||
t.Fatalf("message should mention Keychain/钥匙串; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s; response=%+v", keychain.DisableKeychainEnv, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusJSONReportsDEKMissing(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
cmd := NewRootCommand()
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"--format", "json", "auth", "status"})
|
||||
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("auth status --format json error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal auth status JSON error = %v\noutput:\n%s", err, out.String())
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Fatalf("success = false, want true; response=%+v", resp)
|
||||
}
|
||||
if resp.Authenticated {
|
||||
t.Fatalf("authenticated = true, want false; response=%+v", resp)
|
||||
}
|
||||
if resp.Reason != "dek_missing" {
|
||||
t.Fatalf("reason = %q, want dek_missing; response=%+v", resp.Reason, resp)
|
||||
}
|
||||
if !strings.Contains(resp.Message, "登录密钥") {
|
||||
t.Fatalf("message should mention 登录密钥; response=%+v", resp)
|
||||
}
|
||||
if !strings.Contains(resp.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; response=%+v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthStatusRefreshFailureLeavesStoredTokenIntact(t *testing.T) {
|
||||
// Isolate keychain storage to a per-test directory so the saved
|
||||
// token can't leak into other test packages running in parallel.
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/tui"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
@@ -29,6 +30,8 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var doctorKeychainDiagnose = keychain.Diagnose
|
||||
|
||||
// checkStatus represents the outcome of a single doctor check.
|
||||
type checkStatus string
|
||||
|
||||
@@ -76,6 +79,9 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
authResult := doctorCheckAuth(cmd.Context(), w, jsonOut)
|
||||
checks = append(checks, authResult)
|
||||
|
||||
keychainResult := doctorCheckKeychain(w, jsonOut)
|
||||
checks = append(checks, keychainResult)
|
||||
|
||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||
checks = append(checks, networkResult)
|
||||
|
||||
@@ -132,6 +138,19 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
|
||||
data, err := provider.Status()
|
||||
if err != nil || data == nil {
|
||||
if diagnostic := authStatusDiagnosticFromError(err); diagnostic != nil {
|
||||
r := checkResult{
|
||||
Name: "auth",
|
||||
Status: statusFail,
|
||||
Message: diagnostic.Message,
|
||||
Hint: diagnostic.Hint,
|
||||
Detail: map[string]string{"reason": diagnostic.Reason},
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
r := checkResult{Name: "auth", Status: statusFail, Message: "未登录"}
|
||||
if !edition.Get().IsEmbedded {
|
||||
r.Hint = "运行 dws auth login 进行登录"
|
||||
@@ -182,6 +201,40 @@ func doctorCheckAuth(ctx context.Context, w io.Writer, jsonOut bool) checkResult
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Keychain check ─────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckKeychain(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, tui.Dim("检查钥匙串状态... "))
|
||||
}
|
||||
|
||||
diagnostic := doctorKeychainDiagnose()
|
||||
r := checkResult{
|
||||
Name: "keychain",
|
||||
Status: statusPass,
|
||||
Message: diagnostic.Message,
|
||||
Detail: diagnostic.Detail,
|
||||
}
|
||||
if !diagnostic.OK {
|
||||
r.Status = statusFail
|
||||
r.Hint = diagnostic.Hint
|
||||
if diagnostic.Detail == nil {
|
||||
r.Detail = map[string]string{"reason": diagnostic.Reason}
|
||||
} else if diagnostic.Reason != "" {
|
||||
detail := make(map[string]string, len(diagnostic.Detail)+1)
|
||||
for k, v := range diagnostic.Detail {
|
||||
detail[k] = v
|
||||
}
|
||||
detail["reason"] = diagnostic.Reason
|
||||
r.Detail = detail
|
||||
}
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Network check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
|
||||
@@ -15,9 +15,16 @@ package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCountResults(t *testing.T) {
|
||||
@@ -127,6 +134,108 @@ func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, keychain.NewUnavailableError("read DEK from macOS Keychain", errors.New("default keychain missing"))
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckAuth(context.Background(), &buf, false)
|
||||
|
||||
if r.Name != "auth" {
|
||||
t.Fatalf("name = %q, want auth", r.Name)
|
||||
}
|
||||
if r.Status != statusFail {
|
||||
t.Fatalf("status = %q, want fail", r.Status)
|
||||
}
|
||||
if !strings.Contains(r.Message, "Keychain") && !strings.Contains(r.Message, "钥匙串") {
|
||||
t.Fatalf("message should mention Keychain/钥匙串; result=%+v", r)
|
||||
}
|
||||
if !strings.Contains(r.Hint, keychain.DisableKeychainEnv) {
|
||||
t.Fatalf("hint should mention %s; result=%+v", keychain.DisableKeychainEnv, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsDEKMissing(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
prev := edition.Get()
|
||||
edition.Override(&edition.Hooks{
|
||||
LoadToken: func(configDir string) ([]byte, error) {
|
||||
return nil, fmt.Errorf("load from keychain: %w", keychain.ErrDEKMissing)
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() {
|
||||
edition.Override(prev)
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckAuth(context.Background(), &buf, false)
|
||||
|
||||
if r.Name != "auth" {
|
||||
t.Fatalf("name = %q, want auth", r.Name)
|
||||
}
|
||||
if r.Status != statusFail {
|
||||
t.Fatalf("status = %q, want fail", r.Status)
|
||||
}
|
||||
if !strings.Contains(r.Message, "登录密钥") {
|
||||
t.Fatalf("message should mention 登录密钥; result=%+v", r)
|
||||
}
|
||||
if !strings.Contains(r.Hint, "重新登录") {
|
||||
t.Fatalf("hint should mention 重新登录; result=%+v", r)
|
||||
}
|
||||
detail, ok := r.Detail.(map[string]string)
|
||||
if !ok || detail["reason"] != "dek_missing" {
|
||||
t.Fatalf("detail = %#v, want reason=dek_missing", r.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckKeychainReportsUnavailable(t *testing.T) {
|
||||
prev := doctorKeychainDiagnose
|
||||
doctorKeychainDiagnose = func() keychain.Diagnostic {
|
||||
return keychain.Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "macOS 默认钥匙串不存在",
|
||||
Hint: "恢复默认钥匙串后重试",
|
||||
Detail: map[string]string{
|
||||
"default_keychain": "/tmp/missing.keychain-db",
|
||||
},
|
||||
}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
doctorKeychainDiagnose = prev
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckKeychain(&buf, false)
|
||||
|
||||
if r.Name != "keychain" {
|
||||
t.Fatalf("name = %q, want keychain", r.Name)
|
||||
}
|
||||
if r.Status != statusFail {
|
||||
t.Fatalf("status = %q, want fail", r.Status)
|
||||
}
|
||||
if r.Message != "macOS 默认钥匙串不存在" {
|
||||
t.Fatalf("message = %q", r.Message)
|
||||
}
|
||||
if r.Hint == "" {
|
||||
t.Fatalf("hint is empty; result=%+v", r)
|
||||
}
|
||||
detail, ok := r.Detail.(map[string]string)
|
||||
if !ok || detail["default_keychain"] == "" {
|
||||
t.Fatalf("detail = %#v, want default_keychain", r.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCommandStructure(t *testing.T) {
|
||||
cmd := newDoctorCommand()
|
||||
if cmd.Use != "doctor" {
|
||||
|
||||
@@ -590,6 +590,33 @@ func makePATErrorJSONWithURI(flowID, clientID, uri string) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func makePATErrorJSONWithAuthorizationURL(flowID, clientID, authURL string) string {
|
||||
type patData struct {
|
||||
Desc string `json:"desc"`
|
||||
FlowID string `json:"flowId"`
|
||||
AuthorizationURL string `json:"authorizationUrl"`
|
||||
ClientID string `json:"clientId"`
|
||||
}
|
||||
payload := struct {
|
||||
Code string `json:"code"`
|
||||
Data patData `json:"data"`
|
||||
}{
|
||||
Code: "AGENT_CODE_NOT_EXISTS",
|
||||
Data: patData{
|
||||
Desc: "test auth",
|
||||
FlowID: flowID,
|
||||
AuthorizationURL: authURL,
|
||||
ClientID: clientID,
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func patTestAuthorizationURL(server *httptest.Server) string {
|
||||
return server.URL + "/pat"
|
||||
}
|
||||
|
||||
func TestEnrichPATErrorWithOpenBrowserKeepsAuthorizationURLAmpersandReadable(t *testing.T) {
|
||||
rawURI := "https://open-dev.dingtalk.com/fe/old?hash=%23%2FpersonalAuthorization%3FflowId%3Dflow-copy%26userCode%3DQZYH-D64W#/personalAuthorization?flowId=flow-copy&userCode=QZYH-D64W"
|
||||
raw := makePATErrorJSONWithURI("flow-copy", "test-client-id", rawURI)
|
||||
@@ -664,10 +691,13 @@ func TestHandlePatAuthCheck_Approved(t *testing.T) {
|
||||
|
||||
func TestRunDirectPATAuthCheck_ApprovedRetriesCallback(t *testing.T) {
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
server, configDir := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
if _, err := pat.SetBrowserPolicy(configDir, "", false); err != nil {
|
||||
t.Fatalf("SetBrowserPolicy(default) error = %v", err)
|
||||
}
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
|
||||
var retried atomic.Bool
|
||||
var retryHadKey atomic.Bool
|
||||
err := runDirectPATAuthCheck(context.Background(), &GlobalFlags{}, patErr, func(ctx context.Context) error {
|
||||
@@ -691,7 +721,7 @@ func TestRunDirectPATAuthCheckWaitOnly_ApprovedDoesNotRetry(t *testing.T) {
|
||||
server, _ := setupHandlePATServer(t, "APPROVED", "")
|
||||
defer server.Close()
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
@@ -721,7 +751,7 @@ func TestRunDirectPATAuthCheckWaitOnly_SuppressesBrowserOpen(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { openBrowserFunc = origOpenBrowser })
|
||||
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", "https://example.com/pat")}
|
||||
patErr := &apperrors.PATError{RawJSON: makePATErrorJSONWithURI("flow-direct", "test-client-id", patTestAuthorizationURL(server))}
|
||||
var out bytes.Buffer
|
||||
err := runDirectPATAuthCheckWaitOnly(context.Background(), &GlobalFlags{}, patErr, &out)
|
||||
if err != nil {
|
||||
@@ -1196,7 +1226,8 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
fallback: mock,
|
||||
globalFlags: &GlobalFlags{Format: "table"},
|
||||
}
|
||||
raw := `{"code":"AGENT_CODE_NOT_EXISTS","data":{"desc":"test auth","flowId":"flow-approved","authorizationUrl":"https://example.com/pat","clientId":"test-client-id"}}`
|
||||
authURL := patTestAuthorizationURL(server)
|
||||
raw := makePATErrorJSONWithAuthorizationURL("flow-approved", "test-client-id", authURL)
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := handlePatAuthCheck(context.Background(), runner, executor.Invocation{
|
||||
@@ -1216,7 +1247,7 @@ func TestHandlePatAuthCheck_NonJSONModeRespectsBrowserPolicy(t *testing.T) {
|
||||
if !strings.Contains(buf.String(), "需要 PAT 授权") {
|
||||
t.Fatalf("expected human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if !strings.Contains(buf.String(), "授权链接: https://example.com/pat") {
|
||||
if !strings.Contains(buf.String(), "授权链接: "+authURL) {
|
||||
t.Fatalf("expected authorization URL in human-readable PAT output, got %q", buf.String())
|
||||
}
|
||||
if strings.Contains(buf.String(), "PAT_AUTHORIZATION_URL=") {
|
||||
|
||||
@@ -64,7 +64,7 @@ skill 源默认取二进制内嵌的版本(升级二进制即升级 skill)
|
||||
dws skill setup --mode mono --yes # 非交互装 mono
|
||||
dws skill setup --mode multi --target claude # multi 全装到 ~/.claude/skills/
|
||||
dws skill setup --mode multi -s aitable -s calendar # 只装 aitable + calendar
|
||||
dws skill setup --mode multi -x live -x devdoc # 装其余 18 个,剔除 2 个
|
||||
dws skill setup --mode multi -x live -x devdoc # 装其余 20 个,剔除 2 个
|
||||
dws skill setup --source /path/to/repo # 显式指定 skill 源`,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillSetup,
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
@@ -57,8 +57,8 @@ func TestConvSessions(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestApplyModelArg covers both shapes: replacing an existing model pin
|
||||
// (claudecode's built-in haiku) and inserting before the tail (gemini-style
|
||||
// tails that end with -p and need the prompt to stay trailing).
|
||||
// (claudecode's built-in haiku) and inserting before a prompt tail that must
|
||||
// stay trailing.
|
||||
func TestApplyModelArg(t *testing.T) {
|
||||
replaced := applyModelArg(
|
||||
[]string{"claude", "-p", "--model", "claude-haiku-4-5-20251001", "--strict-mcp-config"},
|
||||
@@ -147,6 +147,128 @@ func TestForwarderSessionAndModelWiring(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInAgentForwardersDefaultToPureChannelPermissions(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("DWS_CONNECT_NO_INSTALL", "1")
|
||||
t.Setenv("DWS_AGENT_CMD", "")
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
stub := t.TempDir()
|
||||
for _, name := range []string{"claude", "codebuddy", "codex", "opencode", "qodercli"} {
|
||||
if err := writeExecStub(stub, name); err != nil {
|
||||
t.Fatalf("stub %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", stub)
|
||||
|
||||
cases := []struct {
|
||||
channel string
|
||||
want []string
|
||||
}{
|
||||
{"claudecode", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
|
||||
{"codebuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
|
||||
{"workbuddy", []string{"--permission-mode", "bypassPermissions", "--dangerously-skip-permissions"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.channel, func(t *testing.T) {
|
||||
fwd, err := forwarderForChannel(tc.channel, "", connectAgentOptions{Memory: true, Yolo: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(%q): %v", tc.channel, err)
|
||||
}
|
||||
ef, ok := fwd.(*execForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("%s forwarder = %T, want *execForwarder", tc.channel, fwd)
|
||||
}
|
||||
got := strings.Join(ef.argv, " ")
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%s argv missing %q: %s", tc.channel, want, got)
|
||||
}
|
||||
}
|
||||
if len(ef.streamArgv) > 0 {
|
||||
streamGot := strings.Join(ef.streamArgv, " ")
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(streamGot, want) {
|
||||
t.Fatalf("%s stream argv missing %q: %s", tc.channel, want, streamGot)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("gemini", func(t *testing.T) {
|
||||
t.Setenv("GEMINI_API_KEY", "test-key")
|
||||
fwd, err := forwarderForChannel("gemini", "gemini-client", connectAgentOptions{Memory: true, Yolo: true, Model: "gemini-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(gemini): %v", err)
|
||||
}
|
||||
gf, ok := fwd.(*geminiAPIForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("gemini forwarder = %T, want *geminiAPIForwarder", fwd)
|
||||
}
|
||||
if gf.model != "gemini-test" {
|
||||
t.Fatalf("gemini model = %q, want gemini-test", gf.model)
|
||||
}
|
||||
})
|
||||
|
||||
for _, ch := range []string{"qoder", "qoderwork"} {
|
||||
t.Run(ch, func(t *testing.T) {
|
||||
fwd, err := forwarderForChannel(ch, "client-"+ch, connectAgentOptions{Memory: true, Yolo: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(%q): %v", ch, err)
|
||||
}
|
||||
qf, ok := fwd.(*qoderStreamForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("%s forwarder = %T, want *qoderStreamForwarder", ch, fwd)
|
||||
}
|
||||
got := strings.Join(qf.commandArgs(), " ")
|
||||
for _, want := range []string{"--permission-mode", "bypass_permissions", "--dangerously-skip-permissions"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%s command args missing %q: %s", ch, want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("codex", func(t *testing.T) {
|
||||
fwd, err := forwarderForChannel("codex", "codex-client", connectAgentOptions{Memory: true, Yolo: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(codex): %v", err)
|
||||
}
|
||||
cf, ok := fwd.(*codexAppServerForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("codex forwarder = %T, want *codexAppServerForwarder", fwd)
|
||||
}
|
||||
params := cf.threadParams("")
|
||||
if got := params["approvalPolicy"]; got != "never" {
|
||||
t.Fatalf("codex approvalPolicy = %v, want never", got)
|
||||
}
|
||||
if got := params["sandbox"]; got != "workspace-write" {
|
||||
t.Fatalf("codex yolo sandbox = %v, want workspace-write", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("opencode", func(t *testing.T) {
|
||||
fwd, err := forwarderForChannel("opencode", "opencode-client", connectAgentOptions{Memory: true, Yolo: true})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(opencode): %v", err)
|
||||
}
|
||||
of, ok := fwd.(*opencodeForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("opencode forwarder = %T, want *opencodeForwarder", fwd)
|
||||
}
|
||||
env := of.server.commandEnv("pw")
|
||||
if !strings.Contains(envValue(env, opencodeConfigContentEnv), `"question":false`) {
|
||||
t.Fatalf("opencode config should disable question tool: %s", envValue(env, opencodeConfigContentEnv))
|
||||
}
|
||||
if !strings.Contains(envValue(env, opencodePermissionEnv), `"question":"deny"`) {
|
||||
t.Fatalf("opencode permission should deny question: %s", envValue(env, opencodePermissionEnv))
|
||||
}
|
||||
if !strings.Contains(envValue(env, opencodePermissionEnv), `"bash":"allow"`) {
|
||||
t.Fatalf("opencode permission should allow gated tools: %s", envValue(env, opencodePermissionEnv))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestRobotConnectAgentFlagsInDryRun checks the new flags surface in the
|
||||
// dry-run preview so callers can see the effective agent tuning.
|
||||
func TestRobotConnectAgentFlagsInDryRun(t *testing.T) {
|
||||
|
||||
@@ -144,11 +144,27 @@ func (p *atMentionPoller) handleMessage(ctx context.Context, msg atMentionMessag
|
||||
convID = msg.SenderStaffID
|
||||
}
|
||||
|
||||
p.queue.run(convID, func() {
|
||||
turn := connectQueuedTurn{
|
||||
convID: convID,
|
||||
text: text,
|
||||
msgID: msg.MsgID,
|
||||
senderStaffID: strings.TrimSpace(msg.SenderStaffID),
|
||||
conversationID: strings.TrimSpace(msg.OpenConversationID),
|
||||
conversationType: strings.TrimSpace(msg.ConversationType),
|
||||
}
|
||||
p.queue.submit(turn, func(turns []connectQueuedTurn) {
|
||||
turn := mergeConnectQueuedTurns(turns)
|
||||
if len(turns) > 1 {
|
||||
fmt.Fprintf(os.Stderr, "[connect][at-poll] 合并 %d 条待处理 @消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
|
||||
}
|
||||
text := turn.text
|
||||
convID := turn.convID
|
||||
senderStaffID := turn.senderStaffID
|
||||
openConversationID := turn.conversationID
|
||||
if p.extras.gate.enabled() {
|
||||
if ok, reason := p.extras.gate.allow(msg.SenderStaffID, "2", msg.OpenConversationID); !ok {
|
||||
if ok, reason := p.extras.gate.allow(senderStaffID, "2", openConversationID); !ok {
|
||||
fmt.Fprintf(os.Stderr, "[connect][at-poll] 已拦截消息(%s): staffId=%s convId=%s\n",
|
||||
reason, msg.SenderStaffID, msg.OpenConversationID)
|
||||
reason, senderStaffID, openConversationID)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -180,8 +196,8 @@ func (p *atMentionPoller) handleMessage(ctx context.Context, msg atMentionMessag
|
||||
p.health.onReply()
|
||||
}
|
||||
|
||||
if reply != "" && msg.OpenConversationID != "" {
|
||||
if serr := p.sendGroupReply(ctx, msg.OpenConversationID, reply); serr != nil {
|
||||
if reply != "" && openConversationID != "" {
|
||||
if serr := p.sendGroupReply(ctx, openConversationID, reply); serr != nil {
|
||||
fmt.Fprintf(os.Stderr, "[connect][at-poll] 群回复发送失败: %v\n", serr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,14 +107,13 @@ while IFS= read -r line; do
|
||||
*\"method\":\"initialize\"*) printf '%s\n' '{"id":1,"result":{}}' ;;
|
||||
*\"method\":\"thread/start\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
|
||||
*\"method\":\"thread/resume\"*) printf '%s\n' '{"id":2,"result":{"thread":{"id":"thr_stub"}}}' ;;
|
||||
*\"method\":\"turn/start\"*)
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}'
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}'
|
||||
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}'
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
*\"method\":\"turn/start\"*)
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"你"}}'
|
||||
printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thr_stub","turnId":"turn_stub","itemId":"item_1","delta":"好"}}'
|
||||
printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thr_stub","turn":{"id":"turn_stub","status":"completed","items":[{"id":"item_1","type":"agentMessage","text":"你好"}]}}}'
|
||||
;;
|
||||
esac
|
||||
done
|
||||
`)
|
||||
fwd := &codexAppServerForwarder{
|
||||
bin: codex,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestExecForwarderRetriesMissingSessionOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "agent")
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
if err := os.WriteFile(bin, []byte(`#!/bin/sh
|
||||
echo "$@" >> "$DWS_STALE_LOG"
|
||||
case " $* " in
|
||||
*" --resume "*)
|
||||
echo "No conversation found with session ID: stale" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo fresh-ok
|
||||
`), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessions := newConvSessions("")
|
||||
if got := sessions.args("conv-1"); len(got) != 2 || got[0] != "--session-id" {
|
||||
t.Fatalf("seed session args = %v, want --session-id", got)
|
||||
}
|
||||
f := &execForwarder{
|
||||
name: "claudecode",
|
||||
argv: []string{bin, "-p"},
|
||||
env: []string{"DWS_STALE_LOG=" + logPath},
|
||||
timeout: 5 * time.Second,
|
||||
sessions: sessions,
|
||||
}
|
||||
|
||||
reply, err := f.forward(context.Background(), "conv-1", "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("forward: %v", err)
|
||||
}
|
||||
if reply != "fresh-ok" {
|
||||
t.Fatalf("reply = %q, want fresh-ok", reply)
|
||||
}
|
||||
calls := readCallLog(t, logPath)
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls = %v, want stale resume then fresh session", calls)
|
||||
}
|
||||
if !strings.Contains(calls[0], "--resume") || !strings.Contains(calls[1], "--session-id") {
|
||||
t.Fatalf("calls = %v, want --resume then --session-id", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecForwarderStreamRetriesMissingSessionOnce(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "agent-stream")
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
if err := os.WriteFile(bin, []byte(`#!/bin/sh
|
||||
echo "$@" >> "$DWS_STALE_LOG"
|
||||
case " $* " in
|
||||
*" --resume "*)
|
||||
echo "session not found: stale" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
printf '%s\n' '{"type":"result","result":"fresh-stream-ok"}'
|
||||
`), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessions := newConvSessions("")
|
||||
_ = sessions.args("conv-1")
|
||||
f := &execForwarder{
|
||||
name: "claudecode",
|
||||
argv: []string{bin, "-p"},
|
||||
streamArgv: []string{bin, "--output-format", "stream-json"},
|
||||
env: []string{"DWS_STALE_LOG=" + logPath},
|
||||
parser: "cc",
|
||||
timeout: 5 * time.Second,
|
||||
sessions: sessions,
|
||||
}
|
||||
|
||||
reply, err := f.forwardStream(context.Background(), "conv-1", "hello", func(string) {})
|
||||
if err != nil {
|
||||
t.Fatalf("forwardStream: %v", err)
|
||||
}
|
||||
if reply != "fresh-stream-ok" {
|
||||
t.Fatalf("reply = %q, want fresh-stream-ok", reply)
|
||||
}
|
||||
calls := readCallLog(t, logPath)
|
||||
if len(calls) != 2 {
|
||||
t.Fatalf("calls = %v, want stale resume then fresh session", calls)
|
||||
}
|
||||
if !strings.Contains(calls[0], "--resume") || !strings.Contains(calls[1], "--session-id") {
|
||||
t.Fatalf("calls = %v, want --resume then --session-id", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecForwarderDoesNotRetryNonSessionErrors(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "agent")
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
if err := os.WriteFile(bin, []byte(`#!/bin/sh
|
||||
echo "$@" >> "$DWS_STALE_LOG"
|
||||
echo "plain failure" >&2
|
||||
exit 1
|
||||
`), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sessions := newConvSessions("")
|
||||
_ = sessions.args("conv-1")
|
||||
f := &execForwarder{
|
||||
name: "claudecode",
|
||||
argv: []string{bin, "-p"},
|
||||
env: []string{"DWS_STALE_LOG=" + logPath},
|
||||
timeout: 5 * time.Second,
|
||||
sessions: sessions,
|
||||
}
|
||||
|
||||
if _, err := f.forward(context.Background(), "conv-1", "hello"); err == nil {
|
||||
t.Fatal("forward error = nil, want error")
|
||||
}
|
||||
calls := readCallLog(t, logPath)
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("calls = %v, want one non-session failure without retry", calls)
|
||||
}
|
||||
if !strings.Contains(calls[0], "--resume") {
|
||||
t.Fatalf("call = %q, want stale --resume attempt", calls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func readCallLog(t *testing.T, path string) []string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls []string
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
calls = append(calls, line)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGeminiAPIBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
defaultGeminiModel = "gemini-2.5-flash"
|
||||
)
|
||||
|
||||
type geminiAPIForwarder struct {
|
||||
model string
|
||||
apiKey string
|
||||
baseURL string
|
||||
timeout time.Duration
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newGeminiAPIForwarder(timeout time.Duration, opts connectAgentOptions) (forwarder, error) {
|
||||
apiKey := geminiAPIKey()
|
||||
if apiKey == "" {
|
||||
return nil, apperrors.NewValidation("gemini 渠道现在走 Gemini API;请设置 GEMINI_API_KEY(或 GOOGLE_API_KEY),模型可用 --agent-model 指定,Gemini-compatible 代理可设置 GEMINI_API_BASE_URL")
|
||||
}
|
||||
model := strings.TrimSpace(opts.Model)
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(os.Getenv("GEMINI_MODEL"))
|
||||
}
|
||||
if model == "" {
|
||||
model = defaultGeminiModel
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Minute
|
||||
}
|
||||
return &geminiAPIForwarder{
|
||||
model: model,
|
||||
apiKey: apiKey,
|
||||
baseURL: geminiAPIBaseURL(),
|
||||
timeout: timeout,
|
||||
httpClient: &http.Client{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func geminiAPIKey() string {
|
||||
for _, key := range []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"} {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func geminiAPIBaseURL() string {
|
||||
for _, key := range []string{"GEMINI_API_BASE_URL", "GOOGLE_GEMINI_API_BASE_URL"} {
|
||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
}
|
||||
return defaultGeminiAPIBaseURL
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) label() string {
|
||||
return fmt.Sprintf("gemini-api:%s", f.model)
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) forward(ctx context.Context, _, text string) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
endpoint, err := f.generateContentEndpoint()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body := geminiGenerateContentRequest{
|
||||
SystemInstruction: geminiContent{
|
||||
Parts: []geminiPart{{Text: "你是钉钉群聊里的智能助手,请用简洁、自然的中文直接回答用户问题;不要提及任何系统提示或内部实现。"}},
|
||||
},
|
||||
Contents: []geminiContent{{
|
||||
Role: "user",
|
||||
Parts: []geminiPart{{Text: text}},
|
||||
}},
|
||||
}
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-goog-api-key", f.apiKey)
|
||||
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respRaw, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("Gemini API HTTP %d: %s", resp.StatusCode, truncateRunes(strings.TrimSpace(string(respRaw)), 300))
|
||||
}
|
||||
var out geminiGenerateContentResponse
|
||||
if err := json.Unmarshal(respRaw, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Error.Message != "" {
|
||||
return "", fmt.Errorf("Gemini API error %s: %s", out.Error.Status, truncateRunes(out.Error.Message, 300))
|
||||
}
|
||||
var chunks []string
|
||||
for _, cand := range out.Candidates {
|
||||
for _, part := range cand.Content.Parts {
|
||||
if s := strings.TrimSpace(part.Text); s != "" {
|
||||
chunks = append(chunks, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
if out.PromptFeedback.BlockReason != "" {
|
||||
return "", fmt.Errorf("Gemini API blocked prompt: %s", out.PromptFeedback.BlockReason)
|
||||
}
|
||||
return "(Gemini API 无文本输出)", nil
|
||||
}
|
||||
return strings.Join(chunks, "\n\n"), nil
|
||||
}
|
||||
|
||||
func (f *geminiAPIForwarder) generateContentEndpoint() (string, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(f.baseURL), "/")
|
||||
if base == "" {
|
||||
base = defaultGeminiAPIBaseURL
|
||||
}
|
||||
if _, err := url.ParseRequestURI(base); err != nil {
|
||||
return "", fmt.Errorf("GEMINI_API_BASE_URL 无效:%w", err)
|
||||
}
|
||||
model := strings.TrimSpace(strings.TrimPrefix(f.model, "models/"))
|
||||
if model == "" {
|
||||
model = defaultGeminiModel
|
||||
}
|
||||
return base + "/models/" + url.PathEscape(model) + ":generateContent", nil
|
||||
}
|
||||
|
||||
type geminiGenerateContentRequest struct {
|
||||
SystemInstruction geminiContent `json:"systemInstruction,omitempty"`
|
||||
Contents []geminiContent `json:"contents"`
|
||||
}
|
||||
|
||||
type geminiContent struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []geminiPart `json:"parts"`
|
||||
}
|
||||
|
||||
type geminiPart struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type geminiGenerateContentResponse struct {
|
||||
Candidates []struct {
|
||||
Content geminiContent `json:"content"`
|
||||
} `json:"candidates"`
|
||||
PromptFeedback struct {
|
||||
BlockReason string `json:"blockReason"`
|
||||
} `json:"promptFeedback"`
|
||||
Error struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
} `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeminiChannelUsesAPIWithoutLocalCLI(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
t.Setenv("GEMINI_API_KEY", "test-key")
|
||||
t.Setenv("GEMINI_API_BASE_URL", "https://gemini-proxy.example/v1beta")
|
||||
|
||||
fwd, err := forwarderForChannel("gemini", "", connectAgentOptions{Model: "gemini-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(gemini): %v", err)
|
||||
}
|
||||
gf, ok := fwd.(*geminiAPIForwarder)
|
||||
if !ok {
|
||||
t.Fatalf("gemini forwarder = %T, want *geminiAPIForwarder", fwd)
|
||||
}
|
||||
if gf.model != "gemini-test" {
|
||||
t.Fatalf("model = %q, want gemini-test", gf.model)
|
||||
}
|
||||
if gf.baseURL != "https://gemini-proxy.example/v1beta" {
|
||||
t.Fatalf("baseURL = %q", gf.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiAPIForwarderForward(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
var gotPath, gotKey, gotText string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotKey = r.Header.Get("x-goog-api-key")
|
||||
var req geminiGenerateContentRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
if len(req.Contents) > 0 && len(req.Contents[0].Parts) > 0 {
|
||||
gotText = req.Contents[0].Parts[0].Text
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"gemini-api-ok"}]}}]}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
t.Setenv("GEMINI_API_KEY", "test-key")
|
||||
t.Setenv("GEMINI_API_BASE_URL", ts.URL)
|
||||
|
||||
fwd, err := forwarderForChannel("gemini", "", connectAgentOptions{Model: "models/gemini-test"})
|
||||
if err != nil {
|
||||
t.Fatalf("forwarderForChannel(gemini): %v", err)
|
||||
}
|
||||
reply, err := fwd.forward(context.Background(), "conv-1", "你好")
|
||||
if err != nil {
|
||||
t.Fatalf("forward: %v", err)
|
||||
}
|
||||
if reply != "gemini-api-ok" {
|
||||
t.Fatalf("reply = %q, want gemini-api-ok", reply)
|
||||
}
|
||||
if gotPath != "/models/gemini-test:generateContent" {
|
||||
t.Fatalf("path = %q", gotPath)
|
||||
}
|
||||
if gotKey != "test-key" {
|
||||
t.Fatalf("x-goog-api-key = %q", gotKey)
|
||||
}
|
||||
if gotText != "你好" {
|
||||
t.Fatalf("prompt text = %q", gotText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiChannelRequiresAPIKey(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("GEMINI_API_KEY", "")
|
||||
t.Setenv("GOOGLE_API_KEY", "")
|
||||
|
||||
_, err := forwarderForChannel("gemini", "", connectAgentOptions{})
|
||||
if err == nil || !strings.Contains(err.Error(), "GEMINI_API_KEY") {
|
||||
t.Fatalf("err = %v, want GEMINI_API_KEY validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiCLIStatusUsesAPIKey(t *testing.T) {
|
||||
clearChannelEnv(t)
|
||||
t.Setenv("GEMINI_API_KEY", "")
|
||||
t.Setenv("GOOGLE_API_KEY", "")
|
||||
if got := connectCliStatus("gemini")["installed"]; got != false {
|
||||
t.Fatalf("installed without key = %v, want false", got)
|
||||
}
|
||||
t.Setenv("GOOGLE_API_KEY", "google-key")
|
||||
if got := connectCliStatus("gemini")["installed"]; got != true {
|
||||
t.Fatalf("installed with key = %v, want true", got)
|
||||
}
|
||||
if got := connectCliStatus("gemini")["autoInstall"]; got != false {
|
||||
t.Fatalf("autoInstall = %v, want false", got)
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ const (
|
||||
opencodeNoTextReply = "(本地 agent 无文本输出)"
|
||||
opencodeServerStartupWait = 20 * time.Second
|
||||
opencodeServerPollInterval = 150 * time.Millisecond
|
||||
opencodeConfigContentEnv = "OPENCODE_CONFIG_CONTENT"
|
||||
opencodePermissionEnv = "OPENCODE_PERMISSION"
|
||||
// opencodeHealthProbeTimeout bounds a single /global/health probe so startup
|
||||
// detection stays snappy even though the shared http.Client has no overall
|
||||
// deadline (message turns rely on the per-turn ctx instead).
|
||||
@@ -240,14 +242,7 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error
|
||||
if s.workDir != "" {
|
||||
cmd.Dir = s.workDir
|
||||
}
|
||||
cmd.Env = append(os.Environ(), s.env...)
|
||||
cmd.Env = append(cmd.Env,
|
||||
"OPENCODE_SERVER_USERNAME="+opencodeServerUsername,
|
||||
"OPENCODE_SERVER_PASSWORD="+password,
|
||||
)
|
||||
if !s.yolo {
|
||||
cmd.Env = append(cmd.Env, `OPENCODE_PERMISSION={"bash":"ask","edit":"ask","write":"ask"}`)
|
||||
}
|
||||
cmd.Env = s.commandEnv(password)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
@@ -269,6 +264,97 @@ func (s *opencodeServer) ensure(ctx context.Context) (*opencodeHTTPClient, error
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *opencodeServer) commandEnv(password string) []string {
|
||||
env := append([]string{}, os.Environ()...)
|
||||
env = append(env, s.env...)
|
||||
env = upsertEnv(env, "OPENCODE_SERVER_USERNAME", opencodeServerUsername)
|
||||
env = upsertEnv(env, "OPENCODE_SERVER_PASSWORD", password)
|
||||
config := opencodeNonInteractiveConfig(envValue(env, opencodeConfigContentEnv))
|
||||
env = upsertEnv(env, opencodeConfigContentEnv, config)
|
||||
env = upsertEnv(env, opencodePermissionEnv, opencodeNonInteractivePermission())
|
||||
return env
|
||||
}
|
||||
|
||||
func opencodeNonInteractiveConfig(existing string) string {
|
||||
cfg := map[string]any{}
|
||||
if strings.TrimSpace(existing) != "" {
|
||||
_ = json.Unmarshal([]byte(existing), &cfg)
|
||||
}
|
||||
tools, _ := cfg["tools"].(map[string]any)
|
||||
if tools == nil {
|
||||
tools = map[string]any{}
|
||||
}
|
||||
tools["question"] = false
|
||||
cfg["tools"] = tools
|
||||
|
||||
permission, _ := cfg["permission"].(map[string]any)
|
||||
if permission == nil {
|
||||
permission = map[string]any{}
|
||||
}
|
||||
for k, v := range opencodeNonInteractivePermissionMap() {
|
||||
permission[k] = v
|
||||
}
|
||||
cfg["permission"] = permission
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return `{"tools":{"question":false},"permission":{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}}`
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func opencodeNonInteractivePermission() string {
|
||||
data, err := json.Marshal(opencodeNonInteractivePermissionMap())
|
||||
if err != nil {
|
||||
return `{"*":"allow","read":"allow","edit":"allow","write":"allow","patch":"allow","apply_patch":"allow","glob":"allow","grep":"allow","bash":"allow","task":"allow","skill":"allow","lsp":"allow","webfetch":"allow","websearch":"allow","question":"deny","external_directory":"allow","doom_loop":"allow"}`
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func opencodeNonInteractivePermissionMap() map[string]string {
|
||||
return map[string]string{
|
||||
"*": "allow",
|
||||
"read": "allow",
|
||||
"edit": "allow",
|
||||
"write": "allow",
|
||||
"patch": "allow",
|
||||
"apply_patch": "allow",
|
||||
"glob": "allow",
|
||||
"grep": "allow",
|
||||
"bash": "allow",
|
||||
"task": "allow",
|
||||
"skill": "allow",
|
||||
"lsp": "allow",
|
||||
"webfetch": "allow",
|
||||
"websearch": "allow",
|
||||
"question": "deny",
|
||||
"external_directory": "allow",
|
||||
"doom_loop": "allow",
|
||||
}
|
||||
}
|
||||
|
||||
func envValue(env []string, key string) string {
|
||||
prefix := key + "="
|
||||
for i := len(env) - 1; i >= 0; i-- {
|
||||
if strings.HasPrefix(env[i], prefix) {
|
||||
return strings.TrimPrefix(env[i], prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func upsertEnv(env []string, key, value string) []string {
|
||||
prefix := key + "="
|
||||
out := make([]string, 0, len(env)+1)
|
||||
for _, item := range env {
|
||||
if strings.HasPrefix(item, prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, item)
|
||||
}
|
||||
return append(out, prefix+value)
|
||||
}
|
||||
|
||||
func (s *opencodeServer) waitHealthy(ctx context.Context, client *opencodeHTTPClient) error {
|
||||
deadline := time.Now().Add(opencodeServerStartupWait)
|
||||
var lastErr error
|
||||
|
||||
@@ -261,6 +261,53 @@ func TestNewOpencodeServerHasNoClientTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeServerEnvDisablesInteractiveGates(t *testing.T) {
|
||||
s := newOpencodeServer("opencode", []string{
|
||||
`OPENCODE_CONFIG_CONTENT={"provider":{"x":true},"tools":{"read":true},"permission":{"bash":"ask","edit":"ask","write":"ask","question":"ask"}}`,
|
||||
`OPENCODE_PERMISSION={"bash":"ask"}`,
|
||||
}, "", false)
|
||||
|
||||
env := s.commandEnv("pw")
|
||||
if got := envValue(env, "OPENCODE_SERVER_USERNAME"); got != opencodeServerUsername {
|
||||
t.Fatalf("server username = %q, want %q", got, opencodeServerUsername)
|
||||
}
|
||||
if got := envValue(env, "OPENCODE_SERVER_PASSWORD"); got != "pw" {
|
||||
t.Fatalf("server password = %q, want pw", got)
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(envValue(env, opencodeConfigContentEnv)), &cfg); err != nil {
|
||||
t.Fatalf("decode OPENCODE_CONFIG_CONTENT: %v", err)
|
||||
}
|
||||
if _, ok := cfg["provider"].(map[string]any)["x"]; !ok {
|
||||
t.Fatalf("existing config fields were not preserved: %#v", cfg)
|
||||
}
|
||||
tools := cfg["tools"].(map[string]any)
|
||||
if got := tools["question"]; got != false {
|
||||
t.Fatalf("tools.question = %#v, want false", got)
|
||||
}
|
||||
permission := cfg["permission"].(map[string]any)
|
||||
for _, key := range []string{"bash", "edit", "write", "external_directory", "doom_loop"} {
|
||||
if got := permission[key]; got != "allow" {
|
||||
t.Fatalf("permission.%s = %#v, want allow", key, got)
|
||||
}
|
||||
}
|
||||
if got := permission["question"]; got != "deny" {
|
||||
t.Fatalf("permission.question = %#v, want deny", got)
|
||||
}
|
||||
|
||||
var envPermission map[string]string
|
||||
if err := json.Unmarshal([]byte(envValue(env, opencodePermissionEnv)), &envPermission); err != nil {
|
||||
t.Fatalf("decode OPENCODE_PERMISSION: %v", err)
|
||||
}
|
||||
if got := envPermission["bash"]; got != "allow" {
|
||||
t.Fatalf("OPENCODE_PERMISSION.bash = %q, want allow", got)
|
||||
}
|
||||
if got := envPermission["question"]; got != "deny" {
|
||||
t.Fatalf("OPENCODE_PERMISSION.question = %q, want deny", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpencodeForwarderMessageGovernedByTurnCtx verifies a slow reply is not
|
||||
// cut by a fixed client timeout (the old 30s bug) and that the per-turn ctx is
|
||||
// the real governor instead.
|
||||
|
||||
@@ -164,7 +164,7 @@ func (f *qoderStreamForwarder) commandArgs() []string {
|
||||
"--input-format", "stream-json",
|
||||
}
|
||||
if f.yolo {
|
||||
args = append(args, "--dangerously-skip-permissions")
|
||||
args = append(args, "--permission-mode", "bypass_permissions", "--dangerously-skip-permissions")
|
||||
} else {
|
||||
args = append(args,
|
||||
"--system-prompt", "",
|
||||
@@ -334,6 +334,11 @@ func (f *qoderStreamForwarder) handleControlRequestLocked(line string) bool {
|
||||
if json.Unmarshal([]byte(line), &ev) != nil || ev.Type != "control_request" || ev.RequestID == "" {
|
||||
return false
|
||||
}
|
||||
subtype, _ := ev.Request["subtype"].(string)
|
||||
if subtype == "" {
|
||||
subtype, _ = ev.Request["type"].(string)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[connect][qoder] 自动拒绝/跳过未桥接的 control_request subtype=%q requestId=%s\n", subtype, ev.RequestID)
|
||||
response := map[string]any{
|
||||
"type": "control_response",
|
||||
"response": map[string]any{
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -125,6 +127,39 @@ func TestQoderForwarderKeepsStreamJSONProcessAlive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
type qoderTestWriteCloser struct {
|
||||
bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *qoderTestWriteCloser) Close() error { return nil }
|
||||
|
||||
func TestQoderControlRequestReturnsImmediately(t *testing.T) {
|
||||
stdin := &qoderTestWriteCloser{}
|
||||
f := &qoderStreamForwarder{name: "qoder", stdin: stdin}
|
||||
|
||||
if !f.handleControlRequestLocked(`{"type":"control_request","request_id":"req-1","request":{"type":"permission","subtype":"permission","tool":"bash"}}`) {
|
||||
t.Fatal("control_request was not handled")
|
||||
}
|
||||
|
||||
var got struct {
|
||||
Type string `json:"type"`
|
||||
Response struct {
|
||||
Subtype string `json:"subtype"`
|
||||
RequestID string `json:"request_id"`
|
||||
Code string `json:"code"`
|
||||
} `json:"response"`
|
||||
}
|
||||
if err := json.Unmarshal(bytes.TrimSpace(stdin.Bytes()), &got); err != nil {
|
||||
t.Fatalf("decode response: %v; raw=%q", err, stdin.String())
|
||||
}
|
||||
if got.Type != "control_response" || got.Response.RequestID != "req-1" {
|
||||
t.Fatalf("response identity mismatch: %#v", got)
|
||||
}
|
||||
if got.Response.Subtype != "error" || got.Response.Code != "unsupported_control_request" {
|
||||
t.Fatalf("response should explicitly unblock with unsupported error: %#v", got.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQoderPersistentLineReadsResultField(t *testing.T) {
|
||||
delta, final, done := parseQoderPersistentLine(`{"type":"result","subtype":"success","result":"OK"}`)
|
||||
if delta != "" || final != "OK" || !done {
|
||||
|
||||
@@ -15,42 +15,79 @@ package helpers
|
||||
|
||||
import "sync"
|
||||
|
||||
// convQueue serialises message handling per conversation: same-chat messages
|
||||
// run in arrival order — a Q&A follow-up ("还是不行") must see the previous
|
||||
// turn's session state, and two parallel agent CLIs racing on one --resume
|
||||
// session corrupt the transcript. Different conversations stay parallel.
|
||||
// Ported from the hermes gateway's per-session promise chain
|
||||
// (gateway/run.py session queues).
|
||||
// convQueue serialises agent turns per conversation while coalescing bursts.
|
||||
// Same-chat agent calls must never run in parallel — two turns racing on one
|
||||
// session corrupt the transcript. But a pure FIFO is also bad UX: if a user
|
||||
// keeps sending clarifications while a long turn is running, the bot should not
|
||||
// spend the next several minutes answering stale intermediate prompts. Instead,
|
||||
// messages arriving during the active turn accumulate in one pending batch; when
|
||||
// the active turn finishes, that whole batch is processed as a single follow-up.
|
||||
// Different conversations still run in parallel.
|
||||
type convQueue struct {
|
||||
mu sync.Mutex
|
||||
tails map[string]chan struct{}
|
||||
mu sync.Mutex
|
||||
states map[string]*convQueueState
|
||||
}
|
||||
|
||||
type convQueueState struct {
|
||||
running bool
|
||||
pending []connectQueuedTurn
|
||||
process func([]connectQueuedTurn)
|
||||
}
|
||||
|
||||
func newConvQueue() *convQueue {
|
||||
return &convQueue{tails: map[string]chan struct{}{}}
|
||||
return &convQueue{states: map[string]*convQueueState{}}
|
||||
}
|
||||
|
||||
// run schedules fn after the conversation's previous task and returns
|
||||
// immediately (the Stream callback must ack fast). The chain entry is removed
|
||||
// once the queue for that conversation drains, so idle chats hold no memory.
|
||||
func (q *convQueue) run(convID string, fn func()) {
|
||||
// submit schedules turn handling and returns immediately (the Stream callback
|
||||
// must ack fast). If the conversation is already running, the turn is appended
|
||||
// to that conversation's pending batch instead of creating another queued worker.
|
||||
func (q *convQueue) submit(turn connectQueuedTurn, process func([]connectQueuedTurn)) {
|
||||
convID := turn.convID
|
||||
q.mu.Lock()
|
||||
prev := q.tails[convID]
|
||||
done := make(chan struct{})
|
||||
q.tails[convID] = done
|
||||
q.mu.Unlock()
|
||||
go func() {
|
||||
defer func() {
|
||||
close(done)
|
||||
q.mu.Lock()
|
||||
if q.tails[convID] == done {
|
||||
delete(q.tails, convID)
|
||||
}
|
||||
q.mu.Unlock()
|
||||
}()
|
||||
if prev != nil {
|
||||
<-prev
|
||||
st := q.states[convID]
|
||||
if st == nil {
|
||||
st = &convQueueState{}
|
||||
q.states[convID] = st
|
||||
}
|
||||
if st.running {
|
||||
st.pending = append(st.pending, turn)
|
||||
if process != nil {
|
||||
st.process = process
|
||||
}
|
||||
fn()
|
||||
}()
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
st.running = true
|
||||
st.process = process
|
||||
q.mu.Unlock()
|
||||
go q.drain(convID, []connectQueuedTurn{turn})
|
||||
}
|
||||
|
||||
func (q *convQueue) drain(convID string, batch []connectQueuedTurn) {
|
||||
for {
|
||||
process := q.processFor(convID)
|
||||
if process != nil {
|
||||
process(batch)
|
||||
}
|
||||
|
||||
q.mu.Lock()
|
||||
st := q.states[convID]
|
||||
if st == nil || len(st.pending) == 0 {
|
||||
delete(q.states, convID)
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
batch = append([]connectQueuedTurn(nil), st.pending...)
|
||||
st.pending = nil
|
||||
q.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (q *convQueue) processFor(convID string) func([]connectQueuedTurn) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if st := q.states[convID]; st != nil {
|
||||
return st.process
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -14,36 +14,44 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestConvQueueSerialSameConversation: tasks of one conversation run in
|
||||
// arrival order even when earlier tasks are slow.
|
||||
func TestConvQueueSerialSameConversation(t *testing.T) {
|
||||
// TestConvQueueCoalescesSameConversationBurst: while one conversation is
|
||||
// running, later messages are batched into one follow-up turn instead of forming
|
||||
// a long FIFO of stale prompts.
|
||||
func TestConvQueueCoalescesSameConversationBurst(t *testing.T) {
|
||||
q := newConvQueue()
|
||||
var mu sync.Mutex
|
||||
var order []int
|
||||
var wg sync.WaitGroup
|
||||
for i := 1; i <= 5; i++ {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
q.run("conv-1", func() {
|
||||
defer wg.Done()
|
||||
if i == 1 {
|
||||
time.Sleep(50 * time.Millisecond) // slow head must not be overtaken
|
||||
}
|
||||
mu.Lock()
|
||||
order = append(order, i)
|
||||
mu.Unlock()
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
batches := make(chan []string, 2)
|
||||
var once sync.Once
|
||||
process := func(batch []connectQueuedTurn) {
|
||||
var got []string
|
||||
for _, turn := range batch {
|
||||
got = append(got, turn.text)
|
||||
}
|
||||
batches <- got
|
||||
once.Do(func() {
|
||||
close(started)
|
||||
<-release
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
for i, v := range order {
|
||||
if v != i+1 {
|
||||
t.Fatalf("order = %v, want 1..5 in sequence", order)
|
||||
}
|
||||
|
||||
q.submit(connectQueuedTurn{convID: "conv-1", text: "first"}, process)
|
||||
<-started
|
||||
q.submit(connectQueuedTurn{convID: "conv-1", text: "second"}, process)
|
||||
q.submit(connectQueuedTurn{convID: "conv-1", text: "third"}, process)
|
||||
close(release)
|
||||
|
||||
if got := <-batches; !reflect.DeepEqual(got, []string{"first"}) {
|
||||
t.Fatalf("first batch = %v, want [first]", got)
|
||||
}
|
||||
if got := <-batches; !reflect.DeepEqual(got, []string{"second", "third"}) {
|
||||
t.Fatalf("pending batch = %v, want [second third]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,14 +61,14 @@ func TestConvQueueParallelAcrossConversations(t *testing.T) {
|
||||
q := newConvQueue()
|
||||
slowRelease := make(chan struct{})
|
||||
slowStarted := make(chan struct{})
|
||||
q.run("conv-slow", func() {
|
||||
q.submit(connectQueuedTurn{convID: "conv-slow", text: "slow"}, func([]connectQueuedTurn) {
|
||||
close(slowStarted)
|
||||
<-slowRelease
|
||||
})
|
||||
<-slowStarted
|
||||
|
||||
fastDone := make(chan struct{})
|
||||
q.run("conv-fast", func() { close(fastDone) })
|
||||
q.submit(connectQueuedTurn{convID: "conv-fast", text: "fast"}, func([]connectQueuedTurn) { close(fastDone) })
|
||||
select {
|
||||
case <-fastDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
@@ -75,12 +83,12 @@ func TestConvQueueDrainsEntries(t *testing.T) {
|
||||
q := newConvQueue()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
q.run("conv-1", func() { wg.Done() })
|
||||
q.submit(connectQueuedTurn{convID: "conv-1", text: "done"}, func([]connectQueuedTurn) { wg.Done() })
|
||||
wg.Wait()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
q.mu.Lock()
|
||||
n := len(q.tails)
|
||||
n := len(q.states)
|
||||
q.mu.Unlock()
|
||||
if n == 0 {
|
||||
return
|
||||
|
||||
@@ -250,19 +250,24 @@ func (f *execForwarder) label() string {
|
||||
return fmt.Sprintf("exec:%s (%s, %s)", f.name, f.argv[0], memo)
|
||||
}
|
||||
|
||||
func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
func (f *execForwarder) hasSession(convID string) bool {
|
||||
return f.sessions != nil && strings.TrimSpace(convID) != ""
|
||||
}
|
||||
|
||||
func (f *execForwarder) commandArgs(argv []string, convID, text string) []string {
|
||||
// Session args go right after the binary, before the spec tail — some specs
|
||||
// (qoder) end the tail with `-p` so the prompt must stay the trailing
|
||||
// positional argument.
|
||||
var args []string
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
if f.hasSession(convID) {
|
||||
args = append(args, f.sessions.args(convID)...)
|
||||
}
|
||||
args = append(args, f.argv[1:]...)
|
||||
args = append(args, argv[1:]...)
|
||||
args = append(args, text)
|
||||
cmd := exec.CommandContext(ctx, f.argv[0], args...)
|
||||
return args
|
||||
}
|
||||
|
||||
func (f *execForwarder) configureCommand(cmd *exec.Cmd) {
|
||||
// Run the agent CLI from a clean, empty directory rather than inheriting the
|
||||
// connector's CWD (often $HOME). Some agents scan the working tree / nearby
|
||||
// config on startup — e.g. `claude -p` takes ~29s from a large $HOME but ~4s
|
||||
@@ -278,37 +283,60 @@ func (f *execForwarder) forward(ctx context.Context, convID, text string) (strin
|
||||
if len(f.env) > 0 {
|
||||
cmd.Env = append(os.Environ(), f.env...)
|
||||
}
|
||||
out, err := cmd.Output()
|
||||
s := strings.TrimSpace(string(out))
|
||||
// Guard against a backend error being mistaken for the answer: some agent
|
||||
// CLIs (claude) print "API Error: 4xx ..." to stdout and still exit 0, so a
|
||||
// non-empty stdout is not proof of a real reply. If stdout is a bare backend
|
||||
// error, return an actionable hint instead of forwarding the raw error into
|
||||
// the chat (issue #14: a custom-provider 422 was echoed to the group).
|
||||
if s != "" && !agentReplyIsError(s) {
|
||||
return brandReply(f.name, s), nil
|
||||
}
|
||||
if s != "" && agentReplyIsError(s) {
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
|
||||
func (f *execForwarder) forward(ctx context.Context, convID, text string) (string, error) {
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
run := func() (string, string, error) {
|
||||
args := f.commandArgs(f.argv, convID, text)
|
||||
cmd := exec.CommandContext(ctx, f.argv[0], args...)
|
||||
f.configureCommand(cmd)
|
||||
out, err := cmd.Output()
|
||||
s := strings.TrimSpace(string(out))
|
||||
// Guard against a backend error being mistaken for the answer: some agent
|
||||
// CLIs (claude) print "API Error: 4xx ..." to stdout and still exit 0, so a
|
||||
// non-empty stdout is not proof of a real reply. If stdout is a bare backend
|
||||
// error, return an actionable hint instead of forwarding the raw error into
|
||||
// the chat (issue #14: a custom-provider 422 was echoed to the group).
|
||||
if s != "" && !agentReplyIsError(s) {
|
||||
return brandReply(f.name, s), "", nil
|
||||
}
|
||||
return agentBackendErrorReply(s), nil
|
||||
if s != "" && agentReplyIsError(s) {
|
||||
if f.hasSession(convID) {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return agentBackendErrorReply(s), "", nil
|
||||
}
|
||||
if err != nil {
|
||||
msg := execErrorMessage(err)
|
||||
return "", msg, err
|
||||
}
|
||||
return "(本地 agent 无文本输出)", "", nil
|
||||
}
|
||||
if err != nil {
|
||||
|
||||
reply, msg, err := run()
|
||||
if err == nil {
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
if f.hasSession(convID) && agentSessionMissingError(msg) {
|
||||
f.sessions.reset(convID)
|
||||
reply, msg, err = run()
|
||||
if err == nil {
|
||||
return reply, nil
|
||||
}
|
||||
}
|
||||
|
||||
if f.hasSession(convID) {
|
||||
// Self-heal session state: if this conversation's session is broken
|
||||
// (e.g. --resume of a session that was never created or got cleaned),
|
||||
// drop the mapping so the next message starts a fresh session instead
|
||||
// of failing forever.
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
msg := err.Error()
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
msg = strings.TrimSpace(string(ee.Stderr))
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return "(本地 agent 无文本输出)", nil
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
|
||||
// convSessions maps a DingTalk conversation to a stable agent session ID, so a
|
||||
@@ -415,6 +443,17 @@ func brandReply(channel, reply string) string {
|
||||
return qoderworkIdentityRe.ReplaceAllString(reply, "我是 QoderWork 助手,钉钉群里的智能助手。")
|
||||
}
|
||||
|
||||
func execErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
msg := err.Error()
|
||||
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
|
||||
msg = strings.TrimSpace(string(ee.Stderr))
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// agentReplyIsError reports whether an agent's stdout is a bare backend error
|
||||
// rather than a real answer. Claude Code prints provider failures as
|
||||
// "API Error: <status> ..." on stdout and may still exit 0, so the connector
|
||||
@@ -424,6 +463,16 @@ func agentReplyIsError(s string) bool {
|
||||
return strings.HasPrefix(strings.TrimSpace(s), "API Error:")
|
||||
}
|
||||
|
||||
// agentSessionMissingError recognizes stale addressable sessions. Agent CLIs
|
||||
// differ in wording, but the operational meaning is the same: the connector's
|
||||
// persisted session id points at a conversation the CLI can no longer resume.
|
||||
func agentSessionMissingError(msg string) bool {
|
||||
lower := strings.ToLower(strings.TrimSpace(msg))
|
||||
return strings.Contains(lower, "no conversation found with session id") ||
|
||||
strings.Contains(lower, "conversation not found") ||
|
||||
strings.Contains(lower, "session not found")
|
||||
}
|
||||
|
||||
// agentBackendErrorReply turns a bare backend error into a short, actionable
|
||||
// Chinese message for the chat, instead of echoing the raw provider error
|
||||
// (issue #14). It keeps only the first line of the raw error, truncated.
|
||||
@@ -588,10 +637,11 @@ func claudeUserSettingsEnv() []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// agentSpecs is the registry of local-agent channels. Most forward to a local
|
||||
// agentSpecs is the registry of agent channels. Most forward to a local
|
||||
// headless CLI (one-shot per message, 24/7, no interactive session). Codex uses
|
||||
// the local CLI binary only to host app-server. Exact headless flags for
|
||||
// non-Codex channels can be overridden per run with DWS_AGENT_CMD.
|
||||
// the local CLI binary only to host app-server; Gemini uses its HTTP API.
|
||||
// Exact headless flags for CLI channels can be overridden per run with
|
||||
// DWS_AGENT_CMD.
|
||||
//
|
||||
// Install policy: npm/pipx (package managers) are auto-installed; curl|bash
|
||||
// remote-script installs and desktop apps are hint-only (we do not silently pipe
|
||||
@@ -608,9 +658,8 @@ var agentSpecs = map[string]agentSpec{
|
||||
"codex": {app: "OpenAI Codex CLI", bins: []string{"codex"},
|
||||
install: []string{"npm", "i", "-g", "@openai/codex"}, hint: "npm i -g @openai/codex",
|
||||
modelFlag: "-m"},
|
||||
"gemini": {app: "Gemini CLI", bins: []string{"gemini"}, argvTail: []string{"-p"},
|
||||
install: []string{"npm", "i", "-g", "@google/gemini-cli"}, hint: "npm i -g @google/gemini-cli",
|
||||
modelFlag: "-m"},
|
||||
"gemini": {app: "Gemini API",
|
||||
hint: "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL"},
|
||||
// opencode is resolved here only to find the local binary. The forwarder
|
||||
// uses `opencode serve --pure` plus HTTP session/message APIs instead of
|
||||
// parsing `opencode run` stdout.
|
||||
@@ -719,6 +768,17 @@ func connectCliStatus(channel string) map[string]any {
|
||||
status["command"] = command
|
||||
}
|
||||
return status
|
||||
case "gemini":
|
||||
status := map[string]any{
|
||||
"required": "GEMINI_API_KEY or GOOGLE_API_KEY",
|
||||
"installed": geminiAPIKey() != "",
|
||||
"autoInstall": false,
|
||||
"installHint": "设置 GEMINI_API_KEY(或 GOOGLE_API_KEY);模型可用 --agent-model 指定;Gemini-compatible 代理可设置 GEMINI_API_BASE_URL",
|
||||
}
|
||||
if base := strings.TrimSpace(geminiAPIBaseURL()); base != "" {
|
||||
status["baseURL"] = base
|
||||
}
|
||||
return status
|
||||
}
|
||||
spec, ok := agentSpecs[channel]
|
||||
if !ok {
|
||||
@@ -785,6 +845,10 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo
|
||||
if !ok {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("渠道 %q 不是 stream-bridge 渠道,无 forwarder", channel))
|
||||
}
|
||||
overridden := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" && channel != "codex"
|
||||
if channel == "gemini" && !overridden {
|
||||
return newGeminiAPIForwarder(timeout, opts)
|
||||
}
|
||||
// Resolve the agent CLI (PATH → app bundle → auto-install → guidance) and
|
||||
// preflight here so a missing dependency errors at connect time.
|
||||
argv, env, err := resolveExecAgent(channel)
|
||||
@@ -795,7 +859,6 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo
|
||||
// model or session flags we cannot know are valid for it. Codex ignores the
|
||||
// override because its channel is app-server only; custom commands belong on
|
||||
// --channel custom.
|
||||
overridden := strings.TrimSpace(os.Getenv("DWS_AGENT_CMD")) != "" && channel != "codex"
|
||||
userPickedModel := opts.Model != "" || strings.TrimSpace(os.Getenv("DWS_AGENT_MODEL")) != ""
|
||||
if !overridden && opts.Model != "" {
|
||||
if spec.modelFlag == "" {
|
||||
@@ -850,14 +913,9 @@ func forwarderForChannel(channel, clientID string, opts connectAgentOptions) (fo
|
||||
if !overridden && opts.Yolo {
|
||||
switch channel {
|
||||
case "claudecode", "codebuddy", "workbuddy":
|
||||
argv = append(argv, "--dangerously-skip-permissions")
|
||||
argv = append(argv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions")
|
||||
if len(streamArgv) > 0 {
|
||||
streamArgv = append(streamArgv, "--dangerously-skip-permissions")
|
||||
}
|
||||
case "gemini":
|
||||
argv = append(argv, "--yolo")
|
||||
if len(streamArgv) > 0 {
|
||||
streamArgv = append(streamArgv, "--yolo")
|
||||
streamArgv = append(streamArgv, "--permission-mode", "bypassPermissions", "--dangerously-skip-permissions")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -883,6 +941,18 @@ func applyModelArg(argv []string, flag, model string) []string {
|
||||
return append(out[:1:1], append([]string{flag, model}, out[1:]...)...)
|
||||
}
|
||||
|
||||
func insertBeforeArg(argv []string, marker string, values ...string) []string {
|
||||
for i := 1; i < len(argv); i++ {
|
||||
if argv[i] == marker {
|
||||
out := append([]string(nil), argv[:i]...)
|
||||
out = append(out, values...)
|
||||
return append(out, argv[i:]...)
|
||||
}
|
||||
}
|
||||
out := append([]string(nil), argv...)
|
||||
return append(out, values...)
|
||||
}
|
||||
|
||||
// msgDedup tracks recently-seen MsgIds so a redelivered message is not
|
||||
// processed (and replied to) twice. Memory is bounded: once the set reaches
|
||||
// limit it is cleared (the chance of a very old MsgId being redelivered after a
|
||||
@@ -935,6 +1005,80 @@ type connectExtras struct {
|
||||
persona string
|
||||
}
|
||||
|
||||
type connectQueuedTurn struct {
|
||||
convID string
|
||||
text string
|
||||
picCode string
|
||||
fileInfo fileInboundInfo
|
||||
webhook string
|
||||
msgID string
|
||||
msgType string
|
||||
senderStaffID string
|
||||
conversationID string
|
||||
conversationType string
|
||||
callbackData chatbot.BotCallbackDataModel
|
||||
}
|
||||
|
||||
func mergeConnectQueuedTurns(turns []connectQueuedTurn) connectQueuedTurn {
|
||||
if len(turns) == 0 {
|
||||
return connectQueuedTurn{}
|
||||
}
|
||||
if len(turns) == 1 {
|
||||
return turns[0]
|
||||
}
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
if connectTurnShouldStayStandalone(turns[i]) {
|
||||
return turns[i]
|
||||
}
|
||||
}
|
||||
merged := turns[len(turns)-1]
|
||||
lines := make([]string, 0, len(turns)+1)
|
||||
lines = append(lines, "用户在上一轮处理期间连续发送了以下消息,请把它们作为同一个最新请求一起处理:")
|
||||
for i, turn := range turns {
|
||||
lines = append(lines, fmt.Sprintf("%d. %s", i+1, connectTurnSummary(turn)))
|
||||
}
|
||||
merged.text = strings.Join(lines, "\n")
|
||||
if merged.picCode == "" && !merged.fileInfo.hasActionable() {
|
||||
for i := len(turns) - 1; i >= 0; i-- {
|
||||
if turns[i].picCode != "" {
|
||||
merged.picCode = turns[i].picCode
|
||||
break
|
||||
}
|
||||
if turns[i].fileInfo.hasActionable() {
|
||||
merged.fileInfo = turns[i].fileInfo
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func connectTurnShouldStayStandalone(turn connectQueuedTurn) bool {
|
||||
if _, ok := parseConnectControlCommand(turn.text); ok {
|
||||
return true
|
||||
}
|
||||
if _, ok := parseDecisionWord(turn.text); ok {
|
||||
return true
|
||||
}
|
||||
return isRetryWord(turn.text)
|
||||
}
|
||||
|
||||
func connectTurnSummary(turn connectQueuedTurn) string {
|
||||
if text := strings.TrimSpace(turn.text); text != "" {
|
||||
return text
|
||||
}
|
||||
if turn.picCode != "" {
|
||||
return "[图片]"
|
||||
}
|
||||
if turn.fileInfo.hasActionable() {
|
||||
if name := strings.TrimSpace(turn.fileInfo.FileName); name != "" {
|
||||
return "[文件: " + name + "]"
|
||||
}
|
||||
return "[文件]"
|
||||
}
|
||||
return "[空消息]"
|
||||
}
|
||||
|
||||
func runStreamConnector(ctx context.Context, channel, clientID, clientSecret string, fwd forwarder, cardCli *aiCardClient, extras *connectExtras) error {
|
||||
if extras == nil {
|
||||
extras = &connectExtras{}
|
||||
@@ -1072,11 +1216,36 @@ func runStreamConnector(ctx context.Context, channel, clientID, clientSecret str
|
||||
if convID == "" {
|
||||
convID = strings.TrimSpace(data.SenderStaffId)
|
||||
}
|
||||
callbackData := data
|
||||
msgID := strings.TrimSpace(data.MsgId)
|
||||
// Same-conversation messages run in arrival order (follow-ups need the
|
||||
// previous turn's session state); different conversations in parallel.
|
||||
queue.run(convID, func() {
|
||||
turn := connectQueuedTurn{
|
||||
convID: convID,
|
||||
text: text,
|
||||
picCode: picCode,
|
||||
fileInfo: fileInfo,
|
||||
webhook: webhook,
|
||||
msgID: msgID,
|
||||
msgType: msgtype,
|
||||
senderStaffID: strings.TrimSpace(data.SenderStaffId),
|
||||
conversationID: strings.TrimSpace(data.ConversationId),
|
||||
conversationType: strings.TrimSpace(data.ConversationType),
|
||||
callbackData: *data,
|
||||
}
|
||||
// Same-conversation agent calls never run in parallel; messages received
|
||||
// while a turn is running are merged into one pending follow-up instead
|
||||
// of forming an unbounded stale FIFO.
|
||||
queue.submit(turn, func(turns []connectQueuedTurn) {
|
||||
turn := mergeConnectQueuedTurns(turns)
|
||||
if len(turns) > 1 {
|
||||
fmt.Fprintf(os.Stderr, "[connect] 合并 %d 条待处理消息 (convId=%s, latestMsgId=%s)\n", len(turns), turn.convID, turn.msgID)
|
||||
}
|
||||
text := turn.text
|
||||
picCode := turn.picCode
|
||||
fileInfo := turn.fileInfo
|
||||
webhook := turn.webhook
|
||||
convID := turn.convID
|
||||
msgID := turn.msgID
|
||||
msgtype := turn.msgType
|
||||
callbackData := &turn.callbackData
|
||||
// Digital-twin text approval: if this is the OWNER replying
|
||||
// 「同意」/「拒绝」 (in their 1:1 chat with the bot) to the pending
|
||||
// request, route it to the gate (decide → execute/decline, with
|
||||
|
||||
@@ -162,3 +162,31 @@ func TestEnvDurationMS(t *testing.T) {
|
||||
t.Fatalf("invalid env falls back to default = %v, want %v", got, def)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsBuildsSinglePrompt(t *testing.T) {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{convID: "conv-1", text: "第一条", msgID: "m1"},
|
||||
{convID: "conv-1", text: "补充:按今天的数据", msgID: "m2"},
|
||||
{convID: "conv-1", text: "最后改成周报口径", msgID: "m3"},
|
||||
})
|
||||
if merged.msgID != "m3" {
|
||||
t.Fatalf("merged msgID = %q, want latest m3", merged.msgID)
|
||||
}
|
||||
for _, want := range []string{"连续发送", "1. 第一条", "2. 补充:按今天的数据", "3. 最后改成周报口径"} {
|
||||
if !strings.Contains(merged.text, want) {
|
||||
t.Fatalf("merged prompt missing %q:\n%s", want, merged.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeConnectQueuedTurnsKeepsControlMessagesStandalone(t *testing.T) {
|
||||
for _, text := range []string{"/clear", "同意", "拒绝", "重试"} {
|
||||
merged := mergeConnectQueuedTurns([]connectQueuedTurn{
|
||||
{convID: "conv-1", text: "先查一下", msgID: "m1"},
|
||||
{convID: "conv-1", text: text, msgID: "m2"},
|
||||
})
|
||||
if merged.text != text || merged.msgID != "m2" {
|
||||
t.Fatalf("control %q merged to (%q,%q), want standalone latest", text, merged.text, merged.msgID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
@@ -53,76 +52,79 @@ func (f *execForwarder) forwardStream(ctx context.Context, convID, text string,
|
||||
ctx, cancel := applyTimeout(ctx, f.timeout)
|
||||
defer cancel()
|
||||
|
||||
var args []string
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
args = append(args, f.sessions.args(convID)...)
|
||||
}
|
||||
args = append(args, f.streamArgv[1:]...)
|
||||
args = append(args, text)
|
||||
cmd := exec.CommandContext(ctx, f.streamArgv[0], args...)
|
||||
if f.workDir != "" {
|
||||
cmd.Dir = f.workDir
|
||||
} else {
|
||||
cmd.Dir = connectWorkDir()
|
||||
}
|
||||
if len(f.env) > 0 {
|
||||
cmd.Env = append(os.Environ(), f.env...)
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var stderrBuf strings.Builder
|
||||
cmd.Stderr = &stderrBuf
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", err
|
||||
run := func() (string, string, error) {
|
||||
args := f.commandArgs(f.streamArgv, convID, text)
|
||||
cmd := exec.CommandContext(ctx, f.streamArgv[0], args...)
|
||||
f.configureCommand(cmd)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", err.Error(), err
|
||||
}
|
||||
var stderrBuf strings.Builder
|
||||
cmd.Stderr = &stderrBuf
|
||||
if err := cmd.Start(); err != nil {
|
||||
return "", err.Error(), err
|
||||
}
|
||||
|
||||
var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns)
|
||||
finalText := ""
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
delta, final := parseStreamLine(f.parser, line)
|
||||
if delta != "" {
|
||||
acc.WriteString(delta)
|
||||
onDelta(brandReply(f.name, acc.String()))
|
||||
}
|
||||
if final != "" {
|
||||
finalText = final
|
||||
}
|
||||
}
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
if finalText == "" {
|
||||
finalText = strings.TrimSpace(acc.String())
|
||||
}
|
||||
// A bare backend error (e.g. claude's "API Error: 4xx ...") must not be
|
||||
// forwarded as the answer (issue #14): return an actionable hint instead.
|
||||
if agentReplyIsError(finalText) {
|
||||
if f.hasSession(convID) {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return agentBackendErrorReply(finalText), "", nil
|
||||
}
|
||||
if finalText != "" {
|
||||
return brandReply(f.name, finalText), "", nil
|
||||
}
|
||||
if waitErr != nil {
|
||||
msg := waitErr.Error()
|
||||
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
|
||||
msg = s
|
||||
}
|
||||
return "", msg, waitErr
|
||||
}
|
||||
return "(本地 agent 无文本输出)", "", nil
|
||||
}
|
||||
|
||||
var acc strings.Builder // accumulated visible text ("cc" deltas / "qoder" turns)
|
||||
finalText := ""
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || !strings.HasPrefix(line, "{") {
|
||||
continue
|
||||
}
|
||||
delta, final := parseStreamLine(f.parser, line)
|
||||
if delta != "" {
|
||||
acc.WriteString(delta)
|
||||
onDelta(brandReply(f.name, acc.String()))
|
||||
}
|
||||
if final != "" {
|
||||
finalText = final
|
||||
reply, msg, err := run()
|
||||
if err == nil {
|
||||
return reply, nil
|
||||
}
|
||||
if f.hasSession(convID) && agentSessionMissingError(msg) {
|
||||
f.sessions.reset(convID)
|
||||
reply, msg, err = run()
|
||||
if err == nil {
|
||||
return reply, nil
|
||||
}
|
||||
}
|
||||
waitErr := cmd.Wait()
|
||||
|
||||
if finalText == "" {
|
||||
finalText = strings.TrimSpace(acc.String())
|
||||
}
|
||||
// A bare backend error (e.g. claude's "API Error: 4xx ...") must not be
|
||||
// forwarded as the answer (issue #14): return an actionable hint instead.
|
||||
if agentReplyIsError(finalText) {
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
return agentBackendErrorReply(finalText), nil
|
||||
}
|
||||
if finalText != "" {
|
||||
return brandReply(f.name, finalText), nil
|
||||
}
|
||||
if f.sessions != nil && strings.TrimSpace(convID) != "" {
|
||||
if f.hasSession(convID) {
|
||||
f.sessions.reset(convID)
|
||||
}
|
||||
if waitErr != nil {
|
||||
msg := waitErr.Error()
|
||||
if s := strings.TrimSpace(stderrBuf.String()); s != "" {
|
||||
msg = s
|
||||
}
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
return "(本地 agent 无文本输出)", nil
|
||||
return "", fmt.Errorf("本地 %s agent 调用失败:%s", f.name, truncateRunes(msg, 300))
|
||||
}
|
||||
|
||||
// parseStreamLine extracts (visible-text delta, final text) from one JSONL
|
||||
|
||||
@@ -583,7 +583,7 @@ func newDevAppRobotConnectCommand(runner executor.Runner) *cobra.Command {
|
||||
cmd.Flags().String("robot-client-id", "", "现成机器人 clientId(AppKey)")
|
||||
cmd.Flags().String("robot-client-secret", "", "现成机器人 clientSecret(AppSecret)")
|
||||
cmd.Flags().String("unified-app-id", "", "统一应用 ID:复用 dev app credentials get 自动取凭证(替代手填 robot-client-id/secret)")
|
||||
cmd.Flags().String("agent-model", "", "覆盖本地 agent 模型(如 claude 的 sonnet/opus;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
|
||||
cmd.Flags().String("agent-model", "", "覆盖 agent 模型(如 claude 的 sonnet/opus、gemini-2.5-pro;默认用渠道内置模型,求快);env: DWS_AGENT_MODEL")
|
||||
cmd.Flags().String("agent-workdir", "", "本地 agent 的运行目录(放知识文件可给机器人上下文;默认空白临时目录,求快);env: DWS_AGENT_WORKDIR")
|
||||
cmd.Flags().Bool("agent-memory", true, "按会话续聊:同一群/单聊共享 agent 会话上下文(codex/opencode/qoder/qoderwork/claudecode/codebuddy/workbuddy 支持;--agent-memory=false 关闭)")
|
||||
cmd.Flags().Int("agent-timeout", 0, "每次 agent 调用的超时时间(秒),0=不限制(默认);env: DWS_AGENT_TIMEOUT_MS(毫秒)")
|
||||
@@ -751,7 +751,7 @@ func resolveAgentYoloMode(cmd *cobra.Command) (bool, error) {
|
||||
// connectAgentOptionsPayload renders the effective agent tuning for the
|
||||
// dry-run preview, including whether session memory actually applies to the
|
||||
// chosen channel (Codex uses app-server threads, opencode uses opencode serve
|
||||
// sessions, CLI session channels use --session-id/--resume, and gemini stays
|
||||
// sessions, CLI session channels use --session-id/--resume, and Gemini API stays
|
||||
// stateless today).
|
||||
func connectAgentOptionsPayload(channel string, opts connectAgentOptions) map[string]any {
|
||||
spec, ok := agentSpecs[channel]
|
||||
|
||||
@@ -29,6 +29,8 @@ func clearChannelEnv(t *testing.T) {
|
||||
"DWS_CONNECT_CMD", "DWS_AGENT_CMD",
|
||||
"WORKBUDDY_CONFIG_DIR", "WORKBUDDY_APP_NAME", "CLAUDECODE",
|
||||
"DWS_AGENT_PERMISSION_MODE", "DWS_AGENT_APPROVAL_MODE",
|
||||
"GEMINI_API_KEY", "GOOGLE_API_KEY", "GEMINI_API_BASE_URL",
|
||||
"GOOGLE_GEMINI_API_BASE_URL", "GEMINI_MODEL",
|
||||
} {
|
||||
t.Setenv(k, "")
|
||||
}
|
||||
@@ -264,7 +266,7 @@ func TestAgentSpecsCoverMainstreamAgents(t *testing.T) {
|
||||
t.Errorf("agentSpecs missing channel %q", ch)
|
||||
continue
|
||||
}
|
||||
if len(spec.bins) == 0 {
|
||||
if ch != "gemini" && len(spec.bins) == 0 {
|
||||
t.Errorf("channel %q has no bins", ch)
|
||||
}
|
||||
if spec.hint == "" {
|
||||
|
||||
+289
-3
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ func init() {
|
||||
name string
|
||||
fn func() *cobra.Command
|
||||
}{
|
||||
{"agoal", newAgoalCommand},
|
||||
{"aisearch", newAisearchCommand},
|
||||
{"aitable", newAitableCommand},
|
||||
{"attendance", newAttendanceCommand},
|
||||
|
||||
@@ -48,6 +48,8 @@ func newSheetCommand() *cobra.Command {
|
||||
dws sheet insert-dimension 在指定位置插入行或列
|
||||
dws sheet delete-dimension 删除指定位置的行或列
|
||||
dws sheet update-dimension 更新指定范围行/列属性(显隐、行高/列宽)
|
||||
dws sheet group-dimension 对指定连续行/列创建分组
|
||||
dws sheet ungroup-dimension 取消指定连续行/列分组
|
||||
dws sheet media-upload 上传附件到表格
|
||||
dws sheet write-image 上传图片并写入表格单元格
|
||||
dws sheet replace 全局查找替换文本
|
||||
|
||||
@@ -39,6 +39,8 @@ var batchOpDispatch = map[string]batchOpMapping{
|
||||
"csv-put": {"set_range_from_csv", BuildCsvPutArgs},
|
||||
"delete-float-image": {"delete_float_image", BuildDeleteFloatImageArgs},
|
||||
"update-dimension": {"update_dimension", BuildUpdateDimensionArgs},
|
||||
"group-dimension": {"group_dimension", BuildGroupDimensionArgs},
|
||||
"ungroup-dimension": {"ungroup_dimension", BuildUngroupDimensionArgs},
|
||||
}
|
||||
|
||||
// translateBatchOp translates a batch operation from CLI format to MCP format.
|
||||
@@ -269,6 +271,27 @@ func BuildUpdateDimensionArgs(input map[string]any) map[string]any {
|
||||
return args
|
||||
}
|
||||
|
||||
// BuildGroupDimensionArgs converts CLI flags to MCP params for group_dimension.
|
||||
func BuildGroupDimensionArgs(input map[string]any) map[string]any {
|
||||
groupState := batchStr(input, "group-state", "groupState")
|
||||
if groupState == "" {
|
||||
groupState = "expand"
|
||||
}
|
||||
return map[string]any{
|
||||
"sheetId": batchStr(input, "sheet-id"),
|
||||
"range": batchStr(input, "range"),
|
||||
"groupState": groupState,
|
||||
}
|
||||
}
|
||||
|
||||
// BuildUngroupDimensionArgs converts CLI flags to MCP params for ungroup_dimension.
|
||||
func BuildUngroupDimensionArgs(input map[string]any) map[string]any {
|
||||
return map[string]any{
|
||||
"sheetId": batchStr(input, "sheet-id"),
|
||||
"range": batchStr(input, "range"),
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCsvContent resolves @filepath and - stdin to CSV text, matching standalone csv-put behavior.
|
||||
func resolveCsvContent(csvVal string) string {
|
||||
switch {
|
||||
@@ -307,8 +330,12 @@ CLI 层自动翻译为 MCP toolName + 参数名,无需记忆 MCP 参数名。
|
||||
支持的 CLI 命令名:
|
||||
range clear / range update / merge-cells / unmerge-cells / update-dimension
|
||||
range fill / range copy-to / add-dimension / delete-dimension / move-dimension
|
||||
group-dimension / ungroup-dimension
|
||||
set-dropdown / delete-dropdown / csv-put / delete-float-image
|
||||
|
||||
注意:batch-update 中 group-dimension 适合默认展开分组;需要 --group-state fold 时请使用独立
|
||||
dws sheet group-dimension 命令。
|
||||
|
||||
--operations 是 JSON 数组,每项包含:
|
||||
toolName CLI 命令名(如 "range clear", "range update")
|
||||
input 该命令的入参(不含 --node),键用 flag 名去掉 --
|
||||
|
||||
@@ -375,6 +375,83 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
updateDimensionCmd.Flags().Bool("hidden", false, "是否隐藏 (true=隐藏, false=显示)")
|
||||
updateDimensionCmd.Flags().Int("pixel-size", 0, "行高或列宽(像素),ROWS 时为行高,COLUMNS 时为列宽")
|
||||
|
||||
groupDimensionCmd := &cobra.Command{
|
||||
Use: "group-dimension",
|
||||
Short: "对指定连续行/列创建分组",
|
||||
Long: `对钉钉表格指定工作表中的连续整行或整列创建分组。
|
||||
|
||||
--range 使用整行/整列范围:
|
||||
行分组:3:7 或 3
|
||||
列分组:C:F 或 C
|
||||
|
||||
支持在 --range 中携带工作表前缀(如 "Sheet1!3:7" / "Sheet1!C:F"),此时将忽略 --sheet-id。
|
||||
创建后可通过 sheet info --include groups 回读 rowGroups / columnGroups。
|
||||
|
||||
--group-state 支持 expand / fold,默认 expand。`,
|
||||
Example: ` # 分组第 3~7 行
|
||||
dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "3:7"
|
||||
|
||||
# 分组并折叠 C~F 列
|
||||
dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "C:F" --group-state fold
|
||||
|
||||
# 使用工作表前缀(忽略 --sheet-id)
|
||||
dws sheet group-dimension --node NODE_ID --sheet-id SHEET_ID --range "Sheet1!3:7"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
|
||||
return err
|
||||
}
|
||||
groupState, _ := cmd.Flags().GetString("group-state")
|
||||
switch groupState {
|
||||
case "", "expand":
|
||||
groupState = "expand"
|
||||
case "fold":
|
||||
default:
|
||||
return fmt.Errorf("--group-state 必须为 expand 或 fold,当前值: %s", groupState)
|
||||
}
|
||||
return callMCPTool("group_dimension", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"range": mustGetFlag(cmd, "range"),
|
||||
"groupState": groupState,
|
||||
})
|
||||
},
|
||||
}
|
||||
groupDimensionCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
groupDimensionCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
groupDimensionCmd.Flags().String("range", "", `整行/整列范围 (必填),如 "3:7" 或 "C:F"`)
|
||||
groupDimensionCmd.Flags().String("group-state", "expand", "创建后的分组状态: expand 或 fold")
|
||||
|
||||
ungroupDimensionCmd := &cobra.Command{
|
||||
Use: "ungroup-dimension",
|
||||
Short: "取消指定连续行/列分组",
|
||||
Long: `取消钉钉表格指定工作表中的连续整行或整列分组。
|
||||
|
||||
--range 使用整行/整列范围:
|
||||
行分组:3:7 或 3
|
||||
列分组:C:F 或 C
|
||||
|
||||
支持在 --range 中携带工作表前缀(如 "Sheet1!3:7" / "Sheet1!C:F"),此时将忽略 --sheet-id。
|
||||
取消后可通过 sheet info --include groups 回读 rowGroups / columnGroups。`,
|
||||
Example: ` # 取消第 3~7 行分组
|
||||
dws sheet ungroup-dimension --node NODE_ID --sheet-id SHEET_ID --range "3:7"
|
||||
|
||||
# 取消 C~F 列分组
|
||||
dws sheet ungroup-dimension --node NODE_ID --sheet-id SHEET_ID --range "C:F"`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "node", "sheet-id", "range"); err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("ungroup_dimension", map[string]any{
|
||||
"nodeId": mustGetFlag(cmd, "node"),
|
||||
"sheetId": mustGetFlag(cmd, "sheet-id"),
|
||||
"range": mustGetFlag(cmd, "range"),
|
||||
})
|
||||
},
|
||||
}
|
||||
ungroupDimensionCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)")
|
||||
ungroupDimensionCmd.Flags().String("sheet-id", "", "工作表 ID 或名称 (必填)")
|
||||
ungroupDimensionCmd.Flags().String("range", "", `整行/整列范围 (必填),如 "3:7" 或 "C:F"`)
|
||||
|
||||
// ── dropdown ──────────────────────────────────────────────────
|
||||
setDropdownCmd := &cobra.Command{
|
||||
Use: "set-dropdown",
|
||||
@@ -499,7 +576,7 @@ sheetId 支持传入工作表 ID 或工作表名称,可通过 sheet list 获
|
||||
return []*cobra.Command{
|
||||
insertDimensionCmd, moveDimensionCmd, addDimensionCmd,
|
||||
mergeCellsCmd, unmergeRangeCmd,
|
||||
deleteDimensionCmd, updateDimensionCmd,
|
||||
deleteDimensionCmd, updateDimensionCmd, groupDimensionCmd, ungroupDimensionCmd,
|
||||
setDropdownCmd, getDropdownCmd, deleteDropdownCmd,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func requireWukongSyncCommand(t *testing.T, root *cobra.Command, path ...string) *cobra.Command {
|
||||
t.Helper()
|
||||
cmd, remaining, err := root.Find(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: command not found: %v", path, err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("%s: unresolved path suffix %v (resolved to %q)", path, remaining, cmd.CommandPath())
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func requireWukongSyncFlags(t *testing.T, cmd *cobra.Command, names ...string) {
|
||||
t.Helper()
|
||||
for _, name := range names {
|
||||
if cmd.Flags().Lookup(name) == nil {
|
||||
t.Fatalf("%s: missing flag --%s", cmd.CommandPath(), name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWukongSyncMailCommands(t *testing.T) {
|
||||
root := newMailCommand()
|
||||
cases := []struct {
|
||||
path []string
|
||||
flags []string
|
||||
}{
|
||||
{[]string{"mailbox", "profile"}, []string{"email"}},
|
||||
{[]string{"message", "batch-get"}, []string{"email", "ids"}},
|
||||
{[]string{"sent-message", "recall"}, []string{"email", "id", "subject", "yes"}},
|
||||
{[]string{"sent-message", "recall-detail"}, []string{"email", "id"}},
|
||||
{[]string{"auto-reply", "update"}, []string{"email", "enabled", "start", "end", "scope", "content"}},
|
||||
{[]string{"allow-list", "list"}, []string{"email"}},
|
||||
{[]string{"allow-list", "add"}, []string{"email", "entries"}},
|
||||
{[]string{"allow-list", "remove"}, []string{"email", "entries"}},
|
||||
{[]string{"block-list", "list"}, []string{"email"}},
|
||||
{[]string{"block-list", "add"}, []string{"email", "entries"}},
|
||||
{[]string{"block-list", "remove"}, []string{"email", "entries"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd := requireWukongSyncCommand(t, root, tc.path...)
|
||||
requireWukongSyncFlags(t, cmd, tc.flags...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWukongSyncChatCommands(t *testing.T) {
|
||||
root := newChatCommand()
|
||||
cases := []struct {
|
||||
path []string
|
||||
flags []string
|
||||
}{
|
||||
{[]string{"group", "notice", "create"}, []string{"group", "content", "sticky", "send-ding", "run-at"}},
|
||||
{[]string{"group", "notice", "edit"}, []string{"group", "notice-id", "content", "sticky", "send-ding"}},
|
||||
{[]string{"group", "notice", "get"}, []string{"group", "notice-id"}},
|
||||
{[]string{"group", "notice", "list"}, []string{"group", "limit", "cursor", "scheduled"}},
|
||||
{[]string{"group", "share-invite"}, []string{"source", "target", "receiver", "expires-seconds", "uuid"}},
|
||||
{[]string{"text", "translate"}, []string{"query", "to"}},
|
||||
{[]string{"category", "create-smart"}, []string{"name", "keywords", "members"}},
|
||||
{[]string{"message", "list-emotion-replies"}, []string{"msg-ids"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd := requireWukongSyncCommand(t, root, tc.path...)
|
||||
requireWukongSyncFlags(t, cmd, tc.flags...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWukongSyncDocCommands(t *testing.T) {
|
||||
root := newDocCommand()
|
||||
|
||||
importCmd := requireWukongSyncCommand(t, root, "import")
|
||||
requireWukongSyncFlags(t, importCmd, "file", "folder", "workspace", "name", "folder-id", "workspace-id")
|
||||
|
||||
importGetCmd := requireWukongSyncCommand(t, root, "import", "get")
|
||||
requireWukongSyncFlags(t, importGetCmd, "task-id")
|
||||
}
|
||||
|
||||
func TestWukongSyncSheetCommands(t *testing.T) {
|
||||
root := newSheetCommand()
|
||||
|
||||
groupCmd := requireWukongSyncCommand(t, root, "group-dimension")
|
||||
requireWukongSyncFlags(t, groupCmd, "node", "sheet-id", "range", "group-state")
|
||||
|
||||
ungroupCmd := requireWukongSyncCommand(t, root, "ungroup-dimension")
|
||||
requireWukongSyncFlags(t, ungroupCmd, "node", "sheet-id", "range")
|
||||
}
|
||||
|
||||
func TestWukongSyncSheetBatchDimensionGroupMapping(t *testing.T) {
|
||||
group, err := translateBatchOp(map[string]any{
|
||||
"toolName": "group-dimension",
|
||||
"input": map[string]any{
|
||||
"sheet-id": "Sheet1",
|
||||
"range": "3:7",
|
||||
"group-state": "fold",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("group-dimension mapping returned error: %v", err)
|
||||
}
|
||||
wantGroup := map[string]any{
|
||||
"toolName": "group_dimension",
|
||||
"input": map[string]any{
|
||||
"sheetId": "Sheet1",
|
||||
"range": "3:7",
|
||||
"groupState": "fold",
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(group, wantGroup) {
|
||||
t.Fatalf("group-dimension mapping mismatch:\n got: %#v\nwant: %#v", group, wantGroup)
|
||||
}
|
||||
|
||||
ungroup, err := translateBatchOp(map[string]any{
|
||||
"toolName": "ungroup-dimension",
|
||||
"input": map[string]any{
|
||||
"sheet-id": "Sheet1",
|
||||
"range": "C:F",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ungroup-dimension mapping returned error: %v", err)
|
||||
}
|
||||
wantUngroup := map[string]any{
|
||||
"toolName": "ungroup_dimension",
|
||||
"input": map[string]any{
|
||||
"sheetId": "Sheet1",
|
||||
"range": "C:F",
|
||||
},
|
||||
}
|
||||
if !reflect.DeepEqual(ungroup, wantUngroup) {
|
||||
t.Fatalf("ungroup-dimension mapping mismatch:\n got: %#v\nwant: %#v", ungroup, wantUngroup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWukongSyncAgoalCommands(t *testing.T) {
|
||||
root := newAgoalCommand()
|
||||
cases := []struct {
|
||||
path []string
|
||||
flags []string
|
||||
}{
|
||||
{[]string{"strategy", "list"}, []string{"scope-type", "scope-id", "request-id"}},
|
||||
{[]string{"strategy", "detail"}, []string{"profile-id", "request-id"}},
|
||||
{[]string{"strategy", "update"}, []string{"profile-id", "content", "request-id"}},
|
||||
{[]string{"contract", "list"}, []string{"scope-type", "scope-id", "request-id"}},
|
||||
{[]string{"contract", "fields"}, []string{"request-id"}},
|
||||
{[]string{"contract", "detail"}, []string{"contract-id", "request-id"}},
|
||||
{[]string{"contract", "update"}, []string{"contract-id", "dimensions", "audit-config", "objective-template", "request-id"}},
|
||||
{[]string{"scorecard", "detail"}, []string{"selected-time", "dept-id", "request-id"}},
|
||||
{[]string{"scorecard", "entity-detail"}, []string{"sc-id", "entity-id", "request-id"}},
|
||||
{[]string{"scorecard", "update"}, []string{"dept-id", "selected-time", "id", "tracking-period-type", "content", "request-id"}},
|
||||
{[]string{"user", "rules"}, []string{"user-id", "request-id"}},
|
||||
{[]string{"user", "objectives"}, []string{"user-id", "rule-id", "period-ids", "request-id"}},
|
||||
{[]string{"report", "list-statistics"}, []string{"keyword", "request-id"}},
|
||||
{[]string{"report", "submit-detail"}, []string{"template-id", "submit-state", "query-date", "page", "page-size", "keyword", "request-id"}},
|
||||
{[]string{"obj-template", "list"}, []string{"keyword", "page", "page-size", "request-id"}},
|
||||
{[]string{"obj-template", "create-or-update"}, []string{"template-id", "title", "objective-weight", "dimension-weight", "compute-by-weight", "dimensions", "request-id"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
cmd := requireWukongSyncCommand(t, root, tc.path...)
|
||||
requireWukongSyncFlags(t, cmd, tc.flags...)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//go:build !darwin
|
||||
|
||||
package keychain
|
||||
|
||||
func platformDiagnose() Diagnostic {
|
||||
return Diagnostic{
|
||||
OK: true,
|
||||
Message: "当前平台使用本地加密凭据后端",
|
||||
Detail: map[string]string{
|
||||
"service": Service,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -63,3 +63,18 @@ func fileDEK(service string) ([]byte, error) {
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func fileDEKReadOnly(service string) ([]byte, error) {
|
||||
keyPath := filepath.Join(StorageDir(service), "dek")
|
||||
key, err := os.ReadFile(keyPath)
|
||||
if err == nil && len(key) == dekBytes {
|
||||
return key, nil
|
||||
}
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, ErrDEKMissing
|
||||
}
|
||||
return nil, fmt.Errorf("read dek: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("read dek: %w", ErrDEKMissing)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
// - Windows: DPAPI + Registry storage
|
||||
package keychain
|
||||
|
||||
import "errors"
|
||||
|
||||
const (
|
||||
// Service is the unified keychain service name for all secrets.
|
||||
Service = "dws-cli"
|
||||
@@ -40,6 +42,11 @@ const (
|
||||
DisableKeychainEnv = "DWS_DISABLE_KEYCHAIN"
|
||||
)
|
||||
|
||||
// ErrDEKMissing means encrypted local data may exist, but the Data Encryption
|
||||
// Key needed to decrypt it is missing. Read paths must not create a new DEK,
|
||||
// because a fresh key cannot decrypt existing ciphertext.
|
||||
var ErrDEKMissing = errors.New("dek missing")
|
||||
|
||||
// KeychainAccess abstracts keychain Get/Set/Remove for dependency injection.
|
||||
type KeychainAccess interface {
|
||||
Get(service, account string) (string, error)
|
||||
@@ -47,6 +54,64 @@ type KeychainAccess interface {
|
||||
Remove(service, account string) error
|
||||
}
|
||||
|
||||
// Diagnostic is a read-only health report for the platform keychain backend.
|
||||
// It never mutates credentials, DEKs, or OS keychain settings.
|
||||
type Diagnostic struct {
|
||||
OK bool `json:"ok"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Hint string `json:"hint,omitempty"`
|
||||
Detail map[string]string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// UnavailableError marks failures where the platform keychain itself could
|
||||
// not be reached, unlocked, or created. Callers can surface a diagnostic
|
||||
// instead of treating the result as a normal missing credential.
|
||||
type UnavailableError struct {
|
||||
Op string
|
||||
Err error
|
||||
}
|
||||
|
||||
func NewUnavailableError(op string, err error) error {
|
||||
return &UnavailableError{Op: op, Err: err}
|
||||
}
|
||||
|
||||
func (e *UnavailableError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Op == "" {
|
||||
if e.Err != nil {
|
||||
return e.Err.Error()
|
||||
}
|
||||
return "keychain unavailable"
|
||||
}
|
||||
if e.Err == nil {
|
||||
return e.Op + ": keychain unavailable"
|
||||
}
|
||||
return e.Op + ": " + e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *UnavailableError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func IsUnavailable(err error) bool {
|
||||
var unavailable *UnavailableError
|
||||
return errors.As(err, &unavailable)
|
||||
}
|
||||
|
||||
func IsDEKMissing(err error) bool {
|
||||
return errors.Is(err, ErrDEKMissing)
|
||||
}
|
||||
|
||||
func Diagnose() Diagnostic {
|
||||
return platformDiagnose()
|
||||
}
|
||||
|
||||
// Get retrieves a value from the keychain.
|
||||
// Returns empty string and nil error if the entry does not exist.
|
||||
func Get(service, account string) (string, error) {
|
||||
|
||||
@@ -21,10 +21,14 @@ import (
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -59,15 +63,169 @@ func safeFileName(account string) string {
|
||||
return safeFileNameRe.ReplaceAllString(account, "_") + ".enc"
|
||||
}
|
||||
|
||||
var readDefaultKeychain = func() ([]byte, error) {
|
||||
return exec.Command("security", "default-keychain", "-d", "user").Output()
|
||||
}
|
||||
|
||||
var (
|
||||
keyringGet = keyring.Get
|
||||
keyringSet = keyring.Set
|
||||
)
|
||||
|
||||
func defaultKeychainPathFromSecurityOutput(output []byte) string {
|
||||
value := strings.TrimSpace(string(output))
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if unquoted, err := strconv.Unquote(value); err == nil {
|
||||
return unquoted
|
||||
}
|
||||
return strings.Trim(value, `"`)
|
||||
}
|
||||
|
||||
func checkDefaultKeychainAvailable() error {
|
||||
output, err := readDefaultKeychain()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
path := defaultKeychainPathFromSecurityOutput(output)
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
|
||||
return NewUnavailableError("read macOS default Keychain", fmt.Errorf("default keychain %q does not exist", path))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func platformDiagnose() Diagnostic {
|
||||
detail := map[string]string{
|
||||
"platform": "darwin",
|
||||
"service": Service,
|
||||
"account": "dek",
|
||||
}
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
detail["mode"] = "file_dek"
|
||||
detail["storage_dir"] = StorageDir(Service)
|
||||
return Diagnostic{
|
||||
OK: true,
|
||||
Message: "macOS Keychain 已禁用, 当前使用 file-DEK 测试模式",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
output, err := readDefaultKeychain()
|
||||
if err != nil {
|
||||
detail["error"] = err.Error()
|
||||
return Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_check_failed",
|
||||
Message: "无法读取 macOS 默认钥匙串配置",
|
||||
Hint: "检查 /usr/bin/security 是否可用, 并确认当前用户钥匙串配置正常。",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
path := defaultKeychainPathFromSecurityOutput(output)
|
||||
if path != "" {
|
||||
detail["default_keychain"] = path
|
||||
}
|
||||
if path == "" {
|
||||
return Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "macOS 默认钥匙串未配置",
|
||||
Hint: "恢复默认钥匙串后重试;测试环境可设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil && os.IsNotExist(err) {
|
||||
return Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_unavailable",
|
||||
Message: "macOS 默认钥匙串不存在",
|
||||
Hint: "恢复默认钥匙串后重试;测试环境可设置 DWS_DISABLE_KEYCHAIN=1 后重新登录。",
|
||||
Detail: detail,
|
||||
}
|
||||
} else if err != nil {
|
||||
detail["error"] = err.Error()
|
||||
return Diagnostic{
|
||||
OK: false,
|
||||
Reason: "keychain_check_failed",
|
||||
Message: "无法访问 macOS 默认钥匙串",
|
||||
Hint: "检查默认钥匙串路径权限与挂载状态。",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
return Diagnostic{
|
||||
OK: true,
|
||||
Message: "macOS 默认钥匙串可用",
|
||||
Detail: detail,
|
||||
}
|
||||
}
|
||||
|
||||
// getDEK retrieves or generates the Data Encryption Key.
|
||||
// When DWS_DISABLE_KEYCHAIN=1 (set in sandboxed runtimes like Codex App
|
||||
// where Keychain APIs are blocked), falls back to a file-based DEK
|
||||
// identical to the Linux scheme. See DisableKeychainEnv docs for the
|
||||
// security tradeoff.
|
||||
func getDEK(service string) ([]byte, error) {
|
||||
return getOrCreateDEK(service)
|
||||
}
|
||||
|
||||
func getDEKReadOnly(service string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return fileDEKReadOnly(service)
|
||||
}
|
||||
if err := checkDefaultKeychainAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
|
||||
defer cancel()
|
||||
|
||||
type result struct {
|
||||
key []byte
|
||||
err error
|
||||
}
|
||||
resCh := make(chan result, 1)
|
||||
|
||||
go func() {
|
||||
defer func() { recover() }()
|
||||
|
||||
encodedKey, err := keyringGet(service, "dek")
|
||||
if err == nil {
|
||||
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if decodeErr == nil && len(key) == dekBytes {
|
||||
resCh <- result{key: key, err: nil}
|
||||
return
|
||||
}
|
||||
resCh <- result{key: nil, err: fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing)}
|
||||
return
|
||||
}
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
resCh <- result{key: nil, err: fmt.Errorf("read DEK from macOS Keychain: %w", ErrDEKMissing)}
|
||||
return
|
||||
}
|
||||
resCh <- result{key: nil, err: NewUnavailableError("read DEK from macOS Keychain", err)}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resCh:
|
||||
return res.key, res.err
|
||||
case <-ctx.Done():
|
||||
return nil, NewUnavailableError("read DEK from macOS Keychain", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
func getOrCreateDEK(service string) ([]byte, error) {
|
||||
if os.Getenv(DisableKeychainEnv) != "" {
|
||||
return fileDEK(service)
|
||||
}
|
||||
if err := checkDefaultKeychainAvailable(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), keychainTimeout)
|
||||
defer cancel()
|
||||
@@ -82,13 +240,16 @@ func getDEK(service string) ([]byte, error) {
|
||||
defer func() { recover() }()
|
||||
|
||||
// Try to get existing DEK from system Keychain
|
||||
encodedKey, err := keyring.Get(service, "dek")
|
||||
encodedKey, err := keyringGet(service, "dek")
|
||||
if err == nil {
|
||||
key, decodeErr := base64.StdEncoding.DecodeString(encodedKey)
|
||||
if decodeErr == nil && len(key) == dekBytes {
|
||||
resCh <- result{key: key, err: nil}
|
||||
return
|
||||
}
|
||||
} else if !errors.Is(err, keyring.ErrNotFound) {
|
||||
resCh <- result{key: nil, err: NewUnavailableError("read DEK from macOS Keychain", err)}
|
||||
return
|
||||
}
|
||||
|
||||
// Generate new DEK if not found or invalid
|
||||
@@ -100,15 +261,18 @@ func getDEK(service string) ([]byte, error) {
|
||||
|
||||
// Store in system Keychain
|
||||
encodedKey = base64.StdEncoding.EncodeToString(key)
|
||||
setErr := keyring.Set(service, "dek", encodedKey)
|
||||
resCh <- result{key: key, err: setErr}
|
||||
if setErr := keyringSet(service, "dek", encodedKey); setErr != nil {
|
||||
resCh <- result{key: nil, err: NewUnavailableError("store DEK in macOS Keychain", setErr)}
|
||||
return
|
||||
}
|
||||
resCh <- result{key: key, err: nil}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-resCh:
|
||||
return res.key, res.err
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
return nil, NewUnavailableError("read DEK from macOS Keychain", ctx.Err())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,10 +321,6 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
}
|
||||
|
||||
func platformGet(service, account string) (string, error) {
|
||||
key, err := getDEK(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -168,6 +328,10 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
key, err := getDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -176,7 +340,7 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
|
||||
func platformSet(service, account, data string) error {
|
||||
key, err := getDEK(service)
|
||||
key, err := getOrCreateDEK(service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,11 +16,128 @@
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
keyringpkg "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
func TestDefaultKeychainPathFromSecurityOutput(t *testing.T) {
|
||||
got := defaultKeychainPathFromSecurityOutput([]byte("\"/Users/me/Library/Keychains/login.keychain-db\"\n"))
|
||||
if got != "/Users/me/Library/Keychains/login.keychain-db" {
|
||||
t.Fatalf("path = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDefaultKeychainAvailableReportsMissingPath(t *testing.T) {
|
||||
missingPath := filepath.Join(t.TempDir(), "missing.keychain-db")
|
||||
prev := readDefaultKeychain
|
||||
readDefaultKeychain = func() ([]byte, error) {
|
||||
return []byte("\"" + missingPath + "\"\n"), nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
readDefaultKeychain = prev
|
||||
})
|
||||
|
||||
err := checkDefaultKeychainAvailable()
|
||||
if !IsUnavailable(err) {
|
||||
t.Fatalf("error = %v, want unavailable", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), missingPath) {
|
||||
t.Fatalf("error = %v, want missing keychain path", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMissingAccountDoesNotReadMacOSDEK(t *testing.T) {
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
|
||||
keychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
|
||||
if err := os.WriteFile(keychainPath, nil, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(default keychain) error = %v", err)
|
||||
}
|
||||
|
||||
prevReadDefault := readDefaultKeychain
|
||||
prevGet := keyringGet
|
||||
prevSet := keyringSet
|
||||
readDefaultKeychain = func() ([]byte, error) {
|
||||
return []byte("\"" + keychainPath + "\"\n"), nil
|
||||
}
|
||||
keyringGet = func(service, account string) (string, error) {
|
||||
t.Fatalf("keyring.Get(%q, %q) called for missing account", service, account)
|
||||
return "", nil
|
||||
}
|
||||
keyringSet = func(service, account, value string) error {
|
||||
t.Fatalf("keyring.Set(%q, %q) called for missing account", service, account)
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
readDefaultKeychain = prevReadDefault
|
||||
keyringGet = prevGet
|
||||
keyringSet = prevSet
|
||||
})
|
||||
|
||||
got, err := Get("test-missing-account", "auth-token")
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v, want nil", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("Get() = %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWithMissingMacOSDEKDoesNotCreateDEK(t *testing.T) {
|
||||
t.Setenv(DisableKeychainEnv, "")
|
||||
|
||||
keychainPath := filepath.Join(t.TempDir(), "login.keychain-db")
|
||||
if err := os.WriteFile(keychainPath, nil, 0600); err != nil {
|
||||
t.Fatalf("WriteFile(default keychain) error = %v", err)
|
||||
}
|
||||
|
||||
prevReadDefault := readDefaultKeychain
|
||||
prevGet := keyringGet
|
||||
prevSet := keyringSet
|
||||
readDefaultKeychain = func() ([]byte, error) {
|
||||
return []byte("\"" + keychainPath + "\"\n"), nil
|
||||
}
|
||||
keyringGet = func(service, account string) (string, error) {
|
||||
if account != "dek" {
|
||||
t.Fatalf("keyring.Get account = %q, want dek", account)
|
||||
}
|
||||
return "", keyringpkg.ErrNotFound
|
||||
}
|
||||
setCalls := 0
|
||||
keyringSet = func(service, account, value string) error {
|
||||
setCalls++
|
||||
return errors.New("keyring.Set should not be called by Get")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
readDefaultKeychain = prevReadDefault
|
||||
keyringGet = prevGet
|
||||
keyringSet = prevSet
|
||||
})
|
||||
|
||||
service := "test-missing-dek"
|
||||
account := "auth-token"
|
||||
dir := StorageDir(service)
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, safeFileName(account)), []byte("ciphertext"), 0600); err != nil {
|
||||
t.Fatalf("WriteFile(ciphertext) error = %v", err)
|
||||
}
|
||||
|
||||
_, err := Get(service, account)
|
||||
if !IsDEKMissing(err) {
|
||||
t.Fatalf("Get() error = %v, want dek missing", err)
|
||||
}
|
||||
if setCalls != 0 {
|
||||
t.Fatalf("keyring.Set calls = %d, want 0", setCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDisableKeychainFallback verifies that setting DWS_DISABLE_KEYCHAIN
|
||||
// routes the DEK to a local file (same scheme as Linux) and the full
|
||||
// Set/Get/Remove cycle works without touching the system Keychain.
|
||||
|
||||
@@ -32,6 +32,10 @@ func getDEK(service string) ([]byte, error) {
|
||||
return fileDEK(service)
|
||||
}
|
||||
|
||||
func getDEKReadOnly(service string) ([]byte, error) {
|
||||
return fileDEKReadOnly(service)
|
||||
}
|
||||
|
||||
const (
|
||||
dekBytes = 32 // DEK = Data Encryption Key (AES-256)
|
||||
ivBytes = 12
|
||||
@@ -106,10 +110,6 @@ func decryptData(data []byte, key []byte) (string, error) {
|
||||
}
|
||||
|
||||
func platformGet(service, account string) (string, error) {
|
||||
key, err := getDEK(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(StorageDir(service), safeFileName(account)))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
@@ -117,6 +117,10 @@ func platformGet(service, account string) (string, error) {
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
key, err := getDEKReadOnly(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plaintext, err := decryptData(data, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -19,6 +19,18 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dir, err := os.MkdirTemp("", "dws-keychain-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
_ = os.Setenv(StorageDirEnv, dir)
|
||||
_ = os.Setenv(DisableKeychainEnv, "1")
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(dir)
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func TestKeychainBasicOperations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -87,6 +99,23 @@ func TestKeychainNonExistentAccount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNonExistentAccountDoesNotCreateFileDEK(t *testing.T) {
|
||||
service := "test-service-readonly-" + t.Name()
|
||||
account := "nonexistent-account"
|
||||
dekPath := filepath.Join(StorageDir(service), "dek")
|
||||
|
||||
got, err := Get(service, account)
|
||||
if err != nil {
|
||||
t.Fatalf("Get() error = %v, want nil", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("Get() = %q, want empty string", got)
|
||||
}
|
||||
if _, err := os.Stat(dekPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("Get() created DEK at %s; stat error = %v", dekPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeychainOverwrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package syncdata
|
||||
|
||||
func StaticServers() []ServerInfo {
|
||||
return []ServerInfo{
|
||||
{ID: "agoal", Name: "Agoal", Endpoint: "https://mcp-gw.dingtalk.com/server/1db49ea94ffe74c25a7079a68b4df6629c79130fb9095d1b499f507015d847a9", Prefixes: []string{"agoal"}},
|
||||
{ID: "aisearch", Name: "AI 搜问", Endpoint: "https://mcp-gw.dingtalk.com/server/ai-search", Prefixes: []string{"aisearch", "enterprise"}},
|
||||
{ID: "aitable", Name: "AI 多维表", Endpoint: "https://mcp-gw.dingtalk.com/server/5f0d121611f14e878f7d42c3e32bf6c4a790d433066adae38c062a657c397047", Prefixes: []string{"table", "record", "field", "base", "attachment", "view", "dashboard", "chart", "export", "import"}},
|
||||
{ID: "aitable-helper", Name: "AI 多维表(辅助)", Endpoint: "https://mcp-gw.dingtalk.com/server/bb2984ee6b10c1560b4fe943ca620f646bed31f215c551a53abf040b52591a95", Prefixes: []string{"form", "share_form"}},
|
||||
|
||||
@@ -5,6 +5,7 @@ package syncdata
|
||||
|
||||
func CmdToProduct() map[string]string {
|
||||
return map[string]string{
|
||||
"agoal": "agoal",
|
||||
"aisearch": "aisearch",
|
||||
"aitable": "aitable",
|
||||
"attendance": "attendance",
|
||||
|
||||
+1
-1
@@ -260,7 +260,7 @@ resolve_skill_mode() {
|
||||
print_multi_mode_notice() {
|
||||
say ""
|
||||
say "🧪 Skill mode: multi (EXPERIMENTAL / preview) — automatic skill install skipped."
|
||||
say " ⚠ multi is not yet stable. 20 product-scoped skills pass dispatch verifier,"
|
||||
say " ⚠ multi is not yet stable. 22 product-scoped skills pass dispatch verifier,"
|
||||
say " but interface, naming and cross-skill references may change in future releases."
|
||||
say " For production / shared environments, use mono mode (--mode mono)."
|
||||
say ""
|
||||
|
||||
@@ -50,6 +50,7 @@ cli_version: ">=1.0.15"
|
||||
## 意图判断决策树
|
||||
|
||||
用户提到"AI应用/创建应用/生成系统/做工具/管理后台/低代码" → `aiapp`
|
||||
用户提到"目标管理/Agoal/战略解码/经营合约/计分卡/目标模板/周月报提交统计" → `agoal`
|
||||
用户提到"找人/搜人/谁负责 XX/某事项的负责人/某项目的人/团队成员/上级/下级/按工号找人/按手机号找人" → `aisearch`
|
||||
用户提到"表格/多维表/AI表格/记录/数据/视图/图表/仪表盘" → `aitable`
|
||||
用户提到"考勤/打卡/排班" → `attendance`
|
||||
|
||||
@@ -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 字符串;不传则默认当天。
|
||||
@@ -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 取消)
|
||||
|
||||
@@ -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 ...`。
|
||||
@@ -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 的当前配置基线 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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` 为逗号分隔字符串。
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 取消)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 ...`。
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 的当前配置基线 |
|
||||
|
||||
## 注意事项
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user