Files

646 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
"strconv"
"strings"
"time"
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"
)
// SearchMsg is the semantic message-search entry point. It exposes the native
// IM search dimensions, can exhaust cursor pagination, and enriches sparse
// search hits through list_messages_by_ids in chunks of 50. A later-page or
// enrichment failure never turns a partial result into a false success: the
// output carries an explicit failure ledger and complete=false.
var SearchMsg = shortcut.Shortcut{
Service: "chat",
Command: "+search-msg",
Product: "im",
Description: "多维搜索消息,可全量翻页并批量富化详情",
Intent: "当你要按关键词、发送者、@对象、会话、消息类型或机器人来源组合搜索 IM 消息时使用;默认查询近 7 天," +
"也可指定精确起止时间。--page-all 会连续拉取游标页,默认再按消息 ID 分批富化详情;任何续页或富化失败都会保留已取得结果并返回逐项失败 ledger,绝不把截断结果标成完整。" +
"--download-resources 使用工作目录内安全路径、默认不覆盖和原子落盘,按既有安全下载约定无需交互确认。",
Risk: shortcut.RiskRead,
Flags: append([]shortcut.Flag{
{Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词"},
{Name: "keyword", Type: shortcut.FlagString, Desc: "--query 的别名", Hidden: true},
{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: "groups", Type: shortcut.FlagStringSlice, Desc: "多个会话 openConversationId"},
{Name: "chat-id", Type: shortcut.FlagStringSlice, Desc: "--groups 的 lark-cli 对齐别名"},
{Name: "senders", Type: shortcut.FlagStringSlice, Desc: "发送者 userId/openDingTalkId 列表"},
{Name: "sender", Type: shortcut.FlagStringSlice, Desc: "--senders 的 lark-cli 对齐别名"},
{Name: "at-me", Type: shortcut.FlagBool, Desc: "只搜索 @我 的消息"},
{Name: "is-at-me", Type: shortcut.FlagBool, Desc: "--at-me 的 lark-cli 对齐别名"},
{Name: "at-ids", Type: shortcut.FlagStringSlice, Desc: "@对象 userId/openDingTalkId 列表"},
{Name: "message-type", Type: shortcut.FlagString, Desc: "下层消息类型过滤值(以当前 IM Schema 为准)"},
{Name: "only-robot", Type: shortcut.FlagBool, Desc: "只搜索机器人消息"},
{Name: "conversation-type", Type: shortcut.FlagString, Desc: "下层会话类型过滤值(以当前 IM Schema 为准)"},
{Name: "chat-type", Type: shortcut.FlagString, Desc: "--conversation-type 的 lark-cli 对齐别名"},
{Name: "days", Type: shortcut.FlagInt, Desc: "默认时间窗的回溯天数", Default: "7"},
{Name: "start", Type: shortcut.FlagString, Desc: "精确开始时间(RFC3339,需与 --end 一起传)"},
{Name: "end", Type: shortcut.FlagString, Desc: "精确结束时间(RFC3339,需与 --start 一起传)"},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(1-100)", Default: "100"},
{Name: "page-size", Type: shortcut.FlagInt, Desc: "--limit 的 lark-cli 对齐别名(1-100)"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{Name: "page-token", Type: shortcut.FlagString, Desc: "--cursor 的 lark-cli 对齐别名"},
{Name: "page-all", Type: shortcut.FlagBool, Desc: "自动连续拉取所有游标页"},
{Name: "page-limit", Type: shortcut.FlagInt, Desc: "--page-all 的最大页数(1-40)", Default: "20"},
{Name: "no-enrich", Type: shortcut.FlagBool, Desc: "不再按消息 ID 批量查询完整详情"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出命中消息的 reaction(默认输出)"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: append([]shortcut.Constraint{
{
Kind: shortcut.ConstraintAtLeastOne,
Flags: []string{"query", "keyword", "group", "conversation-id", "id", "groups", "chat-id", "senders", "sender", "at-me", "is-at-me", "at-ids", "message-type", "only-robot", "conversation-type", "chat-type"},
Description: "至少指定一个内容、身份、会话或消息类型过滤条件",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"start"},
Description: "需与 --end 一起传",
},
{
Kind: shortcut.ConstraintCustom,
Flags: []string{"end"},
Description: "需与 --start 一起传",
},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"groups", "chat-id"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"senders", "sender"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"at-me", "is-at-me"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"conversation-type", "chat-type"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"limit", "page-size"}},
{Kind: shortcut.ConstraintMutuallyExclusive, Flags: []string{"cursor", "page-token"}},
}, chatshortcut.MessageResourceDownloadConstraints()...),
Tips: []string{
`dws chat +search-msg --group <openConversationId> --query "changefree"`,
`dws chat +search-msg --senders <openDingTalkId> --at-me --days 3 --page-all`,
},
Validate: validateSearchMsgWithResources,
Execute: func(rt *shortcut.RuntimeContext) error {
params, err := searchMsgParams(rt)
if err != nil {
return err
}
pageLimit := 1
if rt.Bool("page-all") {
pageLimit = rt.Int("page-limit")
}
cursor := rt.StrFirst("page-token", "cursor")
messages := make([]map[string]any, 0)
seen := map[string]bool{}
failures := make([]map[string]any, 0)
pagesFetched := 0
complete := true
hasMore := false
nextCursor := ""
for pagesFetched < pageLimit {
params["cursor"] = cursor
data, callErr := rt.CallMCPData("im", "search_messages", params)
if callErr != nil {
if pagesFetched == 0 {
return callErr
}
failures = append(failures, map[string]any{
"stage": "search-page",
"cursor": cursor,
"error": callErr.Error(),
})
complete = false
break
}
pagesFetched++
for _, message := range searchMsgItems(data) {
messageID := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
if messageID != "" && messageID != "<nil>" {
if seen[messageID] {
continue
}
seen[messageID] = true
}
messages = append(messages, message)
}
page := chatmsg.Pagination(data)
hasMoreValue, hasMoreKnown := page["hasMore"].(bool)
nextCursor = strings.TrimSpace(fmt.Sprint(page["nextCursor"]))
if !hasMoreKnown {
if nextCursor != "" && nextCursor != "<nil>" {
hasMoreValue = true
} else {
failures = append(failures, map[string]any{
"stage": "search-pagination",
"error": "下层未返回 hasMore 或 nextCursor,无法证明结果完整",
})
complete = false
break
}
}
hasMore = hasMoreValue
if !rt.Bool("page-all") || !hasMore {
complete = !hasMore
break
}
if nextCursor == "" || nextCursor == "<nil>" || nextCursor == cursor {
failures = append(failures, map[string]any{
"stage": "search-page",
"error": "下层返回 hasMore=true,但缺少可继续且会前进的 nextCursor",
})
complete = false
break
}
cursor = nextCursor
}
if rt.Bool("page-all") && hasMore && pagesFetched == pageLimit {
failures = append(failures, map[string]any{
"stage": "search-page-limit",
"error": fmt.Sprintf("达到 --page-limit=%d,仍有更多结果", pageLimit),
})
complete = false
}
enrichedCount := 0
if !rt.Bool("no-enrich") && len(messages) > 0 {
var enrichFailures []map[string]any
messages, enrichedCount, enrichFailures = enrichSearchMessages(rt, messages)
failures = append(failures, enrichFailures...)
if len(enrichFailures) > 0 {
complete = false
}
}
results := make([]map[string]any, 0, len(messages))
for _, m := range messages {
results = append(results, searchMsgProjectWithReactions(m, !rt.Bool("no-reactions")))
}
payload := map[string]any{
"count": len(results),
"messages": results,
"pagesFetched": pagesFetched,
"enrichedCount": enrichedCount,
"complete": complete,
"hasMore": hasMore,
"failedCount": len(failures),
"failures": failures,
}
if hasMore && nextCursor != "" && nextCursor != "<nil>" {
payload["nextCursor"] = nextCursor
}
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, messages, "")
}
return rt.Output(payload)
},
}
func validateSearchMsgWithResources(rt *shortcut.RuntimeContext) error {
if err := validateSearchMsg(rt); err != nil {
return err
}
return chatshortcut.ValidateMessageResourceDownload(rt)
}
func validateSearchMsg(rt *shortcut.RuntimeContext) error {
hasFilter := rt.StrFirst("query", "keyword", "group", "conversation-id", "id", "message-type", "conversation-type", "chat-type") != "" ||
len(rt.StrSlice("groups")) > 0 ||
len(rt.StrSlice("chat-id")) > 0 ||
len(rt.StrSlice("senders")) > 0 ||
len(rt.StrSlice("sender")) > 0 ||
len(rt.StrSlice("at-ids")) > 0 ||
rt.Bool("at-me") ||
rt.Bool("is-at-me") ||
rt.Bool("only-robot")
if !hasFilter {
return apperrors.NewValidation("至少指定一个过滤条件,例如 --query、--group、--senders、--at-me 或 --message-type")
}
if rt.Changed("start") != rt.Changed("end") {
return apperrors.NewValidation("--start 与 --end 必须同时指定")
}
if days := rt.Int("days"); days < 1 || days > 3650 {
return apperrors.NewValidation("--days 必须在 1-3650 之间")
}
limit := rt.IntFirst("limit", "page-size")
if limit < 1 || limit > 100 {
return apperrors.NewValidation("--limit 必须在 1-100 之间")
}
if pageLimit := rt.Int("page-limit"); pageLimit < 1 || pageLimit > 40 {
return apperrors.NewValidation("--page-limit 必须在 1-40 之间")
}
return nil
}
func searchMsgParams(rt *shortcut.RuntimeContext) (map[string]any, error) {
params := map[string]any{"limit": rt.IntFirst("limit", "page-size")}
if value := rt.StrFirst("query", "keyword"); value != "" {
params["keyword"] = value
}
conversationIDs := append([]string{}, rt.StrSlice("groups")...)
conversationIDs = append(conversationIDs, rt.StrSlice("chat-id")...)
if value := rt.StrFirst("group", "conversation-id", "id"); value != "" {
conversationIDs = append(conversationIDs, value)
}
if values := uniqueSearchStrings(conversationIDs); len(values) > 0 {
params["openConversationIds"] = values
}
senders := append([]string{}, rt.StrSlice("senders")...)
senders = append(senders, rt.StrSlice("sender")...)
appendSearchActorIDs(params, senders, "senderUserIds", "senderOpenDingTakIds")
appendSearchActorIDs(params, rt.StrSlice("at-ids"), "atUserIds", "atOpenDingTakIds")
if rt.Bool("at-me") || rt.Bool("is-at-me") {
params["atMe"] = true
}
if value := rt.Str("message-type"); value != "" {
params["messageType"] = value
}
if rt.Changed("only-robot") {
params["onlyRobotMessages"] = rt.Bool("only-robot")
}
if value := rt.StrFirst("conversation-type", "chat-type"); value != "" {
params["searchConvType"] = value
}
if rt.Changed("start") && rt.Changed("end") {
start, err := time.Parse(time.RFC3339, rt.Str("start"))
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--start 必须是 RFC3339 时间: %v", err))
}
end, err := time.Parse(time.RFC3339, rt.Str("end"))
if err != nil {
return nil, apperrors.NewValidation(fmt.Sprintf("--end 必须是 RFC3339 时间: %v", err))
}
if !end.After(start) {
return nil, apperrors.NewValidation("--end 必须晚于 --start")
}
params["startTime"] = start.UnixMilli()
params["endTime"] = end.UnixMilli()
} else {
now := time.Now()
params["startTime"] = now.AddDate(0, 0, -rt.Int("days")).UnixMilli()
params["endTime"] = now.UnixMilli()
}
return params, nil
}
func appendSearchActorIDs(params map[string]any, values []string, userKey, openIDKey string) {
var userIDs, openIDs []string
for _, value := range uniqueSearchStrings(values) {
if len(value) > 0 && (value[0] == 'D' || value[0] == 'd') {
openIDs = append(openIDs, value)
} else {
userIDs = append(userIDs, value)
}
}
if len(userIDs) > 0 {
params[userKey] = userIDs
}
if len(openIDs) > 0 {
params[openIDKey] = openIDs
}
}
func uniqueSearchStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]bool{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
func enrichSearchMessages(rt *shortcut.RuntimeContext, messages []map[string]any) ([]map[string]any, int, []map[string]any) {
detailsByID := map[string]map[string]any{}
failures := make([]map[string]any, 0)
ids := make([]string, 0, len(messages))
for _, message := range messages {
if id := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message))); id != "" && id != "<nil>" {
ids = append(ids, id)
}
}
ids = uniqueSearchStrings(ids)
for start := 0; start < len(ids); start += 50 {
end := start + 50
if end > len(ids) {
end = len(ids)
}
chunk := ids[start:end]
data, err := rt.CallMCPData("im", "list_messages_by_ids", map[string]any{"openMsgIds": chunk})
if err != nil {
failures = append(failures, map[string]any{
"stage": "message-enrichment",
"messageIds": chunk,
"error": err.Error(),
})
continue
}
foundInChunk := map[string]bool{}
for _, detail := range searchMsgItems(data) {
if id := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(detail))); id != "" && id != "<nil>" {
detailsByID[id] = detail
foundInChunk[id] = true
}
}
missing := make([]string, 0)
for _, id := range chunk {
if !foundInChunk[id] {
missing = append(missing, id)
}
}
if len(missing) > 0 {
failures = append(failures, map[string]any{
"stage": "message-enrichment",
"missingMessageIds": missing,
"error": "mget 未返回全部请求消息",
})
}
}
enriched := 0
out := make([]map[string]any, 0, len(messages))
for _, message := range messages {
id := strings.TrimSpace(fmt.Sprint(searchMsgMessageID(message)))
detail := detailsByID[id]
if detail == nil {
out = append(out, message)
continue
}
merged := make(map[string]any, len(message)+len(detail))
for key, value := range message {
merged[key] = value
}
for key, value := range detail {
merged[key] = value
}
out = append(out, merged)
enriched++
}
return out, enriched, failures
}
// searchMsgItems locates the message list inside a search_messages_by_keyword
// response, probing common container keys at the top level and nested under
// "result". Returns nil when no list is found.
func searchMsgItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
for _, root := range []map[string]any{data, searchMsgChildMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return searchMsgFlattenGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return searchMsgToMaps(arr)
}
if inner, ok := data[key].(map[string]any); ok {
for _, k2 := range []string{"list", "messages", "messageList", "items", "data", "records"} {
if arr, ok := inner[k2].([]any); ok {
return searchMsgToMaps(arr)
}
}
}
}
return nil
}
func searchMsgChildMap(data map[string]any, key string) map[string]any {
if value, ok := data[key].(map[string]any); ok {
return value
}
return nil
}
func searchMsgFlattenGroups(groups []any) []map[string]any {
out := make([]map[string]any, 0)
for _, rawGroup := range groups {
group, ok := rawGroup.(map[string]any)
if !ok {
continue
}
messages, ok := group["messages"].([]any)
if !ok {
continue
}
conversationID := strings.TrimSpace(fmt.Sprint(group["openConversationId"]))
conversationTitle := strings.TrimSpace(fmt.Sprint(group["title"]))
singleChat, hasSingleChat := group["singleChat"]
for _, rawMessage := range messages {
message, ok := rawMessage.(map[string]any)
if !ok {
continue
}
item := make(map[string]any, len(message)+3)
for key, value := range message {
item[key] = value
}
if _, exists := item["openConversationId"]; !exists &&
conversationID != "" && conversationID != "<nil>" {
item["openConversationId"] = conversationID
}
if _, exists := item["conversationTitle"]; !exists &&
conversationTitle != "" && conversationTitle != "<nil>" {
item["conversationTitle"] = conversationTitle
}
if _, exists := item["singleChat"]; !exists && hasSingleChat {
item["singleChat"] = singleChat
}
out = append(out, item)
}
}
return out
}
func searchMsgToMaps(arr []any) []map[string]any {
out := make([]map[string]any, 0, len(arr))
for _, it := range arr {
if m, ok := it.(map[string]any); ok {
out = append(out, m)
}
}
return out
}
// searchMsgProject reshapes one matched message into {sender, time, text,
// messageId}, running text through the shared chatmsg cleaning (card/auto-reply
// JSON → readable, ciphertext → marker) and recursively expanding any forwarded
// chat record under "forwarded".
func searchMsgProject(m map[string]any) map[string]any {
return searchMsgProjectWithReactions(m, true)
}
func searchMsgProjectWithReactions(m map[string]any, includeReactions bool) map[string]any {
row := map[string]any{
"sender": searchMsgSender(m),
"time": searchMsgTime(m),
"text": searchMsgCleanText(m),
"messageId": searchMsgMessageID(m),
}
if conversationID := chatmsg.ConversationID(m); conversationID != nil {
row["conversationId"] = conversationID
}
if threadID := chatmsg.ThreadID(m); threadID != nil {
row["threadId"] = threadID
}
if messageType := chatmsg.MessageType(m); messageType != nil {
row["messageType"] = messageType
}
if updateTime := chatmsg.UpdateTime(m); updateTime != nil {
row["updateTime"] = updateTime
}
if includeReactions {
if reactions := chatmsg.Reactions(m); len(reactions) > 0 {
row["reactions"] = reactions
}
}
if quoted := chatmsg.QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := chatmsg.ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
return searchMsgProjectWithReactions(item, includeReactions)
}
if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// searchMsgCleanText runs searchMsgText's extraction through chatmsg.CleanText.
func searchMsgCleanText(m map[string]any) any {
if s, ok := searchMsgText(m).(string); ok {
return chatmsg.CleanText(s)
}
return searchMsgText(m)
}
// searchMsgSender reads a message's sender display name/id, tolerating the
// common sender keys the gateway may use (including a nested sender object). The
// literal string "null" (carried by forwarded sub-messages) and the empty string
// are both treated as absent so they never surface as the speaker.
func searchMsgSender(m map[string]any) any {
norm := func(v any) string {
if s := searchMsgString(v); s != "" && s != "null" {
return s
}
return ""
}
for _, key := range []string{"senderName", "sender_name", "senderNick", "fromName", "senderStaffName"} {
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
for _, k2 := range []string{"name", "nick", "userName", "staffName", "displayName"} {
if v := norm(nested[k2]); v != "" {
return v
}
}
}
if v := norm(m[key]); v != "" {
return v
}
}
for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} {
if v := norm(m[key]); v != "" {
return v
}
}
return nil
}
// searchMsgTime reads a message's send time, returning the raw value (usually
// epoch millis) under whichever candidate key is present.
func searchMsgTime(m map[string]any) any {
for _, key := range []string{"createTime", "sendTime", "gmtCreate", "time", "msgTime", "createAt"} {
if v, ok := m[key]; ok && v != nil {
return v
}
}
return nil
}
// searchMsgText reads a message's textual content, tolerating flat text keys and
// a nested content/text object.
func searchMsgText(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body"} {
if v := searchMsgString(m[key]); v != "" {
return v
}
}
for _, key := range []string{"content", "text", "msg"} {
if nested, ok := m[key].(map[string]any); ok {
for _, k2 := range []string{"text", "content", "richText", "title"} {
if v := searchMsgString(nested[k2]); v != "" {
return v
}
}
}
}
return nil
}
// searchMsgMessageID reads a message's identifier, tolerating the common id keys
// the gateway may use.
func searchMsgMessageID(m map[string]any) any {
for _, key := range []string{"messageId", "message_id", "msgId", "msg_id", "openMessageId", "id"} {
if v := searchMsgString(m[key]); v != "" {
return v
}
}
return nil
}
// searchMsgString coerces a scalar JSON value to a trimmed string, returning ""
// for nil / non-scalar / empty values.
func searchMsgString(v any) string {
switch typed := v.(type) {
case string:
return strings.TrimSpace(typed)
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
default:
return ""
}
}
func init() {
shortcut.Register(SearchMsg)
}