diff --git a/config/config.example.json b/config/config.example.json index 3c9158e9c..050195829 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -129,6 +129,11 @@ "enabled": false, "monitor_usb": true }, + "messages": { + "ack_reaction": "OK", + "ack_reaction_scope": "group-mentions", + "remove_ack_after_reply": false + }, "gateway": { "host": "0.0.0.0", "port": 18790 diff --git a/pkg/channels/ack_reactions.go b/pkg/channels/ack_reactions.go new file mode 100644 index 000000000..8202c002b --- /dev/null +++ b/pkg/channels/ack_reactions.go @@ -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 != "" +} diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 8d2d9a65b..9d81aa830 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" ) type Channel interface { @@ -18,23 +19,30 @@ type Channel interface { } type BaseChannel struct { - config interface{} - bus *bus.MessageBus - running bool - name string - allowList []string + config interface{} + messagesConfig config.MessagesConfig + bus *bus.MessageBus + running bool + 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{ - config: config, - bus: bus, - name: name, - allowList: allowList, - running: false, + config: config, + messagesConfig: messagesCfg, + bus: bus, + name: name, + allowList: allowList, + running: false, } } +// MessagesConfig returns the global messages configuration +func (c *BaseChannel) MessagesConfig() config.MessagesConfig { + return c.messagesConfig +} + func (c *BaseChannel) Name() string { return c.name } diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go index 78c6d1d66..579526cc6 100644 --- a/pkg/channels/base_test.go +++ b/pkg/channels/base_test.go @@ -1,6 +1,10 @@ package channels -import "testing" +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) func TestBaseChannelIsAllowed(t *testing.T) { tests := []struct { @@ -43,7 +47,7 @@ func TestBaseChannelIsAllowed(t *testing.T) { for _, tt := range tests { 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 { t.Fatalf("IsAllowed(%q) = %v, want %v", tt.senderID, got, tt.want) } diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go index 263785c0c..18201866c 100644 --- a/pkg/channels/dingtalk.go +++ b/pkg/channels/dingtalk.go @@ -31,12 +31,12 @@ type DingTalkChannel struct { } // 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 == "" { 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{ BaseChannel: base, diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go index 00aa8ab4d..5efe5caf4 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -28,13 +28,13 @@ type DiscordChannel struct { 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) if err != nil { 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{ BaseChannel: base, diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu_32.go index 4e60fbc11..b243cd628 100644 --- a/pkg/channels/feishu_32.go +++ b/pkg/channels/feishu_32.go @@ -16,7 +16,7 @@ type FeishuChannel struct { } // 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") } diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go index 39dc40ac1..88c2e665d 100644 --- a/pkg/channels/feishu_64.go +++ b/pkg/channels/feishu_64.go @@ -30,8 +30,8 @@ type FeishuChannel struct { cancel context.CancelFunc } -func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { - base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom) +func NewFeishuChannel(cfg config.FeishuConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*FeishuChannel, error) { + base := NewBaseChannel("feishu", cfg, messagesCfg, bus, cfg.AllowFrom) return &FeishuChannel{ BaseChannel: base, @@ -128,7 +128,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error 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 { return nil } @@ -151,14 +151,26 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 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{} - if messageID := stringValue(message.MessageId); messageID != "" { + messageID := stringValue(message.MessageId) + if messageID != "" { metadata["message_id"] = messageID } if messageType := stringValue(message.MessageType); messageType != "" { metadata["message_type"] = messageType } - if chatType := stringValue(message.ChatType); chatType != "" { + if chatType != "" { metadata["chat_type"] = chatType } if sender != nil && sender.TenantKey != nil { @@ -171,6 +183,30 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 "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) return nil } @@ -216,3 +252,30 @@ func stringValue(v *string) string { } 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 +} diff --git a/pkg/channels/line.go b/pkg/channels/line.go index ffb5533e8..aa4168baa 100644 --- a/pkg/channels/line.go +++ b/pkg/channels/line.go @@ -54,12 +54,12 @@ type LINEChannel struct { } // 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 == "" { 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{ BaseChannel: base, diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go index 5fc19adbe..e1fd19e10 100644 --- a/pkg/channels/maixcam.go +++ b/pkg/channels/maixcam.go @@ -28,8 +28,8 @@ type MaixCamMessage struct { Data map[string]interface{} `json:"data"` } -func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { - base := NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom) +func NewMaixCamChannel(cfg config.MaixCamConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { + base := NewBaseChannel("maixcam", cfg, messagesCfg, bus, cfg.AllowFrom) return &MaixCamChannel{ BaseChannel: base, diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 7f6abc4cb..f2a5dd2df 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -61,7 +61,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" { 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 { logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{ "error": err.Error(), @@ -74,7 +74,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.Feishu.Enabled { 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 { logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{ "error": err.Error(), @@ -87,7 +87,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" { 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 { logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{ "error": err.Error(), @@ -100,7 +100,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.MaixCam.Enabled { 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 { logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{ "error": err.Error(), @@ -113,7 +113,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.QQ.Enabled { 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 { logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{ "error": err.Error(), @@ -126,7 +126,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.DingTalk.Enabled && m.config.Channels.DingTalk.ClientID != "" { 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 { logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{ "error": err.Error(), @@ -139,7 +139,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.Slack.Enabled && m.config.Channels.Slack.BotToken != "" { 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 { logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{ "error": err.Error(), @@ -152,7 +152,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" { 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 { logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{ "error": err.Error(), @@ -165,7 +165,7 @@ func (m *Manager) initChannels() error { if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" { 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 { logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{ "error": err.Error(), diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index 5d97fab9c..7cdcfe797 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -91,8 +91,8 @@ type oneBotSendGroupMsgParams struct { Message string `json:"message"` } -func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { - base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom) +func NewOneBotChannel(cfg config.OneBotConfig, messagesCfg config.MessagesConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { + base := NewBaseChannel("onebot", cfg, messagesCfg, messageBus, cfg.AllowFrom) const dedupSize = 1024 return &OneBotChannel{ diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go index 18b4ca0e0..62a7641c6 100644 --- a/pkg/channels/qq.go +++ b/pkg/channels/qq.go @@ -30,8 +30,8 @@ type QQChannel struct { mu sync.RWMutex } -func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { - base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom) +func NewQQChannel(cfg config.QQConfig, messagesCfg config.MessagesConfig, messageBus *bus.MessageBus) (*QQChannel, error) { + base := NewBaseChannel("qq", cfg, messagesCfg, messageBus, cfg.AllowFrom) return &QQChannel{ BaseChannel: base, diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index 5387e9213..5dd211f3b 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -36,7 +36,7 @@ type slackMessageRef struct { 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 == "" { 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) - base := NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom) + base := NewBaseChannel("slack", cfg, messagesCfg, messageBus, cfg.AllowFrom) return &SlackChannel{ BaseChannel: base, diff --git a/pkg/channels/slack_test.go b/pkg/channels/slack_test.go index 3707c2703..153f54f7d 100644 --- a/pkg/channels/slack_test.go +++ b/pkg/channels/slack_test.go @@ -106,7 +106,7 @@ func TestNewSlackChannel(t *testing.T) { BotToken: "", AppToken: "xapp-test", } - _, err := NewSlackChannel(cfg, msgBus) + _, err := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus) if err == nil { t.Error("expected error for missing bot_token, got nil") } @@ -117,7 +117,7 @@ func TestNewSlackChannel(t *testing.T) { BotToken: "xoxb-test", AppToken: "", } - _, err := NewSlackChannel(cfg, msgBus) + _, err := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus) if err == nil { t.Error("expected error for missing app_token, got nil") } @@ -129,7 +129,7 @@ func TestNewSlackChannel(t *testing.T) { AppToken: "xapp-test", AllowFrom: []string{"U123"}, } - ch, err := NewSlackChannel(cfg, msgBus) + ch, err := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -151,7 +151,7 @@ func TestSlackChannelIsAllowed(t *testing.T) { AppToken: "xapp-test", AllowFrom: []string{}, } - ch, _ := NewSlackChannel(cfg, msgBus) + ch, _ := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus) if !ch.IsAllowed("U_ANYONE") { t.Error("empty allowlist should allow all users") } @@ -163,7 +163,7 @@ func TestSlackChannelIsAllowed(t *testing.T) { AppToken: "xapp-test", AllowFrom: []string{"U_ALLOWED"}, } - ch, _ := NewSlackChannel(cfg, msgBus) + ch, _ := NewSlackChannel(cfg, config.MessagesConfig{}, msgBus) if !ch.IsAllowed("U_ALLOWED") { t.Error("allowed user should pass allowlist check") } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 5601d508c..307fc3a31 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -66,7 +66,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann 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{ BaseChannel: base, diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go index c95e59578..90bf53bcf 100644 --- a/pkg/channels/whatsapp.go +++ b/pkg/channels/whatsapp.go @@ -24,8 +24,8 @@ type WhatsAppChannel struct { connected bool } -func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { - base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) +func NewWhatsAppChannel(cfg config.WhatsAppConfig, messagesCfg config.MessagesConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { + base := NewBaseChannel("whatsapp", cfg, messagesCfg, bus, cfg.AllowFrom) return &WhatsAppChannel{ BaseChannel: base, diff --git a/pkg/config/config.go b/pkg/config/config.go index d189ff00b..735e933c1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -51,6 +51,7 @@ type Config struct { Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` + Messages MessagesConfig `json:"messages"` mu sync.RWMutex } @@ -166,6 +167,16 @@ type DevicesConfig struct { 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 { Anthropic ProviderConfig `json:"anthropic"` OpenAI ProviderConfig `json:"openai"` @@ -331,6 +342,11 @@ func DefaultConfig() *Config { Enabled: false, MonitorUSB: true, }, + Messages: MessagesConfig{ + AckReaction: "OK", + AckReactionScope: "group-mentions", + RemoveAckAfterReply: false, + }, } }