1332 lines
40 KiB
Go
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
|
|
}
|