Compare commits

...
19 changed files with 970 additions and 84 deletions
+5
View File
@@ -0,0 +1,5 @@
---
category: Added
---
- **Chat automatic pagination controls** (#970) — adds bounded `--max-items` and cancellable `--page-delay` support to the core IM list shortcuts, with safe continuation metadata and truncation reporting.
+76 -19
View File
@@ -313,17 +313,17 @@ var ConversationList = shortcut.Shortcut{
Examples: []string{"dws chat +conversation-list --limit 50"},
},
},
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页数量;--limit 必须在 1-100"},
{Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标(首次不传或 0)"},
{Name: "exclude-muted", Type: shortcut.FlagBool, Desc: "排除已免打扰会话"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
}, shortcut.AutoPageControlFlags()...),
Constraints: append([]shortcut.Constraint{
{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-limit"}, Description: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
}, shortcut.AutoPageControlConstraints()...),
Tips: []string{
`dws chat +conversation-list --limit 50`,
`dws chat +conversation-list --page-all --limit 100`,
@@ -338,6 +338,9 @@ var ConversationList = shortcut.Shortcut{
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 500 {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
},
Execute: func(rt *shortcut.RuntimeContext) error {
@@ -353,9 +356,20 @@ var ConversationList = shortcut.Shortcut{
complete := false
hasMore := false
nextCursor := int64(0)
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
unsafeResultContinuation := false
failures := make([]map[string]any, 0)
for pagesFetched < pageLimit {
params := map[string]any{"limit": rt.Int("limit")}
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{"stage": "conversation-page-delay", "cursor": cursor, "error": err.Error()})
stopReason = "delay_interrupted"
break
}
}
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, rt.Int("limit"), len(convs))}
if cursor > 0 {
params["cursor"] = cursor
}
@@ -371,6 +385,7 @@ var ConversationList = shortcut.Shortcut{
break
}
pagesFetched++
overflowOnPage := false
for _, conversation := range conversationListProject(data) {
id := strings.TrimSpace(fmt.Sprint(conversation["openConversationId"]))
if id != "" && id != "<nil>" {
@@ -379,6 +394,11 @@ var ConversationList = shortcut.Shortcut{
}
seenConversations[id] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
convs = append(convs, conversation)
}
page := chatmsg.Pagination(data)
@@ -388,6 +408,14 @@ var ConversationList = shortcut.Shortcut{
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层未返回 hasMore,无法证明结果完整"})
break
}
if overflowOnPage {
hasMore = true
nextCursor = 0
unsafeResultContinuation = true
failures = append(failures, map[string]any{"stage": "conversation-pagination", "error": "下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标"})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
break
@@ -398,27 +426,56 @@ var ConversationList = shortcut.Shortcut{
break
}
if !rt.Bool("page-all") {
stopReason = "single_page"
break
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(convs) >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
seenCursors[nextCursor] = true
cursor = nextCursor
}
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
failures = append(failures, map[string]any{"stage": "conversation-page-limit", "error": fmt.Sprintf("达到 --page-limit=%d,仍有更多会话", pageLimit)})
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit && !truncatedByResultLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
payload := map[string]any{
"count": len(convs),
"conversations": convs,
"pagesFetched": pagesFetched,
"complete": complete,
"hasMore": hasMore,
"nextCursor": nextCursor,
"paginationKnown": len(failures) == 0 || hasMore,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0,
"count": len(convs),
"conversations": convs,
"pagesFetched": pagesFetched,
"complete": complete,
"hasMore": hasMore,
"nextCursor": nextCursor,
"paginationKnown": len(failures) == 0 || hasMore,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"truncatedByResultLimit": truncatedByResultLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0,
}
return rt.Output(payload)
chatmsg.ApplyTruncation(payload)
if err := rt.Output(payload); err != nil {
return err
}
if stopReason == "delay_interrupted" && rt.Command().Context().Err() != nil {
return rt.Command().Context().Err()
}
if unsafeResultContinuation {
return apperrors.NewAPI(
fmt.Sprintf("会话列表分页未完成:成功读取 %d 页,存在 %d 个失败项", pagesFetched, len(failures)),
apperrors.WithOperation("im/list_all_conversations"),
apperrors.WithReason("conversation_list_incomplete"),
apperrors.WithOrigin("mcp_gateway"),
apperrors.WithFailureStage("pagination"),
apperrors.WithExecutionStarted(true),
apperrors.WithRetryable(true),
apperrors.WithHint("请根据 failures 和 nextCursor 重试"),
)
}
return nil
},
}
@@ -15,7 +15,9 @@ package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"reflect"
"testing"
@@ -148,6 +150,96 @@ func TestCrossPlatformCoverageConversationListSinglePagePreservesTypedCursor(t *
}
}
func TestCrossPlatformCoverageConversationListMaxItemsPublishesStableTruncation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1", "--page-delay", "0"})
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["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(2) {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
}
func TestCrossPlatformCoverageConversationListRejectsOversizedLimitPage(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"},{"openConversationId":"cid-2"}],"hasMore":true,"nextCursor":2}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--max-items", "1"})
if err := root.Execute(); err == nil {
t.Fatal("oversized lower page unexpectedly published a safe continuation")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageConversationListPropagatesDelayCancellation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1"}],"hasMore":true,"nextCursor":2}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
ctx, cancel := context.WithCancel(context.Background())
cancel()
root.SetContext(ctx)
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+conversation-list", "--page-all", "--page-delay", "1"})
if err := root.Execute(); err == nil || err != context.Canceled {
t.Fatalf("delay cancellation error = %v, want context.Canceled", err)
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageConversationListAutoPageValidationAndOutputFailure(t *testing.T) {
helpers.InitDeps(&larkAlignmentCaller{})
root := newPlatformCoverageRoot()
root.SetArgs([]string{"chat", "+conversation-list", "--max-items", "1"})
if err := root.Execute(); err == nil {
t.Fatal("max-items without page-all unexpectedly succeeded")
}
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[],"hasMore":false}}`,
}}
helpers.InitDeps(fake)
root = newPlatformCoverageRoot()
root.SetOut(chatOutputErrorWriter{err: errors.New("fixture output")})
root.SetArgs([]string{"chat", "+conversation-list"})
if err := root.Execute(); err == nil {
t.Fatal("output error was swallowed")
}
}
func TestCrossPlatformCoverageConversationListDeduplicatesStableIDs(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_all_conversations": `{"result":{"conversationList":[{"openConversationId":"cid-1","title":"一"},{"openConversationId":"cid-1","title":"重复"}],"hasMore":false}}`,
+45 -8
View File
@@ -878,16 +878,16 @@ var ChatListAll = shortcut.Shortcut{
Examples: []string{"dws chat +chat-list-all --limit 50"},
},
},
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{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-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
}, shortcut.AutoPageControlFlags()...),
Constraints: append([]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"},
},
}, shortcut.AutoPageControlConstraints()...),
Tips: []string{
`dws chat +chat-list-all --limit 50`,
`dws chat +chat-list-all --limit 200 --page-all --page-limit 50`,
@@ -908,6 +908,9 @@ func validateChatListAll(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
}
@@ -958,10 +961,21 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
var nextCursor any
for pagesFetched < pageLimit {
params := map[string]any{"limit": baseParams["limit"]}
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "cursor": cursorKey, "error": err.Error(),
})
stopReason = "delay_interrupted"
break
}
}
pageSize, _ := baseParams["limit"].(int)
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, pageSize, len(allGroups))}
if cursorKey != "0" {
params["cursor"] = cursorValue
}
@@ -978,6 +992,7 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
}
pagesFetched++
pageGroups := chatListAllProject(data)
overflowOnPage := false
for _, group := range pageGroups {
id := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
if id == "<nil>" {
@@ -989,6 +1004,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
if id != "" {
seenGroups[id] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
allGroups = append(allGroups, group)
}
@@ -1003,6 +1023,16 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
break
}
hasMore = pageHasMore
if overflowOnPage {
hasMore = true
nextCursor = nil
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "已加入群列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
nextCursor = nil
@@ -1022,8 +1052,13 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
seenCursors[nextKey] = true
cursorKey = nextKey
cursorValue = nextCursor
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allGroups) >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
@@ -1033,9 +1068,11 @@ func readAllChatListAll(rt *shortcut.RuntimeContext, baseParams map[string]any)
"pagesFetched": pagesFetched, "paginationKnown": true,
"complete": complete && len(failures) == 0, "hasMore": hasMore,
"stopReason": stopReason, "truncatedByPageLimit": truncatedByPageLimit,
"failedCount": len(failures), "failures": failures,
"truncatedByResultLimit": truncatedByResultLimit,
"failedCount": len(failures), "failures": failures,
"partial": len(failures) > 0 && len(allGroups) > 0,
}
chatmsg.ApplyTruncation(payload)
if hasMore && nextCursor != nil {
payload["nextCursor"] = nextCursor
}
+1
View File
@@ -634,6 +634,7 @@ func readAllDirectMessages(rt *shortcut.RuntimeContext, params map[string]any) (
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
chatmsg.ApplyTruncation(payload)
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(messages) > 0
@@ -5,6 +5,7 @@ package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"
@@ -73,11 +74,86 @@ func TestCrossPlatformCoverageFlagListPageTokenAndPageLimit(t *testing.T) {
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" {
if payload["complete"] != false || payload["nextCursor"] != float64(9) || payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageFlagListMaxItemsPublishesStableTruncation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"msg-1"}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1", "--page-delay", "0"})
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["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" || payload["nextCursor"] != float64(9) {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
}
func TestCrossPlatformCoverageFlagListFailsClosedOnOversizeAndCanceledDelay(t *testing.T) {
t.Run("oversized lower page", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"},{"openMessageId":"m2"}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--max-items", "1"})
if err := root.Execute(); err == nil {
t.Fatal("oversized lower page unexpectedly published a continuation")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) || payload["nextCursor"] != float64(0) {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["size"] != "1" {
t.Fatalf("calls = %#v", fake.calls)
}
})
t.Run("canceled delay", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_message_favorites": `{"result":{"items":[{"openMessageId":"m1"}],"hasMore":true,"nextCursor":9}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
ctx, cancel := context.WithCancel(context.Background())
cancel()
root.SetContext(ctx)
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+flag-list", "--page-all", "--page-delay", "1"})
if err := root.Execute(); err == nil {
t.Fatal("canceled delay unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
})
}
func TestCrossPlatformCoverageFlagListFailureModes(t *testing.T) {
t.Run("later read failure keeps partial result", func(t *testing.T) {
fake := &larkAlignmentCaller{
@@ -137,7 +213,11 @@ func TestCrossPlatformCoverageFlagListPaginationValidation(t *testing.T) {
{"--size", "31"},
{"--page-token", "not-a-number"},
{"--page-limit", "2"},
{"--max-items", "1"},
{"--page-delay", "1"},
{"--page-all", "--page-limit", "0"},
{"--page-all", "--max-items", "-1"},
{"--page-all", "--page-delay", "-1"},
{"--page-all", "--page-limit", "501"},
{"--cursor", "1", "--page-token", "2"},
} {
+56 -19
View File
@@ -510,21 +510,21 @@ var FlagList = shortcut.Shortcut{
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{
Flags: append([]shortcut.Flag{
{Name: "page-size", Type: shortcut.FlagInt, Default: "20", Desc: "每页数量;下游真实上限为 30,显式页大小必须在 1-30 之间"},
{Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "--page-size 的兼容别名;下游真实上限为 30,显式页大小必须在 1-30 之间"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "Lark 对齐的起始分页参数;起始 cursor 必须是非负整数"},
{Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "钉钉数字分页游标;起始 cursor 必须是非负整数"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动读取全部收藏分页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "20", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
}, shortcut.AutoPageControlFlags()...),
Constraints: append([]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"},
},
}, shortcut.AutoPageControlConstraints()...),
Tips: []string{
`dws chat +flag-list --cursor 0 --page-size 20`,
`dws chat +flag-list --page-size 30 --page-all --page-limit 20`,
@@ -548,6 +548,9 @@ func validateFlagList(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
}
@@ -598,9 +601,20 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
var cursorErr error
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
for pagesFetched < pageLimit {
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, pageSize))
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "cursor": cursor, "error": err.Error(),
})
stopReason = "delay_interrupted"
break
}
}
requestPageSize := shortcut.AutoPageRequestSize(rt, pageSize, len(items))
data, callErr := rt.CallMCPData("im", "list_message_favorites", flagListRequestParams(cursor, requestPageSize))
if callErr != nil {
if pagesFetched == 0 {
return callErr
@@ -613,6 +627,7 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
}
pagesFetched++
pageItems := flagListItems(data)
overflowOnPage := false
for _, item := range pageItems {
messageID := firstNonEmptyMapString(item, "openMessageId", "messageId", "itemId", "id")
if messageID != "" && seenMessages[messageID] {
@@ -621,6 +636,11 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
if messageID != "" {
seenMessages[messageID] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
items = append(items, item)
}
@@ -649,6 +669,16 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
}
}
hasMore = pageHasMore
if overflowOnPage {
hasMore = true
nextCursor = 0
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "收藏列表下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
nextCursor = 0
@@ -669,28 +699,35 @@ func executeFlagList(rt *shortcut.RuntimeContext) error {
}
seenCursors[nextCursor] = true
cursor = nextCursor
if maxItems := rt.Int("max-items"); maxItems > 0 && len(items) >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
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,
"count": len(items),
"items": items,
"pagesFetched": pagesFetched,
"paginationKnown": paginationKnown,
"complete": complete && len(failures) == 0,
"hasMore": hasMore,
"nextCursor": nextCursor,
"stopReason": stopReason,
"truncatedByPageLimit": truncatedByPageLimit,
"truncatedByResultLimit": truncatedByResultLimit,
"failedCount": len(failures),
"failures": failures,
"partial": len(failures) > 0 && len(items) > 0,
}
chatmsg.ApplyTruncation(payload)
if outputErr := rt.Output(payload); outputErr != nil {
return outputErr
}
@@ -5,12 +5,14 @@ package chat
import (
"bytes"
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
)
func TestCrossPlatformCoverageChatListAllPageAllUsesNumericCursorAndDeduplicates(t *testing.T) {
@@ -95,11 +97,86 @@ func TestCrossPlatformCoverageDirectMessagesPageLimitPublishesExecutableContinua
t.Fatal(err)
}
if payload["complete"] != false || payload["hasMore"] != true ||
payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
payload["truncated"] != true || payload["truncatedByPageLimit"] != true || payload["stopReason"] != "page_limit" || payload["nextPage"] == nil {
t.Fatalf("payload = %#v", payload)
}
}
func TestCrossPlatformCoverageChatListAllMaxItemsPublishesStableTruncation(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1", "--page-delay", "0"})
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["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != float64(88) {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
}
func TestCrossPlatformCoverageChatListAllFailsClosedOnOversizeAndCanceledDelay(t *testing.T) {
t.Run("oversized lower page", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"},{"openConversationId":"g2"}],"hasMore":true,"nextCursor":88}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--max-items", "1"})
if err := root.Execute(); err == nil {
t.Fatal("oversized lower page unexpectedly published a continuation")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
if len(fake.calls) != 1 || fake.calls[0].args["limit"] != 1 || payload["nextCursor"] != nil {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", fake.calls, payload)
}
})
t.Run("canceled delay", func(t *testing.T) {
fake := &larkAlignmentCaller{responses: map[string]string{
"im/list_my_groups_pagination": `{"result":{"groups":[{"openConversationId":"g1"}],"hasMore":true,"nextCursor":88}}`,
}}
helpers.InitDeps(fake)
root := newPlatformCoverageRoot()
ctx, cancel := context.WithCancel(context.Background())
cancel()
root.SetContext(ctx)
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+chat-list-all", "--page-all", "--page-delay", "1"})
if err := root.Execute(); err == nil {
t.Fatal("canceled delay unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
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}}`,
@@ -163,6 +240,17 @@ func TestCrossPlatformCoverageChatRemainingPaginationValidation(t *testing.T) {
t.Fatalf("invalid args succeeded: %v", args)
}
}
root := newPlatformCoverageRoot()
cmd, _, err := root.Find([]string{"chat", "+chat-list-all"})
if err != nil {
t.Fatal(err)
}
if err := cmd.Flags().Set("max-items", "1"); err != nil {
t.Fatal(err)
}
if err := validateChatListAll(shortcut.RuntimeContextForTest(cmd, ChatListAll)); err == nil {
t.Fatal("direct auto-page validation unexpectedly succeeded")
}
}
func TestCrossPlatformCoverageChatListAllAdditionalEdges(t *testing.T) {
+13
View File
@@ -86,6 +86,7 @@ var messageResultContractV1 = MessageResultContract{
"hasMore",
"nextPage",
"stopReason",
"truncated",
"truncatedByPageLimit",
"truncatedByResultLimit",
"failedCount",
@@ -122,9 +123,21 @@ func NewMessageListPayload(messages []map[string]any) map[string]any {
"failedCount": 0,
"failures": []map[string]any{},
"partial": false,
"truncated": false,
}
}
// ApplyTruncation publishes the stable aggregate bit while preserving the
// established reason-specific fields for compatibility and diagnosis.
func ApplyTruncation(payload map[string]any) {
if payload == nil {
return
}
byPage, _ := payload["truncatedByPageLimit"].(bool)
byItems, _ := payload["truncatedByResultLimit"].(bool)
payload["truncated"] = byPage || byItems
}
// ListMessageItems returns message rows from the common list response envelopes.
func ListMessageItems(data map[string]any) []map[string]any {
if data == nil {
@@ -441,6 +441,7 @@ func TestCrossPlatformCoverageReactionsNormalizesEmotionReplyList(t *testing.T)
}
func TestCrossPlatformCoverageApplyPaginationReadsNestedEnvelope(t *testing.T) {
ApplyTruncation(nil)
payload := map[string]any{"count": 98}
ApplyPagination(payload, map[string]any{
"result": map[string]any{
+97
View File
@@ -0,0 +1,97 @@
// 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 shortcut
import (
"fmt"
"math"
"time"
)
const maxAutoPageDelayMS = int64(math.MaxInt64) / int64(time.Millisecond)
// AutoPageControlFlags returns the shared item and pacing controls used by
// cursor-based shortcuts. Callers retain ownership of --page-all and their
// product-specific --page-limit defaults.
func AutoPageControlFlags() []Flag {
const evidence = "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"
return []Flag{
{Name: "max-items", Type: FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制)。" + evidence},
{Name: "page-delay", Type: FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待)。" + evidence},
}
}
// AutoPageControlConstraints publishes the runtime-only relationship between
// the pagination switch and its shared controls into Help and Schema.
func AutoPageControlConstraints() []Constraint {
return []Constraint{{
Kind: ConstraintCustom,
Flags: []string{"page-all", "max-items", "page-delay"},
Description: "--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0",
}}
}
// ValidateAutoPageControls enforces the shared contract while preserving the
// historical behavior that defaulted controls do not activate pagination.
func ValidateAutoPageControls(rt *RuntimeContext) error {
if !rt.Bool("page-all") {
if rt.Changed("max-items") || rt.Changed("page-delay") {
return fmt.Errorf("--max-items/--page-delay 仅与 --page-all 一起使用")
}
return nil
}
if rt.Int("max-items") < 0 {
return fmt.Errorf("--max-items 必须大于等于 0")
}
delayMS := rt.Int("page-delay")
if delayMS < 0 {
return fmt.Errorf("--page-delay 必须大于等于 0")
}
if int64(delayMS) > maxAutoPageDelayMS {
return fmt.Errorf("--page-delay 不能大于 %d 毫秒", maxAutoPageDelayMS)
}
return nil
}
// AutoPageRequestSize caps the next lower-page request to the remaining item
// budget. A cursor returned for that request is therefore safe to resume from:
// the CLI did not intentionally discard a suffix of the lower page.
func AutoPageRequestSize(rt *RuntimeContext, pageSize, itemCount int) int {
maxItems := rt.Int("max-items")
if maxItems <= 0 {
return pageSize
}
remaining := maxItems - itemCount
if remaining > 0 && remaining < pageSize {
return remaining
}
return pageSize
}
// WaitAutoPageDelay waits between successful pages and remains cancellable so
// a throttled pagination run cannot ignore command cancellation.
func WaitAutoPageDelay(rt *RuntimeContext) error {
delayMS := rt.Int("page-delay")
if delayMS <= 0 {
return nil
}
timer := time.NewTimer(time.Duration(int64(delayMS)) * time.Millisecond)
defer timer.Stop()
select {
case <-rt.Command().Context().Done():
return rt.Command().Context().Err()
case <-timer.C:
return nil
}
}
+91
View File
@@ -0,0 +1,91 @@
// 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 shortcut
import (
"context"
"strconv"
"testing"
"github.com/spf13/cobra"
)
func autoPageRuntimeForTest(t *testing.T, pageAll bool, maxItems, pageDelay string) *RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "page"}
cmd.SetContext(context.Background())
cmd.Flags().Bool("page-all", false, "")
cmd.Flags().Int("max-items", 0, "")
cmd.Flags().Int("page-delay", 0, "")
if pageAll {
if err := cmd.Flags().Set("page-all", "true"); err != nil {
t.Fatal(err)
}
}
for name, value := range map[string]string{"max-items": maxItems, "page-delay": pageDelay} {
if value != "" {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatal(err)
}
}
}
return RuntimeContextForTest(cmd, Shortcut{})
}
func TestCrossPlatformCoverageAutoPageControlsBoundDelayAndRequestSize(t *testing.T) {
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, false, "1", "")); err == nil {
t.Fatal("max-items without page-all unexpectedly succeeded")
}
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "-1", "")); err == nil {
t.Fatal("negative max-items unexpectedly succeeded")
}
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", "-1")); err == nil {
t.Fatal("negative page-delay unexpectedly succeeded")
}
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", strconv.FormatInt(maxAutoPageDelayMS, 10))); err != nil {
t.Fatalf("maximum safe page-delay failed: %v", err)
}
if strconv.IntSize == 64 {
tooLarge := strconv.FormatInt(maxAutoPageDelayMS+1, 10)
if err := ValidateAutoPageControls(autoPageRuntimeForTest(t, true, "", tooLarge)); err == nil {
t.Fatal("overflowing page-delay unexpectedly succeeded")
}
}
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "0", ""), 100, 40); got != 100 {
t.Fatalf("unlimited request size = %d", got)
}
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 40); got != 10 {
t.Fatalf("remaining request size = %d", got)
}
if got := AutoPageRequestSize(autoPageRuntimeForTest(t, true, "50", ""), 100, 50); got != 100 {
t.Fatalf("exhausted request size = %d", got)
}
}
func TestCrossPlatformCoverageWaitAutoPageDelayNoopAndCancellation(t *testing.T) {
if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "0")); err != nil {
t.Fatalf("zero delay = %v", err)
}
if err := WaitAutoPageDelay(autoPageRuntimeForTest(t, true, "", "1")); err != nil {
t.Fatalf("elapsed delay = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
rt := autoPageRuntimeForTest(t, true, "", "1")
rt.Command().SetContext(ctx)
if err := WaitAutoPageDelay(rt); err != context.Canceled {
t.Fatalf("canceled delay = %v, want context.Canceled", err)
}
}
+44 -6
View File
@@ -93,23 +93,23 @@ var AtMe = shortcut.Shortcut{
},
},
},
Flags: append([]shortcut.Flag{
Flags: append(append([]shortcut.Flag{
{Name: "group", Type: shortcut.FlagString, Desc: "仅查看指定群;可传 openConversationId 或群名"},
{Name: "chat-query", Type: shortcut.FlagString, Desc: "--group 的旧版自然名称入口", Hidden: true},
{Name: "group-query", Type: shortcut.FlagString, Desc: "--chat-query 的兼容别名", Hidden: true},
{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-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部 @我 消息;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{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{
}, shortcut.AutoPageControlFlags()...), chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append(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()...),
}, shortcut.AutoPageControlConstraints()...), chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +at-me`,
`dws chat +at-me --days 3`,
@@ -211,6 +211,9 @@ func validateAtMe(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
}
@@ -240,12 +243,24 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
nextCursor := ""
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
for pagesFetched < pageLimit {
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "cursor": cursor, "error": err.Error(),
})
stopReason = "delay_interrupted"
break
}
}
params := make(map[string]any, len(baseParams))
for key, value := range baseParams {
params[key] = value
}
pageSize, _ := baseParams["limit"].(int)
params["limit"] = shortcut.AutoPageRequestSize(rt, pageSize, len(allItems))
params["cursor"] = cursor
data, err := rt.CallMCPData("chat", "search_at_me_message", params)
if err != nil {
@@ -260,6 +275,7 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
}
pagesFetched++
pageItems := atMeMessageItems(data)
overflowOnPage := false
for _, item := range pageItems {
id := chatmsg.StableMessageID(item)
if id != "" && seenMessages[id] {
@@ -268,6 +284,11 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
if id != "" {
seenMessages[id] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
allItems = append(allItems, item)
}
@@ -282,6 +303,16 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
break
}
hasMore = pageHasMore
if overflowOnPage {
hasMore = true
nextCursor = ""
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "@我消息下层返回条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
nextCursor = ""
@@ -299,8 +330,13 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
}
seenCursors[nextCursor] = true
cursor = nextCursor
if maxItems := rt.Int("max-items"); maxItems > 0 && len(allItems) >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
@@ -312,9 +348,11 @@ func readAllAtMePages(rt *shortcut.RuntimeContext, baseParams map[string]any) (m
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["truncatedByResultLimit"] = truncatedByResultLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allItems) > 0
chatmsg.ApplyTruncation(payload)
if hasMore && nextCursor != "" {
payload["nextCursor"] = nextCursor
}
+34 -10
View File
@@ -60,7 +60,7 @@ var ChatMessages = shortcut.Shortcut{
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --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。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
@@ -86,7 +86,7 @@ var ChatMessages = shortcut.Shortcut{
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;可附带发送者姓名解析,无稳定身份时保留全部消息,唯一解析出稳定身份后按 senderId 筛选同一次读取结果;" +
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --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。" +
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
Examples: []string{
@@ -117,9 +117,11 @@ var ChatMessages = shortcut.Shortcut{
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的兼容别名", Hidden: true},
{Name: "direction", Type: shortcut.FlagString, Enum: []string{"newer", "older"}, Desc: "时间方向 newer/older;省略时为 older,从时间边界向前读取"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-results 仅与 --page-all 一起使用且不能为负数"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "沿 typed nextPage.time 自动读取后续页;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items 仅与 --page-all 一起使用且不能为负数;--max-results 仅与 --page-all 一起使用且不能为负数;--page-delay 仅与 --page-all 一起使用且不能为负数"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
{Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-results 仅与 --page-all 一起使用且不能为负数;0 表示仅受页数上限约束"},
{Name: "max-items", Type: shortcut.FlagInt, Desc: "自动翻页最多返回条数(默认 0 表示不限制);--max-items 仅与 --page-all 一起使用且不能为负数"},
{Name: "max-results", Type: shortcut.FlagInt, Desc: "--max-items 的公开兼容别名;--max-results 仅与 --page-all 一起使用且不能为负数"},
{Name: "page-delay", Type: shortcut.FlagInt, Desc: "自动翻页每页之间等待毫秒数(默认 0 表示不等待);--page-delay 仅与 --page-all 一起使用且不能为负数"},
{Name: "output", Shorthand: "o", Type: shortcut.FlagString, Desc: "把完整结构化 ledger 原子写入工作目录内的相对 JSON 文件"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
@@ -138,7 +140,10 @@ var ChatMessages = shortcut.Shortcut{
{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-items"}, Description: "--max-items 仅与 --page-all 一起使用且不能为负数"},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "max-results"}, Description: "--max-results 仅与 --page-all 一起使用且不能为负数"},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"max-items", "max-results"}},
{Kind: shortcut.ConstraintCustom, Flags: []string{"page-all", "page-delay"}, Description: "--page-delay 仅与 --page-all 一起使用且不能为负数"},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"output", "overwrite"},
@@ -179,16 +184,25 @@ func validateChatMessages(rt *shortcut.RuntimeContext) error {
return localChatOptionError("invalid_page_size", "+chat-messages 的 --"+name+" 必须大于 0", "--"+name)
}
}
if !rt.Bool("page-all") && (rt.Changed("page-limit") || rt.Changed("max-results")) {
return apperrors.NewValidation("--page-limit/--max-results 仅与 --page-all 一起使用")
if !rt.Bool("page-all") && rt.Changed("page-limit") {
return apperrors.NewValidation("--page-limit 仅与 --page-all 一起使用")
}
if !rt.Bool("page-all") && rt.Changed("max-results") {
return apperrors.NewValidation("--max-results 仅与 --page-all 一起使用")
}
if rt.Bool("page-all") {
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > chatMessagesHardPageLimit {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
if rt.Int("max-results") < 0 {
return apperrors.NewValidation("--max-results 不能小于 0")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
if rt.Int("max-results") < 0 {
return apperrors.NewValidation("--max-results 不能小于 0")
}
if rt.Changed("max-items") && rt.Changed("max-results") {
return apperrors.NewValidation("--max-items 与 --max-results 不能同时使用")
}
if rt.Changed("output") {
if err := chatshortcut.ValidateMessageExportOutput(rt.Str("output")); err != nil {
@@ -477,7 +491,7 @@ 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")
maxResults := rt.IntFirst("max-items", "max-results")
basePageSize, _ := request.params["limit"].(int)
if basePageSize <= 0 {
basePageSize = chatMessagesAllPageSize
@@ -496,6 +510,15 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
var nextPage map[string]any
for pagesFetched < pageLimit {
if pagesFetched > 0 {
if delayErr := shortcut.WaitAutoPageDelay(rt); delayErr != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "error": delayErr.Error(),
})
stopReason = "delay_interrupted"
break
}
}
request.params["limit"] = basePageSize
if maxResults > 0 {
remaining := maxResults - len(allItems)
@@ -656,6 +679,7 @@ func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesReq
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["truncatedByResultLimit"] = truncatedByResultLimit
chatmsg.ApplyTruncation(payload)
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(results) > 0
+52 -11
View File
@@ -503,7 +503,29 @@ func chatMessagesRuntimeForTest(t *testing.T, values map[string]string) *shortcu
return shortcut.RuntimeContextForTest(cmd, ChatMessages)
}
func TestCrossPlatformCoverageChatMessagesKeepsMaxResultsPublic(t *testing.T) {
root := newPlatformCoverageRoot()
cmd, _, err := root.Find([]string{"chat", "+chat-messages"})
if err != nil {
t.Fatal(err)
}
flag := cmd.Flags().Lookup("max-results")
if flag == nil || flag.Hidden {
t.Fatalf("--max-results must remain a visible compatibility flag: %#v", flag)
}
}
func TestCrossPlatformCoverageChatMessagesAdditionalValidationAndHelpers(t *testing.T) {
for _, values := range []map[string]string{
{"max-results": "1"},
{"max-items": "1"},
{"page-all": "true", "max-results": "-1"},
{"page-all": "true", "max-items": "1", "max-results": "1"},
} {
if err := validateChatMessages(chatMessagesRuntimeForTest(t, values)); err == nil {
t.Fatalf("pagination validation unexpectedly accepted %#v", values)
}
}
for _, values := range []map[string]string{
{"time": "2026-01-01", "start": "2026-01-01"},
{"direction": "older", "start": "2026-01-01"},
@@ -613,21 +635,23 @@ func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T
})
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)
for _, flag := range []string{"max-items", "max-results"} {
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{flag: "1"}),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older", timeRange: configuredRange},
)
if err != nil || payload["truncated"] != true || payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("%s payload=%#v err=%v", flag, payload, err)
}
}
caller = &chatMessagesPagingCaller{responses: []string{
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"messages":[{"openMessageId":"m1","createTime":"2026-01-03 00:00:00"}]}}`,
}}
payload, _, err = collectAllChatMessages(
payload, _, err := collectAllChatMessages(
runtimeWith(t, caller, map[string]string{"max-results": "1"}),
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
@@ -650,6 +674,23 @@ func TestCrossPlatformCoverageChatMessagesAdditionalCollectionEdges(t *testing.T
}
})
t.Run("canceled delay", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"hasMore":true,"nextCursor":1234,"messages":[{"openMessageId":"m1"}]}}`,
}}
rt := runtimeWith(t, caller, map[string]string{"page-delay": "1"})
ctx, cancel := context.WithCancel(context.Background())
cancel()
rt.Command().SetContext(ctx)
payload, _, err := collectAllChatMessages(
rt,
chatMessagesRequest{tool: "list_conversation_message_v2", params: map[string]any{}, direction: "older"},
)
if err == nil || payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != 1 {
t.Fatalf("payload=%#v err=%v", payload, err)
}
})
t.Run("first failure ledger output error", func(t *testing.T) {
helpers.InitDeps(&chatMessagesPagingCaller{failAt: 1})
root := newPlatformCoverageRoot()
+57 -7
View File
@@ -84,17 +84,17 @@ var MyGroups = shortcut.Shortcut{
},
},
},
Flags: []shortcut.Flag{
Flags: append([]shortcut.Flag{
{Name: "type", Type: shortcut.FlagString, Desc: "按群类型过滤(可选,如返回中的 groupType/conversationType,大小写不敏感)", Required: false},
{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-all", Type: shortcut.FlagBool, Desc: "沿 nextCursor 自动读取全部已加入群;--page-limit 仅与 --page-all 一起使用且范围 1-500;--max-items/--page-delay 仅与 --page-all 一起使用;值必须大于等于 0"},
{Name: "page-limit", Type: shortcut.FlagInt, Default: "50", Desc: "--page-limit 仅与 --page-all 一起使用且范围 1-500"},
},
Constraints: []shortcut.Constraint{
}, shortcut.AutoPageControlFlags()...),
Constraints: append([]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"},
},
}, shortcut.AutoPageControlConstraints()...),
Tips: []string{
`dws chat +my-groups`,
`dws chat +my-groups --type group`,
@@ -116,6 +116,9 @@ func validateMyGroups(rt *shortcut.RuntimeContext) error {
return apperrors.NewValidation("--page-limit 必须在 1-500 之间")
}
}
if err := shortcut.ValidateAutoPageControls(rt); err != nil {
return apperrors.NewValidation(err.Error())
}
return nil
}
@@ -180,10 +183,22 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
hasMore := false
stopReason := "source_complete"
truncatedByPageLimit := false
truncatedByResultLimit := false
eligibleCount := 0
var nextCursor any
for pagesFetched < pageLimit {
params := map[string]any{"limit": baseParams["limit"]}
if pagesFetched > 0 {
if err := shortcut.WaitAutoPageDelay(rt); err != nil {
failures = append(failures, map[string]any{
"page": pagesFetched + 1, "stage": "delay", "cursor": cursorKey, "error": err.Error(),
})
stopReason = "delay_interrupted"
break
}
}
pageSize, _ := baseParams["limit"].(int)
params := map[string]any{"limit": shortcut.AutoPageRequestSize(rt, pageSize, eligibleCount)}
if cursorKey != "0" {
params["cursor"] = cursorValue
}
@@ -200,6 +215,7 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
}
pagesFetched++
pageGroups := myGroupsExtract(data)
overflowOnPage := false
for _, group := range pageGroups {
id := myGroupsStr(group, "openConversationId", "openConversationID", "conversationId", "openCid", "cid", "id")
if id != "" && seenGroups[id] {
@@ -208,7 +224,15 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
if id != "" {
seenGroups[id] = true
}
if maxItems := rt.Int("max-items"); maxItems > 0 && myGroupsMatchesFilter(rt, group) && eligibleCount >= maxItems {
truncatedByResultLimit = true
overflowOnPage = true
continue
}
allGroups = append(allGroups, group)
if myGroupsMatchesFilter(rt, group) {
eligibleCount++
}
}
page := chatmsg.Pagination(data)
@@ -222,6 +246,16 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
break
}
hasMore = pageHasMore
if overflowOnPage {
hasMore = true
nextCursor = nil
failures = append(failures, map[string]any{
"page": pagesFetched, "stage": "pagination",
"error": "我的群列表下层返回的匹配条数超过请求的剩余额度,无法生成不跳项的安全续页游标",
})
stopReason = "pagination_error"
break
}
if !hasMore {
complete = true
nextCursor = nil
@@ -241,8 +275,13 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
seenCursors[nextKey] = true
cursorKey = nextKey
cursorValue = nextCursor
if maxItems := rt.Int("max-items"); maxItems > 0 && eligibleCount >= maxItems {
truncatedByResultLimit = true
stopReason = "result_limit"
break
}
}
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit && !truncatedByResultLimit {
truncatedByPageLimit = true
stopReason = "page_limit"
}
@@ -254,9 +293,11 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
payload["truncatedByResultLimit"] = truncatedByResultLimit
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allGroups) > 0
chatmsg.ApplyTruncation(payload)
if hasMore && nextCursor != nil {
payload["nextCursor"] = nextCursor
}
@@ -275,6 +316,15 @@ func readAllMyGroups(rt *shortcut.RuntimeContext, baseParams map[string]any) (ma
)
}
func myGroupsMatchesFilter(rt *shortcut.RuntimeContext, group map[string]any) bool {
typeFilter := strings.TrimSpace(rt.Str("type"))
if typeFilter == "" {
return true
}
groupType, _ := myGroupsProject(group)["type"].(string)
return strings.EqualFold(strings.TrimSpace(groupType), typeFilter)
}
func myGroupsCursorString(value any) string {
if value == nil {
return ""
@@ -5,6 +5,7 @@ package smart
import (
"bytes"
"context"
"encoding/json"
"testing"
@@ -83,6 +84,31 @@ func TestCrossPlatformCoverageAtMePageAllContinuesAcrossEmptyIntermediatePage(t
}
}
func TestCrossPlatformCoverageAtMeMaxItemsPublishesStableTruncation(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+at-me", "--page-all", "--max-items", "1", "--page-delay", "0"})
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["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(caller.args) != 1 || caller.args[0]["limit"] != 1 || payload["nextCursor"] != "cursor-2" {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", caller.args, payload)
}
}
func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMerge(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[{"openConversationId":"g1","title":"群一","groupType":"group"}],"hasMore":true,"nextCursor":88}}`,
@@ -109,12 +135,119 @@ func TestCrossPlatformCoverageMyGroupsPageAllUsesNumericCursorAndFiltersAfterMer
}
}
func TestCrossPlatformCoverageMyGroupsMaxItemsAppliesAfterTypeFilter(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{
`{"result":{"groups":[{"openConversationId":"p1","groupType":"p2p"}],"hasMore":true,"nextCursor":2}}`,
`{"result":{"groups":[{"openConversationId":"g1","groupType":"group"}],"hasMore":true,"nextCursor":3}}`,
}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
root.SetArgs([]string{"chat", "+my-groups", "--type", "group", "--page-all", "--max-items", "1", "--page-delay", "0"})
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["truncated"] != true ||
payload["truncatedByResultLimit"] != true || payload["stopReason"] != "result_limit" {
t.Fatalf("payload = %#v", payload)
}
if len(caller.args) != 2 || caller.args[0]["limit"] != 1 || caller.args[1]["limit"] != 1 || payload["nextCursor"] != float64(3) {
t.Fatalf("unsafe filtered continuation: calls=%#v payload=%#v", caller.args, payload)
}
}
func TestCrossPlatformCoverageRemainingListsFailClosedOnOversizeAndCanceledDelay(t *testing.T) {
tests := []struct {
name string
command string
response string
extra []string
}{
{
name: "at-me",
command: "+at-me",
response: `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"},{"openMessageId":"m2"}]}],` +
`"hasMore":true,"nextCursor":"next"}}`,
},
{
name: "my-groups",
command: "+my-groups",
response: `{"result":{"groups":[{"openConversationId":"g1","groupType":"group"},{"openConversationId":"g2","groupType":"group"}],"hasMore":true,"nextCursor":2}}`,
extra: []string{"--type", "group"},
},
}
for _, tc := range tests {
t.Run(tc.name+" oversized lower page", func(t *testing.T) {
caller := &chatMessagesPagingCaller{responses: []string{tc.response}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
var output bytes.Buffer
root.SetOut(&output)
args := append([]string{"chat", tc.command}, tc.extra...)
args = append(args, "--page-all", "--max-items", "1")
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatal("oversized lower page unexpectedly published a continuation")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "pagination_error" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
if len(caller.args) != 1 || caller.args[0]["limit"] != 1 || payload["nextCursor"] != nil {
t.Fatalf("unsafe continuation: calls=%#v payload=%#v", caller.args, payload)
}
})
t.Run(tc.name+" canceled delay", func(t *testing.T) {
continuing := tc.response
if tc.command == "+at-me" {
continuing = `{"result":{"conversationMessagesList":[{"messages":[{"openMessageId":"m1"}]}],"hasMore":true,"nextCursor":"next"}}`
} else {
continuing = `{"result":{"groups":[{"openConversationId":"g1","groupType":"group"}],"hasMore":true,"nextCursor":2}}`
}
caller := &chatMessagesPagingCaller{responses: []string{continuing}}
helpers.InitDeps(caller)
root := newPlatformCoverageRoot()
ctx, cancel := context.WithCancel(context.Background())
cancel()
root.SetContext(ctx)
var output bytes.Buffer
root.SetOut(&output)
args := append([]string{"chat", tc.command}, tc.extra...)
args = append(args, "--page-all", "--page-delay", "1")
root.SetArgs(args)
if err := root.Execute(); err == nil {
t.Fatal("canceled delay unexpectedly succeeded")
}
var payload map[string]any
if err := json.Unmarshal(output.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if payload["stopReason"] != "delay_interrupted" || payload["failedCount"] != float64(1) {
t.Fatalf("payload = %#v", payload)
}
})
}
}
func TestCrossPlatformCoverageRemainingReadPaginationValidation(t *testing.T) {
for _, args := range [][]string{
{"chat", "+at-me", "--page-limit", "2"},
{"chat", "+at-me", "--max-items", "1"},
{"chat", "+at-me", "--page-delay", "1"},
{"chat", "+at-me", "--page-all", "--page-limit", "501"},
{"chat", "+at-me", "--page-all", "--max-items", "-1"},
{"chat", "+my-groups", "--limit", "201"},
{"chat", "+my-groups", "--page-limit", "2"},
{"chat", "+my-groups", "--max-items", "1"},
{"chat", "+my-groups", "--page-all", "--page-limit", "501"},
} {
helpers.InitDeps(&chatMessagesPagingCaller{})
@@ -463,6 +463,7 @@ func collectAllThreadReplies(rt *shortcut.RuntimeContext, params map[string]any)
payload["hasMore"] = hasMore
payload["stopReason"] = stopReason
payload["truncatedByPageLimit"] = truncatedByPageLimit
chatmsg.ApplyTruncation(payload)
payload["failedCount"] = len(failures)
payload["failures"] = failures
payload["partial"] = len(failures) > 0 && len(allItems) > 0
@@ -12,11 +12,11 @@
<!-- DWS_MESSAGE_RESULT_CONTRACT_START -->
- `version`: `im.message-list.v1`
- `message_fields`: `messageId`, `conversationId`, `threadId`, `sender`, `senderId`, `senderType`, `messageType`, `text`, `createTime`, `updateTime`, `reactions`, `quotedMessage`, `forwarded`, `resourceRefs`
- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads`
- `envelope_fields`: `contractVersion`, `messages`, `count`, `resolvedFilters`, `queryRange`, `pagesFetched`, `paginationKnown`, `complete`, `hasMore`, `nextPage`, `stopReason`, `truncated`, `truncatedByPageLimit`, `truncatedByResultLimit`, `failedCount`, `failures`, `partial`, `scope`, `resourceDownloads`
<!-- DWS_MESSAGE_RESULT_CONTRACT_END -->
当 `complete=false` 时不能称为全量成功。`nextPage` 只能来自真实 lower boundary;
`failedCount/failures`、`partial` 和两个 truncated 字段必须原样保留。
`failedCount/failures`、`partial`、总 `truncated` 和两个原因字段必须原样保留。
当 Runtime 解析并应用自然发送者条件时,`resolvedFilters.senders[]` 保留原查询及选中的
`userId/openDingTalkId`。消息展示名可以与通讯录姓名不同;只能用稳定 `senderId` 与解析结果关联,
不得重新做姓名字符串比较。