| 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 |