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:
zhangweiii 2026-02-17 14:44:09 +08:00
parent 57dac394c5
commit 093d24f9ab
18 changed files with 264 additions and 50 deletions

View file

@ -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

View 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 != ""
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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,

View file

@ -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,

View file

@ -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")
}

View file

@ -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
}

View file

@ -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,

View file

@ -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,

View file

@ -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(),

View file

@ -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{

View file

@ -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,

View file

@ -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,

View file

@ -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")
}

View file

@ -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,

View file

@ -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,

View file

@ -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,
},
}
}