412 lines
13 KiB
Go
412 lines
13 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 helpers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// chatRecordLookup records which entries DingTalk erased to unknownMsgType in
|
|
// a Stream callback. The indexes line up with forwardMessages returned by the
|
|
// user-state message API, which still exposes the original message metadata.
|
|
type chatRecordLookup struct {
|
|
MsgID string
|
|
UnknownIndexes []int
|
|
}
|
|
|
|
type chatRecordToolCall func(context.Context, string, string, map[string]any) (string, error)
|
|
|
|
var chatRecordDownloadToolCall chatRecordToolCall = callMCPToolReturnTextOnServer
|
|
|
|
type chatRecordMessage struct {
|
|
OpenMessageID string `json:"openMessageId"`
|
|
OpenConversationID string `json:"openConversationId"`
|
|
CreateTime string `json:"createTime"`
|
|
Content string `json:"content"`
|
|
ForwardMessages []chatRecordMessage `json:"forwardMessages"`
|
|
}
|
|
|
|
type chatRecordMessagesEnvelope struct {
|
|
Result struct {
|
|
Messages []chatRecordMessage `json:"messages"`
|
|
} `json:"result"`
|
|
}
|
|
|
|
type chatRecordEnrichment struct {
|
|
Prompt string
|
|
Files []fileInboundInfo
|
|
MissingCount int
|
|
}
|
|
|
|
var (
|
|
chatRecordMediaIDPattern = regexp.MustCompile(`\(?mediaId=([^\s)]+)\)?`)
|
|
chatRecordFileIDPattern = regexp.MustCompile(`(?i)\bfileId:\s*([^\s]+)`)
|
|
chatRecordFileNamePattern = regexp.MustCompile(`(?m)^\[文件\]\s*(.*?)(?:\s+fileId:|$)`)
|
|
chatRecordDownloadHintPattern = regexp.MustCompile(`\s*注意:如需下载使用dws\s+(?:chat message download-media|drive download)命令下载\s*`)
|
|
chatRecordMkdirAll = os.MkdirAll
|
|
)
|
|
|
|
func chatRecordEntries(content interface{}) []interface{} {
|
|
m, ok := content.(map[string]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if entries, ok := m["chatRecord"].([]interface{}); ok {
|
|
return entries
|
|
}
|
|
if raw, ok := m["chatRecord"].(string); ok {
|
|
var entries []interface{}
|
|
if json.Unmarshal([]byte(raw), &entries) == nil {
|
|
return entries
|
|
}
|
|
}
|
|
if entries, ok := m["contents"].([]interface{}); ok {
|
|
return entries
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func chatRecordUnknownIndexes(content interface{}) []int {
|
|
entries := chatRecordEntries(content)
|
|
indexes := make([]int, 0)
|
|
for i, entry := range entries {
|
|
node, ok := entry.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
for _, key := range []string{"msgType", "msgtype", "type"} {
|
|
if value, ok := node[key].(string); ok && strings.EqualFold(strings.TrimSpace(value), "unknownMsgType") {
|
|
info := parseTypedFileInbound("file", node)
|
|
if !info.hasActionable() {
|
|
indexes = append(indexes, i)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return indexes
|
|
}
|
|
|
|
// recoverChatRecordUnknowns resolves only the entries that were
|
|
// unknownMsgType in the Stream callback. It deliberately runs after the
|
|
// callback returns, so user-state MCP lookups cannot delay DingTalk's ACK.
|
|
func recoverChatRecordUnknowns(ctx context.Context, lookup chatRecordLookup, call chatRecordToolCall) (chatRecordEnrichment, error) {
|
|
var enrichment chatRecordEnrichment
|
|
if strings.TrimSpace(lookup.MsgID) == "" || len(lookup.UnknownIndexes) == 0 {
|
|
return enrichment, nil
|
|
}
|
|
|
|
raw, err := call(ctx, "im", "list_messages_by_ids", map[string]any{
|
|
"openMsgIds": []string{strings.TrimSpace(lookup.MsgID)},
|
|
})
|
|
if err != nil {
|
|
return enrichment, fmt.Errorf("查询合并转发消息: %w", err)
|
|
}
|
|
envelope, err := parseChatRecordMessages(raw)
|
|
if err != nil {
|
|
return enrichment, fmt.Errorf("解析合并转发消息: %w", err)
|
|
}
|
|
var outer *chatRecordMessage
|
|
for i := range envelope.Result.Messages {
|
|
if envelope.Result.Messages[i].OpenMessageID == strings.TrimSpace(lookup.MsgID) {
|
|
outer = &envelope.Result.Messages[i]
|
|
break
|
|
}
|
|
}
|
|
if outer == nil && len(envelope.Result.Messages) == 1 {
|
|
outer = &envelope.Result.Messages[0]
|
|
}
|
|
if outer == nil {
|
|
return enrichment, fmt.Errorf("未找到外层消息 %s", strings.TrimSpace(lookup.MsgID))
|
|
}
|
|
|
|
indexes := uniqueValidIndexes(lookup.UnknownIndexes, len(outer.ForwardMessages))
|
|
if len(indexes) == 0 {
|
|
return enrichment, fmt.Errorf("unknownMsgType 索引超出转发消息范围")
|
|
}
|
|
|
|
// list_messages_by_ids keeps mediaId for images/audio but can omit fileId
|
|
// and even stamp forwarded files with the outer conversation ID. Query every
|
|
// candidate source conversation visible in the same record and match by the
|
|
// stable inner openMessageId to recover the actual fileId.
|
|
resolvedByID := make(map[string]chatRecordMessage)
|
|
needsFileLookup := false
|
|
for _, index := range indexes {
|
|
message := outer.ForwardMessages[index]
|
|
resolvedByID[message.OpenMessageID] = message
|
|
if looksLikeForwardedFile(message.Content) && chatRecordFileID(message.Content) == "" {
|
|
needsFileLookup = true
|
|
}
|
|
}
|
|
if needsFileLookup {
|
|
enrichForwardedFileLocators(ctx, outer.ForwardMessages, resolvedByID, call)
|
|
}
|
|
|
|
lines := []string{"已通过钉钉消息接口补拉到合并转发中原先标记为 unknownMsgType 的内容:"}
|
|
for _, index := range indexes {
|
|
message := outer.ForwardMessages[index]
|
|
if resolved, ok := resolvedByID[message.OpenMessageID]; ok {
|
|
message = resolved
|
|
}
|
|
lines = append(lines, fmt.Sprintf("%d. %s", index+1, humanChatRecordContent(message.Content)))
|
|
info, ok := recoveredForwardAttachment(message)
|
|
if !ok || !info.hasActionable() {
|
|
if looksLikeForwardedAttachment(message.Content) {
|
|
enrichment.MissingCount++
|
|
}
|
|
continue
|
|
}
|
|
enrichment.Files = append(enrichment.Files, info)
|
|
}
|
|
enrichment.Prompt = strings.Join(lines, "\n")
|
|
return enrichment, nil
|
|
}
|
|
|
|
func parseChatRecordMessages(raw string) (chatRecordMessagesEnvelope, error) {
|
|
var envelope chatRecordMessagesEnvelope
|
|
if err := json.Unmarshal([]byte(raw), &envelope); err != nil {
|
|
return envelope, err
|
|
}
|
|
return envelope, nil
|
|
}
|
|
|
|
func uniqueValidIndexes(indexes []int, length int) []int {
|
|
seen := make(map[int]struct{}, len(indexes))
|
|
out := make([]int, 0, len(indexes))
|
|
for _, index := range indexes {
|
|
if index < 0 || index >= length {
|
|
continue
|
|
}
|
|
if _, ok := seen[index]; ok {
|
|
continue
|
|
}
|
|
seen[index] = struct{}{}
|
|
out = append(out, index)
|
|
}
|
|
sort.Ints(out)
|
|
return out
|
|
}
|
|
|
|
func enrichForwardedFileLocators(ctx context.Context, all []chatRecordMessage, resolved map[string]chatRecordMessage, call chatRecordToolCall) {
|
|
candidates := make(map[string]struct{})
|
|
var earliest time.Time
|
|
for _, message := range all {
|
|
if conversationID := strings.TrimSpace(message.OpenConversationID); conversationID != "" {
|
|
candidates[conversationID] = struct{}{}
|
|
}
|
|
if parsed, err := time.ParseInLocation("2006-01-02 15:04:05", strings.TrimSpace(message.CreateTime), time.Local); err == nil && (earliest.IsZero() || parsed.Before(earliest)) {
|
|
earliest = parsed
|
|
}
|
|
}
|
|
if earliest.IsZero() {
|
|
return
|
|
}
|
|
start := earliest.Add(-time.Minute).Format("2006-01-02 15:04:05")
|
|
for conversationID := range candidates {
|
|
raw, err := call(ctx, "chat", "list_conversation_message_v2", map[string]any{
|
|
"openconversation_id": conversationID,
|
|
"time": start,
|
|
"forward": true,
|
|
"limit": 50,
|
|
})
|
|
if err != nil {
|
|
continue
|
|
}
|
|
envelope, err := parseChatRecordMessages(raw)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
for _, message := range envelope.Result.Messages {
|
|
current, wanted := resolved[message.OpenMessageID]
|
|
if !wanted || chatRecordFileID(message.Content) == "" {
|
|
continue
|
|
}
|
|
current.Content = message.Content
|
|
current.OpenConversationID = message.OpenConversationID
|
|
if strings.TrimSpace(current.CreateTime) == "" {
|
|
current.CreateTime = message.CreateTime
|
|
}
|
|
resolved[message.OpenMessageID] = current
|
|
}
|
|
}
|
|
}
|
|
|
|
func recoveredForwardAttachment(message chatRecordMessage) (fileInboundInfo, bool) {
|
|
content := strings.TrimSpace(message.Content)
|
|
info := fileInboundInfo{
|
|
OpenMessageID: strings.TrimSpace(message.OpenMessageID),
|
|
OpenConversationID: strings.TrimSpace(message.OpenConversationID),
|
|
}
|
|
if mediaID := chatRecordMediaID(content); mediaID != "" {
|
|
info.MediaID = mediaID
|
|
switch {
|
|
case strings.Contains(content, "[图片消息]"):
|
|
info.MediaType, info.FileName = "image", "转发图片"
|
|
case strings.Contains(content, "[语音消息]"):
|
|
info.MediaType, info.FileName = "audio", "转发语音.bin"
|
|
case strings.Contains(content, "[视频消息]"):
|
|
info.MediaType, info.FileName = "video", "转发视频.bin"
|
|
default:
|
|
info.MediaType, info.FileName = "file", "转发媒体.bin"
|
|
}
|
|
return info, true
|
|
}
|
|
if !looksLikeForwardedFile(content) {
|
|
return fileInboundInfo{}, false
|
|
}
|
|
info.FileID = chatRecordFileID(content)
|
|
info.FileName = chatRecordFileName(content)
|
|
if info.FileName == "" {
|
|
info.FileName = "转发文件"
|
|
}
|
|
info.MediaType = mediaTypeFromFileName(info.FileName)
|
|
return info, true
|
|
}
|
|
|
|
func chatRecordMediaID(content string) string {
|
|
match := chatRecordMediaIDPattern.FindStringSubmatch(content)
|
|
if len(match) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(match[1])
|
|
}
|
|
|
|
func chatRecordFileID(content string) string {
|
|
match := chatRecordFileIDPattern.FindStringSubmatch(content)
|
|
if len(match) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(match[1])
|
|
}
|
|
|
|
func chatRecordFileName(content string) string {
|
|
match := chatRecordFileNamePattern.FindStringSubmatch(content)
|
|
if len(match) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(match[1])
|
|
}
|
|
|
|
func looksLikeForwardedFile(content string) bool {
|
|
return strings.Contains(content, "[文件]")
|
|
}
|
|
|
|
func looksLikeForwardedAttachment(content string) bool {
|
|
return looksLikeForwardedFile(content) || chatRecordMediaID(content) != ""
|
|
}
|
|
|
|
func humanChatRecordContent(content string) string {
|
|
content = chatRecordDownloadHintPattern.ReplaceAllString(content, "")
|
|
content = chatRecordMediaIDPattern.ReplaceAllString(content, "")
|
|
content = chatRecordFileIDPattern.ReplaceAllString(content, "")
|
|
content = strings.TrimSpace(content)
|
|
if content == "" {
|
|
return "[无法提取文字内容]"
|
|
}
|
|
return content
|
|
}
|
|
|
|
func mediaTypeFromFileName(fileName string) string {
|
|
switch strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))) {
|
|
case ".mp4", ".mov", ".m4v", ".avi", ".mkv", ".webm":
|
|
return "video"
|
|
case ".mp3", ".m4a", ".aac", ".wav", ".amr", ".ogg", ".flac":
|
|
return "audio"
|
|
case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic":
|
|
return "image"
|
|
default:
|
|
return "file"
|
|
}
|
|
}
|
|
|
|
func (c *aiCardClient) downloadRecoveredChatRecordFile(ctx context.Context, info fileInboundInfo) (string, error) {
|
|
return c.downloadRecoveredChatRecordFileWithCall(ctx, info, chatRecordDownloadToolCall)
|
|
}
|
|
|
|
func (c *aiCardClient) downloadRecoveredChatRecordFileWithCall(ctx context.Context, info fileInboundInfo, call chatRecordToolCall) (string, error) {
|
|
var raw string
|
|
var err error
|
|
switch {
|
|
case strings.TrimSpace(info.MediaID) != "":
|
|
raw, err = call(ctx, "im", "get_resource_download_url", map[string]any{
|
|
"resourceType": "mediaId",
|
|
"resourceId": strings.TrimSpace(info.MediaID),
|
|
"openMessageId": strings.TrimSpace(info.OpenMessageID),
|
|
"openConversationId": strings.TrimSpace(info.OpenConversationID),
|
|
})
|
|
case strings.TrimSpace(info.FileID) != "":
|
|
raw, err = call(ctx, "drive", "download_file", map[string]any{
|
|
"fileId": strings.TrimSpace(info.FileID),
|
|
})
|
|
default:
|
|
return "", fmt.Errorf("转发附件缺少 mediaId/fileId")
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resourceURL, headers, err := parseDownloadInfo(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return downloadConnectURLToTemp(ctx, c.httpClient, resourceURL, headers, info.FileName)
|
|
}
|
|
|
|
func downloadConnectURLToTemp(ctx context.Context, client *http.Client, resourceURL string, headers map[string]string, fileName string) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, resourceURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for key, value := range headers {
|
|
req.Header.Set(key, value)
|
|
}
|
|
resp, err := connectMediaDownloadClient(client).Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 400 {
|
|
return "", fmt.Errorf("转发附件下载 HTTP %d", resp.StatusCode)
|
|
}
|
|
if resp.ContentLength > mediaMaxDownloadBytes {
|
|
return "", fmt.Errorf("转发附件过大:%d 字节,最大允许 %d 字节", resp.ContentLength, mediaMaxDownloadBytes)
|
|
}
|
|
dir := filepath.Join(os.TempDir(), "dws-connect-media")
|
|
if err := chatRecordMkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
ext := ""
|
|
if name := strings.TrimSpace(fileName); name != "" {
|
|
ext = filepath.Ext(filepath.Base(name))
|
|
}
|
|
if ext == "" {
|
|
ext = mediaExt(resourceURL, resp.Header.Get("Content-Type"))
|
|
}
|
|
dest := filepath.Join(dir, uuid.NewString()+ext)
|
|
if err := writeCompleteMediaFile(dest, resp.Body); err != nil {
|
|
return "", err
|
|
}
|
|
return dest, nil
|
|
}
|