Compare commits

..
Author SHA1 Message Date
chichuan 6297b6b0c8 Merge pull request #915 from DingTalk-Real-AI/codex/changelog-v1.0.58-beta.1
docs: seal v1.0.58-beta.1 changelog
2026-08-07 23:46:16 +08:00
chichuan e8905a1984 docs: seal v1.0.58-beta.1 changelog 2026-08-07 23:37:52 +08:00
github-actions[bot] 2662f87ad0 Merge pull request #908 from liyuan333/feat/html-import-hints
feat(doc): fall back to upload chain for non-importable import formats
2026-08-07 19:58:57 +08:00
liyuan 5a5b567eb0 Merge remote-tracking branch 'upstream/main' into feat/html-import-hints
# Conflicts:
#	CHANGELOG.md
2026-08-07 19:27:49 +08:00
liyuan c2db909bd2 Merge branch 'feat/html-import-hints' of github.com:liyuan333/dingtalk-workspace-cli into feat/html-import-hints 2026-08-07 19:17:33 +08:00
liyuan 871542ef0c 评审意见修改 2026-08-07 19:17:25 +08:00
github-actions[bot] 2c7d0f3ac4 Merge pull request #907 from dxb121/codex/multi-im-shortcuts-hardening
feat(chat): harden multi-IM shortcuts and pagination
2026-08-07 18:43:01 +08:00
栩朝 5a345228eb fix(chat): address pagination and audit review feedback 2026-08-07 18:26:39 +08:00
栩朝 b2b6153424 fix(chat): preserve flag-list size schema default 2026-08-07 17:19:36 +08:00
栩朝 59efb4facb feat(chat): harden multi-IM shortcuts and pagination 2026-08-07 17:19:36 +08:00
liyuan333 3605d4f450 Merge branch 'main' into feat/html-import-hints 2026-08-07 17:17:16 +08:00
liyuan f34b7741d4 Merge branch 'feat/html-import-hints' of github.com:liyuan333/dingtalk-workspace-cli into feat/html-import-hints 2026-08-07 17:09:21 +08:00
liyuan 2c573ec892 评审意见修改 2026-08-07 17:07:28 +08:00
github-actions[bot] 3027337a34 Merge pull request #901 from DingTalk-Real-AI/codex/ai-table-shortcut
feat(aitable): expose and verify complete AI Table shortcut surface
2026-08-07 16:41:11 +08:00
liyuan333 d0ad33034e Merge branch 'main' into feat/html-import-hints 2026-08-07 16:36:33 +08:00
liyuan 5f13876e6e fix(doc): address import fallback review — shared prechecks, clean upload primitive, marked JSON envelope 2026-08-07 16:25:19 +08:00
liyuan 116117e987 feat(doc): fall back to upload chain for non-importable import formats 2026-08-07 14:07:35 +08:00
61 changed files with 7151 additions and 446 deletions
+44
View File
@@ -6,15 +6,58 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
## [Unreleased]
## [1.0.58-beta.1] - 2026-08-07
### Added
- **Robot image and file messages** (#867) — `dws chat message send-by-bot`
now supports image URLs and local-file uploads through explicit message
types, while retaining Markdown as the default and preserving its existing
title and text requirements.
- **Conversation shortcut-bar management** (#877) — adds `dws chat toolbar`
commands to list, add, hide, sort, and manage custom conversation shortcuts,
with validation and confirmation for destructive removal.
- **Complete AI Table Shortcut surface** (#901) — makes all 92 supported
AI Table Shortcuts discoverable through Runtime Schema and adds reliable
Base, table, record, attachment, view, dashboard, and workflow operations
with explicit confirmation and result-verification semantics for writes.
### Changed
- **Doc import upload fallback** — `dws doc import` no longer fails on file
formats outside the conversion whitelist (html, pdf, zip, extensionless,
and any future format): it now hands the file to the document-space upload
chain (the same primitive as `dws drive upload --workspace`), stores the
original file at the requested `--folder`/`--workspace` target, and prints
an explicit stderr notice with the supported-format list and the
convert-to-md alternative. The fallback shares the import file checks
(20MB cap, empty-file guard), keeps `--format json` / `--dry-run` output as
a single JSON document, and marks the machine-readable result with
`fallback: "upload"` and `converted: false` so agents never mistake the
stored file for a converted online document. The fallback fails closed
unless the commit response parses as JSON and carries a file identity
(exposed as `dentry_id`); empty or unverifiable responses surface as
errors instead of fabricated success. Importable formats and
`dws sheet import` validation are unchanged.
- **IM natural-target and history alignment** — Chat shortcuts can resolve natural user/group targets before execution, and message-history workflows expose bounded time ranges, ordering, explicit all-page controls, continuation ledgers, safe local export, and thread-reply pagination without treating empty or incomplete reads as successful results. Bundled mono/multi Skills and intent routing now describe the same executable surface.
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
- **Release-equivalent PR compatibility gate** (#889) — pull-request
admission now runs command-surface compatibility checks against the current
release baseline before code reaches `main`.
- **Reviewer routing governance** (#903) — updates the Reviewer Router pool
used for new ready PRs while retaining the existing current-head review and
required-check gates.
### Fixed
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
the registered remote tool name `verify_formula`; the previous
`formula_verify` name failed at gateway dispatch.
- **CLI and parameter recovery boundaries** (#864) — command and parameter
recovery now fail closed when an Agent-provided path or flag cannot be
reconciled with the executable CLI surface, reducing unsafe hallucinated
retries.
## [1.0.57-beta.4] - 2026-08-06
@@ -149,6 +192,7 @@ and compatibility and CI reliability fixes.
### Fixed
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
+1
View File
@@ -89,6 +89,7 @@ policy: test-auth-legacy-compat
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
@python3 scripts/run_chat_shortcut_live_audit_test.py
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
+7 -6
View File
@@ -70,16 +70,17 @@
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
### im → chat(5)
### im → chat(7)
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|---|---|---|
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` 当前页过滤 + `--exclude-muted` + page-size/page-token 别名 + openConversationId/name/conversationType 投影。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
### task → todo(3)
+1 -1
View File
@@ -2681,7 +2681,7 @@
"risk": "write",
"status": "reviewed_available",
"disposition": "primary_smart",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果。",
"semantic_delta": "一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。",
"availability": "available"
},
{
+18
View File
@@ -173,6 +173,24 @@ func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
}
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeReadDispatch(t *testing.T) {
_, preview, attempts, err := executeParamAliasDryRunE2E(t,
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
)
if err != nil {
t.Fatalf("flag-list dry-run error = %v", err)
}
if len(attempts) != 0 {
t.Fatalf("flag-list dry-run crossed dispatch boundary: %#v", attempts)
}
if !preview.DryRun || preview.Executed || preview.Tool != "list_message_favorites" {
t.Fatalf("flag-list dry-run preview = %#v", preview)
}
if preview.Arguments["cursor"] != float64(0) || preview.Arguments["size"] != "20" {
t.Fatalf("flag-list dry-run arguments = %#v", preview.Arguments)
}
}
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
t.Helper()
originalArgs := os.Args
@@ -48,7 +48,7 @@ var paramAliasCompleteCommands = map[string][]string{
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--size", "7"},
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--page-size", "7"},
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
@@ -212,7 +212,7 @@ var paramAliasNewIMCases = []struct {
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
{command: "chat +chat-update", emitted: "title", canonical: "name"},
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
{command: "chat +flag-list", emitted: "limit", canonical: "size"},
{command: "chat +flag-list", emitted: "limit", canonical: "page-size"},
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
+2 -2
View File
@@ -568,9 +568,9 @@ var generatedParamAliases = []ParamAliasEntry{
{
CLIPath: "chat +flag-list",
Aliases: map[string]string{
"limit": "size",
"limit": "page-size",
},
Blocked: []string{"count", "max", "max-results", "max-size", "page", "page-size", "per-page"},
Blocked: []string{"count", "max", "max-results", "max-size", "page", "per-page"},
},
{
CLIPath: "chat +group-members",
+2 -2
View File
@@ -148,7 +148,7 @@
"chat +chat-update": {"scoped_aliases": {"conversation-id": "group", "open-conversation-id": "group", "chat-id": "group", "title": "name", "new-title": "name"}, "block": ["id", "group-id", "group-ids", "conversation-ids", "open-conversation-ids"], "scope_strict": true, "note": "--group accepts a name or CID, so only explicit CID spellings are mapped; generic --id is blocked."},
"chat +conversation-set-top": {"scoped_aliases": {"open-conversation-id": "conversation-id", "chat-id": "conversation-id", "open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "top", "set-top"], "scope_strict": true, "note": "Singular/list cardinality stays explicit; top/set-top cannot be rewritten to the inverse --off switch."},
"chat +feed-group-query-item": {"scoped_aliases": {"open-conversation-ids": "conversation-ids", "chat-ids": "conversation-ids"}, "block": ["group", "groups", "group-id", "group-ids", "conversation-id", "open-conversation-id"], "scope_strict": true, "note": "The real field is an openConversationId list; group names and singular IDs are not converted."},
"chat +flag-list": {"scoped_aliases": {"limit": "size"}, "block": ["max", "max-results", "max-size", "count", "page", "page-size", "per-page"], "scope_strict": true, "note": "Only limit and size are reviewed as the same page bound; total-count and page-number spellings are not equivalent."},
"chat +flag-list": {"scoped_aliases": {"limit": "page-size"}, "block": ["max", "max-results", "max-size", "count", "page", "per-page"], "scope_strict": true, "note": "Native --page-size is the canonical page bound, native --size is its command-owned compatibility alias, and --limit is reviewed as value-preservingly equivalent to --page-size; total-count and page-number spellings are not equivalent."},
"chat +messages-batch-recall-by-bot": {"block": ["msg-id", "message-id", "open-message-id", "msg-ids", "message-ids", "open-message-ids"], "note": "--keys carries processQueryKey values returned by bot sending; it is not an openMessageId field."},
"chat +messages-combine-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "Source and destination conversation roles remain explicit; role-free CID spellings cannot choose a side."},
"chat +messages-forward": {"scoped_aliases": {"src-open-cid": "src-conversation-id", "src-open-conversation-id": "src-conversation-id", "source-conversation-id": "src-conversation-id", "dest-open-cid": "dest-conversation-id", "dest-open-conversation-id": "dest-conversation-id", "target-conversation-id": "dest-conversation-id", "destination-conversation-id": "dest-conversation-id", "src-open-message-id": "msg-id", "source-message-id": "msg-id"}, "block": ["group-id", "group-ids"], "ambiguous": ["conversation-id", "open-conversation-id", "group", "chat", "chat-id", "id"], "scope_strict": true, "note": "The message role is uniquely the source message, but source/destination conversation roles cannot be inferred from a generic CID."},
@@ -361,7 +361,7 @@
{"command": "chat +chat-update", "emitted": "id", "expect": "did-you-mean:blocked", "via": "guard:generic-id-value-domain"},
{"command": "chat +chat-update", "emitted": "title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +chat-update", "emitted": "new-title", "expect": "name", "via": "override:scoped-group-title"},
{"command": "chat +flag-list", "emitted": "limit", "expect": "size", "via": "override:scoped-page-bound"},
{"command": "chat +flag-list", "emitted": "limit", "expect": "page-size", "via": "override:scoped-page-bound"},
{"command": "chat +flag-list", "emitted": "max", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +flag-list", "emitted": "max-results", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
{"command": "chat +flag-list", "emitted": "max-size", "expect": "did-you-mean:blocked", "via": "guard:page-size-vs-total-count"},
+9 -6
View File
@@ -91,7 +91,10 @@ func resolveNativeChatTarget(raw string) (string, error) {
return resolved.Selected.OpenConversationID, nil
}
const maxConversationCategoryTitleRunes = 15
const (
maxConversationCategoryTitleRunes = 15
chatFavoritesMaxPageSize = 30
)
func validatedConversationCategoryTitle(raw string) (string, error) {
title := strings.TrimSpace(raw)
@@ -7467,16 +7470,16 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
首次请求可省略分页参数,CLI 会按 Open 服务契约传 cursor=0、size="20"。
返回 hasMore=true 时,将 nextCursor 作为下一次的 --cursor。`,
Example: ` dws chat message list-favorites
dws chat message list-favorites --size 50
dws chat message list-favorites --cursor 20 --size 20`,
dws chat message list-favorites --size 30
dws chat message list-favorites --cursor 20 --size 20`,
RunE: func(cmd *cobra.Command, args []string) error {
cursor, _ := cmd.Flags().GetInt64("cursor")
if cursor < 0 {
return apperrors.NewValidation("--cursor must be greater than or equal to 0")
}
size, _ := cmd.Flags().GetInt("size")
if size < 1 || size > 100 {
return apperrors.NewValidation("--size must be between 1 and 100")
if size < 1 || size > chatFavoritesMaxPageSize {
return apperrors.NewValidation("--size must be between 1 and 30")
}
return callMCPToolOnServer("im", "list_message_favorites", map[string]any{
"cursor": cursor,
@@ -7515,7 +7518,7 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成
},
})
chatMessageListFavoritesCmd.Flags().Int64("cursor", 0, "数字分页游标(默认 0;翻页时传上次返回的 nextCursor)")
chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-100)")
chatMessageListFavoritesCmd.Flags().Int("size", 20, "一次拉取的收藏数量(默认 20,范围 1-30)")
// ── group list-my-groups: 拉取我创建/管理的群 ──────────────
+4 -4
View File
@@ -190,23 +190,23 @@ func TestChatListFavoritesSuppliesOpenDefaults(t *testing.T) {
func TestChatListFavoritesMapsExplicitPagination(t *testing.T) {
caller := &chatFavoritesCaller{}
err := executeChatFavoritesCommand(t, caller,
"message", "list-favorites", "--cursor", "42", "--size", "50")
"message", "list-favorites", "--cursor", "42", "--size", "30")
if err != nil {
t.Fatalf("list-favorites returned error: %v", err)
}
want := map[string]any{"cursor": int64(42), "size": "50"}
want := map[string]any{"cursor": int64(42), "size": "30"}
if len(caller.calls) != 1 || caller.calls[0].productID != "im" || caller.calls[0].toolName != "list_message_favorites" || !reflect.DeepEqual(caller.calls[0].args, want) {
t.Fatalf("calls = %#v, want im/list_message_favorites %#v", caller.calls, want)
}
}
func TestChatListFavoritesRejectsInvalidSize(t *testing.T) {
for _, size := range []string{"-1", "0", "101"} {
for _, size := range []string{"-1", "0", "31"} {
t.Run(size, func(t *testing.T) {
caller := &chatFavoritesCaller{}
err := executeChatFavoritesCommand(t, caller, "message", "list-favorites", "--size", size)
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 100") {
if err == nil || !strings.Contains(err.Error(), "--size must be between 1 and 30") {
t.Fatalf("error = %v, want size validation error", err)
}
if got := apperrors.ExitCode(err); got != 3 {
+40
View File
@@ -221,6 +221,43 @@ func runDocUpload(cmd *cobra.Command, _ []string) error {
return callMCPTool("commit_uploaded_file", commitArgs)
}
// docSpaceUploadCommitText 执行文档空间三步上传(凭证 → PUT → 入库)并
// 返回 commit 响应原文,供 doc import 的白名单外回退链路组装结构化结果。
// 与 runDocUpload 的区别:不打印输出、不携带 doc upload 的 --workspace
// 兼容告警,调用方负责结果投影。
func docSpaceUploadCommitText(ctx context.Context, filePath, fileName string, fileSize int64, folder, workspace string) (string, error) {
step1Args := map[string]any{}
if folder != "" {
step1Args["folderId"] = folder
}
if workspace != "" {
step1Args["workspaceId"] = workspace
}
text, err := callMCPToolReturnText(ctx, "get_file_upload_info", step1Args)
if err != nil {
return "", err
}
resourceURL, uploadKey, ossHeaders, err := parseUploadInfo(text)
if err != nil {
return "", err
}
if err := httpPutFile(ctx, resourceURL, ossHeaders, filePath, fileSize); err != nil {
return "", err
}
commitArgs := map[string]any{
"uploadKey": uploadKey,
"name": fileName,
"fileSize": float64(fileSize),
}
if folder != "" {
commitArgs["folderId"] = folder
}
if workspace != "" {
commitArgs["workspaceId"] = workspace
}
return callMCPToolReturnText(ctx, "commit_uploaded_file", commitArgs)
}
// parseUploadInfo extracts resourceUrl, uploadKey and headers from the MCP tool response.
func parseUploadInfo(text string) (resourceURL, uploadKey string, headers map[string]string, err error) {
var data map[string]any
@@ -3764,6 +3801,9 @@ CLI 内部自动完成全部流程:
xlsx, xls → 电子表格
md, txt → 文字文档
xmind, mark → 脑图
其他格式(html/pdf/zip 等)→ 不做在线文档转换,自动改走文件上传链路,
以原文件形式存入 --folder/--workspace 指定位置;如需在线文档请先转换
为 md;上传到钉盘请用 dws drive upload
文件大小限制: 20MB
+125 -1
View File
@@ -58,6 +58,11 @@ type importFlowConfig struct {
timeoutAsResult bool
nextCommand string
poll importPollPolicy
// uploadFallback 开启后,所有不在 supportedFormats 白名单内的文件
// (html/pdf/zip/无扩展名等)不再报错断链,统一移交文档空间文件上传
// 链路原样入库;白名单即后端转换能力的封闭集合,无需第二份格式枚举。
// 回退共享 prepareImportFile 的存在性 / 20MB / 空文件校验。
uploadFallback bool
}
type preparedImportFile struct {
@@ -98,6 +103,10 @@ func docImportFlowConfig() importFlowConfig {
workspaceFlags: []string{"workspace", "workspace-id"},
nextCommand: "dws doc import get --task-id %s",
poll: defaultImportPollPolicy(),
// 白名单外的格式改走文档空间的文件上传链路
// (与 drive upload --workspace 同一条 doc-space 上传原语),
// 目标 flags(--folder/--workspace)与 import 同构,链路不中断。
uploadFallback: true,
}
}
@@ -165,7 +174,11 @@ func prepareImportFile(cmd *cobra.Command, args []string, cfg importFlowConfig)
}
extension := strings.TrimPrefix(strings.ToLower(filepath.Ext(filePath)), ".")
if !cfg.supportedFormats[extension] {
// 非回退配置保持基线校验顺序:扩展名门禁先于导入目标校验
// (sheet import 对无目标的非 Excel 文件必须先报 unsupported)。
// uploadFallback 配置的白名单外文件继续走完共享校验,由
// runImportCommand 分派到上传回退。
if !cfg.supportedFormats[extension] && !cfg.uploadFallback {
return preparedImportFile{}, fmt.Errorf("unsupported file format %q, supported: %s", extension, cfg.supportedFormatsText)
}
@@ -197,11 +210,122 @@ func (cfg importFlowConfig) callTool(ctx context.Context, toolName string, args
return callMCPToolReturnText(ctx, toolName, args)
}
// runImportUploadFallback 承接白名单外格式:不再报错断链,改走文档空间
// 文件上传链路原样入库。回退在 prepareImportFile 之后执行,共享存在性 /
// 20MB / 空文件校验;不复用 runDocUpload,避免携带 doc upload 的
// --workspace 兼容告警。移交事实通过 stderr 显式告知,机器可读结果统一
// 携带 fallback=upload / converted=false 标记,防止 Agent 误判已完成
// 在线文档转换。
func runImportUploadFallback(cmd *cobra.Command, cfg importFlowConfig, file preparedImportFile) error {
label := file.extension
if label == "" {
label = "无扩展名"
}
deps.Out.PrintWarning(fmt.Sprintf(
"%s 文件不支持转换为在线文档(支持: %s),已自动改走文件上传链路,以原文件形式存入 --folder/--workspace 指定的目标位置;如需在线文档,请先将内容转换为 md 后重新执行 doc import;上传到钉盘请用 dws drive upload",
label, cfg.supportedFormatsText))
// prepareImportFile 的 name 去掉了扩展名;上传保留原始文件名形态
uploadName := file.name
if filepath.Ext(uploadName) == "" && file.extension != "" {
uploadName += "." + file.extension
}
jsonMode := deps.Caller.Format() == "json"
if deps.Caller.DryRun() {
if jsonMode {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true,
"executed": false,
"preview_kind": "plan",
"operation": "上传文件到钉钉文档",
"requested_operation": cfg.operation,
"fallback": "upload",
"converted": false,
"file": file.path,
"name": uploadName,
"format": file.extension,
"size": file.size,
})
}
deps.Out.PrintKeyValue("操作", "上传文件到钉钉文档(doc import 回退)")
deps.Out.PrintKeyValue("文件", file.path)
deps.Out.PrintKeyValue("名称", uploadName)
deps.Out.PrintKeyValue("格式", file.extension)
deps.Out.PrintKeyValue("大小", fmt.Sprintf("%d bytes", file.size))
return nil
}
ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
if !jsonMode {
deps.Out.PrintInfo("按原文件上传中(未转换为在线文档)...")
}
text, err := docSpaceUploadCommitText(ctx, file.path, uploadName, file.size, file.folder, file.workspace)
if err != nil {
return err
}
// fail-closed:commit 响应必须可解析且带文件标识才算成功;
// 空响应(legacy ack)、非 JSON 或缺少标识都不得包装为 success
commit, dentryID, err := parseUploadCommitResult(text)
if err != nil {
return err
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"operation": "上传文件到钉钉文档",
"requested_operation": cfg.operation,
"fallback": "upload",
"converted": false,
"name": uploadName,
"format": file.extension,
"dentry_id": dentryID,
"result": commit,
})
}
// uploadCommitIDKeys 是 commit_uploaded_file 响应中可作为文件标识的字段,
// 按优先级排列;服务端可能返回平铺对象或包一层 result envelope。
var uploadCommitIDKeys = []string{"dentryUuid", "dentryId", "nodeId", "fileId", "id"}
// parseUploadCommitResult 校验入库响应:拒绝空响应,要求 JSON 对象且
// 含文件标识,返回解析后的对象与标识值。任何不满足都返回错误,
// 由调用方向用户提示核对入库结果,而不是伪装成功。
func parseUploadCommitResult(text string) (map[string]any, string, error) {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return nil, "", fmt.Errorf("上传入库未返回结果(commit_uploaded_file 响应为空),无法确认文件已入库;请用 dws doc list 核对目标位置")
}
var parsed map[string]any
if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil {
return nil, "", fmt.Errorf("上传入库响应无法解析为 JSON,无法确认文件已入库;原始响应: %s", trimmed)
}
payload := parsed
if inner, ok := parsed["result"].(map[string]any); ok {
payload = inner
}
for _, key := range uploadCommitIDKeys {
if v, ok := payload[key].(string); ok && strings.TrimSpace(v) != "" {
return parsed, v, nil
}
}
return nil, "", fmt.Errorf("上传入库响应缺少文件标识(%s 均为空),无法确认文件已入库;原始响应: %s", strings.Join(uploadCommitIDKeys, "/"), trimmed)
}
func runImportCommand(cmd *cobra.Command, args []string, cfg importFlowConfig) error {
file, err := prepareImportFile(cmd, args, cfg)
if err != nil {
return err
}
// 非回退配置的白名单外文件已在 prepareImportFile 中按基线顺序拒绝
if cfg.uploadFallback && !cfg.supportedFormats[file.extension] {
return runImportUploadFallback(cmd, cfg, file)
}
jsonMode := deps.Caller.Format() == "json"
if deps.Caller.DryRun() {
@@ -0,0 +1,379 @@
// 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"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
var (
errUploadInfo = errors.New("upload info failed")
errUploadPut = errors.New("put failed")
)
func htmlFallbackCommand(t *testing.T, filePath string) *cobra.Command {
t.Helper()
// callMCPToolReturnText 从 os.Args 解析产品名(doc)
oldArgs := os.Args
os.Args = []string{"dws", "doc"}
t.Cleanup(func() { os.Args = oldArgs })
cmd := &cobra.Command{Use: "import"}
cmd.Flags().String("file", "", "")
cmd.Flags().String("name", "", "")
cmd.Flags().String("folder", "", "")
cmd.Flags().String("workspace", "", "")
cmd.Flags().String("folder-id", "", "")
cmd.Flags().String("workspace-id", "", "")
if filePath != "" {
if err := cmd.Flags().Set("file", filePath); err != nil {
t.Fatalf("set import file: %v", err)
}
}
return cmd
}
func TestCrossPlatformCoverageDocImportHTMLUploadRedirect(t *testing.T) {
uploadSteps := []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"dentryUuid":"node-1","name":"sales.html"}`},
}
t.Run("html upload fallback emits marked json without legacy warnings", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: uploadSteps}
installScriptedCaller(t, caller)
var stdout, warnings bytes.Buffer
deps.Out.w = &stdout
deps.Out.errW = &warnings
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
t.Fatal(err)
}
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if caller.calls != 2 || caller.tool != "commit_uploaded_file" {
t.Fatalf("fallback calls = %d last tool = %q, want 2 calls ending in commit_uploaded_file", caller.calls, caller.tool)
}
if got := caller.args["workspaceId"]; got != "ws-1" {
t.Fatalf("commit workspaceId = %v, want ws-1", got)
}
if got := caller.args["name"]; got != "sales.html" {
t.Fatalf("commit name = %v, want original file name with extension", got)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("fallback result must be one JSON document: %v\n%s", err, stdout.String())
}
if payload["success"] != true || payload["fallback"] != "upload" || payload["converted"] != false {
t.Fatalf("fallback markers missing: %#v", payload)
}
if payload["dentry_id"] != "node-1" {
t.Fatalf("dentry_id = %v, want node-1", payload["dentry_id"])
}
if payload["requested_operation"] != "导入本地文件为在线文档" {
t.Fatalf("requested_operation = %v", payload["requested_operation"])
}
if !strings.Contains(warnings.String(), "文件上传链路") {
t.Fatalf("fallback must announce the upload on stderr, got %q", warnings.String())
}
if strings.Contains(warnings.String(), "deprecated") {
t.Fatalf("fallback must not emit the doc upload deprecation warning, got %q", warnings.String())
}
})
t.Run("json dry run stays a single json document", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("json dry-run output is not JSON: %v\n%s", err, stdout.String())
}
if payload["dry_run"] != true || payload["executed"] != false || payload["fallback"] != "upload" || payload["converted"] != false {
t.Fatalf("dry-run fallback payload = %#v", payload)
}
})
t.Run("any non-importable format is redirected, not enumerated", func(t *testing.T) {
for _, ext := range []string{"pdf", "zip", "png", "mp4"} {
caller := &scriptedToolCaller{steps: uploadSteps}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, ext))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand(%s) error = %v, want upload fallback success", ext, err)
}
if caller.tool != "commit_uploaded_file" {
t.Fatalf("%s last tool = %q, want commit_uploaded_file", ext, caller.tool)
}
}
})
t.Run("uppercase htm extension via positional argument", func(t *testing.T) {
caller := &scriptedToolCaller{steps: uploadSteps}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
path := writeImportFixture(t, "HTM")
cmd := htmlFallbackCommand(t, "")
if err := runImportCommand(cmd, []string{path}, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if caller.tool != "commit_uploaded_file" {
t.Fatalf("last tool = %q, want commit_uploaded_file", caller.tool)
}
})
t.Run("hidden folder-id alias reaches the upload chain", func(t *testing.T) {
caller := &scriptedToolCaller{steps: uploadSteps}
installScriptedCaller(t, caller)
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := cmd.Flags().Set("folder-id", "folder-abc"); err != nil {
t.Fatal(err)
}
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if got := caller.args["folderId"]; got != "folder-abc" {
t.Fatalf("commit folderId = %v, want folder-abc from --folder-id alias", got)
}
})
t.Run("extensionless file is redirected with a readable label", func(t *testing.T) {
caller := &scriptedToolCaller{steps: uploadSteps}
installScriptedCaller(t, caller)
var warnings bytes.Buffer
deps.Out.errW = &warnings
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
noExt := filepath.Join(t.TempDir(), "README")
if err := os.WriteFile(noExt, []byte("plain"), 0o600); err != nil {
t.Fatal(err)
}
cmd := htmlFallbackCommand(t, noExt)
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v, want upload fallback success", err)
}
if !strings.Contains(warnings.String(), "无扩展名") {
t.Fatalf("warning must label the extensionless file, got %q", warnings.String())
}
})
t.Run("fallback enforces the shared 20MB limit", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
big := filepath.Join(t.TempDir(), "big.html")
f, err := os.Create(big)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(importMaxFileSize + 1); err != nil {
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
cmd := htmlFallbackCommand(t, big)
err = runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "exceeds 20MB limit") {
t.Fatalf("runImportCommand() error = %v, want 20MB limit rejection", err)
}
})
t.Run("fallback enforces the shared empty-file guard", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
empty := filepath.Join(t.TempDir(), "empty.html")
if err := os.WriteFile(empty, nil, 0o600); err != nil {
t.Fatal(err)
}
cmd := htmlFallbackCommand(t, empty)
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "file is empty") {
t.Fatalf("runImportCommand() error = %v, want empty-file rejection", err)
}
})
t.Run("importable formats keep the conversion path", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", dry: true}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
cmd := htmlFallbackCommand(t, writeImportFixture(t, "md"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("import dry-run output is not JSON: %v\n%s", err, stdout.String())
}
if payload["operation"] != "导入本地文件为在线文档" {
t.Fatalf("operation = %v, want conversion path", payload["operation"])
}
if _, ok := payload["fallback"]; ok {
t.Fatalf("importable format must not carry fallback marker: %#v", payload)
}
})
t.Run("missing file falls through to the import required-flag error", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
cmd := htmlFallbackCommand(t, "")
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "--file is required") {
t.Fatalf("runImportCommand() error = %v, want --file required", err)
}
})
t.Run("text dry run prints the fallback plan as key values", func(t *testing.T) {
caller := &scriptedToolCaller{dry: true}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
if caller.calls != 0 {
t.Fatalf("dry run must not call MCP, calls = %d", caller.calls)
}
if !strings.Contains(stdout.String(), "doc import 回退") || !strings.Contains(stdout.String(), "sales.html") {
t.Fatalf("text dry-run must print the fallback plan, got %q", stdout.String())
}
})
t.Run("upload chain errors propagate", func(t *testing.T) {
cases := []struct {
name string
steps []scriptedToolStep
putErr error
wantErr string
}{
{name: "upload info request fails", steps: []scriptedToolStep{{err: errUploadInfo}}, wantErr: "upload info failed"},
{name: "upload credentials incomplete", steps: []scriptedToolStep{{text: `{"resourceUrl":""}`}}, wantErr: "incomplete upload credentials"},
{name: "http put fails", steps: []scriptedToolStep{{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`}}, putErr: errUploadPut, wantErr: "put failed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{steps: tc.steps})
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return tc.putErr })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
}
})
}
})
t.Run("commit responses that cannot prove success are rejected", func(t *testing.T) {
cases := []struct {
name string
commit string
wantErr string
}{
{name: "empty legacy ack", commit: " ", wantErr: "响应为空"},
{name: "non-json text", commit: "commit-ok-plain-text", wantErr: "无法解析为 JSON"},
{name: "missing file identity", commit: `{"ok":true}`, wantErr: "缺少文件标识"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: tc.commit},
}})
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
err := runImportCommand(cmd, nil, docImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("runImportCommand() error = %v, want %q", err, tc.wantErr)
}
})
}
})
t.Run("nested result envelope yields the dentry id", func(t *testing.T) {
caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{
{text: `{"resourceUrl":"https://upload.example.test/object","uploadKey":"key-1"}`},
{text: `{"result":{"dentryUuid":"nested-node-9"}}`},
}}
installScriptedCaller(t, caller)
var stdout bytes.Buffer
deps.Out.w = &stdout
SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { return nil })
t.Cleanup(func() { SetHTTPPutFile(nil) })
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := runImportCommand(cmd, nil, docImportFlowConfig()); err != nil {
t.Fatalf("runImportCommand() error = %v", err)
}
var payload map[string]any
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("fallback result must stay JSON: %v\n%s", err, stdout.String())
}
if payload["dentry_id"] != "nested-node-9" {
t.Fatalf("dentry_id = %v, want nested-node-9", payload["dentry_id"])
}
})
t.Run("sheet import keeps rejecting html without fallback", func(t *testing.T) {
installScriptedCaller(t, &scriptedToolCaller{})
// 无导入目标时也必须先报 unsupported(基线校验顺序:扩展名先于目标)
cmd := htmlFallbackCommand(t, writeImportFixture(t, "html"))
err := runImportCommand(cmd, nil, sheetImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
t.Fatalf("sheet import (no target) error = %v, want unsupported file format", err)
}
cmd = htmlFallbackCommand(t, writeImportFixture(t, "html"))
if err := cmd.Flags().Set("workspace", "ws-1"); err != nil {
t.Fatal(err)
}
err = runImportCommand(cmd, nil, sheetImportFlowConfig())
if err == nil || !strings.Contains(err.Error(), "unsupported file format") {
t.Fatalf("sheet import (with target) error = %v, want unsupported file format", err)
}
})
}
+502 -35
View File
@@ -21,11 +21,18 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
const (
chatSearchHardPageLimit = 500
chatSearchMaxWindowSize = 100
chatListAllHardPageLimit = 500
)
// ChatSearch searches groups by keyword (search_groups on the im server).
var ChatSearch = shortcut.Shortcut{
Service: "chat",
@@ -33,8 +40,8 @@ var ChatSearch = shortcut.Shortcut{
Aliases: []string{"+chat-group-search", "+search-group"},
SinglePositionalAliasFor: "query",
Product: "im",
Description: "按关键词搜索群聊",
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。",
Description: "按关键词分页搜索群聊,支持有界自动翻页和完整性检查",
Intent: "当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;默认读取一页,明确要求全部候选时加 --page-all,并用 --page-limit 保持有界。结果按 openConversationId 去重,并公开 complete、hasMore、nextCursor、stopReason 和 failures,避免把截断或失败结果误当完整候选集。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -48,15 +55,15 @@ var ChatSearch = shortcut.Shortcut{
CLIPath: "chat +chat-search",
PrimaryCLIPath: "chat +chat-search",
},
Description: "按关键词搜索群聊",
Description: "按关键词分页搜索群聊,支持有界自动翻页和完整性检查",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按关键词搜索群聊",
UseWhen: []string{"当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;按群名模糊搜索,只读分页返回匹配的群列表。"},
AgentSummary: "按关键词分页搜索群聊,支持有界自动翻页和完整性检查",
UseWhen: []string{"当你只记得群名称关键词、需要拿到群 openConversationId 以便发消息或管理该群时使用;默认读取一页,明确要求全部候选时加 --page-all,并用 --page-limit 保持有界。结果按 openConversationId 去重,并公开 complete、hasMore、nextCursor、stopReason 和 failures,避免把截断或失败结果误当完整候选集。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{"dws chat +chat-search --query \"项目冲刺\""},
},
@@ -64,30 +71,327 @@ var ChatSearch = shortcut.Shortcut{
Flags: []shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "群名称关键词"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量"},
{Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页返回数量;显式页大小必须在 1-100 之间"},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的 Lark 对齐别名;显式页大小必须在 1-100 之间"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "cursor", Type: shortcut.FlagString, Default: "0", Desc: "分页游标,翻页传 nextCursor"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "--cursor 的 Lark 对齐别名"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部群搜索分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已设置免打扰的群聊"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"query", "keyword"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size", "size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-size"}, Description: "显式页大小必须在 1-100 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{`dws chat +chat-search --query "项目冲刺"`},
Execute: func(rt *shortcut.RuntimeContext) error {
query := rt.Str("query")
if query == "" {
query = rt.Str("keyword")
}
params := map[string]any{
"keyword": query,
"limit": rt.IntFirst("limit", "size"),
"cursor": rt.Str("cursor"),
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
}
return rt.CallMCP("search_groups", params)
Tips: []string{
`dws chat +chat-search --query "项目冲刺"`,
`dws chat +chat-search --query "项目" --page-size 100 --page-all --page-limit 20`,
},
Validate: validateChatSearch,
Execute: executeChatSearch,
}
func validateChatSearch(rt *shortcut.RuntimeContext) error {
if size := chatSearchPageSize(rt); size < 1 || size > 100 {
return apperrors.NewValidation("--limit/--page-size/--size 必须在 1-100 之间")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > chatSearchHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
func chatSearchPageSize(rt *shortcut.RuntimeContext) int {
if rt.Changed("page-size") {
return rt.Int("page-size")
}
if rt.Changed("size") {
return rt.Int("size")
}
return rt.Int("limit")
}
func chatSearchStartCursor(rt *shortcut.RuntimeContext) string {
if rt.Changed("page-token") {
if value := strings.TrimSpace(rt.Str("page-token")); value != "" {
return value
}
}
if value := strings.TrimSpace(rt.Str("cursor")); value != "" {
return value
}
return "0"
}
func chatSearchRequestParams(query string, pageSize int, cursor string, excludeMuted bool) map[string]any {
params := map[string]any{
"keyword": query,
"limit": pageSize,
"cursor": cursor,
}
if excludeMuted {
params["excludeMuted"] = true
}
return params
}
func executeChatSearch(rt *shortcut.RuntimeContext) error {
query := strings.TrimSpace(rt.StrFirst("query", "keyword"))
pageSize := chatSearchPageSize(rt)
requestPageSize := pageSize
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
}
cursor := chatSearchStartCursor(rt)
if rt.DryRun() {
return rt.CallMCP("search_groups", chatSearchRequestParams(query, pageSize, cursor, rt.Bool("exclude-muted")))
}
initialCursor := cursor
seenCursors := map[string]bool{cursor: true}
seenChats := map[string]bool{}
chats := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
paginationKnown := true
complete := false
hasMore := false
nextCursor := ""
stopReason := "source_complete"
truncatedByPageLimit := false
maxWindowProbeUsed := false
completionEvidence := ""
for pagesFetched < pageLimit {
params := chatSearchRequestParams(query, requestPageSize, cursor, rt.Bool("exclude-muted"))
data, err := rt.CallMCPData("im", "search_groups", params)
if err != nil {
if pagesFetched == 0 {
return err
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": err.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageItems := chatSearchItems(data)
for _, chat := range pageItems {
id := strings.TrimSpace(fmt.Sprint(chat["openConversationId"]))
if id != "" && id != "<nil>" && seenChats[id] {
continue
}
if id != "" && id != "<nil>" {
seenChats[id] = true
}
chats = append(chats, chat)
}
page := chatmsg.Pagination(data)
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
nextCursor = chatSearchCursorString(page["nextCursor"])
if !hasMoreKnown {
switch {
case nextCursor != "":
pageHasMore = true
case len(pageItems) < requestPageSize:
paginationKnown = false
complete = true
hasMore = false
stopReason = "legacy_short_page"
default:
paginationKnown = false
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "群搜索返回满页结果但缺少 hasMore/nextCursor,无法证明结果完整",
})
stopReason = "pagination_error"
}
if complete || len(failures) > 0 {
break
}
}
hasMore = pageHasMore
if !hasMore {
// A full first page with hasMore=false/no cursor is the live legacy
// shape that can hide additional rows. Once a prior page supplied a
// valid continuation cursor, its terminal hasMore=false is trustworthy.
fullPageWithoutCursor := len(pageItems) >= requestPageSize && nextCursor == "" &&
(pagesFetched == 1 || (maxWindowProbeUsed && pagesFetched == 2))
if fullPageWithoutCursor {
paginationKnown = false
if rt.Bool("page-all") && initialCursor == "0" && !maxWindowProbeUsed && requestPageSize < chatSearchMaxWindowSize && pagesFetched < pageLimit {
maxWindowProbeUsed = true
requestPageSize = chatSearchMaxWindowSize
cursor = initialCursor
stopReason = "max_window_probe"
continue
}
if !rt.Bool("page-all") {
complete = false
stopReason = "single_page_full_untrusted"
break
}
if pagesFetched >= pageLimit && requestPageSize < chatSearchMaxWindowSize {
truncatedByPageLimit = true
complete = false
stopReason = "page_limit"
break
}
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "群搜索在最大窗口仍返回满页且没有可用 nextCursor,无法证明结果完整",
})
complete = false
stopReason = "pagination_error"
break
}
complete = true
nextCursor = ""
if maxWindowProbeUsed {
paginationKnown = false
completionEvidence = "max_window_short_page"
stopReason = "max_window_short_page"
} else {
completionEvidence = "backend_has_more_false_short_page"
stopReason = "source_complete"
}
break
}
if nextCursor == "" || seenCursors[nextCursor] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "群搜索返回 hasMore=true,但 nextCursor 缺失或未前进",
})
stopReason = "pagination_error"
break
}
if !rt.Bool("page-all") {
stopReason = "single_page"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = rt.Bool("page-all")
if truncatedByPageLimit {
stopReason = "page_limit"
}
}
payload := map[string]any{
"query": query,
"count": len(chats),
"chats": chats,
"pagesFetched": pagesFetched,
"paginationKnown": paginationKnown,
"complete": complete && len(failures) == 0,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0 && len(chats) > 0,
"requestedPageSize": pageSize,
"effectivePageSize": requestPageSize,
}
if completionEvidence != "" {
payload["completionEvidence"] = completionEvidence
}
if rt.Bool("exclude-muted") {
payload["filter"] = map[string]any{"excludeMuted": true}
}
if err := rt.Output(payload); err != nil {
return err
}
if len(failures) > 0 {
return apperrors.NewAPI(
fmt.Sprintf("群搜索分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/search_groups"),
apperrors.WithReason("chat_search_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
return nil
}
func chatSearchItems(data map[string]any) []map[string]any {
if data == nil {
return []map[string]any{}
}
scopes := []map[string]any{data}
for _, key := range []string{"result", "data"} {
if nested, ok := data[key].(map[string]any); ok {
scopes = append(scopes, nested)
}
}
for _, scope := range scopes {
for _, key := range []string{"groups", "chats", "items", "list", "result"} {
if raw, ok := scope[key].([]any); ok {
return projectChatSearchItems(raw)
}
}
}
return []map[string]any{}
}
func projectChatSearchItems(raw []any) []map[string]any {
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
group, ok := item.(map[string]any)
if !ok {
continue
}
projected := make(map[string]any, len(group)+1)
for key, value := range group {
projected[key] = value
}
if _, exists := projected["openConversationId"]; !exists {
for _, key := range []string{"conversationId", "chatId", "id"} {
if value, ok := group[key]; ok {
projected["openConversationId"] = value
break
}
}
}
if _, exists := projected["name"]; !exists {
for _, key := range []string{"title", "conversationName"} {
if value, ok := group[key]; ok {
projected["name"] = value
break
}
}
}
out = append(out, projected)
}
return out
}
func chatSearchCursorString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || text == "0" {
return ""
}
return text
}
// ChatMembersList lists members of a group (get_group_members, chat server).
@@ -575,27 +879,190 @@ var ChatListAll = shortcut.Shortcut{
},
},
Flags: []shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量(最大 200)"},
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页返回数量;--limit 必须在 1-200 之间"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传 nextCursor"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{`dws chat +chat-list-all --limit 50`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +chat-list-all --limit 50`,
`dws chat +chat-list-all --limit 200 --page-all --page-limit 50`,
},
Validate: validateChatListAll,
Execute: executeChatListAll,
}
func validateChatListAll(rt *shortcut.RuntimeContext) error {
if limit := rt.Int("limit"); limit < 1 || limit > 200 {
return apperrors.NewValidation("--limit 必须在 1-200 之间")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > chatListAllHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if c := rt.Str("cursor"); c != "" && c != "0" {
params["cursor"] = c
}
return nil
}
func executeChatListAll(rt *shortcut.RuntimeContext) error {
params := map[string]any{}
if rt.Int("limit") > 0 {
params["limit"] = rt.Int("limit")
}
if c := rt.Str("cursor"); c != "" && c != "0" {
params["cursor"] = c
}
if rt.Bool("page-all") {
payload, err := readAllChatListAll(rt, params)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
return err
}
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
if err != nil {
return err
}
groups := chatListAllProject(data)
payload := map[string]any{"count": len(groups), "groups": groups}
chatmsg.ApplyPagination(payload, data)
payload["pagesFetched"] = 1
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
payload["stopReason"] = "single_page"
}
return rt.Output(payload)
}
func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any) (map[string]any, error) {
pageLimit := rt.Int("page-limit")
cursorValue := baseParams["cursor"]
cursorKey := chatListAllCursorString(cursorValue)
if cursorKey == "" {
cursorKey = "0"
}
seenCursors := map[string]bool{cursorKey: true}
seenGroups := map[string]bool{}
allGroups := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
complete := false
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
var nextCursor any
for pagesFetched < pageLimit {
params := map[string]any{"limit": baseParams["limit"]}
if cursorKey != "0" {
params["cursor"] = cursorValue
}
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
if err != nil {
return err
if pagesFetched == 0 {
return nil, err
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursorKey, "error": err.Error(),
})
stopReason = "read_failure"
break
}
groups := chatListAllProject(data)
payload := map[string]any{"count": len(groups), "groups": groups}
chatmsg.ApplyPagination(payload, data)
return rt.Output(payload)
},
pagesFetched++
pageGroups := chatListAllProject(data)
for _, group := range pageGroups {
id := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
if id == "<nil>" {
id = ""
}
if id != "" && seenGroups[id] {
continue
}
if id != "" {
seenGroups[id] = true
}
allGroups = append(allGroups, group)
}
page := chatmsg.Pagination(data)
pageHasMore, known := page["hasMore"].(bool)
if !known {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "已加入群列表下层未返回可靠的 hasMore,无法证明结果完整",
})
stopReason = "pagination_error"
break
}
hasMore = pageHasMore
if !hasMore {
complete = true
nextCursor = nil
stopReason = "source_complete"
break
}
nextCursor = page["nextCursor"]
nextKey := chatListAllCursorString(nextCursor)
if nextKey == "" || seenCursors[nextKey] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "已加入群列表下层返回 hasMore=true,但 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
seenCursors[nextKey] = true
cursorKey = nextKey
cursorValue = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := map[string]any{
"count": len(allGroups), "groups": allGroups,
"pagesFetched": pagesFetched, "paginationKnown": true,
"complete": complete && len(failures) == 0, "hasMore": hasMore,
"stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures), "failures": failures,
"partial": len(failures) > 0 && len(allGroups) > 0,
}
if hasMore && nextCursor != nil {
payload["nextCursor"] = nextCursor
}
if len(failures) == 0 {
return payload, nil
}
return payload, apperrors.NewAPI(
fmt.Sprintf("已加入群列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_my_groups_pagination"),
apperrors.WithReason("chat_list_all_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
func chatListAllCursorString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || text == "0" {
return ""
}
return text
}
// chatListAllProject reshapes list_my_groups_pagination into a clean group list
@@ -6,6 +6,8 @@ package chat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"reflect"
"testing"
@@ -213,6 +215,19 @@ func TestCrossPlatformCoverageChatListProjectionHelpers(t *testing.T) {
if _, err := normalizeChatListTypes([]string{"bad"}); err == nil {
t.Fatal("invalid type accepted")
}
var tuple map[string]any
if err := json.Unmarshal([]byte(`{"result":[[{"openConversationId":"tuple"}],2,true]}`), &tuple); err != nil {
t.Fatal(err)
}
page, err := chatListPagination(tuple)
if err != nil || page["nextCursor"] != float64(2) || page["hasMore"] != true || len(chatListProject(tuple)) != 1 {
t.Fatalf("tuple normalization rows=%#v page=%#v err=%v", chatListProject(tuple), page, err)
}
ordinary := map[string]any{"result": []any{map[string]any{"openConversationId": "ordinary"}}}
page, err = chatListPagination(ordinary)
if err != nil || page != nil || len(chatListProject(ordinary)) != 1 {
t.Fatalf("ordinary list rows=%#v page=%#v err=%v", chatListProject(ordinary), page, err)
}
}
func TestCrossPlatformCoverageChatListExecuteAndHelperEdges(t *testing.T) {
@@ -317,3 +332,310 @@ func TestCrossPlatformCoverageChatListCursorAndPageSizeDirect(t *testing.T) {
t.Fatal("expected executeChatList cursor error")
}
}
func TestCrossPlatformCoverageChatListPageAllMergesBeforeTypeFilter(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversations":[{"openConversationId":"direct-1","title":"单聊","singleChat":true}],"hasMore":true,"nextCursor":2}}`,
`{"result":{"conversations":[{"openConversationId":"direct-1","title":"重复单聊","singleChat":true},{"openConversationId":"group-1","title":"项目群","singleChat":false}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list", "--types", "group", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != 2 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
t.Fatalf("payload = %#v", payload)
}
chat := payload["chats"].([]any)[0].(map[string]any)
if chat["openConversationId"] != "group-1" || chat["conversationType"] != "group" {
t.Fatalf("chat = %#v", chat)
}
}
func TestCrossPlatformCoverageChatListProbesMaximumWindowWhenBackendFalselyEndsFullFirstPage(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversations":[{"openConversationId":"group-1","title":"项目一群","singleChat":false}],"hasMore":false}}`,
`{"result":{"conversations":[{"openConversationId":"group-1","title":"项目一群","singleChat":false},{"openConversationId":"group-2","title":"项目二群","singleChat":false}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].args["limit"] != 1 || fake.calls[1].args["limit"] != 100 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["complete"] != true || payload["paginationKnown"] != false ||
payload["completionEvidence"] != "max_window_short_page" || payload["stopReason"] != "max_window_short_page" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListPageLimitPublishesContinuation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"group-1","title":"项目群","singleChat":false}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list", "--page-all", "--page-limit", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["hasMore"] != true || payload["nextCursor"] != float64(9) ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListLaterPageFailureKeepsPartialLedger(t *testing.T) {
fake := &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"im/list_all_conversations": {`{"result":{"conversations":[{"openConversationId":"group-1","title":"项目群","singleChat":false}],"hasMore":true,"nextCursor":2}}`},
},
failProductToolAt: map[string]int{"im/list_all_conversations": 2},
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("expected later-page error")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["complete"] != false || payload["partial"] != true ||
payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+chat-list"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
if _, err := chatListNextCursor(float64(1.5)); err == nil {
t.Fatal("fractional cursor unexpectedly accepted")
}
if _, err := chatListNextCursor(struct{}{}); err == nil {
t.Fatal("unsupported cursor unexpectedly accepted")
}
}
func TestCrossPlatformCoverageChatListAdditionalPaginationEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+chat-list"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("first read failure", func(t *testing.T) {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_all_conversations": 1}})
if err == nil {
t.Fatal("first read failure unexpectedly succeeded")
}
})
t.Run("cursor without hasMore continues", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversations":[{"openConversationId":"g1"}],"nextCursor":2}}`,
`{"result":{"conversations":[],"hasMore":false}}`,
},
}}, "--page-all")
if err != nil || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("gateway tuple preserves continuation metadata", func(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":[[{"openConversationId":"g1","singleChat":false}],2,true]}`,
`{"result":[[{"openConversationId":"g2","singleChat":false}],0,false]}`,
},
}}
payload, err := run(t, fake, "--page-size", "1", "--page-all")
if err != nil {
t.Fatalf("tuple pagination payload=%#v calls=%#v err=%v", payload, fake.calls, err)
}
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != 2 {
t.Fatalf("tuple pagination calls = %#v", fake.calls)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["hasMore"] != false || payload["stopReason"] != "source_complete" {
t.Fatalf("tuple pagination payload = %#v", payload)
}
})
for name, response := range map[string]string{
"missing metadata": `{"result":[[{"openConversationId":"g1"}]]}`,
"invalid hasMore": `{"result":[[{"openConversationId":"g1"}],2,"true"]}`,
} {
t.Run("malformed gateway tuple "+name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": response,
}}, "--page-all")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("malformed tuple payload=%#v err=%v", payload, err)
}
})
}
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"g1"}]}}`,
}}, "--page-size", "1")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("single full page is untrusted", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":false}}`,
}}, "--page-size", "1")
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page_full_untrusted" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("bounded probe reports page limit", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":false}}`,
}}, "--page-size", "1", "--page-all", "--page-limit", "1")
if err != nil || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("full maximum probe fails closed", func(t *testing.T) {
chats := make([]map[string]any, chatListMaxWindowSize)
for i := range chats {
chats[i] = map[string]any{"openConversationId": fmt.Sprintf("probe-%d", i)}
}
second, marshalErr := json.Marshal(map[string]any{"result": map[string]any{"conversations": chats, "hasMore": false}})
if marshalErr != nil {
t.Fatal(marshalErr)
}
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_all_conversations": {
`{"result":{"conversations":[{"openConversationId":"first"}],"hasMore":false}}`,
string(second),
},
}}, "--page-size", "1", "--page-all")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
response string
wantErr bool
stop string
}{
{name: "missing continuation", response: `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
{name: "single page continuation", response: `{"result":{"conversations":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_all_conversations": tc.response}})
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("exclude muted and output errors", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversations":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+chat-list", "--exclude-muted"})
if err := root.Execute(); err == nil || fake.calls[0].args["excludeMuted"] != true {
t.Fatalf("calls=%#v err=%v", fake.calls, err)
}
})
}
func TestCrossPlatformCoverageChatListCursorTypeEdges(t *testing.T) {
for _, tc := range []struct {
name string
value any
ok bool
}{
{name: "nil", value: nil, ok: true},
{name: "int", value: int(1), ok: true},
{name: "negative int", value: int(-1)},
{name: "int64", value: int64(2), ok: true},
{name: "negative int64", value: int64(-1)},
{name: "float64", value: float64(3), ok: true},
{name: "negative float64", value: float64(-1)},
{name: "json number", value: json.Number("4"), ok: true},
{name: "invalid json number", value: json.Number("bad")},
{name: "empty string", value: "", ok: true},
{name: "string", value: "5", ok: true},
{name: "invalid string", value: "bad"},
{name: "unsupported", value: struct{}{}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := chatListNextCursor(tc.value)
if (err == nil) != tc.ok {
t.Fatalf("value=%#v err=%v", tc.value, err)
}
})
}
}
+238 -37
View File
@@ -16,9 +16,12 @@ package chat
import (
"encoding/json"
"fmt"
"math"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
@@ -27,6 +30,8 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
const directMessagesHardPageLimit = 500
// MessagesSend sends a text/markdown message as the current user
// (send_personal_message, chat server). Media/file variants are not covered.
// MessagesReply quote-replies a message (send_personal_message, chat server).
@@ -283,8 +288,8 @@ var MessagesList = shortcut.Shortcut{
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=从给定时间往现在拉,false=往以前拉"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量;显式页大小必须大于 0"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名;显式页大小必须大于 0", Hidden: true},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
Constraints: []shortcut.Constraint{
@@ -407,16 +412,6 @@ func listMessagesResolveMaps(data map[string]any) []map[string]any {
return out
}
// listMessagesFirst returns the first present candidate key's value.
func listMessagesFirst(m map[string]any, keys ...string) (any, bool) {
for _, k := range keys {
if v, ok := m[k]; ok {
return v, true
}
}
return nil, false
}
// MessagesListDirect pulls messages of a single chat (list_individual_chat_message, chat server).
var MessagesListDirect = shortcut.Shortcut{
Service: "chat",
@@ -454,40 +449,246 @@ var MessagesListDirect = shortcut.Shortcut{
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "对方 openDingTalkId(与 --user 二选一)"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\"", Required: true},
{Name: "forward", Type: shortcut.FlagBool, Default: "true", Desc: "true=往现在拉,false=往以前拉"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量;显式页大小必须大于 0"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名;显式页大小必须大于 0", Hidden: true},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿毫秒级 nextCursor 自动读取全部单聊消息;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
},
Tips: []string{`dws chat +messages-list-direct --user <userId> --time "2025-03-01 00:00:00"`},
Execute: func(rt *shortcut.RuntimeContext) error {
params := map[string]any{
"time": rt.Str("time"),
"forward": rt.Bool("forward"),
Tips: []string{
`dws chat +messages-list-direct --user <userId> --time "2025-03-01 00:00:00"`,
`dws chat +messages-list-direct --open-dingtalk-id <openDingTalkId> --time "2025-03-01 00:00:00" --forward=false --page-all`,
},
Execute: executeMessagesListDirect,
}
func validateMessagesListDirectPagination(rt *shortcut.RuntimeContext) error {
// This existing command shipped without formal Schema constraints. Keep its
// published constraint set stable and validate the new pagination options at
// runtime so adding --page-all remains backwards compatible for consumers.
for _, name := range []string{"limit", "size"} {
if rt.Changed(name) && rt.Int(name) <= 0 {
return apperrors.NewValidation("--" + name + " 必须大于 0")
}
switch {
case rt.Str("open-dingtalk-id") != "":
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
case rt.Str("user") != "":
params["userId"] = rt.Str("user")
default:
return fmt.Errorf("--user 或 --open-dingtalk-id 必填其一")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > directMessagesHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
}
return nil
}
func executeMessagesListDirect(rt *shortcut.RuntimeContext) error {
if err := validateMessagesListDirectPagination(rt); err != nil {
return err
}
params := map[string]any{
"time": rt.Str("time"),
"forward": rt.Bool("forward"),
}
switch {
case rt.Str("open-dingtalk-id") != "":
params["openDingTalkId"] = rt.Str("open-dingtalk-id")
case rt.Str("user") != "":
params["userId"] = rt.Str("user")
default:
return fmt.Errorf("--user 或 --open-dingtalk-id 必填其一")
}
if limit := rt.IntFirst("limit", "size"); limit > 0 {
params["limit"] = limit
}
if rt.Bool("page-all") {
payload, err := readAllDirectMessages(rt, params)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
return err
}
data, err := rt.CallMCPData("chat", "list_individual_chat_message", params)
if err != nil {
return err
}
messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions"))
payload := map[string]any{"count": len(messages), "messages": messages}
direction := "older"
if rt.Bool("forward") {
direction = "newer"
}
chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction)
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
payload["stopReason"] = "single_page"
}
return rt.Output(payload)
}
func readAllDirectMessages(rt *shortcut.RuntimeContext, params map[string]any) (map[string]any, error) {
pageLimit := rt.Int("page-limit")
direction := "older"
if rt.Bool("forward") {
direction = "newer"
}
seenCursors := map[string]bool{}
seenMessages := map[string]bool{}
allItems := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
complete := false
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
var nextPage map[string]any
for pagesFetched < pageLimit {
data, err := rt.CallMCPData("chat", "list_individual_chat_message", params)
if err != nil {
return err
if pagesFetched == 0 {
return nil, err
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "error": err.Error(),
})
stopReason = "read_failure"
break
}
messages := listMessagesProjectWithReactions(data, !rt.Bool("no-reactions"))
payload := map[string]any{"count": len(messages), "messages": messages}
direction := "older"
if rt.Bool("forward") {
direction = "newer"
pagesFetched++
pageItems := listMessagesResolveMaps(data)
for _, item := range pageItems {
id := chatmsg.StableMessageID(item)
if id != "" && seenMessages[id] {
continue
}
if id != "" {
seenMessages[id] = true
}
allItems = append(allItems, item)
}
chatmsg.ApplyMessagePagination(payload, data, listMessagesResolveMaps(data), direction)
return rt.Output(payload)
},
page := chatmsg.Pagination(data)
pageHasMore, known := page["hasMore"].(bool)
if !known {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "单聊消息下层未返回可靠的 hasMore,无法证明结果完整",
})
stopReason = "pagination_error"
break
}
hasMore = pageHasMore
if !hasMore {
complete = true
nextPage = nil
stopReason = "source_complete"
break
}
if len(pageItems) == 0 {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "单聊消息下层返回 hasMore=true 但当前页没有消息",
})
stopReason = "pagination_error"
break
}
cursorKey, boundary, cursorErr := directMessageCursorBoundary(page["nextCursor"])
if cursorErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "单聊消息下层返回 hasMore=true,但 nextCursor 无效: " + cursorErr.Error(),
})
stopReason = "pagination_error"
break
}
if seenCursors[cursorKey] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "单聊消息毫秒 nextCursor 停滞",
})
stopReason = "pagination_error"
break
}
seenCursors[cursorKey] = true
nextPage = map[string]any{
"direction": direction, "time": boundary, "nextCursor": page["nextCursor"],
}
params["time"] = boundary
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
messages := make([]map[string]any, 0, len(allItems))
for _, item := range allItems {
messages = append(messages, listMessageProjectOneWithReactions(item, !rt.Bool("no-reactions")))
}
payload := chatmsg.NewMessageListPayload(messages)
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = true
payload["complete"] = complete && len(failures) == 0
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(messages) > 0
if hasMore && nextPage != nil {
payload["nextPage"] = nextPage
}
if len(failures) == 0 {
return payload, nil
}
return payload, apperrors.NewAPI(
fmt.Sprintf("单聊消息分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("chat/list_individual_chat_message"),
apperrors.WithReason("messages_list_direct_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextPage 重试"),
)
}
func directMessageCursorBoundary(value any) (string, string, error) {
var millis int64
switch typed := value.(type) {
case int:
millis = int64(typed)
case int32:
millis = int64(typed)
case int64:
millis = typed
case float32:
asFloat := float64(typed)
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(asFloat)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = parsed
default:
return "", "", fmt.Errorf("缺少毫秒级分页游标")
}
if millis <= 0 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
key := strconv.FormatInt(millis, 10)
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
return key, boundary, nil
}
// MessagesListTopicReplies pulls topic replies (list_topic_replies, chat server).
@@ -0,0 +1,328 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageChatSearchDryRunStopsBeforeRead(t *testing.T) {
fake := &larkAlignmentCaller{dryRun: true}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+chat-search", "--query", "项目", "--page-size", "20",
"--cursor", "cursor-1", "--exclude-muted", "--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("chat-search dry-run made lower calls: %#v", fake.calls)
}
}
func TestCrossPlatformCoverageChatSearchPageAllUsesOpaqueCursorAndDeduplicates(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/search_groups": {
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"}],"hasMore":true,"nextCursor":"cursor-2"}}`,
`{"result":{"groups":[{"openConversationId":"cid-1","title":"重复项目群"},{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].args["cursor"] != "0" || fake.calls[1].args["cursor"] != "cursor-2" {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
t.Fatalf("payload = %#v", payload)
}
chats := payload["chats"].([]any)
if chats[0].(map[string]any)["name"] != "项目一群" || chats[1].(map[string]any)["openConversationId"] != "cid-2" {
t.Fatalf("chats = %#v", chats)
}
}
func TestCrossPlatformCoverageChatSearchProbesMaximumWindowWhenBackendFalselyEndsFullFirstPage(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/search_groups": {
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"}],"hasMore":false}}`,
`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目一群"},{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].args["limit"] != 1 || fake.calls[1].args["limit"] != 100 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["complete"] != true || payload["paginationKnown"] != false ||
payload["completionEvidence"] != "max_window_short_page" || payload["stopReason"] != "max_window_short_page" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatSearchPageTokenAndPageLimit(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[{"openConversationId":"cid-2","title":"项目二群"}],"hasMore":true,"nextCursor":"cursor-3"}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-token", "cursor-2", "--page-all", "--page-limit", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].args["cursor"] != "cursor-2" {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["hasMore"] != true || payload["nextCursor"] != "cursor-3" ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatSearchFailureModes(t *testing.T) {
t.Run("later read failure keeps partial result", func(t *testing.T) {
fake := &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"im/search_groups": {`{"result":{"groups":[{"openConversationId":"cid-1","title":"项目群"}],"hasMore":true,"nextCursor":"cursor-2"}}`},
},
failProductToolAt: map[string]int{"im/search_groups": 2},
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("expected later-page error")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["partial"] != true || payload["stopReason"] != "read_failure" {
t.Fatalf("payload = %#v", payload)
}
})
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[{"openConversationId":"cid-1","title":"项目群"}]}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--page-size", "1", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("missing pagination unexpectedly succeeded")
}
})
t.Run("short legacy page is bounded complete", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{"im/search_groups": `{"result":[]}`}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-search", "--query", "不存在"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != true || payload["paginationKnown"] != false || payload["stopReason"] != "legacy_short_page" {
t.Fatalf("payload = %#v", payload)
}
})
}
func TestCrossPlatformCoverageChatSearchPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"--page-size", "0"},
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
{"--cursor", "c1", "--page-token", "c2"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+chat-search", "--query", "项目"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageChatSearchAdditionalPaginationEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+chat-search", "--query", "项目"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("first read failure", func(t *testing.T) {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/search_groups": 1}})
if err == nil {
t.Fatal("first read failure unexpectedly succeeded")
}
})
t.Run("cursor without hasMore continues", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/search_groups": {
`{"result":{"groups":[{"openConversationId":"g1"}],"nextCursor":"next"}}`,
`{"result":{"groups":[],"hasMore":false}}`,
},
}}, "--page-all")
if err != nil || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("single full page is untrusted", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":false}}`,
}}, "--page-size", "1")
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page_full_untrusted" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("bounded probe reports page limit", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":false}}`,
}}, "--page-size", "1", "--page-all", "--page-limit", "1")
if err != nil || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("full maximum probe fails closed", func(t *testing.T) {
groups := make([]map[string]any, chatSearchMaxWindowSize)
for i := range groups {
groups[i] = map[string]any{"openConversationId": fmt.Sprintf("probe-%d", i)}
}
second, marshalErr := json.Marshal(map[string]any{"result": map[string]any{"groups": groups, "hasMore": false}})
if marshalErr != nil {
t.Fatal(marshalErr)
}
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/search_groups": {
`{"result":{"groups":[{"openConversationId":"first"}],"hasMore":false}}`,
string(second),
},
}}, "--page-size", "1", "--page-all")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
response string
args []string
wantErr bool
stop string
}{
{name: "missing continuation", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
{name: "single page continuation", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":"next"}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/search_groups": tc.response}, failProductToolAt: map[string]int{}}, tc.args...)
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("exclude muted reaches lower call and output errors propagate", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--exclude-muted"})
if err := root.Execute(); err == nil || fake.calls[0].args["excludeMuted"] != true {
t.Fatalf("calls=%#v err=%v", fake.calls, err)
}
})
}
func TestCrossPlatformCoverageChatSearchProjectionEdges(t *testing.T) {
if got := chatSearchItems(nil); len(got) != 0 {
t.Fatalf("nil items = %#v", got)
}
if got := chatSearchItems(map[string]any{"result": map[string]any{"unknown": true}}); len(got) != 0 {
t.Fatalf("unknown items = %#v", got)
}
got := projectChatSearchItems([]any{
"invalid",
map[string]any{"conversationId": "g1", "conversationName": "项目群"},
})
if len(got) != 1 || got[0]["openConversationId"] != "g1" || got[0]["name"] != "项目群" {
t.Fatalf("projected = %#v", got)
}
for _, value := range []any{nil, "", "0", "<nil>"} {
if cursor := chatSearchCursorString(value); cursor != "" {
t.Fatalf("cursor %v = %q", value, cursor)
}
}
helpers.InitDeps(&larkAlignmentCaller{responses: map[string]string{
"im/search_groups": `{"result":{"groups":[],"hasMore":false}}`,
}})
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{"chat", "+chat-search", "--query", "项目", "--cursor="})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
}
@@ -316,7 +316,7 @@ func TestCrossPlatformCoverageFlagAndMgetValidation(t *testing.T) {
cases := [][]string{
{"chat", "+flag-create", "--message-ids", strings.Join(tooMany, ","), "--conversation-id", "cid", "--yes"},
{"chat", "+flag-list", "--cursor", "-1"},
{"chat", "+flag-list", "--size", "101"},
{"chat", "+flag-list", "--size", "31"},
{"chat", "+messages-mget", "--msg-ids", strings.Join(makeIDs(51), ",")},
{"chat", "+messages-mget", "--msg-ids", "msg", "--download-resources", "--output-dir", "../escape"},
}
@@ -0,0 +1,274 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"encoding/json"
"errors"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeRead(t *testing.T) {
fake := &larkAlignmentCaller{dryRun: true}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Fatalf("flag-list dry-run made lower calls: %#v", fake.calls)
}
}
func TestCrossPlatformCoverageFlagListPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_message_favorites": {
`{"result":{"items":[{"openMessageId":"msg-1","openConversationId":"cid-1","summary":"一"}],"hasMore":true,"nextCursor":7}}`,
`{"result":{"items":[{"openMessageId":"msg-1","openConversationId":"cid-1","summary":"重复"},{"openMessageId":"msg-2","openConversationId":"cid-2","summary":"二"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-size", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].args["cursor"] != 0 || fake.calls[0].args["size"] != "1" || fake.calls[1].args["cursor"] != 7 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) || payload["complete"] != true || payload["hasMore"] != false {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-2"}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-token", "7", "--page-all", "--page-limit", "1"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 1 || fake.calls[0].args["cursor"] != 7 {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) {
t.Run("later read failure keeps partial result", func(t *testing.T) {
fake := &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"im/list_message_favorites": {`{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":7}}`},
},
failProductToolAt: map[string]int{"im/list_message_favorites": 2},
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("expected later-page error")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["partial"] != true || payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
t.Fatalf("payload = %#v", payload)
}
})
t.Run("stalled numeric cursor fails closed", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":7}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{"chat", "+flag-list", "--cursor", "7", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("stalled cursor unexpectedly succeeded")
}
})
t.Run("full legacy page without pagination fails closed", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}]}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{"chat", "+flag-list", "--page-size", "1"})
if err := root.Execute(); err == nil {
t.Fatal("full legacy page unexpectedly succeeded")
}
})
}
func TestCrossPlatformCoverageFlagListPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"--page-size", "0"},
{"--page-size", "31"},
{"--size", "31"},
{"--page-token", "not-a-number"},
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
{"--cursor", "1", "--page-token", "2"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+flag-list"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
if _, err := flagListNextCursor(float64(1.5)); err == nil {
t.Fatal("fractional cursor unexpectedly accepted")
}
if _, err := flagListNextCursor(struct{}{}); err == nil {
t.Fatal("unsupported cursor unexpectedly accepted")
}
}
func TestCrossPlatformCoverageFlagListAdditionalEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+flag-list"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
for _, token := range []string{"", "0"} {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[],"hasMore":false}}`,
}}, "--page-token="+token)
if err != nil || payload["complete"] != true {
t.Fatalf("token=%q payload=%#v err=%v", token, payload, err)
}
}
t.Run("first read failure", func(t *testing.T) {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_message_favorites": 1}})
if err == nil {
t.Fatal("first read failure unexpectedly succeeded")
}
})
t.Run("cursor without hasMore continues", func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_message_favorites": {
`{"result":{"items":[{"openMessageId":"m1"}],"nextCursor":2}}`,
`{"result":{"items":[],"hasMore":false}}`,
},
}}, "--page-all")
if err != nil || payload["pagesFetched"] != float64(2) || payload["complete"] != true {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
response string
wantErr bool
stop string
}{
{name: "single page continuation", response: `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
{name: "missing continuation", response: `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true}}`, wantErr: true, stop: "pagination_error"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_message_favorites": tc.response}})
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("output errors propagate", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+flag-list"})
if err := root.Execute(); err == nil {
t.Fatal("output error was swallowed")
}
})
if got := flagListItems(nil); len(got) != 0 {
t.Fatalf("nil items = %#v", got)
}
if got := flagListItems(map[string]any{"result": map[string]any{"items": []any{"invalid", map[string]any{"id": "m1"}}}}); len(got) != 1 {
t.Fatalf("projected items = %#v", got)
}
if got := firstNonEmptyMapString(map[string]any{}, "missing"); got != "" {
t.Fatalf("missing identity = %q", got)
}
}
func TestCrossPlatformCoverageFlagListCursorTypeEdges(t *testing.T) {
for _, tc := range []struct {
name string
value any
ok bool
}{
{name: "nil", value: nil, ok: true},
{name: "int", value: int(1), ok: true},
{name: "negative int", value: int(-1)},
{name: "int64", value: int64(2), ok: true},
{name: "negative int64", value: int64(-1)},
{name: "float64", value: float64(3), ok: true},
{name: "negative float64", value: float64(-1)},
{name: "json number", value: json.Number("4"), ok: true},
{name: "invalid json number", value: json.Number("bad")},
{name: "empty string", value: "", ok: true},
{name: "string", value: "5", ok: true},
{name: "invalid string", value: "bad"},
{name: "unsupported", value: struct{}{}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := flagListNextCursor(tc.value)
if (err == nil) != tc.ok {
t.Fatalf("value=%#v err=%v", tc.value, err)
}
})
}
}
+559 -41
View File
@@ -7,6 +7,7 @@ package chat
import (
"encoding/json"
"fmt"
"math"
"strconv"
"strings"
@@ -16,6 +17,13 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
const (
flagListHardPageLimit = 500
flagListMaxPageSize = 30
chatListHardPageLimit = 500
chatListMaxWindowSize = 100
)
// ChatCreate creates a DingTalk group after resolving every natural member and
// the optional owner to stable DingTalk identities. Description, initial-bot,
// idempotency, and Lark visibility semantics remain deliberately unsupported.
@@ -499,34 +507,291 @@ var FlagList = shortcut.Shortcut{
Service: "chat",
Command: "+flag-list",
Product: "im",
Description: "分页查询当前用户收藏的消息",
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;返回下层分页结果,不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
Description: "分页查询当前用户收藏的消息,支持有界自动翻页",
Intent: "当你要查看当前用户的 DingTalk message favorite 列表时使用;默认读取一页,明确要求全部收藏时加 --page-all,并用 --page-limit 保持有界。底层实际使用数字 cursor,结果按 openMessageId 去重并公开 complete、hasMore、nextCursor、stopReason 和 failures;它不把 message favorite 与 Pin、会话置顶或 Lark feed-layer thread flag 混为一谈。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "数字分页游标;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;--cursor 必须大于等于 0,--size 必须在 1-100 之间"},
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;下游真实上限为 30,显式页大小必须在 1-30 之间"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "--page-size 的兼容别名;下游真实上限为 30,显式页大小必须在 1-30 之间"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "钉钉数字分页游标;起始 cursor 必须是非负整数"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{{
Kind: shortcut.ConstraintCustom,
Flags: []string{"cursor", "size"},
Description: "--cursor 必须大于等于 0,--size 必须在 1-100 之间",
}},
Tips: []string{`dws chat +flag-list --cursor 0 --size 20`},
Validate: func(rt *shortcut.RuntimeContext) error {
if rt.Int("cursor") < 0 {
return apperrors.NewValidation("--cursor 必须大于等于 0")
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-size", "size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"page-token", "cursor"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-size", "size"}, Description: "显式页大小必须在 1-30 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-token", "cursor"}, Description: "起始 cursor 必须是非负整数"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +flag-list --cursor 0 --page-size 20`,
`dws chat +flag-list --page-size 30 --page-all --page-limit 20`,
},
Validate: validateFlagList,
Execute: executeFlagList,
}
func validateFlagList(rt *shortcut.RuntimeContext) error {
if size := flagListPageSize(rt); size < 1 || size > flagListMaxPageSize {
return apperrors.NewValidation("--page-size/--size 必须在 1-30 之间")
}
if _, err := flagListStartCursor(rt); err != nil {
return err
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > flagListHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if size := rt.Int("size"); size < 1 || size > 100 {
return apperrors.NewValidation("--size 必须在 1-100 之间")
}
return nil
}
func flagListPageSize(rt *shortcut.RuntimeContext) int {
if rt.Changed("size") {
return rt.Int("size")
}
return rt.Int("page-size")
}
func flagListStartCursor(rt *shortcut.RuntimeContext) (int, error) {
if rt.Changed("page-token") {
token := strings.TrimSpace(rt.Str("page-token"))
if token == "" || token == "0" {
return 0, nil
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("list_message_favorites", map[string]any{
"cursor": rt.Int("cursor"),
"size": strconv.Itoa(rt.Int("size")),
})
},
value, err := strconv.Atoi(token)
if err != nil || value < 0 {
return 0, apperrors.NewValidation("--page-token 必须是非负整数")
}
return value, nil
}
if value := rt.Int("cursor"); value < 0 {
return 0, apperrors.NewValidation("--cursor 必须大于等于 0")
}
return rt.Int("cursor"), nil
}
func executeFlagList(rt *shortcut.RuntimeContext) error {
pageSize := flagListPageSize(rt)
cursor, _ := flagListStartCursor(rt) // Validate has already accepted the cursor.
if rt.DryRun() {
return rt.CallMCP("list_message_favorites", flagListRequestParams(cursor, pageSize))
}
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
}
seenCursors := map[int]bool{cursor: true}
seenMessages := map[string]bool{}
items := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
paginationKnown := true
complete := false
hasMore := false
nextCursor := 0
var cursorErr error
stopReason := "source_complete"
truncatedByPageLimit := false
for pagesFetched < pageLimit {
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, pageSize))
if callErr != nil {
if pagesFetched == 0 {
return callErr
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": callErr.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageItems := flagListItems(data)
for _, item := range pageItems {
messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id")
if messageID != "" && seenMessages[messageID] {
continue
}
if messageID != "" {
seenMessages[messageID] = true
}
items = append(items, item)
}
page := chatmsg.Pagination(data)
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
nextCursor, cursorErr = flagListNextCursor(page["nextCursor"])
if !hasMoreKnown {
switch {
case nextCursor > 0:
pageHasMore = true
case len(pageItems) < pageSize:
paginationKnown = false
complete = true
hasMore = false
stopReason = "legacy_short_page"
default:
paginationKnown = false
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "收藏列表返回满页结果但缺少 hasMore/nextCursor,无法证明结果完整",
})
stopReason = "pagination_error"
}
if complete || len(failures) > 0 {
break
}
}
hasMore = pageHasMore
if !hasMore {
complete = true
nextCursor = 0
stopReason = "source_complete"
break
}
if cursorErr != nil || nextCursor <= 0 || seenCursors[nextCursor] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "收藏列表返回 hasMore=true,但数字 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
if !rt.Bool("page-all") {
stopReason = "single_page"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = rt.Bool("page-all")
if truncatedByPageLimit {
stopReason = "page_limit"
}
}
payload := map[string]any{
"count": len(items),
"items": items,
"pagesFetched": pagesFetched,
"paginationKnown": paginationKnown,
"complete": complete && len(failures) == 0,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0 && len(items) > 0,
}
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
if len(failures) > 0 {
return apperrors.NewAPI(
fmt.Sprintf("收藏消息分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_message_favorites"),
apperrors.WithReason("flag_list_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
return nil
}
func flagListRequestParams(cursor, pageSize int) map[string]any {
return map[string]any{
"cursor": cursor,
"size": strconv.Itoa(pageSize),
}
}
func flagListItems(data map[string]any) []map[string]any {
if data == nil {
return []map[string]any{}
}
scopes := []map[string]any{data}
for _, key := range []string{"result", "data"} {
if nested, ok := data[key].(map[string]any); ok {
scopes = append(scopes, nested)
}
}
for _, scope := range scopes {
for _, key := range []string{"items", "favorites", "messageFavorites", "list"} {
raw, ok := scope[key].([]any)
if !ok {
continue
}
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if value, ok := item.(map[string]any); ok {
out = append(out, value)
}
}
return out
}
}
return []map[string]any{}
}
func flagListNextCursor(value any) (int, error) {
switch typed := value.(type) {
case nil:
return 0, nil
case int:
if typed < 0 {
return 0, fmt.Errorf("negative cursor")
}
return typed, nil
case int64:
if typed < 0 || int64(int(typed)) != typed {
return 0, fmt.Errorf("cursor out of range")
}
return int(typed), nil
case float64:
if typed < 0 || math.Trunc(typed) != typed || typed > float64(int(^uint(0)>>1)) {
return 0, fmt.Errorf("invalid numeric cursor")
}
return int(typed), nil
case json.Number:
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
if err != nil || parsed < 0 || int64(int(parsed)) != parsed {
return 0, fmt.Errorf("invalid json cursor")
}
return int(parsed), nil
case string:
text := strings.TrimSpace(typed)
if text == "" || text == "0" {
return 0, nil
}
parsed, err := strconv.Atoi(text)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("invalid string cursor")
}
return parsed, nil
default:
return 0, fmt.Errorf("unsupported cursor type %T", value)
}
}
func firstNonEmptyMapString(item map[string]any, keys ...string) string {
for _, key := range keys {
value := strings.TrimSpace(fmt.Sprint(item[key]))
if value != "" && value != "<nil>" {
return value
}
}
return ""
}
// FeedGroupQueryItem is a client-side exact filter over a DingTalk conversation
@@ -615,18 +880,18 @@ func feedGroupQueryProject(conversations []map[string]any, requestedIDs []string
// ChatList is the lark-cli +chat-list alignment for DingTalk. It lists the
// current user's conversations through list_all_conversations, defaults to
// groups only (lark omit-types behavior), and applies --types / --exclude-muted
// as an honest client-side subset over the current page. Sort order and bot
// groups only (lark omit-types behavior), and applies --types after all fetched
// pages have been merged. Sort order and bot
// identity p2p stripping are intentionally omitted: DingTalk has no matching
// sort_type parameter here, and DWS shortcuts run as the signed-in user.
var ChatList = shortcut.Shortcut{
Service: "chat",
Command: "+chat-list",
Product: "im",
Description: "列出当前用户加入的会话(默认群聊;可选包含单聊)",
Description: "分页列出当前用户加入的会话(默认群聊;可选包含单聊)",
Intent: "当你要像 lark-cli +chat-list 一样列出当前用户加入的会话时使用;默认只返回群聊," +
"--types 可追加 p2p(钉钉投影为 conversationType=direct)。" +
"--exclude-muted 交给下层 excludeMuted;--types 过滤发生在当前页本地,不会伪造跨页完整性。" +
"--exclude-muted 交给下层 excludeMuted;默认读取一页,明确要求全部时加 --page-all,合并并去重所有已读取页面后再执行 --types 过滤,结果公开完整性 ledger。" +
"不支持 lark 的 sort/sort-type,也不模拟 bot 身份剥离 p2p。",
Risk: shortcut.RiskRead,
Flags: []shortcut.Flag{
@@ -635,6 +900,8 @@ var ChatList = shortcut.Shortcut{
{Name: "limit", Type: shortcut.FlagInt, Desc: "--page-size 的别名,必须在 1-100 之间"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "分页游标;若提供则必须是非负整数"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "--page-token 的整数别名"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部会话分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
},
Constraints: []shortcut.Constraint{
@@ -655,10 +922,12 @@ var ChatList = shortcut.Shortcut{
Flags: []string{"page-token"},
Description: "若提供则必须是非负整数",
},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +chat-list`,
`dws chat +chat-list --types group,p2p --exclude-muted --page-size 50`,
`dws chat +chat-list --types group --page-size 100 --page-all --page-limit 20`,
},
Validate: validateChatList,
Execute: executeChatList,
@@ -675,6 +944,14 @@ func validateChatList(rt *shortcut.RuntimeContext) error {
if _, err := chatListCursor(rt); err != nil {
return err
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > chatListHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
@@ -690,28 +967,269 @@ func executeChatList(rt *shortcut.RuntimeContext) error {
if err != nil {
return err
}
params := map[string]any{"limit": chatListPageSize(rt)}
if cursor > 0 {
params["cursor"] = cursor
pageSize := chatListPageSize(rt)
requestPageSize := pageSize
initialCursor := cursor
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
seenCursors := map[int]bool{cursor: true}
seenChats := map[string]bool{}
allChats := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
paginationKnown := true
complete := false
hasMore := false
nextCursor := 0
stopReason := "source_complete"
truncatedByPageLimit := false
maxWindowProbeUsed := false
completionEvidence := ""
for pagesFetched < pageLimit {
params := map[string]any{"limit": requestPageSize}
if cursor > 0 {
params["cursor"] = cursor
}
if rt.Bool("exclude-muted") {
params["excludeMuted"] = true
}
data, callErr := rt.CallMCPData("im", "list_all_conversations", params)
if callErr != nil {
if pagesFetched == 0 {
return callErr
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": callErr.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageItems := chatListProject(data)
for _, chat := range pageItems {
id := firstNonEmptyMapString(chat, "openConversationId")
if id != "" && seenChats[id] {
continue
}
if id != "" {
seenChats[id] = true
}
allChats = append(allChats, chat)
}
page, paginationErr := chatListPagination(data)
if paginationErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination", "error": paginationErr.Error(),
})
stopReason = "pagination_error"
break
}
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
nextCursor, err = chatListNextCursor(page["nextCursor"])
if !hasMoreKnown {
switch {
case nextCursor > 0:
pageHasMore = true
case len(pageItems) < requestPageSize:
paginationKnown = false
complete = true
hasMore = false
stopReason = "legacy_short_page"
default:
paginationKnown = false
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "会话列表返回满页结果但缺少 hasMore/nextCursor,无法证明结果完整",
})
stopReason = "pagination_error"
}
if complete || len(failures) > 0 {
break
}
}
hasMore = pageHasMore
if !hasMore {
// A full first page with hasMore=false/no cursor is the live legacy
// shape that can hide additional rows. Once a prior page supplied a
// valid continuation cursor, its terminal hasMore=false is trustworthy.
fullPageWithoutCursor := len(pageItems) >= requestPageSize && nextCursor <= 0 &&
(pagesFetched == 1 || (maxWindowProbeUsed && pagesFetched == 2))
if fullPageWithoutCursor {
paginationKnown = false
if rt.Bool("page-all") && initialCursor == 0 && !maxWindowProbeUsed && requestPageSize < chatListMaxWindowSize && pagesFetched < pageLimit {
maxWindowProbeUsed = true
requestPageSize = chatListMaxWindowSize
cursor = initialCursor
stopReason = "max_window_probe"
continue
}
if !rt.Bool("page-all") {
complete = false
stopReason = "single_page_full_untrusted"
break
}
if pagesFetched >= pageLimit && requestPageSize < chatListMaxWindowSize {
truncatedByPageLimit = true
complete = false
stopReason = "page_limit"
break
}
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "会话列表在最大窗口仍返回满页且没有可用 nextCursor,无法证明结果完整",
})
complete = false
stopReason = "pagination_error"
break
}
complete = true
nextCursor = 0
if maxWindowProbeUsed {
paginationKnown = false
completionEvidence = "max_window_short_page"
stopReason = "max_window_short_page"
} else {
completionEvidence = "backend_has_more_false_short_page"
stopReason = "source_complete"
}
break
}
if err != nil || nextCursor <= 0 || seenCursors[nextCursor] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "会话列表返回 hasMore=true,但数字 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
if !rt.Bool("page-all") {
stopReason = "single_page"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
data, err := rt.CallMCPData("im", "list_all_conversations", params)
if err != nil {
return err
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = rt.Bool("page-all")
if truncatedByPageLimit {
stopReason = "page_limit"
}
}
chats := chatListFilterTypes(chatListProject(data), types)
chats := chatListFilterTypes(allChats, types)
payload := map[string]any{
"count": len(chats),
"chats": chats,
"requestedTypes": types,
"count": len(chats),
"chats": chats,
"requestedTypes": types,
"pagesFetched": pagesFetched,
"paginationKnown": paginationKnown,
"complete": complete && len(failures) == 0,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0 && len(chats) > 0,
"requestedPageSize": pageSize,
"effectivePageSize": requestPageSize,
}
if completionEvidence != "" {
payload["completionEvidence"] = completionEvidence
}
if rt.Bool("exclude-muted") {
payload["filter"] = map[string]any{"excludeMuted": true}
}
chatmsg.ApplyPagination(payload, data)
return rt.Output(payload)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
if len(failures) > 0 {
return apperrors.NewAPI(
fmt.Sprintf("会话列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_all_conversations"),
apperrors.WithReason("chat_list_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
return nil
}
// chatListPagination preserves the shared map/envelope pagination formats and
// supplements them with the list_all_conversations gateway tuple:
// result:[conversationList,nextCursor,hasMore]. Tuple positions are specific to
// this RPC, so they are normalized here instead of broadening chatmsg.Pagination
// for unrelated Chat shortcuts.
func chatListPagination(data map[string]any) (map[string]any, error) {
page := chatmsg.Pagination(data)
for _, key := range []string{"result", "data"} {
values, ok := data[key].([]any)
if !ok || len(values) == 0 {
continue
}
if _, tuple := values[0].([]any); !tuple {
continue
}
if len(values) < 3 {
return nil, fmt.Errorf("会话列表 tuple 分页元数据不完整:期望 [conversationList,nextCursor,hasMore]")
}
hasMore, ok := values[2].(bool)
if !ok {
return nil, fmt.Errorf("会话列表 tuple 的 hasMore 必须是布尔值")
}
return map[string]any{
"nextCursor": values[1],
"hasMore": hasMore,
"complete": !hasMore,
}, nil
}
return page, nil
}
func chatListNextCursor(value any) (int, error) {
switch typed := value.(type) {
case nil:
return 0, nil
case int:
if typed < 0 {
return 0, fmt.Errorf("negative cursor")
}
return typed, nil
case int64:
if typed < 0 || int64(int(typed)) != typed {
return 0, fmt.Errorf("cursor out of range")
}
return int(typed), nil
case float64:
if typed < 0 || math.Trunc(typed) != typed || typed > float64(int(^uint(0)>>1)) {
return 0, fmt.Errorf("invalid numeric cursor")
}
return int(typed), nil
case json.Number:
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
if err != nil || parsed < 0 || int64(int(parsed)) != parsed {
return 0, fmt.Errorf("invalid json cursor")
}
return int(parsed), nil
case string:
text := strings.TrimSpace(typed)
if text == "" || text == "0" {
return 0, nil
}
parsed, err := strconv.Atoi(text)
if err != nil || parsed < 0 {
return 0, fmt.Errorf("invalid string cursor")
}
return parsed, nil
default:
return 0, fmt.Errorf("unsupported cursor type %T", value)
}
}
func chatListPageSize(rt *shortcut.RuntimeContext) int {
@@ -24,6 +24,7 @@ type larkAlignmentCall struct {
type larkAlignmentCaller struct {
calls []larkAlignmentCall
dryRun bool
failTarget string
failProductTool string
failProductToolAt map[string]int
@@ -97,7 +98,7 @@ func (f *larkAlignmentCaller) CallReadTool(ctx context.Context, product, tool st
}
func (f *larkAlignmentCaller) Format() string { return "json" }
func (f *larkAlignmentCaller) DryRun() bool { return false }
func (f *larkAlignmentCaller) DryRun() bool { return f.dryRun }
func (f *larkAlignmentCaller) Fields() string { return "" }
func (f *larkAlignmentCaller) JQ() string { return "" }
@@ -409,12 +410,12 @@ func TestCrossPlatformCoverageLarkAlignmentWriteMappings(t *testing.T) {
},
{
name: "flag-list",
args: []string{"chat", "+flag-list", "--cursor", "3", "--size", "50"},
args: []string{"chat", "+flag-list", "--cursor", "3", "--size", "30"},
product: "im",
tool: "list_message_favorites",
wantArgs: map[string]any{
"cursor": 3,
"size": "50",
"size": "30",
},
},
}
@@ -705,7 +706,7 @@ func TestCrossPlatformCoverageFindMessageSenderOpenDingTalkIDIgnoresUnrelatedNes
}
}
func TestChatListDefaultsToGroupsAndSupportsLarkAliases(t *testing.T) {
func TestCrossPlatformCoverageChatListDefaultsToGroupsAndSupportsLarkAliases(t *testing.T) {
fake := &larkAlignmentCaller{
responses: map[string]string{
"im/list_all_conversations": `{
@@ -755,7 +756,7 @@ func TestChatListDefaultsToGroupsAndSupportsLarkAliases(t *testing.T) {
}
}
func TestChatListIncludesP2PAndRejectsInvalidTypes(t *testing.T) {
func TestCrossPlatformCoverageChatListIncludesP2PAndRejectsInvalidTypes(t *testing.T) {
fake := &larkAlignmentCaller{
responses: map[string]string{
"im/list_all_conversations": `{
@@ -798,7 +799,7 @@ func TestChatListIncludesP2PAndRejectsInvalidTypes(t *testing.T) {
}
}
func TestChatListP2POnlyDropsGroups(t *testing.T) {
func TestCrossPlatformCoverageChatListP2POnlyDropsGroups(t *testing.T) {
fake := &larkAlignmentCaller{
responses: map[string]string{
"im/list_all_conversations": `{
@@ -0,0 +1,396 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package chat
import (
"bytes"
"encoding/json"
"errors"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"im/list_my_groups_pagination": {
`{"result":{"groups":[{"openConversationId":"g1","title":"群一"}],"hasMore":true,"nextCursor":88}}`,
`{"result":{"groups":[{"openConversationId":"g1","title":"重复群"},{"openConversationId":"g2","title":"群二"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--limit", "1", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[1].args["cursor"] != float64(88) {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["hasMore"] != false || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageDirectMessagesPageAllUsesMillisecondCursor(t *testing.T) {
const cursorMillis int64 = 1785919699136
fake := &larkAlignmentCaller{sequenceResponses: map[string][]string{
"chat/list_individual_chat_message": {
`{"result":{"messages":[{"openMessageId":"m2","createTime":"2026-08-05 16:48:19"}],"hasMore":true,"nextCursor":1785919699136}}`,
`{"result":{"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:19"}],"hasMore":false}}`,
},
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--open-dingtalk-id", "D-user",
"--time", "2026-08-05 16:49:00", "--forward=false", "--limit", "1",
"--page-all", "--page-limit", "5",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if len(fake.calls) != 2 || fake.calls[1].args["time"] != wantBoundary {
t.Fatalf("calls = %#v", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(2) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinuation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00",
"--page-all", "--page-limit", "1",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["hasMore"] != true ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageDirectMessagesPageAllFailsClosedWithoutMillisecondCursor(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00", "--page-all",
})
if err := root.Execute(); err == nil {
t.Fatal("missing direct-message cursor unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListAllLaterReadFailureKeepsPartialLedger(t *testing.T) {
fake := &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"im/list_my_groups_pagination": {`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`},
},
failProductToolAt: map[string]int{"im/list_my_groups_pagination": 2},
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("later group-list failure unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["complete"] != false || payload["partial"] != true ||
payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatRemainingPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+chat-list-all", "--limit", "201"},
{"chat", "+chat-list-all", "--page-limit", "2"},
{"chat", "+chat-list-all", "--page-all", "--page-limit", "501"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-limit", "2"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-all", "--page-limit", "501"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageChatListAllAdditionalEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+chat-list-all"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("single page terminal and continuing", func(t *testing.T) {
for _, tc := range []struct {
name string
response string
stop string
}{
{name: "terminal", response: `{"result":{"groups":[],"hasMore":false}}`, stop: "source_complete"},
{name: "continuing", response: `{"result":{"groups":[],"hasMore":true,"nextCursor":2}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_my_groups_pagination": tc.response}})
if err != nil || payload["stopReason"] != tc.stop || payload["pagesFetched"] != float64(1) {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
})
t.Run("first read failure", func(t *testing.T) {
for _, args := range [][]string{nil, {"--page-all"}} {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"im/list_my_groups_pagination": 1}}, args...)
if err == nil {
t.Fatalf("first read failure unexpectedly succeeded for %v", args)
}
}
})
for _, tc := range []struct {
name string
response string
args []string
wantErr bool
stop string
}{
{name: "unknown pagination", response: `{"result":{"groups":[]}}`, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
{name: "missing continuation", response: `{"result":{"groups":[],"hasMore":true}}`, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
{name: "page limit", response: `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"im/list_my_groups_pagination": tc.response}}, tc.args...)
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("output errors propagate", func(t *testing.T) {
for _, args := range [][]string{nil, {"--page-all"}} {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs(append([]string{"chat", "+chat-list-all"}, args...))
if err := root.Execute(); err == nil {
t.Fatalf("output error swallowed for args %v", args)
}
}
})
for _, value := range []any{nil, "", "0", "<nil>"} {
if got := chatListAllCursorString(value); got != "" {
t.Fatalf("cursor %v = %q", value, got)
}
}
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"title":"无 ID 群"}],"hasMore":false}}`,
}}, "--page-all")
if err != nil || payload["count"] != float64(1) {
t.Fatalf("missing-id payload=%#v err=%v", payload, err)
}
}
func TestCrossPlatformCoverageDirectMessagesAdditionalEdges(t *testing.T) {
run := func(t *testing.T, fake *larkAlignmentCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{
"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-08-05 16:49:00",
}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
for _, args := range [][]string{
{"chat", "+messages-list-direct", "--time", "2026-01-01"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--limit", "0"},
{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--size", "0"},
} {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
t.Run("single page terminal and continuing", func(t *testing.T) {
for _, tc := range []struct {
name string
response string
stop string
}{
{name: "terminal", response: `{"result":{"messages":[],"hasMore":false}}`, stop: "source_complete"},
{name: "continuing", response: `{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`, stop: "single_page"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{responses: map[string]string{"chat/list_individual_chat_message": tc.response}})
if err != nil || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
})
t.Run("read failures", func(t *testing.T) {
_, err := run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"chat/list_individual_chat_message": 1}})
if err == nil {
t.Fatal("single-page read failure unexpectedly succeeded")
}
_, err = run(t, &larkAlignmentCaller{failProductToolAt: map[string]int{"chat/list_individual_chat_message": 1}}, "--page-all")
if err == nil {
t.Fatal("first all-page read failure unexpectedly succeeded")
}
payload, err := run(t, &larkAlignmentCaller{
sequenceResponses: map[string][]string{
"chat/list_individual_chat_message": {`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`},
},
failProductToolAt: map[string]int{"chat/list_individual_chat_message": 2},
}, "--page-all")
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
responses []string
}{
{name: "unknown pagination", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"}]}}`}},
{name: "empty continuing page", responses: []string{`{"result":{"messages":[],"hasMore":true,"nextCursor":1785919699136}}`}},
{name: "stalled cursor and duplicate", responses: []string{
`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
`{"result":{"messages":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":1785919699136}}`,
}},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &larkAlignmentCaller{sequenceResponses: map[string][]string{
"chat/list_individual_chat_message": tc.responses,
}}, "--page-all")
if err == nil || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("all-page output errors propagate", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"chat/list_individual_chat_message": `{"result":{"messages":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+messages-list-direct", "--user", "u1", "--time", "2026-01-01", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("output error was swallowed")
}
})
for _, tc := range []struct {
name string
value any
ok bool
}{
{name: "int", value: int(1), ok: true},
{name: "int32", value: int32(2), ok: true},
{name: "float32", value: float32(3), ok: true},
{name: "float64", value: float64(4), ok: true},
{name: "string", value: "5", ok: true},
{name: "fractional float32", value: float32(1.5)},
{name: "negative float64", value: float64(-1)},
{name: "invalid string", value: "invalid"},
{name: "unsupported", value: true},
{name: "zero", value: int64(0)},
} {
t.Run("cursor "+tc.name, func(t *testing.T) {
key, boundary, err := directMessageCursorBoundary(tc.value)
if tc.ok {
if err != nil || key == "" || boundary == "" {
t.Fatalf("cursor=(%q,%q,%v)", key, boundary, err)
}
return
}
if err == nil {
t.Fatalf("invalid cursor succeeded: (%q,%q)", key, boundary)
}
})
}
}
+59 -9
View File
@@ -32,10 +32,13 @@ package chatmsg
import (
"encoding/json"
"fmt"
"math"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// MessageListContractVersion identifies the additive, compatibility-preserving
@@ -75,6 +78,8 @@ var messageResultContractV1 = MessageResultContract{
"contractVersion",
"messages",
"count",
"resolvedFilters",
"queryRange",
"pagesFetched",
"paginationKnown",
"complete",
@@ -774,10 +779,10 @@ func ApplyPagination(payload, data map[string]any) {
}
}
// ApplyMessagePagination publishes message-list completeness without claiming
// the lower response's nextCursor is a valid CLI input. DingTalk's executable
// message-list contract paginates with the boundary message createTime, so the
// resume object uses exactly that accepted parameter.
// ApplyMessagePagination publishes message-list completeness and converts the
// authoritative millisecond nextCursor into the RFC3339Nano time boundary
// accepted by the executable message-list command. Projected createTime is
// deliberately not used because it is only second precision.
func ApplyMessagePagination(payload, data map[string]any, messages []map[string]any, direction string) {
payload["contractVersion"] = MessageListContractVersion
payload["pagesFetched"] = 1
@@ -812,25 +817,70 @@ func ApplyMessagePagination(payload, data map[string]any, messages []map[string]
payload["complete"] = value
}
hasMore, _ := page["hasMore"].(bool)
if !hasMore || len(messages) == 0 {
if !hasMore {
return
}
boundary := CreateTime(messages[len(messages)-1])
if boundary == nil {
if len(messages) == 0 {
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层返回 hasMore=true,但末条消息缺少可继续读取的 createTime",
"error": "下层返回 hasMore=true 但当前页没有消息",
}}
return
}
next := map[string]any{"time": boundary}
_, boundary, err := messagePaginationCursorBoundary(page["nextCursor"])
if err != nil {
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层返回 hasMore=true,但 nextCursor 无效: " + err.Error(),
}}
return
}
next := map[string]any{"time": boundary, "nextCursor": page["nextCursor"]}
if strings.TrimSpace(direction) != "" {
next["direction"] = direction
}
payload["nextPage"] = next
}
func messagePaginationCursorBoundary(value any) (string, string, error) {
var millis int64
switch typed := value.(type) {
case int:
millis = int64(typed)
case int32:
millis = int64(typed)
case int64:
millis = typed
case float32:
asFloat := float64(typed)
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(asFloat)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = parsed
default:
return "", "", fmt.Errorf("缺少毫秒级分页游标")
}
if millis <= 0 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
key := strconv.FormatInt(millis, 10)
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
return key, boundary, nil
}
// Pagination extracts hasMore/nextCursor from the response root or a common
// result/data envelope. When hasMore is present it also emits the explicit
// inverse "complete", making truncation hard for an Agent to overlook.
+60 -4
View File
@@ -16,6 +16,7 @@ package chatmsg
import (
"strings"
"testing"
"time"
)
func TestCrossPlatformCoverageSender(t *testing.T) {
@@ -220,6 +221,46 @@ func TestCrossPlatformCoverageMessageLedgerNilAndCursorOnlyBoundaries(t *testing
}
}
func TestCrossPlatformCoverageMessagePaginationCursorTypeEdges(t *testing.T) {
for _, tc := range []struct {
name string
value any
ok bool
}{
{name: "int", value: int(1), ok: true},
{name: "int32", value: int32(2), ok: true},
{name: "float32", value: float32(3), ok: true},
{name: "float64", value: float64(4), ok: true},
{name: "string", value: "5", ok: true},
{name: "fractional float32", value: float32(1.5)},
{name: "negative float64", value: float64(-1)},
{name: "invalid string", value: "not-a-cursor"},
{name: "unsupported", value: true},
{name: "zero", value: int64(0)},
} {
t.Run(tc.name, func(t *testing.T) {
key, boundary, err := messagePaginationCursorBoundary(tc.value)
if tc.ok {
if err != nil || key == "" || boundary == "" {
t.Fatalf("cursor = (%q, %q, %v)", key, boundary, err)
}
return
}
if err == nil {
t.Fatalf("invalid cursor unexpectedly succeeded: (%q, %q)", key, boundary)
}
})
}
payload := NewMessageListPayload(nil)
ApplyMessagePagination(payload, map[string]any{
"result": map[string]any{"hasMore": true, "nextCursor": int64(1)},
}, nil, "older")
if payload["failedCount"] != 1 || payload["complete"] != false {
t.Fatalf("empty continuing page = %#v", payload)
}
}
func TestCrossPlatformCoverageQuotedMessageIsBoundedAndSemantic(t *testing.T) {
got := QuotedMessage(map[string]any{
"quotedMessage": map[string]any{
@@ -423,26 +464,41 @@ func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
}
}
func TestCrossPlatformCoverageApplyMessagePaginationUsesExecutableTimeBoundary(t *testing.T) {
func TestCrossPlatformCoverageApplyMessagePaginationUsesAuthoritativeMillisecondCursor(t *testing.T) {
const cursorMillis int64 = 1785919699136
payload := map[string]any{}
ApplyMessagePagination(payload, map[string]any{
"result": map[string]any{
"hasMore": true,
"nextCursor": "not-a-message-list-cli-flag",
"nextCursor": cursorMillis,
},
}, []map[string]any{
{"createTime": "2026-07-28 10:00:00"},
{"createTime": "2026-07-28 09:00:00"},
}, "older")
if _, leaked := payload["nextCursor"]; leaked {
t.Fatalf("message pagination exposed unusable cursor: %#v", payload)
t.Fatalf("message pagination exposed cursor outside nextPage: %#v", payload)
}
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
next, ok := payload["nextPage"].(map[string]any)
if !ok || next["time"] != "2026-07-28 09:00:00" || next["direction"] != "older" {
if !ok || next["time"] != wantBoundary || next["nextCursor"] != cursorMillis || next["direction"] != "older" {
t.Fatalf("message nextPage = %#v", payload["nextPage"])
}
}
func TestCrossPlatformCoverageApplyMessagePaginationStopsWhenComplete(t *testing.T) {
payload := map[string]any{}
ApplyMessagePagination(payload, map[string]any{
"result": map[string]any{"hasMore": false},
}, nil, "older")
if payload["paginationKnown"] != true || payload["hasMore"] != false || payload["complete"] != true {
t.Fatalf("completed message pagination = %#v", payload)
}
if _, ok := payload["nextPage"]; ok {
t.Fatalf("completed message pagination unexpectedly exposed nextPage: %#v", payload)
}
}
func TestCrossPlatformCoverageApplyMessagePaginationFailsClosedWhenCompletenessIsUnknown(t *testing.T) {
payload := map[string]any{}
ApplyMessagePagination(payload, map[string]any{"result": map[string]any{"messages": []any{}}}, nil, "older")
+4 -4
View File
@@ -360,12 +360,12 @@ var Import = shortcut.Shortcut{
Service: "doc",
Command: "+import",
Product: productDoc,
Description: "上传本地文件并等待转换成在线文档对象",
Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
Description: "上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库",
Intent: "当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。白名单外格式(html/pdf 等)不报错,自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。",
Risk: shortcut.RiskWrite,
Safety: contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown"},
Contract: docContract("+import", "上传本地文件并等待转换成在线文档对象",
"当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。",
Contract: docContract("+import", "上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库",
"当用户要把工作区内的 doc/docx/xls/xlsx/md/txt/xmind/mark 文件导入为钉钉在线对象,并可指定目标文件夹或知识库时使用。白名单外格式(html/pdf 等)不报错,自动按原文件上传入库,结果带 fallback=upload、converted=false 标记。",
[]string{`dws doc +import --file ./report.docx --folder <FOLDER_ID>`, `dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要"`}),
Flags: []shortcut.Flag{
{Name: "file", Type: shortcut.FlagString, Desc: "本地文件路径", Required: true},
+1 -1
View File
@@ -698,7 +698,7 @@
},
"+thread-replies": {
"disposition": "primary_smart",
"semantic_delta": "接受消息列表直接返回的 threadId(兼容 topicId),拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文和时间边界分页;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"semantic_delta": "接受话题主消息 ID 并通过只读消息详情自动解析 conversation/thread,也接受显式 group + threadId(兼容 topicId);拉取回复并输出稳定身份、引用、reaction、resourceRefs、可读正文,使用下层毫秒级 nextCursor 安全分页以避免同秒回复漏读,并支持全量结果 asc/desc;可选对回复资源去重后安全落盘并返回逐项失败 ledger。",
"risk": "read",
"public": true,
"reviewed": true
+1 -1
View File
@@ -10,7 +10,7 @@
"+update": {"disposition":"primary_smart","semantic_delta":"统一追加、覆盖和 block 级精确修改,并集中处理内容输入、定位和确认。","risk":"write","public":true,"reviewed":true},
"+checkpoint-update": {"disposition":"primary_smart","semantic_delta":"写入前保存版本快照,更新后读回验证并输出逐步 ledger。","risk":"write","public":true,"reviewed":true},
"+export": {"disposition":"primary_smart","semantic_delta":"一体化提交、轮询导出任务并按 no-clobber 策略安全下载到本地。","risk":"read","public":true,"reviewed":true},
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果。","risk":"write","public":true,"reviewed":true},
"+import": {"disposition":"primary_smart","semantic_delta":"一体化创建会话、上传、确认转换并轮询导入结果;白名单外格式自动改走文件上传原样入库(fallback=upload、converted=false)。","risk":"write","public":true,"reviewed":true},
"+history-save": {"disposition":"semantic_adapter","semantic_delta":"以文档历史语义命名手动版本快照,避免暴露底层 RPC 命名。","risk":"write","public":true,"reviewed":true},
"+history-list": {"disposition":"semantic_adapter","semantic_delta":"统一历史版本分页参数并返回可用于回滚的版本列表。","risk":"read","public":true,"reviewed":true},
"+history-revert": {"disposition":"primary_smart","semantic_delta":"先验证目标版本存在,再执行回滚并读回当前文档状态。","risk":"high-risk-write","public":true,"reviewed":true},
+212 -45
View File
@@ -14,6 +14,7 @@
package smart
import (
"fmt"
"strconv"
"strings"
"time"
@@ -21,12 +22,18 @@ import (
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
)
const (
atMeDefaultPageLimit = 50
atMeHardPageLimit = 500
)
// AtMe: pull the messages that recently @-mentioned ME across chats in one step.
//
// Steps:
@@ -93,74 +100,96 @@ var AtMe = shortcut.Shortcut{
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(默认 7);--days 必须在 1-3650 之间", Default: "7", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50);--limit 必须大于 0", Default: "50"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"group", "chat-query", "group-query"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"days"}, Description: "--days 必须在 1-3650 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +at-me`,
`dws chat +at-me --days 3`,
`dws chat +at-me --group "项目群"`,
`dws chat +at-me --days 30 --page-all --page-limit 50`,
},
Validate: validateAtMe,
Execute: func(rt *shortcut.RuntimeContext) error {
groupID := ""
directTarget := strings.TrimSpace(rt.Str("group"))
queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query"))
if directTarget != "" || queryTarget != "" {
resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget)
if err != nil {
return err
}
groupID = resolved.Selected.OpenConversationID
}
Execute: executeAtMe,
}
// Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults
// to 7; guard against non-positive overrides so the window stays sane.
days := rt.Int("days")
if days <= 0 {
days = 7
func executeAtMe(rt *shortcut.RuntimeContext) error {
groupID := ""
directTarget := strings.TrimSpace(rt.Str("group"))
queryTarget := strings.TrimSpace(rt.StrFirst("chat-query", "group-query"))
if directTarget != "" || queryTarget != "" {
resolved, err := targetresolver.ResolveChatTarget(rt, directTarget, queryTarget)
if err != nil {
return err
}
now := time.Now()
startMs := now.AddDate(0, 0, -days).UnixMilli()
endMs := now.UnixMilli()
groupID = resolved.Selected.OpenConversationID
}
// Step 2 — search @me messages. startTime/endTime/limit/cursor and the
// first-page defaults (limit 50, cursor "0") mirror
// helpers.chatMessageListMentionsCmd's search_at_me_message call.
params := map[string]any{
"startTime": startMs,
"endTime": endMs,
"limit": rt.Int("limit"),
"cursor": rt.Str("cursor"),
}
if groupID != "" {
params["openConversationId"] = groupID
// Step 1 — look-back window [now-Nd, now] in epoch millis. Validation has
// already constrained days to the published 1-3650 range.
days := rt.Int("days")
now := time.Now()
startMs := now.AddDate(0, 0, -days).UnixMilli()
endMs := now.UnixMilli()
// Step 2 — search @me messages. startTime/endTime/limit/cursor and the
// first-page defaults (limit 50, cursor "0") mirror
// helpers.chatMessageListMentionsCmd's search_at_me_message call.
params := map[string]any{
"startTime": startMs,
"endTime": endMs,
"limit": rt.Int("limit"),
"cursor": rt.Str("cursor"),
}
if groupID != "" {
params["openConversationId"] = groupID
}
var items []map[string]any
var payload map[string]any
var readErr error
if rt.Bool("page-all") {
payload, items, readErr = readAllAtMePages(rt, params)
if payload == nil {
return readErr
}
} else {
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
if err != nil {
return err
}
// Step 3 — always publish the stable list envelope, including for an empty
// or newly-shaped response. This keeps common .messages[]/.items[] jq
// projections deterministic instead of turning absence into null.
items := atMeMessageItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, atMeProjectWithReactions(m, !rt.Bool("no-reactions")))
}
payload := chatmsg.NewMessageListPayload(results)
payload["items"] = atMeCompatibilityItems(results)
items = atMeMessageItems(data)
payload = atMePayload(items, !rt.Bool("no-reactions"))
chatmsg.ApplyPagination(payload, data)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID)
payload["pagesFetched"] = 1
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
payload["stopReason"] = "single_page"
}
return rt.Output(payload)
},
}
results := make([]map[string]any, 0, len(items))
if projected, ok := payload["messages"].([]map[string]any); ok {
results = projected
}
if rt.Bool("download-resources") && readErr == nil {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, groupID)
}
if len(results) == 0 {
payload["messages"] = []map[string]any{}
payload["items"] = []map[string]any{}
}
if err := rt.Output(payload); err != nil {
return err
}
return readErr
}
func validateAtMe(rt *shortcut.RuntimeContext) error {
@@ -174,9 +203,147 @@ func validateAtMe(rt *shortcut.RuntimeContext) error {
if rt.Int("limit") <= 0 {
return localChatOptionError("invalid_page_size", "+at-me 的 --limit 必须大于 0", "--limit")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > atMeHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
func atMePayload(items []map[string]any, includeReactions bool) map[string]any {
results := make([]map[string]any, 0, len(items))
for _, message := range items {
results = append(results, atMeProjectWithReactions(message, includeReactions))
}
payload := chatmsg.NewMessageListPayload(results)
payload["items"] = atMeCompatibilityItems(results)
return payload
}
func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (map[string]any, []map[string]any, error) {
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), atMeDefaultPageLimit)
cursor := strings.TrimSpace(fmt.Sprint(baseParams["cursor"]))
if cursor == "" || cursor == "<nil>" {
cursor = "0"
}
seenCursors := map[string]bool{cursor: true}
seenMessages := map[string]bool{}
allItems := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
complete := false
hasMore := false
nextCursor := ""
stopReason := "source_complete"
truncatedByPageLimit := false
for pagesFetched < pageLimit {
params := make(map[string]any, len(baseParams))
for key, value := range baseParams {
params[key] = value
}
params["cursor"] = cursor
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
if err != nil {
if pagesFetched == 0 {
return nil, nil, err
}
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursor, "error": err.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageItems := atMeMessageItems(data)
for _, item := range pageItems {
id := chatmsg.StableMessageID(item)
if id != "" && seenMessages[id] {
continue
}
if id != "" {
seenMessages[id] = true
}
allItems = append(allItems, item)
}
page := chatmsg.Pagination(data)
pageHasMore, known := page["hasMore"].(bool)
if !known {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "@我消息下层未返回可靠的 hasMore,无法证明结果完整",
})
stopReason = "pagination_error"
break
}
hasMore = pageHasMore
if !hasMore {
complete = true
nextCursor = ""
stopReason = "source_complete"
break
}
nextCursor = atMeCursorString(page["nextCursor"])
if nextCursor == "" || seenCursors[nextCursor] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "@我消息下层返回 hasMore=true,但 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := atMePayload(allItems, !rt.Bool("no-reactions"))
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = true
payload["complete"] = complete && len(failures) == 0
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allItems) > 0
if hasMore && nextCursor != "" {
payload["nextCursor"] = nextCursor
}
if len(failures) == 0 {
return payload, allItems, nil
}
return payload, allItems, apperrors.NewAPI(
fmt.Sprintf("@我消息分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("chat/search_at_me_message"),
apperrors.WithReason("at_me_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
func atMeCursorString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || text == "0" {
return ""
}
return text
}
// atMeCompatibilityItems preserves the common list/items projection used by
// older Agent snippets while keeping messages as the canonical v1 contract.
// Its conversation field is an object so `.items[].conversation.name` is safe.
+316 -44
View File
@@ -14,7 +14,10 @@
package smart
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
@@ -36,7 +39,11 @@ const (
)
func formatDingTalkMessageBoundary(now time.Time) string {
return now.In(dingTalkMessageLocation).Format("2006-01-02 15:04:05")
localized := now.In(dingTalkMessageLocation)
if localized.Nanosecond() != 0 {
return localized.Format(time.RFC3339Nano)
}
return localized.Format("2006-01-02 15:04:05")
}
// ChatMessages resolves one conversation, projects messages into the shared
@@ -49,10 +56,10 @@ var ChatMessages = shortcut.Shortcut{
Service: "chat",
Command: "+chat-messages",
Product: "chat",
Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
Intent: "当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" +
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。",
Risk: shortcut.RiskRead,
@@ -68,20 +75,20 @@ var ChatMessages = shortcut.Shortcut{
CLIPath: "chat +chat-messages",
PrimaryCLIPath: "chat +chat-messages",
},
Description: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed built-in Shortcut adapter: it routes group or direct-message history reads, projects a stable message shape, and optionally orchestrates safe resource downloads with a failure ledger.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按会话 ID、群名或用户 ID 读取消息,支持有界全量分页与原子 JSON 导出",
UseWhen: []string{"当你想快速看一个群聊或单聊里的消息(谁在什么时间说了什么),而不想拿到大段原始消息字段时使用;" +
AgentSummary: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略 --time 时默认从当前时间向前读取最近消息;也可指定时间边界并用 --direction newer/older 控制方向。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
"全量读取用 --page-all,并由 --page-limit/--max-results 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
AvoidWhen: []string{"要跨多个会话按关键词、发送者或消息类型检索时使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget"},
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
Examples: []string{
"dws chat +chat-messages --group <openConversationId> --direction older",
"dws chat +chat-messages --open-dingtalk-id <openDingTalkId> --download-resources --output-dir ./downloads",
@@ -93,11 +100,18 @@ var ChatMessages = shortcut.Shortcut{
{Name: "conversation-id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "open-conversation-id", Type: shortcut.FlagString, Desc: "--conversation-id 的兼容别名", Hidden: true},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "按群名唯一解析目标会话(可选,与其他会话目标参数互斥)"},
{Name: "user", Type: shortcut.FlagString, Desc: "单聊对方的 userId,与 --group 互斥"},
{Name: "user-query", Type: shortcut.FlagString, Desc: "按姓名解析唯一 openDingTalkId 的兼容入口", Hidden: true},
{Name: "open-dingtalk-id", Type: shortcut.FlagString, Desc: "单聊对方的 openDingTalkId,与 --group/--user 互斥"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者并筛选同一次会话读取结果(可选,可重复或逗号分隔)"},
{Name: "time", Type: shortcut.FlagString, Desc: "时间边界,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD;省略时从当前时间向前读取最近消息"},
{Name: "start", Type: shortcut.FlagString, Desc: "范围开始时间(可选、包含),支持 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(可选、包含)"},
{Name: "end", Type: shortcut.FlagString, Desc: "范围结束时间(可选、不包含);仅传开始时间时默认为当前时间"},
{Name: "end-time", Type: shortcut.FlagString, Desc: "--end 的 lark-cli 对齐别名(可选、不包含)"},
{Name: "order", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "结果及范围遍历顺序 asc/desc(可选,默认 desc;asc 必须指定 --start/--start-time)"},
{Name: "sort", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "--order 的 lark-cli 对齐别名(可选;asc 必须指定 --start/--start-time)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的消息条数;显式页大小必须大于 0"},
{Name: "size", Type: shortcut.FlagInt, Desc: "--limit 的旧版别名", Hidden: true},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true},
@@ -111,7 +125,17 @@ var ChatMessages = shortcut.Shortcut{
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintExactlyOne, Flags: []string{"group", "conversation-id", "id", "open-conversation-id", "chat-query", "user", "user-query", "open-dingtalk-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "size", "page-size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"order", "sort"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"time", "order", "sort"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"direction", "order", "sort"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"order", "sort"}, Description: "asc 必须指定 --start/--start-time"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式页大小必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"},
@@ -123,6 +147,7 @@ var ChatMessages = shortcut.Shortcut{
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"`,
`dws chat +chat-messages --group <openconversation_id> --start "2025-03-01T00:00:00+08:00" --end "2025-03-02T00:00:00+08:00" --order asc --page-all`,
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --page-all --page-limit 50`,
`dws chat +chat-messages --group <openconversation_id> --direction older --page-all --output ./exports/messages.json`,
},
@@ -137,6 +162,17 @@ func validateChatMessages(rt *shortcut.RuntimeContext) error {
if rt.Changed("time") && strings.TrimSpace(rt.Str("time")) != "" && !validChatTime(rt.Str("time")) {
return localChatOptionError("invalid_time_boundary", "+chat-messages 的 --time 格式无效", "--time")
}
rangeFlagsChanged := rt.Changed("start") || rt.Changed("start-time") || rt.Changed("end") ||
rt.Changed("end-time") || rt.Changed("order") || rt.Changed("sort")
if rt.Changed("time") && rangeFlagsChanged {
return apperrors.NewValidation("--time 兼容边界模式不能与 --start/--end/--order 范围模式混用")
}
if rt.Changed("direction") && rangeFlagsChanged {
return apperrors.NewValidation("--direction 兼容方向不能与 --start/--end/--order 范围模式混用")
}
if _, err := resolveChatMessageTimeRange(rt, time.Now()); err != nil {
return err
}
for _, name := range []string{"limit", "size", "page-size"} {
if rt.Changed(name) && rt.Int(name) <= 0 {
return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name)
@@ -168,6 +204,107 @@ type chatMessagesRequest struct {
params map[string]any
direction string
fallbackConversationID string
timeRange chatMessageTimeRange
}
type chatMessagesSenderFilter struct {
requested bool
applied bool
stableIDs map[string]bool
resolutions []targetresolver.UserResolution
failure map[string]any
}
// resolveOptionalChatMessagesSenderFilter is deliberately local to
// +chat-messages. Sender resolution is an optional post-read filter here: an
// unresolved name must not suppress the command's primary conversation-list
// result. +search-msg keeps its stricter semantics because sender identity is
// part of the lower search request there.
func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter {
queries := rt.StrSlice("sender-query")
filter := chatMessagesSenderFilter{
requested: len(queries) > 0,
stableIDs: map[string]bool{},
}
if !filter.requested {
return filter
}
resolutions, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
if err != nil {
failure := map[string]any{
"stage": "sender_resolution",
"queries": queries,
"error": err.Error(),
}
var typed *apperrors.Error
if errors.As(err, &typed) {
if typed.Reason != "" {
failure["reason"] = typed.Reason
}
if len(typed.Details) > 0 {
failure["details"] = typed.Details
}
}
filter.failure = failure
return filter
}
filter.resolutions = resolutions
for _, resolution := range resolutions {
for _, identity := range []string{
resolution.Selected.UserID,
resolution.Selected.OpenDingTalkID,
} {
if identity = strings.TrimSpace(identity); identity != "" {
filter.stableIDs[identity] = true
}
}
}
// ResolveUsers(IdentityAny) only returns users extracted with at least one
// stable userId/openDingTalkId and fails when no resolvable user remains.
filter.applied = true
return filter
}
// applyOptionalChatMessagesSenderFilter preserves the unfiltered message list
// when optional resolution fails. Successful resolution filters both the
// public projection and raw rows so exports and resource downloads cannot
// accidentally include messages outside the requested sender set.
func applyOptionalChatMessagesSenderFilter(
rt *shortcut.RuntimeContext,
payload map[string]any,
rawItems []map[string]any,
filter chatMessagesSenderFilter,
) []map[string]any {
if !filter.requested || payload == nil {
return rawItems
}
if !filter.applied {
failures, _ := payload["failures"].([]map[string]any)
priorFailures := len(failures)
failures = append(failures, filter.failure)
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(rawItems) > 0
if priorFailures == 0 {
payload["stopReason"] = "sender_resolution_failed"
}
return rawItems
}
filtered := make([]map[string]any, 0, len(rawItems))
for _, item := range rawItems {
identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item)))
if identity != "" && identity != "<nil>" && filter.stableIDs[identity] {
filtered = append(filtered, item)
}
}
payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions"))
payload["count"] = len(filtered)
payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions}
return filtered
}
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
@@ -194,12 +331,17 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
openID = resolved.Selected.OpenDingTalkID
}
now := time.Now()
timeRange, err := resolveChatMessageTimeRange(rt, now)
if err != nil {
return chatMessagesRequest{}, err
}
direction := strings.TrimSpace(strings.ToLower(rt.Str("direction")))
if direction == "" {
direction = "older"
direction = timeRange.direction()
}
params := map[string]any{
"time": formatDingTalkMessageBoundary(time.Now()),
"time": timeRange.initialBoundary(now),
"forward": direction == "newer",
}
if rt.Changed("time") && rt.Str("time") != "" {
@@ -211,7 +353,7 @@ func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesReques
params["limit"] = chatMessagesAllPageSize
}
request := chatMessagesRequest{params: params, direction: direction}
request := chatMessagesRequest{params: params, direction: direction, timeRange: timeRange}
switch {
case groupID != "":
request.tool = "list_conversation_message_v2"
@@ -239,6 +381,19 @@ func executeChatMessages(rt *shortcut.RuntimeContext) error {
} else {
payload, rawItems, err = collectOneChatMessagesPage(rt, request)
}
if err != nil && (payload == nil || payload["pagesFetched"] == 0) {
// A sender name is only an optional post-read filter. If the primary
// message read never produced a page, do not make a misleading and
// unnecessary directory request before returning the read failure.
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return err
}
senderFilter := resolveOptionalChatMessagesSenderFilter(rt)
rawItems = applyOptionalChatMessagesSenderFilter(rt, payload, rawItems, senderFilter)
if err != nil {
// Full-page collection returns its failure ledger together with a
// non-zero error. Publish that ledger for diagnosis, but stop before
@@ -285,10 +440,32 @@ func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessage
if err != nil {
return nil, nil, err
}
items := chatMessageItems(data)
rawItems := chatMessageItems(data)
items, terminalReached, rangeFailures := request.timeRange.filter(rawItems)
sortMessagesByCreateTimeStable(items, request.timeRange.order)
results := projectChatMessages(items, !rt.Bool("no-reactions"))
payload := chatmsg.NewMessageListPayload(results)
chatmsg.ApplyMessagePagination(payload, data, items, request.direction)
chatmsg.ApplyMessagePagination(payload, data, rawItems, request.direction)
if metadata := request.timeRange.metadata(); metadata != nil {
payload["queryRange"] = metadata
}
if len(rangeFailures) > 0 {
failures, _ := payload["failures"].([]map[string]any)
failures = append(failures, rangeFailures...)
payload["failures"] = failures
payload["failedCount"] = len(failures)
payload["complete"] = false
payload["partial"] = len(items) > 0
payload["stopReason"] = "time_filter_error"
return payload, items, nil
}
if terminalReached {
payload["complete"] = true
payload["hasMore"] = false
payload["stopReason"] = request.timeRange.stopReason()
delete(payload, "nextPage")
return payload, items, nil
}
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
@@ -300,8 +477,12 @@ func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessage
func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit)
maxResults := rt.Int("max-results")
basePageSize, _ := request.params["limit"].(int)
if basePageSize <= 0 {
basePageSize = chatMessagesAllPageSize
}
seenIDs := map[string]bool{}
seenBoundaries := map[string]bool{fmt.Sprint(request.params["time"]): true}
seenCursors := map[string]bool{}
allItems := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
@@ -314,6 +495,13 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
var nextPage map[string]any
for pagesFetched < pageLimit {
request.params["limit"] = basePageSize
if maxResults > 0 {
remaining := maxResults - len(allItems)
if remaining < basePageSize {
request.params["limit"] = remaining
}
}
data, err := rt.CallMCPData("chat", request.tool, request.params)
if err != nil {
failures = append(failures, map[string]any{
@@ -325,8 +513,10 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
break
}
pagesFetched++
items := chatMessageItems(data)
keptOnPage := make([]map[string]any, 0, len(items))
rawItems := chatMessageItems(data)
items, terminalReached, rangeFailures := request.timeRange.filter(rawItems)
failures = append(failures, rangeFailures...)
moreEligibleOnPage := false
for _, item := range items {
stableID := chatmsg.StableMessageID(item)
if stableID != "" && seenIDs[stableID] {
@@ -335,11 +525,27 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
if stableID != "" {
seenIDs[stableID] = true
}
allItems = append(allItems, item)
keptOnPage = append(keptOnPage, item)
if maxResults > 0 && len(allItems) >= maxResults {
break
moreEligibleOnPage = true
continue
}
allItems = append(allItems, item)
}
if len(rangeFailures) > 0 {
stopReason = "time_filter_error"
break
}
if terminalReached {
if maxResults > 0 && moreEligibleOnPage {
truncatedByResultLimit = true
hasMore = true
stopReason = "result_limit"
} else {
complete = true
hasMore = false
stopReason = request.timeRange.stopReason()
}
break
}
page := chatmsg.Pagination(data)
@@ -357,22 +563,34 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
hasMore = pageHasMore
if maxResults > 0 && len(allItems) >= maxResults {
truncatedByResultLimit = pageHasMore || len(keptOnPage) < len(items)
truncatedByResultLimit = pageHasMore || moreEligibleOnPage
if truncatedByResultLimit {
hasMore = true
stopReason = "result_limit"
if len(keptOnPage) > 0 {
nextPage = messageNextPage(keptOnPage[len(keptOnPage)-1], request.direction)
boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"]))
if boundary == "" || boundary == "<nil>" {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "达到结果上限但无法生成可靠的 nextPage.time",
})
stopReason = "pagination_error"
nextPage = nil
}
if moreEligibleOnPage {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "达到 --max-results 时当前下层页仍有未返回消息,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
cursorKey, boundary, cursorErr := chatMessagesNextCursorBoundary(page["nextCursor"])
if cursorErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "达到结果上限但 nextCursor 无效,无法安全续页: " + cursorErr.Error(),
})
stopReason = "pagination_error"
break
}
seenCursors[cursorKey] = true
nextPage = map[string]any{
"direction": request.direction,
"time": boundary,
"nextCursor": page["nextCursor"],
}
break
}
@@ -383,7 +601,7 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
stopReason = "source_complete"
break
}
if len(items) == 0 {
if len(rawItems) == 0 {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
@@ -392,18 +610,31 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
stopReason = "pagination_error"
break
}
nextPage = messageNextPage(items[len(items)-1], request.direction)
boundary := strings.TrimSpace(fmt.Sprint(nextPage["time"]))
if boundary == "" || boundary == "<nil>" || seenBoundaries[boundary] {
cursorKey, boundary, cursorErr := chatMessagesNextCursorBoundary(page["nextCursor"])
if cursorErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "hasMore=true 但 nextPage.time 缺失或停滞",
"error": "hasMore=true 但 nextCursor 无效,无法安全续页: " + cursorErr.Error(),
})
stopReason = "pagination_error"
break
}
seenBoundaries[boundary] = true
if seenCursors[cursorKey] {
failures = append(failures, map[string]any{
"page": pagesFetched,
"stage": "pagination",
"error": "hasMore=true 但毫秒 nextCursor 停滞",
})
stopReason = "pagination_error"
break
}
seenCursors[cursorKey] = true
nextPage = map[string]any{
"direction": request.direction,
"time": boundary,
"nextCursor": page["nextCursor"],
}
request.params["time"] = boundary
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
@@ -411,8 +642,12 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
stopReason = "page_limit"
}
sortMessagesByCreateTimeStable(allItems, request.timeRange.order)
results := projectChatMessages(allItems, !rt.Bool("no-reactions"))
payload := chatmsg.NewMessageListPayload(results)
if metadata := request.timeRange.metadata(); metadata != nil {
payload["queryRange"] = metadata
}
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = paginationKnown
payload["complete"] = complete && len(failures) == 0
@@ -430,6 +665,8 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
failureStage := "pagination"
if stopReason == "read_failure" {
failureStage = "read"
} else if stopReason == "time_filter_error" {
failureStage = "time_filter"
}
return payload, allItems, apperrors.NewAPI(
fmt.Sprintf("全量消息读取未完成:%d 页成功,%d 个页面失败", pagesFetched, len(failures)),
@@ -460,11 +697,46 @@ func projectChatMessages(items []map[string]any, includeReactions bool) []map[st
return results
}
func messageNextPage(message map[string]any, direction string) map[string]any {
return map[string]any{
"time": chatmsg.CreateTime(message),
"direction": direction,
// chatMessagesNextCursorBoundary converts the authoritative millisecond
// cursor returned by DingTalk message-list tools into the exact RFC3339Nano
// boundary accepted by their time parameter. Projected createTime is only
// second precision and must never drive pagination: doing so skips messages
// when a page boundary splits several messages created within the same second.
func chatMessagesNextCursorBoundary(value any) (string, string, error) {
var millis int64
switch typed := value.(type) {
case int:
millis = int64(typed)
case int32:
millis = int64(typed)
case int64:
millis = typed
case float32:
asFloat := float64(typed)
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(asFloat)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = parsed
default:
return "", "", fmt.Errorf("缺少毫秒级分页游标")
}
if millis <= 0 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
cursorKey := strconv.FormatInt(millis, 10)
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
return cursorKey, boundary, nil
}
// chatMessageItems defensively unwraps the message list from the response,
+334 -6
View File
@@ -18,11 +18,14 @@ import (
"context"
"encoding/json"
stderrors "errors"
"math"
"os"
"testing"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
@@ -147,8 +150,9 @@ func (*chatMessagesPagingCaller) Fields() string { return "" }
func (*chatMessagesPagingCaller) JQ() string { return "" }
func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicates(t *testing.T) {
const cursorMillis int64 = 1767225600123
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1767225600123,"messages":[{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"},{"openMessageId":"m0","createTime":"2025-12-31 00:00:00"}]}}`,
}}
helpers.InitDeps(caller)
@@ -162,7 +166,8 @@ func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicate
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || caller.args[1]["time"] != "2026-01-01 00:00:00" {
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if len(caller.args) != 2 || caller.args[1]["time"] != wantBoundary {
t.Fatalf("pagination calls = %#v", caller.args)
}
var payload map[string]any
@@ -176,9 +181,158 @@ func TestCrossPlatformCoverageChatMessagesPageAllUsesTypedBoundaryAndDeduplicate
}
}
func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t *testing.T) {
func TestCrossPlatformCoverageChatMessagesMillisecondCursorDoesNotSkipSameSecondRows(t *testing.T) {
const cursorMillis int64 = 1785919699136
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1785919699136,"messages":[{"openMessageId":"m4","createTime":"2026-08-05 16:48:19"},{"openMessageId":"m3","createTime":"2026-08-05 16:48:19"},{"openMessageId":"m2","createTime":"2026-08-05 16:48:19"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:19"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--conversation-id", "cid", "--time", "2026-08-05 16:49:00",
"--page-size", "3", "--page-all", "--page-limit", "5",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if len(caller.args) != 2 || caller.args[1]["time"] != wantBoundary {
t.Fatalf("pagination calls = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(4) || payload["complete"] != true || payload["pagesFetched"] != float64(2) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatMessagesDescendingRangeStopsAtInclusiveStart(t *testing.T) {
const cursorMillis int64 = 1767312000456
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1767312000456,"messages":[{"openMessageId":"m3","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"}]}}`,
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 12:00:00"},{"openMessageId":"m0","createTime":"2026-01-01 11:59:59"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--conversation-id", "cid",
"--start", "2026-01-01 12:00:00", "--end", "2026-01-04 00:00:00",
"--order", "desc", "--page-all", "--page-limit", "5",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if len(caller.args) != 2 || caller.args[0]["time"] != "2026-01-04 00:00:00" ||
caller.args[0]["forward"] != false || caller.args[1]["time"] != wantBoundary {
t.Fatalf("range calls = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != true || payload["hasMore"] != false ||
payload["count"] != float64(3) || payload["stopReason"] != "range_start" {
t.Fatalf("range payload = %#v", payload)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "m3" ||
messages[2].(map[string]any)["messageId"] != "m1" {
t.Fatalf("descending messages = %#v", messages)
}
rangeMeta := payload["queryRange"].(map[string]any)
if rangeMeta["semantics"] != "[start,end)" || rangeMeta["order"] != "desc" {
t.Fatalf("queryRange = %#v", rangeMeta)
}
}
func TestCrossPlatformCoverageChatMessagesAscendingRangeStopsAtExclusiveEnd(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 12:00:00"},{"openMessageId":"m2","createTime":"2026-01-02 00:00:00"},{"openMessageId":"m-end","createTime":"2026-01-03 00:00:00"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--conversation-id", "cid",
"--start-time", "2026-01-01 12:00:00", "--end-time", "2026-01-03 00:00:00",
"--sort", "asc", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 1 || caller.args[0]["time"] != "2026-01-01 12:00:00" ||
caller.args[0]["forward"] != true {
t.Fatalf("ascending call = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != true || payload["hasMore"] != false ||
payload["count"] != float64(2) || payload["stopReason"] != "range_end" {
t.Fatalf("ascending payload = %#v", payload)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "m1" ||
messages[1].(map[string]any)["messageId"] != "m2" {
t.Fatalf("ascending messages = %#v", messages)
}
}
func TestCrossPlatformCoverageChatMessagesFirstReadFailureSkipsOptionalSenderResolution(t *testing.T) {
caller := &chatMessagesPagingCaller{
responses: []string{`{"result":[]}`},
failAt: 1,
}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetOut(&bytes.Buffer{})
root.SetArgs([]string{
"chat", "+chat-messages", "--conversation-id", "cid", "--page-all",
"--sender-query", "张三",
})
if err := root.Execute(); err == nil {
t.Fatal("first-page failure unexpectedly succeeded")
}
if len(caller.args) != 1 {
t.Fatalf("calls after first read failure = %#v, want only the message read", caller.args)
}
}
func TestCrossPlatformCoverageChatMessagesRangeValidationStopsBeforeRead(t *testing.T) {
for _, args := range [][]string{
{"--start", "2026-01-02", "--end", "2026-01-01"},
{"--end", "2026-01-02", "--order", "asc"},
{"--time", "2026-01-02", "--start", "2026-01-01"},
{"--direction", "newer", "--start", "2026-01-01"},
{"--start", "2026-01-01", "--start-time", "2026-01-01"},
} {
caller := &chatMessagesPagingCaller{responses: []string{`{"result":{"hasMore":false,"messages":[]}}`}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{"chat", "+chat-messages", "--conversation-id", "cid"}, args...))
if err := root.Execute(); err == nil {
t.Errorf("invalid range succeeded: %v", args)
}
if len(caller.args) != 0 {
t.Errorf("invalid range reached message read: %v calls=%#v", args, caller.args)
}
}
}
func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t *testing.T) {
const cursorMillis int64 = 1767225600123
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1767225600123,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
@@ -196,9 +350,10 @@ func TestCrossPlatformCoverageChatMessagesPageAllPublishesBoundedContinuation(t
t.Fatal(err)
}
next, _ := payload["nextPage"].(map[string]any)
wantBoundary := time.UnixMilli(cursorMillis).UTC().Format(time.RFC3339Nano)
if payload["complete"] != false || payload["hasMore"] != true ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" ||
next["time"] != "2026-01-01 00:00:00" {
next["time"] != wantBoundary || next["nextCursor"] != float64(cursorMillis) {
t.Fatalf("bounded payload = %#v", payload)
}
}
@@ -233,7 +388,7 @@ func TestCrossPlatformCoverageChatMessagesFailedPageDoesNotExportPartialLedger(t
t.Chdir(t.TempDir())
caller := &chatMessagesPagingCaller{
responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1767225600123,"messages":[{"openMessageId":"m1","createTime":"2026-01-01 00:00:00"}]}}`,
},
failAt: 2,
}
@@ -332,3 +487,176 @@ func TestCrossPlatformCoverageChatMessagesExportRejectsNonJSONPlaceholder(t *tes
t.Fatalf("placeholder output was created: %v", err)
}
}
func chatMessagesRuntimeForTest(t *testing.T, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
root := newPlatformCoverageRoot()
cmd, _, err := root.Find([]string{"chat", "+chat-messages"})
if err != nil {
t.Fatal(err)
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s=%q: %v", name, value, err)
}
}
return shortcut.RuntimeContextForTest(cmd, ChatMessages)
}
func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *testing.T) {
for _, values := range []map[string]string{
{"time": "2026-01-01", "start": "2026-01-01"},
{"direction": "older", "start": "2026-01-01"},
} {
if err := validateChatMessages(chatMessagesRuntimeForTest(t, values)); err == nil {
t.Fatalf("direct validation unexpectedly accepted %#v", values)
}
}
if _, err := resolveChatMessagesRequest(chatMessagesRuntimeForTest(t, map[string]string{
"conversation-id": "cid", "start": "not-a-time",
})); err == nil {
t.Fatal("invalid direct request range unexpectedly succeeded")
}
helpers.InitDeps(&platformCoverageCaller{
contactSearchResult: `{"result":[{"name":"测试用户甲"}]}`,
})
filter := resolveOptionalChatMessagesSenderFilter(chatMessagesRuntimeForTest(t, map[string]string{
"sender-query": "测试用户甲",
}))
if filter.applied || filter.failure == nil {
t.Fatalf("identity-free sender resolution = %#v", filter)
}
for _, tc := range []struct {
name string
value any
wantError bool
}{
{name: "int", value: int(1234)},
{name: "int32", value: int32(1234)},
{name: "int64", value: int64(1234)},
{name: "float32", value: float32(1024)},
{name: "float64", value: float64(1234)},
{name: "string", value: "1234"},
{name: "float32 nan", value: float32(math.NaN()), wantError: true},
{name: "float32 infinity", value: float32(math.Inf(1)), wantError: true},
{name: "float64 fractional", value: 1.5, wantError: true},
{name: "bad string", value: "opaque", wantError: true},
{name: "missing", value: nil, wantError: true},
{name: "zero", value: 0, wantError: true},
} {
t.Run(tc.name, func(t *testing.T) {
_, _, err := chatMessagesNextCursorBoundary(tc.value)
if (err != nil) != tc.wantError {
t.Fatalf("value=%#v err=%v wantError=%v", tc.value, err, tc.wantError)
}
})
}
}
func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T) {
runtimeWith := func(t *testing.T, caller *chatMessagesPagingCaller, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
helpers.InitDeps(caller)
return chatMessagesRuntimeForTest(t, values)
}
start := time.Date(2026, 1, 2, 0, 0, 0, 0, dingTalkMessageLocation)
configuredRange := chatMessageTimeRange{configured: true, start: &start, order: "desc"}
t.Run("single-page range failures and terminal", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"bad","createTime":"invalid"}]}}`,
}}
payload, _, err := collectOneChatMessagesPage(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err != nil || payload["stopReason"] != "time_filter_error" || payload["queryRange"] == nil {
t.Fatalf("payload=%#v err=%v", payload, err)
}
caller = &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
}}
payload, _, err = collectOneChatMessagesPage(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err != nil || payload["complete"] != true || payload["stopReason"] != "range_start" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("default page size and time-filter failure", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[]}}`,
}}
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
if err != nil || payload["complete"] != true || caller.args[0]["limit"] != chatMessagesAllPageSize {
t.Fatalf("payload=%#v calls=%#v err=%v", payload, caller.args, err)
}
caller = &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[{"openMessageId":"bad","createTime":"invalid"}]}}`,
}}
payload, _, err = collectAllChatMessages(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err == nil || payload["stopReason"] != "time_filter_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("terminal result limit and unsafe continuation", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m2","createTime":"2026-01-03 00:00:00"},{"openMessageId":"m1","createTime":"2026-01-02 00:00:00"},{"openMessageId":"old","createTime":"2026-01-01 00:00:00"}]}}`,
}}
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err != nil || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
caller = &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-03 00:00:00"}]}}`,
}}
payload, _, err = collectAllChatMessages(
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
if err == nil || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("stalled cursor", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m2"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m1"}]}}`,
}}
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, nil),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
if err == nil || payload["stopReason"] != "pagination_error" || len(caller.args) != 2 {
t.Fatalf("payload=%#v calls=%#v err=%v", payload, caller.args, err)
}
})
t.Run("first failure ledger output error", func(t *testing.T) {
helpers.InitDeps(&chatMessagesPagingCaller{failAt: 1})
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs([]string{"chat", "+chat-messages", "--conversation-id", "cid", "--page-all"})
if err := root.Execute(); err == nil || err.Error() != "fixture output failure" {
t.Fatalf("error=%v", err)
}
})
}
@@ -37,11 +37,13 @@ type platformCoverageCall struct {
}
type platformCoverageCaller struct {
calls []platformCoverageCall
dry bool
contactSearchResult string
aisearchResult string
failTool string
calls []platformCoverageCall
dry bool
contactSearchResult string
aisearchResult string
chatMessagesResult string
searchMessagesResult string
failTool string
}
func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -65,6 +67,14 @@ func (f *platformCoverageCaller) CallTool(_ context.Context, product, tool strin
text = `{"result":{"userId":"u1"}}`
case "im/search_groups":
text = `{"result":[{"openConversationId":"cid-1","title":"项目冲刺"}]}`
case "chat/list_conversation_message_v2":
if f.chatMessagesResult != "" {
text = f.chatMessagesResult
}
case "im/search_messages":
if f.searchMessagesResult != "" {
text = f.searchMessagesResult
}
}
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
}
@@ -153,6 +163,8 @@ func TestCrossPlatformCoverageIMObservedCompatibilityAliasesConflictWithCanonica
for _, args := range [][]string{
{"chat", "+chat-messages", "--limit", "5", "--page-size", "5", "--conversation-id", "cid"},
{"chat", "+search-msg", "--query", "评测", "--text", "评测", "--no-enrich"},
{"chat", "+search-msg", "--query", "评测", "--start", "2026-01-01T00:00:00+08:00", "--start-time", "2026-01-01T00:00:00+08:00", "--end", "2026-01-02T00:00:00+08:00", "--no-enrich"},
{"chat", "+search-msg", "--query", "评测", "--order", "asc", "--sort", "asc", "--no-enrich"},
} {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
@@ -296,7 +308,7 @@ func TestCrossPlatformCoverageBroadcastUsesEnterpriseAliasAndUserIDFallback(t *t
dry: true,
aisearchResult: `{"result":[{
"userId":"user-alias",
"meta":{"name":"柏荣","nick":"大柚"}
"meta":{"name":"测试用户甲","nick":"测试花名甲"}
}]}`,
}
helpers.InitDeps(fake)
@@ -458,11 +458,11 @@ func TestCrossPlatformCoverageChatMessagesValidationAndFailureBoundaries(t *test
}{
{name: "single read failure", failAt: 1, args: []string{"--group", "cid123456789"}, wantError: true},
{name: "all first read failure", failAt: 1, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "all later read failure", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true,"nextCursor":1767225600123}}`}, failAt: 2, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "missing pagination", responses: []string{`{"result":{"messages":[]}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "empty page with continuation", responses: []string{`{"result":{"messages":[],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all"}, wantError: true},
{name: "result limit without boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}, wantError: true},
{name: "result limit with boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"},{"openMessageId":"m2","createTime":"1"}],"hasMore":true}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}},
{name: "result limit with boundary", responses: []string{`{"result":{"messages":[{"openMessageId":"m1","createTime":"2"}],"hasMore":true,"nextCursor":1767225600123}}`}, args: []string{"--group", "cid123456789", "--page-all", "--max-results", "1"}},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &smartCoverageCaller{
@@ -6,7 +6,6 @@ package smart
import (
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
@@ -19,13 +18,8 @@ func defaultChatPageLimit(value, fallback int) int {
}
func validChatTime(value string) bool {
value = strings.TrimSpace(value)
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
if _, err := time.Parse(layout, value); err == nil {
return true
}
}
return false
_, err := parseDingTalkMessageTime(strings.TrimSpace(value))
return err == nil
}
func localChatOptionError(reason, message string, flags ...string) error {
@@ -0,0 +1,246 @@
// 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.
package smart
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
const messageTimeRangeSemantics = "[start,end)"
type chatMessageTimeRange struct {
configured bool
start *time.Time
end *time.Time
order string
}
func resolveChatMessageTimeRange(rt *shortcut.RuntimeContext, now time.Time) (chatMessageTimeRange, error) {
startValue := strings.TrimSpace(rt.StrFirst("start", "start-time"))
endValue := strings.TrimSpace(rt.StrFirst("end", "end-time"))
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
configured := startValue != "" || endValue != "" || order != ""
if order == "" {
order = "desc"
}
result := chatMessageTimeRange{configured: configured, order: order}
if startValue != "" {
parsed, err := parseDingTalkMessageTime(startValue)
if err != nil {
return chatMessageTimeRange{}, localChatOptionError(
"invalid_start_time", "+chat-messages 的开始时间格式无效", "--start/--start-time")
}
result.start = &parsed
}
if endValue != "" {
parsed, err := parseDingTalkMessageTime(endValue)
if err != nil {
return chatMessageTimeRange{}, localChatOptionError(
"invalid_end_time", "+chat-messages 的结束时间格式无效", "--end/--end-time")
}
result.end = &parsed
}
if result.start != nil && result.end != nil && !result.end.After(*result.start) {
return chatMessageTimeRange{}, apperrors.NewValidation("--end/--end-time 必须晚于 --start/--start-time")
}
if order == "asc" && result.start == nil {
return chatMessageTimeRange{}, apperrors.NewValidation("升序读取必须指定 --start/--start-time,避免把最近一页倒序后误报为最早消息")
}
if result.start != nil && result.end == nil {
effectiveEnd := now
result.end = &effectiveEnd
}
return result, nil
}
func (r chatMessageTimeRange) initialBoundary(now time.Time) string {
if !r.configured {
return formatDingTalkMessageBoundary(now.Truncate(time.Second))
}
if r.order == "asc" && r.start != nil {
return formatDingTalkMessageBoundary(*r.start)
}
if r.end != nil {
return formatDingTalkMessageBoundary(*r.end)
}
return formatDingTalkMessageBoundary(now)
}
func (r chatMessageTimeRange) direction() string {
if r.order == "asc" {
return "newer"
}
return "older"
}
func (r chatMessageTimeRange) stopReason() string {
if r.order == "asc" {
return "range_end"
}
return "range_start"
}
func (r chatMessageTimeRange) metadata() map[string]any {
if !r.configured {
return nil
}
result := map[string]any{
"order": r.order,
"semantics": messageTimeRangeSemantics,
}
if r.start != nil {
result["startTime"] = r.start.Format(time.RFC3339Nano)
}
if r.end != nil {
result["endTime"] = r.end.Format(time.RFC3339Nano)
}
return result
}
func (r chatMessageTimeRange) filter(items []map[string]any) ([]map[string]any, bool, []map[string]any) {
if r.start == nil && r.end == nil {
return items, false, nil
}
filtered := make([]map[string]any, 0, len(items))
unknown := make([]string, 0)
terminalReached := false
for _, item := range items {
messageTime, ok := parseMessageTimestamp(chatmsg.CreateTime(item))
if !ok {
unknown = append(unknown, chatmsg.StableMessageID(item))
continue
}
if r.start != nil && messageTime.Before(*r.start) {
if r.order == "desc" {
terminalReached = true
}
continue
}
if r.end != nil && !messageTime.Before(*r.end) {
if r.order == "asc" {
terminalReached = true
}
continue
}
filtered = append(filtered, item)
}
if len(unknown) == 0 {
return filtered, terminalReached, nil
}
return filtered, terminalReached, []map[string]any{{
"stage": "time_filter",
"messageIds": unknown,
"error": "消息缺少可解析的 createTime,无法证明时间范围结果完整",
}}
}
func parseDingTalkMessageTime(value string) (time.Time, error) {
value = strings.TrimSpace(value)
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02"} {
if parsed, err := time.ParseInLocation(layout, value, dingTalkMessageLocation); err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("unsupported message time %q", value)
}
func parseMessageTimestamp(value any) (time.Time, bool) {
switch typed := value.(type) {
case string:
trimmed := strings.TrimSpace(typed)
if parsed, err := parseDingTalkMessageTime(trimmed); err == nil {
return parsed, true
}
if numeric, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
return unixMessageTime(numeric), true
}
case json.Number:
if numeric, err := typed.Int64(); err == nil {
return unixMessageTime(numeric), true
}
case float64:
return unixMessageTime(int64(typed)), true
case float32:
return unixMessageTime(int64(typed)), true
case int:
return unixMessageTime(int64(typed)), true
case int64:
return unixMessageTime(typed), true
case int32:
return unixMessageTime(int64(typed)), true
}
return time.Time{}, false
}
func unixMessageTime(value int64) time.Time {
if value > -1_000_000_000_000 && value < 1_000_000_000_000 {
return time.Unix(value, 0)
}
return time.UnixMilli(value)
}
func sortMessagesByCreateTimeStable(messages []map[string]any, order string) {
order = strings.ToLower(strings.TrimSpace(order))
if order != "asc" && order != "desc" {
return
}
sort.SliceStable(messages, func(i, j int) bool {
left, leftOK := parseMessageTimestamp(chatmsg.CreateTime(messages[i]))
right, rightOK := parseMessageTimestamp(chatmsg.CreateTime(messages[j]))
if leftOK != rightOK {
return leftOK
}
if !leftOK || left.Equal(right) {
return false
}
if order == "asc" {
return left.Before(right)
}
return left.After(right)
})
}
func searchMessageQueryRange(params map[string]any, order string) map[string]any {
result := map[string]any{
"order": order,
"semantics": messageTimeRangeSemantics,
}
if start, ok := numericMillis(params["startTime"]); ok {
result["startTime"] = time.UnixMilli(start).In(dingTalkMessageLocation).Format(time.RFC3339)
}
if end, ok := numericMillis(params["endTime"]); ok {
result["endTime"] = time.UnixMilli(end).In(dingTalkMessageLocation).Format(time.RFC3339)
}
return result
}
func numericMillis(value any) (int64, bool) {
switch typed := value.(type) {
case int64:
return typed, true
case int:
return int64(typed), true
case float64:
return int64(typed), true
case json.Number:
parsed, err := typed.Int64()
return parsed, err == nil
default:
return 0, false
}
}
@@ -0,0 +1,242 @@
// 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.
package smart
import (
"encoding/json"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/spf13/cobra"
)
func messageTimeRangeRuntime(t *testing.T, values map[string]string) *shortcut.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
for _, name := range []string{"start", "start-time", "end", "end-time", "order", "sort"} {
cmd.Flags().String(name, "", "")
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return shortcut.RuntimeContextForTest(cmd, shortcut.Shortcut{})
}
func TestCrossPlatformCoverageResolveMessageTimeRange(t *testing.T) {
now := time.Date(2026, 8, 7, 12, 0, 0, 0, dingTalkMessageLocation)
plain, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, nil), now)
if err != nil || plain.configured || plain.order != "desc" || plain.start != nil || plain.end != nil {
t.Fatalf("plain range = %#v, %v", plain, err)
}
if plain.metadata() != nil || plain.initialBoundary(now) != formatDingTalkMessageBoundary(now) {
t.Fatalf("plain metadata/boundary = %#v, %q", plain.metadata(), plain.initialBoundary(now))
}
orderOnly, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
"order": "desc",
}), now)
if err != nil || !orderOnly.configured || orderOnly.start != nil || orderOnly.end != nil ||
orderOnly.initialBoundary(now) != formatDingTalkMessageBoundary(now) {
t.Fatalf("order-only range = %#v, %v", orderOnly, err)
}
ascending, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
"start-time": "2026-08-01",
"end-time": "2026-08-03 00:00:00",
"sort": " ASC ",
}), now)
if err != nil {
t.Fatal(err)
}
if ascending.direction() != "newer" || ascending.stopReason() != "range_end" {
t.Fatalf("ascending direction/reason = %q, %q", ascending.direction(), ascending.stopReason())
}
if ascending.initialBoundary(now) != formatDingTalkMessageBoundary(*ascending.start) {
t.Fatalf("ascending boundary = %q", ascending.initialBoundary(now))
}
metadata := ascending.metadata()
if metadata["order"] != "asc" || metadata["semantics"] != messageTimeRangeSemantics || metadata["startTime"] == nil || metadata["endTime"] == nil {
t.Fatalf("ascending metadata = %#v", metadata)
}
startOnly, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
"start": "2026-08-01T00:00:00+08:00",
}), now)
if err != nil {
t.Fatal(err)
}
if startOnly.end == nil || !startOnly.end.Equal(now) || startOnly.direction() != "older" || startOnly.stopReason() != "range_start" {
t.Fatalf("start-only range = %#v", startOnly)
}
if startOnly.initialBoundary(now) != formatDingTalkMessageBoundary(now) {
t.Fatalf("start-only boundary = %q", startOnly.initialBoundary(now))
}
fractional, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, map[string]string{
"start": "2026-08-02T00:00:00.125+08:00",
"end": "2026-08-03T00:00:00.500+08:00",
}), now)
if err != nil {
t.Fatal(err)
}
if got := fractional.initialBoundary(now); got != "2026-08-03T00:00:00.5+08:00" {
t.Fatalf("fractional boundary = %q", got)
}
fractionalMetadata := fractional.metadata()
if fractionalMetadata["startTime"] != "2026-08-02T00:00:00.125+08:00" ||
fractionalMetadata["endTime"] != "2026-08-03T00:00:00.5+08:00" {
t.Fatalf("fractional metadata = %#v", fractionalMetadata)
}
for name, values := range map[string]map[string]string{
"invalid start": {"start": "not-a-time"},
"invalid end": {"end": "not-a-time"},
"reversed": {"start": "2026-08-03", "end": "2026-08-02"},
"asc no start": {"end": "2026-08-03", "order": "asc"},
} {
t.Run(name, func(t *testing.T) {
if _, err := resolveChatMessageTimeRange(messageTimeRangeRuntime(t, values), now); err == nil {
t.Fatal("expected validation error")
}
})
}
}
func TestCrossPlatformCoverageMessageTimeRangeParsesLocalAndEpochTimes(t *testing.T) {
local, err := parseDingTalkMessageTime("2026-01-02 03:04:05")
if err != nil {
t.Fatal(err)
}
if local.Location().String() != "CST" || local.Format(time.RFC3339) != "2026-01-02T03:04:05+08:00" {
t.Fatalf("local time = %s (%s)", local.Format(time.RFC3339), local.Location())
}
for _, value := range []any{
local.Unix(), int(local.Unix()), int32(local.Unix()),
local.UnixMilli(), float64(local.UnixMilli()), json.Number("1767294245000"), "1767294245000",
} {
parsed, ok := parseMessageTimestamp(value)
if !ok || parsed.Unix() != local.Unix() {
t.Fatalf("parse %v = %s, %v", value, parsed, ok)
}
}
float32Seconds := float32(local.Unix())
if parsed, ok := parseMessageTimestamp(float32Seconds); !ok || parsed.Unix() != int64(float32Seconds) {
t.Fatalf("parse float32 %v = %s, %v", float32Seconds, parsed, ok)
}
if parsed, err := parseDingTalkMessageTime("2026-01-02T03:04:05+08:00"); err != nil || parsed.Unix() != local.Unix() {
t.Fatalf("RFC3339 parse = %s, %v", parsed, err)
}
if _, err := parseDingTalkMessageTime("unsupported"); err == nil {
t.Fatal("unsupported time unexpectedly parsed")
}
for _, value := range []any{json.Number("bad"), "bad", true, nil} {
if parsed, ok := parseMessageTimestamp(value); ok || !parsed.IsZero() {
t.Fatalf("invalid timestamp %v = %s, %v", value, parsed, ok)
}
}
}
func TestCrossPlatformCoverageMessageTimeRangeStableSortKeepsUnknownRows(t *testing.T) {
messages := []map[string]any{
{"openMessageId": "unknown"},
{"openMessageId": "later", "createTime": "2026-01-02 00:00:00"},
{"openMessageId": "same-a", "createTime": "2026-01-01 00:00:00"},
{"openMessageId": "same-b", "createTime": "2026-01-01 00:00:00"},
}
sortMessagesByCreateTimeStable(messages, "asc")
want := []string{"same-a", "same-b", "later", "unknown"}
for index, id := range want {
if messages[index]["openMessageId"] != id {
t.Fatalf("sorted messages = %#v", messages)
}
}
sortMessagesByCreateTimeStable(messages, "desc")
if messages[0]["openMessageId"] != "later" || messages[len(messages)-1]["openMessageId"] != "unknown" {
t.Fatalf("descending messages = %#v", messages)
}
before := append([]map[string]any(nil), messages...)
sortMessagesByCreateTimeStable(messages, "sideways")
for index := range before {
if before[index]["openMessageId"] != messages[index]["openMessageId"] {
t.Fatalf("invalid-order sort changed messages = %#v", messages)
}
}
}
func TestCrossPlatformCoverageMessageTimeRangeFiltering(t *testing.T) {
start, _ := parseDingTalkMessageTime("2026-08-02 00:00:00")
end, _ := parseDingTalkMessageTime("2026-08-04 00:00:00")
items := []map[string]any{
{"openMessageId": "before", "createTime": "2026-08-01 00:00:00"},
{"openMessageId": "inside", "createTime": "2026-08-03 00:00:00"},
{"openMessageId": "end", "createTime": "2026-08-04 00:00:00"},
{"openMessageId": "unknown"},
}
plain := chatMessageTimeRange{}
plainItems, terminal, failures := plain.filter(items)
if len(plainItems) != len(items) || terminal || failures != nil {
t.Fatalf("plain filter = %#v, %v, %#v", plainItems, terminal, failures)
}
desc := chatMessageTimeRange{start: &start, end: &end, order: "desc"}
filtered, terminal, failures := desc.filter(items)
if len(filtered) != 1 || filtered[0]["openMessageId"] != "inside" || !terminal || len(failures) != 1 {
t.Fatalf("descending filter = %#v, %v, %#v", filtered, terminal, failures)
}
asc := chatMessageTimeRange{start: &start, end: &end, order: "asc"}
filtered, terminal, failures = asc.filter(items[1:3])
if len(filtered) != 1 || filtered[0]["openMessageId"] != "inside" || !terminal || failures != nil {
t.Fatalf("ascending filter = %#v, %v, %#v", filtered, terminal, failures)
}
filtered, terminal, failures = desc.filter(items[1:2])
if len(filtered) != 1 || terminal || failures != nil {
t.Fatalf("clean filter = %#v, %v, %#v", filtered, terminal, failures)
}
fractionalStart, _ := parseDingTalkMessageTime("2026-08-03T00:00:00+08:00")
fractionalEnd, _ := parseDingTalkMessageTime("2026-08-03T00:00:00.500+08:00")
fractionalRange := chatMessageTimeRange{start: &fractionalStart, end: &fractionalEnd, order: "desc"}
fractionalItems := []map[string]any{
{"openMessageId": "inside-fraction", "createTime": "2026-08-03T00:00:00.250+08:00"},
{"openMessageId": "end-fraction", "createTime": "2026-08-03T00:00:00.500+08:00"},
}
filtered, terminal, failures = fractionalRange.filter(fractionalItems)
if len(filtered) != 1 || filtered[0]["openMessageId"] != "inside-fraction" || terminal || failures != nil {
t.Fatalf("fractional filter = %#v, %v, %#v", filtered, terminal, failures)
}
}
func TestCrossPlatformCoverageSearchMessageQueryRangeNumericInputs(t *testing.T) {
start := time.Date(2026, 8, 1, 0, 0, 0, 0, dingTalkMessageLocation).UnixMilli()
end := time.Date(2026, 8, 2, 0, 0, 0, 0, dingTalkMessageLocation).UnixMilli()
queryRange := searchMessageQueryRange(map[string]any{
"startTime": int(start),
"endTime": end,
}, "asc")
if queryRange["order"] != "asc" || queryRange["semantics"] != messageTimeRangeSemantics || queryRange["startTime"] == nil || queryRange["endTime"] == nil {
t.Fatalf("query range = %#v", queryRange)
}
for _, value := range []any{int64(start), int(start), float64(start), json.Number("1785513600000")} {
if parsed, ok := numericMillis(value); !ok || parsed != start {
t.Fatalf("numericMillis(%T) = %d, %v", value, parsed, ok)
}
}
for _, value := range []any{json.Number("bad"), "1785513600000", nil} {
if parsed, ok := numericMillis(value); ok || parsed != 0 {
t.Fatalf("invalid numericMillis(%T) = %d, %v", value, parsed, ok)
}
}
queryRange = searchMessageQueryRange(map[string]any{"startTime": "bad", "endTime": true}, "desc")
if len(queryRange) != 2 {
t.Fatalf("invalid numeric query range = %#v", queryRange)
}
}
+192 -29
View File
@@ -14,16 +14,23 @@
package smart
import (
"fmt"
"strconv"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
const (
myGroupsDefaultPageLimit = 50
myGroupsHardPageLimit = 500
)
// MyGroups: list the groups I've joined and project just the key fields
// (会话id / 名称 / 群主 / 人数 / 类型) into a clean, composed payload — instead of
// paging through `chat group list-all` and squinting at the raw MCP response.
@@ -79,48 +86,204 @@ var MyGroups = shortcut.Shortcut{
},
Flags: []shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200)", Default: "200"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 200);--limit 必须在 1-200 之间", Default: "200"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-200 之间"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Tips: []string{
`dws chat +my-groups`,
`dws chat +my-groups --type group`,
`dws chat +my-groups --page-all --page-limit 50`,
},
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — list my groups. `limit` mirrors chat.go's list_my_groups_pagination
// call site (chat group list-all); pass a generous page size.
params := map[string]any{"limit": rt.Int("limit")}
if cursor := strings.TrimSpace(rt.Str("cursor")); cursor != "" && cursor != "0" {
params["cursor"] = cursor
Validate: validateMyGroups,
Execute: executeMyGroups,
}
func validateMyGroups(rt *shortcut.RuntimeContext) error {
if limit := rt.Int("limit"); limit < 1 || limit > 200 {
return apperrors.NewValidation("--limit 必须在 1-200 之间")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if limit := rt.Int("page-limit"); limit < 1 || limit > myGroupsHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
func executeMyGroups(rt *shortcut.RuntimeContext) error {
// Step 1 — list my groups. `limit` mirrors chat.go's list_my_groups_pagination
// call site (chat group list-all); pass a generous page size.
params := map[string]any{"limit": rt.Int("limit")}
if cursor := strings.TrimSpace(rt.Str("cursor")); cursor != "" && cursor != "0" {
params["cursor"] = cursor
}
if rt.Bool("page-all") {
payload, err := readAllMyGroups(rt, params)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
return err
}
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
if err != nil {
return err
}
payload := myGroupsPayload(rt, myGroupsExtract(data))
chatmsg.ApplyPagination(payload, data)
payload["pagesFetched"] = 1
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else {
payload["stopReason"] = "single_page"
}
return rt.Output(payload)
}
func myGroupsPayload(rt *shortcut.RuntimeContext, groups []map[string]any) map[string]any {
typeFilter := strings.TrimSpace(rt.Str("type"))
projected := make([]map[string]any, 0, len(groups))
for _, group := range groups {
row := myGroupsProject(group)
if typeFilter != "" {
groupType, _ := row["type"].(string)
if !strings.EqualFold(strings.TrimSpace(groupType), typeFilter) {
continue
}
}
projected = append(projected, row)
}
return map[string]any{"count": len(projected), "groups": projected}
}
func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (map[string]any, error) {
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), myGroupsDefaultPageLimit)
cursorValue := baseParams["cursor"]
cursorKey := myGroupsCursorString(cursorValue)
if cursorKey == "" {
cursorKey = "0"
}
seenCursors := map[string]bool{cursorKey: true}
seenGroups := map[string]bool{}
allGroups := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
complete := false
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
var nextCursor any
for pagesFetched < pageLimit {
params := map[string]any{"limit": baseParams["limit"]}
if cursorKey != "0" {
params["cursor"] = cursorValue
}
data, err := rt.CallMCPData("im", "list_my_groups_pagination", params)
if err != nil {
return err
}
// Step 2 — project key fields defensively.
groups := myGroupsExtract(data)
typeFilter := strings.TrimSpace(rt.Str("type"))
projected := make([]map[string]any, 0, len(groups))
for _, g := range groups {
row := myGroupsProject(g)
if typeFilter != "" {
gt, _ := row["type"].(string)
if !strings.EqualFold(strings.TrimSpace(gt), typeFilter) {
continue
}
if pagesFetched == 0 {
return nil, err
}
projected = append(projected, row)
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "cursor": cursorKey, "error": err.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
pageGroups := myGroupsExtract(data)
for _, group := range pageGroups {
id := myGroupsStr(group, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id")
if id != "" && seenGroups[id] {
continue
}
if id != "" {
seenGroups[id] = true
}
allGroups = append(allGroups, group)
}
payload := map[string]any{
"count": len(projected),
"groups": projected,
page := chatmsg.Pagination(data)
pageHasMore, known := page["hasMore"].(bool)
if !known {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "我的群列表下层未返回可靠的 hasMore,无法证明结果完整",
})
stopReason = "pagination_error"
break
}
chatmsg.ApplyPagination(payload, data)
return rt.Output(payload)
},
hasMore = pageHasMore
if !hasMore {
complete = true
nextCursor = nil
stopReason = "source_complete"
break
}
nextCursor = page["nextCursor"]
nextKey := myGroupsCursorString(nextCursor)
if nextKey == "" || seenCursors[nextKey] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "我的群列表下层返回 hasMore=true,但 nextCursor 缺失、无效或未前进",
})
stopReason = "pagination_error"
break
}
seenCursors[nextKey] = true
cursorKey = nextKey
cursorValue = nextCursor
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := myGroupsPayload(rt, allGroups)
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = true
payload["complete"] = complete && len(failures) == 0
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allGroups) > 0
if hasMore && nextCursor != nil {
payload["nextCursor"] = nextCursor
}
if len(failures) == 0 {
return payload, nil
}
return payload, apperrors.NewAPI(
fmt.Sprintf("我的群列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_my_groups_pagination"),
apperrors.WithReason("my_groups_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
func myGroupsCursorString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" || text == "<nil>" || text == "0" {
return ""
}
return text
}
// myGroupsExtract walks a list_my_groups_pagination response and returns its
@@ -5,13 +5,82 @@
package smart
import (
"bytes"
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageNaturalSearchAndSenderFlagsStayPublicOptional(t *testing.T) {
tests := []struct {
name string
flags []shortcut.Flag
want []string
}{
{name: "search natural adapters", flags: SearchMsg.Flags, want: []string{"chat-query", "sender-query"}},
{name: "chat messages natural adapters", flags: ChatMessages.Flags, want: []string{"chat-query", "sender-query"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
for _, name := range tc.want {
found := false
for _, flag := range tc.flags {
if flag.Name != name {
continue
}
found = true
if flag.Hidden {
t.Fatalf("--%s unexpectedly became hidden", name)
}
if flag.Required {
t.Fatalf("--%s unexpectedly became required", name)
}
break
}
if !found {
t.Fatalf("public optional flag --%s is missing", name)
}
}
})
}
}
func TestCrossPlatformCoverageMessageTimeAlignmentFlagsStayPublicOptional(t *testing.T) {
for _, tc := range []struct {
name string
flags []shortcut.Flag
}{
{name: "search message", flags: SearchMsg.Flags},
{name: "conversation message", flags: ChatMessages.Flags},
} {
t.Run(tc.name, func(t *testing.T) {
for _, name := range []string{"start", "start-time", "end", "end-time", "order", "sort"} {
found := false
for _, flag := range tc.flags {
if flag.Name != name {
continue
}
found = true
if flag.Hidden || flag.Required {
t.Fatalf("--%s must stay public and optional: %#v", name, flag)
}
if (name == "order" || name == "sort") && !reflect.DeepEqual(flag.Enum, []string{"asc", "desc"}) {
t.Fatalf("--%s enum = %#v", name, flag.Enum)
}
break
}
if !found {
t.Fatalf("public optional flag --%s is missing", name)
}
}
})
}
}
func TestCrossPlatformCoverageChatMessagesResolvesNaturalChatAndUserTargets(t *testing.T) {
tests := []struct {
name string
@@ -76,6 +145,103 @@ func TestCrossPlatformCoverageChatMessagesStableGroupBypassesNaturalResolution(t
}
}
func TestCrossPlatformCoverageChatMessagesOptionallyFiltersResolvedSenderByEitherStableID(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[{"name":"测试用户甲","userId":"uid-fixture-sender-1","openDingTalkId":"D1"}]}`,
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"m-open","sender":"群昵称甲","senderOpenDingTalkId":"D1","content":"open id match"},
{"openMessageId":"m-user","sender":"群昵称甲","senderUserId":"uid-fixture-sender-1","content":"user id match"},
{"openMessageId":"m-other","sender":"其他人","senderOpenDingTalkId":"D2","content":"other"}
]}}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "测试用户甲", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
fake.calls[1].tool != "search_contact_by_key_word" {
t.Fatalf("calls = %#v, want message read followed by optional sender resolve", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if payload["complete"] != true || payload["count"] != float64(2) {
t.Fatalf("filtered payload = %#v", payload)
}
filters, ok := payload["resolvedFilters"].(map[string]any)
if !ok {
t.Fatalf("resolvedFilters missing: %#v", payload)
}
senders, ok := filters["senders"].([]any)
if !ok || len(senders) != 1 {
t.Fatalf("resolved senders = %#v", filters["senders"])
}
selected := senders[0].(map[string]any)["selected"].(map[string]any)
if selected["userId"] != "uid-fixture-sender-1" || selected["openDingTalkId"] != "D1" {
t.Fatalf("selected identity = %#v", selected)
}
messages, ok := payload["messages"].([]any)
if !ok || len(messages) != 2 {
t.Fatalf("messages = %#v", payload["messages"])
}
for _, raw := range messages {
message := raw.(map[string]any)
if message["sender"] != "群昵称甲" || message["senderId"] == "D2" {
t.Fatalf("unexpected filtered message = %#v", message)
}
}
}
func TestCrossPlatformCoverageChatMessagesSenderResolutionFailureKeepsUnfilteredMessages(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[]}`,
chatMessagesResult: `{"result":{"hasMore":false,"messages":[
{"openMessageId":"m1","sender":"群昵称甲","senderOpenDingTalkId":"D1","content":"仍然返回"}
]}}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+chat-messages", "--group", "cid-fixture-chat-0001",
"--sender-query", "不存在的人", "--page-all",
})
if err := root.Execute(); err != nil {
t.Fatalf("optional sender failure stopped message read: %v", err)
}
if len(fake.calls) != 2 || fake.calls[0].tool != "list_conversation_message_v2" ||
fake.calls[1].tool != "search_contact_by_key_word" {
t.Fatalf("calls = %#v, want message read followed by non-blocking failed resolve", fake.calls)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
if payload["count"] != float64(1) || payload["complete"] != false ||
payload["partial"] != true || payload["failedCount"] != float64(1) ||
payload["stopReason"] != "sender_resolution_failed" {
t.Fatalf("unfiltered fallback payload = %#v", payload)
}
if _, exists := payload["resolvedFilters"]; exists {
t.Fatalf("failed resolution unexpectedly published resolvedFilters: %#v", payload)
}
failures, ok := payload["failures"].([]any)
if !ok || len(failures) != 1 || failures[0].(map[string]any)["stage"] != "sender_resolution" {
t.Fatalf("resolution failures = %#v", payload["failures"])
}
}
func TestCrossPlatformCoverageChatMessagesNaturalUserAmbiguityStopsBeforeMessageRead(t *testing.T) {
fake := &platformCoverageCaller{contactSearchResult: `{"result":[{"name":"张三","userId":"u1","openDingTalkId":"D1"},{"name":"张三","userId":"u2","openDingTalkId":"D2"}]}`}
helpers.InitDeps(fake)
@@ -172,6 +338,47 @@ func TestCrossPlatformCoverageSearchMsgResolvesNaturalChatAndSenderBeforeSearch(
}
}
func TestCrossPlatformCoverageSearchMsgPreservesResolvedSenderWhenDisplayNameDiffers(t *testing.T) {
fake := &platformCoverageCaller{
contactSearchResult: `{"result":[{"name":"测试用户甲","userId":"uid-fixture-sender-1","openDingTalkId":"D1"}]}`,
searchMessagesResult: `{"result":{"messages":[{"openMessageId":"m1","openConversationId":"cid-1","sender":"群昵称甲","senderOpenDingTalkId":"D1","content":"评测消息"}],"hasMore":false}}`,
}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+search-msg", "--sender-query", "测试用户甲", "--no-enrich"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatalf("decode output: %v\n%s", err, output.String())
}
filters, ok := payload["resolvedFilters"].(map[string]any)
if !ok {
t.Fatalf("resolvedFilters missing: %#v", payload)
}
senders, ok := filters["senders"].([]any)
if !ok || len(senders) != 1 {
t.Fatalf("resolved senders = %#v", filters["senders"])
}
resolution := senders[0].(map[string]any)
selected := resolution["selected"].(map[string]any)
if resolution["query"] != "测试用户甲" || selected["openDingTalkId"] != "D1" {
t.Fatalf("sender resolution = %#v", resolution)
}
messages, ok := payload["messages"].([]any)
if !ok || len(messages) != 1 {
t.Fatalf("messages = %#v", payload["messages"])
}
message := messages[0].(map[string]any)
if message["sender"] != "群昵称甲" || message["senderId"] != "D1" {
t.Fatalf("projected message = %#v", message)
}
}
func TestCrossPlatformCoverageSearchMsgAcceptsStableIDInChatQuery(t *testing.T) {
fake := &platformCoverageCaller{}
helpers.InitDeps(fake)
@@ -0,0 +1,306 @@
// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
package smart
import (
"bytes"
"encoding/json"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageAtMePageAllUsesOpaqueCursorAndDeduplicates(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[{"openConversationId":"cid","title":"群","messages":[{"openMessageId":"m2"},{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`,
`{"result":{"conversationMessagesList":[{"openConversationId":"cid","title":"群","messages":[{"openMessageId":"m1"},{"openMessageId":"m0"}]}],"hasMore":false}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+at-me", "--limit", "2", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || caller.args[0]["cursor"] != "0" || caller.args[1]["cursor"] != "cursor-2" {
t.Fatalf("calls = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(3) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["hasMore"] != false || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
if len(payload["items"].([]any)) != 3 {
t.Fatalf("compatibility items = %#v", payload["items"])
}
}
func TestCrossPlatformCoverageAtMePageAllFailsClosedWithoutContinuation(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+at-me", "--page-all"})
if err := root.Execute(); err == nil {
t.Fatal("missing @me continuation unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["complete"] != false || payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageAtMePageAllContinuesAcrossEmptyIntermediatePage(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[],"hasMore":true,"nextCursor":"cursor-2"}}`,
`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":false}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+at-me", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) || payload["complete"] != true {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMerge(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[{"openConversationId":"g1","title":"群一","groupType":"group"}],"hasMore":true,"nextCursor":88}}`,
`{"result":{"groups":[{"openConversationId":"g1","title":"重复群","groupType":"group"},{"openConversationId":"g2","title":"单聊","groupType":"p2p"}],"hasMore":false}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+my-groups", "--type", "group", "--page-all", "--page-limit", "5"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || caller.args[1]["cursor"] != float64(88) {
t.Fatalf("calls = %#v", caller.args)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) ||
payload["complete"] != true || payload["failedCount"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+at-me", "--page-limit", "2"},
{"chat", "+at-me", "--page-all", "--page-limit", "501"},
{"chat", "+my-groups", "--limit", "201"},
{"chat", "+my-groups", "--page-limit", "2"},
{"chat", "+my-groups", "--page-all", "--page-limit", "501"},
} {
helpers.InitDeps(&chatMessagesPagingCaller{})
root := newPlatformCoverageRoot()
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("invalid args succeeded: %v", args)
}
}
}
func TestCrossPlatformCoverageMyGroupsAdditionalEdges(t *testing.T) {
run := func(t *testing.T, caller *chatMessagesPagingCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+my-groups"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("single-page outcomes", func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[],"hasMore":false}}`,
}}, "--cursor", "cursor-1")
if err != nil || payload["complete"] != true || payload["stopReason"] != "source_complete" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
payload, err = run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`,
}})
if err != nil || payload["complete"] != false || payload["stopReason"] != "single_page" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if _, err = run(t, &chatMessagesPagingCaller{failAt: 1}); err == nil {
t.Fatal("single-page read failure unexpectedly succeeded")
}
})
t.Run("all-page failures", func(t *testing.T) {
if _, err := run(t, &chatMessagesPagingCaller{failAt: 1}, "--page-all"); err == nil {
t.Fatal("first-page failure unexpectedly succeeded")
}
payload, err := run(t, &chatMessagesPagingCaller{
responses: []string{`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`},
failAt: 2,
}, "--page-all")
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
responses []string
args []string
stop string
wantErr bool
}{
{name: "unknown pagination", responses: []string{`{"result":{"groups":[]}}`}, args: []string{"--page-all"}, stop: "pagination_error", wantErr: true},
{name: "missing continuation", responses: []string{`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true}}`}, args: []string{"--page-all"}, stop: "pagination_error", wantErr: true},
{name: "stalled continuation", responses: []string{
`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`,
`{"result":{"groups":[{"openConversationId":"g2"}],"hasMore":true,"nextCursor":2}}`,
}, args: []string{"--page-all"}, stop: "pagination_error", wantErr: true},
{name: "page limit", responses: []string{`{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":2}}`}, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: tc.responses}, tc.args...)
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("output failures", func(t *testing.T) {
helpers.InitDeps(&chatMessagesPagingCaller{responses: []string{`{"result":{"groups":[],"hasMore":false}}`}})
for _, args := range [][]string{{"chat", "+my-groups"}, {"chat", "+my-groups", "--page-all"}} {
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatalf("output failure swallowed for %v", args)
}
}
})
for _, value := range []any{nil, "", "0", "<nil>", 0} {
if got := myGroupsCursorString(value); got != "" {
t.Fatalf("cursor %v = %q", value, got)
}
}
}
func TestCrossPlatformCoverageAtMeAdditionalEdges(t *testing.T) {
run := func(t *testing.T, caller *chatMessagesPagingCaller, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs(append([]string{"chat", "+at-me"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
var payload map[string]any
if decodeErr := json.Unmarshal(output.Bytes(), &payload); decodeErr != nil {
t.Fatal(decodeErr)
}
return payload, err
}
t.Run("single page terminal and read failure", func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[],"hasMore":false}}`,
}})
if err != nil || payload["complete"] != true || payload["stopReason"] != "source_complete" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
_, err = run(t, &chatMessagesPagingCaller{failAt: 1})
if err == nil {
t.Fatal("single-page read failure unexpectedly succeeded")
}
})
t.Run("all-page read failures", func(t *testing.T) {
_, err := run(t, &chatMessagesPagingCaller{failAt: 1}, "--page-all")
if err == nil {
t.Fatal("first all-page read failure unexpectedly succeeded")
}
payload, err := run(t, &chatMessagesPagingCaller{
responses: []string{`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}`},
failAt: 2,
}, "--page-all")
if err == nil || payload["partial"] != true || payload["stopReason"] != "read_failure" {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
for _, tc := range []struct {
name string
responses []string
args []string
wantErr bool
stop string
}{
{name: "unknown pagination", responses: []string{`{"result":{"conversationMessagesList":[]}}`}, args: []string{"--page-all"}, wantErr: true, stop: "pagination_error"},
{name: "page limit", responses: []string{`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}`}, args: []string{"--page-all", "--page-limit", "1"}, stop: "page_limit"},
{name: "empty cursor defaults", responses: []string{`{"result":{"conversationMessagesList":[],"hasMore":false}}`}, args: []string{"--cursor=", "--page-all"}, stop: "source_complete"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: tc.responses}, tc.args...)
if (err != nil) != tc.wantErr || payload["stopReason"] != tc.stop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if tc.stop == "page_limit" && payload["nextCursor"] != "next" {
t.Fatalf("continuation payload=%#v", payload)
}
})
}
t.Run("output errors propagate", func(t *testing.T) {
helpers.InitDeps(&chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[],"hasMore":false}}`,
}})
root := newPlatformCoverageRoot()
root.SetOut(chatMessagesFailWriter{})
root.SetArgs([]string{"chat", "+at-me"})
if err := root.Execute(); err == nil {
t.Fatal("output error was swallowed")
}
})
for _, value := range []any{nil, "", "0", "<nil>"} {
if got := atMeCursorString(value); got != "" {
t.Fatalf("cursor %v = %q", value, got)
}
}
}
+57 -27
View File
@@ -38,8 +38,8 @@ var SearchMsg = shortcut.Shortcut{
Service: "chat",
Command: "+search-msg",
Product: "im",
Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。" +
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
Intent: "当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。" +
"--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用安全本地路径、默认不覆盖和原子落盘。",
Risk: shortcut.RiskRead,
@@ -55,16 +55,16 @@ var SearchMsg = shortcut.Shortcut{
CLIPath: "chat +search-msg",
PrimaryCLIPath: "chat +search-msg",
},
Description: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
Description: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed search adapter: it combines filters, cursor pagination, batched mget enrichment, stable projection, completeness accounting, and optional safe resource downloads.",
},
Selection: contract.SelectionSpec{
AgentSummary: "按稳定 ID 和内容等条件跨会话搜索消息,可全量翻页并批量富化",
UseWhen: []string{"当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;会话与发送者过滤使用稳定 ID。默认查询近 7 天,也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"},
AvoidWhen: []string{"只想读取一个已知会话的连续历史时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
AgentSummary: "按发送者、关键词、@对象、会话或消息类型等条件搜索消息,可限定单个、多个或全部会话",
UseWhen: []string{"当你要按发送者、关键词、@对象、消息类型、机器人来源或会话范围等条件搜索 IM 消息时使用;搜索范围可为单个、多个或全部会话,会话与发送者的公开过滤参数使用稳定 ID。默认查询近 7 天,也可指定精确起止时间及输出顺序。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。--download-resources 使用安全本地路径、默认不覆盖和原子落盘。"},
AvoidWhen: []string{"只想查看或导出一个指定会话的消息记录、且没有发送者、关键词、@对象或消息类型等主要筛选条件时使用 +chat-messages;已有精确消息 ID 时使用 +messages-mget"},
Examples: []string{
"dws chat +search-msg --query \"周报\" --senders <openDingTalkId> --days 3 --page-all",
"dws chat +search-msg --group <openConversationId> --message-type file --download-resources --output-dir ./downloads",
@@ -81,10 +81,10 @@ var SearchMsg = shortcut.Shortcut{
{Name: "id", Type: shortcut.FlagString, Desc: "--group 的别名", Hidden: true},
{Name: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"},
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"},
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名解析会话的兼容入口", Hidden: true},
{Name: "chat-query", Type: shortcut.FlagStringSlice, Desc: "按群名唯一解析会话过滤条件(可选,可重复或逗号分隔)"},
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名解析发送者的兼容入口", Hidden: true},
{Name: "sender-query", Type: shortcut.FlagStringSlice, Desc: "按姓名唯一解析发送者过滤条件(可选,可重复或逗号分隔)"},
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
{Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"},
{Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"},
@@ -93,8 +93,12 @@ var SearchMsg = shortcut.Shortcut{
{Name: "conversation-type", Type: shortcut.FlagString, Desc: "下层会话类型过滤值(以当前 IM Schema 为准)"},
{Name: "chat-type", Type: shortcut.FlagString, Desc: "--conversation-type 的 lark-cli 对齐别名"},
{Name: "days", Type: shortcut.FlagInt, Desc: "默认时间窗的回溯天数", Default: "7"},
{Name: "start", Type: shortcut.FlagString, Desc: "精确开始时间(RFC3339,需与 --end 一起传)"},
{Name: "end", Type: shortcut.FlagString, Desc: "精确结束时间(RFC3339,需与 --start 一起传)"},
{Name: "start", Type: shortcut.FlagString, Desc: "精确开始时间(RFC3339,需与 --end/--end-time 一起传)"},
{Name: "start-time", Type: shortcut.FlagString, Desc: "--start 的 lark-cli 对齐别名(RFC3339,需与 --end/--end-time 一起传)"},
{Name: "end", Type: shortcut.FlagString, Desc: "精确结束时间(RFC3339,需与 --start/--start-time 一起传)"},
{Name: "end-time", Type: shortcut.FlagString, Desc: "--end 的 lark-cli 对齐别名(RFC3339,需与 --start/--start-time 一起传)"},
{Name: "order", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "按消息创建时间稳定排列输出 asc/desc(可选,默认 desc)"},
{Name: "sort", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "--order 的 lark-cli 对齐别名(可选)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(1-100)", Default: "100"},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的 lark-cli 对齐别名(1-100)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
@@ -112,14 +116,17 @@ var SearchMsg = shortcut.Shortcut{
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"start"},
Description: "需与 --end 一起传",
Flags: []string{"start", "start-time"},
Description: "需与 --end/--end-time 一起传",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"end"},
Description: "需与 --start 一起传",
Flags: []string{"end", "end-time"},
Description: "需与 --start/--start-time 一起传",
},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"start", "start-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"end", "end-time"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"order", "sort"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"groups", "chat-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"at-me", "is-at-me"}},
@@ -134,7 +141,7 @@ var SearchMsg = shortcut.Shortcut{
},
Validate: validateSearchMsgWithResources,
Execute: func(rt *shortcut.RuntimeContext) error {
params, err := searchMsgParams(rt)
params, resolvedFilters, err := searchMsgParams(rt)
if err != nil {
return err
}
@@ -229,6 +236,11 @@ var SearchMsg = shortcut.Shortcut{
}
}
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
if order == "" {
order = "desc"
}
sortMessagesByCreateTimeStable(messages, order)
results := make([]map[string]any, 0, len(messages))
for _, m := range messages {
results = append(results, searchMsgProjectWithReactions(m, !rt.Bool("no-reactions")))
@@ -245,6 +257,10 @@ var SearchMsg = shortcut.Shortcut{
"paginationKnown": paginationKnown,
"failedCount": len(failures),
"failures": failures,
"queryRange": searchMessageQueryRange(params, order),
}
if len(resolvedFilters.Senders) > 0 {
payload["resolvedFilters"] = resolvedFilters
}
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
payload["nextCursor"] = nextCursor
@@ -281,8 +297,10 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
if !hasFilter {
return apperrors.NewValidation("至少指定一个过滤条件,例如 --query、--group、--senders、--at-me 或 --message-type")
}
if rt.Changed("start") != rt.Changed("end") {
return apperrors.NewValidation("--start 与 --end 必须同时指定")
startChanged := rt.Changed("start") || rt.Changed("start-time")
endChanged := rt.Changed("end") || rt.Changed("end-time")
if startChanged != endChanged {
return apperrors.NewValidation("--start/--start-time 与 --end/--end-time 必须同时指定")
}
if days := rt.Int("days"); days < 1 || days > 3650 {
return apperrors.NewValidation("--days 必须在 1-3650 之间")
@@ -297,8 +315,17 @@ func validateSearchMsg(rt *shortcut.RuntimeContext) error {
return nil
}
func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
// searchResolvedFilters preserves the natural sender facts applied to the
// lower search. Message sender names are display labels and may differ from
// the directory name, so consumers must join the selected stable identity to
// each projected message's senderId instead of comparing those two names.
type searchResolvedFilters struct {
Senders []targetresolver.UserResolution `json:"senders,omitempty"`
}
func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, searchResolvedFilters, error) {
params := map[string]any{"limit": rt.IntFirst("limit", "page-size")}
resolvedFilters := searchResolvedFilters{}
if value := rt.StrFirst("query", "keyword", "text", "text-query"); value != "" {
params["keyword"] = value
}
@@ -311,7 +338,7 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
for _, query := range queries {
resolved, err := targetresolver.ResolveChatTarget(rt, "", query)
if err != nil {
return nil, err
return nil, searchResolvedFilters{}, err
}
conversationIDs = append(conversationIDs, resolved.Selected.OpenConversationID)
}
@@ -324,8 +351,9 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
if queries := rt.StrSlice("sender-query"); len(queries) > 0 {
resolvedUsers, err := targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
if err != nil {
return nil, err
return nil, searchResolvedFilters{}, err
}
resolvedFilters.Senders = append(resolvedFilters.Senders, resolvedUsers...)
for _, resolved := range resolvedUsers {
identity := resolved.Selected.OpenDingTalkID
if identity == "" {
@@ -349,17 +377,19 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
params["searchConvType"] = value
}
if rt.Changed("start") && rt.Changed("end") {
start, err := time.Parse(time.RFC3339, rt.Str("start"))
startValue := rt.StrFirst("start", "start-time")
endValue := rt.StrFirst("end", "end-time")
if startValue != "" && endValue != "" {
start, err := time.Parse(time.RFC3339, startValue)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--start 必须是 RFC3339 时间: %v", err))
return nil, searchResolvedFilters{}, apperrors.NewValidation(fmt.Sprintf("--start/--start-time 必须是 RFC3339 时间: %v", err))
}
end, err := time.Parse(time.RFC3339, rt.Str("end"))
end, err := time.Parse(time.RFC3339, endValue)
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--end 必须是 RFC3339 时间: %v", err))
return nil, searchResolvedFilters{}, apperrors.NewValidation(fmt.Sprintf("--end/--end-time 必须是 RFC3339 时间: %v", err))
}
if !end.After(start) {
return nil, apperrors.NewValidation("--end 必须晚于 --start")
return nil, searchResolvedFilters{}, apperrors.NewValidation("--end 必须晚于 --start")
}
params["startTime"] = start.UnixMilli()
params["endTime"] = end.UnixMilli()
@@ -368,7 +398,7 @@ func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
params["startTime"] = now.AddDate(0, 0, -rt.Int("days")).UnixMilli()
params["endTime"] = now.UnixMilli()
}
return params, nil
return params, resolvedFilters, nil
}
func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) {
@@ -11,6 +11,7 @@ import (
"errors"
"reflect"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
@@ -22,6 +23,8 @@ type searchMsgExecutionCaller struct {
failEnrichment bool
omitPagination bool
omitMgetItem bool
firstResponse string
mgetResponse string
}
func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -40,6 +43,9 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m2","content":"sparse-2"}],"hasMore":false}}`), nil
}
if f.firstResponse != "" {
return searchMsgToolResult(f.firstResponse), nil
}
return searchMsgToolResult(`{"result":{"messages":[{"openMessageId":"m1","content":"sparse-1"}],"hasMore":true,"nextCursor":"c2"}}`), nil
case "list_messages_by_ids":
if f.failEnrichment {
@@ -48,6 +54,9 @@ func (f *searchMsgExecutionCaller) CallTool(_ context.Context, product, tool str
if f.omitMgetItem {
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"}]}`), nil
}
if f.mgetResponse != "" {
return searchMsgToolResult(f.mgetResponse), nil
}
return searchMsgToolResult(`{"result":[{"openMessageId":"m1","content":"detail-1"},{"openMessageId":"m2","content":"detail-2"}]}`), nil
default:
return nil, errors.New("unexpected tool")
@@ -84,7 +93,7 @@ func executeSearchMsg(t *testing.T, caller *searchMsgExecutionCaller, args ...st
return payload
}
func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
caller := &searchMsgExecutionCaller{}
payload := executeSearchMsg(t, caller,
"--query", "周报",
@@ -142,7 +151,7 @@ func TestSearchMsgPagesAndEnrichesWithAdvancedFilters(t *testing.T) {
}
}
func TestSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
caller := &searchMsgExecutionCaller{failSecondPage: true}
payload := executeSearchMsg(t, caller,
"--query", "周报",
@@ -160,7 +169,7 @@ func TestSearchMsgLaterPageFailurePublishesPartialLedger(t *testing.T) {
}
}
func TestSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
caller := &searchMsgExecutionCaller{failEnrichment: true}
payload := executeSearchMsg(t, caller, "--query", "周报")
if payload["complete"] != false || payload["count"] != float64(1) ||
@@ -169,7 +178,7 @@ func TestSearchMsgEnrichmentFailureKeepsSearchHits(t *testing.T) {
}
}
func TestSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
caller := &searchMsgExecutionCaller{omitPagination: true}
payload := executeSearchMsg(t, caller, "--query", "周报", "--no-enrich")
if payload["complete"] != false || payload["count"] != float64(1) ||
@@ -183,7 +192,7 @@ func TestSearchMsgMissingPaginationCannotClaimComplete(t *testing.T) {
}
}
func TestSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
func TestCrossPlatformCoverageSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
caller := &searchMsgExecutionCaller{omitMgetItem: true}
payload := executeSearchMsg(t, caller, "--query", "周报", "--page-all")
if payload["complete"] != false || payload["count"] != float64(2) ||
@@ -199,3 +208,34 @@ func TestSearchMsgMissingMgetItemPublishesFailureLedger(t *testing.T) {
t.Fatalf("failure = %#v", failure)
}
}
func TestCrossPlatformCoverageSearchMsgLarkTimeAliasesAndAscendingOrder(t *testing.T) {
caller := &searchMsgExecutionCaller{
firstResponse: `{"result":{"messages":[{"openMessageId":"m2","createTime":1782892800000,"content":"later"},{"openMessageId":"m1","createTime":1782806400000,"content":"earlier"}],"hasMore":false}}`,
}
payload := executeSearchMsg(t, caller,
"--query", "周报",
"--start-time", "2026-07-01T00:00:00+08:00",
"--end-time", "2026-07-03T00:00:00+08:00",
"--sort", "asc",
"--no-enrich",
)
if len(caller.calls) != 1 {
t.Fatalf("calls = %#v", caller.calls)
}
wantStart, _ := time.Parse(time.RFC3339, "2026-07-01T00:00:00+08:00")
wantEnd, _ := time.Parse(time.RFC3339, "2026-07-03T00:00:00+08:00")
if caller.calls[0].args["startTime"] != wantStart.UnixMilli() ||
caller.calls[0].args["endTime"] != wantEnd.UnixMilli() {
t.Fatalf("time params = %#v", caller.calls[0].args)
}
messages := payload["messages"].([]any)
if messages[0].(map[string]any)["messageId"] != "m1" ||
messages[1].(map[string]any)["messageId"] != "m2" {
t.Fatalf("ascending messages = %#v", messages)
}
rangeMeta := payload["queryRange"].(map[string]any)
if rangeMeta["order"] != "asc" || rangeMeta["semantics"] != "[start,end)" {
t.Fatalf("queryRange = %#v", rangeMeta)
}
}
+459 -65
View File
@@ -14,25 +14,36 @@
package smart
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
chatshortcut "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chat"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
)
const (
threadRepliesDefaultPageLimit = 50
threadRepliesHardPageLimit = 500
)
// ThreadReplies: fetch every reply under one topic ("话题") message and print a
// clean projected list (speaker / text / time) instead of a raw dump.
//
// Steps:
// 1. call list_topic_replies (chat server) with openconversationId=--group and
// topicId=--topic-id, optionally startTime=--time and pageSize=--limit —
// param keys copied verbatim from chat.go's list-topic-replies call site;
// 2. defensively unwrap the reply list (multiple candidate container keys) and
// 1. accept either --group plus --thread-id/--topic-id, or resolve a root
// --message-id to its conversation/thread context through list_messages_by_ids;
// 2. call list_topic_replies (chat server), optionally with startTime=--time
// and pageSize=--limit;
// 3. defensively unwrap the reply list (multiple candidate container keys) and
// project each reply to {sender, text, createTime} tolerating field aliases;
// 3. print via rt.Output as {replies, count} so it honours --format/--jq/--fields.
// 4. print via rt.Output as {replies, count} so it honours --format/--jq/--fields.
//
// The default path only reads and reshapes topic replies;
// --download-resources additionally writes resource files locally.
@@ -42,10 +53,11 @@ var ThreadReplies = shortcut.Shortcut{
Service: "chat",
Command: "+thread-replies",
Product: "chat",
Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
Intent: "当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么)," +
"而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表," +
"可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。" +
Description: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页",
Intent: "当你已经拿到某个群里一条「话题消息」的主消息 ID 或 threadId/topicId、想快速看这条话题下的回复(谁在什么时间回复了什么)," +
"而不想拿到一大坨原始消息字段时使用;可直接传 --message-id 自动只读解析会话和 thread,也可传 --group 配合 --thread-id(兼容 --topic-id)," +
"可选 --time 指定手工续页边界、--limit/--page-size 指定每页条数;--page-all 沿下层毫秒级 nextCursor 自动续页,--page-limit 保持有界;--order/--sort 支持 desc,asc 需与 --page-all 一起使用," +
"结果用 complete、hasMore、nextPage、stopReason 和 failures 明确证明是否完整,再在本地投影出每条回复的发言人、文本和回复时间。" +
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
@@ -60,101 +72,483 @@ var ThreadReplies = shortcut.Shortcut{
CLIPath: "chat +thread-replies",
PrimaryCLIPath: "chat +thread-replies",
},
Description: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
Description: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页",
Interface: &contract.InterfaceSpec{
Mode: "composite",
Availability: "available",
Reason: "Reviewed thread reader adapter: it accepts threadId/topicId, reads lower topic replies, projects stable message fields, and optionally downloads reply resources safely.",
Reason: "Reviewed thread reader adapter: it accepts a root message ID or threadId/topicId, reads lower topic replies, projects stable message fields, orders complete results, and optionally downloads reply resources safely.",
},
Selection: contract.SelectionSpec{
AgentSummary: "拉取某条话题消息的全部回复并投影出发言人/文本/时间",
UseWhen: []string{"当你已经拿到某个群里一条「话题消息」的 threadId/topicId、想快速看这条话题下的全部回复(谁在什么时间回复了什么),而不想拿到一大坨原始消息字段时使用;内部按 --group(群会话 ID)和 --thread-id(兼容 --topic-id)拉取该话题的回复列表,可选 --time 指定起始时间、--limit 指定每页条数,再在本地投影出每条回复的发言人、文本和回复时间。默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"},
AvoidWhen: []string{"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"},
AgentSummary: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页",
UseWhen: []string{"当你已经拿到某个群里一条「话题消息」的主消息 ID 或 threadId/topicId、想快速看这条话题下的回复(谁在什么时间回复了什么)," +
"而不想拿到一大坨原始消息字段时使用;可直接传 --message-id 自动只读解析会话和 thread,也可传 --group 配合 --thread-id(兼容 --topic-id)," +
"可选 --time 指定手工续页边界、--limit/--page-size 指定每页条数;--page-all 沿下层毫秒级 nextCursor 自动续页,--page-limit 保持有界;--order/--sort 支持 desc,asc 需与 --page-all 一起使用," +
"结果用 complete、hasMore、nextPage、stopReason 和 failures 明确证明是否完整,再在本地投影出每条回复的发言人、文本和回复时间。" +
"默认只读且不会发送或修改任何消息;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。"},
AvoidWhen: []string{"要回复 Thread 或发送新回复时不要使用此读取入口;当前没有经过验证的 thread writer Shortcut"},
Examples: []string{
"dws chat +thread-replies --message-id <rootOpenMessageId> --page-all --order asc",
"dws chat +thread-replies --group <openConversationId> --thread-id <threadId>",
"dws chat +thread-replies --group <openConversationId> --topic-id <topicId> --download-resources --output-dir ./downloads",
},
},
},
Flags: append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID(openConversationId,必填)", Required: true},
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId)"},
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名"},
{Name: "group", Type: shortcut.FlagString, Desc: "群会话 ID;--thread-id/--topic-id 必须同时提供 --group;--group 与 --message-id 解析出的 conversationId 必须匹配"},
{Name: "message-id", Type: shortcut.FlagString, Desc: "话题主消息 openMessageId;自动只读解析 conversationId 和 threadId;--group 与 --message-id 解析出的 conversationId 必须匹配"},
{Name: "thread-id", Type: shortcut.FlagString, Desc: "话题/线程 ID(可直接使用消息列表返回的 threadId);--thread-id/--topic-id 必须同时提供 --group"},
{Name: "topic-id", Type: shortcut.FlagString, Desc: "--thread-id 的兼容别名;--thread-id/--topic-id 必须同时提供 --group"},
{Name: "time", Type: shortcut.FlagString, Desc: "起始时间,如 \"2025-03-01 00:00:00\";--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD(可选)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页拉取的回复条数;--limit 必须大于 0"},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的公开兼容别名;必须大于 0"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿下层毫秒级 nextCursor 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;asc 必须与 --page-all 一起使用"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "order", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "回复输出顺序 asc/desc(可选,默认 desc;asc 必须与 --page-all 一起使用)"},
{Name: "sort", Type: shortcut.FlagString, Enum: []string{"asc", "desc"}, Desc: "--order 的 lark-cli 对齐别名(可选;asc 必须与 --page-all 一起使用)"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出回复 reaction(默认输出)"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintExactlyOne,
Flags: []string{"thread-id", "topic-id"},
Description: "--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
Flags: []string{"message-id", "thread-id", "topic-id"},
Description: "--message-id、--thread-id 与兼容参数 --topic-id 必须且只能指定一个",
},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"order", "sort"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"group", "thread-id", "topic-id"}, Description: "--thread-id/--topic-id 必须同时提供 --group"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"group", "message-id"}, Description: "--group 与 --message-id 解析出的 conversationId 必须匹配"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"time"}, Description: "--time 必须是 RFC3339、YYYY-MM-DD HH:mm:ss 或 YYYY-MM-DD"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit", "page-size"}, Description: "显式页大小必须大于 0"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"order", "sort", "page-all"}, Description: "asc 必须与 --page-all 一起使用"},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +thread-replies --message-id <rootOpenMessageId> --page-all --order asc`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId>`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --time "2025-03-01 00:00:00" --limit 20`,
`dws chat +thread-replies --group <openconversationId> --thread-id <threadId> --page-size 50 --page-all --page-limit 20`,
},
Validate: validateThreadReplies,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — fetch the topic replies. Param keys (openconversationId /
// topicId / startTime / pageSize) are copied verbatim from chat.go's
// list-topic-replies call site.
params := map[string]any{
"openconversationId": rt.Str("group"),
"topicId": rt.StrFirst("thread-id", "topic-id"),
"forward": false,
}
if rt.Changed("time") && rt.Str("time") != "" {
params["startTime"] = rt.Str("time")
}
if rt.Changed("limit") && rt.Int("limit") > 0 {
params["pageSize"] = rt.Int("limit")
}
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
if err != nil {
return err
}
// Step 2 — defensively unwrap and project. Response shape has no
// contract, so probe multiple candidate container/field keys.
items := threadReplyItems(data)
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, projectChatMessageWithReactions(m, !rt.Bool("no-reactions")))
}
payload := map[string]any{
"replies": results,
"count": len(results),
}
chatmsg.ApplyMessagePagination(payload, data, items, "older")
if rt.Bool("download-resources") {
chatshortcut.AttachMessageResourceDownloads(
payload,
chatshortcut.DownloadMessageResources(rt, items, rt.Str("group")),
)
}
return rt.Output(payload)
},
Execute: executeThreadReplies,
}
func validateThreadReplies(rt *shortcut.RuntimeContext) error {
if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil {
return err
}
if rt.Changed("limit") && rt.Int("limit") <= 0 {
return localChatOptionError("invalid_page_size", "+thread-replies 的 --limit 必须大于 0", "--limit")
for _, name := range []string{"limit", "page-size"} {
if rt.Changed(name) && rt.Int(name) <= 0 {
return localChatOptionError("invalid_page_size", "+thread-replies 的 --"+name+" 必须大于 0", "--"+name)
}
}
if value := strings.TrimSpace(rt.Str("time")); value != "" && !validChatTime(value) {
return localChatOptionError("invalid_time_boundary", "+thread-replies 的 --time 格式无效", "--time")
}
if strings.TrimSpace(rt.Str("message-id")) == "" && strings.TrimSpace(rt.Str("group")) == "" {
return apperrors.NewValidation("--thread-id/--topic-id 必须同时提供 --group")
}
if threadRepliesOrder(rt) == "asc" && !rt.Bool("page-all") {
return apperrors.NewValidation("asc 必须与 --page-all 一起使用")
}
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > threadRepliesHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
return nil
}
func executeThreadReplies(rt *shortcut.RuntimeContext) error {
target, err := resolveThreadRepliesTarget(rt)
if err != nil {
return err
}
params := map[string]any{
"openconversationId": target.conversationID,
"topicId": target.threadID,
"forward": false,
}
if value := strings.TrimSpace(rt.Str("time")); value != "" {
params["startTime"] = value
}
if pageSize := rt.IntFirst("limit", "page-size"); pageSize > 0 {
params["pageSize"] = pageSize
}
var payload map[string]any
var items []map[string]any
err = nil
if rt.Bool("page-all") {
payload, items, err = collectAllThreadReplies(rt, params)
} else {
payload, items, err = collectOneThreadRepliesPage(rt, params)
}
applyThreadRepliesResultContract(payload, items, target, threadRepliesOrder(rt))
if err != nil {
if payload != nil {
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
}
return err
}
if rt.Bool("download-resources") {
chatshortcut.AttachMessageResourceDownloads(
payload,
chatshortcut.DownloadMessageResources(rt, items, target.conversationID),
)
}
return rt.Output(payload)
}
type threadRepliesTarget struct {
conversationID string
threadID string
resolvedFromMessageID string
}
func resolveThreadRepliesTarget(rt *shortcut.RuntimeContext) (threadRepliesTarget, error) {
messageID := strings.TrimSpace(rt.Str("message-id"))
if messageID == "" {
return threadRepliesTarget{
conversationID: strings.TrimSpace(rt.Str("group")),
threadID: strings.TrimSpace(rt.StrFirst("thread-id", "topic-id")),
}, nil
}
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{
"openMsgIds": []string{messageID},
})
if err != nil {
return threadRepliesTarget{}, err
}
var matched map[string]any
for _, item := range threadReplyItems(data) {
if threadRepliesString(chatmsg.MessageID(item)) == messageID {
matched = item
break
}
}
if matched == nil {
return threadRepliesTarget{}, apperrors.NewAPI(
"未找到指定的话题主消息,无法解析 thread",
apperrors.WithOperation("im/list_messages_by_ids"),
apperrors.WithReason("thread_root_message_not_found"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithHint("请确认 --message-id 是可读取的话题主消息 openMessageId"),
)
}
conversationID := threadRepliesString(chatmsg.ConversationID(matched))
threadID := threadRepliesString(chatmsg.ThreadID(matched))
if conversationID == "" || threadID == "" {
return threadRepliesTarget{}, apperrors.NewAPI(
"消息详情未返回 conversationId/threadId,无法读取话题回复",
apperrors.WithOperation("im/list_messages_by_ids"),
apperrors.WithReason("thread_context_missing"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithHint("请确认 --message-id 指向话题主消息,或改用 --group 与 --thread-id"),
)
}
if explicitGroup := strings.TrimSpace(rt.Str("group")); explicitGroup != "" && explicitGroup != conversationID {
return threadRepliesTarget{}, apperrors.NewValidation("--group 与 --message-id 解析出的 conversationId 不匹配")
}
return threadRepliesTarget{
conversationID: conversationID,
threadID: threadID,
resolvedFromMessageID: messageID,
}, nil
}
func threadRepliesOrder(rt *shortcut.RuntimeContext) string {
if order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort"))); order != "" {
return order
}
return "desc"
}
func applyThreadRepliesResultContract(payload map[string]any, items []map[string]any, target threadRepliesTarget, order string) {
if payload == nil {
return
}
if order == "asc" {
reverseThreadReplyMaps(items)
if replies, ok := payload["replies"].([]map[string]any); ok {
reverseThreadReplyMaps(replies)
}
}
payload["order"] = order
if payload["complete"] == true {
payload["orderScope"] = "complete_result"
} else {
payload["orderScope"] = "fetched_pages"
}
payload["conversationId"] = target.conversationID
payload["threadId"] = target.threadID
if target.resolvedFromMessageID != "" {
payload["resolvedFromMessageId"] = target.resolvedFromMessageID
}
}
func reverseThreadReplyMaps(items []map[string]any) {
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
items[left], items[right] = items[right], items[left]
}
}
func threadRepliesString(value any) string {
if value == nil {
return ""
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "<nil>" {
return ""
}
return text
}
func collectOneThreadRepliesPage(rt *shortcut.RuntimeContext, params map[string]any) (map[string]any, []map[string]any, error) {
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
if err != nil {
return nil, nil, err
}
items := threadReplyItems(data)
payload := newThreadRepliesPayload(items, !rt.Bool("no-reactions"))
applyOneThreadRepliesPagination(payload, data)
if payload["complete"] == true {
payload["stopReason"] = "source_complete"
} else if payload["failedCount"] != 0 {
payload["stopReason"] = "pagination_error"
} else {
payload["stopReason"] = "single_page"
}
return payload, items, nil
}
func applyOneThreadRepliesPagination(payload, data map[string]any) {
payload["pagesFetched"] = 1
page := chatmsg.Pagination(data)
hasMore, hasMoreKnown := page["hasMore"].(bool)
if !hasMoreKnown {
payload["paginationKnown"] = false
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层未返回可靠的 hasMore,无法证明话题回复完整",
}}
return
}
payload["paginationKnown"] = true
payload["hasMore"] = hasMore
payload["complete"] = !hasMore
if !hasMore {
return
}
_, boundary, err := threadRepliesNextCursorBoundary(page["nextCursor"])
if err != nil {
payload["paginationKnown"] = false
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "hasMore=true 但 nextCursor 无效,无法安全续页: " + err.Error(),
}}
return
}
payload["nextPage"] = map[string]any{
"direction": "older",
"time": boundary,
"nextCursor": page["nextCursor"],
}
}
func collectAllThreadReplies(rt *shortcut.RuntimeContext, params map[string]any) (map[string]any, []map[string]any, error) {
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), threadRepliesDefaultPageLimit)
seenIDs := map[string]bool{}
seenCursors := map[string]bool{}
allItems := make([]map[string]any, 0)
failures := make([]map[string]any, 0)
pagesFetched := 0
paginationKnown := true
complete := false
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
var nextPage map[string]any
for pagesFetched < pageLimit {
data, err := rt.CallMCPData("chat", "list_topic_replies", params)
if err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "read", "error": err.Error(),
})
stopReason = "read_failure"
break
}
pagesFetched++
rawItems := threadReplyItems(data)
for _, item := range rawItems {
stableID := chatmsg.StableMessageID(item)
if stableID != "" && seenIDs[stableID] {
continue
}
if stableID != "" {
seenIDs[stableID] = true
}
allItems = append(allItems, item)
}
page := chatmsg.Pagination(data)
pageHasMore, hasMoreKnown := page["hasMore"].(bool)
if !hasMoreKnown {
paginationKnown = false
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "下层未返回可靠的 hasMore,无法证明话题回复完整",
})
stopReason = "pagination_error"
break
}
hasMore = pageHasMore
if !pageHasMore {
complete = true
hasMore = false
nextPage = nil
stopReason = "source_complete"
break
}
if len(rawItems) == 0 {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "下层返回 hasMore=true 但当前页没有回复,无法证明分页可以安全推进",
})
stopReason = "pagination_error"
break
}
cursorKey, boundary, cursorErr := threadRepliesNextCursorBoundary(page["nextCursor"])
if cursorErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "hasMore=true 但 nextCursor 无效,无法安全续页: " + cursorErr.Error(),
})
stopReason = "pagination_error"
break
}
if seenCursors[cursorKey] {
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "hasMore=true 但 nextCursor 停滞",
})
stopReason = "pagination_error"
break
}
seenCursors[cursorKey] = true
nextPage = map[string]any{
"direction": "older",
"time": boundary,
"nextCursor": page["nextCursor"],
}
params["startTime"] = boundary
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := newThreadRepliesPayload(allItems, !rt.Bool("no-reactions"))
payload["pagesFetched"] = pagesFetched
payload["paginationKnown"] = paginationKnown
payload["complete"] = complete && len(failures) == 0
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allItems) > 0
if hasMore && nextPage != nil {
payload["nextPage"] = nextPage
}
if len(failures) > 0 {
failureStage := "pagination"
if stopReason == "read_failure" {
failureStage = "read"
}
return payload, allItems, apperrors.NewAPI(
fmt.Sprintf("话题回复全量读取未完成:%d 页成功,%d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("chat/list_topic_replies"),
apperrors.WithReason("thread_replies_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage(failureStage),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextPage 重试"),
)
}
return payload, allItems, nil
}
// threadRepliesNextCursorBoundary converts the authoritative millisecond
// cursor returned by list_topic_replies into the exact RFC3339 boundary that
// the same lower tool accepts as startTime. Message createTime is deliberately
// not used here: it is only second-precision and can skip replies when a page
// boundary splits several messages created within the same second.
func threadRepliesNextCursorBoundary(value any) (string, string, error) {
var millis int64
switch typed := value.(type) {
case int:
millis = int64(typed)
case int32:
millis = int64(typed)
case int64:
millis = typed
case float32:
asFloat := float64(typed)
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(asFloat)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = parsed
default:
return "", "", fmt.Errorf("缺少毫秒级分页游标")
}
if millis <= 0 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
cursorKey := strconv.FormatInt(millis, 10)
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
return cursorKey, boundary, nil
}
func newThreadRepliesPayload(items []map[string]any, includeReactions bool) map[string]any {
results := make([]map[string]any, 0, len(items))
for _, item := range items {
results = append(results, projectChatMessageWithReactions(item, includeReactions))
}
return map[string]any{
"contractVersion": chatmsg.MessageListContractVersion,
"replies": results,
"count": len(results),
"pagesFetched": 0,
"paginationKnown": false,
"complete": false,
"hasMore": false,
"failedCount": 0,
"failures": []map[string]any{},
"partial": false,
}
}
// threadReplyItems defensively unwraps the reply list from the response,
// tolerating the common container keys and one level of nesting under a
// "result"/"data" wrapper.
@@ -0,0 +1,492 @@
// 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 smart
import (
"bytes"
"encoding/json"
stderrors "errors"
"math"
"reflect"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
)
func TestCrossPlatformCoverageThreadRepliesResolvesRootMessageIDBeforeReadingReplies(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[{"openMessageId":"root-message","openConversationId":"cid","openConvThreadId":"thread","createTime":"2026-08-05 16:48:30"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"reply-1","createTime":"2026-08-05 19:46:00"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+thread-replies", "--message-id", "root-message"})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || !reflect.DeepEqual(caller.args[0]["openMsgIds"], []string{"root-message"}) ||
caller.args[1]["openconversationId"] != "cid" || caller.args[1]["topicId"] != "thread" ||
caller.args[1]["forward"] != false {
t.Fatalf("resolution calls = %#v", caller.args)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if payload["conversationId"] != "cid" || payload["threadId"] != "thread" ||
payload["resolvedFromMessageId"] != "root-message" || payload["order"] != "desc" ||
payload["orderScope"] != "complete_result" || payload["count"] != float64(1) {
t.Fatalf("resolved payload = %#v", payload)
}
}
func TestCrossPlatformCoverageThreadRepliesMessageResolutionFailsClosedWithoutThreadContext(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[{"openMessageId":"ordinary-message","openConversationId":"cid"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+thread-replies", "--message-id", "ordinary-message"})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI || typed.Reason != "thread_context_missing" {
t.Fatalf("error = %#v", err)
}
if len(caller.args) != 1 || !reflect.DeepEqual(caller.args[0]["openMsgIds"], []string{"ordinary-message"}) {
t.Fatalf("unexpected fallback calls = %#v", caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesMessageResolutionRejectsConversationMismatch(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[{"openMessageId":"root-message","openConversationId":"resolved-cid","openConvThreadId":"thread"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+thread-replies", "--message-id", "root-message", "--group", "different-cid",
})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryValidation {
t.Fatalf("error = %#v", err)
}
if len(caller.args) != 1 {
t.Fatalf("mismatch reached replies/contact fallback: %#v", caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllOrdersCompleteResultAscending(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m3","createTime":"2026-08-06 21:28:41"},{"openMessageId":"m2","createTime":"2026-08-06 21:28:40"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 19:46:00"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
"--page-all", "--sort", "asc",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
replies, _ := payload["replies"].([]any)
if len(replies) != 3 || replies[0].(map[string]any)["messageId"] != "m1" ||
replies[1].(map[string]any)["messageId"] != "m2" ||
replies[2].(map[string]any)["messageId"] != "m3" || payload["order"] != "asc" ||
payload["orderScope"] != "complete_result" || payload["complete"] != true {
t.Fatalf("ascending payload = %#v", payload)
}
}
func TestCrossPlatformCoverageThreadRepliesAscendingRequiresPageAll(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{`{"result":{"hasMore":false,"messages":[]}}`}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--order", "asc",
})
if err := root.Execute(); err == nil {
t.Fatal("single-page asc unexpectedly succeeded")
}
if len(caller.args) != 0 {
t.Fatalf("invalid asc reached lower read: %#v", caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesThreadSelectorRequiresGroup(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{`{"result":{"hasMore":false,"messages":[]}}`}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+thread-replies", "--thread-id", "thread"})
if err := root.Execute(); err == nil {
t.Fatal("thread selector without group unexpectedly succeeded")
}
if len(caller.args) != 0 {
t.Fatalf("invalid selector reached lower read: %#v", caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllUsesMillisecondCursorAndDeduplicates(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m2","createTime":"2026-08-06 21:28:40","content":"同秒第一条"},{"openMessageId":"m1","createTime":"2026-08-06 21:28:40","content":"同秒第二条"}]}}`,
`{"result":{"hasMore":false,"messages":[{"openMessageId":"m1","createTime":"2026-08-06 21:28:40","content":"重复边界"},{"openMessageId":"m0","createTime":"2026-08-06 21:28:39","content":"下一页"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
"--page-size", "2", "--page-all", "--page-limit", "5", "--no-reactions",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if len(caller.args) != 2 || caller.args[0]["pageSize"] != 2 ||
caller.args[0]["forward"] != false || caller.args[1]["startTime"] != "2026-08-06T13:28:39.361Z" {
t.Fatalf("pagination calls = %#v", caller.args)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if payload["complete"] != true || payload["hasMore"] != false ||
payload["count"] != float64(3) || payload["pagesFetched"] != float64(2) ||
payload["stopReason"] != "source_complete" || payload["failedCount"] != float64(0) {
t.Fatalf("all-page payload = %#v", payload)
}
replies, _ := payload["replies"].([]any)
if len(replies) != 3 || replies[0].(map[string]any)["messageId"] != "m2" ||
replies[1].(map[string]any)["messageId"] != "m1" ||
replies[2].(map[string]any)["messageId"] != "m0" {
t.Fatalf("deduplicated replies = %#v", replies)
}
}
func TestCrossPlatformCoverageThreadRepliesSinglePagePublishesMillisecondContinuation(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-06 21:28:39"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-size", "1",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
next, _ := payload["nextPage"].(map[string]any)
if payload["complete"] != false || payload["hasMore"] != true ||
payload["paginationKnown"] != true || payload["failedCount"] != float64(0) ||
payload["stopReason"] != "single_page" || next["time"] != "2026-08-06T13:28:39.361Z" ||
next["nextCursor"] != float64(1786022919361) {
t.Fatalf("single-page payload = %#v", payload)
}
}
func TestCrossPlatformCoverageThreadRepliesSinglePageFailsClosedWithoutCursor(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-08-06 21:28:39"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-size", "1",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if payload["complete"] != false || payload["hasMore"] != true ||
payload["paginationKnown"] != false || payload["failedCount"] != float64(1) ||
payload["stopReason"] != "pagination_error" || payload["nextPage"] != nil {
t.Fatalf("single-page missing-cursor payload = %#v", payload)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllAcceptsEmptyTerminalPage(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:20"}]}}`,
`{"result":{"hasMore":false,"messages":[]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
"--limit", "1", "--page-all", "--page-limit", "5",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if len(caller.args) != 2 || payload["complete"] != true || payload["hasMore"] != false ||
payload["count"] != float64(1) || payload["pagesFetched"] != float64(2) {
t.Fatalf("empty terminal page payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllPublishesBoundedContinuation(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:20"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
"--page-all", "--page-limit", "1",
})
if err := root.Execute(); err != nil {
t.Fatal(err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
next, _ := payload["nextPage"].(map[string]any)
if len(caller.args) != 1 || payload["complete"] != false || payload["hasMore"] != true ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" ||
next["time"] != "2026-08-06T13:28:39.361Z" || next["direction"] != "older" ||
next["nextCursor"] != float64(1786022919361) {
t.Fatalf("bounded payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllReturnsPartialLedgerOnLaterFailure(t *testing.T) {
caller := &chatMessagesPagingCaller{
responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-05 16:48:20"}]}}`,
},
failAt: 2,
}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-all",
})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Category != apperrors.CategoryAPI ||
typed.Reason != "thread_replies_incomplete" || !typed.Retryable ||
typed.ExecutionStarted == nil || !*typed.ExecutionStarted {
t.Fatalf("error = %#v", err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if len(caller.args) != 2 || payload["partial"] != true || payload["complete"] != false ||
payload["count"] != float64(1) || payload["pagesFetched"] != float64(1) ||
payload["failedCount"] != float64(1) || payload["stopReason"] != "read_failure" {
t.Fatalf("partial payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllFailsClosedWithoutCursor(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-all",
})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "thread_replies_incomplete" {
t.Fatalf("error = %#v", err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if len(caller.args) != 1 || payload["complete"] != false ||
payload["failedCount"] != float64(1) || payload["stopReason"] != "pagination_error" {
t.Fatalf("stalled payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesPageAllFailsClosedOnStalledCursor(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m2","createTime":"2026-08-06 21:28:40"}]}}`,
`{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[{"openMessageId":"m1","createTime":"2026-08-06 21:28:39"}]}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread", "--page-all",
})
err := root.Execute()
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "thread_replies_incomplete" {
t.Fatalf("error = %#v", err)
}
payload := decodeThreadRepliesPayload(t, output.Bytes())
if len(caller.args) != 2 || payload["complete"] != false ||
payload["count"] != float64(2) || payload["failedCount"] != float64(1) ||
payload["stopReason"] != "pagination_error" {
t.Fatalf("stalled cursor payload = %#v calls=%#v", payload, caller.args)
}
}
func TestCrossPlatformCoverageThreadRepliesNextCursorBoundaryValidation(t *testing.T) {
for _, test := range []struct {
name string
value any
wantKey string
wantTime string
wantError bool
}{
{name: "integer", value: int64(1786022919361), wantKey: "1786022919361", wantTime: "2026-08-06T13:28:39.361Z"},
{name: "native integer", value: int(1786022919361), wantKey: "1786022919361", wantTime: "2026-08-06T13:28:39.361Z"},
{name: "int32", value: int32(1234), wantKey: "1234", wantTime: "1970-01-01T00:00:01.234Z"},
{name: "float32", value: float32(1024), wantKey: "1024", wantTime: "1970-01-01T00:00:01.024Z"},
{name: "json number", value: float64(1786022919361), wantKey: "1786022919361", wantTime: "2026-08-06T13:28:39.361Z"},
{name: "string", value: "1786022919361", wantKey: "1786022919361", wantTime: "2026-08-06T13:28:39.361Z"},
{name: "missing", value: nil, wantError: true},
{name: "fractional", value: 1786022919361.5, wantError: true},
{name: "float32 nan", value: float32(math.NaN()), wantError: true},
{name: "float32 infinity", value: float32(math.Inf(1)), wantError: true},
{name: "zero", value: 0, wantError: true},
{name: "negative", value: -1, wantError: true},
{name: "opaque", value: "not-a-millisecond-cursor", wantError: true},
} {
t.Run(test.name, func(t *testing.T) {
key, boundary, err := threadRepliesNextCursorBoundary(test.value)
if test.wantError {
if err == nil {
t.Fatalf("threadRepliesNextCursorBoundary(%#v) = %q, %q, nil", test.value, key, boundary)
}
return
}
if err != nil || key != test.wantKey || boundary != test.wantTime {
t.Fatalf("threadRepliesNextCursorBoundary(%#v) = %q, %q, %v", test.value, key, boundary, err)
}
})
}
}
func TestCrossPlatformCoverageThreadRepliesAdditionalEdges(t *testing.T) {
run := func(t *testing.T, caller *chatMessagesPagingCaller, writer any, args ...string) (map[string]any, error) {
t.Helper()
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
if failWriter, ok := writer.(chatMessagesFailWriter); ok {
root.SetOut(failWriter)
} else {
root.SetOut(&output)
}
root.SetArgs(append([]string{"chat", "+thread-replies"}, args...))
err := root.Execute()
if output.Len() == 0 {
return nil, err
}
return decodeThreadRepliesPayload(t, output.Bytes()), err
}
t.Run("explicit time and single read failure", func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":false,"messages":[]}}`,
}}, nil, "--group", "cid", "--thread-id", "thread", "--time", "2026-08-06")
if err != nil || payload["complete"] != true {
t.Fatalf("payload=%#v err=%v", payload, err)
}
if _, err = run(t, &chatMessagesPagingCaller{failAt: 1}, nil,
"--group", "cid", "--thread-id", "thread"); err == nil {
t.Fatal("single-page read failure unexpectedly succeeded")
}
})
t.Run("root-message resolution failures", func(t *testing.T) {
if _, err := run(t, &chatMessagesPagingCaller{failAt: 1}, nil, "--message-id", "root"); err == nil {
t.Fatal("message-detail read failure unexpectedly succeeded")
}
if _, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[{"openMessageId":"other"}]}}`,
}}, nil, "--message-id", "root"); err == nil {
t.Fatal("missing root message unexpectedly succeeded")
}
})
for _, tc := range []struct {
name string
response string
wantStop string
}{
{name: "unknown pagination", response: `{"result":{"messages":[]}}`, wantStop: "pagination_error"},
{name: "empty continuation page", response: `{"result":{"hasMore":true,"nextCursor":1786022919361,"messages":[]}}`, wantStop: "pagination_error"},
} {
t.Run(tc.name, func(t *testing.T) {
payload, err := run(t, &chatMessagesPagingCaller{responses: []string{tc.response}}, nil,
"--group", "cid", "--thread-id", "thread", "--page-all")
if err == nil || payload["stopReason"] != tc.wantStop {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
}
t.Run("failure ledger output error", func(t *testing.T) {
_, err := run(t, &chatMessagesPagingCaller{responses: []string{
`{"result":{"messages":[]}}`,
}}, chatMessagesFailWriter{}, "--group", "cid", "--thread-id", "thread", "--page-all")
if err == nil || err.Error() != "fixture output failure" {
t.Fatalf("error=%v", err)
}
})
applyThreadRepliesResultContract(nil, nil, threadRepliesTarget{}, "desc")
if got := threadRepliesString("<nil>"); got != "" {
t.Fatalf("threadRepliesString(<nil>) = %q", got)
}
}
func TestCrossPlatformCoverageThreadRepliesPaginationValidationStopsBeforeRead(t *testing.T) {
for _, args := range [][]string{
{"--limit", "0"},
{"--page-size", "0"},
{"--limit", "1", "--page-size", "1"},
{"--page-limit", "2"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--page-limit", "501"},
} {
caller := &chatMessagesPagingCaller{responses: []string{`{"result":{"hasMore":false,"messages":[]}}`}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
root.SetArgs(append([]string{
"chat", "+thread-replies", "--group", "cid", "--thread-id", "thread",
}, args...))
if err := root.Execute(); err == nil {
t.Errorf("invalid pagination succeeded: %v", args)
}
if len(caller.args) != 0 {
t.Errorf("invalid pagination reached read: %v calls=%#v", args, caller.args)
}
}
}
func decodeThreadRepliesPayload(t *testing.T, raw []byte) map[string]any {
t.Helper()
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode thread replies payload: %v\n%s", err, raw)
}
return payload
}
+18 -2
View File
@@ -29,6 +29,7 @@ const (
const (
chatResolutionPageSize = 10
chatResolutionMaxWindowSize = 100
chatResolutionPageLimit = 40
userSearchDefaultResultLimit = 20
)
@@ -192,13 +193,15 @@ func ResolveChat(rt Reader, query string) (ChatResolution, error) {
)
}
cursor := "0"
requestPageSize := chatResolutionPageSize
maxWindowProbeUsed := false
seenCursors := map[string]bool{cursor: true}
chats := make([]Chat, 0)
complete := false
for pageNumber := 1; pageNumber <= chatResolutionPageLimit; pageNumber++ {
data, err := rt.CallMCPData("im", "search_groups", map[string]any{
"keyword": query,
"limit": chatResolutionPageSize,
"limit": requestPageSize,
"cursor": cursor,
})
if err != nil {
@@ -209,12 +212,25 @@ func ResolveChat(rt Reader, query string) (ChatResolution, error) {
page := extractChatPagination(data)
switch {
case page.hasMoreKnown && !page.hasMore:
if len(rows) >= requestPageSize && page.nextCursor == "" &&
(pageNumber == 1 || (maxWindowProbeUsed && pageNumber == 2)) {
if cursor == "0" && !maxWindowProbeUsed && requestPageSize < chatResolutionMaxWindowSize && pageNumber < chatResolutionPageLimit {
requestPageSize = chatResolutionMaxWindowSize
maxWindowProbeUsed = true
continue
}
return ChatResolution{}, newIncompleteChatResolutionError(
query,
dedupeChats(chats),
"群搜索返回满页并声明 hasMore=false,但没有可用 nextCursor,无法证明候选完整",
)
}
complete = true
case page.hasMoreKnown && page.hasMore:
// A usable cursor is required below.
case page.nextCursor != "":
// Some versions omit hasMore but still publish a continuation cursor.
case len(rows) < chatResolutionPageSize:
case len(rows) < requestPageSize:
// Compatibility for older responses that return a short bare array
// without pagination metadata.
complete = true
@@ -370,6 +370,31 @@ func TestCrossPlatformCoverageResolveChatFailsClosedWhenPaginationCannotAdvance(
}
}
func TestCrossPlatformCoverageResolveChatRejectsFullMaximumProbeWithoutCursor(t *testing.T) {
page := func(size int, prefix string) []any {
rows := make([]any, size)
for i := range rows {
rows[i] = map[string]any{
"openConversationId": fmt.Sprintf("%s-%d", prefix, i),
"title": fmt.Sprintf("项目群候选-%s-%d", prefix, i),
}
}
return rows
}
reader := &chatResolutionReader{responses: []map[string]any{
{"result": page(chatResolutionPageSize, "first"), "hasMore": false},
{"result": page(chatResolutionMaxWindowSize, "probe"), "hasMore": false},
}}
_, err := ResolveChat(reader, "项目群")
var typed *apperrors.Error
if !stderrors.As(err, &typed) || typed.Reason != "resolution_incomplete" {
t.Fatalf("error = %#v", err)
}
if len(reader.calls) != 2 || reader.calls[1]["limit"] != chatResolutionMaxWindowSize {
t.Fatalf("calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageResolveChatAcceptsShortLegacyPageWithoutPaginationMetadata(t *testing.T) {
reader := &chatResolutionReader{responses: []map[string]any{{
"result": []any{
@@ -382,6 +407,33 @@ func TestCrossPlatformCoverageResolveChatAcceptsShortLegacyPageWithoutPagination
}
}
func TestCrossPlatformCoverageResolveChatProbesMaximumWindowBeforeSelecting(t *testing.T) {
firstPage := make([]any, chatResolutionPageSize)
for i := range firstPage {
firstPage[i] = map[string]any{
"openConversationId": fmt.Sprintf("archive-%d", i),
"title": fmt.Sprintf("项目群-归档-%d", i),
}
}
reader := &chatResolutionReader{responses: []map[string]any{
{"result": map[string]any{"groups": firstPage, "hasMore": false}},
{"result": map[string]any{"groups": []any{
map[string]any{"openConversationId": "active", "title": "项目群"},
}, "hasMore": false}},
}}
resolved, err := ResolveChat(reader, "项目群")
if err != nil {
t.Fatal(err)
}
if resolved.Selected.OpenConversationID != "active" || resolved.MatchType != "exact" {
t.Fatalf("resolved = %#v", resolved)
}
if len(reader.calls) != 2 || reader.calls[0]["limit"] != chatResolutionPageSize ||
reader.calls[1]["limit"] != chatResolutionMaxWindowSize || reader.calls[1]["cursor"] != "0" {
t.Fatalf("calls = %#v", reader.calls)
}
}
func TestCrossPlatformCoverageResolutionErrorCarriesStructuredCandidates(t *testing.T) {
err := newResolutionError(StatusAmbiguous, "chat", "项目群", []Chat{
{OpenConversationID: "c1", Name: "项目群"},
+5 -3
View File
@@ -10,14 +10,16 @@ chat_skill="skills/multi/dingtalk-chat/SKILL.md"
event_skill="skills/multi/dingtalk-misc/references/event.md"
mono_skill="skills/mono/SKILL.md"
runtime_contract="skills/multi/dingtalk-shared/references/runtime-contract.md"
chat_max_bytes=10000
chat_target_bytes=10000
chat_max_overage_percent=5
chat_max_bytes=$((chat_target_bytes * (100 + chat_max_overage_percent) / 100))
event_max_bytes=10000
runtime_contract_max_bytes=3000
chat_bytes="$(wc -c < "$chat_skill" | tr -d ' ')"
if [ "$chat_bytes" -gt "$chat_max_bytes" ]; then
printf '%s\n' \
"skill context budget exceeded: $chat_skill is ${chat_bytes} bytes (max ${chat_max_bytes})" >&2
"skill context budget exceeded: $chat_skill is ${chat_bytes} bytes (target ${chat_target_bytes}, max ${chat_max_bytes} with ${chat_max_overage_percent}% allowance)" >&2
exit 1
fi
@@ -106,4 +108,4 @@ if grep -Fq "充分阅读产品参考文件" "$mono_skill"; then
fi
printf '%s\n' \
"skill context budget: ok (chat_bytes=$chat_bytes max=$chat_max_bytes event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
"skill context budget: ok (chat_bytes=$chat_bytes target=$chat_target_bytes max=$chat_max_bytes allowance=${chat_max_overage_percent}% event_bytes=$event_bytes event_max=$event_max_bytes runtime_contract_bytes=$runtime_contract_bytes runtime_contract_max=$runtime_contract_max_bytes shortcut_rows=$shortcut_rows)"
@@ -77,7 +77,7 @@
]
},
{
"id": "chat.search.cross-conversation",
"id": "chat.search.filtered",
"preferred_tool": "chat.shortcut_search_msg",
"allowed_fallbacks": [
{
+197 -16
View File
@@ -234,6 +234,100 @@ def lower_count(tool: str, raw: Any) -> int | None:
return None
def distinct_lower_message_count(
raw_calls: list[tuple[str, str, Any]], tool: str
) -> int | None:
"""Count unique messages across all pages without retaining their IDs.
Time-boundary pagination may repeat the boundary message on the following
page. Summing page lengths would therefore report a false projection
mismatch for an otherwise correct de-duplicating Shortcut.
"""
metrics = distinct_lower_message_metrics(raw_calls, tool)
return metrics[0] if metrics is not None else None
def distinct_lower_message_metrics(
raw_calls: list[tuple[str, str, Any]], tool: str
) -> tuple[int, int, int] | None:
"""Aggregate count/reaction/thread facts over one stable message-ID set."""
seen: set[str] = set()
reaction_messages: set[str] = set()
thread_messages: set[str] = set()
anonymous = 0
found_collection = False
identity_keys = ("openMessageId", "openMsgId", "messageId", "msgId", "id")
for _, call_tool, raw in raw_calls:
if call_tool != tool:
continue
rows: list[Any] | None = None
for path in LIST_PATHS_BY_TOOL.get(tool, ()):
rows = at_path(raw, path)
if rows is not None:
break
if rows is None:
continue
found_collection = True
for row in rows:
identity = ""
if isinstance(row, dict):
for key in identity_keys:
value = row.get(key)
if value not in (None, ""):
identity = f"id:{value}"
break
if not identity:
identity = f"anonymous:{anonymous}"
anonymous += 1
seen.add(identity)
if reaction_message_count(row, upper=False) > 0:
reaction_messages.add(identity)
if thread_message_count(row, upper=False) > 0:
thread_messages.add(identity)
if not found_collection:
return None
return len(seen), len(reaction_messages), len(thread_messages)
def distinct_lower_collection_count(
raw_calls: list[tuple[str, str, Any]],
tool: str,
identity_keys: tuple[str, ...],
) -> int | None:
"""Count unique rows across every lower page for one tool."""
seen: set[str] = set()
anonymous = 0
found_collection = False
for _, call_tool, raw in raw_calls:
if call_tool != tool:
continue
rows: list[Any] | None = None
for path in LIST_PATHS_BY_TOOL.get(tool, ()):
rows = at_path(raw, path)
if rows is not None:
break
if rows is None:
continue
found_collection = True
for row in rows:
if not isinstance(row, dict):
anonymous += 1
continue
identity = ""
for key in identity_keys:
value = row.get(key)
if value not in (None, ""):
identity = str(value)
break
if identity:
seen.add(identity)
else:
anonymous += 1
if not found_collection:
return None
return len(seen) + anonymous
def response_is_error(value: Any) -> bool:
if not isinstance(value, dict):
return False
@@ -384,6 +478,41 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
upper_count = count_projection_items(capture.stdout)
expected_lower_count = lower_count(tool, raw) if tool else None
pagination_raw = raw
lower_message_projection = raw
lower_message_metrics: tuple[int, int, int] | None = None
if capture.command == "+thread-replies" and tool == "list_topic_replies":
lower_message_metrics = distinct_lower_message_metrics(
capture.raw_calls, "list_topic_replies"
)
expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None
elif capture.command == "+at-me" and tool == "search_at_me_message":
lower_message_metrics = distinct_lower_message_metrics(
capture.raw_calls, "search_at_me_message"
)
expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None
elif (
capture.command == "+messages-list-direct"
and tool == "list_individual_chat_message"
):
lower_message_metrics = distinct_lower_message_metrics(
capture.raw_calls, "list_individual_chat_message"
)
expected_lower_count = lower_message_metrics[0] if lower_message_metrics else None
elif capture.command in {"+chat-list-all", "+my-groups"} and tool == (
"list_my_groups_pagination"
):
expected_lower_count = distinct_lower_collection_count(
capture.raw_calls,
"list_my_groups_pagination",
(
"openConversationId",
"openconversation_id",
"conversationId",
"openCid",
"cid",
"id",
),
)
if capture.command == "+chat-members-list":
counts = upper.get("counts", {}) if isinstance(upper, dict) else {}
upper_count = counts.get("total") if isinstance(counts, dict) else None
@@ -412,12 +541,26 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
exact_passthrough = bool(capture.raw_calls and upper == raw)
upper_message_projection = primary_projection_for_message_metrics(upper)
upper_reactions = reaction_message_count(upper_message_projection, upper=True)
lower_reactions = reaction_message_count(raw, upper=False)
upper_threads = thread_message_count(upper_message_projection, upper=True)
lower_threads = thread_message_count(raw, upper=False)
if lower_message_metrics is not None:
_, lower_reactions, lower_threads = lower_message_metrics
else:
lower_reactions = reaction_message_count(lower_message_projection, upper=False)
lower_threads = thread_message_count(lower_message_projection, upper=False)
upper_pagination = pagination_meta(upper)
lower_pagination = pagination_meta(pagination_raw)
upper_explicitly_incomplete = (
isinstance(upper, dict)
and upper.get("complete") is False
and (
upper.get("hasMore") is True
or bool(upper.get("failures"))
or bool(upper.get("truncatedByPageLimit"))
or bool(upper.get("truncatedByResultLimit"))
)
)
if capture.timed_out:
status = "timeout"
elif capture.exit_code != 0:
@@ -426,10 +569,22 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
status = "error_envelope"
elif not capture.raw_calls:
status = "no_lower_capture"
elif exact_passthrough:
status = "pass"
elif upper_explicitly_incomplete:
status = "incomplete"
elif expected_lower_count == 0 and upper_count in (None, 0):
# An empty collection is useful evidence, but it is not a successful
# non-empty business-data E2E. This must precede exact-passthrough so a
# raw empty list cannot be promoted to pass merely because projection
# preserved it byte-for-byte.
status = "pass_empty"
elif expected_lower_count is not None and upper_count != expected_lower_count:
status = "projection_mismatch"
elif upper_count == 0:
# The upper projection is empty but the lower response shape does not
# expose a countable collection, so correctness cannot be proven.
status = "projection_unverified"
elif exact_passthrough:
status = "pass"
elif lower_reactions > 0 and upper_reactions != lower_reactions:
status = "projection_mismatch"
elif lower_threads > 0 and upper_threads != lower_threads:
@@ -439,8 +594,6 @@ def summarize_capture(capture: Capture) -> dict[str, Any]:
or not upper_pagination.get("resume_present")
):
status = "pagination_mismatch"
elif expected_lower_count == 0 and upper_count == 0:
status = "pass_empty"
else:
status = "pass"
@@ -571,6 +724,7 @@ def live_audit(
open_task_override = os.environ.get("DWS_AUDIT_OPEN_TASK_ID", "").strip()
topic_group_override = os.environ.get("DWS_AUDIT_TOPIC_GROUP_ID", "").strip()
topic_id_override = os.environ.get("DWS_AUDIT_TOPIC_ID", "").strip()
topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip()
media_group_override = os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip()
media_message_override = os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip()
media_resource_override = os.environ.get("DWS_AUDIT_MEDIA_RESOURCE_ID", "").strip()
@@ -592,17 +746,19 @@ def live_audit(
return capture
category = run("+category-list")
groups_all = run("+chat-list-all", "--limit", "200")
groups_all = run(
"+chat-list-all", "--limit", "200", "--page-all", "--page-limit", "50"
)
groups_mine = run("+chat-list-mine", "--limit", "200")
run("+conversation-list", "--limit", "100")
run("+conversation-list-top", "--limit", "100")
run("+messages-list-unread-conversations", "--count", "100")
run("+unread-chats", "--count", "100")
run("+chat-list-join-requests", "--limit", "50")
run("+at-me", "--days", "30")
run("+at-me", "--days", "30", "--page-all", "--page-limit", "50")
run("+bot-search", "--page", "1", "--size", "100")
run("+bot-find", "--query", "机器人", "--limit", "20")
run("+my-groups")
run("+my-groups", "--page-all", "--page-limit", "50")
category_id = first_string(category.upper(), "categoryId", "category_id")
if category_id:
@@ -815,6 +971,9 @@ def live_audit(
"--forward=false",
"--limit",
"20",
"--page-all",
"--page-limit",
"50",
],
timeout,
)
@@ -904,17 +1063,32 @@ def live_audit(
"topicId",
)
if topic_id:
run(
"+thread-replies",
topic_args = [
"--group",
topic_group_override or group_id,
"--topic-id",
topic_id,
"--time",
time.strftime("%Y-%m-%d %H:%M:%S"),
"--limit",
"20",
)
]
if topic_page_size:
topic_args.extend(
[
"--limit",
topic_page_size,
"--page-all",
"--page-limit",
"50",
]
)
else:
topic_args.extend(
[
"--time",
time.strftime("%Y-%m-%d %H:%M:%S"),
"--limit",
"20",
]
)
run("+thread-replies", *topic_args)
else:
results["+thread-replies"] = blocked(
"+thread-replies",
@@ -1129,6 +1303,11 @@ def main() -> int:
]
if any(topic_override) and not all(topic_override):
parser.error("topic fixture override requires both group and topic IDs")
topic_page_size = os.environ.get("DWS_AUDIT_TOPIC_PAGE_SIZE", "").strip()
if topic_page_size and (
not topic_page_size.isdigit() or int(topic_page_size) < 1
):
parser.error("DWS_AUDIT_TOPIC_PAGE_SIZE must be a positive integer")
media_override = [
os.environ.get("DWS_AUDIT_MEDIA_GROUP_ID", "").strip(),
os.environ.get("DWS_AUDIT_MEDIA_MESSAGE_ID", "").strip(),
@@ -1165,6 +1344,7 @@ def main() -> int:
failures = {
"projection_mismatch",
"projection_unverified",
"pagination_mismatch",
"backend_error",
"cli_error",
@@ -1172,6 +1352,7 @@ def main() -> int:
"timeout",
"no_lower_capture",
"local_artifact_mismatch",
"incomplete",
}
return 1 if any(result["status"] in failures for result in results) else 0
@@ -4,8 +4,12 @@
from __future__ import annotations
from run_chat_shortcut_live_audit import (
Capture,
distinct_lower_collection_count,
distinct_lower_message_count,
primary_projection_for_message_metrics,
reaction_message_count,
summarize_capture,
thread_message_count,
)
@@ -41,11 +45,245 @@ def test_non_envelope_passthrough() -> None:
check("bare list passes through", primary_projection_for_message_metrics(rows) is rows)
def test_thread_reply_pages_are_deduplicated() -> None:
raw_calls = [
(
"chat",
"list_topic_replies",
{
"result": {
"hasMore": True,
"messages": [
{
"openMessageId": "m1",
"openConvThreadId": "thread-1",
"emotionReplyList": [{"type": "LIKE"}],
"createTime": "2026-08-05 16:48:20",
}
],
}
},
),
(
"chat",
"list_topic_replies",
{
"result": {
"hasMore": False,
"messages": [
{
"openMessageId": "m1",
"openConvThreadId": "thread-1",
"emotionReplyList": [{"type": "LIKE"}],
"createTime": "2026-08-05 16:48:20",
},
{"openMessageId": "m0", "createTime": "2026-08-05 16:47:20"},
],
}
},
),
]
check(
"boundary duplicate counted once",
distinct_lower_message_count(raw_calls, "list_topic_replies") == 2,
)
capture = Capture(
command="+thread-replies",
argv=[],
exit_code=0,
stdout=(
'{"replies":[{"messageId":"m1","threadId":"thread-1",'
'"reactions":[{"type":"LIKE"}]},{"messageId":"m0"}],'
'"count":2,"complete":true,"hasMore":false,"pagesFetched":2}'
),
stderr="",
raw_calls=raw_calls,
duration_ms=1,
)
summary = summarize_capture(capture)
check("multi-page projection passes", summary["status"] == "pass")
check("all lower calls reported", summary["lower"]["call_count"] == 2)
check("unique lower count reported", summary["lower"]["count"] == 2)
check("boundary reaction counted once", summary["lower"]["reaction_message_count"] == 1)
check("boundary thread counted once", summary["lower"]["thread_message_count"] == 1)
def test_group_pages_are_counted_across_all_lower_calls() -> None:
raw_calls = [
(
"im",
"list_my_groups_pagination",
{"result": {"groups": [{"openConversationId": "g1"}], "hasMore": True}},
),
(
"im",
"list_my_groups_pagination",
{
"result": {
"groups": [
{"openConversationId": "g1"},
{"openConversationId": "g2"},
],
"hasMore": False,
}
},
),
]
check(
"group pages deduplicated",
distinct_lower_collection_count(
raw_calls,
"list_my_groups_pagination",
("openConversationId", "conversationId", "id"),
)
== 2,
)
capture = Capture(
command="+chat-list-all",
argv=[],
exit_code=0,
stdout=(
'{"groups":[{"openConversationId":"g1"},'
'{"openConversationId":"g2"}],"count":2,'
'"complete":true,"hasMore":false,"pagesFetched":2}'
),
stderr="",
raw_calls=raw_calls,
duration_ms=1,
)
summary = summarize_capture(capture)
check("multi-page group projection passes", summary["status"] == "pass")
check("all group rows reported", summary["lower"]["count"] == 2)
def test_thread_reply_empty_terminal_page_passes() -> None:
raw_calls = [
(
"chat",
"list_topic_replies",
{
"result": {
"hasMore": True,
"messages": [
{"openMessageId": "m1", "createTime": "2026-08-05 16:48:20"}
],
}
},
),
("chat", "list_topic_replies", {"result": {"hasMore": False, "messages": []}}),
]
capture = Capture(
command="+thread-replies",
argv=[],
exit_code=0,
stdout=(
'{"replies":[{"messageId":"m1"}],"count":1,'
'"complete":true,"hasMore":false,"pagesFetched":2}'
),
stderr="",
raw_calls=raw_calls,
duration_ms=1,
)
summary = summarize_capture(capture)
check("empty terminal page passes", summary["status"] == "pass")
check("first-page item retained", summary["lower"]["count"] == 1)
def test_empty_projection_is_not_promoted_to_non_empty_pass() -> None:
capture = Capture(
command="+bot-search",
argv=[],
exit_code=0,
stdout='{"bots":[],"count":0}',
stderr="",
raw_calls=[("bot", "search_my_robots", {"result": {"robots": []}})],
duration_ms=1,
)
summary = summarize_capture(capture)
check("empty projected collection is explicit", summary["status"] == "pass_empty")
dropped_projection = Capture(
command="+bot-search",
argv=[],
exit_code=0,
stdout='{"bots":[],"count":0}',
stderr="",
raw_calls=[
("bot", "search_my_robots", {"result": {"robots": [{"robotId": "r1"}]}})
],
duration_ms=1,
)
summary = summarize_capture(dropped_projection)
check("non-empty lower projection cannot pass empty", summary["status"] == "projection_mismatch")
unknown_lower_shape = Capture(
command="+bot-search",
argv=[],
exit_code=0,
stdout='{"bots":[],"count":0}',
stderr="",
raw_calls=[("bot", "search_my_robots", {"result": {"unexpected": []}})],
duration_ms=1,
)
summary = summarize_capture(unknown_lower_shape)
check("unknown lower collection is not a success", summary["status"] == "projection_unverified")
no_upper_count = Capture(
command="+unread-chats",
argv=[],
exit_code=0,
stdout='{"result":[]}',
stderr="",
raw_calls=[
(
"chat",
"unread_message_conversation_list",
{"result": {"conversations": []}},
)
],
duration_ms=1,
)
summary = summarize_capture(no_upper_count)
check(
"lower-confirmed empty collection without upper count is explicit",
summary["status"] == "pass_empty",
)
def test_incomplete_projection_is_not_promoted_to_pass() -> None:
capture = Capture(
command="+thread-replies",
argv=[],
exit_code=0,
stdout=(
'{"replies":[{"messageId":"m1"}],"count":1,'
'"complete":false,"hasMore":true,"truncatedByPageLimit":true,'
'"pagesFetched":1,"nextPage":{"time":"2026-08-05T08:48:19.136Z"}}'
),
stderr="",
raw_calls=[(
"chat",
"list_topic_replies",
{"result": {"hasMore": True, "nextCursor": 1785919699136,
"messages": [{"openMessageId": "m1"}]}},
)],
duration_ms=1,
)
summary = summarize_capture(capture)
check("bounded partial read is explicit", summary["status"] == "incomplete")
def main() -> None:
tests = [
test_compatibility_aliases_are_counted_once,
test_projection_fallback_order,
test_non_envelope_passthrough,
test_thread_reply_pages_are_deduplicated,
test_group_pages_are_counted_across_all_lower_calls,
test_thread_reply_empty_terminal_page_passes,
test_empty_projection_is_not_promoted_to_non_empty_pass,
test_incomplete_projection_is_not_promoted_to_pass,
]
for test in tests:
print(f"{test.__name__}:")
+1
View File
@@ -15,6 +15,7 @@
| "这个 alidocs 表格链接帮我看下"(粘贴原始 URL) | 先 probe 节点类型 | `dws doc info --node` → 按 `extension` 路由 | 直接调 `sheet` | `alidocs/i/nodes/{id}` 可能是文档/axls/able/xlsx 等,禁止凭 URL 猜类型 |
| "读一下这个 xlsx 的数据" / xlsx 节点链接 | 下载本地表格文件 | `dws drive download --node` | `sheet range read` | xlsx / xls / xlsm / csv 是上传的本地文件(`contentType=DOCUMENT`),sheet 命令只支持在线表格,必须下载后本地解析 |
| "把这个在线表格导出为 xlsx 文件" | 在线表格格式转换 | `dws sheet export` | `dws drive download` | `export` 是 axls → xlsx 的导出转换;`download` 只能下载已有的 xlsx 节点 |
| "把这个 html 文件放到钉钉/导入 html 页面" | HTML 文件上钉 | `drive upload`(钉盘)或 `doc import`(文档空间/知识库,自动改走上传链路) | `doc read + doc create` 重写链 | `doc import` 对白名单外格式(html/pdf/zip 等)不报错,自动改走文件上传原样入库(stderr 有改道提示);用户明确要"可编辑在线文档"时先把 HTML 转 md 再 import |
| "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `doc` / `aitable` | 个人待办提醒,非文档内容或数据表 |
| "给自己留一个明天下午的时间块/建个个人日程" | 创建个人日程 | `calendar event create` | `todo` | 个人 schedule 仍属于日历事件,不是待办 |
| "帮我把这个文件传到网盘" | 钉盘上传 | `drive upload` | — | 文件上传是存储层操作,归 drive |
+2 -2
View File
@@ -1411,11 +1411,11 @@ Usage:
dws chat message list-favorites [flags]
Example:
dws chat message list-favorites
dws chat message list-favorites --size 50
dws chat message list-favorites --size 30
dws chat message list-favorites --cursor 20 --size 20
Flags:
--cursor int 数字分页游标,默认 0;翻页时传上次返回的 nextCursor
--size int 一次拉取的收藏数量,默认 20,范围 1-100
--size int 一次拉取的收藏数量,默认 20,范围 1-30
注意:
- 首次请求可省略分页参数,CLI 会自动向 Open 服务传入 cursor=0、size="20"
+2 -1
View File
@@ -142,7 +142,7 @@ Flags:
### 导入本地文件为在线文档
详见 [doc/doc-import.md](./doc/doc-import.md)。
详见 [doc/doc-import.md](./doc/doc-import.md)。白名单外格式(html/pdf/zip/无扩展名等)不报错,自动改走文件上传原样入库,JSON 结果带 `fallback=upload`、`converted=false` 标记。
```
Usage:
@@ -671,6 +671,7 @@ Flags:
用户说"导入文件/导入为在线文档/导入 Word/导入 Excel/导入 xmind/导入 Markdown/把本地文件转在线文档":
- 导入并转换为在线文档 → `doc import --file <本地路径>`
- 支持 docx/doc/xlsx/xls/md/txt/xmind/mark,文件大小不超过 20MB
- 白名单外格式(html/pdf/zip/无扩展名等)不报错:自动改走文件上传链路原样入库(stderr 有改道提示),JSON 结果带 `fallback=upload`、`converted=false` 标记,产出的是文件对象而非在线文档,不得报告为"已转换";要"可编辑在线文档"时先把内容转为 md 再导入
- 如果用户指定知识库或文件夹,补充 `--workspace` 或 `--folder`
- 不要把本地文件内容先读出来再 `doc create/update`;应直接使用 `doc import`
@@ -31,6 +31,8 @@ dws doc import get --task-id <TASK_ID> --format json
支持格式:docx、doc、xlsx、xls、md、txt、xmind、mark。文件大小上限 20MB。
不支持转换的格式(html/pdf/zip/无扩展名等所有白名单外格式)不报错:CLI 自动改走文件上传链路,以原文件形式存入 `--folder`/`--workspace` 指定的文档空间/知识库位置(同样受 20MB 上限约束),stderr 会输出改道提示,JSON 结果带 `fallback=upload`、`converted=false` 标记——表示产出的是文件对象而非在线文档,Agent 不得将其报告为"已转换"。若用户要的是"可编辑的在线文档",先把内容转换为 md,再用 md 文件执行 `doc import`;若目标是钉盘,直接用 `dws drive upload`。
## 工作流
1. 确认本地文件存在且格式受支持。
+7 -5
View File
@@ -42,13 +42,13 @@ metadata:
| <!-- dws-intent: chat.send.dm -->按姓名发简单文本或 Markdown | `dws chat +dm --to <姓名> --text <内容>` | CLI 解析唯一用户;多候选时停止,不先手工查 ID |
| <!-- dws-intent: chat.send.group -->按群名或 ID 发简单文本或 Markdown | `dws chat +send-to-group --group <群名或ID> --text <内容>` | 稳定 ID 直接使用;群名多候选时停止 |
| <!-- dws-intent: chat.send.advanced -->文件、Bot、Webhook、复杂 @ 或高级发送 | `dws chat +messages-send` | Bot 多群用 `--groups/--groups-file` 并检查逐项 ledger |
| <!-- dws-intent: chat.read.conversation -->读取指定群聊或单聊 | `dws chat +chat-messages` | 全量加 `--page-all`,检查完整性 ledger |
| <!-- dws-intent: chat.search.cross-conversation -->跨会话多维搜索 | `dws chat +search-msg` | 内容用 `--query`;检查 `complete` |
| <!-- dws-intent: chat.read.conversation -->读取指定会话、返回较多消息 | `dws chat +chat-messages` | 粗粒度读取;目标条件明确时优先 `+search-msg` |
| <!-- dws-intent: chat.search.filtered -->多维度条件搜索(发送者/关键词/@/类型,单/跨会话) | `dws chat +search-msg` | 目标条件明确时使用 |
| 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 |
| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 |
| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` |
| 修改群名称 | `dws chat group rename --id <openConversationId> --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` |
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名>` | 空结果仍返回数组 |
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 |
| 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` |
| 读取并下载消息资源 | 查询命令加 `--download-resources` | 不另起手工下载循环;下载失败项保留在结果中 |
| <!-- dws-intent: chat.conversation.list-top -->查看置顶会话 | `dws chat +conversation-list-top` | 会话 Top 与消息 Pin、消息 Top、Favorite 不同 |
@@ -60,11 +60,13 @@ metadata:
|---|---|
| 已知消息 ID 批量读取详情 | `dws chat +messages-mget` |
| 已知资源引用单独下载 | `dws chat +messages-resource-download` |
| 按关键词搜索群 | `dws chat +chat-search` |
| 查看消息收藏 | `dws chat +flag-list` |
| <!-- dws-intent: chat.reply.quote -->引用回复 | `dws chat +messages-reply`;成功结果保留新消息/会话/投递与原消息来源上下文 |
| 撤回当前用户消息 | `dws chat +messages-recall --msg-id <openMessageId>`;可省略会话 ID,由 CLI 只读补齐;兼容单值 `--message-ids` |
| 已知 thread/topic ID 读取回复 | `dws chat +thread-replies` |
| 已知话题主消息 ID 或 thread/topic ID 读取回复 | `dws chat +thread-replies` |
| <!-- dws-intent: chat.create.group -->按成员 ID 或姓名创建群聊 | `dws chat +chat-create`;成员/群主均可自然解析,任一歧义都会在创建前整体停止 |
| 跨全部会话查看 @我的消息 | `dws chat +at-me` |
| 跨全部会话查看 @我的消息 | `dws chat +at-me --page-all` |
### 发送入口边界
@@ -11,7 +11,13 @@
## 群聊消息
<!-- dws-intent: chat.read.conversation -->读取指定群聊或单聊默认使用 `dws chat +chat-messages`;它负责目标唯一解析、稳定消息投影和完整性 ledger。
<!-- dws-intent: chat.read.conversation -->读取或导出指定群聊/单聊的消息记录时使用 `dws chat +chat-messages`。可附带非必填的 `--sender-query <姓名>`:没有稳定 ID 时保留全部消息,唯一解析成功后按 userId/openDingTalkId 筛选同一次读取结果并返回 `resolvedFilters`,不要补跑 `+search-msg`。
用户以发送者、关键词、@对象或消息类型为主要条件直接检索时优先使用 `+search-msg`;范围可以是单个、多个或全部会话。
指定会话按时间读取时,`+chat-messages` 使用公开可选的 `--start/--end/--order`,范围固定为
`[start,end)`;仅开始时间表示到本次执行当前时间,仅结束时间只支持 `desc`,升序必须有开始时间。
兼容别名为 `--start-time/--end-time/--sort`。旧 `--time/--direction` 只用于单边界兼容模式,不能混用。
已知群 ID 时直接读取:
@@ -28,6 +34,12 @@ dws chat +chat-messages --group <openConversationId> \
--format json
```
```bash
dws chat +chat-messages --group <openConversationId> \
--start "2026-08-01T00:00:00+08:00" --end "2026-08-02T00:00:00+08:00" \
--order asc --page-all --format json
```
必须检查 `complete/hasMore/nextPage/stopReason/failures`;达到页数或结果上限不是来源完整。
只有群名时,读取历史直接用 `+chat-messages --group <群名>`,普通文本发送直接用 `+send-to-group`。其它尚不接受群名的高级动作才先用 `+chat-search --query <群名>`;只有唯一候选才把 `openConversationId` 传给下一步。查询结果需要资源时在读取命令上加 `--download-resources`,不要让 Agent 手工遍历资源引用。按姓名读取单聊时先解析唯一用户 ID,再传给 `+chat-messages --user`。
@@ -8,8 +8,8 @@
- <!-- dws-intent: chat.conversation.list-top -->查看置顶会话默认使用 `dws chat +conversation-list-top`;
原子 `list-top-conversations` 只在需要原始响应时作为 fallback。
- <!-- dws-intent: chat.read.conversation -->取得目标会话后读取消息默认使用 `dws chat +chat-messages`;
不要回到原子 `message list` 作为普通路线。
- <!-- dws-intent: chat.read.conversation -->取得目标会话后读取或导出消息记录,默认使用 `dws chat +chat-messages`;
可附带非必填的 `--sender-query` 解析姓名:无稳定 ID 返回全部,唯一解析成功后按 `senderId` 筛选同一次读取结果。不要回到原子 `message list`,也不要补跑 `+search-msg`。直接条件检索优先使用 `+search-msg`。
## 必读约束
@@ -35,12 +35,16 @@ dws chat conversation-info --open-dingtalk-id <openDingTalkId> --format json
| 命令 | 用途 | 参数 |
|------|------|------|
| `+conversation-list` | 获取当前用户会话 | 要求“全部”时加 `--page-all`;检查 `complete` / `failures` |
| `+chat-list` | 列出当前用户会话(默认群聊,可选单聊) | 默认只返回群聊;`--types group,p2p` 可包含单聊;要求全部时加 `--page-all`,合并去重后再过滤类型 |
| `+chat-list-all` | 获取当前用户加入的全部群 | 要求全部时加 `--page-all`;沿数字 `nextCursor` 去重聚合 |
| `+my-groups` | 获取并投影当前用户加入的群 | 要求全部时加 `--page-all`;读完后再应用 `--type` 本地过滤 |
| `+conversation-list-top` | 获取置顶会话列表 | 可选 `--limit` `--cursor` `--exclude-muted`;使用稳定 `conversations[]` |
| `message list-unread-conversations` | 获取未读会话列表 | 可选 `--count` `--exclude-muted` |
| `clear-red-point` | 清除指定会话红点 | `--conversation-id`,别名 `--id` / `--chat` |
| `clear-all-red-point` | 清除所有会话红点,一键全部已读 | 无参数 |
翻页时,`hasMore=true` 用返回的 `nextCursor` 作为下次 `--cursor`。
翻页时,`hasMore=true` 用返回的 `nextCursor` 作为下次 `--cursor`。Shortcut 全量读取应检查
`complete`、`stopReason` 和 `failures`;达到 `--page-limit` 时会保留可继续的 `nextCursor`。
### 会话置顶与通知
@@ -25,7 +25,7 @@
| 命令 | 用途 | 示例与要点 |
|------|------|------------|
| `+chat-search` | 按关键词搜索群聊 | `dws chat +chat-search --query "项目冲刺" --limit 20 --cursor 0`;多候选必须消歧 |
| `+chat-search` | 按关键词搜索群聊 | 默认一页;要求全部候选时用 `dws chat +chat-search --query "项目冲刺" --page-all`;可用 `--page-size/--page-token` 或兼容的 `--limit/--cursor`,并检查完整性 ledger;多候选必须消歧 |
| `chat search-common` | 搜索共同群 | `dws chat search-common --nicks "风雷,山乔" --match-mode AND --limit 20 --cursor 0` |
| `chat group get-by-group-id` | 数字群号转 openConversationId | `dws chat group get-by-group-id --group-id 12345678` |
| `+chat-bots` | 查看群内所有机器人 | `dws chat +chat-bots --group <群名或openConversationId>`;内部唯一解析自然群名 |
@@ -13,10 +13,10 @@
| <!-- dws-intent: chat.send.dm -->按姓名发送简单文本/Markdown | `dws chat +dm` |
| <!-- dws-intent: chat.send.group -->按群名发送简单文本/Markdown | `dws chat +send-to-group` |
| <!-- dws-intent: chat.send.advanced -->文件、Bot、Webhook、复杂 @、已知 ID 或幂等发送 | `dws chat +messages-send` |
| <!-- dws-intent: chat.read.conversation -->读取一个指定群聊或单聊 | `dws chat +chat-messages` |
| <!-- dws-intent: chat.search.cross-conversation -->跨会话、多条件或全量翻页搜索 | `dws chat +search-msg` |
| <!-- dws-intent: chat.read.conversation -->读取或导出指定群聊/单聊,可附带发送者姓名 | `dws chat +chat-messages`;姓名用非必填 `--sender-query` |
| <!-- dws-intent: chat.search.filtered -->直接按发送者、关键词、@对象或消息类型搜索,可限定单个或跨多个会话 | `dws chat +search-msg` |
| <!-- dws-intent: chat.reply.quote -->引用回复已有消息 | `dws chat +messages-reply` |
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名>` |
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` |
| 撤回当前用户消息 | `dws chat +messages-recall --msg-id <openMessageId>` |
以下原子命令只用于 Shortcut 未暴露的底层字段、原始响应或精确分页控制。不得把它们重新写成
@@ -36,6 +36,9 @@
- 建议发送时带 `--uuid`,失败重试复用同一个值。
- Bot/Webhook 只支持文本/Markdown;Bot 多群使用 `+messages-send --groups/--groups-file` 的逐项
ledger。不要把 user 文件/图片能力外推到 Bot。
- `+at-me` 和 `+messages-list-direct` 要求全量时使用 `--page-all`,并检查 `complete`、
`hasMore`、`stopReason` 和 `failures`;`+messages-list-direct` 的续页时间来自下层毫秒
`nextCursor`,不得用只有秒精度的消息展示时间手工拼接。
## 原子 fallback 命令明细
@@ -90,6 +93,25 @@ dws chat message send --group <openConversationId> --msg-type profile --contact-
`+messages-forward-topic` 转发;不要把它作为普通 `--group` 猜测写入。引用回复使用
`+messages-reply`,但这不等于 Thread 内新增回复。
读取话题回复可直接传话题主消息 `--message-id`,CLI 会先通过只读消息详情解析出
`conversationId/threadId`;也可显式传 `--group` 加 `--thread-id/--topic-id`。前一种模式如果同时传
`--group`,会校验它与消息解析出的会话一致;解析失败只会报错,不会错误转去查询通讯录。
默认只读一页;完整读取必须显式加 `--page-all`。可用
`--limit/--page-size` 控制每页条数、用 `--page-limit` 限制最大页数;自动续页使用下层返回的
毫秒级 `nextCursor` 无损生成下一次 `startTime`,不能使用只有秒级精度的回复 `createTime`。
输出默认 `--order desc`(兼容 `--sort`)。由于下层的 `newer/older` 表示读取方向而不是结果排序,
`asc` 只允许与 `--page-all` 一起使用:完整拉取后对整体结果升序排列,避免把单个“最新页”的本地反转
伪装为全局升序。结果中的 `orderScope=complete_result` 表示完整结果排序;读取被页数上限或错误截断时为
`fetched_pages`,并仍须结合完整性 ledger 判断。
必须检查 `complete`、`hasMore`、`stopReason` 和
`failures`,`complete=false` 时不得声称已经拿到全部回复。
```bash
dws chat +thread-replies --message-id <rootOpenMessageId> --page-all --order asc
dws chat +thread-replies --group <openConversationId> --thread-id <openConvThreadId> --page-all --page-limit 50
```
### 拉取消息的底层 fallback
默认使用 `dws chat +chat-messages`。群聊的 `--group` 可传群名或 openConversationId;
@@ -97,6 +119,32 @@ dws chat message send --group <openConversationId> --msg-type profile --contact-
`--max-results` 控制边界,用 `--output <相对.json>` 原子导出。只有需要原始响应或显式手工
continuation 时才使用下表;原子 `message list` 不代表自动全量分页。
可附带非必填的 `--sender-query <姓名>` 做读取后筛选。未传姓名时正常返回全部;姓名未解析出稳定 ID 时保留全部并记录失败;唯一解析出 userId/openDingTalkId 后按消息 `senderId` 筛选,覆盖最终 `messages/count` 并返回 `resolvedFilters`。该调用已经完成读取、解析与筛选,不要补跑 `+search-msg`。
时间范围参数同样公开但非必填:`--start`(包含)、`--end`(不包含)、`--order asc|desc`,
兼容别名为 `--start-time/--end-time/--sort`。范围固定为 `[start,end)`;仅开始时间表示到本次执行当前时间,
仅结束时间只支持 `desc`,`asc` 必须提供开始时间。旧 `--time/--direction` 保持兼容但不能和范围模式混用。
```bash
dws chat +chat-messages --group "项目群" --sender-query "测试用户甲" --page-all --format json
dws chat +chat-messages --group "项目群" --start "2026-08-01T00:00:00+08:00" --end "2026-08-02T00:00:00+08:00" --order asc --page-all --format json
```
当会话已经确定、任务只需要完整读取结果中可由消息字段判断的子集时,不新增按条件专用的
Shortcut 参数。在同一次 `+chat-messages` 调用中使用全局 `--jq`,让 Runtime 完成读取后、
在 stdout 前筛选;表达式必须保留根信封、用筛选结果覆盖 `messages` 并同步重算 `count`,
不得丢失 `complete`、`hasMore`、`failures` 等完整性 ledger。不要先输出全量 JSON,再由
Agent 或另一条命令二次处理。
```bash
# 例:读取完整会话后,只返回存在 reaction 的消息
dws chat +chat-messages --group "项目群" --page-all --format json \
--jq '. as $root | [.messages[] | select((.reactions // []) | length > 0)] as $matched | $root | .messages = $matched | .count = ($matched | length)'
```
发送者姓名不是普通结果字段条件:仍用 `--sender-query <姓名>` 先解析稳定身份,再按
`senderId` 筛选,不能用 `--jq` 对展示名做字符串匹配。
| 命令 | 用途 | 示例与要点 |
|------|------|------------|
| `message list` | 拉取指定群聊或单聊消息 | `dws chat message list --group <cid> --time "2025-03-01 00:00:00" --direction older`;目标三选一,`--direction newer/older` 优先于旧 `--forward` |
@@ -117,9 +165,29 @@ continuation 时才使用下表;原子 `message list` 不代表自动全量分
### 搜索消息的底层 fallback
默认使用 `dws chat +search-msg`;搜索内容使用 canonical `--query`,群名和发送者姓名分别使用
`--chat-query` / `--sender-query`。需要 Shortcut 未暴露的原始过滤字段或响应时,才评估
`message search-advanced`。它是原子 `message search` 的严格超集,但不是 Agent 高频默认入口。
直接按发送者、关键词、@对象或消息类型检索时优先使用 `dws chat +search-msg`;搜索范围可以是单个、多个或全部会话。若已选择 `+chat-messages` 读取指定会话,可由其非必填 `--sender-query` 在同一次调用完成姓名解析和筛选。
- 搜索内容使用公开参数 `--query`。
- 已知稳定会话 ID 使用 `--group` / `--groups`;已知稳定发送者 ID 使用 `--senders`。
- 只有群名时使用非必填参数 `--chat-query`,由 CLI 唯一解析会话。
- 只有发送者姓名时使用非必填参数 `--sender-query`,由 CLI 唯一解析人员。
- 不要把群名传给只接受稳定 ID 的会话参数,也不要把姓名传给 `--senders`。零命中或多候选时停止,不选择第一项。
- 不传会话过滤时搜索全部会话;`--page-all` 只翻完当前时间范围内的游标页,默认时间范围是最近 7 天。
- 精确范围使用成对的 `--start/--end`(兼容 `--start-time/--end-time`);`--order`(兼容 `--sort`)稳定排列本次实际取得的结果。未 `--page-all` 或 `complete=false` 时不能称为完整范围的全局排序。
需要 Shortcut 未暴露的原始过滤字段或响应时,才评估 `message search-advanced`。它是原子 `message search` 的严格超集,但不是 Agent 高频默认入口。
```bash
# 单群 + 发送者姓名
dws chat +search-msg --chat-query "项目群" --sender-query "测试用户甲" --page-all --format json
# 单群 + 关键词
dws chat +search-msg --chat-query "项目群" --query "发布计划" --page-all --format json
# 跨全部会话 + 发送者姓名
dws chat +search-msg --sender-query "测试用户甲" --page-all --format json
dws chat +search-msg --query "发布计划" --start-time "2026-08-01T00:00:00+08:00" --end-time "2026-08-02T00:00:00+08:00" --sort asc --page-all --format json
```
```bash
dws chat message search-advanced --query "周报" --start "2026-04-01T00:00:00+08:00" --end "2026-04-15T00:00:00+08:00"
@@ -220,8 +288,10 @@ dws chat +messages-update-card --biz-id <bizId> --content "最终内容" --flow-
| `message set-top-msg` / `unset-top-msg` | 置顶/取消置顶会话内某条消息 | `--open-conversation-id` `--msg-id` |
| `message add-favorite` | 收藏消息 | `--open-message-id` `--open-conversation-id` |
| `message remove-favorite` | 取消收藏消息 | `--open-message-id` `--open-conversation-id` |
| `message list-favorites` | 查询收藏消息列表 | 可选 `--cursor` `--size` |
| `+flag-list` | 查询收藏消息列表的默认 Shortcut | 默认一页;要求全部时加 `--page-all`;`--page-size/--size` 范围为 1-30,可用 `--page-token` 或兼容的 `--cursor` 续页,检查 `complete` / `failures` |
| `message list-favorites` | 原始单页收藏结果 fallback | 可选 `--cursor` `--size` |
`+flag-list` 查询钉钉 message favorite,底层使用数字 cursor;它与消息 Pin、消息 Top 和会话置顶属于不同对象层级。
消息置顶 `set-top-msg` 与会话置顶 `chat set-top` 不同:前者置顶会话内消息,后者置顶整个会话。
### 表情回应
@@ -12,11 +12,17 @@
<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->
- `version`: `im.message-list.v1`
- `message_fields`: `messageId`, `conversationId`, `threadId`, `sender`, `senderId`, `senderType`, `messageType`, `text`, `createTime`, `updateTime`, `reactions`, `quotedMessage`, `forwarded`, `resourceRefs`
- `envelope_fields`: `contractVersion`, `messages`, `count`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `resourceDownloads`
- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `resourceDownloads`
<!-- DWS_MESSAGE_RESULT_CONTRACT_END -->
当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary;
`failedCount/failures`、`partial` 和两个 truncated 字段必须原样保留。
当 Runtime 解析并应用自然发送者条件时,`resolvedFilters.senders[]` 保留原查询及选中的
`userId/openDingTalkId`。消息展示名可以与通讯录姓名不同;只能用稳定 `senderId` 与解析结果关联,
不得重新做姓名字符串比较。
当查询声明时间范围或顺序时,`queryRange` 保留规范化的 `startTime`、`endTime`、`order` 与
`semantics=[start,end)`。排序只覆盖本次实际取得的 `messages`;`complete=false` 时不得把它描述为完整范围的全局排序。
## `+messages-send` 身份矩阵
@@ -9,8 +9,8 @@
| 给姓名发简单文本 | `+dm` | 先查人再原子发送 |
| 给群名发简单文本 | `+send-to-group` | 先搜群再原子发送 |
| 文件、Bot、Webhook、复杂 @、幂等 | `+messages-send` | 为不同身份各走一套原子入口 |
| 浏览指定会话 | `+chat-messages` | 跨会话搜索命令 |
| 跨会话或多维过滤 | `+search-msg` | 单会话浏览命令 |
| 读取或导出指定会话,可附带发送者姓名 | `+chat-messages`;姓名用非必填 `--sender-query` | 无稳定 ID 返回全部,有则读后筛选;不补跑搜索 |
| 直接按发送者、关键词、@对象或消息类型搜索 | `+search-msg` | 条件检索优先,可限定单个或跨多个会话 |
| 已知消息 IDs 取详情 | `+messages-mget` | 重新搜索关键词 |
| @我的消息 | `+at-me` | 全量消息后本地猜测 @ |
| 已知 thread/topic ID 的回复 | `+thread-replies` | 普通消息列表 |
+1 -1
View File
@@ -55,7 +55,7 @@ metadata:
| `dws doc +fetch` | read | 读取完整或局部文档内容,并按 detail 控制保真度 |
| `dws doc +find-doc` | read | 按关键词搜索云文档并投影关键字段(只读) |
| `dws doc +grant-and-share` | write | 确保目标角色后按姓名逐人发送文档链接 |
| `dws doc +import` | write | 上传本地文件并等待转换成在线文档对象 |
| `dws doc +import` | write | 上传本地文件并等待转换成在线文档对象;白名单外格式自动改走文件上传原样入库 |
| `dws doc +inspect` | read | 聚合文档元信息,并按需附带样式、权限、历史、媒体和评论 |
| `dws doc +list` | read | 列出文件夹或知识库下的直接子节点 |
| `dws doc +media-download` | read | 安全下载文档正文附件到工作目录 |
@@ -5,6 +5,7 @@
> **支持的文件格式**:docx, doc, xlsx, xls, md, txt, xmind, mark
> **文件大小限制**:20MB
> **不支持转换的格式**:白名单外的所有格式(html/pdf/zip/无扩展名等)不报错,CLI 自动改走文件上传链路,以原文件形式存入 `--folder`/`--workspace` 指定的文档空间/知识库位置(同样受 20MB 上限约束);JSON 结果带 `fallback=upload`、`converted=false` 标记,表示产出文件对象而非在线文档;需要"可编辑在线文档"时先把内容转为 md 再导入;目标是钉盘时直接用 `dws drive upload`。
---
@@ -65,6 +66,7 @@ Flags:
- `import` 是一体化命令,一条命令自动完成创建会话→上传→确认→轮询,**无需手动编排**。CLI 内部使用渐进式退避轮询(最多约 5 分钟)。
- `import` 超时或中断后,CLI 会输出 `taskId`,可用 `dws doc import get --task-id <taskId>` 手动查询任务状态。
- 支持的文件格式:docx, doc, xlsx, xls, md, txt, xmind, mark(共 8 种)。
- 白名单外格式(html/pdf/zip/无扩展名等)不报错:自动改走文件上传链路,以原文件形式存入目标文档空间/知识库(stderr 有改道提示,同样受 20MB 上限约束),JSON 结果带 `fallback=upload`、`converted=false` 标记——返回文件对象而非在线文档,不得报告为"已转换";要"可编辑在线文档"时先转 md 再导入,目标是钉盘时用 `dws drive upload`。
- 文件大小限制:20MB。超过限制时 CLI 会直接报错,不会发起网络请求。
- `--folder` 和 `--workspace` 至少传一个,不传会报错。如需导入到"我的文档"根目录,请先获取 workspaceId:`dws wiki space list --type myWikiSpace --format json`,然后传 `--workspace <workspaceId>`。
- `--folder` 优先级高于 `--workspace`:两者都传时以 `--folder` 为准。