986 lines
41 KiB
Go
986 lines
41 KiB
Go
// 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 (
|
||
"errors"
|
||
"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"
|
||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||
)
|
||
|
||
var dingTalkMessageLocation = time.FixedZone("CST", 8*60*60)
|
||
|
||
const (
|
||
chatMessagesDefaultPageLimit = 50
|
||
chatMessagesHardPageLimit = 500
|
||
chatMessagesAllPageSize = 100
|
||
)
|
||
|
||
func formatDingTalkMessageBoundary(now time.Time) string {
|
||
localized := now.In(dingTalkMessageLocation)
|
||
if localized.Nanosecond() != 0 {
|
||
return localized.Format(time.RFC3339Nano)
|
||
}
|
||
return localized.Format("2006-01-02 15:04:05")
|
||
}
|
||
|
||
// ChatMessages resolves one conversation, projects messages into the shared
|
||
// typed result contract, and optionally follows bounded continuation pages,
|
||
// downloads resources, or atomically exports the complete ledger as JSON.
|
||
//
|
||
// dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"
|
||
// dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --limit 50
|
||
var ChatMessages = shortcut.Shortcut{
|
||
Service: "chat",
|
||
Command: "+chat-messages",
|
||
Product: "chat",
|
||
Description: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||
Intent: "当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选的姓名、userId 或 openDingTalkId 混合入口:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选并保留 identity_unverified,可交付精确命中但不能把原值升级为已验证身份或作完整否定结论。--sender-query 只按姓名唯一解析,解析失败会抑制未过滤消息并返回错误。不传发送者条件时原样读取会话且不查询发送者身份。sender 展示名不参与身份比较;" +
|
||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||
"默认只读;--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_chat_messages",
|
||
CanonicalPath: "chat.shortcut_chat_messages",
|
||
CLIPath: "chat +chat-messages",
|
||
PrimaryCLIPath: "chat +chat-messages",
|
||
},
|
||
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: "读取指定群聊或单聊的消息记录,支持有界全量分页与原子 JSON 导出",
|
||
UseWhen: []string{"当你要读取或导出一个指定群聊或单聊的消息记录时使用;--sender 是可选的姓名、userId 或 openDingTalkId 混合入口:姓名优先唯一解析,稳定 ID 精确路由;通讯录无法分类时仍按原值 userId 筛选并保留 identity_unverified,可交付精确命中但不能把原值升级为已验证身份或作完整否定结论。--sender-query 只按姓名唯一解析,解析失败会抑制未过滤消息并返回错误。不传发送者条件时原样读取会话且不查询发送者身份。sender 展示名不参与身份比较;" +
|
||
"群聊的 --group 可传群名或 openConversationId,单聊可传 --user 或 --open-dingtalk-id,所有目标参数互斥且必须选一个。自然群名只在唯一解析后读取,多候选会返回结构化 candidates。" +
|
||
"省略时间参数时默认从当前时间向前读取最近消息;兼容模式可用 --time/--direction,范围模式可用公开可选的 --start/--end/--order(兼容 --start-time/--end-time/--sort),范围语义为 [start,end)。" +
|
||
"全量读取用 --page-all,并由 --page-limit/--max-items 保持有界;结果公开 complete、hasMore、nextPage、stopReason、截断和逐页失败,不能把部分结果称为完整。--output 把同一 ledger 原子写为工作目录内 JSON。" +
|
||
"默认只读;--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘。"},
|
||
AvoidWhen: []string{"以发送者、关键词、@对象或消息类型为主的直接条件检索优先使用 +search-msg;已有一批精确消息 ID 时使用 +messages-mget。已选择会话读取时可在同一次调用附带发送者姓名,不需要再搜索消息"},
|
||
Examples: []string{
|
||
"dws chat +chat-messages --group <openConversationId> --direction older",
|
||
"dws chat +chat-messages --group <openConversationId> --direction older --jq '.messages[] | {messageId, text}'",
|
||
},
|
||
},
|
||
},
|
||
Flags: append([]shortcut.Flag{
|
||
{Name: "group", Type: shortcut.FlagString, Desc: "群名称或 openConversationId,与单聊目标互斥"},
|
||
{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: "按群名唯一解析目标会话(可选,与其他会话目标参数互斥)"},
|
||
{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", Type: shortcut.FlagStringSlice, Desc: "单个或多个发送者姓名、userId 或 openDingTalkId;姓名唯一解析,稳定 ID 精确路由,通讯录无法分类时按原值 userId 筛选并保留身份未验证状态"},
|
||
{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},
|
||
{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-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-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{
|
||
{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{"sender", "sender-query"}},
|
||
{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-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"},
|
||
Description: "--output 必须是工作目录内的相对 JSON 文件;默认不覆盖,--overwrite 仅与 --output 一起使用",
|
||
},
|
||
}, chatshortcut.MessageResourceDownloadConstraints()...),
|
||
Tips: []string{
|
||
`dws chat +chat-messages --group <openconversation_id> --time "2025-03-01 00:00:00"`,
|
||
`dws chat +chat-messages --group <openconversation_id> --start "2025-03-01T00:00:00+08:00" --end "2025-03-02T00:00:00+08:00" --order asc --page-all`,
|
||
`dws chat +chat-messages --user <userId> --time "2025-03-01 00:00:00" --page-all --page-limit 50`,
|
||
`dws chat +chat-messages --group <openconversation_id> --direction older --page-all --output ./exports/messages.json`,
|
||
`dws chat +chat-messages --group <openconversation_id> --direction older --jq '.messages[] | {messageId, text}'`,
|
||
},
|
||
Validate: validateChatMessages,
|
||
Execute: executeChatMessages,
|
||
}
|
||
|
||
func validateChatMessages(rt *shortcut.RuntimeContext) error {
|
||
if err := chatshortcut.ValidateMessageResourceDownload(rt); err != nil {
|
||
return err
|
||
}
|
||
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)
|
||
}
|
||
}
|
||
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 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 {
|
||
return err
|
||
}
|
||
} else if rt.Bool("overwrite") {
|
||
return apperrors.NewValidation("--overwrite 仅与 --output 一起使用")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
type chatMessagesRequest struct {
|
||
tool string
|
||
params map[string]any
|
||
direction string
|
||
fallbackConversationID string
|
||
timeRange chatMessageTimeRange
|
||
}
|
||
|
||
type chatMessagesSenderFilter struct {
|
||
requested bool
|
||
applied bool
|
||
inputs []string
|
||
inputMode string
|
||
stableIDs map[string]bool
|
||
resolutions []targetresolver.UserResolution
|
||
resolutionFailure map[string]any
|
||
resolutionErr error
|
||
scopeErr error
|
||
}
|
||
|
||
// resolveOptionalChatMessagesSenderFilter accepts the same mixed user target
|
||
// as +search-msg while retaining --sender-query as a compatibility entry. It
|
||
// resolves only explicitly requested senders; it never enumerates every member
|
||
// or tries to infer a person from message display names.
|
||
func resolveOptionalChatMessagesSenderFilter(rt *shortcut.RuntimeContext) chatMessagesSenderFilter {
|
||
direct := uniqueChatMessageTargets(rt.StrSlice("sender"))
|
||
queries := uniqueChatMessageTargets(rt.StrSlice("sender-query"))
|
||
inputs := direct
|
||
inputMode := "sender"
|
||
if len(inputs) == 0 {
|
||
inputs = queries
|
||
inputMode = "sender-query"
|
||
}
|
||
filter := chatMessagesSenderFilter{
|
||
requested: len(inputs) > 0,
|
||
inputs: inputs,
|
||
inputMode: inputMode,
|
||
stableIDs: map[string]bool{},
|
||
}
|
||
if !filter.requested {
|
||
return filter
|
||
}
|
||
|
||
var resolutions []targetresolver.UserResolution
|
||
var err error
|
||
if len(direct) > 0 {
|
||
for _, value := range direct {
|
||
var resolved targetresolver.UserResolution
|
||
resolved, err = targetresolver.ResolveSenderTarget(rt, value, targetresolver.IdentityAny)
|
||
if err != nil {
|
||
break
|
||
}
|
||
resolutions = append(resolutions, resolved)
|
||
}
|
||
} else {
|
||
resolutions, err = targetresolver.ResolveUsers(rt, queries, targetresolver.IdentityAny)
|
||
}
|
||
if err != nil {
|
||
failure := map[string]any{
|
||
"stage": "sender_resolution",
|
||
"inputs": inputs,
|
||
"mode": inputMode,
|
||
"error": err.Error(),
|
||
}
|
||
var typed *apperrors.Error
|
||
if errors.As(err, &typed) {
|
||
if typed.Reason != "" {
|
||
failure["reason"] = typed.Reason
|
||
}
|
||
if typed.Hint != "" {
|
||
failure["hint"] = typed.Hint
|
||
}
|
||
if len(typed.Details) > 0 {
|
||
failure["details"] = typed.Details
|
||
}
|
||
}
|
||
filter.resolutionFailure = failure
|
||
filter.resolutionErr = err
|
||
return filter
|
||
}
|
||
|
||
filter.resolutions = resolutions
|
||
addChatMessagesResolvedSenderIDs(filter.stableIDs, resolutions)
|
||
filter.applied = true
|
||
return filter
|
||
}
|
||
|
||
func uniqueChatMessageTargets(values []string) []string {
|
||
seen := map[string]bool{}
|
||
result := make([]string, 0, len(values))
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" || seen[value] {
|
||
continue
|
||
}
|
||
seen[value] = true
|
||
result = append(result, value)
|
||
}
|
||
return result
|
||
}
|
||
|
||
func addChatMessagesResolvedSenderIDs(ids map[string]bool, resolutions []targetresolver.UserResolution) {
|
||
for _, resolution := range resolutions {
|
||
if identity := strings.TrimSpace(resolution.Selected.UserID); identity != "" {
|
||
ids[identity] = true
|
||
}
|
||
if identity := strings.TrimSpace(resolution.Selected.OpenDingTalkID); identity != "" {
|
||
ids[identity] = true
|
||
}
|
||
}
|
||
}
|
||
|
||
// applyOptionalChatMessagesSenderFilter fails closed when an explicitly
|
||
// requested sender cannot be resolved. 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 == nil || !filter.requested || payload == nil {
|
||
return rawItems
|
||
}
|
||
if !filter.applied {
|
||
failures, _ := payload["failures"].([]map[string]any)
|
||
priorFailures := len(failures)
|
||
failure := filter.resolutionFailure
|
||
if failure == nil {
|
||
failure = map[string]any{
|
||
"stage": "sender_resolution",
|
||
"inputs": filter.inputs,
|
||
"mode": filter.inputMode,
|
||
"error": "无法把发送者目标唯一解析为稳定身份",
|
||
}
|
||
}
|
||
failures = append(failures, failure)
|
||
payload["failures"] = failures
|
||
payload["failedCount"] = len(failures)
|
||
payload["complete"] = false
|
||
payload["partial"] = false
|
||
payload["messages"] = []map[string]any{}
|
||
payload["count"] = 0
|
||
payload["senderFilter"] = map[string]any{
|
||
"requested": true,
|
||
"inputs": filter.inputs,
|
||
"mode": filter.inputMode,
|
||
"applied": false,
|
||
"status": "resolution_failed",
|
||
"suppressedCount": len(rawItems),
|
||
}
|
||
payload["identityResult"] = map[string]any{
|
||
"status": "resolution_failed",
|
||
"nameComparisonAllowed": false,
|
||
"stableIdField": "senderId",
|
||
"hint": "当前发送者尚未解析为稳定身份,不能根据 sender 展示名判断该人员是否发过消息。请先完成人员解析。",
|
||
}
|
||
if priorFailures == 0 {
|
||
payload["stopReason"] = "sender_resolution_failed"
|
||
}
|
||
return nil
|
||
}
|
||
|
||
filtered := make([]map[string]any, 0, len(rawItems))
|
||
unverifiableMessageIDs := make([]string, 0)
|
||
for _, item := range rawItems {
|
||
identity := strings.TrimSpace(fmt.Sprint(chatmsg.SenderID(item)))
|
||
if identity == "" || identity == "<nil>" {
|
||
messageID := chatmsg.StableMessageID(item)
|
||
if messageID == "" {
|
||
messageID = "<unknown>"
|
||
}
|
||
unverifiableMessageIDs = append(unverifiableMessageIDs, messageID)
|
||
continue
|
||
}
|
||
if filter.stableIDs[identity] {
|
||
filtered = append(filtered, item)
|
||
}
|
||
}
|
||
if len(unverifiableMessageIDs) > 0 {
|
||
filter.scopeErr = apperrors.NewAPI(
|
||
"部分消息缺少 senderId,无法证明发送者过滤范围;已阻止完整成功与导出",
|
||
apperrors.WithReason("chat_messages_sender_scope_unverified"),
|
||
apperrors.WithRetryable(false),
|
||
apperrors.WithHint("请保留 trace_id 并检查消息读取服务是否返回稳定 senderId"),
|
||
apperrors.WithDetails(map[string]any{
|
||
"requestedSenders": filter.inputs,
|
||
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
|
||
}),
|
||
)
|
||
failures, _ := payload["failures"].([]map[string]any)
|
||
failures = append(failures, map[string]any{
|
||
"stage": "sender_scope_verification",
|
||
"error": filter.scopeErr.Error(),
|
||
"unverifiableMessageIds": uniqueChatMessageTargets(unverifiableMessageIDs),
|
||
})
|
||
payload["failures"] = failures
|
||
payload["failedCount"] = len(failures)
|
||
payload["complete"] = false
|
||
payload["partial"] = len(filtered) > 0
|
||
}
|
||
unverifiedSenderInputs := chatMessagesUnverifiedSenderInputs(filter.resolutions)
|
||
if len(unverifiedSenderInputs) > 0 {
|
||
failures, _ := payload["failures"].([]map[string]any)
|
||
failures = append(failures, map[string]any{
|
||
"stage": "sender_identity_verification",
|
||
"inputs": unverifiedSenderInputs,
|
||
"error": "通讯录未能确认这些混合发送者参数是姓名还是 userId;已按精确 userId 过滤,但不能据此作完整否定结论",
|
||
})
|
||
payload["failures"] = failures
|
||
payload["failedCount"] = len(failures)
|
||
payload["complete"] = false
|
||
payload["partial"] = len(filtered) > 0
|
||
}
|
||
payload["messages"] = projectChatMessages(filtered, !rt.Bool("no-reactions"))
|
||
payload["count"] = len(filtered)
|
||
payload["resolvedFilters"] = map[string]any{"senders": filter.resolutions}
|
||
filterStatus := "applied"
|
||
if filter.scopeErr != nil {
|
||
filterStatus = "scope_unverified"
|
||
} else if len(unverifiedSenderInputs) > 0 {
|
||
filterStatus = "identity_unverified"
|
||
}
|
||
payload["senderFilter"] = map[string]any{
|
||
"requested": true,
|
||
"inputs": filter.inputs,
|
||
"mode": filter.inputMode,
|
||
"applied": true,
|
||
"status": filterStatus,
|
||
}
|
||
if len(unverifiedSenderInputs) > 0 {
|
||
payload["senderFilter"].(map[string]any)["unverifiedInputs"] = unverifiedSenderInputs
|
||
}
|
||
identityResult := map[string]any{
|
||
"status": "evaluated",
|
||
"nameComparisonAllowed": false,
|
||
"negativeConclusionAllowed": true,
|
||
"stableIdField": "senderId",
|
||
}
|
||
if filter.scopeErr != nil {
|
||
identityResult["status"] = "scope_unverified"
|
||
identityResult["negativeConclusionAllowed"] = false
|
||
identityResult["hint"] = "部分消息缺少稳定 senderId,当前结果不能用于断言该人员没有发过消息。"
|
||
} else if len(unverifiedSenderInputs) > 0 {
|
||
identityResult["status"] = "identity_unverified"
|
||
identityResult["negativeConclusionAllowed"] = false
|
||
identityResult["hint"] = "当前混合发送者参数未能经通讯录确认;已按原值 userId 精确比较,若无命中仍不能断言该人员没有发过消息。"
|
||
}
|
||
payload["identityResult"] = identityResult
|
||
return filtered
|
||
}
|
||
|
||
func chatMessagesUnverifiedSenderInputs(
|
||
resolutions []targetresolver.UserResolution,
|
||
) []string {
|
||
values := make([]string, 0)
|
||
for _, resolution := range resolutions {
|
||
if targetresolver.IsUnverifiedUserIDResolution(resolution) {
|
||
values = append(values, resolution.Query)
|
||
}
|
||
}
|
||
return uniqueChatMessageTargets(values)
|
||
}
|
||
|
||
func attachUnfilteredSenderIdentitySemantics(payload map[string]any, filter chatMessagesSenderFilter) {
|
||
if payload == nil || filter.requested {
|
||
return
|
||
}
|
||
payload["senderFilter"] = map[string]any{
|
||
"requested": false,
|
||
"applied": false,
|
||
"status": "not_requested",
|
||
}
|
||
payload["identityResult"] = map[string]any{
|
||
"status": "requires_query_check",
|
||
"negativeConclusionAllowed": false,
|
||
"hint": "若原始任务包含自然人发送者条件,请先将目标人解析为唯一 userId/openDingTalkId,再与 messages[].senderId 比较;禁止直接比较 messages[].sender 展示名。",
|
||
}
|
||
}
|
||
|
||
func resolveChatMessagesRequest(rt *shortcut.RuntimeContext) (chatMessagesRequest, error) {
|
||
groupID := strings.TrimSpace(rt.StrFirst("conversation-id", "id", "open-conversation-id"))
|
||
userID := rt.Str("user")
|
||
openID := rt.Str("open-dingtalk-id")
|
||
if targetresolver.LooksLikeOpenConversationID(openID) {
|
||
return chatMessagesRequest{}, apperrors.NewValidation(
|
||
"--open-dingtalk-id 收到的是群 openConversationId;群聊请改用 --group(兼容别名 --chat)",
|
||
)
|
||
}
|
||
if openID != "" {
|
||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openID); err != nil {
|
||
return chatMessagesRequest{}, err
|
||
}
|
||
}
|
||
if groupID == "" && (rt.Str("group") != "" || rt.Str("chat-query") != "") {
|
||
resolved, err := targetresolver.ResolveChatTarget(rt, rt.Str("group"), rt.Str("chat-query"))
|
||
if err != nil {
|
||
return chatMessagesRequest{}, err
|
||
}
|
||
groupID = resolved.Selected.OpenConversationID
|
||
}
|
||
if query := rt.Str("user-query"); query != "" {
|
||
resolved, err := targetresolver.ResolveUser(rt, query, targetresolver.IdentityOpenDingTalkID)
|
||
if err != nil {
|
||
return chatMessagesRequest{}, err
|
||
}
|
||
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 = timeRange.direction()
|
||
}
|
||
params := map[string]any{
|
||
"time": timeRange.initialBoundary(now),
|
||
"forward": direction == "newer",
|
||
}
|
||
if rt.Changed("time") && rt.Str("time") != "" {
|
||
params["time"] = rt.Str("time")
|
||
}
|
||
if limit := rt.IntFirst("limit", "size", "page-size"); limit > 0 {
|
||
params["limit"] = limit
|
||
} else if rt.Bool("page-all") {
|
||
params["limit"] = chatMessagesAllPageSize
|
||
}
|
||
|
||
request := chatMessagesRequest{params: params, direction: direction, timeRange: timeRange}
|
||
switch {
|
||
case groupID != "":
|
||
request.tool = "list_conversation_message_v2"
|
||
request.params["openconversation_id"] = groupID
|
||
request.fallbackConversationID = groupID
|
||
case openID != "":
|
||
request.tool = "list_individual_chat_message"
|
||
request.params["openDingTalkId"] = openID
|
||
default:
|
||
request.tool = "list_individual_chat_message"
|
||
request.params["userId"] = userID
|
||
}
|
||
return request, nil
|
||
}
|
||
|
||
func executeChatMessages(rt *shortcut.RuntimeContext) error {
|
||
request, err := resolveChatMessagesRequest(rt)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var payload map[string]any
|
||
var rawItems []map[string]any
|
||
if rt.Bool("page-all") {
|
||
payload, rawItems, err = collectAllChatMessages(rt, request)
|
||
} 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)
|
||
attachUnfilteredSenderIdentitySemantics(payload, 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
|
||
// resource downloads or a requested export can look successful.
|
||
if payload != nil {
|
||
if outputErr := rt.Output(payload); outputErr != nil {
|
||
return outputErr
|
||
}
|
||
}
|
||
return err
|
||
}
|
||
if senderFilter.requested && !senderFilter.applied {
|
||
if payload != nil {
|
||
if outputErr := rt.Output(payload); outputErr != nil {
|
||
return outputErr
|
||
}
|
||
}
|
||
return senderFilter.resolutionErr
|
||
}
|
||
if senderFilter.scopeErr != nil {
|
||
if payload != nil {
|
||
if outputErr := rt.Output(payload); outputErr != nil {
|
||
return outputErr
|
||
}
|
||
}
|
||
return senderFilter.scopeErr
|
||
}
|
||
if rt.Bool("download-resources") {
|
||
chatshortcut.AttachMessageResourceDownloads(
|
||
payload,
|
||
chatshortcut.DownloadMessageResources(rt, rawItems, request.fallbackConversationID),
|
||
)
|
||
}
|
||
if rt.Changed("output") {
|
||
if rt.DryRun() {
|
||
payload["export"] = map[string]any{
|
||
"dryRun": true,
|
||
"format": "json",
|
||
"localPath": rt.Str("output"),
|
||
"overwrite": rt.Bool("overwrite"),
|
||
}
|
||
} else {
|
||
path, size, writeErr := chatshortcut.WriteMessageExportJSON(
|
||
rt.Str("output"), rt.Bool("overwrite"), payload)
|
||
if writeErr != nil {
|
||
return writeErr
|
||
}
|
||
payload["export"] = map[string]any{
|
||
"format": "json",
|
||
"localPath": path,
|
||
"sizeBytes": size,
|
||
}
|
||
}
|
||
}
|
||
return rt.Output(payload)
|
||
}
|
||
|
||
func collectOneChatMessagesPage(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
|
||
data, err := rt.CallMCPData("chat", request.tool, request.params)
|
||
if err != nil {
|
||
return nil, nil, err
|
||
}
|
||
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, 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 {
|
||
payload["stopReason"] = "single_page"
|
||
}
|
||
return payload, items, nil
|
||
}
|
||
|
||
func collectAllChatMessages(rt *shortcut.RuntimeContext, request chatMessagesRequest) (map[string]any, []map[string]any, error) {
|
||
pageLimit := defaultChatPageLimit(rt.Int("page-limit"), chatMessagesDefaultPageLimit)
|
||
maxResults := rt.IntFirst("max-items", "max-results")
|
||
basePageSize, _ := request.params["limit"].(int)
|
||
if basePageSize <= 0 {
|
||
basePageSize = chatMessagesAllPageSize
|
||
}
|
||
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
|
||
truncatedByResultLimit := false
|
||
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)
|
||
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{
|
||
"page": pagesFetched + 1,
|
||
"stage": "read",
|
||
"error": err.Error(),
|
||
})
|
||
stopReason = "read_failure"
|
||
break
|
||
}
|
||
pagesFetched++
|
||
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] {
|
||
continue
|
||
}
|
||
if stableID != "" {
|
||
seenIDs[stableID] = true
|
||
}
|
||
if maxResults > 0 && len(allItems) >= maxResults {
|
||
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)
|
||
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 maxResults > 0 && len(allItems) >= maxResults {
|
||
truncatedByResultLimit = pageHasMore || moreEligibleOnPage
|
||
if truncatedByResultLimit {
|
||
hasMore = true
|
||
stopReason = "result_limit"
|
||
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
|
||
}
|
||
}
|
||
if !pageHasMore {
|
||
complete = true
|
||
hasMore = false
|
||
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 := chatMessagesNextCursorBoundary(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": request.direction,
|
||
"time": boundary,
|
||
"nextCursor": page["nextCursor"],
|
||
}
|
||
request.params["time"] = boundary
|
||
}
|
||
if !complete && hasMore && len(failures) == 0 && pagesFetched >= pageLimit {
|
||
truncatedByPageLimit = true
|
||
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
|
||
payload["hasMore"] = hasMore
|
||
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
|
||
if hasMore && nextPage != nil {
|
||
payload["nextPage"] = nextPage
|
||
}
|
||
if len(failures) > 0 {
|
||
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)),
|
||
apperrors.WithOperation("chat/"+request.tool),
|
||
apperrors.WithReason("chat_messages_incomplete"),
|
||
apperrors.WithOrigin("mcp_gateway"),
|
||
apperrors.WithFailureStage(failureStage),
|
||
apperrors.WithExecutionStarted(true),
|
||
apperrors.WithRetryable(true),
|
||
apperrors.WithHint("请根据 failures 和 nextPage 重试;失败 ledger 不会写入 --output 文件"),
|
||
apperrors.WithDetails(map[string]any{
|
||
"pagesFetched": pagesFetched,
|
||
"failedCount": len(failures),
|
||
"failures": failures,
|
||
"partial": len(results) > 0,
|
||
"stopReason": stopReason,
|
||
}),
|
||
)
|
||
}
|
||
return payload, allItems, nil
|
||
}
|
||
|
||
func projectChatMessages(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 results
|
||
}
|
||
|
||
// 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,
|
||
// tolerating the common container keys and one level of nesting under a
|
||
// "result"/"data" wrapper.
|
||
func chatMessageItems(data map[string]any) []map[string]any {
|
||
return chatmsg.ListMessageItems(data)
|
||
}
|
||
|
||
// projectChatMessage reshapes one raw message into the clean
|
||
// {sender, text, createTime} projection, rendering card/auto-reply JSON and
|
||
// marking encrypted messages via chatmsg, and recursively expanding forwarded
|
||
// chat records under "forwarded".
|
||
func projectChatMessage(m map[string]any) map[string]any {
|
||
return projectChatMessageWithReactions(m, true)
|
||
}
|
||
|
||
func projectChatMessageWithReactions(m map[string]any, includeReactions bool) map[string]any {
|
||
return chatmsg.ProjectMessageV1(m, includeReactions)
|
||
}
|
||
|
||
func init() {
|
||
shortcut.Register(ChatMessages)
|
||
}
|