Files
LaodingBot/internal/transport/feishu/bot.go

442 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package feishu
import (
"context"
"encoding/json"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
"sync"
"time"
"laodingbot/internal/logger"
lark "github.com/larksuite/oapi-sdk-go/v3"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
)
type Bot struct {
appID string
appSecret string
verifyToken string
apiClient *lark.Client
log *logger.Logger
dedupTTL time.Duration
dedupMu sync.Mutex
dedupSeen map[string]time.Time
}
type IncomingMessage struct {
MessageID string
ChatID string
UserID string
MsgType string
Text string
FileName string
FileKey string
FileMime string
FileBytes []byte
FilePath string
}
const maxFeishuFileBytes = 20 * 1024 * 1024
func NewBot(appID, appSecret, verifyToken, _ string, _ string, log *logger.Logger) (*Bot, error) {
if appID == "" || appSecret == "" {
return nil, fmt.Errorf("empty feishu app credentials")
}
return &Bot{
appID: appID,
appSecret: appSecret,
verifyToken: verifyToken,
apiClient: lark.NewClient(appID, appSecret,
lark.WithLogLevel(toLarkLogLevel(log)),
lark.WithReqTimeout(10*time.Second),
lark.WithEnableTokenCache(true),
),
log: log,
dedupTTL: 10 * time.Minute,
dedupSeen: make(map[string]time.Time),
}, nil
}
func (b *Bot) Run(ctx context.Context, handler func(context.Context, IncomingMessage) (string, error)) error {
if b.log != nil {
b.log.Infof("feishu websocket transport started")
}
eventHandler := dispatcher.NewEventDispatcher(b.verifyToken, "").
OnP2MessageReceiveV1(func(evtCtx context.Context, event *larkim.P2MessageReceiveV1) error {
incoming, ok := parseIncoming(event)
if !ok {
if b.log != nil {
b.log.Debugf("skip unsupported or invalid feishu event")
}
return nil
}
if !b.shouldProcessMessage(incoming.MessageID) {
if b.log != nil {
b.log.Warnf("skip duplicated feishu message message_id=%s chat_id=%s", incoming.MessageID, incoming.ChatID)
}
return nil
}
if incoming.MsgType == "file" {
b.enrichFileIncoming(evtCtx, &incoming)
}
if b.log != nil {
b.log.Infof("feishu message received message_id=%s chat_id=%s user_id=%s msg_type=%s text=%s", incoming.MessageID, incoming.ChatID, incoming.UserID, incoming.MsgType, incoming.Text)
}
reply, err := handler(evtCtx, incoming)
if err != nil {
if b.log != nil {
b.log.Errorf("feishu handler failed chat_id=%s err=%v", incoming.ChatID, err)
}
return err
}
if strings.TrimSpace(reply) == "" {
if b.log != nil {
b.log.Debugf("feishu empty reply chat_id=%s", incoming.ChatID)
}
return nil
}
return b.sendText(evtCtx, incoming.ChatID, reply)
})
wsClient := larkws.NewClient(
b.appID,
b.appSecret,
larkws.WithEventHandler(eventHandler),
larkws.WithLogLevel(toLarkLogLevel(b.log)),
)
errCh := make(chan error, 1)
go func() {
errCh <- wsClient.Start(ctx)
}()
select {
case <-ctx.Done():
if b.log != nil {
b.log.Infof("feishu websocket transport stopped: %v", ctx.Err())
}
return ctx.Err()
case err := <-errCh:
if err != nil && b.log != nil {
b.log.Errorf("feishu websocket transport failed err=%v", err)
}
return err
}
}
func (b *Bot) sendText(ctx context.Context, chatID, text string) error {
resp, err := b.apiClient.Im.Message.Create(ctx, larkim.NewCreateMessageReqBuilder().
ReceiveIdType("chat_id").
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(chatID).
MsgType("text").
Content(fmt.Sprintf(`{"text":%q}`, text)).
Uuid(fmt.Sprintf("%d", time.Now().UnixNano())).
Build()).
Build())
if err != nil {
if b.log != nil {
b.log.Errorf("feishu send message request failed chat_id=%s err=%v", chatID, err)
}
return err
}
if !resp.Success() {
if b.log != nil {
b.log.Warnf("feishu send message unsuccessful chat_id=%s code=%d msg=%s", chatID, resp.Code, resp.Msg)
}
return fmt.Errorf("feishu send message failed: code=%d msg=%s log_id=%s", resp.Code, resp.Msg, resp.LogId())
}
if b.log != nil {
b.log.Debugf("feishu message sent chat_id=%s text_len=%d", chatID, len(text))
}
return nil
}
func extractText(content string) (string, error) {
var parsed struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
return "", err
}
return parsed.Text, nil
}
func extractFileMeta(content string) (fileName string, fileKey string, err error) {
var parsed map[string]any
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
return "", "", err
}
readString := func(keys ...string) string {
for _, key := range keys {
if v, ok := parsed[key]; ok {
s, ok := v.(string)
if ok {
trimmed := strings.TrimSpace(s)
if trimmed != "" {
return trimmed
}
}
}
}
return ""
}
fileName = readString("file_name", "fileName", "name", "filename")
fileKey = readString("file_key", "fileKey", "key")
return fileName, fileKey, nil
}
func buildFileRecognitionText(fileName, fileKey string) string {
if strings.TrimSpace(fileName) == "" {
fileName = "(unknown)"
}
if strings.TrimSpace(fileKey) == "" {
fileKey = "(unknown)"
}
return strings.Join([]string{
"用户发送了一条飞书文件消息。",
"文件名: " + fileName,
"文件Key: " + fileKey,
"系统将先上传该文件到 LLM Provider再由模型完成文档解析。若上传失败本次请求将直接中止。",
}, "\n")
}
func (b *Bot) enrichFileIncoming(ctx context.Context, incoming *IncomingMessage) {
if incoming == nil {
return
}
if strings.TrimSpace(incoming.MessageID) == "" || strings.TrimSpace(incoming.FileKey) == "" {
incoming.Text = buildFileRecognitionText(incoming.FileName, incoming.FileKey)
incoming.Text += "\n\n未找到完整 file_key 或 message_id暂时无法下载文件内容。"
return
}
content, fileName, err := b.downloadFileContent(ctx, incoming.MessageID, incoming.FileKey)
if err != nil {
if b.log != nil {
b.log.Warnf("feishu download file content failed message_id=%s file_key=%s err=%v", incoming.MessageID, incoming.FileKey, err)
}
incoming.Text = buildFileRecognitionText(incoming.FileName, incoming.FileKey)
incoming.Text += "\n\n文件下载失败: " + err.Error()
return
}
if strings.TrimSpace(fileName) != "" {
incoming.FileName = fileName
}
incoming.FileBytes = content
incoming.FileMime = detectMimeByName(incoming.FileName)
localPath, saveErr := saveIncomingFile("files", incoming.FileName, incoming.FileBytes)
if saveErr != nil {
if b.log != nil {
b.log.Warnf("save incoming feishu file failed name=%s err=%v", incoming.FileName, saveErr)
}
incoming.Text = buildFileRecognitionText(incoming.FileName, incoming.FileKey)
incoming.Text += "\n\n文件已下载但本地保存失败: " + saveErr.Error()
return
}
incoming.FilePath = localPath
incoming.Text = buildFileRecognitionText(incoming.FileName, incoming.FileKey)
incoming.Text += fmt.Sprintf("\n\n文件已下载并保存到本地路径=%s大小=%d bytesmime=%s。", incoming.FilePath, len(content), incoming.FileMime)
}
func (b *Bot) downloadFileContent(ctx context.Context, messageID, fileKey string) ([]byte, string, error) {
req := larkim.NewGetMessageResourceReqBuilder().
MessageId(messageID).
FileKey(fileKey).
Type("file").
Build()
resp, err := b.apiClient.Im.MessageResource.Get(ctx, req)
if err != nil {
return nil, "", err
}
if resp == nil || resp.File == nil {
if resp != nil {
return nil, "", fmt.Errorf("empty file stream code=%d msg=%s", resp.Code, resp.Msg)
}
return nil, "", fmt.Errorf("empty file stream")
}
bts, err := io.ReadAll(resp.File)
if err != nil {
return nil, "", err
}
if len(bts) > maxFeishuFileBytes {
return nil, "", fmt.Errorf("file too large: %d bytes, max=%d", len(bts), maxFeishuFileBytes)
}
return bts, strings.TrimSpace(resp.FileName), nil
}
func detectMimeByName(fileName string) string {
ext := strings.ToLower(strings.TrimSpace(filepath.Ext(fileName)))
if ext == "" {
return "application/octet-stream"
}
m := strings.TrimSpace(mime.TypeByExtension(ext))
if m == "" {
return "application/octet-stream"
}
return m
}
func saveIncomingFile(baseDir, fileName string, content []byte) (string, error) {
if len(content) == 0 {
return "", fmt.Errorf("empty file content")
}
if strings.TrimSpace(baseDir) == "" {
baseDir = "files"
}
if err := os.MkdirAll(baseDir, 0o755); err != nil {
return "", err
}
safeName := sanitizeFileName(fileName)
if safeName == "" {
safeName = "upload.bin"
}
finalName := fmt.Sprintf("%d_%s", time.Now().UnixNano(), safeName)
target := filepath.Join(baseDir, finalName)
if err := os.WriteFile(target, content, 0o644); err != nil {
return "", err
}
abs, err := filepath.Abs(target)
if err != nil {
return target, nil
}
return abs, nil
}
func sanitizeFileName(fileName string) string {
name := strings.TrimSpace(filepath.Base(fileName))
if name == "" || name == "." || name == ".." {
return ""
}
var b strings.Builder
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
out := strings.TrimSpace(b.String())
if out == "" || out == "." || out == ".." {
return ""
}
if strings.HasPrefix(out, ".") {
out = "file" + out
}
return out
}
func parseIncoming(event *larkim.P2MessageReceiveV1) (IncomingMessage, bool) {
if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
return IncomingMessage{}, false
}
if event.Event.Sender.SenderType != nil && *event.Event.Sender.SenderType != "user" {
return IncomingMessage{}, false
}
msg := event.Event.Message
if msg.MessageType == nil || msg.ChatId == nil || msg.Content == nil || msg.MessageId == nil {
return IncomingMessage{}, false
}
msgType := strings.TrimSpace(*msg.MessageType)
if msgType == "" {
return IncomingMessage{}, false
}
userID := ""
if event.Event.Sender.SenderId.OpenId != nil {
userID = *event.Event.Sender.SenderId.OpenId
} else if event.Event.Sender.SenderId.UserId != nil {
userID = *event.Event.Sender.SenderId.UserId
} else if event.Event.Sender.SenderId.UnionId != nil {
userID = *event.Event.Sender.SenderId.UnionId
}
incoming := IncomingMessage{
MessageID: *msg.MessageId,
ChatID: *msg.ChatId,
UserID: userID,
MsgType: msgType,
}
switch msgType {
case "text":
text, err := extractText(*msg.Content)
if err != nil {
return IncomingMessage{}, false
}
incoming.Text = text
return incoming, true
case "file":
fileName, fileKey, err := extractFileMeta(*msg.Content)
if err != nil {
return IncomingMessage{}, false
}
incoming.FileName = fileName
incoming.FileKey = fileKey
incoming.Text = buildFileRecognitionText(fileName, fileKey)
return incoming, true
default:
return IncomingMessage{}, false
}
}
func (b *Bot) shouldProcessMessage(messageID string) bool {
messageID = strings.TrimSpace(messageID)
if messageID == "" {
if b.log != nil {
b.log.Warnf("feishu message without message_id; skip idempotency check")
}
return true
}
now := time.Now()
b.dedupMu.Lock()
defer b.dedupMu.Unlock()
for id, seenAt := range b.dedupSeen {
if now.Sub(seenAt) > b.dedupTTL {
delete(b.dedupSeen, id)
}
}
if _, exists := b.dedupSeen[messageID]; exists {
return false
}
b.dedupSeen[messageID] = now
return true
}
func toLarkLogLevel(log *logger.Logger) larkcore.LogLevel {
if log == nil {
return larkcore.LogLevelInfo
}
switch log.Level() {
case logger.LevelDebug:
return larkcore.LogLevelDebug
case logger.LevelWarn:
return larkcore.LogLevelWarn
case logger.LevelError:
return larkcore.LogLevelError
default:
return larkcore.LogLevelInfo
}
}