返回 DeepSeek-Reasonix
outbound_media.go
根目录 / internal / bot / feishu / outbound_media.go
1 package feishu
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "os"
11 "path/filepath"
12 "strings"
13
14 "reasonix/internal/bot"
15
16 larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
17 )
18
19 const (
20 maxOutboundMediaBytes = 25 * 1024 * 1024
21 maxOutboundMediaTotalBytes = maxOutboundMediaBytes
22 )
23
24 type outboundMedia struct {
25 name string
26 data []byte
27 }
28
29 // loadOutboundMedia validates and reads every requested item before any remote
30 // message is sent. This keeps local policy failures from producing a successful
31 // text message followed by a retryable /send error.
32 func (a *adapter) loadOutboundMedia(refs []string) ([]outboundMedia, error) {
33 media := make([]outboundMedia, 0, len(refs))
34 totalBytes := 0
35 for _, ref := range refs {
36 data, name, err := a.readOutboundFile(ref)
37 if err != nil {
38 return nil, err
39 }
40 if len(data) > maxOutboundMediaTotalBytes-totalBytes {
41 return nil, fmt.Errorf("feishu outbound media: total payload must not exceed 25 MB")
42 }
43 totalBytes += len(data)
44 media = append(media, outboundMedia{name: name, data: data})
45 }
46 return media, nil
47 }
48
49 func (a *adapter) sendMedia(ctx context.Context, msg bot.OutboundMessage, media []outboundMedia) (bot.SendResult, error) {
50 var result bot.SendResult
51 for _, item := range media {
52 res, err := a.sendOneMedia(ctx, msg, item)
53 if err != nil {
54 a.logger.Warn("feishu media send failed", "err", err)
55 return result, err
56 }
57 result.Merge(res)
58 }
59 return result, nil
60 }
61
62 func (a *adapter) sendOneMedia(ctx context.Context, msg bot.OutboundMessage, media outboundMedia) (bot.SendResult, error) {
63 mimeType := http.DetectContentType(media.data[:min(len(media.data), 512)])
64 if strings.HasPrefix(mimeType, "image/") {
65 imageKey, err := a.uploadImage(ctx, media.data)
66 if err == nil {
67 content, _ := json.Marshal(map[string]string{"image_key": imageKey})
68 return a.sendSDKContent(ctx, msg, larkim.MsgTypeImage, string(content))
69 }
70 a.logger.Warn("feishu image upload failed; falling back to file", "err", err)
71 }
72 fileKey, err := a.uploadFile(ctx, media.name, media.data)
73 if err != nil {
74 return bot.SendResult{}, err
75 }
76 content, _ := json.Marshal(map[string]string{"file_key": fileKey})
77 return a.sendSDKContent(ctx, msg, larkim.MsgTypeFile, string(content))
78 }
79
80 // readOutboundFile reads a bare filename from exactly one configured root.
81 // os.Root pins each root directory and prevents symlink traversal outside it;
82 // the selected file is then sized and read through the same open handle.
83 func (a *adapter) readOutboundFile(ref string) ([]byte, string, error) {
84 ref = strings.TrimSpace(ref)
85 if ref == "" {
86 return nil, "", fmt.Errorf("feishu outbound media: empty ref")
87 }
88 if len(a.cfg.OutboundMediaRoots) == 0 {
89 return nil, "", fmt.Errorf("feishu outbound media: local file sending is disabled (set outbound_media_roots)")
90 }
91 name := filepath.Base(ref)
92 if name != ref || name == "." || name == ".." || name == string(filepath.Separator) {
93 return nil, "", fmt.Errorf("feishu outbound media: ref must be a bare file name")
94 }
95
96 var selected *os.File
97 var selectedSize int64
98 for i, root := range a.cfg.OutboundMediaRoots {
99 root = strings.TrimSpace(root)
100 if root == "" {
101 continue
102 }
103 if !filepath.IsAbs(root) {
104 return nil, "", fmt.Errorf("feishu outbound media: configured root %d must be absolute", i+1)
105 }
106 rootHandle, err := os.OpenRoot(root)
107 if err != nil {
108 if os.IsNotExist(err) {
109 continue
110 }
111 return nil, "", fmt.Errorf("feishu outbound media: configured root %d is unavailable: %w", i+1, err)
112 }
113 file, openErr := rootHandle.Open(name)
114 closeErr := rootHandle.Close()
115 if openErr != nil {
116 if os.IsNotExist(openErr) {
117 continue
118 }
119 return nil, "", fmt.Errorf("feishu outbound media: cannot open %q in configured root %d: %w", name, i+1, openErr)
120 }
121 if closeErr != nil {
122 _ = file.Close()
123 return nil, "", fmt.Errorf("feishu outbound media: close configured root %d: %w", i+1, closeErr)
124 }
125 info, err := file.Stat()
126 if err != nil {
127 _ = file.Close()
128 return nil, "", fmt.Errorf("feishu outbound media: stat %q: %w", name, err)
129 }
130 if !info.Mode().IsRegular() {
131 _ = file.Close()
132 continue
133 }
134 if selected != nil {
135 _ = file.Close()
136 return nil, "", fmt.Errorf("feishu outbound media: %q exists in more than one configured root", name)
137 }
138 selected = file
139 selectedSize = info.Size()
140 defer selected.Close()
141 }
142 if selected == nil {
143 return nil, "", fmt.Errorf("feishu outbound media: %q not found in any configured root", name)
144 }
145 if selectedSize == 0 || selectedSize > maxOutboundMediaBytes {
146 return nil, "", fmt.Errorf("feishu outbound media: %q must be between 1 byte and 25 MB", name)
147 }
148
149 raw, err := io.ReadAll(io.LimitReader(selected, maxOutboundMediaBytes+1))
150 if err != nil {
151 return nil, "", fmt.Errorf("feishu outbound media: read %q: %w", name, err)
152 }
153 if len(raw) == 0 || len(raw) > maxOutboundMediaBytes {
154 return nil, "", fmt.Errorf("feishu outbound media: %q must be between 1 byte and 25 MB", name)
155 }
156 return raw, name, nil
157 }
158
159 func (a *adapter) uploadImage(ctx context.Context, data []byte) (string, error) {
160 client, err := a.sdkClient()
161 if err != nil {
162 return "", err
163 }
164 var key string
165 err = withTransientRetry(ctx, a.logger, "upload image", func(ctx context.Context) error {
166 req := larkim.NewCreateImageReqBuilder().
167 Body(larkim.NewCreateImageReqBodyBuilder().
168 ImageType(larkim.CreateImageImageTypeMessage).
169 Image(bytes.NewReader(data)).
170 Build()).
171 Build()
172 resp, err := client.Im.Image.Create(ctx, req)
173 if err != nil {
174 return err
175 }
176 if resp == nil {
177 return fmt.Errorf("feishu image upload error: empty response")
178 }
179 if !resp.Success() {
180 return fmt.Errorf("feishu image upload error: %s", feishuCodeError(resp.Code, resp.Msg))
181 }
182 if resp.Data == nil || resp.Data.ImageKey == nil {
183 return fmt.Errorf("feishu image upload error: missing image key")
184 }
185 key = *resp.Data.ImageKey
186 return nil
187 })
188 return key, err
189 }
190
191 func (a *adapter) uploadFile(ctx context.Context, name string, data []byte) (string, error) {
192 client, err := a.sdkClient()
193 if err != nil {
194 return "", err
195 }
196 if strings.TrimSpace(name) == "" {
197 name = "media.bin"
198 }
199 var key string
200 err = withTransientRetry(ctx, a.logger, "upload file", func(ctx context.Context) error {
201 req := larkim.NewCreateFileReqBuilder().
202 Body(larkim.NewCreateFileReqBodyBuilder().
203 FileType(feishuFileType(name)).
204 FileName(name).
205 File(bytes.NewReader(data)).
206 Build()).
207 Build()
208 resp, err := client.Im.File.Create(ctx, req)
209 if err != nil {
210 return err
211 }
212 if resp == nil {
213 return fmt.Errorf("feishu file upload error: empty response")
214 }
215 if !resp.Success() {
216 return fmt.Errorf("feishu file upload error: %s", feishuCodeError(resp.Code, resp.Msg))
217 }
218 if resp.Data == nil || resp.Data.FileKey == nil {
219 return fmt.Errorf("feishu file upload error: missing file key")
220 }
221 key = *resp.Data.FileKey
222 return nil
223 })
224 return key, err
225 }
226
227 func feishuFileType(name string) string {
228 switch strings.ToLower(filepath.Ext(name)) {
229 case ".pdf":
230 return "pdf"
231 case ".doc", ".docx":
232 return "doc"
233 case ".xls", ".xlsx":
234 return "xls"
235 case ".ppt", ".pptx":
236 return "ppt"
237 case ".mp4":
238 return "mp4"
239 case ".opus":
240 return "opus"
241 default:
242 return "stream"
243 }
244 }
245
245 lines GO