| 1 | package feishu |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/bot" |
| 12 | |
| 13 | larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" |
| 14 | ) |
| 15 | |
| 16 | // maxFeishuMediaBytes matches the bot gateway's inbound media cap. |
| 17 | const maxFeishuMediaBytes = 25 * 1024 * 1024 |
| 18 | |
| 19 | const resourceDownloadTimeout = 30 * time.Second |
| 20 | |
| 21 | type imageContent struct { |
| 22 | ImageKey string `json:"image_key"` |
| 23 | } |
| 24 | |
| 25 | type fileContent struct { |
| 26 | FileKey string `json:"file_key"` |
| 27 | FileName string `json:"file_name"` |
| 28 | } |
| 29 | |
| 30 | // mentionRef 是 SDK 事件与 webhook 事件两种 mention 表示的归一化形态。 |
| 31 | type mentionRef struct { |
| 32 | Key string |
| 33 | OpenID string |
| 34 | Name string |
| 35 | } |
| 36 | |
| 37 | func sdkMentionRefs(mentions []*larkim.MentionEvent) []mentionRef { |
| 38 | refs := make([]mentionRef, 0, len(mentions)) |
| 39 | for _, m := range mentions { |
| 40 | if m == nil { |
| 41 | continue |
| 42 | } |
| 43 | ref := mentionRef{Key: stringPtrValue(m.Key), Name: stringPtrValue(m.Name)} |
| 44 | if m.Id != nil { |
| 45 | ref.OpenID = stringPtrValue(m.Id.OpenId) |
| 46 | } |
| 47 | refs = append(refs, ref) |
| 48 | } |
| 49 | return refs |
| 50 | } |
| 51 | |
| 52 | // mentionsBot 判断消息是否 @ 了本 bot。bot open_id 未知时退回“任意 @ 均放行” |
| 53 | // 的旧行为,避免因为 bot/v3/info 拉取失败而完全失聪。 |
| 54 | func (a *adapter) mentionsBot(mentions []mentionRef) bool { |
| 55 | botID := a.botOpenID() |
| 56 | if botID == "" { |
| 57 | return len(mentions) > 0 |
| 58 | } |
| 59 | for _, m := range mentions { |
| 60 | if m.OpenID != "" && m.OpenID == botID { |
| 61 | return true |
| 62 | } |
| 63 | } |
| 64 | return false |
| 65 | } |
| 66 | |
| 67 | // replaceMentionPlaceholders 把 "@_user_N" 占位符还原为可读的 "@显示名"; |
| 68 | // bot 自己的占位符直接移除,模型看到的输入不再包含对 bot 的 @。 |
| 69 | func (a *adapter) replaceMentionPlaceholders(text string, mentions []mentionRef) string { |
| 70 | botID := a.botOpenID() |
| 71 | for _, m := range mentions { |
| 72 | if m.Key == "" { |
| 73 | continue |
| 74 | } |
| 75 | replacement := "" |
| 76 | if (botID == "" || m.OpenID != botID) && m.Name != "" { |
| 77 | replacement = "@" + m.Name |
| 78 | } |
| 79 | text = strings.ReplaceAll(text, m.Key, replacement) |
| 80 | } |
| 81 | return strings.TrimSpace(text) |
| 82 | } |
| 83 | |
| 84 | // parseInboundContent parses Feishu content without fetching remote resources. |
| 85 | // Media loaders run later, after the gateway allowlist admits the sender. |
| 86 | func (a *adapter) parseInboundContent(msgType, content, messageID string) (text string, media []bot.InboundMedia, ok bool) { |
| 87 | switch msgType { |
| 88 | case "text": |
| 89 | var tc textContent |
| 90 | if err := json.Unmarshal([]byte(content), &tc); err != nil { |
| 91 | a.logger.Warn("feishu message ignored", "reason", "bad_content", "message", logHash(messageID), "err", err) |
| 92 | return "", nil, false |
| 93 | } |
| 94 | return tc.Text, nil, true |
| 95 | case "image": |
| 96 | var ic imageContent |
| 97 | if err := json.Unmarshal([]byte(content), &ic); err != nil || strings.TrimSpace(ic.ImageKey) == "" { |
| 98 | return "", nil, false |
| 99 | } |
| 100 | return "", []bot.InboundMedia{a.deferredMedia(messageID, ic.ImageKey, "image", "", "[图片下载失败]")}, true |
| 101 | case "sticker": |
| 102 | var fc fileContent |
| 103 | if err := json.Unmarshal([]byte(content), &fc); err != nil || strings.TrimSpace(fc.FileKey) == "" { |
| 104 | return "", nil, false |
| 105 | } |
| 106 | return "", []bot.InboundMedia{a.deferredMedia(messageID, fc.FileKey, "image", "", "[sticker]")}, true |
| 107 | case "file": |
| 108 | var fc fileContent |
| 109 | if err := json.Unmarshal([]byte(content), &fc); err != nil || strings.TrimSpace(fc.FileKey) == "" { |
| 110 | return "", nil, false |
| 111 | } |
| 112 | return "", []bot.InboundMedia{a.deferredMedia(messageID, fc.FileKey, "file", fc.FileName, fmt.Sprintf("[文件下载失败: %s]", fc.FileName))}, true |
| 113 | case "post": |
| 114 | return a.parsePostContent(content, messageID) |
| 115 | default: |
| 116 | return "", nil, false |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | // parsePostContent 解析富文本(post)消息:抽取文本、链接、@,并下载内嵌图片。 |
| 121 | func (a *adapter) parsePostContent(content, messageID string) (string, []bot.InboundMedia, bool) { |
| 122 | var post struct { |
| 123 | Title string `json:"title"` |
| 124 | Content [][]struct { |
| 125 | Tag string `json:"tag"` |
| 126 | Text string `json:"text"` |
| 127 | Href string `json:"href"` |
| 128 | UserName string `json:"user_name"` |
| 129 | ImageKey string `json:"image_key"` |
| 130 | } `json:"content"` |
| 131 | } |
| 132 | if err := json.Unmarshal([]byte(content), &post); err != nil { |
| 133 | a.logger.Warn("feishu message ignored", "reason", "bad_post_content", "message", logHash(messageID), "err", err) |
| 134 | return "", nil, false |
| 135 | } |
| 136 | var b strings.Builder |
| 137 | var media []bot.InboundMedia |
| 138 | if title := strings.TrimSpace(post.Title); title != "" { |
| 139 | b.WriteString(title) |
| 140 | b.WriteString("\n") |
| 141 | } |
| 142 | for _, paragraph := range post.Content { |
| 143 | for _, run := range paragraph { |
| 144 | switch run.Tag { |
| 145 | case "text", "code_block", "md": |
| 146 | b.WriteString(run.Text) |
| 147 | case "a": |
| 148 | switch { |
| 149 | case run.Text != "" && run.Href != "" && run.Text != run.Href: |
| 150 | fmt.Fprintf(&b, "%s (%s)", run.Text, run.Href) |
| 151 | case run.Href != "": |
| 152 | b.WriteString(run.Href) |
| 153 | default: |
| 154 | b.WriteString(run.Text) |
| 155 | } |
| 156 | case "at": |
| 157 | if run.UserName != "" { |
| 158 | b.WriteString("@" + run.UserName) |
| 159 | } |
| 160 | case "img": |
| 161 | if strings.TrimSpace(run.ImageKey) == "" { |
| 162 | continue |
| 163 | } |
| 164 | media = append(media, a.deferredMedia(messageID, run.ImageKey, "image", "", "[图片下载失败]")) |
| 165 | case "media": |
| 166 | b.WriteString("[视频]") |
| 167 | } |
| 168 | } |
| 169 | b.WriteString("\n") |
| 170 | } |
| 171 | return strings.TrimRight(b.String(), "\n"), media, true |
| 172 | } |
| 173 | |
| 174 | func (a *adapter) deferredMedia(messageID, key, typ, name, failureText string) bot.InboundMedia { |
| 175 | return bot.InboundMedia{ |
| 176 | Name: name, |
| 177 | FailureText: failureText, |
| 178 | Load: func(ctx context.Context) ([]byte, string, error) { |
| 179 | fetch := a.fetchResource |
| 180 | if fetch == nil { |
| 181 | fetch = a.sdkFetchResource |
| 182 | } |
| 183 | data, fetchedName, err := fetch(ctx, messageID, key, typ) |
| 184 | if err != nil { |
| 185 | a.logger.Warn("feishu media download failed", "message", logHash(messageID), "type", typ, "err", err) |
| 186 | return nil, "", err |
| 187 | } |
| 188 | if strings.TrimSpace(name) != "" { |
| 189 | fetchedName = name |
| 190 | } |
| 191 | return data, fetchedName, nil |
| 192 | }, |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // sdkFetchResource 经 SDK 鉴权接口下载消息资源(图片/文件)。 |
| 197 | func (a *adapter) sdkFetchResource(ctx context.Context, messageID, key, typ string) ([]byte, string, error) { |
| 198 | client, err := a.sdkClient() |
| 199 | if err != nil { |
| 200 | return nil, "", err |
| 201 | } |
| 202 | ctx, cancel := context.WithTimeout(ctx, resourceDownloadTimeout) |
| 203 | defer cancel() |
| 204 | var data []byte |
| 205 | var fileName string |
| 206 | err = withTransientRetry(ctx, a.logger, "get message resource", func(ctx context.Context) error { |
| 207 | req := larkim.NewGetMessageResourceReqBuilder(). |
| 208 | MessageId(messageID). |
| 209 | FileKey(key). |
| 210 | Type(typ). |
| 211 | Build() |
| 212 | resp, err := client.Im.MessageResource.Get(ctx, req) |
| 213 | if err != nil { |
| 214 | return err |
| 215 | } |
| 216 | if resp == nil { |
| 217 | return fmt.Errorf("feishu resource error: empty response") |
| 218 | } |
| 219 | if !resp.Success() { |
| 220 | return fmt.Errorf("feishu resource error: %s", feishuCodeError(resp.Code, resp.Msg)) |
| 221 | } |
| 222 | raw, err := io.ReadAll(io.LimitReader(resp.File, maxFeishuMediaBytes+1)) |
| 223 | if err != nil { |
| 224 | return err |
| 225 | } |
| 226 | if len(raw) == 0 || len(raw) > maxFeishuMediaBytes { |
| 227 | return fmt.Errorf("feishu resource must be between 1 byte and 25 MB") |
| 228 | } |
| 229 | data, fileName = raw, resp.FileName |
| 230 | return nil |
| 231 | }) |
| 232 | return data, fileName, err |
| 233 | } |
| 234 |