234 lines
6.9 KiB
Go
234 lines
6.9 KiB
Go
package feishu
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"strings"
|
||
"time"
|
||
|
||
"reasonix/internal/bot"
|
||
|
||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||
)
|
||
|
||
// maxFeishuMediaBytes matches the bot gateway's inbound media cap.
|
||
const maxFeishuMediaBytes = 25 * 1024 * 1024
|
||
|
||
const resourceDownloadTimeout = 30 * time.Second
|
||
|
||
type imageContent struct {
|
||
ImageKey string `json:"image_key"`
|
||
}
|
||
|
||
type fileContent struct {
|
||
FileKey string `json:"file_key"`
|
||
FileName string `json:"file_name"`
|
||
}
|
||
|
||
// mentionRef 是 SDK 事件与 webhook 事件两种 mention 表示的归一化形态。
|
||
type mentionRef struct {
|
||
Key string
|
||
OpenID string
|
||
Name string
|
||
}
|
||
|
||
func sdkMentionRefs(mentions []*larkim.MentionEvent) []mentionRef {
|
||
refs := make([]mentionRef, 0, len(mentions))
|
||
for _, m := range mentions {
|
||
if m == nil {
|
||
continue
|
||
}
|
||
ref := mentionRef{Key: stringPtrValue(m.Key), Name: stringPtrValue(m.Name)}
|
||
if m.Id != nil {
|
||
ref.OpenID = stringPtrValue(m.Id.OpenId)
|
||
}
|
||
refs = append(refs, ref)
|
||
}
|
||
return refs
|
||
}
|
||
|
||
// mentionsBot 判断消息是否 @ 了本 bot。bot open_id 未知时退回“任意 @ 均放行”
|
||
// 的旧行为,避免因为 bot/v3/info 拉取失败而完全失聪。
|
||
func (a *adapter) mentionsBot(mentions []mentionRef) bool {
|
||
botID := a.botOpenID()
|
||
if botID == "" {
|
||
return len(mentions) > 0
|
||
}
|
||
for _, m := range mentions {
|
||
if m.OpenID != "" && m.OpenID == botID {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// replaceMentionPlaceholders 把 "@_user_N" 占位符还原为可读的 "@显示名";
|
||
// bot 自己的占位符直接移除,模型看到的输入不再包含对 bot 的 @。
|
||
func (a *adapter) replaceMentionPlaceholders(text string, mentions []mentionRef) string {
|
||
botID := a.botOpenID()
|
||
for _, m := range mentions {
|
||
if m.Key == "" {
|
||
continue
|
||
}
|
||
replacement := ""
|
||
if (botID == "" || m.OpenID != botID) && m.Name != "" {
|
||
replacement = "@" + m.Name
|
||
}
|
||
text = strings.ReplaceAll(text, m.Key, replacement)
|
||
}
|
||
return strings.TrimSpace(text)
|
||
}
|
||
|
||
// parseInboundContent parses Feishu content without fetching remote resources.
|
||
// Media loaders run later, after the gateway allowlist admits the sender.
|
||
func (a *adapter) parseInboundContent(msgType, content, messageID string) (text string, media []bot.InboundMedia, ok bool) {
|
||
switch msgType {
|
||
case "text":
|
||
var tc textContent
|
||
if err := json.Unmarshal([]byte(content), &tc); err != nil {
|
||
a.logger.Warn("feishu message ignored", "reason", "bad_content", "message", logHash(messageID), "err", err)
|
||
return "", nil, false
|
||
}
|
||
return tc.Text, nil, true
|
||
case "image":
|
||
var ic imageContent
|
||
if err := json.Unmarshal([]byte(content), &ic); err != nil || strings.TrimSpace(ic.ImageKey) == "" {
|
||
return "", nil, false
|
||
}
|
||
return "", []bot.InboundMedia{a.deferredMedia(messageID, ic.ImageKey, "image", "", "[图片下载失败]")}, true
|
||
case "sticker":
|
||
var fc fileContent
|
||
if err := json.Unmarshal([]byte(content), &fc); err != nil || strings.TrimSpace(fc.FileKey) == "" {
|
||
return "", nil, false
|
||
}
|
||
return "", []bot.InboundMedia{a.deferredMedia(messageID, fc.FileKey, "image", "", "[sticker]")}, true
|
||
case "file":
|
||
var fc fileContent
|
||
if err := json.Unmarshal([]byte(content), &fc); err != nil || strings.TrimSpace(fc.FileKey) == "" {
|
||
return "", nil, false
|
||
}
|
||
return "", []bot.InboundMedia{a.deferredMedia(messageID, fc.FileKey, "file", fc.FileName, fmt.Sprintf("[文件下载失败: %s]", fc.FileName))}, true
|
||
case "post":
|
||
return a.parsePostContent(content, messageID)
|
||
default:
|
||
return "", nil, false
|
||
}
|
||
}
|
||
|
||
// parsePostContent 解析富文本(post)消息:抽取文本、链接、@,并下载内嵌图片。
|
||
func (a *adapter) parsePostContent(content, messageID string) (string, []bot.InboundMedia, bool) {
|
||
var post struct {
|
||
Title string `json:"title"`
|
||
Content [][]struct {
|
||
Tag string `json:"tag"`
|
||
Text string `json:"text"`
|
||
Href string `json:"href"`
|
||
UserName string `json:"user_name"`
|
||
ImageKey string `json:"image_key"`
|
||
} `json:"content"`
|
||
}
|
||
if err := json.Unmarshal([]byte(content), &post); err != nil {
|
||
a.logger.Warn("feishu message ignored", "reason", "bad_post_content", "message", logHash(messageID), "err", err)
|
||
return "", nil, false
|
||
}
|
||
var b strings.Builder
|
||
var media []bot.InboundMedia
|
||
if title := strings.TrimSpace(post.Title); title != "" {
|
||
b.WriteString(title)
|
||
b.WriteString("\n")
|
||
}
|
||
for _, paragraph := range post.Content {
|
||
for _, run := range paragraph {
|
||
switch run.Tag {
|
||
case "text", "code_block", "md":
|
||
b.WriteString(run.Text)
|
||
case "a":
|
||
switch {
|
||
case run.Text != "" && run.Href != "" && run.Text != run.Href:
|
||
fmt.Fprintf(&b, "%s (%s)", run.Text, run.Href)
|
||
case run.Href != "":
|
||
b.WriteString(run.Href)
|
||
default:
|
||
b.WriteString(run.Text)
|
||
}
|
||
case "at":
|
||
if run.UserName != "" {
|
||
b.WriteString("@" + run.UserName)
|
||
}
|
||
case "img":
|
||
if strings.TrimSpace(run.ImageKey) == "" {
|
||
continue
|
||
}
|
||
media = append(media, a.deferredMedia(messageID, run.ImageKey, "image", "", "[图片下载失败]"))
|
||
case "media":
|
||
b.WriteString("[视频]")
|
||
}
|
||
}
|
||
b.WriteString("\n")
|
||
}
|
||
return strings.TrimRight(b.String(), "\n"), media, true
|
||
}
|
||
|
||
func (a *adapter) deferredMedia(messageID, key, typ, name, failureText string) bot.InboundMedia {
|
||
return bot.InboundMedia{
|
||
Name: name,
|
||
FailureText: failureText,
|
||
Load: func(ctx context.Context) ([]byte, string, error) {
|
||
fetch := a.fetchResource
|
||
if fetch == nil {
|
||
fetch = a.sdkFetchResource
|
||
}
|
||
data, fetchedName, err := fetch(ctx, messageID, key, typ)
|
||
if err != nil {
|
||
a.logger.Warn("feishu media download failed", "message", logHash(messageID), "type", typ, "err", err)
|
||
return nil, "", err
|
||
}
|
||
if strings.TrimSpace(name) != "" {
|
||
fetchedName = name
|
||
}
|
||
return data, fetchedName, nil
|
||
},
|
||
}
|
||
}
|
||
|
||
// sdkFetchResource 经 SDK 鉴权接口下载消息资源(图片/文件)。
|
||
func (a *adapter) sdkFetchResource(ctx context.Context, messageID, key, typ string) ([]byte, string, error) {
|
||
client, err := a.sdkClient()
|
||
if err != nil {
|
||
return nil, "", err
|
||
}
|
||
ctx, cancel := context.WithTimeout(ctx, resourceDownloadTimeout)
|
||
defer cancel()
|
||
var data []byte
|
||
var fileName string
|
||
err = withTransientRetry(ctx, a.logger, "get message resource", func(ctx context.Context) error {
|
||
req := larkim.NewGetMessageResourceReqBuilder().
|
||
MessageId(messageID).
|
||
FileKey(key).
|
||
Type(typ).
|
||
Build()
|
||
resp, err := client.Im.MessageResource.Get(ctx, req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if resp == nil {
|
||
return fmt.Errorf("feishu resource error: empty response")
|
||
}
|
||
if !resp.Success() {
|
||
return fmt.Errorf("feishu resource error: %s", feishuCodeError(resp.Code, resp.Msg))
|
||
}
|
||
raw, err := io.ReadAll(io.LimitReader(resp.File, maxFeishuMediaBytes+1))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if len(raw) == 0 || len(raw) > maxFeishuMediaBytes {
|
||
return fmt.Errorf("feishu resource must be between 1 byte and 25 MB")
|
||
}
|
||
data, fileName = raw, resp.FileName
|
||
return nil
|
||
})
|
||
return data, fileName, err
|
||
}
|