798 lines
27 KiB
Go
798 lines
27 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"
|
|
"io"
|
|
"mime"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// mediaMaxDownloadBytes caps a single inbound attachment download. Real merged
|
|
// forwards can contain high-resolution screen recordings above 250 MiB, so the
|
|
// connector allows up to 512 MiB but rejects larger payloads explicitly. It
|
|
// must never silently truncate a file and then tell an agent it has the
|
|
// original content.
|
|
const mediaMaxDownloadBytes = 512 << 20
|
|
|
|
const mediaDownloadTimeout = 5 * time.Minute
|
|
|
|
// connectAttachmentMIME returns the best MIME type available for a downloaded
|
|
// attachment. DingTalk's download API returns an opaque URL, so the connector
|
|
// must recover the type from the preserved filename and, when necessary, the
|
|
// actual bytes before handing it to a multimodal backend.
|
|
func connectAttachmentMIME(path string) string {
|
|
if typ := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))); typ != "" {
|
|
typ = strings.TrimSpace(strings.SplitN(typ, ";", 2)[0])
|
|
// Generic .bin paths are common for DingTalk voice messages. Sniff their
|
|
// bytes instead of telling a multimodal backend they are opaque binary.
|
|
if typ != "application/octet-stream" {
|
|
return typ
|
|
}
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "application/octet-stream"
|
|
}
|
|
defer f.Close()
|
|
buf := make([]byte, 512)
|
|
n, _ := f.Read(buf)
|
|
if n == 0 {
|
|
return "application/octet-stream"
|
|
}
|
|
return http.DetectContentType(buf[:n])
|
|
}
|
|
|
|
var (
|
|
mediaMkdirAll = os.MkdirAll
|
|
mediaCreate = os.Create
|
|
mediaCopy = io.Copy
|
|
mediaRemove = os.Remove
|
|
connectMediaCallRaw = func(c *aiCardClient, ctx context.Context, method, path string, body map[string]any) (string, error) {
|
|
return c.callRaw(ctx, method, path, body)
|
|
}
|
|
)
|
|
|
|
// pictureDownloadCode digs the downloadCode out of a picture callback's
|
|
// loosely-typed content payload (the stream SDK models Content as
|
|
// interface{}).
|
|
func pictureDownloadCode(content interface{}) string {
|
|
m, ok := content.(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
for _, key := range []string{"downloadCode", "pictureDownloadCode"} {
|
|
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
|
|
return strings.TrimSpace(v)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func stringField(m map[string]interface{}, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := m[key].(string); ok && strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// explicitPictureDownloadCode only accepts the picture-specific field. A
|
|
// generic downloadCode can represent any kind of attachment and must not be
|
|
// classified as an image merely because it is downloadable.
|
|
func explicitPictureDownloadCode(content interface{}) string {
|
|
m, ok := content.(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return stringField(m, "pictureDownloadCode")
|
|
}
|
|
|
|
// richTextPictureDownloadCodes returns the media download codes embedded in a
|
|
// msgtype="richText" callback, preserving the node order. DingTalk represents
|
|
// an inline picture as a richText node instead of a top-level picture message:
|
|
//
|
|
// {"type":"picture","downloadCode":"...","pictureDownloadCode":"..."}
|
|
//
|
|
// The two code fields identify the same picture; pictureDownloadCode handles
|
|
// their precedence and returns only one code per node.
|
|
func richTextPictureDownloadCodes(content interface{}) []string {
|
|
pictures, _ := richTextInboundMedia(content)
|
|
return pictures
|
|
}
|
|
|
|
// richTextInboundMedia applies the same capability-based discovery to every
|
|
// inline node. A non-picture node with a locator is preserved as an
|
|
// attachment instead of being discarded because its type is new or unknown.
|
|
func richTextInboundMedia(content interface{}) (pictureCodes []string, files []fileInboundInfo) {
|
|
m, ok := content.(map[string]interface{})
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
items, ok := m["richText"].([]interface{})
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
seenPictures := make(map[string]struct{})
|
|
seenFiles := make(map[string]struct{})
|
|
for _, item := range items {
|
|
node, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
typeHint := strings.ToLower(stringField(node, "type", "msgType", "msgtype"))
|
|
code := ""
|
|
if explicitPictureDownloadCode(node) != "" {
|
|
code = pictureDownloadCode(node)
|
|
}
|
|
if code == "" && (typeHint == "" || typeHint == "picture" || typeHint == "image") {
|
|
code = pictureDownloadCode(node)
|
|
}
|
|
if code != "" {
|
|
if _, exists := seenPictures[code]; !exists {
|
|
seenPictures[code] = struct{}{}
|
|
pictureCodes = append(pictureCodes, code)
|
|
}
|
|
continue
|
|
}
|
|
info := parseTypedFileInbound(typeHint, node)
|
|
if !info.hasActionable() {
|
|
continue
|
|
}
|
|
key := fileInboundKey(info)
|
|
if _, exists := seenFiles[key]; !exists {
|
|
seenFiles[key] = struct{}{}
|
|
files = append(files, info)
|
|
}
|
|
}
|
|
return pictureCodes, files
|
|
}
|
|
|
|
// fileInboundInfo carries everything a msgtype="file" callback might expose.
|
|
// Client-sent files (a user attaching a file in the DingTalk client) surface
|
|
// DownloadCode + FileName; API-sent files (`dws chat message send --msg-type
|
|
// file --dentry-id --space-id`) surface DentryID + SpaceID + FileName +
|
|
// FileType + FileSize + FilePath and NO DownloadCode. Both shapes have to be
|
|
// recognisable or the connector silently drops legitimate file messages.
|
|
type fileInboundInfo struct {
|
|
DownloadCode string
|
|
// MediaID + message/conversation IDs identify media embedded in a
|
|
// forwarded chat record. DingTalk's Stream callback can erase these into
|
|
// unknownMsgType, while the user-state message API still preserves them.
|
|
MediaID string
|
|
OpenMessageID string
|
|
OpenConversationID string
|
|
// FileID is the dentryUuid returned by the user-state conversation API for
|
|
// forwarded files. It is resolved through drive.download_file.
|
|
FileID string
|
|
FileName string
|
|
FileType string
|
|
FilePath string
|
|
MediaType string
|
|
DentryID int64
|
|
SpaceID int64
|
|
FileSize int64
|
|
}
|
|
|
|
func (f fileInboundInfo) hasActionable() bool {
|
|
return strings.TrimSpace(f.DownloadCode) != "" ||
|
|
(strings.TrimSpace(f.MediaID) != "" && strings.TrimSpace(f.OpenMessageID) != "" && strings.TrimSpace(f.OpenConversationID) != "") ||
|
|
strings.TrimSpace(f.FileID) != "" ||
|
|
(f.DentryID != 0 && f.SpaceID != 0)
|
|
}
|
|
|
|
// parseFileInbound reads every relevant field out of a file callback's
|
|
// content payload (msgtype="file"). The content is a loosely-typed
|
|
// map[string]interface{}; numeric fields (dentryId/spaceId/fileSize) can be
|
|
// JSON strings or numbers depending on which endpoint sent the message, so
|
|
// both branches are handled.
|
|
func parseFileInbound(content interface{}) fileInboundInfo {
|
|
info := fileInboundInfo{}
|
|
m, ok := content.(map[string]interface{})
|
|
if !ok {
|
|
return info
|
|
}
|
|
info.DownloadCode = stringField(m, "downloadCode", "fileDownloadCode")
|
|
info.MediaID = stringField(m, "mediaId", "mediaID")
|
|
info.OpenMessageID = stringField(m, "openMessageId", "openMessageID")
|
|
info.OpenConversationID = stringField(m, "openConversationId", "openConversationID")
|
|
info.FileID = stringField(m, "fileId", "fileID", "dentryUuid", "dentryUUID")
|
|
info.FileName = stringField(m, "fileName", "name")
|
|
info.FileType = stringField(m, "fileType")
|
|
info.FilePath = stringField(m, "filePath")
|
|
info.MediaType = stringField(m, "mediaType")
|
|
info.DentryID = readInt64Field(m, "dentryId", "dentryID")
|
|
info.SpaceID = readInt64Field(m, "spaceId", "spaceID")
|
|
info.FileSize = readInt64Field(m, "fileSize", "size")
|
|
if info.FileName == "" {
|
|
info.FileName = "未知文件"
|
|
}
|
|
return info
|
|
}
|
|
|
|
// inboundMediaType normalizes the callback spellings used for downloadable
|
|
// non-picture media. The returned value is only used to make the agent prompt
|
|
// precise; download authorization still comes exclusively from downloadCode
|
|
// or dentryId+spaceId.
|
|
func inboundMediaType(msgtype string) string {
|
|
switch strings.ToLower(strings.TrimSpace(msgtype)) {
|
|
case "image", "picture":
|
|
return "image"
|
|
case "audio", "voice":
|
|
return "audio"
|
|
case "video":
|
|
return "video"
|
|
default:
|
|
return "file"
|
|
}
|
|
}
|
|
|
|
func parseTypedFileInbound(msgtype string, content interface{}) fileInboundInfo {
|
|
info := parseFileInbound(content)
|
|
if mediaType := strings.TrimSpace(msgtype); mediaType != "" {
|
|
info.MediaType = inboundMediaType(mediaType)
|
|
}
|
|
if info.MediaType == "" || info.MediaType == "file" {
|
|
info.MediaType = mediaTypeFromFileName(info.FileName)
|
|
}
|
|
if info.FileName == "未知文件" {
|
|
switch info.MediaType {
|
|
case "audio":
|
|
info.FileName = "语音消息"
|
|
case "video":
|
|
info.FileName = "视频消息"
|
|
}
|
|
}
|
|
return info
|
|
}
|
|
|
|
func fileInboundKey(info fileInboundInfo) string {
|
|
switch {
|
|
case strings.TrimSpace(info.DownloadCode) != "":
|
|
return "download:" + strings.TrimSpace(info.DownloadCode)
|
|
case strings.TrimSpace(info.MediaID) != "":
|
|
return "media:" + strings.TrimSpace(info.MediaID) + ":" + strings.TrimSpace(info.OpenMessageID) + ":" + strings.TrimSpace(info.OpenConversationID)
|
|
case strings.TrimSpace(info.FileID) != "":
|
|
return "file:" + strings.TrimSpace(info.FileID)
|
|
case info.DentryID != 0 && info.SpaceID != 0:
|
|
return fmt.Sprintf("dentry:%d:%d", info.SpaceID, info.DentryID)
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// chatRecordInboundMedia extracts every actionable attachment that DingTalk
|
|
// preserved in a msgtype=chatRecord callback. The observed callback encodes
|
|
// the record array as a JSON string under content.chatRecord; accepting an
|
|
// already-decoded array as well keeps the parser compatible with SDK changes.
|
|
//
|
|
// Some forwarded entries arrive as {"msgType":"unknownMsgType"} with no
|
|
// message id, download code, or storage id. Those entries are counted for
|
|
// diagnostics but cannot be recovered by the connector because the callback
|
|
// contains no locator for the original bytes.
|
|
func chatRecordInboundMedia(content interface{}) (pictureCodes []string, files []fileInboundInfo, unrecoverableCount int) {
|
|
m, ok := content.(map[string]interface{})
|
|
if !ok {
|
|
return nil, nil, 0
|
|
}
|
|
|
|
var entries []interface{}
|
|
switch raw := m["chatRecord"].(type) {
|
|
case string:
|
|
if err := json.Unmarshal([]byte(raw), &entries); err != nil {
|
|
return nil, nil, 0
|
|
}
|
|
case []interface{}:
|
|
entries = raw
|
|
default:
|
|
// A few callback variants call the decoded array "contents".
|
|
if decoded, ok := m["contents"].([]interface{}); ok {
|
|
entries = decoded
|
|
}
|
|
}
|
|
|
|
seenPictures := make(map[string]struct{})
|
|
seenFiles := make(map[string]struct{})
|
|
for _, entry := range entries {
|
|
node, ok := entry.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
msgtype := ""
|
|
for _, key := range []string{"msgType", "msgtype", "type"} {
|
|
if value, ok := node[key].(string); ok && strings.TrimSpace(value) != "" {
|
|
msgtype = strings.ToLower(strings.TrimSpace(value))
|
|
break
|
|
}
|
|
}
|
|
// Attachment discovery is capability-based. The nested msgType is a
|
|
// classification hint only; a future/unknown type with a valid locator
|
|
// must still reach the backend with its original bytes.
|
|
pictureCode := ""
|
|
if explicitPictureDownloadCode(node) != "" {
|
|
pictureCode = pictureDownloadCode(node)
|
|
}
|
|
if pictureCode == "" && (msgtype == "picture" || msgtype == "image") {
|
|
pictureCode = pictureDownloadCode(node)
|
|
}
|
|
if pictureCode != "" {
|
|
if _, exists := seenPictures[pictureCode]; !exists {
|
|
seenPictures[pictureCode] = struct{}{}
|
|
pictureCodes = append(pictureCodes, pictureCode)
|
|
}
|
|
continue
|
|
}
|
|
|
|
info := parseTypedFileInbound(msgtype, node)
|
|
if info.hasActionable() {
|
|
key := fileInboundKey(info)
|
|
if _, exists := seenFiles[key]; !exists {
|
|
seenFiles[key] = struct{}{}
|
|
files = append(files, info)
|
|
}
|
|
continue
|
|
}
|
|
if msgtype == "unknownmsgtype" {
|
|
unrecoverableCount++
|
|
}
|
|
}
|
|
return pictureCodes, files, unrecoverableCount
|
|
}
|
|
|
|
func hasChatRecordPayload(content interface{}) bool {
|
|
m, ok := content.(map[string]interface{})
|
|
if !ok {
|
|
return false
|
|
}
|
|
switch raw := m["chatRecord"].(type) {
|
|
case string:
|
|
return strings.TrimSpace(raw) != ""
|
|
case []interface{}:
|
|
return true
|
|
}
|
|
_, hasDecodedContents := m["contents"].([]interface{})
|
|
return hasDecodedContents
|
|
}
|
|
|
|
// callbackInboundMedia discovers downloadable payloads from their locator
|
|
// fields instead of an allowlist of msgtype values. msgtype is retained only
|
|
// as a media classification hint, so newly introduced message types are
|
|
// forwarded immediately without requiring a connector release.
|
|
func callbackInboundMedia(msgtype string, content interface{}) (pictureCodes []string, files []fileInboundInfo, unrecoverableCount int) {
|
|
seenPictures := make(map[string]struct{})
|
|
seenFiles := make(map[string]struct{})
|
|
addPicture := func(code string) {
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
return
|
|
}
|
|
if _, exists := seenPictures[code]; exists {
|
|
return
|
|
}
|
|
seenPictures[code] = struct{}{}
|
|
pictureCodes = append(pictureCodes, code)
|
|
}
|
|
addFile := func(info fileInboundInfo) {
|
|
if !info.hasActionable() {
|
|
return
|
|
}
|
|
key := fileInboundKey(info)
|
|
if _, picture := seenPictures[info.DownloadCode]; picture {
|
|
return
|
|
}
|
|
if _, exists := seenFiles[key]; exists {
|
|
return
|
|
}
|
|
seenFiles[key] = struct{}{}
|
|
files = append(files, info)
|
|
}
|
|
|
|
richPictures, richFiles := richTextInboundMedia(content)
|
|
for _, code := range richPictures {
|
|
addPicture(code)
|
|
}
|
|
for _, info := range richFiles {
|
|
addFile(info)
|
|
}
|
|
pictureCode := ""
|
|
if explicitPictureDownloadCode(content) != "" {
|
|
pictureCode = pictureDownloadCode(content)
|
|
}
|
|
if pictureCode == "" && (strings.EqualFold(msgtype, "picture") || strings.EqualFold(msgtype, "image")) {
|
|
pictureCode = pictureDownloadCode(content)
|
|
}
|
|
addPicture(pictureCode)
|
|
addFile(parseTypedFileInbound(msgtype, content))
|
|
|
|
nestedPictures, nestedFiles, nestedUnknown := chatRecordInboundMedia(content)
|
|
for _, code := range nestedPictures {
|
|
addPicture(code)
|
|
}
|
|
for _, info := range nestedFiles {
|
|
addFile(info)
|
|
}
|
|
return pictureCodes, files, nestedUnknown
|
|
}
|
|
|
|
// readInt64Field pulls an int64 out of the loose content map under any of the
|
|
// provided keys, tolerating JSON string / float64 / int64 / json.Number.
|
|
func readInt64Field(m map[string]interface{}, keys ...string) int64 {
|
|
for _, key := range keys {
|
|
v, ok := m[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch t := v.(type) {
|
|
case string:
|
|
if s := strings.TrimSpace(t); s != "" {
|
|
if n, err := strconv.ParseInt(s, 10, 64); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
case float64:
|
|
return int64(t)
|
|
case int64:
|
|
return t
|
|
case int:
|
|
return int64(t)
|
|
case json.Number:
|
|
if n, err := t.Int64(); err == nil {
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// fileDownloadInfo preserves the two-value shape used by legacy callers that
|
|
// only care about the downloadCode / fileName pair.
|
|
func fileDownloadInfo(content interface{}) (downloadCode, fileName string) {
|
|
info := parseFileInbound(content)
|
|
return info.DownloadCode, info.FileName
|
|
}
|
|
|
|
// summarizeContent renders the callback content into a short one-line string
|
|
// for stderr diagnostics (used when a file/other callback is being dropped
|
|
// so the operator can tell why after the fact).
|
|
func summarizeContent(content interface{}) string {
|
|
if content == nil {
|
|
return "<nil>"
|
|
}
|
|
b, err := json.Marshal(content)
|
|
if err != nil {
|
|
return fmt.Sprintf("<unmarshalable:%v>", err)
|
|
}
|
|
s := string(b)
|
|
if len(s) > 400 {
|
|
s = s[:400] + "…"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// rawCallbackPrompt preserves message types whose payload is meaningful but
|
|
// has no locally recognised text/media shape (for example msgtype=chatRecord).
|
|
// The connector should not decide that such messages are empty: forwarding
|
|
// the type and JSON payload lets the backend model interpret new and complex
|
|
// DingTalk message formats without waiting for a CLI-side parser update.
|
|
func rawCallbackPrompt(msgtype string, content interface{}) string {
|
|
b, err := json.Marshal(content)
|
|
if err != nil {
|
|
b = []byte(fmt.Sprintf("%v", content))
|
|
}
|
|
return fmt.Sprintf("用户发送了一条钉钉消息,msgtype=%q。请解析以下原始消息 JSON,提取有用信息并处理用户意图:\n%s", strings.TrimSpace(msgtype), b)
|
|
}
|
|
|
|
// extractCallbackText pulls the visible text out of a structured-text callback
|
|
// payload (msgtype=richText / markdown / etc.) for the case where the SDK's
|
|
// data.Text.Content is empty. This matters because `dws chat message send
|
|
// --group ... --text ...` sends msgType="markdown" by default, and DingTalk's
|
|
// stream callback for markdown messages routinely leaves data.Text.Content
|
|
// blank while stashing the real body in data.Content (loosely-typed).
|
|
// Returns "" if no text-shaped field is found.
|
|
func extractCallbackText(content interface{}) string {
|
|
switch v := content.(type) {
|
|
case string:
|
|
return strings.TrimSpace(v)
|
|
case map[string]interface{}:
|
|
// Common shapes: {"text":"..."}, {"title":"...","text":"..."},
|
|
// {"content":"..."}, richText {"richText":[{"text":"..."}]}.
|
|
for _, key := range []string{"text", "content", "markdown", "title", "recognition"} {
|
|
if s, ok := v[key].(string); ok && strings.TrimSpace(s) != "" {
|
|
return strings.TrimSpace(s)
|
|
}
|
|
}
|
|
if arr, ok := v["richText"].([]interface{}); ok {
|
|
var b strings.Builder
|
|
for _, item := range arr {
|
|
if m, ok := item.(map[string]interface{}); ok {
|
|
if s, ok := m["text"].(string); ok {
|
|
b.WriteString(s)
|
|
}
|
|
}
|
|
}
|
|
if s := strings.TrimSpace(b.String()); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
// interactiveCard: another bot @-mentioning this bot arrives as
|
|
// msgtype="interactiveCard" with the body nested in
|
|
// content.cardContent[].children[].value (elementType TEXT). The SDK
|
|
// leaves Text.Content blank, so without this the connector drops a
|
|
// legitimate bot-to-bot @ message.
|
|
if s := extractCardContentText(v["cardContent"]); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// cardContentLeaves flattens an interactiveCard cardContent tree into its
|
|
// ordered TEXT leaf values. Shape (verified live): cardContent is an array of
|
|
// blocks, each with a "children" array of {elementType:"TEXT", value:"..."}
|
|
// leaves; nested blocks recurse via their own "children".
|
|
func cardContentLeaves(v interface{}) []string {
|
|
arr, ok := v.([]interface{})
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var leaves []string
|
|
var walk func(items []interface{})
|
|
walk = func(items []interface{}) {
|
|
for _, item := range items {
|
|
m, ok := item.(map[string]interface{})
|
|
if !ok {
|
|
continue
|
|
}
|
|
if s, ok := m["value"].(string); ok && s != "" {
|
|
leaves = append(leaves, s)
|
|
}
|
|
if kids, ok := m["children"].([]interface{}); ok {
|
|
walk(kids)
|
|
}
|
|
}
|
|
}
|
|
walk(arr)
|
|
return leaves
|
|
}
|
|
|
|
// extractCardContentText joins all interactiveCard leaves — the raw body,
|
|
// mention included. Used by the generic extractCallbackText fallback.
|
|
func extractCardContentText(v interface{}) string {
|
|
return strings.TrimSpace(strings.Join(cardContentLeaves(v), ""))
|
|
}
|
|
|
|
// extractInteractiveCardText returns the interactiveCard body with the leading
|
|
// @-mention removed. A bot @-ing another bot renders the mention as its own
|
|
// leading "@name" TEXT leaf (the display name may itself contain spaces, so we
|
|
// drop by leaf boundary, not by whitespace), followed by the real message in
|
|
// the next leaf. Returns "" when there is nothing beyond the mention.
|
|
func extractInteractiveCardText(content interface{}) string {
|
|
m, ok := content.(map[string]interface{})
|
|
if !ok {
|
|
return ""
|
|
}
|
|
leaves := cardContentLeaves(m["cardContent"])
|
|
for len(leaves) > 0 && strings.HasPrefix(strings.TrimSpace(leaves[0]), "@") {
|
|
leaves = leaves[1:]
|
|
}
|
|
return strings.TrimSpace(strings.Join(leaves, ""))
|
|
}
|
|
|
|
// downloadMessageFile resolves a chatbot media callback (picture etc.) to a
|
|
// local temp file via the robot messageFiles/download API. Error-screenshot
|
|
// questions are the top Q&A inbound; without this the connector silently
|
|
// drops every picture message.
|
|
func (c *aiCardClient) downloadMessageFile(ctx context.Context, robotCode, downloadCode string) (string, error) {
|
|
return c.downloadMessageFileNamed(ctx, robotCode, downloadCode, "")
|
|
}
|
|
|
|
// downloadMessageFileNamed is downloadMessageFile with an optional original
|
|
// file name. Keeping its extension materially improves audio/video/file
|
|
// handling across local agents whose tool selection depends on the path.
|
|
func (c *aiCardClient) downloadMessageFileNamed(ctx context.Context, robotCode, downloadCode, fileName string) (string, error) {
|
|
raw, err := c.callRaw(ctx, http.MethodPost, "/v1.0/robot/messageFiles/download", map[string]any{
|
|
"robotCode": robotCode,
|
|
"downloadCode": downloadCode,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var parsed struct {
|
|
DownloadUrl string `json:"downloadUrl"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || strings.TrimSpace(parsed.DownloadUrl) == "" {
|
|
return "", fmt.Errorf("messageFiles/download 未返回 downloadUrl: %s", truncateRunes(raw, 200))
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.DownloadUrl, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
resp, err := connectMediaDownloadClient(c.httpClient).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 := mediaMkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
ext := filepath.Ext(strings.TrimSpace(fileName))
|
|
if ext == "" {
|
|
ext = mediaExt(parsed.DownloadUrl, 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
|
|
}
|
|
|
|
// getUserUnionID resolves a staffId (userId) to unionId via the contact API.
|
|
// The connector needs unionId to call the storage/drive download APIs.
|
|
func (c *aiCardClient) getUserUnionID(ctx context.Context, userID string) (string, error) {
|
|
raw, err := c.callRaw(ctx, http.MethodGet, "/v1.0/contact/users/"+userID, nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("contact/users/%s: %w", userID, err)
|
|
}
|
|
var parsed struct {
|
|
UnionID string `json:"unionId"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || strings.TrimSpace(parsed.UnionID) == "" {
|
|
return "", fmt.Errorf("contact/users/%s 未返回 unionId: %s", userID, truncateRunes(raw, 200))
|
|
}
|
|
return parsed.UnionID, nil
|
|
}
|
|
|
|
// downloadDentryFile downloads a file from DingTalk storage by numeric
|
|
// dentryId + spaceId (the shape API-sent file callbacks provide). It resolves
|
|
// download info via the v2.0 storage API and saves the file to a local temp
|
|
// path, returning the path.
|
|
func (c *aiCardClient) downloadDentryFile(ctx context.Context, spaceID, dentryID int64, unionID, fileName string) (string, error) {
|
|
path := fmt.Sprintf("/v2.0/storage/spaces/%d/dentries/%d/getDownloadInfo", spaceID, dentryID)
|
|
raw, err := connectMediaCallRaw(c, ctx, http.MethodPost, path, map[string]any{
|
|
"unionId": unionID,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("getDownloadInfo spaceId=%d dentryId=%d: %w", spaceID, dentryID, err)
|
|
}
|
|
var parsed struct {
|
|
ResourceURL string `json:"resourceUrl"`
|
|
HeadersMap map[string]string `json:"headers"`
|
|
}
|
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil || strings.TrimSpace(parsed.ResourceURL) == "" {
|
|
return "", fmt.Errorf("getDownloadInfo 未返回 resourceUrl: %s", truncateRunes(raw, 200))
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.ResourceURL, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for k, v := range parsed.HeadersMap {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := connectMediaDownloadClient(c.httpClient).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 := mediaMkdirAll(dir, 0o755); err != nil {
|
|
return "", err
|
|
}
|
|
ext := filepath.Ext(fileName)
|
|
if ext == "" {
|
|
ext = mediaExt(parsed.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
|
|
}
|
|
|
|
// writeCompleteMediaFile writes at most mediaMaxDownloadBytes and verifies the
|
|
// stream ended. Reading one byte past the cap distinguishes an exact-size file
|
|
// from a larger file; the latter is removed instead of leaving a corrupt local
|
|
// artifact that an agent could mistake for the original.
|
|
func writeCompleteMediaFile(dest string, src io.Reader) error {
|
|
f, err := mediaCreate(dest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, copyErr := mediaCopy(f, io.LimitReader(src, mediaMaxDownloadBytes+1))
|
|
closeErr := f.Close()
|
|
if copyErr != nil {
|
|
_ = mediaRemove(dest)
|
|
return copyErr
|
|
}
|
|
if closeErr != nil {
|
|
_ = mediaRemove(dest)
|
|
return closeErr
|
|
}
|
|
if n > mediaMaxDownloadBytes {
|
|
_ = mediaRemove(dest)
|
|
return fmt.Errorf("媒体文件超过最大允许大小 %d 字节,未保存截断文件", mediaMaxDownloadBytes)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func connectMediaDownloadClient(base *http.Client) *http.Client {
|
|
if base == nil {
|
|
return &http.Client{Timeout: mediaDownloadTimeout}
|
|
}
|
|
clone := *base
|
|
if clone.Timeout <= 0 || clone.Timeout < mediaDownloadTimeout {
|
|
clone.Timeout = mediaDownloadTimeout
|
|
}
|
|
return &clone
|
|
}
|
|
|
|
func cleanupConnectMediaAttachments(attachments []connectMediaAttachment) {
|
|
root := filepath.Join(os.TempDir(), "dws-connect-media")
|
|
for _, attachment := range attachments {
|
|
path := filepath.Clean(strings.TrimSpace(attachment.LocalPath))
|
|
if path == "." || filepath.Dir(path) != root {
|
|
continue
|
|
}
|
|
_ = os.Remove(path)
|
|
}
|
|
}
|
|
|
|
// mediaExt picks a file extension from the response content type, falling
|
|
// back to the URL path, then ".png" (DingTalk screenshots default to png).
|
|
func mediaExt(rawURL, contentType string) string {
|
|
ct := strings.ToLower(contentType)
|
|
switch {
|
|
case strings.Contains(ct, "png"):
|
|
return ".png"
|
|
case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"):
|
|
return ".jpg"
|
|
case strings.Contains(ct, "gif"):
|
|
return ".gif"
|
|
case strings.Contains(ct, "webp"):
|
|
return ".webp"
|
|
}
|
|
if u, err := url.Parse(rawURL); err == nil {
|
|
if ext := strings.ToLower(filepath.Ext(u.Path)); len(ext) >= 2 && len(ext) <= 5 {
|
|
return ext
|
|
}
|
|
}
|
|
return ".png"
|
|
}
|