Files
dingtalk-workspace-cli/internal/shortcut/chatmsg/chatmsg.go
Dennis4477 b4ea1f168d fix(chat): render cards, forwards and encrypted messages
Normalize message projections across read shortcuts, preserve mixed user JSON, expand forwarded records, mask ciphertext, and accept media-download message ID aliases while retaining the Cobra/Schema required contract.
2026-07-23 00:18:46 +08:00

293 lines
9.7 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 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
// "||<version>||<type>||<len>" (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
}