返回 DeepSeek-Reasonix
media.go
根目录 / internal / bot / media.go
1 package bot
2
3 import (
4 "context"
5 "fmt"
6 "io"
7 "mime"
8 "net/http"
9 "net/url"
10 "path"
11 "strings"
12 "time"
13
14 "reasonix/internal/control"
15 )
16
17 const maxBotMediaBytes = 25 * 1024 * 1024
18
19 var botMediaHTTPClient = &http.Client{Timeout: 30 * time.Second}
20
21 func saveInboundMedia(ctx context.Context, workspaceRoot string, mediaURLs []string) (refs []string, errs []error) {
22 for _, rawURL := range mediaURLs {
23 ref, err := saveOneInboundMedia(ctx, workspaceRoot, rawURL)
24 if err != nil {
25 errs = append(errs, err)
26 continue
27 }
28 refs = append(refs, ref)
29 }
30 return refs, errs
31 }
32
33 func saveOneInboundMedia(ctx context.Context, workspaceRoot, rawURL string) (string, error) {
34 rawURL = strings.TrimSpace(rawURL)
35 if rawURL == "" {
36 return "", fmt.Errorf("empty media URL")
37 }
38 u, err := url.Parse(rawURL)
39 if err != nil {
40 return "", err
41 }
42 if u.Scheme != "http" && u.Scheme != "https" {
43 return "", fmt.Errorf("unsupported media URL scheme %q", u.Scheme)
44 }
45 req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
46 if err != nil {
47 return "", err
48 }
49 resp, err := botMediaHTTPClient.Do(req)
50 if err != nil {
51 return "", err
52 }
53 defer resp.Body.Close()
54 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
55 return "", fmt.Errorf("download media: HTTP %d", resp.StatusCode)
56 }
57 raw, err := io.ReadAll(io.LimitReader(resp.Body, maxBotMediaBytes+1))
58 if err != nil {
59 return "", err
60 }
61 if len(raw) == 0 || len(raw) > maxBotMediaBytes {
62 return "", fmt.Errorf("media must be between 1 byte and 25 MB")
63 }
64 contentType := resp.Header.Get("Content-Type")
65 if semi := strings.Index(contentType, ";"); semi >= 0 {
66 contentType = strings.TrimSpace(contentType[:semi])
67 }
68 if strings.TrimSpace(contentType) == "" || strings.EqualFold(contentType, "application/octet-stream") {
69 contentType = http.DetectContentType(raw[:min(len(raw), 512)])
70 }
71 name := mediaFilename(u, contentType)
72 if strings.HasPrefix(strings.ToLower(contentType), "image/") {
73 return control.SaveImageBytesInRoot(workspaceRoot, contentType, raw)
74 }
75 return control.SaveAttachmentBytesInRoot(workspaceRoot, name, raw)
76 }
77
78 func saveInboundMediaItems(ctx context.Context, workspaceRoot string, items []InboundMedia) (refs, fallbacks []string, errs []error) {
79 for _, item := range items {
80 ref, err := saveOneInboundMediaItem(ctx, workspaceRoot, item)
81 if err != nil {
82 errs = append(errs, err)
83 if fallback := strings.TrimSpace(item.FailureText); fallback != "" {
84 fallbacks = append(fallbacks, fallback)
85 }
86 continue
87 }
88 refs = append(refs, ref)
89 }
90 return refs, fallbacks, errs
91 }
92
93 func saveOneInboundMediaItem(ctx context.Context, workspaceRoot string, item InboundMedia) (string, error) {
94 if item.Load != nil {
95 data, name, err := item.Load(ctx)
96 if err != nil {
97 return "", err
98 }
99 item.Data = data
100 if strings.TrimSpace(item.Name) == "" {
101 item.Name = name
102 }
103 }
104 if len(item.Data) == 0 || len(item.Data) > maxBotMediaBytes {
105 return "", fmt.Errorf("media must be between 1 byte and 25 MB")
106 }
107 contentType := strings.TrimSpace(item.MIME)
108 if contentType == "" || strings.EqualFold(contentType, "application/octet-stream") {
109 contentType = http.DetectContentType(item.Data[:min(len(item.Data), 512)])
110 }
111 if strings.HasPrefix(strings.ToLower(contentType), "image/") {
112 if ref, err := control.SaveImageBytesInRoot(workspaceRoot, contentType, item.Data); err == nil {
113 return ref, nil
114 }
115 // 图片超过 10MB 上限或签名不被识别时,降级存为普通附件而不是丢弃。
116 }
117 name := strings.TrimSpace(item.Name)
118 if name == "" {
119 if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
120 name = "media" + exts[0]
121 } else {
122 name = "media.bin"
123 }
124 }
125 return control.SaveAttachmentBytesInRoot(workspaceRoot, name, item.Data)
126 }
127
128 func appendMediaFallbacks(text string, fallbacks []string) string {
129 for _, fallback := range fallbacks {
130 fallback = strings.TrimSpace(fallback)
131 if fallback == "" {
132 continue
133 }
134 if strings.TrimSpace(text) != "" {
135 text += "\n"
136 }
137 text += fallback
138 }
139 return text
140 }
141
142 func mediaFilename(u *url.URL, contentType string) string {
143 base := path.Base(u.Path)
144 if base == "." || base == "/" || strings.TrimSpace(base) == "" {
145 if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
146 return "media" + exts[0]
147 }
148 return "media.bin"
149 }
150 return base
151 }
152
153 func appendMediaRefs(text string, refs []string) string {
154 if len(refs) == 0 {
155 return text
156 }
157 var b strings.Builder
158 if strings.TrimSpace(text) != "" {
159 b.WriteString(text)
160 b.WriteString("\n\n")
161 }
162 b.WriteString("Attachments:")
163 for _, ref := range refs {
164 b.WriteString("\n@")
165 b.WriteString(ref)
166 }
167 return b.String()
168 }
169
169 lines GO