Add unified ShouldRespondInGroup to BaseChannel, replacing scattered per-channel group filtering logic. Introduce GroupTriggerConfig (with mention_only + prefixes), TypingConfig, and PlaceholderConfig types. Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE hardcoded mention-only to the shared mechanism. Add group trigger entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom. Legacy config fields are preserved with automatic migration.
229 lines
5.6 KiB
Go
229 lines
5.6 KiB
Go
//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
|
|
|
|
package feishu
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
|
|
lark "github.com/larksuite/oapi-sdk-go/v3"
|
|
larkdispatcher "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"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
)
|
|
|
|
type FeishuChannel struct {
|
|
*channels.BaseChannel
|
|
config config.FeishuConfig
|
|
client *lark.Client
|
|
wsClient *larkws.Client
|
|
|
|
mu sync.Mutex
|
|
cancel context.CancelFunc
|
|
}
|
|
|
|
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
|
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
|
|
channels.WithGroupTrigger(cfg.GroupTrigger),
|
|
)
|
|
|
|
return &FeishuChannel{
|
|
BaseChannel: base,
|
|
config: cfg,
|
|
client: lark.NewClient(cfg.AppID, cfg.AppSecret),
|
|
}, nil
|
|
}
|
|
|
|
func (c *FeishuChannel) Start(ctx context.Context) error {
|
|
if c.config.AppID == "" || c.config.AppSecret == "" {
|
|
return fmt.Errorf("feishu app_id or app_secret is empty")
|
|
}
|
|
|
|
dispatcher := larkdispatcher.NewEventDispatcher(c.config.VerificationToken, c.config.EncryptKey).
|
|
OnP2MessageReceiveV1(c.handleMessageReceive)
|
|
|
|
runCtx, cancel := context.WithCancel(ctx)
|
|
|
|
c.mu.Lock()
|
|
c.cancel = cancel
|
|
c.wsClient = larkws.NewClient(
|
|
c.config.AppID,
|
|
c.config.AppSecret,
|
|
larkws.WithEventHandler(dispatcher),
|
|
)
|
|
wsClient := c.wsClient
|
|
c.mu.Unlock()
|
|
|
|
c.SetRunning(true)
|
|
logger.InfoC("feishu", "Feishu channel started (websocket mode)")
|
|
|
|
go func() {
|
|
if err := wsClient.Start(runCtx); err != nil {
|
|
logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
}()
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *FeishuChannel) Stop(ctx context.Context) error {
|
|
c.mu.Lock()
|
|
if c.cancel != nil {
|
|
c.cancel()
|
|
c.cancel = nil
|
|
}
|
|
c.wsClient = nil
|
|
c.mu.Unlock()
|
|
|
|
c.SetRunning(false)
|
|
logger.InfoC("feishu", "Feishu channel stopped")
|
|
return nil
|
|
}
|
|
|
|
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
|
if !c.IsRunning() {
|
|
return channels.ErrNotRunning
|
|
}
|
|
|
|
if msg.ChatID == "" {
|
|
return fmt.Errorf("chat ID is empty")
|
|
}
|
|
|
|
payload, err := json.Marshal(map[string]string{"text": msg.Content})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal feishu content: %w", err)
|
|
}
|
|
|
|
req := larkim.NewCreateMessageReqBuilder().
|
|
ReceiveIdType(larkim.ReceiveIdTypeChatId).
|
|
Body(larkim.NewCreateMessageReqBodyBuilder().
|
|
ReceiveId(msg.ChatID).
|
|
MsgType(larkim.MsgTypeText).
|
|
Content(string(payload)).
|
|
Uuid(fmt.Sprintf("picoclaw-%d", time.Now().UnixNano())).
|
|
Build()).
|
|
Build()
|
|
|
|
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
|
if err != nil {
|
|
return fmt.Errorf("feishu send: %w", channels.ErrTemporary)
|
|
}
|
|
|
|
if !resp.Success() {
|
|
return fmt.Errorf("feishu api error (code=%d msg=%s): %w", resp.Code, resp.Msg, channels.ErrTemporary)
|
|
}
|
|
|
|
logger.DebugCF("feishu", "Feishu message sent", map[string]any{
|
|
"chat_id": msg.ChatID,
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error {
|
|
if event == nil || event.Event == nil || event.Event.Message == nil {
|
|
return nil
|
|
}
|
|
|
|
message := event.Event.Message
|
|
sender := event.Event.Sender
|
|
|
|
chatID := stringValue(message.ChatId)
|
|
if chatID == "" {
|
|
return nil
|
|
}
|
|
|
|
senderID := extractFeishuSenderID(sender)
|
|
if senderID == "" {
|
|
senderID = "unknown"
|
|
}
|
|
|
|
content := extractFeishuMessageContent(message)
|
|
if content == "" {
|
|
content = "[empty message]"
|
|
}
|
|
|
|
metadata := map[string]string{}
|
|
messageID := ""
|
|
if mid := stringValue(message.MessageId); mid != "" {
|
|
messageID = mid
|
|
}
|
|
if messageType := stringValue(message.MessageType); messageType != "" {
|
|
metadata["message_type"] = messageType
|
|
}
|
|
if chatType := stringValue(message.ChatType); chatType != "" {
|
|
metadata["chat_type"] = chatType
|
|
}
|
|
if sender != nil && sender.TenantKey != nil {
|
|
metadata["tenant_key"] = *sender.TenantKey
|
|
}
|
|
|
|
chatType := stringValue(message.ChatType)
|
|
var peer bus.Peer
|
|
if chatType == "p2p" {
|
|
peer = bus.Peer{Kind: "direct", ID: senderID}
|
|
} else {
|
|
peer = bus.Peer{Kind: "group", ID: chatID}
|
|
// In group chats, apply unified group trigger filtering
|
|
respond, cleaned := c.ShouldRespondInGroup(false, content)
|
|
if !respond {
|
|
return nil
|
|
}
|
|
content = cleaned
|
|
}
|
|
|
|
logger.InfoCF("feishu", "Feishu message received", map[string]any{
|
|
"sender_id": senderID,
|
|
"chat_id": chatID,
|
|
"preview": utils.Truncate(content, 80),
|
|
})
|
|
|
|
c.HandleMessage(peer, messageID, senderID, chatID, content, nil, metadata)
|
|
return nil
|
|
}
|
|
|
|
func extractFeishuSenderID(sender *larkim.EventSender) string {
|
|
if sender == nil || sender.SenderId == nil {
|
|
return ""
|
|
}
|
|
|
|
if sender.SenderId.UserId != nil && *sender.SenderId.UserId != "" {
|
|
return *sender.SenderId.UserId
|
|
}
|
|
if sender.SenderId.OpenId != nil && *sender.SenderId.OpenId != "" {
|
|
return *sender.SenderId.OpenId
|
|
}
|
|
if sender.SenderId.UnionId != nil && *sender.SenderId.UnionId != "" {
|
|
return *sender.SenderId.UnionId
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func extractFeishuMessageContent(message *larkim.EventMessage) string {
|
|
if message == nil || message.Content == nil || *message.Content == "" {
|
|
return ""
|
|
}
|
|
|
|
if message.MessageType != nil && *message.MessageType == larkim.MsgTypeText {
|
|
var textPayload struct {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal([]byte(*message.Content), &textPayload); err == nil {
|
|
return textPayload.Text
|
|
}
|
|
}
|
|
|
|
return *message.Content
|
|
}
|