diff --git a/.gitignore b/.gitignore index cf4e4f1c..5551f325 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,7 @@ dwsbin /docs/shortcut-comparison.html /docs/shortcut-gsb-eval.* /scripts/run_shortcut_real_read_matrix.py + +# Local coverage artifacts +coverage-shortcut.txt +coverage-*.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index d331b527..d8773eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +### Fixed + +- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag". + ## [1.0.54] - 2026-07-21 This release promotes the validated `v1.0.54-beta.2` baseline to stable. It restores the default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes. diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index 49f5588b..cc244011 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -3143,6 +3143,25 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 # resource-id: 从 dws chat message list 返回的消息内容中获取 mediaId # message-id: 从 dws chat message list 返回的 openMessageId # open-conversation-id: 从 dws chat search 获取 openConversationId`, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Cobra validates required flags after PreRunE. Copy a supplied alias + // into the canonical flag first so --message-id can remain a hard + // required fact in both the executable and Agent Schema contracts. + if cmd.Flags().Changed("message-id") { + return nil + } + alias := "" + switch { + case cmd.Flags().Changed("msg-id"): + alias = "msg-id" + case cmd.Flags().Changed("open-message-id"): + alias = "open-message-id" + default: + return nil + } + value, _ := cmd.Flags().GetString(alias) // registered string flags above + return cmd.Flags().Set("message-id", value) + }, RunE: func(cmd *cobra.Command, args []string) error { if err := validateRequiredFlags(cmd, "type", "resource-id", "message-id", "open-conversation-id", "output"); err != nil { return err @@ -3226,6 +3245,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 _ = chatMessageDownloadMediaCmd.MarkFlagRequired("open-conversation-id") chatMessageDownloadMediaCmd.Flags().String("message-id", "", "消息 openMessageId (必填)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("message-id") + // Hidden aliases: agents routinely pass --msg-id / --open-message-id since + // the message-list output exposes the field as openMessageId/msgId. Accept + // them transparently instead of failing with "unknown flag". + chatMessageDownloadMediaCmd.Flags().String("msg-id", "", "--message-id 的别名") + _ = chatMessageDownloadMediaCmd.Flags().MarkHidden("msg-id") + chatMessageDownloadMediaCmd.Flags().String("open-message-id", "", "--message-id 的别名") + _ = chatMessageDownloadMediaCmd.Flags().MarkHidden("open-message-id") chatMessageDownloadMediaCmd.Flags().String("output", "", "本地保存路径,文件或目录 (必填)") _ = chatMessageDownloadMediaCmd.MarkFlagRequired("output") diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index ba10d2f7..53ad7d8a 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -222,6 +222,12 @@ func TestCrossPlatformCoverageChatWebhookReplyConversationAndDownloadEdges(t *te tmp := t.TempDir() base := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--message-id=mid"} _ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, append(base, "--output="+filepath.Join(tmp, "dry"))...) + for _, alias := range []string{"--msg-id=mid", "--open-message-id=mid"} { + aliasArgs := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", alias, "--output=" + filepath.Join(tmp, "alias-dry")} + _ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, aliasArgs...) + } + missingMessageID := []string{"message", "download-media", "--type=mediaId", "--resource-id=r", "--open-conversation-id=cid", "--output=" + filepath.Join(tmp, "missing-id")} + _ = runChatCoverageCommand(t, &productExampleCaller{dry: true}, missingMessageID...) for _, tc := range []struct { step scriptedToolStep out string diff --git a/internal/shortcut/chat/chat_message.go b/internal/shortcut/chat/chat_message.go index 429cd044..96dbe1be 100644 --- a/internal/shortcut/chat/chat_message.go +++ b/internal/shortcut/chat/chat_message.go @@ -17,6 +17,7 @@ import ( "fmt" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // MessagesSend sends a text/markdown message as the current user @@ -242,6 +243,11 @@ var MessagesList = shortcut.Shortcut{ // clean output projection. Both the list container and the per-item field names are // probed defensively across candidate keys, so an empty or unexpected shape // yields an empty list rather than a crash or fabricated data. +// +// Text goes through the shared chatmsg projection so card/auto-reply JSON is +// rendered readable and encrypted ciphertext is marked, and forwarded chat +// records ("聊天记录") expand their nested messages under "forwarded" instead of +// collapsing to a "[卡片]" summary. func listMessagesProject(data map[string]any) []map[string]any { raw := listMessagesResolveList(data) out := make([]map[string]any, 0, len(raw)) @@ -250,29 +256,39 @@ func listMessagesProject(data map[string]any) []map[string]any { if !ok { continue } - row := map[string]any{} - if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok { - row["messageId"] = v - } - if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok { - row["senderId"] = v - } - if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok { - row["msgType"] = v - } - if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok { - row["createTime"] = v - } - if v, ok := listMessagesFirst(m, "text", "content", "plainText"); ok { - row["text"] = v - } - if len(row) > 0 { + if row := listMessageProjectOne(m); len(row) > 0 { out = append(out, row) } } return out } +// listMessageProjectOne projects a single message into the native +// {messageId, senderId, msgType, createTime, text(, forwarded)} shape, reused +// recursively for forwarded chat records. +func listMessageProjectOne(m map[string]any) map[string]any { + row := map[string]any{} + if v, ok := listMessagesFirst(m, "openMessageId", "openMsgId", "messageId", "msgId"); ok { + row["messageId"] = v + } + if v, ok := listMessagesFirst(m, "senderOpenDingTalkId", "senderUserId", "senderId", "senderStaffId"); ok { + row["senderId"] = v + } + if v, ok := listMessagesFirst(m, "msgType", "messageType", "type"); ok { + row["msgType"] = v + } + if v, ok := listMessagesFirst(m, "createTime", "sendTime", "gmtCreate", "messageTime"); ok { + row["createTime"] = v + } + if text := chatmsg.Text(m); text != nil { + row["text"] = text + } + if forwarded := chatmsg.Forwarded(m, listMessageProjectOne); len(forwarded) > 0 { + row["forwarded"] = forwarded + } + return row +} + // listMessagesResolveList locates the list payload, tolerating a bare top-level // array container or nesting one level deeper under a common envelope key. func listMessagesResolveList(data map[string]any) []any { diff --git a/internal/shortcut/chat/chat_message_project_test.go b/internal/shortcut/chat/chat_message_project_test.go new file mode 100644 index 00000000..76437217 --- /dev/null +++ b/internal/shortcut/chat/chat_message_project_test.go @@ -0,0 +1,56 @@ +// 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 chat + +import ( + "strings" + "testing" +) + +const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1" + +func TestListMessageProjectOne(t *testing.T) { + // full field mapping + forwarded expansion; an encrypted body is marked (no + // cross-conversation recovery), not leaked as base64. + row := listMessageProjectOne(map[string]any{ + "openMessageId": "mid", + "senderOpenDingTalkId": "DXYZ", + "msgType": "text", + "createTime": "2026-07-19 13:37:03", + "content": testCipher, + "forwardMessages": []any{ + map[string]any{"openMessageId": "c1", "senderOpenDingTalkId": "DA", "content": "子消息", "createTime": "t"}, + }, + }) + + if row["messageId"] != "mid" || row["senderId"] != "DXYZ" || row["msgType"] != "text" { + t.Fatalf("field mapping = %#v", row) + } + if row["createTime"] != "2026-07-19 13:37:03" { + t.Errorf("createTime = %v", row["createTime"]) + } + if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") || strings.Contains(s, "||2||1||") { + t.Errorf("encrypted text = %v, want marker", row["text"]) + } + fwd, ok := row["forwarded"].([]map[string]any) + if !ok || len(fwd) != 1 || fwd[0]["messageId"] != "c1" || fwd[0]["text"] != "子消息" { + t.Errorf("forwarded = %#v", row["forwarded"]) + } + + // a bare message with no recognizable fields → empty row (no keys) + row = listMessageProjectOne(map[string]any{"unrelated": 1}) + if len(row) != 0 { + t.Errorf("empty message row = %#v, want no keys", row) + } +} diff --git a/internal/shortcut/chatmsg/chatmsg.go b/internal/shortcut/chatmsg/chatmsg.go new file mode 100644 index 00000000..b1389819 --- /dev/null +++ b/internal/shortcut/chatmsg/chatmsg.go @@ -0,0 +1,292 @@ +// 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 chatmsg holds the shared, read-only projection helpers for DingTalk +// message-list responses (list_individual_chat_message, +// list_conversation_message_v2, search_at_me_message, search_messages_by_keyword, +// list_topic_replies, …). Several shortcuts reshape those raw responses into a +// clean speaker/text/time list; centralising the fiddly bits here keeps them +// consistent and fixed in one place: +// +// - Sender: the display name lives under the bare "sender" key, forwarded +// entries carry the literal string "null", and some responses nest the +// speaker in a {name:…} object — all handled here. +// - Text: out-of-office auto-replies / cards arrive as raw rich-content JSON, +// and card/robot messages arrive as undecryptable ciphertext; CleanText +// renders the former to readable text and marks the latter, WITHOUT ever +// rewriting ordinary text that merely contains a JSON fragment. +// - Forwarded: a forwarded chat record ("聊天记录") hides its real per-message +// bodies in forwardMessages while the top-level content is a lossy summary. +package chatmsg + +import ( + "encoding/json" + "regexp" + "strings" +) + +// Sender reads a message's speaker display name, tolerating common sender-name +// keys. The message-list responses carry the display name under the bare +// "sender" key (verified live), so it is probed first; the remaining aliases and +// the *Id fallbacks keep the projection resilient to other shapes. The literal +// string "null" (forwarded entries) and the empty string are treated as absent, +// and a nested {name:…} sender object yields its display name rather than the +// raw object. +func Sender(m map[string]any) any { + for _, key := range []string{"sender", "senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId", "senderOpenDingTalkId"} { + v, ok := m[key] + if !ok || v == nil { + continue + } + switch t := v.(type) { + case string: + if t == "" || t == "null" { + continue + } + return t + case map[string]any: + // Nested sender object: extract a display-name field; never return + // the raw map (it would surface a JSON object and block fallbacks). + if name := senderDisplayName(t); name != "" { + return name + } + continue + default: + // Scalar id (e.g. numeric) — usable as-is. + return v + } + } + return nil +} + +// senderDisplayName extracts a human name from a nested sender object. +func senderDisplayName(m map[string]any) string { + for _, k := range []string{"name", "nick", "userName", "staffName", "displayName", "senderName"} { + if s, ok := m[k].(string); ok { + if s = strings.TrimSpace(s); s != "" && s != "null" { + return s + } + } + } + return "" +} + +// Text reads a message's textual content (tolerating common text keys and one +// level of nesting) and runs it through CleanText. +func Text(m map[string]any) any { + for _, key := range []string{"text", "content", "msgContent", "message", "body", "plainText"} { + v, ok := m[key] + if !ok || v == nil { + continue + } + switch t := v.(type) { + case string: + if t != "" { + return CleanText(t) + } + case map[string]any: + for _, inner := range []string{"text", "content", "value"} { + if s, ok := t[inner].(string); ok && s != "" { + return CleanText(s) + } + } + } + } + return nil +} + +// CreateTime reads a message's create/send time under whichever candidate key is +// present, returning the raw value. +func CreateTime(m map[string]any) any { + for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} { + if v, ok := m[key]; ok && v != nil { + return v + } + } + return nil +} + +// Forwarded projects the nested messages of a forwarded chat record. The caller +// supplies its own per-message projection so each command keeps its own row +// shape; project is applied recursively, so multi-level forwards expand too. +func Forwarded(m map[string]any, project func(map[string]any) map[string]any) []map[string]any { + raw, ok := m["forwardMessages"].([]any) + if !ok || len(raw) == 0 { + return nil + } + out := make([]map[string]any, 0, len(raw)) + for _, e := range raw { + if sub, ok := e.(map[string]any); ok { + out = append(out, project(sub)) + } + } + return out +} + +// CleanText makes a message body human-readable WITHOUT ever rewriting ordinary +// text. It only transforms a body that is a genuine DingTalk structured message: +// +// - Encrypted card/robot ciphertext (base64 + "||v||t||len" trailer) → a clear +// "[加密消息]" marker instead of the raw base64. +// - A rich-content card (out-of-office auto-reply, link/preview card, …) whose +// lines include at least one recognised rich-content block → the readable +// text extracted from those blocks, with the card's decorative JSON lines and +// "empty" placeholders dropped. +// +// Crucially, if NO line is a recognised rich-content block (e.g. ordinary text +// that merely embeds a `{"approved":false}` fragment), the original string is +// returned verbatim — a JSON line is never silently dropped. +func CleanText(s string) string { + if IsEncrypted(s) { + return "[加密消息,无法解码]" + } + + // Fast path: no JSON delimiters at all — the overwhelming common case. + if !strings.ContainsAny(s, "{[") { + return s + } + + lines := strings.Split(s, "\n") + isJSON := make([]bool, len(lines)) + isDecoration := make([]bool, len(lines)) + extracted := make([][]string, len(lines)) + anyExtracted := false + for i, line := range lines { + t := strings.TrimSpace(line) + if !strings.HasPrefix(t, "{") && !strings.HasPrefix(t, "[") { + continue + } + var v any + if json.Unmarshal([]byte(t), &v) != nil { + continue + } + isJSON[i] = true + isDecoration[i] = isKnownRichDecoration(v) + if texts := richItemTexts(v); len(texts) > 0 { + extracted[i] = texts + anyExtracted = true + } + } + + // No recognised rich-content block anywhere → treat the whole body as plain + // text (which may merely contain a JSON fragment) and return it untouched. + if !anyExtracted { + return s + } + + out := make([]string, 0, len(lines)) + for i, line := range lines { + if len(extracted[i]) > 0 { + out = append(out, extracted[i]...) + continue + } + // In card mode, drop only JSON shapes known to be card decoration. + // Unrecognised JSON may be user-authored message content and must remain + // verbatim even when another line contains a rich-content block. + if isJSON[i] && isDecoration[i] { + continue + } + if t := strings.TrimSpace(line); t == "" || t == "empty" { + continue + } + out = append(out, line) + } + // anyExtracted is true here, so out always holds at least one non-empty + // extracted text — the joined result is never empty. + return strings.TrimSpace(strings.Join(out, "\n")) +} + +// isKnownRichDecoration recognises the two decoration records emitted alongside +// DingTalk rich-content bodies. Keep this deliberately narrow: an arbitrary JSON +// object in the same message is user content unless its shape is known here. +func isKnownRichDecoration(node any) bool { + m, ok := node.(map[string]any) + if !ok { + return false + } + _, hasPreviewURL := m["previewUrl"] + _, hasTitle := m["title"] + _, hasAutoLayout := m["autoLayout"] + _, hasEnableForward := m["enableForward"] + return (hasPreviewURL && hasTitle) || (hasAutoLayout && hasEnableForward) +} + +// richItemTexts walks a decoded DingTalk rich-content blob and returns the +// readable text carried by its rich-content items (items[].data.text). It only +// harvests item bodies, so decorative fields (card titles, preview URLs, layout +// config) contribute nothing and are dropped. An empty result means "not a +// recognised rich-content block". +func richItemTexts(node any) []string { + var texts []string + var walk func(n any) + walk = func(n any) { + switch t := n.(type) { + case []any: + for _, e := range t { + walk(e) + } + case map[string]any: + if items, ok := t["items"].([]any); ok { + for _, it := range items { + mm, ok := it.(map[string]any) + if !ok { + continue + } + data, ok := mm["data"].(map[string]any) + if !ok { + continue + } + if s, ok := data["text"].(string); ok { + if s = strings.TrimSpace(s); s != "" { + texts = append(texts, s) + } + } + } + } + for _, e := range t { + walk(e) + } + } + } + walk(node) + return texts +} + +// encryptedTrailerRE matches DingTalk's encrypted-message trailer +// "||||||" (e.g. "||2||1||196") anchored at the end. +var encryptedTrailerRE = regexp.MustCompile(`\|\|\d+\|\|\d+\|\|\d+\s*$`) + +// IsEncrypted reports whether a message body is a raw DingTalk encrypted-message +// ciphertext: a base64 blob (DingTalk wraps it across several lines) followed by +// the "||v||t||len" trailer. It is intentionally strict — both the trailer and a +// pure-base64 body are required — so ordinary text (CJK, punctuation, …) never +// trips it. +func IsEncrypted(s string) bool { + s = strings.TrimSpace(s) + if !encryptedTrailerRE.MatchString(s) { + return false + } + body := strings.TrimSpace(encryptedTrailerRE.ReplaceAllString(s, "")) + if len(body) < 32 { + return false + } + for _, r := range body { + switch { + case r >= 'A' && r <= 'Z', r >= 'a' && r <= 'z', r >= '0' && r <= '9', + r == '+', r == '/', r == '=', r == '\n', r == '\r', r == ' ', r == '\t': + default: + return false + } + } + return true +} diff --git a/internal/shortcut/chatmsg/chatmsg_test.go b/internal/shortcut/chatmsg/chatmsg_test.go new file mode 100644 index 00000000..b9f0c618 --- /dev/null +++ b/internal/shortcut/chatmsg/chatmsg_test.go @@ -0,0 +1,176 @@ +// 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 chatmsg + +import ( + "strings" + "testing" +) + +func TestSender(t *testing.T) { + // The display name lives under the bare "sender" key. + if got := Sender(map[string]any{"sender": "念晨", "senderOpenDingTalkId": "D1"}); got != "念晨" { + t.Fatalf("sender = %v, want 念晨", got) + } + // Falls back to the open id when no display name is present. + if got := Sender(map[string]any{"senderOpenDingTalkId": "DXYZ"}); got != "DXYZ" { + t.Fatalf("sender fallback = %v, want DXYZ", got) + } + // forwardMessages entries carry the literal string "null" — treat as absent. + if got := Sender(map[string]any{"sender": "null"}); got != nil { + t.Fatalf("sender \"null\" = %v, want nil", got) + } + if got := Sender(map[string]any{"sender": "null", "senderName": "念晨"}); got != "念晨" { + t.Fatalf("sender \"null\" fallthrough = %v, want 念晨", got) + } + // A nested {name:…} sender object yields its display name, not the raw map. + if got := Sender(map[string]any{"sender": map[string]any{"name": "Alice"}}); got != "Alice" { + t.Fatalf("nested sender = %v, want Alice", got) + } + // A nested sender object with no usable name must not block the fallback. + if got := Sender(map[string]any{"sender": map[string]any{"foo": "bar"}, "senderName": "Bob"}); got != "Bob" { + t.Fatalf("nested-no-name fallthrough = %v, want Bob", got) + } + // A scalar numeric id is returned as-is. + if got := Sender(map[string]any{"senderId": float64(42)}); got != float64(42) { + t.Fatalf("numeric sender id = %v", got) + } +} + +func TestCleanText(t *testing.T) { + // Out-of-office auto-reply: readable body lives in items[].data.text; the + // decorative preview/config JSON lines and "empty" placeholder are dropped. + autoReply := "* 仅你和对方可见\n" + + `[{"text":{"minSupportVersion":"1.1","translateMap":{},"version":"1.2","items":[{"fallbackKey":"","data":{"text":"你好,我在出差中,消息回复可能不及时。"},"style":{"size":15,"bold":0},"type":"text"}]},"type":"markdown"}]` + "\n" + + `{"previewUrl":"dingtalk://x","title":{"text":"自动回复","type":"text"}}` + "\n" + + "empty\n" + + `{"autoLayout":false,"enableForward":false}` + if got, want := CleanText(autoReply), "* 仅你和对方可见\n你好,我在出差中,消息回复可能不及时。"; got != want { + t.Fatalf("auto-reply cleaned = %q, want %q", got, want) + } + + // P1 regression: ordinary text whose middle line is a JSON fragment (no + // rich-content block anywhere) must be returned VERBATIM, not rewritten. + mixed := "payload:\n{\"approved\":false}\nplease check" + if got := CleanText(mixed); got != mixed { + t.Fatalf("mixed text was rewritten: got %q, want %q", got, mixed) + } + + // An ordinary JSON line must also survive when a different line contains a + // recognised rich-content block. Card mode is not permission to discard + // unrelated user-authored JSON. + richAndPlain := `[{"items":[{"data":{"text":"卡片正文"}}]}]` + "\n" + + `{"approved":false}` + if got, want := CleanText(richAndPlain), "卡片正文\n{\"approved\":false}"; got != want { + t.Fatalf("mixed rich/plain JSON was rewritten: got %q, want %q", got, want) + } + + // Malformed items (non-map item, item whose "data" isn't a map) are skipped; + // only the well-formed item's text is extracted. + blob := `[{"items":["notmap",{"data":"notmap"},{"data":{"text":"有效正文"}}]}]` + if got := CleanText(blob); got != "有效正文" { + t.Fatalf("CleanText rich edge = %q, want 有效正文", got) + } + + tests := map[string]string{ + "上周五 7.1 KW": "上周五 7.1 KW", + "上周客户统计的[图片消息](mediaId=@lQ)": "上周客户统计的[图片消息](mediaId=@lQ)", + "[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载": "[文件] 简历.pdf fileId: qnY 注意:如需下载使用dws drive download命令下载", + "[讨论] 排期\n明天开会": "[讨论] 排期\n明天开会", + // a lone JSON object that isn't a rich-content block is left untouched + `{"autoLayout":false,"enableForward":false}`: `{"autoLayout":false,"enableForward":false}`, + } + for in, want := range tests { + if got := CleanText(in); got != want { + t.Errorf("CleanText(%q) = %q, want %q", in, got, want) + } + } +} + +func TestIsEncryptedAndMarker(t *testing.T) { + cipher := "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP\n" + + "7oDptklFO+YzDltH+myErV6rkc8URHYykpeSDsMP6kznFa9E320NsIntfY771dx+\n" + + "||2||1||196" + if !IsEncrypted(cipher) { + t.Fatalf("ciphertext not detected: %q", cipher) + } + if got := CleanText(cipher); !strings.Contains(got, "加密消息") || strings.Contains(got, "||2||1||") { + t.Fatalf("encrypted cleaned = %q, want marker not ciphertext", got) + } + for _, s := range []string{ + "上周五 7.1 KW", + "价格 100||2||1||3", + "[图片消息](mediaId=@lQLPJwDw3VmNDcfMos0DhLB3OHPQeTBlzgov2Oi1ly4A)", + "大哥,我看了一下我觉得有几个点可以关注一下", + strings.Repeat("好", 20) + "||2||1||1", // long CJK body + trailer, not base64 + } { + if IsEncrypted(s) { + t.Errorf("false positive: %q flagged as encrypted", s) + } + } +} + +func TestText(t *testing.T) { + if got := Text(map[string]any{"content": "你好"}); got != "你好" { + t.Errorf("Text string = %v", got) + } + if got := Text(map[string]any{"content": map[string]any{"text": "嵌套"}}); got != "嵌套" { + t.Errorf("Text nested = %v", got) + } + if got := Text(map[string]any{"plainText": "纯文本"}); got != "纯文本" { + t.Errorf("Text plainText = %v", got) + } + if got := Text(map[string]any{"foo": 1}); got != nil { + t.Errorf("Text none = %v, want nil", got) + } +} + +func TestCreateTime(t *testing.T) { + if got := CreateTime(map[string]any{"sendTime": "2026-07-19 13:37:03"}); got != "2026-07-19 13:37:03" { + t.Errorf("CreateTime = %v", got) + } + if got := CreateTime(map[string]any{}); got != nil { + t.Errorf("CreateTime empty = %v, want nil", got) + } +} + +func TestForwarded(t *testing.T) { + var project func(m map[string]any) map[string]any + project = func(m map[string]any) map[string]any { + row := map[string]any{"text": Text(m)} + if fwd := Forwarded(m, project); len(fwd) > 0 { // recurse + row["forwarded"] = fwd + } + return row + } + if got := Forwarded(map[string]any{"content": "x"}, project); got != nil { + t.Errorf("Forwarded none = %v", got) + } + fwd := Forwarded(map[string]any{ + "forwardMessages": []any{ + map[string]any{"content": "a"}, + "not-a-map", + map[string]any{"content": "b", "forwardMessages": []any{ + map[string]any{"content": "nested"}, + }}, + }, + }, project) + if len(fwd) != 2 || fwd[0]["text"] != "a" || fwd[1]["text"] != "b" { + t.Fatalf("Forwarded = %#v", fwd) + } + nested, ok := fwd[1]["forwarded"].([]map[string]any) + if !ok || len(nested) != 1 || nested[0]["text"] != "nested" { + t.Errorf("nested forwarded = %#v", fwd[1]["forwarded"]) + } +} diff --git a/internal/shortcut/smart/at_me.go b/internal/shortcut/smart/at_me.go index 904a3c59..93de75df 100644 --- a/internal/shortcut/smart/at_me.go +++ b/internal/shortcut/smart/at_me.go @@ -19,6 +19,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // AtMe: pull the messages that recently @-mentioned ME across chats in one step. @@ -91,12 +92,7 @@ var AtMe = shortcut.Shortcut{ } results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, map[string]any{ - "sender": atMeSender(m), - "time": atMeTime(m), - "text": atMeText(m), - "conversation": atMeConversation(m), - }) + results = append(results, atMeProject(m)) } return rt.Output(map[string]any{"messages": results}) }, @@ -194,28 +190,62 @@ func atMeToMaps(arr []any) []map[string]any { 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 { + row := map[string]any{ + "sender": atMeSender(m), + "time": atMeTime(m), + "text": atMeCleanText(m), + "conversation": atMeConversation(m), + } + if forwarded := chatmsg.Forwarded(m, atMeProject); 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). +// 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 := atMeString(m[key]); v != "" { + 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 := atMeString(nested[k2]); v != "" { + if v := norm(nested[k2]); v != "" { return v } } } - if v := atMeString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} { - if v := atMeString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } diff --git a/internal/shortcut/smart/at_me_search_msg_test.go b/internal/shortcut/smart/at_me_search_msg_test.go new file mode 100644 index 00000000..b5f389f9 --- /dev/null +++ b/internal/shortcut/smart/at_me_search_msg_test.go @@ -0,0 +1,131 @@ +// 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 ( + "strings" + "testing" +) + +const testCipher = "SwzNkAraDE6lUHUNlVT3mjFdbxL6dWvmt77XtjACdpJx9VFibzTbW9KtDbkzGOYP||2||1||1" + +func TestAtMeProject(t *testing.T) { + // nested sender object + plain text + row := atMeProject(map[string]any{ + "sender": map[string]any{"name": "念晨"}, + "createTime": "2026-07-19 13:37:03", + "content": "普通消息", + "conversationTitle": "群A", + "openConversationId": "cid1", + }) + if row["sender"] != "念晨" || row["text"] != "普通消息" || row["conversation"] != "群A" { + t.Fatalf("atMeProject nested = %#v", row) + } + + // encrypted content → marked (never leaked); id-only sender fallback; a + // forwarded sub-message whose sender is the literal "null" must be nulled. + row = atMeProject(map[string]any{ + "senderId": "DXYZ", + "openMessageId": "m1", + "content": testCipher, + "forwardMessages": []any{ + map[string]any{"sender": "null", "content": "子消息", "createTime": "t"}, + }, + }) + if row["sender"] != "DXYZ" { + t.Errorf("atMeProject id-fallback sender = %v", row["sender"]) + } + if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") { + t.Errorf("atMeProject encrypted text = %v, want marker", row["text"]) + } + fwd, ok := row["forwarded"].([]map[string]any) + if !ok || len(fwd) != 1 { + t.Fatalf("atMeProject forwarded = %#v", row["forwarded"]) + } + if fwd[0]["sender"] != nil { + t.Errorf("forwarded sub sender = %v, want nil (literal \"null\")", fwd[0]["sender"]) + } + + // no sender / no text at all → nils, no forwarded key + row = atMeProject(map[string]any{"createTime": "t"}) + if row["sender"] != nil || row["text"] != nil { + t.Errorf("atMeProject empty = %#v", row) + } + if _, has := row["forwarded"]; has { + t.Errorf("atMeProject plain unexpectedly has forwarded") + } +} + +func TestSearchMsgProject(t *testing.T) { + // nested sender + plain text + messageId + row := searchMsgProject(map[string]any{ + "sender": map[string]any{"nick": "千启"}, + "createTime": "2026-07-19 13:37:03", + "content": "命中关键词的消息", + "msgId": "mid1", + }) + if row["sender"] != "千启" || row["text"] != "命中关键词的消息" { + t.Fatalf("searchMsgProject = %#v", row) + } + + // encrypted → marker; id-only sender; forwarded "null" sender nulled. + row = searchMsgProject(map[string]any{ + "senderId": "DAAA", + "openMessageId": "m2", + "content": testCipher, + "forwardMessages": []any{ + map[string]any{"sender": "null", "content": "转发子消息", "createTime": "t"}, + }, + }) + if row["sender"] != "DAAA" { + t.Errorf("searchMsgProject sender = %v", row["sender"]) + } + if s, _ := row["text"].(string); !strings.Contains(s, "加密消息") { + t.Errorf("searchMsgProject encrypted text = %v, want marker", row["text"]) + } + fwd, ok := row["forwarded"].([]map[string]any) + if !ok || len(fwd) != 1 || fwd[0]["sender"] != nil { + t.Errorf("searchMsgProject forwarded = %#v", row["forwarded"]) + } + + // no sender / no text + row = searchMsgProject(map[string]any{"createTime": "t"}) + if row["sender"] != nil || row["text"] != nil { + t.Errorf("searchMsgProject empty = %#v", row) + } +} + +// TestSenderHelpers exercises the atMe/searchMsg sender key families directly: +// a senderName-family key (first probe loop), a flat string under "sender" +// (second loop), and the "null" sentinel normalisation. +func TestSenderHelpers(t *testing.T) { + cases := []struct { + fn func(map[string]any) any + name string + }{ + {atMeSender, "atMeSender"}, + {searchMsgSender, "searchMsgSender"}, + } + for _, c := range cases { + if got := c.fn(map[string]any{"senderName": "张三"}); got != "张三" { + t.Errorf("%s senderName = %v, want 张三", c.name, got) + } + if got := c.fn(map[string]any{"sender": "李四"}); got != "李四" { + t.Errorf("%s flat sender = %v, want 李四", c.name, got) + } + if got := c.fn(map[string]any{"senderName": "null"}); got != nil { + t.Errorf("%s \"null\" = %v, want nil", c.name, got) + } + } +} diff --git a/internal/shortcut/smart/chat_messages.go b/internal/shortcut/smart/chat_messages.go index e183a063..3cac53ec 100644 --- a/internal/shortcut/smart/chat_messages.go +++ b/internal/shortcut/smart/chat_messages.go @@ -17,6 +17,7 @@ import ( "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // ChatMessages: fetch the message list of one conversation (group OR single @@ -111,11 +112,7 @@ var ChatMessages = shortcut.Shortcut{ items := chatMessageItems(data) results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, map[string]any{ - "sender": chatMessageSender(m), - "text": chatMessageText(m), - "createTime": chatMessageCreateTime(m), - }) + results = append(results, projectChatMessage(m)) } return rt.Output(map[string]any{ @@ -156,53 +153,20 @@ func chatMessageItems(data map[string]any) []map[string]any { return nil } -// chatMessageSender reads a message's speaker display name, tolerating common -// sender-name keys. -func chatMessageSender(m map[string]any) any { - for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} { - if v, ok := m[key]; ok && v != nil { - if s, ok := v.(string); ok && s == "" { - continue - } - return v - } +// 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 { + row := map[string]any{ + "sender": chatmsg.Sender(m), + "text": chatmsg.Text(m), + "createTime": chatmsg.CreateTime(m), } - return nil -} - -// chatMessageText reads a message's textual content, tolerating common text -// keys and one level of nesting (e.g. {"content":{"text":"..."}}). -func chatMessageText(m map[string]any) any { - for _, key := range []string{"text", "content", "msgContent", "message", "body"} { - v, ok := m[key] - if !ok || v == nil { - continue - } - switch t := v.(type) { - case string: - if t != "" { - return t - } - case map[string]any: - for _, inner := range []string{"text", "content", "value"} { - if s, ok := t[inner].(string); ok && s != "" { - return s - } - } - } + if forwarded := chatmsg.Forwarded(m, projectChatMessage); len(forwarded) > 0 { + row["forwarded"] = forwarded } - return nil -} - -// chatMessageCreateTime reads a message's create/send time, returning the raw -// value under whichever candidate key is present. -func chatMessageCreateTime(m map[string]any) any { - for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} { - if v, ok := m[key]; ok && v != nil { - return v - } - } - return nil + return row } func init() { diff --git a/internal/shortcut/smart/chat_messages_test.go b/internal/shortcut/smart/chat_messages_test.go new file mode 100644 index 00000000..f0cb7614 --- /dev/null +++ b/internal/shortcut/smart/chat_messages_test.go @@ -0,0 +1,62 @@ +// 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 "testing" + +// TestProjectChatMessageExpandsForwarded guards that a forwarded chat record +// ("聊天记录") exposes its nested messages under "forwarded" instead of +// collapsing to the lossy top-level "[卡片]" summary, recursing through nested +// forwards, and that the string-"null" sender is nulled out. The per-field +// behaviour (sender/text/encryption) is covered in the chatmsg package tests. +func TestProjectChatMessageExpandsForwarded(t *testing.T) { + row := projectChatMessage(map[string]any{ + "sender": "hugozhu", + "content": "hugozhu与opencode-agent的聊天记录\nopencode-agent:[卡片]", + "createTime": "2026-07-20 21:41:21", + "forwardMessages": []any{ + map[string]any{"sender": "null", "content": "读下冬翔发给我的最近两条消息", "createTime": "2026-07-20 09:30:33"}, + map[string]any{"sender": "冬翔", "content": "W29 工作总结", "createTime": "2026-07-19 23:35:40", + // nested forward inside a forward — must expand recursively. + "forwardMessages": []any{ + map[string]any{"sender": "念晨", "content": "收到", "createTime": "2026-07-19 23:36:00"}, + }, + }, + }, + }) + + if row["sender"] != "hugozhu" { + t.Fatalf("top sender = %v, want hugozhu", row["sender"]) + } + forwarded, ok := row["forwarded"].([]map[string]any) + if !ok || len(forwarded) != 2 { + t.Fatalf("forwarded = %#v, want 2 entries", row["forwarded"]) + } + if forwarded[0]["sender"] != nil { + t.Errorf("forwarded[0].sender = %v, want nil (string \"null\")", forwarded[0]["sender"]) + } + if forwarded[0]["text"] != "读下冬翔发给我的最近两条消息" { + t.Errorf("forwarded[0].text = %v", forwarded[0]["text"]) + } + nested, ok := forwarded[1]["forwarded"].([]map[string]any) + if !ok || len(nested) != 1 || nested[0]["sender"] != "念晨" { + t.Errorf("nested forwarded = %#v, want 1 entry from 念晨", forwarded[1]["forwarded"]) + } + + // A plain message must not grow a "forwarded" key. + plain := projectChatMessage(map[string]any{"sender": "念晨", "content": "hi", "createTime": "t"}) + if _, has := plain["forwarded"]; has { + t.Errorf("plain message unexpectedly has forwarded key: %#v", plain) + } +} diff --git a/internal/shortcut/smart/search_msg.go b/internal/shortcut/smart/search_msg.go index 6a544d3d..29e1c80b 100644 --- a/internal/shortcut/smart/search_msg.go +++ b/internal/shortcut/smart/search_msg.go @@ -19,6 +19,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" ) // SearchMsg: search messages inside a single group chat by keyword in one step. @@ -108,12 +109,7 @@ var SearchMsg = shortcut.Shortcut{ } results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, map[string]any{ - "sender": searchMsgSender(m), - "time": searchMsgTime(m), - "text": searchMsgText(m), - "messageId": searchMsgMessageID(m), - }) + results = append(results, searchMsgProject(m)) } return rt.Output(map[string]any{"messages": results}) }, @@ -152,28 +148,61 @@ func searchMsgToMaps(arr []any) []map[string]any { 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 { + row := map[string]any{ + "sender": searchMsgSender(m), + "time": searchMsgTime(m), + "text": searchMsgCleanText(m), + "messageId": searchMsgMessageID(m), + } + if forwarded := chatmsg.Forwarded(m, searchMsgProject); 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). +// 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 := searchMsgString(m[key]); v != "" { + 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 := searchMsgString(nested[k2]); v != "" { + if v := norm(nested[k2]); v != "" { return v } } } - if v := searchMsgString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } for _, key := range []string{"senderId", "sender_id", "senderUserId", "senderStaffId", "openDingTalkId"} { - if v := searchMsgString(m[key]); v != "" { + if v := norm(m[key]); v != "" { return v } } diff --git a/internal/shortcut/smart/thread_replies.go b/internal/shortcut/smart/thread_replies.go index e1854db3..2dc1c25a 100644 --- a/internal/shortcut/smart/thread_replies.go +++ b/internal/shortcut/smart/thread_replies.go @@ -78,11 +78,7 @@ var ThreadReplies = shortcut.Shortcut{ items := threadReplyItems(data) results := make([]map[string]any, 0, len(items)) for _, m := range items { - results = append(results, map[string]any{ - "sender": threadReplySender(m), - "text": threadReplyText(m), - "createTime": threadReplyCreateTime(m), - }) + results = append(results, projectChatMessage(m)) } return rt.Output(map[string]any{ @@ -124,55 +120,6 @@ func threadReplyItems(data map[string]any) []map[string]any { return nil } -// threadReplySender reads a reply's speaker display name, tolerating common -// sender-name keys. -func threadReplySender(m map[string]any) any { - for _, key := range []string{"senderName", "senderNick", "nick", "senderStaffName", "userName", "name", "senderId", "senderStaffId"} { - if v, ok := m[key]; ok && v != nil { - if s, ok := v.(string); ok && s == "" { - continue - } - return v - } - } - return nil -} - -// threadReplyText reads a reply's textual content, tolerating common text keys -// and one level of nesting (e.g. {"text":{"content":"..."}}). -func threadReplyText(m map[string]any) any { - for _, key := range []string{"text", "content", "msgContent", "message", "body"} { - v, ok := m[key] - if !ok || v == nil { - continue - } - switch t := v.(type) { - case string: - if t != "" { - return t - } - case map[string]any: - for _, inner := range []string{"content", "text", "value"} { - if s, ok := t[inner].(string); ok && s != "" { - return s - } - } - } - } - return nil -} - -// threadReplyCreateTime reads a reply's create/send time, returning the raw -// value under whichever candidate key is present. -func threadReplyCreateTime(m map[string]any) any { - for _, key := range []string{"createTime", "sendTime", "gmtCreate", "createAt", "timestamp", "time"} { - if v, ok := m[key]; ok && v != nil { - return v - } - } - return nil -} - func init() { shortcut.Register(ThreadReplies) }