Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0838e7e41 | ||
|
|
9c6ab99bf1 | ||
|
|
87ab311764 | ||
|
|
15c075e6a6 | ||
|
|
f6d1e685e0 | ||
|
|
81108e150b | ||
|
|
dad9aefefa | ||
|
|
71d49cb12b | ||
|
|
f7e2efaaa2 | ||
|
|
557208e16b | ||
|
|
53401dbb0c | ||
|
|
6c52ac37dd | ||
|
|
95a17a3ffc | ||
|
|
3318741508 | ||
|
|
3d7ab2690c | ||
|
|
17eefcd24b | ||
|
|
6f62ce7997 | ||
|
|
096dfd48f0 | ||
|
|
2ab0edd5c6 | ||
|
|
4b3272bcd4 | ||
|
|
80d5d24637 | ||
|
|
e40397e239 | ||
|
|
15bc7fdc3f | ||
|
|
da049be58d | ||
|
|
6ec64e8a03 | ||
|
|
bca56cbba6 | ||
|
|
bb48aa0cc8 | ||
|
|
6ffb4bcb93 | ||
|
|
08595594d7 | ||
|
|
540bbac35b | ||
|
|
c4d5595ca9 | ||
|
|
86d1eb8030 | ||
|
|
ab529e5ee5 | ||
|
|
15cb1f4311 | ||
|
|
97ca00868f | ||
|
|
df8885c350 | ||
|
|
4e27a3a84a | ||
|
|
8685464c53 | ||
|
|
26b5939f9f | ||
|
|
95bcace6bd | ||
|
|
ce57cdf260 | ||
|
|
3ec138ba99 | ||
|
|
f419c0f96d | ||
|
|
17101a8901 | ||
|
|
66aa00fb50 | ||
|
|
9d6e151a6f | ||
|
|
0df41d3eff | ||
|
|
2a1ed8cc7a | ||
|
|
c5951a10ff | ||
|
|
3dbd29ab50 | ||
|
|
1b50c7a5b4 | ||
|
|
cbd70d1b88 | ||
|
|
0ae8949d40 | ||
|
|
0975d970d1 | ||
|
|
7808673431 | ||
|
|
50ed921ca1 | ||
|
|
b34c29ec35 | ||
|
|
97e5ded043 | ||
|
|
7ceeafbae8 | ||
|
|
54b4a24a14 | ||
|
|
e1bfb343f4 | ||
|
|
7da423bf3c | ||
|
|
fa83ee579c | ||
|
|
25c694aa2a | ||
|
|
e064d394ba |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
@@ -6,6 +6,31 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59] - 2026-08-20
|
||||
|
||||
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage.
|
||||
|
||||
- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
## [1.0.59-beta.5] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
|
||||
## [1.0.59-beta.4] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.59-beta.4"
|
||||
version "1.0.59-beta.5"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-arm64.tar.gz"
|
||||
sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz"
|
||||
sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz"
|
||||
sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz"
|
||||
sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip"
|
||||
sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip"
|
||||
sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Products**: 13
|
||||
- **Total commands**: 160
|
||||
- **Total commands**: 163
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
|
||||
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
|
||||
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
|
||||
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
|
||||
|
||||
_Group chats, conversations, messages, and robot/webhook integrations._
|
||||
|
||||
**23 commands**
|
||||
**26 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
|
||||
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
|
||||
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
|
||||
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
|
||||
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
|
||||
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
|
||||
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
|
||||
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
|
||||
|
||||
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "list_by_keyword_and_time_range":
|
||||
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
|
||||
case "get_minutes_basic_info":
|
||||
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
|
||||
case "get_minutes_transcription":
|
||||
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
|
||||
case "create_personal_todo":
|
||||
return `{"success":true,"result":{"taskId":"task-1"}}`
|
||||
case "get_todo_detail":
|
||||
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
|
||||
case "get_user_todos_in_current_org":
|
||||
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
|
||||
case "add_todo_reminder":
|
||||
return `{"success":true}`
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1"}`
|
||||
case "move_document", "add_member", "update_member", "remove_member":
|
||||
return `{"success":true}`
|
||||
case "get_document_info":
|
||||
if len(c.calls) > 1 {
|
||||
switch c.calls[len(c.calls)-2].tool {
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
|
||||
case "move_document":
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
|
||||
}
|
||||
}
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
|
||||
@@ -232,6 +232,67 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
}
|
||||
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for the
|
||||
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
|
||||
// separate map lets this test file land before the draft replaces the formal
|
||||
// param_concepts.json: inactive candidate templates are ignored, while every
|
||||
// command becomes mandatory as soon as one of its reviewed aliases is active.
|
||||
var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
|
||||
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
|
||||
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
|
||||
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
|
||||
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
|
||||
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
|
||||
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
|
||||
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
|
||||
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
|
||||
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
|
||||
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
|
||||
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
|
||||
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
|
||||
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
|
||||
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
|
||||
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
|
||||
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
|
||||
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
|
||||
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
|
||||
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
|
||||
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
|
||||
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
|
||||
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
|
||||
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
|
||||
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
|
||||
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
|
||||
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
|
||||
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
|
||||
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
|
||||
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
|
||||
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
|
||||
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
|
||||
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
|
||||
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
|
||||
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
|
||||
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
|
||||
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
|
||||
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
|
||||
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
|
||||
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
|
||||
}
|
||||
|
||||
// A command can expose more than one mutually exclusive canonical route. In
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
@@ -531,6 +592,20 @@ var paramAliasNewConfirmationCases = []struct {
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// Candidate confirmation cases become active with the joint draft. One write
|
||||
// workflow per product plus TODO's reminder workflow proves semantic aliasing
|
||||
// cannot move execution across the shared --yes barrier.
|
||||
var paramAliasCandidateConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
|
||||
{command: "todo +create", emitted: "deadline", canonical: "due"},
|
||||
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
|
||||
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
// old concept aliases, command overrides, native compatibility flags, read and
|
||||
// write commands, and different products. Every reviewed alias is still
|
||||
@@ -600,6 +675,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// Candidate representatives exercise the final transport boundary for each
|
||||
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
|
||||
// exists in the loaded reviewed table, so the tests are mergeable before the
|
||||
// joint draft is promoted to internal/cli/param_concepts.json.
|
||||
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
|
||||
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
|
||||
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
|
||||
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
|
||||
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
|
||||
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
|
||||
paramAliasPayloadCaseKey("todo comment add", "text"): true,
|
||||
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
|
||||
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
|
||||
paramAliasPayloadCaseKey("todo task update", "status"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
@@ -709,6 +808,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
|
||||
activeCommands := make(map[string]bool)
|
||||
activeFixtureCases := make(map[string]bool)
|
||||
activeCases := 0
|
||||
executedRepresentatives := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
@@ -717,6 +817,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
activeCommands[fixture.Command] = true
|
||||
activeCases++
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
activeFixtureCases[caseKey] = true
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
|
||||
@@ -729,8 +831,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
@@ -742,26 +843,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
if activeCases == 0 {
|
||||
t.Fatal("reviewed fixture contains no active alias cases")
|
||||
}
|
||||
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
|
||||
for command := range paramAliasCompleteCommands {
|
||||
if !activeCommands[command] {
|
||||
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
|
||||
}
|
||||
templateCommands[command] = true
|
||||
}
|
||||
for command := range paramAliasCandidateCompleteCommands {
|
||||
if activeCommands[command] {
|
||||
templateCommands[command] = true
|
||||
}
|
||||
}
|
||||
for command := range activeCommands {
|
||||
if _, ok := paramAliasCompleteCommands[command]; !ok {
|
||||
if !templateCommands[command] {
|
||||
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
|
||||
}
|
||||
}
|
||||
if len(activeCommands) != len(paramAliasCompleteCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
|
||||
if len(activeCommands) != len(templateCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
|
||||
}
|
||||
for caseKey := range paramAliasRepresentativePayloadCases {
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
|
||||
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
|
||||
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
|
||||
if !activeFixtureCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
activeRepresentatives++
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != activeRepresentatives {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,7 +1209,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
tests := append([]struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{}, paramAliasNewConfirmationCases...)
|
||||
for _, candidate := range paramAliasCandidateConfirmationCases {
|
||||
entry, exists := cli.LookupParamAlias(candidate.command)
|
||||
target, active := entry.ResolveAlias(candidate.emitted)
|
||||
if exists && active && target == candidate.canonical {
|
||||
tests = append(tests, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
@@ -1173,6 +1304,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return complete, true
|
||||
}
|
||||
complete, ok = paramAliasCandidateCompleteCommands[command]
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat emotion favorite",
|
||||
"chat emotion list",
|
||||
"chat emotion send",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
if got, want := int(product["count"].(float64)), 220; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
cliPath string
|
||||
params map[string]string
|
||||
}{
|
||||
{
|
||||
cliPath: "chat emotion list",
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion send",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"emotion-id": "emotionId",
|
||||
"group": "openConversationId",
|
||||
"open-dingtalk-id": "receiverOpenDingTalkId",
|
||||
"idempotency-key": "uuid",
|
||||
},
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion favorite",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"name": "name",
|
||||
"source-conversation-id": "sourceConversationId",
|
||||
"source-message-id": "sourceMessageId",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.cliPath, func(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
|
||||
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range tc.params {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
|
||||
result, _ := leaf["result"].(map[string]any)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -10569,7 +10569,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, 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, chatTextCmd, newChatToolbarCommand())
|
||||
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, newChatToolbarCommand(), newChatEmotionCommand())
|
||||
|
||||
// Keep the v1.0.56 command surface recognizable while directing callers to
|
||||
// the supported nested commands. The chat root's "im" alias makes these
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
|
||||
|
||||
func newChatEmotionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "emotion",
|
||||
Short: "个人收藏表情",
|
||||
Long: "查询、发送和新增当前用户的个人收藏表情。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newChatEmotionListCommand(),
|
||||
newChatEmotionSendCommand(),
|
||||
newChatEmotionFavoriteCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出个人收藏表情",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "list_personal_emotions",
|
||||
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
|
||||
},
|
||||
Description: "列出当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出当前用户的个人收藏表情",
|
||||
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
|
||||
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
|
||||
Examples: []string{"dws chat emotion list --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionSendCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "发送个人收藏表情",
|
||||
Long: `发送当前用户的个人收藏表情。
|
||||
|
||||
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
|
||||
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
|
||||
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
|
||||
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
target, err := personalEmotionSendTarget(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
emotionID, _ := cmd.Flags().GetString("emotion-id")
|
||||
if strings.TrimSpace(emotionID) != "" {
|
||||
payload["emotionId"] = strings.TrimSpace(emotionID)
|
||||
}
|
||||
for key, value := range target {
|
||||
payload[key] = value
|
||||
}
|
||||
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
|
||||
payload["uuid"] = uuid
|
||||
}
|
||||
return callMCPToolOnServer("im", "send_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
|
||||
cmd.Flags().String("emotion-id", "", "表情 ID")
|
||||
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
|
||||
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
|
||||
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
|
||||
cmd.Flags().String("uuid", "", "幂等键")
|
||||
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
|
||||
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
|
||||
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
})
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionSendContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionFavoriteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "favorite",
|
||||
Short: "新增个人收藏表情",
|
||||
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
|
||||
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
|
||||
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
|
||||
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if strings.TrimSpace(name) != "" {
|
||||
payload["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if strings.TrimSpace(sourceConversationID) != "" {
|
||||
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
|
||||
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
|
||||
}
|
||||
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
|
||||
cmd.Flags().String("name", "", "表情名称")
|
||||
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
|
||||
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionFavoriteContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
|
||||
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
|
||||
userID, _ := cmd.Flags().GetString("user")
|
||||
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
|
||||
userID = strings.TrimSpace(userID)
|
||||
openDingTalkID = strings.TrimSpace(openDingTalkID)
|
||||
specified := 0
|
||||
for _, value := range []string{groupID, userID, openDingTalkID} {
|
||||
if value != "" {
|
||||
specified++
|
||||
}
|
||||
}
|
||||
if specified != 1 {
|
||||
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
|
||||
}
|
||||
if groupID != "" {
|
||||
return map[string]any{"openConversationId": groupID}, nil
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
|
||||
}
|
||||
if isOpenDingTalkID(userID) {
|
||||
return map[string]any{"receiverOpenDingTalkId": userID}, nil
|
||||
}
|
||||
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
|
||||
}
|
||||
|
||||
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
|
||||
hasConversation := strings.TrimSpace(sourceConversationID) != ""
|
||||
hasMessage := strings.TrimSpace(sourceMessageID) != ""
|
||||
if hasConversation != hasMessage {
|
||||
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalEmotionSendContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "send_personal_emotion",
|
||||
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
|
||||
},
|
||||
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以当前用户身份发送个人收藏表情",
|
||||
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
|
||||
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
|
||||
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
|
||||
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func personalEmotionFavoriteContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "favorite_personal_emotion",
|
||||
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
|
||||
},
|
||||
Description: "将 mediaId 新增到当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "新增当前用户的个人收藏表情",
|
||||
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
|
||||
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
|
||||
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "name", Property: "name", Required: boolPtr(false)},
|
||||
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
|
||||
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type personalEmotionCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type personalEmotionCaller struct {
|
||||
calls []personalEmotionCall
|
||||
}
|
||||
|
||||
func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied})
|
||||
if server == "contact" && tool == "get_user_info_by_user_ids" {
|
||||
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
|
||||
}
|
||||
return textToolResult(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (*personalEmotionCaller) Format() string { return "json" }
|
||||
func (*personalEmotionCaller) DryRun() bool { return false }
|
||||
func (*personalEmotionCaller) Fields() string { return "" }
|
||||
func (*personalEmotionCaller) JQ() string { return "" }
|
||||
|
||||
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installHelpersCoreDeps(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
|
||||
t.Helper()
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "im" || call.tool != tool {
|
||||
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, want) {
|
||||
t.Fatalf("args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
|
||||
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
|
||||
caller := &personalEmotionCaller{}
|
||||
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
|
||||
t.Fatalf("chat emotion list returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
|
||||
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--emotion-id", "emotion123",
|
||||
"--group", "cid123",
|
||||
"--idempotency-key", "idem-001",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"emotionId": "emotion123",
|
||||
"openConversationId": "cid123",
|
||||
"uuid": "idem-001",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
|
||||
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--open-dingtalk-id", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
|
||||
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
|
||||
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", "u1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
resolveCall := caller.calls[0]
|
||||
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
|
||||
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
|
||||
}
|
||||
sendCall := caller.calls[1]
|
||||
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
|
||||
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
|
||||
}
|
||||
want := map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID2,
|
||||
}
|
||||
if !reflect.DeepEqual(sendCall.args, want) {
|
||||
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing target",
|
||||
args: []string{"emotion", "send", "--media-id", "@media"},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "send", "--group", "cid"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
|
||||
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "favorite",
|
||||
"--media-id", "@media",
|
||||
"--name", "赞",
|
||||
"--source-conversation-id", "cid123",
|
||||
"--source-message-id", "msg123",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion favorite returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"name": "赞",
|
||||
"sourceConversationId": "cid123",
|
||||
"sourceMessageId": "msg123",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "favorite", "--name", "赞"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
{
|
||||
name: "source conversation only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
{
|
||||
name: "source message only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1212,6 +1212,51 @@ Flags:
|
||||
--msg-ids string 消息 ID 列表,逗号分隔 (必填)
|
||||
```
|
||||
|
||||
### emotion (个人收藏表情)
|
||||
|
||||
#### 列出个人收藏表情
|
||||
```
|
||||
Usage:
|
||||
dws chat emotion list [flags]
|
||||
Example:
|
||||
dws chat emotion list --format json
|
||||
```
|
||||
当前用户身份由 MCP server 注入,无业务参数。
|
||||
|
||||
#### 发送个人收藏表情
|
||||
```
|
||||
Usage:
|
||||
dws chat emotion send [flags]
|
||||
Example:
|
||||
dws chat emotion send --media-id <mediaId> --group <openConversationId>
|
||||
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
|
||||
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>
|
||||
Flags:
|
||||
--media-id string 表情媒体 ID (必填)
|
||||
--emotion-id string 表情 ID (可选)
|
||||
--conversation-id string 群聊 openConversationId(与 --group 等价)
|
||||
--group string 群聊 openConversationId
|
||||
--user string 单聊接收人 userId,CLI 会解析为 openDingTalkId
|
||||
--open-dingtalk-id string 单聊接收人 openDingTalkId
|
||||
--uuid string 幂等键
|
||||
--idempotency-key string 幂等键(--uuid 等价)
|
||||
```
|
||||
目标参数 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一。该命令会真实发送表情,执行前必须确认目标和 mediaId。
|
||||
|
||||
#### 新增个人收藏表情
|
||||
```
|
||||
Usage:
|
||||
dws chat emotion favorite [flags]
|
||||
Example:
|
||||
dws chat emotion favorite --media-id <mediaId> --name "赞"
|
||||
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>
|
||||
Flags:
|
||||
--media-id string 待收藏 mediaId (必填)
|
||||
--name string 表情名称
|
||||
--source-conversation-id string 来源会话 ID,需与 --source-message-id 成对指定
|
||||
--source-message-id string 来源消息 ID,需与 --source-conversation-id 成对指定
|
||||
```
|
||||
|
||||
### list-top-conversations (置顶会话)
|
||||
|
||||
#### 拉取置顶会话列表
|
||||
@@ -2129,6 +2174,9 @@ Flags:
|
||||
用户说"群公告/发布公告/修改公告/查看公告/定时公告" → `chat group notice create/edit/get/list`
|
||||
用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids`
|
||||
用户说"批量查消息回复/表情回复/文字回复/消息回应列表" → `chat message list-emotion-replies`
|
||||
用户说"查看个人收藏表情/列出我的收藏表情" → `chat emotion list`
|
||||
用户说"发送个人收藏表情/发表情包" → `chat emotion send`
|
||||
用户说"收藏表情/新增个人收藏表情" → `chat emotion favorite`
|
||||
用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji`
|
||||
用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji`
|
||||
用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion`
|
||||
@@ -2200,6 +2248,7 @@ Flags:
|
||||
- `chat message list-emotion-replies` — 批量拉取消息的表情回复和文字回复
|
||||
- `chat message add-text-emotion` / `update-text-emotion` / `remove-text-emotion` — 对消息添加、原地更新或移除文字表情回应
|
||||
- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用
|
||||
- `chat emotion list` / `send` / `favorite` — 当前用户个人收藏表情列表、发送和新增;不同于消息 reaction/文字回应
|
||||
- `chat category list` — 获取用户自定义会话分组列表
|
||||
- `chat category list-conversations` — 拉取指定分组下的会话列表
|
||||
- `chat category create-smart` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则)
|
||||
|
||||
@@ -47,6 +47,7 @@ metadata:
|
||||
| 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 |
|
||||
| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 |
|
||||
| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` |
|
||||
| 个人收藏表情列表/发送/收藏 | `dws chat emotion list/send/favorite` | 约束见 leaf Schema |
|
||||
| 修改群名称 | `dws chat group rename --id <openConversationId> --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` |
|
||||
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 |
|
||||
| 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` |
|
||||
|
||||
@@ -57,8 +57,10 @@
|
||||
| `message add-emoji` / `remove-emoji` | 默认 emoji reaction |
|
||||
| `message create-text-emotion` / `add-text-emotion` / `update-text-emotion` / `remove-text-emotion` | 文字表情 |
|
||||
| `message list-emotion-replies` | 批量 reaction/文字回应 |
|
||||
| `emotion list` / `send` / `favorite` | 当前用户个人收藏表情列表、发送和新增 |
|
||||
|
||||
Favorite、消息 Pin、消息 Top 与会话 Top 是四种对象,不能互换。
|
||||
个人收藏表情与消息 reaction/文字回应不同;发送收藏表情使用 `chat emotion send`,给已有消息贴表情使用 `chat message add-emoji` 或 `chat message add-text-emotion`。
|
||||
|
||||
## 群与成员底层能力
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-doc
|
||||
description: 钉钉在线文字文档(adoc)本体及其内容的操作:查找、创建、读取、文档信息、编辑、块、评论、附件与媒体、白板卡片、导入、导出(docx/markdown/pdf)、版本、模板、权限、分享及Markdown/JSONML写入。不包括:文档空间与钉盘的文件管理(归 dingtalk-drive,doc 同名原子命令已弃用)、知识库空间与节点管理(归 dingtalk-wiki)、原生 .md 文件读写(归 dingtalk-misc)、电子表格 axls(归 dingtalk-misc)、AI 表格 able(归 dingtalk-aitable)。命令前缀:dws doc。
|
||||
description: 钉钉在线文字文档(adoc)的查找创建、读写、文档信息、白板卡片、附件、评论、媒体、导入导出、版本、模板、权限分享;本地文件转在线文档或协作编辑归 dingtalk-doc。原文件/文件夹归 dingtalk-drive,知识库归 dingtalk-wiki,原生 .md/axls 归 dingtalk-misc,able 归 dingtalk-aitable。前缀:dws doc。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -35,21 +35,23 @@ metadata:
|
||||
|
||||
## Golden Route
|
||||
|
||||
选择最小入口。ID/URL 直用;只有标题时先搜索,唯一候选后执行,禁止默认第一项。
|
||||
走最小入口:ID/URL 直用;标题先搜索,唯一命中再执行。成本顺序:稳定 ID → shortcut → 局部读 → 精确写;禁以产品 Schema、全文或原子命令起步。
|
||||
|
||||
| 用户意图 | 唯一推荐入口 | 关键边界 |
|
||||
|---|---|---|
|
||||
| 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` |
|
||||
| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | `--scope keyword/section/range` 可减少无关正文;非 adoc 切换对应产品 |
|
||||
| 最近访问文档 | `dws doc +search`(省略 `--query`) | `--limit` 为每页量,`--max-items` 为总上限;完整集合才加 `--page-all` 并检查 `complete` |
|
||||
| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | 具体术语直用 `keyword`;章节先 `outline` 再 `section`;只有整篇任务才用默认 `full` |
|
||||
| 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node <ID或URL>` | 仅打开任务所需的 `--include-*`,不要默认全取 |
|
||||
| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 长或复杂 Markdown 使用 `@file`/stdin;根据结果验证真实 `nodeId` |
|
||||
| 追加、覆盖或精确编辑 block | `dws doc +update --node <ID或URL> --command <动作>` | `--expected-revision` 仅用于 JSONML 原子覆盖;先消费 Schema confirmation |
|
||||
| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 先在本地完成正文;Runtime 负责 Markdown 分片和回读,禁止 Agent 自行拆成多次远程写入 |
|
||||
| 追加、覆盖或精确编辑 block | `dws doc +update --node <ID或URL> --command <动作>` | 唯一文本直接 `str_replace`;章节/block 先局部取 ID 再精准修改;整篇覆盖才用 overwrite |
|
||||
| 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` |
|
||||
| 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 |
|
||||
| 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` |
|
||||
| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | folder/workspace 可选,缺省导入默认根;纯上传切 `dingtalk-drive` |
|
||||
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | 没有名称/关键词时使用;不调用 search/create |
|
||||
| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 有查询词才使用;零或多候选停止 |
|
||||
| 本地文件转在线文档 | `dws doc +import --file <相对路径>` | “传上去/放进文件夹”不改变路由;在线改、协作编辑或转换用 import,仅保留原文件切 `dingtalk-drive` |
|
||||
| 封面/背景 | `+resource-update/+resource-delete`;`+background-update/+background-delete` | 写后 `+inspect --include-style`;禁查 Catalog |
|
||||
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | “我的/我这边”只查 MY;明确公开才查 PUBLIC;“有哪些/全部”翻页至完整 |
|
||||
| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 来源可选 MY/PUBLIC;零命中停止,禁止拿无关模板替代;多候选消歧 |
|
||||
| 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search |
|
||||
| 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` |
|
||||
| 添加/调整/移除协作者权限 | `dws doc +access-grant/+access-change/+access-revoke` | 先读取现有权限;姓名歧义或 profile 不一致时禁止写入 |
|
||||
@@ -58,18 +60,20 @@ metadata:
|
||||
|
||||
## 关键结果语义
|
||||
|
||||
- 保留真实 `nodeId`、URL、资源类型和容器;标题不能替代稳定 ID。
|
||||
- 按状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读且不重试写入;`retryable` 仅限明确未开始;权限、参数、认证失败停止。
|
||||
- 保留真实 `nodeId`、URL、资源类型和容器;同轮创建后直接复用返回 ID,禁止再按标题或钉盘搜索。
|
||||
- 先消费完整回执;Runtime 已分片/回读时不重复读取。只有局部验收、`partial_success` 或 commit-unknown 才再 `+fetch`。
|
||||
- 状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读、不重试写;`retryable` 仅限明确未开始;权限/参数/认证失败停止。
|
||||
- 仅在结果明确且关键内容回读匹配后报告写入完成。
|
||||
- 搜索、列表和批量结果必须检查 `complete`、`hasMore`、continuation 和失败项;单页结果不得表述为完整集合。
|
||||
- 搜索/列表检查 `complete`、`hasMore`、cursor 和失败项。“有哪些/列出/全部”逐页至完整;只有示例/前 N 条可提前停止并声明范围。
|
||||
- `+import` 已含上传、转换和轮询;成功检查 `success=true`、`taskId`、`documentUrl`。超时或中断保留 `taskId`、查询原任务,禁止重导。
|
||||
- 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。
|
||||
|
||||
## 参数与安全边界
|
||||
|
||||
- `@file` 统一协议:已有或临时文件先暂存到 cwd 后传 `@相对路径`;单次生成文本优先 `--content -` (stdin);禁止绝对路径和 `..`。
|
||||
- `@file`:已有或临时文件先暂存到 cwd;传 `@相对路径`,禁绝对路径和 `..`。
|
||||
- `doc +update` 的动作由 `--command` 指定;block 操作的 ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表。
|
||||
- Schema 门禁:已知只读路由且参数明确时直接执行;写命令或 selection/参数不清、准备 Help 时,本轮仅查一次 `dws schema --cli-path "doc +<leaf>" --compact --fields use_when,avoid_when,parameters,constraints,confirmation -f json`;禁用产品级/`--all`。
|
||||
- 执行前消费其中的 `confirmation`:`user_required` 且用户已确认同一目标/动作/参数才加 `--yes`,否则预览/询问;禁止靠失败探测门禁。
|
||||
- Schema 门禁:不确定时仅查一次精确 leaf:`--fields use_when,avoid_when,parameters,constraints,confirmation`;禁用产品级/`--all`。准备 Help 时,本轮仅查一次。
|
||||
- 消费本页或精确 Schema 的 `confirmation`:`user_required` 且原请求/预授权已确认目标、动作、参数时,首调即加 `--yes`;否则预览/询问;禁止靠失败探测门禁。
|
||||
- JSONML 顶层必须是单个非空元素;禁止 `[[...]]` 元素数组包裹。
|
||||
|
||||
## 按需加载
|
||||
@@ -80,25 +84,24 @@ Golden Route 已给出命令且参数足够时,禁止读取 reference。其余
|
||||
|---|---|
|
||||
| 低频/无 shortcut 意图消歧 | [intent-guide.md](references/intent-guide.md) / [doc.md](references/doc.md) 对应章节 |
|
||||
| 分页、`partial_success`、`status=unknown` 或恢复 | [contracts.md](references/contracts.md) |
|
||||
| 复杂 JSONML/非默认操作 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
|
||||
| block/划词评论/媒体高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
|
||||
| 复杂 JSONML、长文或局部精准读写 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
|
||||
| block/划词评论/媒体/封面/背景高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
|
||||
| 导出/导入失败恢复 | [export](references/doc/doc-export.md) / [import](references/doc/doc-import.md) |
|
||||
|
||||
`+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;禁止预加载或连读。
|
||||
常规 `+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;需策略时仅读上表一项,禁预加载/连读。
|
||||
|
||||
## 错误最短路径
|
||||
|
||||
1. 零命中、多候选、类型不明或分页不完整:停止后续写入,展示候选或 continuation;禁止默认第一项。
|
||||
2. Help 不参与选路;按上方门禁先消费一次精确 leaf Schema。只有真实 `unknown flag`/契约漂移后才查一次 leaf Help;`unknown command` 只查一次 shortcut 清单,禁止试探后缀和 `dws doc --help | grep/head`。
|
||||
3. `REVISION_CONFLICT`:重新读取当前 revision,展示差异;未经用户确认不得改成无 revision 覆盖。
|
||||
4. `partial_success`:保留已完成步骤,优先执行补偿或从 checkpoint 继续,不重放成功步骤。
|
||||
5. `doc_write_commit_unknown`:先回读文档确认状态;禁止自动重试创建或追加。
|
||||
6. 认证、权限或 profile 错误:只读取 `dingtalk-shared` 对应 reference;不要尝试同义底层命令绕过。
|
||||
7. 导出或媒体失败:保留 `jobId/resourceId/nodeId` 后停止;禁止 `curl`、安装依赖、Python 文档库或手写 HTTP 兜底。
|
||||
4. `doc_write_commit_unknown`:先回读;禁止自动重试创建或追加。
|
||||
5. 认证、权限或 profile 错误:只读 `dingtalk-shared` 对应 reference,不用底层命令绕过。
|
||||
6. 导出/媒体失败:保留稳定 ID 后停止;禁止网络请求、安装依赖或本地文档库兜底。
|
||||
|
||||
## 跨产品边界
|
||||
|
||||
- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive`
|
||||
- 普通文件/目录/纯上传下载/节点权限 → `dingtalk-drive`;保留原文件用原子命令 `dws drive upload --workspace`,在线转换用 `doc +import --workspace`
|
||||
- 知识库空间、节点层级和成员管理 → `dingtalk-wiki`
|
||||
- 原生 `.md` 文件读取和编辑 → `dingtalk-misc`
|
||||
- `axls` / `able` → 对应电子表格或多维表 Skill
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
| `success` | 所有计划步骤完成,且需要验证的内容已经回读 | 可以向用户报告完成 |
|
||||
| `partial_success` | 已发生部分副作用,后续步骤失败 | 检查 `steps` 与 `compensation`,不得重放成功步骤 |
|
||||
| `unknown` | 请求已发出但无法确定服务端是否提交 | 先回读目标;创建和追加禁止自动重试 |
|
||||
| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after`,最多有界重试一次 |
|
||||
| `failed` | 已确认没有完成目标动作 | 根据 `retryable` 和 `suggestedActions` 决定是否重试 |
|
||||
| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after_seconds`,最多有界重试一次 |
|
||||
| `failed` | 已确认没有完成目标动作 | 根据 `retryable`、`actions` 和 details 决定是否重试 |
|
||||
|
||||
`ok=true` 或进程退出码为零不能替代 `status/verified`。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。
|
||||
进程退出码为零不能替代业务证据。采用 `doc.operation.v1` 的 shortcut 回执固定提供 `contractVersion/ok/status/complete/operation/steps/data/warnings/compensation`;`target/failures/verification` 只有实际操作返回时才能消费,不是通用字段。`+import` 使用现有导入回执,成功时检查 `success=true`、`taskId`、`documentUrl`、`documentName` 与 `documentType`,不要要求不存在的 `status/steps`。业务 `status` 不应与框架外层 `outcome` 混为一谈。
|
||||
|
||||
稳定返回 ID/任务 ID 只用于定位目标、恢复流程或继续查询,不能单独证明写操作成功。成功证据优先级从高到低为:与该操作匹配的明确服务端成功终态 → 契约要求的写后读回匹配 → 仅 transport/退出码;异步任务必须使用返回的任务 ID 查询到成功终态。只有成功终态成立且所有必要回读均匹配时才报告成功;`partial_success`、`unknown`、未完成任务或仅返回资源/任务 ID 都不得报告完成。Runtime 已给出充分回读证据时,不为“再确认一次”重复请求。
|
||||
|
||||
## 分页与完整性
|
||||
|
||||
@@ -24,14 +26,14 @@
|
||||
|
||||
- `complete=true`:已证明覆盖请求范围;
|
||||
- `hasMore=true`:仍有后续页,必须保留有效 continuation;
|
||||
- `truncated=true`:因 maxPages/maxItems/timeout 等边界停止;
|
||||
- `failures[]`:已返回部分数据但某一页或后处理失败。
|
||||
- `truncated=true`:成功返回因 `max_pages` 或 `max_items` 边界停止,并通过 `stopReason` 说明原因;
|
||||
- 后续页请求失败、cursor 缺失/停滞/循环时返回 typed error;部分结果位于 `details.items`,并保留 `details.status=partial_success`、`complete=false`、`reason/page/nextCursor/count`。不要期待成功回执的空 `failures[]` 承载这类错误,也不要把 timeout 写成 `truncated`。
|
||||
|
||||
只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。
|
||||
只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。用户问“有哪些”“全部”或要求“列出结果”时,使用返回的 cursor/continuation 继续取页直至完整;只有用户明确要示例、前 N 条或接受部分结果时才可提前停止,并说明已覆盖范围。分页应复用原查询与过滤条件,不得换命令或放宽关键词。
|
||||
|
||||
## 错误
|
||||
|
||||
结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 和 suggested actions。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。
|
||||
结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 与已有的 `actions`/details。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。不要发明 `suggestedAction` 或顶层 `nextCommand` 字段。
|
||||
|
||||
## 安全落盘
|
||||
|
||||
|
||||
@@ -26,10 +26,14 @@
|
||||
dws doc +template-search --query "周报" --source PUBLIC --format json
|
||||
```
|
||||
|
||||
来源按用户原话守门:“我的模板/我这边”只查 `MY`,明确“公开/钉钉模板库”才查 `PUBLIC`;不得为了凑结果跨来源扩展。未指定来源时保持默认 `MY`。
|
||||
|
||||
- `selection.status=resolved`:取唯一候选的 `templateId`。
|
||||
- `selection.status=not_found`:报告零命中后停止。
|
||||
- `selection.status=not_found`:报告零命中后停止;不得改用语义不相干的热门模板,更不得擅自创建文档。
|
||||
- `selection.status=selection_required`:展示候选并要求用户选择,禁止默认第一项。
|
||||
|
||||
若返回 `hasMore=true`,沿原 query/source 使用 cursor 继续搜索;只有服务端返回完整结果后才能判断零命中或完整候选集。
|
||||
|
||||
选定后只创建一次:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,7 +12,7 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --
|
||||
|
||||
- 统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。
|
||||
- `@file` 禁止绝对路径和 `..` 逃逸;不要直接引用宿主临时目录。
|
||||
- `--name` 是文档名称,不等于正文中的 heading。用户明确要求“正文一级标题”时,正文仍须包含对应 H1。
|
||||
- `--name` 是文档名称;默认不要再在正文开头重复同名 H1,只有用户明确要求正文一级标题时才保留。
|
||||
- JSONML 顶层必须是数组;仅在确实需要富结构时加载 JSONML cookbook。
|
||||
|
||||
`+create` 负责创建、长 Markdown 分片和最终回读验证。正常成功结果至少包含:
|
||||
@@ -35,6 +35,18 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --
|
||||
- `status=unknown`:服务端可能已经提交;先定位并读取文档,禁止自动重试。
|
||||
- 没有真实 `nodeId` 或写回执时,禁止声称“已创建”。
|
||||
|
||||
## 创作与执行策略
|
||||
|
||||
采用 Plan → Execute → Observe → Iterate,将循环放在本地内容与定点修正上,不把长文拆成一串远程创建/追加:
|
||||
|
||||
1. **Plan**:明确受众、目的、范围和结构;正文由一个主上下文串行维护,避免按章节并行生成造成重复、矛盾和语气漂移。
|
||||
2. **Draft**:短内容可直接传;多行、长文或含特殊字符时先形成 cwd 内相对文件。用户未要求富结构时优先 Markdown;只有样式/引用/嵌套结构确有必要时才用 JSONML。
|
||||
3. **Execute**:只调用一次 `+create`。DWS Runtime 会分片长 Markdown、记录每步并回读验证;Agent 不采用“先骨架、再逐节远程插入”流程,以减少网络次数、顺序错误和 commit-unknown 面。
|
||||
4. **Observe**:先检查回执中的 `nodeId`、`verified`、`steps` 和失败状态。回执已证明完整时不重复拉全文;需要内容质量验收时,只用 `+fetch --scope section/keyword` 读取待检查部分。
|
||||
5. **Iterate**:后续修正复用同一 `nodeId`,按 [`doc-update.md`](doc-update.md) 做最小 block/文本修改,禁止重新创建整篇。
|
||||
|
||||
交付前检查标题是否重复、段落是否连贯、编号是否统一;只有真实行列数据才使用表格,富组件服务于理解而不是装饰。明确字数要求时应在写入前完成本地统计,不能凭模型估算宣称达标。
|
||||
|
||||
## 高级通道
|
||||
|
||||
只有 shortcut 未公开所需的底层参数或需要原始响应时,才读取精确 leaf Schema 后使用 `dws doc create`。不要因为熟悉旧参数就默认退回原子命令,也不要使用已删除的 Python 创建脚本。
|
||||
|
||||
@@ -8,14 +8,15 @@ dws doc +import --file ./report.docx --folder <FOLDER_ID> --format json
|
||||
dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要" --format json
|
||||
```
|
||||
|
||||
`+import` 一次完成创建会话、上传、确认转换和轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。
|
||||
`+import` 一次完成创建会话、上传、确认转换和终态轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。转换成功回执包含 `success=true`、`taskId`、`documentUrl`、`documentName` 和 `documentType`,不包含 `status` 或 `steps`;成功返回即表示本次内部轮询已到终态。超时或中断时保留错误中的 `taskId`,只查询原任务。
|
||||
|
||||
## 本地文件边界
|
||||
|
||||
- `--file` 只接受当前工作目录内已存在的相对路径;禁止绝对路径、`..` 或符号链接逃逸。
|
||||
- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录,两者都有时优先 `--folder`。
|
||||
- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录。通常只提供一个;当前 Runtime 在两者同时出现时会一并传给服务端,未声明客户端优先级,不要依赖 `--folder` 覆盖 `--workspace`。
|
||||
- CLI 负责上传和格式转换。不要先用 Python/Office 库解析文件,不要安装本地依赖来伪造在线导入结果,也不要手写 HTTP 上传。
|
||||
- 白名单外格式(如 HTML/PDF)自动改走原文件上传,返回 `fallback=upload`、`converted=false`;不得报告成已经转换为可编辑在线文档。
|
||||
- “在线改/协作编辑/转在线文档”属于导入转换;“存着/归档/保留原文件/不要转换”属于 `dingtalk-drive` 纯上传。目标为文档空间时,纯上传使用 `drive upload --workspace <WORKSPACE_ID>`,不要因容器叫“文档空间”就误报为在线文档。
|
||||
|
||||
## 失败处理
|
||||
|
||||
|
||||
@@ -16,10 +16,22 @@ dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./d
|
||||
dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID> --format json
|
||||
```
|
||||
|
||||
## 封面与背景 Shortcut
|
||||
|
||||
```bash
|
||||
dws doc +resource-update --node <DOC_ID> --file ./cover.png --format json
|
||||
dws doc +resource-download --node <DOC_ID> --output ./cover.png --format json
|
||||
dws doc +resource-delete --node <DOC_ID> --format json
|
||||
dws doc +background-update --node <DOC_ID> --color "#E8F2FE" --format json
|
||||
dws doc +background-delete --node <DOC_ID> --format json
|
||||
```
|
||||
|
||||
封面不是正文媒体 block;背景仅接受 `#RRGGBB` 纯色。设置/清除后用一次 `+inspect --include-style` 验证,禁止为这些已知能力查询 shortcut Catalog。
|
||||
|
||||
## 稳定 ID 与结果
|
||||
|
||||
- `resourceId`、`blockId`、`nodeId` 必须来自真实 media/block 返回,不能从标题或本地文件名猜测。
|
||||
- 插入后保留 `resourceId` 和插入结果;下载必须检查 `localPath`、`sizeBytes > 0`。
|
||||
- 插入成功回执在 `data.blockId` 返回已回读验证的媒体块 ID;后续定位只复用该 `blockId`。回执不提供相邻空块字段,不得据此猜测或自动删除其他块。插入回执成功后禁止重传媒体。下载必须检查 `localPath`、`sizeBytes > 0`。
|
||||
- 下载输出只接受工作目录内相对路径,默认 no-clobber。
|
||||
- 删除源文件是独立的破坏性本地操作,不属于媒体下载;只有用户明确要求且下载验证成功后才能执行。
|
||||
|
||||
|
||||
@@ -24,10 +24,24 @@ dws doc +fetch --node <DOC_ID> --scope keyword --keyword "风险|结论" --conte
|
||||
|
||||
只有需要块 ID、revision 或 JSONML 保真结构时才提高 `--detail`;先读取最小必要范围,避免把整篇大文档放入上下文。
|
||||
|
||||
整篇读取使用默认 scope 或 `--scope full`;`full` 不是关键词,禁止写成 `--keyword full`。
|
||||
|
||||
## 最小读取漏斗
|
||||
|
||||
按用户已经给出的线索选最短路径,不先拉全文:
|
||||
|
||||
1. 已知稳定 ID/URL,且任务确实涉及整篇:直接默认 `full + simple`,一次返回 Markdown。
|
||||
2. 用户给出具体术语、错误码或同义词:直接 `keyword`;`foo|bar` 是 OR,`context-before/after` 是字符数。该模式会缩小 Agent 输出与 token,但当前 Runtime 仍需取得正文后做本地投影,不把它误述为减少上游传输。
|
||||
3. 用户指向某章但没有 block ID:先 `outline --detail with-ids`,再用返回的真实标题 ID 执行 `section`。通常两次小结果比反复处理整篇更稳定。
|
||||
4. 已知起止 block:直接 `range`;只找表格、图片等结构时用 `tags`。
|
||||
5. 只有确需全文总结、全局一致性检查或整篇保真改写时,才读取完整内容。
|
||||
|
||||
`simple` 用于阅读;`with-ids` 用于定位下一次写操作;`full` 只用于必须保留样式、引用或 JSONML 结构的编辑。局部结果只能证明所选范围,不得据此声称已检查整篇。
|
||||
|
||||
## 后续路由
|
||||
|
||||
- 读后修改:把稳定的 `nodeId` 交给 [`doc-update.md`](doc-update.md) 的 `+update` 或 `+checkpoint-update`。
|
||||
- 附件/图片:先用 [`doc-media.md`](doc-media.md) 的 `+media-list` 取得稳定 `resourceId`,再下载;不要复用正文中的临时签名 URL。
|
||||
- 富结构专家编辑:确实需要原始 JSONML 或 shortcut 未公开的参数时,先读取精确 leaf Schema,再使用原子 `doc read`/`doc block`。
|
||||
|
||||
禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档。
|
||||
禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档;同一次任务里已拿到稳定 `nodeId` 后,后续调用直接复用,避免再次按标题搜索。
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
普通追加、覆盖和 block 编辑统一使用 `+update`:
|
||||
|
||||
`--command` 只接受下列枚举值,不接受 JSON、自然语言或拼接子命令;动作参数必须分别传给 `--content/--old/--new/--block-id/--after-block-id`。
|
||||
|
||||
```bash
|
||||
dws doc +update --node <DOC_ID> --command append --content "补充说明" --format json
|
||||
dws doc +update --node <DOC_ID> --command append --content @append.md --format json
|
||||
@@ -38,9 +40,29 @@ block ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表,禁止
|
||||
|
||||
`--expected-revision` 只允许 `--command overwrite --doc-format jsonml`。Markdown、append 和 block 接口没有服务端原子 revision 契约,禁止用写前读取模拟乐观锁。
|
||||
|
||||
## 最小改写决策
|
||||
|
||||
| 已知条件 | 推荐路径 | 成本与成功率理由 |
|
||||
|---|---|---|
|
||||
| 用户明确要求在末尾追加 | 直接 `append` | 不为找末尾先拉全文;需要语气衔接时只读末节 |
|
||||
| 已知唯一旧文本与新文本 | 直接 `str_replace --old --new` | 省掉 block 解析;旧文本不唯一时 Runtime 必须失败,不放宽匹配 |
|
||||
| 指定章节但没有 block ID | `+fetch outline` → `+fetch section --detail with-ids` → block 动作 | 两个小读取换取稳定锚点,避免全文 token 和误改相邻章节 |
|
||||
| 已知真实 block ID | 直接 `block_replace/delete/insert_after` | 最小副作用;不改无关 block |
|
||||
| 多处富结构保真修改 | `+fetch --detail full` 后定点 JSONML 更新 | 保留图片、附件、引用、表格和样式;不要从 Markdown 有损重建 |
|
||||
| 整篇重要覆盖 | `+checkpoint-update --mode overwrite` | 自动保存恢复点、执行并回读;普通 overwrite 只用于明确不需恢复点的场景 |
|
||||
|
||||
同一篇文档的正文由一个主上下文串行维护:Plan(确定最小变更)→ Execute(一次写)→ Observe(先消费回执)→ Iterate(只修未达标部分)。不要按章节并行写同一文档,也不要每次迭代都重新读取全文或 Schema。
|
||||
|
||||
## Block ID 生命周期与保真
|
||||
|
||||
- `block_replace` 成功后 Runtime 使用同一 `blockId` 回读验证,该 ID 可继续作为锚点;验证失败时先局部 `+fetch` 核对现状。`block_delete` 成功后旧 ID 失效,不得继续复用。
|
||||
- `block_insert_after` / `block_copy_insert_after` 后,原锚点通常仍可识别,但新 block 的 ID 必须来自真实返回或局部回读,禁止按顺序猜测。
|
||||
- `str_replace` 的简单行内替换通常不要求重新取 ID;若后续依赖块结构,仍以局部回读为准。
|
||||
- 从 Markdown 读取后覆盖整篇可能丢失图片、附件、@人/@文档、评论锚点、表格样式和嵌套块。只改局部时使用 block 手术;确需整篇保真改写时使用 `full` JSONML,并以 `--expected-revision` 防止覆盖并发修改。
|
||||
|
||||
## 确认与验证
|
||||
|
||||
- 执行前从精确 leaf Schema 消费 `confirmation`;需要确认时先 `--dry-run`/询问,用户确认同一参数后追加 `--yes`,禁止先失败探测门禁。
|
||||
- `+update` 与 `+checkpoint-update` 当前都要求用户确认。目标、动作、内容范围或参数变化后必须重新确认;只有发现本地文档与 live leaf 漂移时才查一次精确 Schema,禁止每次写入都重复发现。
|
||||
- `+update` 已负责回读验证。除非结果为 `unknown` 或任务需要额外结构验收,不要再做一次全篇读取。
|
||||
- `doc_write_verification_failed` 表示写入已经发生,必须先读取现状;禁止直接重复写入。
|
||||
- `partial_success` 只恢复未完成步骤,不能重放成功步骤。
|
||||
|
||||
Reference in New Issue
Block a user