package feishu import ( "context" "encoding/json" "fmt" "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 Text string } 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 non-text 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 b.log != nil { b.log.Infof("feishu message received message_id=%s chat_id=%s user_id=%s text=%s", incoming.MessageID, incoming.ChatID, incoming.UserID, 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 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.MessageType != "text" || msg.ChatId == nil || msg.Content == nil || msg.MessageId == nil { return IncomingMessage{}, false } text, err := extractText(*msg.Content) if err != nil { 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 } return IncomingMessage{ MessageID: *msg.MessageId, ChatID: *msg.ChatId, UserID: userID, Text: text, }, true } 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 } }