// 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 ( "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. 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; // 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. // // dws chat +thread-replies --group --thread-id var ThreadReplies = shortcut.Shortcut{ Service: "chat", Command: "+thread-replies", Product: "chat", 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{ Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", }, Contract: corecmd.ContractDecl{ Identity: contract.ToolIdentitySpec{ ProductID: "chat", Name: "shortcut_thread_replies", CanonicalPath: "chat.shortcut_thread_replies", CLIPath: "chat +thread-replies", PrimaryCLIPath: "chat +thread-replies", }, Description: "按主消息 ID 或 thread/topic ID 分页读取话题回复,支持完整排序与有界自动翻页", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", 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: "按主消息 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 ", }, }, }, Flags: append([]shortcut.Flag{ {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{"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", "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: executeThreadReplies, } func validateThreadReplies(rt *shortcut.RuntimeContext) error { if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil { return err } 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 chatmsg.ApplyTruncation(payload) 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. func threadReplyItems(data map[string]any) []map[string]any { if data == nil { return nil } // Try the list keys directly on the response, then inside a wrapper. scopes := []map[string]any{data} for _, wrap := range []string{"result", "data"} { if inner, ok := data[wrap].(map[string]any); ok { scopes = append(scopes, inner) } } for _, scope := range scopes { for _, key := range []string{"replies", "list", "items", "messages", "records", "data", "result"} { if raw, ok := scope[key].([]any); ok { out := make([]map[string]any, 0, len(raw)) for _, e := range raw { if m, ok := e.(map[string]any); ok { out = append(out, m) } } if len(out) > 0 { return out } } } } return nil } func init() { shortcut.Register(ThreadReplies) }