返回 DeepSeek-Reasonix
inbound.go
根目录 / internal / bot / feishu / inbound.go
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
234 lines GO