Files
dingtalk-workspace-cli/internal/shortcut/smart/at_me.go
T

367 lines
12 KiB
Go
Raw 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 (
"strconv"
"strings"
"time"
"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"
)
// AtMe: pull the messages that recently @-mentioned ME across chats in one step.
//
// Steps:
// 1. compute the look-back window [now-Nd, now] in local time and express both
// bounds as epoch millis. N defaults to 7 days and is overridable via
// --days; mirroring `dws chat message list-mentions`, which feeds startTime/
// endTime as epoch millis to search_at_me_message.
// 2. call search_at_me_message on the chat server with startTime/endTime/limit/
// cursor — the exact parameter names + first-page defaults (limit 50,
// cursor "0") used by helpers.chatMessageListMentionsCmd.
// 3. defensively project each returned message down to {sender, time, text,
// conversation} (multiple candidate keys per field) and print via rt.Output
// so it honours --format/--jq/--fields. When the response carries no
// recognisable message list we fall back to printing the raw payload.
//
// This replaces manually working out the millisecond time window and copying
// the list-mentions incantation. The default path only searches and reshapes;
// --download-resources additionally writes resource files locally.
//
// dws chat +at-me
// dws chat +at-me --days 3
var AtMe = shortcut.Shortcut{
Service: "chat",
Command: "+at-me",
Product: "chat",
Description: "查最近 @我 的消息(自动算时间窗,投影发送人/时间/内容/会话)",
Intent: "当你想快速看回最近谁在群里或单聊里 @了你、但不想手动把起止时间换算成毫秒、也不想记 list-mentions 的一堆参数时使用;" +
"内部按本地时区算出「最近 N 天」(默认 7 天,可用 --days 调整回溯天数)的时间窗,搜索这段时间内 @我 的消息," +
"再在本地把每条消息投影成发送人、时间、内容、所在会话四个关键字段。" +
"默认只读且不会发送、撤回或标记任何消息;传 --download-resources 时会在工作目录写文件,因此命令按本地写入操作确认。",
Risk: shortcut.RiskRead,
RiskWhen: chatshortcut.MessageResourceDownloadRisk,
Flags: append([]shortcut.Flag{
{Name: "days", Type: shortcut.FlagInt, Desc: "回溯天数(可选,默认 7)", Default: "7", Required: false},
{Name: "limit", Type: shortcut.FlagInt, Desc: "每页返回数量(默认 50)", Default: "50"},
{Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,翻页传上次的 nextCursor", Default: "0"},
{Name: "no-reactions", Type: shortcut.FlagBool, Desc: "不输出消息 reaction(默认输出)"},
}, chatshortcut.MessageResourceDownloadFlags()...),
Constraints: chatshortcut.MessageResourceDownloadConstraints(),
Tips: []string{
`dws chat +at-me`,
`dws chat +at-me --days 3`,
},
Validate: chatshortcut.ValidateMessageResourceDownload,
Execute: func(rt *shortcut.RuntimeContext) error {
// Step 1 — look-back window [now-Nd, now] in epoch millis. days defaults
// to 7; guard against non-positive overrides so the window stays sane.
days := rt.Int("days")
if days <= 0 {
days = 7
}
now := time.Now()
startMs := now.AddDate(0, 0, -days).UnixMilli()
endMs := now.UnixMilli()
// Step 2 — search @me messages. startTime/endTime/limit/cursor and the
// first-page defaults (limit 50, cursor "0") mirror
// helpers.chatMessageListMentionsCmd's search_at_me_message call.
data, err := rt.CallMCPData("chat", "search_at_me_message", map[string]any{
"startTime": startMs,
"endTime": endMs,
"limit": rt.Int("limit"),
"cursor": rt.Str("cursor"),
})
if err != nil {
return err
}
// Step 3 — project matched messages; fall back to the raw payload when we
// cannot locate a recognisable message list.
items := atMeMessageItems(data)
if len(items) == 0 {
return rt.Output(data)
}
results := make([]map[string]any, 0, len(items))
for _, m := range items {
results = append(results, atMeProjectWithReactions(m, !rt.Bool("no-reactions")))
}
payload := map[string]any{"messages": results}
chatmsg.ApplyPagination(payload, data)
if rt.Bool("download-resources") {
payload["resourceDownloads"] = chatshortcut.DownloadMessageResources(rt, items, "")
}
return rt.Output(payload)
},
}
// atMeMessageItems locates the message list inside a search_at_me_message
// response, probing common container keys at the top level and nested under
// "result". Returns nil when no list is found.
func atMeMessageItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
// Preferred real shape (verified against the live gateway):
// {result:{conversationMessagesList:[{title,openConversationId,messages:[…]}]}}
// The messages are nested two levels deep and grouped per conversation, so
// flatten every group into a single message list while carrying the group's
// conversation title/id down onto each message.
for _, root := range []map[string]any{data, atMeChildMap(data, "result")} {
if root == nil {
continue
}
if groups, ok := root["conversationMessagesList"].([]any); ok {
return atMeFlattenGroups(groups)
}
}
keys := []string{"list", "messages", "messageList", "items", "data", "records", "result"}
for _, key := range keys {
if arr, ok := data[key].([]any); ok {
return atMeToMaps(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 atMeToMaps(arr)
}
}
}
}
return nil
}
// atMeChildMap returns data[key] as a map, or nil.
func atMeChildMap(data map[string]any, key string) map[string]any {
if m, ok := data[key].(map[string]any); ok {
return m
}
return nil
}
// atMeFlattenGroups flattens conversationMessagesList groups into a single
// message list, injecting the group's conversation title / id onto each message
// (when the message itself lacks them) so the projection can show a readable
// conversation.
func atMeFlattenGroups(groups []any) []map[string]any {
var out []map[string]any
for _, g := range groups {
grp, ok := g.(map[string]any)
if !ok {
continue
}
title := atMeString(grp["title"])
cid := atMeString(grp["openConversationId"])
msgs, ok := grp["messages"].([]any)
if !ok {
continue
}
for _, mm := range msgs {
m, ok := mm.(map[string]any)
if !ok {
continue
}
if title != "" {
if _, has := m["conversationTitle"]; !has {
m["conversationTitle"] = title
}
}
if cid != "" {
if _, has := m["openConversationId"]; !has {
m["openConversationId"] = cid
}
}
out = append(out, m)
}
}
return out
}
func atMeToMaps(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
}
// atMeProject reshapes one @me message into {sender, time, text, conversation},
// running text through the shared chatmsg cleaning (card/auto-reply JSON →
// readable, ciphertext → marker) and recursively expanding any forwarded chat
// record under "forwarded".
func atMeProject(m map[string]any) map[string]any {
return atMeProjectWithReactions(m, true)
}
func atMeProjectWithReactions(m map[string]any, includeReactions bool) map[string]any {
row := map[string]any{
"sender": atMeSender(m),
"time": atMeTime(m),
"text": atMeCleanText(m),
"conversation": atMeConversation(m),
}
if messageID := chatmsg.MessageID(m); messageID != nil {
row["messageId"] = messageID
}
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.Resources(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
return atMeProjectWithReactions(item, includeReactions)
}
if forwarded := chatmsg.Forwarded(m, projectForwarded); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// atMeCleanText runs atMeText's extraction through chatmsg.CleanText so
// card/auto-reply JSON and ciphertext render readable instead of leaking raw.
func atMeCleanText(m map[string]any) any {
if s, ok := atMeText(m).(string); ok {
return chatmsg.CleanText(s)
}
return atMeText(m)
}
// atMeSender 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 atMeSender(m map[string]any) any {
norm := func(v any) string {
if s := atMeString(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
}
// atMeTime reads a message's send time, returning the raw value (usually epoch
// millis) under whichever candidate key is present.
func atMeTime(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
}
// atMeText reads a message's textual content, tolerating flat text keys and a
// nested content/text object.
func atMeText(m map[string]any) any {
for _, key := range []string{"text", "content", "msgContent", "message", "body"} {
if v := atMeString(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 := atMeString(nested[k2]); v != "" {
return v
}
}
}
}
return nil
}
// atMeConversation reads the conversation (chat) this message belongs to,
// preferring a readable title and falling back to the conversation id.
func atMeConversation(m map[string]any) any {
for _, key := range []string{"conversationTitle", "chatTitle", "groupName", "conversationName", "title"} {
if v := atMeString(m[key]); v != "" {
return v
}
}
for _, key := range []string{"openConversationId", "conversationId", "conversation_id", "cid", "chatId"} {
if v := atMeString(m[key]); v != "" {
return v
}
}
return nil
}
// atMeString coerces a scalar JSON value to a trimmed string, returning "" for
// nil / non-scalar / empty values.
func atMeString(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(AtMe)
}