Files

398 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"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid"
)
// mediaMaxDownloadBytes caps a single inbound image download (screenshots are
// well under this; the cap is a hostile-input guard).
const mediaMaxDownloadBytes = 20 << 20
// 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 ""
}
// 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
FileName string
FileType string
FilePath string
DentryID int64
SpaceID int64
FileSize int64
}
func (f fileInboundInfo) hasActionable() bool {
return strings.TrimSpace(f.DownloadCode) != "" || (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
}
for _, key := range []string{"downloadCode", "fileDownloadCode"} {
if v, ok := m[key].(string); ok && strings.TrimSpace(v) != "" {
info.DownloadCode = strings.TrimSpace(v)
break
}
}
if v, ok := m["fileName"].(string); ok {
info.FileName = strings.TrimSpace(v)
}
if v, ok := m["fileType"].(string); ok {
info.FileType = strings.TrimSpace(v)
}
if v, ok := m["filePath"].(string); ok {
info.FilePath = strings.TrimSpace(v)
}
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
}
// 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
}
// 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"} {
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) {
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 := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("媒体下载 HTTP %d", resp.StatusCode)
}
dir := filepath.Join(os.TempDir(), "dws-connect-media")
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
dest := filepath.Join(dir, uuid.NewString()+mediaExt(parsed.DownloadUrl, resp.Header.Get("Content-Type")))
f, err := os.Create(dest)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
_ = os.Remove(dest)
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 := c.callRaw(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 := c.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return "", fmt.Errorf("钉盘文件下载 HTTP %d", resp.StatusCode)
}
dir := filepath.Join(os.TempDir(), "dws-connect-media")
if err := os.MkdirAll(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)
f, err := os.Create(dest)
if err != nil {
return "", err
}
defer f.Close()
if _, err := io.Copy(f, io.LimitReader(resp.Body, mediaMaxDownloadBytes)); err != nil {
_ = os.Remove(dest)
return "", err
}
return dest, nil
}
// 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"
}