Files

1332 lines
40 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"
"fmt"
"math"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// MessageListContractVersion identifies the additive, compatibility-preserving
// public envelope shared by message list/search/mget/thread projections.
const MessageListContractVersion = "im.message-list.v1"
// MessageResultContract is the reviewed additive contract shared by message
// list, search, mget, @me and thread projections. Keep this descriptor small:
// Runtime owns the values, while Skill references and policy checks consume a
// copy of these field names so prose cannot silently invent another result
// shape.
type MessageResultContract struct {
Version string
MessageFields []string
EnvelopeFields []string
}
var messageResultContractV1 = MessageResultContract{
Version: MessageListContractVersion,
MessageFields: []string{
"messageId",
"conversationId",
"threadId",
"sender",
"senderId",
"senderType",
"messageType",
"text",
"createTime",
"updateTime",
"reactions",
"quotedMessage",
"forwarded",
"resourceRefs",
},
EnvelopeFields: []string{
"contractVersion",
"messages",
"count",
"resolvedFilters",
"queryRange",
"pagesFetched",
"paginationKnown",
"complete",
"hasMore",
"nextPage",
"stopReason",
"truncated",
"truncatedByPageLimit",
"truncatedByResultLimit",
"failedCount",
"failures",
"partial",
"scope",
"resourceDownloads",
},
}
// CurrentMessageResultContract returns defensive copies so callers cannot
// mutate the process-wide reviewed descriptor.
func CurrentMessageResultContract() MessageResultContract {
contract := messageResultContractV1
contract.MessageFields = append([]string(nil), contract.MessageFields...)
contract.EnvelopeFields = append([]string(nil), contract.EnvelopeFields...)
return contract
}
// NewMessageListPayload initializes the common result ledger before a caller
// adds pagination or resource-download facts.
func NewMessageListPayload(messages []map[string]any) map[string]any {
if messages == nil {
messages = []map[string]any{}
}
return map[string]any{
"contractVersion": MessageListContractVersion,
"messages": messages,
"count": len(messages),
"pagesFetched": 0,
"paginationKnown": false,
"complete": false,
"hasMore": false,
"failedCount": 0,
"failures": []map[string]any{},
"partial": false,
"truncated": false,
}
}
// ApplyTruncation publishes the stable aggregate bit while preserving the
// established reason-specific fields for compatibility and diagnosis.
func ApplyTruncation(payload map[string]any) {
if payload == nil {
return
}
byPage, _ := payload["truncatedByPageLimit"].(bool)
byItems, _ := payload["truncatedByResultLimit"].(bool)
payload["truncated"] = byPage || byItems
}
// ListMessageItems returns message rows from the common list response envelopes.
func ListMessageItems(data map[string]any) []map[string]any {
if data == nil {
return nil
}
scopes := []map[string]any{data}
for _, wrapper := range []string{"result", "data"} {
if inner, ok := data[wrapper].(map[string]any); ok {
scopes = append(scopes, inner)
}
}
for _, scope := range scopes {
for _, key := range []string{"messages", "list", "items", "records", "data", "result"} {
if rows, ok := scope[key].([]any); ok {
items := messageMaps(rows)
if len(items) > 0 {
return items
}
}
}
}
return nil
}
// SearchMessageItems flattens grouped search results into stable message rows.
func SearchMessageItems(data map[string]any) []map[string]any {
return SearchItems(data)
}
func messageMaps(rows []any) []map[string]any {
items := make([]map[string]any, 0, len(rows))
for _, row := range rows {
if item, ok := row.(map[string]any); ok {
items = append(items, item)
}
}
return items
}
// StableMessageID returns the normalized message identity used for
// cross-page deduplication. An empty value means the lower response did not
// publish a stable identity; callers must keep that row rather than guessing.
func StableMessageID(message map[string]any) string {
value := MessageID(message)
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
// 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", "msg", "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", "richText", "title", "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
}
// MessageID preserves the stable message identity needed by follow-up reply,
// reaction, resource and deduplication operations.
func MessageID(m map[string]any) any {
return firstMessageValue(m, "openMessageId", "openMsgId", "messageId", "message_id", "msgId", "msg_id", "id")
}
// ConversationID preserves the stable conversation identity carried by list
// and search responses.
func ConversationID(m map[string]any) any {
return firstMessageValue(m, "openConversationId", "openconversation_id", "conversationId", "conversation_id", "chatId", "chat_id")
}
// ThreadID preserves the stable topic/thread identity needed to continue from
// a message-list result into the thread-replies command.
func ThreadID(m map[string]any) any {
return firstMessageValue(
m,
"openConvThreadId",
"openConversationThreadId",
"threadId",
"thread_id",
"topicId",
"topic_id",
)
}
// MessageType preserves the lower message type when present.
func MessageType(m map[string]any) any {
return firstMessageValue(m, "msgType", "messageType", "message_type", "type")
}
// SenderID preserves the stable sender identity without replacing the legacy
// scalar sender display field. Nested sender records and both userId families
// are accepted because list/search/mget currently expose different shapes.
func SenderID(m map[string]any) any {
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
if value := firstMessageValue(nested,
"openDingTalkId", "openDingtalkId", "userId", "senderId", "id"); value != nil {
return value
}
}
}
return firstMessageValue(m,
"senderOpenDingTalkId", "senderOpenDingtalkId", "senderUserId",
"senderId", "sender_id", "senderStaffId", "openDingTalkId", "userId")
}
// SenderType returns only an explicitly published lower sender type. It does
// not guess that every sender identity is a user because bot/system messages
// can share the same generic senderId key.
func SenderType(m map[string]any) any {
for _, key := range []string{"sender", "from", "senderUser"} {
if nested, ok := m[key].(map[string]any); ok {
if value := firstMessageValue(nested, "senderType", "type", "entityType"); value != nil {
return value
}
}
}
return firstMessageValue(m, "senderType", "sender_type", "fromType", "from_type")
}
// ProjectMessageV1 is the single compatibility-preserving core projection for
// list, search, mget, @me, and thread readers. Public wrappers may retain
// legacy aliases such as time or msgType, but the underlying identity,
// context, reaction, quote, forward, and resource semantics come from here.
func ProjectMessageV1(m map[string]any, includeReactions bool) map[string]any {
ownedResources := Resources(m)
row := map[string]any{
"sender": Sender(m),
"text": projectedResourceText(m, ownedResources),
"createTime": CreateTime(m),
}
if value := MessageID(m); value != nil {
row["messageId"] = value
}
if value := ConversationID(m); value != nil {
row["conversationId"] = value
}
if value := ThreadID(m); value != nil {
row["threadId"] = value
}
if value := SenderID(m); value != nil {
row["senderId"] = value
}
if value := SenderType(m); value != nil {
row["senderType"] = value
}
if value := MessageType(m); value != nil {
row["messageType"] = value
}
if value := UpdateTime(m); value != nil {
row["updateTime"] = value
}
if includeReactions {
if reactions := Reactions(m); len(reactions) > 0 {
row["reactions"] = reactions
}
}
if quoted := QuotedMessage(m); len(quoted) > 0 {
row["quotedMessage"] = quoted
}
if resources := ResourcesDeep(m); len(resources) > 0 {
row["resourceRefs"] = resources
}
projectForwarded := func(item map[string]any) map[string]any {
return ProjectMessageV1(item, includeReactions)
}
if forwarded := Forwarded(m, projectForwarded); len(forwarded) > 0 {
row["forwarded"] = forwarded
}
return row
}
// QuotedMessage projects one level of quoted/replied-to context. It is
// deliberately non-recursive: a reply chain may be arbitrarily deep or even
// cyclic after gateway reshaping, while an Agent primarily needs the quoted
// message's stable identity, speaker, readable body and time.
func QuotedMessage(m map[string]any) map[string]any {
var quoted map[string]any
for _, key := range []string{"quotedMessage", "replyMessage", "quoted", "replyToMessage"} {
if value, ok := m[key].(map[string]any); ok {
quoted = value
break
}
}
if quoted == nil {
return nil
}
out := map[string]any{}
if value := MessageID(quoted); value != nil {
out["messageId"] = value
}
if value := ConversationID(quoted); value != nil {
out["conversationId"] = value
}
if value := ThreadID(quoted); value != nil {
out["threadId"] = value
}
if value := Sender(quoted); value != nil {
out["sender"] = value
}
resources := Resources(quoted)
if value := projectedResourceText(quoted, resources); value != nil {
out["text"] = value
}
if value := CreateTime(quoted); value != nil {
out["createTime"] = value
}
if value := MessageType(quoted); value != nil {
out["messageType"] = value
}
if len(resources) > 0 {
out["resourceRefs"] = resources
}
return out
}
func firstMessageValue(m map[string]any, keys ...string) any {
for _, key := range keys {
value, ok := m[key]
if !ok || value == nil {
continue
}
if text, isString := value.(string); isString && strings.TrimSpace(text) == "" {
continue
}
return value
}
return nil
}
// Resources extracts actionable media and drive-file references from both
// structured message fields and the textual mediaId/fileId notation returned
// by older DingTalk message APIs. Every reference publishes the exact Shortcut
// arguments already known from the message, plus ready=false and missing fields
// when a follow-up lookup is still required. This shared shape is used by list,
// search, mget, quoted messages and thread replies.
func Resources(m map[string]any) []map[string]any {
if m == nil {
return nil
}
mediaIDs := make([]string, 0)
collectResourceIDs(m, "mediaid", mediaIDTextRE, &mediaIDs)
mediaIDs = uniqueResourceIDs(mediaIDs)
sort.Strings(mediaIDs)
mediaNames := make(map[string]resourceNameCandidate)
collectResourceNames(m, "mediaid", mediaNames)
fileIDs := make([]string, 0)
collectResourceIDs(m, "fileid", fileIDTextRE, &fileIDs)
fileIDs = uniqueResourceIDs(fileIDs)
sort.Strings(fileIDs)
fileNames := make(map[string]resourceNameCandidate)
collectResourceNames(m, "fileid", fileNames)
if len(mediaIDs) == 0 && len(fileIDs) == 0 {
return nil
}
messageID := strings.TrimSpace(fmt.Sprint(MessageID(m)))
conversationID := strings.TrimSpace(fmt.Sprint(ConversationID(m)))
if messageID == "<nil>" {
messageID = ""
}
if conversationID == "<nil>" {
conversationID = ""
}
out := make([]map[string]any, 0, len(mediaIDs)+len(fileIDs))
for _, id := range mediaIDs {
arguments := map[string]any{
"type": "mediaId",
"resource-id": id,
}
missing := make([]string, 0, 2)
if messageID != "" {
arguments["message-id"] = messageID
} else {
missing = append(missing, "message-id")
}
if conversationID != "" {
arguments["open-conversation-id"] = conversationID
} else {
missing = append(missing, "open-conversation-id")
}
resource := map[string]any{
"type": "mediaId",
"resourceId": id,
"download": map[string]any{
"shortcut": "+messages-resource-download",
"arguments": arguments,
"ready": len(missing) == 0,
"missing": missing,
},
}
if candidate, ok := mediaNames[id]; ok {
resource["name"] = candidate.name
}
out = append(out, resource)
}
for _, id := range fileIDs {
resource := map[string]any{
"type": "fileId",
"resourceId": id,
"download": map[string]any{
"shortcut": "+messages-resource-download",
"arguments": map[string]any{
"type": "fileId",
"resource-id": id,
},
"ready": true,
"missing": []string{},
},
}
if candidate, ok := fileNames[id]; ok {
resource["name"] = candidate.name
}
out = append(out, resource)
}
return out
}
// ResourcesDeep returns resources from a message and each nested quoted,
// replied-to or forwarded message. Every nested resource is projected from the
// child message that owns it, so its download arguments never reuse the parent
// message ID. A missing child conversation ID inherits the enclosing
// conversation because quoted and forwarded records often omit that duplicate
// field.
func ResourcesDeep(m map[string]any) []map[string]any {
return resourcesDeep(m, "", 0)
}
const maxResourceMessageDepth = 32
func resourcesDeep(m map[string]any, inheritedConversationID string, depth int) []map[string]any {
if m == nil || depth > maxResourceMessageDepth {
return nil
}
conversationID := strings.TrimSpace(fmt.Sprint(ConversationID(m)))
if conversationID == "" || conversationID == "<nil>" {
conversationID = inheritedConversationID
}
owned := m
if ConversationID(m) == nil && conversationID != "" {
owned = make(map[string]any, len(m)+1)
for key, value := range m {
owned[key] = value
}
owned["openConversationId"] = conversationID
}
out := append([]map[string]any(nil), Resources(owned)...)
if depth == maxResourceMessageDepth {
return out
}
for _, child := range nestedMessageChildren(m) {
out = append(out, resourcesDeep(child, conversationID, depth+1)...)
}
return out
}
var mediaIDTextRE = regexp.MustCompile(`(?i)\bmedia[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileIDTextRE = regexp.MustCompile(`(?i)\bfile[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var fileNameAndIDTextRE = regexp.MustCompile(`(?i)\[文件\]\s*([^\r\n]*?)\s+file[_-]?id\s*[:=]\s*["']?([^"'\s)\]}>,]+)`)
var legacyResourceDownloadHintRE = regexp.MustCompile(`\s*注意:如需下载使用dws\s+(?:chat message download-media|drive download)命令下载\s*`)
// projectedResourceText removes only the exact, machine-generated download
// hint emitted by older IM APIs. The readable resource marker and ID remain in
// text, while resourceRefs publishes the current executable download command.
// Text without an owned mediaId/fileId is left byte-for-byte unchanged so an
// ordinary user sentence mentioning a command can never be rewritten.
func projectedResourceText(m map[string]any, resources []map[string]any) any {
value := Text(m)
text, ok := value.(string)
if !ok || len(resources) == 0 ||
(!mediaIDTextRE.MatchString(text) && !fileIDTextRE.MatchString(text)) {
return value
}
return strings.TrimSpace(legacyResourceDownloadHintRE.ReplaceAllString(text, ""))
}
type resourceNameCandidate struct {
name string
priority int
}
const (
resourceNamePriorityText = 1
resourceNamePriorityStructured = 2
)
// collectResourceNames keeps a resource ID paired with a name only when both
// are present in the same structured object or in the legacy, machine-shaped
// "[文件] name fileId: id" text. This deliberately does not borrow a generic
// message title or sender name: an unknown resource name is safer than a
// plausible but incorrect one.
func collectResourceNames(value any, targetKey string, out map[string]resourceNameCandidate) {
switch typed := value.(type) {
case map[string]any:
directIDs := directResourceIDs(typed, targetKey)
if len(directIDs) == 1 {
if name := directResourceName(typed, targetKey); name != "" {
recordResourceName(out, directIDs[0], name, resourceNamePriorityStructured)
}
}
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if isNestedMessageBoundaryKey(normalizeMessageKey(key)) {
continue
}
collectResourceNames(typed[key], targetKey, out)
}
case []any:
for _, child := range typed {
collectResourceNames(child, targetKey, out)
}
case []map[string]any:
for _, child := range typed {
collectResourceNames(child, targetKey, out)
}
case string:
if targetKey == "fileid" {
for _, match := range fileNameAndIDTextRE.FindAllStringSubmatch(typed, -1) {
name := strings.TrimSpace(match[1])
id := resourceIDScalar(match[2])
if name != "" && id != "" {
recordResourceName(out, id, name, resourceNamePriorityText)
}
}
}
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
collectResourceNames(decoded, targetKey, out)
}
}
}
}
func directResourceIDs(value map[string]any, targetKey string) []string {
resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint(
firstMessageValue(value, "resourceType", "resource_type"))))
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
ids := make([]string, 0, 1)
for _, key := range keys {
normalizedKey := normalizeMessageKey(key)
if normalizedKey != targetKey &&
!(normalizedKey == "resourceid" && resourceType == targetKey) {
continue
}
if id := resourceIDScalar(value[key]); id != "" {
ids = append(ids, id)
}
}
return uniqueResourceIDs(ids)
}
func directResourceName(value map[string]any, targetKey string) string {
for _, wanted := range []string{"filename", "resourcename", "originalfilename"} {
if name := directResourceString(value, wanted); name != "" {
return name
}
}
// A bare "name" is accepted only inside an explicit resource envelope.
// Message rows also commonly contain a sender/group name, which must never
// become the attachment filename merely because the row has a resource ID.
resourceType := normalizeMessageKey(strings.TrimSpace(fmt.Sprint(
firstMessageValue(value, "resourceType", "resource_type"))))
if resourceType == targetKey && directResourceString(value, "resourceid") != "" {
return directResourceString(value, "name")
}
return ""
}
func directResourceString(value map[string]any, wanted string) string {
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if normalizeMessageKey(key) != wanted {
continue
}
if text, ok := value[key].(string); ok {
return strings.TrimSpace(text)
}
}
return ""
}
func recordResourceName(
out map[string]resourceNameCandidate,
id, name string,
priority int,
) {
id = resourceIDScalar(id)
name = strings.TrimSpace(name)
if id == "" || name == "" {
return
}
if current, ok := out[id]; ok && current.priority >= priority {
return
}
out[id] = resourceNameCandidate{name: name, priority: priority}
}
func collectResourceIDs(value any, targetKey string, textPattern *regexp.Regexp, out *[]string) {
switch typed := value.(type) {
case map[string]any:
resourceType := strings.TrimSpace(fmt.Sprint(firstMessageValue(typed, "resourceType", "resource_type")))
for key, child := range typed {
normalizedKey := normalizeMessageKey(key)
if normalizedKey == targetKey ||
(normalizedKey == "resourceid" && strings.EqualFold(resourceType, targetKey)) {
if id := resourceIDScalar(child); id != "" {
*out = append(*out, id)
}
}
if isNestedMessageBoundaryKey(normalizedKey) {
continue
}
collectResourceIDs(child, targetKey, textPattern, out)
}
case []any:
for _, child := range typed {
collectResourceIDs(child, targetKey, textPattern, out)
}
case []map[string]any:
for _, child := range typed {
collectResourceIDs(child, targetKey, textPattern, out)
}
case string:
for _, match := range textPattern.FindAllStringSubmatch(typed, -1) {
if len(match) > 1 {
if id := resourceIDScalar(match[1]); id != "" {
*out = append(*out, id)
}
}
}
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
collectResourceIDs(decoded, targetKey, textPattern, out)
}
}
}
}
func normalizeMessageKey(key string) string {
return strings.ToLower(strings.NewReplacer("_", "", "-", "").Replace(key))
}
func isNestedMessageBoundaryKey(key string) bool {
switch key {
case "quotedmessage", "replymessage", "quoted", "replytomessage",
"forwardmessages", "forwardedmessages", "forwarded":
return true
default:
return false
}
}
func nestedMessageMaps(value any) []map[string]any {
switch typed := value.(type) {
case map[string]any:
return []map[string]any{typed}
case []map[string]any:
return typed
case []any:
out := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if child, ok := item.(map[string]any); ok {
out = append(out, child)
}
}
return out
case string:
var decoded any
if json.Unmarshal([]byte(strings.TrimSpace(typed)), &decoded) == nil {
return nestedMessageMaps(decoded)
}
}
return nil
}
func nestedMessageChildren(value any) []map[string]any {
out := make([]map[string]any, 0)
switch typed := value.(type) {
case map[string]any:
for key, child := range typed {
if isNestedMessageBoundaryKey(normalizeMessageKey(key)) {
out = append(out, nestedMessageMaps(child)...)
continue
}
out = append(out, nestedMessageChildren(child)...)
}
case []any:
for _, child := range typed {
out = append(out, nestedMessageChildren(child)...)
}
case []map[string]any:
for _, child := range typed {
out = append(out, nestedMessageChildren(child)...)
}
case string:
trimmed := strings.TrimSpace(typed)
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
var decoded any
if json.Unmarshal([]byte(trimmed), &decoded) == nil {
out = append(out, nestedMessageChildren(decoded)...)
}
}
}
return out
}
func resourceIDScalar(value any) string {
text, ok := value.(string)
if !ok {
return ""
}
return strings.Trim(strings.TrimSpace(text), `"'`)
}
func uniqueResourceIDs(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
}
// UpdateTime reads an edited message's update time. Gateways sometimes echo
// createTime as updateTime even when the message was never edited; omit that
// duplicate so Agents do not infer a nonexistent edit.
func UpdateTime(m map[string]any) any {
createTime := CreateTime(m)
for _, key := range []string{"updateTime", "modifiedTime", "gmtModified", "editTime"} {
if v, ok := m[key]; ok && v != nil {
if createTime != nil && reflect.DeepEqual(v, createTime) {
return nil
}
return v
}
}
return nil
}
// Reactions normalises DingTalk's inline emotionReplyList into one compact,
// Agent-friendly block. Unlike Lark, DingTalk already returns these reactions
// with message-list responses, so this projection performs no extra network
// request.
//
// Output shape:
//
// "reactions": {
// "counts": [{"emoji": "赞", "count": 3}],
// "details": [{"emoji": "赞", "replyUsers": ["..."]}]
// }
func Reactions(m map[string]any) map[string]any {
var raw []any
for _, key := range []string{"emotionReplyList", "reactionList", "reactions"} {
switch value := m[key].(type) {
case []any:
raw = value
case []map[string]any:
raw = make([]any, 0, len(value))
for _, item := range value {
raw = append(raw, item)
}
}
if len(raw) > 0 {
break
}
}
if len(raw) == 0 {
return nil
}
counts := make([]map[string]any, 0, len(raw))
details := make([]map[string]any, 0, len(raw))
for _, item := range raw {
entry, ok := item.(map[string]any)
if !ok {
continue
}
emoji := firstReactionValue(entry, "emoji", "emojiName", "reactionType", "emotionName", "text")
users := reactionUsers(entry)
count := reactionCount(entry, len(users))
if emoji == nil && count == 0 && len(users) == 0 {
continue
}
countRow := map[string]any{"count": count}
detailRow := map[string]any{}
if emoji != nil {
countRow["emoji"] = emoji
detailRow["emoji"] = emoji
}
if len(users) > 0 {
detailRow["replyUsers"] = users
}
counts = append(counts, countRow)
if len(detailRow) > 0 {
details = append(details, detailRow)
}
}
if len(counts) == 0 && len(details) == 0 {
return nil
}
out := map[string]any{}
if len(counts) > 0 {
out["counts"] = counts
}
if len(details) > 0 {
out["details"] = details
}
return out
}
func firstReactionValue(m map[string]any, keys ...string) any {
for _, key := range keys {
value, ok := m[key]
if !ok || value == nil {
continue
}
if text, isString := value.(string); isString && strings.TrimSpace(text) == "" {
continue
}
return value
}
return nil
}
func reactionUsers(m map[string]any) []any {
for _, key := range []string{"replyUsers", "users", "operators"} {
switch value := m[key].(type) {
case []any:
return value
case []string:
out := make([]any, 0, len(value))
for _, item := range value {
out = append(out, item)
}
return out
}
}
return nil
}
func reactionCount(m map[string]any, fallback int) any {
for _, key := range []string{"count", "replyCount", "reactionCount"} {
switch value := m[key].(type) {
case int, int32, int64, float32, float64, json.Number:
return value
case string:
if strings.TrimSpace(value) != "" {
return value
}
}
}
return fallback
}
// ApplyPagination carries lower-layer completeness facts into a projected
// Shortcut payload. It intentionally preserves cursor values only in the
// command output (where callers need them to continue); audit reports redact
// those values and retain only their presence.
func ApplyPagination(payload, data map[string]any) {
for key, value := range Pagination(data) {
payload[key] = value
}
}
// ApplyMessagePagination publishes message-list completeness and converts the
// authoritative millisecond nextCursor into the RFC3339Nano time boundary
// accepted by the executable message-list command. Projected createTime is
// deliberately not used because it is only second precision.
func ApplyMessagePagination(payload, data map[string]any, messages []map[string]any, direction string) {
payload["contractVersion"] = MessageListContractVersion
payload["pagesFetched"] = 1
payload["enrichedCount"] = 0
payload["failedCount"] = 0
payload["failures"] = []map[string]any{}
payload["hasMore"] = false
payload["complete"] = false
page := Pagination(data)
if len(page) == 0 {
payload["paginationKnown"] = false
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层未返回可靠的 hasMore/nextCursor,无法证明结果完整",
}}
return
}
value, hasMoreKnown := page["hasMore"]
if !hasMoreKnown {
payload["paginationKnown"] = false
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层仅返回 cursor、未返回 hasMore,无法证明结果完整",
}}
return
}
payload["paginationKnown"] = true
payload["hasMore"] = value
if value, ok := page["complete"]; ok {
payload["complete"] = value
}
hasMore, _ := page["hasMore"].(bool)
if !hasMore {
return
}
if len(messages) == 0 {
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层返回 hasMore=true 但当前页没有消息",
}}
return
}
_, boundary, err := messagePaginationCursorBoundary(page["nextCursor"])
if err != nil {
payload["failedCount"] = 1
payload["failures"] = []map[string]any{{
"stage": "pagination",
"error": "下层返回 hasMore=true,但 nextCursor 无效: " + err.Error(),
}}
return
}
next := map[string]any{"time": boundary, "nextCursor": page["nextCursor"]}
if strings.TrimSpace(direction) != "" {
next["direction"] = direction
}
payload["nextPage"] = next
}
func messagePaginationCursorBoundary(value any) (string, string, error) {
var millis int64
switch typed := value.(type) {
case int:
millis = int64(typed)
case int32:
millis = int64(typed)
case int64:
millis = typed
case float32:
asFloat := float64(typed)
if math.IsNaN(asFloat) || math.IsInf(asFloat, 0) || asFloat <= 0 || math.Trunc(asFloat) != asFloat || asFloat > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(asFloat)
case float64:
if math.IsNaN(typed) || math.IsInf(typed, 0) || typed <= 0 || math.Trunc(typed) != typed || typed > math.MaxInt64 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = int64(typed)
case string:
parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
if err != nil {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
millis = parsed
default:
return "", "", fmt.Errorf("缺少毫秒级分页游标")
}
if millis <= 0 {
return "", "", fmt.Errorf("必须是正整数毫秒时间戳")
}
key := strconv.FormatInt(millis, 10)
boundary := time.UnixMilli(millis).UTC().Format(time.RFC3339Nano)
return key, boundary, nil
}
// Pagination extracts hasMore/nextCursor from the response root or a common
// result/data envelope. When hasMore is present it also emits the explicit
// inverse "complete", making truncation hard for an Agent to overlook.
func Pagination(data map[string]any) map[string]any {
if data == nil {
return nil
}
scopes := []map[string]any{data}
for _, key := range []string{"result", "data"} {
if inner, ok := data[key].(map[string]any); ok {
scopes = append(scopes, inner)
}
}
for _, scope := range scopes {
out := map[string]any{}
for _, key := range []string{"hasMore", "has_more"} {
if value, ok := scope[key].(bool); ok {
out["hasMore"] = value
out["complete"] = !value
break
}
}
for _, key := range []string{"nextCursor", "next_cursor", "nextToken", "next_token", "pageToken", "page_token"} {
if value, ok := scope[key]; ok && paginationValuePresent(value) {
out["nextCursor"] = value
break
}
}
if len(out) > 0 {
return out
}
}
return nil
}
func paginationValuePresent(value any) bool {
switch typed := value.(type) {
case nil:
return false
case json.Number:
number, err := typed.Float64()
return err != nil || number != 0
case string:
return strings.TrimSpace(typed) != "" && strings.TrimSpace(typed) != "0"
case int:
return typed != 0
case int64:
return typed != 0
case float64:
return typed != 0
default:
return true
}
}
// 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
}