feat: add configurable ack reaction support for Feishu
Add global messages configuration to control acknowledgment reactions across all channels. Implement Feishu message reaction (emoji) support based on configuration scope. Features: - Add MessagesConfig with ack_reaction, ack_reaction_scope, remove_ack_after_reply - Add ShouldAckReaction() for cross-channel ack reaction logic - Implement Feishu message reaction via Lark API - Update all channels to receive MessagesConfig via BaseChannel Configuration options: - ack_reaction: emoji to use (e.g., "OK"), empty to disable - ack_reaction_scope: "all", "direct", "group-all", "group-mentions", "off" - remove_ack_after_reply: reserved for future use Refs: inspired by openclaw implementation
This commit is contained in:
parent
57dac394c5
commit
093d24f9ab
18 changed files with 264 additions and 50 deletions
|
|
@ -129,6 +129,11 @@
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"monitor_usb": true
|
"monitor_usb": true
|
||||||
},
|
},
|
||||||
|
"messages": {
|
||||||
|
"ack_reaction": "OK",
|
||||||
|
"ack_reaction_scope": "group-mentions",
|
||||||
|
"remove_ack_after_reply": false
|
||||||
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790
|
"port": 18790
|
||||||
|
|
|
||||||
118
pkg/channels/ack_reactions.go
Normal file
118
pkg/channels/ack_reactions.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package channels
|
||||||
|
|
||||||
|
// AckReactionScope defines when to send acknowledgment reactions
|
||||||
|
type AckReactionScope string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AckReactionScopeAll enables ack reactions for all messages
|
||||||
|
AckReactionScopeAll AckReactionScope = "all"
|
||||||
|
// AckReactionScopeDirect enables ack reactions only for direct messages
|
||||||
|
AckReactionScopeDirect AckReactionScope = "direct"
|
||||||
|
// AckReactionScopeGroupAll enables ack reactions for all group messages
|
||||||
|
AckReactionScopeGroupAll AckReactionScope = "group-all"
|
||||||
|
// AckReactionScopeGroupMentions enables ack reactions only when mentioned in groups
|
||||||
|
AckReactionScopeGroupMentions AckReactionScope = "group-mentions"
|
||||||
|
// AckReactionScopeOff disables ack reactions
|
||||||
|
AckReactionScopeOff AckReactionScope = "off"
|
||||||
|
// AckReactionScopeNone disables ack reactions (alias)
|
||||||
|
AckReactionScopeNone AckReactionScope = "none"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AckReactionParams contains parameters for determining whether to send an ack reaction
|
||||||
|
type AckReactionParams struct {
|
||||||
|
// Scope is the configured ack reaction scope
|
||||||
|
Scope AckReactionScope
|
||||||
|
// IsDirect indicates if the message is a direct/private message
|
||||||
|
IsDirect bool
|
||||||
|
// IsGroup indicates if the message is from a group
|
||||||
|
IsGroup bool
|
||||||
|
// IsMentionableGroup indicates if the group supports mentions
|
||||||
|
IsMentionableGroup bool
|
||||||
|
// RequireMention indicates if the group requires mentioning the bot to respond
|
||||||
|
RequireMention bool
|
||||||
|
// CanDetectMention indicates if the platform can detect mentions
|
||||||
|
CanDetectMention bool
|
||||||
|
// WasMentioned indicates if the bot was mentioned in the message
|
||||||
|
WasMentioned bool
|
||||||
|
// ShouldBypassMention indicates if mention requirements should be bypassed
|
||||||
|
ShouldBypassMention bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldAckReaction determines whether an ack reaction should be sent based on parameters
|
||||||
|
// Reference: openclaw implementation
|
||||||
|
func ShouldAckReaction(params AckReactionParams) bool {
|
||||||
|
// Default to group-mentions if not specified
|
||||||
|
scope := params.Scope
|
||||||
|
if scope == "" {
|
||||||
|
scope = AckReactionScopeGroupMentions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled cases
|
||||||
|
if scope == AckReactionScopeOff || scope == AckReactionScopeNone {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// All messages
|
||||||
|
if scope == AckReactionScopeAll {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct messages only
|
||||||
|
if scope == AckReactionScopeDirect {
|
||||||
|
return params.IsDirect
|
||||||
|
}
|
||||||
|
|
||||||
|
// All group messages
|
||||||
|
if scope == AckReactionScopeGroupAll {
|
||||||
|
return params.IsGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group mentions only
|
||||||
|
if scope == AckReactionScopeGroupMentions {
|
||||||
|
// Not a mentionable group, don't ack
|
||||||
|
if !params.IsMentionableGroup {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// No mention required, don't ack (avoid over-acknowledging)
|
||||||
|
if !params.RequireMention {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Can't detect mentions, don't ack
|
||||||
|
if !params.CanDetectMention {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Mentioned or bypass required, ack
|
||||||
|
return params.WasMentioned || params.ShouldBypassMention
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// AckReactionManager manages the lifecycle of acknowledgment reactions
|
||||||
|
type AckReactionManager struct {
|
||||||
|
// RemoveAfterReply indicates whether to remove the ack after reply
|
||||||
|
RemoveAfterReply bool
|
||||||
|
// ReactionValue is the current reaction value (emoji)
|
||||||
|
ReactionValue string
|
||||||
|
// Added indicates if the ack reaction has been added
|
||||||
|
Added bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAckReactionManager creates a new ack reaction manager
|
||||||
|
func NewAckReactionManager(removeAfterReply bool, reaction string) *AckReactionManager {
|
||||||
|
return &AckReactionManager{
|
||||||
|
RemoveAfterReply: removeAfterReply,
|
||||||
|
ReactionValue: reaction,
|
||||||
|
Added: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkAdded marks the ack reaction as added
|
||||||
|
func (m *AckReactionManager) MarkAdded() {
|
||||||
|
m.Added = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldRemoveAfterReply determines whether to remove the ack after reply
|
||||||
|
func (m *AckReactionManager) ShouldRemoveAfterReply() bool {
|
||||||
|
return m.RemoveAfterReply && m.Added && m.ReactionValue != ""
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Channel interface {
|
type Channel interface {
|
||||||
|
|
@ -18,23 +19,30 @@ type Channel interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
type BaseChannel struct {
|
type BaseChannel struct {
|
||||||
config interface{}
|
config interface{}
|
||||||
bus *bus.MessageBus
|
messagesConfig config.MessagesConfig
|
||||||
running bool
|
bus *bus.MessageBus
|
||||||
name string
|
running bool
|
||||||
allowList []string
|
name string
|
||||||
|
allowList []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBaseChannel(name string, config interface{}, bus *bus.MessageBus, allowList []string) *BaseChannel {
|
func NewBaseChannel(name string, config interface{}, messagesCfg config.MessagesConfig, bus *bus.MessageBus, allowList []string) *BaseChannel {
|
||||||
return &BaseChannel{
|
return &BaseChannel{
|
||||||
config: config,
|
config: config,
|
||||||
bus: bus,
|
messagesConfig: messagesCfg,
|
||||||
name: name,
|
bus: bus,
|
||||||
allowList: allowList,
|
name: name,
|
||||||
running: false,
|
allowList: allowList,
|
||||||
|
running: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MessagesConfig returns the global messages configuration
|
||||||
|
func (c *BaseChannel) MessagesConfig() config.MessagesConfig {
|
||||||
|
return c.messagesConfig
|
||||||
|
}
|
||||||
|
|
||||||
func (c *BaseChannel) Name() string {
|
func (c *BaseChannel) Name() string {
|
||||||
return c.name
|
return c.name
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
package channels
|
package channels
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
func TestBaseChannelIsAllowed(t *testing.T) {
|
func TestBaseChannelIsAllowed(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
@ -43,7 +47,7 @@ func TestBaseChannelIsAllowed(t *testing.T) {
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ch := NewBaseChannel("test", nil, nil, tt.allowList)
|
ch := NewBaseChannel("test", nil, config.MessagesConfig{}, nil, tt.allowList)
|
||||||
if got := ch.IsAllowed(tt.senderID); got != tt.want {
|
if got := ch.IsAllowed(tt.senderID); got != tt.want {
|
||||||
t.Fatalf("IsAllowed(%q) = %v, want %v", tt.senderID, got, tt.want)
|
t.Fatalf("IsAllowed(%q) = %v, want %v", tt.senderID, got, tt.want)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,12 @@ type DingTalkChannel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDingTalkChannel creates a new DingTalk channel instance
|
// NewDingTalkChannel creates a new DingTalk channel instance
|
||||||
func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
|
func NewDingTalkChannel(cfg config.DingTalkConfig, messagesCfg config.MessagesConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) {
|
||||||
if cfg.ClientID == "" || cfg.ClientSecret == "" {
|
if cfg.ClientID == "" || cfg.ClientSecret == "" {
|
||||||
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom)
|
base := NewBaseChannel("dingtalk", cfg, messagesCfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &DingTalkChannel{
|
return &DingTalkChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,13 @@ type DiscordChannel struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
func NewDiscordChannel(cfg config.DiscordConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
|
||||||
session, err := discordgo.New("Bot " + cfg.Token)
|
session, err := discordgo.New("Bot " + cfg.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("discord", cfg, bus, cfg.AllowFrom)
|
base := NewBaseChannel("discord", cfg, messagesCfg, bus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &DiscordChannel{
|
return &DiscordChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ type FeishuChannel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
|
||||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
func NewFeishuChannel(cfg config.FeishuConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||||
return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config")
|
return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ type FeishuChannel struct {
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
func NewFeishuChannel(cfg config.FeishuConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
|
||||||
base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom)
|
base := NewBaseChannel("feishu", cfg, messagesCfg, bus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &FeishuChannel{
|
return &FeishuChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
@ -128,7 +128,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2MessageReceiveV1) error {
|
func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||||
if event == nil || event.Event == nil || event.Event.Message == nil {
|
if event == nil || event.Event == nil || event.Event.Message == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -151,14 +151,26 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
|
||||||
content = "[empty message]"
|
content = "[empty message]"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine chat type: p2p = direct, group = group chat
|
||||||
|
chatType := stringValue(message.ChatType)
|
||||||
|
isGroup := chatType == "group"
|
||||||
|
isDirect := chatType == "p2p"
|
||||||
|
|
||||||
|
// Check if bot was mentioned
|
||||||
|
wasMentioned := false
|
||||||
|
if message.Mentions != nil && len(message.Mentions) > 0 {
|
||||||
|
wasMentioned = true
|
||||||
|
}
|
||||||
|
|
||||||
metadata := map[string]string{}
|
metadata := map[string]string{}
|
||||||
if messageID := stringValue(message.MessageId); messageID != "" {
|
messageID := stringValue(message.MessageId)
|
||||||
|
if messageID != "" {
|
||||||
metadata["message_id"] = messageID
|
metadata["message_id"] = messageID
|
||||||
}
|
}
|
||||||
if messageType := stringValue(message.MessageType); messageType != "" {
|
if messageType := stringValue(message.MessageType); messageType != "" {
|
||||||
metadata["message_type"] = messageType
|
metadata["message_type"] = messageType
|
||||||
}
|
}
|
||||||
if chatType := stringValue(message.ChatType); chatType != "" {
|
if chatType != "" {
|
||||||
metadata["chat_type"] = chatType
|
metadata["chat_type"] = chatType
|
||||||
}
|
}
|
||||||
if sender != nil && sender.TenantKey != nil {
|
if sender != nil && sender.TenantKey != nil {
|
||||||
|
|
@ -171,6 +183,30 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
|
||||||
"preview": utils.Truncate(content, 80),
|
"preview": utils.Truncate(content, 80),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add emoji ack reaction based on configuration
|
||||||
|
ackReaction := c.MessagesConfig().AckReaction
|
||||||
|
if ackReaction != "" && messageID != "" {
|
||||||
|
shouldAck := ShouldAckReaction(AckReactionParams{
|
||||||
|
Scope: AckReactionScope(c.MessagesConfig().AckReactionScope),
|
||||||
|
IsDirect: isDirect,
|
||||||
|
IsGroup: isGroup,
|
||||||
|
IsMentionableGroup: true, // Feishu groups support mentions
|
||||||
|
RequireMention: true,
|
||||||
|
CanDetectMention: true,
|
||||||
|
WasMentioned: wasMentioned,
|
||||||
|
ShouldBypassMention: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if shouldAck {
|
||||||
|
if err := c.addMessageReaction(ctx, messageID, ackReaction); err != nil {
|
||||||
|
logger.ErrorCF("feishu", "Failed to add emoji reaction", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
"message_id": messageID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -216,3 +252,30 @@ func stringValue(v *string) string {
|
||||||
}
|
}
|
||||||
return *v
|
return *v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *FeishuChannel) addMessageReaction(ctx context.Context, messageID, emojiType string) error {
|
||||||
|
req := larkim.NewCreateMessageReactionReqBuilder().
|
||||||
|
MessageId(messageID).
|
||||||
|
Body(larkim.NewCreateMessageReactionReqBodyBuilder().
|
||||||
|
ReactionType(larkim.NewEmojiBuilder().
|
||||||
|
EmojiType(emojiType).
|
||||||
|
Build()).
|
||||||
|
Build()).
|
||||||
|
Build()
|
||||||
|
|
||||||
|
resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create message reaction: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !resp.Success() {
|
||||||
|
return fmt.Errorf("feishu reaction api error: code=%d msg=%s", resp.Code, resp.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("feishu", "Emoji reaction added", map[string]interface{}{
|
||||||
|
"message_id": messageID,
|
||||||
|
"emoji_type": emojiType,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,12 +54,12 @@ type LINEChannel struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLINEChannel creates a new LINE channel instance.
|
// NewLINEChannel creates a new LINE channel instance.
|
||||||
func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
func NewLINEChannel(cfg config.LINEConfig, messagesCfg config.MessagesConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
|
||||||
if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
|
if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
|
||||||
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom)
|
base := NewBaseChannel("line", cfg, messagesCfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &LINEChannel{
|
return &LINEChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ type MaixCamMessage struct {
|
||||||
Data map[string]interface{} `json:"data"`
|
Data map[string]interface{} `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
|
func NewMaixCamChannel(cfg config.MaixCamConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
|
||||||
base := NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom)
|
base := NewBaseChannel("maixcam", cfg, messagesCfg, bus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &MaixCamChannel{
|
return &MaixCamChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" {
|
if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
|
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
|
||||||
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
|
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.config.Messages, m.bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -74,7 +74,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.Feishu.Enabled {
|
if m.config.Channels.Feishu.Enabled {
|
||||||
logger.DebugC("channels", "Attempting to initialize Feishu channel")
|
logger.DebugC("channels", "Attempting to initialize Feishu channel")
|
||||||
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
|
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.config.Messages, m.bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -87,7 +87,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
|
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
logger.DebugC("channels", "Attempting to initialize Discord channel")
|
||||||
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
|
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.config.Messages, m.bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -100,7 +100,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.MaixCam.Enabled {
|
if m.config.Channels.MaixCam.Enabled {
|
||||||
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
|
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
|
||||||
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
|
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.config.Messages, m.bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -113,7 +113,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.QQ.Enabled {
|
if m.config.Channels.QQ.Enabled {
|
||||||
logger.DebugC("channels", "Attempting to initialize QQ channel")
|
logger.DebugC("channels", "Attempting to initialize QQ channel")
|
||||||
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
|
qq, err := NewQQChannel(m.config.Channels.QQ, m.config.Messages, m.bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -126,7 +126,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
|
if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
|
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
|
||||||
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
|
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.config.Messages, m.bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -139,7 +139,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
|
if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize Slack channel")
|
logger.DebugC("channels", "Attempting to initialize Slack channel")
|
||||||
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
|
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.config.Messages, m.bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -152,7 +152,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
|
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize LINE channel")
|
logger.DebugC("channels", "Attempting to initialize LINE channel")
|
||||||
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
|
line, err := NewLINEChannel(m.config.Channels.LINE, m.config.Messages, m.bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
@ -165,7 +165,7 @@ func (m *Manager) initChannels() error {
|
||||||
|
|
||||||
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
|
if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
|
||||||
logger.DebugC("channels", "Attempting to initialize OneBot channel")
|
logger.DebugC("channels", "Attempting to initialize OneBot channel")
|
||||||
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
|
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.config.Messages, m.bus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
|
logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
|
|
|
||||||
|
|
@ -91,8 +91,8 @@ type oneBotSendGroupMsgParams struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
func NewOneBotChannel(cfg config.OneBotConfig, messagesCfg config.MessagesConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
|
||||||
base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
|
base := NewBaseChannel("onebot", cfg, messagesCfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
const dedupSize = 1024
|
const dedupSize = 1024
|
||||||
return &OneBotChannel{
|
return &OneBotChannel{
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,8 @@ type QQChannel struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
|
func NewQQChannel(cfg config.QQConfig, messagesCfg config.MessagesConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
|
||||||
base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom)
|
base := NewBaseChannel("qq", cfg, messagesCfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &QQChannel{
|
return &QQChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ type slackMessageRef struct {
|
||||||
Timestamp string
|
Timestamp string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
|
func NewSlackChannel(cfg config.SlackConfig, messagesCfg config.MessagesConfig, messageBus *bus.MessageBus) (*SlackChannel, error) {
|
||||||
if cfg.BotToken == "" || cfg.AppToken == "" {
|
if cfg.BotToken == "" || cfg.AppToken == "" {
|
||||||
return nil, fmt.Errorf("slack bot_token and app_token are required")
|
return nil, fmt.Errorf("slack bot_token and app_token are required")
|
||||||
}
|
}
|
||||||
|
|
@ -48,7 +48,7 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
|
||||||
|
|
||||||
socketClient := socketmode.New(api)
|
socketClient := socketmode.New(api)
|
||||||
|
|
||||||
base := NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom)
|
base := NewBaseChannel("slack", cfg, messagesCfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &SlackChannel{
|
return &SlackChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,7 @@ func TestNewSlackChannel(t *testing.T) {
|
||||||
BotToken: "",
|
BotToken: "",
|
||||||
AppToken: "xapp-test",
|
AppToken: "xapp-test",
|
||||||
}
|
}
|
||||||
_, err := NewSlackChannel(cfg, msgBus)
|
_, err := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing bot_token, got nil")
|
t.Error("expected error for missing bot_token, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -117,7 +117,7 @@ func TestNewSlackChannel(t *testing.T) {
|
||||||
BotToken: "xoxb-test",
|
BotToken: "xoxb-test",
|
||||||
AppToken: "",
|
AppToken: "",
|
||||||
}
|
}
|
||||||
_, err := NewSlackChannel(cfg, msgBus)
|
_, err := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error for missing app_token, got nil")
|
t.Error("expected error for missing app_token, got nil")
|
||||||
}
|
}
|
||||||
|
|
@ -129,7 +129,7 @@ func TestNewSlackChannel(t *testing.T) {
|
||||||
AppToken: "xapp-test",
|
AppToken: "xapp-test",
|
||||||
AllowFrom: []string{"U123"},
|
AllowFrom: []string{"U123"},
|
||||||
}
|
}
|
||||||
ch, err := NewSlackChannel(cfg, msgBus)
|
ch, err := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +151,7 @@ func TestSlackChannelIsAllowed(t *testing.T) {
|
||||||
AppToken: "xapp-test",
|
AppToken: "xapp-test",
|
||||||
AllowFrom: []string{},
|
AllowFrom: []string{},
|
||||||
}
|
}
|
||||||
ch, _ := NewSlackChannel(cfg, msgBus)
|
ch, _ := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||||
if !ch.IsAllowed("U_ANYONE") {
|
if !ch.IsAllowed("U_ANYONE") {
|
||||||
t.Error("empty allowlist should allow all users")
|
t.Error("empty allowlist should allow all users")
|
||||||
}
|
}
|
||||||
|
|
@ -163,7 +163,7 @@ func TestSlackChannelIsAllowed(t *testing.T) {
|
||||||
AppToken: "xapp-test",
|
AppToken: "xapp-test",
|
||||||
AllowFrom: []string{"U_ALLOWED"},
|
AllowFrom: []string{"U_ALLOWED"},
|
||||||
}
|
}
|
||||||
ch, _ := NewSlackChannel(cfg, msgBus)
|
ch, _ := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus)
|
||||||
if !ch.IsAllowed("U_ALLOWED") {
|
if !ch.IsAllowed("U_ALLOWED") {
|
||||||
t.Error("allowed user should pass allowlist check")
|
t.Error("allowed user should pass allowlist check")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
|
||||||
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom)
|
base := NewBaseChannel("telegram", telegramCfg, cfg.Messages, bus, telegramCfg.AllowFrom)
|
||||||
|
|
||||||
return &TelegramChannel{
|
return &TelegramChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ type WhatsAppChannel struct {
|
||||||
connected bool
|
connected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
|
func NewWhatsAppChannel(cfg config.WhatsAppConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
|
||||||
base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom)
|
base := NewBaseChannel("whatsapp", cfg, messagesCfg, bus, cfg.AllowFrom)
|
||||||
|
|
||||||
return &WhatsAppChannel{
|
return &WhatsAppChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ type Config struct {
|
||||||
Tools ToolsConfig `json:"tools"`
|
Tools ToolsConfig `json:"tools"`
|
||||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||||
Devices DevicesConfig `json:"devices"`
|
Devices DevicesConfig `json:"devices"`
|
||||||
|
Messages MessagesConfig `json:"messages"`
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -166,6 +167,16 @@ type DevicesConfig struct {
|
||||||
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MessagesConfig controls global message behavior for all channels
|
||||||
|
type MessagesConfig struct {
|
||||||
|
// AckReaction is the emoji used to acknowledge inbound messages (empty to disable)
|
||||||
|
AckReaction string `json:"ack_reaction" env:"PICOCLAW_MESSAGES_ACK_REACTION"`
|
||||||
|
// AckReactionScope controls when to send ack reactions: "all", "direct", "group-all", "group-mentions", "off"
|
||||||
|
AckReactionScope string `json:"ack_reaction_scope" env:"PICOCLAW_MESSAGES_ACK_REACTION_SCOPE"`
|
||||||
|
// RemoveAckAfterReply removes the ack reaction after reply is sent
|
||||||
|
RemoveAckAfterReply bool `json:"remove_ack_after_reply" env:"PICOCLAW_MESSAGES_REMOVE_ACK_AFTER_REPLY"`
|
||||||
|
}
|
||||||
|
|
||||||
type ProvidersConfig struct {
|
type ProvidersConfig struct {
|
||||||
Anthropic ProviderConfig `json:"anthropic"`
|
Anthropic ProviderConfig `json:"anthropic"`
|
||||||
OpenAI ProviderConfig `json:"openai"`
|
OpenAI ProviderConfig `json:"openai"`
|
||||||
|
|
@ -331,6 +342,11 @@ func DefaultConfig() *Config {
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
MonitorUSB: true,
|
MonitorUSB: true,
|
||||||
},
|
},
|
||||||
|
Messages: MessagesConfig{
|
||||||
|
AckReaction: "OK",
|
||||||
|
AckReactionScope: "group-mentions",
|
||||||
|
RemoveAckAfterReply: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue