diff --git a/CHANGELOG.md b/CHANGELOG.md index 277cf601..629bbba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,12 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ### Changed +- **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. ### 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. @@ -149,6 +151,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. diff --git a/Makefile b/Makefile index c3124311..d388de88 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/docs/shortcut-lark-alignment.md b/docs/shortcut-lark-alignment.md index 302ae54a..6d40e790 100644 --- a/docs/shortcut-lark-alignment.md +++ b/docs/shortcut-lark-alignment.md @@ -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) diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index ad9215e9..1a4ed074 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -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 diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 0900d86f..8aff408d 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -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"}, diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index d8b4816c..65dd8e4e 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -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", diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index fe5b02f7..7fc3145d 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -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"}, diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 7db040a9..0003918e 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -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: 拉取我创建/管理的群 ────────────── diff --git a/internal/helpers/chat_favorites_test.go b/internal/helpers/chat_favorites_test.go index e66468d3..82fe7609 100644 --- a/internal/helpers/chat_favorites_test.go +++ b/internal/helpers/chat_favorites_test.go @@ -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 { diff --git a/internal/shortcut/chat/chat_group.go b/internal/shortcut/chat/chat_group.go index 3b596e06..215b3f87 100644 --- a/internal/shortcut/chat/chat_group.go +++ b/internal/shortcut/chat/chat_group.go @@ -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 != "" && seenChats[id] { + continue + } + if id != "" && id != "" { + 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 == "" || 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 == "" { + 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 == "" || text == "0" { + return "" + } + return text } // chatListAllProject reshapes list_my_groups_pagination into a clean group list diff --git a/internal/shortcut/chat/chat_list_cross_platform_coverage_test.go b/internal/shortcut/chat/chat_list_cross_platform_coverage_test.go index e2c0b215..1d2e615b 100644 --- a/internal/shortcut/chat/chat_list_cross_platform_coverage_test.go +++ b/internal/shortcut/chat/chat_list_cross_platform_coverage_test.go @@ -6,6 +6,8 @@ package chat import ( "bytes" "encoding/json" + "errors" + "fmt" "reflect" "testing" @@ -317,3 +319,276 @@ 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("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) + } + }) + } +} diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 352d454f..72328efd 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -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 --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 --time "2025-03-01 00:00:00"`, + `dws chat +messages-list-direct --open-dingtalk-id --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). diff --git a/internal/shortcut/chat/chat_search_pagination_test.go b/internal/shortcut/chat/chat_search_pagination_test.go new file mode 100644 index 00000000..f1ddf102 --- /dev/null +++ b/internal/shortcut/chat/chat_search_pagination_test.go @@ -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", ""} { + 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) + } +} diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index b19af0de..7edecd4e 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -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"}, } diff --git a/internal/shortcut/chat/flag_list_pagination_test.go b/internal/shortcut/chat/flag_list_pagination_test.go new file mode 100644 index 00000000..b9b99611 --- /dev/null +++ b/internal/shortcut/chat/flag_list_pagination_test.go @@ -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) + } + }) + } +} diff --git a/internal/shortcut/chat/lark_alignment.go b/internal/shortcut/chat/lark_alignment.go index 2ba9d4b6..62dc20bd 100644 --- a/internal/shortcut/chat/lark_alignment.go +++ b/internal/shortcut/chat/lark_alignment.go @@ -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, 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 != "" { + 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,231 @@ 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 := chatmsg.Pagination(data) + 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 +} + +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 { diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index f6cb0eed..91d91752 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -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": `{ diff --git a/internal/shortcut/chat/remaining_read_pagination_test.go b/internal/shortcut/chat/remaining_read_pagination_test.go new file mode 100644 index 00000000..821ba023 --- /dev/null +++ b/internal/shortcut/chat/remaining_read_pagination_test.go @@ -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", ""} { + 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) + } + }) + } +} diff --git a/internal/shortcut/chatmsg/chatmsg.go b/internal/shortcut/chatmsg/chatmsg.go index d2d6ee04..ed186909 100644 --- a/internal/shortcut/chatmsg/chatmsg.go +++ b/internal/shortcut/chatmsg/chatmsg.go @@ -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. diff --git a/internal/shortcut/chatmsg/chatmsg_test.go b/internal/shortcut/chatmsg/chatmsg_test.go index 63d0b797..c9f7f244 100644 --- a/internal/shortcut/chatmsg/chatmsg_test.go +++ b/internal/shortcut/chatmsg/chatmsg_test.go @@ -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") diff --git a/internal/shortcut/semantic_catalog.json b/internal/shortcut/semantic_catalog.json index a0346df5..b529ffba 100644 --- a/internal/shortcut/semantic_catalog.json +++ b/internal/shortcut/semantic_catalog.json @@ -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 diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index c7d3b4b5..881c0206 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -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 == "" { + 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 == "" || 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. diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index 3348c822..da9486dc 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -14,7 +14,10 @@ package smart import ( + "errors" "fmt" + "math" + "strconv" "strings" "time" @@ -49,10 +52,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 +71,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 --direction older", "dws chat +chat-messages --open-dingtalk-id --download-resources --output-dir ./downloads", @@ -93,11 +96,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 +121,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 +143,7 @@ var ChatMessages = shortcut.Shortcut{ }, chatshortcut.MessageResourceDownloadConstraints()...), Tips: []string{ `dws chat +chat-messages --group --time "2025-03-01 00:00:00"`, + `dws chat +chat-messages --group --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 --time "2025-03-01 00:00:00" --page-all --page-limit 50`, `dws chat +chat-messages --group --direction older --page-all --output ./exports/messages.json`, }, @@ -137,6 +158,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 +200,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 != "" && 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 +327,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 +349,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 +377,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 +436,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 +473,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 +491,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 +509,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 +521,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 +559,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 == "" { - 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 +597,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 +606,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 == "" || 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 +638,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 +661,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 +693,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, diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go index 3200faca..f94438fb 100644 --- a/internal/shortcut/smart/chat_messages_test.go +++ b/internal/shortcut/smart/chat_messages_test.go @@ -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) + } + }) +} diff --git a/internal/shortcut/smart/compatibility_coverage_test.go b/internal/shortcut/smart/compatibility_coverage_test.go index 175eab7f..f6d0b6c6 100644 --- a/internal/shortcut/smart/compatibility_coverage_test.go +++ b/internal/shortcut/smart/compatibility_coverage_test.go @@ -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) diff --git a/internal/shortcut/smart/coverage_completion_test.go b/internal/shortcut/smart/coverage_completion_test.go index eaaba2d0..4503d79e 100644 --- a/internal/shortcut/smart/coverage_completion_test.go +++ b/internal/shortcut/smart/coverage_completion_test.go @@ -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{ diff --git a/internal/shortcut/smart/local_chat_validation.go b/internal/shortcut/smart/local_chat_validation.go index 71a2bb8c..464f5e85 100644 --- a/internal/shortcut/smart/local_chat_validation.go +++ b/internal/shortcut/smart/local_chat_validation.go @@ -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 { diff --git a/internal/shortcut/smart/message_time_range.go b/internal/shortcut/smart/message_time_range.go new file mode 100644 index 00000000..c1d38a14 --- /dev/null +++ b/internal/shortcut/smart/message_time_range.go @@ -0,0 +1,243 @@ +// 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.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.RFC3339) + } + if r.end != nil { + result["endTime"] = r.end.Format(time.RFC3339) + } + 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 + } +} diff --git a/internal/shortcut/smart/message_time_range_test.go b/internal/shortcut/smart/message_time_range_test.go new file mode 100644 index 00000000..8ad03c5c --- /dev/null +++ b/internal/shortcut/smart/message_time_range_test.go @@ -0,0 +1,207 @@ +// 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)) + } + + 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)) + } + + 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) + } +} + +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) + } +} diff --git a/internal/shortcut/smart/my_groups.go b/internal/shortcut/smart/my_groups.go index 3aec3bc7..1c2d3ae9 100644 --- a/internal/shortcut/smart/my_groups.go +++ b/internal/shortcut/smart/my_groups.go @@ -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 == "" || text == "0" { + return "" + } + return text } // myGroupsExtract walks a list_my_groups_pagination response and returns its diff --git a/internal/shortcut/smart/natural_target_resolution_test.go b/internal/shortcut/smart/natural_target_resolution_test.go index 7e3419b0..dd0f2ec9 100644 --- a/internal/shortcut/smart/natural_target_resolution_test.go +++ b/internal/shortcut/smart/natural_target_resolution_test.go @@ -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) diff --git a/internal/shortcut/smart/remaining_read_pagination_test.go b/internal/shortcut/smart/remaining_read_pagination_test.go new file mode 100644 index 00000000..2400d989 --- /dev/null +++ b/internal/shortcut/smart/remaining_read_pagination_test.go @@ -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", "", 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", ""} { + if got := atMeCursorString(value); got != "" { + t.Fatalf("cursor %v = %q", value, got) + } + } +} diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index f9ffae27..a584a6a9 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -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 --days 3 --page-all", "dws chat +search-msg --group --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 != "" { 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) { diff --git a/internal/shortcut/smart/search_msg_execution_test.go b/internal/shortcut/smart/search_msg_execution_test.go index e7a66b6f..92a6c7d0 100644 --- a/internal/shortcut/smart/search_msg_execution_test.go +++ b/internal/shortcut/smart/search_msg_execution_test.go @@ -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) + } +} diff --git a/internal/shortcut/smart/thread_replies.go b/internal/shortcut/smart/thread_replies.go index eedce0e9..53697622 100644 --- a/internal/shortcut/smart/thread_replies.go +++ b/internal/shortcut/smart/thread_replies.go @@ -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 --page-all --order asc", "dws chat +thread-replies --group --thread-id ", - "dws chat +thread-replies --group --topic-id --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 --page-all --order asc`, `dws chat +thread-replies --group --thread-id `, `dws chat +thread-replies --group --thread-id --time "2025-03-01 00:00:00" --limit 20`, + `dws chat +thread-replies --group --thread-id --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 == "" { + 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. diff --git a/internal/shortcut/smart/thread_replies_pagination_test.go b/internal/shortcut/smart/thread_replies_pagination_test.go new file mode 100644 index 00000000..823ecd25 --- /dev/null +++ b/internal/shortcut/smart/thread_replies_pagination_test.go @@ -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(""); got != "" { + t.Fatalf("threadRepliesString() = %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 +} diff --git a/internal/shortcut/targetresolver/resolver.go b/internal/shortcut/targetresolver/resolver.go index a4c6bd21..f4cf72cd 100644 --- a/internal/shortcut/targetresolver/resolver.go +++ b/internal/shortcut/targetresolver/resolver.go @@ -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 diff --git a/internal/shortcut/targetresolver/resolver_test.go b/internal/shortcut/targetresolver/resolver_test.go index a975a7ae..4e81b49d 100644 --- a/internal/shortcut/targetresolver/resolver_test.go +++ b/internal/shortcut/targetresolver/resolver_test.go @@ -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: "项目群"}, diff --git a/scripts/policy/check-skill-context-budget.sh b/scripts/policy/check-skill-context-budget.sh index 1d63b6d7..4d0356d8 100755 --- a/scripts/policy/check-skill-context-budget.sh +++ b/scripts/policy/check-skill-context-budget.sh @@ -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)" diff --git a/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json b/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json index d904c90a..cc2d1822 100644 --- a/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json +++ b/scripts/policy/multi-im-skill-chain/testdata/intent_routes.json @@ -77,7 +77,7 @@ ] }, { - "id": "chat.search.cross-conversation", + "id": "chat.search.filtered", "preferred_tool": "chat.shortcut_search_msg", "allowed_fallbacks": [ { diff --git a/scripts/run_chat_shortcut_live_audit.py b/scripts/run_chat_shortcut_live_audit.py index 27cd9972..af3bdff8 100755 --- a/scripts/run_chat_shortcut_live_audit.py +++ b/scripts/run_chat_shortcut_live_audit.py @@ -234,6 +234,61 @@ 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. + """ + return distinct_lower_collection_count( + raw_calls, + tool, + ("openMessageId", "openMsgId", "messageId", "msgId", "id"), + ) + + +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 +439,52 @@ 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 + if capture.command == "+thread-replies" and tool == "list_topic_replies": + expected_lower_count = distinct_lower_message_count( + capture.raw_calls, "list_topic_replies" + ) + lower_message_projection = [ + call_raw + for _, call_tool, call_raw in capture.raw_calls + if call_tool == "list_topic_replies" + ] + elif capture.command == "+at-me" and tool == "search_at_me_message": + expected_lower_count = distinct_lower_message_count( + capture.raw_calls, "search_at_me_message" + ) + lower_message_projection = [ + call_raw + for _, call_tool, call_raw in capture.raw_calls + if call_tool == "search_at_me_message" + ] + elif ( + capture.command == "+messages-list-direct" + and tool == "list_individual_chat_message" + ): + expected_lower_count = distinct_lower_message_count( + capture.raw_calls, "list_individual_chat_message" + ) + lower_message_projection = [ + call_raw + for _, call_tool, call_raw in capture.raw_calls + if call_tool == "list_individual_chat_message" + ] + 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 +513,23 @@ 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) + lower_reactions = reaction_message_count(lower_message_projection, upper=False) upper_threads = thread_message_count(upper_message_projection, upper=True) - lower_threads = thread_message_count(raw, 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,6 +538,16 @@ def summarize_capture(capture: Capture) -> dict[str, Any]: status = "error_envelope" elif not capture.raw_calls: status = "no_lower_capture" + elif upper_explicitly_incomplete: + status = "incomplete" + elif upper_count == 0 or ( + expected_lower_count == 0 and upper_count is None + ): + # 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 exact_passthrough: status = "pass" elif expected_lower_count is not None and upper_count != expected_lower_count: @@ -439,8 +561,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 +691,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 +713,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 +938,9 @@ def live_audit( "--forward=false", "--limit", "20", + "--page-all", + "--page-limit", + "50", ], timeout, ) @@ -904,17 +1030,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 +1270,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(), @@ -1172,6 +1318,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 diff --git a/scripts/run_chat_shortcut_live_audit_test.py b/scripts/run_chat_shortcut_live_audit_test.py index e56a9b44..f76812ad 100644 --- a/scripts/run_chat_shortcut_live_audit_test.py +++ b/scripts/run_chat_shortcut_live_audit_test.py @@ -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,206 @@ 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", "createTime": "2026-08-05 16:48:20"} + ], + } + }, + ), + ( + "chat", + "list_topic_replies", + { + "result": { + "hasMore": False, + "messages": [ + {"openMessageId": "m1", "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"},{"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) + + +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") + + 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__}:") diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 07c3acbe..f3421650 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -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" diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index 8ad44903..2f3c0b36 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -42,13 +42,13 @@ metadata: | 按姓名发简单文本或 Markdown | `dws chat +dm --to <姓名> --text <内容>` | CLI 解析唯一用户;多候选时停止,不先手工查 ID | | 按群名或 ID 发简单文本或 Markdown | `dws chat +send-to-group --group <群名或ID> --text <内容>` | 稳定 ID 直接使用;群名多候选时停止 | | 文件、Bot、Webhook、复杂 @ 或高级发送 | `dws chat +messages-send` | Bot 多群用 `--groups/--groups-file` 并检查逐项 ledger | -| 读取指定群聊或单聊 | `dws chat +chat-messages` | 全量加 `--page-all`,检查完整性 ledger | -| 跨会话多维搜索 | `dws chat +search-msg` | 内容用 `--query`;检查 `complete` | +| 读取指定会话、返回较多消息 | `dws chat +chat-messages` | 粗粒度读取;目标条件明确时优先 `+search-msg` | +| 多维度条件搜索(发送者/关键词/@/类型,单/跨会话) | `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 --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 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 chat +messages-reply`;成功结果保留新消息/会话/投递与原消息来源上下文 | | 撤回当前用户消息 | `dws chat +messages-recall --msg-id `;可省略会话 ID,由 CLI 只读补齐;兼容单值 `--message-ids` | -| 已知 thread/topic ID 读取回复 | `dws chat +thread-replies` | +| 已知话题主消息 ID 或 thread/topic ID 读取回复 | `dws chat +thread-replies` | | 按成员 ID 或姓名创建群聊 | `dws chat +chat-create`;成员/群主均可自然解析,任一歧义都会在创建前整体停止 | -| 跨全部会话查看 @我的消息 | `dws chat +at-me` | +| 跨全部会话查看 @我的消息 | `dws chat +at-me --page-all` | ### 发送入口边界 diff --git a/skills/multi/dingtalk-chat/references/01-messaging.md b/skills/multi/dingtalk-chat/references/01-messaging.md index b301ee04..d5b304ee 100644 --- a/skills/multi/dingtalk-chat/references/01-messaging.md +++ b/skills/multi/dingtalk-chat/references/01-messaging.md @@ -11,7 +11,13 @@ ## 群聊消息 -读取指定群聊或单聊默认使用 `dws chat +chat-messages`;它负责目标唯一解析、稳定消息投影和完整性 ledger。 +读取或导出指定群聊/单聊的消息记录时使用 `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 \ --format json ``` +```bash +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 +``` + 必须检查 `complete/hasMore/nextPage/stopReason/failures`;达到页数或结果上限不是来源完整。 只有群名时,读取历史直接用 `+chat-messages --group <群名>`,普通文本发送直接用 `+send-to-group`。其它尚不接受群名的高级动作才先用 `+chat-search --query <群名>`;只有唯一候选才把 `openConversationId` 传给下一步。查询结果需要资源时在读取命令上加 `--download-resources`,不要让 Agent 手工遍历资源引用。按姓名读取单聊时先解析唯一用户 ID,再传给 `+chat-messages --user`。 diff --git a/skills/multi/dingtalk-chat/references/chat/chat-conversation.md b/skills/multi/dingtalk-chat/references/chat/chat-conversation.md index 148bc6db..ffc296cf 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-conversation.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-conversation.md @@ -8,8 +8,8 @@ - 查看置顶会话默认使用 `dws chat +conversation-list-top`; 原子 `list-top-conversations` 只在需要原始响应时作为 fallback。 -- 取得目标会话后读取消息默认使用 `dws chat +chat-messages`; - 不要回到原子 `message list` 作为普通路线。 +- 取得目标会话后读取或导出消息记录,默认使用 `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 --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`。 ### 会话置顶与通知 diff --git a/skills/multi/dingtalk-chat/references/chat/chat-group.md b/skills/multi/dingtalk-chat/references/chat/chat-group.md index 05cf48a7..a8e3cde4 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-group.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-group.md @@ -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>`;内部唯一解析自然群名 | diff --git a/skills/multi/dingtalk-chat/references/chat/chat-message.md b/skills/multi/dingtalk-chat/references/chat/chat-message.md index 49949894..f04f923d 100644 --- a/skills/multi/dingtalk-chat/references/chat/chat-message.md +++ b/skills/multi/dingtalk-chat/references/chat/chat-message.md @@ -13,10 +13,10 @@ | 按姓名发送简单文本/Markdown | `dws chat +dm` | | 按群名发送简单文本/Markdown | `dws chat +send-to-group` | | 文件、Bot、Webhook、复杂 @、已知 ID 或幂等发送 | `dws chat +messages-send` | -| 读取一个指定群聊或单聊 | `dws chat +chat-messages` | -| 跨会话、多条件或全量翻页搜索 | `dws chat +search-msg` | +| 读取或导出指定群聊/单聊,可附带发送者姓名 | `dws chat +chat-messages`;姓名用非必填 `--sender-query` | +| 直接按发送者、关键词、@对象或消息类型搜索,可限定单个或跨多个会话 | `dws chat +search-msg` | | 引用回复已有消息 | `dws chat +messages-reply` | -| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名>` | +| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | | 撤回当前用户消息 | `dws chat +messages-recall --msg-id ` | 以下原子命令只用于 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 --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 --page-all --order asc +dws chat +thread-replies --group --thread-id --page-all --page-limit 50 +``` + ### 拉取消息的底层 fallback 默认使用 `dws chat +chat-messages`。群聊的 `--group` 可传群名或 openConversationId; @@ -97,6 +119,32 @@ dws chat message send --group --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 --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 --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` 不同:前者置顶会话内消息,后者置顶整个会话。 ### 表情回应 diff --git a/skills/multi/dingtalk-chat/references/contracts.md b/skills/multi/dingtalk-chat/references/contracts.md index 59d47f27..88bb2c11 100644 --- a/skills/multi/dingtalk-chat/references/contracts.md +++ b/skills/multi/dingtalk-chat/references/contracts.md @@ -12,11 +12,17 @@ - `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` 当 `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` 身份矩阵 diff --git a/skills/multi/dingtalk-chat/references/intent-guide.md b/skills/multi/dingtalk-chat/references/intent-guide.md index 6f64a323..c6c3feef 100644 --- a/skills/multi/dingtalk-chat/references/intent-guide.md +++ b/skills/multi/dingtalk-chat/references/intent-guide.md @@ -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` | 普通消息列表 |