Files
ragflow/internal/channels/feishu.go
Haruko386 5249de53da feat[Go]: add feishu chat bot for chat channel (#17561)
### Summary

As title, related to #17520
2026-07-31 11:40:25 +08:00

460 lines
12 KiB
Go

//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package channels
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
"strings"
"sync"
"time"
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"
"ragflow/internal/channels/core"
)
const (
defaultFeishuDomain = "feishu"
defaultFeishuTimeout = 30 * time.Second
feishuMessageTTL = time.Hour
feishuQueueSize = 64
feishuWorkerIdle = 5 * time.Minute
)
type feishuAccount struct {
AccountID string
AppID string
AppSecret string
Domain string
Timeout time.Duration
}
type feishuChannel struct {
account feishuAccount
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
handler core.MessageHandler
rest *lark.Client
wsClient *larkws.Client
seen map[string]time.Time
workers map[string]*feishuWorker
}
type feishuWorker struct {
queue chan core.IncomingMessage
}
// newFeishuChannel creates a Feishu channel backed by the official Lark SDK.
func newFeishuChannel(account feishuAccount) *feishuChannel {
if account.Domain == "" {
account.Domain = defaultFeishuDomain
}
if account.Timeout <= 0 {
account.Timeout = defaultFeishuTimeout
}
ch := &feishuChannel{
account: account,
ctx: context.Background(),
seen: map[string]time.Time{},
workers: map[string]*feishuWorker{},
}
ch.rest = lark.NewClient(
account.AppID,
account.AppSecret,
lark.WithOpenBaseUrl(feishuBaseURL(account.Domain)),
lark.WithLogLevel(larkcore.LogLevelInfo),
lark.WithReqTimeout(account.Timeout),
)
ch.wsClient = larkws.NewClient(
account.AppID,
account.AppSecret,
larkws.WithDomain(feishuBaseURL(account.Domain)),
larkws.WithEventHandler(ch.eventDispatcher()),
larkws.WithLogLevel(larkcore.LogLevelInfo),
)
return ch
}
// newFeishuChannelFromConfig builds a Feishu channel from chat_channel.config.credential.
func newFeishuChannelFromConfig(accountID string, cfg map[string]any) (*feishuChannel, error) {
appID := firstString(cfg, "app_id", "appId")
appSecret := firstString(cfg, "app_secret", "appSecret")
if appID == "" || appSecret == "" {
return nil, fmt.Errorf("feishu account %q is missing app_id or app_secret", accountID)
}
timeout := defaultFeishuTimeout
if raw, ok := cfg["timeout_secs"]; ok {
if parsed := feishuDurationSeconds(raw); parsed > 0 {
timeout = parsed
}
}
return newFeishuChannel(feishuAccount{
AccountID: accountID,
AppID: appID,
AppSecret: appSecret,
Domain: valueOrDefault(firstString(cfg, "domain"), defaultFeishuDomain),
Timeout: timeout,
}), nil
}
// ChannelID returns the platform identifier used by the chat-channel runtime.
func (c *feishuChannel) ChannelID() string {
return "feishu"
}
// AccountID returns the chat_channel.id bound to this Feishu runtime instance.
func (c *feishuChannel) AccountID() string {
return c.account.AccountID
}
// SetMessageHandler installs the RAGFlow bridge invoked for inbound Feishu messages.
func (c *feishuChannel) SetMessageHandler(handler core.MessageHandler) {
c.mu.Lock()
defer c.mu.Unlock()
c.handler = handler
}
// Start begins the Feishu WebSocket event client.
func (c *feishuChannel) Start(ctx context.Context) error {
c.mu.Lock()
if c.cancel != nil {
c.mu.Unlock()
return nil
}
runCtx, cancel := context.WithCancel(ctx)
c.ctx = runCtx
c.cancel = cancel
c.mu.Unlock()
go c.run(runCtx)
return nil
}
// Stop cancels the Feishu WebSocket client and clears transient workers.
func (c *feishuChannel) Stop(ctx context.Context) error {
c.mu.Lock()
cancel := c.cancel
wsClient := c.wsClient
c.cancel = nil
c.seen = map[string]time.Time{}
c.workers = map[string]*feishuWorker{}
c.mu.Unlock()
if cancel != nil {
cancel()
}
if wsClient != nil {
wsClient.Close()
}
return nil
}
// Send posts an outgoing RAGFlow answer to Feishu.
func (c *feishuChannel) Send(ctx context.Context, msg core.OutgoingMessage) error {
if strings.TrimSpace(msg.ChatID) == "" {
return errors.New("chat_id is required")
}
if strings.TrimSpace(msg.Text) == "" {
return nil
}
content, _ := json.Marshal(map[string]string{"text": msg.Text})
contentText := string(content)
msgType := "text"
if strings.TrimSpace(msg.ReplyToMessageID) != "" {
resp, err := c.rest.Im.Message.Reply(ctx, larkim.NewReplyMessageReqBuilder().
MessageId(msg.ReplyToMessageID).
Body(larkim.NewReplyMessageReqBodyBuilder().
MsgType(msgType).
Content(contentText).
Build()).
Build())
if err != nil {
return err
}
return feishuResponseError("reply", resp.Code, resp.Msg)
}
resp, err := c.rest.Im.Message.Create(ctx, larkim.NewCreateMessageReqBuilder().
ReceiveIdType("chat_id").
Body(larkim.NewCreateMessageReqBodyBuilder().
ReceiveId(msg.ChatID).
MsgType(msgType).
Content(contentText).
Build()).
Build())
if err != nil {
return err
}
return feishuResponseError("create", resp.Code, resp.Msg)
}
// run keeps the SDK WebSocket client active until the channel stops.
func (c *feishuChannel) run(ctx context.Context) {
if c.wsClient == nil {
log.Printf("[feishu:%s] WebSocket client is not initialized", c.account.AccountID)
return
}
if err := c.wsClient.Start(ctx); err != nil && ctx.Err() == nil {
log.Printf("[feishu:%s] WebSocket client exited: %v", c.account.AccountID, err)
}
}
// eventDispatcher builds the Feishu event handler used by the WebSocket client.
func (c *feishuChannel) eventDispatcher() *dispatcher.EventDispatcher {
return dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(
func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
incoming, ok := c.normalizeMessage(event)
if !ok {
return nil
}
c.enqueueIncoming(incoming)
return nil
},
)
}
// normalizeMessage converts a Feishu SDK message event to the chat-channel message model.
func (c *feishuChannel) normalizeMessage(event *larkim.P2MessageReceiveV1) (core.IncomingMessage, bool) {
if event == nil || event.Event == nil || event.Event.Message == nil {
return core.IncomingMessage{}, false
}
message := event.Event.Message
messageID := stringValue(message.MessageId)
if messageID == "" {
return core.IncomingMessage{}, false
}
raw := map[string]any{}
if data, err := json.Marshal(event); err == nil {
_ = json.Unmarshal(data, &raw)
}
return core.IncomingMessage{
Channel: c.ChannelID(),
AccountID: c.account.AccountID,
ChatID: stringValue(message.ChatId),
ChatType: stringValue(message.ChatType),
MessageID: messageID,
SenderID: feishuSenderID(event.Event.Sender),
Text: feishuMessageText(message.Content),
Raw: raw,
}, true
}
// enqueueIncoming schedules Feishu message handling and marks it seen after a successful handoff.
func (c *feishuChannel) enqueueIncoming(incoming core.IncomingMessage) bool {
now := time.Now()
var worker *feishuWorker
startWorker := false
queueFull := false
c.mu.Lock()
c.pruneSeenLocked(now)
if _, ok := c.seen[incoming.MessageID]; ok {
c.mu.Unlock()
return false
}
if c.workers == nil {
c.workers = map[string]*feishuWorker{}
}
worker = c.workers[incoming.ChatID]
if worker == nil {
worker = &feishuWorker{queue: make(chan core.IncomingMessage, feishuQueueSize)}
c.workers[incoming.ChatID] = worker
startWorker = true
}
select {
case worker.queue <- incoming:
c.seen[incoming.MessageID] = now
c.mu.Unlock()
if startWorker {
go c.runWorker(c.ctx, incoming.ChatID, worker)
}
return true
case <-c.ctx.Done():
c.mu.Unlock()
default:
queueFull = true
c.mu.Unlock()
}
if startWorker {
go c.runWorker(c.ctx, incoming.ChatID, worker)
}
if queueFull {
log.Printf("[feishu:%s] dropping message %s for chat %s: queue is full", c.account.AccountID, incoming.MessageID, incoming.ChatID)
}
return false
}
// runWorker processes one Feishu chat's inbound messages sequentially.
func (c *feishuChannel) runWorker(ctx context.Context, chatID string, worker *feishuWorker) {
idle := time.NewTimer(feishuWorkerIdle)
defer idle.Stop()
for {
select {
case <-ctx.Done():
return
case msg := <-worker.queue:
if ctx.Err() != nil {
return
}
handlerCtx, cancel := context.WithTimeout(ctx, c.account.Timeout)
c.handleIncoming(handlerCtx, msg)
cancel()
resetTimer(idle, feishuWorkerIdle)
case <-idle.C:
if c.retireWorker(chatID, worker) {
return
}
resetTimer(idle, feishuWorkerIdle)
}
}
}
// retireWorker removes an idle Feishu chat worker only while it is current and empty.
func (c *feishuChannel) retireWorker(chatID string, worker *feishuWorker) bool {
c.mu.Lock()
defer c.mu.Unlock()
current := c.workers[chatID]
if current != worker {
return true
}
if len(worker.queue) > 0 {
return false
}
delete(c.workers, chatID)
return true
}
// handleIncoming invokes the installed RAGFlow bridge for one queued Feishu message.
func (c *feishuChannel) handleIncoming(ctx context.Context, incoming core.IncomingMessage) {
c.mu.Lock()
handler := c.handler
c.mu.Unlock()
if handler == nil {
return
}
if err := handler(ctx, incoming); err != nil {
log.Printf("[feishu:%s] message handler error: %v", c.account.AccountID, err)
}
}
// pruneSeenLocked removes expired duplicate-tracking entries while c.mu is held.
func (c *feishuChannel) pruneSeenLocked(now time.Time) {
for key, ts := range c.seen {
if now.Sub(ts) > feishuMessageTTL {
delete(c.seen, key)
}
}
}
// feishuMessageText extracts plain text from Feishu's JSON-encoded message content.
func feishuMessageText(content *string) string {
raw := stringValue(content)
if raw == "" {
return ""
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return raw
}
if text, ok := payload["text"].(string); ok {
return text
}
return ""
}
// feishuSenderID returns the sender open_id from a Feishu event sender.
func feishuSenderID(sender *larkim.EventSender) string {
if sender == nil || sender.SenderId == nil {
return ""
}
return stringValue(sender.SenderId.OpenId)
}
// feishuBaseURL maps Feishu/Lark domain config to the SDK base URL.
func feishuBaseURL(domain string) string {
if strings.EqualFold(strings.TrimSpace(domain), "lark") {
return lark.LarkBaseUrl
}
return lark.FeishuBaseUrl
}
// feishuResponseError returns an error when a Feishu SDK response has a non-zero code.
func feishuResponseError(action string, code int, message string) error {
if code == 0 {
return nil
}
return fmt.Errorf("feishu %s failed: code=%d msg=%s", action, code, message)
}
// feishuDurationSeconds converts a config value measured in seconds to a duration.
func feishuDurationSeconds(value any) time.Duration {
switch v := value.(type) {
case int:
return time.Duration(v) * time.Second
case int64:
return time.Duration(v) * time.Second
case float64:
return time.Duration(v) * time.Second
case json.Number:
n, err := v.Int64()
if err != nil {
return 0
}
return time.Duration(n) * time.Second
case string:
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0
}
return time.Duration(n) * time.Second
default:
return 0
}
}
// stringValue dereferences an optional string.
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}