247 lines
6.8 KiB
Go
247 lines
6.8 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.
|
|
|
|
package smart
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg"
|
|
)
|
|
|
|
const messageTimeRangeSemantics = "[start,end)"
|
|
|
|
type chatMessageTimeRange struct {
|
|
configured bool
|
|
start *time.Time
|
|
end *time.Time
|
|
order string
|
|
}
|
|
|
|
func resolveChatMessageTimeRange(rt *shortcut.RuntimeContext, now time.Time) (chatMessageTimeRange, error) {
|
|
startValue := strings.TrimSpace(rt.StrFirst("start", "start-time"))
|
|
endValue := strings.TrimSpace(rt.StrFirst("end", "end-time"))
|
|
order := strings.ToLower(strings.TrimSpace(rt.StrFirst("order", "sort")))
|
|
configured := startValue != "" || endValue != "" || order != ""
|
|
if order == "" {
|
|
order = "desc"
|
|
}
|
|
|
|
result := chatMessageTimeRange{configured: configured, order: order}
|
|
if startValue != "" {
|
|
parsed, err := parseDingTalkMessageTime(startValue)
|
|
if err != nil {
|
|
return chatMessageTimeRange{}, localChatOptionError(
|
|
"invalid_start_time", "+chat-messages 的开始时间格式无效", "--start/--start-time")
|
|
}
|
|
result.start = &parsed
|
|
}
|
|
if endValue != "" {
|
|
parsed, err := parseDingTalkMessageTime(endValue)
|
|
if err != nil {
|
|
return chatMessageTimeRange{}, localChatOptionError(
|
|
"invalid_end_time", "+chat-messages 的结束时间格式无效", "--end/--end-time")
|
|
}
|
|
result.end = &parsed
|
|
}
|
|
if result.start != nil && result.end != nil && !result.end.After(*result.start) {
|
|
return chatMessageTimeRange{}, apperrors.NewValidation("--end/--end-time 必须晚于 --start/--start-time")
|
|
}
|
|
if order == "asc" && result.start == nil {
|
|
return chatMessageTimeRange{}, apperrors.NewValidation("升序读取必须指定 --start/--start-time,避免把最近一页倒序后误报为最早消息")
|
|
}
|
|
if result.start != nil && result.end == nil {
|
|
effectiveEnd := now
|
|
result.end = &effectiveEnd
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (r chatMessageTimeRange) initialBoundary(now time.Time) string {
|
|
if !r.configured {
|
|
return formatDingTalkMessageBoundary(now.Truncate(time.Second))
|
|
}
|
|
if r.order == "asc" && r.start != nil {
|
|
return formatDingTalkMessageBoundary(*r.start)
|
|
}
|
|
if r.end != nil {
|
|
return formatDingTalkMessageBoundary(*r.end)
|
|
}
|
|
return formatDingTalkMessageBoundary(now)
|
|
}
|
|
|
|
func (r chatMessageTimeRange) direction() string {
|
|
if r.order == "asc" {
|
|
return "newer"
|
|
}
|
|
return "older"
|
|
}
|
|
|
|
func (r chatMessageTimeRange) stopReason() string {
|
|
if r.order == "asc" {
|
|
return "range_end"
|
|
}
|
|
return "range_start"
|
|
}
|
|
|
|
func (r chatMessageTimeRange) metadata() map[string]any {
|
|
if !r.configured {
|
|
return nil
|
|
}
|
|
result := map[string]any{
|
|
"order": r.order,
|
|
"semantics": messageTimeRangeSemantics,
|
|
}
|
|
if r.start != nil {
|
|
result["startTime"] = r.start.Format(time.RFC3339Nano)
|
|
}
|
|
if r.end != nil {
|
|
result["endTime"] = r.end.Format(time.RFC3339Nano)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (r chatMessageTimeRange) filter(items []map[string]any) ([]map[string]any, bool, []map[string]any) {
|
|
if r.start == nil && r.end == nil {
|
|
return items, false, nil
|
|
}
|
|
filtered := make([]map[string]any, 0, len(items))
|
|
unknown := make([]string, 0)
|
|
terminalReached := false
|
|
for _, item := range items {
|
|
messageTime, ok := parseMessageTimestamp(chatmsg.CreateTime(item))
|
|
if !ok {
|
|
unknown = append(unknown, chatmsg.StableMessageID(item))
|
|
continue
|
|
}
|
|
if r.start != nil && messageTime.Before(*r.start) {
|
|
if r.order == "desc" {
|
|
terminalReached = true
|
|
}
|
|
continue
|
|
}
|
|
if r.end != nil && !messageTime.Before(*r.end) {
|
|
if r.order == "asc" {
|
|
terminalReached = true
|
|
}
|
|
continue
|
|
}
|
|
filtered = append(filtered, item)
|
|
}
|
|
if len(unknown) == 0 {
|
|
return filtered, terminalReached, nil
|
|
}
|
|
return filtered, terminalReached, []map[string]any{{
|
|
"stage": "time_filter",
|
|
"messageIds": unknown,
|
|
"error": "消息缺少可解析的 createTime,无法证明时间范围结果完整",
|
|
}}
|
|
}
|
|
|
|
func parseDingTalkMessageTime(value string) (time.Time, error) {
|
|
value = strings.TrimSpace(value)
|
|
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
|
return parsed, nil
|
|
}
|
|
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02"} {
|
|
if parsed, err := time.ParseInLocation(layout, value, dingTalkMessageLocation); err == nil {
|
|
return parsed, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("unsupported message time %q", value)
|
|
}
|
|
|
|
func parseMessageTimestamp(value any) (time.Time, bool) {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
trimmed := strings.TrimSpace(typed)
|
|
if parsed, err := parseDingTalkMessageTime(trimmed); err == nil {
|
|
return parsed, true
|
|
}
|
|
if numeric, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
|
|
return unixMessageTime(numeric), true
|
|
}
|
|
case json.Number:
|
|
if numeric, err := typed.Int64(); err == nil {
|
|
return unixMessageTime(numeric), true
|
|
}
|
|
case float64:
|
|
return unixMessageTime(int64(typed)), true
|
|
case float32:
|
|
return unixMessageTime(int64(typed)), true
|
|
case int:
|
|
return unixMessageTime(int64(typed)), true
|
|
case int64:
|
|
return unixMessageTime(typed), true
|
|
case int32:
|
|
return unixMessageTime(int64(typed)), true
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
func unixMessageTime(value int64) time.Time {
|
|
if value > -1_000_000_000_000 && value < 1_000_000_000_000 {
|
|
return time.Unix(value, 0)
|
|
}
|
|
return time.UnixMilli(value)
|
|
}
|
|
|
|
func sortMessagesByCreateTimeStable(messages []map[string]any, order string) {
|
|
order = strings.ToLower(strings.TrimSpace(order))
|
|
if order != "asc" && order != "desc" {
|
|
return
|
|
}
|
|
sort.SliceStable(messages, func(i, j int) bool {
|
|
left, leftOK := parseMessageTimestamp(chatmsg.CreateTime(messages[i]))
|
|
right, rightOK := parseMessageTimestamp(chatmsg.CreateTime(messages[j]))
|
|
if leftOK != rightOK {
|
|
return leftOK
|
|
}
|
|
if !leftOK || left.Equal(right) {
|
|
return false
|
|
}
|
|
if order == "asc" {
|
|
return left.Before(right)
|
|
}
|
|
return left.After(right)
|
|
})
|
|
}
|
|
|
|
func searchMessageQueryRange(params map[string]any, order string) map[string]any {
|
|
result := map[string]any{
|
|
"order": order,
|
|
"semantics": messageTimeRangeSemantics,
|
|
}
|
|
if start, ok := numericMillis(params["startTime"]); ok {
|
|
result["startTime"] = time.UnixMilli(start).In(dingTalkMessageLocation).Format(time.RFC3339)
|
|
}
|
|
if end, ok := numericMillis(params["endTime"]); ok {
|
|
result["endTime"] = time.UnixMilli(end).In(dingTalkMessageLocation).Format(time.RFC3339)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func numericMillis(value any) (int64, bool) {
|
|
switch typed := value.(type) {
|
|
case int64:
|
|
return typed, true
|
|
case int:
|
|
return int64(typed), true
|
|
case float64:
|
|
return int64(typed), true
|
|
case json.Number:
|
|
parsed, err := typed.Int64()
|
|
return parsed, err == nil
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|