Files
dingtalk-workspace-cli/internal/helpers/connect_chatrecord.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
}