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 bytes,mime=%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 } }