From dfcf15bfff98bb1779b1e59a501ad39c962888ca Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 20 Feb 2026 23:18:46 +0800 Subject: [PATCH 001/104] refactor(channels): add factory registry and export SetRunning on BaseChannel --- pkg/channels/base.go | 4 ++++ pkg/channels/registry.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+) create mode 100644 pkg/channels/registry.go diff --git a/pkg/channels/base.go b/pkg/channels/base.go index cd6419ebb..3f0a766ea 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -101,3 +101,7 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st func (c *BaseChannel) setRunning(running bool) { c.running = running } + +func (c *BaseChannel) SetRunning(running bool) { + c.running = running +} diff --git a/pkg/channels/registry.go b/pkg/channels/registry.go new file mode 100644 index 000000000..36a05bf3e --- /dev/null +++ b/pkg/channels/registry.go @@ -0,0 +1,32 @@ +package channels + +import ( + "sync" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// ChannelFactory is a constructor function that creates a Channel from config and message bus. +// Each channel subpackage registers one or more factories via init(). +type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) + +var ( + factoriesMu sync.RWMutex + factories = map[string]ChannelFactory{} +) + +// RegisterFactory registers a named channel factory. Called from subpackage init() functions. +func RegisterFactory(name string, f ChannelFactory) { + factoriesMu.Lock() + defer factoriesMu.Unlock() + factories[name] = f +} + +// getFactory looks up a channel factory by name. +func getFactory(name string) (ChannelFactory, bool) { + factoriesMu.RLock() + defer factoriesMu.RUnlock() + f, ok := factories[name] + return f, ok +} From 083e29ebd94fec3efacc238bc152595f436ea2dc Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 20 Feb 2026 23:19:40 +0800 Subject: [PATCH 002/104] refactor(channels): replace direct constructors with factory registry in manager --- pkg/channels/manager.go | 178 +++++++++++----------------------------- 1 file changed, 48 insertions(+), 130 deletions(-) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 75edaf49e..091982282 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -43,166 +43,84 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error return m, nil } +// initChannel is a helper that looks up a factory by name and creates the channel. +func (m *Manager) initChannel(name, displayName string) { + f, ok := getFactory(name) + if !ok { + logger.WarnCF("channels", "Factory not registered", map[string]interface{}{ + "channel": displayName, + }) + return + } + logger.DebugCF("channels", "Attempting to initialize channel", map[string]interface{}{ + "channel": displayName, + }) + ch, err := f(m.config, m.bus) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize channel", map[string]interface{}{ + "channel": displayName, + "error": err.Error(), + }) + } else { + m.channels[name] = ch + logger.InfoCF("channels", "Channel enabled successfully", map[string]interface{}{ + "channel": displayName, + }) + } +} + func (m *Manager) initChannels() error { logger.InfoC("channels", "Initializing channel manager") if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" { - logger.DebugC("channels", "Attempting to initialize Telegram channel") - telegram, err := NewTelegramChannel(m.config, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["telegram"] = telegram - logger.InfoC("channels", "Telegram channel enabled successfully") - } + m.initChannel("telegram", "Telegram") } 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) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["whatsapp"] = whatsapp - logger.InfoC("channels", "WhatsApp channel enabled successfully") - } + m.initChannel("whatsapp", "WhatsApp") } if m.config.Channels.Feishu.Enabled { - logger.DebugC("channels", "Attempting to initialize Feishu channel") - feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["feishu"] = feishu - logger.InfoC("channels", "Feishu channel enabled successfully") - } + m.initChannel("feishu", "Feishu") } 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) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["discord"] = discord - logger.InfoC("channels", "Discord channel enabled successfully") - } + m.initChannel("discord", "Discord") } if m.config.Channels.MaixCam.Enabled { - logger.DebugC("channels", "Attempting to initialize MaixCam channel") - maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["maixcam"] = maixcam - logger.InfoC("channels", "MaixCam channel enabled successfully") - } + m.initChannel("maixcam", "MaixCam") } if m.config.Channels.QQ.Enabled { - logger.DebugC("channels", "Attempting to initialize QQ channel") - qq, err := NewQQChannel(m.config.Channels.QQ, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["qq"] = qq - logger.InfoC("channels", "QQ channel enabled successfully") - } + m.initChannel("qq", "QQ") } 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) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["dingtalk"] = dingtalk - logger.InfoC("channels", "DingTalk channel enabled successfully") - } + m.initChannel("dingtalk", "DingTalk") } 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) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["slack"] = slackCh - logger.InfoC("channels", "Slack channel enabled successfully") - } + m.initChannel("slack", "Slack") } 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) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["line"] = line - logger.InfoC("channels", "LINE channel enabled successfully") - } + m.initChannel("line", "LINE") } 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) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["onebot"] = onebot - logger.InfoC("channels", "OneBot channel enabled successfully") - } + m.initChannel("onebot", "OneBot") } if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" { - logger.DebugC("channels", "Attempting to initialize WeCom channel") - wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["wecom"] = wecom - logger.InfoC("channels", "WeCom channel enabled successfully") - } + m.initChannel("wecom", "WeCom") } if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" { - logger.DebugC("channels", "Attempting to initialize WeCom App channel") - wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["wecom_app"] = wecomApp - logger.InfoC("channels", "WeCom App channel enabled successfully") - } + m.initChannel("wecom_app", "WeCom App") } - logger.InfoCF("channels", "Channel initialization completed", map[string]any{ + logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{ "enabled_channels": len(m.channels), }) @@ -226,11 +144,11 @@ func (m *Manager) StartAll(ctx context.Context) error { go m.dispatchOutbound(dispatchCtx) for name, channel := range m.channels { - logger.InfoCF("channels", "Starting channel", map[string]any{ + logger.InfoCF("channels", "Starting channel", map[string]interface{}{ "channel": name, }) if err := channel.Start(ctx); err != nil { - logger.ErrorCF("channels", "Failed to start channel", map[string]any{ + logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{ "channel": name, "error": err.Error(), }) @@ -253,11 +171,11 @@ func (m *Manager) StopAll(ctx context.Context) error { } for name, channel := range m.channels { - logger.InfoCF("channels", "Stopping channel", map[string]any{ + logger.InfoCF("channels", "Stopping channel", map[string]interface{}{ "channel": name, }) if err := channel.Stop(ctx); err != nil { - logger.ErrorCF("channels", "Error stopping channel", map[string]any{ + logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{ "channel": name, "error": err.Error(), }) @@ -292,14 +210,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { m.mu.RUnlock() if !exists { - logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{ + logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{ "channel": msg.Channel, }) continue } if err := channel.Send(ctx, msg); err != nil { - logger.ErrorCF("channels", "Error sending message to channel", map[string]any{ + logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{ "channel": msg.Channel, "error": err.Error(), }) @@ -315,13 +233,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) { return channel, ok } -func (m *Manager) GetStatus() map[string]any { +func (m *Manager) GetStatus() map[string]interface{} { m.mu.RLock() defer m.mu.RUnlock() - status := make(map[string]any) + status := make(map[string]interface{}) for name, channel := range m.channels { - status[name] = map[string]any{ + status[name] = map[string]interface{}{ "enabled": true, "running": channel.IsRunning(), } From 6122ab664b6171fd1250c63865143d44089ac85b Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 20 Feb 2026 23:25:44 +0800 Subject: [PATCH 003/104] refactor(channels): add channel subpackages and update gateway imports --- cmd/picoclaw/internal/gateway/helpers.go | 19 +- pkg/channels/dingtalk/dingtalk.go | 202 ++++ pkg/channels/dingtalk/init.go | 13 + pkg/channels/discord/discord.go | 373 +++++++ pkg/channels/discord/init.go | 13 + pkg/channels/feishu/common.go | 9 + pkg/channels/feishu/feishu_32.go | 37 + pkg/channels/feishu/feishu_64.go | 221 ++++ pkg/channels/feishu/init.go | 13 + pkg/channels/line/init.go | 13 + pkg/channels/line/line.go | 607 +++++++++++ pkg/channels/maixcam/init.go | 13 + pkg/channels/maixcam/maixcam.go | 244 +++++ pkg/channels/onebot/init.go | 13 + pkg/channels/onebot/onebot.go | 980 ++++++++++++++++++ pkg/channels/qq/init.go | 13 + pkg/channels/qq/qq.go | 248 +++++ pkg/channels/slack/init.go | 13 + pkg/channels/slack/slack.go | 444 ++++++++ pkg/channels/slack/slack_test.go | 174 ++++ pkg/channels/telegram/init.go | 13 + pkg/channels/telegram/telegram.go | 526 ++++++++++ pkg/channels/telegram/telegram_commands.go | 153 +++ pkg/channels/wecom/app.go | 636 ++++++++++++ pkg/channels/wecom/app_test.go | 1086 ++++++++++++++++++++ pkg/channels/wecom/bot.go | 469 +++++++++ pkg/channels/wecom/bot_test.go | 753 ++++++++++++++ pkg/channels/wecom/common.go | 134 +++ pkg/channels/wecom/init.go | 16 + pkg/channels/whatsapp/init.go | 13 + pkg/channels/whatsapp/whatsapp.go | 193 ++++ 31 files changed, 7651 insertions(+), 3 deletions(-) create mode 100644 pkg/channels/dingtalk/dingtalk.go create mode 100644 pkg/channels/dingtalk/init.go create mode 100644 pkg/channels/discord/discord.go create mode 100644 pkg/channels/discord/init.go create mode 100644 pkg/channels/feishu/common.go create mode 100644 pkg/channels/feishu/feishu_32.go create mode 100644 pkg/channels/feishu/feishu_64.go create mode 100644 pkg/channels/feishu/init.go create mode 100644 pkg/channels/line/init.go create mode 100644 pkg/channels/line/line.go create mode 100644 pkg/channels/maixcam/init.go create mode 100644 pkg/channels/maixcam/maixcam.go create mode 100644 pkg/channels/onebot/init.go create mode 100644 pkg/channels/onebot/onebot.go create mode 100644 pkg/channels/qq/init.go create mode 100644 pkg/channels/qq/qq.go create mode 100644 pkg/channels/slack/init.go create mode 100644 pkg/channels/slack/slack.go create mode 100644 pkg/channels/slack/slack_test.go create mode 100644 pkg/channels/telegram/init.go create mode 100644 pkg/channels/telegram/telegram.go create mode 100644 pkg/channels/telegram/telegram_commands.go create mode 100644 pkg/channels/wecom/app.go create mode 100644 pkg/channels/wecom/app_test.go create mode 100644 pkg/channels/wecom/bot.go create mode 100644 pkg/channels/wecom/bot_test.go create mode 100644 pkg/channels/wecom/common.go create mode 100644 pkg/channels/wecom/init.go create mode 100644 pkg/channels/whatsapp/init.go create mode 100644 pkg/channels/whatsapp/whatsapp.go diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index a06625dc9..98262d5ae 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -15,6 +15,9 @@ import ( "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + dch "github.com/sipeed/picoclaw/pkg/channels/discord" + slackch "github.com/sipeed/picoclaw/pkg/channels/slack" + tgram "github.com/sipeed/picoclaw/pkg/channels/telegram" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" @@ -25,6 +28,16 @@ import ( "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/voice" + + // Channel factory registrations (blank imports trigger init()) + _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" + _ "github.com/sipeed/picoclaw/pkg/channels/feishu" + _ "github.com/sipeed/picoclaw/pkg/channels/line" + _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" + _ "github.com/sipeed/picoclaw/pkg/channels/onebot" + _ "github.com/sipeed/picoclaw/pkg/channels/qq" + _ "github.com/sipeed/picoclaw/pkg/channels/wecom" + _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" ) func gatewayCmd(debug bool) error { @@ -130,19 +143,19 @@ func gatewayCmd(debug bool) error { if transcriber != nil { if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { - if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { + if tc, ok := telegramChannel.(*tgram.TelegramChannel); ok { tc.SetTranscriber(transcriber) logger.InfoC("voice", "Groq transcription attached to Telegram channel") } } if discordChannel, ok := channelManager.GetChannel("discord"); ok { - if dc, ok := discordChannel.(*channels.DiscordChannel); ok { + if dc, ok := discordChannel.(*dch.DiscordChannel); ok { dc.SetTranscriber(transcriber) logger.InfoC("voice", "Groq transcription attached to Discord channel") } } if slackChannel, ok := channelManager.GetChannel("slack"); ok { - if sc, ok := slackChannel.(*channels.SlackChannel); ok { + if sc, ok := slackChannel.(*slackch.SlackChannel); ok { sc.SetTranscriber(transcriber) logger.InfoC("voice", "Groq transcription attached to Slack channel") } diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go new file mode 100644 index 000000000..0edb0023c --- /dev/null +++ b/pkg/channels/dingtalk/dingtalk.go @@ -0,0 +1,202 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// DingTalk channel implementation using Stream Mode + +package dingtalk + +import ( + "context" + "fmt" + "sync" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + "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" +) + +// DingTalkChannel implements the Channel interface for DingTalk (钉钉) +// It uses WebSocket for receiving messages via stream mode and API for sending +type DingTalkChannel struct { + *channels.BaseChannel + config config.DingTalkConfig + clientID string + clientSecret string + streamClient *client.StreamClient + ctx context.Context + cancel context.CancelFunc + // Map to store session webhooks for each chat + sessionWebhooks sync.Map // chatID -> sessionWebhook +} + +// NewDingTalkChannel creates a new DingTalk channel instance +func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (*DingTalkChannel, error) { + if cfg.ClientID == "" || cfg.ClientSecret == "" { + return nil, fmt.Errorf("dingtalk client_id and client_secret are required") + } + + base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom) + + return &DingTalkChannel{ + BaseChannel: base, + config: cfg, + clientID: cfg.ClientID, + clientSecret: cfg.ClientSecret, + }, nil +} + +// Start initializes the DingTalk channel with Stream Mode +func (c *DingTalkChannel) Start(ctx context.Context) error { + logger.InfoC("dingtalk", "Starting DingTalk channel (Stream Mode)...") + + c.ctx, c.cancel = context.WithCancel(ctx) + + // Create credential config + cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret) + + // Create the stream client with options + c.streamClient = client.NewStreamClient( + client.WithAppCredential(cred), + client.WithAutoReconnect(true), + ) + + // Register chatbot callback handler (IChatBotMessageHandler is a function type) + c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived) + + // Start the stream client + if err := c.streamClient.Start(c.ctx); err != nil { + return fmt.Errorf("failed to start stream client: %w", err) + } + + c.SetRunning(true) + logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)") + return nil +} + +// Stop gracefully stops the DingTalk channel +func (c *DingTalkChannel) Stop(ctx context.Context) error { + logger.InfoC("dingtalk", "Stopping DingTalk channel...") + + if c.cancel != nil { + c.cancel() + } + + if c.streamClient != nil { + c.streamClient.Close() + } + + c.SetRunning(false) + logger.InfoC("dingtalk", "DingTalk channel stopped") + return nil +} + +// Send sends a message to DingTalk via the chatbot reply API +func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("dingtalk channel not running") + } + + // Get session webhook from storage + sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) + if !ok { + return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) + } + + sessionWebhook, ok := sessionWebhookRaw.(string) + if !ok { + return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) + } + + logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{ + "chat_id": msg.ChatID, + "preview": utils.Truncate(msg.Content, 100), + }) + + // Use the session webhook to send the reply + return c.SendDirectReply(ctx, sessionWebhook, msg.Content) +} + +// onChatBotMessageReceived implements the IChatBotMessageHandler function signature +// This is called by the Stream SDK when a new message arrives +// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) +func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { + // Extract message content from Text field + content := data.Text.Content + if content == "" { + // Try to extract from Content interface{} if Text is empty + if contentMap, ok := data.Content.(map[string]interface{}); ok { + if textContent, ok := contentMap["content"].(string); ok { + content = textContent + } + } + } + + if content == "" { + return nil, nil // Ignore empty messages + } + + senderID := data.SenderStaffId + senderNick := data.SenderNick + chatID := senderID + if data.ConversationType != "1" { + // For group chats + chatID = data.ConversationId + } + + // Store the session webhook for this chat so we can reply later + c.sessionWebhooks.Store(chatID, data.SessionWebhook) + + metadata := map[string]string{ + "sender_name": senderNick, + "conversation_id": data.ConversationId, + "conversation_type": data.ConversationType, + "platform": "dingtalk", + "session_webhook": data.SessionWebhook, + } + + if data.ConversationType == "1" { + metadata["peer_kind"] = "direct" + metadata["peer_id"] = senderID + } else { + metadata["peer_kind"] = "group" + metadata["peer_id"] = data.ConversationId + } + + logger.DebugCF("dingtalk", "Received message", map[string]interface{}{ + "sender_nick": senderNick, + "sender_id": senderID, + "preview": utils.Truncate(content, 50), + }) + + // Handle the message through the base channel + c.HandleMessage(senderID, chatID, content, nil, metadata) + + // Return nil to indicate we've handled the message asynchronously + // The response will be sent through the message bus + return nil, nil +} + +// SendDirectReply sends a direct reply using the session webhook +func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, content string) error { + replier := chatbot.NewChatbotReplier() + + // Convert string content to []byte for the API + contentBytes := []byte(content) + titleBytes := []byte("PicoClaw") + + // Send markdown formatted reply + err := replier.SimpleReplyMarkdown( + ctx, + sessionWebhook, + titleBytes, + contentBytes, + ) + + if err != nil { + return fmt.Errorf("failed to send reply: %w", err) + } + + return nil +} diff --git a/pkg/channels/dingtalk/init.go b/pkg/channels/dingtalk/init.go new file mode 100644 index 000000000..5f49bce8c --- /dev/null +++ b/pkg/channels/dingtalk/init.go @@ -0,0 +1,13 @@ +package dingtalk + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("dingtalk", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewDingTalkChannel(cfg.Channels.DingTalk, b) + }) +} diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go new file mode 100644 index 000000000..6c4efd87c --- /dev/null +++ b/pkg/channels/discord/discord.go @@ -0,0 +1,373 @@ +package discord + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/bwmarrin/discordgo" + "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" + "github.com/sipeed/picoclaw/pkg/voice" +) + +const ( + transcriptionTimeout = 30 * time.Second + sendTimeout = 10 * time.Second +) + +type DiscordChannel struct { + *channels.BaseChannel + session *discordgo.Session + config config.DiscordConfig + transcriber *voice.GroqTranscriber + ctx context.Context + typingMu sync.Mutex + typingStop map[string]chan struct{} // chatID → stop signal + botUserID string // stored for mention checking +} + +func NewDiscordChannel(cfg config.DiscordConfig, 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 := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom) + + return &DiscordChannel{ + BaseChannel: base, + session: session, + config: cfg, + transcriber: nil, + ctx: context.Background(), + typingStop: make(map[string]chan struct{}), + }, nil +} + +func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { + c.transcriber = transcriber +} + +func (c *DiscordChannel) getContext() context.Context { + if c.ctx == nil { + return context.Background() + } + return c.ctx +} + +func (c *DiscordChannel) Start(ctx context.Context) error { + logger.InfoC("discord", "Starting Discord bot") + + c.ctx = ctx + + // Get bot user ID before opening session to avoid race condition + botUser, err := c.session.User("@me") + if err != nil { + return fmt.Errorf("failed to get bot user: %w", err) + } + c.botUserID = botUser.ID + + c.session.AddHandler(c.handleMessage) + + if err := c.session.Open(); err != nil { + return fmt.Errorf("failed to open discord session: %w", err) + } + + c.SetRunning(true) + + logger.InfoCF("discord", "Discord bot connected", map[string]any{ + "username": botUser.Username, + "user_id": botUser.ID, + }) + + return nil +} + +func (c *DiscordChannel) Stop(ctx context.Context) error { + logger.InfoC("discord", "Stopping Discord bot") + c.SetRunning(false) + + // Stop all typing goroutines before closing session + c.typingMu.Lock() + for chatID, stop := range c.typingStop { + close(stop) + delete(c.typingStop, chatID) + } + c.typingMu.Unlock() + + if err := c.session.Close(); err != nil { + return fmt.Errorf("failed to close discord session: %w", err) + } + + return nil +} + +func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + c.stopTyping(msg.ChatID) + + if !c.IsRunning() { + return fmt.Errorf("discord bot not running") + } + + channelID := msg.ChatID + if channelID == "" { + return fmt.Errorf("channel ID is empty") + } + + runes := []rune(msg.Content) + if len(runes) == 0 { + return nil + } + + chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars + + for _, chunk := range chunks { + if err := c.sendChunk(ctx, channelID, chunk); err != nil { + return err + } + } + + return nil +} + +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { + // Use the passed ctx for timeout control + sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := c.session.ChannelMessageSend(channelID, content) + done <- err + }() + + select { + case err := <-done: + if err != nil { + return fmt.Errorf("failed to send discord message: %w", err) + } + return nil + case <-sendCtx.Done(): + return fmt.Errorf("send message timeout: %w", sendCtx.Err()) + } +} + +// appendContent safely appends content to existing text +func appendContent(content, suffix string) string { + if content == "" { + return suffix + } + return content + "\n" + suffix +} + +func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) { + if m == nil || m.Author == nil { + return + } + + if m.Author.ID == s.State.User.ID { + return + } + + // Check allowlist first to avoid downloading attachments and transcribing for rejected users + if !c.IsAllowed(m.Author.ID) { + logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ + "user_id": m.Author.ID, + }) + return + } + + // If configured to only respond to mentions, check if bot is mentioned + // Skip this check for DMs (GuildID is empty) - DMs should always be responded to + if c.config.MentionOnly && m.GuildID != "" { + isMentioned := false + for _, mention := range m.Mentions { + if mention.ID == c.botUserID { + isMentioned = true + break + } + } + if !isMentioned { + logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{ + "user_id": m.Author.ID, + }) + return + } + } + + senderID := m.Author.ID + senderName := m.Author.Username + if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { + senderName += "#" + m.Author.Discriminator + } + + content := m.Content + content = c.stripBotMention(content) + mediaPaths := make([]string, 0, len(m.Attachments)) + localFiles := make([]string, 0, len(m.Attachments)) + + // Ensure temp files are cleaned up when function returns + defer func() { + for _, file := range localFiles { + if err := os.Remove(file); err != nil { + logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{ + "file": file, + "error": err.Error(), + }) + } + } + }() + + for _, attachment := range m.Attachments { + isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType) + + if isAudio { + localPath := c.downloadAttachment(attachment.URL, attachment.Filename) + if localPath != "" { + localFiles = append(localFiles, localPath) + + transcribedText := "" + if c.transcriber != nil && c.transcriber.IsAvailable() { + ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout) + result, err := c.transcriber.Transcribe(ctx, localPath) + cancel() // Release context resources immediately to avoid leaks in for loop + + if err != nil { + logger.ErrorCF("discord", "Voice transcription failed", map[string]any{ + "error": err.Error(), + }) + transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename) + } else { + transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text) + logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{ + "text": result.Text, + }) + } + } else { + transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename) + } + + content = appendContent(content, transcribedText) + } else { + logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{ + "url": attachment.URL, + "filename": attachment.Filename, + }) + mediaPaths = append(mediaPaths, attachment.URL) + content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) + } + } else { + mediaPaths = append(mediaPaths, attachment.URL) + content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) + } + } + + if content == "" && len(mediaPaths) == 0 { + return + } + + if content == "" { + content = "[media only]" + } + + // Start typing after all early returns — guaranteed to have a matching Send() + c.startTyping(m.ChannelID) + + logger.DebugCF("discord", "Received message", map[string]any{ + "sender_name": senderName, + "sender_id": senderID, + "preview": utils.Truncate(content, 50), + }) + + peerKind := "channel" + peerID := m.ChannelID + if m.GuildID == "" { + peerKind = "direct" + peerID = senderID + } + + metadata := map[string]string{ + "message_id": m.ID, + "user_id": senderID, + "username": m.Author.Username, + "display_name": senderName, + "guild_id": m.GuildID, + "channel_id": m.ChannelID, + "is_dm": fmt.Sprintf("%t", m.GuildID == ""), + "peer_kind": peerKind, + "peer_id": peerID, + } + + c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata) +} + +// startTyping starts a continuous typing indicator loop for the given chatID. +// It stops any existing typing loop for that chatID before starting a new one. +func (c *DiscordChannel) startTyping(chatID string) { + c.typingMu.Lock() + // Stop existing loop for this chatID if any + if stop, ok := c.typingStop[chatID]; ok { + close(stop) + } + stop := make(chan struct{}) + c.typingStop[chatID] = stop + c.typingMu.Unlock() + + go func() { + if err := c.session.ChannelTyping(chatID); err != nil { + logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err}) + } + ticker := time.NewTicker(8 * time.Second) + defer ticker.Stop() + timeout := time.After(5 * time.Minute) + for { + select { + case <-stop: + return + case <-timeout: + return + case <-c.ctx.Done(): + return + case <-ticker.C: + if err := c.session.ChannelTyping(chatID); err != nil { + logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err}) + } + } + } + }() +} + +// stopTyping stops the typing indicator loop for the given chatID. +func (c *DiscordChannel) stopTyping(chatID string) { + c.typingMu.Lock() + defer c.typingMu.Unlock() + if stop, ok := c.typingStop[chatID]; ok { + close(stop) + delete(c.typingStop, chatID) + } +} + +func (c *DiscordChannel) downloadAttachment(url, filename string) string { + return utils.DownloadFile(url, filename, utils.DownloadOptions{ + LoggerPrefix: "discord", + }) +} + +// stripBotMention removes the bot mention from the message content. +// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname). +func (c *DiscordChannel) stripBotMention(text string) string { + if c.botUserID == "" { + return text + } + // Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID> + text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "") + text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "") + return strings.TrimSpace(text) +} diff --git a/pkg/channels/discord/init.go b/pkg/channels/discord/init.go new file mode 100644 index 000000000..15a539804 --- /dev/null +++ b/pkg/channels/discord/init.go @@ -0,0 +1,13 @@ +package discord + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("discord", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewDiscordChannel(cfg.Channels.Discord, b) + }) +} diff --git a/pkg/channels/feishu/common.go b/pkg/channels/feishu/common.go new file mode 100644 index 000000000..e8a057741 --- /dev/null +++ b/pkg/channels/feishu/common.go @@ -0,0 +1,9 @@ +package feishu + +// stringValue safely dereferences a *string pointer. +func stringValue(v *string) string { + if v == nil { + return "" + } + return *v +} diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go new file mode 100644 index 000000000..14711e49e --- /dev/null +++ b/pkg/channels/feishu/feishu_32.go @@ -0,0 +1,37 @@ +//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 + +package feishu + +import ( + "context" + "errors" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +// FeishuChannel is a stub implementation for 32-bit architectures +type FeishuChannel struct { + *channels.BaseChannel +} + +// 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) { + 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") +} + +// Start is a stub method to satisfy the Channel interface +func (c *FeishuChannel) Start(ctx context.Context) error { + return nil +} + +// Stop is a stub method to satisfy the Channel interface +func (c *FeishuChannel) Stop(ctx context.Context) error { + return nil +} + +// Send is a stub method to satisfy the Channel interface +func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + return errors.New("feishu channel is not supported on 32-bit architectures") +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go new file mode 100644 index 000000000..a49ee34cb --- /dev/null +++ b/pkg/channels/feishu/feishu_64.go @@ -0,0 +1,221 @@ +//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) + + 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]interface{}{ + "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 fmt.Errorf("feishu channel not running") + } + + 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("failed to send feishu message: %w", err) + } + + if !resp.Success() { + return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg) + } + + logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{ + "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{} + if messageID := stringValue(message.MessageId); messageID != "" { + metadata["message_id"] = messageID + } + 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) + if chatType == "p2p" { + metadata["peer_kind"] = "direct" + metadata["peer_id"] = senderID + } else { + metadata["peer_kind"] = "group" + metadata["peer_id"] = chatID + } + + logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{ + "sender_id": senderID, + "chat_id": chatID, + "preview": utils.Truncate(content, 80), + }) + + c.HandleMessage(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 +} diff --git a/pkg/channels/feishu/init.go b/pkg/channels/feishu/init.go new file mode 100644 index 000000000..7e5a62dae --- /dev/null +++ b/pkg/channels/feishu/init.go @@ -0,0 +1,13 @@ +package feishu + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("feishu", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewFeishuChannel(cfg.Channels.Feishu, b) + }) +} diff --git a/pkg/channels/line/init.go b/pkg/channels/line/init.go new file mode 100644 index 000000000..9265575cc --- /dev/null +++ b/pkg/channels/line/init.go @@ -0,0 +1,13 @@ +package line + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("line", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewLINEChannel(cfg.Channels.LINE, b) + }) +} diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go new file mode 100644 index 000000000..7df0491d9 --- /dev/null +++ b/pkg/channels/line/line.go @@ -0,0 +1,607 @@ +package line + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strings" + "sync" + "time" + + "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" +) + +const ( + lineAPIBase = "https://api.line.me/v2/bot" + lineDataAPIBase = "https://api-data.line.me/v2/bot" + lineReplyEndpoint = lineAPIBase + "/message/reply" + linePushEndpoint = lineAPIBase + "/message/push" + lineContentEndpoint = lineDataAPIBase + "/message/%s/content" + lineBotInfoEndpoint = lineAPIBase + "/info" + lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" + lineReplyTokenMaxAge = 25 * time.Second +) + +type replyTokenEntry struct { + token string + timestamp time.Time +} + +// LINEChannel implements the Channel interface for LINE Official Account +// using the LINE Messaging API with HTTP webhook for receiving messages +// and REST API for sending messages. +type LINEChannel struct { + *channels.BaseChannel + config config.LINEConfig + httpServer *http.Server + botUserID string // Bot's user ID + botBasicID string // Bot's basic ID (e.g. @216ru...) + botDisplayName string // Bot's display name for text-based mention detection + replyTokens sync.Map // chatID -> replyTokenEntry + quoteTokens sync.Map // chatID -> quoteToken (string) + ctx context.Context + cancel context.CancelFunc +} + +// NewLINEChannel creates a new LINE channel instance. +func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) { + if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" { + return nil, fmt.Errorf("line channel_secret and channel_access_token are required") + } + + base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom) + + return &LINEChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +// Start launches the HTTP webhook server. +func (c *LINEChannel) Start(ctx context.Context) error { + logger.InfoC("line", "Starting LINE channel (Webhook Mode)") + + c.ctx, c.cancel = context.WithCancel(ctx) + + // Fetch bot profile to get bot's userId for mention detection + if err := c.fetchBotInfo(); err != nil { + logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{ + "error": err.Error(), + }) + } else { + logger.InfoCF("line", "Bot info fetched", map[string]interface{}{ + "bot_user_id": c.botUserID, + "basic_id": c.botBasicID, + "display_name": c.botDisplayName, + }) + } + + mux := http.NewServeMux() + path := c.config.WebhookPath + if path == "" { + path = "/webhook/line" + } + mux.HandleFunc(path, c.webhookHandler) + + addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) + c.httpServer = &http.Server{ + Addr: addr, + Handler: mux, + } + + go func() { + logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{ + "addr": addr, + "path": path, + }) + if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("line", "Webhook server error", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + + c.SetRunning(true) + logger.InfoC("line", "LINE channel started (Webhook Mode)") + return nil +} + +// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API. +func (c *LINEChannel) fetchBotInfo() error { + req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("bot info API returned status %d", resp.StatusCode) + } + + var info struct { + UserID string `json:"userId"` + BasicID string `json:"basicId"` + DisplayName string `json:"displayName"` + } + if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { + return err + } + + c.botUserID = info.UserID + c.botBasicID = info.BasicID + c.botDisplayName = info.DisplayName + return nil +} + +// Stop gracefully shuts down the HTTP server. +func (c *LINEChannel) Stop(ctx context.Context) error { + logger.InfoC("line", "Stopping LINE channel") + + if c.cancel != nil { + c.cancel() + } + + if c.httpServer != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := c.httpServer.Shutdown(shutdownCtx); err != nil { + logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{ + "error": err.Error(), + }) + } + } + + c.SetRunning(false) + logger.InfoC("line", "LINE channel stopped") + return nil +} + +// webhookHandler handles incoming LINE webhook requests. +func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + signature := r.Header.Get("X-Line-Signature") + if !c.verifySignature(body, signature) { + logger.WarnC("line", "Invalid webhook signature") + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + var payload struct { + Events []lineEvent `json:"events"` + } + if err := json.Unmarshal(body, &payload); err != nil { + logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Bad request", http.StatusBadRequest) + return + } + + // Return 200 immediately, process events asynchronously + w.WriteHeader(http.StatusOK) + + for _, event := range payload.Events { + go c.processEvent(event) + } +} + +// verifySignature validates the X-Line-Signature using HMAC-SHA256. +func (c *LINEChannel) verifySignature(body []byte, signature string) bool { + if signature == "" { + return false + } + + mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret)) + mac.Write(body) + expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + return hmac.Equal([]byte(expected), []byte(signature)) +} + +// LINE webhook event types +type lineEvent struct { + Type string `json:"type"` + ReplyToken string `json:"replyToken"` + Source lineSource `json:"source"` + Message json.RawMessage `json:"message"` + Timestamp int64 `json:"timestamp"` +} + +type lineSource struct { + Type string `json:"type"` // "user", "group", "room" + UserID string `json:"userId"` + GroupID string `json:"groupId"` + RoomID string `json:"roomId"` +} + +type lineMessage struct { + ID string `json:"id"` + Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker" + Text string `json:"text"` + QuoteToken string `json:"quoteToken"` + Mention *struct { + Mentionees []lineMentionee `json:"mentionees"` + } `json:"mention"` + ContentProvider struct { + Type string `json:"type"` + } `json:"contentProvider"` +} + +type lineMentionee struct { + Index int `json:"index"` + Length int `json:"length"` + Type string `json:"type"` // "user", "all" + UserID string `json:"userId"` +} + +func (c *LINEChannel) processEvent(event lineEvent) { + if event.Type != "message" { + logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{ + "type": event.Type, + }) + return + } + + senderID := event.Source.UserID + chatID := c.resolveChatID(event.Source) + isGroup := event.Source.Type == "group" || event.Source.Type == "room" + + var msg lineMessage + if err := json.Unmarshal(event.Message, &msg); err != nil { + logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{ + "error": err.Error(), + }) + return + } + + // In group chats, only respond when the bot is mentioned + if isGroup && !c.isBotMentioned(msg) { + logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{ + "chat_id": chatID, + }) + return + } + + // Store reply token for later use + if event.ReplyToken != "" { + c.replyTokens.Store(chatID, replyTokenEntry{ + token: event.ReplyToken, + timestamp: time.Now(), + }) + } + + // Store quote token for quoting the original message in reply + if msg.QuoteToken != "" { + c.quoteTokens.Store(chatID, msg.QuoteToken) + } + + var content string + var mediaPaths []string + localFiles := []string{} + + defer func() { + for _, file := range localFiles { + if err := os.Remove(file); err != nil { + logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{ + "file": file, + "error": err.Error(), + }) + } + } + }() + + switch msg.Type { + case "text": + content = msg.Text + // Strip bot mention from text in group chats + if isGroup { + content = c.stripBotMention(content, msg) + } + case "image": + localPath := c.downloadContent(msg.ID, "image.jpg") + if localPath != "" { + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + content = "[image]" + } + case "audio": + localPath := c.downloadContent(msg.ID, "audio.m4a") + if localPath != "" { + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + content = "[audio]" + } + case "video": + localPath := c.downloadContent(msg.ID, "video.mp4") + if localPath != "" { + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + content = "[video]" + } + case "file": + content = "[file]" + case "sticker": + content = "[sticker]" + default: + content = fmt.Sprintf("[%s]", msg.Type) + } + + if strings.TrimSpace(content) == "" { + return + } + + metadata := map[string]string{ + "platform": "line", + "source_type": event.Source.Type, + "message_id": msg.ID, + } + + if isGroup { + metadata["peer_kind"] = "group" + metadata["peer_id"] = chatID + } else { + metadata["peer_kind"] = "direct" + metadata["peer_id"] = senderID + } + + logger.DebugCF("line", "Received message", map[string]interface{}{ + "sender_id": senderID, + "chat_id": chatID, + "message_type": msg.Type, + "is_group": isGroup, + "preview": utils.Truncate(content, 50), + }) + + // Show typing/loading indicator (requires user ID, not group ID) + c.sendLoading(senderID) + + c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) +} + +// isBotMentioned checks if the bot is mentioned in the message. +// It first checks the mention metadata (userId match), then falls back +// to text-based detection using the bot's display name, since LINE may +// not include userId in mentionees for Official Accounts. +func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { + // Check mention metadata + if msg.Mention != nil { + for _, m := range msg.Mention.Mentionees { + if m.Type == "all" { + return true + } + if c.botUserID != "" && m.UserID == c.botUserID { + return true + } + } + // Mention metadata exists with mentionees but bot not matched by userId. + // The bot IS likely mentioned (LINE includes mention struct when bot is @-ed), + // so check if any mentionee overlaps with bot display name in text. + if c.botDisplayName != "" { + for _, m := range msg.Mention.Mentionees { + if m.Index >= 0 && m.Length > 0 { + runes := []rune(msg.Text) + end := m.Index + m.Length + if end <= len(runes) { + mentionText := string(runes[m.Index:end]) + if strings.Contains(mentionText, c.botDisplayName) { + return true + } + } + } + } + } + } + + // Fallback: text-based detection with display name + if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) { + return true + } + + return false +} + +// stripBotMention removes the @BotName mention text from the message. +func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { + stripped := false + + // Try to strip using mention metadata indices + if msg.Mention != nil { + runes := []rune(text) + for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- { + m := msg.Mention.Mentionees[i] + // Strip if userId matches OR if the mention text contains the bot display name + shouldStrip := false + if c.botUserID != "" && m.UserID == c.botUserID { + shouldStrip = true + } else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 { + end := m.Index + m.Length + if end <= len(runes) { + mentionText := string(runes[m.Index:end]) + if strings.Contains(mentionText, c.botDisplayName) { + shouldStrip = true + } + } + } + if shouldStrip { + start := m.Index + end := m.Index + m.Length + if start >= 0 && end <= len(runes) { + runes = append(runes[:start], runes[end:]...) + stripped = true + } + } + } + if stripped { + return strings.TrimSpace(string(runes)) + } + } + + // Fallback: strip @DisplayName from text + if c.botDisplayName != "" { + text = strings.ReplaceAll(text, "@"+c.botDisplayName, "") + } + + return strings.TrimSpace(text) +} + +// resolveChatID determines the chat ID from the event source. +// For group/room messages, use the group/room ID; for 1:1, use the user ID. +func (c *LINEChannel) resolveChatID(source lineSource) string { + switch source.Type { + case "group": + return source.GroupID + case "room": + return source.RoomID + default: + return source.UserID + } +} + +// Send sends a message to LINE. It first tries the Reply API (free) +// using a cached reply token, then falls back to the Push API. +func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("line channel not running") + } + + // Load and consume quote token for this chat + var quoteToken string + if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok { + quoteToken = qt.(string) + } + + // Try reply token first (free, valid for ~25 seconds) + if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { + tokenEntry := entry.(replyTokenEntry) + if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { + if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { + logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{ + "chat_id": msg.ChatID, + "quoted": quoteToken != "", + }) + return nil + } + logger.DebugC("line", "Reply API failed, falling back to Push API") + } + } + + // Fall back to Push API + return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) +} + +// buildTextMessage creates a text message object, optionally with quoteToken. +func buildTextMessage(content, quoteToken string) map[string]string { + msg := map[string]string{ + "type": "text", + "text": content, + } + if quoteToken != "" { + msg["quoteToken"] = quoteToken + } + return msg +} + +// sendReply sends a message using the LINE Reply API. +func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error { + payload := map[string]interface{}{ + "replyToken": replyToken, + "messages": []map[string]string{buildTextMessage(content, quoteToken)}, + } + + return c.callAPI(ctx, lineReplyEndpoint, payload) +} + +// sendPush sends a message using the LINE Push API. +func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error { + payload := map[string]interface{}{ + "to": to, + "messages": []map[string]string{buildTextMessage(content, quoteToken)}, + } + + return c.callAPI(ctx, linePushEndpoint, payload) +} + +// sendLoading sends a loading animation indicator to the chat. +func (c *LINEChannel) sendLoading(chatID string) { + payload := map[string]interface{}{ + "chatId": chatID, + "loadingSeconds": 60, + } + if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil { + logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{ + "error": err.Error(), + }) + } +} + +// callAPI makes an authenticated POST request to the LINE API. +func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error { + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// downloadContent downloads media content from the LINE API. +func (c *LINEChannel) downloadContent(messageID, filename string) string { + url := fmt.Sprintf(lineContentEndpoint, messageID) + return utils.DownloadFile(url, filename, utils.DownloadOptions{ + LoggerPrefix: "line", + ExtraHeaders: map[string]string{ + "Authorization": "Bearer " + c.config.ChannelAccessToken, + }, + }) +} diff --git a/pkg/channels/maixcam/init.go b/pkg/channels/maixcam/init.go new file mode 100644 index 000000000..5a269b22b --- /dev/null +++ b/pkg/channels/maixcam/init.go @@ -0,0 +1,13 @@ +package maixcam + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("maixcam", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewMaixCamChannel(cfg.Channels.MaixCam, b) + }) +} diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go new file mode 100644 index 000000000..d3c6662d7 --- /dev/null +++ b/pkg/channels/maixcam/maixcam.go @@ -0,0 +1,244 @@ +package maixcam + +import ( + "context" + "encoding/json" + "fmt" + "net" + "sync" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type MaixCamChannel struct { + *channels.BaseChannel + config config.MaixCamConfig + listener net.Listener + clients map[net.Conn]bool + clientsMux sync.RWMutex +} + +type MaixCamMessage struct { + Type string `json:"type"` + Tips string `json:"tips"` + Timestamp float64 `json:"timestamp"` + Data map[string]interface{} `json:"data"` +} + +func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { + base := channels.NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom) + + return &MaixCamChannel{ + BaseChannel: base, + config: cfg, + clients: make(map[net.Conn]bool), + }, nil +} + +func (c *MaixCamChannel) Start(ctx context.Context) error { + logger.InfoC("maixcam", "Starting MaixCam channel server") + + addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen on %s: %w", addr, err) + } + + c.listener = listener + c.SetRunning(true) + + logger.InfoCF("maixcam", "MaixCam server listening", map[string]interface{}{ + "host": c.config.Host, + "port": c.config.Port, + }) + + go c.acceptConnections(ctx) + + return nil +} + +func (c *MaixCamChannel) acceptConnections(ctx context.Context) { + logger.DebugC("maixcam", "Starting connection acceptor") + + for { + select { + case <-ctx.Done(): + logger.InfoC("maixcam", "Stopping connection acceptor") + return + default: + conn, err := c.listener.Accept() + if err != nil { + if c.IsRunning() { + logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{ + "error": err.Error(), + }) + } + return + } + + logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]interface{}{ + "remote_addr": conn.RemoteAddr().String(), + }) + + c.clientsMux.Lock() + c.clients[conn] = true + c.clientsMux.Unlock() + + go c.handleConnection(conn, ctx) + } + } +} + +func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { + logger.DebugC("maixcam", "Handling MaixCam connection") + + defer func() { + conn.Close() + c.clientsMux.Lock() + delete(c.clients, conn) + c.clientsMux.Unlock() + logger.DebugC("maixcam", "Connection closed") + }() + + decoder := json.NewDecoder(conn) + + for { + select { + case <-ctx.Done(): + return + default: + var msg MaixCamMessage + if err := decoder.Decode(&msg); err != nil { + if err.Error() != "EOF" { + logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{ + "error": err.Error(), + }) + } + return + } + + c.processMessage(msg, conn) + } + } +} + +func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) { + switch msg.Type { + case "person_detected": + c.handlePersonDetection(msg) + case "heartbeat": + logger.DebugC("maixcam", "Received heartbeat") + case "status": + c.handleStatusUpdate(msg) + default: + logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{ + "type": msg.Type, + }) + } +} + +func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { + logger.InfoCF("maixcam", "", map[string]interface{}{ + "timestamp": msg.Timestamp, + "data": msg.Data, + }) + + senderID := "maixcam" + chatID := "default" + + classInfo, ok := msg.Data["class_name"].(string) + if !ok { + classInfo = "person" + } + + score, _ := msg.Data["score"].(float64) + x, _ := msg.Data["x"].(float64) + y, _ := msg.Data["y"].(float64) + w, _ := msg.Data["w"].(float64) + h, _ := msg.Data["h"].(float64) + + content := fmt.Sprintf("📷 Person detected!\nClass: %s\nConfidence: %.2f%%\nPosition: (%.0f, %.0f)\nSize: %.0fx%.0f", + classInfo, score*100, x, y, w, h) + + metadata := map[string]string{ + "timestamp": fmt.Sprintf("%.0f", msg.Timestamp), + "class_id": fmt.Sprintf("%.0f", msg.Data["class_id"]), + "score": fmt.Sprintf("%.2f", score), + "x": fmt.Sprintf("%.0f", x), + "y": fmt.Sprintf("%.0f", y), + "w": fmt.Sprintf("%.0f", w), + "h": fmt.Sprintf("%.0f", h), + "peer_kind": "channel", + "peer_id": "default", + } + + c.HandleMessage(senderID, chatID, content, []string{}, metadata) +} + +func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { + logger.InfoCF("maixcam", "Status update from MaixCam", map[string]interface{}{ + "status": msg.Data, + }) +} + +func (c *MaixCamChannel) Stop(ctx context.Context) error { + logger.InfoC("maixcam", "Stopping MaixCam channel") + c.SetRunning(false) + + if c.listener != nil { + c.listener.Close() + } + + c.clientsMux.Lock() + defer c.clientsMux.Unlock() + + for conn := range c.clients { + conn.Close() + } + c.clients = make(map[net.Conn]bool) + + logger.InfoC("maixcam", "MaixCam channel stopped") + return nil +} + +func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("maixcam channel not running") + } + + c.clientsMux.RLock() + defer c.clientsMux.RUnlock() + + if len(c.clients) == 0 { + logger.WarnC("maixcam", "No MaixCam devices connected") + return fmt.Errorf("no connected MaixCam devices") + } + + response := map[string]interface{}{ + "type": "command", + "timestamp": float64(0), + "message": msg.Content, + "chat_id": msg.ChatID, + } + + data, err := json.Marshal(response) + if err != nil { + return fmt.Errorf("failed to marshal response: %w", err) + } + + var sendErr error + for conn := range c.clients { + if _, err := conn.Write(data); err != nil { + logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{ + "client": conn.RemoteAddr().String(), + "error": err.Error(), + }) + sendErr = err + } + } + + return sendErr +} diff --git a/pkg/channels/onebot/init.go b/pkg/channels/onebot/init.go new file mode 100644 index 000000000..84c06dfd6 --- /dev/null +++ b/pkg/channels/onebot/init.go @@ -0,0 +1,13 @@ +package onebot + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("onebot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewOneBotChannel(cfg.Channels.OneBot, b) + }) +} diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go new file mode 100644 index 000000000..209f2dc00 --- /dev/null +++ b/pkg/channels/onebot/onebot.go @@ -0,0 +1,980 @@ +package onebot + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" + + "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" + "github.com/sipeed/picoclaw/pkg/voice" +) + +type OneBotChannel struct { + *channels.BaseChannel + config config.OneBotConfig + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + dedup map[string]struct{} + dedupRing []string + dedupIdx int + mu sync.Mutex + writeMu sync.Mutex + echoCounter int64 + selfID int64 + pending map[string]chan json.RawMessage + pendingMu sync.Mutex + transcriber *voice.GroqTranscriber + lastMessageID sync.Map + pendingEmojiMsg sync.Map +} + +type oneBotRawEvent struct { + PostType string `json:"post_type"` + MessageType string `json:"message_type"` + SubType string `json:"sub_type"` + MessageID json.RawMessage `json:"message_id"` + UserID json.RawMessage `json:"user_id"` + GroupID json.RawMessage `json:"group_id"` + RawMessage string `json:"raw_message"` + Message json.RawMessage `json:"message"` + Sender json.RawMessage `json:"sender"` + SelfID json.RawMessage `json:"self_id"` + Time json.RawMessage `json:"time"` + MetaEventType string `json:"meta_event_type"` + NoticeType string `json:"notice_type"` + Echo string `json:"echo"` + RetCode json.RawMessage `json:"retcode"` + Status json.RawMessage `json:"status"` + Data json.RawMessage `json:"data"` +} + +type BotStatus struct { + Online bool `json:"online"` + Good bool `json:"good"` +} + +func isAPIResponse(raw json.RawMessage) bool { + if len(raw) == 0 { + return false + } + var s string + if json.Unmarshal(raw, &s) == nil { + return s == "ok" || s == "failed" + } + var bs BotStatus + if json.Unmarshal(raw, &bs) == nil { + return bs.Online || bs.Good + } + return false +} + +type oneBotSender struct { + UserID json.RawMessage `json:"user_id"` + Nickname string `json:"nickname"` + Card string `json:"card"` +} + +type oneBotAPIRequest struct { + Action string `json:"action"` + Params interface{} `json:"params"` + Echo string `json:"echo,omitempty"` +} + +type oneBotMessageSegment struct { + Type string `json:"type"` + Data map[string]interface{} `json:"data"` +} + +func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { + base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom) + + const dedupSize = 1024 + return &OneBotChannel{ + BaseChannel: base, + config: cfg, + dedup: make(map[string]struct{}, dedupSize), + dedupRing: make([]string, dedupSize), + dedupIdx: 0, + pending: make(map[string]chan json.RawMessage), + }, nil +} + +func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { + c.transcriber = transcriber +} + +func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) { + go func() { + _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]interface{}{ + "message_id": messageID, + "emoji_id": emojiID, + "set": set, + }, 5*time.Second) + if err != nil { + logger.DebugCF("onebot", "Failed to set emoji like", map[string]interface{}{ + "message_id": messageID, + "error": err.Error(), + }) + } + }() +} + +func (c *OneBotChannel) Start(ctx context.Context) error { + if c.config.WSUrl == "" { + return fmt.Errorf("OneBot ws_url not configured") + } + + logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{ + "ws_url": c.config.WSUrl, + }) + + c.ctx, c.cancel = context.WithCancel(ctx) + + if err := c.connect(); err != nil { + logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{ + "error": err.Error(), + }) + } else { + go c.listen() + c.fetchSelfID() + } + + if c.config.ReconnectInterval > 0 { + go c.reconnectLoop() + } else { + if c.conn == nil { + return fmt.Errorf("failed to connect to OneBot and reconnect is disabled") + } + } + + c.SetRunning(true) + logger.InfoC("onebot", "OneBot channel started successfully") + + return nil +} + +func (c *OneBotChannel) connect() error { + dialer := websocket.DefaultDialer + dialer.HandshakeTimeout = 10 * time.Second + + header := make(map[string][]string) + if c.config.AccessToken != "" { + header["Authorization"] = []string{"Bearer " + c.config.AccessToken} + } + + conn, _, err := dialer.Dial(c.config.WSUrl, header) + if err != nil { + return err + } + + conn.SetPongHandler(func(appData string) error { + _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + + c.mu.Lock() + c.conn = conn + c.mu.Unlock() + + go c.pinger(conn) + + logger.InfoC("onebot", "WebSocket connected") + return nil +} + +func (c *OneBotChannel) pinger(conn *websocket.Conn) { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + c.writeMu.Lock() + err := conn.WriteMessage(websocket.PingMessage, nil) + c.writeMu.Unlock() + if err != nil { + logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]interface{}{ + "error": err.Error(), + }) + return + } + } + } +} + +func (c *OneBotChannel) fetchSelfID() { + resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second) + if err != nil { + logger.WarnCF("onebot", "Failed to get_login_info", map[string]interface{}{ + "error": err.Error(), + }) + return + } + + type loginInfo struct { + UserID json.RawMessage `json:"user_id"` + Nickname string `json:"nickname"` + } + for _, extract := range []func() (*loginInfo, error){ + func() (*loginInfo, error) { + var w struct { + Data loginInfo `json:"data"` + } + err := json.Unmarshal(resp, &w) + return &w.Data, err + }, + func() (*loginInfo, error) { + var f loginInfo + err := json.Unmarshal(resp, &f) + return &f, err + }, + } { + info, err := extract() + if err != nil || len(info.UserID) == 0 { + continue + } + if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 { + atomic.StoreInt64(&c.selfID, uid) + logger.InfoCF("onebot", "Bot self ID retrieved", map[string]interface{}{ + "self_id": uid, + "nickname": info.Nickname, + }) + return + } + } + + logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]interface{}{ + "response": string(resp), + }) +} + +func (c *OneBotChannel) sendAPIRequest(action string, params interface{}, timeout time.Duration) (json.RawMessage, error) { + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + return nil, fmt.Errorf("WebSocket not connected") + } + + echo := fmt.Sprintf("api_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&c.echoCounter, 1)) + + ch := make(chan json.RawMessage, 1) + c.pendingMu.Lock() + c.pending[echo] = ch + c.pendingMu.Unlock() + + defer func() { + c.pendingMu.Lock() + delete(c.pending, echo) + c.pendingMu.Unlock() + }() + + req := oneBotAPIRequest{ + Action: action, + Params: params, + Echo: echo, + } + + data, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal API request: %w", err) + } + + c.writeMu.Lock() + err = conn.WriteMessage(websocket.TextMessage, data) + c.writeMu.Unlock() + + if err != nil { + return nil, fmt.Errorf("failed to write API request: %w", err) + } + + select { + case resp := <-ch: + return resp, nil + case <-time.After(timeout): + return nil, fmt.Errorf("API request %s timed out after %v", action, timeout) + case <-c.ctx.Done(): + return nil, fmt.Errorf("context cancelled") + } +} + +func (c *OneBotChannel) reconnectLoop() { + interval := time.Duration(c.config.ReconnectInterval) * time.Second + if interval < 5*time.Second { + interval = 5 * time.Second + } + + for { + select { + case <-c.ctx.Done(): + return + case <-time.After(interval): + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + logger.InfoC("onebot", "Attempting to reconnect...") + if err := c.connect(); err != nil { + logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{ + "error": err.Error(), + }) + } else { + go c.listen() + c.fetchSelfID() + } + } + } + } +} + +func (c *OneBotChannel) Stop(ctx context.Context) error { + logger.InfoC("onebot", "Stopping OneBot channel") + c.SetRunning(false) + + if c.cancel != nil { + c.cancel() + } + + c.pendingMu.Lock() + for echo, ch := range c.pending { + close(ch) + delete(c.pending, echo) + } + c.pendingMu.Unlock() + + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + + return nil +} + +func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("OneBot channel not running") + } + + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + return fmt.Errorf("OneBot WebSocket not connected") + } + + action, params, err := c.buildSendRequest(msg) + if err != nil { + return err + } + + echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) + + req := oneBotAPIRequest{ + Action: action, + Params: params, + Echo: echo, + } + + data, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal OneBot request: %w", err) + } + + c.writeMu.Lock() + err = conn.WriteMessage(websocket.TextMessage, data) + c.writeMu.Unlock() + + if err != nil { + logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{ + "error": err.Error(), + }) + return err + } + + if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok { + if mid, ok := msgID.(string); ok && mid != "" { + c.setMsgEmojiLike(mid, 289, false) + } + } + + return nil +} + +func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment { + var segments []oneBotMessageSegment + + if lastMsgID, ok := c.lastMessageID.Load(chatID); ok { + if msgID, ok := lastMsgID.(string); ok && msgID != "" { + segments = append(segments, oneBotMessageSegment{ + Type: "reply", + Data: map[string]interface{}{"id": msgID}, + }) + } + } + + segments = append(segments, oneBotMessageSegment{ + Type: "text", + Data: map[string]interface{}{"text": content}, + }) + + return segments +} + +func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) { + chatID := msg.ChatID + segments := c.buildMessageSegments(chatID, msg.Content) + + var action, idKey string + var rawID string + if rest, ok := strings.CutPrefix(chatID, "group:"); ok { + action, idKey, rawID = "send_group_msg", "group_id", rest + } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok { + action, idKey, rawID = "send_private_msg", "user_id", rest + } else { + action, idKey, rawID = "send_private_msg", "user_id", chatID + } + + id, err := strconv.ParseInt(rawID, 10, 64) + if err != nil { + return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID) + } + return action, map[string]interface{}{idKey: id, "message": segments}, nil +} + +func (c *OneBotChannel) listen() { + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + logger.WarnC("onebot", "WebSocket connection is nil, listener exiting") + return + } + + for { + select { + case <-c.ctx.Done(): + return + default: + _, message, err := conn.ReadMessage() + if err != nil { + logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{ + "error": err.Error(), + }) + c.mu.Lock() + if c.conn == conn { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() + return + } + + _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + + var raw oneBotRawEvent + if err := json.Unmarshal(message, &raw); err != nil { + logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{ + "error": err.Error(), + "payload": string(message), + }) + continue + } + + logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{ + "length": len(message), + "post_type": raw.PostType, + "sub_type": raw.SubType, + }) + + if raw.Echo != "" { + c.pendingMu.Lock() + ch, ok := c.pending[raw.Echo] + c.pendingMu.Unlock() + + if ok { + select { + case ch <- message: + default: + } + } else { + logger.DebugCF("onebot", "Received API response (no waiter)", map[string]interface{}{ + "echo": raw.Echo, + "status": string(raw.Status), + }) + } + continue + } + + if isAPIResponse(raw.Status) { + logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]interface{}{ + "status": string(raw.Status), + }) + continue + } + + c.handleRawEvent(&raw) + } + } +} + +func parseJSONInt64(raw json.RawMessage) (int64, error) { + if len(raw) == 0 { + return 0, nil + } + + var n int64 + if err := json.Unmarshal(raw, &n); err == nil { + return n, nil + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strconv.ParseInt(s, 10, 64) + } + return 0, fmt.Errorf("cannot parse as int64: %s", string(raw)) +} + +func parseJSONString(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + + return string(raw) +} + +type parseMessageResult struct { + Text string + IsBotMentioned bool + Media []string + LocalFiles []string + ReplyTo string +} + +func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult { + if len(raw) == 0 { + return parseMessageResult{} + } + + var s string + if err := json.Unmarshal(raw, &s); err == nil { + mentioned := false + if selfID > 0 { + cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) + if strings.Contains(s, cqAt) { + mentioned = true + s = strings.ReplaceAll(s, cqAt, "") + s = strings.TrimSpace(s) + } + } + return parseMessageResult{Text: s, IsBotMentioned: mentioned} + } + + var segments []map[string]interface{} + if err := json.Unmarshal(raw, &segments); err != nil { + return parseMessageResult{} + } + + var textParts []string + mentioned := false + selfIDStr := strconv.FormatInt(selfID, 10) + var media []string + var localFiles []string + var replyTo string + + for _, seg := range segments { + segType, _ := seg["type"].(string) + data, _ := seg["data"].(map[string]interface{}) + + switch segType { + case "text": + if data != nil { + if t, ok := data["text"].(string); ok { + textParts = append(textParts, t) + } + } + + case "at": + if data != nil && selfID > 0 { + qqVal := fmt.Sprintf("%v", data["qq"]) + if qqVal == selfIDStr || qqVal == "all" { + mentioned = true + } + } + + case "image", "video", "file": + if data != nil { + url, _ := data["url"].(string) + if url != "" { + defaults := map[string]string{"image": "image.jpg", "video": "video.mp4", "file": "file"} + filename := defaults[segType] + if f, ok := data["file"].(string); ok && f != "" { + filename = f + } else if n, ok := data["name"].(string); ok && n != "" { + filename = n + } + localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{ + LoggerPrefix: "onebot", + }) + if localPath != "" { + media = append(media, localPath) + localFiles = append(localFiles, localPath) + textParts = append(textParts, fmt.Sprintf("[%s]", segType)) + } + } + } + + case "record": + if data != nil { + url, _ := data["url"].(string) + if url != "" { + localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{ + LoggerPrefix: "onebot", + }) + if localPath != "" { + localFiles = append(localFiles, localPath) + if c.transcriber != nil && c.transcriber.IsAvailable() { + tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second) + result, err := c.transcriber.Transcribe(tctx, localPath) + tcancel() + if err != nil { + logger.WarnCF("onebot", "Voice transcription failed", map[string]interface{}{ + "error": err.Error(), + }) + textParts = append(textParts, "[voice (transcription failed)]") + media = append(media, localPath) + } else { + textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text)) + } + } else { + textParts = append(textParts, "[voice]") + media = append(media, localPath) + } + } + } + } + + case "reply": + if data != nil { + if id, ok := data["id"]; ok { + replyTo = fmt.Sprintf("%v", id) + } + } + + case "face": + if data != nil { + faceID, _ := data["id"] + textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID)) + } + + case "forward": + textParts = append(textParts, "[forward message]") + + default: + + } + } + + return parseMessageResult{ + Text: strings.TrimSpace(strings.Join(textParts, "")), + IsBotMentioned: mentioned, + Media: media, + LocalFiles: localFiles, + ReplyTo: replyTo, + } +} + +func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { + switch raw.PostType { + case "message": + if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 { + if !c.IsAllowed(strconv.FormatInt(userID, 10)) { + logger.DebugCF("onebot", "Message rejected by allowlist", map[string]interface{}{ + "user_id": userID, + }) + return + } + } + c.handleMessage(raw) + + case "message_sent": + logger.DebugCF("onebot", "Bot sent message event", map[string]interface{}{ + "message_type": raw.MessageType, + "message_id": parseJSONString(raw.MessageID), + }) + + case "meta_event": + c.handleMetaEvent(raw) + + case "notice": + c.handleNoticeEvent(raw) + + case "request": + logger.DebugCF("onebot", "Request event received", map[string]interface{}{ + "sub_type": raw.SubType, + }) + + case "": + logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{ + "echo": raw.Echo, + "status": raw.Status, + }) + + default: + logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{ + "post_type": raw.PostType, + }) + } +} + +func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { + if raw.MetaEventType == "lifecycle" { + logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{"sub_type": raw.SubType}) + } else if raw.MetaEventType != "heartbeat" { + logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil) + } +} + +func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) { + fields := map[string]interface{}{ + "notice_type": raw.NoticeType, + "sub_type": raw.SubType, + "group_id": parseJSONString(raw.GroupID), + "user_id": parseJSONString(raw.UserID), + "message_id": parseJSONString(raw.MessageID), + } + switch raw.NoticeType { + case "group_recall", "group_increase", "group_decrease", + "friend_add", "group_admin", "group_ban": + logger.InfoCF("onebot", "Notice: "+raw.NoticeType, fields) + default: + logger.DebugCF("onebot", "Notice: "+raw.NoticeType, fields) + } +} + +func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { + // Parse fields from raw event + userID, err := parseJSONInt64(raw.UserID) + if err != nil { + logger.WarnCF("onebot", "Failed to parse user_id", map[string]interface{}{ + "error": err.Error(), + "raw": string(raw.UserID), + }) + return + } + + groupID, _ := parseJSONInt64(raw.GroupID) + selfID, _ := parseJSONInt64(raw.SelfID) + messageID := parseJSONString(raw.MessageID) + + if selfID == 0 { + selfID = atomic.LoadInt64(&c.selfID) + } + + parsed := c.parseMessageSegments(raw.Message, selfID) + isBotMentioned := parsed.IsBotMentioned + + content := raw.RawMessage + if content == "" { + content = parsed.Text + } else if selfID > 0 { + cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) + if strings.Contains(content, cqAt) { + isBotMentioned = true + content = strings.ReplaceAll(content, cqAt, "") + content = strings.TrimSpace(content) + } + } + + if parsed.Text != "" && content != parsed.Text && (len(parsed.Media) > 0 || parsed.ReplyTo != "") { + content = parsed.Text + } + + var sender oneBotSender + if len(raw.Sender) > 0 { + if err := json.Unmarshal(raw.Sender, &sender); err != nil { + logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{ + "error": err.Error(), + "sender": string(raw.Sender), + }) + } + } + + // Clean up temp files when done + if len(parsed.LocalFiles) > 0 { + defer func() { + for _, f := range parsed.LocalFiles { + if err := os.Remove(f); err != nil { + logger.DebugCF("onebot", "Failed to remove temp file", map[string]interface{}{ + "path": f, + "error": err.Error(), + }) + } + } + }() + } + + if c.isDuplicate(messageID) { + logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{ + "message_id": messageID, + }) + return + } + + if content == "" { + logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{ + "message_id": messageID, + }) + return + } + + senderID := strconv.FormatInt(userID, 10) + var chatID string + + metadata := map[string]string{ + "message_id": messageID, + } + + if parsed.ReplyTo != "" { + metadata["reply_to_message_id"] = parsed.ReplyTo + } + + switch raw.MessageType { + case "private": + chatID = "private:" + senderID + metadata["peer_kind"] = "direct" + metadata["peer_id"] = senderID + + case "group": + groupIDStr := strconv.FormatInt(groupID, 10) + chatID = "group:" + groupIDStr + metadata["peer_kind"] = "group" + metadata["peer_id"] = groupIDStr + metadata["group_id"] = groupIDStr + + senderUserID, _ := parseJSONInt64(sender.UserID) + if senderUserID > 0 { + metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10) + } + + if sender.Card != "" { + metadata["sender_name"] = sender.Card + } else if sender.Nickname != "" { + metadata["sender_name"] = sender.Nickname + } + + triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned) + if !triggered { + logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{ + "sender": senderID, + "group": groupIDStr, + "is_mentioned": isBotMentioned, + "content": truncate(content, 100), + }) + return + } + content = strippedContent + + default: + logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{ + "type": raw.MessageType, + "message_id": messageID, + "user_id": userID, + }) + return + } + + logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{ + "sender": senderID, + "chat_id": chatID, + "message_id": messageID, + "length": len(content), + "content": truncate(content, 100), + "media_count": len(parsed.Media), + }) + + if sender.Nickname != "" { + metadata["nickname"] = sender.Nickname + } + + c.lastMessageID.Store(chatID, messageID) + + if raw.MessageType == "group" && messageID != "" && messageID != "0" { + c.setMsgEmojiLike(messageID, 289, true) + c.pendingEmojiMsg.Store(chatID, messageID) + } + + c.HandleMessage(senderID, chatID, content, parsed.Media, metadata) +} + +func (c *OneBotChannel) isDuplicate(messageID string) bool { + if messageID == "" || messageID == "0" { + return false + } + + c.mu.Lock() + defer c.mu.Unlock() + + if _, exists := c.dedup[messageID]; exists { + return true + } + + if old := c.dedupRing[c.dedupIdx]; old != "" { + delete(c.dedup, old) + } + c.dedupRing[c.dedupIdx] = messageID + c.dedup[messageID] = struct{}{} + c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing) + + return false +} + +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "..." +} + +func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) { + if isBotMentioned { + return true, strings.TrimSpace(content) + } + + for _, prefix := range c.config.GroupTriggerPrefix { + if prefix == "" { + continue + } + if strings.HasPrefix(content, prefix) { + return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) + } + } + + return false, content +} diff --git a/pkg/channels/qq/init.go b/pkg/channels/qq/init.go new file mode 100644 index 000000000..15b955089 --- /dev/null +++ b/pkg/channels/qq/init.go @@ -0,0 +1,13 @@ +package qq + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("qq", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewQQChannel(cfg.Channels.QQ, b) + }) +} diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go new file mode 100644 index 000000000..9b07be0cc --- /dev/null +++ b/pkg/channels/qq/qq.go @@ -0,0 +1,248 @@ +package qq + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/tencent-connect/botgo" + "github.com/tencent-connect/botgo/dto" + "github.com/tencent-connect/botgo/event" + "github.com/tencent-connect/botgo/openapi" + "github.com/tencent-connect/botgo/token" + "golang.org/x/oauth2" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type QQChannel struct { + *channels.BaseChannel + config config.QQConfig + api openapi.OpenAPI + tokenSource oauth2.TokenSource + ctx context.Context + cancel context.CancelFunc + sessionManager botgo.SessionManager + processedIDs map[string]bool + mu sync.RWMutex +} + +func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { + base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom) + + return &QQChannel{ + BaseChannel: base, + config: cfg, + processedIDs: make(map[string]bool), + }, nil +} + +func (c *QQChannel) Start(ctx context.Context) error { + if c.config.AppID == "" || c.config.AppSecret == "" { + return fmt.Errorf("QQ app_id and app_secret not configured") + } + + logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") + + // 创建 token source + credentials := &token.QQBotCredentials{ + AppID: c.config.AppID, + AppSecret: c.config.AppSecret, + } + c.tokenSource = token.NewQQBotTokenSource(credentials) + + // 创建子 context + c.ctx, c.cancel = context.WithCancel(ctx) + + // 启动自动刷新 token 协程 + if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil { + return fmt.Errorf("failed to start token refresh: %w", err) + } + + // 初始化 OpenAPI 客户端 + c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) + + // 注册事件处理器 + intent := event.RegisterHandlers( + c.handleC2CMessage(), + c.handleGroupATMessage(), + ) + + // 获取 WebSocket 接入点 + wsInfo, err := c.api.WS(c.ctx, nil, "") + if err != nil { + return fmt.Errorf("failed to get websocket info: %w", err) + } + + logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{ + "shards": wsInfo.Shards, + }) + + // 创建并保存 sessionManager + c.sessionManager = botgo.NewSessionManager() + + // 在 goroutine 中启动 WebSocket 连接,避免阻塞 + go func() { + if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil { + logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{ + "error": err.Error(), + }) + c.SetRunning(false) + } + }() + + c.SetRunning(true) + logger.InfoC("qq", "QQ bot started successfully") + + return nil +} + +func (c *QQChannel) Stop(ctx context.Context) error { + logger.InfoC("qq", "Stopping QQ bot") + c.SetRunning(false) + + if c.cancel != nil { + c.cancel() + } + + return nil +} + +func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("QQ bot not running") + } + + // 构造消息 + msgToCreate := &dto.MessageToCreate{ + Content: msg.Content, + } + + // C2C 消息发送 + _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) + if err != nil { + logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{ + "error": err.Error(), + }) + return err + } + + return nil +} + +// handleC2CMessage 处理 QQ 私聊消息 +func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { + return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { + // 去重检查 + if c.isDuplicate(data.ID) { + return nil + } + + // 提取用户信息 + var senderID string + if data.Author != nil && data.Author.ID != "" { + senderID = data.Author.ID + } else { + logger.WarnC("qq", "Received message with no sender ID") + return nil + } + + // 提取消息内容 + content := data.Content + if content == "" { + logger.DebugC("qq", "Received empty message, ignoring") + return nil + } + + logger.InfoCF("qq", "Received C2C message", map[string]interface{}{ + "sender": senderID, + "length": len(content), + }) + + // 转发到消息总线 + metadata := map[string]string{ + "message_id": data.ID, + "peer_kind": "direct", + "peer_id": senderID, + } + + c.HandleMessage(senderID, senderID, content, []string{}, metadata) + + return nil + } +} + +// handleGroupATMessage 处理群@消息 +func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { + return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { + // 去重检查 + if c.isDuplicate(data.ID) { + return nil + } + + // 提取用户信息 + var senderID string + if data.Author != nil && data.Author.ID != "" { + senderID = data.Author.ID + } else { + logger.WarnC("qq", "Received group message with no sender ID") + return nil + } + + // 提取消息内容(去掉 @ 机器人部分) + content := data.Content + if content == "" { + logger.DebugC("qq", "Received empty group message, ignoring") + return nil + } + + logger.InfoCF("qq", "Received group AT message", map[string]interface{}{ + "sender": senderID, + "group": data.GroupID, + "length": len(content), + }) + + // 转发到消息总线(使用 GroupID 作为 ChatID) + metadata := map[string]string{ + "message_id": data.ID, + "group_id": data.GroupID, + "peer_kind": "group", + "peer_id": data.GroupID, + } + + c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata) + + return nil + } +} + +// isDuplicate 检查消息是否重复 +func (c *QQChannel) isDuplicate(messageID string) bool { + c.mu.Lock() + defer c.mu.Unlock() + + if c.processedIDs[messageID] { + return true + } + + c.processedIDs[messageID] = true + + // 简单清理:限制 map 大小 + if len(c.processedIDs) > 10000 { + // 清空一半 + count := 0 + for id := range c.processedIDs { + if count >= 5000 { + break + } + delete(c.processedIDs, id) + count++ + } + } + + return false +} diff --git a/pkg/channels/slack/init.go b/pkg/channels/slack/init.go new file mode 100644 index 000000000..c131bb291 --- /dev/null +++ b/pkg/channels/slack/init.go @@ -0,0 +1,13 @@ +package slack + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("slack", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewSlackChannel(cfg.Channels.Slack, b) + }) +} diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go new file mode 100644 index 000000000..dc5190fc9 --- /dev/null +++ b/pkg/channels/slack/slack.go @@ -0,0 +1,444 @@ +package slack + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + + "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" + "github.com/sipeed/picoclaw/pkg/voice" +) + +type SlackChannel struct { + *channels.BaseChannel + config config.SlackConfig + api *slack.Client + socketClient *socketmode.Client + botUserID string + teamID string + transcriber *voice.GroqTranscriber + ctx context.Context + cancel context.CancelFunc + pendingAcks sync.Map +} + +type slackMessageRef struct { + ChannelID string + Timestamp string +} + +func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) { + if cfg.BotToken == "" || cfg.AppToken == "" { + return nil, fmt.Errorf("slack bot_token and app_token are required") + } + + api := slack.New( + cfg.BotToken, + slack.OptionAppLevelToken(cfg.AppToken), + ) + + socketClient := socketmode.New(api) + + base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom) + + return &SlackChannel{ + BaseChannel: base, + config: cfg, + api: api, + socketClient: socketClient, + }, nil +} + +func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { + c.transcriber = transcriber +} + +func (c *SlackChannel) Start(ctx context.Context) error { + logger.InfoC("slack", "Starting Slack channel (Socket Mode)") + + c.ctx, c.cancel = context.WithCancel(ctx) + + authResp, err := c.api.AuthTest() + if err != nil { + return fmt.Errorf("slack auth test failed: %w", err) + } + c.botUserID = authResp.UserID + c.teamID = authResp.TeamID + + logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{ + "bot_user_id": c.botUserID, + "team": authResp.Team, + }) + + go c.eventLoop() + + go func() { + if err := c.socketClient.RunContext(c.ctx); err != nil { + if c.ctx.Err() == nil { + logger.ErrorCF("slack", "Socket Mode connection error", map[string]interface{}{ + "error": err.Error(), + }) + } + } + }() + + c.SetRunning(true) + logger.InfoC("slack", "Slack channel started (Socket Mode)") + return nil +} + +func (c *SlackChannel) Stop(ctx context.Context) error { + logger.InfoC("slack", "Stopping Slack channel") + + if c.cancel != nil { + c.cancel() + } + + c.SetRunning(false) + logger.InfoC("slack", "Slack channel stopped") + return nil +} + +func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("slack channel not running") + } + + channelID, threadTS := parseSlackChatID(msg.ChatID) + if channelID == "" { + return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + } + + opts := []slack.MsgOption{ + slack.MsgOptionText(msg.Content, false), + } + + if threadTS != "" { + opts = append(opts, slack.MsgOptionTS(threadTS)) + } + + _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) + if err != nil { + return fmt.Errorf("failed to send slack message: %w", err) + } + + if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { + msgRef := ref.(slackMessageRef) + c.api.AddReaction("white_check_mark", slack.ItemRef{ + Channel: msgRef.ChannelID, + Timestamp: msgRef.Timestamp, + }) + } + + logger.DebugCF("slack", "Message sent", map[string]interface{}{ + "channel_id": channelID, + "thread_ts": threadTS, + }) + + return nil +} + +func (c *SlackChannel) eventLoop() { + for { + select { + case <-c.ctx.Done(): + return + case event, ok := <-c.socketClient.Events: + if !ok { + return + } + switch event.Type { + case socketmode.EventTypeEventsAPI: + c.handleEventsAPI(event) + case socketmode.EventTypeSlashCommand: + c.handleSlashCommand(event) + case socketmode.EventTypeInteractive: + if event.Request != nil { + c.socketClient.Ack(*event.Request) + } + } + } + } +} + +func (c *SlackChannel) handleEventsAPI(event socketmode.Event) { + if event.Request != nil { + c.socketClient.Ack(*event.Request) + } + + eventsAPIEvent, ok := event.Data.(slackevents.EventsAPIEvent) + if !ok { + return + } + + switch ev := eventsAPIEvent.InnerEvent.Data.(type) { + case *slackevents.MessageEvent: + c.handleMessageEvent(ev) + case *slackevents.AppMentionEvent: + c.handleAppMention(ev) + } +} + +func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { + if ev.User == c.botUserID || ev.User == "" { + return + } + if ev.BotID != "" { + return + } + if ev.SubType != "" && ev.SubType != "file_share" { + return + } + + // 检查白名单,避免为被拒绝的用户下载附件 + if !c.IsAllowed(ev.User) { + logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{ + "user_id": ev.User, + }) + return + } + + senderID := ev.User + channelID := ev.Channel + threadTS := ev.ThreadTimeStamp + messageTS := ev.TimeStamp + + chatID := channelID + if threadTS != "" { + chatID = channelID + "/" + threadTS + } + + c.api.AddReaction("eyes", slack.ItemRef{ + Channel: channelID, + Timestamp: messageTS, + }) + + c.pendingAcks.Store(chatID, slackMessageRef{ + ChannelID: channelID, + Timestamp: messageTS, + }) + + content := ev.Text + content = c.stripBotMention(content) + + var mediaPaths []string + localFiles := []string{} // 跟踪需要清理的本地文件 + + // 确保临时文件在函数返回时被清理 + defer func() { + for _, file := range localFiles { + if err := os.Remove(file); err != nil { + logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{ + "file": file, + "error": err.Error(), + }) + } + } + }() + + if ev.Message != nil && len(ev.Message.Files) > 0 { + for _, file := range ev.Message.Files { + localPath := c.downloadSlackFile(file) + if localPath == "" { + continue + } + localFiles = append(localFiles, localPath) + mediaPaths = append(mediaPaths, localPath) + + if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() { + ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) + defer cancel() + result, err := c.transcriber.Transcribe(ctx, localPath) + + if err != nil { + logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()}) + content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name) + } else { + content += fmt.Sprintf("\n[voice transcription: %s]", result.Text) + } + } else { + content += fmt.Sprintf("\n[file: %s]", file.Name) + } + } + } + + if strings.TrimSpace(content) == "" { + return + } + + peerKind := "channel" + peerID := channelID + if strings.HasPrefix(channelID, "D") { + peerKind = "direct" + peerID = senderID + } + + metadata := map[string]string{ + "message_ts": messageTS, + "channel_id": channelID, + "thread_ts": threadTS, + "platform": "slack", + "peer_kind": peerKind, + "peer_id": peerID, + "team_id": c.teamID, + } + + logger.DebugCF("slack", "Received message", map[string]interface{}{ + "sender_id": senderID, + "chat_id": chatID, + "preview": utils.Truncate(content, 50), + "has_thread": threadTS != "", + }) + + c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) +} + +func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { + if ev.User == c.botUserID { + return + } + + if !c.IsAllowed(ev.User) { + logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{ + "user_id": ev.User, + }) + return + } + + senderID := ev.User + channelID := ev.Channel + threadTS := ev.ThreadTimeStamp + messageTS := ev.TimeStamp + + var chatID string + if threadTS != "" { + chatID = channelID + "/" + threadTS + } else { + chatID = channelID + "/" + messageTS + } + + c.api.AddReaction("eyes", slack.ItemRef{ + Channel: channelID, + Timestamp: messageTS, + }) + + c.pendingAcks.Store(chatID, slackMessageRef{ + ChannelID: channelID, + Timestamp: messageTS, + }) + + content := c.stripBotMention(ev.Text) + + if strings.TrimSpace(content) == "" { + return + } + + mentionPeerKind := "channel" + mentionPeerID := channelID + if strings.HasPrefix(channelID, "D") { + mentionPeerKind = "direct" + mentionPeerID = senderID + } + + metadata := map[string]string{ + "message_ts": messageTS, + "channel_id": channelID, + "thread_ts": threadTS, + "platform": "slack", + "is_mention": "true", + "peer_kind": mentionPeerKind, + "peer_id": mentionPeerID, + "team_id": c.teamID, + } + + c.HandleMessage(senderID, chatID, content, nil, metadata) +} + +func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { + cmd, ok := event.Data.(slack.SlashCommand) + if !ok { + return + } + + if event.Request != nil { + c.socketClient.Ack(*event.Request) + } + + if !c.IsAllowed(cmd.UserID) { + logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{ + "user_id": cmd.UserID, + }) + return + } + + senderID := cmd.UserID + channelID := cmd.ChannelID + chatID := channelID + content := cmd.Text + + if strings.TrimSpace(content) == "" { + content = "help" + } + + metadata := map[string]string{ + "channel_id": channelID, + "platform": "slack", + "is_command": "true", + "trigger_id": cmd.TriggerID, + "peer_kind": "channel", + "peer_id": channelID, + "team_id": c.teamID, + } + + logger.DebugCF("slack", "Slash command received", map[string]interface{}{ + "sender_id": senderID, + "command": cmd.Command, + "text": utils.Truncate(content, 50), + }) + + c.HandleMessage(senderID, chatID, content, nil, metadata) +} + +func (c *SlackChannel) downloadSlackFile(file slack.File) string { + downloadURL := file.URLPrivateDownload + if downloadURL == "" { + downloadURL = file.URLPrivate + } + if downloadURL == "" { + logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID}) + return "" + } + + return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{ + LoggerPrefix: "slack", + ExtraHeaders: map[string]string{ + "Authorization": "Bearer " + c.config.BotToken, + }, + }) +} + +func (c *SlackChannel) stripBotMention(text string) string { + mention := fmt.Sprintf("<@%s>", c.botUserID) + text = strings.ReplaceAll(text, mention, "") + return strings.TrimSpace(text) +} + +func parseSlackChatID(chatID string) (channelID, threadTS string) { + parts := strings.SplitN(chatID, "/", 2) + channelID = parts[0] + if len(parts) > 1 { + threadTS = parts[1] + } + return +} diff --git a/pkg/channels/slack/slack_test.go b/pkg/channels/slack/slack_test.go new file mode 100644 index 000000000..30e0d2d73 --- /dev/null +++ b/pkg/channels/slack/slack_test.go @@ -0,0 +1,174 @@ +package slack + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestParseSlackChatID(t *testing.T) { + tests := []struct { + name string + chatID string + wantChanID string + wantThread string + }{ + { + name: "channel only", + chatID: "C123456", + wantChanID: "C123456", + wantThread: "", + }, + { + name: "channel with thread", + chatID: "C123456/1234567890.123456", + wantChanID: "C123456", + wantThread: "1234567890.123456", + }, + { + name: "DM channel", + chatID: "D987654", + wantChanID: "D987654", + wantThread: "", + }, + { + name: "empty string", + chatID: "", + wantChanID: "", + wantThread: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + chanID, threadTS := parseSlackChatID(tt.chatID) + if chanID != tt.wantChanID { + t.Errorf("parseSlackChatID(%q) channelID = %q, want %q", tt.chatID, chanID, tt.wantChanID) + } + if threadTS != tt.wantThread { + t.Errorf("parseSlackChatID(%q) threadTS = %q, want %q", tt.chatID, threadTS, tt.wantThread) + } + }) + } +} + +func TestStripBotMention(t *testing.T) { + ch := &SlackChannel{botUserID: "U12345BOT"} + + tests := []struct { + name string + input string + want string + }{ + { + name: "mention at start", + input: "<@U12345BOT> hello there", + want: "hello there", + }, + { + name: "mention in middle", + input: "hey <@U12345BOT> can you help", + want: "hey can you help", + }, + { + name: "no mention", + input: "hello world", + want: "hello world", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "only mention", + input: "<@U12345BOT>", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ch.stripBotMention(tt.input) + if got != tt.want { + t.Errorf("stripBotMention(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestNewSlackChannel(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("missing bot token", func(t *testing.T) { + cfg := config.SlackConfig{ + BotToken: "", + AppToken: "xapp-test", + } + _, err := NewSlackChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing bot_token, got nil") + } + }) + + t.Run("missing app token", func(t *testing.T) { + cfg := config.SlackConfig{ + BotToken: "xoxb-test", + AppToken: "", + } + _, err := NewSlackChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing app_token, got nil") + } + }) + + t.Run("valid config", func(t *testing.T) { + cfg := config.SlackConfig{ + BotToken: "xoxb-test", + AppToken: "xapp-test", + AllowFrom: []string{"U123"}, + } + ch, err := NewSlackChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != "slack" { + t.Errorf("Name() = %q, want %q", ch.Name(), "slack") + } + if ch.IsRunning() { + t.Error("new channel should not be running") + } + }) +} + +func TestSlackChannelIsAllowed(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("empty allowlist allows all", func(t *testing.T) { + cfg := config.SlackConfig{ + BotToken: "xoxb-test", + AppToken: "xapp-test", + AllowFrom: []string{}, + } + ch, _ := NewSlackChannel(cfg, msgBus) + if !ch.IsAllowed("U_ANYONE") { + t.Error("empty allowlist should allow all users") + } + }) + + t.Run("allowlist restricts users", func(t *testing.T) { + cfg := config.SlackConfig{ + BotToken: "xoxb-test", + AppToken: "xapp-test", + AllowFrom: []string{"U_ALLOWED"}, + } + ch, _ := NewSlackChannel(cfg, msgBus) + if !ch.IsAllowed("U_ALLOWED") { + t.Error("allowed user should pass allowlist check") + } + if ch.IsAllowed("U_BLOCKED") { + t.Error("non-allowed user should be blocked") + } + }) +} diff --git a/pkg/channels/telegram/init.go b/pkg/channels/telegram/init.go new file mode 100644 index 000000000..ac87bb805 --- /dev/null +++ b/pkg/channels/telegram/init.go @@ -0,0 +1,13 @@ +package telegram + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewTelegramChannel(cfg, b) + }) +} diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go new file mode 100644 index 000000000..f4c5108df --- /dev/null +++ b/pkg/channels/telegram/telegram.go @@ -0,0 +1,526 @@ +package telegram + +import ( + "context" + "fmt" + "net/http" + "net/url" + "os" + "regexp" + "strings" + "sync" + "time" + + th "github.com/mymmrac/telego/telegohandler" + + "github.com/mymmrac/telego" + "github.com/mymmrac/telego/telegohandler" + tu "github.com/mymmrac/telego/telegoutil" + + "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" + "github.com/sipeed/picoclaw/pkg/voice" +) + +type TelegramChannel struct { + *channels.BaseChannel + bot *telego.Bot + commands TelegramCommander + config *config.Config + chatIDs map[string]int64 + transcriber *voice.GroqTranscriber + placeholders sync.Map // chatID -> messageID + stopThinking sync.Map // chatID -> thinkingCancel +} + +type thinkingCancel struct { + fn context.CancelFunc +} + +func (c *thinkingCancel) Cancel() { + if c != nil && c.fn != nil { + c.fn() + } +} + +func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { + var opts []telego.BotOption + telegramCfg := cfg.Channels.Telegram + + if telegramCfg.Proxy != "" { + proxyURL, parseErr := url.Parse(telegramCfg.Proxy) + if parseErr != nil { + return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr) + } + opts = append(opts, telego.WithHTTPClient(&http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + }, + })) + } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" { + // Use environment proxy if configured + opts = append(opts, telego.WithHTTPClient(&http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + }, + })) + } + + bot, err := telego.NewBot(telegramCfg.Token, opts...) + if err != nil { + return nil, fmt.Errorf("failed to create telegram bot: %w", err) + } + + base := channels.NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom) + + return &TelegramChannel{ + BaseChannel: base, + commands: NewTelegramCommands(bot, cfg), + bot: bot, + config: cfg, + chatIDs: make(map[string]int64), + transcriber: nil, + placeholders: sync.Map{}, + stopThinking: sync.Map{}, + }, nil +} + +func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { + c.transcriber = transcriber +} + +func (c *TelegramChannel) Start(ctx context.Context) error { + logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") + + updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{ + Timeout: 30, + }) + if err != nil { + return fmt.Errorf("failed to start long polling: %w", err) + } + + bh, err := telegohandler.NewBotHandler(c.bot, updates) + if err != nil { + return fmt.Errorf("failed to create bot handler: %w", err) + } + + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + c.commands.Help(ctx, message) + return nil + }, th.CommandEqual("help")) + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.Start(ctx, message) + }, th.CommandEqual("start")) + + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.Show(ctx, message) + }, th.CommandEqual("show")) + + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.commands.List(ctx, message) + }, th.CommandEqual("list")) + + bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { + return c.handleMessage(ctx, &message) + }, th.AnyMessage()) + + c.SetRunning(true) + logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{ + "username": c.bot.Username(), + }) + + go bh.Start() + + go func() { + <-ctx.Done() + bh.Stop() + }() + + return nil +} +func (c *TelegramChannel) Stop(ctx context.Context) error { + logger.InfoC("telegram", "Stopping Telegram bot...") + c.SetRunning(false) + return nil +} + +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("telegram bot not running") + } + + chatID, err := parseChatID(msg.ChatID) + if err != nil { + return fmt.Errorf("invalid chat ID: %w", err) + } + + // Stop thinking animation + if stop, ok := c.stopThinking.Load(msg.ChatID); ok { + if cf, ok := stop.(*thinkingCancel); ok && cf != nil { + cf.Cancel() + } + c.stopThinking.Delete(msg.ChatID) + } + + htmlContent := markdownToTelegramHTML(msg.Content) + + // Try to edit placeholder + if pID, ok := c.placeholders.Load(msg.ChatID); ok { + c.placeholders.Delete(msg.ChatID) + editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) + editMsg.ParseMode = telego.ModeHTML + + if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { + return nil + } + // Fallback to new message if edit fails + } + + tgMsg := tu.Message(tu.ID(chatID), htmlContent) + tgMsg.ParseMode = telego.ModeHTML + + if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { + logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ + "error": err.Error(), + }) + tgMsg.ParseMode = "" + _, err = c.bot.SendMessage(ctx, tgMsg) + return err + } + + return nil +} + +func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { + if message == nil { + return fmt.Errorf("message is nil") + } + + user := message.From + if user == nil { + return fmt.Errorf("message sender (user) is nil") + } + + senderID := fmt.Sprintf("%d", user.ID) + if user.Username != "" { + senderID = fmt.Sprintf("%d|%s", user.ID, user.Username) + } + + // 检查白名单,避免为被拒绝的用户下载附件 + if !c.IsAllowed(senderID) { + logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{ + "user_id": senderID, + }) + return nil + } + + chatID := message.Chat.ID + c.chatIDs[senderID] = chatID + + content := "" + mediaPaths := []string{} + localFiles := []string{} // 跟踪需要清理的本地文件 + + // 确保临时文件在函数返回时被清理 + defer func() { + for _, file := range localFiles { + if err := os.Remove(file); err != nil { + logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{ + "file": file, + "error": err.Error(), + }) + } + } + }() + + if message.Text != "" { + content += message.Text + } + + if message.Caption != "" { + if content != "" { + content += "\n" + } + content += message.Caption + } + + if len(message.Photo) > 0 { + photo := message.Photo[len(message.Photo)-1] + photoPath := c.downloadPhoto(ctx, photo.FileID) + if photoPath != "" { + localFiles = append(localFiles, photoPath) + mediaPaths = append(mediaPaths, photoPath) + if content != "" { + content += "\n" + } + content += "[image: photo]" + } + } + + if message.Voice != nil { + voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") + if voicePath != "" { + localFiles = append(localFiles, voicePath) + mediaPaths = append(mediaPaths, voicePath) + + transcribedText := "" + if c.transcriber != nil && c.transcriber.IsAvailable() { + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + result, err := c.transcriber.Transcribe(ctx, voicePath) + if err != nil { + logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{ + "error": err.Error(), + "path": voicePath, + }) + transcribedText = "[voice (transcription failed)]" + } else { + transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) + logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{ + "text": result.Text, + }) + } + } else { + transcribedText = "[voice]" + } + + if content != "" { + content += "\n" + } + content += transcribedText + } + } + + if message.Audio != nil { + audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") + if audioPath != "" { + localFiles = append(localFiles, audioPath) + mediaPaths = append(mediaPaths, audioPath) + if content != "" { + content += "\n" + } + content += "[audio]" + } + } + + if message.Document != nil { + docPath := c.downloadFile(ctx, message.Document.FileID, "") + if docPath != "" { + localFiles = append(localFiles, docPath) + mediaPaths = append(mediaPaths, docPath) + if content != "" { + content += "\n" + } + content += "[file]" + } + } + + if content == "" { + content = "[empty message]" + } + + logger.DebugCF("telegram", "Received message", map[string]interface{}{ + "sender_id": senderID, + "chat_id": fmt.Sprintf("%d", chatID), + "preview": utils.Truncate(content, 50), + }) + + // Thinking indicator + err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) + if err != nil { + logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{ + "error": err.Error(), + }) + } + + // Stop any previous thinking animation + chatIDStr := fmt.Sprintf("%d", chatID) + if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { + if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { + cf.Cancel() + } + } + + // Create cancel function for thinking state + _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) + c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) + + pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) + if err == nil { + pID := pMsg.MessageID + c.placeholders.Store(chatIDStr, pID) + } + + peerKind := "direct" + peerID := fmt.Sprintf("%d", user.ID) + if message.Chat.Type != "private" { + peerKind = "group" + peerID = fmt.Sprintf("%d", chatID) + } + + metadata := map[string]string{ + "message_id": fmt.Sprintf("%d", message.MessageID), + "user_id": fmt.Sprintf("%d", user.ID), + "username": user.Username, + "first_name": user.FirstName, + "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), + "peer_kind": peerKind, + "peer_id": peerID, + } + + c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) + return nil +} + +func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { + file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) + if err != nil { + logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{ + "error": err.Error(), + }) + return "" + } + + return c.downloadFileWithInfo(file, ".jpg") +} + +func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string { + if file.FilePath == "" { + return "" + } + + url := c.bot.FileDownloadURL(file.FilePath) + logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url}) + + // Use FilePath as filename for better identification + filename := file.FilePath + ext + return utils.DownloadFile(url, filename, utils.DownloadOptions{ + LoggerPrefix: "telegram", + }) +} + +func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string { + file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) + if err != nil { + logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{ + "error": err.Error(), + }) + return "" + } + + return c.downloadFileWithInfo(file, ext) +} + +func parseChatID(chatIDStr string) (int64, error) { + var id int64 + _, err := fmt.Sscanf(chatIDStr, "%d", &id) + return id, err +} + +func markdownToTelegramHTML(text string) string { + if text == "" { + return "" + } + + codeBlocks := extractCodeBlocks(text) + text = codeBlocks.text + + inlineCodes := extractInlineCodes(text) + text = inlineCodes.text + + text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1") + + text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1") + + text = escapeHTML(text) + + text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `$1`) + + text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "$1") + + text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "$1") + + reItalic := regexp.MustCompile(`_([^_]+)_`) + text = reItalic.ReplaceAllStringFunc(text, func(s string) string { + match := reItalic.FindStringSubmatch(s) + if len(match) < 2 { + return s + } + return "" + match[1] + "" + }) + + text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "$1") + + text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ") + + for i, code := range inlineCodes.codes { + escaped := escapeHTML(code) + text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) + } + + for i, code := range codeBlocks.codes { + escaped := escapeHTML(code) + text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("
%s
", escaped)) + } + + return text +} + +type codeBlockMatch struct { + text string + codes []string +} + +func extractCodeBlocks(text string) codeBlockMatch { + re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") + matches := re.FindAllStringSubmatch(text, -1) + + codes := make([]string, 0, len(matches)) + for _, match := range matches { + codes = append(codes, match[1]) + } + + i := 0 + text = re.ReplaceAllStringFunc(text, func(m string) string { + placeholder := fmt.Sprintf("\x00CB%d\x00", i) + i++ + return placeholder + }) + + return codeBlockMatch{text: text, codes: codes} +} + +type inlineCodeMatch struct { + text string + codes []string +} + +func extractInlineCodes(text string) inlineCodeMatch { + re := regexp.MustCompile("`([^`]+)`") + matches := re.FindAllStringSubmatch(text, -1) + + codes := make([]string, 0, len(matches)) + for _, match := range matches { + codes = append(codes, match[1]) + } + + i := 0 + text = re.ReplaceAllStringFunc(text, func(m string) string { + placeholder := fmt.Sprintf("\x00IC%d\x00", i) + i++ + return placeholder + }) + + return inlineCodeMatch{text: text, codes: codes} +} + +func escapeHTML(text string) string { + text = strings.ReplaceAll(text, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + return text +} diff --git a/pkg/channels/telegram/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go new file mode 100644 index 000000000..4bf1b3aff --- /dev/null +++ b/pkg/channels/telegram/telegram_commands.go @@ -0,0 +1,153 @@ +package telegram + +import ( + "context" + "fmt" + "strings" + + "github.com/mymmrac/telego" + "github.com/sipeed/picoclaw/pkg/config" +) + +type TelegramCommander interface { + Help(ctx context.Context, message telego.Message) error + Start(ctx context.Context, message telego.Message) error + Show(ctx context.Context, message telego.Message) error + List(ctx context.Context, message telego.Message) error +} + +type cmd struct { + bot *telego.Bot + config *config.Config +} + +func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander { + return &cmd{ + bot: bot, + config: cfg, + } +} + +func commandArgs(text string) string { + parts := strings.SplitN(text, " ", 2) + if len(parts) < 2 { + return "" + } + return strings.TrimSpace(parts[1]) +} +func (c *cmd) Help(ctx context.Context, message telego.Message) error { + msg := `/start - Start the bot +/help - Show this help message +/show [model|channel] - Show current configuration +/list [models|channels] - List available options + ` + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: msg, + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} + +func (c *cmd) Start(ctx context.Context, message telego.Message) error { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Hello! I am PicoClaw 🦞", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} + +func (c *cmd) Show(ctx context.Context, message telego.Message) error { + args := commandArgs(message.Text) + if args == "" { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Usage: /show [model|channel]", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + var response string + switch args { + case "model": + response = fmt.Sprintf("Current Model: %s (Provider: %s)", + c.config.Agents.Defaults.Model, + c.config.Agents.Defaults.Provider) + case "channel": + response = "Current Channel: telegram" + default: + response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args) + } + + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: response, + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} +func (c *cmd) List(ctx context.Context, message telego.Message) error { + args := commandArgs(message.Text) + if args == "" { + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: "Usage: /list [models|channels]", + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err + } + + var response string + switch args { + case "models": + provider := c.config.Agents.Defaults.Provider + if provider == "" { + provider = "configured default" + } + response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", + c.config.Agents.Defaults.Model, provider) + + case "channels": + var enabled []string + if c.config.Channels.Telegram.Enabled { + enabled = append(enabled, "telegram") + } + if c.config.Channels.WhatsApp.Enabled { + enabled = append(enabled, "whatsapp") + } + if c.config.Channels.Feishu.Enabled { + enabled = append(enabled, "feishu") + } + if c.config.Channels.Discord.Enabled { + enabled = append(enabled, "discord") + } + if c.config.Channels.Slack.Enabled { + enabled = append(enabled, "slack") + } + response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")) + + default: + response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args) + } + + _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ + ChatID: telego.ChatID{ID: message.Chat.ID}, + Text: response, + ReplyParameters: &telego.ReplyParameters{ + MessageID: message.MessageID, + }, + }) + return err +} diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go new file mode 100644 index 000000000..85c017958 --- /dev/null +++ b/pkg/channels/wecom/app.go @@ -0,0 +1,636 @@ +package wecom + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "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" +) + +const ( + wecomAPIBase = "https://qyapi.weixin.qq.com" +) + +// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用) +type WeComAppChannel struct { + *channels.BaseChannel + config config.WeComAppConfig + server *http.Server + accessToken string + tokenExpiry time.Time + tokenMu sync.RWMutex + ctx context.Context + cancel context.CancelFunc + processedMsgs map[string]bool // Message deduplication: msg_id -> processed + msgMu sync.RWMutex +} + +// WeComXMLMessage represents the XML message structure from WeCom +type WeComXMLMessage struct { + XMLName xml.Name `xml:"xml"` + ToUserName string `xml:"ToUserName"` + FromUserName string `xml:"FromUserName"` + CreateTime int64 `xml:"CreateTime"` + MsgType string `xml:"MsgType"` + Content string `xml:"Content"` + MsgId int64 `xml:"MsgId"` + AgentID int64 `xml:"AgentID"` + PicUrl string `xml:"PicUrl"` + MediaId string `xml:"MediaId"` + Format string `xml:"Format"` + ThumbMediaId string `xml:"ThumbMediaId"` + LocationX float64 `xml:"Location_X"` + LocationY float64 `xml:"Location_Y"` + Scale int `xml:"Scale"` + Label string `xml:"Label"` + Title string `xml:"Title"` + Description string `xml:"Description"` + Url string `xml:"Url"` + Event string `xml:"Event"` + EventKey string `xml:"EventKey"` +} + +// WeComTextMessage represents text message for sending +type WeComTextMessage struct { + ToUser string `json:"touser"` + MsgType string `json:"msgtype"` + AgentID int64 `json:"agentid"` + Text struct { + Content string `json:"content"` + } `json:"text"` + Safe int `json:"safe,omitempty"` +} + +// WeComMarkdownMessage represents markdown message for sending +type WeComMarkdownMessage struct { + ToUser string `json:"touser"` + MsgType string `json:"msgtype"` + AgentID int64 `json:"agentid"` + Markdown struct { + Content string `json:"content"` + } `json:"markdown"` +} + +// WeComImageMessage represents image message for sending +type WeComImageMessage struct { + ToUser string `json:"touser"` + MsgType string `json:"msgtype"` + AgentID int64 `json:"agentid"` + Image struct { + MediaID string `json:"media_id"` + } `json:"image"` +} + +// WeComAccessTokenResponse represents the access token API response +type WeComAccessTokenResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` +} + +// WeComSendMessageResponse represents the send message API response +type WeComSendMessageResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + InvalidUser string `json:"invaliduser"` + InvalidParty string `json:"invalidparty"` + InvalidTag string `json:"invalidtag"` +} + +// PKCS7Padding adds PKCS7 padding +type PKCS7Padding struct{} + +// NewWeComAppChannel creates a new WeCom App channel instance +func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) { + if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 { + return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required") + } + + base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom) + + return &WeComAppChannel{ + BaseChannel: base, + config: cfg, + processedMsgs: make(map[string]bool), + }, nil +} + +// Name returns the channel name +func (c *WeComAppChannel) Name() string { + return "wecom_app" +} + +// Start initializes the WeCom App channel with HTTP webhook server +func (c *WeComAppChannel) Start(ctx context.Context) error { + logger.InfoC("wecom_app", "Starting WeCom App channel...") + + c.ctx, c.cancel = context.WithCancel(ctx) + + // Get initial access token + if err := c.refreshAccessToken(); err != nil { + logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]interface{}{ + "error": err.Error(), + }) + } + + // Start token refresh goroutine + go c.tokenRefreshLoop() + + // Setup HTTP server for webhook + mux := http.NewServeMux() + webhookPath := c.config.WebhookPath + if webhookPath == "" { + webhookPath = "/webhook/wecom-app" + } + mux.HandleFunc(webhookPath, c.handleWebhook) + + // Health check endpoint + mux.HandleFunc("/health/wecom-app", c.handleHealth) + + addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) + c.server = &http.Server{ + Addr: addr, + Handler: mux, + } + + c.SetRunning(true) + logger.InfoCF("wecom_app", "WeCom App channel started", map[string]interface{}{ + "address": addr, + "path": webhookPath, + }) + + // Start server in goroutine + go func() { + if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("wecom_app", "HTTP server error", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + + return nil +} + +// Stop gracefully stops the WeCom App channel +func (c *WeComAppChannel) Stop(ctx context.Context) error { + logger.InfoC("wecom_app", "Stopping WeCom App channel...") + + if c.cancel != nil { + c.cancel() + } + + if c.server != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + c.server.Shutdown(shutdownCtx) + } + + c.SetRunning(false) + logger.InfoC("wecom_app", "WeCom App channel stopped") + return nil +} + +// Send sends a message to WeCom user proactively using access token +func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("wecom_app channel not running") + } + + accessToken := c.getAccessToken() + if accessToken == "" { + return fmt.Errorf("no valid access token available") + } + + logger.DebugCF("wecom_app", "Sending message", map[string]interface{}{ + "chat_id": msg.ChatID, + "preview": utils.Truncate(msg.Content, 100), + }) + + return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) +} + +// handleWebhook handles incoming webhook requests from WeCom +func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // Log all incoming requests for debugging + logger.DebugCF("wecom_app", "Received webhook request", map[string]interface{}{ + "method": r.Method, + "url": r.URL.String(), + "path": r.URL.Path, + "query": r.URL.RawQuery, + }) + + if r.Method == http.MethodGet { + // Handle verification request + c.handleVerification(ctx, w, r) + return + } + + if r.Method == http.MethodPost { + // Handle message callback + c.handleMessageCallback(ctx, w, r) + return + } + + logger.WarnCF("wecom_app", "Method not allowed", map[string]interface{}{ + "method": r.Method, + }) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} + +// handleVerification handles the URL verification request from WeCom +func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + msgSignature := query.Get("msg_signature") + timestamp := query.Get("timestamp") + nonce := query.Get("nonce") + echostr := query.Get("echostr") + + logger.DebugCF("wecom_app", "Handling verification request", map[string]interface{}{ + "msg_signature": msgSignature, + "timestamp": timestamp, + "nonce": nonce, + "echostr": echostr, + "corp_id": c.config.CorpID, + }) + + if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" { + logger.ErrorC("wecom_app", "Missing parameters in verification request") + http.Error(w, "Missing parameters", http.StatusBadRequest) + return + } + + // Verify signature + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { + logger.WarnCF("wecom_app", "Signature verification failed", map[string]interface{}{ + "token": c.config.Token, + "msg_signature": msgSignature, + "timestamp": timestamp, + "nonce": nonce, + }) + http.Error(w, "Invalid signature", http.StatusForbidden) + return + } + + logger.DebugC("wecom_app", "Signature verification passed") + + // Decrypt echostr with CorpID verification + // For WeCom App (自建应用), receiveid should be corp_id + logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]interface{}{ + "encoding_aes_key": c.config.EncodingAESKey, + "corp_id": c.config.CorpID, + }) + decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{ + "error": err.Error(), + "encoding_aes_key": c.config.EncodingAESKey, + "corp_id": c.config.CorpID, + }) + http.Error(w, "Decryption failed", http.StatusInternalServerError) + return + } + + logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]interface{}{ + "decrypted": decryptedEchoStr, + }) + + // Remove BOM and whitespace as per WeCom documentation + // The response must be plain text without quotes, BOM, or newlines + decryptedEchoStr = strings.TrimSpace(decryptedEchoStr) + decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM + w.Write([]byte(decryptedEchoStr)) +} + +// handleMessageCallback handles incoming messages from WeCom +func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + msgSignature := query.Get("msg_signature") + timestamp := query.Get("timestamp") + nonce := query.Get("nonce") + + if msgSignature == "" || timestamp == "" || nonce == "" { + http.Error(w, "Missing parameters", http.StatusBadRequest) + return + } + + // Read request body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Parse XML to get encrypted message + var encryptedMsg struct { + XMLName xml.Name `xml:"xml"` + ToUserName string `xml:"ToUserName"` + Encrypt string `xml:"Encrypt"` + AgentID string `xml:"AgentID"` + } + + if err := xml.Unmarshal(body, &encryptedMsg); err != nil { + logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Invalid XML", http.StatusBadRequest) + return + } + + // Verify signature + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { + logger.WarnC("wecom_app", "Message signature verification failed") + http.Error(w, "Invalid signature", http.StatusForbidden) + return + } + + // Decrypt message with CorpID verification + // For WeCom App (自建应用), receiveid should be corp_id + decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Decryption failed", http.StatusInternalServerError) + return + } + + // Parse decrypted XML message + var msg WeComXMLMessage + if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil { + logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Invalid message format", http.StatusBadRequest) + return + } + + // Process the message with context + go c.processMessage(ctx, msg) + + // Return success response immediately + // WeCom App requires response within configured timeout (default 5 seconds) + w.Write([]byte("success")) +} + +// processMessage processes the received message +func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) { + // Skip non-text messages for now (can be extended) + if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" { + logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]interface{}{ + "msg_type": msg.MsgType, + }) + return + } + + // Message deduplication: Use msg_id to prevent duplicate processing + // As per WeCom documentation, use msg_id for deduplication + msgID := fmt.Sprintf("%d", msg.MsgId) + c.msgMu.Lock() + if c.processedMsgs[msgID] { + c.msgMu.Unlock() + logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]interface{}{ + "msg_id": msgID, + }) + return + } + c.processedMsgs[msgID] = true + c.msgMu.Unlock() + + // Clean up old messages periodically (keep last 1000) + if len(c.processedMsgs) > 1000 { + c.msgMu.Lock() + c.processedMsgs = make(map[string]bool) + c.msgMu.Unlock() + } + + senderID := msg.FromUserName + chatID := senderID // WeCom App uses user ID as chat ID for direct messages + + // Build metadata + // WeCom App only supports direct messages (private chat) + metadata := map[string]string{ + "msg_type": msg.MsgType, + "msg_id": fmt.Sprintf("%d", msg.MsgId), + "agent_id": fmt.Sprintf("%d", msg.AgentID), + "platform": "wecom_app", + "media_id": msg.MediaId, + "create_time": fmt.Sprintf("%d", msg.CreateTime), + "peer_kind": "direct", + "peer_id": senderID, + } + + content := msg.Content + + logger.DebugCF("wecom_app", "Received message", map[string]interface{}{ + "sender_id": senderID, + "msg_type": msg.MsgType, + "preview": utils.Truncate(content, 50), + }) + + // Handle the message through the base channel + c.HandleMessage(senderID, chatID, content, nil, metadata) +} + +// tokenRefreshLoop periodically refreshes the access token +func (c *WeComAppChannel) tokenRefreshLoop() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + for { + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + if err := c.refreshAccessToken(); err != nil { + logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]interface{}{ + "error": err.Error(), + }) + } + } + } +} + +// refreshAccessToken gets a new access token from WeCom API +func (c *WeComAppChannel) refreshAccessToken() error { + apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s", + wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret)) + + resp, err := http.Get(apiURL) + if err != nil { + return fmt.Errorf("failed to request access token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var tokenResp WeComAccessTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if tokenResp.ErrCode != 0 { + return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode) + } + + c.tokenMu.Lock() + c.accessToken = tokenResp.AccessToken + c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early + c.tokenMu.Unlock() + + logger.DebugC("wecom_app", "Access token refreshed successfully") + return nil +} + +// getAccessToken returns the current valid access token +func (c *WeComAppChannel) getAccessToken() string { + c.tokenMu.RLock() + defer c.tokenMu.RUnlock() + + if time.Now().After(c.tokenExpiry) { + return "" + } + + return c.accessToken +} + +// sendTextMessage sends a text message to a user +func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error { + apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) + + msg := WeComTextMessage{ + ToUser: userID, + MsgType: "text", + AgentID: c.config.AgentID, + } + msg.Text.Content = content + + jsonData, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + // Use configurable timeout (default 5 seconds) + timeout := c.config.ReplyTimeout + if timeout <= 0 { + timeout = 5 + } + + reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var sendResp WeComSendMessageResponse + if err := json.Unmarshal(body, &sendResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if sendResp.ErrCode != 0 { + return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) + } + + return nil +} + +// sendMarkdownMessage sends a markdown message to a user +func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error { + apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) + + msg := WeComMarkdownMessage{ + ToUser: userID, + MsgType: "markdown", + AgentID: c.config.AgentID, + } + msg.Markdown.Content = content + + jsonData, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + // Use configurable timeout (default 5 seconds) + timeout := c.config.ReplyTimeout + if timeout <= 0 { + timeout = 5 + } + + reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var sendResp WeComSendMessageResponse + if err := json.Unmarshal(body, &sendResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if sendResp.ErrCode != 0 { + return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) + } + + return nil +} + +// handleHealth handles health check requests +func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { + status := map[string]interface{}{ + "status": "ok", + "running": c.IsRunning(), + "has_token": c.getAccessToken() != "", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go new file mode 100644 index 000000000..d9817fd49 --- /dev/null +++ b/pkg/channels/wecom/app_test.go @@ -0,0 +1,1086 @@ +package wecom + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "encoding/json" + "encoding/xml" + "fmt" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// generateTestAESKeyApp generates a valid test AES key for WeCom App +func generateTestAESKeyApp() string { + // AES key needs to be 32 bytes (256 bits) for AES-256 + key := make([]byte, 32) + for i := range key { + key[i] = byte(i + 1) + } + // Return base64 encoded key without padding + return base64.StdEncoding.EncodeToString(key)[:43] +} + +// encryptTestMessageApp encrypts a message for testing WeCom App +func encryptTestMessageApp(message, aesKey string) (string, error) { + // Decode AES key + key, err := base64.StdEncoding.DecodeString(aesKey + "=") + if err != nil { + return "", err + } + + // Prepare message: random(16) + msg_len(4) + msg + corp_id + random := make([]byte, 0, 16) + for i := 0; i < 16; i++ { + random = append(random, byte(i+1)) + } + + msgBytes := []byte(message) + corpID := []byte("test_corp_id") + + msgLen := uint32(len(msgBytes)) + lenBytes := make([]byte, 4) + binary.BigEndian.PutUint32(lenBytes, msgLen) + + plainText := append(random, lenBytes...) + plainText = append(plainText, msgBytes...) + plainText = append(plainText, corpID...) + + // PKCS7 padding + blockSize := aes.BlockSize + padding := blockSize - len(plainText)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + plainText = append(plainText, padText...) + + // Encrypt + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize]) + cipherText := make([]byte, len(plainText)) + mode.CryptBlocks(cipherText, plainText) + + return base64.StdEncoding.EncodeToString(cipherText), nil +} + +// generateSignatureApp generates a signature for testing WeCom App +func generateSignatureApp(token, timestamp, nonce, msgEncrypt string) string { + params := []string{token, timestamp, nonce, msgEncrypt} + sort.Strings(params) + str := strings.Join(params, "") + hash := sha1.Sum([]byte(str)) + return fmt.Sprintf("%x", hash) +} + +func TestNewWeComAppChannel(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("missing corp_id", func(t *testing.T) { + cfg := config.WeComAppConfig{ + CorpID: "", + CorpSecret: "test_secret", + AgentID: 1000002, + } + _, err := NewWeComAppChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing corp_id, got nil") + } + }) + + t.Run("missing corp_secret", func(t *testing.T) { + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "", + AgentID: 1000002, + } + _, err := NewWeComAppChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing corp_secret, got nil") + } + }) + + t.Run("missing agent_id", func(t *testing.T) { + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 0, + } + _, err := NewWeComAppChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing agent_id, got nil") + } + }) + + t.Run("valid config", func(t *testing.T) { + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + AllowFrom: []string{"user1", "user2"}, + } + ch, err := NewWeComAppChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != "wecom_app" { + t.Errorf("Name() = %q, want %q", ch.Name(), "wecom_app") + } + if ch.IsRunning() { + t.Error("new channel should not be running") + } + }) +} + +func TestWeComAppChannelIsAllowed(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("empty allowlist allows all", func(t *testing.T) { + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + AllowFrom: []string{}, + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + if !ch.IsAllowed("any_user") { + t.Error("empty allowlist should allow all users") + } + }) + + t.Run("allowlist restricts users", func(t *testing.T) { + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + AllowFrom: []string{"allowed_user"}, + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + if !ch.IsAllowed("allowed_user") { + t.Error("allowed user should pass allowlist check") + } + if ch.IsAllowed("blocked_user") { + t.Error("non-allowed user should be blocked") + } + }) +} + +func TestWeComAppVerifySignature(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + Token: "test_token", + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + t.Run("valid signature", func(t *testing.T) { + timestamp := "1234567890" + nonce := "test_nonce" + msgEncrypt := "test_message" + expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt) + + if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { + t.Error("valid signature should pass verification") + } + }) + + t.Run("invalid signature", func(t *testing.T) { + timestamp := "1234567890" + nonce := "test_nonce" + msgEncrypt := "test_message" + + if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { + t.Error("invalid signature should fail verification") + } + }) + + t.Run("empty token skips verification", func(t *testing.T) { + cfgEmpty := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + Token: "", + } + chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) + + if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { + t.Error("empty token should skip verification and return true") + } + }) +} + +func TestWeComAppDecryptMessage(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("decrypt without AES key", func(t *testing.T) { + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + EncodingAESKey: "", + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + // Without AES key, message should be base64 decoded only + plainText := "hello world" + encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) + + result, err := decryptMessage(encoded, ch.config.EncodingAESKey) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != plainText { + t.Errorf("decryptMessage() = %q, want %q", result, plainText) + } + }) + + t.Run("decrypt with AES key", func(t *testing.T) { + aesKey := generateTestAESKeyApp() + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + EncodingAESKey: aesKey, + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + originalMsg := "Hello" + encrypted, err := encryptTestMessageApp(originalMsg, aesKey) + if err != nil { + t.Fatalf("failed to encrypt test message: %v", err) + } + + result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != originalMsg { + t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg) + } + }) + + t.Run("invalid base64", func(t *testing.T) { + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + EncodingAESKey: "", + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) + if err == nil { + t.Error("expected error for invalid base64, got nil") + } + }) + + t.Run("invalid AES key", func(t *testing.T) { + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + EncodingAESKey: "invalid_key", + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) + if err == nil { + t.Error("expected error for invalid AES key, got nil") + } + }) + + t.Run("ciphertext too short", func(t *testing.T) { + aesKey := generateTestAESKeyApp() + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + EncodingAESKey: aesKey, + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + // Encrypt a very short message that results in ciphertext less than block size + shortData := make([]byte, 8) + _, err := decryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey) + if err == nil { + t.Error("expected error for short ciphertext, got nil") + } + }) +} + +func TestWeComAppPKCS7Unpad(t *testing.T) { + tests := []struct { + name string + input []byte + expected []byte + }{ + { + name: "empty input", + input: []byte{}, + expected: []byte{}, + }, + { + name: "valid padding 3 bytes", + input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...), + expected: []byte("hello"), + }, + { + name: "valid padding 16 bytes (full block)", + input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...), + expected: []byte("123456789012345"), + }, + { + name: "invalid padding larger than data", + input: []byte{20}, + expected: nil, // should return error + }, + { + name: "invalid padding zero", + input: append([]byte("test"), byte(0)), + expected: nil, // should return error + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := pkcs7Unpad(tt.input) + if tt.expected == nil { + // This case should return an error + if err == nil { + t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result) + } + return + } + if err != nil { + t.Errorf("pkcs7Unpad() unexpected error: %v", err) + return + } + if !bytes.Equal(result, tt.expected) { + t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestWeComAppHandleVerification(t *testing.T) { + msgBus := bus.NewMessageBus() + aesKey := generateTestAESKeyApp() + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + Token: "test_token", + EncodingAESKey: aesKey, + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + t.Run("valid verification request", func(t *testing.T) { + echostr := "test_echostr_123" + encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey) + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr) + + req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + w := httptest.NewRecorder() + + ch.handleVerification(context.Background(), w, req) + + if w.Code != http.StatusOK { + t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != echostr { + t.Errorf("response body = %q, want %q", w.Body.String(), echostr) + } + }) + + t.Run("missing parameters", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=sig×tamp=ts", nil) + w := httptest.NewRecorder() + + ch.handleVerification(context.Background(), w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) + } + }) + + t.Run("invalid signature", func(t *testing.T) { + echostr := "test_echostr" + encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey) + timestamp := "1234567890" + nonce := "test_nonce" + + req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + w := httptest.NewRecorder() + + ch.handleVerification(context.Background(), w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) + } + }) +} + +func TestWeComAppHandleMessageCallback(t *testing.T) { + msgBus := bus.NewMessageBus() + aesKey := generateTestAESKeyApp() + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + Token: "test_token", + EncodingAESKey: aesKey, + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + t.Run("valid message callback", func(t *testing.T) { + // Create XML message + xmlMsg := WeComXMLMessage{ + ToUserName: "corp_id", + FromUserName: "user123", + CreateTime: 1234567890, + MsgType: "text", + Content: "Hello World", + MsgId: 123456, + AgentID: 1000002, + } + xmlData, _ := xml.Marshal(xmlMsg) + + // Encrypt message + encrypted, _ := encryptTestMessageApp(string(xmlData), aesKey) + + // Create encrypted XML wrapper + encryptedWrapper := struct { + XMLName xml.Name `xml:"xml"` + Encrypt string `xml:"Encrypt"` + }{ + Encrypt: encrypted, + } + wrapperData, _ := xml.Marshal(encryptedWrapper) + + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignatureApp("test_token", timestamp, nonce, encrypted) + + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + w := httptest.NewRecorder() + + ch.handleMessageCallback(context.Background(), w, req) + + if w.Code != http.StatusOK { + t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != "success" { + t.Errorf("response body = %q, want %q", w.Body.String(), "success") + } + }) + + t.Run("missing parameters", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=sig", nil) + w := httptest.NewRecorder() + + ch.handleMessageCallback(context.Background(), w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) + } + }) + + t.Run("invalid XML", func(t *testing.T) { + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignatureApp("test_token", timestamp, nonce, "") + + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml")) + w := httptest.NewRecorder() + + ch.handleMessageCallback(context.Background(), w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) + } + }) + + t.Run("invalid signature", func(t *testing.T) { + encryptedWrapper := struct { + XMLName xml.Name `xml:"xml"` + Encrypt string `xml:"Encrypt"` + }{ + Encrypt: "encrypted_data", + } + wrapperData, _ := xml.Marshal(encryptedWrapper) + + timestamp := "1234567890" + nonce := "test_nonce" + + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + w := httptest.NewRecorder() + + ch.handleMessageCallback(context.Background(), w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) + } + }) +} + +func TestWeComAppProcessMessage(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + t.Run("process text message", func(t *testing.T) { + msg := WeComXMLMessage{ + ToUserName: "corp_id", + FromUserName: "user123", + CreateTime: 1234567890, + MsgType: "text", + Content: "Hello World", + MsgId: 123456, + AgentID: 1000002, + } + + // Should not panic + ch.processMessage(context.Background(), msg) + }) + + t.Run("process image message", func(t *testing.T) { + msg := WeComXMLMessage{ + ToUserName: "corp_id", + FromUserName: "user123", + CreateTime: 1234567890, + MsgType: "image", + PicUrl: "https://example.com/image.jpg", + MediaId: "media_123", + MsgId: 123456, + AgentID: 1000002, + } + + // Should not panic + ch.processMessage(context.Background(), msg) + }) + + t.Run("process voice message", func(t *testing.T) { + msg := WeComXMLMessage{ + ToUserName: "corp_id", + FromUserName: "user123", + CreateTime: 1234567890, + MsgType: "voice", + MediaId: "media_123", + Format: "amr", + MsgId: 123456, + AgentID: 1000002, + } + + // Should not panic + ch.processMessage(context.Background(), msg) + }) + + t.Run("skip unsupported message type", func(t *testing.T) { + msg := WeComXMLMessage{ + ToUserName: "corp_id", + FromUserName: "user123", + CreateTime: 1234567890, + MsgType: "video", + MsgId: 123456, + AgentID: 1000002, + } + + // Should not panic + ch.processMessage(context.Background(), msg) + }) + + t.Run("process event message", func(t *testing.T) { + msg := WeComXMLMessage{ + ToUserName: "corp_id", + FromUserName: "user123", + CreateTime: 1234567890, + MsgType: "event", + Event: "subscribe", + MsgId: 123456, + AgentID: 1000002, + } + + // Should not panic + ch.processMessage(context.Background(), msg) + }) +} + +func TestWeComAppHandleWebhook(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + Token: "test_token", + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + t.Run("GET request calls verification", func(t *testing.T) { + echostr := "test_echostr" + encoded := base64.StdEncoding.EncodeToString([]byte(echostr)) + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignatureApp("test_token", timestamp, nonce, encoded) + + req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil) + w := httptest.NewRecorder() + + ch.handleWebhook(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) + } + }) + + t.Run("POST request calls message callback", func(t *testing.T) { + encryptedWrapper := struct { + XMLName xml.Name `xml:"xml"` + Encrypt string `xml:"Encrypt"` + }{ + Encrypt: base64.StdEncoding.EncodeToString([]byte("test")), + } + wrapperData, _ := xml.Marshal(encryptedWrapper) + + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt) + + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + w := httptest.NewRecorder() + + ch.handleWebhook(w, req) + + // Should not be method not allowed + if w.Code == http.StatusMethodNotAllowed { + t.Error("POST request should not return Method Not Allowed") + } + }) + + t.Run("unsupported method", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/webhook/wecom-app", nil) + w := httptest.NewRecorder() + + ch.handleWebhook(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } + }) +} + +func TestWeComAppHandleHealth(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", nil) + w := httptest.NewRecorder() + + ch.handleHealth(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Content-Type = %q, want %q", contentType, "application/json") + } + + body := w.Body.String() + if !strings.Contains(body, "status") || !strings.Contains(body, "running") || !strings.Contains(body, "has_token") { + t.Errorf("response body should contain status, running, and has_token fields, got: %s", body) + } +} + +func TestWeComAppAccessToken(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.WeComAppConfig{ + CorpID: "test_corp_id", + CorpSecret: "test_secret", + AgentID: 1000002, + } + ch, _ := NewWeComAppChannel(cfg, msgBus) + + t.Run("get empty access token initially", func(t *testing.T) { + token := ch.getAccessToken() + if token != "" { + t.Errorf("getAccessToken() = %q, want empty string", token) + } + }) + + t.Run("set and get access token", func(t *testing.T) { + ch.tokenMu.Lock() + ch.accessToken = "test_token_123" + ch.tokenExpiry = time.Now().Add(1 * time.Hour) + ch.tokenMu.Unlock() + + token := ch.getAccessToken() + if token != "test_token_123" { + t.Errorf("getAccessToken() = %q, want %q", token, "test_token_123") + } + }) + + t.Run("expired token returns empty", func(t *testing.T) { + ch.tokenMu.Lock() + ch.accessToken = "expired_token" + ch.tokenExpiry = time.Now().Add(-1 * time.Hour) + ch.tokenMu.Unlock() + + token := ch.getAccessToken() + if token != "" { + t.Errorf("getAccessToken() = %q, want empty string for expired token", token) + } + }) +} + +func TestWeComAppMessageStructures(t *testing.T) { + t.Run("WeComTextMessage structure", func(t *testing.T) { + msg := WeComTextMessage{ + ToUser: "user123", + MsgType: "text", + AgentID: 1000002, + } + msg.Text.Content = "Hello World" + + if msg.ToUser != "user123" { + t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123") + } + if msg.MsgType != "text" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") + } + if msg.AgentID != 1000002 { + t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) + } + if msg.Text.Content != "Hello World" { + t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") + } + + // Test JSON marshaling + jsonData, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal JSON: %v", err) + } + + var unmarshaled WeComTextMessage + err = json.Unmarshal(jsonData, &unmarshaled) + if err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + if unmarshaled.ToUser != msg.ToUser { + t.Errorf("JSON round-trip failed for ToUser") + } + }) + + t.Run("WeComMarkdownMessage structure", func(t *testing.T) { + msg := WeComMarkdownMessage{ + ToUser: "user123", + MsgType: "markdown", + AgentID: 1000002, + } + msg.Markdown.Content = "# Hello\nWorld" + + if msg.Markdown.Content != "# Hello\nWorld" { + t.Errorf("Markdown.Content = %q, want %q", msg.Markdown.Content, "# Hello\nWorld") + } + + // Test JSON marshaling + jsonData, err := json.Marshal(msg) + if err != nil { + t.Fatalf("failed to marshal JSON: %v", err) + } + + if !bytes.Contains(jsonData, []byte("markdown")) { + t.Error("JSON should contain 'markdown' field") + } + }) + + t.Run("WeComImageMessage structure", func(t *testing.T) { + msg := WeComImageMessage{ + ToUser: "user123", + MsgType: "image", + AgentID: 1000002, + } + msg.Image.MediaID = "media_123456" + + if msg.Image.MediaID != "media_123456" { + t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456") + } + }) + + t.Run("WeComAccessTokenResponse structure", func(t *testing.T) { + jsonData := `{ + "errcode": 0, + "errmsg": "ok", + "access_token": "test_access_token", + "expires_in": 7200 + }` + + var resp WeComAccessTokenResponse + err := json.Unmarshal([]byte(jsonData), &resp) + if err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + if resp.ErrCode != 0 { + t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0) + } + if resp.ErrMsg != "ok" { + t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok") + } + if resp.AccessToken != "test_access_token" { + t.Errorf("AccessToken = %q, want %q", resp.AccessToken, "test_access_token") + } + if resp.ExpiresIn != 7200 { + t.Errorf("ExpiresIn = %d, want %d", resp.ExpiresIn, 7200) + } + }) + + t.Run("WeComSendMessageResponse structure", func(t *testing.T) { + jsonData := `{ + "errcode": 0, + "errmsg": "ok", + "invaliduser": "", + "invalidparty": "", + "invalidtag": "" + }` + + var resp WeComSendMessageResponse + err := json.Unmarshal([]byte(jsonData), &resp) + if err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + if resp.ErrCode != 0 { + t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0) + } + if resp.ErrMsg != "ok" { + t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok") + } + }) +} + +func TestWeComAppXMLMessageStructure(t *testing.T) { + xmlData := ` + + + + 1234567890 + + + 1234567890123456 + 1000002 +` + + var msg WeComXMLMessage + err := xml.Unmarshal([]byte(xmlData), &msg) + if err != nil { + t.Fatalf("failed to unmarshal XML: %v", err) + } + + if msg.ToUserName != "corp_id" { + t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id") + } + if msg.FromUserName != "user123" { + t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123") + } + if msg.CreateTime != 1234567890 { + t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890) + } + if msg.MsgType != "text" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") + } + if msg.Content != "Hello World" { + t.Errorf("Content = %q, want %q", msg.Content, "Hello World") + } + if msg.MsgId != 1234567890123456 { + t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456) + } + if msg.AgentID != 1000002 { + t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) + } +} + +func TestWeComAppXMLMessageImage(t *testing.T) { + xmlData := ` + + + + 1234567890 + + + + 1234567890123456 + 1000002 +` + + var msg WeComXMLMessage + err := xml.Unmarshal([]byte(xmlData), &msg) + if err != nil { + t.Fatalf("failed to unmarshal XML: %v", err) + } + + if msg.MsgType != "image" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "image") + } + if msg.PicUrl != "https://example.com/image.jpg" { + t.Errorf("PicUrl = %q, want %q", msg.PicUrl, "https://example.com/image.jpg") + } + if msg.MediaId != "media_123" { + t.Errorf("MediaId = %q, want %q", msg.MediaId, "media_123") + } +} + +func TestWeComAppXMLMessageVoice(t *testing.T) { + xmlData := ` + + + + 1234567890 + + + + 1234567890123456 + 1000002 +` + + var msg WeComXMLMessage + err := xml.Unmarshal([]byte(xmlData), &msg) + if err != nil { + t.Fatalf("failed to unmarshal XML: %v", err) + } + + if msg.MsgType != "voice" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "voice") + } + if msg.Format != "amr" { + t.Errorf("Format = %q, want %q", msg.Format, "amr") + } +} + +func TestWeComAppXMLMessageLocation(t *testing.T) { + xmlData := ` + + + + 1234567890 + + 39.9042 + 116.4074 + 16 + + 1234567890123456 + 1000002 +` + + var msg WeComXMLMessage + err := xml.Unmarshal([]byte(xmlData), &msg) + if err != nil { + t.Fatalf("failed to unmarshal XML: %v", err) + } + + if msg.MsgType != "location" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "location") + } + if msg.LocationX != 39.9042 { + t.Errorf("LocationX = %f, want %f", msg.LocationX, 39.9042) + } + if msg.LocationY != 116.4074 { + t.Errorf("LocationY = %f, want %f", msg.LocationY, 116.4074) + } + if msg.Scale != 16 { + t.Errorf("Scale = %d, want %d", msg.Scale, 16) + } + if msg.Label != "Beijing" { + t.Errorf("Label = %q, want %q", msg.Label, "Beijing") + } +} + +func TestWeComAppXMLMessageLink(t *testing.T) { + xmlData := ` + + + + 1234567890 + + <![CDATA[Link Title]]> + + + 1234567890123456 + 1000002 +` + + var msg WeComXMLMessage + err := xml.Unmarshal([]byte(xmlData), &msg) + if err != nil { + t.Fatalf("failed to unmarshal XML: %v", err) + } + + if msg.MsgType != "link" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "link") + } + if msg.Title != "Link Title" { + t.Errorf("Title = %q, want %q", msg.Title, "Link Title") + } + if msg.Description != "Link Description" { + t.Errorf("Description = %q, want %q", msg.Description, "Link Description") + } + if msg.Url != "https://example.com" { + t.Errorf("Url = %q, want %q", msg.Url, "https://example.com") + } +} + +func TestWeComAppXMLMessageEvent(t *testing.T) { + xmlData := ` + + + + 1234567890 + + + + 1000002 +` + + var msg WeComXMLMessage + err := xml.Unmarshal([]byte(xmlData), &msg) + if err != nil { + t.Fatalf("failed to unmarshal XML: %v", err) + } + + if msg.MsgType != "event" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "event") + } + if msg.Event != "subscribe" { + t.Errorf("Event = %q, want %q", msg.Event, "subscribe") + } + if msg.EventKey != "event_key_123" { + t.Errorf("EventKey = %q, want %q", msg.EventKey, "event_key_123") + } +} diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go new file mode 100644 index 000000000..9683a308f --- /dev/null +++ b/pkg/channels/wecom/bot.go @@ -0,0 +1,469 @@ +package wecom + +import ( + "bytes" + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "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" +) + +// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人) +// Uses webhook callback mode - simpler than WeCom App but only supports passive replies +type WeComBotChannel struct { + *channels.BaseChannel + config config.WeComConfig + server *http.Server + ctx context.Context + cancel context.CancelFunc + processedMsgs map[string]bool // Message deduplication: msg_id -> processed + msgMu sync.RWMutex +} + +// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT) +type WeComBotMessage struct { + MsgID string `json:"msgid"` + AIBotID string `json:"aibotid"` + ChatID string `json:"chatid"` // Session ID, only present for group chats + ChatType string `json:"chattype"` // "single" for DM, "group" for group chat + From struct { + UserID string `json:"userid"` + } `json:"from"` + ResponseURL string `json:"response_url"` + MsgType string `json:"msgtype"` // text, image, voice, file, mixed + Text struct { + Content string `json:"content"` + } `json:"text"` + Image struct { + URL string `json:"url"` + } `json:"image"` + Voice struct { + Content string `json:"content"` // Voice to text content + } `json:"voice"` + File struct { + URL string `json:"url"` + } `json:"file"` + Mixed struct { + MsgItem []struct { + MsgType string `json:"msgtype"` + Text struct { + Content string `json:"content"` + } `json:"text"` + Image struct { + URL string `json:"url"` + } `json:"image"` + } `json:"msg_item"` + } `json:"mixed"` + Quote struct { + MsgType string `json:"msgtype"` + Text struct { + Content string `json:"content"` + } `json:"text"` + } `json:"quote"` +} + +// WeComBotReplyMessage represents the reply message structure +type WeComBotReplyMessage struct { + MsgType string `json:"msgtype"` + Text struct { + Content string `json:"content"` + } `json:"text,omitempty"` +} + +// NewWeComBotChannel creates a new WeCom Bot channel instance +func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) { + if cfg.Token == "" || cfg.WebhookURL == "" { + return nil, fmt.Errorf("wecom token and webhook_url are required") + } + + base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom) + + return &WeComBotChannel{ + BaseChannel: base, + config: cfg, + processedMsgs: make(map[string]bool), + }, nil +} + +// Name returns the channel name +func (c *WeComBotChannel) Name() string { + return "wecom" +} + +// Start initializes the WeCom Bot channel with HTTP webhook server +func (c *WeComBotChannel) Start(ctx context.Context) error { + logger.InfoC("wecom", "Starting WeCom Bot channel...") + + c.ctx, c.cancel = context.WithCancel(ctx) + + // Setup HTTP server for webhook + mux := http.NewServeMux() + webhookPath := c.config.WebhookPath + if webhookPath == "" { + webhookPath = "/webhook/wecom" + } + mux.HandleFunc(webhookPath, c.handleWebhook) + + // Health check endpoint + mux.HandleFunc("/health/wecom", c.handleHealth) + + addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) + c.server = &http.Server{ + Addr: addr, + Handler: mux, + } + + c.SetRunning(true) + logger.InfoCF("wecom", "WeCom Bot channel started", map[string]interface{}{ + "address": addr, + "path": webhookPath, + }) + + // Start server in goroutine + go func() { + if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("wecom", "HTTP server error", map[string]interface{}{ + "error": err.Error(), + }) + } + }() + + return nil +} + +// Stop gracefully stops the WeCom Bot channel +func (c *WeComBotChannel) Stop(ctx context.Context) error { + logger.InfoC("wecom", "Stopping WeCom Bot channel...") + + if c.cancel != nil { + c.cancel() + } + + if c.server != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + c.server.Shutdown(shutdownCtx) + } + + c.SetRunning(false) + logger.InfoC("wecom", "WeCom Bot channel stopped") + return nil +} + +// Send sends a message to WeCom user via webhook API +// Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message +// For delayed responses, we use the webhook URL +func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return fmt.Errorf("wecom channel not running") + } + + logger.DebugCF("wecom", "Sending message via webhook", map[string]interface{}{ + "chat_id": msg.ChatID, + "preview": utils.Truncate(msg.Content, 100), + }) + + return c.sendWebhookReply(ctx, msg.ChatID, msg.Content) +} + +// handleWebhook handles incoming webhook requests from WeCom +func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if r.Method == http.MethodGet { + // Handle verification request + c.handleVerification(ctx, w, r) + return + } + + if r.Method == http.MethodPost { + // Handle message callback + c.handleMessageCallback(ctx, w, r) + return + } + + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) +} + +// handleVerification handles the URL verification request from WeCom +func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + msgSignature := query.Get("msg_signature") + timestamp := query.Get("timestamp") + nonce := query.Get("nonce") + echostr := query.Get("echostr") + + if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" { + http.Error(w, "Missing parameters", http.StatusBadRequest) + return + } + + // Verify signature + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { + logger.WarnC("wecom", "Signature verification failed") + http.Error(w, "Invalid signature", http.StatusForbidden) + return + } + + // Decrypt echostr + // For AIBOT (智能机器人), receiveid should be empty string "" + // Reference: https://developer.work.weixin.qq.com/document/path/101033 + decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") + if err != nil { + logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Decryption failed", http.StatusInternalServerError) + return + } + + // Remove BOM and whitespace as per WeCom documentation + // The response must be plain text without quotes, BOM, or newlines + decryptedEchoStr = strings.TrimSpace(decryptedEchoStr) + decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM + w.Write([]byte(decryptedEchoStr)) +} + +// handleMessageCallback handles incoming messages from WeCom +func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + msgSignature := query.Get("msg_signature") + timestamp := query.Get("timestamp") + nonce := query.Get("nonce") + + if msgSignature == "" || timestamp == "" || nonce == "" { + http.Error(w, "Missing parameters", http.StatusBadRequest) + return + } + + // Read request body + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Parse XML to get encrypted message + var encryptedMsg struct { + XMLName xml.Name `xml:"xml"` + ToUserName string `xml:"ToUserName"` + Encrypt string `xml:"Encrypt"` + AgentID string `xml:"AgentID"` + } + + if err := xml.Unmarshal(body, &encryptedMsg); err != nil { + logger.ErrorCF("wecom", "Failed to parse XML", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Invalid XML", http.StatusBadRequest) + return + } + + // Verify signature + if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { + logger.WarnC("wecom", "Message signature verification failed") + http.Error(w, "Invalid signature", http.StatusForbidden) + return + } + + // Decrypt message + // For AIBOT (智能机器人), receiveid should be empty string "" + // Reference: https://developer.work.weixin.qq.com/document/path/101033 + decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") + if err != nil { + logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Decryption failed", http.StatusInternalServerError) + return + } + + // Parse decrypted JSON message (AIBOT uses JSON format) + var msg WeComBotMessage + if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil { + logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{ + "error": err.Error(), + }) + http.Error(w, "Invalid message format", http.StatusBadRequest) + return + } + + // Process the message asynchronously with context + go c.processMessage(ctx, msg) + + // Return success response immediately + // WeCom Bot requires response within configured timeout (default 5 seconds) + w.Write([]byte("success")) +} + +// processMessage processes the received message +func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) { + // Skip unsupported message types + if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && msg.MsgType != "mixed" { + logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{ + "msg_type": msg.MsgType, + }) + return + } + + // Message deduplication: Use msg_id to prevent duplicate processing + msgID := msg.MsgID + c.msgMu.Lock() + if c.processedMsgs[msgID] { + c.msgMu.Unlock() + logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{ + "msg_id": msgID, + }) + return + } + c.processedMsgs[msgID] = true + c.msgMu.Unlock() + + // Clean up old messages periodically (keep last 1000) + if len(c.processedMsgs) > 1000 { + c.msgMu.Lock() + c.processedMsgs = make(map[string]bool) + c.msgMu.Unlock() + } + + senderID := msg.From.UserID + + // Determine if this is a group chat or direct message + // ChatType: "single" for DM, "group" for group chat + isGroupChat := msg.ChatType == "group" + + var chatID, peerKind, peerID string + if isGroupChat { + // Group chat: use ChatID as chatID and peer_id + chatID = msg.ChatID + peerKind = "group" + peerID = msg.ChatID + } else { + // Direct message: use senderID as chatID and peer_id + chatID = senderID + peerKind = "direct" + peerID = senderID + } + + // Extract content based on message type + var content string + switch msg.MsgType { + case "text": + content = msg.Text.Content + case "voice": + content = msg.Voice.Content // Voice to text content + case "mixed": + // For mixed messages, concatenate text items + for _, item := range msg.Mixed.MsgItem { + if item.MsgType == "text" { + content += item.Text.Content + } + } + case "image", "file": + // For image and file, we don't have text content + content = "" + } + + // Build metadata + metadata := map[string]string{ + "msg_type": msg.MsgType, + "msg_id": msg.MsgID, + "platform": "wecom", + "peer_kind": peerKind, + "peer_id": peerID, + "response_url": msg.ResponseURL, + } + if isGroupChat { + metadata["chat_id"] = msg.ChatID + metadata["sender_id"] = senderID + } + + logger.DebugCF("wecom", "Received message", map[string]interface{}{ + "sender_id": senderID, + "msg_type": msg.MsgType, + "peer_kind": peerKind, + "is_group_chat": isGroupChat, + "preview": utils.Truncate(content, 50), + }) + + // Handle the message through the base channel + c.HandleMessage(senderID, chatID, content, nil, metadata) +} + +// sendWebhookReply sends a reply using the webhook URL +func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error { + reply := WeComBotReplyMessage{ + MsgType: "text", + } + reply.Text.Content = content + + jsonData, err := json.Marshal(reply) + if err != nil { + return fmt.Errorf("failed to marshal reply: %w", err) + } + + // Use configurable timeout (default 5 seconds) + timeout := c.config.ReplyTimeout + if timeout <= 0 { + timeout = 5 + } + + reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.config.WebhookURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send webhook reply: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + // Check response + var result struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if result.ErrCode != 0 { + return fmt.Errorf("webhook API error: %s (code: %d)", result.ErrMsg, result.ErrCode) + } + + return nil +} + +// handleHealth handles health check requests +func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { + status := map[string]interface{}{ + "status": "ok", + "running": c.IsRunning(), + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(status) +} diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go new file mode 100644 index 000000000..460e0058f --- /dev/null +++ b/pkg/channels/wecom/bot_test.go @@ -0,0 +1,753 @@ +package wecom + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "encoding/json" + "encoding/xml" + "fmt" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +// generateTestAESKey generates a valid test AES key +func generateTestAESKey() string { + // AES key needs to be 32 bytes (256 bits) for AES-256 + key := make([]byte, 32) + for i := range key { + key[i] = byte(i) + } + // Return base64 encoded key without padding + return base64.StdEncoding.EncodeToString(key)[:43] +} + +// encryptTestMessage encrypts a message for testing (AIBOT JSON format) +func encryptTestMessage(message, aesKey string) (string, error) { + // Decode AES key + key, err := base64.StdEncoding.DecodeString(aesKey + "=") + if err != nil { + return "", err + } + + // Prepare message: random(16) + msg_len(4) + msg + receiveid + random := make([]byte, 0, 16) + for i := 0; i < 16; i++ { + random = append(random, byte(i)) + } + + msgBytes := []byte(message) + receiveID := []byte("test_aibot_id") + + msgLen := uint32(len(msgBytes)) + lenBytes := make([]byte, 4) + binary.BigEndian.PutUint32(lenBytes, msgLen) + + plainText := append(random, lenBytes...) + plainText = append(plainText, msgBytes...) + plainText = append(plainText, receiveID...) + + // PKCS7 padding + blockSize := aes.BlockSize + padding := blockSize - len(plainText)%blockSize + padText := bytes.Repeat([]byte{byte(padding)}, padding) + plainText = append(plainText, padText...) + + // Encrypt + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + + mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize]) + cipherText := make([]byte, len(plainText)) + mode.CryptBlocks(cipherText, plainText) + + return base64.StdEncoding.EncodeToString(cipherText), nil +} + +// generateSignature generates a signature for testing +func generateSignature(token, timestamp, nonce, msgEncrypt string) string { + params := []string{token, timestamp, nonce, msgEncrypt} + sort.Strings(params) + str := strings.Join(params, "") + hash := sha1.Sum([]byte(str)) + return fmt.Sprintf("%x", hash) +} + +func TestNewWeComBotChannel(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("missing token", func(t *testing.T) { + cfg := config.WeComConfig{ + Token: "", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + } + _, err := NewWeComBotChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing token, got nil") + } + }) + + t.Run("missing webhook_url", func(t *testing.T) { + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "", + } + _, err := NewWeComBotChannel(cfg, msgBus) + if err == nil { + t.Error("expected error for missing webhook_url, got nil") + } + }) + + t.Run("valid config", func(t *testing.T) { + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + AllowFrom: []string{"user1", "user2"}, + } + ch, err := NewWeComBotChannel(cfg, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ch.Name() != "wecom" { + t.Errorf("Name() = %q, want %q", ch.Name(), "wecom") + } + if ch.IsRunning() { + t.Error("new channel should not be running") + } + }) +} + +func TestWeComBotChannelIsAllowed(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("empty allowlist allows all", func(t *testing.T) { + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + AllowFrom: []string{}, + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + if !ch.IsAllowed("any_user") { + t.Error("empty allowlist should allow all users") + } + }) + + t.Run("allowlist restricts users", func(t *testing.T) { + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + AllowFrom: []string{"allowed_user"}, + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + if !ch.IsAllowed("allowed_user") { + t.Error("allowed user should pass allowlist check") + } + if ch.IsAllowed("blocked_user") { + t.Error("non-allowed user should be blocked") + } + }) +} + +func TestWeComBotVerifySignature(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + t.Run("valid signature", func(t *testing.T) { + timestamp := "1234567890" + nonce := "test_nonce" + msgEncrypt := "test_message" + expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt) + + if !verifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { + t.Error("valid signature should pass verification") + } + }) + + t.Run("invalid signature", func(t *testing.T) { + timestamp := "1234567890" + nonce := "test_nonce" + msgEncrypt := "test_message" + + if verifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { + t.Error("invalid signature should fail verification") + } + }) + + t.Run("empty token skips verification", func(t *testing.T) { + // Create a channel manually with empty token to test the behavior + cfgEmpty := config.WeComConfig{ + Token: "", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + } + base := channels.NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom) + chEmpty := &WeComBotChannel{ + BaseChannel: base, + config: cfgEmpty, + } + + if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { + t.Error("empty token should skip verification and return true") + } + }) +} + +func TestWeComBotDecryptMessage(t *testing.T) { + msgBus := bus.NewMessageBus() + + t.Run("decrypt without AES key", func(t *testing.T) { + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + EncodingAESKey: "", + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + // Without AES key, message should be base64 decoded only + plainText := "hello world" + encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) + + result, err := decryptMessage(encoded, ch.config.EncodingAESKey) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != plainText { + t.Errorf("decryptMessage() = %q, want %q", result, plainText) + } + }) + + t.Run("decrypt with AES key", func(t *testing.T) { + aesKey := generateTestAESKey() + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + EncodingAESKey: aesKey, + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + originalMsg := "Hello" + encrypted, err := encryptTestMessage(originalMsg, aesKey) + if err != nil { + t.Fatalf("failed to encrypt test message: %v", err) + } + + result, err := decryptMessage(encrypted, ch.config.EncodingAESKey) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != originalMsg { + t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg) + } + }) + + t.Run("invalid base64", func(t *testing.T) { + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + EncodingAESKey: "", + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + _, err := decryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) + if err == nil { + t.Error("expected error for invalid base64, got nil") + } + }) + + t.Run("invalid AES key", func(t *testing.T) { + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + EncodingAESKey: "invalid_key", + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + _, err := decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) + if err == nil { + t.Error("expected error for invalid AES key, got nil") + } + }) +} + +func TestWeComBotPKCS7Unpad(t *testing.T) { + tests := []struct { + name string + input []byte + expected []byte + }{ + { + name: "empty input", + input: []byte{}, + expected: []byte{}, + }, + { + name: "valid padding 3 bytes", + input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...), + expected: []byte("hello"), + }, + { + name: "valid padding 16 bytes (full block)", + input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...), + expected: []byte("123456789012345"), + }, + { + name: "invalid padding larger than data", + input: []byte{20}, + expected: nil, // should return error + }, + { + name: "invalid padding zero", + input: append([]byte("test"), byte(0)), + expected: nil, // should return error + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := pkcs7Unpad(tt.input) + if tt.expected == nil { + // This case should return an error + if err == nil { + t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result) + } + return + } + if err != nil { + t.Errorf("pkcs7Unpad() unexpected error: %v", err) + return + } + if !bytes.Equal(result, tt.expected) { + t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestWeComBotHandleVerification(t *testing.T) { + msgBus := bus.NewMessageBus() + aesKey := generateTestAESKey() + cfg := config.WeComConfig{ + Token: "test_token", + EncodingAESKey: aesKey, + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + t.Run("valid verification request", func(t *testing.T) { + echostr := "test_echostr_123" + encryptedEchostr, _ := encryptTestMessage(echostr, aesKey) + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr) + + req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + w := httptest.NewRecorder() + + ch.handleVerification(context.Background(), w, req) + + if w.Code != http.StatusOK { + t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != echostr { + t.Errorf("response body = %q, want %q", w.Body.String(), echostr) + } + }) + + t.Run("missing parameters", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil) + w := httptest.NewRecorder() + + ch.handleVerification(context.Background(), w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) + } + }) + + t.Run("invalid signature", func(t *testing.T) { + echostr := "test_echostr" + encryptedEchostr, _ := encryptTestMessage(echostr, aesKey) + timestamp := "1234567890" + nonce := "test_nonce" + + req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + w := httptest.NewRecorder() + + ch.handleVerification(context.Background(), w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) + } + }) +} + +func TestWeComBotHandleMessageCallback(t *testing.T) { + msgBus := bus.NewMessageBus() + aesKey := generateTestAESKey() + cfg := config.WeComConfig{ + Token: "test_token", + EncodingAESKey: aesKey, + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + t.Run("valid direct message callback", func(t *testing.T) { + // Create JSON message for direct chat (single) + jsonMsg := `{ + "msgid": "test_msg_id_123", + "aibotid": "test_aibot_id", + "chattype": "single", + "from": {"userid": "user123"}, + "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + "msgtype": "text", + "text": {"content": "Hello World"} + }` + + // Encrypt message + encrypted, _ := encryptTestMessage(jsonMsg, aesKey) + + // Create encrypted XML wrapper + encryptedWrapper := struct { + XMLName xml.Name `xml:"xml"` + Encrypt string `xml:"Encrypt"` + }{ + Encrypt: encrypted, + } + wrapperData, _ := xml.Marshal(encryptedWrapper) + + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignature("test_token", timestamp, nonce, encrypted) + + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + w := httptest.NewRecorder() + + ch.handleMessageCallback(context.Background(), w, req) + + if w.Code != http.StatusOK { + t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != "success" { + t.Errorf("response body = %q, want %q", w.Body.String(), "success") + } + }) + + t.Run("valid group message callback", func(t *testing.T) { + // Create JSON message for group chat + jsonMsg := `{ + "msgid": "test_msg_id_456", + "aibotid": "test_aibot_id", + "chatid": "group_chat_id_123", + "chattype": "group", + "from": {"userid": "user456"}, + "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + "msgtype": "text", + "text": {"content": "Hello Group"} + }` + + // Encrypt message + encrypted, _ := encryptTestMessage(jsonMsg, aesKey) + + // Create encrypted XML wrapper + encryptedWrapper := struct { + XMLName xml.Name `xml:"xml"` + Encrypt string `xml:"Encrypt"` + }{ + Encrypt: encrypted, + } + wrapperData, _ := xml.Marshal(encryptedWrapper) + + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignature("test_token", timestamp, nonce, encrypted) + + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + w := httptest.NewRecorder() + + ch.handleMessageCallback(context.Background(), w, req) + + if w.Code != http.StatusOK { + t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) + } + if w.Body.String() != "success" { + t.Errorf("response body = %q, want %q", w.Body.String(), "success") + } + }) + + t.Run("missing parameters", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil) + w := httptest.NewRecorder() + + ch.handleMessageCallback(context.Background(), w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) + } + }) + + t.Run("invalid XML", func(t *testing.T) { + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignature("test_token", timestamp, nonce, "") + + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml")) + w := httptest.NewRecorder() + + ch.handleMessageCallback(context.Background(), w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) + } + }) + + t.Run("invalid signature", func(t *testing.T) { + encryptedWrapper := struct { + XMLName xml.Name `xml:"xml"` + Encrypt string `xml:"Encrypt"` + }{ + Encrypt: "encrypted_data", + } + wrapperData, _ := xml.Marshal(encryptedWrapper) + + timestamp := "1234567890" + nonce := "test_nonce" + + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + w := httptest.NewRecorder() + + ch.handleMessageCallback(context.Background(), w, req) + + if w.Code != http.StatusForbidden { + t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) + } + }) +} + +func TestWeComBotProcessMessage(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + t.Run("process direct text message", func(t *testing.T) { + msg := WeComBotMessage{ + MsgID: "test_msg_id_123", + AIBotID: "test_aibot_id", + ChatType: "single", + ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + MsgType: "text", + } + msg.From.UserID = "user123" + msg.Text.Content = "Hello World" + + // Should not panic + ch.processMessage(context.Background(), msg) + }) + + t.Run("process group text message", func(t *testing.T) { + msg := WeComBotMessage{ + MsgID: "test_msg_id_456", + AIBotID: "test_aibot_id", + ChatID: "group_chat_id_123", + ChatType: "group", + ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + MsgType: "text", + } + msg.From.UserID = "user456" + msg.Text.Content = "Hello Group" + + // Should not panic + ch.processMessage(context.Background(), msg) + }) + + t.Run("process voice message", func(t *testing.T) { + msg := WeComBotMessage{ + MsgID: "test_msg_id_789", + AIBotID: "test_aibot_id", + ChatType: "single", + ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + MsgType: "voice", + } + msg.From.UserID = "user123" + msg.Voice.Content = "Voice message text" + + // Should not panic + ch.processMessage(context.Background(), msg) + }) + + t.Run("skip unsupported message type", func(t *testing.T) { + msg := WeComBotMessage{ + MsgID: "test_msg_id_000", + AIBotID: "test_aibot_id", + ChatType: "single", + ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + MsgType: "video", + } + msg.From.UserID = "user123" + + // Should not panic + ch.processMessage(context.Background(), msg) + }) +} + +func TestWeComBotHandleWebhook(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + t.Run("GET request calls verification", func(t *testing.T) { + echostr := "test_echostr" + encoded := base64.StdEncoding.EncodeToString([]byte(echostr)) + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignature("test_token", timestamp, nonce, encoded) + + req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil) + w := httptest.NewRecorder() + + ch.handleWebhook(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) + } + }) + + t.Run("POST request calls message callback", func(t *testing.T) { + encryptedWrapper := struct { + XMLName xml.Name `xml:"xml"` + Encrypt string `xml:"Encrypt"` + }{ + Encrypt: base64.StdEncoding.EncodeToString([]byte("test")), + } + wrapperData, _ := xml.Marshal(encryptedWrapper) + + timestamp := "1234567890" + nonce := "test_nonce" + signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt) + + req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + w := httptest.NewRecorder() + + ch.handleWebhook(w, req) + + // Should not be method not allowed + if w.Code == http.StatusMethodNotAllowed { + t.Error("POST request should not return Method Not Allowed") + } + }) + + t.Run("unsupported method", func(t *testing.T) { + req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil) + w := httptest.NewRecorder() + + ch.handleWebhook(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed) + } + }) +} + +func TestWeComBotHandleHealth(t *testing.T) { + msgBus := bus.NewMessageBus() + cfg := config.WeComConfig{ + Token: "test_token", + WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + } + ch, _ := NewWeComBotChannel(cfg, msgBus) + + req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil) + w := httptest.NewRecorder() + + ch.handleHealth(w, req) + + if w.Code != http.StatusOK { + t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) + } + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Content-Type = %q, want %q", contentType, "application/json") + } + + body := w.Body.String() + if !strings.Contains(body, "status") || !strings.Contains(body, "running") { + t.Errorf("response body should contain status and running fields, got: %s", body) + } +} + +func TestWeComBotReplyMessage(t *testing.T) { + msg := WeComBotReplyMessage{ + MsgType: "text", + } + msg.Text.Content = "Hello World" + + if msg.MsgType != "text" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") + } + if msg.Text.Content != "Hello World" { + t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") + } +} + +func TestWeComBotMessageStructure(t *testing.T) { + jsonData := `{ + "msgid": "test_msg_id_123", + "aibotid": "test_aibot_id", + "chatid": "group_chat_id_123", + "chattype": "group", + "from": {"userid": "user123"}, + "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + "msgtype": "text", + "text": {"content": "Hello World"} + }` + + var msg WeComBotMessage + err := json.Unmarshal([]byte(jsonData), &msg) + if err != nil { + t.Fatalf("failed to unmarshal JSON: %v", err) + } + + if msg.MsgID != "test_msg_id_123" { + t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123") + } + if msg.AIBotID != "test_aibot_id" { + t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id") + } + if msg.ChatID != "group_chat_id_123" { + t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123") + } + if msg.ChatType != "group" { + t.Errorf("ChatType = %q, want %q", msg.ChatType, "group") + } + if msg.From.UserID != "user123" { + t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123") + } + if msg.MsgType != "text" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") + } + if msg.Text.Content != "Hello World" { + t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") + } +} diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go new file mode 100644 index 000000000..3c1629577 --- /dev/null +++ b/pkg/channels/wecom/common.go @@ -0,0 +1,134 @@ +package wecom + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/sha1" + "encoding/base64" + "encoding/binary" + "fmt" + "sort" + "strings" +) + +// blockSize is the PKCS7 block size used by WeCom (32) +const blockSize = 32 + +// verifySignature verifies the message signature for WeCom +// This is a common function used by both WeCom Bot and WeCom App +func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { + if token == "" { + return true // Skip verification if token is not set + } + + // Sort parameters + params := []string{token, timestamp, nonce, msgEncrypt} + sort.Strings(params) + + // Concatenate + str := strings.Join(params, "") + + // SHA1 hash + hash := sha1.Sum([]byte(str)) + expectedSignature := fmt.Sprintf("%x", hash) + + return expectedSignature == msgSignature +} + +// decryptMessage decrypts the encrypted message using AES +// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id +func decryptMessage(encryptedMsg, encodingAESKey string) (string, error) { + return decryptMessageWithVerify(encryptedMsg, encodingAESKey, "") +} + +// decryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid +// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification. +func decryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) { + if encodingAESKey == "" { + // No encryption, return as is (base64 decode) + decoded, err := base64.StdEncoding.DecodeString(encryptedMsg) + if err != nil { + return "", err + } + return string(decoded), nil + } + + // Decode AES key (base64) + aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") + if err != nil { + return "", fmt.Errorf("failed to decode AES key: %w", err) + } + + // Decode encrypted message + cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg) + if err != nil { + return "", fmt.Errorf("failed to decode message: %w", err) + } + + // AES decrypt + block, err := aes.NewCipher(aesKey) + if err != nil { + return "", fmt.Errorf("failed to create cipher: %w", err) + } + + if len(cipherText) < aes.BlockSize { + return "", fmt.Errorf("ciphertext too short") + } + + // IV is the first 16 bytes of AESKey + iv := aesKey[:aes.BlockSize] + mode := cipher.NewCBCDecrypter(block, iv) + plainText := make([]byte, len(cipherText)) + mode.CryptBlocks(plainText, cipherText) + + // Remove PKCS7 padding + plainText, err = pkcs7Unpad(plainText) + if err != nil { + return "", fmt.Errorf("failed to unpad: %w", err) + } + + // Parse message structure + // Format: random(16) + msg_len(4) + msg + receiveid + if len(plainText) < 20 { + return "", fmt.Errorf("decrypted message too short") + } + + msgLen := binary.BigEndian.Uint32(plainText[16:20]) + if int(msgLen) > len(plainText)-20 { + return "", fmt.Errorf("invalid message length") + } + + msg := plainText[20 : 20+msgLen] + + // Verify receiveid if provided + if receiveid != "" && len(plainText) > 20+int(msgLen) { + actualReceiveID := string(plainText[20+msgLen:]) + if actualReceiveID != receiveid { + return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID) + } + } + + return string(msg), nil +} + +// pkcs7Unpad removes PKCS7 padding with validation +func pkcs7Unpad(data []byte) ([]byte, error) { + if len(data) == 0 { + return data, nil + } + padding := int(data[len(data)-1]) + // WeCom uses 32-byte block size for PKCS7 padding + if padding == 0 || padding > blockSize { + return nil, fmt.Errorf("invalid padding size: %d", padding) + } + if padding > len(data) { + return nil, fmt.Errorf("padding size larger than data") + } + // Verify all padding bytes + for i := 0; i < padding; i++ { + if data[len(data)-1-i] != byte(padding) { + return nil, fmt.Errorf("invalid padding byte at position %d", i) + } + } + return data[:len(data)-padding], nil +} diff --git a/pkg/channels/wecom/init.go b/pkg/channels/wecom/init.go new file mode 100644 index 000000000..3ef1ecdf3 --- /dev/null +++ b/pkg/channels/wecom/init.go @@ -0,0 +1,16 @@ +package wecom + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("wecom", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewWeComBotChannel(cfg.Channels.WeCom, b) + }) + channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewWeComAppChannel(cfg.Channels.WeComApp, b) + }) +} diff --git a/pkg/channels/whatsapp/init.go b/pkg/channels/whatsapp/init.go new file mode 100644 index 000000000..d9c2669c3 --- /dev/null +++ b/pkg/channels/whatsapp/init.go @@ -0,0 +1,13 @@ +package whatsapp + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("whatsapp", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewWhatsAppChannel(cfg.Channels.WhatsApp, b) + }) +} diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go new file mode 100644 index 000000000..1ac256766 --- /dev/null +++ b/pkg/channels/whatsapp/whatsapp.go @@ -0,0 +1,193 @@ +package whatsapp + +import ( + "context" + "encoding/json" + "fmt" + "log" + "sync" + "time" + + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/utils" +) + +type WhatsAppChannel struct { + *channels.BaseChannel + conn *websocket.Conn + config config.WhatsAppConfig + url string + mu sync.Mutex + connected bool +} + +func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { + base := channels.NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) + + return &WhatsAppChannel{ + BaseChannel: base, + config: cfg, + url: cfg.BridgeURL, + connected: false, + }, nil +} + +func (c *WhatsAppChannel) Start(ctx context.Context) error { + log.Printf("Starting WhatsApp channel connecting to %s...", c.url) + + dialer := websocket.DefaultDialer + dialer.HandshakeTimeout = 10 * time.Second + + conn, _, err := dialer.Dial(c.url, nil) + if err != nil { + return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err) + } + + c.mu.Lock() + c.conn = conn + c.connected = true + c.mu.Unlock() + + c.SetRunning(true) + log.Println("WhatsApp channel connected") + + go c.listen(ctx) + + return nil +} + +func (c *WhatsAppChannel) Stop(ctx context.Context) error { + log.Println("Stopping WhatsApp channel...") + + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn != nil { + if err := c.conn.Close(); err != nil { + log.Printf("Error closing WhatsApp connection: %v", err) + } + c.conn = nil + } + + c.connected = false + c.SetRunning(false) + + return nil +} + +func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn == nil { + return fmt.Errorf("whatsapp connection not established") + } + + payload := map[string]interface{}{ + "type": "message", + "to": msg.ChatID, + "content": msg.Content, + } + + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + return fmt.Errorf("failed to send message: %w", err) + } + + return nil +} + +func (c *WhatsAppChannel) listen(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + default: + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + time.Sleep(1 * time.Second) + continue + } + + _, message, err := conn.ReadMessage() + if err != nil { + log.Printf("WhatsApp read error: %v", err) + time.Sleep(2 * time.Second) + continue + } + + var msg map[string]interface{} + if err := json.Unmarshal(message, &msg); err != nil { + log.Printf("Failed to unmarshal WhatsApp message: %v", err) + continue + } + + msgType, ok := msg["type"].(string) + if !ok { + continue + } + + if msgType == "message" { + c.handleIncomingMessage(msg) + } + } + } +} + +func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) { + senderID, ok := msg["from"].(string) + if !ok { + return + } + + chatID, ok := msg["chat"].(string) + if !ok { + chatID = senderID + } + + content, ok := msg["content"].(string) + if !ok { + content = "" + } + + var mediaPaths []string + if mediaData, ok := msg["media"].([]interface{}); ok { + mediaPaths = make([]string, 0, len(mediaData)) + for _, m := range mediaData { + if path, ok := m.(string); ok { + mediaPaths = append(mediaPaths, path) + } + } + } + + metadata := make(map[string]string) + if messageID, ok := msg["id"].(string); ok { + metadata["message_id"] = messageID + } + if userName, ok := msg["from_name"].(string); ok { + metadata["user_name"] = userName + } + + if chatID == senderID { + metadata["peer_kind"] = "direct" + metadata["peer_id"] = senderID + } else { + metadata["peer_kind"] = "group" + metadata["peer_id"] = chatID + } + + log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50)) + + c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) +} From 59a889b608ebf5a6165e76344c0e1cf9d43c4e86 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 20 Feb 2026 23:26:33 +0800 Subject: [PATCH 004/104] refactor(channels): remove old channel files from parent package --- pkg/channels/dingtalk.go | 204 ------ pkg/channels/discord.go | 373 ---------- pkg/channels/feishu_32.go | 38 - pkg/channels/feishu_64.go | 227 ------ pkg/channels/line.go | 606 ---------------- pkg/channels/maixcam.go | 243 ------- pkg/channels/onebot.go | 984 ------------------------- pkg/channels/qq.go | 247 ------- pkg/channels/slack.go | 443 ------------ pkg/channels/slack_test.go | 174 ----- pkg/channels/telegram.go | 539 -------------- pkg/channels/telegram_commands.go | 156 ---- pkg/channels/wecom.go | 605 ---------------- pkg/channels/wecom_app.go | 584 --------------- pkg/channels/wecom_app_test.go | 1104 ----------------------------- pkg/channels/wecom_test.go | 785 -------------------- pkg/channels/whatsapp.go | 195 ----- 17 files changed, 7507 deletions(-) delete mode 100644 pkg/channels/dingtalk.go delete mode 100644 pkg/channels/discord.go delete mode 100644 pkg/channels/feishu_32.go delete mode 100644 pkg/channels/feishu_64.go delete mode 100644 pkg/channels/line.go delete mode 100644 pkg/channels/maixcam.go delete mode 100644 pkg/channels/onebot.go delete mode 100644 pkg/channels/qq.go delete mode 100644 pkg/channels/slack.go delete mode 100644 pkg/channels/slack_test.go delete mode 100644 pkg/channels/telegram.go delete mode 100644 pkg/channels/telegram_commands.go delete mode 100644 pkg/channels/wecom.go delete mode 100644 pkg/channels/wecom_app.go delete mode 100644 pkg/channels/wecom_app_test.go delete mode 100644 pkg/channels/wecom_test.go delete mode 100644 pkg/channels/whatsapp.go diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go deleted file mode 100644 index 662fba3b7..000000000 --- a/pkg/channels/dingtalk.go +++ /dev/null @@ -1,204 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// DingTalk channel implementation using Stream Mode - -package channels - -import ( - "context" - "fmt" - "sync" - - "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" - "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -// DingTalkChannel implements the Channel interface for DingTalk (钉钉) -// It uses WebSocket for receiving messages via stream mode and API for sending -type DingTalkChannel struct { - *BaseChannel - config config.DingTalkConfig - clientID string - clientSecret string - streamClient *client.StreamClient - ctx context.Context - cancel context.CancelFunc - // Map to store session webhooks for each chat - sessionWebhooks sync.Map // chatID -> sessionWebhook -} - -// NewDingTalkChannel creates a new DingTalk channel instance -func NewDingTalkChannel(cfg config.DingTalkConfig, 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) - - return &DingTalkChannel{ - BaseChannel: base, - config: cfg, - clientID: cfg.ClientID, - clientSecret: cfg.ClientSecret, - }, nil -} - -// Start initializes the DingTalk channel with Stream Mode -func (c *DingTalkChannel) Start(ctx context.Context) error { - logger.InfoC("dingtalk", "Starting DingTalk channel (Stream Mode)...") - - c.ctx, c.cancel = context.WithCancel(ctx) - - // Create credential config - cred := client.NewAppCredentialConfig(c.clientID, c.clientSecret) - - // Create the stream client with options - c.streamClient = client.NewStreamClient( - client.WithAppCredential(cred), - client.WithAutoReconnect(true), - ) - - // Register chatbot callback handler (IChatBotMessageHandler is a function type) - c.streamClient.RegisterChatBotCallbackRouter(c.onChatBotMessageReceived) - - // Start the stream client - if err := c.streamClient.Start(c.ctx); err != nil { - return fmt.Errorf("failed to start stream client: %w", err) - } - - c.setRunning(true) - logger.InfoC("dingtalk", "DingTalk channel started (Stream Mode)") - return nil -} - -// Stop gracefully stops the DingTalk channel -func (c *DingTalkChannel) Stop(ctx context.Context) error { - logger.InfoC("dingtalk", "Stopping DingTalk channel...") - - if c.cancel != nil { - c.cancel() - } - - if c.streamClient != nil { - c.streamClient.Close() - } - - c.setRunning(false) - logger.InfoC("dingtalk", "DingTalk channel stopped") - return nil -} - -// Send sends a message to DingTalk via the chatbot reply API -func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("dingtalk channel not running") - } - - // Get session webhook from storage - sessionWebhookRaw, ok := c.sessionWebhooks.Load(msg.ChatID) - if !ok { - return fmt.Errorf("no session_webhook found for chat %s, cannot send message", msg.ChatID) - } - - sessionWebhook, ok := sessionWebhookRaw.(string) - if !ok { - return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) - } - - logger.DebugCF("dingtalk", "Sending message", map[string]any{ - "chat_id": msg.ChatID, - "preview": utils.Truncate(msg.Content, 100), - }) - - // Use the session webhook to send the reply - return c.SendDirectReply(ctx, sessionWebhook, msg.Content) -} - -// onChatBotMessageReceived implements the IChatBotMessageHandler function signature -// This is called by the Stream SDK when a new message arrives -// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) -func (c *DingTalkChannel) onChatBotMessageReceived( - ctx context.Context, - data *chatbot.BotCallbackDataModel, -) ([]byte, error) { - // Extract message content from Text field - content := data.Text.Content - if content == "" { - // Try to extract from Content interface{} if Text is empty - if contentMap, ok := data.Content.(map[string]any); ok { - if textContent, ok := contentMap["content"].(string); ok { - content = textContent - } - } - } - - if content == "" { - return nil, nil // Ignore empty messages - } - - senderID := data.SenderStaffId - senderNick := data.SenderNick - chatID := senderID - if data.ConversationType != "1" { - // For group chats - chatID = data.ConversationId - } - - // Store the session webhook for this chat so we can reply later - c.sessionWebhooks.Store(chatID, data.SessionWebhook) - - metadata := map[string]string{ - "sender_name": senderNick, - "conversation_id": data.ConversationId, - "conversation_type": data.ConversationType, - "platform": "dingtalk", - "session_webhook": data.SessionWebhook, - } - - if data.ConversationType == "1" { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID - } else { - metadata["peer_kind"] = "group" - metadata["peer_id"] = data.ConversationId - } - - logger.DebugCF("dingtalk", "Received message", map[string]any{ - "sender_nick": senderNick, - "sender_id": senderID, - "preview": utils.Truncate(content, 50), - }) - - // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) - - // Return nil to indicate we've handled the message asynchronously - // The response will be sent through the message bus - return nil, nil -} - -// SendDirectReply sends a direct reply using the session webhook -func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, content string) error { - replier := chatbot.NewChatbotReplier() - - // Convert string content to []byte for the API - contentBytes := []byte(content) - titleBytes := []byte("PicoClaw") - - // Send markdown formatted reply - err := replier.SimpleReplyMarkdown( - ctx, - sessionWebhook, - titleBytes, - contentBytes, - ) - if err != nil { - return fmt.Errorf("failed to send reply: %w", err) - } - - return nil -} diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go deleted file mode 100644 index f6faa3373..000000000 --- a/pkg/channels/discord.go +++ /dev/null @@ -1,373 +0,0 @@ -package channels - -import ( - "context" - "fmt" - "os" - "strings" - "sync" - "time" - - "github.com/bwmarrin/discordgo" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" -) - -const ( - transcriptionTimeout = 30 * time.Second - sendTimeout = 10 * time.Second -) - -type DiscordChannel struct { - *BaseChannel - session *discordgo.Session - config config.DiscordConfig - transcriber *voice.GroqTranscriber - ctx context.Context - typingMu sync.Mutex - typingStop map[string]chan struct{} // chatID → stop signal - botUserID string // stored for mention checking -} - -func NewDiscordChannel(cfg config.DiscordConfig, 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) - - return &DiscordChannel{ - BaseChannel: base, - session: session, - config: cfg, - transcriber: nil, - ctx: context.Background(), - typingStop: make(map[string]chan struct{}), - }, nil -} - -func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - -func (c *DiscordChannel) getContext() context.Context { - if c.ctx == nil { - return context.Background() - } - return c.ctx -} - -func (c *DiscordChannel) Start(ctx context.Context) error { - logger.InfoC("discord", "Starting Discord bot") - - c.ctx = ctx - - // Get bot user ID before opening session to avoid race condition - botUser, err := c.session.User("@me") - if err != nil { - return fmt.Errorf("failed to get bot user: %w", err) - } - c.botUserID = botUser.ID - - c.session.AddHandler(c.handleMessage) - - if err := c.session.Open(); err != nil { - return fmt.Errorf("failed to open discord session: %w", err) - } - - c.setRunning(true) - - logger.InfoCF("discord", "Discord bot connected", map[string]any{ - "username": botUser.Username, - "user_id": botUser.ID, - }) - - return nil -} - -func (c *DiscordChannel) Stop(ctx context.Context) error { - logger.InfoC("discord", "Stopping Discord bot") - c.setRunning(false) - - // Stop all typing goroutines before closing session - c.typingMu.Lock() - for chatID, stop := range c.typingStop { - close(stop) - delete(c.typingStop, chatID) - } - c.typingMu.Unlock() - - if err := c.session.Close(); err != nil { - return fmt.Errorf("failed to close discord session: %w", err) - } - - return nil -} - -func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - c.stopTyping(msg.ChatID) - - if !c.IsRunning() { - return fmt.Errorf("discord bot not running") - } - - channelID := msg.ChatID - if channelID == "" { - return fmt.Errorf("channel ID is empty") - } - - runes := []rune(msg.Content) - if len(runes) == 0 { - return nil - } - - chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars - - for _, chunk := range chunks { - if err := c.sendChunk(ctx, channelID, chunk); err != nil { - return err - } - } - - return nil -} - -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { - // Use the passed ctx for timeout control - sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) - defer cancel() - - done := make(chan error, 1) - go func() { - _, err := c.session.ChannelMessageSend(channelID, content) - done <- err - }() - - select { - case err := <-done: - if err != nil { - return fmt.Errorf("failed to send discord message: %w", err) - } - return nil - case <-sendCtx.Done(): - return fmt.Errorf("send message timeout: %w", sendCtx.Err()) - } -} - -// appendContent safely appends content to existing text -func appendContent(content, suffix string) string { - if content == "" { - return suffix - } - return content + "\n" + suffix -} - -func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) { - if m == nil || m.Author == nil { - return - } - - if m.Author.ID == s.State.User.ID { - return - } - - // Check allowlist first to avoid downloading attachments and transcribing for rejected users - if !c.IsAllowed(m.Author.ID) { - logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ - "user_id": m.Author.ID, - }) - return - } - - // If configured to only respond to mentions, check if bot is mentioned - // Skip this check for DMs (GuildID is empty) - DMs should always be responded to - if c.config.MentionOnly && m.GuildID != "" { - isMentioned := false - for _, mention := range m.Mentions { - if mention.ID == c.botUserID { - isMentioned = true - break - } - } - if !isMentioned { - logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{ - "user_id": m.Author.ID, - }) - return - } - } - - senderID := m.Author.ID - senderName := m.Author.Username - if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { - senderName += "#" + m.Author.Discriminator - } - - content := m.Content - content = c.stripBotMention(content) - mediaPaths := make([]string, 0, len(m.Attachments)) - localFiles := make([]string, 0, len(m.Attachments)) - - // Ensure temp files are cleaned up when function returns - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) - } - } - }() - - for _, attachment := range m.Attachments { - isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType) - - if isAudio { - localPath := c.downloadAttachment(attachment.URL, attachment.Filename) - if localPath != "" { - localFiles = append(localFiles, localPath) - - var transcribedText string - if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout) - result, err := c.transcriber.Transcribe(ctx, localPath) - cancel() // Release context resources immediately to avoid leaks in for loop - - if err != nil { - logger.ErrorCF("discord", "Voice transcription failed", map[string]any{ - "error": err.Error(), - }) - transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename) - } else { - transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text) - logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{ - "text": result.Text, - }) - } - } else { - transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename) - } - - content = appendContent(content, transcribedText) - } else { - logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{ - "url": attachment.URL, - "filename": attachment.Filename, - }) - mediaPaths = append(mediaPaths, attachment.URL) - content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) - } - } else { - mediaPaths = append(mediaPaths, attachment.URL) - content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) - } - } - - if content == "" && len(mediaPaths) == 0 { - return - } - - if content == "" { - content = "[media only]" - } - - // Start typing after all early returns — guaranteed to have a matching Send() - c.startTyping(m.ChannelID) - - logger.DebugCF("discord", "Received message", map[string]any{ - "sender_name": senderName, - "sender_id": senderID, - "preview": utils.Truncate(content, 50), - }) - - peerKind := "channel" - peerID := m.ChannelID - if m.GuildID == "" { - peerKind = "direct" - peerID = senderID - } - - metadata := map[string]string{ - "message_id": m.ID, - "user_id": senderID, - "username": m.Author.Username, - "display_name": senderName, - "guild_id": m.GuildID, - "channel_id": m.ChannelID, - "is_dm": fmt.Sprintf("%t", m.GuildID == ""), - "peer_kind": peerKind, - "peer_id": peerID, - } - - c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata) -} - -// startTyping starts a continuous typing indicator loop for the given chatID. -// It stops any existing typing loop for that chatID before starting a new one. -func (c *DiscordChannel) startTyping(chatID string) { - c.typingMu.Lock() - // Stop existing loop for this chatID if any - if stop, ok := c.typingStop[chatID]; ok { - close(stop) - } - stop := make(chan struct{}) - c.typingStop[chatID] = stop - c.typingMu.Unlock() - - go func() { - if err := c.session.ChannelTyping(chatID); err != nil { - logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err}) - } - ticker := time.NewTicker(8 * time.Second) - defer ticker.Stop() - timeout := time.After(5 * time.Minute) - for { - select { - case <-stop: - return - case <-timeout: - return - case <-c.ctx.Done(): - return - case <-ticker.C: - if err := c.session.ChannelTyping(chatID); err != nil { - logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err}) - } - } - } - }() -} - -// stopTyping stops the typing indicator loop for the given chatID. -func (c *DiscordChannel) stopTyping(chatID string) { - c.typingMu.Lock() - defer c.typingMu.Unlock() - if stop, ok := c.typingStop[chatID]; ok { - close(stop) - delete(c.typingStop, chatID) - } -} - -func (c *DiscordChannel) downloadAttachment(url, filename string) string { - return utils.DownloadFile(url, filename, utils.DownloadOptions{ - LoggerPrefix: "discord", - }) -} - -// stripBotMention removes the bot mention from the message content. -// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname). -func (c *DiscordChannel) stripBotMention(text string) string { - if c.botUserID == "" { - return text - } - // Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID> - text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "") - text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "") - return strings.TrimSpace(text) -} diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu_32.go deleted file mode 100644 index 5109b8195..000000000 --- a/pkg/channels/feishu_32.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64 - -package channels - -import ( - "context" - "errors" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" -) - -// FeishuChannel is a stub implementation for 32-bit architectures -type FeishuChannel struct { - *BaseChannel -} - -// 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) { - 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", - ) -} - -// Start is a stub method to satisfy the Channel interface -func (c *FeishuChannel) Start(ctx context.Context) error { - return nil -} - -// Stop is a stub method to satisfy the Channel interface -func (c *FeishuChannel) Stop(ctx context.Context) error { - return nil -} - -// Send is a stub method to satisfy the Channel interface -func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - return errors.New("feishu channel is not supported on 32-bit architectures") -} diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go deleted file mode 100644 index 42e74980f..000000000 --- a/pkg/channels/feishu_64.go +++ /dev/null @@ -1,227 +0,0 @@ -//go:build amd64 || arm64 || riscv64 || mips64 || ppc64 - -package channels - -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/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -type FeishuChannel struct { - *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 := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom) - - 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 fmt.Errorf("feishu channel not running") - } - - 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("failed to send feishu message: %w", err) - } - - if !resp.Success() { - return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg) - } - - 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{} - if messageID := stringValue(message.MessageId); messageID != "" { - metadata["message_id"] = messageID - } - 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) - if chatType == "p2p" { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID - } else { - metadata["peer_kind"] = "group" - metadata["peer_id"] = chatID - } - - logger.InfoCF("feishu", "Feishu message received", map[string]any{ - "sender_id": senderID, - "chat_id": chatID, - "preview": utils.Truncate(content, 80), - }) - - c.HandleMessage(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 -} - -func stringValue(v *string) string { - if v == nil { - return "" - } - return *v -} diff --git a/pkg/channels/line.go b/pkg/channels/line.go deleted file mode 100644 index 44134996f..000000000 --- a/pkg/channels/line.go +++ /dev/null @@ -1,606 +0,0 @@ -package channels - -import ( - "bytes" - "context" - "crypto/hmac" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "sync" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -const ( - lineAPIBase = "https://api.line.me/v2/bot" - lineDataAPIBase = "https://api-data.line.me/v2/bot" - lineReplyEndpoint = lineAPIBase + "/message/reply" - linePushEndpoint = lineAPIBase + "/message/push" - lineContentEndpoint = lineDataAPIBase + "/message/%s/content" - lineBotInfoEndpoint = lineAPIBase + "/info" - lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" - lineReplyTokenMaxAge = 25 * time.Second -) - -type replyTokenEntry struct { - token string - timestamp time.Time -} - -// LINEChannel implements the Channel interface for LINE Official Account -// using the LINE Messaging API with HTTP webhook for receiving messages -// and REST API for sending messages. -type LINEChannel struct { - *BaseChannel - config config.LINEConfig - httpServer *http.Server - botUserID string // Bot's user ID - botBasicID string // Bot's basic ID (e.g. @216ru...) - botDisplayName string // Bot's display name for text-based mention detection - replyTokens sync.Map // chatID -> replyTokenEntry - quoteTokens sync.Map // chatID -> quoteToken (string) - ctx context.Context - cancel context.CancelFunc -} - -// NewLINEChannel creates a new LINE channel instance. -func NewLINEChannel(cfg config.LINEConfig, 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) - - return &LINEChannel{ - BaseChannel: base, - config: cfg, - }, nil -} - -// Start launches the HTTP webhook server. -func (c *LINEChannel) Start(ctx context.Context) error { - logger.InfoC("line", "Starting LINE channel (Webhook Mode)") - - c.ctx, c.cancel = context.WithCancel(ctx) - - // Fetch bot profile to get bot's userId for mention detection - if err := c.fetchBotInfo(); err != nil { - logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{ - "error": err.Error(), - }) - } else { - logger.InfoCF("line", "Bot info fetched", map[string]any{ - "bot_user_id": c.botUserID, - "basic_id": c.botBasicID, - "display_name": c.botDisplayName, - }) - } - - mux := http.NewServeMux() - path := c.config.WebhookPath - if path == "" { - path = "/webhook/line" - } - mux.HandleFunc(path, c.webhookHandler) - - addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) - c.httpServer = &http.Server{ - Addr: addr, - Handler: mux, - } - - go func() { - logger.InfoCF("line", "LINE webhook server listening", map[string]any{ - "addr": addr, - "path": path, - }) - if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("line", "Webhook server error", map[string]any{ - "error": err.Error(), - }) - } - }() - - c.setRunning(true) - logger.InfoC("line", "LINE channel started (Webhook Mode)") - return nil -} - -// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API. -func (c *LINEChannel) fetchBotInfo() error { - req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil) - if err != nil { - return err - } - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) - - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("bot info API returned status %d", resp.StatusCode) - } - - var info struct { - UserID string `json:"userId"` - BasicID string `json:"basicId"` - DisplayName string `json:"displayName"` - } - if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { - return err - } - - c.botUserID = info.UserID - c.botBasicID = info.BasicID - c.botDisplayName = info.DisplayName - return nil -} - -// Stop gracefully shuts down the HTTP server. -func (c *LINEChannel) Stop(ctx context.Context) error { - logger.InfoC("line", "Stopping LINE channel") - - if c.cancel != nil { - c.cancel() - } - - if c.httpServer != nil { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - if err := c.httpServer.Shutdown(shutdownCtx); err != nil { - logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{ - "error": err.Error(), - }) - } - } - - c.setRunning(false) - logger.InfoC("line", "LINE channel stopped") - return nil -} - -// webhookHandler handles incoming LINE webhook requests. -func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - logger.ErrorCF("line", "Failed to read request body", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - - signature := r.Header.Get("X-Line-Signature") - if !c.verifySignature(body, signature) { - logger.WarnC("line", "Invalid webhook signature") - http.Error(w, "Forbidden", http.StatusForbidden) - return - } - - var payload struct { - Events []lineEvent `json:"events"` - } - if err := json.Unmarshal(body, &payload); err != nil { - logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Bad request", http.StatusBadRequest) - return - } - - // Return 200 immediately, process events asynchronously - w.WriteHeader(http.StatusOK) - - for _, event := range payload.Events { - go c.processEvent(event) - } -} - -// verifySignature validates the X-Line-Signature using HMAC-SHA256. -func (c *LINEChannel) verifySignature(body []byte, signature string) bool { - if signature == "" { - return false - } - - mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret)) - mac.Write(body) - expected := base64.StdEncoding.EncodeToString(mac.Sum(nil)) - - return hmac.Equal([]byte(expected), []byte(signature)) -} - -// LINE webhook event types -type lineEvent struct { - Type string `json:"type"` - ReplyToken string `json:"replyToken"` - Source lineSource `json:"source"` - Message json.RawMessage `json:"message"` - Timestamp int64 `json:"timestamp"` -} - -type lineSource struct { - Type string `json:"type"` // "user", "group", "room" - UserID string `json:"userId"` - GroupID string `json:"groupId"` - RoomID string `json:"roomId"` -} - -type lineMessage struct { - ID string `json:"id"` - Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker" - Text string `json:"text"` - QuoteToken string `json:"quoteToken"` - Mention *struct { - Mentionees []lineMentionee `json:"mentionees"` - } `json:"mention"` - ContentProvider struct { - Type string `json:"type"` - } `json:"contentProvider"` -} - -type lineMentionee struct { - Index int `json:"index"` - Length int `json:"length"` - Type string `json:"type"` // "user", "all" - UserID string `json:"userId"` -} - -func (c *LINEChannel) processEvent(event lineEvent) { - if event.Type != "message" { - logger.DebugCF("line", "Ignoring non-message event", map[string]any{ - "type": event.Type, - }) - return - } - - senderID := event.Source.UserID - chatID := c.resolveChatID(event.Source) - isGroup := event.Source.Type == "group" || event.Source.Type == "room" - - var msg lineMessage - if err := json.Unmarshal(event.Message, &msg); err != nil { - logger.ErrorCF("line", "Failed to parse message", map[string]any{ - "error": err.Error(), - }) - return - } - - // In group chats, only respond when the bot is mentioned - if isGroup && !c.isBotMentioned(msg) { - logger.DebugCF("line", "Ignoring group message without mention", map[string]any{ - "chat_id": chatID, - }) - return - } - - // Store reply token for later use - if event.ReplyToken != "" { - c.replyTokens.Store(chatID, replyTokenEntry{ - token: event.ReplyToken, - timestamp: time.Now(), - }) - } - - // Store quote token for quoting the original message in reply - if msg.QuoteToken != "" { - c.quoteTokens.Store(chatID, msg.QuoteToken) - } - - var content string - var mediaPaths []string - localFiles := []string{} - - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) - } - } - }() - - switch msg.Type { - case "text": - content = msg.Text - // Strip bot mention from text in group chats - if isGroup { - content = c.stripBotMention(content, msg) - } - case "image": - localPath := c.downloadContent(msg.ID, "image.jpg") - if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) - content = "[image]" - } - case "audio": - localPath := c.downloadContent(msg.ID, "audio.m4a") - if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) - content = "[audio]" - } - case "video": - localPath := c.downloadContent(msg.ID, "video.mp4") - if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) - content = "[video]" - } - case "file": - content = "[file]" - case "sticker": - content = "[sticker]" - default: - content = fmt.Sprintf("[%s]", msg.Type) - } - - if strings.TrimSpace(content) == "" { - return - } - - metadata := map[string]string{ - "platform": "line", - "source_type": event.Source.Type, - "message_id": msg.ID, - } - - if isGroup { - metadata["peer_kind"] = "group" - metadata["peer_id"] = chatID - } else { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID - } - - logger.DebugCF("line", "Received message", map[string]any{ - "sender_id": senderID, - "chat_id": chatID, - "message_type": msg.Type, - "is_group": isGroup, - "preview": utils.Truncate(content, 50), - }) - - // Show typing/loading indicator (requires user ID, not group ID) - c.sendLoading(senderID) - - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) -} - -// isBotMentioned checks if the bot is mentioned in the message. -// It first checks the mention metadata (userId match), then falls back -// to text-based detection using the bot's display name, since LINE may -// not include userId in mentionees for Official Accounts. -func (c *LINEChannel) isBotMentioned(msg lineMessage) bool { - // Check mention metadata - if msg.Mention != nil { - for _, m := range msg.Mention.Mentionees { - if m.Type == "all" { - return true - } - if c.botUserID != "" && m.UserID == c.botUserID { - return true - } - } - // Mention metadata exists with mentionees but bot not matched by userId. - // The bot IS likely mentioned (LINE includes mention struct when bot is @-ed), - // so check if any mentionee overlaps with bot display name in text. - if c.botDisplayName != "" { - for _, m := range msg.Mention.Mentionees { - if m.Index >= 0 && m.Length > 0 { - runes := []rune(msg.Text) - end := m.Index + m.Length - if end <= len(runes) { - mentionText := string(runes[m.Index:end]) - if strings.Contains(mentionText, c.botDisplayName) { - return true - } - } - } - } - } - } - - // Fallback: text-based detection with display name - if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) { - return true - } - - return false -} - -// stripBotMention removes the @BotName mention text from the message. -func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string { - stripped := false - - // Try to strip using mention metadata indices - if msg.Mention != nil { - runes := []rune(text) - for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- { - m := msg.Mention.Mentionees[i] - // Strip if userId matches OR if the mention text contains the bot display name - shouldStrip := false - if c.botUserID != "" && m.UserID == c.botUserID { - shouldStrip = true - } else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 { - end := m.Index + m.Length - if end <= len(runes) { - mentionText := string(runes[m.Index:end]) - if strings.Contains(mentionText, c.botDisplayName) { - shouldStrip = true - } - } - } - if shouldStrip { - start := m.Index - end := m.Index + m.Length - if start >= 0 && end <= len(runes) { - runes = append(runes[:start], runes[end:]...) - stripped = true - } - } - } - if stripped { - return strings.TrimSpace(string(runes)) - } - } - - // Fallback: strip @DisplayName from text - if c.botDisplayName != "" { - text = strings.ReplaceAll(text, "@"+c.botDisplayName, "") - } - - return strings.TrimSpace(text) -} - -// resolveChatID determines the chat ID from the event source. -// For group/room messages, use the group/room ID; for 1:1, use the user ID. -func (c *LINEChannel) resolveChatID(source lineSource) string { - switch source.Type { - case "group": - return source.GroupID - case "room": - return source.RoomID - default: - return source.UserID - } -} - -// Send sends a message to LINE. It first tries the Reply API (free) -// using a cached reply token, then falls back to the Push API. -func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("line channel not running") - } - - // Load and consume quote token for this chat - var quoteToken string - if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok { - quoteToken = qt.(string) - } - - // Try reply token first (free, valid for ~25 seconds) - if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok { - tokenEntry := entry.(replyTokenEntry) - if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { - if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { - logger.DebugCF("line", "Message sent via Reply API", map[string]any{ - "chat_id": msg.ChatID, - "quoted": quoteToken != "", - }) - return nil - } - logger.DebugC("line", "Reply API failed, falling back to Push API") - } - } - - // Fall back to Push API - return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) -} - -// buildTextMessage creates a text message object, optionally with quoteToken. -func buildTextMessage(content, quoteToken string) map[string]string { - msg := map[string]string{ - "type": "text", - "text": content, - } - if quoteToken != "" { - msg["quoteToken"] = quoteToken - } - return msg -} - -// sendReply sends a message using the LINE Reply API. -func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error { - payload := map[string]any{ - "replyToken": replyToken, - "messages": []map[string]string{buildTextMessage(content, quoteToken)}, - } - - return c.callAPI(ctx, lineReplyEndpoint, payload) -} - -// sendPush sends a message using the LINE Push API. -func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error { - payload := map[string]any{ - "to": to, - "messages": []map[string]string{buildTextMessage(content, quoteToken)}, - } - - return c.callAPI(ctx, linePushEndpoint, payload) -} - -// sendLoading sends a loading animation indicator to the chat. -func (c *LINEChannel) sendLoading(chatID string) { - payload := map[string]any{ - "chatId": chatID, - "loadingSeconds": 60, - } - if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil { - logger.DebugCF("line", "Failed to send loading indicator", map[string]any{ - "error": err.Error(), - }) - } -} - -// callAPI makes an authenticated POST request to the LINE API. -func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error { - body, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) - - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("API request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody)) - } - - return nil -} - -// downloadContent downloads media content from the LINE API. -func (c *LINEChannel) downloadContent(messageID, filename string) string { - url := fmt.Sprintf(lineContentEndpoint, messageID) - return utils.DownloadFile(url, filename, utils.DownloadOptions{ - LoggerPrefix: "line", - ExtraHeaders: map[string]string{ - "Authorization": "Bearer " + c.config.ChannelAccessToken, - }, - }) -} diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go deleted file mode 100644 index 34ce62b20..000000000 --- a/pkg/channels/maixcam.go +++ /dev/null @@ -1,243 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "fmt" - "net" - "sync" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" -) - -type MaixCamChannel struct { - *BaseChannel - config config.MaixCamConfig - listener net.Listener - clients map[net.Conn]bool - clientsMux sync.RWMutex -} - -type MaixCamMessage struct { - Type string `json:"type"` - Tips string `json:"tips"` - Timestamp float64 `json:"timestamp"` - Data map[string]any `json:"data"` -} - -func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { - base := NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom) - - return &MaixCamChannel{ - BaseChannel: base, - config: cfg, - clients: make(map[net.Conn]bool), - }, nil -} - -func (c *MaixCamChannel) Start(ctx context.Context) error { - logger.InfoC("maixcam", "Starting MaixCam channel server") - - addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port) - listener, err := net.Listen("tcp", addr) - if err != nil { - return fmt.Errorf("failed to listen on %s: %w", addr, err) - } - - c.listener = listener - c.setRunning(true) - - logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{ - "host": c.config.Host, - "port": c.config.Port, - }) - - go c.acceptConnections(ctx) - - return nil -} - -func (c *MaixCamChannel) acceptConnections(ctx context.Context) { - logger.DebugC("maixcam", "Starting connection acceptor") - - for { - select { - case <-ctx.Done(): - logger.InfoC("maixcam", "Stopping connection acceptor") - return - default: - conn, err := c.listener.Accept() - if err != nil { - if c.running { - logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{ - "error": err.Error(), - }) - } - return - } - - logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{ - "remote_addr": conn.RemoteAddr().String(), - }) - - c.clientsMux.Lock() - c.clients[conn] = true - c.clientsMux.Unlock() - - go c.handleConnection(conn, ctx) - } - } -} - -func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { - logger.DebugC("maixcam", "Handling MaixCam connection") - - defer func() { - conn.Close() - c.clientsMux.Lock() - delete(c.clients, conn) - c.clientsMux.Unlock() - logger.DebugC("maixcam", "Connection closed") - }() - - decoder := json.NewDecoder(conn) - - for { - select { - case <-ctx.Done(): - return - default: - var msg MaixCamMessage - if err := decoder.Decode(&msg); err != nil { - if err.Error() != "EOF" { - logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{ - "error": err.Error(), - }) - } - return - } - - c.processMessage(msg, conn) - } - } -} - -func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) { - switch msg.Type { - case "person_detected": - c.handlePersonDetection(msg) - case "heartbeat": - logger.DebugC("maixcam", "Received heartbeat") - case "status": - c.handleStatusUpdate(msg) - default: - logger.WarnCF("maixcam", "Unknown message type", map[string]any{ - "type": msg.Type, - }) - } -} - -func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { - logger.InfoCF("maixcam", "", map[string]any{ - "timestamp": msg.Timestamp, - "data": msg.Data, - }) - - senderID := "maixcam" - chatID := "default" - - classInfo, ok := msg.Data["class_name"].(string) - if !ok { - classInfo = "person" - } - - score, _ := msg.Data["score"].(float64) - x, _ := msg.Data["x"].(float64) - y, _ := msg.Data["y"].(float64) - w, _ := msg.Data["w"].(float64) - h, _ := msg.Data["h"].(float64) - - content := fmt.Sprintf("📷 Person detected!\nClass: %s\nConfidence: %.2f%%\nPosition: (%.0f, %.0f)\nSize: %.0fx%.0f", - classInfo, score*100, x, y, w, h) - - metadata := map[string]string{ - "timestamp": fmt.Sprintf("%.0f", msg.Timestamp), - "class_id": fmt.Sprintf("%.0f", msg.Data["class_id"]), - "score": fmt.Sprintf("%.2f", score), - "x": fmt.Sprintf("%.0f", x), - "y": fmt.Sprintf("%.0f", y), - "w": fmt.Sprintf("%.0f", w), - "h": fmt.Sprintf("%.0f", h), - "peer_kind": "channel", - "peer_id": "default", - } - - c.HandleMessage(senderID, chatID, content, []string{}, metadata) -} - -func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { - logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{ - "status": msg.Data, - }) -} - -func (c *MaixCamChannel) Stop(ctx context.Context) error { - logger.InfoC("maixcam", "Stopping MaixCam channel") - c.setRunning(false) - - if c.listener != nil { - c.listener.Close() - } - - c.clientsMux.Lock() - defer c.clientsMux.Unlock() - - for conn := range c.clients { - conn.Close() - } - c.clients = make(map[net.Conn]bool) - - logger.InfoC("maixcam", "MaixCam channel stopped") - return nil -} - -func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("maixcam channel not running") - } - - c.clientsMux.RLock() - defer c.clientsMux.RUnlock() - - if len(c.clients) == 0 { - logger.WarnC("maixcam", "No MaixCam devices connected") - return fmt.Errorf("no connected MaixCam devices") - } - - response := map[string]any{ - "type": "command", - "timestamp": float64(0), - "message": msg.Content, - "chat_id": msg.ChatID, - } - - data, err := json.Marshal(response) - if err != nil { - return fmt.Errorf("failed to marshal response: %w", err) - } - - var sendErr error - for conn := range c.clients { - if _, err := conn.Write(data); err != nil { - logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{ - "client": conn.RemoteAddr().String(), - "error": err.Error(), - }) - sendErr = err - } - } - - return sendErr -} diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go deleted file mode 100644 index 4576a11ce..000000000 --- a/pkg/channels/onebot.go +++ /dev/null @@ -1,984 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "fmt" - "os" - "strconv" - "strings" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" -) - -type OneBotChannel struct { - *BaseChannel - config config.OneBotConfig - conn *websocket.Conn - ctx context.Context - cancel context.CancelFunc - dedup map[string]struct{} - dedupRing []string - dedupIdx int - mu sync.Mutex - writeMu sync.Mutex - echoCounter int64 - selfID int64 - pending map[string]chan json.RawMessage - pendingMu sync.Mutex - transcriber *voice.GroqTranscriber - lastMessageID sync.Map - pendingEmojiMsg sync.Map -} - -type oneBotRawEvent struct { - PostType string `json:"post_type"` - MessageType string `json:"message_type"` - SubType string `json:"sub_type"` - MessageID json.RawMessage `json:"message_id"` - UserID json.RawMessage `json:"user_id"` - GroupID json.RawMessage `json:"group_id"` - RawMessage string `json:"raw_message"` - Message json.RawMessage `json:"message"` - Sender json.RawMessage `json:"sender"` - SelfID json.RawMessage `json:"self_id"` - Time json.RawMessage `json:"time"` - MetaEventType string `json:"meta_event_type"` - NoticeType string `json:"notice_type"` - Echo string `json:"echo"` - RetCode json.RawMessage `json:"retcode"` - Status json.RawMessage `json:"status"` - Data json.RawMessage `json:"data"` -} - -type BotStatus struct { - Online bool `json:"online"` - Good bool `json:"good"` -} - -func isAPIResponse(raw json.RawMessage) bool { - if len(raw) == 0 { - return false - } - var s string - if json.Unmarshal(raw, &s) == nil { - return s == "ok" || s == "failed" - } - var bs BotStatus - if json.Unmarshal(raw, &bs) == nil { - return bs.Online || bs.Good - } - return false -} - -type oneBotSender struct { - UserID json.RawMessage `json:"user_id"` - Nickname string `json:"nickname"` - Card string `json:"card"` -} - -type oneBotAPIRequest struct { - Action string `json:"action"` - Params any `json:"params"` - Echo string `json:"echo,omitempty"` -} - -type oneBotMessageSegment struct { - Type string `json:"type"` - Data map[string]any `json:"data"` -} - -func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { - base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom) - - const dedupSize = 1024 - return &OneBotChannel{ - BaseChannel: base, - config: cfg, - dedup: make(map[string]struct{}, dedupSize), - dedupRing: make([]string, dedupSize), - dedupIdx: 0, - pending: make(map[string]chan json.RawMessage), - }, nil -} - -func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - -func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) { - go func() { - _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{ - "message_id": messageID, - "emoji_id": emojiID, - "set": set, - }, 5*time.Second) - if err != nil { - logger.DebugCF("onebot", "Failed to set emoji like", map[string]any{ - "message_id": messageID, - "error": err.Error(), - }) - } - }() -} - -func (c *OneBotChannel) Start(ctx context.Context) error { - if c.config.WSUrl == "" { - return fmt.Errorf("OneBot ws_url not configured") - } - - logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{ - "ws_url": c.config.WSUrl, - }) - - c.ctx, c.cancel = context.WithCancel(ctx) - - if err := c.connect(); err != nil { - logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{ - "error": err.Error(), - }) - } else { - go c.listen() - c.fetchSelfID() - } - - if c.config.ReconnectInterval > 0 { - go c.reconnectLoop() - } else { - if c.conn == nil { - return fmt.Errorf("failed to connect to OneBot and reconnect is disabled") - } - } - - c.setRunning(true) - logger.InfoC("onebot", "OneBot channel started successfully") - - return nil -} - -func (c *OneBotChannel) connect() error { - dialer := websocket.DefaultDialer - dialer.HandshakeTimeout = 10 * time.Second - - header := make(map[string][]string) - if c.config.AccessToken != "" { - header["Authorization"] = []string{"Bearer " + c.config.AccessToken} - } - - conn, resp, err := dialer.Dial(c.config.WSUrl, header) - if resp != nil { - resp.Body.Close() - } - if err != nil { - return err - } - - conn.SetPongHandler(func(appData string) error { - _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) - return nil - }) - _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) - - c.mu.Lock() - c.conn = conn - c.mu.Unlock() - - go c.pinger(conn) - - logger.InfoC("onebot", "WebSocket connected") - return nil -} - -func (c *OneBotChannel) pinger(conn *websocket.Conn) { - ticker := time.NewTicker(30 * time.Second) - defer ticker.Stop() - - for { - select { - case <-c.ctx.Done(): - return - case <-ticker.C: - c.writeMu.Lock() - err := conn.WriteMessage(websocket.PingMessage, nil) - c.writeMu.Unlock() - if err != nil { - logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]any{ - "error": err.Error(), - }) - return - } - } - } -} - -func (c *OneBotChannel) fetchSelfID() { - resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second) - if err != nil { - logger.WarnCF("onebot", "Failed to get_login_info", map[string]any{ - "error": err.Error(), - }) - return - } - - type loginInfo struct { - UserID json.RawMessage `json:"user_id"` - Nickname string `json:"nickname"` - } - for _, extract := range []func() (*loginInfo, error){ - func() (*loginInfo, error) { - var w struct { - Data loginInfo `json:"data"` - } - err := json.Unmarshal(resp, &w) - return &w.Data, err - }, - func() (*loginInfo, error) { - var f loginInfo - err := json.Unmarshal(resp, &f) - return &f, err - }, - } { - info, err := extract() - if err != nil || len(info.UserID) == 0 { - continue - } - if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 { - atomic.StoreInt64(&c.selfID, uid) - logger.InfoCF("onebot", "Bot self ID retrieved", map[string]any{ - "self_id": uid, - "nickname": info.Nickname, - }) - return - } - } - - logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]any{ - "response": string(resp), - }) -} - -func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) { - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - if conn == nil { - return nil, fmt.Errorf("WebSocket not connected") - } - - echo := fmt.Sprintf("api_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&c.echoCounter, 1)) - - ch := make(chan json.RawMessage, 1) - c.pendingMu.Lock() - c.pending[echo] = ch - c.pendingMu.Unlock() - - defer func() { - c.pendingMu.Lock() - delete(c.pending, echo) - c.pendingMu.Unlock() - }() - - req := oneBotAPIRequest{ - Action: action, - Params: params, - Echo: echo, - } - - data, err := json.Marshal(req) - if err != nil { - return nil, fmt.Errorf("failed to marshal API request: %w", err) - } - - c.writeMu.Lock() - err = conn.WriteMessage(websocket.TextMessage, data) - c.writeMu.Unlock() - - if err != nil { - return nil, fmt.Errorf("failed to write API request: %w", err) - } - - select { - case resp := <-ch: - return resp, nil - case <-time.After(timeout): - return nil, fmt.Errorf("API request %s timed out after %v", action, timeout) - case <-c.ctx.Done(): - return nil, fmt.Errorf("context canceled") - } -} - -func (c *OneBotChannel) reconnectLoop() { - interval := time.Duration(c.config.ReconnectInterval) * time.Second - if interval < 5*time.Second { - interval = 5 * time.Second - } - - for { - select { - case <-c.ctx.Done(): - return - case <-time.After(interval): - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - if conn == nil { - logger.InfoC("onebot", "Attempting to reconnect...") - if err := c.connect(); err != nil { - logger.ErrorCF("onebot", "Reconnect failed", map[string]any{ - "error": err.Error(), - }) - } else { - go c.listen() - c.fetchSelfID() - } - } - } - } -} - -func (c *OneBotChannel) Stop(ctx context.Context) error { - logger.InfoC("onebot", "Stopping OneBot channel") - c.setRunning(false) - - if c.cancel != nil { - c.cancel() - } - - c.pendingMu.Lock() - for echo, ch := range c.pending { - close(ch) - delete(c.pending, echo) - } - c.pendingMu.Unlock() - - c.mu.Lock() - if c.conn != nil { - c.conn.Close() - c.conn = nil - } - c.mu.Unlock() - - return nil -} - -func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("OneBot channel not running") - } - - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - if conn == nil { - return fmt.Errorf("OneBot WebSocket not connected") - } - - action, params, err := c.buildSendRequest(msg) - if err != nil { - return err - } - - echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) - - req := oneBotAPIRequest{ - Action: action, - Params: params, - Echo: echo, - } - - data, err := json.Marshal(req) - if err != nil { - return fmt.Errorf("failed to marshal OneBot request: %w", err) - } - - c.writeMu.Lock() - err = conn.WriteMessage(websocket.TextMessage, data) - c.writeMu.Unlock() - - if err != nil { - logger.ErrorCF("onebot", "Failed to send message", map[string]any{ - "error": err.Error(), - }) - return err - } - - if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok { - if mid, ok := msgID.(string); ok && mid != "" { - c.setMsgEmojiLike(mid, 289, false) - } - } - - return nil -} - -func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment { - var segments []oneBotMessageSegment - - if lastMsgID, ok := c.lastMessageID.Load(chatID); ok { - if msgID, ok := lastMsgID.(string); ok && msgID != "" { - segments = append(segments, oneBotMessageSegment{ - Type: "reply", - Data: map[string]any{"id": msgID}, - }) - } - } - - segments = append(segments, oneBotMessageSegment{ - Type: "text", - Data: map[string]any{"text": content}, - }) - - return segments -} - -func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) { - chatID := msg.ChatID - segments := c.buildMessageSegments(chatID, msg.Content) - - var action, idKey string - var rawID string - if rest, ok := strings.CutPrefix(chatID, "group:"); ok { - action, idKey, rawID = "send_group_msg", "group_id", rest - } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok { - action, idKey, rawID = "send_private_msg", "user_id", rest - } else { - action, idKey, rawID = "send_private_msg", "user_id", chatID - } - - id, err := strconv.ParseInt(rawID, 10, 64) - if err != nil { - return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID) - } - return action, map[string]any{idKey: id, "message": segments}, nil -} - -func (c *OneBotChannel) listen() { - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - if conn == nil { - logger.WarnC("onebot", "WebSocket connection is nil, listener exiting") - return - } - - for { - select { - case <-c.ctx.Done(): - return - default: - _, message, err := conn.ReadMessage() - if err != nil { - logger.ErrorCF("onebot", "WebSocket read error", map[string]any{ - "error": err.Error(), - }) - c.mu.Lock() - if c.conn == conn { - c.conn.Close() - c.conn = nil - } - c.mu.Unlock() - return - } - - _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second)) - - var raw oneBotRawEvent - if err := json.Unmarshal(message, &raw); err != nil { - logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{ - "error": err.Error(), - "payload": string(message), - }) - continue - } - - logger.DebugCF("onebot", "WebSocket event", map[string]any{ - "length": len(message), - "post_type": raw.PostType, - "sub_type": raw.SubType, - }) - - if raw.Echo != "" { - c.pendingMu.Lock() - ch, ok := c.pending[raw.Echo] - c.pendingMu.Unlock() - - if ok { - select { - case ch <- message: - default: - } - } else { - logger.DebugCF("onebot", "Received API response (no waiter)", map[string]any{ - "echo": raw.Echo, - "status": string(raw.Status), - }) - } - continue - } - - if isAPIResponse(raw.Status) { - logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]any{ - "status": string(raw.Status), - }) - continue - } - - c.handleRawEvent(&raw) - } - } -} - -func parseJSONInt64(raw json.RawMessage) (int64, error) { - if len(raw) == 0 { - return 0, nil - } - - var n int64 - if err := json.Unmarshal(raw, &n); err == nil { - return n, nil - } - - var s string - if err := json.Unmarshal(raw, &s); err == nil { - return strconv.ParseInt(s, 10, 64) - } - return 0, fmt.Errorf("cannot parse as int64: %s", string(raw)) -} - -func parseJSONString(raw json.RawMessage) string { - if len(raw) == 0 { - return "" - } - var s string - if err := json.Unmarshal(raw, &s); err == nil { - return s - } - - return string(raw) -} - -type parseMessageResult struct { - Text string - IsBotMentioned bool - Media []string - LocalFiles []string - ReplyTo string -} - -func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult { - if len(raw) == 0 { - return parseMessageResult{} - } - - var s string - if err := json.Unmarshal(raw, &s); err == nil { - mentioned := false - if selfID > 0 { - cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) - if strings.Contains(s, cqAt) { - mentioned = true - s = strings.ReplaceAll(s, cqAt, "") - s = strings.TrimSpace(s) - } - } - return parseMessageResult{Text: s, IsBotMentioned: mentioned} - } - - var segments []map[string]any - if err := json.Unmarshal(raw, &segments); err != nil { - return parseMessageResult{} - } - - var textParts []string - mentioned := false - selfIDStr := strconv.FormatInt(selfID, 10) - var media []string - var localFiles []string - var replyTo string - - for _, seg := range segments { - segType, _ := seg["type"].(string) - data, _ := seg["data"].(map[string]any) - - switch segType { - case "text": - if data != nil { - if t, ok := data["text"].(string); ok { - textParts = append(textParts, t) - } - } - - case "at": - if data != nil && selfID > 0 { - qqVal := fmt.Sprintf("%v", data["qq"]) - if qqVal == selfIDStr || qqVal == "all" { - mentioned = true - } - } - - case "image", "video", "file": - if data != nil { - url, _ := data["url"].(string) - if url != "" { - defaults := map[string]string{"image": "image.jpg", "video": "video.mp4", "file": "file"} - filename := defaults[segType] - if f, ok := data["file"].(string); ok && f != "" { - filename = f - } else if n, ok := data["name"].(string); ok && n != "" { - filename = n - } - localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{ - LoggerPrefix: "onebot", - }) - if localPath != "" { - media = append(media, localPath) - localFiles = append(localFiles, localPath) - textParts = append(textParts, fmt.Sprintf("[%s]", segType)) - } - } - } - - case "record": - if data != nil { - url, _ := data["url"].(string) - if url != "" { - localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{ - LoggerPrefix: "onebot", - }) - if localPath != "" { - localFiles = append(localFiles, localPath) - if c.transcriber != nil && c.transcriber.IsAvailable() { - tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second) - result, err := c.transcriber.Transcribe(tctx, localPath) - tcancel() - if err != nil { - logger.WarnCF("onebot", "Voice transcription failed", map[string]any{ - "error": err.Error(), - }) - textParts = append(textParts, "[voice (transcription failed)]") - media = append(media, localPath) - } else { - textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text)) - } - } else { - textParts = append(textParts, "[voice]") - media = append(media, localPath) - } - } - } - } - - case "reply": - if data != nil { - if id, ok := data["id"]; ok { - replyTo = fmt.Sprintf("%v", id) - } - } - - case "face": - if data != nil { - faceID, _ := data["id"] - textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID)) - } - - case "forward": - textParts = append(textParts, "[forward message]") - - default: - } - } - - return parseMessageResult{ - Text: strings.TrimSpace(strings.Join(textParts, "")), - IsBotMentioned: mentioned, - Media: media, - LocalFiles: localFiles, - ReplyTo: replyTo, - } -} - -func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { - switch raw.PostType { - case "message": - if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 { - if !c.IsAllowed(strconv.FormatInt(userID, 10)) { - logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{ - "user_id": userID, - }) - return - } - } - c.handleMessage(raw) - - case "message_sent": - logger.DebugCF("onebot", "Bot sent message event", map[string]any{ - "message_type": raw.MessageType, - "message_id": parseJSONString(raw.MessageID), - }) - - case "meta_event": - c.handleMetaEvent(raw) - - case "notice": - c.handleNoticeEvent(raw) - - case "request": - logger.DebugCF("onebot", "Request event received", map[string]any{ - "sub_type": raw.SubType, - }) - - case "": - logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{ - "echo": raw.Echo, - "status": raw.Status, - }) - - default: - logger.DebugCF("onebot", "Unknown post_type", map[string]any{ - "post_type": raw.PostType, - }) - } -} - -func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { - if raw.MetaEventType == "lifecycle" { - logger.InfoCF("onebot", "Lifecycle event", map[string]any{"sub_type": raw.SubType}) - } else if raw.MetaEventType != "heartbeat" { - logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil) - } -} - -func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) { - fields := map[string]any{ - "notice_type": raw.NoticeType, - "sub_type": raw.SubType, - "group_id": parseJSONString(raw.GroupID), - "user_id": parseJSONString(raw.UserID), - "message_id": parseJSONString(raw.MessageID), - } - switch raw.NoticeType { - case "group_recall", "group_increase", "group_decrease", - "friend_add", "group_admin", "group_ban": - logger.InfoCF("onebot", "Notice: "+raw.NoticeType, fields) - default: - logger.DebugCF("onebot", "Notice: "+raw.NoticeType, fields) - } -} - -func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { - // Parse fields from raw event - userID, err := parseJSONInt64(raw.UserID) - if err != nil { - logger.WarnCF("onebot", "Failed to parse user_id", map[string]any{ - "error": err.Error(), - "raw": string(raw.UserID), - }) - return - } - - groupID, _ := parseJSONInt64(raw.GroupID) - selfID, _ := parseJSONInt64(raw.SelfID) - messageID := parseJSONString(raw.MessageID) - - if selfID == 0 { - selfID = atomic.LoadInt64(&c.selfID) - } - - parsed := c.parseMessageSegments(raw.Message, selfID) - isBotMentioned := parsed.IsBotMentioned - - content := raw.RawMessage - if content == "" { - content = parsed.Text - } else if selfID > 0 { - cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID) - if strings.Contains(content, cqAt) { - isBotMentioned = true - content = strings.ReplaceAll(content, cqAt, "") - content = strings.TrimSpace(content) - } - } - - if parsed.Text != "" && content != parsed.Text && (len(parsed.Media) > 0 || parsed.ReplyTo != "") { - content = parsed.Text - } - - var sender oneBotSender - if len(raw.Sender) > 0 { - if err := json.Unmarshal(raw.Sender, &sender); err != nil { - logger.WarnCF("onebot", "Failed to parse sender", map[string]any{ - "error": err.Error(), - "sender": string(raw.Sender), - }) - } - } - - // Clean up temp files when done - if len(parsed.LocalFiles) > 0 { - defer func() { - for _, f := range parsed.LocalFiles { - if err := os.Remove(f); err != nil { - logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{ - "path": f, - "error": err.Error(), - }) - } - } - }() - } - - if c.isDuplicate(messageID) { - logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{ - "message_id": messageID, - }) - return - } - - if content == "" { - logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{ - "message_id": messageID, - }) - return - } - - senderID := strconv.FormatInt(userID, 10) - var chatID string - - metadata := map[string]string{ - "message_id": messageID, - } - - if parsed.ReplyTo != "" { - metadata["reply_to_message_id"] = parsed.ReplyTo - } - - switch raw.MessageType { - case "private": - chatID = "private:" + senderID - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID - - case "group": - groupIDStr := strconv.FormatInt(groupID, 10) - chatID = "group:" + groupIDStr - metadata["peer_kind"] = "group" - metadata["peer_id"] = groupIDStr - metadata["group_id"] = groupIDStr - - senderUserID, _ := parseJSONInt64(sender.UserID) - if senderUserID > 0 { - metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10) - } - - if sender.Card != "" { - metadata["sender_name"] = sender.Card - } else if sender.Nickname != "" { - metadata["sender_name"] = sender.Nickname - } - - triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned) - if !triggered { - logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{ - "sender": senderID, - "group": groupIDStr, - "is_mentioned": isBotMentioned, - "content": truncate(content, 100), - }) - return - } - content = strippedContent - - default: - logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{ - "type": raw.MessageType, - "message_id": messageID, - "user_id": userID, - }) - return - } - - logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{ - "sender": senderID, - "chat_id": chatID, - "message_id": messageID, - "length": len(content), - "content": truncate(content, 100), - "media_count": len(parsed.Media), - }) - - if sender.Nickname != "" { - metadata["nickname"] = sender.Nickname - } - - c.lastMessageID.Store(chatID, messageID) - - if raw.MessageType == "group" && messageID != "" && messageID != "0" { - c.setMsgEmojiLike(messageID, 289, true) - c.pendingEmojiMsg.Store(chatID, messageID) - } - - c.HandleMessage(senderID, chatID, content, parsed.Media, metadata) -} - -func (c *OneBotChannel) isDuplicate(messageID string) bool { - if messageID == "" || messageID == "0" { - return false - } - - c.mu.Lock() - defer c.mu.Unlock() - - if _, exists := c.dedup[messageID]; exists { - return true - } - - if old := c.dedupRing[c.dedupIdx]; old != "" { - delete(c.dedup, old) - } - c.dedupRing[c.dedupIdx] = messageID - c.dedup[messageID] = struct{}{} - c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing) - - return false -} - -func truncate(s string, n int) string { - runes := []rune(s) - if len(runes) <= n { - return s - } - return string(runes[:n]) + "..." -} - -func (c *OneBotChannel) checkGroupTrigger( - content string, - isBotMentioned bool, -) (triggered bool, strippedContent string) { - if isBotMentioned { - return true, strings.TrimSpace(content) - } - - for _, prefix := range c.config.GroupTriggerPrefix { - if prefix == "" { - continue - } - if strings.HasPrefix(content, prefix) { - return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) - } - } - - return false, content -} diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go deleted file mode 100644 index b10776db6..000000000 --- a/pkg/channels/qq.go +++ /dev/null @@ -1,247 +0,0 @@ -package channels - -import ( - "context" - "fmt" - "sync" - "time" - - "github.com/tencent-connect/botgo" - "github.com/tencent-connect/botgo/dto" - "github.com/tencent-connect/botgo/event" - "github.com/tencent-connect/botgo/openapi" - "github.com/tencent-connect/botgo/token" - "golang.org/x/oauth2" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" -) - -type QQChannel struct { - *BaseChannel - config config.QQConfig - api openapi.OpenAPI - tokenSource oauth2.TokenSource - ctx context.Context - cancel context.CancelFunc - sessionManager botgo.SessionManager - processedIDs map[string]bool - mu sync.RWMutex -} - -func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { - base := NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom) - - return &QQChannel{ - BaseChannel: base, - config: cfg, - processedIDs: make(map[string]bool), - }, nil -} - -func (c *QQChannel) Start(ctx context.Context) error { - if c.config.AppID == "" || c.config.AppSecret == "" { - return fmt.Errorf("QQ app_id and app_secret not configured") - } - - logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") - - // create token source - credentials := &token.QQBotCredentials{ - AppID: c.config.AppID, - AppSecret: c.config.AppSecret, - } - c.tokenSource = token.NewQQBotTokenSource(credentials) - - // create child context - c.ctx, c.cancel = context.WithCancel(ctx) - - // start auto-refresh token goroutine - if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil { - return fmt.Errorf("failed to start token refresh: %w", err) - } - - // initialize OpenAPI client - c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) - - // register event handlers - intent := event.RegisterHandlers( - c.handleC2CMessage(), - c.handleGroupATMessage(), - ) - - // get WebSocket endpoint - wsInfo, err := c.api.WS(c.ctx, nil, "") - if err != nil { - return fmt.Errorf("failed to get websocket info: %w", err) - } - - logger.InfoCF("qq", "Got WebSocket info", map[string]any{ - "shards": wsInfo.Shards, - }) - - // create and save sessionManager - c.sessionManager = botgo.NewSessionManager() - - // start WebSocket connection in goroutine to avoid blocking - go func() { - if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil { - logger.ErrorCF("qq", "WebSocket session error", map[string]any{ - "error": err.Error(), - }) - c.setRunning(false) - } - }() - - c.setRunning(true) - logger.InfoC("qq", "QQ bot started successfully") - - return nil -} - -func (c *QQChannel) Stop(ctx context.Context) error { - logger.InfoC("qq", "Stopping QQ bot") - c.setRunning(false) - - if c.cancel != nil { - c.cancel() - } - - return nil -} - -func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("QQ bot not running") - } - - // construct message - msgToCreate := &dto.MessageToCreate{ - Content: msg.Content, - } - - // send C2C message - _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) - if err != nil { - logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ - "error": err.Error(), - }) - return err - } - - return nil -} - -// handleC2CMessage handles QQ private messages -func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { - return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { - // deduplication check - if c.isDuplicate(data.ID) { - return nil - } - - // extract user info - var senderID string - if data.Author != nil && data.Author.ID != "" { - senderID = data.Author.ID - } else { - logger.WarnC("qq", "Received message with no sender ID") - return nil - } - - // extract message content - content := data.Content - if content == "" { - logger.DebugC("qq", "Received empty message, ignoring") - return nil - } - - logger.InfoCF("qq", "Received C2C message", map[string]any{ - "sender": senderID, - "length": len(content), - }) - - // forward to message bus - metadata := map[string]string{ - "message_id": data.ID, - "peer_kind": "direct", - "peer_id": senderID, - } - - c.HandleMessage(senderID, senderID, content, []string{}, metadata) - - return nil - } -} - -// handleGroupATMessage handles group @messages -func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { - return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { - // deduplication check - if c.isDuplicate(data.ID) { - return nil - } - - // extract user info - var senderID string - if data.Author != nil && data.Author.ID != "" { - senderID = data.Author.ID - } else { - logger.WarnC("qq", "Received group message with no sender ID") - return nil - } - - // extract message content (remove @bot part) - content := data.Content - if content == "" { - logger.DebugC("qq", "Received empty group message, ignoring") - return nil - } - - logger.InfoCF("qq", "Received group AT message", map[string]any{ - "sender": senderID, - "group": data.GroupID, - "length": len(content), - }) - - // forward to message bus (use GroupID as ChatID) - metadata := map[string]string{ - "message_id": data.ID, - "group_id": data.GroupID, - "peer_kind": "group", - "peer_id": data.GroupID, - } - - c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata) - - return nil - } -} - -// isDuplicate checks if message is duplicate -func (c *QQChannel) isDuplicate(messageID string) bool { - c.mu.Lock() - defer c.mu.Unlock() - - if c.processedIDs[messageID] { - return true - } - - c.processedIDs[messageID] = true - - // simple cleanup: limit map size - if len(c.processedIDs) > 10000 { - // clear half - count := 0 - for id := range c.processedIDs { - if count >= 5000 { - break - } - delete(c.processedIDs, id) - count++ - } - } - - return false -} diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go deleted file mode 100644 index cfb731b16..000000000 --- a/pkg/channels/slack.go +++ /dev/null @@ -1,443 +0,0 @@ -package channels - -import ( - "context" - "fmt" - "os" - "strings" - "sync" - "time" - - "github.com/slack-go/slack" - "github.com/slack-go/slack/slackevents" - "github.com/slack-go/slack/socketmode" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" -) - -type SlackChannel struct { - *BaseChannel - config config.SlackConfig - api *slack.Client - socketClient *socketmode.Client - botUserID string - teamID string - transcriber *voice.GroqTranscriber - ctx context.Context - cancel context.CancelFunc - pendingAcks sync.Map -} - -type slackMessageRef struct { - ChannelID string - Timestamp string -} - -func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*SlackChannel, error) { - if cfg.BotToken == "" || cfg.AppToken == "" { - return nil, fmt.Errorf("slack bot_token and app_token are required") - } - - api := slack.New( - cfg.BotToken, - slack.OptionAppLevelToken(cfg.AppToken), - ) - - socketClient := socketmode.New(api) - - base := NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom) - - return &SlackChannel{ - BaseChannel: base, - config: cfg, - api: api, - socketClient: socketClient, - }, nil -} - -func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - -func (c *SlackChannel) Start(ctx context.Context) error { - logger.InfoC("slack", "Starting Slack channel (Socket Mode)") - - c.ctx, c.cancel = context.WithCancel(ctx) - - authResp, err := c.api.AuthTest() - if err != nil { - return fmt.Errorf("slack auth test failed: %w", err) - } - c.botUserID = authResp.UserID - c.teamID = authResp.TeamID - - logger.InfoCF("slack", "Slack bot connected", map[string]any{ - "bot_user_id": c.botUserID, - "team": authResp.Team, - }) - - go c.eventLoop() - - go func() { - if err := c.socketClient.RunContext(c.ctx); err != nil { - if c.ctx.Err() == nil { - logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{ - "error": err.Error(), - }) - } - } - }() - - c.setRunning(true) - logger.InfoC("slack", "Slack channel started (Socket Mode)") - return nil -} - -func (c *SlackChannel) Stop(ctx context.Context) error { - logger.InfoC("slack", "Stopping Slack channel") - - if c.cancel != nil { - c.cancel() - } - - c.setRunning(false) - logger.InfoC("slack", "Slack channel stopped") - return nil -} - -func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("slack channel not running") - } - - channelID, threadTS := parseSlackChatID(msg.ChatID) - if channelID == "" { - return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) - } - - opts := []slack.MsgOption{ - slack.MsgOptionText(msg.Content, false), - } - - if threadTS != "" { - opts = append(opts, slack.MsgOptionTS(threadTS)) - } - - _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) - if err != nil { - return fmt.Errorf("failed to send slack message: %w", err) - } - - if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { - msgRef := ref.(slackMessageRef) - c.api.AddReaction("white_check_mark", slack.ItemRef{ - Channel: msgRef.ChannelID, - Timestamp: msgRef.Timestamp, - }) - } - - logger.DebugCF("slack", "Message sent", map[string]any{ - "channel_id": channelID, - "thread_ts": threadTS, - }) - - return nil -} - -func (c *SlackChannel) eventLoop() { - for { - select { - case <-c.ctx.Done(): - return - case event, ok := <-c.socketClient.Events: - if !ok { - return - } - switch event.Type { - case socketmode.EventTypeEventsAPI: - c.handleEventsAPI(event) - case socketmode.EventTypeSlashCommand: - c.handleSlashCommand(event) - case socketmode.EventTypeInteractive: - if event.Request != nil { - c.socketClient.Ack(*event.Request) - } - } - } - } -} - -func (c *SlackChannel) handleEventsAPI(event socketmode.Event) { - if event.Request != nil { - c.socketClient.Ack(*event.Request) - } - - eventsAPIEvent, ok := event.Data.(slackevents.EventsAPIEvent) - if !ok { - return - } - - switch ev := eventsAPIEvent.InnerEvent.Data.(type) { - case *slackevents.MessageEvent: - c.handleMessageEvent(ev) - case *slackevents.AppMentionEvent: - c.handleAppMention(ev) - } -} - -func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { - if ev.User == c.botUserID || ev.User == "" { - return - } - if ev.BotID != "" { - return - } - if ev.SubType != "" && ev.SubType != "file_share" { - return - } - - // check allowlist to avoid downloading attachments for rejected users - if !c.IsAllowed(ev.User) { - logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{ - "user_id": ev.User, - }) - return - } - - senderID := ev.User - channelID := ev.Channel - threadTS := ev.ThreadTimeStamp - messageTS := ev.TimeStamp - - chatID := channelID - if threadTS != "" { - chatID = channelID + "/" + threadTS - } - - c.api.AddReaction("eyes", slack.ItemRef{ - Channel: channelID, - Timestamp: messageTS, - }) - - c.pendingAcks.Store(chatID, slackMessageRef{ - ChannelID: channelID, - Timestamp: messageTS, - }) - - content := ev.Text - content = c.stripBotMention(content) - - var mediaPaths []string - localFiles := []string{} // track local files that need cleanup - - // ensure temp files are cleaned up when function returns - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) - } - } - }() - - if ev.Message != nil && len(ev.Message.Files) > 0 { - for _, file := range ev.Message.Files { - localPath := c.downloadSlackFile(file) - if localPath == "" { - continue - } - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) - - if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) - defer cancel() - result, err := c.transcriber.Transcribe(ctx, localPath) - - if err != nil { - logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()}) - content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name) - } else { - content += fmt.Sprintf("\n[voice transcription: %s]", result.Text) - } - } else { - content += fmt.Sprintf("\n[file: %s]", file.Name) - } - } - } - - if strings.TrimSpace(content) == "" { - return - } - - peerKind := "channel" - peerID := channelID - if strings.HasPrefix(channelID, "D") { - peerKind = "direct" - peerID = senderID - } - - metadata := map[string]string{ - "message_ts": messageTS, - "channel_id": channelID, - "thread_ts": threadTS, - "platform": "slack", - "peer_kind": peerKind, - "peer_id": peerID, - "team_id": c.teamID, - } - - logger.DebugCF("slack", "Received message", map[string]any{ - "sender_id": senderID, - "chat_id": chatID, - "preview": utils.Truncate(content, 50), - "has_thread": threadTS != "", - }) - - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) -} - -func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { - if ev.User == c.botUserID { - return - } - - if !c.IsAllowed(ev.User) { - logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{ - "user_id": ev.User, - }) - return - } - - senderID := ev.User - channelID := ev.Channel - threadTS := ev.ThreadTimeStamp - messageTS := ev.TimeStamp - - var chatID string - if threadTS != "" { - chatID = channelID + "/" + threadTS - } else { - chatID = channelID + "/" + messageTS - } - - c.api.AddReaction("eyes", slack.ItemRef{ - Channel: channelID, - Timestamp: messageTS, - }) - - c.pendingAcks.Store(chatID, slackMessageRef{ - ChannelID: channelID, - Timestamp: messageTS, - }) - - content := c.stripBotMention(ev.Text) - - if strings.TrimSpace(content) == "" { - return - } - - mentionPeerKind := "channel" - mentionPeerID := channelID - if strings.HasPrefix(channelID, "D") { - mentionPeerKind = "direct" - mentionPeerID = senderID - } - - metadata := map[string]string{ - "message_ts": messageTS, - "channel_id": channelID, - "thread_ts": threadTS, - "platform": "slack", - "is_mention": "true", - "peer_kind": mentionPeerKind, - "peer_id": mentionPeerID, - "team_id": c.teamID, - } - - c.HandleMessage(senderID, chatID, content, nil, metadata) -} - -func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { - cmd, ok := event.Data.(slack.SlashCommand) - if !ok { - return - } - - if event.Request != nil { - c.socketClient.Ack(*event.Request) - } - - if !c.IsAllowed(cmd.UserID) { - logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{ - "user_id": cmd.UserID, - }) - return - } - - senderID := cmd.UserID - channelID := cmd.ChannelID - chatID := channelID - content := cmd.Text - - if strings.TrimSpace(content) == "" { - content = "help" - } - - metadata := map[string]string{ - "channel_id": channelID, - "platform": "slack", - "is_command": "true", - "trigger_id": cmd.TriggerID, - "peer_kind": "channel", - "peer_id": channelID, - "team_id": c.teamID, - } - - logger.DebugCF("slack", "Slash command received", map[string]any{ - "sender_id": senderID, - "command": cmd.Command, - "text": utils.Truncate(content, 50), - }) - - c.HandleMessage(senderID, chatID, content, nil, metadata) -} - -func (c *SlackChannel) downloadSlackFile(file slack.File) string { - downloadURL := file.URLPrivateDownload - if downloadURL == "" { - downloadURL = file.URLPrivate - } - if downloadURL == "" { - logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID}) - return "" - } - - return utils.DownloadFile(downloadURL, file.Name, utils.DownloadOptions{ - LoggerPrefix: "slack", - ExtraHeaders: map[string]string{ - "Authorization": "Bearer " + c.config.BotToken, - }, - }) -} - -func (c *SlackChannel) stripBotMention(text string) string { - mention := fmt.Sprintf("<@%s>", c.botUserID) - text = strings.ReplaceAll(text, mention, "") - return strings.TrimSpace(text) -} - -func parseSlackChatID(chatID string) (channelID, threadTS string) { - parts := strings.SplitN(chatID, "/", 2) - channelID = parts[0] - if len(parts) > 1 { - threadTS = parts[1] - } - return channelID, threadTS -} diff --git a/pkg/channels/slack_test.go b/pkg/channels/slack_test.go deleted file mode 100644 index 3707c2703..000000000 --- a/pkg/channels/slack_test.go +++ /dev/null @@ -1,174 +0,0 @@ -package channels - -import ( - "testing" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" -) - -func TestParseSlackChatID(t *testing.T) { - tests := []struct { - name string - chatID string - wantChanID string - wantThread string - }{ - { - name: "channel only", - chatID: "C123456", - wantChanID: "C123456", - wantThread: "", - }, - { - name: "channel with thread", - chatID: "C123456/1234567890.123456", - wantChanID: "C123456", - wantThread: "1234567890.123456", - }, - { - name: "DM channel", - chatID: "D987654", - wantChanID: "D987654", - wantThread: "", - }, - { - name: "empty string", - chatID: "", - wantChanID: "", - wantThread: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - chanID, threadTS := parseSlackChatID(tt.chatID) - if chanID != tt.wantChanID { - t.Errorf("parseSlackChatID(%q) channelID = %q, want %q", tt.chatID, chanID, tt.wantChanID) - } - if threadTS != tt.wantThread { - t.Errorf("parseSlackChatID(%q) threadTS = %q, want %q", tt.chatID, threadTS, tt.wantThread) - } - }) - } -} - -func TestStripBotMention(t *testing.T) { - ch := &SlackChannel{botUserID: "U12345BOT"} - - tests := []struct { - name string - input string - want string - }{ - { - name: "mention at start", - input: "<@U12345BOT> hello there", - want: "hello there", - }, - { - name: "mention in middle", - input: "hey <@U12345BOT> can you help", - want: "hey can you help", - }, - { - name: "no mention", - input: "hello world", - want: "hello world", - }, - { - name: "empty string", - input: "", - want: "", - }, - { - name: "only mention", - input: "<@U12345BOT>", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := ch.stripBotMention(tt.input) - if got != tt.want { - t.Errorf("stripBotMention(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestNewSlackChannel(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("missing bot token", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "", - AppToken: "xapp-test", - } - _, err := NewSlackChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing bot_token, got nil") - } - }) - - t.Run("missing app token", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "", - } - _, err := NewSlackChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing app_token, got nil") - } - }) - - t.Run("valid config", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "xapp-test", - AllowFrom: []string{"U123"}, - } - ch, err := NewSlackChannel(cfg, msgBus) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ch.Name() != "slack" { - t.Errorf("Name() = %q, want %q", ch.Name(), "slack") - } - if ch.IsRunning() { - t.Error("new channel should not be running") - } - }) -} - -func TestSlackChannelIsAllowed(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("empty allowlist allows all", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "xapp-test", - AllowFrom: []string{}, - } - ch, _ := NewSlackChannel(cfg, msgBus) - if !ch.IsAllowed("U_ANYONE") { - t.Error("empty allowlist should allow all users") - } - }) - - t.Run("allowlist restricts users", func(t *testing.T) { - cfg := config.SlackConfig{ - BotToken: "xoxb-test", - AppToken: "xapp-test", - AllowFrom: []string{"U_ALLOWED"}, - } - ch, _ := NewSlackChannel(cfg, msgBus) - if !ch.IsAllowed("U_ALLOWED") { - t.Error("allowed user should pass allowlist check") - } - if ch.IsAllowed("U_BLOCKED") { - t.Error("non-allowed user should be blocked") - } - }) -} diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go deleted file mode 100644 index 6592d9bc0..000000000 --- a/pkg/channels/telegram.go +++ /dev/null @@ -1,539 +0,0 @@ -package channels - -import ( - "context" - "fmt" - "net/http" - "net/url" - "os" - "regexp" - "strings" - "sync" - "time" - - "github.com/mymmrac/telego" - "github.com/mymmrac/telego/telegohandler" - th "github.com/mymmrac/telego/telegohandler" - tu "github.com/mymmrac/telego/telegoutil" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" -) - -var ( - reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`) - reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) - reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) - reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) - reBoldUnder = regexp.MustCompile(`__(.+?)__`) - reItalic = regexp.MustCompile(`_([^_]+)_`) - reStrike = regexp.MustCompile(`~~(.+?)~~`) - reListItem = regexp.MustCompile(`^[-*]\s+`) - reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") - reInlineCode = regexp.MustCompile("`([^`]+)`") -) - -type TelegramChannel struct { - *BaseChannel - bot *telego.Bot - commands TelegramCommander - config *config.Config - chatIDs map[string]int64 - transcriber *voice.GroqTranscriber - placeholders sync.Map // chatID -> messageID - stopThinking sync.Map // chatID -> thinkingCancel -} - -type thinkingCancel struct { - fn context.CancelFunc -} - -func (c *thinkingCancel) Cancel() { - if c != nil && c.fn != nil { - c.fn() - } -} - -func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { - var opts []telego.BotOption - telegramCfg := cfg.Channels.Telegram - - if telegramCfg.Proxy != "" { - proxyURL, parseErr := url.Parse(telegramCfg.Proxy) - if parseErr != nil { - return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr) - } - opts = append(opts, telego.WithHTTPClient(&http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - }, - })) - } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" { - // Use environment proxy if configured - opts = append(opts, telego.WithHTTPClient(&http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - }, - })) - } - - bot, err := telego.NewBot(telegramCfg.Token, opts...) - if err != nil { - return nil, fmt.Errorf("failed to create telegram bot: %w", err) - } - - base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom) - - return &TelegramChannel{ - BaseChannel: base, - commands: NewTelegramCommands(bot, cfg), - bot: bot, - config: cfg, - chatIDs: make(map[string]int64), - transcriber: nil, - placeholders: sync.Map{}, - stopThinking: sync.Map{}, - }, nil -} - -func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - -func (c *TelegramChannel) Start(ctx context.Context) error { - logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") - - updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{ - Timeout: 30, - }) - if err != nil { - return fmt.Errorf("failed to start long polling: %w", err) - } - - bh, err := telegohandler.NewBotHandler(c.bot, updates) - if err != nil { - return fmt.Errorf("failed to create bot handler: %w", err) - } - - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - c.commands.Help(ctx, message) - return nil - }, th.CommandEqual("help")) - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.commands.Start(ctx, message) - }, th.CommandEqual("start")) - - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.commands.Show(ctx, message) - }, th.CommandEqual("show")) - - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.commands.List(ctx, message) - }, th.CommandEqual("list")) - - bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { - return c.handleMessage(ctx, &message) - }, th.AnyMessage()) - - c.setRunning(true) - logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ - "username": c.bot.Username(), - }) - - go bh.Start() - - go func() { - <-ctx.Done() - bh.Stop() - }() - - return nil -} - -func (c *TelegramChannel) Stop(ctx context.Context) error { - logger.InfoC("telegram", "Stopping Telegram bot...") - c.setRunning(false) - return nil -} - -func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("telegram bot not running") - } - - chatID, err := parseChatID(msg.ChatID) - if err != nil { - return fmt.Errorf("invalid chat ID: %w", err) - } - - // Stop thinking animation - if stop, ok := c.stopThinking.Load(msg.ChatID); ok { - if cf, ok := stop.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - c.stopThinking.Delete(msg.ChatID) - } - - htmlContent := markdownToTelegramHTML(msg.Content) - - // Try to edit placeholder - if pID, ok := c.placeholders.Load(msg.ChatID); ok { - c.placeholders.Delete(msg.ChatID) - editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) - editMsg.ParseMode = telego.ModeHTML - - if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { - return nil - } - // Fallback to new message if edit fails - } - - tgMsg := tu.Message(tu.ID(chatID), htmlContent) - tgMsg.ParseMode = telego.ModeHTML - - if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ - "error": err.Error(), - }) - tgMsg.ParseMode = "" - _, err = c.bot.SendMessage(ctx, tgMsg) - return err - } - - return nil -} - -func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { - if message == nil { - return fmt.Errorf("message is nil") - } - - user := message.From - if user == nil { - return fmt.Errorf("message sender (user) is nil") - } - - senderID := fmt.Sprintf("%d", user.ID) - if user.Username != "" { - senderID = fmt.Sprintf("%d|%s", user.ID, user.Username) - } - - // check allowlist to avoid downloading attachments for rejected users - if !c.IsAllowed(senderID) { - logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{ - "user_id": senderID, - }) - return nil - } - - chatID := message.Chat.ID - c.chatIDs[senderID] = chatID - - content := "" - mediaPaths := []string{} - localFiles := []string{} // track local files that need cleanup - - // ensure temp files are cleaned up when function returns - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) - } - } - }() - - if message.Text != "" { - content += message.Text - } - - if message.Caption != "" { - if content != "" { - content += "\n" - } - content += message.Caption - } - - if len(message.Photo) > 0 { - photo := message.Photo[len(message.Photo)-1] - photoPath := c.downloadPhoto(ctx, photo.FileID) - if photoPath != "" { - localFiles = append(localFiles, photoPath) - mediaPaths = append(mediaPaths, photoPath) - if content != "" { - content += "\n" - } - content += "[image: photo]" - } - } - - if message.Voice != nil { - voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") - if voicePath != "" { - localFiles = append(localFiles, voicePath) - mediaPaths = append(mediaPaths, voicePath) - - var transcribedText string - if c.transcriber != nil && c.transcriber.IsAvailable() { - transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - result, err := c.transcriber.Transcribe(transcriberCtx, voicePath) - if err != nil { - logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{ - "error": err.Error(), - "path": voicePath, - }) - transcribedText = "[voice (transcription failed)]" - } else { - transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) - logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{ - "text": result.Text, - }) - } - } else { - transcribedText = "[voice]" - } - - if content != "" { - content += "\n" - } - content += transcribedText - } - } - - if message.Audio != nil { - audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") - if audioPath != "" { - localFiles = append(localFiles, audioPath) - mediaPaths = append(mediaPaths, audioPath) - if content != "" { - content += "\n" - } - content += "[audio]" - } - } - - if message.Document != nil { - docPath := c.downloadFile(ctx, message.Document.FileID, "") - if docPath != "" { - localFiles = append(localFiles, docPath) - mediaPaths = append(mediaPaths, docPath) - if content != "" { - content += "\n" - } - content += "[file]" - } - } - - if content == "" { - content = "[empty message]" - } - - logger.DebugCF("telegram", "Received message", map[string]any{ - "sender_id": senderID, - "chat_id": fmt.Sprintf("%d", chatID), - "preview": utils.Truncate(content, 50), - }) - - // Thinking indicator - err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) - if err != nil { - logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{ - "error": err.Error(), - }) - } - - // Stop any previous thinking animation - chatIDStr := fmt.Sprintf("%d", chatID) - if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { - if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - } - - // Create cancel function for thinking state - _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) - c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) - - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) - if err == nil { - pID := pMsg.MessageID - c.placeholders.Store(chatIDStr, pID) - } - - peerKind := "direct" - peerID := fmt.Sprintf("%d", user.ID) - if message.Chat.Type != "private" { - peerKind = "group" - peerID = fmt.Sprintf("%d", chatID) - } - - metadata := map[string]string{ - "message_id": fmt.Sprintf("%d", message.MessageID), - "user_id": fmt.Sprintf("%d", user.ID), - "username": user.Username, - "first_name": user.FirstName, - "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), - "peer_kind": peerKind, - "peer_id": peerID, - } - - c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) - return nil -} - -func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { - file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) - if err != nil { - logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{ - "error": err.Error(), - }) - return "" - } - - return c.downloadFileWithInfo(file, ".jpg") -} - -func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) string { - if file.FilePath == "" { - return "" - } - - url := c.bot.FileDownloadURL(file.FilePath) - logger.DebugCF("telegram", "File URL", map[string]any{"url": url}) - - // Use FilePath as filename for better identification - filename := file.FilePath + ext - return utils.DownloadFile(url, filename, utils.DownloadOptions{ - LoggerPrefix: "telegram", - }) -} - -func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string { - file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) - if err != nil { - logger.ErrorCF("telegram", "Failed to get file", map[string]any{ - "error": err.Error(), - }) - return "" - } - - return c.downloadFileWithInfo(file, ext) -} - -func parseChatID(chatIDStr string) (int64, error) { - var id int64 - _, err := fmt.Sscanf(chatIDStr, "%d", &id) - return id, err -} - -func markdownToTelegramHTML(text string) string { - if text == "" { - return "" - } - - codeBlocks := extractCodeBlocks(text) - text = codeBlocks.text - - inlineCodes := extractInlineCodes(text) - text = inlineCodes.text - - text = reHeading.ReplaceAllString(text, "$1") - - text = reBlockquote.ReplaceAllString(text, "$1") - - text = escapeHTML(text) - - text = reLink.ReplaceAllString(text, `$1`) - - text = reBoldStar.ReplaceAllString(text, "$1") - - text = reBoldUnder.ReplaceAllString(text, "$1") - - text = reItalic.ReplaceAllStringFunc(text, func(s string) string { - match := reItalic.FindStringSubmatch(s) - if len(match) < 2 { - return s - } - return "" + match[1] + "" - }) - - text = reStrike.ReplaceAllString(text, "$1") - - text = reListItem.ReplaceAllString(text, "• ") - - for i, code := range inlineCodes.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) - } - - for i, code := range codeBlocks.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll( - text, - fmt.Sprintf("\x00CB%d\x00", i), - fmt.Sprintf("
%s
", escaped), - ) - } - - return text -} - -type codeBlockMatch struct { - text string - codes []string -} - -func extractCodeBlocks(text string) codeBlockMatch { - matches := reCodeBlock.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) - } - - i := 0 - text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00CB%d\x00", i) - i++ - return placeholder - }) - - return codeBlockMatch{text: text, codes: codes} -} - -type inlineCodeMatch struct { - text string - codes []string -} - -func extractInlineCodes(text string) inlineCodeMatch { - matches := reInlineCode.FindAllStringSubmatch(text, -1) - - codes := make([]string, 0, len(matches)) - for _, match := range matches { - codes = append(codes, match[1]) - } - - i := 0 - text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { - placeholder := fmt.Sprintf("\x00IC%d\x00", i) - i++ - return placeholder - }) - - return inlineCodeMatch{text: text, codes: codes} -} - -func escapeHTML(text string) string { - text = strings.ReplaceAll(text, "&", "&") - text = strings.ReplaceAll(text, "<", "<") - text = strings.ReplaceAll(text, ">", ">") - return text -} diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go deleted file mode 100644 index f28434f46..000000000 --- a/pkg/channels/telegram_commands.go +++ /dev/null @@ -1,156 +0,0 @@ -package channels - -import ( - "context" - "fmt" - "strings" - - "github.com/mymmrac/telego" - - "github.com/sipeed/picoclaw/pkg/config" -) - -type TelegramCommander interface { - Help(ctx context.Context, message telego.Message) error - Start(ctx context.Context, message telego.Message) error - Show(ctx context.Context, message telego.Message) error - List(ctx context.Context, message telego.Message) error -} - -type cmd struct { - bot *telego.Bot - config *config.Config -} - -func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander { - return &cmd{ - bot: bot, - config: cfg, - } -} - -func commandArgs(text string) string { - parts := strings.SplitN(text, " ", 2) - if len(parts) < 2 { - return "" - } - return strings.TrimSpace(parts[1]) -} - -func (c *cmd) Help(ctx context.Context, message telego.Message) error { - msg := `/start - Start the bot -/help - Show this help message -/show [model|channel] - Show current configuration -/list [models|channels] - List available options - ` - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: msg, - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} - -func (c *cmd) Start(ctx context.Context, message telego.Message) error { - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Hello! I am PicoClaw 🦞", - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} - -func (c *cmd) Show(ctx context.Context, message telego.Message) error { - args := commandArgs(message.Text) - if args == "" { - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Usage: /show [model|channel]", - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err - } - - var response string - switch args { - case "model": - response = fmt.Sprintf("Current Model: %s (Provider: %s)", - c.config.Agents.Defaults.GetModelName(), - c.config.Agents.Defaults.Provider) - case "channel": - response = "Current Channel: telegram" - default: - response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args) - } - - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: response, - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} - -func (c *cmd) List(ctx context.Context, message telego.Message) error { - args := commandArgs(message.Text) - if args == "" { - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Usage: /list [models|channels]", - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err - } - - var response string - switch args { - case "models": - provider := c.config.Agents.Defaults.Provider - if provider == "" { - provider = "configured default" - } - response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", - c.config.Agents.Defaults.GetModelName(), provider) - - case "channels": - var enabled []string - if c.config.Channels.Telegram.Enabled { - enabled = append(enabled, "telegram") - } - if c.config.Channels.WhatsApp.Enabled { - enabled = append(enabled, "whatsapp") - } - if c.config.Channels.Feishu.Enabled { - enabled = append(enabled, "feishu") - } - if c.config.Channels.Discord.Enabled { - enabled = append(enabled, "discord") - } - if c.config.Channels.Slack.Enabled { - enabled = append(enabled, "slack") - } - response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")) - - default: - response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args) - } - - _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ - ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: response, - ReplyParameters: &telego.ReplyParameters{ - MessageID: message.MessageID, - }, - }) - return err -} diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go deleted file mode 100644 index f8daf89de..000000000 --- a/pkg/channels/wecom.go +++ /dev/null @@ -1,605 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// WeCom Bot (企业微信智能机器人) channel implementation -// Uses webhook callback mode for receiving messages and webhook API for sending replies - -package channels - -import ( - "bytes" - "context" - "crypto/aes" - "crypto/cipher" - "crypto/sha1" - "encoding/base64" - "encoding/binary" - "encoding/json" - "encoding/xml" - "fmt" - "io" - "net/http" - "sort" - "strings" - "sync" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人) -// Uses webhook callback mode - simpler than WeCom App but only supports passive replies -type WeComBotChannel struct { - *BaseChannel - config config.WeComConfig - server *http.Server - ctx context.Context - cancel context.CancelFunc - processedMsgs map[string]bool // Message deduplication: msg_id -> processed - msgMu sync.RWMutex -} - -// WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT) -type WeComBotMessage struct { - MsgID string `json:"msgid"` - AIBotID string `json:"aibotid"` - ChatID string `json:"chatid"` // Session ID, only present for group chats - ChatType string `json:"chattype"` // "single" for DM, "group" for group chat - From struct { - UserID string `json:"userid"` - } `json:"from"` - ResponseURL string `json:"response_url"` - MsgType string `json:"msgtype"` // text, image, voice, file, mixed - Text struct { - Content string `json:"content"` - } `json:"text"` - Image struct { - URL string `json:"url"` - } `json:"image"` - Voice struct { - Content string `json:"content"` // Voice to text content - } `json:"voice"` - File struct { - URL string `json:"url"` - } `json:"file"` - Mixed struct { - MsgItem []struct { - MsgType string `json:"msgtype"` - Text struct { - Content string `json:"content"` - } `json:"text"` - Image struct { - URL string `json:"url"` - } `json:"image"` - } `json:"msg_item"` - } `json:"mixed"` - Quote struct { - MsgType string `json:"msgtype"` - Text struct { - Content string `json:"content"` - } `json:"text"` - } `json:"quote"` -} - -// WeComBotReplyMessage represents the reply message structure -type WeComBotReplyMessage struct { - MsgType string `json:"msgtype"` - Text struct { - Content string `json:"content"` - } `json:"text,omitempty"` -} - -// NewWeComBotChannel creates a new WeCom Bot channel instance -func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) { - if cfg.Token == "" || cfg.WebhookURL == "" { - return nil, fmt.Errorf("wecom token and webhook_url are required") - } - - base := NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom) - - return &WeComBotChannel{ - BaseChannel: base, - config: cfg, - processedMsgs: make(map[string]bool), - }, nil -} - -// Name returns the channel name -func (c *WeComBotChannel) Name() string { - return "wecom" -} - -// Start initializes the WeCom Bot channel with HTTP webhook server -func (c *WeComBotChannel) Start(ctx context.Context) error { - logger.InfoC("wecom", "Starting WeCom Bot channel...") - - c.ctx, c.cancel = context.WithCancel(ctx) - - // Setup HTTP server for webhook - mux := http.NewServeMux() - webhookPath := c.config.WebhookPath - if webhookPath == "" { - webhookPath = "/webhook/wecom" - } - mux.HandleFunc(webhookPath, c.handleWebhook) - - // Health check endpoint - mux.HandleFunc("/health/wecom", c.handleHealth) - - addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) - c.server = &http.Server{ - Addr: addr, - Handler: mux, - } - - c.setRunning(true) - logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{ - "address": addr, - "path": webhookPath, - }) - - // Start server in goroutine - go func() { - if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom", "HTTP server error", map[string]any{ - "error": err.Error(), - }) - } - }() - - return nil -} - -// Stop gracefully stops the WeCom Bot channel -func (c *WeComBotChannel) Stop(ctx context.Context) error { - logger.InfoC("wecom", "Stopping WeCom Bot channel...") - - if c.cancel != nil { - c.cancel() - } - - if c.server != nil { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - c.server.Shutdown(shutdownCtx) - } - - c.setRunning(false) - logger.InfoC("wecom", "WeCom Bot channel stopped") - return nil -} - -// Send sends a message to WeCom user via webhook API -// Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message -// For delayed responses, we use the webhook URL -func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("wecom channel not running") - } - - logger.DebugCF("wecom", "Sending message via webhook", map[string]any{ - "chat_id": msg.ChatID, - "preview": utils.Truncate(msg.Content, 100), - }) - - return c.sendWebhookReply(ctx, msg.ChatID, msg.Content) -} - -// handleWebhook handles incoming webhook requests from WeCom -func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - if r.Method == http.MethodGet { - // Handle verification request - c.handleVerification(ctx, w, r) - return - } - - if r.Method == http.MethodPost { - // Handle message callback - c.handleMessageCallback(ctx, w, r) - return - } - - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) -} - -// handleVerification handles the URL verification request from WeCom -func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - msgSignature := query.Get("msg_signature") - timestamp := query.Get("timestamp") - nonce := query.Get("nonce") - echostr := query.Get("echostr") - - if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" { - http.Error(w, "Missing parameters", http.StatusBadRequest) - return - } - - // Verify signature - if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { - logger.WarnC("wecom", "Signature verification failed") - http.Error(w, "Invalid signature", http.StatusForbidden) - return - } - - // Decrypt echostr - // For AIBOT (智能机器人), receiveid should be empty string "" - // Reference: https://developer.work.weixin.qq.com/document/path/101033 - decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") - if err != nil { - logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - // Remove BOM and whitespace as per WeCom documentation - // The response must be plain text without quotes, BOM, or newlines - decryptedEchoStr = strings.TrimSpace(decryptedEchoStr) - decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM - w.Write([]byte(decryptedEchoStr)) -} - -// handleMessageCallback handles incoming messages from WeCom -func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - msgSignature := query.Get("msg_signature") - timestamp := query.Get("timestamp") - nonce := query.Get("nonce") - - if msgSignature == "" || timestamp == "" || nonce == "" { - http.Error(w, "Missing parameters", http.StatusBadRequest) - return - } - - // Read request body - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read body", http.StatusBadRequest) - return - } - defer r.Body.Close() - - // Parse XML to get encrypted message - var encryptedMsg struct { - XMLName xml.Name `xml:"xml"` - ToUserName string `xml:"ToUserName"` - Encrypt string `xml:"Encrypt"` - AgentID string `xml:"AgentID"` - } - - if err = xml.Unmarshal(body, &encryptedMsg); err != nil { - logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Invalid XML", http.StatusBadRequest) - return - } - - // Verify signature - if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { - logger.WarnC("wecom", "Message signature verification failed") - http.Error(w, "Invalid signature", http.StatusForbidden) - return - } - - // Decrypt message - // For AIBOT (智能机器人), receiveid should be empty string "" - // Reference: https://developer.work.weixin.qq.com/document/path/101033 - decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") - if err != nil { - logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - // Parse decrypted JSON message (AIBOT uses JSON format) - var msg WeComBotMessage - if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil { - logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Invalid message format", http.StatusBadRequest) - return - } - - // Process the message asynchronously with context - go c.processMessage(ctx, msg) - - // Return success response immediately - // WeCom Bot requires response within configured timeout (default 5 seconds) - w.Write([]byte("success")) -} - -// processMessage processes the received message -func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) { - // Skip unsupported message types - if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && - msg.MsgType != "mixed" { - logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{ - "msg_type": msg.MsgType, - }) - return - } - - // Message deduplication: Use msg_id to prevent duplicate processing - msgID := msg.MsgID - c.msgMu.Lock() - if c.processedMsgs[msgID] { - c.msgMu.Unlock() - logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ - "msg_id": msgID, - }) - return - } - c.processedMsgs[msgID] = true - c.msgMu.Unlock() - - // Clean up old messages periodically (keep last 1000) - if len(c.processedMsgs) > 1000 { - c.msgMu.Lock() - c.processedMsgs = make(map[string]bool) - c.msgMu.Unlock() - } - - senderID := msg.From.UserID - - // Determine if this is a group chat or direct message - // ChatType: "single" for DM, "group" for group chat - isGroupChat := msg.ChatType == "group" - - var chatID, peerKind, peerID string - if isGroupChat { - // Group chat: use ChatID as chatID and peer_id - chatID = msg.ChatID - peerKind = "group" - peerID = msg.ChatID - } else { - // Direct message: use senderID as chatID and peer_id - chatID = senderID - peerKind = "direct" - peerID = senderID - } - - // Extract content based on message type - var content string - switch msg.MsgType { - case "text": - content = msg.Text.Content - case "voice": - content = msg.Voice.Content // Voice to text content - case "mixed": - // For mixed messages, concatenate text items - for _, item := range msg.Mixed.MsgItem { - if item.MsgType == "text" { - content += item.Text.Content - } - } - case "image", "file": - // For image and file, we don't have text content - content = "" - } - - // Build metadata - metadata := map[string]string{ - "msg_type": msg.MsgType, - "msg_id": msg.MsgID, - "platform": "wecom", - "peer_kind": peerKind, - "peer_id": peerID, - "response_url": msg.ResponseURL, - } - if isGroupChat { - metadata["chat_id"] = msg.ChatID - metadata["sender_id"] = senderID - } - - logger.DebugCF("wecom", "Received message", map[string]any{ - "sender_id": senderID, - "msg_type": msg.MsgType, - "peer_kind": peerKind, - "is_group_chat": isGroupChat, - "preview": utils.Truncate(content, 50), - }) - - // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) -} - -// sendWebhookReply sends a reply using the webhook URL -func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error { - reply := WeComBotReplyMessage{ - MsgType: "text", - } - reply.Text.Content = content - - jsonData, err := json.Marshal(reply) - if err != nil { - return fmt.Errorf("failed to marshal reply: %w", err) - } - - // Use configurable timeout (default 5 seconds) - timeout := c.config.ReplyTimeout - if timeout <= 0 { - timeout = 5 - } - - reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.config.WebhookURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: time.Duration(timeout) * time.Second} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to send webhook reply: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - // Check response - var result struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - } - if err := json.Unmarshal(body, &result); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if result.ErrCode != 0 { - return fmt.Errorf("webhook API error: %s (code: %d)", result.ErrMsg, result.ErrCode) - } - - return nil -} - -// handleHealth handles health check requests -func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { - status := map[string]any{ - "status": "ok", - "running": c.IsRunning(), - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(status) -} - -// WeCom common utilities for both WeCom Bot and WeCom App -// The following functions were moved from wecom_common.go - -// WeComVerifySignature verifies the message signature for WeCom -// This is a common function used by both WeCom Bot and WeCom App -func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { - if token == "" { - return true // Skip verification if token is not set - } - - // Sort parameters - params := []string{token, timestamp, nonce, msgEncrypt} - sort.Strings(params) - - // Concatenate - str := strings.Join(params, "") - - // SHA1 hash - hash := sha1.Sum([]byte(str)) - expectedSignature := fmt.Sprintf("%x", hash) - - return expectedSignature == msgSignature -} - -// WeComDecryptMessage decrypts the encrypted message using AES -// This is a common function used by both WeCom Bot and WeCom App -// For AIBOT, receiveid should be the aibotid; for other apps, it should be corp_id -func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) { - return WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, "") -} - -// WeComDecryptMessageWithVerify decrypts the encrypted message and optionally verifies receiveid -// receiveid: for AIBOT use aibotid, for WeCom App use corp_id. If empty, skip verification. -func WeComDecryptMessageWithVerify(encryptedMsg, encodingAESKey, receiveid string) (string, error) { - if encodingAESKey == "" { - // No encryption, return as is (base64 decode) - decoded, err := base64.StdEncoding.DecodeString(encryptedMsg) - if err != nil { - return "", err - } - return string(decoded), nil - } - - // Decode AES key (base64) - aesKey, err := base64.StdEncoding.DecodeString(encodingAESKey + "=") - if err != nil { - return "", fmt.Errorf("failed to decode AES key: %w", err) - } - - // Decode encrypted message - cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg) - if err != nil { - return "", fmt.Errorf("failed to decode message: %w", err) - } - - // AES decrypt - block, err := aes.NewCipher(aesKey) - if err != nil { - return "", fmt.Errorf("failed to create cipher: %w", err) - } - - if len(cipherText) < aes.BlockSize { - return "", fmt.Errorf("ciphertext too short") - } - - // IV is the first 16 bytes of AESKey - iv := aesKey[:aes.BlockSize] - mode := cipher.NewCBCDecrypter(block, iv) - plainText := make([]byte, len(cipherText)) - mode.CryptBlocks(plainText, cipherText) - - // Remove PKCS7 padding - plainText, err = pkcs7UnpadWeCom(plainText) - if err != nil { - return "", fmt.Errorf("failed to unpad: %w", err) - } - - // Parse message structure - // Format: random(16) + msg_len(4) + msg + receiveid - if len(plainText) < 20 { - return "", fmt.Errorf("decrypted message too short") - } - - msgLen := binary.BigEndian.Uint32(plainText[16:20]) - if int(msgLen) > len(plainText)-20 { - return "", fmt.Errorf("invalid message length") - } - - msg := plainText[20 : 20+msgLen] - - // Verify receiveid if provided - if receiveid != "" && len(plainText) > 20+int(msgLen) { - actualReceiveID := string(plainText[20+msgLen:]) - if actualReceiveID != receiveid { - return "", fmt.Errorf("receiveid mismatch: expected %s, got %s", receiveid, actualReceiveID) - } - } - - return string(msg), nil -} - -// pkcs7UnpadWeCom removes PKCS7 padding with validation -// WeCom uses block size of 32 (not standard AES block size of 16) -const wecomBlockSize = 32 - -func pkcs7UnpadWeCom(data []byte) ([]byte, error) { - if len(data) == 0 { - return data, nil - } - padding := int(data[len(data)-1]) - // WeCom uses 32-byte block size for PKCS7 padding - if padding == 0 || padding > wecomBlockSize { - return nil, fmt.Errorf("invalid padding size: %d", padding) - } - if padding > len(data) { - return nil, fmt.Errorf("padding size larger than data") - } - // Verify all padding bytes - for i := 0; i < padding; i++ { - if data[len(data)-1-i] != byte(padding) { - return nil, fmt.Errorf("invalid padding byte at position %d", i) - } - } - return data[:len(data)-padding], nil -} diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go deleted file mode 100644 index 302603445..000000000 --- a/pkg/channels/wecom_app.go +++ /dev/null @@ -1,584 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// WeCom App (企业微信自建应用) channel implementation -// Supports receiving messages via webhook callback and sending messages proactively - -package channels - -import ( - "bytes" - "context" - "encoding/json" - "encoding/xml" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "sync" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -const ( - wecomAPIBase = "https://qyapi.weixin.qq.com" -) - -// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用) -type WeComAppChannel struct { - *BaseChannel - config config.WeComAppConfig - server *http.Server - accessToken string - tokenExpiry time.Time - tokenMu sync.RWMutex - ctx context.Context - cancel context.CancelFunc - processedMsgs map[string]bool // Message deduplication: msg_id -> processed - msgMu sync.RWMutex -} - -// WeComXMLMessage represents the XML message structure from WeCom -type WeComXMLMessage struct { - XMLName xml.Name `xml:"xml"` - ToUserName string `xml:"ToUserName"` - FromUserName string `xml:"FromUserName"` - CreateTime int64 `xml:"CreateTime"` - MsgType string `xml:"MsgType"` - Content string `xml:"Content"` - MsgId int64 `xml:"MsgId"` - AgentID int64 `xml:"AgentID"` - PicUrl string `xml:"PicUrl"` - MediaId string `xml:"MediaId"` - Format string `xml:"Format"` - ThumbMediaId string `xml:"ThumbMediaId"` - LocationX float64 `xml:"Location_X"` - LocationY float64 `xml:"Location_Y"` - Scale int `xml:"Scale"` - Label string `xml:"Label"` - Title string `xml:"Title"` - Description string `xml:"Description"` - Url string `xml:"Url"` - Event string `xml:"Event"` - EventKey string `xml:"EventKey"` -} - -// WeComTextMessage represents text message for sending -type WeComTextMessage struct { - ToUser string `json:"touser"` - MsgType string `json:"msgtype"` - AgentID int64 `json:"agentid"` - Text struct { - Content string `json:"content"` - } `json:"text"` - Safe int `json:"safe,omitempty"` -} - -// WeComMarkdownMessage represents markdown message for sending -type WeComMarkdownMessage struct { - ToUser string `json:"touser"` - MsgType string `json:"msgtype"` - AgentID int64 `json:"agentid"` - Markdown struct { - Content string `json:"content"` - } `json:"markdown"` -} - -// WeComImageMessage represents image message for sending -type WeComImageMessage struct { - ToUser string `json:"touser"` - MsgType string `json:"msgtype"` - AgentID int64 `json:"agentid"` - Image struct { - MediaID string `json:"media_id"` - } `json:"image"` -} - -// WeComAccessTokenResponse represents the access token API response -type WeComAccessTokenResponse struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - AccessToken string `json:"access_token"` - ExpiresIn int `json:"expires_in"` -} - -// WeComSendMessageResponse represents the send message API response -type WeComSendMessageResponse struct { - ErrCode int `json:"errcode"` - ErrMsg string `json:"errmsg"` - InvalidUser string `json:"invaliduser"` - InvalidParty string `json:"invalidparty"` - InvalidTag string `json:"invalidtag"` -} - -// PKCS7Padding adds PKCS7 padding -type PKCS7Padding struct{} - -// NewWeComAppChannel creates a new WeCom App channel instance -func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) { - if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 { - return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required") - } - - base := NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom) - - return &WeComAppChannel{ - BaseChannel: base, - config: cfg, - processedMsgs: make(map[string]bool), - }, nil -} - -// Name returns the channel name -func (c *WeComAppChannel) Name() string { - return "wecom_app" -} - -// Start initializes the WeCom App channel with HTTP webhook server -func (c *WeComAppChannel) Start(ctx context.Context) error { - logger.InfoC("wecom_app", "Starting WeCom App channel...") - - c.ctx, c.cancel = context.WithCancel(ctx) - - // Get initial access token - if err := c.refreshAccessToken(); err != nil { - logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{ - "error": err.Error(), - }) - } - - // Start token refresh goroutine - go c.tokenRefreshLoop() - - // Setup HTTP server for webhook - mux := http.NewServeMux() - webhookPath := c.config.WebhookPath - if webhookPath == "" { - webhookPath = "/webhook/wecom-app" - } - mux.HandleFunc(webhookPath, c.handleWebhook) - - // Health check endpoint - mux.HandleFunc("/health/wecom-app", c.handleHealth) - - addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) - c.server = &http.Server{ - Addr: addr, - Handler: mux, - } - - c.setRunning(true) - logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{ - "address": addr, - "path": webhookPath, - }) - - // Start server in goroutine - go func() { - if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{ - "error": err.Error(), - }) - } - }() - - return nil -} - -// Stop gracefully stops the WeCom App channel -func (c *WeComAppChannel) Stop(ctx context.Context) error { - logger.InfoC("wecom_app", "Stopping WeCom App channel...") - - if c.cancel != nil { - c.cancel() - } - - if c.server != nil { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - c.server.Shutdown(shutdownCtx) - } - - c.setRunning(false) - logger.InfoC("wecom_app", "WeCom App channel stopped") - return nil -} - -// Send sends a message to WeCom user proactively using access token -func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - if !c.IsRunning() { - return fmt.Errorf("wecom_app channel not running") - } - - accessToken := c.getAccessToken() - if accessToken == "" { - return fmt.Errorf("no valid access token available") - } - - logger.DebugCF("wecom_app", "Sending message", map[string]any{ - "chat_id": msg.ChatID, - "preview": utils.Truncate(msg.Content, 100), - }) - - return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) -} - -// handleWebhook handles incoming webhook requests from WeCom -func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - // Log all incoming requests for debugging - logger.DebugCF("wecom_app", "Received webhook request", map[string]any{ - "method": r.Method, - "url": r.URL.String(), - "path": r.URL.Path, - "query": r.URL.RawQuery, - }) - - if r.Method == http.MethodGet { - // Handle verification request - c.handleVerification(ctx, w, r) - return - } - - if r.Method == http.MethodPost { - // Handle message callback - c.handleMessageCallback(ctx, w, r) - return - } - - logger.WarnCF("wecom_app", "Method not allowed", map[string]any{ - "method": r.Method, - }) - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) -} - -// handleVerification handles the URL verification request from WeCom -func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - msgSignature := query.Get("msg_signature") - timestamp := query.Get("timestamp") - nonce := query.Get("nonce") - echostr := query.Get("echostr") - - logger.DebugCF("wecom_app", "Handling verification request", map[string]any{ - "msg_signature": msgSignature, - "timestamp": timestamp, - "nonce": nonce, - "echostr": echostr, - "corp_id": c.config.CorpID, - }) - - if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" { - logger.ErrorC("wecom_app", "Missing parameters in verification request") - http.Error(w, "Missing parameters", http.StatusBadRequest) - return - } - - // Verify signature - if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { - logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{ - "token": c.config.Token, - "msg_signature": msgSignature, - "timestamp": timestamp, - "nonce": nonce, - }) - http.Error(w, "Invalid signature", http.StatusForbidden) - return - } - - logger.DebugC("wecom_app", "Signature verification passed") - - // Decrypt echostr with CorpID verification - // For WeCom App (自建应用), receiveid should be corp_id - logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{ - "encoding_aes_key": c.config.EncodingAESKey, - "corp_id": c.config.CorpID, - }) - decryptedEchoStr, err := WeComDecryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) - if err != nil { - logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{ - "error": err.Error(), - "encoding_aes_key": c.config.EncodingAESKey, - "corp_id": c.config.CorpID, - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{ - "decrypted": decryptedEchoStr, - }) - - // Remove BOM and whitespace as per WeCom documentation - // The response must be plain text without quotes, BOM, or newlines - decryptedEchoStr = strings.TrimSpace(decryptedEchoStr) - decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM - w.Write([]byte(decryptedEchoStr)) -} - -// handleMessageCallback handles incoming messages from WeCom -func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) { - query := r.URL.Query() - msgSignature := query.Get("msg_signature") - timestamp := query.Get("timestamp") - nonce := query.Get("nonce") - - if msgSignature == "" || timestamp == "" || nonce == "" { - http.Error(w, "Missing parameters", http.StatusBadRequest) - return - } - - // Read request body - body, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, "Failed to read body", http.StatusBadRequest) - return - } - defer r.Body.Close() - - // Parse XML to get encrypted message - var encryptedMsg struct { - XMLName xml.Name `xml:"xml"` - ToUserName string `xml:"ToUserName"` - Encrypt string `xml:"Encrypt"` - AgentID string `xml:"AgentID"` - } - - if err = xml.Unmarshal(body, &encryptedMsg); err != nil { - logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Invalid XML", http.StatusBadRequest) - return - } - - // Verify signature - if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { - logger.WarnC("wecom_app", "Message signature verification failed") - http.Error(w, "Invalid signature", http.StatusForbidden) - return - } - - // Decrypt message with CorpID verification - // For WeCom App (自建应用), receiveid should be corp_id - decryptedMsg, err := WeComDecryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) - if err != nil { - logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Decryption failed", http.StatusInternalServerError) - return - } - - // Parse decrypted XML message - var msg WeComXMLMessage - if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil { - logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{ - "error": err.Error(), - }) - http.Error(w, "Invalid message format", http.StatusBadRequest) - return - } - - // Process the message with context - go c.processMessage(ctx, msg) - - // Return success response immediately - // WeCom App requires response within configured timeout (default 5 seconds) - w.Write([]byte("success")) -} - -// processMessage processes the received message -func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) { - // Skip non-text messages for now (can be extended) - if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" { - logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{ - "msg_type": msg.MsgType, - }) - return - } - - // Message deduplication: Use msg_id to prevent duplicate processing - // As per WeCom documentation, use msg_id for deduplication - msgID := fmt.Sprintf("%d", msg.MsgId) - c.msgMu.Lock() - if c.processedMsgs[msgID] { - c.msgMu.Unlock() - logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ - "msg_id": msgID, - }) - return - } - c.processedMsgs[msgID] = true - c.msgMu.Unlock() - - // Clean up old messages periodically (keep last 1000) - if len(c.processedMsgs) > 1000 { - c.msgMu.Lock() - c.processedMsgs = make(map[string]bool) - c.msgMu.Unlock() - } - - senderID := msg.FromUserName - chatID := senderID // WeCom App uses user ID as chat ID for direct messages - - // Build metadata - // WeCom App only supports direct messages (private chat) - metadata := map[string]string{ - "msg_type": msg.MsgType, - "msg_id": fmt.Sprintf("%d", msg.MsgId), - "agent_id": fmt.Sprintf("%d", msg.AgentID), - "platform": "wecom_app", - "media_id": msg.MediaId, - "create_time": fmt.Sprintf("%d", msg.CreateTime), - "peer_kind": "direct", - "peer_id": senderID, - } - - content := msg.Content - - logger.DebugCF("wecom_app", "Received message", map[string]any{ - "sender_id": senderID, - "msg_type": msg.MsgType, - "preview": utils.Truncate(content, 50), - }) - - // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) -} - -// tokenRefreshLoop periodically refreshes the access token -func (c *WeComAppChannel) tokenRefreshLoop() { - ticker := time.NewTicker(5 * time.Minute) - defer ticker.Stop() - - for { - select { - case <-c.ctx.Done(): - return - case <-ticker.C: - if err := c.refreshAccessToken(); err != nil { - logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{ - "error": err.Error(), - }) - } - } - } -} - -// refreshAccessToken gets a new access token from WeCom API -func (c *WeComAppChannel) refreshAccessToken() error { - apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s", - wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret)) - - resp, err := http.Get(apiURL) - if err != nil { - return fmt.Errorf("failed to request access token: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var tokenResp WeComAccessTokenResponse - if err := json.Unmarshal(body, &tokenResp); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if tokenResp.ErrCode != 0 { - return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode) - } - - c.tokenMu.Lock() - c.accessToken = tokenResp.AccessToken - c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early - c.tokenMu.Unlock() - - logger.DebugC("wecom_app", "Access token refreshed successfully") - return nil -} - -// getAccessToken returns the current valid access token -func (c *WeComAppChannel) getAccessToken() string { - c.tokenMu.RLock() - defer c.tokenMu.RUnlock() - - if time.Now().After(c.tokenExpiry) { - return "" - } - - return c.accessToken -} - -// sendTextMessage sends a text message to a user -func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error { - apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) - - msg := WeComTextMessage{ - ToUser: userID, - MsgType: "text", - AgentID: c.config.AgentID, - } - msg.Text.Content = content - - jsonData, err := json.Marshal(msg) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - // Use configurable timeout (default 5 seconds) - timeout := c.config.ReplyTimeout - if timeout <= 0 { - timeout = 5 - } - - reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: time.Duration(timeout) * time.Second} - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("failed to send message: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var sendResp WeComSendMessageResponse - if err := json.Unmarshal(body, &sendResp); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if sendResp.ErrCode != 0 { - return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) - } - - return nil -} - -// handleHealth handles health check requests -func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { - status := map[string]any{ - "status": "ok", - "running": c.IsRunning(), - "has_token": c.getAccessToken() != "", - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(status) -} diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go deleted file mode 100644 index abf15c52b..000000000 --- a/pkg/channels/wecom_app_test.go +++ /dev/null @@ -1,1104 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// WeCom App (企业微信自建应用) channel tests - -package channels - -import ( - "bytes" - "context" - "crypto/aes" - "crypto/cipher" - "crypto/sha1" - "encoding/base64" - "encoding/binary" - "encoding/json" - "encoding/xml" - "fmt" - "net/http" - "net/http/httptest" - "sort" - "strings" - "testing" - "time" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" -) - -// generateTestAESKeyApp generates a valid test AES key for WeCom App -func generateTestAESKeyApp() string { - // AES key needs to be 32 bytes (256 bits) for AES-256 - key := make([]byte, 32) - for i := range key { - key[i] = byte(i + 1) - } - // Return base64 encoded key without padding - return base64.StdEncoding.EncodeToString(key)[:43] -} - -// encryptTestMessageApp encrypts a message for testing WeCom App -func encryptTestMessageApp(message, aesKey string) (string, error) { - // Decode AES key - key, err := base64.StdEncoding.DecodeString(aesKey + "=") - if err != nil { - return "", err - } - - // Prepare message: random(16) + msg_len(4) + msg + corp_id - random := make([]byte, 0, 16) - for i := 0; i < 16; i++ { - random = append(random, byte(i+1)) - } - - msgBytes := []byte(message) - corpID := []byte("test_corp_id") - - msgLen := uint32(len(msgBytes)) - lenBytes := make([]byte, 4) - binary.BigEndian.PutUint32(lenBytes, msgLen) - - plainText := append(random, lenBytes...) - plainText = append(plainText, msgBytes...) - plainText = append(plainText, corpID...) - - // PKCS7 padding - blockSize := aes.BlockSize - padding := blockSize - len(plainText)%blockSize - padText := bytes.Repeat([]byte{byte(padding)}, padding) - plainText = append(plainText, padText...) - - // Encrypt - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - - mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize]) - cipherText := make([]byte, len(plainText)) - mode.CryptBlocks(cipherText, plainText) - - return base64.StdEncoding.EncodeToString(cipherText), nil -} - -// generateSignatureApp generates a signature for testing WeCom App -func generateSignatureApp(token, timestamp, nonce, msgEncrypt string) string { - params := []string{token, timestamp, nonce, msgEncrypt} - sort.Strings(params) - str := strings.Join(params, "") - hash := sha1.Sum([]byte(str)) - return fmt.Sprintf("%x", hash) -} - -func TestNewWeComAppChannel(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("missing corp_id", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "", - CorpSecret: "test_secret", - AgentID: 1000002, - } - _, err := NewWeComAppChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing corp_id, got nil") - } - }) - - t.Run("missing corp_secret", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "", - AgentID: 1000002, - } - _, err := NewWeComAppChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing corp_secret, got nil") - } - }) - - t.Run("missing agent_id", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 0, - } - _, err := NewWeComAppChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing agent_id, got nil") - } - }) - - t.Run("valid config", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - AllowFrom: []string{"user1", "user2"}, - } - ch, err := NewWeComAppChannel(cfg, msgBus) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ch.Name() != "wecom_app" { - t.Errorf("Name() = %q, want %q", ch.Name(), "wecom_app") - } - if ch.IsRunning() { - t.Error("new channel should not be running") - } - }) -} - -func TestWeComAppChannelIsAllowed(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("empty allowlist allows all", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - AllowFrom: []string{}, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - if !ch.IsAllowed("any_user") { - t.Error("empty allowlist should allow all users") - } - }) - - t.Run("allowlist restricts users", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - AllowFrom: []string{"allowed_user"}, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - if !ch.IsAllowed("allowed_user") { - t.Error("allowed user should pass allowlist check") - } - if ch.IsAllowed("blocked_user") { - t.Error("non-allowed user should be blocked") - } - }) -} - -func TestWeComAppVerifySignature(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("valid signature", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - msgEncrypt := "test_message" - expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt) - - if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { - t.Error("valid signature should pass verification") - } - }) - - t.Run("invalid signature", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - msgEncrypt := "test_message" - - if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { - t.Error("invalid signature should fail verification") - } - }) - - t.Run("empty token skips verification", func(t *testing.T) { - cfgEmpty := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "", - } - chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) - - if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { - t.Error("empty token should skip verification and return true") - } - }) -} - -func TestWeComAppDecryptMessage(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("decrypt without AES key", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: "", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - // Without AES key, message should be base64 decoded only - plainText := "hello world" - encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) - - result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != plainText { - t.Errorf("decryptMessage() = %q, want %q", result, plainText) - } - }) - - t.Run("decrypt with AES key", func(t *testing.T) { - aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: aesKey, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - originalMsg := "Hello" - encrypted, err := encryptTestMessageApp(originalMsg, aesKey) - if err != nil { - t.Fatalf("failed to encrypt test message: %v", err) - } - - result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != originalMsg { - t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg) - } - }) - - t.Run("invalid base64", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: "", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for invalid base64, got nil") - } - }) - - t.Run("invalid AES key", func(t *testing.T) { - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: "invalid_key", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for invalid AES key, got nil") - } - }) - - t.Run("ciphertext too short", func(t *testing.T) { - aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - EncodingAESKey: aesKey, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - // Encrypt a very short message that results in ciphertext less than block size - shortData := make([]byte, 8) - _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for short ciphertext, got nil") - } - }) -} - -func TestWeComAppPKCS7Unpad(t *testing.T) { - tests := []struct { - name string - input []byte - expected []byte - }{ - { - name: "empty input", - input: []byte{}, - expected: []byte{}, - }, - { - name: "valid padding 3 bytes", - input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...), - expected: []byte("hello"), - }, - { - name: "valid padding 16 bytes (full block)", - input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...), - expected: []byte("123456789012345"), - }, - { - name: "invalid padding larger than data", - input: []byte{20}, - expected: nil, // should return error - }, - { - name: "invalid padding zero", - input: append([]byte("test"), byte(0)), - expected: nil, // should return error - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := pkcs7UnpadWeCom(tt.input) - if tt.expected == nil { - // This case should return an error - if err == nil { - t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result) - } - return - } - if err != nil { - t.Errorf("pkcs7Unpad() unexpected error: %v", err) - return - } - if !bytes.Equal(result, tt.expected) { - t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestWeComAppHandleVerification(t *testing.T) { - msgBus := bus.NewMessageBus() - aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - EncodingAESKey: aesKey, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("valid verification request", func(t *testing.T) { - echostr := "test_echostr_123" - encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey) - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr) - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, - nil, - ) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != echostr { - t.Errorf("response body = %q, want %q", w.Body.String(), echostr) - } - }) - - t.Run("missing parameters", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=sig×tamp=ts", nil) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid signature", func(t *testing.T) { - echostr := "test_echostr" - encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey) - timestamp := "1234567890" - nonce := "test_nonce" - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, - nil, - ) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusForbidden { - t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) - } - }) -} - -func TestWeComAppHandleMessageCallback(t *testing.T) { - msgBus := bus.NewMessageBus() - aesKey := generateTestAESKeyApp() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - EncodingAESKey: aesKey, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("valid message callback", func(t *testing.T) { - // Create XML message - xmlMsg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "text", - Content: "Hello World", - MsgId: 123456, - AgentID: 1000002, - } - xmlData, _ := xml.Marshal(xmlMsg) - - // Encrypt message - encrypted, _ := encryptTestMessageApp(string(xmlData), aesKey) - - // Create encrypted XML wrapper - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: encrypted, - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, encrypted) - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != "success" { - t.Errorf("response body = %q, want %q", w.Body.String(), "success") - } - }) - - t.Run("missing parameters", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=sig", nil) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid XML", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, "") - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - strings.NewReader("invalid xml"), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid signature", func(t *testing.T) { - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: "encrypted_data", - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusForbidden { - t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) - } - }) -} - -func TestWeComAppProcessMessage(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("process text message", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "text", - Content: "Hello World", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process image message", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "image", - PicUrl: "https://example.com/image.jpg", - MediaId: "media_123", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process voice message", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "voice", - MediaId: "media_123", - Format: "amr", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("skip unsupported message type", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "video", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process event message", func(t *testing.T) { - msg := WeComXMLMessage{ - ToUserName: "corp_id", - FromUserName: "user123", - CreateTime: 1234567890, - MsgType: "event", - Event: "subscribe", - MsgId: 123456, - AgentID: 1000002, - } - - // Should not panic - ch.processMessage(context.Background(), msg) - }) -} - -func TestWeComAppHandleWebhook(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - Token: "test_token", - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("GET request calls verification", func(t *testing.T) { - echostr := "test_echostr" - encoded := base64.StdEncoding.EncodeToString([]byte(echostr)) - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, encoded) - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, - nil, - ) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - }) - - t.Run("POST request calls message callback", func(t *testing.T) { - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: base64.StdEncoding.EncodeToString([]byte("test")), - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt) - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - // Should not be method not allowed - if w.Code == http.StatusMethodNotAllowed { - t.Error("POST request should not return Method Not Allowed") - } - }) - - t.Run("unsupported method", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/webhook/wecom-app", nil) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed) - } - }) -} - -func TestWeComAppHandleHealth(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", nil) - w := httptest.NewRecorder() - - ch.handleHealth(w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - - contentType := w.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("Content-Type = %q, want %q", contentType, "application/json") - } - - body := w.Body.String() - if !strings.Contains(body, "status") || !strings.Contains(body, "running") || !strings.Contains(body, "has_token") { - t.Errorf("response body should contain status, running, and has_token fields, got: %s", body) - } -} - -func TestWeComAppAccessToken(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComAppConfig{ - CorpID: "test_corp_id", - CorpSecret: "test_secret", - AgentID: 1000002, - } - ch, _ := NewWeComAppChannel(cfg, msgBus) - - t.Run("get empty access token initially", func(t *testing.T) { - token := ch.getAccessToken() - if token != "" { - t.Errorf("getAccessToken() = %q, want empty string", token) - } - }) - - t.Run("set and get access token", func(t *testing.T) { - ch.tokenMu.Lock() - ch.accessToken = "test_token_123" - ch.tokenExpiry = time.Now().Add(1 * time.Hour) - ch.tokenMu.Unlock() - - token := ch.getAccessToken() - if token != "test_token_123" { - t.Errorf("getAccessToken() = %q, want %q", token, "test_token_123") - } - }) - - t.Run("expired token returns empty", func(t *testing.T) { - ch.tokenMu.Lock() - ch.accessToken = "expired_token" - ch.tokenExpiry = time.Now().Add(-1 * time.Hour) - ch.tokenMu.Unlock() - - token := ch.getAccessToken() - if token != "" { - t.Errorf("getAccessToken() = %q, want empty string for expired token", token) - } - }) -} - -func TestWeComAppMessageStructures(t *testing.T) { - t.Run("WeComTextMessage structure", func(t *testing.T) { - msg := WeComTextMessage{ - ToUser: "user123", - MsgType: "text", - AgentID: 1000002, - } - msg.Text.Content = "Hello World" - - if msg.ToUser != "user123" { - t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123") - } - if msg.MsgType != "text" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") - } - if msg.AgentID != 1000002 { - t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) - } - if msg.Text.Content != "Hello World" { - t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") - } - - // Test JSON marshaling - jsonData, err := json.Marshal(msg) - if err != nil { - t.Fatalf("failed to marshal JSON: %v", err) - } - - var unmarshaled WeComTextMessage - err = json.Unmarshal(jsonData, &unmarshaled) - if err != nil { - t.Fatalf("failed to unmarshal JSON: %v", err) - } - - if unmarshaled.ToUser != msg.ToUser { - t.Errorf("JSON round-trip failed for ToUser") - } - }) - - t.Run("WeComMarkdownMessage structure", func(t *testing.T) { - msg := WeComMarkdownMessage{ - ToUser: "user123", - MsgType: "markdown", - AgentID: 1000002, - } - msg.Markdown.Content = "# Hello\nWorld" - - if msg.Markdown.Content != "# Hello\nWorld" { - t.Errorf("Markdown.Content = %q, want %q", msg.Markdown.Content, "# Hello\nWorld") - } - - // Test JSON marshaling - jsonData, err := json.Marshal(msg) - if err != nil { - t.Fatalf("failed to marshal JSON: %v", err) - } - - if !bytes.Contains(jsonData, []byte("markdown")) { - t.Error("JSON should contain 'markdown' field") - } - }) - - t.Run("WeComAccessTokenResponse structure", func(t *testing.T) { - jsonData := `{ - "errcode": 0, - "errmsg": "ok", - "access_token": "test_access_token", - "expires_in": 7200 - }` - - var resp WeComAccessTokenResponse - err := json.Unmarshal([]byte(jsonData), &resp) - if err != nil { - t.Fatalf("failed to unmarshal JSON: %v", err) - } - - if resp.ErrCode != 0 { - t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0) - } - if resp.ErrMsg != "ok" { - t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok") - } - if resp.AccessToken != "test_access_token" { - t.Errorf("AccessToken = %q, want %q", resp.AccessToken, "test_access_token") - } - if resp.ExpiresIn != 7200 { - t.Errorf("ExpiresIn = %d, want %d", resp.ExpiresIn, 7200) - } - }) - - t.Run("WeComSendMessageResponse structure", func(t *testing.T) { - jsonData := `{ - "errcode": 0, - "errmsg": "ok", - "invaliduser": "", - "invalidparty": "", - "invalidtag": "" - }` - - var resp WeComSendMessageResponse - err := json.Unmarshal([]byte(jsonData), &resp) - if err != nil { - t.Fatalf("failed to unmarshal JSON: %v", err) - } - - if resp.ErrCode != 0 { - t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0) - } - if resp.ErrMsg != "ok" { - t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok") - } - }) -} - -func TestWeComAppXMLMessageStructure(t *testing.T) { - xmlData := ` - - - - 1234567890 - - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.ToUserName != "corp_id" { - t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id") - } - if msg.FromUserName != "user123" { - t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123") - } - if msg.CreateTime != 1234567890 { - t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890) - } - if msg.MsgType != "text" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") - } - if msg.Content != "Hello World" { - t.Errorf("Content = %q, want %q", msg.Content, "Hello World") - } - if msg.MsgId != 1234567890123456 { - t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456) - } - if msg.AgentID != 1000002 { - t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) - } -} - -func TestWeComAppXMLMessageImage(t *testing.T) { - xmlData := ` - - - - 1234567890 - - - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "image" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "image") - } - if msg.PicUrl != "https://example.com/image.jpg" { - t.Errorf("PicUrl = %q, want %q", msg.PicUrl, "https://example.com/image.jpg") - } - if msg.MediaId != "media_123" { - t.Errorf("MediaId = %q, want %q", msg.MediaId, "media_123") - } -} - -func TestWeComAppXMLMessageVoice(t *testing.T) { - xmlData := ` - - - - 1234567890 - - - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "voice" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "voice") - } - if msg.Format != "amr" { - t.Errorf("Format = %q, want %q", msg.Format, "amr") - } -} - -func TestWeComAppXMLMessageLocation(t *testing.T) { - xmlData := ` - - - - 1234567890 - - 39.9042 - 116.4074 - 16 - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "location" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "location") - } - if msg.LocationX != 39.9042 { - t.Errorf("LocationX = %f, want %f", msg.LocationX, 39.9042) - } - if msg.LocationY != 116.4074 { - t.Errorf("LocationY = %f, want %f", msg.LocationY, 116.4074) - } - if msg.Scale != 16 { - t.Errorf("Scale = %d, want %d", msg.Scale, 16) - } - if msg.Label != "Beijing" { - t.Errorf("Label = %q, want %q", msg.Label, "Beijing") - } -} - -func TestWeComAppXMLMessageLink(t *testing.T) { - xmlData := ` - - - - 1234567890 - - <![CDATA[Link Title]]> - - - 1234567890123456 - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "link" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "link") - } - if msg.Title != "Link Title" { - t.Errorf("Title = %q, want %q", msg.Title, "Link Title") - } - if msg.Description != "Link Description" { - t.Errorf("Description = %q, want %q", msg.Description, "Link Description") - } - if msg.Url != "https://example.com" { - t.Errorf("Url = %q, want %q", msg.Url, "https://example.com") - } -} - -func TestWeComAppXMLMessageEvent(t *testing.T) { - xmlData := ` - - - - 1234567890 - - - - 1000002 -` - - var msg WeComXMLMessage - err := xml.Unmarshal([]byte(xmlData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal XML: %v", err) - } - - if msg.MsgType != "event" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "event") - } - if msg.Event != "subscribe" { - t.Errorf("Event = %q, want %q", msg.Event, "subscribe") - } - if msg.EventKey != "event_key_123" { - t.Errorf("EventKey = %q, want %q", msg.EventKey, "event_key_123") - } -} diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go deleted file mode 100644 index 8afa7e8c3..000000000 --- a/pkg/channels/wecom_test.go +++ /dev/null @@ -1,785 +0,0 @@ -// PicoClaw - Ultra-lightweight personal AI agent -// WeCom Bot (企业微信智能机器人) channel tests - -package channels - -import ( - "bytes" - "context" - "crypto/aes" - "crypto/cipher" - "crypto/sha1" - "encoding/base64" - "encoding/binary" - "encoding/json" - "encoding/xml" - "fmt" - "net/http" - "net/http/httptest" - "sort" - "strings" - "testing" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" -) - -// generateTestAESKey generates a valid test AES key -func generateTestAESKey() string { - // AES key needs to be 32 bytes (256 bits) for AES-256 - key := make([]byte, 32) - for i := range key { - key[i] = byte(i) - } - // Return base64 encoded key without padding - return base64.StdEncoding.EncodeToString(key)[:43] -} - -// encryptTestMessage encrypts a message for testing (AIBOT JSON format) -func encryptTestMessage(message, aesKey string) (string, error) { - // Decode AES key - key, err := base64.StdEncoding.DecodeString(aesKey + "=") - if err != nil { - return "", err - } - - // Prepare message: random(16) + msg_len(4) + msg + receiveid - random := make([]byte, 0, 16) - for i := 0; i < 16; i++ { - random = append(random, byte(i)) - } - - msgBytes := []byte(message) - receiveID := []byte("test_aibot_id") - - msgLen := uint32(len(msgBytes)) - lenBytes := make([]byte, 4) - binary.BigEndian.PutUint32(lenBytes, msgLen) - - plainText := append(random, lenBytes...) - plainText = append(plainText, msgBytes...) - plainText = append(plainText, receiveID...) - - // PKCS7 padding - blockSize := aes.BlockSize - padding := blockSize - len(plainText)%blockSize - padText := bytes.Repeat([]byte{byte(padding)}, padding) - plainText = append(plainText, padText...) - - // Encrypt - block, err := aes.NewCipher(key) - if err != nil { - return "", err - } - - mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize]) - cipherText := make([]byte, len(plainText)) - mode.CryptBlocks(cipherText, plainText) - - return base64.StdEncoding.EncodeToString(cipherText), nil -} - -// generateSignature generates a signature for testing -func generateSignature(token, timestamp, nonce, msgEncrypt string) string { - params := []string{token, timestamp, nonce, msgEncrypt} - sort.Strings(params) - str := strings.Join(params, "") - hash := sha1.Sum([]byte(str)) - return fmt.Sprintf("%x", hash) -} - -func TestNewWeComBotChannel(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("missing token", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - _, err := NewWeComBotChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing token, got nil") - } - }) - - t.Run("missing webhook_url", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "", - } - _, err := NewWeComBotChannel(cfg, msgBus) - if err == nil { - t.Error("expected error for missing webhook_url, got nil") - } - }) - - t.Run("valid config", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - AllowFrom: []string{"user1", "user2"}, - } - ch, err := NewWeComBotChannel(cfg, msgBus) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if ch.Name() != "wecom" { - t.Errorf("Name() = %q, want %q", ch.Name(), "wecom") - } - if ch.IsRunning() { - t.Error("new channel should not be running") - } - }) -} - -func TestWeComBotChannelIsAllowed(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("empty allowlist allows all", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - AllowFrom: []string{}, - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - if !ch.IsAllowed("any_user") { - t.Error("empty allowlist should allow all users") - } - }) - - t.Run("allowlist restricts users", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - AllowFrom: []string{"allowed_user"}, - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - if !ch.IsAllowed("allowed_user") { - t.Error("allowed user should pass allowlist check") - } - if ch.IsAllowed("blocked_user") { - t.Error("non-allowed user should be blocked") - } - }) -} - -func TestWeComBotVerifySignature(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - t.Run("valid signature", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - msgEncrypt := "test_message" - expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt) - - if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) { - t.Error("valid signature should pass verification") - } - }) - - t.Run("invalid signature", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - msgEncrypt := "test_message" - - if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) { - t.Error("invalid signature should fail verification") - } - }) - - t.Run("empty token skips verification", func(t *testing.T) { - // Create a channel manually with empty token to test the behavior - cfgEmpty := config.WeComConfig{ - Token: "", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - chEmpty := &WeComBotChannel{ - config: cfgEmpty, - } - - if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { - t.Error("empty token should skip verification and return true") - } - }) -} - -func TestWeComBotDecryptMessage(t *testing.T) { - msgBus := bus.NewMessageBus() - - t.Run("decrypt without AES key", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: "", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - // Without AES key, message should be base64 decoded only - plainText := "hello world" - encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) - - result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != plainText { - t.Errorf("decryptMessage() = %q, want %q", result, plainText) - } - }) - - t.Run("decrypt with AES key", func(t *testing.T) { - aesKey := generateTestAESKey() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: aesKey, - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - originalMsg := "Hello" - encrypted, err := encryptTestMessage(originalMsg, aesKey) - if err != nil { - t.Fatalf("failed to encrypt test message: %v", err) - } - - result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if result != originalMsg { - t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg) - } - }) - - t.Run("invalid base64", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: "", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for invalid base64, got nil") - } - }) - - t.Run("invalid AES key", func(t *testing.T) { - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - EncodingAESKey: "invalid_key", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey) - if err == nil { - t.Error("expected error for invalid AES key, got nil") - } - }) -} - -func TestWeComBotPKCS7Unpad(t *testing.T) { - tests := []struct { - name string - input []byte - expected []byte - }{ - { - name: "empty input", - input: []byte{}, - expected: []byte{}, - }, - { - name: "valid padding 3 bytes", - input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...), - expected: []byte("hello"), - }, - { - name: "valid padding 16 bytes (full block)", - input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...), - expected: []byte("123456789012345"), - }, - { - name: "invalid padding larger than data", - input: []byte{20}, - expected: nil, // should return error - }, - { - name: "invalid padding zero", - input: append([]byte("test"), byte(0)), - expected: nil, // should return error - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := pkcs7UnpadWeCom(tt.input) - if tt.expected == nil { - // This case should return an error - if err == nil { - t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result) - } - return - } - if err != nil { - t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err) - return - } - if !bytes.Equal(result, tt.expected) { - t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected) - } - }) - } -} - -func TestWeComBotHandleVerification(t *testing.T) { - msgBus := bus.NewMessageBus() - aesKey := generateTestAESKey() - cfg := config.WeComConfig{ - Token: "test_token", - EncodingAESKey: aesKey, - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - t.Run("valid verification request", func(t *testing.T) { - echostr := "test_echostr_123" - encryptedEchostr, _ := encryptTestMessage(echostr, aesKey) - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr) - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, - nil, - ) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != echostr { - t.Errorf("response body = %q, want %q", w.Body.String(), echostr) - } - }) - - t.Run("missing parameters", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid signature", func(t *testing.T) { - echostr := "test_echostr" - encryptedEchostr, _ := encryptTestMessage(echostr, aesKey) - timestamp := "1234567890" - nonce := "test_nonce" - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, - nil, - ) - w := httptest.NewRecorder() - - ch.handleVerification(context.Background(), w, req) - - if w.Code != http.StatusForbidden { - t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) - } - }) -} - -func TestWeComBotHandleMessageCallback(t *testing.T) { - msgBus := bus.NewMessageBus() - aesKey := generateTestAESKey() - cfg := config.WeComConfig{ - Token: "test_token", - EncodingAESKey: aesKey, - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - t.Run("valid direct message callback", func(t *testing.T) { - // Create JSON message for direct chat (single) - jsonMsg := `{ - "msgid": "test_msg_id_123", - "aibotid": "test_aibot_id", - "chattype": "single", - "from": {"userid": "user123"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello World"} - }` - - // Encrypt message - encrypted, _ := encryptTestMessage(jsonMsg, aesKey) - - // Create encrypted XML wrapper - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: encrypted, - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encrypted) - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != "success" { - t.Errorf("response body = %q, want %q", w.Body.String(), "success") - } - }) - - t.Run("valid group message callback", func(t *testing.T) { - // Create JSON message for group chat - jsonMsg := `{ - "msgid": "test_msg_id_456", - "aibotid": "test_aibot_id", - "chatid": "group_chat_id_123", - "chattype": "group", - "from": {"userid": "user456"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello Group"} - }` - - // Encrypt message - encrypted, _ := encryptTestMessage(jsonMsg, aesKey) - - // Create encrypted XML wrapper - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: encrypted, - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encrypted) - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - if w.Body.String() != "success" { - t.Errorf("response body = %q, want %q", w.Body.String(), "success") - } - }) - - t.Run("missing parameters", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid XML", func(t *testing.T) { - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, "") - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - strings.NewReader("invalid xml"), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusBadRequest { - t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest) - } - }) - - t.Run("invalid signature", func(t *testing.T) { - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: "encrypted_data", - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleMessageCallback(context.Background(), w, req) - - if w.Code != http.StatusForbidden { - t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden) - } - }) -} - -func TestWeComBotProcessMessage(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - t.Run("process direct text message", func(t *testing.T) { - msg := WeComBotMessage{ - MsgID: "test_msg_id_123", - AIBotID: "test_aibot_id", - ChatType: "single", - ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - MsgType: "text", - } - msg.From.UserID = "user123" - msg.Text.Content = "Hello World" - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process group text message", func(t *testing.T) { - msg := WeComBotMessage{ - MsgID: "test_msg_id_456", - AIBotID: "test_aibot_id", - ChatID: "group_chat_id_123", - ChatType: "group", - ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - MsgType: "text", - } - msg.From.UserID = "user456" - msg.Text.Content = "Hello Group" - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("process voice message", func(t *testing.T) { - msg := WeComBotMessage{ - MsgID: "test_msg_id_789", - AIBotID: "test_aibot_id", - ChatType: "single", - ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - MsgType: "voice", - } - msg.From.UserID = "user123" - msg.Voice.Content = "Voice message text" - - // Should not panic - ch.processMessage(context.Background(), msg) - }) - - t.Run("skip unsupported message type", func(t *testing.T) { - msg := WeComBotMessage{ - MsgID: "test_msg_id_000", - AIBotID: "test_aibot_id", - ChatType: "single", - ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - MsgType: "video", - } - msg.From.UserID = "user123" - - // Should not panic - ch.processMessage(context.Background(), msg) - }) -} - -func TestWeComBotHandleWebhook(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - t.Run("GET request calls verification", func(t *testing.T) { - echostr := "test_echostr" - encoded := base64.StdEncoding.EncodeToString([]byte(echostr)) - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encoded) - - req := httptest.NewRequest( - http.MethodGet, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, - nil, - ) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - }) - - t.Run("POST request calls message callback", func(t *testing.T) { - encryptedWrapper := struct { - XMLName xml.Name `xml:"xml"` - Encrypt string `xml:"Encrypt"` - }{ - Encrypt: base64.StdEncoding.EncodeToString([]byte("test")), - } - wrapperData, _ := xml.Marshal(encryptedWrapper) - - timestamp := "1234567890" - nonce := "test_nonce" - signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt) - - req := httptest.NewRequest( - http.MethodPost, - "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, - bytes.NewReader(wrapperData), - ) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - // Should not be method not allowed - if w.Code == http.StatusMethodNotAllowed { - t.Error("POST request should not return Method Not Allowed") - } - }) - - t.Run("unsupported method", func(t *testing.T) { - req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil) - w := httptest.NewRecorder() - - ch.handleWebhook(w, req) - - if w.Code != http.StatusMethodNotAllowed { - t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed) - } - }) -} - -func TestWeComBotHandleHealth(t *testing.T) { - msgBus := bus.NewMessageBus() - cfg := config.WeComConfig{ - Token: "test_token", - WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - } - ch, _ := NewWeComBotChannel(cfg, msgBus) - - req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil) - w := httptest.NewRecorder() - - ch.handleHealth(w, req) - - if w.Code != http.StatusOK { - t.Errorf("status code = %d, want %d", w.Code, http.StatusOK) - } - - contentType := w.Header().Get("Content-Type") - if contentType != "application/json" { - t.Errorf("Content-Type = %q, want %q", contentType, "application/json") - } - - body := w.Body.String() - if !strings.Contains(body, "status") || !strings.Contains(body, "running") { - t.Errorf("response body should contain status and running fields, got: %s", body) - } -} - -func TestWeComBotReplyMessage(t *testing.T) { - msg := WeComBotReplyMessage{ - MsgType: "text", - } - msg.Text.Content = "Hello World" - - if msg.MsgType != "text" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") - } - if msg.Text.Content != "Hello World" { - t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") - } -} - -func TestWeComBotMessageStructure(t *testing.T) { - jsonData := `{ - "msgid": "test_msg_id_123", - "aibotid": "test_aibot_id", - "chatid": "group_chat_id_123", - "chattype": "group", - "from": {"userid": "user123"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello World"} - }` - - var msg WeComBotMessage - err := json.Unmarshal([]byte(jsonData), &msg) - if err != nil { - t.Fatalf("failed to unmarshal JSON: %v", err) - } - - if msg.MsgID != "test_msg_id_123" { - t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123") - } - if msg.AIBotID != "test_aibot_id" { - t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id") - } - if msg.ChatID != "group_chat_id_123" { - t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123") - } - if msg.ChatType != "group" { - t.Errorf("ChatType = %q, want %q", msg.ChatType, "group") - } - if msg.From.UserID != "user123" { - t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123") - } - if msg.MsgType != "text" { - t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") - } - if msg.Text.Content != "Hello World" { - t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World") - } -} diff --git a/pkg/channels/whatsapp.go b/pkg/channels/whatsapp.go deleted file mode 100644 index 2dc4017ac..000000000 --- a/pkg/channels/whatsapp.go +++ /dev/null @@ -1,195 +0,0 @@ -package channels - -import ( - "context" - "encoding/json" - "fmt" - "log" - "sync" - "time" - - "github.com/gorilla/websocket" - - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/utils" -) - -type WhatsAppChannel struct { - *BaseChannel - conn *websocket.Conn - config config.WhatsAppConfig - url string - mu sync.Mutex - connected bool -} - -func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { - base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) - - return &WhatsAppChannel{ - BaseChannel: base, - config: cfg, - url: cfg.BridgeURL, - connected: false, - }, nil -} - -func (c *WhatsAppChannel) Start(ctx context.Context) error { - log.Printf("Starting WhatsApp channel connecting to %s...", c.url) - - dialer := websocket.DefaultDialer - dialer.HandshakeTimeout = 10 * time.Second - - conn, resp, err := dialer.Dial(c.url, nil) - if resp != nil { - resp.Body.Close() - } - if err != nil { - return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err) - } - - c.mu.Lock() - c.conn = conn - c.connected = true - c.mu.Unlock() - - c.setRunning(true) - log.Println("WhatsApp channel connected") - - go c.listen(ctx) - - return nil -} - -func (c *WhatsAppChannel) Stop(ctx context.Context) error { - log.Println("Stopping WhatsApp channel...") - - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn != nil { - if err := c.conn.Close(); err != nil { - log.Printf("Error closing WhatsApp connection: %v", err) - } - c.conn = nil - } - - c.connected = false - c.setRunning(false) - - return nil -} - -func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - return fmt.Errorf("whatsapp connection not established") - } - - payload := map[string]any{ - "type": "message", - "to": msg.ChatID, - "content": msg.Content, - } - - data, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { - return fmt.Errorf("failed to send message: %w", err) - } - - return nil -} - -func (c *WhatsAppChannel) listen(ctx context.Context) { - for { - select { - case <-ctx.Done(): - return - default: - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - if conn == nil { - time.Sleep(1 * time.Second) - continue - } - - _, message, err := conn.ReadMessage() - if err != nil { - log.Printf("WhatsApp read error: %v", err) - time.Sleep(2 * time.Second) - continue - } - - var msg map[string]any - if err := json.Unmarshal(message, &msg); err != nil { - log.Printf("Failed to unmarshal WhatsApp message: %v", err) - continue - } - - msgType, ok := msg["type"].(string) - if !ok { - continue - } - - if msgType == "message" { - c.handleIncomingMessage(msg) - } - } - } -} - -func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { - senderID, ok := msg["from"].(string) - if !ok { - return - } - - chatID, ok := msg["chat"].(string) - if !ok { - chatID = senderID - } - - content, ok := msg["content"].(string) - if !ok { - content = "" - } - - var mediaPaths []string - if mediaData, ok := msg["media"].([]any); ok { - mediaPaths = make([]string, 0, len(mediaData)) - for _, m := range mediaData { - if path, ok := m.(string); ok { - mediaPaths = append(mediaPaths, path) - } - } - } - - metadata := make(map[string]string) - if messageID, ok := msg["id"].(string); ok { - metadata["message_id"] = messageID - } - if userName, ok := msg["from_name"].(string); ok { - metadata["user_name"] = userName - } - - if chatID == senderID { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID - } else { - metadata["peer_kind"] = "group" - metadata["peer_id"] = chatID - } - - log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50)) - - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) -} From cd2227235440389b88ddbda0d65fff23fa1f1f7f Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 20 Feb 2026 23:52:41 +0800 Subject: [PATCH 005/104] refactor(channels): remove redundant setRunning method from BaseChannel --- pkg/channels/base.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 3f0a766ea..ff734fdb0 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -98,10 +98,6 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st c.bus.PublishInbound(msg) } -func (c *BaseChannel) setRunning(running bool) { - c.running = running -} - func (c *BaseChannel) SetRunning(running bool) { c.running = running } From d97848389bfbecbd2453f50f76ef5a0e50ac59f2 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 21 Feb 2026 00:00:29 +0800 Subject: [PATCH 006/104] refactor(channels): replace bool with atomic.Bool for running state in BaseChannel --- pkg/channels/base.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/channels/base.go b/pkg/channels/base.go index ff734fdb0..5d77c6c0d 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -3,6 +3,7 @@ package channels import ( "context" "strings" + "sync/atomic" "github.com/sipeed/picoclaw/pkg/bus" ) @@ -19,7 +20,7 @@ type Channel interface { type BaseChannel struct { config any bus *bus.MessageBus - running bool + running atomic.Bool name string allowList []string } @@ -30,7 +31,6 @@ func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []st bus: bus, name: name, allowList: allowList, - running: false, } } @@ -39,7 +39,7 @@ func (c *BaseChannel) Name() string { } func (c *BaseChannel) IsRunning() bool { - return c.running + return c.running.Load() } func (c *BaseChannel) IsAllowed(senderID string) bool { @@ -99,5 +99,5 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st } func (c *BaseChannel) SetRunning(running bool) { - c.running = running + c.running.Store(running) } From b25b3c13246f7de7de12c37d2ec183c9e531f245 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 21 Feb 2026 16:35:56 +0800 Subject: [PATCH 007/104] fix: golangci-lint run --fix --- cmd/picoclaw/internal/gateway/helpers.go | 22 ++--- pkg/channels/dingtalk/dingtalk.go | 13 ++- pkg/channels/discord/discord.go | 5 +- pkg/channels/feishu/feishu_64.go | 6 +- pkg/channels/line/line.go | 36 +++---- pkg/channels/maixcam/maixcam.go | 26 ++--- pkg/channels/manager.go | 28 +++--- pkg/channels/onebot/onebot.go | 91 +++++++++--------- pkg/channels/qq/qq.go | 10 +- pkg/channels/slack/slack.go | 22 ++--- pkg/channels/telegram/telegram.go | 36 +++---- pkg/channels/telegram/telegram_commands.go | 3 + pkg/channels/wecom/app.go | 40 ++++---- pkg/channels/wecom/app_test.go | 73 ++++++++++---- pkg/channels/wecom/bot.go | 27 +++--- pkg/channels/wecom/bot_test.go | 105 +++++++++++++-------- pkg/channels/whatsapp/whatsapp.go | 8 +- 17 files changed, 315 insertions(+), 236 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 98262d5ae..a73ad5e4b 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -15,9 +15,17 @@ import ( "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" dch "github.com/sipeed/picoclaw/pkg/channels/discord" + _ "github.com/sipeed/picoclaw/pkg/channels/feishu" + _ "github.com/sipeed/picoclaw/pkg/channels/line" + _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" + _ "github.com/sipeed/picoclaw/pkg/channels/onebot" + _ "github.com/sipeed/picoclaw/pkg/channels/qq" slackch "github.com/sipeed/picoclaw/pkg/channels/slack" - tgram "github.com/sipeed/picoclaw/pkg/channels/telegram" + tgramch "github.com/sipeed/picoclaw/pkg/channels/telegram" + _ "github.com/sipeed/picoclaw/pkg/channels/wecom" + _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" @@ -28,16 +36,6 @@ import ( "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/voice" - - // Channel factory registrations (blank imports trigger init()) - _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" - _ "github.com/sipeed/picoclaw/pkg/channels/feishu" - _ "github.com/sipeed/picoclaw/pkg/channels/line" - _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" - _ "github.com/sipeed/picoclaw/pkg/channels/onebot" - _ "github.com/sipeed/picoclaw/pkg/channels/qq" - _ "github.com/sipeed/picoclaw/pkg/channels/wecom" - _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" ) func gatewayCmd(debug bool) error { @@ -143,7 +141,7 @@ func gatewayCmd(debug bool) error { if transcriber != nil { if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { - if tc, ok := telegramChannel.(*tgram.TelegramChannel); ok { + if tc, ok := telegramChannel.(*tgramch.TelegramChannel); ok { tc.SetTranscriber(transcriber) logger.InfoC("voice", "Groq transcription attached to Telegram channel") } diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 0edb0023c..afc0de47f 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -10,6 +10,7 @@ import ( "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -109,7 +110,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID) } - logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{ + logger.DebugCF("dingtalk", "Sending message", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) @@ -121,12 +122,15 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // onChatBotMessageReceived implements the IChatBotMessageHandler function signature // This is called by the Stream SDK when a new message arrives // IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) -func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) { +func (c *DingTalkChannel) onChatBotMessageReceived( + ctx context.Context, + data *chatbot.BotCallbackDataModel, +) ([]byte, error) { // Extract message content from Text field content := data.Text.Content if content == "" { // Try to extract from Content interface{} if Text is empty - if contentMap, ok := data.Content.(map[string]interface{}); ok { + if contentMap, ok := data.Content.(map[string]any); ok { if textContent, ok := contentMap["content"].(string); ok { content = textContent } @@ -164,7 +168,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch metadata["peer_id"] = data.ConversationId } - logger.DebugCF("dingtalk", "Received message", map[string]interface{}{ + logger.DebugCF("dingtalk", "Received message", map[string]any{ "sender_nick": senderNick, "sender_id": senderID, "preview": utils.Truncate(content, 50), @@ -193,7 +197,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c titleBytes, contentBytes, ) - if err != nil { return fmt.Errorf("failed to send reply: %w", err) } diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 6c4efd87c..b83ac28fd 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -9,6 +9,7 @@ import ( "time" "github.com/bwmarrin/discordgo" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -322,7 +323,7 @@ func (c *DiscordChannel) startTyping(chatID string) { go func() { if err := c.session.ChannelTyping(chatID); err != nil { - logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err}) + logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err}) } ticker := time.NewTicker(8 * time.Second) defer ticker.Stop() @@ -337,7 +338,7 @@ func (c *DiscordChannel) startTyping(chatID string) { return case <-ticker.C: if err := c.session.ChannelTyping(chatID); err != nil { - logger.DebugCF("discord", "ChannelTyping error", map[string]interface{}{"chatID": chatID, "err": err}) + logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err}) } } } diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index a49ee34cb..aa4e141c4 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -66,7 +66,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error { go func() { if err := wsClient.Start(runCtx); err != nil { - logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{ + logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{ "error": err.Error(), }) } @@ -122,7 +122,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg) } - logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{ + logger.DebugCF("feishu", "Feishu message sent", map[string]any{ "chat_id": msg.ChatID, }) @@ -175,7 +175,7 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 metadata["peer_id"] = chatID } - logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{ + logger.InfoCF("feishu", "Feishu message received", map[string]any{ "sender_id": senderID, "chat_id": chatID, "preview": utils.Truncate(content, 80), diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 7df0491d9..4e1d0dfd3 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -76,11 +76,11 @@ func (c *LINEChannel) Start(ctx context.Context) error { // Fetch bot profile to get bot's userId for mention detection if err := c.fetchBotInfo(); err != nil { - logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{ + logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{ "error": err.Error(), }) } else { - logger.InfoCF("line", "Bot info fetched", map[string]interface{}{ + logger.InfoCF("line", "Bot info fetched", map[string]any{ "bot_user_id": c.botUserID, "basic_id": c.botBasicID, "display_name": c.botDisplayName, @@ -101,12 +101,12 @@ func (c *LINEChannel) Start(ctx context.Context) error { } go func() { - logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{ + logger.InfoCF("line", "LINE webhook server listening", map[string]any{ "addr": addr, "path": path, }) if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("line", "Webhook server error", map[string]interface{}{ + logger.ErrorCF("line", "Webhook server error", map[string]any{ "error": err.Error(), }) } @@ -163,7 +163,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error { shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := c.httpServer.Shutdown(shutdownCtx); err != nil { - logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{ + logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{ "error": err.Error(), }) } @@ -183,7 +183,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { - logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{ + logger.ErrorCF("line", "Failed to read request body", map[string]any{ "error": err.Error(), }) http.Error(w, "Bad request", http.StatusBadRequest) @@ -201,7 +201,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { Events []lineEvent `json:"events"` } if err := json.Unmarshal(body, &payload); err != nil { - logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{ + logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{ "error": err.Error(), }) http.Error(w, "Bad request", http.StatusBadRequest) @@ -267,7 +267,7 @@ type lineMentionee struct { func (c *LINEChannel) processEvent(event lineEvent) { if event.Type != "message" { - logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{ + logger.DebugCF("line", "Ignoring non-message event", map[string]any{ "type": event.Type, }) return @@ -279,7 +279,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { var msg lineMessage if err := json.Unmarshal(event.Message, &msg); err != nil { - logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{ + logger.ErrorCF("line", "Failed to parse message", map[string]any{ "error": err.Error(), }) return @@ -287,7 +287,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { // In group chats, only respond when the bot is mentioned if isGroup && !c.isBotMentioned(msg) { - logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{ + logger.DebugCF("line", "Ignoring group message without mention", map[string]any{ "chat_id": chatID, }) return @@ -313,7 +313,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { - logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{ + logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{ "file": file, "error": err.Error(), }) @@ -375,7 +375,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { metadata["peer_id"] = senderID } - logger.DebugCF("line", "Received message", map[string]interface{}{ + logger.DebugCF("line", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, "message_type": msg.Type, @@ -506,7 +506,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { tokenEntry := entry.(replyTokenEntry) if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge { if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil { - logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{ + logger.DebugCF("line", "Message sent via Reply API", map[string]any{ "chat_id": msg.ChatID, "quoted": quoteToken != "", }) @@ -534,7 +534,7 @@ func buildTextMessage(content, quoteToken string) map[string]string { // sendReply sends a message using the LINE Reply API. func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "replyToken": replyToken, "messages": []map[string]string{buildTextMessage(content, quoteToken)}, } @@ -544,7 +544,7 @@ func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteT // sendPush sends a message using the LINE Push API. func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error { - payload := map[string]interface{}{ + payload := map[string]any{ "to": to, "messages": []map[string]string{buildTextMessage(content, quoteToken)}, } @@ -554,19 +554,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri // sendLoading sends a loading animation indicator to the chat. func (c *LINEChannel) sendLoading(chatID string) { - payload := map[string]interface{}{ + payload := map[string]any{ "chatId": chatID, "loadingSeconds": 60, } if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil { - logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{ + logger.DebugCF("line", "Failed to send loading indicator", map[string]any{ "error": err.Error(), }) } } // callAPI makes an authenticated POST request to the LINE API. -func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error { +func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to marshal payload: %w", err) diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index d3c6662d7..a7bff55e0 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -22,10 +22,10 @@ type MaixCamChannel struct { } type MaixCamMessage struct { - Type string `json:"type"` - Tips string `json:"tips"` - Timestamp float64 `json:"timestamp"` - Data map[string]interface{} `json:"data"` + Type string `json:"type"` + Tips string `json:"tips"` + Timestamp float64 `json:"timestamp"` + Data map[string]any `json:"data"` } func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { @@ -50,7 +50,7 @@ func (c *MaixCamChannel) Start(ctx context.Context) error { c.listener = listener c.SetRunning(true) - logger.InfoCF("maixcam", "MaixCam server listening", map[string]interface{}{ + logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{ "host": c.config.Host, "port": c.config.Port, }) @@ -72,14 +72,14 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) { conn, err := c.listener.Accept() if err != nil { if c.IsRunning() { - logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{ + logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{ "error": err.Error(), }) } return } - logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]interface{}{ + logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{ "remote_addr": conn.RemoteAddr().String(), }) @@ -113,7 +113,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { var msg MaixCamMessage if err := decoder.Decode(&msg); err != nil { if err.Error() != "EOF" { - logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{ + logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{ "error": err.Error(), }) } @@ -134,14 +134,14 @@ func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) { case "status": c.handleStatusUpdate(msg) default: - logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{ + logger.WarnCF("maixcam", "Unknown message type", map[string]any{ "type": msg.Type, }) } } func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { - logger.InfoCF("maixcam", "", map[string]interface{}{ + logger.InfoCF("maixcam", "", map[string]any{ "timestamp": msg.Timestamp, "data": msg.Data, }) @@ -179,7 +179,7 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { } func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { - logger.InfoCF("maixcam", "Status update from MaixCam", map[string]interface{}{ + logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{ "status": msg.Data, }) } @@ -217,7 +217,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return fmt.Errorf("no connected MaixCam devices") } - response := map[string]interface{}{ + response := map[string]any{ "type": "command", "timestamp": float64(0), "message": msg.Content, @@ -232,7 +232,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro var sendErr error for conn := range c.clients { if _, err := conn.Write(data); err != nil { - logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{ + logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{ "client": conn.RemoteAddr().String(), "error": err.Error(), }) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 091982282..7baef058c 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -47,23 +47,23 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error func (m *Manager) initChannel(name, displayName string) { f, ok := getFactory(name) if !ok { - logger.WarnCF("channels", "Factory not registered", map[string]interface{}{ + logger.WarnCF("channels", "Factory not registered", map[string]any{ "channel": displayName, }) return } - logger.DebugCF("channels", "Attempting to initialize channel", map[string]interface{}{ + logger.DebugCF("channels", "Attempting to initialize channel", map[string]any{ "channel": displayName, }) ch, err := f(m.config, m.bus) if err != nil { - logger.ErrorCF("channels", "Failed to initialize channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to initialize channel", map[string]any{ "channel": displayName, "error": err.Error(), }) } else { m.channels[name] = ch - logger.InfoCF("channels", "Channel enabled successfully", map[string]interface{}{ + logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ "channel": displayName, }) } @@ -120,7 +120,7 @@ func (m *Manager) initChannels() error { m.initChannel("wecom_app", "WeCom App") } - logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{ + logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) @@ -144,11 +144,11 @@ func (m *Manager) StartAll(ctx context.Context) error { go m.dispatchOutbound(dispatchCtx) for name, channel := range m.channels { - logger.InfoCF("channels", "Starting channel", map[string]interface{}{ + logger.InfoCF("channels", "Starting channel", map[string]any{ "channel": name, }) if err := channel.Start(ctx); err != nil { - logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{ + logger.ErrorCF("channels", "Failed to start channel", map[string]any{ "channel": name, "error": err.Error(), }) @@ -171,11 +171,11 @@ func (m *Manager) StopAll(ctx context.Context) error { } for name, channel := range m.channels { - logger.InfoCF("channels", "Stopping channel", map[string]interface{}{ + logger.InfoCF("channels", "Stopping channel", map[string]any{ "channel": name, }) if err := channel.Stop(ctx); err != nil { - logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{ + logger.ErrorCF("channels", "Error stopping channel", map[string]any{ "channel": name, "error": err.Error(), }) @@ -210,14 +210,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { m.mu.RUnlock() if !exists { - logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{ + logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{ "channel": msg.Channel, }) continue } if err := channel.Send(ctx, msg); err != nil { - logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{ + logger.ErrorCF("channels", "Error sending message to channel", map[string]any{ "channel": msg.Channel, "error": err.Error(), }) @@ -233,13 +233,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) { return channel, ok } -func (m *Manager) GetStatus() map[string]interface{} { +func (m *Manager) GetStatus() map[string]any { m.mu.RLock() defer m.mu.RUnlock() - status := make(map[string]interface{}) + status := make(map[string]any) for name, channel := range m.channels { - status[name] = map[string]interface{}{ + status[name] = map[string]any{ "enabled": true, "running": channel.IsRunning(), } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 209f2dc00..3d2e64e2a 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -88,14 +88,14 @@ type oneBotSender struct { } type oneBotAPIRequest struct { - Action string `json:"action"` - Params interface{} `json:"params"` - Echo string `json:"echo,omitempty"` + Action string `json:"action"` + Params any `json:"params"` + Echo string `json:"echo,omitempty"` } type oneBotMessageSegment struct { - Type string `json:"type"` - Data map[string]interface{} `json:"data"` + Type string `json:"type"` + Data map[string]any `json:"data"` } func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { @@ -118,13 +118,13 @@ func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) { go func() { - _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]interface{}{ + _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{ "message_id": messageID, "emoji_id": emojiID, "set": set, }, 5*time.Second) if err != nil { - logger.DebugCF("onebot", "Failed to set emoji like", map[string]interface{}{ + logger.DebugCF("onebot", "Failed to set emoji like", map[string]any{ "message_id": messageID, "error": err.Error(), }) @@ -137,14 +137,14 @@ func (c *OneBotChannel) Start(ctx context.Context) error { return fmt.Errorf("OneBot ws_url not configured") } - logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{ + logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{ "ws_url": c.config.WSUrl, }) c.ctx, c.cancel = context.WithCancel(ctx) if err := c.connect(); err != nil { - logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{ + logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{ "error": err.Error(), }) } else { @@ -209,7 +209,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) { err := conn.WriteMessage(websocket.PingMessage, nil) c.writeMu.Unlock() if err != nil { - logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]interface{}{ + logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]any{ "error": err.Error(), }) return @@ -221,7 +221,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) { func (c *OneBotChannel) fetchSelfID() { resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second) if err != nil { - logger.WarnCF("onebot", "Failed to get_login_info", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to get_login_info", map[string]any{ "error": err.Error(), }) return @@ -251,7 +251,7 @@ func (c *OneBotChannel) fetchSelfID() { } if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 { atomic.StoreInt64(&c.selfID, uid) - logger.InfoCF("onebot", "Bot self ID retrieved", map[string]interface{}{ + logger.InfoCF("onebot", "Bot self ID retrieved", map[string]any{ "self_id": uid, "nickname": info.Nickname, }) @@ -259,12 +259,12 @@ func (c *OneBotChannel) fetchSelfID() { } } - logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]interface{}{ + logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]any{ "response": string(resp), }) } -func (c *OneBotChannel) sendAPIRequest(action string, params interface{}, timeout time.Duration) (json.RawMessage, error) { +func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) { c.mu.Lock() conn := c.conn c.mu.Unlock() @@ -333,7 +333,7 @@ func (c *OneBotChannel) reconnectLoop() { if conn == nil { logger.InfoC("onebot", "Attempting to reconnect...") if err := c.connect(); err != nil { - logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{ + logger.ErrorCF("onebot", "Reconnect failed", map[string]any{ "error": err.Error(), }) } else { @@ -406,7 +406,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error c.writeMu.Unlock() if err != nil { - logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{ + logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) return err @@ -428,20 +428,20 @@ func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMes if msgID, ok := lastMsgID.(string); ok && msgID != "" { segments = append(segments, oneBotMessageSegment{ Type: "reply", - Data: map[string]interface{}{"id": msgID}, + Data: map[string]any{"id": msgID}, }) } } segments = append(segments, oneBotMessageSegment{ Type: "text", - Data: map[string]interface{}{"text": content}, + Data: map[string]any{"text": content}, }) return segments } -func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) { +func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) { chatID := msg.ChatID segments := c.buildMessageSegments(chatID, msg.Content) @@ -459,7 +459,7 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter if err != nil { return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID) } - return action, map[string]interface{}{idKey: id, "message": segments}, nil + return action, map[string]any{idKey: id, "message": segments}, nil } func (c *OneBotChannel) listen() { @@ -479,7 +479,7 @@ func (c *OneBotChannel) listen() { default: _, message, err := conn.ReadMessage() if err != nil { - logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{ + logger.ErrorCF("onebot", "WebSocket read error", map[string]any{ "error": err.Error(), }) c.mu.Lock() @@ -495,14 +495,14 @@ func (c *OneBotChannel) listen() { var raw oneBotRawEvent if err := json.Unmarshal(message, &raw); err != nil { - logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{ "error": err.Error(), "payload": string(message), }) continue } - logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{ + logger.DebugCF("onebot", "WebSocket event", map[string]any{ "length": len(message), "post_type": raw.PostType, "sub_type": raw.SubType, @@ -519,7 +519,7 @@ func (c *OneBotChannel) listen() { default: } } else { - logger.DebugCF("onebot", "Received API response (no waiter)", map[string]interface{}{ + logger.DebugCF("onebot", "Received API response (no waiter)", map[string]any{ "echo": raw.Echo, "status": string(raw.Status), }) @@ -528,7 +528,7 @@ func (c *OneBotChannel) listen() { } if isAPIResponse(raw.Status) { - logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]interface{}{ + logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]any{ "status": string(raw.Status), }) continue @@ -595,7 +595,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) return parseMessageResult{Text: s, IsBotMentioned: mentioned} } - var segments []map[string]interface{} + var segments []map[string]any if err := json.Unmarshal(raw, &segments); err != nil { return parseMessageResult{} } @@ -609,7 +609,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) for _, seg := range segments { segType, _ := seg["type"].(string) - data, _ := seg["data"].(map[string]interface{}) + data, _ := seg["data"].(map[string]any) switch segType { case "text": @@ -663,7 +663,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) result, err := c.transcriber.Transcribe(tctx, localPath) tcancel() if err != nil { - logger.WarnCF("onebot", "Voice transcription failed", map[string]interface{}{ + logger.WarnCF("onebot", "Voice transcription failed", map[string]any{ "error": err.Error(), }) textParts = append(textParts, "[voice (transcription failed)]") @@ -714,7 +714,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { case "message": if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 { if !c.IsAllowed(strconv.FormatInt(userID, 10)) { - logger.DebugCF("onebot", "Message rejected by allowlist", map[string]interface{}{ + logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{ "user_id": userID, }) return @@ -723,7 +723,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { c.handleMessage(raw) case "message_sent": - logger.DebugCF("onebot", "Bot sent message event", map[string]interface{}{ + logger.DebugCF("onebot", "Bot sent message event", map[string]any{ "message_type": raw.MessageType, "message_id": parseJSONString(raw.MessageID), }) @@ -735,18 +735,18 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { c.handleNoticeEvent(raw) case "request": - logger.DebugCF("onebot", "Request event received", map[string]interface{}{ + logger.DebugCF("onebot", "Request event received", map[string]any{ "sub_type": raw.SubType, }) case "": - logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{ + logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{ "echo": raw.Echo, "status": raw.Status, }) default: - logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{ + logger.DebugCF("onebot", "Unknown post_type", map[string]any{ "post_type": raw.PostType, }) } @@ -754,14 +754,14 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) { if raw.MetaEventType == "lifecycle" { - logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{"sub_type": raw.SubType}) + logger.InfoCF("onebot", "Lifecycle event", map[string]any{"sub_type": raw.SubType}) } else if raw.MetaEventType != "heartbeat" { logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil) } } func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) { - fields := map[string]interface{}{ + fields := map[string]any{ "notice_type": raw.NoticeType, "sub_type": raw.SubType, "group_id": parseJSONString(raw.GroupID), @@ -781,7 +781,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { // Parse fields from raw event userID, err := parseJSONInt64(raw.UserID) if err != nil { - logger.WarnCF("onebot", "Failed to parse user_id", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to parse user_id", map[string]any{ "error": err.Error(), "raw": string(raw.UserID), }) @@ -818,7 +818,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { var sender oneBotSender if len(raw.Sender) > 0 { if err := json.Unmarshal(raw.Sender, &sender); err != nil { - logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{ + logger.WarnCF("onebot", "Failed to parse sender", map[string]any{ "error": err.Error(), "sender": string(raw.Sender), }) @@ -830,7 +830,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { defer func() { for _, f := range parsed.LocalFiles { if err := os.Remove(f); err != nil { - logger.DebugCF("onebot", "Failed to remove temp file", map[string]interface{}{ + logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{ "path": f, "error": err.Error(), }) @@ -840,14 +840,14 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { } if c.isDuplicate(messageID) { - logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{ + logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{ "message_id": messageID, }) return } if content == "" { - logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{ + logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{ "message_id": messageID, }) return @@ -890,7 +890,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned) if !triggered { - logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{ + logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{ "sender": senderID, "group": groupIDStr, "is_mentioned": isBotMentioned, @@ -901,7 +901,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { content = strippedContent default: - logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{ + logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{ "type": raw.MessageType, "message_id": messageID, "user_id": userID, @@ -909,7 +909,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { return } - logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{ + logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{ "sender": senderID, "chat_id": chatID, "message_id": messageID, @@ -962,7 +962,10 @@ func truncate(s string, n int) string { return string(runes[:n]) + "..." } -func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) { +func (c *OneBotChannel) checkGroupTrigger( + content string, + isBotMentioned bool, +) (triggered bool, strippedContent string) { if isBotMentioned { return true, strings.TrimSpace(content) } diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 9b07be0cc..2a95bbd06 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -78,7 +78,7 @@ func (c *QQChannel) Start(ctx context.Context) error { return fmt.Errorf("failed to get websocket info: %w", err) } - logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{ + logger.InfoCF("qq", "Got WebSocket info", map[string]any{ "shards": wsInfo.Shards, }) @@ -88,7 +88,7 @@ func (c *QQChannel) Start(ctx context.Context) error { // 在 goroutine 中启动 WebSocket 连接,避免阻塞 go func() { if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil { - logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{ + logger.ErrorCF("qq", "WebSocket session error", map[string]any{ "error": err.Error(), }) c.SetRunning(false) @@ -125,7 +125,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { // C2C 消息发送 _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) if err != nil { - logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{ + logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ "error": err.Error(), }) return err @@ -158,7 +158,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return nil } - logger.InfoCF("qq", "Received C2C message", map[string]interface{}{ + logger.InfoCF("qq", "Received C2C message", map[string]any{ "sender": senderID, "length": len(content), }) @@ -200,7 +200,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return nil } - logger.InfoCF("qq", "Received group AT message", map[string]interface{}{ + logger.InfoCF("qq", "Received group AT message", map[string]any{ "sender": senderID, "group": data.GroupID, "length": len(content), diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index dc5190fc9..cafe53103 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -76,7 +76,7 @@ func (c *SlackChannel) Start(ctx context.Context) error { c.botUserID = authResp.UserID c.teamID = authResp.TeamID - logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{ + logger.InfoCF("slack", "Slack bot connected", map[string]any{ "bot_user_id": c.botUserID, "team": authResp.Team, }) @@ -86,7 +86,7 @@ func (c *SlackChannel) Start(ctx context.Context) error { go func() { if err := c.socketClient.RunContext(c.ctx); err != nil { if c.ctx.Err() == nil { - logger.ErrorCF("slack", "Socket Mode connection error", map[string]interface{}{ + logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{ "error": err.Error(), }) } @@ -141,7 +141,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error }) } - logger.DebugCF("slack", "Message sent", map[string]interface{}{ + logger.DebugCF("slack", "Message sent", map[string]any{ "channel_id": channelID, "thread_ts": threadTS, }) @@ -203,7 +203,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { // 检查白名单,避免为被拒绝的用户下载附件 if !c.IsAllowed(ev.User) { - logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{ + logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{ "user_id": ev.User, }) return @@ -239,7 +239,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { - logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{ + logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{ "file": file, "error": err.Error(), }) @@ -262,7 +262,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { result, err := c.transcriber.Transcribe(ctx, localPath) if err != nil { - logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()}) + logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()}) content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name) } else { content += fmt.Sprintf("\n[voice transcription: %s]", result.Text) @@ -294,7 +294,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { "team_id": c.teamID, } - logger.DebugCF("slack", "Received message", map[string]interface{}{ + logger.DebugCF("slack", "Received message", map[string]any{ "sender_id": senderID, "chat_id": chatID, "preview": utils.Truncate(content, 50), @@ -310,7 +310,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { } if !c.IsAllowed(ev.User) { - logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{ + logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{ "user_id": ev.User, }) return @@ -376,7 +376,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { } if !c.IsAllowed(cmd.UserID) { - logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{ + logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{ "user_id": cmd.UserID, }) return @@ -401,7 +401,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "team_id": c.teamID, } - logger.DebugCF("slack", "Slash command received", map[string]interface{}{ + logger.DebugCF("slack", "Slash command received", map[string]any{ "sender_id": senderID, "command": cmd.Command, "text": utils.Truncate(content, 50), @@ -416,7 +416,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string { downloadURL = file.URLPrivate } if downloadURL == "" { - logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID}) + logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID}) return "" } diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f4c5108df..7619440e2 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -11,10 +11,9 @@ import ( "sync" "time" - th "github.com/mymmrac/telego/telegohandler" - "github.com/mymmrac/telego" "github.com/mymmrac/telego/telegohandler" + th "github.com/mymmrac/telego/telegohandler" tu "github.com/mymmrac/telego/telegoutil" "github.com/sipeed/picoclaw/pkg/bus" @@ -128,7 +127,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error { }, th.AnyMessage()) c.SetRunning(true) - logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{ + logger.InfoCF("telegram", "Telegram bot connected", map[string]any{ "username": c.bot.Username(), }) @@ -141,6 +140,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error { return nil } + func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") c.SetRunning(false) @@ -183,7 +183,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err tgMsg.ParseMode = telego.ModeHTML if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { - logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{ + logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ "error": err.Error(), }) tgMsg.ParseMode = "" @@ -211,7 +211,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes // 检查白名单,避免为被拒绝的用户下载附件 if !c.IsAllowed(senderID) { - logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{ + logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{ "user_id": senderID, }) return nil @@ -228,7 +228,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { - logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{ + logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{ "file": file, "error": err.Error(), }) @@ -268,19 +268,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes transcribedText := "" if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() - result, err := c.transcriber.Transcribe(ctx, voicePath) + result, err := c.transcriber.Transcribe(transcriberCtx, voicePath) if err != nil { - logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{ + logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{ "error": err.Error(), "path": voicePath, }) transcribedText = "[voice (transcription failed)]" } else { transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) - logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{ + logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{ "text": result.Text, }) } @@ -323,7 +323,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = "[empty message]" } - logger.DebugCF("telegram", "Received message", map[string]interface{}{ + logger.DebugCF("telegram", "Received message", map[string]any{ "sender_id": senderID, "chat_id": fmt.Sprintf("%d", chatID), "preview": utils.Truncate(content, 50), @@ -332,7 +332,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes // Thinking indicator err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) if err != nil { - logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{ + logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{ "error": err.Error(), }) } @@ -379,7 +379,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { - logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{ + logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{ "error": err.Error(), }) return "" @@ -394,7 +394,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st } url := c.bot.FileDownloadURL(file.FilePath) - logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url}) + logger.DebugCF("telegram", "File URL", map[string]any{"url": url}) // Use FilePath as filename for better identification filename := file.FilePath + ext @@ -406,7 +406,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { - logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{ + logger.ErrorCF("telegram", "Failed to get file", map[string]any{ "error": err.Error(), }) return "" @@ -464,7 +464,11 @@ func markdownToTelegramHTML(text string) string { for i, code := range codeBlocks.codes { escaped := escapeHTML(code) - text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("
%s
", escaped)) + text = strings.ReplaceAll( + text, + fmt.Sprintf("\x00CB%d\x00", i), + fmt.Sprintf("
%s
", escaped), + ) } return text diff --git a/pkg/channels/telegram/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go index 4bf1b3aff..f17912260 100644 --- a/pkg/channels/telegram/telegram_commands.go +++ b/pkg/channels/telegram/telegram_commands.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/mymmrac/telego" + "github.com/sipeed/picoclaw/pkg/config" ) @@ -35,6 +36,7 @@ func commandArgs(text string) string { } return strings.TrimSpace(parts[1]) } + func (c *cmd) Help(ctx context.Context, message telego.Message) error { msg := `/start - Start the bot /help - Show this help message @@ -96,6 +98,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error { }) return err } + func (c *cmd) List(ctx context.Context, message telego.Message) error { args := commandArgs(message.Text) if args == "" { diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 85c017958..f3557d60f 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -142,7 +142,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error { // Get initial access token if err := c.refreshAccessToken(); err != nil { - logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]interface{}{ + logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]any{ "error": err.Error(), }) } @@ -168,7 +168,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error { } c.SetRunning(true) - logger.InfoCF("wecom_app", "WeCom App channel started", map[string]interface{}{ + logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{ "address": addr, "path": webhookPath, }) @@ -176,7 +176,7 @@ func (c *WeComAppChannel) Start(ctx context.Context) error { // Start server in goroutine go func() { if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom_app", "HTTP server error", map[string]interface{}{ + logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{ "error": err.Error(), }) } @@ -215,7 +215,7 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("no valid access token available") } - logger.DebugCF("wecom_app", "Sending message", map[string]interface{}{ + logger.DebugCF("wecom_app", "Sending message", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) @@ -228,7 +228,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) ctx := r.Context() // Log all incoming requests for debugging - logger.DebugCF("wecom_app", "Received webhook request", map[string]interface{}{ + logger.DebugCF("wecom_app", "Received webhook request", map[string]any{ "method": r.Method, "url": r.URL.String(), "path": r.URL.Path, @@ -247,7 +247,7 @@ func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) return } - logger.WarnCF("wecom_app", "Method not allowed", map[string]interface{}{ + logger.WarnCF("wecom_app", "Method not allowed", map[string]any{ "method": r.Method, }) http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -261,7 +261,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons nonce := query.Get("nonce") echostr := query.Get("echostr") - logger.DebugCF("wecom_app", "Handling verification request", map[string]interface{}{ + logger.DebugCF("wecom_app", "Handling verification request", map[string]any{ "msg_signature": msgSignature, "timestamp": timestamp, "nonce": nonce, @@ -277,7 +277,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons // Verify signature if !verifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) { - logger.WarnCF("wecom_app", "Signature verification failed", map[string]interface{}{ + logger.WarnCF("wecom_app", "Signature verification failed", map[string]any{ "token": c.config.Token, "msg_signature": msgSignature, "timestamp": timestamp, @@ -291,13 +291,13 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons // Decrypt echostr with CorpID verification // For WeCom App (自建应用), receiveid should be corp_id - logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]interface{}{ + logger.DebugCF("wecom_app", "Attempting to decrypt echostr", map[string]any{ "encoding_aes_key": c.config.EncodingAESKey, "corp_id": c.config.CorpID, }) decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, c.config.CorpID) if err != nil { - logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{ + logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), "encoding_aes_key": c.config.EncodingAESKey, "corp_id": c.config.CorpID, @@ -306,7 +306,7 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons return } - logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]interface{}{ + logger.DebugCF("wecom_app", "Successfully decrypted echostr", map[string]any{ "decrypted": decryptedEchoStr, }) @@ -345,8 +345,8 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp AgentID string `xml:"AgentID"` } - if err := xml.Unmarshal(body, &encryptedMsg); err != nil { - logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]interface{}{ + if err = xml.Unmarshal(body, &encryptedMsg); err != nil { + logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid XML", http.StatusBadRequest) @@ -364,7 +364,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp // For WeCom App (自建应用), receiveid should be corp_id decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, c.config.CorpID) if err != nil { - logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{ + logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) @@ -374,7 +374,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp // Parse decrypted XML message var msg WeComXMLMessage if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil { - logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]interface{}{ + logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid message format", http.StatusBadRequest) @@ -393,7 +393,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) { // Skip non-text messages for now (can be extended) if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" { - logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]interface{}{ + logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]any{ "msg_type": msg.MsgType, }) return @@ -405,7 +405,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag c.msgMu.Lock() if c.processedMsgs[msgID] { c.msgMu.Unlock() - logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]interface{}{ + logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return @@ -438,7 +438,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag content := msg.Content - logger.DebugCF("wecom_app", "Received message", map[string]interface{}{ + logger.DebugCF("wecom_app", "Received message", map[string]any{ "sender_id": senderID, "msg_type": msg.MsgType, "preview": utils.Truncate(content, 50), @@ -459,7 +459,7 @@ func (c *WeComAppChannel) tokenRefreshLoop() { return case <-ticker.C: if err := c.refreshAccessToken(); err != nil { - logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]interface{}{ + logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]any{ "error": err.Error(), }) } @@ -625,7 +625,7 @@ func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, // handleHealth handles health check requests func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { - status := map[string]interface{}{ + status := map[string]any{ "status": "ok", "running": c.IsRunning(), "has_token": c.getAccessToken() != "", diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go index d9817fd49..5420949de 100644 --- a/pkg/channels/wecom/app_test.go +++ b/pkg/channels/wecom/app_test.go @@ -396,7 +396,11 @@ func TestWeComAppHandleVerification(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr) - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, + nil, + ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) @@ -426,7 +430,11 @@ func TestWeComAppHandleVerification(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, + nil, + ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) @@ -478,7 +486,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encrypted) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -507,7 +519,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, "") - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml")) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + strings.NewReader("invalid xml"), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -529,7 +545,11 @@ func TestWeComAppHandleMessageCallback(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom-app?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -643,7 +663,11 @@ func TestWeComAppHandleWebhook(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encoded) - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, + nil, + ) w := httptest.NewRecorder() ch.handleWebhook(w, req) @@ -666,7 +690,11 @@ func TestWeComAppHandleWebhook(t *testing.T) { nonce := "test_nonce" signature := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom-app?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleWebhook(w, req) @@ -832,15 +860,24 @@ func TestWeComAppMessageStructures(t *testing.T) { if msg.Image.MediaID != "media_123456" { t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456") } + if msg.ToUser != "user123" { + t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123") + } + if msg.MsgType != "image" { + t.Errorf("MsgType = %q, want %q", msg.MsgType, "image") + } + if msg.AgentID != 1000002 { + t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002) + } }) t.Run("WeComAccessTokenResponse structure", func(t *testing.T) { jsonData := `{ - "errcode": 0, - "errmsg": "ok", - "access_token": "test_access_token", - "expires_in": 7200 - }` + "errcode": 0, + "errmsg": "ok", + "access_token": "test_access_token", + "expires_in": 7200 + }` var resp WeComAccessTokenResponse err := json.Unmarshal([]byte(jsonData), &resp) @@ -864,12 +901,12 @@ func TestWeComAppMessageStructures(t *testing.T) { t.Run("WeComSendMessageResponse structure", func(t *testing.T) { jsonData := `{ - "errcode": 0, - "errmsg": "ok", - "invaliduser": "", - "invalidparty": "", - "invalidtag": "" - }` + "errcode": 0, + "errmsg": "ok", + "invaliduser": "", + "invalidparty": "", + "invalidtag": "" + }` var resp WeComSendMessageResponse err := json.Unmarshal([]byte(jsonData), &resp) diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 9683a308f..17ee2107f 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -125,7 +125,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error { } c.SetRunning(true) - logger.InfoCF("wecom", "WeCom Bot channel started", map[string]interface{}{ + logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{ "address": addr, "path": webhookPath, }) @@ -133,7 +133,7 @@ func (c *WeComBotChannel) Start(ctx context.Context) error { // Start server in goroutine go func() { if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom", "HTTP server error", map[string]interface{}{ + logger.ErrorCF("wecom", "HTTP server error", map[string]any{ "error": err.Error(), }) } @@ -169,7 +169,7 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("wecom channel not running") } - logger.DebugCF("wecom", "Sending message via webhook", map[string]interface{}{ + logger.DebugCF("wecom", "Sending message via webhook", map[string]any{ "chat_id": msg.ChatID, "preview": utils.Truncate(msg.Content, 100), }) @@ -221,7 +221,7 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons // Reference: https://developer.work.weixin.qq.com/document/path/101033 decryptedEchoStr, err := decryptMessageWithVerify(echostr, c.config.EncodingAESKey, "") if err != nil { - logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{ + logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) @@ -263,8 +263,8 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp AgentID string `xml:"AgentID"` } - if err := xml.Unmarshal(body, &encryptedMsg); err != nil { - logger.ErrorCF("wecom", "Failed to parse XML", map[string]interface{}{ + if err = xml.Unmarshal(body, &encryptedMsg); err != nil { + logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid XML", http.StatusBadRequest) @@ -283,7 +283,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp // Reference: https://developer.work.weixin.qq.com/document/path/101033 decryptedMsg, err := decryptMessageWithVerify(encryptedMsg.Encrypt, c.config.EncodingAESKey, "") if err != nil { - logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{ + logger.ErrorCF("wecom", "Failed to decrypt message", map[string]any{ "error": err.Error(), }) http.Error(w, "Decryption failed", http.StatusInternalServerError) @@ -293,7 +293,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp // Parse decrypted JSON message (AIBOT uses JSON format) var msg WeComBotMessage if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil { - logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{ + logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]any{ "error": err.Error(), }) http.Error(w, "Invalid message format", http.StatusBadRequest) @@ -311,8 +311,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp // processMessage processes the received message func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) { // Skip unsupported message types - if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && msg.MsgType != "mixed" { - logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{ + if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && + msg.MsgType != "mixed" { + logger.DebugCF("wecom", "Skipping non-supported message type", map[string]any{ "msg_type": msg.MsgType, }) return @@ -323,7 +324,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag c.msgMu.Lock() if c.processedMsgs[msgID] { c.msgMu.Unlock() - logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{ + logger.DebugCF("wecom", "Skipping duplicate message", map[string]any{ "msg_id": msgID, }) return @@ -390,7 +391,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag metadata["sender_id"] = senderID } - logger.DebugCF("wecom", "Received message", map[string]interface{}{ + logger.DebugCF("wecom", "Received message", map[string]any{ "sender_id": senderID, "msg_type": msg.MsgType, "peer_kind": peerKind, @@ -459,7 +460,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content // handleHealth handles health check requests func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { - status := map[string]interface{}{ + status := map[string]any{ "status": "ok", "running": c.IsRunning(), } diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go index 460e0058f..328b145c2 100644 --- a/pkg/channels/wecom/bot_test.go +++ b/pkg/channels/wecom/bot_test.go @@ -18,7 +18,6 @@ import ( "testing" "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) @@ -196,10 +195,8 @@ func TestWeComBotVerifySignature(t *testing.T) { Token: "", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", } - base := channels.NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom) chEmpty := &WeComBotChannel{ - BaseChannel: base, - config: cfgEmpty, + config: cfgEmpty, } if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { @@ -356,7 +353,11 @@ func TestWeComBotHandleVerification(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr) - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, + nil, + ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) @@ -386,7 +387,11 @@ func TestWeComBotHandleVerification(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, + nil, + ) w := httptest.NewRecorder() ch.handleVerification(context.Background(), w, req) @@ -410,14 +415,14 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { t.Run("valid direct message callback", func(t *testing.T) { // Create JSON message for direct chat (single) jsonMsg := `{ - "msgid": "test_msg_id_123", - "aibotid": "test_aibot_id", - "chattype": "single", - "from": {"userid": "user123"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello World"} - }` + "msgid": "test_msg_id_123", + "aibotid": "test_aibot_id", + "chattype": "single", + "from": {"userid": "user123"}, + "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + "msgtype": "text", + "text": {"content": "Hello World"} + }` // Encrypt message encrypted, _ := encryptTestMessage(jsonMsg, aesKey) @@ -435,7 +440,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encrypted) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -451,15 +460,15 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { t.Run("valid group message callback", func(t *testing.T) { // Create JSON message for group chat jsonMsg := `{ - "msgid": "test_msg_id_456", - "aibotid": "test_aibot_id", - "chatid": "group_chat_id_123", - "chattype": "group", - "from": {"userid": "user456"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello Group"} - }` + "msgid": "test_msg_id_456", + "aibotid": "test_aibot_id", + "chatid": "group_chat_id_123", + "chattype": "group", + "from": {"userid": "user456"}, + "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + "msgtype": "text", + "text": {"content": "Hello Group"} + }` // Encrypt message encrypted, _ := encryptTestMessage(jsonMsg, aesKey) @@ -477,7 +486,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encrypted) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -506,7 +519,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, "") - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml")) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + strings.NewReader("invalid xml"), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -528,7 +545,11 @@ func TestWeComBotHandleMessageCallback(t *testing.T) { timestamp := "1234567890" nonce := "test_nonce" - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleMessageCallback(context.Background(), w, req) @@ -623,7 +644,11 @@ func TestWeComBotHandleWebhook(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encoded) - req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil) + req := httptest.NewRequest( + http.MethodGet, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, + nil, + ) w := httptest.NewRecorder() ch.handleWebhook(w, req) @@ -646,7 +671,11 @@ func TestWeComBotHandleWebhook(t *testing.T) { nonce := "test_nonce" signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt) - req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData)) + req := httptest.NewRequest( + http.MethodPost, + "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, + bytes.NewReader(wrapperData), + ) w := httptest.NewRecorder() ch.handleWebhook(w, req) @@ -713,15 +742,15 @@ func TestWeComBotReplyMessage(t *testing.T) { func TestWeComBotMessageStructure(t *testing.T) { jsonData := `{ - "msgid": "test_msg_id_123", - "aibotid": "test_aibot_id", - "chatid": "group_chat_id_123", - "chattype": "group", - "from": {"userid": "user123"}, - "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", - "msgtype": "text", - "text": {"content": "Hello World"} - }` + "msgid": "test_msg_id_123", + "aibotid": "test_aibot_id", + "chatid": "group_chat_id_123", + "chattype": "group", + "from": {"userid": "user123"}, + "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", + "msgtype": "text", + "text": {"content": "Hello World"} + }` var msg WeComBotMessage err := json.Unmarshal([]byte(jsonData), &msg) diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 1ac256766..7e8f13ab6 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -87,7 +87,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("whatsapp connection not established") } - payload := map[string]interface{}{ + payload := map[string]any{ "type": "message", "to": msg.ChatID, "content": msg.Content, @@ -127,7 +127,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) { continue } - var msg map[string]interface{} + var msg map[string]any if err := json.Unmarshal(message, &msg); err != nil { log.Printf("Failed to unmarshal WhatsApp message: %v", err) continue @@ -145,7 +145,7 @@ func (c *WhatsAppChannel) listen(ctx context.Context) { } } -func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) { +func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { senderID, ok := msg["from"].(string) if !ok { return @@ -162,7 +162,7 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) { } var mediaPaths []string - if mediaData, ok := msg["media"].([]interface{}); ok { + if mediaData, ok := msg["media"].([]any); ok { mediaPaths = make([]string, 0, len(mediaData)) for _, m := range mediaData { if path, ok := m.(string); ok { From 931093c19d79ed1d0ff6ad444b086b6c29d0298b Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sun, 22 Feb 2026 21:57:12 +0800 Subject: [PATCH 008/104] refactor(bus,channels): promote peer and messageID from metadata to structured fields Add bus.Peer struct and explicit Peer/MessageID fields to InboundMessage, replacing the implicit peer_kind/peer_id/message_id metadata convention. - Add Peer{Kind, ID} type to pkg/bus/types.go - Extend InboundMessage with Peer and MessageID fields - Change BaseChannel.HandleMessage signature to accept peer and messageID - Adapt all 12 channel implementations to pass structured peer/messageID - Simplify agent extractPeer() to read msg.Peer directly - extractParentPeer unchanged (parent_peer still via metadata) --- pkg/agent/loop.go | 11 +++++------ pkg/bus/types.go | 8 ++++++++ pkg/channels/base.go | 21 ++++++++++++++------- pkg/channels/dingtalk/dingtalk.go | 9 ++++----- pkg/channels/discord/discord.go | 7 +++---- pkg/channels/feishu/feishu_32.go | 4 +++- pkg/channels/feishu/feishu_64.go | 14 +++++++------- pkg/channels/line/line.go | 10 ++++------ pkg/channels/maixcam/maixcam.go | 4 +--- pkg/channels/onebot/onebot.go | 14 ++++++-------- pkg/channels/qq/qq.go | 31 ++++++++++++++++++++----------- pkg/channels/slack/slack.go | 16 +++++++--------- pkg/channels/telegram/telegram.go | 16 ++++++++++++---- pkg/channels/wecom/app.go | 7 ++++--- pkg/channels/wecom/bot.go | 6 +++--- pkg/channels/whatsapp/whatsapp.go | 14 +++++++------- 16 files changed, 108 insertions(+), 84 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 693f2227b..131f7eb4f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1119,21 +1119,20 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) return "", false } -// extractPeer extracts the routing peer from inbound message metadata. +// extractPeer extracts the routing peer from the inbound message's structured Peer field. func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { - peerKind := msg.Metadata["peer_kind"] - if peerKind == "" { + if msg.Peer.Kind == "" { return nil } - peerID := msg.Metadata["peer_id"] + peerID := msg.Peer.ID if peerID == "" { - if peerKind == "direct" { + if msg.Peer.Kind == "direct" { peerID = msg.SenderID } else { peerID = msg.ChatID } } - return &routing.RoutePeer{Kind: peerKind, ID: peerID} + return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID} } // extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 44f9181a5..081f13a0b 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -1,11 +1,19 @@ package bus +// Peer identifies the routing peer for a message (direct, group, channel, etc.) +type Peer struct { + Kind string `json:"kind"` // "direct" | "group" | "channel" | "" + ID string `json:"id"` +} + type InboundMessage struct { Channel string `json:"channel"` SenderID string `json:"sender_id"` ChatID string `json:"chat_id"` Content string `json:"content"` Media []string `json:"media,omitempty"` + Peer Peer `json:"peer"` // routing peer + MessageID string `json:"message_id,omitempty"` // platform message ID SessionKey string `json:"session_key"` Metadata map[string]string `json:"metadata,omitempty"` } diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 5d77c6c0d..5e603f0d4 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -81,18 +81,25 @@ func (c *BaseChannel) IsAllowed(senderID string) bool { return false } -func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []string, metadata map[string]string) { +func (c *BaseChannel) HandleMessage( + peer bus.Peer, + messageID, senderID, chatID, content string, + media []string, + metadata map[string]string, +) { if !c.IsAllowed(senderID) { return } msg := bus.InboundMessage{ - Channel: c.name, - SenderID: senderID, - ChatID: chatID, - Content: content, - Media: media, - Metadata: metadata, + Channel: c.name, + SenderID: senderID, + ChatID: chatID, + Content: content, + Media: media, + Peer: peer, + MessageID: messageID, + Metadata: metadata, } c.bus.PublishInbound(msg) diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index afc0de47f..a8aee65d6 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -160,12 +160,11 @@ func (c *DingTalkChannel) onChatBotMessageReceived( "session_webhook": data.SessionWebhook, } + var peer bus.Peer if data.ConversationType == "1" { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} } else { - metadata["peer_kind"] = "group" - metadata["peer_id"] = data.ConversationId + peer = bus.Peer{Kind: "group", ID: data.ConversationId} } logger.DebugCF("dingtalk", "Received message", map[string]any{ @@ -175,7 +174,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived( }) // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(peer, "", senderID, chatID, content, nil, metadata) // Return nil to indicate we've handled the message asynchronously // The response will be sent through the message bus diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index b83ac28fd..416a94710 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -294,19 +294,18 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag peerID = senderID } + peer := bus.Peer{Kind: peerKind, ID: peerID} + metadata := map[string]string{ - "message_id": m.ID, "user_id": senderID, "username": m.Author.Username, "display_name": senderName, "guild_id": m.GuildID, "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), - "peer_kind": peerKind, - "peer_id": peerID, } - c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata) + c.HandleMessage(peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata) } // startTyping starts a continuous typing indicator loop for the given chatID. diff --git a/pkg/channels/feishu/feishu_32.go b/pkg/channels/feishu/feishu_32.go index 14711e49e..d0ec758c6 100644 --- a/pkg/channels/feishu/feishu_32.go +++ b/pkg/channels/feishu/feishu_32.go @@ -18,7 +18,9 @@ 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) { - 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", + ) } // Start is a stub method to satisfy the Channel interface diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index aa4e141c4..d67823974 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -153,8 +153,9 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 } metadata := map[string]string{} - if messageID := stringValue(message.MessageId); messageID != "" { - metadata["message_id"] = messageID + messageID := "" + if mid := stringValue(message.MessageId); mid != "" { + messageID = mid } if messageType := stringValue(message.MessageType); messageType != "" { metadata["message_type"] = messageType @@ -167,12 +168,11 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 } chatType := stringValue(message.ChatType) + var peer bus.Peer if chatType == "p2p" { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} } else { - metadata["peer_kind"] = "group" - metadata["peer_id"] = chatID + peer = bus.Peer{Kind: "group", ID: chatID} } logger.InfoCF("feishu", "Feishu message received", map[string]any{ @@ -181,7 +181,7 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 "preview": utils.Truncate(content, 80), }) - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(peer, messageID, senderID, chatID, content, nil, metadata) return nil } diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 4e1d0dfd3..96297e2cd 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -364,15 +364,13 @@ func (c *LINEChannel) processEvent(event lineEvent) { metadata := map[string]string{ "platform": "line", "source_type": event.Source.Type, - "message_id": msg.ID, } + var peer bus.Peer if isGroup { - metadata["peer_kind"] = "group" - metadata["peer_id"] = chatID + peer = bus.Peer{Kind: "group", ID: chatID} } else { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} } logger.DebugCF("line", "Received message", map[string]any{ @@ -386,7 +384,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { // Show typing/loading indicator (requires user ID, not group ID) c.sendLoading(senderID) - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(peer, msg.ID, senderID, chatID, content, mediaPaths, metadata) } // isBotMentioned checks if the bot is mentioned in the message. diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index a7bff55e0..280098dda 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -171,11 +171,9 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { "y": fmt.Sprintf("%.0f", y), "w": fmt.Sprintf("%.0f", w), "h": fmt.Sprintf("%.0f", h), - "peer_kind": "channel", - "peer_id": "default", } - c.HandleMessage(senderID, chatID, content, []string{}, metadata) + c.HandleMessage(bus.Peer{Kind: "channel", ID: "default"}, "", senderID, chatID, content, []string{}, metadata) } func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 3d2e64e2a..642eebd1d 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -856,9 +856,9 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { senderID := strconv.FormatInt(userID, 10) var chatID string - metadata := map[string]string{ - "message_id": messageID, - } + var peer bus.Peer + + metadata := map[string]string{} if parsed.ReplyTo != "" { metadata["reply_to_message_id"] = parsed.ReplyTo @@ -867,14 +867,12 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { switch raw.MessageType { case "private": chatID = "private:" + senderID - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} case "group": groupIDStr := strconv.FormatInt(groupID, 10) chatID = "group:" + groupIDStr - metadata["peer_kind"] = "group" - metadata["peer_id"] = groupIDStr + peer = bus.Peer{Kind: "group", ID: groupIDStr} metadata["group_id"] = groupIDStr senderUserID, _ := parseJSONInt64(sender.UserID) @@ -929,7 +927,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { c.pendingEmojiMsg.Store(chatID, messageID) } - c.HandleMessage(senderID, chatID, content, parsed.Media, metadata) + c.HandleMessage(peer, messageID, senderID, chatID, content, parsed.Media, metadata) } func (c *OneBotChannel) isDuplicate(messageID string) bool { diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 2a95bbd06..429e23cbf 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -164,13 +164,17 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { }) // 转发到消息总线 - metadata := map[string]string{ - "message_id": data.ID, - "peer_kind": "direct", - "peer_id": senderID, - } + metadata := map[string]string{} - c.HandleMessage(senderID, senderID, content, []string{}, metadata) + c.HandleMessage( + bus.Peer{Kind: "direct", ID: senderID}, + data.ID, + senderID, + senderID, + content, + []string{}, + metadata, + ) return nil } @@ -208,13 +212,18 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { // 转发到消息总线(使用 GroupID 作为 ChatID) metadata := map[string]string{ - "message_id": data.ID, - "group_id": data.GroupID, - "peer_kind": "group", - "peer_id": data.GroupID, + "group_id": data.GroupID, } - c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata) + c.HandleMessage( + bus.Peer{Kind: "group", ID: data.GroupID}, + data.ID, + senderID, + data.GroupID, + content, + []string{}, + metadata, + ) return nil } diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index cafe53103..b459a7140 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -284,13 +284,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { peerID = senderID } + peer := bus.Peer{Kind: peerKind, ID: peerID} + metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, "thread_ts": threadTS, "platform": "slack", - "peer_kind": peerKind, - "peer_id": peerID, "team_id": c.teamID, } @@ -301,7 +301,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { "has_thread": threadTS != "", }) - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(peer, messageTS, senderID, chatID, content, mediaPaths, metadata) } func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { @@ -351,18 +351,18 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { mentionPeerID = senderID } + mentionPeer := bus.Peer{Kind: mentionPeerKind, ID: mentionPeerID} + metadata := map[string]string{ "message_ts": messageTS, "channel_id": channelID, "thread_ts": threadTS, "platform": "slack", "is_mention": "true", - "peer_kind": mentionPeerKind, - "peer_id": mentionPeerID, "team_id": c.teamID, } - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(mentionPeer, messageTS, senderID, chatID, content, nil, metadata) } func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { @@ -396,8 +396,6 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "platform": "slack", "is_command": "true", "trigger_id": cmd.TriggerID, - "peer_kind": "channel", - "peer_id": channelID, "team_id": c.teamID, } @@ -407,7 +405,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "text": utils.Truncate(content, 50), }) - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(bus.Peer{Kind: "channel", ID: channelID}, "", senderID, chatID, content, nil, metadata) } func (c *SlackChannel) downloadSlackFile(file slack.File) string { diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 7619440e2..5703000b4 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -362,17 +362,25 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes peerID = fmt.Sprintf("%d", chatID) } + peer := bus.Peer{Kind: peerKind, ID: peerID} + messageID := fmt.Sprintf("%d", message.MessageID) + metadata := map[string]string{ - "message_id": fmt.Sprintf("%d", message.MessageID), "user_id": fmt.Sprintf("%d", user.ID), "username": user.Username, "first_name": user.FirstName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), - "peer_kind": peerKind, - "peer_id": peerID, } - c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) + c.HandleMessage( + peer, + messageID, + fmt.Sprintf("%d", user.ID), + fmt.Sprintf("%d", chatID), + content, + mediaPaths, + metadata, + ) return nil } diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index f3557d60f..873431d3c 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -425,6 +425,9 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag // Build metadata // WeCom App only supports direct messages (private chat) + peer := bus.Peer{Kind: "direct", ID: senderID} + messageID := fmt.Sprintf("%d", msg.MsgId) + metadata := map[string]string{ "msg_type": msg.MsgType, "msg_id": fmt.Sprintf("%d", msg.MsgId), @@ -432,8 +435,6 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag "platform": "wecom_app", "media_id": msg.MediaId, "create_time": fmt.Sprintf("%d", msg.CreateTime), - "peer_kind": "direct", - "peer_id": senderID, } content := msg.Content @@ -445,7 +446,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag }) // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(peer, messageID, senderID, chatID, content, nil, metadata) } // tokenRefreshLoop periodically refreshes the access token diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 17ee2107f..3a8a16c43 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -378,12 +378,12 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag } // Build metadata + peer := bus.Peer{Kind: peerKind, ID: peerID} + metadata := map[string]string{ "msg_type": msg.MsgType, "msg_id": msg.MsgID, "platform": "wecom", - "peer_kind": peerKind, - "peer_id": peerID, "response_url": msg.ResponseURL, } if isGroupChat { @@ -400,7 +400,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag }) // Handle the message through the base channel - c.HandleMessage(senderID, chatID, content, nil, metadata) + c.HandleMessage(peer, msg.MsgID, senderID, chatID, content, nil, metadata) } // sendWebhookReply sends a reply using the webhook URL diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 7e8f13ab6..1a5401172 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -172,22 +172,22 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { } metadata := make(map[string]string) - if messageID, ok := msg["id"].(string); ok { - metadata["message_id"] = messageID + var messageID string + if mid, ok := msg["id"].(string); ok { + messageID = mid } if userName, ok := msg["from_name"].(string); ok { metadata["user_name"] = userName } + var peer bus.Peer if chatID == senderID { - metadata["peer_kind"] = "direct" - metadata["peer_id"] = senderID + peer = bus.Peer{Kind: "direct", ID: senderID} } else { - metadata["peer_kind"] = "group" - metadata["peer_id"] = chatID + peer = bus.Peer{Kind: "group", ID: chatID} } log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50)) - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(peer, messageID, senderID, chatID, content, mediaPaths, metadata) } From c669784216c8d126863384b6848d5d511a1146de Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sun, 22 Feb 2026 22:25:07 +0800 Subject: [PATCH 009/104] refactor(channels): unify Start/Stop lifecycle and fix goroutine/context leaks - OneBot: remove close(ch) race in Stop() pending cleanup; add WriteDeadline to Send/sendAPIRequest - Telegram: add cancelCtx; Stop() now calls bh.Stop(), cancel(), and cleans up thinking CancelFuncs - Discord: add cancelCtx via WithCancel; Stop() calls cancel(); remove unused getContext() - WhatsApp: add cancelCtx; Send() adds WriteDeadline; replace stdlib log with project logger - MaixCam: add cancelCtx; Send() adds WriteDeadline; Stop() calls cancel() before closing --- pkg/channels/discord/discord.go | 17 ++++++------ pkg/channels/maixcam/maixcam.go | 25 +++++++++++++---- pkg/channels/onebot/onebot.go | 7 +++-- pkg/channels/telegram/telegram.go | 35 +++++++++++++++++++---- pkg/channels/whatsapp/whatsapp.go | 46 +++++++++++++++++++++++-------- 5 files changed, 96 insertions(+), 34 deletions(-) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 416a94710..faf1e1358 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -29,6 +29,7 @@ type DiscordChannel struct { config config.DiscordConfig transcriber *voice.GroqTranscriber ctx context.Context + cancel context.CancelFunc typingMu sync.Mutex typingStop map[string]chan struct{} // chatID → stop signal botUserID string // stored for mention checking @@ -56,17 +57,10 @@ func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { c.transcriber = transcriber } -func (c *DiscordChannel) getContext() context.Context { - if c.ctx == nil { - return context.Background() - } - return c.ctx -} - func (c *DiscordChannel) Start(ctx context.Context) error { logger.InfoC("discord", "Starting Discord bot") - c.ctx = ctx + c.ctx, c.cancel = context.WithCancel(ctx) // Get bot user ID before opening session to avoid race condition botUser, err := c.session.User("@me") @@ -103,6 +97,11 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { } c.typingMu.Unlock() + // Cancel our context so typing goroutines using c.ctx.Done() exit + if c.cancel != nil { + c.cancel() + } + if err := c.session.Close(); err != nil { return fmt.Errorf("failed to close discord session: %w", err) } @@ -236,7 +235,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag transcribedText := "" if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout) + ctx, cancel := context.WithTimeout(c.ctx, transcriptionTimeout) result, err := c.transcriber.Transcribe(ctx, localPath) cancel() // Release context resources immediately to avoid leaks in for loop diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index 280098dda..05213b095 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "sync" + "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -17,6 +18,8 @@ type MaixCamChannel struct { *channels.BaseChannel config config.MaixCamConfig listener net.Listener + ctx context.Context + cancel context.CancelFunc clients map[net.Conn]bool clientsMux sync.RWMutex } @@ -41,9 +44,12 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC func (c *MaixCamChannel) Start(ctx context.Context) error { logger.InfoC("maixcam", "Starting MaixCam channel server") + c.ctx, c.cancel = context.WithCancel(ctx) + addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port) listener, err := net.Listen("tcp", addr) if err != nil { + c.cancel() return fmt.Errorf("failed to listen on %s: %w", addr, err) } @@ -55,17 +61,17 @@ func (c *MaixCamChannel) Start(ctx context.Context) error { "port": c.config.Port, }) - go c.acceptConnections(ctx) + go c.acceptConnections() return nil } -func (c *MaixCamChannel) acceptConnections(ctx context.Context) { +func (c *MaixCamChannel) acceptConnections() { logger.DebugC("maixcam", "Starting connection acceptor") for { select { - case <-ctx.Done(): + case <-c.ctx.Done(): logger.InfoC("maixcam", "Stopping connection acceptor") return default: @@ -87,12 +93,12 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) { c.clients[conn] = true c.clientsMux.Unlock() - go c.handleConnection(conn, ctx) + go c.handleConnection(conn) } } } -func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { +func (c *MaixCamChannel) handleConnection(conn net.Conn) { logger.DebugC("maixcam", "Handling MaixCam connection") defer func() { @@ -107,7 +113,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) { for { select { - case <-ctx.Done(): + case <-c.ctx.Done(): return default: var msg MaixCamMessage @@ -186,6 +192,11 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error { logger.InfoC("maixcam", "Stopping MaixCam channel") c.SetRunning(false) + // Cancel context first to signal goroutines to exit + if c.cancel != nil { + c.cancel() + } + if c.listener != nil { c.listener.Close() } @@ -229,6 +240,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro var sendErr error for conn := range c.clients { + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if _, err := conn.Write(data); err != nil { logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{ "client": conn.RemoteAddr().String(), @@ -236,6 +248,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro }) sendErr = err } + _ = conn.SetWriteDeadline(time.Time{}) } return sendErr diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 642eebd1d..4f35888ca 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -298,7 +298,9 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D } c.writeMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) err = conn.WriteMessage(websocket.TextMessage, data) + _ = conn.SetWriteDeadline(time.Time{}) c.writeMu.Unlock() if err != nil { @@ -354,8 +356,7 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { } c.pendingMu.Lock() - for echo, ch := range c.pending { - close(ch) + for echo := range c.pending { delete(c.pending, echo) } c.pendingMu.Unlock() @@ -402,7 +403,9 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error } c.writeMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) err = conn.WriteMessage(websocket.TextMessage, data) + _ = conn.SetWriteDeadline(time.Time{}) c.writeMu.Unlock() if err != nil { diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 5703000b4..af825ddc9 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -27,10 +27,13 @@ import ( type TelegramChannel struct { *channels.BaseChannel bot *telego.Bot + bh *telegohandler.BotHandler commands TelegramCommander config *config.Config chatIDs map[string]int64 transcriber *voice.GroqTranscriber + ctx context.Context + cancel context.CancelFunc placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> thinkingCancel } @@ -94,17 +97,22 @@ func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") - updates, err := c.bot.UpdatesViaLongPolling(ctx, &telego.GetUpdatesParams{ + c.ctx, c.cancel = context.WithCancel(ctx) + + updates, err := c.bot.UpdatesViaLongPolling(c.ctx, &telego.GetUpdatesParams{ Timeout: 30, }) if err != nil { + c.cancel() return fmt.Errorf("failed to start long polling: %w", err) } bh, err := telegohandler.NewBotHandler(c.bot, updates) if err != nil { + c.cancel() return fmt.Errorf("failed to create bot handler: %w", err) } + c.bh = bh bh.HandleMessage(func(ctx *th.Context, message telego.Message) error { c.commands.Help(ctx, message) @@ -133,17 +141,32 @@ func (c *TelegramChannel) Start(ctx context.Context) error { go bh.Start() - go func() { - <-ctx.Done() - bh.Stop() - }() - return nil } func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") c.SetRunning(false) + + // Clean up all thinking cancel functions to avoid context leaks + c.stopThinking.Range(func(key, value any) bool { + if cf, ok := value.(*thinkingCancel); ok && cf != nil { + cf.Cancel() + } + c.stopThinking.Delete(key) + return true + }) + + // Stop the bot handler + if c.bh != nil { + c.bh.Stop() + } + + // Cancel our context (stops long polling) + if c.cancel != nil { + c.cancel() + } + return nil } diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 1a5401172..cbc82fd09 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "sync" "time" @@ -13,6 +12,7 @@ import ( "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" ) @@ -21,6 +21,8 @@ type WhatsAppChannel struct { conn *websocket.Conn config config.WhatsAppConfig url string + ctx context.Context + cancel context.CancelFunc mu sync.Mutex connected bool } @@ -37,13 +39,18 @@ func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsA } func (c *WhatsAppChannel) Start(ctx context.Context) error { - log.Printf("Starting WhatsApp channel connecting to %s...", c.url) + logger.InfoCF("whatsapp", "Starting WhatsApp channel", map[string]any{ + "bridge_url": c.url, + }) + + c.ctx, c.cancel = context.WithCancel(ctx) dialer := websocket.DefaultDialer dialer.HandshakeTimeout = 10 * time.Second conn, _, err := dialer.Dial(c.url, nil) if err != nil { + c.cancel() return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err) } @@ -53,22 +60,29 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error { c.mu.Unlock() c.SetRunning(true) - log.Println("WhatsApp channel connected") + logger.InfoC("whatsapp", "WhatsApp channel connected") - go c.listen(ctx) + go c.listen() return nil } func (c *WhatsAppChannel) Stop(ctx context.Context) error { - log.Println("Stopping WhatsApp channel...") + logger.InfoC("whatsapp", "Stopping WhatsApp channel...") + + // Cancel context first to signal listen goroutine to exit + if c.cancel != nil { + c.cancel() + } c.mu.Lock() defer c.mu.Unlock() if c.conn != nil { if err := c.conn.Close(); err != nil { - log.Printf("Error closing WhatsApp connection: %v", err) + logger.ErrorCF("whatsapp", "Error closing WhatsApp connection", map[string]any{ + "error": err.Error(), + }) } c.conn = nil } @@ -98,17 +112,20 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("failed to marshal message: %w", err) } + _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + _ = c.conn.SetWriteDeadline(time.Time{}) return fmt.Errorf("failed to send message: %w", err) } + _ = c.conn.SetWriteDeadline(time.Time{}) return nil } -func (c *WhatsAppChannel) listen(ctx context.Context) { +func (c *WhatsAppChannel) listen() { for { select { - case <-ctx.Done(): + case <-c.ctx.Done(): return default: c.mu.Lock() @@ -122,14 +139,18 @@ func (c *WhatsAppChannel) listen(ctx context.Context) { _, message, err := conn.ReadMessage() if err != nil { - log.Printf("WhatsApp read error: %v", err) + logger.ErrorCF("whatsapp", "WhatsApp read error", map[string]any{ + "error": err.Error(), + }) time.Sleep(2 * time.Second) continue } var msg map[string]any if err := json.Unmarshal(message, &msg); err != nil { - log.Printf("Failed to unmarshal WhatsApp message: %v", err) + logger.ErrorCF("whatsapp", "Failed to unmarshal WhatsApp message", map[string]any{ + "error": err.Error(), + }) continue } @@ -187,7 +208,10 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { peer = bus.Peer{Kind: "group", ID: chatID} } - log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50)) + logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{ + "sender": senderID, + "preview": utils.Truncate(content, 50), + }) c.HandleMessage(peer, messageID, senderID, chatID, content, mediaPaths, metadata) } From a91de8546c74dbeddadbe39dffe0eecffe05c37e Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sun, 22 Feb 2026 22:46:29 +0800 Subject: [PATCH 010/104] refactor(channels): unify message splitting and add per-channel worker queues Move message splitting from individual channels (Discord) to the Manager layer via per-channel worker goroutines. Each channel now declares its max message length through BaseChannelOption/MessageLengthProvider, and the Manager automatically splits oversized outbound messages before dispatch. This prevents one slow channel from blocking all others. - Add WithMaxMessageLength option and MessageLengthProvider interface - Set platform-specific limits (Discord 2000, Telegram 4096, Slack 40000, etc.) - Convert SplitMessage to rune-aware counting for correct Unicode handling - Replace single dispatcher goroutine with per-channel buffered worker queues - Remove Discord's internal SplitMessage call (now handled centrally) --- pkg/channels/base.go | 50 ++++++++++--- pkg/channels/dingtalk/dingtalk.go | 2 +- pkg/channels/discord/discord.go | 15 +--- pkg/channels/line/line.go | 2 +- pkg/channels/manager.go | 112 ++++++++++++++++++++++++++--- pkg/channels/slack/slack.go | 2 +- pkg/channels/telegram/telegram.go | 8 ++- pkg/channels/wecom/app.go | 2 +- pkg/channels/wecom/bot.go | 2 +- pkg/channels/whatsapp/whatsapp.go | 2 +- pkg/utils/message.go | 114 ++++++++++++++++++------------ pkg/utils/message_test.go | 60 +++++++++++----- 12 files changed, 272 insertions(+), 99 deletions(-) diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 5e603f0d4..f70145981 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -17,21 +17,55 @@ type Channel interface { IsAllowed(senderID string) bool } -type BaseChannel struct { - config any - bus *bus.MessageBus - running atomic.Bool - name string - allowList []string +// BaseChannelOption is a functional option for configuring a BaseChannel. +type BaseChannelOption func(*BaseChannel) + +// WithMaxMessageLength sets the maximum message length (in runes) for a channel. +// Messages exceeding this limit will be automatically split by the Manager. +// A value of 0 means no limit. +func WithMaxMessageLength(n int) BaseChannelOption { + return func(c *BaseChannel) { c.maxMessageLength = n } } -func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel { - return &BaseChannel{ +// MessageLengthProvider is an opt-in interface that channels implement +// to advertise their maximum message length. The Manager uses this via +// type assertion to decide whether to split outbound messages. +type MessageLengthProvider interface { + MaxMessageLength() int +} + +type BaseChannel struct { + config any + bus *bus.MessageBus + running atomic.Bool + name string + allowList []string + maxMessageLength int +} + +func NewBaseChannel( + name string, + config any, + bus *bus.MessageBus, + allowList []string, + opts ...BaseChannelOption, +) *BaseChannel { + bc := &BaseChannel{ config: config, bus: bus, name: name, allowList: allowList, } + for _, opt := range opts { + opt(bc) + } + return bc +} + +// MaxMessageLength returns the maximum message length (in runes) for this channel. +// A value of 0 means no limit. +func (c *BaseChannel) MaxMessageLength() int { + return c.maxMessageLength } func (c *BaseChannel) Name() string { diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index a8aee65d6..e051add1f 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -38,7 +38,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } - base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(20000)) return &DingTalkChannel{ BaseChannel: base, diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index faf1e1358..623bc9f48 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -41,7 +41,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC return nil, fmt.Errorf("failed to create discord session: %w", err) } - base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom) + base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(2000)) return &DiscordChannel{ BaseChannel: base, @@ -121,20 +121,11 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return fmt.Errorf("channel ID is empty") } - runes := []rune(msg.Content) - if len(runes) == 0 { + if len([]rune(msg.Content)) == 0 { return nil } - chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars - - for _, chunk := range chunks { - if err := c.sendChunk(ctx, channelID, chunk); err != nil { - return err - } - } - - return nil + return c.sendChunk(ctx, channelID, msg.Content) } func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 96297e2cd..9744e1848 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -60,7 +60,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } - base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(5000)) return &LINEChannel{ BaseChannel: base, diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 7baef058c..081d616da 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -15,10 +15,20 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" ) +const defaultChannelQueueSize = 100 + +type channelWorker struct { + ch Channel + queue chan bus.OutboundMessage + done chan struct{} +} + type Manager struct { channels map[string]Channel + workers map[string]*channelWorker bus *bus.MessageBus config *config.Config dispatchTask *asyncTask @@ -32,6 +42,7 @@ type asyncTask struct { func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error) { m := &Manager{ channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), bus: messageBus, config: cfg, } @@ -63,6 +74,11 @@ func (m *Manager) initChannel(name, displayName string) { }) } else { m.channels[name] = ch + m.workers[name] = &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, defaultChannelQueueSize), + done: make(chan struct{}), + } logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ "channel": displayName, }) @@ -141,8 +157,6 @@ func (m *Manager) StartAll(ctx context.Context) error { dispatchCtx, cancel := context.WithCancel(ctx) m.dispatchTask = &asyncTask{cancel: cancel} - go m.dispatchOutbound(dispatchCtx) - for name, channel := range m.channels { logger.InfoCF("channels", "Starting channel", map[string]any{ "channel": name, @@ -155,6 +169,14 @@ func (m *Manager) StartAll(ctx context.Context) error { } } + // Start per-channel workers + for name, w := range m.workers { + go m.runWorker(dispatchCtx, name, w) + } + + // Start the dispatcher that reads from the bus and routes to workers + go m.dispatchOutbound(dispatchCtx) + logger.InfoC("channels", "All channels started") return nil } @@ -165,11 +187,21 @@ func (m *Manager) StopAll(ctx context.Context) error { logger.InfoC("channels", "Stopping all channels") + // Cancel dispatcher first if m.dispatchTask != nil { m.dispatchTask.cancel() m.dispatchTask = nil } + // Close all worker queues and wait for them to drain + for _, w := range m.workers { + close(w.queue) + } + for _, w := range m.workers { + <-w.done + } + + // Stop all channels for name, channel := range m.channels { logger.InfoCF("channels", "Stopping channel", map[string]any{ "channel": name, @@ -186,6 +218,44 @@ func (m *Manager) StopAll(ctx context.Context) error { return nil } +// runWorker processes outbound messages for a single channel, splitting +// messages that exceed the channel's maximum message length. +func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) { + defer close(w.done) + for { + select { + case msg, ok := <-w.queue: + if !ok { + return + } + maxLen := 0 + if mlp, ok := w.ch.(MessageLengthProvider); ok { + maxLen = mlp.MaxMessageLength() + } + if maxLen > 0 && len([]rune(msg.Content)) > maxLen { + chunks := utils.SplitMessage(msg.Content, maxLen) + for _, chunk := range chunks { + chunkMsg := msg + chunkMsg.Content = chunk + if err := w.ch.Send(ctx, chunkMsg); err != nil { + logger.ErrorCF("channels", "Error sending chunk", map[string]any{ + "channel": name, "error": err.Error(), + }) + } + } + } else { + if err := w.ch.Send(ctx, msg); err != nil { + logger.ErrorCF("channels", "Error sending message", map[string]any{ + "channel": name, "error": err.Error(), + }) + } + } + case <-ctx.Done(): + return + } + } +} + func (m *Manager) dispatchOutbound(ctx context.Context) { logger.InfoC("channels", "Outbound dispatcher started") @@ -206,7 +276,8 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { } m.mu.RLock() - channel, exists := m.channels[msg.Channel] + _, exists := m.channels[msg.Channel] + w, wExists := m.workers[msg.Channel] m.mu.RUnlock() if !exists { @@ -216,11 +287,12 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { continue } - if err := channel.Send(ctx, msg); err != nil { - logger.ErrorCF("channels", "Error sending message to channel", map[string]any{ - "channel": msg.Channel, - "error": err.Error(), - }) + if wExists { + select { + case w.queue <- msg: + case <-ctx.Done(): + return + } } } } @@ -262,17 +334,28 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { m.mu.Lock() defer m.mu.Unlock() m.channels[name] = channel + m.workers[name] = &channelWorker{ + ch: channel, + queue: make(chan bus.OutboundMessage, defaultChannelQueueSize), + done: make(chan struct{}), + } } func (m *Manager) UnregisterChannel(name string) { m.mu.Lock() defer m.mu.Unlock() + if w, ok := m.workers[name]; ok { + close(w.queue) + <-w.done + } + delete(m.workers, name) delete(m.channels, name) } func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { m.mu.RLock() - channel, exists := m.channels[channelName] + _, exists := m.channels[channelName] + w, wExists := m.workers[channelName] m.mu.RUnlock() if !exists { @@ -285,5 +368,16 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten Content: content, } + if wExists { + select { + case w.queue <- msg: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + + // Fallback: direct send (should not happen) + channel, _ := m.channels[channelName] return channel.Send(ctx, msg) } diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index b459a7140..fc0bee505 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -50,7 +50,7 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack socketClient := socketmode.New(api) - base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(40000)) return &SlackChannel{ BaseChannel: base, diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index af825ddc9..578e3c51e 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -76,7 +76,13 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann return nil, fmt.Errorf("failed to create telegram bot: %w", err) } - base := channels.NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom) + base := channels.NewBaseChannel( + "telegram", + telegramCfg, + bus, + telegramCfg.AllowFrom, + channels.WithMaxMessageLength(4096), + ) return &TelegramChannel{ BaseChannel: base, diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 873431d3c..eb1711d75 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -120,7 +120,7 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) ( return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required") } - base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048)) return &WeComAppChannel{ BaseChannel: base, diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 3a8a16c43..bbac8611a 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -87,7 +87,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We return nil, fmt.Errorf("wecom token and webhook_url are required") } - base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048)) return &WeComBotChannel{ BaseChannel: base, diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index cbc82fd09..b5f3e99d7 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -28,7 +28,7 @@ type WhatsAppChannel struct { } func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { - base := channels.NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) + base := channels.NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) return &WhatsAppChannel{ BaseChannel: base, diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 1d05950d9..52a967f4c 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -5,11 +5,20 @@ import ( ) // SplitMessage splits long messages into chunks, preserving code block integrity. +// The maxLen parameter is measured in runes (Unicode characters), not bytes. // The function reserves a buffer (10% of maxLen, min 50) to leave room for closing code blocks, // but may extend to maxLen when needed. // Call SplitMessage with the full text content and the maximum allowed length of a single message; // it returns a slice of message chunks that each respect maxLen and avoid splitting fenced code blocks. func SplitMessage(content string, maxLen int) []string { + if maxLen <= 0 { + if content == "" { + return nil + } + return []string{content} + } + + runes := []rune(content) var messages []string // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible @@ -21,9 +30,9 @@ func SplitMessage(content string, maxLen int) []string { codeBlockBuffer = maxLen / 2 } - for len(content) > 0 { - if len(content) <= maxLen { - messages = append(messages, content) + for len(runes) > 0 { + if len(runes) <= maxLen { + messages = append(messages, string(runes)) break } @@ -34,56 +43,66 @@ func SplitMessage(content string, maxLen int) []string { } // Find natural split point within the effective limit - msgEnd := findLastNewline(content[:effectiveLimit], 200) + msgEnd := findLastNewlineRunes(runes[:effectiveLimit], 200) if msgEnd <= 0 { - msgEnd = findLastSpace(content[:effectiveLimit], 100) + msgEnd = findLastSpaceRunes(runes[:effectiveLimit], 100) } if msgEnd <= 0 { msgEnd = effectiveLimit } // Check if this would end with an incomplete code block - candidate := content[:msgEnd] - unclosedIdx := findLastUnclosedCodeBlock(candidate) + candidate := runes[:msgEnd] + unclosedIdx := findLastUnclosedCodeBlockRunes(candidate) if unclosedIdx >= 0 { // Message would end with incomplete code block // Try to extend up to maxLen to include the closing ``` - if len(content) > msgEnd { - closingIdx := findNextClosingCodeBlock(content, msgEnd) + if len(runes) > msgEnd { + closingIdx := findNextClosingCodeBlockRunes(runes, msgEnd) if closingIdx > 0 && closingIdx <= maxLen { // Extend to include the closing ``` msgEnd = closingIdx } else { // Code block is too long to fit in one chunk or missing closing fence. // Try to split inside by injecting closing and reopening fences. - headerEnd := strings.Index(content[unclosedIdx:], "\n") + candidateStr := string(candidate) + unclosedStr := string(runes[unclosedIdx:]) + headerEnd := strings.Index(unclosedStr, "\n") + var header string if headerEnd == -1 { - headerEnd = unclosedIdx + 3 + header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3])) } else { - headerEnd += unclosedIdx + header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+headerEnd])) } - header := strings.TrimSpace(content[unclosedIdx:headerEnd]) + headerEndIdx := unclosedIdx + len([]rune(header)) + if headerEnd != -1 { + headerEndIdx = unclosedIdx + headerEnd + } + + _ = candidateStr // used above for context // If we have a reasonable amount of content after the header, split inside - if msgEnd > headerEnd+20 { + if msgEnd > headerEndIdx+20 { // Find a better split point closer to maxLen innerLimit := maxLen - 5 // Leave room for "\n```" - betterEnd := findLastNewline(content[:innerLimit], 200) - if betterEnd > headerEnd { + betterEnd := findLastNewlineRunes(runes[:innerLimit], 200) + if betterEnd > headerEndIdx { msgEnd = betterEnd } else { msgEnd = innerLimit } - messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```") - content = strings.TrimSpace(header + "\n" + content[msgEnd:]) + chunk := strings.TrimRight(string(runes[:msgEnd]), " \t\n\r") + "\n```" + messages = append(messages, chunk) + remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:])) + runes = []rune(remaining) continue } // Otherwise, try to split before the code block starts - newEnd := findLastNewline(content[:unclosedIdx], 200) + newEnd := findLastNewlineRunes(runes[:unclosedIdx], 200) if newEnd <= 0 { - newEnd = findLastSpace(content[:unclosedIdx], 100) + newEnd = findLastSpaceRunes(runes[:unclosedIdx], 100) } if newEnd > 0 { msgEnd = newEnd @@ -93,8 +112,10 @@ func SplitMessage(content string, maxLen int) []string { msgEnd = unclosedIdx } else { msgEnd = maxLen - 5 - messages = append(messages, strings.TrimRight(content[:msgEnd], " \t\n\r")+"\n```") - content = strings.TrimSpace(header + "\n" + content[msgEnd:]) + chunk := strings.TrimRight(string(runes[:msgEnd]), " \t\n\r") + "\n```" + messages = append(messages, chunk) + remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:])) + runes = []rune(remaining) continue } } @@ -106,21 +127,22 @@ func SplitMessage(content string, maxLen int) []string { msgEnd = effectiveLimit } - messages = append(messages, content[:msgEnd]) - content = strings.TrimSpace(content[msgEnd:]) + messages = append(messages, string(runes[:msgEnd])) + remaining := strings.TrimSpace(string(runes[msgEnd:])) + runes = []rune(remaining) } return messages } -// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ``` -// Returns the position of the opening ``` or -1 if all code blocks are complete -func findLastUnclosedCodeBlock(text string) int { +// findLastUnclosedCodeBlockRunes finds the last opening ``` that doesn't have a closing ``` +// Returns the rune position of the opening ``` or -1 if all code blocks are complete +func findLastUnclosedCodeBlockRunes(runes []rune) int { inCodeBlock := false lastOpenIdx := -1 - for i := 0; i < len(text); i++ { - if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { + for i := 0; i < len(runes); i++ { + if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { // Toggle code block state on each fence if !inCodeBlock { // Entering a code block: record this opening fence @@ -137,41 +159,41 @@ func findLastUnclosedCodeBlock(text string) int { return -1 } -// findNextClosingCodeBlock finds the next closing ``` starting from a position -// Returns the position after the closing ``` or -1 if not found -func findNextClosingCodeBlock(text string, startIdx int) int { - for i := startIdx; i < len(text); i++ { - if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' { +// findNextClosingCodeBlockRunes finds the next closing ``` starting from a rune position +// Returns the rune position after the closing ``` or -1 if not found +func findNextClosingCodeBlockRunes(runes []rune, startIdx int) int { + for i := startIdx; i < len(runes); i++ { + if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { return i + 3 } } return -1 } -// findLastNewline finds the last newline character within the last N characters -// Returns the position of the newline or -1 if not found -func findLastNewline(s string, searchWindow int) int { - searchStart := len(s) - searchWindow +// findLastNewlineRunes finds the last newline character within the last N runes +// Returns the rune position of the newline or -1 if not found +func findLastNewlineRunes(runes []rune, searchWindow int) int { + searchStart := len(runes) - searchWindow if searchStart < 0 { searchStart = 0 } - for i := len(s) - 1; i >= searchStart; i-- { - if s[i] == '\n' { + for i := len(runes) - 1; i >= searchStart; i-- { + if runes[i] == '\n' { return i } } return -1 } -// findLastSpace finds the last space character within the last N characters -// Returns the position of the space or -1 if not found -func findLastSpace(s string, searchWindow int) int { - searchStart := len(s) - searchWindow +// findLastSpaceRunes finds the last space character within the last N runes +// Returns the rune position of the space or -1 if not found +func findLastSpaceRunes(runes []rune, searchWindow int) int { + searchStart := len(runes) - searchWindow if searchStart < 0 { searchStart = 0 } - for i := len(s) - 1; i >= searchStart; i-- { - if s[i] == ' ' || s[i] == '\t' { + for i := len(runes) - 1; i >= searchStart; i-- { + if runes[i] == ' ' || runes[i] == '\t' { return i } } diff --git a/pkg/utils/message_test.go b/pkg/utils/message_test.go index 338509437..78e1e2b40 100644 --- a/pkg/utils/message_test.go +++ b/pkg/utils/message_test.go @@ -34,11 +34,15 @@ func TestSplitMessage(t *testing.T) { maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { - if len(chunks[0]) > 2000 { - t.Errorf("Chunk 0 too large: %d", len(chunks[0])) + if len([]rune(chunks[0])) > 2000 { + t.Errorf("Chunk 0 too large: %d runes", len([]rune(chunks[0]))) } - if len(chunks[0])+len(chunks[1]) != len(longText) { - t.Errorf("Total length mismatch. Got %d, want %d", len(chunks[0])+len(chunks[1]), len(longText)) + if len([]rune(chunks[0]))+len([]rune(chunks[1])) != len([]rune(longText)) { + t.Errorf( + "Total rune length mismatch. Got %d, want %d", + len([]rune(chunks[0]))+len([]rune(chunks[1])), + len([]rune(longText)), + ) } }, }, @@ -53,11 +57,11 @@ func TestSplitMessage(t *testing.T) { maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { - if len(chunks[0]) != 1750 { - t.Errorf("Expected chunk 0 to be 1750 length (split at newline), got %d", len(chunks[0])) + if len([]rune(chunks[0])) != 1750 { + t.Errorf("Expected chunk 0 to be 1750 runes (split at newline), got %d", len([]rune(chunks[0]))) } if chunks[1] != strings.Repeat("b", 300) { - t.Errorf("Chunk 1 content mismatch. Len: %d", len(chunks[1])) + t.Errorf("Chunk 1 content mismatch. Len: %d", len([]rune(chunks[1]))) } }, }, @@ -78,17 +82,39 @@ func TestSplitMessage(t *testing.T) { }, }, { - name: "Preserve Unicode characters", - content: strings.Repeat("\u4e16", 1000), // 3000 bytes + name: "Preserve Unicode characters (rune-aware)", + content: strings.Repeat("\u4e16", 2500), // 2500 runes, 7500 bytes maxLen: 2000, expectChunks: 2, checkContent: func(t *testing.T, chunks []string) { - // Just verify we didn't panic and got valid strings. - // Go strings are UTF-8, if we split mid-rune it would be bad, - // but standard slicing might do that. - // Let's assume standard behavior is acceptable or check if it produces invalid rune? - if !strings.Contains(chunks[0], "\u4e16") { - t.Error("Chunk should contain unicode characters") + // Verify chunks contain valid unicode and don't split mid-rune + for i, chunk := range chunks { + runeCount := len([]rune(chunk)) + if runeCount > 2000 { + t.Errorf("Chunk %d has %d runes, exceeds maxLen 2000", i, runeCount) + } + if !strings.Contains(chunk, "\u4e16") { + t.Errorf("Chunk %d should contain unicode characters", i) + } + } + // Verify total rune count is preserved + totalRunes := 0 + for _, chunk := range chunks { + totalRunes += len([]rune(chunk)) + } + if totalRunes != 2500 { + t.Errorf("Total rune count mismatch. Got %d, want 2500", totalRunes) + } + }, + }, + { + name: "Zero maxLen returns single chunk", + content: "Hello world", + maxLen: 0, + expectChunks: 1, + checkContent: func(t *testing.T, chunks []string) { + if chunks[0] != "Hello world" { + t.Errorf("Expected original content, got %q", chunks[0]) } }, }, @@ -145,7 +171,7 @@ func TestSplitMessage_CodeBlockIntegrity(t *testing.T) { } // First chunk should contain meaningful content - if len(chunks[0]) > 40 { - t.Errorf("First chunk exceeded maxLen: length %d", len(chunks[0])) + if len([]rune(chunks[0])) > 40 { + t.Errorf("First chunk exceeded maxLen: length %d runes", len([]rune(chunks[0]))) } } From 038fdf500043456f17ff4734bebf309d82fc0214 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sun, 22 Feb 2026 23:27:55 +0800 Subject: [PATCH 011/104] refactor(media): add MediaStore for unified media file lifecycle management Channels previously deleted downloaded media files via defer os.Remove, racing with the async Agent consumer. Introduce MediaStore to decouple file ownership: channels register files on download, Agent releases them after processing via ReleaseAll(scope). - New pkg/media with MediaStore interface + FileMediaStore implementation - InboundMessage gains MediaScope field for lifecycle tracking - BaseChannel gains SetMediaStore/GetMediaStore + BuildMediaScope helper - Manager injects MediaStore into channels; AgentLoop releases on completion - Telegram, Discord, Slack, OneBot, LINE channels migrated from defer os.Remove to store.Store() with media:// refs --- cmd/picoclaw/internal/gateway/helpers.go | 9 +- pkg/agent/loop.go | 65 +++++--- pkg/bus/types.go | 5 +- pkg/channels/base.go | 38 ++++- pkg/channels/discord/discord.go | 28 ++-- pkg/channels/line/line.go | 33 +++-- pkg/channels/manager.go | 19 ++- pkg/channels/onebot/onebot.go | 66 +++++---- pkg/channels/slack/slack.go | 28 ++-- pkg/channels/telegram/telegram.go | 39 ++--- pkg/media/store.go | 102 +++++++++++++ pkg/media/store_test.go | 179 +++++++++++++++++++++++ 12 files changed, 484 insertions(+), 127 deletions(-) create mode 100644 pkg/media/store.go create mode 100644 pkg/media/store_test.go diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index a73ad5e4b..ec5ad5485 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -32,6 +32,7 @@ import ( "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" @@ -116,13 +117,17 @@ func gatewayCmd(debug bool) error { return tools.SilentResult(response) }) - channelManager, err := channels.NewManager(cfg, msgBus) + // Create media store for file lifecycle management + mediaStore := media.NewFileMediaStore() + + channelManager, err := channels.NewManager(cfg, msgBus, mediaStore) if err != nil { return fmt.Errorf("error creating channel manager: %w", err) } - // Inject channel manager into agent loop for command handling + // Inject channel manager and media store into agent loop agentLoop.SetChannelManager(channelManager) + agentLoop.SetMediaStore(mediaStore) var transcriber *voice.GroqTranscriber groqAPIKey := cfg.Providers.Groq.APIKey diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 131f7eb4f..124f45675 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -21,6 +21,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/skills" @@ -38,6 +39,7 @@ type AgentLoop struct { summarizing sync.Map fallback *providers.FallbackChain channelManager *channels.Manager + mediaStore media.MediaStore } // processOptions configures how a message is processed @@ -165,33 +167,47 @@ func (al *AgentLoop) Run(ctx context.Context) error { continue } - response, err := al.processMessage(ctx, msg) - if err != nil { - response = fmt.Sprintf("Error processing message: %v", err) - } - - if response != "" { - // Check if the message tool already sent a response during this round. - // If so, skip publishing to avoid duplicate messages to the user. - // Use default agent's tools to check (message tool is shared). - alreadySent := false - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent != nil { - if tool, ok := defaultAgent.Tools.Get("message"); ok { - if mt, ok := tool.(*tools.MessageTool); ok { - alreadySent = mt.HasSentInRound() + // Process message and ensure media is released afterward + func() { + defer func() { + if al.mediaStore != nil && msg.MediaScope != "" { + if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { + logger.WarnCF("agent", "Failed to release media", map[string]any{ + "scope": msg.MediaScope, + "error": releaseErr.Error(), + }) } } + }() + + response, err := al.processMessage(ctx, msg) + if err != nil { + response = fmt.Sprintf("Error processing message: %v", err) } - if !alreadySent { - al.bus.PublishOutbound(bus.OutboundMessage{ - Channel: msg.Channel, - ChatID: msg.ChatID, - Content: response, - }) + if response != "" { + // Check if the message tool already sent a response during this round. + // If so, skip publishing to avoid duplicate messages to the user. + // Use default agent's tools to check (message tool is shared). + alreadySent := false + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent != nil { + if tool, ok := defaultAgent.Tools.Get("message"); ok { + if mt, ok := tool.(*tools.MessageTool); ok { + alreadySent = mt.HasSentInRound() + } + } + } + + if !alreadySent { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: response, + }) + } } - } + }() } } @@ -214,6 +230,11 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { al.channelManager = cm } +// SetMediaStore injects a MediaStore for media lifecycle management. +func (al *AgentLoop) SetMediaStore(s media.MediaStore) { + al.mediaStore = s +} + // RecordLastChannel records the last active channel for this workspace. // This uses the atomic state save mechanism to prevent data loss on crash. func (al *AgentLoop) RecordLastChannel(channel string) error { diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 081f13a0b..e49713eb8 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -12,8 +12,9 @@ type InboundMessage struct { ChatID string `json:"chat_id"` Content string `json:"content"` Media []string `json:"media,omitempty"` - Peer Peer `json:"peer"` // routing peer - MessageID string `json:"message_id,omitempty"` // platform message ID + Peer Peer `json:"peer"` // routing peer + MessageID string `json:"message_id,omitempty"` // platform message ID + MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope SessionKey string `json:"session_key"` Metadata map[string]string `json:"metadata,omitempty"` } diff --git a/pkg/channels/base.go b/pkg/channels/base.go index f70145981..d967d9e91 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -5,7 +5,10 @@ import ( "strings" "sync/atomic" + "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/media" ) type Channel interface { @@ -41,6 +44,7 @@ type BaseChannel struct { name string allowList []string maxMessageLength int + mediaStore media.MediaStore } func NewBaseChannel( @@ -125,15 +129,18 @@ func (c *BaseChannel) HandleMessage( return } + scope := BuildMediaScope(c.name, chatID, messageID) + msg := bus.InboundMessage{ - Channel: c.name, - SenderID: senderID, - ChatID: chatID, - Content: content, - Media: media, - Peer: peer, - MessageID: messageID, - Metadata: metadata, + Channel: c.name, + SenderID: senderID, + ChatID: chatID, + Content: content, + Media: media, + Peer: peer, + MessageID: messageID, + MediaScope: scope, + Metadata: metadata, } c.bus.PublishInbound(msg) @@ -142,3 +149,18 @@ func (c *BaseChannel) HandleMessage( func (c *BaseChannel) SetRunning(running bool) { c.running.Store(running) } + +// SetMediaStore injects a MediaStore into the channel. +func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s } + +// GetMediaStore returns the injected MediaStore (may be nil). +func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore } + +// BuildMediaScope constructs a scope key for media lifecycle tracking. +func BuildMediaScope(channel, chatID, messageID string) string { + id := messageID + if id == "" { + id = uuid.New().String() + } + return channel + ":" + chatID + ":" + id +} diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 623bc9f48..7977d32e1 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -3,7 +3,6 @@ package discord import ( "context" "fmt" - "os" "strings" "sync" "time" @@ -14,6 +13,7 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/voice" ) @@ -202,19 +202,22 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag content := m.Content content = c.stripBotMention(content) mediaPaths := make([]string, 0, len(m.Attachments)) - localFiles := make([]string, 0, len(m.Attachments)) - // Ensure temp files are cleaned up when function returns - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) + scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID) + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "discord", + }, scope) + if err == nil { + return ref } } - }() + return localPath // fallback + } for _, attachment := range m.Attachments { isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType) @@ -222,8 +225,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if isAudio { localPath := c.downloadAttachment(attachment.URL, attachment.Filename) if localPath != "" { - localFiles = append(localFiles, localPath) - transcribedText := "" if c.transcriber != nil && c.transcriber.IsAvailable() { ctx, cancel := context.WithTimeout(c.ctx, transcriptionTimeout) @@ -245,6 +246,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename) } + mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename)) content = appendContent(content, transcribedText) } else { logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{ diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 9744e1848..272a53c6e 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -10,7 +10,6 @@ import ( "fmt" "io" "net/http" - "os" "strings" "sync" "time" @@ -19,6 +18,7 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -308,18 +308,22 @@ func (c *LINEChannel) processEvent(event lineEvent) { var content string var mediaPaths []string - localFiles := []string{} - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) + scope := channels.BuildMediaScope("line", chatID, msg.ID) + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "line", + }, scope) + if err == nil { + return ref } } - }() + return localPath // fallback + } switch msg.Type { case "text": @@ -331,22 +335,19 @@ func (c *LINEChannel) processEvent(event lineEvent) { case "image": localPath := c.downloadContent(msg.ID, "image.jpg") if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg")) content = "[image]" } case "audio": localPath := c.downloadContent(msg.ID, "audio.m4a") if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a")) content = "[audio]" } case "video": localPath := c.downloadContent(msg.ID, "video.mp4") if localPath != "" { - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4")) content = "[video]" } case "file": diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 081d616da..37af01796 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -31,6 +32,7 @@ type Manager struct { workers map[string]*channelWorker bus *bus.MessageBus config *config.Config + mediaStore media.MediaStore dispatchTask *asyncTask mu sync.RWMutex } @@ -39,12 +41,13 @@ type asyncTask struct { cancel context.CancelFunc } -func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error) { +func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { m := &Manager{ - channels: make(map[string]Channel), - workers: make(map[string]*channelWorker), - bus: messageBus, - config: cfg, + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + bus: messageBus, + config: cfg, + mediaStore: store, } if err := m.initChannels(); err != nil { @@ -73,6 +76,12 @@ func (m *Manager) initChannel(name, displayName string) { "error": err.Error(), }) } else { + // Inject MediaStore if channel supports it + if m.mediaStore != nil { + if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok { + setter.SetMediaStore(m.mediaStore) + } + } m.channels[name] = ch m.workers[name] = &channelWorker{ ch: ch, diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 4f35888ca..e2fe541f1 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" "strconv" "strings" "sync" @@ -17,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/voice" ) @@ -575,11 +575,15 @@ type parseMessageResult struct { Text string IsBotMentioned bool Media []string - LocalFiles []string ReplyTo string } -func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult { +func (c *OneBotChannel) parseMessageSegments( + raw json.RawMessage, + selfID int64, + store media.MediaStore, + scope string, +) parseMessageResult { if len(raw) == 0 { return parseMessageResult{} } @@ -606,10 +610,23 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) var textParts []string mentioned := false selfIDStr := strconv.FormatInt(selfID, 10) - var media []string - var localFiles []string + var mediaRefs []string var replyTo string + // Helper to register a local file with the media store + storeFile := func(localPath, filename string) string { + if store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "onebot", + }, scope) + if err == nil { + return ref + } + } + return localPath // fallback + } + for _, seg := range segments { segType, _ := seg["type"].(string) data, _ := seg["data"].(map[string]any) @@ -645,8 +662,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) LoggerPrefix: "onebot", }) if localPath != "" { - media = append(media, localPath) - localFiles = append(localFiles, localPath) + mediaRefs = append(mediaRefs, storeFile(localPath, filename)) textParts = append(textParts, fmt.Sprintf("[%s]", segType)) } } @@ -660,7 +676,6 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) LoggerPrefix: "onebot", }) if localPath != "" { - localFiles = append(localFiles, localPath) if c.transcriber != nil && c.transcriber.IsAvailable() { tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second) result, err := c.transcriber.Transcribe(tctx, localPath) @@ -670,13 +685,15 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) "error": err.Error(), }) textParts = append(textParts, "[voice (transcription failed)]") - media = append(media, localPath) + mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr")) } else { textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text)) + // Still store the file so it can be released later + storeFile(localPath, "voice.amr") } } else { textParts = append(textParts, "[voice]") - media = append(media, localPath) + mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr")) } } } @@ -706,8 +723,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) return parseMessageResult{ Text: strings.TrimSpace(strings.Join(textParts, "")), IsBotMentioned: mentioned, - Media: media, - LocalFiles: localFiles, + Media: mediaRefs, ReplyTo: replyTo, } } @@ -799,7 +815,17 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { selfID = atomic.LoadInt64(&c.selfID) } - parsed := c.parseMessageSegments(raw.Message, selfID) + // Compute scope for media store before parsing (parsing may download files) + var chatIDForScope string + switch raw.MessageType { + case "group": + chatIDForScope = "group:" + strconv.FormatInt(groupID, 10) + default: + chatIDForScope = "private:" + strconv.FormatInt(userID, 10) + } + scope := channels.BuildMediaScope("onebot", chatIDForScope, messageID) + + parsed := c.parseMessageSegments(raw.Message, selfID, c.GetMediaStore(), scope) isBotMentioned := parsed.IsBotMentioned content := raw.RawMessage @@ -828,20 +854,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { } } - // Clean up temp files when done - if len(parsed.LocalFiles) > 0 { - defer func() { - for _, f := range parsed.LocalFiles { - if err := os.Remove(f); err != nil { - logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{ - "path": f, - "error": err.Error(), - }) - } - } - }() - } - if c.isDuplicate(messageID) { logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{ "message_id": messageID, diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index fc0bee505..53d7c0609 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -3,7 +3,6 @@ package slack import ( "context" "fmt" - "os" "strings" "sync" "time" @@ -16,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/voice" ) @@ -233,19 +233,22 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { content = c.stripBotMention(content) var mediaPaths []string - localFiles := []string{} // 跟踪需要清理的本地文件 - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) + scope := channels.BuildMediaScope("slack", chatID, messageTS) + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "slack", + }, scope) + if err == nil { + return ref } } - }() + return localPath // fallback + } if ev.Message != nil && len(ev.Message.Files) > 0 { for _, file := range ev.Message.Files { @@ -253,8 +256,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { if localPath == "" { continue } - localFiles = append(localFiles, localPath) - mediaPaths = append(mediaPaths, localPath) + mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name)) if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() { ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 578e3c51e..af7155799 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -20,6 +20,7 @@ import ( "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/voice" ) @@ -251,19 +252,24 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content := "" mediaPaths := []string{} - localFiles := []string{} // 跟踪需要清理的本地文件 - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) + chatIDStr := fmt.Sprintf("%d", chatID) + messageIDStr := fmt.Sprintf("%d", message.MessageID) + scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr) + + // Helper to register a local file with the media store + storeMedia := func(localPath, filename string) string { + if store := c.GetMediaStore(); store != nil { + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "telegram", + }, scope) + if err == nil { + return ref } } - }() + return localPath // fallback: use raw path + } if message.Text != "" { content += message.Text @@ -280,8 +286,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes photo := message.Photo[len(message.Photo)-1] photoPath := c.downloadPhoto(ctx, photo.FileID) if photoPath != "" { - localFiles = append(localFiles, photoPath) - mediaPaths = append(mediaPaths, photoPath) + mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg")) if content != "" { content += "\n" } @@ -292,8 +297,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Voice != nil { voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") if voicePath != "" { - localFiles = append(localFiles, voicePath) - mediaPaths = append(mediaPaths, voicePath) + mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg")) transcribedText := "" if c.transcriber != nil && c.transcriber.IsAvailable() { @@ -327,8 +331,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Audio != nil { audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") if audioPath != "" { - localFiles = append(localFiles, audioPath) - mediaPaths = append(mediaPaths, audioPath) + mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3")) if content != "" { content += "\n" } @@ -339,8 +342,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Document != nil { docPath := c.downloadFile(ctx, message.Document.FileID, "") if docPath != "" { - localFiles = append(localFiles, docPath) - mediaPaths = append(mediaPaths, docPath) + mediaPaths = append(mediaPaths, storeMedia(docPath, "document")) if content != "" { content += "\n" } @@ -367,7 +369,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes } // Stop any previous thinking animation - chatIDStr := fmt.Sprintf("%d", chatID) if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { cf.Cancel() diff --git a/pkg/media/store.go b/pkg/media/store.go new file mode 100644 index 000000000..8d03c03ef --- /dev/null +++ b/pkg/media/store.go @@ -0,0 +1,102 @@ +package media + +import ( + "fmt" + "os" + "sync" + + "github.com/google/uuid" +) + +// MediaMeta holds metadata about a stored media file. +type MediaMeta struct { + Filename string + ContentType string + Source string // "telegram", "discord", "tool:image-gen", etc. +} + +// MediaStore manages the lifecycle of media files associated with processing scopes. +type MediaStore interface { + // Store registers an existing local file under the given scope. + // Returns a ref identifier (e.g. "media://"). + // Store does not move or copy the file; it only records the mapping. + Store(localPath string, meta MediaMeta, scope string) (ref string, err error) + + // Resolve returns the local file path for a given ref. + Resolve(ref string) (localPath string, err error) + + // ReleaseAll deletes all files registered under the given scope + // and removes the mapping entries. File-not-exist errors are ignored. + ReleaseAll(scope string) error +} + +// FileMediaStore is a pure in-memory implementation of MediaStore. +// Files are expected to already exist on disk (e.g. in /tmp/picoclaw_media/). +type FileMediaStore struct { + mu sync.RWMutex + refToPath map[string]string + scopeToRefs map[string]map[string]struct{} +} + +// NewFileMediaStore creates a new FileMediaStore. +func NewFileMediaStore() *FileMediaStore { + return &FileMediaStore{ + refToPath: make(map[string]string), + scopeToRefs: make(map[string]map[string]struct{}), + } +} + +// Store registers a local file under the given scope. The file must exist. +func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (string, error) { + if _, err := os.Stat(localPath); err != nil { + return "", fmt.Errorf("media store: file does not exist: %s", localPath) + } + + ref := "media://" + uuid.New().String()[:8] + + s.mu.Lock() + defer s.mu.Unlock() + + s.refToPath[ref] = localPath + if s.scopeToRefs[scope] == nil { + s.scopeToRefs[scope] = make(map[string]struct{}) + } + s.scopeToRefs[scope][ref] = struct{}{} + + return ref, nil +} + +// Resolve returns the local path for the given ref. +func (s *FileMediaStore) Resolve(ref string) (string, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + path, ok := s.refToPath[ref] + if !ok { + return "", fmt.Errorf("media store: unknown ref: %s", ref) + } + return path, nil +} + +// ReleaseAll removes all files under the given scope and cleans up mappings. +func (s *FileMediaStore) ReleaseAll(scope string) error { + s.mu.Lock() + defer s.mu.Unlock() + + refs, ok := s.scopeToRefs[scope] + if !ok { + return nil + } + + for ref := range refs { + if path, exists := s.refToPath[ref]; exists { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + // Log but continue — best effort cleanup + } + delete(s.refToPath, ref) + } + } + + delete(s.scopeToRefs, scope) + return nil +} diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go new file mode 100644 index 000000000..361582307 --- /dev/null +++ b/pkg/media/store_test.go @@ -0,0 +1,179 @@ +package media + +import ( + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func createTempFile(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("test content"), 0o644); err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + return path +} + +func TestStoreAndResolve(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + path := createTempFile(t, dir, "photo.jpg") + + ref, err := store.Store(path, MediaMeta{Filename: "photo.jpg", Source: "telegram"}, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + if !strings.HasPrefix(ref, "media://") { + t.Errorf("ref should start with media://, got %q", ref) + } + + resolved, err := store.Resolve(ref) + if err != nil { + t.Fatalf("Resolve failed: %v", err) + } + if resolved != path { + t.Errorf("Resolve returned %q, want %q", resolved, path) + } +} + +func TestReleaseAll(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + paths := make([]string, 3) + refs := make([]string, 3) + for i := 0; i < 3; i++ { + paths[i] = createTempFile(t, dir, strings.Repeat("a", i+1)+".jpg") + var err error + refs[i], err = store.Store(paths[i], MediaMeta{Source: "test"}, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + } + + if err := store.ReleaseAll("scope1"); err != nil { + t.Fatalf("ReleaseAll failed: %v", err) + } + + // Files should be deleted + for _, p := range paths { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("file %q should have been deleted", p) + } + } + + // Refs should be unresolvable + for _, ref := range refs { + if _, err := store.Resolve(ref); err == nil { + t.Errorf("Resolve(%q) should fail after ReleaseAll", ref) + } + } +} + +func TestMultiScopeIsolation(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + pathA := createTempFile(t, dir, "fileA.jpg") + pathB := createTempFile(t, dir, "fileB.jpg") + + refA, _ := store.Store(pathA, MediaMeta{Source: "test"}, "scopeA") + refB, _ := store.Store(pathB, MediaMeta{Source: "test"}, "scopeB") + + // Release only scopeA + if err := store.ReleaseAll("scopeA"); err != nil { + t.Fatalf("ReleaseAll(scopeA) failed: %v", err) + } + + // scopeA file should be gone + if _, err := os.Stat(pathA); !os.IsNotExist(err) { + t.Error("file A should have been deleted") + } + if _, err := store.Resolve(refA); err == nil { + t.Error("refA should be unresolvable after release") + } + + // scopeB file should still exist + if _, err := os.Stat(pathB); err != nil { + t.Error("file B should still exist") + } + resolved, err := store.Resolve(refB) + if err != nil { + t.Fatalf("refB should still resolve: %v", err) + } + if resolved != pathB { + t.Errorf("resolved %q, want %q", resolved, pathB) + } +} + +func TestReleaseAllIdempotent(t *testing.T) { + store := NewFileMediaStore() + + // ReleaseAll on non-existent scope should not error + if err := store.ReleaseAll("nonexistent"); err != nil { + t.Fatalf("ReleaseAll on empty scope should not error: %v", err) + } + + // Create and release, then release again + dir := t.TempDir() + path := createTempFile(t, dir, "file.jpg") + _, _ = store.Store(path, MediaMeta{Source: "test"}, "scope1") + + if err := store.ReleaseAll("scope1"); err != nil { + t.Fatalf("first ReleaseAll failed: %v", err) + } + if err := store.ReleaseAll("scope1"); err != nil { + t.Fatalf("second ReleaseAll should not error: %v", err) + } +} + +func TestStoreNonexistentFile(t *testing.T) { + store := NewFileMediaStore() + + _, err := store.Store("/nonexistent/path/file.jpg", MediaMeta{Source: "test"}, "scope1") + if err == nil { + t.Error("Store should fail for nonexistent file") + } +} + +func TestConcurrentSafety(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + const goroutines = 20 + const filesPerGoroutine = 5 + + var wg sync.WaitGroup + wg.Add(goroutines) + + for g := 0; g < goroutines; g++ { + go func(gIdx int) { + defer wg.Done() + scope := strings.Repeat("s", gIdx+1) + + for i := 0; i < filesPerGoroutine; i++ { + path := createTempFile(t, dir, strings.Repeat("f", gIdx*filesPerGoroutine+i+1)+".tmp") + ref, err := store.Store(path, MediaMeta{Source: "test"}, scope) + if err != nil { + t.Errorf("Store failed: %v", err) + return + } + + if _, err := store.Resolve(ref); err != nil { + t.Errorf("Resolve failed: %v", err) + } + } + + if err := store.ReleaseAll(scope); err != nil { + t.Errorf("ReleaseAll failed: %v", err) + } + }(g) + } + + wg.Wait() +} From 38a26d702cde8971a1e1b3b08084a57634e8eb75 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sun, 22 Feb 2026 23:51:55 +0800 Subject: [PATCH 012/104] refactor(channels): add per-channel rate limiting and send retry with error classification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define sentinel error types (ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed) so the Manager can classify Send failures and choose the right retry strategy: permanent errors bail immediately, rate-limit errors use a fixed 1s delay, and temporary/unknown errors use exponential backoff (500ms→1s→2s, capped at 8s, up to 3 retries). A per-channel token-bucket rate limiter (golang.org/x/time/rate) throttles outbound sends before they hit the platform API. --- go.mod | 1 + go.sum | 2 + pkg/channels/errors.go | 21 ++ pkg/channels/errors_test.go | 56 +++++ pkg/channels/manager.go | 127 +++++++++-- pkg/channels/manager_test.go | 418 +++++++++++++++++++++++++++++++++++ 6 files changed, 601 insertions(+), 24 deletions(-) create mode 100644 pkg/channels/errors.go create mode 100644 pkg/channels/errors_test.go create mode 100644 pkg/channels/manager_test.go diff --git a/go.mod b/go.mod index 98e20d07d..2d7624cf7 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/time v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index abbb11cd6..bd5165d7e 100644 --- a/go.sum +++ b/go.sum @@ -236,6 +236,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= diff --git a/pkg/channels/errors.go b/pkg/channels/errors.go new file mode 100644 index 000000000..09ee88b3f --- /dev/null +++ b/pkg/channels/errors.go @@ -0,0 +1,21 @@ +package channels + +import "errors" + +var ( + // ErrNotRunning indicates the channel is not running. + // Manager will not retry. + ErrNotRunning = errors.New("channel not running") + + // ErrRateLimit indicates the platform returned a rate-limit response (e.g. HTTP 429). + // Manager will wait a fixed delay and retry. + ErrRateLimit = errors.New("rate limited") + + // ErrTemporary indicates a transient failure (e.g. network timeout, 5xx). + // Manager will use exponential backoff and retry. + ErrTemporary = errors.New("temporary failure") + + // ErrSendFailed indicates a permanent failure (e.g. invalid chat ID, 4xx non-429). + // Manager will not retry. + ErrSendFailed = errors.New("send failed") +) diff --git a/pkg/channels/errors_test.go b/pkg/channels/errors_test.go new file mode 100644 index 000000000..e5592345a --- /dev/null +++ b/pkg/channels/errors_test.go @@ -0,0 +1,56 @@ +package channels + +import ( + "errors" + "fmt" + "testing" +) + +func TestErrorsIs(t *testing.T) { + wrapped := fmt.Errorf("telegram API: %w", ErrRateLimit) + if !errors.Is(wrapped, ErrRateLimit) { + t.Error("wrapped ErrRateLimit should match") + } + if errors.Is(wrapped, ErrTemporary) { + t.Error("wrapped ErrRateLimit should not match ErrTemporary") + } +} + +func TestErrorsIsAllTypes(t *testing.T) { + sentinels := []error{ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed} + + for _, sentinel := range sentinels { + wrapped := fmt.Errorf("context: %w", sentinel) + if !errors.Is(wrapped, sentinel) { + t.Errorf("wrapped %v should match itself", sentinel) + } + + // Verify it doesn't match other sentinel errors + for _, other := range sentinels { + if other == sentinel { + continue + } + if errors.Is(wrapped, other) { + t.Errorf("wrapped %v should not match %v", sentinel, other) + } + } + } +} + +func TestErrorMessages(t *testing.T) { + tests := []struct { + err error + want string + }{ + {ErrNotRunning, "channel not running"}, + {ErrRateLimit, "rate limited"}, + {ErrTemporary, "temporary failure"}, + {ErrSendFailed, "send failed"}, + } + + for _, tt := range tests { + if got := tt.err.Error(); got != tt.want { + t.Errorf("error message = %q, want %q", got, tt.want) + } + } +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 37af01796..1bc321cec 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -8,8 +8,13 @@ package channels import ( "context" + "errors" "fmt" + "math" "sync" + "time" + + "golang.org/x/time/rate" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" @@ -19,12 +24,28 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) -const defaultChannelQueueSize = 100 +const ( + defaultChannelQueueSize = 100 + defaultRateLimit = 10 // default 10 msg/s + maxRetries = 3 + rateLimitDelay = 1 * time.Second + baseBackoff = 500 * time.Millisecond + maxBackoff = 8 * time.Second +) + +// channelRateConfig maps channel name to per-second rate limit. +var channelRateConfig = map[string]float64{ + "telegram": 20, + "discord": 1, + "slack": 1, + "line": 10, +} type channelWorker struct { - ch Channel - queue chan bus.OutboundMessage - done chan struct{} + ch Channel + queue chan bus.OutboundMessage + done chan struct{} + limiter *rate.Limiter } type Manager struct { @@ -83,11 +104,7 @@ func (m *Manager) initChannel(name, displayName string) { } } m.channels[name] = ch - m.workers[name] = &channelWorker{ - ch: ch, - queue: make(chan bus.OutboundMessage, defaultChannelQueueSize), - done: make(chan struct{}), - } + m.workers[name] = newChannelWorker(name, ch) logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ "channel": displayName, }) @@ -227,6 +244,23 @@ func (m *Manager) StopAll(ctx context.Context) error { return nil } +// newChannelWorker creates a channelWorker with a rate limiter configured +// for the given channel name. +func newChannelWorker(name string, ch Channel) *channelWorker { + rateVal := float64(defaultRateLimit) + if r, ok := channelRateConfig[name]; ok { + rateVal = r + } + burst := int(math.Max(1, math.Ceil(rateVal/2))) + + return &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, defaultChannelQueueSize), + done: make(chan struct{}), + limiter: rate.NewLimiter(rate.Limit(rateVal), burst), + } +} + // runWorker processes outbound messages for a single channel, splitting // messages that exceed the channel's maximum message length. func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) { @@ -246,18 +280,10 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) for _, chunk := range chunks { chunkMsg := msg chunkMsg.Content = chunk - if err := w.ch.Send(ctx, chunkMsg); err != nil { - logger.ErrorCF("channels", "Error sending chunk", map[string]any{ - "channel": name, "error": err.Error(), - }) - } + m.sendWithRetry(ctx, name, w, chunkMsg) } } else { - if err := w.ch.Send(ctx, msg); err != nil { - logger.ErrorCF("channels", "Error sending message", map[string]any{ - "channel": name, "error": err.Error(), - }) - } + m.sendWithRetry(ctx, name, w, msg) } case <-ctx.Done(): return @@ -265,6 +291,63 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) } } +// sendWithRetry sends a message through the channel with rate limiting and +// retry logic. It classifies errors to determine the retry strategy: +// - ErrNotRunning / ErrSendFailed: permanent, no retry +// - ErrRateLimit: fixed delay retry +// - ErrTemporary / unknown: exponential backoff retry +func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) { + // Rate limit: wait for token + if err := w.limiter.Wait(ctx); err != nil { + // ctx cancelled, shutting down + return + } + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + lastErr = w.ch.Send(ctx, msg) + if lastErr == nil { + return + } + + // Permanent failures — don't retry + if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) { + break + } + + // Last attempt exhausted — don't sleep + if attempt == maxRetries { + break + } + + // Rate limit error — fixed delay + if errors.Is(lastErr, ErrRateLimit) { + select { + case <-time.After(rateLimitDelay): + continue + case <-ctx.Done(): + return + } + } + + // ErrTemporary or unknown error — exponential backoff + backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + } + + // All retries exhausted or permanent failure + logger.ErrorCF("channels", "Send failed", map[string]any{ + "channel": name, + "chat_id": msg.ChatID, + "error": lastErr.Error(), + "retries": maxRetries, + }) +} + func (m *Manager) dispatchOutbound(ctx context.Context) { logger.InfoC("channels", "Outbound dispatcher started") @@ -343,11 +426,7 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { m.mu.Lock() defer m.mu.Unlock() m.channels[name] = channel - m.workers[name] = &channelWorker{ - ch: channel, - queue: make(chan bus.OutboundMessage, defaultChannelQueueSize), - done: make(chan struct{}), - } + m.workers[name] = newChannelWorker(name, channel) } func (m *Manager) UnregisterChannel(name string) { diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go new file mode 100644 index 000000000..162c9f8c9 --- /dev/null +++ b/pkg/channels/manager_test.go @@ -0,0 +1,418 @@ +package channels + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/time/rate" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +// mockChannel is a test double that delegates Send to a configurable function. +type mockChannel struct { + BaseChannel + sendFn func(ctx context.Context, msg bus.OutboundMessage) error +} + +func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + return m.sendFn(ctx, msg) +} + +func (m *mockChannel) Start(ctx context.Context) error { return nil } +func (m *mockChannel) Stop(ctx context.Context) error { return nil } + +// newTestManager creates a minimal Manager suitable for unit tests. +func newTestManager() *Manager { + return &Manager{ + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + } +} + +func TestSendWithRetry_Success(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 1 { + t.Fatalf("expected 1 Send call, got %d", callCount) + } +} + +func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + if callCount <= 2 { + return fmt.Errorf("network error: %w", ErrTemporary) + } + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 3 { + t.Fatalf("expected 3 Send calls (2 failures + 1 success), got %d", callCount) + } +} + +func TestSendWithRetry_PermanentFailure(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return fmt.Errorf("bad chat ID: %w", ErrSendFailed) + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 1 { + t.Fatalf("expected 1 Send call (no retry for permanent failure), got %d", callCount) + } +} + +func TestSendWithRetry_NotRunning(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return ErrNotRunning + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 1 { + t.Fatalf("expected 1 Send call (no retry for ErrNotRunning), got %d", callCount) + } +} + +func TestSendWithRetry_RateLimitRetry(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + if callCount == 1 { + return fmt.Errorf("429: %w", ErrRateLimit) + } + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + start := time.Now() + m.sendWithRetry(ctx, "test", w, msg) + elapsed := time.Since(start) + + if callCount != 2 { + t.Fatalf("expected 2 Send calls (1 rate limit + 1 success), got %d", callCount) + } + // Should have waited at least rateLimitDelay (1s) but allow some slack + if elapsed < 900*time.Millisecond { + t.Fatalf("expected at least ~1s delay for rate limit retry, got %v", elapsed) + } +} + +func TestSendWithRetry_MaxRetriesExhausted(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return fmt.Errorf("timeout: %w", ErrTemporary) + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + expected := maxRetries + 1 // initial attempt + maxRetries retries + if callCount != expected { + t.Fatalf("expected %d Send calls, got %d", expected, callCount) + } +} + +func TestSendWithRetry_UnknownError(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + if callCount == 1 { + return errors.New("random unexpected error") + } + return nil + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + m.sendWithRetry(ctx, "test", w, msg) + + if callCount != 2 { + t.Fatalf("expected 2 Send calls (unknown error treated as temporary), got %d", callCount) + } +} + +func TestSendWithRetry_ContextCancelled(t *testing.T) { + m := newTestManager() + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + return fmt.Errorf("timeout: %w", ErrTemporary) + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + // Cancel context after first Send attempt returns + ch.sendFn = func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + cancel() + return fmt.Errorf("timeout: %w", ErrTemporary) + } + + m.sendWithRetry(ctx, "test", w, msg) + + // Should have called Send once, then noticed ctx cancelled during backoff + if callCount != 1 { + t.Fatalf("expected 1 Send call before context cancellation, got %d", callCount) + } +} + +func TestWorkerRateLimiter(t *testing.T) { + m := newTestManager() + + var mu sync.Mutex + var sendTimes []time.Time + + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + mu.Lock() + sendTimes = append(sendTimes, time.Now()) + mu.Unlock() + return nil + }, + } + + // Create a worker with a low rate: 2 msg/s, burst 1 + w := &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, 10), + done: make(chan struct{}), + limiter: rate.NewLimiter(2, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go m.runWorker(ctx, "test", w) + + // Enqueue 4 messages + for i := 0; i < 4; i++ { + w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: fmt.Sprintf("msg%d", i)} + } + + // Wait enough time for all messages to be sent (4 msgs at 2/s = ~2s, give extra margin) + time.Sleep(3 * time.Second) + + mu.Lock() + times := make([]time.Time, len(sendTimes)) + copy(times, sendTimes) + mu.Unlock() + + if len(times) != 4 { + t.Fatalf("expected 4 sends, got %d", len(times)) + } + + // Verify rate limiting: total duration should be at least 1s + // (first message immediate, then ~500ms between each subsequent one at 2/s) + totalDuration := times[len(times)-1].Sub(times[0]) + if totalDuration < 1*time.Second { + t.Fatalf("expected total duration >= 1s for 4 msgs at 2/s rate, got %v", totalDuration) + } +} + +func TestNewChannelWorker_DefaultRate(t *testing.T) { + ch := &mockChannel{} + w := newChannelWorker("unknown_channel", ch) + + if w.limiter == nil { + t.Fatal("expected limiter to be non-nil") + } + if w.limiter.Limit() != rate.Limit(defaultRateLimit) { + t.Fatalf("expected rate limit %v, got %v", rate.Limit(defaultRateLimit), w.limiter.Limit()) + } +} + +func TestNewChannelWorker_ConfiguredRate(t *testing.T) { + ch := &mockChannel{} + + for name, expectedRate := range channelRateConfig { + w := newChannelWorker(name, ch) + if w.limiter.Limit() != rate.Limit(expectedRate) { + t.Fatalf("channel %s: expected rate %v, got %v", name, expectedRate, w.limiter.Limit()) + } + } +} + +func TestRunWorker_MessageSplitting(t *testing.T) { + m := newTestManager() + + var mu sync.Mutex + var received []string + + ch := &mockChannelWithLength{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + mu.Lock() + received = append(received, msg.Content) + mu.Unlock() + return nil + }, + }, + maxLen: 5, + } + + w := &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, 10), + done: make(chan struct{}), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go m.runWorker(ctx, "test", w) + + // Send a message that should be split + w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello world"} + + time.Sleep(100 * time.Millisecond) + + mu.Lock() + count := len(received) + mu.Unlock() + + if count < 2 { + t.Fatalf("expected message to be split into at least 2 chunks, got %d", count) + } +} + +// mockChannelWithLength implements MessageLengthProvider. +type mockChannelWithLength struct { + mockChannel + maxLen int +} + +func (m *mockChannelWithLength) MaxMessageLength() int { + return m.maxLen +} + +func TestSendWithRetry_ExponentialBackoff(t *testing.T) { + m := newTestManager() + + var callTimes []time.Time + var callCount atomic.Int32 + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callTimes = append(callTimes, time.Now()) + callCount.Add(1) + return fmt.Errorf("timeout: %w", ErrTemporary) + }, + } + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx := context.Background() + msg := bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "hello"} + + start := time.Now() + m.sendWithRetry(ctx, "test", w, msg) + totalElapsed := time.Since(start) + + // With maxRetries=3: attempts at 0, ~500ms, ~1.5s, ~3.5s + // Total backoff: 500ms + 1s + 2s = 3.5s + // Allow some margin + if totalElapsed < 3*time.Second { + t.Fatalf("expected total elapsed >= 3s for exponential backoff, got %v", totalElapsed) + } + + if int(callCount.Load()) != maxRetries+1 { + t.Fatalf("expected %d calls, got %d", maxRetries+1, callCount.Load()) + } +} From afc7a1988f0af7479f3bc1625e4e3e24a360ea67 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 00:44:45 +0800 Subject: [PATCH 013/104] refactor(bus): fix deadlock and concurrency issues in MessageBus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PublishInbound/PublishOutbound held RLock during blocking channel sends, deadlocking against Close() which needs a write lock when the buffer is full. ConsumeInbound/SubscribeOutbound used bare receives instead of comma-ok, causing zero-value processing or busy loops after close. Replace sync.RWMutex+bool with atomic.Bool+done channel so Publish methods use a lock-free 3-way select (send / done / ctx.Done). Add context.Context parameter to both Publish methods so callers can cancel or timeout blocked sends. Close() now only sets the atomic flag and closes the done channel—never closes the data channels—eliminating send-on-closed-channel panics. - Remove dead code: RegisterHandler, GetHandler, handlers map, MessageHandler type (zero callers across the whole repo) - Add ErrBusClosed sentinel error - Update all 10 caller sites to pass context - Add msgBus.Close() to gateway and agent shutdown flows - Add pkg/bus/bus_test.go with 11 test cases covering basic round-trip, context cancellation, closed-bus behavior, concurrent publish+close, full-buffer timeout, and idempotent Close --- cmd/picoclaw/internal/agent/helpers.go | 1 + cmd/picoclaw/internal/gateway/helpers.go | 1 + pkg/agent/loop.go | 10 +- pkg/bus/bus.go | 81 ++++---- pkg/bus/bus_test.go | 229 +++++++++++++++++++++++ pkg/bus/types.go | 2 - pkg/channels/base.go | 2 +- pkg/devices/service.go | 2 +- pkg/heartbeat/service.go | 3 +- pkg/tools/cron.go | 4 +- pkg/tools/subagent.go | 2 +- 11 files changed, 283 insertions(+), 54 deletions(-) create mode 100644 pkg/bus/bus_test.go diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index 746e9755e..f754abc65 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -48,6 +48,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error { } msgBus := bus.NewMessageBus() + defer msgBus.Close() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) // Print agent startup info (only for interactive mode) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index ec5ad5485..e3a51b5e9 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -223,6 +223,7 @@ func gatewayCmd(debug bool) error { cp.Close() } cancel() + msgBus.Close() healthServer.Stop(context.Background()) deviceService.Stop() heartbeatService.Stop() diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 124f45675..ebbeec0c1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -121,7 +121,7 @@ func registerSharedTools( // Message tool messageTool := tools.NewMessageTool() messageTool.SetSendCallback(func(channel, chatID, content string) error { - msgBus.PublishOutbound(bus.OutboundMessage{ + msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: content, @@ -200,7 +200,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { } if !alreadySent { - al.bus.PublishOutbound(bus.OutboundMessage{ + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: msg.Channel, ChatID: msg.ChatID, Content: response, @@ -469,7 +469,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 8. Optional: send response via bus if opts.SendResponse { - al.bus.PublishOutbound(bus.OutboundMessage{ + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: finalContent, @@ -586,7 +586,7 @@ func (al *AgentLoop) runLLMIteration( }) if retry == 0 && !constants.IsInternalChannel(opts.Channel) { - al.bus.PublishOutbound(bus.OutboundMessage{ + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: "Context window exceeded. Compressing history and retrying...", @@ -716,7 +716,7 @@ func (al *AgentLoop) runLLMIteration( // Send ForUser content to user immediately if not Silent if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { - al.bus.PublishOutbound(bus.OutboundMessage{ + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ Channel: opts.Channel, ChatID: opts.ChatID, Content: toolResult.ForUser, diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 58c0a25d5..100ddc456 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -2,81 +2,80 @@ package bus import ( "context" - "sync" + "errors" + "sync/atomic" ) +// ErrBusClosed is returned when publishing to a closed MessageBus. +var ErrBusClosed = errors.New("message bus closed") + type MessageBus struct { inbound chan InboundMessage outbound chan OutboundMessage - handlers map[string]MessageHandler - closed bool - mu sync.RWMutex + done chan struct{} + closed atomic.Bool } func NewMessageBus() *MessageBus { return &MessageBus{ inbound: make(chan InboundMessage, 100), outbound: make(chan OutboundMessage, 100), - handlers: make(map[string]MessageHandler), + done: make(chan struct{}), } } -func (mb *MessageBus) PublishInbound(msg InboundMessage) { - mb.mu.RLock() - defer mb.mu.RUnlock() - if mb.closed { - return +func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { + if mb.closed.Load() { + return ErrBusClosed + } + select { + case mb.inbound <- msg: + return nil + case <-mb.done: + return ErrBusClosed + case <-ctx.Done(): + return ctx.Err() } - mb.inbound <- msg } func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) { select { - case msg := <-mb.inbound: - return msg, true + case msg, ok := <-mb.inbound: + return msg, ok + case <-mb.done: + return InboundMessage{}, false case <-ctx.Done(): return InboundMessage{}, false } } -func (mb *MessageBus) PublishOutbound(msg OutboundMessage) { - mb.mu.RLock() - defer mb.mu.RUnlock() - if mb.closed { - return +func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { + if mb.closed.Load() { + return ErrBusClosed + } + select { + case mb.outbound <- msg: + return nil + case <-mb.done: + return ErrBusClosed + case <-ctx.Done(): + return ctx.Err() } - mb.outbound <- msg } func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) { select { - case msg := <-mb.outbound: - return msg, true + case msg, ok := <-mb.outbound: + return msg, ok + case <-mb.done: + return OutboundMessage{}, false case <-ctx.Done(): return OutboundMessage{}, false } } -func (mb *MessageBus) RegisterHandler(channel string, handler MessageHandler) { - mb.mu.Lock() - defer mb.mu.Unlock() - mb.handlers[channel] = handler -} - -func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) { - mb.mu.RLock() - defer mb.mu.RUnlock() - handler, ok := mb.handlers[channel] - return handler, ok -} - func (mb *MessageBus) Close() { - mb.mu.Lock() - defer mb.mu.Unlock() - if mb.closed { - return + if mb.closed.CompareAndSwap(false, true) { + close(mb.done) } - mb.closed = true - close(mb.inbound) - close(mb.outbound) } diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go new file mode 100644 index 000000000..47826824e --- /dev/null +++ b/pkg/bus/bus_test.go @@ -0,0 +1,229 @@ +package bus + +import ( + "context" + "sync" + "testing" + "time" +) + +func TestPublishConsume(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctx := context.Background() + + msg := InboundMessage{ + Channel: "test", + SenderID: "user1", + ChatID: "chat1", + Content: "hello", + } + + if err := mb.PublishInbound(ctx, msg); err != nil { + t.Fatalf("PublishInbound failed: %v", err) + } + + got, ok := mb.ConsumeInbound(ctx) + if !ok { + t.Fatal("ConsumeInbound returned ok=false") + } + if got.Content != "hello" { + t.Fatalf("expected content 'hello', got %q", got.Content) + } + if got.Channel != "test" { + t.Fatalf("expected channel 'test', got %q", got.Channel) + } +} + +func TestPublishOutboundSubscribe(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctx := context.Background() + + msg := OutboundMessage{ + Channel: "telegram", + ChatID: "123", + Content: "world", + } + + if err := mb.PublishOutbound(ctx, msg); err != nil { + t.Fatalf("PublishOutbound failed: %v", err) + } + + got, ok := mb.SubscribeOutbound(ctx) + if !ok { + t.Fatal("SubscribeOutbound returned ok=false") + } + if got.Content != "world" { + t.Fatalf("expected content 'world', got %q", got.Content) + } +} + +func TestPublishInbound_ContextCancel(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + // Fill the buffer + ctx := context.Background() + for i := 0; i < 100; i++ { + if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { + t.Fatalf("fill failed at %d: %v", i, err) + } + } + + // Now buffer is full; publish with a cancelled context + cancelCtx, cancel := context.WithCancel(context.Background()) + cancel() + + err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"}) + if err == nil { + t.Fatal("expected error from cancelled context, got nil") + } + if err != context.Canceled { + t.Fatalf("expected context.Canceled, got %v", err) + } +} + +func TestPublishInbound_BusClosed(t *testing.T) { + mb := NewMessageBus() + mb.Close() + + err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) + if err != ErrBusClosed { + t.Fatalf("expected ErrBusClosed, got %v", err) + } +} + +func TestPublishOutbound_BusClosed(t *testing.T) { + mb := NewMessageBus() + mb.Close() + + err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"}) + if err != ErrBusClosed { + t.Fatalf("expected ErrBusClosed, got %v", err) + } +} + +func TestConsumeInbound_ContextCancel(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, ok := mb.ConsumeInbound(ctx) + if ok { + t.Fatal("expected ok=false when context is cancelled") + } +} + +func TestConsumeInbound_BusClosed(t *testing.T) { + mb := NewMessageBus() + mb.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, ok := mb.ConsumeInbound(ctx) + if ok { + t.Fatal("expected ok=false when bus is closed") + } +} + +func TestSubscribeOutbound_BusClosed(t *testing.T) { + mb := NewMessageBus() + mb.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, ok := mb.SubscribeOutbound(ctx) + if ok { + t.Fatal("expected ok=false when bus is closed") + } +} + +func TestConcurrentPublishClose(t *testing.T) { + mb := NewMessageBus() + ctx := context.Background() + + const numGoroutines = 100 + var wg sync.WaitGroup + wg.Add(numGoroutines + 1) + + // Spawn many goroutines trying to publish + for i := 0; i < numGoroutines; i++ { + go func() { + defer wg.Done() + // Use a short timeout context so we don't block forever after close + publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond) + defer cancel() + // Errors are expected; we just must not panic or deadlock + _ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"}) + }() + } + + // Close from another goroutine + go func() { + defer wg.Done() + time.Sleep(5 * time.Millisecond) + mb.Close() + }() + + // Must complete without deadlock + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + // success + case <-time.After(5 * time.Second): + t.Fatal("test timed out - possible deadlock") + } +} + +func TestPublishInbound_FullBuffer(t *testing.T) { + mb := NewMessageBus() + defer mb.Close() + + ctx := context.Background() + + // Fill the buffer + for i := 0; i < 100; i++ { + if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { + t.Fatalf("fill failed at %d: %v", i, err) + } + } + + // Buffer is full; publish with short timeout + timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"}) + if err == nil { + t.Fatal("expected error when buffer is full and context times out") + } + if err != context.DeadlineExceeded { + t.Fatalf("expected context.DeadlineExceeded, got %v", err) + } +} + +func TestCloseIdempotent(t *testing.T) { + mb := NewMessageBus() + + // Multiple Close calls must not panic + mb.Close() + mb.Close() + mb.Close() + + // After close, publish should return ErrBusClosed + err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"}) + if err != ErrBusClosed { + t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err) + } +} diff --git a/pkg/bus/types.go b/pkg/bus/types.go index e49713eb8..358829c55 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -24,5 +24,3 @@ type OutboundMessage struct { ChatID string `json:"chat_id"` Content string `json:"content"` } - -type MessageHandler func(InboundMessage) error diff --git a/pkg/channels/base.go b/pkg/channels/base.go index d967d9e91..adacb8c78 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -143,7 +143,7 @@ func (c *BaseChannel) HandleMessage( Metadata: metadata, } - c.bus.PublishInbound(msg) + c.bus.PublishInbound(context.TODO(), msg) } func (c *BaseChannel) SetRunning(running bool) { diff --git a/pkg/devices/service.go b/pkg/devices/service.go index 1541d3c57..408e1c8aa 100644 --- a/pkg/devices/service.go +++ b/pkg/devices/service.go @@ -127,7 +127,7 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) { } msg := ev.FormatMessage() - msgBus.PublishOutbound(bus.OutboundMessage{ + msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ Channel: platform, ChatID: userID, Content: msg, diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index e05a9fdbf..3e58dbc7a 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -7,6 +7,7 @@ package heartbeat import ( + "context" "fmt" "os" "path/filepath" @@ -307,7 +308,7 @@ func (hs *HeartbeatService) sendResponse(response string) { return } - msgBus.PublishOutbound(bus.OutboundMessage{ + msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ Channel: platform, ChatID: userID, Content: response, diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 562fffc84..3c13f5968 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -294,7 +294,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM) } - t.msgBus.PublishOutbound(bus.OutboundMessage{ + t.msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: output, @@ -304,7 +304,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // If deliver=true, send message directly without agent processing if job.Payload.Deliver { - t.msgBus.PublishOutbound(bus.OutboundMessage{ + t.msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: job.Payload.Message, diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index ad371a649..081a02872 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -218,7 +218,7 @@ After completing the task, provide a clear summary of what was done.` // Send announce message back to main agent if sm.bus != nil { announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result) - sm.bus.PublishInbound(bus.InboundMessage{ + sm.bus.PublishInbound(context.TODO(), bus.InboundMessage{ Channel: "system", SenderID: fmt.Sprintf("subagent:%s", task.ID), // Format: "original_channel:original_chat_id" for routing back From d72c9c1ee608157ba64bb500e2bd7f58c7ec6776 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 01:45:48 +0800 Subject: [PATCH 014/104] refactor(channels): standardize Send error classification with sentinel types All 12 channel Send methods now return proper sentinel errors (ErrNotRunning, ErrTemporary, ErrRateLimit, ErrSendFailed) instead of plain fmt.Errorf strings, enabling Manager's sendWithRetry classification logic to actually work. - Add ClassifySendError/ClassifyNetError helpers in errutil.go for HTTP-based channels - LINE/WeCom Bot/WeCom App: use ClassifySendError for HTTP status-based classification - SDK channels (Telegram/Discord/Slack/QQ/DingTalk/Feishu): wrap errors as ErrTemporary - WebSocket channels (OneBot/WhatsApp/MaixCam): wrap write errors as ErrTemporary - WhatsApp: add missing IsRunning() check in Send - WhatsApp/OneBot/MaixCam: add ctx.Done() check before entering write path - Telegram Stop: clean up placeholders sync.Map to prevent state leaks --- pkg/channels/dingtalk/dingtalk.go | 4 +- pkg/channels/discord/discord.go | 6 +- pkg/channels/errutil.go | 30 ++++++++++ pkg/channels/errutil_test.go | 97 +++++++++++++++++++++++++++++++ pkg/channels/feishu/feishu_64.go | 6 +- pkg/channels/line/line.go | 6 +- pkg/channels/maixcam/maixcam.go | 11 +++- pkg/channels/onebot/onebot.go | 11 +++- pkg/channels/qq/qq.go | 4 +- pkg/channels/slack/slack.go | 4 +- pkg/channels/telegram/telegram.go | 15 +++-- pkg/channels/wecom/app.go | 16 ++++- pkg/channels/wecom/bot.go | 9 ++- pkg/channels/whatsapp/whatsapp.go | 15 ++++- 14 files changed, 204 insertions(+), 30 deletions(-) create mode 100644 pkg/channels/errutil.go create mode 100644 pkg/channels/errutil_test.go diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index e051add1f..c49769761 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -96,7 +96,7 @@ func (c *DingTalkChannel) Stop(ctx context.Context) error { // Send sends a message to DingTalk via the chatbot reply API func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("dingtalk channel not running") + return channels.ErrNotRunning } // Get session webhook from storage @@ -197,7 +197,7 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c contentBytes, ) if err != nil { - return fmt.Errorf("failed to send reply: %w", err) + return fmt.Errorf("dingtalk send: %w", channels.ErrTemporary) } return nil diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 7977d32e1..d5524f7f9 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -113,7 +113,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro c.stopTyping(msg.ChatID) if !c.IsRunning() { - return fmt.Errorf("discord bot not running") + return channels.ErrNotRunning } channelID := msg.ChatID @@ -142,11 +142,11 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin select { case err := <-done: if err != nil { - return fmt.Errorf("failed to send discord message: %w", err) + return fmt.Errorf("discord send: %w", channels.ErrTemporary) } return nil case <-sendCtx.Done(): - return fmt.Errorf("send message timeout: %w", sendCtx.Err()) + return sendCtx.Err() } } diff --git a/pkg/channels/errutil.go b/pkg/channels/errutil.go new file mode 100644 index 000000000..319e3c980 --- /dev/null +++ b/pkg/channels/errutil.go @@ -0,0 +1,30 @@ +package channels + +import ( + "fmt" + "net/http" +) + +// ClassifySendError wraps a raw error with the appropriate sentinel based on +// an HTTP status code. Channels that perform HTTP API calls should use this +// in their Send path. +func ClassifySendError(statusCode int, rawErr error) error { + switch { + case statusCode == http.StatusTooManyRequests: + return fmt.Errorf("%w: %v", ErrRateLimit, rawErr) + case statusCode >= 500: + return fmt.Errorf("%w: %v", ErrTemporary, rawErr) + case statusCode >= 400: + return fmt.Errorf("%w: %v", ErrSendFailed, rawErr) + default: + return rawErr + } +} + +// ClassifyNetError wraps a network/timeout error as ErrTemporary. +func ClassifyNetError(err error) error { + if err == nil { + return nil + } + return fmt.Errorf("%w: %v", ErrTemporary, err) +} diff --git a/pkg/channels/errutil_test.go b/pkg/channels/errutil_test.go new file mode 100644 index 000000000..e3d35f65b --- /dev/null +++ b/pkg/channels/errutil_test.go @@ -0,0 +1,97 @@ +package channels + +import ( + "errors" + "fmt" + "testing" +) + +func TestClassifySendError(t *testing.T) { + raw := fmt.Errorf("some API error") + + tests := []struct { + name string + statusCode int + wantIs error + wantNil bool + }{ + {"429 -> ErrRateLimit", 429, ErrRateLimit, false}, + {"500 -> ErrTemporary", 500, ErrTemporary, false}, + {"502 -> ErrTemporary", 502, ErrTemporary, false}, + {"503 -> ErrTemporary", 503, ErrTemporary, false}, + {"400 -> ErrSendFailed", 400, ErrSendFailed, false}, + {"403 -> ErrSendFailed", 403, ErrSendFailed, false}, + {"404 -> ErrSendFailed", 404, ErrSendFailed, false}, + {"200 -> raw error", 200, nil, false}, + {"201 -> raw error", 201, nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ClassifySendError(tt.statusCode, raw) + if err == nil { + t.Fatal("expected non-nil error") + } + if tt.wantIs != nil { + if !errors.Is(err, tt.wantIs) { + t.Errorf("errors.Is(err, %v) = false, want true; err = %v", tt.wantIs, err) + } + } else { + // Should return the raw error unchanged + if err != raw { + t.Errorf("expected raw error to be returned unchanged for status %d, got %v", tt.statusCode, err) + } + } + }) + } +} + +func TestClassifySendErrorNoFalsePositive(t *testing.T) { + raw := fmt.Errorf("some error") + + // 429 should NOT match ErrTemporary or ErrSendFailed + err := ClassifySendError(429, raw) + if errors.Is(err, ErrTemporary) { + t.Error("429 should not match ErrTemporary") + } + if errors.Is(err, ErrSendFailed) { + t.Error("429 should not match ErrSendFailed") + } + + // 500 should NOT match ErrRateLimit or ErrSendFailed + err = ClassifySendError(500, raw) + if errors.Is(err, ErrRateLimit) { + t.Error("500 should not match ErrRateLimit") + } + if errors.Is(err, ErrSendFailed) { + t.Error("500 should not match ErrSendFailed") + } + + // 400 should NOT match ErrRateLimit or ErrTemporary + err = ClassifySendError(400, raw) + if errors.Is(err, ErrRateLimit) { + t.Error("400 should not match ErrRateLimit") + } + if errors.Is(err, ErrTemporary) { + t.Error("400 should not match ErrTemporary") + } +} + +func TestClassifyNetError(t *testing.T) { + t.Run("nil error returns nil", func(t *testing.T) { + if err := ClassifyNetError(nil); err != nil { + t.Errorf("expected nil, got %v", err) + } + }) + + t.Run("non-nil error wraps as ErrTemporary", func(t *testing.T) { + raw := fmt.Errorf("connection refused") + err := ClassifyNetError(raw) + if err == nil { + t.Fatal("expected non-nil error") + } + if !errors.Is(err, ErrTemporary) { + t.Errorf("errors.Is(err, ErrTemporary) = false, want true; err = %v", err) + } + }) +} diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index d67823974..5245cd99d 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -91,7 +91,7 @@ func (c *FeishuChannel) Stop(ctx context.Context) error { func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("feishu channel not running") + return channels.ErrNotRunning } if msg.ChatID == "" { @@ -115,11 +115,11 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error resp, err := c.client.Im.V1.Message.Create(ctx, req) if err != nil { - return fmt.Errorf("failed to send feishu message: %w", err) + return fmt.Errorf("feishu send: %w", channels.ErrTemporary) } if !resp.Success() { - return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg) + 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{ diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 272a53c6e..fd06334d5 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -491,7 +491,7 @@ func (c *LINEChannel) resolveChatID(source lineSource) string { // using a cached reply token, then falls back to the Push API. func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("line channel not running") + return channels.ErrNotRunning } // Load and consume quote token for this chat @@ -582,13 +582,13 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("API request failed: %w", err) + return channels.ClassifyNetError(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody)) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody))) } return nil diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index 05213b095..b5b7259f9 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -215,7 +215,14 @@ func (c *MaixCamChannel) Stop(ctx context.Context) error { func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("maixcam channel not running") + return channels.ErrNotRunning + } + + // Check ctx before entering write path + select { + case <-ctx.Done(): + return ctx.Err() + default: } c.clientsMux.RLock() @@ -246,7 +253,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro "client": conn.RemoteAddr().String(), "error": err.Error(), }) - sendErr = err + sendErr = fmt.Errorf("maixcam send: %w", channels.ErrTemporary) } _ = conn.SetWriteDeadline(time.Time{}) } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index e2fe541f1..76950663e 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -373,7 +373,14 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("OneBot channel not running") + return channels.ErrNotRunning + } + + // Check ctx before entering write path + select { + case <-ctx.Done(): + return ctx.Err() + default: } c.mu.Lock() @@ -412,7 +419,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error logger.ErrorCF("onebot", "Failed to send message", map[string]any{ "error": err.Error(), }) - return err + return fmt.Errorf("onebot send: %w", channels.ErrTemporary) } if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok { diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 429e23cbf..69f323e6e 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -114,7 +114,7 @@ func (c *QQChannel) Stop(ctx context.Context) error { func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("QQ bot not running") + return channels.ErrNotRunning } // 构造消息 @@ -128,7 +128,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ "error": err.Error(), }) - return err + return fmt.Errorf("qq send: %w", channels.ErrTemporary) } return nil diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 53d7c0609..9e066e00a 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -112,7 +112,7 @@ func (c *SlackChannel) Stop(ctx context.Context) error { func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("slack channel not running") + return channels.ErrNotRunning } channelID, threadTS := parseSlackChatID(msg.ChatID) @@ -130,7 +130,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error _, _, err := c.api.PostMessageContext(ctx, channelID, opts...) if err != nil { - return fmt.Errorf("failed to send slack message: %w", err) + return fmt.Errorf("slack send: %w", channels.ErrTemporary) } if ref, ok := c.pendingAcks.LoadAndDelete(msg.ChatID); ok { diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index af7155799..a07eb6579 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -164,6 +164,12 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { return true }) + // Clean up placeholder state + c.placeholders.Range(func(key, value any) bool { + c.placeholders.Delete(key) + return true + }) + // Stop the bot handler if c.bh != nil { c.bh.Stop() @@ -179,12 +185,12 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("telegram bot not running") + return channels.ErrNotRunning } chatID, err := parseChatID(msg.ChatID) if err != nil { - return fmt.Errorf("invalid chat ID: %w", err) + return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } // Stop thinking animation @@ -217,8 +223,9 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err "error": err.Error(), }) tgMsg.ParseMode = "" - _, err = c.bot.SendMessage(ctx, tgMsg) - return err + if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil { + return fmt.Errorf("telegram send: %w", channels.ErrTemporary) + } } return nil diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index eb1711d75..41861e8fc 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -207,7 +207,7 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error { // Send sends a message to WeCom user proactively using access token func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("wecom_app channel not running") + return channels.ErrNotRunning } accessToken := c.getAccessToken() @@ -548,10 +548,15 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user client := &http.Client{Timeout: time.Duration(timeout) * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to send message: %w", err) + return channels.ClassifyNetError(err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body))) + } + body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) @@ -603,10 +608,15 @@ func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, client := &http.Client{Timeout: time.Duration(timeout) * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to send message: %w", err) + return channels.ClassifyNetError(err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body))) + } + body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index bbac8611a..7960802fb 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -166,7 +166,7 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error { // For delayed responses, we use the webhook URL func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { - return fmt.Errorf("wecom channel not running") + return channels.ErrNotRunning } logger.DebugCF("wecom", "Sending message via webhook", map[string]any{ @@ -433,10 +433,15 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content client := &http.Client{Timeout: time.Duration(timeout) * time.Second} resp, err := client.Do(req) if err != nil { - return fmt.Errorf("failed to send webhook reply: %w", err) + return channels.ClassifyNetError(err) } defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("webhook API error: %s", string(body))) + } + body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response: %w", err) diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index b5f3e99d7..97032334f 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -94,11 +94,22 @@ func (c *WhatsAppChannel) Stop(ctx context.Context) error { } func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + // Check ctx before acquiring lock + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + c.mu.Lock() defer c.mu.Unlock() if c.conn == nil { - return fmt.Errorf("whatsapp connection not established") + return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } payload := map[string]any{ @@ -115,7 +126,7 @@ func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err _ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { _ = c.conn.SetWriteDeadline(time.Time{}) - return fmt.Errorf("failed to send message: %w", err) + return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } _ = c.conn.SetWriteDeadline(time.Time{}) From 65a09208c46db62dfda6825e1ca137b4a5ec2e56 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 02:39:09 +0800 Subject: [PATCH 015/104] refactor(channels): consolidate HTTP servers into shared server managed by Manager Merge 3 independent channel HTTP servers (LINE :18791, WeCom Bot :18793, WeCom App :18792) and the health server (:18790) into a single shared HTTP server on the Gateway address. Channels implement WebhookHandler and/or HealthChecker interfaces to register their handlers on the shared mux. Also change Gateway default host from 0.0.0.0 to 127.0.0.1 for security. --- cmd/picoclaw/internal/gateway/helpers.go | 17 +++--- pkg/channels/line/line.go | 53 ++++++------------ pkg/channels/manager.go | 69 +++++++++++++++++++++++- pkg/channels/webhook.go | 20 +++++++ pkg/channels/wecom/app.go | 63 +++++++++------------- pkg/channels/wecom/bot.go | 63 +++++++++------------- pkg/health/server.go | 7 +++ 7 files changed, 167 insertions(+), 125 deletions(-) create mode 100644 pkg/channels/webhook.go diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index e3a51b5e9..5ebf26d78 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -2,9 +2,7 @@ package gateway import ( "context" - "errors" "fmt" - "net/http" "os" "os/signal" "path/filepath" @@ -200,16 +198,16 @@ func gatewayCmd(debug bool) error { fmt.Println("✓ Device event service started") } + // Setup shared HTTP server with health endpoints and webhook handlers + healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) + channelManager.SetupHTTPServer(addr, healthServer) + if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) } - healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) - go func() { - if err := healthServer.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()}) - } - }() + fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) go agentLoop.Run(ctx) @@ -224,12 +222,11 @@ func gatewayCmd(debug bool) error { } cancel() msgBus.Close() - healthServer.Stop(context.Background()) + channelManager.StopAll(ctx) deviceService.Stop() heartbeatService.Stop() cronService.Stop() agentLoop.Stop() - channelManager.StopAll(ctx) fmt.Println("✓ Gateway stopped") return nil diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index fd06334d5..6ae048468 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -44,7 +44,6 @@ type replyTokenEntry struct { type LINEChannel struct { *channels.BaseChannel config config.LINEConfig - httpServer *http.Server botUserID string // Bot's user ID botBasicID string // Bot's basic ID (e.g. @216ru...) botDisplayName string // Bot's display name for text-based mention detection @@ -68,7 +67,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha }, nil } -// Start launches the HTTP webhook server. +// Start initializes the LINE channel. func (c *LINEChannel) Start(ctx context.Context) error { logger.InfoC("line", "Starting LINE channel (Webhook Mode)") @@ -87,31 +86,6 @@ func (c *LINEChannel) Start(ctx context.Context) error { }) } - mux := http.NewServeMux() - path := c.config.WebhookPath - if path == "" { - path = "/webhook/line" - } - mux.HandleFunc(path, c.webhookHandler) - - addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) - c.httpServer = &http.Server{ - Addr: addr, - Handler: mux, - } - - go func() { - logger.InfoCF("line", "LINE webhook server listening", map[string]any{ - "addr": addr, - "path": path, - }) - if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("line", "Webhook server error", map[string]any{ - "error": err.Error(), - }) - } - }() - c.SetRunning(true) logger.InfoC("line", "LINE channel started (Webhook Mode)") return nil @@ -151,7 +125,7 @@ func (c *LINEChannel) fetchBotInfo() error { return nil } -// Stop gracefully shuts down the HTTP server. +// Stop gracefully stops the LINE channel. func (c *LINEChannel) Stop(ctx context.Context) error { logger.InfoC("line", "Stopping LINE channel") @@ -159,21 +133,24 @@ func (c *LINEChannel) Stop(ctx context.Context) error { c.cancel() } - if c.httpServer != nil { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - if err := c.httpServer.Shutdown(shutdownCtx); err != nil { - logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{ - "error": err.Error(), - }) - } - } - c.SetRunning(false) logger.InfoC("line", "LINE channel stopped") return nil } +// WebhookPath returns the path for registering on the shared HTTP server. +func (c *LINEChannel) WebhookPath() string { + if c.config.WebhookPath != "" { + return c.config.WebhookPath + } + return "/webhook/line" +} + +// ServeHTTP implements http.Handler for the shared HTTP server. +func (c *LINEChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.webhookHandler(w, r) +} + // webhookHandler handles incoming LINE webhook requests. func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 1bc321cec..dadc068e9 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "math" + "net/http" "sync" "time" @@ -19,6 +20,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" @@ -55,6 +57,8 @@ type Manager struct { config *config.Config mediaStore media.MediaStore dispatchTask *asyncTask + mux *http.ServeMux + httpServer *http.Server mu sync.RWMutex } @@ -169,6 +173,43 @@ func (m *Manager) initChannels() error { return nil } +// SetupHTTPServer creates a shared HTTP server with the given listen address. +// It registers health endpoints from the health server and discovers channels +// that implement WebhookHandler and/or HealthChecker to register their handlers. +func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { + m.mux = http.NewServeMux() + + // Register health endpoints + if healthServer != nil { + healthServer.RegisterOnMux(m.mux) + } + + // Discover and register webhook handlers and health checkers + for name, ch := range m.channels { + if wh, ok := ch.(WebhookHandler); ok { + m.mux.Handle(wh.WebhookPath(), wh) + logger.InfoCF("channels", "Webhook handler registered", map[string]any{ + "channel": name, + "path": wh.WebhookPath(), + }) + } + if hc, ok := ch.(HealthChecker); ok { + m.mux.HandleFunc(hc.HealthPath(), hc.HealthHandler) + logger.InfoCF("channels", "Health endpoint registered", map[string]any{ + "channel": name, + "path": hc.HealthPath(), + }) + } + } + + m.httpServer = &http.Server{ + Addr: addr, + Handler: m.mux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } +} + func (m *Manager) StartAll(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() @@ -203,6 +244,20 @@ func (m *Manager) StartAll(ctx context.Context) error { // Start the dispatcher that reads from the bus and routes to workers go m.dispatchOutbound(dispatchCtx) + // Start shared HTTP server if configured + if m.httpServer != nil { + go func() { + logger.InfoCF("channels", "Shared HTTP server listening", map[string]any{ + "addr": m.httpServer.Addr, + }) + if err := m.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("channels", "Shared HTTP server error", map[string]any{ + "error": err.Error(), + }) + } + }() + } + logger.InfoC("channels", "All channels started") return nil } @@ -213,7 +268,19 @@ func (m *Manager) StopAll(ctx context.Context) error { logger.InfoC("channels", "Stopping all channels") - // Cancel dispatcher first + // Shutdown shared HTTP server first + if m.httpServer != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := m.httpServer.Shutdown(shutdownCtx); err != nil { + logger.ErrorCF("channels", "Shared HTTP server shutdown error", map[string]any{ + "error": err.Error(), + }) + } + m.httpServer = nil + } + + // Cancel dispatcher if m.dispatchTask != nil { m.dispatchTask.cancel() m.dispatchTask = nil diff --git a/pkg/channels/webhook.go b/pkg/channels/webhook.go new file mode 100644 index 000000000..3cf27baf6 --- /dev/null +++ b/pkg/channels/webhook.go @@ -0,0 +1,20 @@ +package channels + +import "net/http" + +// WebhookHandler is an optional interface for channels that receive messages +// via HTTP webhooks. Manager discovers channels implementing this interface +// and registers them on the shared HTTP server. +type WebhookHandler interface { + // WebhookPath returns the path to mount this handler on the shared server. + // Examples: "/webhook/line", "/webhook/wecom" + WebhookPath() string + http.Handler // ServeHTTP(w http.ResponseWriter, r *http.Request) +} + +// HealthChecker is an optional interface for channels that expose +// a health check endpoint on the shared HTTP server. +type HealthChecker interface { + HealthPath() string + HealthHandler(w http.ResponseWriter, r *http.Request) +} diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 41861e8fc..52750505c 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -28,7 +28,6 @@ const ( type WeComAppChannel struct { *channels.BaseChannel config config.WeComAppConfig - server *http.Server accessToken string tokenExpiry time.Time tokenMu sync.RWMutex @@ -134,7 +133,7 @@ func (c *WeComAppChannel) Name() string { return "wecom_app" } -// Start initializes the WeCom App channel with HTTP webhook server +// Start initializes the WeCom App channel func (c *WeComAppChannel) Start(ctx context.Context) error { logger.InfoC("wecom_app", "Starting WeCom App channel...") @@ -150,37 +149,8 @@ func (c *WeComAppChannel) Start(ctx context.Context) error { // Start token refresh goroutine go c.tokenRefreshLoop() - // Setup HTTP server for webhook - mux := http.NewServeMux() - webhookPath := c.config.WebhookPath - if webhookPath == "" { - webhookPath = "/webhook/wecom-app" - } - mux.HandleFunc(webhookPath, c.handleWebhook) - - // Health check endpoint - mux.HandleFunc("/health/wecom-app", c.handleHealth) - - addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) - c.server = &http.Server{ - Addr: addr, - Handler: mux, - } - c.SetRunning(true) - logger.InfoCF("wecom_app", "WeCom App channel started", map[string]any{ - "address": addr, - "path": webhookPath, - }) - - // Start server in goroutine - go func() { - if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom_app", "HTTP server error", map[string]any{ - "error": err.Error(), - }) - } - }() + logger.InfoC("wecom_app", "WeCom App channel started") return nil } @@ -193,12 +163,6 @@ func (c *WeComAppChannel) Stop(ctx context.Context) error { c.cancel() } - if c.server != nil { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - c.server.Shutdown(shutdownCtx) - } - c.SetRunning(false) logger.InfoC("wecom_app", "WeCom App channel stopped") return nil @@ -223,6 +187,29 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) } +// WebhookPath returns the path for registering on the shared HTTP server. +func (c *WeComAppChannel) WebhookPath() string { + if c.config.WebhookPath != "" { + return c.config.WebhookPath + } + return "/webhook/wecom-app" +} + +// ServeHTTP implements http.Handler for the shared HTTP server. +func (c *WeComAppChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.handleWebhook(w, r) +} + +// HealthPath returns the health check endpoint path. +func (c *WeComAppChannel) HealthPath() string { + return "/health/wecom-app" +} + +// HealthHandler handles health check requests. +func (c *WeComAppChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { + c.handleHealth(w, r) +} + // handleWebhook handles incoming webhook requests from WeCom func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 7960802fb..d5912bddc 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -24,7 +24,6 @@ import ( type WeComBotChannel struct { *channels.BaseChannel config config.WeComConfig - server *http.Server ctx context.Context cancel context.CancelFunc processedMsgs map[string]bool // Message deduplication: msg_id -> processed @@ -101,43 +100,14 @@ func (c *WeComBotChannel) Name() string { return "wecom" } -// Start initializes the WeCom Bot channel with HTTP webhook server +// Start initializes the WeCom Bot channel func (c *WeComBotChannel) Start(ctx context.Context) error { logger.InfoC("wecom", "Starting WeCom Bot channel...") c.ctx, c.cancel = context.WithCancel(ctx) - // Setup HTTP server for webhook - mux := http.NewServeMux() - webhookPath := c.config.WebhookPath - if webhookPath == "" { - webhookPath = "/webhook/wecom" - } - mux.HandleFunc(webhookPath, c.handleWebhook) - - // Health check endpoint - mux.HandleFunc("/health/wecom", c.handleHealth) - - addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort) - c.server = &http.Server{ - Addr: addr, - Handler: mux, - } - c.SetRunning(true) - logger.InfoCF("wecom", "WeCom Bot channel started", map[string]any{ - "address": addr, - "path": webhookPath, - }) - - // Start server in goroutine - go func() { - if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.ErrorCF("wecom", "HTTP server error", map[string]any{ - "error": err.Error(), - }) - } - }() + logger.InfoC("wecom", "WeCom Bot channel started") return nil } @@ -150,12 +120,6 @@ func (c *WeComBotChannel) Stop(ctx context.Context) error { c.cancel() } - if c.server != nil { - shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - c.server.Shutdown(shutdownCtx) - } - c.SetRunning(false) logger.InfoC("wecom", "WeCom Bot channel stopped") return nil @@ -177,6 +141,29 @@ func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return c.sendWebhookReply(ctx, msg.ChatID, msg.Content) } +// WebhookPath returns the path for registering on the shared HTTP server. +func (c *WeComBotChannel) WebhookPath() string { + if c.config.WebhookPath != "" { + return c.config.WebhookPath + } + return "/webhook/wecom" +} + +// ServeHTTP implements http.Handler for the shared HTTP server. +func (c *WeComBotChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + c.handleWebhook(w, r) +} + +// HealthPath returns the health check endpoint path. +func (c *WeComBotChannel) HealthPath() string { + return "/health/wecom" +} + +// HealthHandler handles health check requests. +func (c *WeComBotChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { + c.handleHealth(w, r) +} + // handleWebhook handles incoming webhook requests from WeCom func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/pkg/health/server.go b/pkg/health/server.go index 77b36034d..de1ff60fe 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -156,6 +156,13 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { }) } +// RegisterOnMux registers /health and /ready handlers onto the given mux. +// This allows the health endpoints to be served by a shared HTTP server. +func (s *Server) RegisterOnMux(mux *http.ServeMux) { + mux.HandleFunc("/health", s.healthHandler) + mux.HandleFunc("/ready", s.readyHandler) +} + func statusString(ok bool) string { if ok { return "ok" From e10b1e1fd4b5f6a96d3555c60c8d0e4e8f39dba8 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 03:10:57 +0800 Subject: [PATCH 016/104] feat(channels): add MediaSender optional interface for outbound media Add outbound media sending capability so the agent can publish media attachments (images, files, audio, video) through channels via the bus. - Add MediaPart and OutboundMediaMessage types to bus - Add PublishOutboundMedia/SubscribeOutboundMedia bus methods - Add MediaSender interface discovered via type assertion by Manager - Add media dispatch/worker in Manager with shared retry logic - Extend ToolResult with Media field and MediaResult constructor - Publish outbound media from agent loop on tool results - Implement SendMedia for Telegram, Discord, Slack, LINE, OneBot, WeCom --- pkg/agent/loop.go | 13 ++ pkg/bus/bus.go | 41 +++++-- pkg/bus/types.go | 16 +++ pkg/channels/discord/discord.go | 98 +++++++++++++++ pkg/channels/line/line.go | 30 +++++ pkg/channels/manager.go | 150 +++++++++++++++++++++-- pkg/channels/media.go | 15 +++ pkg/channels/onebot/onebot.go | 111 +++++++++++++++++ pkg/channels/slack/slack.go | 54 +++++++++ pkg/channels/telegram/telegram.go | 85 +++++++++++++ pkg/channels/wecom/app.go | 194 ++++++++++++++++++++++++++++++ pkg/tools/result.go | 17 +++ 12 files changed, 809 insertions(+), 15 deletions(-) create mode 100644 pkg/channels/media.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ebbeec0c1..091332d1a 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -728,6 +728,19 @@ func (al *AgentLoop) runLLMIteration( }) } + // If tool returned media refs, publish them as outbound media + if len(toolResult.Media) > 0 && opts.SendResponse { + parts := make([]bus.MediaPart, 0, len(toolResult.Media)) + for _, ref := range toolResult.Media { + parts = append(parts, bus.MediaPart{Ref: ref}) + } + al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Parts: parts, + }) + } + // Determine content for LLM based on tool result contentForLLM := toolResult.ForLLM if contentForLLM == "" && toolResult.Err != nil { diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 100ddc456..6a1c987b7 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -10,17 +10,19 @@ import ( var ErrBusClosed = errors.New("message bus closed") type MessageBus struct { - inbound chan InboundMessage - outbound chan OutboundMessage - done chan struct{} - closed atomic.Bool + inbound chan InboundMessage + outbound chan OutboundMessage + outboundMedia chan OutboundMediaMessage + done chan struct{} + closed atomic.Bool } func NewMessageBus() *MessageBus { return &MessageBus{ - inbound: make(chan InboundMessage, 100), - outbound: make(chan OutboundMessage, 100), - done: make(chan struct{}), + inbound: make(chan InboundMessage, 100), + outbound: make(chan OutboundMessage, 100), + outboundMedia: make(chan OutboundMediaMessage, 100), + done: make(chan struct{}), } } @@ -74,6 +76,31 @@ func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, b } } +func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { + if mb.closed.Load() { + return ErrBusClosed + } + select { + case mb.outboundMedia <- msg: + return nil + case <-mb.done: + return ErrBusClosed + case <-ctx.Done(): + return ctx.Err() + } +} + +func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) { + select { + case msg, ok := <-mb.outboundMedia: + return msg, ok + case <-mb.done: + return OutboundMediaMessage{}, false + case <-ctx.Done(): + return OutboundMediaMessage{}, false + } +} + func (mb *MessageBus) Close() { if mb.closed.CompareAndSwap(false, true) { close(mb.done) diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 358829c55..1a7a14170 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -24,3 +24,19 @@ type OutboundMessage struct { ChatID string `json:"chat_id"` Content string `json:"content"` } + +// MediaPart describes a single media attachment to send. +type MediaPart struct { + Type string `json:"type"` // "image" | "audio" | "video" | "file" + Ref string `json:"ref"` // media store ref, e.g. "media://abc123" + Caption string `json:"caption,omitempty"` // optional caption text + Filename string `json:"filename,omitempty"` // original filename hint + ContentType string `json:"content_type,omitempty"` // MIME type hint +} + +// OutboundMediaMessage carries media attachments from Agent to channels via the bus. +type OutboundMediaMessage struct { + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Parts []MediaPart `json:"parts"` +} diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index d5524f7f9..7987f45a9 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -3,6 +3,7 @@ package discord import ( "context" "fmt" + "os" "strings" "sync" "time" @@ -128,6 +129,103 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return c.sendChunk(ctx, channelID, msg.Content) } +// SendMedia implements the channels.MediaSender interface. +func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + c.stopTyping(msg.ChatID) + + if !c.IsRunning() { + return channels.ErrNotRunning + } + + channelID := msg.ChatID + if channelID == "" { + return fmt.Errorf("channel ID is empty") + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + // Collect all files into a single ChannelMessageSendComplex call + files := make([]*discordgo.File, 0, len(msg.Parts)) + var caption string + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("discord", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + file, err := os.Open(localPath) + if err != nil { + logger.ErrorCF("discord", "Failed to open media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + // Note: discordgo reads from the Reader and we can't close it before send + + filename := part.Filename + if filename == "" { + filename = "file" + } + + files = append(files, &discordgo.File{ + Name: filename, + ContentType: part.ContentType, + Reader: file, + }) + + if part.Caption != "" && caption == "" { + caption = part.Caption + } + } + + if len(files) == 0 { + return nil + } + + sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) + defer cancel() + + done := make(chan error, 1) + go func() { + _, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Content: caption, + Files: files, + }) + done <- err + }() + + select { + case err := <-done: + // Close all file readers + for _, f := range files { + if closer, ok := f.Reader.(*os.File); ok { + closer.Close() + } + } + if err != nil { + return fmt.Errorf("discord send media: %w", channels.ErrTemporary) + } + return nil + case <-sendCtx.Done(): + // Close all file readers + for _, f := range files { + if closer, ok := f.Reader.(*os.File); ok { + closer.Close() + } + } + return sendCtx.Err() + } +} + func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 6ae048468..5b0af4f1d 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -496,6 +496,36 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken) } +// SendMedia implements the channels.MediaSender interface. +// LINE requires media to be accessible via public URL; since we only have local files, +// we fall back to sending a text message with the filename/caption. +// For full support, an external file hosting service would be needed. +func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + // LINE Messaging API requires publicly accessible URLs for media messages. + // Since we only have local file paths, send caption text as fallback. + for _, part := range msg.Parts { + caption := part.Caption + if caption == "" { + caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) + } + + if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil { + return err + } + } + + return nil +} + // buildTextMessage creates a text message object, optionally with quoteToken. func buildTextMessage(content, quoteToken string) map[string]string { msg := map[string]string{ diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index dadc068e9..92412edeb 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -44,10 +44,12 @@ var channelRateConfig = map[string]float64{ } type channelWorker struct { - ch Channel - queue chan bus.OutboundMessage - done chan struct{} - limiter *rate.Limiter + ch Channel + queue chan bus.OutboundMessage + mediaQueue chan bus.OutboundMediaMessage + done chan struct{} + mediaDone chan struct{} + limiter *rate.Limiter } type Manager struct { @@ -239,10 +241,12 @@ func (m *Manager) StartAll(ctx context.Context) error { // Start per-channel workers for name, w := range m.workers { go m.runWorker(dispatchCtx, name, w) + go m.runMediaWorker(dispatchCtx, name, w) } // Start the dispatcher that reads from the bus and routes to workers go m.dispatchOutbound(dispatchCtx) + go m.dispatchOutboundMedia(dispatchCtx) // Start shared HTTP server if configured if m.httpServer != nil { @@ -293,6 +297,13 @@ func (m *Manager) StopAll(ctx context.Context) error { for _, w := range m.workers { <-w.done } + // Close all media worker queues and wait for them to drain + for _, w := range m.workers { + close(w.mediaQueue) + } + for _, w := range m.workers { + <-w.mediaDone + } // Stop all channels for name, channel := range m.channels { @@ -321,10 +332,12 @@ func newChannelWorker(name string, ch Channel) *channelWorker { burst := int(math.Max(1, math.Ceil(rateVal/2))) return &channelWorker{ - ch: ch, - queue: make(chan bus.OutboundMessage, defaultChannelQueueSize), - done: make(chan struct{}), - limiter: rate.NewLimiter(rate.Limit(rateVal), burst), + ch: ch, + queue: make(chan bus.OutboundMessage, defaultChannelQueueSize), + mediaQueue: make(chan bus.OutboundMediaMessage, defaultChannelQueueSize), + done: make(chan struct{}), + mediaDone: make(chan struct{}), + limiter: rate.NewLimiter(rate.Limit(rateVal), burst), } } @@ -457,6 +470,125 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { } } +func (m *Manager) dispatchOutboundMedia(ctx context.Context) { + logger.InfoC("channels", "Outbound media dispatcher started") + + for { + select { + case <-ctx.Done(): + logger.InfoC("channels", "Outbound media dispatcher stopped") + return + default: + msg, ok := m.bus.SubscribeOutboundMedia(ctx) + if !ok { + continue + } + + // Silently skip internal channels + if constants.IsInternalChannel(msg.Channel) { + continue + } + + m.mu.RLock() + _, exists := m.channels[msg.Channel] + w, wExists := m.workers[msg.Channel] + m.mu.RUnlock() + + if !exists { + logger.WarnCF("channels", "Unknown channel for outbound media message", map[string]any{ + "channel": msg.Channel, + }) + continue + } + + if wExists { + select { + case w.mediaQueue <- msg: + case <-ctx.Done(): + return + } + } + } + } +} + +// runMediaWorker processes outbound media messages for a single channel. +func (m *Manager) runMediaWorker(ctx context.Context, name string, w *channelWorker) { + defer close(w.mediaDone) + for { + select { + case msg, ok := <-w.mediaQueue: + if !ok { + return + } + m.sendMediaWithRetry(ctx, name, w, msg) + case <-ctx.Done(): + return + } + } +} + +// sendMediaWithRetry sends a media message through the channel with rate limiting and +// retry logic. If the channel does not implement MediaSender, it silently skips. +func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMediaMessage) { + ms, ok := w.ch.(MediaSender) + if !ok { + logger.DebugCF("channels", "Channel does not support MediaSender, skipping media", map[string]any{ + "channel": name, + }) + return + } + + // Rate limit: wait for token + if err := w.limiter.Wait(ctx); err != nil { + return + } + + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + lastErr = ms.SendMedia(ctx, msg) + if lastErr == nil { + return + } + + // Permanent failures — don't retry + if errors.Is(lastErr, ErrNotRunning) || errors.Is(lastErr, ErrSendFailed) { + break + } + + // Last attempt exhausted — don't sleep + if attempt == maxRetries { + break + } + + // Rate limit error — fixed delay + if errors.Is(lastErr, ErrRateLimit) { + select { + case <-time.After(rateLimitDelay): + continue + case <-ctx.Done(): + return + } + } + + // ErrTemporary or unknown error — exponential backoff + backoff := min(time.Duration(float64(baseBackoff)*math.Pow(2, float64(attempt))), maxBackoff) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + } + + // All retries exhausted or permanent failure + logger.ErrorCF("channels", "SendMedia failed", map[string]any{ + "channel": name, + "chat_id": msg.ChatID, + "error": lastErr.Error(), + "retries": maxRetries, + }) +} + func (m *Manager) GetChannel(name string) (Channel, bool) { m.mu.RLock() defer m.mu.RUnlock() @@ -502,6 +634,8 @@ func (m *Manager) UnregisterChannel(name string) { if w, ok := m.workers[name]; ok { close(w.queue) <-w.done + close(w.mediaQueue) + <-w.mediaDone } delete(m.workers, name) delete(m.channels, name) diff --git a/pkg/channels/media.go b/pkg/channels/media.go new file mode 100644 index 000000000..c645a6180 --- /dev/null +++ b/pkg/channels/media.go @@ -0,0 +1,15 @@ +package channels + +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +// MediaSender is an optional interface for channels that can send +// media attachments (images, files, audio, video). +// Manager discovers channels implementing this interface via type +// assertion and routes OutboundMediaMessage to them. +type MediaSender interface { + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error +} diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 76950663e..fb357cf27 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -431,6 +431,117 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } +// SendMedia implements the channels.MediaSender interface. +func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + c.mu.Lock() + conn := c.conn + c.mu.Unlock() + + if conn == nil { + return fmt.Errorf("OneBot WebSocket not connected") + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + // Build media segments + var segments []oneBotMessageSegment + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("onebot", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + segType := "image" + switch part.Type { + case "image": + segType = "image" + case "video": + segType = "video" + case "audio": + segType = "record" + default: + segType = "file" + } + + segments = append(segments, oneBotMessageSegment{ + Type: segType, + Data: map[string]any{"file": "file://" + localPath}, + }) + + if part.Caption != "" { + segments = append(segments, oneBotMessageSegment{ + Type: "text", + Data: map[string]any{"text": part.Caption}, + }) + } + } + + if len(segments) == 0 { + return nil + } + + chatID := msg.ChatID + var action, idKey string + var rawID string + if rest, ok := strings.CutPrefix(chatID, "group:"); ok { + action, idKey, rawID = "send_group_msg", "group_id", rest + } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok { + action, idKey, rawID = "send_private_msg", "user_id", rest + } else { + action, idKey, rawID = "send_private_msg", "user_id", chatID + } + + id, err := strconv.ParseInt(rawID, 10, 64) + if err != nil { + return fmt.Errorf("invalid %s in chatID: %s: %w", idKey, chatID, channels.ErrSendFailed) + } + + echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1)) + + req := oneBotAPIRequest{ + Action: action, + Params: map[string]any{idKey: id, "message": segments}, + Echo: echo, + } + + data, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal OneBot request: %w", err) + } + + c.writeMu.Lock() + _ = conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) + err = conn.WriteMessage(websocket.TextMessage, data) + _ = conn.SetWriteDeadline(time.Time{}) + c.writeMu.Unlock() + + if err != nil { + logger.ErrorCF("onebot", "Failed to send media message", map[string]any{ + "error": err.Error(), + }) + return fmt.Errorf("onebot send media: %w", channels.ErrTemporary) + } + + return nil +} + func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment { var segments []oneBotMessageSegment diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 9e066e00a..f2dda15ac 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -149,6 +149,60 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } +// SendMedia implements the channels.MediaSender interface. +func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + channelID, _ := parseSlackChatID(msg.ChatID) + if channelID == "" { + return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("slack", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + filename := part.Filename + if filename == "" { + filename = "file" + } + + title := part.Caption + if title == "" { + title = filename + } + + _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ + Channel: channelID, + File: localPath, + Filename: filename, + Title: title, + }) + if err != nil { + logger.ErrorCF("slack", "Failed to upload media", map[string]any{ + "filename": filename, + "error": err.Error(), + }) + return fmt.Errorf("slack send media: %w", channels.ErrTemporary) + } + } + + return nil +} + func (c *SlackChannel) eventLoop() { for { select { diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index a07eb6579..f9390b8ed 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -231,6 +231,91 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return nil } +// SendMedia implements the channels.MediaSender interface. +func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + chatID, err := parseChatID(msg.ChatID) + if err != nil { + return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("telegram", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + file, err := os.Open(localPath) + if err != nil { + logger.ErrorCF("telegram", "Failed to open media file", map[string]any{ + "path": localPath, + "error": err.Error(), + }) + continue + } + + filename := part.Filename + if filename == "" { + filename = "file" + } + + switch part.Type { + case "image": + params := &telego.SendPhotoParams{ + ChatID: tu.ID(chatID), + Photo: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendPhoto(ctx, params) + case "audio": + params := &telego.SendAudioParams{ + ChatID: tu.ID(chatID), + Audio: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendAudio(ctx, params) + case "video": + params := &telego.SendVideoParams{ + ChatID: tu.ID(chatID), + Video: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendVideo(ctx, params) + default: // "file" or unknown types + params := &telego.SendDocumentParams{ + ChatID: tu.ID(chatID), + Document: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendDocument(ctx, params) + } + + file.Close() + + if err != nil { + logger.ErrorCF("telegram", "Failed to send media", map[string]any{ + "type": part.Type, + "error": err.Error(), + }) + return fmt.Errorf("telegram send media: %w", channels.ErrTemporary) + } + } + + return nil +} + func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error { if message == nil { return fmt.Errorf("message is nil") diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 52750505c..4c2a4d326 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -7,8 +7,11 @@ import ( "encoding/xml" "fmt" "io" + "mime/multipart" "net/http" "net/url" + "os" + "path/filepath" "strings" "sync" "time" @@ -187,6 +190,197 @@ func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content) } +// SendMedia implements the channels.MediaSender interface. +func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + accessToken := c.getAccessToken() + if accessToken == "" { + return fmt.Errorf("no valid access token available: %w", channels.ErrTemporary) + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store available: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + // Map part type to WeCom media type + mediaType := "file" + switch part.Type { + case "image": + mediaType = "image" + case "audio": + mediaType = "voice" + case "video": + mediaType = "video" + default: + mediaType = "file" + } + + // Upload media to get media_id + mediaID, err := c.uploadMedia(ctx, accessToken, mediaType, localPath) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to upload media", map[string]any{ + "type": mediaType, + "error": err.Error(), + }) + // Fallback: send caption as text + if part.Caption != "" { + _ = c.sendTextMessage(ctx, accessToken, msg.ChatID, part.Caption) + } + continue + } + + // Send media message using the media_id + if mediaType == "image" { + err = c.sendImageMessage(ctx, accessToken, msg.ChatID, mediaID) + } else { + // For non-image types, send as text fallback with caption + caption := part.Caption + if caption == "" { + caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename) + } + err = c.sendTextMessage(ctx, accessToken, msg.ChatID, caption) + } + + if err != nil { + return err + } + } + + return nil +} + +// uploadMedia uploads a local file to WeCom temporary media storage. +func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaType, localPath string) (string, error) { + apiURL := fmt.Sprintf("%s/cgi-bin/media/upload?access_token=%s&type=%s", + wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaType)) + + file, err := os.Open(localPath) + if err != nil { + return "", fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + filename := filepath.Base(localPath) + formFile, err := writer.CreateFormFile("media", filename) + if err != nil { + return "", fmt.Errorf("failed to create form file: %w", err) + } + + if _, err = io.Copy(formFile, file); err != nil { + return "", fmt.Errorf("failed to copy file content: %w", err) + } + writer.Close() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, body) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", channels.ClassifyNetError(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return "", channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom upload error: %s", string(respBody))) + } + + var result struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + MediaID string `json:"media_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to parse upload response: %w", err) + } + + if result.ErrCode != 0 { + return "", fmt.Errorf("upload API error: %s (code: %d)", result.ErrMsg, result.ErrCode) + } + + return result.MediaID, nil +} + +// sendImageMessage sends an image message using a media_id. +func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, userID, mediaID string) error { + apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) + + msg := WeComImageMessage{ + ToUser: userID, + MsgType: "image", + AgentID: c.config.AgentID, + } + msg.Image.MediaID = mediaID + + jsonData, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("failed to marshal message: %w", err) + } + + timeout := c.config.ReplyTimeout + if timeout <= 0 { + timeout = 5 + } + + reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + resp, err := client.Do(req) + if err != nil { + return channels.ClassifyNetError(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(respBody))) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + var sendResp WeComSendMessageResponse + if err := json.Unmarshal(respBody, &sendResp); err != nil { + return fmt.Errorf("failed to parse response: %w", err) + } + + if sendResp.ErrCode != 0 { + return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) + } + + return nil +} + // WebhookPath returns the path for registering on the shared HTTP server. func (c *WeComAppChannel) WebhookPath() string { if c.config.WebhookPath != "" { diff --git a/pkg/tools/result.go b/pkg/tools/result.go index b13055b1c..cab833284 100644 --- a/pkg/tools/result.go +++ b/pkg/tools/result.go @@ -30,6 +30,10 @@ type ToolResult struct { // Err is the underlying error (not JSON serialized). // Used for internal error handling and logging. Err error `json:"-"` + + // Media contains media store refs produced by this tool. + // When non-empty, the agent will publish these as OutboundMediaMessage. + Media []string `json:"media,omitempty"` } // NewToolResult creates a basic ToolResult with content for the LLM. @@ -120,6 +124,19 @@ func UserResult(content string) *ToolResult { } } +// MediaResult creates a ToolResult with media refs for the user. +// The agent will publish these refs as OutboundMediaMessage. +// +// Example: +// +// result := MediaResult("Image generated successfully", []string{"media://abc123"}) +func MediaResult(forLLM string, mediaRefs []string) *ToolResult { + return &ToolResult{ + ForLLM: forLLM, + Media: mediaRefs, + } +} + // MarshalJSON implements custom JSON serialization. // The Err field is excluded from JSON output via the json:"-" tag. func (tr *ToolResult) MarshalJSON() ([]byte, error) { From e00745489ddc576f835a9e82ce68792f41fee2a7 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 03:47:12 +0800 Subject: [PATCH 017/104] refactor(channels): remove channel-side voice transcription (Phase 12) Remove SetTranscriber and inline transcription logic from 4 channels (Telegram, Discord, Slack, OneBot) and the gateway wiring. Voice/audio files are still downloaded and stored in MediaStore with simple text annotations ([voice], [audio: filename], [file: name]). The pkg/voice package is preserved for future Agent-level transcription middleware. --- cmd/picoclaw/internal/gateway/helpers.go | 44 ++------------------- pkg/channels/discord/discord.go | 49 +++++------------------- pkg/channels/onebot/onebot.go | 27 +------------ pkg/channels/slack/slack.go | 23 +---------- pkg/channels/telegram/telegram.go | 31 +-------------- 5 files changed, 17 insertions(+), 157 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 5ebf26d78..758157f53 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -6,7 +6,6 @@ import ( "os" "os/signal" "path/filepath" - "strings" "time" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" @@ -14,14 +13,14 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" _ "github.com/sipeed/picoclaw/pkg/channels/dingtalk" - dch "github.com/sipeed/picoclaw/pkg/channels/discord" + _ "github.com/sipeed/picoclaw/pkg/channels/discord" _ "github.com/sipeed/picoclaw/pkg/channels/feishu" _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" _ "github.com/sipeed/picoclaw/pkg/channels/qq" - slackch "github.com/sipeed/picoclaw/pkg/channels/slack" - tgramch "github.com/sipeed/picoclaw/pkg/channels/telegram" + _ "github.com/sipeed/picoclaw/pkg/channels/slack" + _ "github.com/sipeed/picoclaw/pkg/channels/telegram" _ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" "github.com/sipeed/picoclaw/pkg/config" @@ -34,7 +33,6 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/voice" ) func gatewayCmd(debug bool) error { @@ -127,42 +125,6 @@ func gatewayCmd(debug bool) error { agentLoop.SetChannelManager(channelManager) agentLoop.SetMediaStore(mediaStore) - var transcriber *voice.GroqTranscriber - groqAPIKey := cfg.Providers.Groq.APIKey - if groqAPIKey == "" { - for _, mc := range cfg.ModelList { - if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { - groqAPIKey = mc.APIKey - break - } - } - } - if groqAPIKey != "" { - transcriber = voice.NewGroqTranscriber(groqAPIKey) - logger.InfoC("voice", "Groq voice transcription enabled") - } - - if transcriber != nil { - if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { - if tc, ok := telegramChannel.(*tgramch.TelegramChannel); ok { - tc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Telegram channel") - } - } - if discordChannel, ok := channelManager.GetChannel("discord"); ok { - if dc, ok := discordChannel.(*dch.DiscordChannel); ok { - dc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Discord channel") - } - } - if slackChannel, ok := channelManager.GetChannel("slack"); ok { - if sc, ok := slackChannel.(*slackch.SlackChannel); ok { - sc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Slack channel") - } - } - } - enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 7987f45a9..68725b124 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -16,24 +16,21 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) const ( - transcriptionTimeout = 30 * time.Second - sendTimeout = 10 * time.Second + sendTimeout = 10 * time.Second ) type DiscordChannel struct { *channels.BaseChannel - session *discordgo.Session - config config.DiscordConfig - transcriber *voice.GroqTranscriber - ctx context.Context - cancel context.CancelFunc - typingMu sync.Mutex - typingStop map[string]chan struct{} // chatID → stop signal - botUserID string // stored for mention checking + session *discordgo.Session + config config.DiscordConfig + ctx context.Context + cancel context.CancelFunc + typingMu sync.Mutex + typingStop map[string]chan struct{} // chatID → stop signal + botUserID string // stored for mention checking } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { @@ -48,16 +45,11 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC BaseChannel: base, session: session, config: cfg, - transcriber: nil, ctx: context.Background(), typingStop: make(map[string]chan struct{}), }, nil } -func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *DiscordChannel) Start(ctx context.Context) error { logger.InfoC("discord", "Starting Discord bot") @@ -265,7 +257,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } - // Check allowlist first to avoid downloading attachments and transcribing for rejected users + // Check allowlist first to avoid downloading attachments for rejected users if !c.IsAllowed(m.Author.ID) { logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ "user_id": m.Author.ID, @@ -323,29 +315,8 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag if isAudio { localPath := c.downloadAttachment(attachment.URL, attachment.Filename) if localPath != "" { - transcribedText := "" - if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(c.ctx, transcriptionTimeout) - result, err := c.transcriber.Transcribe(ctx, localPath) - cancel() // Release context resources immediately to avoid leaks in for loop - - if err != nil { - logger.ErrorCF("discord", "Voice transcription failed", map[string]any{ - "error": err.Error(), - }) - transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename) - } else { - transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text) - logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{ - "text": result.Text, - }) - } - } else { - transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename) - } - mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename)) - content = appendContent(content, transcribedText) + content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename)) } else { logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{ "url": attachment.URL, diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index fb357cf27..001965238 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -18,7 +18,6 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) type OneBotChannel struct { @@ -36,7 +35,6 @@ type OneBotChannel struct { selfID int64 pending map[string]chan json.RawMessage pendingMu sync.Mutex - transcriber *voice.GroqTranscriber lastMessageID sync.Map pendingEmojiMsg sync.Map } @@ -112,10 +110,6 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One }, nil } -func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) { go func() { _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{ @@ -794,25 +788,8 @@ func (c *OneBotChannel) parseMessageSegments( LoggerPrefix: "onebot", }) if localPath != "" { - if c.transcriber != nil && c.transcriber.IsAvailable() { - tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second) - result, err := c.transcriber.Transcribe(tctx, localPath) - tcancel() - if err != nil { - logger.WarnCF("onebot", "Voice transcription failed", map[string]any{ - "error": err.Error(), - }) - textParts = append(textParts, "[voice (transcription failed)]") - mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr")) - } else { - textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text)) - // Still store the file so it can be released later - storeFile(localPath, "voice.amr") - } - } else { - textParts = append(textParts, "[voice]") - mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr")) - } + textParts = append(textParts, "[voice]") + mediaRefs = append(mediaRefs, storeFile(localPath, "voice.amr")) } } } diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index f2dda15ac..a8d329d65 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -5,7 +5,6 @@ import ( "fmt" "strings" "sync" - "time" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" @@ -17,7 +16,6 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) type SlackChannel struct { @@ -27,7 +25,6 @@ type SlackChannel struct { socketClient *socketmode.Client botUserID string teamID string - transcriber *voice.GroqTranscriber ctx context.Context cancel context.CancelFunc pendingAcks sync.Map @@ -60,10 +57,6 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack }, nil } -func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *SlackChannel) Start(ctx context.Context) error { logger.InfoC("slack", "Starting Slack channel (Socket Mode)") @@ -311,21 +304,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { continue } mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name)) - - if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) - defer cancel() - result, err := c.transcriber.Transcribe(ctx, localPath) - - if err != nil { - logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()}) - content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name) - } else { - content += fmt.Sprintf("\n[voice transcription: %s]", result.Text) - } - } else { - content += fmt.Sprintf("\n[file: %s]", file.Name) - } + content += fmt.Sprintf("\n[file: %s]", file.Name) } } diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index f9390b8ed..9544987ec 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -22,7 +22,6 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) type TelegramChannel struct { @@ -32,7 +31,6 @@ type TelegramChannel struct { commands TelegramCommander config *config.Config chatIDs map[string]int64 - transcriber *voice.GroqTranscriber ctx context.Context cancel context.CancelFunc placeholders sync.Map // chatID -> messageID @@ -91,16 +89,11 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann bot: bot, config: cfg, chatIDs: make(map[string]int64), - transcriber: nil, placeholders: sync.Map{}, stopThinking: sync.Map{}, }, nil } -func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") @@ -391,32 +384,10 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if voicePath != "" { mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg")) - transcribedText := "" - if c.transcriber != nil && c.transcriber.IsAvailable() { - transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - result, err := c.transcriber.Transcribe(transcriberCtx, voicePath) - if err != nil { - logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{ - "error": err.Error(), - "path": voicePath, - }) - transcribedText = "[voice (transcription failed)]" - } else { - transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) - logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{ - "text": result.Text, - }) - } - } else { - transcribedText = "[voice]" - } - if content != "" { content += "\n" } - content += transcribedText + content += "[voice]" } } From f8b656ec378663df1c2b262923c7ac935846a576 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 04:11:11 +0800 Subject: [PATCH 018/104] refactor(channels): standardize group chat trigger filtering (Phase 8) 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. --- pkg/channels/base.go | 47 +++++++++ pkg/channels/base_test.go | 127 +++++++++++++++++++++- pkg/channels/dingtalk/dingtalk.go | 11 +- pkg/channels/discord/discord.go | 25 +++-- pkg/channels/feishu/feishu_64.go | 10 +- pkg/channels/line/line.go | 26 +++-- pkg/channels/onebot/onebot.go | 28 +---- pkg/channels/qq/qq.go | 11 +- pkg/channels/slack/slack.go | 14 ++- pkg/channels/telegram/telegram.go | 63 +++++++++++ pkg/channels/wecom/app.go | 5 +- pkg/channels/wecom/bot.go | 14 ++- pkg/config/config.go | 170 +++++++++++++++++++----------- pkg/config/defaults.go | 1 + 14 files changed, 446 insertions(+), 106 deletions(-) diff --git a/pkg/channels/base.go b/pkg/channels/base.go index adacb8c78..e345aedf0 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/media" ) @@ -30,6 +31,11 @@ func WithMaxMessageLength(n int) BaseChannelOption { return func(c *BaseChannel) { c.maxMessageLength = n } } +// WithGroupTrigger sets the group trigger configuration for a channel. +func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption { + return func(c *BaseChannel) { c.groupTrigger = gt } +} + // MessageLengthProvider is an opt-in interface that channels implement // to advertise their maximum message length. The Manager uses this via // type assertion to decide whether to split outbound messages. @@ -44,6 +50,7 @@ type BaseChannel struct { name string allowList []string maxMessageLength int + groupTrigger config.GroupTriggerConfig mediaStore media.MediaStore } @@ -72,6 +79,46 @@ func (c *BaseChannel) MaxMessageLength() int { return c.maxMessageLength } +// ShouldRespondInGroup determines whether the bot should respond in a group chat. +// Each channel is responsible for: +// 1. Detecting isMentioned (platform-specific) +// 2. Stripping bot mention from content (platform-specific) +// 3. Calling this method to get the group response decision +// +// Logic: +// - If isMentioned → always respond +// - If mention_only configured and not mentioned → ignore +// - If prefixes configured → respond if content starts with any prefix (strip it) +// - If prefixes configured but no match and not mentioned → ignore +// - Otherwise (no group_trigger configured) → respond to all (permissive default) +func (c *BaseChannel) ShouldRespondInGroup(isMentioned bool, content string) (bool, string) { + gt := c.groupTrigger + + // Mentioned → always respond + if isMentioned { + return true, strings.TrimSpace(content) + } + + // mention_only → require mention + if gt.MentionOnly { + return false, content + } + + // Prefix matching + if len(gt.Prefixes) > 0 { + for _, prefix := range gt.Prefixes { + if prefix != "" && strings.HasPrefix(content, prefix) { + return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) + } + } + // Prefixes configured but none matched and not mentioned → ignore + return false, content + } + + // No group_trigger configured → permissive (respond to all) + return true, strings.TrimSpace(content) +} + func (c *BaseChannel) Name() string { return c.name } diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go index 78c6d1d66..e56ad3ee9 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 { @@ -50,3 +54,124 @@ func TestBaseChannelIsAllowed(t *testing.T) { }) } } + +func TestShouldRespondInGroup(t *testing.T) { + tests := []struct { + name string + gt config.GroupTriggerConfig + isMentioned bool + content string + wantRespond bool + wantContent string + }{ + { + name: "no config - permissive default", + gt: config.GroupTriggerConfig{}, + isMentioned: false, + content: "hello world", + wantRespond: true, + wantContent: "hello world", + }, + { + name: "no config - mentioned", + gt: config.GroupTriggerConfig{}, + isMentioned: true, + content: "hello world", + wantRespond: true, + wantContent: "hello world", + }, + { + name: "mention_only - not mentioned", + gt: config.GroupTriggerConfig{MentionOnly: true}, + isMentioned: false, + content: "hello world", + wantRespond: false, + wantContent: "hello world", + }, + { + name: "mention_only - mentioned", + gt: config.GroupTriggerConfig{MentionOnly: true}, + isMentioned: true, + content: "hello world", + wantRespond: true, + wantContent: "hello world", + }, + { + name: "prefix match", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}}, + isMentioned: false, + content: "/ask hello", + wantRespond: true, + wantContent: "hello", + }, + { + name: "prefix no match - not mentioned", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}}, + isMentioned: false, + content: "hello world", + wantRespond: false, + wantContent: "hello world", + }, + { + name: "prefix no match - but mentioned", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask"}}, + isMentioned: true, + content: "hello world", + wantRespond: true, + wantContent: "hello world", + }, + { + name: "multiple prefixes - second matches", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask", "/bot"}}, + isMentioned: false, + content: "/bot help me", + wantRespond: true, + wantContent: "help me", + }, + { + name: "mention_only with prefixes - mentioned overrides", + gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}}, + isMentioned: true, + content: "hello", + wantRespond: true, + wantContent: "hello", + }, + { + name: "mention_only with prefixes - not mentioned, no prefix", + gt: config.GroupTriggerConfig{MentionOnly: true, Prefixes: []string{"/ask"}}, + isMentioned: false, + content: "hello", + wantRespond: false, + wantContent: "hello", + }, + { + name: "empty prefix in list is skipped", + gt: config.GroupTriggerConfig{Prefixes: []string{"", "/ask"}}, + isMentioned: false, + content: "/ask test", + wantRespond: true, + wantContent: "test", + }, + { + name: "prefix strips leading whitespace after prefix", + gt: config.GroupTriggerConfig{Prefixes: []string{"/ask "}}, + isMentioned: false, + content: "/ask hello", + wantRespond: true, + wantContent: "hello", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := NewBaseChannel("test", nil, nil, nil, WithGroupTrigger(tt.gt)) + gotRespond, gotContent := ch.ShouldRespondInGroup(tt.isMentioned, tt.content) + if gotRespond != tt.wantRespond { + t.Errorf("ShouldRespondInGroup() respond = %v, want %v", gotRespond, tt.wantRespond) + } + if gotContent != tt.wantContent { + t.Errorf("ShouldRespondInGroup() content = %q, want %q", gotContent, tt.wantContent) + } + }) + } +} diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index c49769761..b28bc850f 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -38,7 +38,10 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } - base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(20000)) + base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(20000), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &DingTalkChannel{ BaseChannel: base, @@ -165,6 +168,12 @@ func (c *DingTalkChannel) onChatBotMessageReceived( peer = bus.Peer{Kind: "direct", ID: senderID} } else { peer = bus.Peer{Kind: "group", ID: data.ConversationId} + // In group chats, apply unified group trigger filtering + respond, cleaned := c.ShouldRespondInGroup(false, content) + if !respond { + return nil, nil + } + content = cleaned } logger.DebugCF("dingtalk", "Received message", map[string]any{ diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 68725b124..4ef4906c1 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -39,7 +39,10 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC return nil, fmt.Errorf("failed to create discord session: %w", err) } - base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(2000)) + base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, + channels.WithMaxMessageLength(2000), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &DiscordChannel{ BaseChannel: base, @@ -265,9 +268,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } - // If configured to only respond to mentions, check if bot is mentioned - // Skip this check for DMs (GuildID is empty) - DMs should always be responded to - if c.config.MentionOnly && m.GuildID != "" { + content := m.Content + + // In guild (group) channels, apply unified group trigger filtering + // DMs (GuildID is empty) always get a response + if m.GuildID != "" { isMentioned := false for _, mention := range m.Mentions { if mention.ID == c.botUserID { @@ -275,12 +280,18 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag break } } - if !isMentioned { - logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{ + content = c.stripBotMention(content) + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) + if !respond { + logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{ "user_id": m.Author.ID, }) return } + content = cleaned + } else { + // DMs: just strip bot mention without filtering + content = c.stripBotMention(content) } senderID := m.Author.ID @@ -289,8 +300,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag senderName += "#" + m.Author.Discriminator } - content := m.Content - content = c.stripBotMention(content) mediaPaths := make([]string, 0, len(m.Attachments)) scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID) diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5245cd99d..aaaf6cf1b 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -32,7 +32,9 @@ type FeishuChannel struct { } func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { - base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom) + base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom, + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &FeishuChannel{ BaseChannel: base, @@ -173,6 +175,12 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 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{ diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 5b0af4f1d..a79931bc9 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -59,7 +59,10 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha return nil, fmt.Errorf("line channel_secret and channel_access_token are required") } - base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(5000)) + base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(5000), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &LINEChannel{ BaseChannel: base, @@ -262,14 +265,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } - // In group chats, only respond when the bot is mentioned - if isGroup && !c.isBotMentioned(msg) { - logger.DebugCF("line", "Ignoring group message without mention", map[string]any{ - "chat_id": chatID, - }) - return - } - // Store reply token for later use if event.ReplyToken != "" { c.replyTokens.Store(chatID, replyTokenEntry{ @@ -339,6 +334,19 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } + // In group chats, apply unified group trigger filtering + if isGroup { + isMentioned := c.isBotMentioned(msg) + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) + if !respond { + logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{ + "chat_id": chatID, + }) + return + } + content = cleaned + } + metadata := map[string]string{ "platform": "line", "source_type": event.Source.Type, diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 001965238..f32cb4948 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -97,7 +97,9 @@ type oneBotMessageSegment struct { } func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { - base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom, + channels.WithGroupTrigger(cfg.GroupTrigger), + ) const dedupSize = 1024 return &OneBotChannel{ @@ -996,8 +998,8 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { metadata["sender_name"] = sender.Nickname } - triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned) - if !triggered { + respond, strippedContent := c.ShouldRespondInGroup(isBotMentioned, content) + if !respond { logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{ "sender": senderID, "group": groupIDStr, @@ -1069,23 +1071,3 @@ func truncate(s string, n int) string { } return string(runes[:n]) + "..." } - -func (c *OneBotChannel) checkGroupTrigger( - content string, - isBotMentioned bool, -) (triggered bool, strippedContent string) { - if isBotMentioned { - return true, strings.TrimSpace(content) - } - - for _, prefix := range c.config.GroupTriggerPrefix { - if prefix == "" { - continue - } - if strings.HasPrefix(content, prefix) { - return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) - } - } - - return false, content -} diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 69f323e6e..011eb6c3c 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -32,7 +32,9 @@ type QQChannel struct { } func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { - base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom) + base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &QQChannel{ BaseChannel: base, @@ -204,6 +206,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return nil } + // GroupAT event means bot is always mentioned; apply group trigger filtering + respond, cleaned := c.ShouldRespondInGroup(true, content) + if !respond { + return nil + } + content = cleaned + logger.InfoCF("qq", "Received group AT message", map[string]any{ "sender": senderID, "group": data.GroupID, diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index a8d329d65..6fba2e0b4 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -47,7 +47,10 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack socketClient := socketmode.New(api) - base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(40000)) + base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(40000), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &SlackChannel{ BaseChannel: base, @@ -279,6 +282,15 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { content := ev.Text content = c.stripBotMention(content) + // In non-DM channels, apply group trigger filtering + if !strings.HasPrefix(channelID, "D") { + respond, cleaned := c.ShouldRespondInGroup(false, content) + if !respond { + return + } + content = cleaned + } + var mediaPaths []string scope := channels.BuildMediaScope("slack", chatID, messageTS) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 9544987ec..c5c055163 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -81,6 +81,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann bus, telegramCfg.AllowFrom, channels.WithMaxMessageLength(4096), + channels.WithGroupTrigger(telegramCfg.GroupTrigger), ) return &TelegramChannel{ @@ -417,6 +418,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = "[empty message]" } + // In group chats, apply unified group trigger filtering + if message.Chat.Type != "private" { + isMentioned := c.isBotMentioned(message) + if isMentioned { + content = c.stripBotMention(content) + } + respond, cleaned := c.ShouldRespondInGroup(isMentioned, content) + if !respond { + return nil + } + content = cleaned + } + logger.DebugCF("telegram", "Received message", map[string]any{ "sender_id": senderID, "chat_id": fmt.Sprintf("%d", chatID), @@ -629,3 +643,52 @@ func escapeHTML(text string) string { text = strings.ReplaceAll(text, ">", ">") return text } + +// isBotMentioned checks if the bot is mentioned in the message via entities. +func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool { + botUsername := c.bot.Username() + if botUsername == "" { + return false + } + + entities := message.Entities + if entities == nil { + entities = message.CaptionEntities + } + + for _, entity := range entities { + if entity.Type == "mention" { + // Extract the mention text from the message + text := message.Text + if text == "" { + text = message.Caption + } + runes := []rune(text) + end := entity.Offset + entity.Length + if end <= len(runes) { + mention := string(runes[entity.Offset:end]) + if strings.EqualFold(mention, "@"+botUsername) { + return true + } + } + } + if entity.Type == "text_mention" && entity.User != nil { + if entity.User.Username == botUsername { + return true + } + } + } + return false +} + +// stripBotMention removes the @bot mention from the content. +func (c *TelegramChannel) stripBotMention(content string) string { + botUsername := c.bot.Username() + if botUsername == "" { + return content + } + // Case-insensitive replacement + re := regexp.MustCompile(`(?i)@` + regexp.QuoteMeta(botUsername)) + content = re.ReplaceAllString(content, "") + return strings.TrimSpace(content) +} diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 4c2a4d326..53b53ffb8 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -122,7 +122,10 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) ( return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required") } - base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048)) + base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(2048), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &WeComAppChannel{ BaseChannel: base, diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index d5912bddc..7ffe4734b 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -86,7 +86,10 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We return nil, fmt.Errorf("wecom token and webhook_url are required") } - base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048)) + base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(2048), + channels.WithGroupTrigger(cfg.GroupTrigger), + ) return &WeComBotChannel{ BaseChannel: base, @@ -367,6 +370,15 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag // Build metadata peer := bus.Peer{Kind: peerKind, ID: peerID} + // In group chats, apply unified group trigger filtering + if isGroupChat { + respond, cleaned := c.ShouldRespondInGroup(false, content) + if !respond { + return + } + content = cleaned + } + metadata := map[string]string{ "msg_type": msg.MsgType, "msg_id": msg.MsgID, diff --git a/pkg/config/config.go b/pkg/config/config.go index 16559a2df..0c89d05eb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -204,6 +204,23 @@ type ChannelsConfig struct { WeComApp WeComAppConfig `json:"wecom_app"` } +// GroupTriggerConfig controls when the bot responds in group chats. +type GroupTriggerConfig struct { + MentionOnly bool `json:"mention_only,omitempty"` + Prefixes []string `json:"prefixes,omitempty"` +} + +// TypingConfig controls typing indicator behavior (Phase 10). +type TypingConfig struct { + Enabled bool `json:"enabled,omitempty"` +} + +// PlaceholderConfig controls placeholder message behavior (Phase 10). +type PlaceholderConfig struct { + Enabled bool `json:"enabled,omitempty"` + Text string `json:"text,omitempty"` +} + type WhatsAppConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` @@ -211,26 +228,33 @@ type WhatsAppConfig struct { } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` - VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` - MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type MaixCamConfig struct { @@ -241,69 +265,82 @@ type MaixCamConfig struct { } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` - ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type LINEConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` - ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` - ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` + ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` + ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type OneBotConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` - WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` - ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` - GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"` + WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"` + ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"` + GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type WeComConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` - WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` + WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } type WeComAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` - CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` - CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` - AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` + CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` + CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` + AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } type HeartbeatConfig struct { @@ -536,6 +573,9 @@ func LoadConfig(path string) (*Config, error) { return nil, err } + // Migrate legacy channel config fields to new unified structures + cfg.migrateChannelConfigs() + // Auto-migrate: if only legacy providers config exists, convert to model_list if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() { cfg.ModelList = ConvertProvidersToModelList(cfg) @@ -549,6 +589,18 @@ func LoadConfig(path string) (*Config, error) { return cfg, nil } +func (c *Config) migrateChannelConfigs() { + // Discord: mention_only -> group_trigger.mention_only + if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly { + c.Channels.Discord.GroupTrigger.MentionOnly = true + } + + // OneBot: group_trigger_prefix -> group_trigger.prefixes + if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 { + c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix + } +} + func SaveConfig(path string, cfg *Config) error { data, err := json.MarshalIndent(cfg, "", " ") if err != nil { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index cf799140d..5c53a3963 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -80,6 +80,7 @@ func DefaultConfig() *Config { WebhookPort: 18791, WebhookPath: "/webhook/line", AllowFrom: FlexibleStringSlice{}, + GroupTrigger: GroupTriggerConfig{MentionOnly: true}, }, OneBot: OneBotConfig{ Enabled: false, From c1ed163e77f23e2e81f99e4952907dbecc89de23 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 12:29:27 -0800 Subject: [PATCH 019/104] Added a native WhatsApp channel implementation. --- .goreleaser.yaml | 2 +- Makefile | 19 +++ README.md | 41 +++++- config/config.example.json | 2 + docs/troubleshooting.md | 43 ++++++ go.mod | 25 ++++ go.sum | 98 +++++++++++++ pkg/agent/loop.go | 32 ++++- pkg/channels/manager.go | 24 +++- pkg/channels/whatsapp_native.go | 235 ++++++++++++++++++++++++++++++++ pkg/config/config.go | 8 +- pkg/config/defaults.go | 8 +- pkg/migrate/config.go | 6 + 13 files changed, 531 insertions(+), 12 deletions(-) create mode 100644 docs/troubleshooting.md create mode 100644 pkg/channels/whatsapp_native.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index af26509e6..cc221c2fd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -31,7 +31,7 @@ builds: - loong64 - arm goarm: - - "7" + - "7" main: ./cmd/picoclaw ignore: - goos: windows diff --git a/Makefile b/Makefile index a14723616..ba8168617 100644 --- a/Makefile +++ b/Makefile @@ -87,11 +87,30 @@ build: generate @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) +## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit) +build-linux-arm: generate + @echo "Building for linux/arm (GOARM=7)..." + @mkdir -p $(BUILD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm" + +## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit) +build-linux-arm64: generate + @echo "Building for linux/arm64..." + @mkdir -p $(BUILD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + @echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64" + +## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit) +build-pi-zero: build-linux-arm build-linux-arm64 + @echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)" + ## build-all: Build picoclaw for all platforms build-all: generate @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) diff --git a/README.md b/README.md index 72a933b6f..35c62434f 100644 --- a/README.md +++ b/README.md @@ -154,10 +154,15 @@ make build # Build for multiple platforms make build-all +# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64) +make build-pi-zero + # Build And Install make install ``` +**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both. + ## 🐳 Docker Compose You can also run PicoClaw using Docker Compose without installing anything locally. @@ -284,12 +289,13 @@ That's it! You have a working AI assistant in 2 minutes. ## 💬 Chat Apps -Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom +Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom | Channel | Setup | | ------------ | ---------------------------------- | | **Telegram** | Easy (just a token) | | **Discord** | Easy (bot token + intents) | +| **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **QQ** | Easy (AppID + AppSecret) | | **DingTalk** | Medium (app credentials) | | **LINE** | Medium (credentials + webhook URL) | @@ -380,6 +386,33 @@ picoclaw gateway +
+WhatsApp (native via whatsmeow) + +PicoClaw can connect to WhatsApp in two ways: + +- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). +- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. + +**Configure (native)** + +```json +{ + "channels": { + "whatsapp": { + "enabled": true, + "use_native": true, + "session_store_path": "", + "allow_from": [] + } + } +} +``` + +If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices. + +
+
QQ @@ -1066,7 +1099,11 @@ picoclaw agent -m "Hello" "allow_from": [""] }, "whatsapp": { - "enabled": false + "enabled": false, + "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", + "allow_from": [] }, "feishu": { "enabled": false, diff --git a/config/config.example.json b/config/config.example.json index 9575039f8..2a2fcc149 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -75,6 +75,8 @@ "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", + "use_native": false, + "session_store_path": "", "allow_from": [] }, "feishu": { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 000000000..219d2c6e3 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,43 @@ +# Troubleshooting + +## "model ... not found in model_list" or OpenRouter "free is not a valid model ID" + +**Symptom:** You see either: + +- `Error creating provider: model "openrouter/free" not found in model_list` +- OpenRouter returns 400: `"free is not a valid model ID"` + +**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand. + +- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it. +- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing). + +**Fix:** In `~/.picoclaw/config.json` (or your config path): + +1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`). +2. That entry’s **model** must be a valid OpenRouter model ID, for example: + - `"openrouter/free"` – auto free-tier + - `"google/gemini-2.0-flash-exp:free"` + - `"meta-llama/llama-3.1-8b-instruct:free"` + +Example snippet: + +```json +{ + "agents": { + "defaults": { + "model": "openrouter-free" + } + }, + "model_list": [ + { + "model_name": "openrouter-free", + "model": "openrouter/free", + "api_key": "sk-or-v1-YOUR_OPENROUTER_KEY", + "api_base": "https://openrouter.ai/api/v1" + } + ] +} +``` + +Get your key at [OpenRouter Keys](https://openrouter.ai/keys). diff --git a/go.mod b/go.mod index 9bca4c127..d7f9b1901 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/openai/openai-go/v3 v3.22.0 @@ -18,16 +19,40 @@ require ( github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 + go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 golang.org/x/oauth2 v0.35.0 golang.org/x/time v0.14.0 + google.golang.org/protobuf v1.36.11 + modernc.org/sqlite v1.46.1 ) require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/beeper/argo-go v1.1.2 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect + go.mau.fi/libsignal v0.2.1 // indirect + go.mau.fi/util v0.9.6 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + rsc.io/qr v0.2.0 // indirect ) require ( diff --git a/go.sum b/go.sum index dfb477e51..941ab67ce 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,20 @@ cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= +github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -25,12 +35,19 @@ github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= +github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= @@ -42,6 +59,7 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -63,6 +81,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -72,6 +92,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -91,8 +113,21 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= @@ -105,13 +140,23 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -153,11 +198,19 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= +github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= +go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= +go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= +go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= +go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ= +go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -171,10 +224,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -217,8 +274,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= @@ -227,6 +287,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -234,6 +296,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -243,6 +307,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -255,6 +321,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -269,3 +337,33 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 7a4e9077f..572561d0e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -209,8 +209,15 @@ func (al *AgentLoop) Run(ctx context.Context) error { ChatID: msg.ChatID, Content: response, }) + logger.InfoCF("agent", "Published outbound response", + map[string]any{ + "channel": msg.Channel, + "chat_id": msg.ChatID, + "content_len": len(response), + }) + } else { + logger.DebugCF("agent", "Skipped outbound (message tool already sent)", map[string]any{"channel": msg.Channel}) } - } }() } } @@ -308,8 +315,14 @@ func (al *AgentLoop) ProcessDirectWithChannel( // ProcessHeartbeat processes a heartbeat request without session history. // Each heartbeat is independent and doesn't accumulate context. +// It uses the same mutex as processMessage so heartbeat and user messages never run concurrently. func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { agent := al.registry.GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for heartbeat") + } + al.agentMu.Lock() + defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: "heartbeat", Channel: channel, @@ -362,6 +375,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) if !ok { agent = al.registry.GetDefaultAgent() } + if agent == nil { + return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) + } + + // Reset message-tool state for this round so we don't skip publishing due to a previous round. + if tool, ok := agent.Tools.Get("message"); ok { + if mt, ok := tool.(tools.ContextualTool); ok { + mt.SetContext(msg.Channel, msg.ChatID) + } + } // Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron) sessionKey := route.SessionKey @@ -376,6 +399,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "matched_by": route.MatchedBy, }) + al.agentMu.Lock() + defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: msg.Channel, @@ -428,10 +453,15 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe // Use default agent for system messages agent := al.registry.GetDefaultAgent() + if agent == nil { + return "", fmt.Errorf("no default agent for system message") + } // Use the origin session for context sessionKey := routing.BuildAgentMainSessionKey(agent.ID) + al.agentMu.Lock() + defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: originChannel, diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 38b408f5e..dfc4fd9f9 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -12,6 +12,7 @@ import ( "fmt" "math" "net/http" + "path/filepath" "sync" "time" @@ -210,8 +211,27 @@ func (m *Manager) initChannels() error { m.initChannel("telegram", "Telegram") } - if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" { - m.initChannel("whatsapp", "WhatsApp") + if m.config.Channels.WhatsApp.Enabled { + waCfg := m.config.Channels.WhatsApp + useNative := waCfg.UseNative + if useNative { + logger.DebugC("channels", "Attempting to initialize WhatsApp native channel (whatsmeow)") + storePath := waCfg.SessionStorePath + if storePath == "" { + storePath = filepath.Join(m.config.WorkspacePath(), "whatsapp") + } + ch, err := NewWhatsAppNativeChannel(waCfg, m.bus, storePath) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize WhatsApp native channel", map[string]any{ + "error": err.Error(), + }) + } else { + m.channels["whatsapp"] = ch + logger.InfoC("channels", "WhatsApp native channel enabled successfully") + } + } else if waCfg.BridgeURL != "" { + m.initChannel("whatsapp", "WhatsApp") + } } if m.config.Channels.Feishu.Enabled { diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go new file mode 100644 index 000000000..cae89bb00 --- /dev/null +++ b/pkg/channels/whatsapp_native.go @@ -0,0 +1,235 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package channels + +import ( + "context" + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/mdp/qrterminal/v3" + _ "modernc.org/sqlite" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types/events" + waLog "go.mau.fi/whatsmeow/util/log" + "google.golang.org/protobuf/proto" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/utils" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" +) + +const ( + sqliteDriver = "sqlite" + whatsappDBName = "store.db" +) + +// WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge). +type WhatsAppNativeChannel struct { + *BaseChannel + config config.WhatsAppConfig + storePath string + client *whatsmeow.Client + container *sqlstore.Container + mu sync.Mutex +} + +// NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. +// storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (*WhatsAppNativeChannel, error) { + base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) + if storePath == "" { + storePath = "whatsapp" + } + return &WhatsAppNativeChannel{ + BaseChannel: base, + config: cfg, + storePath: storePath, + }, nil +} + +func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { + log.Printf("Starting WhatsApp native channel (whatsmeow), store: %s", c.storePath) + + if err := os.MkdirAll(c.storePath, 0700); err != nil { + return fmt.Errorf("create session store dir: %w", err) + } + + dbPath := filepath.Join(c.storePath, whatsappDBName) + connStr := "file:" + dbPath + "?_foreign_keys=on" + + // Open DB and enable foreign keys explicitly (modernc.org/sqlite does not set them from URI). + db, err := sql.Open(sqliteDriver, connStr) + if err != nil { + return fmt.Errorf("open whatsapp store: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + if _, err = db.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil { + _ = db.Close() + return fmt.Errorf("enable foreign keys: %w", err) + } + + waLogger := waLog.Stdout("WhatsApp", "WARN", true) + container := sqlstore.NewWithDB(db, sqliteDriver, waLogger) + if err = container.Upgrade(ctx); err != nil { + _ = db.Close() + return fmt.Errorf("open whatsapp store: %w", err) + } + + deviceStore, err := container.GetFirstDevice(ctx) + if err != nil { + _ = container.Close() + return fmt.Errorf("get device store: %w", err) + } + + client := whatsmeow.NewClient(deviceStore, waLogger) + client.AddEventHandler(c.eventHandler) + + c.mu.Lock() + c.container = container + c.client = client + c.mu.Unlock() + + if client.Store.ID == nil { + qrChan, err := client.GetQRChannel(ctx) + if err != nil { + _ = container.Close() + return fmt.Errorf("get QR channel: %w", err) + } + if err := client.Connect(); err != nil { + _ = container.Close() + return fmt.Errorf("connect: %w", err) + } + for evt := range qrChan { + if evt.Event == "code" { + log.Println("Scan this QR code with WhatsApp (Linked Devices):") + qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + HalfBlocks: true, + }) + } else { + log.Printf("WhatsApp login event: %s", evt.Event) + } + } + } else { + if err := client.Connect(); err != nil { + _ = container.Close() + return fmt.Errorf("connect: %w", err) + } + } + + c.setRunning(true) + log.Println("WhatsApp native channel connected") + return nil +} + +func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { + log.Println("Stopping WhatsApp native channel...") + c.mu.Lock() + client := c.client + container := c.container + c.client = nil + c.container = nil + c.mu.Unlock() + + if client != nil { + client.Disconnect() + } + if container != nil { + _ = container.Close() + } + c.setRunning(false) + return nil +} + +func (c *WhatsAppNativeChannel) eventHandler(evt interface{}) { + switch v := evt.(type) { + case *events.Message: + c.handleIncoming(v) + } +} + +func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { + if evt.Message == nil { + return + } + senderID := evt.Info.Sender.String() + chatID := evt.Info.Chat.String() + content := evt.Message.GetConversation() + if content == "" && evt.Message.ExtendedTextMessage != nil { + content = evt.Message.ExtendedTextMessage.GetText() + } + + var mediaPaths []string + // Optional: resolve media to local paths if needed; for now we only forward text to the bus. + _ = mediaPaths + + metadata := make(map[string]string) + metadata["message_id"] = evt.Info.ID + if evt.Info.PushName != "" { + metadata["user_name"] = evt.Info.PushName + } + if evt.Info.Chat.Server == types.GroupServer { + metadata["peer_kind"] = "group" + metadata["peer_id"] = chatID + } else { + metadata["peer_kind"] = "direct" + metadata["peer_id"] = senderID + } + + log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50)) + c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) +} + +func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + c.mu.Lock() + client := c.client + c.mu.Unlock() + + if client == nil || !client.IsConnected() { + return fmt.Errorf("whatsapp connection not established") + } + + to, err := parseJID(msg.ChatID) + if err != nil { + return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) + } + + waMsg := &waE2E.Message{ + Conversation: proto.String(msg.Content), + } + + _, err = client.SendMessage(ctx, to, waMsg) + if err != nil { + return fmt.Errorf("send message: %w", err) + } + return nil +} + +// parseJID converts a chat ID (phone number or JID string) to types.JID. +func parseJID(s string) (types.JID, error) { + s = strings.TrimSpace(s) + if s == "" { + return types.JID{}, fmt.Errorf("empty chat id") + } + if strings.Contains(s, "@") { + return types.ParseJID(s) + } + // Assume phone number for user chat. + return types.NewJID(s, types.DefaultUserServer), nil +} diff --git a/pkg/config/config.go b/pkg/config/config.go index bdd4d8823..9a6fda58a 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -223,9 +223,11 @@ type PlaceholderConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` + SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } type TelegramConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index d19ce1d38..aa7f6de98 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -25,9 +25,11 @@ func DefaultConfig() *Config { }, Channels: ChannelsConfig{ WhatsApp: WhatsAppConfig{ - Enabled: false, - BridgeURL: "ws://localhost:3001", - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + BridgeURL: "ws://localhost:3001", + UseNative: false, + SessionStorePath: "", + AllowFrom: FlexibleStringSlice{}, }, Telegram: TelegramConfig{ Enabled: false, diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 869b39827..ea91565e8 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -165,6 +165,12 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) { if v, ok := getString(cMap, "bridge_url"); ok { cfg.Channels.WhatsApp.BridgeURL = v } + if v, ok := getBool(cMap, "use_native"); ok { + cfg.Channels.WhatsApp.UseNative = v + } + if v, ok := getString(cMap, "session_store_path"); ok { + cfg.Channels.WhatsApp.SessionStorePath = v + } case "feishu": cfg.Channels.Feishu.Enabled = enabled cfg.Channels.Feishu.AllowFrom = allowFrom From 60b68b305a1eb61057945ed6efe2b2ef2b3264ee Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 04:55:15 +0800 Subject: [PATCH 020/104] feat(channels): add typing/placeholder automation and Pico Protocol channel (Phase 10 + 7) Phase 10: Define TypingCapable, MessageEditor, PlaceholderRecorder interfaces. Manager orchestrates outbound typing stop and placeholder editing via preSend. Migrate Telegram, Discord, Slack, OneBot to register state with Manager instead of handling locally in Send. Phase 7: Add native WebSocket Pico Protocol channel as reference implementation of all optional capability interfaces. --- cmd/picoclaw/internal/gateway/helpers.go | 1 + pkg/channels/base.go | 27 +- pkg/channels/discord/discord.go | 14 +- pkg/channels/interfaces.go | 24 ++ pkg/channels/manager.go | 56 +++ pkg/channels/manager_test.go | 216 ++++++++++++ pkg/channels/onebot/onebot.go | 13 +- pkg/channels/pico/init.go | 13 + pkg/channels/pico/pico.go | 430 +++++++++++++++++++++++ pkg/channels/pico/protocol.go | 46 +++ pkg/channels/slack/slack.go | 24 ++ pkg/channels/telegram/telegram.go | 113 +++--- pkg/config/config.go | 12 + pkg/config/defaults.go | 14 + 14 files changed, 913 insertions(+), 90 deletions(-) create mode 100644 pkg/channels/interfaces.go create mode 100644 pkg/channels/pico/init.go create mode 100644 pkg/channels/pico/pico.go create mode 100644 pkg/channels/pico/protocol.go diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 758157f53..6ac41fab1 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -18,6 +18,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/onebot" + _ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/qq" _ "github.com/sipeed/picoclaw/pkg/channels/slack" _ "github.com/sipeed/picoclaw/pkg/channels/telegram" diff --git a/pkg/channels/base.go b/pkg/channels/base.go index e345aedf0..c22a27eb9 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -44,14 +44,15 @@ type MessageLengthProvider interface { } type BaseChannel struct { - config any - bus *bus.MessageBus - running atomic.Bool - name string - allowList []string - maxMessageLength int - groupTrigger config.GroupTriggerConfig - mediaStore media.MediaStore + config any + bus *bus.MessageBus + running atomic.Bool + name string + allowList []string + maxMessageLength int + groupTrigger config.GroupTriggerConfig + mediaStore media.MediaStore + placeholderRecorder PlaceholderRecorder } func NewBaseChannel( @@ -203,6 +204,16 @@ func (c *BaseChannel) SetMediaStore(s media.MediaStore) { c.mediaStore = s } // GetMediaStore returns the injected MediaStore (may be nil). func (c *BaseChannel) GetMediaStore() media.MediaStore { return c.mediaStore } +// SetPlaceholderRecorder injects a PlaceholderRecorder into the channel. +func (c *BaseChannel) SetPlaceholderRecorder(r PlaceholderRecorder) { + c.placeholderRecorder = r +} + +// GetPlaceholderRecorder returns the injected PlaceholderRecorder (may be nil). +func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder { + return c.placeholderRecorder +} + // BuildMediaScope constructs a scope key for media lifecycle tracking. func BuildMediaScope(channel, chatID, messageID string) string { id := messageID diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 4ef4906c1..ee698da61 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -106,8 +106,6 @@ func (c *DiscordChannel) Stop(ctx context.Context) error { } func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { - c.stopTyping(msg.ChatID) - if !c.IsRunning() { return channels.ErrNotRunning } @@ -126,8 +124,6 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro // SendMedia implements the channels.MediaSender interface. func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { - c.stopTyping(msg.ChatID) - if !c.IsRunning() { return channels.ErrNotRunning } @@ -221,6 +217,12 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes } } +// EditMessage implements channels.MessageEditor. +func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { + _, err := c.session.ChannelMessageEdit(chatID, messageID, content) + return err +} + func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) @@ -350,6 +352,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag // Start typing after all early returns — guaranteed to have a matching Send() c.startTyping(m.ChannelID) + // Register typing stop with Manager for outbound orchestration + if rec := c.GetPlaceholderRecorder(); rec != nil { + rec.RecordTypingStop("discord", m.ChannelID, func() { c.stopTyping(m.ChannelID) }) + } logger.DebugCF("discord", "Received message", map[string]any{ "sender_name": senderName, diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go new file mode 100644 index 000000000..32bfe95f8 --- /dev/null +++ b/pkg/channels/interfaces.go @@ -0,0 +1,24 @@ +package channels + +import "context" + +// TypingCapable — channels that can show a typing/thinking indicator. +// StartTyping begins the indicator and returns a stop function. +// The stop function MUST be idempotent and safe to call multiple times. +type TypingCapable interface { + StartTyping(ctx context.Context, chatID string) (stop func(), err error) +} + +// MessageEditor — channels that can edit an existing message. +// messageID is always string; channels convert platform-specific types internally. +type MessageEditor interface { + EditMessage(ctx context.Context, chatID string, messageID string, content string) error +} + +// PlaceholderRecorder is injected into channels by Manager. +// Channels call these methods on inbound to register typing/placeholder state. +// Manager uses the registered state on outbound to stop typing and edit placeholders. +type PlaceholderRecorder interface { + RecordPlaceholder(channel, chatID, placeholderID string) + RecordTypingStop(channel, chatID string, stop func()) +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 92412edeb..4b1a43b7b 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -62,12 +62,55 @@ type Manager struct { mux *http.ServeMux httpServer *http.Server mu sync.RWMutex + placeholders sync.Map // "channel:chatID" → placeholderID (string) + typingStops sync.Map // "channel:chatID" → func() } type asyncTask struct { cancel context.CancelFunc } +// RecordPlaceholder registers a placeholder message for later editing. +// Implements PlaceholderRecorder. +func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { + key := channel + ":" + chatID + m.placeholders.Store(key, placeholderID) +} + +// RecordTypingStop registers a typing stop function for later invocation. +// Implements PlaceholderRecorder. +func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { + key := channel + ":" + chatID + m.typingStops.Store(key, stop) +} + +// preSend handles typing stop and placeholder editing before sending a message. +// Returns true if the message was edited into a placeholder (skip Send). +func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { + key := name + ":" + msg.ChatID + + // 1. Stop typing + if v, loaded := m.typingStops.LoadAndDelete(key); loaded { + if stop, ok := v.(func()); ok { + stop() // idempotent, safe + } + } + + // 2. Try editing placeholder + if v, loaded := m.placeholders.LoadAndDelete(key); loaded { + if placeholderID, ok := v.(string); ok && placeholderID != "" { + if editor, ok := ch.(MessageEditor); ok { + if err := editor.EditMessage(ctx, msg.ChatID, placeholderID, msg.Content); err == nil { + return true // edited successfully, skip Send + } + // edit failed → fall through to normal Send + } + } + } + + return false +} + func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { m := &Manager{ channels: make(map[string]Channel), @@ -109,6 +152,10 @@ func (m *Manager) initChannel(name, displayName string) { setter.SetMediaStore(m.mediaStore) } } + // Inject PlaceholderRecorder if channel supports it + if setter, ok := ch.(interface{ SetPlaceholderRecorder(PlaceholderRecorder) }); ok { + setter.SetPlaceholderRecorder(m) + } m.channels[name] = ch m.workers[name] = newChannelWorker(name, ch) logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ @@ -168,6 +215,10 @@ func (m *Manager) initChannels() error { m.initChannel("wecom_app", "WeCom App") } + if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" { + m.initChannel("pico", "Pico") + } + logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) @@ -383,6 +434,11 @@ func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWork return } + // Pre-send: stop typing and try to edit placeholder + if m.preSend(ctx, name, msg, w.ch) { + return // placeholder was edited successfully, skip Send + } + var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { lastErr = w.ch.Send(ctx, msg) diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 162c9f8c9..0573c0a8e 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -416,3 +416,219 @@ func TestSendWithRetry_ExponentialBackoff(t *testing.T) { t.Fatalf("expected %d calls, got %d", maxRetries+1, callCount.Load()) } } + +// --- Phase 10: preSend orchestration tests --- + +// mockMessageEditor is a channel that supports MessageEditor. +type mockMessageEditor struct { + mockChannel + editFn func(ctx context.Context, chatID, messageID, content string) error +} + +func (m *mockMessageEditor) EditMessage(ctx context.Context, chatID, messageID, content string) error { + return m.editFn(ctx, chatID, messageID, content) +} + +func TestPreSend_PlaceholderEditSuccess(t *testing.T) { + m := newTestManager() + var sendCalled bool + var editCalled bool + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + sendCalled = true + return nil + }, + }, + editFn: func(_ context.Context, chatID, messageID, content string) error { + editCalled = true + if chatID != "123" { + t.Fatalf("expected chatID 123, got %s", chatID) + } + if messageID != "456" { + t.Fatalf("expected messageID 456, got %s", messageID) + } + if content != "hello" { + t.Fatalf("expected content 'hello', got %s", content) + } + return nil + }, + } + + // Register placeholder + m.RecordPlaceholder("test", "123", "456") + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if !edited { + t.Fatal("expected preSend to return true (placeholder edited)") + } + if !editCalled { + t.Fatal("expected EditMessage to be called") + } + if sendCalled { + t.Fatal("expected Send to NOT be called when placeholder edited") + } +} + +func TestPreSend_PlaceholderEditFails_FallsThrough(t *testing.T) { + m := newTestManager() + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + return fmt.Errorf("edit failed") + }, + } + + m.RecordPlaceholder("test", "123", "456") + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if edited { + t.Fatal("expected preSend to return false when edit fails") + } +} + +func TestPreSend_TypingStopCalled(t *testing.T) { + m := newTestManager() + var stopCalled bool + + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + } + + m.RecordTypingStop("test", "123", func() { + stopCalled = true + }) + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + m.preSend(context.Background(), "test", msg, ch) + + if !stopCalled { + t.Fatal("expected typing stop func to be called") + } +} + +func TestPreSend_NoRegisteredState(t *testing.T) { + m := newTestManager() + + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + } + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if edited { + t.Fatal("expected preSend to return false with no registered state") + } +} + +func TestPreSend_TypingAndPlaceholder(t *testing.T) { + m := newTestManager() + var stopCalled bool + var editCalled bool + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + editCalled = true + return nil + }, + } + + m.RecordTypingStop("test", "123", func() { + stopCalled = true + }) + m.RecordPlaceholder("test", "123", "456") + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if !stopCalled { + t.Fatal("expected typing stop to be called") + } + if !editCalled { + t.Fatal("expected EditMessage to be called") + } + if !edited { + t.Fatal("expected preSend to return true") + } +} + +func TestRecordPlaceholder_ConcurrentSafe(t *testing.T) { + m := newTestManager() + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + chatID := fmt.Sprintf("chat_%d", i%10) + m.RecordPlaceholder("test", chatID, fmt.Sprintf("msg_%d", i)) + }(i) + } + wg.Wait() +} + +func TestRecordTypingStop_ConcurrentSafe(t *testing.T) { + m := newTestManager() + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + chatID := fmt.Sprintf("chat_%d", i%10) + m.RecordTypingStop("test", chatID, func() {}) + }(i) + } + wg.Wait() +} + +func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) { + m := newTestManager() + var sendCalled bool + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + sendCalled = true + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + return nil // edit succeeds + }, + } + + m.RecordPlaceholder("test", "123", "456") + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + m.sendWithRetry(context.Background(), "test", w, msg) + + if sendCalled { + t.Fatal("expected Send to NOT be called when placeholder was edited") + } +} diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index f32cb4948..682025b67 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -418,12 +418,6 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("onebot send: %w", channels.ErrTemporary) } - if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok { - if mid, ok := msgID.(string); ok && mid != "" { - c.setMsgEmojiLike(mid, 289, false) - } - } - return nil } @@ -1037,6 +1031,13 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { if raw.MessageType == "group" && messageID != "" && messageID != "0" { c.setMsgEmojiLike(messageID, 289, true) c.pendingEmojiMsg.Store(chatID, messageID) + // Register emoji stop with Manager for outbound orchestration + if rec := c.GetPlaceholderRecorder(); rec != nil { + capturedMsgID := messageID + rec.RecordTypingStop("onebot", chatID, func() { + c.setMsgEmojiLike(capturedMsgID, 289, false) + }) + } } c.HandleMessage(peer, messageID, senderID, chatID, content, parsed.Media, metadata) diff --git a/pkg/channels/pico/init.go b/pkg/channels/pico/init.go new file mode 100644 index 000000000..96d764418 --- /dev/null +++ b/pkg/channels/pico/init.go @@ -0,0 +1,13 @@ +package pico + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("pico", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewPicoChannel(cfg.Channels.Pico, b) + }) +} diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go new file mode 100644 index 000000000..1c28ca732 --- /dev/null +++ b/pkg/channels/pico/pico.go @@ -0,0 +1,430 @@ +package pico + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// picoConn represents a single WebSocket connection. +type picoConn struct { + id string + conn *websocket.Conn + sessionID string + writeMu sync.Mutex + closed atomic.Bool +} + +// writeJSON sends a JSON message to the connection with write locking. +func (pc *picoConn) writeJSON(v any) error { + if pc.closed.Load() { + return fmt.Errorf("connection closed") + } + pc.writeMu.Lock() + defer pc.writeMu.Unlock() + return pc.conn.WriteJSON(v) +} + +// close closes the connection. +func (pc *picoConn) close() { + if pc.closed.CompareAndSwap(false, true) { + pc.conn.Close() + } +} + +// PicoChannel implements the native Pico Protocol WebSocket channel. +// It serves as the reference implementation for all optional capability interfaces. +type PicoChannel struct { + *channels.BaseChannel + config config.PicoConfig + upgrader websocket.Upgrader + connections sync.Map // connID → *picoConn + connCount atomic.Int32 + ctx context.Context + cancel context.CancelFunc +} + +// NewPicoChannel creates a new Pico Protocol channel. +func NewPicoChannel(cfg config.PicoConfig, messageBus *bus.MessageBus) (*PicoChannel, error) { + if cfg.Token == "" { + return nil, fmt.Errorf("pico token is required") + } + + base := channels.NewBaseChannel("pico", cfg, messageBus, cfg.AllowFrom) + + allowOrigins := cfg.AllowOrigins + checkOrigin := func(r *http.Request) bool { + if len(allowOrigins) == 0 { + return true // allow all if not configured + } + origin := r.Header.Get("Origin") + for _, allowed := range allowOrigins { + if allowed == "*" || allowed == origin { + return true + } + } + return false + } + + return &PicoChannel{ + BaseChannel: base, + config: cfg, + upgrader: websocket.Upgrader{ + CheckOrigin: checkOrigin, + ReadBufferSize: 1024, + WriteBufferSize: 1024, + }, + }, nil +} + +// Start implements Channel. +func (c *PicoChannel) Start(ctx context.Context) error { + logger.InfoC("pico", "Starting Pico Protocol channel") + c.ctx, c.cancel = context.WithCancel(ctx) + c.SetRunning(true) + logger.InfoC("pico", "Pico Protocol channel started") + return nil +} + +// Stop implements Channel. +func (c *PicoChannel) Stop(ctx context.Context) error { + logger.InfoC("pico", "Stopping Pico Protocol channel") + c.SetRunning(false) + + // Close all connections + c.connections.Range(func(key, value any) bool { + if pc, ok := value.(*picoConn); ok { + pc.close() + } + c.connections.Delete(key) + return true + }) + + if c.cancel != nil { + c.cancel() + } + + logger.InfoC("pico", "Pico Protocol channel stopped") + return nil +} + +// WebhookPath implements channels.WebhookHandler. +func (c *PicoChannel) WebhookPath() string { return "/pico/" } + +// ServeHTTP implements http.Handler for the shared HTTP server. +func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/pico") + + switch { + case path == "/ws" || path == "/ws/": + c.handleWebSocket(w, r) + default: + http.NotFound(w, r) + } +} + +// Send implements Channel — sends a message to the appropriate WebSocket connection. +func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + outMsg := newMessage(TypeMessageCreate, map[string]any{ + "content": msg.Content, + }) + + return c.broadcastToSession(msg.ChatID, outMsg) +} + +// EditMessage implements channels.MessageEditor. +func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { + outMsg := newMessage(TypeMessageUpdate, map[string]any{ + "message_id": messageID, + "content": content, + }) + return c.broadcastToSession(chatID, outMsg) +} + +// StartTyping implements channels.TypingCapable. +func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + startMsg := newMessage(TypeTypingStart, nil) + if err := c.broadcastToSession(chatID, startMsg); err != nil { + return func() {}, err + } + return func() { + stopMsg := newMessage(TypeTypingStop, nil) + c.broadcastToSession(chatID, stopMsg) + }, nil +} + +// broadcastToSession sends a message to all connections with a matching session. +func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { + // chatID format: "pico:" + sessionID := strings.TrimPrefix(chatID, "pico:") + msg.SessionID = sessionID + + var sent bool + c.connections.Range(func(key, value any) bool { + pc, ok := value.(*picoConn) + if !ok { + return true + } + if pc.sessionID == sessionID { + if err := pc.writeJSON(msg); err != nil { + logger.DebugCF("pico", "Write to connection failed", map[string]any{ + "conn_id": pc.id, + "error": err.Error(), + }) + } else { + sent = true + } + } + return true + }) + + if !sent { + return fmt.Errorf("no active connections for session %s: %w", sessionID, channels.ErrSendFailed) + } + return nil +} + +// handleWebSocket upgrades the HTTP connection and manages the WebSocket lifecycle. +func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { + if !c.IsRunning() { + http.Error(w, "channel not running", http.StatusServiceUnavailable) + return + } + + // Authenticate + if !c.authenticate(r) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Check connection limit + maxConns := c.config.MaxConnections + if maxConns <= 0 { + maxConns = 100 + } + if int(c.connCount.Load()) >= maxConns { + http.Error(w, "too many connections", http.StatusServiceUnavailable) + return + } + + conn, err := c.upgrader.Upgrade(w, r, nil) + if err != nil { + logger.ErrorCF("pico", "WebSocket upgrade failed", map[string]any{ + "error": err.Error(), + }) + return + } + + // Determine session ID from query param or generate one + sessionID := r.URL.Query().Get("session_id") + if sessionID == "" { + sessionID = uuid.New().String() + } + + pc := &picoConn{ + id: uuid.New().String(), + conn: conn, + sessionID: sessionID, + } + + c.connections.Store(pc.id, pc) + c.connCount.Add(1) + + logger.InfoCF("pico", "WebSocket client connected", map[string]any{ + "conn_id": pc.id, + "session_id": sessionID, + }) + + go c.readLoop(pc) +} + +// authenticate checks the Bearer token from header or query parameter. +func (c *PicoChannel) authenticate(r *http.Request) bool { + token := c.config.Token + if token == "" { + return false + } + + // Check Authorization header + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + if strings.TrimPrefix(auth, "Bearer ") == token { + return true + } + } + + // Check query parameter + if r.URL.Query().Get("token") == token { + return true + } + + return false +} + +// readLoop reads messages from a WebSocket connection. +func (c *PicoChannel) readLoop(pc *picoConn) { + defer func() { + pc.close() + c.connections.Delete(pc.id) + c.connCount.Add(-1) + logger.InfoCF("pico", "WebSocket client disconnected", map[string]any{ + "conn_id": pc.id, + "session_id": pc.sessionID, + }) + }() + + readTimeout := time.Duration(c.config.ReadTimeout) * time.Second + if readTimeout <= 0 { + readTimeout = 60 * time.Second + } + + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + pc.conn.SetPongHandler(func(appData string) error { + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + return nil + }) + + // Start ping ticker + pingInterval := time.Duration(c.config.PingInterval) * time.Second + if pingInterval <= 0 { + pingInterval = 30 * time.Second + } + go c.pingLoop(pc, pingInterval) + + for { + select { + case <-c.ctx.Done(): + return + default: + } + + _, rawMsg, err := pc.conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + logger.DebugCF("pico", "WebSocket read error", map[string]any{ + "conn_id": pc.id, + "error": err.Error(), + }) + } + return + } + + _ = pc.conn.SetReadDeadline(time.Now().Add(readTimeout)) + + var msg PicoMessage + if err := json.Unmarshal(rawMsg, &msg); err != nil { + errMsg := newError("invalid_message", "failed to parse message") + pc.writeJSON(errMsg) + continue + } + + c.handleMessage(pc, msg) + } +} + +// pingLoop sends periodic ping frames to keep the connection alive. +func (c *PicoChannel) pingLoop(pc *picoConn, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-c.ctx.Done(): + return + case <-ticker.C: + if pc.closed.Load() { + return + } + pc.writeMu.Lock() + err := pc.conn.WriteMessage(websocket.PingMessage, nil) + pc.writeMu.Unlock() + if err != nil { + return + } + } + } +} + +// handleMessage processes an inbound Pico Protocol message. +func (c *PicoChannel) handleMessage(pc *picoConn, msg PicoMessage) { + switch msg.Type { + case TypePing: + pong := newMessage(TypePong, nil) + pong.ID = msg.ID + pc.writeJSON(pong) + + case TypeMessageSend: + c.handleMessageSend(pc, msg) + + default: + errMsg := newError("unknown_type", fmt.Sprintf("unknown message type: %s", msg.Type)) + pc.writeJSON(errMsg) + } +} + +// handleMessageSend processes an inbound message.send from a client. +func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { + content, _ := msg.Payload["content"].(string) + if strings.TrimSpace(content) == "" { + errMsg := newError("empty_content", "message content is empty") + pc.writeJSON(errMsg) + return + } + + sessionID := msg.SessionID + if sessionID == "" { + sessionID = pc.sessionID + } + + chatID := "pico:" + sessionID + senderID := "pico-user" + + peer := bus.Peer{Kind: "direct", ID: "pico:" + sessionID} + + metadata := map[string]string{ + "platform": "pico", + "session_id": sessionID, + "conn_id": pc.id, + } + + logger.DebugCF("pico", "Received message", map[string]any{ + "session_id": sessionID, + "preview": truncate(content, 50), + }) + + // Register typing with Manager + if rec := c.GetPlaceholderRecorder(); rec != nil { + stop, err := c.StartTyping(c.ctx, chatID) + if err == nil { + rec.RecordTypingStop("pico", chatID, stop) + } + } + + c.HandleMessage(peer, msg.ID, senderID, chatID, content, nil, metadata) +} + +// truncate truncates a string to maxLen runes. +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen]) + "..." +} diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go new file mode 100644 index 000000000..ca18df1dd --- /dev/null +++ b/pkg/channels/pico/protocol.go @@ -0,0 +1,46 @@ +package pico + +import "time" + +// Protocol message types. +const ( + // Client → Server + TypeMessageSend = "message.send" + TypeMediaSend = "media.send" + TypePing = "ping" + + // Server → Client + TypeMessageCreate = "message.create" + TypeMessageUpdate = "message.update" + TypeMediaCreate = "media.create" + TypeTypingStart = "typing.start" + TypeTypingStop = "typing.stop" + TypeError = "error" + TypePong = "pong" +) + +// PicoMessage is the wire format for all Pico Protocol messages. +type PicoMessage struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + SessionID string `json:"session_id,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` + Payload map[string]any `json:"payload,omitempty"` +} + +// newMessage creates a PicoMessage with the given type and payload. +func newMessage(msgType string, payload map[string]any) PicoMessage { + return PicoMessage{ + Type: msgType, + Timestamp: time.Now().UnixMilli(), + Payload: payload, + } +} + +// newError creates an error PicoMessage. +func newError(code, message string) PicoMessage { + return newMessage(TypeError, map[string]any{ + "code": code, + "message": message, + }) +} diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 6fba2e0b4..e64525310 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -274,6 +274,18 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { Timestamp: messageTS, }) + // Register typing stop (remove "eyes" reaction) with Manager + if rec := c.GetPlaceholderRecorder(); rec != nil { + capturedChannelID := channelID + capturedMessageTS := messageTS + rec.RecordTypingStop("slack", chatID, func() { + c.api.RemoveReaction("eyes", slack.ItemRef{ + Channel: capturedChannelID, + Timestamp: capturedMessageTS, + }) + }) + } + c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, @@ -380,6 +392,18 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { Timestamp: messageTS, }) + // Register typing stop (remove "eyes" reaction) with Manager + if rec := c.GetPlaceholderRecorder(); rec != nil { + capturedChannelID := channelID + capturedMessageTS := messageTS + rec.RecordTypingStop("slack", chatID, func() { + c.api.RemoveReaction("eyes", slack.ItemRef{ + Channel: capturedChannelID, + Timestamp: capturedMessageTS, + }) + }) + } + c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index c5c055163..98477f3a8 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -7,8 +7,8 @@ import ( "net/url" "os" "regexp" + "strconv" "strings" - "sync" "time" "github.com/mymmrac/telego" @@ -26,25 +26,13 @@ import ( type TelegramChannel struct { *channels.BaseChannel - bot *telego.Bot - bh *telegohandler.BotHandler - commands TelegramCommander - config *config.Config - chatIDs map[string]int64 - ctx context.Context - cancel context.CancelFunc - placeholders sync.Map // chatID -> messageID - stopThinking sync.Map // chatID -> thinkingCancel -} - -type thinkingCancel struct { - fn context.CancelFunc -} - -func (c *thinkingCancel) Cancel() { - if c != nil && c.fn != nil { - c.fn() - } + bot *telego.Bot + bh *telegohandler.BotHandler + commands TelegramCommander + config *config.Config + chatIDs map[string]int64 + ctx context.Context + cancel context.CancelFunc } func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { @@ -85,13 +73,11 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann ) return &TelegramChannel{ - BaseChannel: base, - commands: NewTelegramCommands(bot, cfg), - bot: bot, - config: cfg, - chatIDs: make(map[string]int64), - placeholders: sync.Map{}, - stopThinking: sync.Map{}, + BaseChannel: base, + commands: NewTelegramCommands(bot, cfg), + bot: bot, + config: cfg, + chatIDs: make(map[string]int64), }, nil } @@ -149,21 +135,6 @@ func (c *TelegramChannel) Stop(ctx context.Context) error { logger.InfoC("telegram", "Stopping Telegram bot...") c.SetRunning(false) - // Clean up all thinking cancel functions to avoid context leaks - c.stopThinking.Range(func(key, value any) bool { - if cf, ok := value.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - c.stopThinking.Delete(key) - return true - }) - - // Clean up placeholder state - c.placeholders.Range(func(key, value any) bool { - c.placeholders.Delete(key) - return true - }) - // Stop the bot handler if c.bh != nil { c.bh.Stop() @@ -187,28 +158,9 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } - // Stop thinking animation - if stop, ok := c.stopThinking.Load(msg.ChatID); ok { - if cf, ok := stop.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - c.stopThinking.Delete(msg.ChatID) - } - htmlContent := markdownToTelegramHTML(msg.Content) - // Try to edit placeholder - if pID, ok := c.placeholders.Load(msg.ChatID); ok { - c.placeholders.Delete(msg.ChatID) - editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) - editMsg.ParseMode = telego.ModeHTML - - if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { - return nil - } - // Fallback to new message if edit fails - } - + // Typing/placeholder handled by Manager.preSend — just send the message tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML @@ -225,6 +177,23 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return nil } +// EditMessage implements channels.MessageEditor. +func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { + cid, err := parseChatID(chatID) + if err != nil { + return err + } + mid, err := strconv.Atoi(messageID) + if err != nil { + return err + } + htmlContent := markdownToTelegramHTML(content) + editMsg := tu.EditMessageText(tu.ID(cid), mid, htmlContent) + editMsg.ParseMode = telego.ModeHTML + _, err = c.bot.EditMessageText(ctx, editMsg) + return err +} + // SendMedia implements the channels.MediaSender interface. func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { @@ -445,21 +414,21 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes }) } - // Stop any previous thinking animation - if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { - if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { - cf.Cancel() - } - } - - // Create cancel function for thinking state + // Create cancel function for thinking state and register with Manager _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) - c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) + if rec := c.GetPlaceholderRecorder(); rec != nil { + rec.RecordTypingStop("telegram", chatIDStr, thinkCancel) + } else { + // No recorder — cancel immediately to avoid context leak + thinkCancel() + } pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) if err == nil { pID := pMsg.MessageID - c.placeholders.Store(chatIDStr, pID) + if rec := c.GetPlaceholderRecorder(); rec != nil { + rec.RecordPlaceholder("telegram", chatIDStr, fmt.Sprintf("%d", pID)) + } } peerKind := "direct" diff --git a/pkg/config/config.go b/pkg/config/config.go index 0c89d05eb..d32e8db90 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -202,6 +202,7 @@ type ChannelsConfig struct { OneBot OneBotConfig `json:"onebot"` WeCom WeComConfig `json:"wecom"` WeComApp WeComAppConfig `json:"wecom_app"` + Pico PicoConfig `json:"pico"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -343,6 +344,17 @@ type WeComAppConfig struct { GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` } +type PicoConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` + AllowOrigins []string `json:"allow_origins,omitempty"` + PingInterval int `json:"ping_interval,omitempty"` // seconds, default 30 + ReadTimeout int `json:"read_timeout,omitempty"` // seconds, default 60 + WriteTimeout int `json:"write_timeout,omitempty"` // seconds, default 10 + MaxConnections int `json:"max_connections,omitempty"` // default 100 + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 5c53a3963..8445510e2 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -33,6 +33,11 @@ func DefaultConfig() *Config { Enabled: false, Token: "", AllowFrom: FlexibleStringSlice{}, + Typing: TypingConfig{Enabled: true}, + Placeholder: PlaceholderConfig{ + Enabled: true, + Text: "Thinking... 💭", + }, }, Feishu: FeishuConfig{ Enabled: false, @@ -114,6 +119,15 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, ReplyTimeout: 5, }, + Pico: PicoConfig{ + Enabled: false, + Token: "", + PingInterval: 30, + ReadTimeout: 60, + WriteTimeout: 10, + MaxConnections: 100, + AllowFrom: FlexibleStringSlice{}, + }, }, Providers: ProvidersConfig{ OpenAI: OpenAIProviderConfig{WebSearch: true}, From 5d304a9aebca2b10af84fcdcf813e1fd3d6ab261 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 05:22:18 +0800 Subject: [PATCH 021/104] fix: resolve golangci-lint issues in channel system --- pkg/channels/manager.go | 2 +- pkg/channels/pico/protocol.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 4b1a43b7b..8e72efc5c 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -153,7 +153,7 @@ func (m *Manager) initChannel(name, displayName string) { } } // Inject PlaceholderRecorder if channel supports it - if setter, ok := ch.(interface{ SetPlaceholderRecorder(PlaceholderRecorder) }); ok { + if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok { setter.SetPlaceholderRecorder(m) } m.channels[name] = ch diff --git a/pkg/channels/pico/protocol.go b/pkg/channels/pico/protocol.go index ca18df1dd..0a630e193 100644 --- a/pkg/channels/pico/protocol.go +++ b/pkg/channels/pico/protocol.go @@ -4,12 +4,12 @@ import "time" // Protocol message types. const ( - // Client → Server + // TypeMessageSend is sent from client to server. TypeMessageSend = "message.send" TypeMediaSend = "media.send" TypePing = "ping" - // Server → Client + // TypeMessageCreate is sent from server to client. TypeMessageCreate = "message.create" TypeMessageUpdate = "message.update" TypeMediaCreate = "media.create" From a7276e2632d2b8a978167b5f82abe81fb82bae7d Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 05:46:34 +0800 Subject: [PATCH 022/104] refactor(channels): move SplitMessage from pkg/utils to pkg/channels Message splitting is exclusively a Manager responsibility. Moving it into the channels package eliminates the cross-package dependency and aligns with the refactoring plan. --- pkg/channels/manager.go | 3 +-- pkg/{utils/message.go => channels/split.go} | 2 +- pkg/{utils/message_test.go => channels/split_test.go} | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) rename pkg/{utils/message.go => channels/split.go} (99%) rename pkg/{utils/message_test.go => channels/split_test.go} (99%) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 8e72efc5c..07c2ce1e2 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -23,7 +23,6 @@ import ( "github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" - "github.com/sipeed/picoclaw/pkg/utils" ) const ( @@ -407,7 +406,7 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) maxLen = mlp.MaxMessageLength() } if maxLen > 0 && len([]rune(msg.Content)) > maxLen { - chunks := utils.SplitMessage(msg.Content, maxLen) + chunks := SplitMessage(msg.Content, maxLen) for _, chunk := range chunks { chunkMsg := msg chunkMsg.Content = chunk diff --git a/pkg/utils/message.go b/pkg/channels/split.go similarity index 99% rename from pkg/utils/message.go rename to pkg/channels/split.go index 52a967f4c..a455c5741 100644 --- a/pkg/utils/message.go +++ b/pkg/channels/split.go @@ -1,4 +1,4 @@ -package utils +package channels import ( "strings" diff --git a/pkg/utils/message_test.go b/pkg/channels/split_test.go similarity index 99% rename from pkg/utils/message_test.go rename to pkg/channels/split_test.go index 78e1e2b40..d6356bdb9 100644 --- a/pkg/utils/message_test.go +++ b/pkg/channels/split_test.go @@ -1,4 +1,4 @@ -package utils +package channels import ( "strings" From f645e9a377ee348b365a23821a4dd47e13a5cc60 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 06:03:23 +0800 Subject: [PATCH 023/104] fix: address PR review feedback across channel system - MediaStore: use full UUID to prevent ref collisions, preserve and expose metadata via ResolveWithMeta, include underlying OS errors - Agent loop: populate MediaPart Type/Filename/ContentType from MediaStore metadata so channels can dispatch media correctly - SplitMessage: fix byte-vs-rune index mixup in code block header parsing, remove dead candidateStr variable - Pico auth: restrict query-param token behind AllowTokenQuery config flag (default false) to prevent token leakage via logs/referer - HandleMessage: replace context.TODO with caller-propagated ctx, log PublishInbound failures instead of silently discarding - Gateway shutdown: use fresh 15s timeout context for StopAll so graceful shutdown is not short-circuited by the cancelled parent ctx --- cmd/picoclaw/internal/gateway/helpers.go | 8 ++++- pkg/agent/loop.go | 42 +++++++++++++++++++++- pkg/channels/base.go | 10 +++++- pkg/channels/dingtalk/dingtalk.go | 2 +- pkg/channels/discord/discord.go | 2 +- pkg/channels/feishu/feishu_64.go | 4 +-- pkg/channels/line/line.go | 2 +- pkg/channels/maixcam/maixcam.go | 11 +++++- pkg/channels/onebot/onebot.go | 2 +- pkg/channels/pico/pico.go | 13 ++++--- pkg/channels/qq/qq.go | 4 +-- pkg/channels/slack/slack.go | 6 ++-- pkg/channels/split.go | 18 +++++++--- pkg/channels/telegram/telegram.go | 2 +- pkg/channels/wecom/app.go | 2 +- pkg/channels/wecom/bot.go | 2 +- pkg/channels/whatsapp/whatsapp.go | 2 +- pkg/config/config.go | 17 ++++----- pkg/media/store.go | 41 ++++++++++++++++------ pkg/media/store_test.go | 44 ++++++++++++++++++++++++ 20 files changed, 187 insertions(+), 47 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 6ac41fab1..dd93087f4 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -185,7 +185,13 @@ func gatewayCmd(debug bool) error { } cancel() msgBus.Close() - channelManager.StopAll(ctx) + + // Use a fresh context with timeout for graceful shutdown, + // since the original ctx is already cancelled. + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) + defer shutdownCancel() + + channelManager.StopAll(shutdownCtx) deviceService.Stop() heartbeatService.Stop() cronService.Stop() diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 091332d1a..99ca0eaec 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -10,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + "path/filepath" "strings" "sync" "sync/atomic" @@ -235,6 +236,36 @@ func (al *AgentLoop) SetMediaStore(s media.MediaStore) { al.mediaStore = s } +// inferMediaType determines the media type ("image", "audio", "video", "file") +// from a filename and MIME content type. +func inferMediaType(filename, contentType string) string { + ct := strings.ToLower(contentType) + fn := strings.ToLower(filename) + + if strings.HasPrefix(ct, "image/") { + return "image" + } + if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { + return "audio" + } + if strings.HasPrefix(ct, "video/") { + return "video" + } + + // Fallback: infer from extension + ext := filepath.Ext(fn) + switch ext { + case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": + return "image" + case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": + return "audio" + case ".mp4", ".avi", ".mov", ".webm", ".mkv": + return "video" + } + + return "file" +} + // RecordLastChannel records the last active channel for this workspace. // This uses the atomic state save mechanism to prevent data loss on crash. func (al *AgentLoop) RecordLastChannel(channel string) error { @@ -732,7 +763,16 @@ func (al *AgentLoop) runLLMIteration( if len(toolResult.Media) > 0 && opts.SendResponse { parts := make([]bus.MediaPart, 0, len(toolResult.Media)) for _, ref := range toolResult.Media { - parts = append(parts, bus.MediaPart{Ref: ref}) + part := bus.MediaPart{Ref: ref} + // Populate metadata from MediaStore when available + if al.mediaStore != nil { + if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) } al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ Channel: opts.Channel, diff --git a/pkg/channels/base.go b/pkg/channels/base.go index c22a27eb9..c6a5f1cdc 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -9,6 +9,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" ) @@ -168,6 +169,7 @@ func (c *BaseChannel) IsAllowed(senderID string) bool { } func (c *BaseChannel) HandleMessage( + ctx context.Context, peer bus.Peer, messageID, senderID, chatID, content string, media []string, @@ -191,7 +193,13 @@ func (c *BaseChannel) HandleMessage( Metadata: metadata, } - c.bus.PublishInbound(context.TODO(), msg) + if err := c.bus.PublishInbound(ctx, msg); err != nil { + logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{ + "channel": c.name, + "chat_id": chatID, + "error": err.Error(), + }) + } } func (c *BaseChannel) SetRunning(running bool) { diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index b28bc850f..7ab73b4d3 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -183,7 +183,7 @@ func (c *DingTalkChannel) onChatBotMessageReceived( }) // Handle the message through the base channel - c.HandleMessage(peer, "", senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata) // Return nil to indicate we've handled the message asynchronously // The response will be sent through the message bus diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index ee698da61..464a4db7b 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -381,7 +381,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } - c.HandleMessage(peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata) + c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata) } // startTyping starts a continuous typing indicator loop for the given chatID. diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index aaaf6cf1b..4b8eddd21 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -131,7 +131,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 } @@ -189,7 +189,7 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2 "preview": utils.Truncate(content, 80), }) - c.HandleMessage(peer, messageID, senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata) return nil } diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index a79931bc9..399617064 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -370,7 +370,7 @@ func (c *LINEChannel) processEvent(event lineEvent) { // Show typing/loading indicator (requires user ID, not group ID) c.sendLoading(senderID) - c.HandleMessage(peer, msg.ID, senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata) } // isBotMentioned checks if the bot is mentioned in the message. diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index b5b7259f9..dceaec4c5 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -179,7 +179,16 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { "h": fmt.Sprintf("%.0f", h), } - c.HandleMessage(bus.Peer{Kind: "channel", ID: "default"}, "", senderID, chatID, content, []string{}, metadata) + c.HandleMessage( + c.ctx, + bus.Peer{Kind: "channel", ID: "default"}, + "", + senderID, + chatID, + content, + []string{}, + metadata, + ) } func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) { diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 682025b67..b47685397 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -1040,7 +1040,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { } } - c.HandleMessage(peer, messageID, senderID, chatID, content, parsed.Media, metadata) + c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata) } func (c *OneBotChannel) isDuplicate(messageID string) bool { diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 1c28ca732..9809786e3 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -255,7 +255,8 @@ func (c *PicoChannel) handleWebSocket(w http.ResponseWriter, r *http.Request) { go c.readLoop(pc) } -// authenticate checks the Bearer token from header or query parameter. +// authenticate checks the Bearer token from the Authorization header. +// Query parameter authentication is only allowed when AllowTokenQuery is explicitly enabled. func (c *PicoChannel) authenticate(r *http.Request) bool { token := c.config.Token if token == "" { @@ -270,9 +271,11 @@ func (c *PicoChannel) authenticate(r *http.Request) bool { } } - // Check query parameter - if r.URL.Query().Get("token") == token { - return true + // Check query parameter only when explicitly allowed + if c.config.AllowTokenQuery { + if r.URL.Query().Get("token") == token { + return true + } } return false @@ -417,7 +420,7 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { } } - c.HandleMessage(peer, msg.ID, senderID, chatID, content, nil, metadata) + c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata) } // truncate truncates a string to maxLen runes. diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 011eb6c3c..c43c13655 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -168,7 +168,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { // 转发到消息总线 metadata := map[string]string{} - c.HandleMessage( + c.HandleMessage(c.ctx, bus.Peer{Kind: "direct", ID: senderID}, data.ID, senderID, @@ -224,7 +224,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { "group_id": data.GroupID, } - c.HandleMessage( + c.HandleMessage(c.ctx, bus.Peer{Kind: "group", ID: data.GroupID}, data.ID, senderID, diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index e64525310..c6b3c829e 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -360,7 +360,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { "has_thread": threadTS != "", }) - c.HandleMessage(peer, messageTS, senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata) } func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { @@ -433,7 +433,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { "team_id": c.teamID, } - c.HandleMessage(mentionPeer, messageTS, senderID, chatID, content, nil, metadata) + c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata) } func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { @@ -476,7 +476,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "text": utils.Truncate(content, 50), }) - c.HandleMessage(bus.Peer{Kind: "channel", ID: channelID}, "", senderID, chatID, content, nil, metadata) + c.HandleMessage(c.ctx, bus.Peer{Kind: "channel", ID: channelID}, "", senderID, chatID, content, nil, metadata) } func (c *SlackChannel) downloadSlackFile(file slack.File) string { diff --git a/pkg/channels/split.go b/pkg/channels/split.go index a455c5741..27d76df1b 100644 --- a/pkg/channels/split.go +++ b/pkg/channels/split.go @@ -66,9 +66,8 @@ func SplitMessage(content string, maxLen int) []string { } else { // Code block is too long to fit in one chunk or missing closing fence. // Try to split inside by injecting closing and reopening fences. - candidateStr := string(candidate) - unclosedStr := string(runes[unclosedIdx:]) - headerEnd := strings.Index(unclosedStr, "\n") + fenceRunes := runes[unclosedIdx:] + headerEnd := findNewlineInRunes(fenceRunes) var header string if headerEnd == -1 { header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3])) @@ -80,8 +79,6 @@ func SplitMessage(content string, maxLen int) []string { headerEndIdx = unclosedIdx + headerEnd } - _ = candidateStr // used above for context - // If we have a reasonable amount of content after the header, split inside if msgEnd > headerEndIdx+20 { // Find a better split point closer to maxLen @@ -170,6 +167,17 @@ func findNextClosingCodeBlockRunes(runes []rune, startIdx int) int { return -1 } +// findNewlineInRunes finds the first newline character in a rune slice. +// Returns the rune index of the newline or -1 if not found. +func findNewlineInRunes(runes []rune) int { + for i, r := range runes { + if r == '\n' { + return i + } + } + return -1 +} + // findLastNewlineRunes finds the last newline character within the last N runes // Returns the rune position of the newline or -1 if not found func findLastNewlineRunes(runes []rune, searchWindow int) int { diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 98477f3a8..31be4d489 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -448,7 +448,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } - c.HandleMessage( + c.HandleMessage(c.ctx, peer, messageID, fmt.Sprintf("%d", user.ID), diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 53b53ffb8..e822e67b2 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -630,7 +630,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag }) // Handle the message through the base channel - c.HandleMessage(peer, messageID, senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata) } // tokenRefreshLoop periodically refreshes the access token diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 7ffe4734b..401c9c5ec 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -399,7 +399,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag }) // Handle the message through the base channel - c.HandleMessage(peer, msg.MsgID, senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata) } // sendWebhookReply sends a reply using the webhook URL diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 97032334f..b4599b5a0 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -224,5 +224,5 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { "preview": utils.Truncate(content, 50), }) - c.HandleMessage(peer, messageID, senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata) } diff --git a/pkg/config/config.go b/pkg/config/config.go index d32e8db90..56453cd33 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -345,14 +345,15 @@ type WeComAppConfig struct { } type PicoConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` - AllowOrigins []string `json:"allow_origins,omitempty"` - PingInterval int `json:"ping_interval,omitempty"` // seconds, default 30 - ReadTimeout int `json:"read_timeout,omitempty"` // seconds, default 60 - WriteTimeout int `json:"write_timeout,omitempty"` // seconds, default 10 - MaxConnections int `json:"max_connections,omitempty"` // default 100 - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"` + AllowTokenQuery bool `json:"allow_token_query,omitempty"` + AllowOrigins []string `json:"allow_origins,omitempty"` + PingInterval int `json:"ping_interval,omitempty"` + ReadTimeout int `json:"read_timeout,omitempty"` + WriteTimeout int `json:"write_timeout,omitempty"` + MaxConnections int `json:"max_connections,omitempty"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` } type HeartbeatConfig struct { diff --git a/pkg/media/store.go b/pkg/media/store.go index 8d03c03ef..2df4420e9 100644 --- a/pkg/media/store.go +++ b/pkg/media/store.go @@ -25,23 +25,32 @@ type MediaStore interface { // Resolve returns the local file path for a given ref. Resolve(ref string) (localPath string, err error) + // ResolveWithMeta returns the local file path and metadata for a given ref. + ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error) + // ReleaseAll deletes all files registered under the given scope // and removes the mapping entries. File-not-exist errors are ignored. ReleaseAll(scope string) error } +// mediaEntry holds the path and metadata for a stored media file. +type mediaEntry struct { + path string + meta MediaMeta +} + // FileMediaStore is a pure in-memory implementation of MediaStore. // Files are expected to already exist on disk (e.g. in /tmp/picoclaw_media/). type FileMediaStore struct { mu sync.RWMutex - refToPath map[string]string + refs map[string]mediaEntry scopeToRefs map[string]map[string]struct{} } // NewFileMediaStore creates a new FileMediaStore. func NewFileMediaStore() *FileMediaStore { return &FileMediaStore{ - refToPath: make(map[string]string), + refs: make(map[string]mediaEntry), scopeToRefs: make(map[string]map[string]struct{}), } } @@ -49,15 +58,15 @@ func NewFileMediaStore() *FileMediaStore { // Store registers a local file under the given scope. The file must exist. func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) (string, error) { if _, err := os.Stat(localPath); err != nil { - return "", fmt.Errorf("media store: file does not exist: %s", localPath) + return "", fmt.Errorf("media store: %s: %w", localPath, err) } - ref := "media://" + uuid.New().String()[:8] + ref := "media://" + uuid.New().String() s.mu.Lock() defer s.mu.Unlock() - s.refToPath[ref] = localPath + s.refs[ref] = mediaEntry{path: localPath, meta: meta} if s.scopeToRefs[scope] == nil { s.scopeToRefs[scope] = make(map[string]struct{}) } @@ -71,11 +80,23 @@ func (s *FileMediaStore) Resolve(ref string) (string, error) { s.mu.RLock() defer s.mu.RUnlock() - path, ok := s.refToPath[ref] + entry, ok := s.refs[ref] if !ok { return "", fmt.Errorf("media store: unknown ref: %s", ref) } - return path, nil + return entry.path, nil +} + +// ResolveWithMeta returns the local path and metadata for the given ref. +func (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + entry, ok := s.refs[ref] + if !ok { + return "", MediaMeta{}, fmt.Errorf("media store: unknown ref: %s", ref) + } + return entry.path, entry.meta, nil } // ReleaseAll removes all files under the given scope and cleans up mappings. @@ -89,11 +110,11 @@ func (s *FileMediaStore) ReleaseAll(scope string) error { } for ref := range refs { - if path, exists := s.refToPath[ref]; exists { - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + if entry, exists := s.refs[ref]; exists { + if err := os.Remove(entry.path); err != nil && !os.IsNotExist(err) { // Log but continue — best effort cleanup } - delete(s.refToPath, ref) + delete(s.refs, ref) } } diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go index 361582307..95bd1eb7a 100644 --- a/pkg/media/store_test.go +++ b/pkg/media/store_test.go @@ -139,6 +139,50 @@ func TestStoreNonexistentFile(t *testing.T) { if err == nil { t.Error("Store should fail for nonexistent file") } + // Error message should include the underlying os error, not just "file does not exist" + if !strings.Contains(err.Error(), "no such file or directory") { + t.Errorf("Error should contain OS error detail, got: %v", err) + } +} + +func TestResolveWithMeta(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + path := createTempFile(t, dir, "image.png") + meta := MediaMeta{ + Filename: "image.png", + ContentType: "image/png", + Source: "telegram", + } + + ref, err := store.Store(path, meta, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + resolvedPath, resolvedMeta, err := store.ResolveWithMeta(ref) + if err != nil { + t.Fatalf("ResolveWithMeta failed: %v", err) + } + if resolvedPath != path { + t.Errorf("ResolveWithMeta path = %q, want %q", resolvedPath, path) + } + if resolvedMeta.Filename != meta.Filename { + t.Errorf("ResolveWithMeta Filename = %q, want %q", resolvedMeta.Filename, meta.Filename) + } + if resolvedMeta.ContentType != meta.ContentType { + t.Errorf("ResolveWithMeta ContentType = %q, want %q", resolvedMeta.ContentType, meta.ContentType) + } + if resolvedMeta.Source != meta.Source { + t.Errorf("ResolveWithMeta Source = %q, want %q", resolvedMeta.Source, meta.Source) + } + + // Unknown ref should fail + _, _, err = store.ResolveWithMeta("media://nonexistent") + if err == nil { + t.Error("ResolveWithMeta should fail for unknown ref") + } } func TestConcurrentSafety(t *testing.T) { From 56d80373eb7c4e41023c759dc88dc9e4c9c95393 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 06:56:48 +0800 Subject: [PATCH 024/104] feat(identity): add unified user identity with canonical platform:id format Introduce SenderInfo struct and pkg/identity package to standardize user identification across all channels. Each channel now constructs structured sender info (platform, platformID, canonicalID, username, displayName) instead of ad-hoc string IDs. Allow-list matching supports all legacy formats (numeric ID, @username, id|username) plus the new canonical "platform:id" format. Session key resolution also handles canonical peerIDs for backward-compatible identity link matching. --- pkg/bus/types.go | 10 ++ pkg/channels/base.go | 44 +++++- pkg/channels/base_test.go | 88 ++++++++++++ pkg/channels/dingtalk/dingtalk.go | 15 +- pkg/channels/discord/discord.go | 26 ++-- pkg/channels/feishu/feishu_64.go | 13 +- pkg/channels/line/line.go | 13 +- pkg/channels/maixcam/maixcam.go | 12 ++ pkg/channels/onebot/onebot.go | 25 +++- pkg/channels/pico/pico.go | 13 +- pkg/channels/qq/qq.go | 23 +++ pkg/channels/slack/slack.go | 42 +++++- pkg/channels/telegram/telegram.go | 22 +-- pkg/channels/wecom/app.go | 10 +- pkg/channels/wecom/bot.go | 14 +- pkg/channels/whatsapp/whatsapp.go | 16 ++- pkg/identity/identity.go | 107 ++++++++++++++ pkg/identity/identity_test.go | 229 ++++++++++++++++++++++++++++++ pkg/routing/session_key.go | 9 ++ pkg/routing/session_key_test.go | 45 ++++++ 20 files changed, 742 insertions(+), 34 deletions(-) create mode 100644 pkg/identity/identity.go create mode 100644 pkg/identity/identity_test.go diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 1a7a14170..7ad8f0417 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -6,9 +6,19 @@ type Peer struct { ID string `json:"id"` } +// SenderInfo provides structured sender identity information. +type SenderInfo struct { + Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ... + PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456" + CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format + Username string `json:"username,omitempty"` // username (e.g. @alice) + DisplayName string `json:"display_name,omitempty"` // display name +} + type InboundMessage struct { Channel string `json:"channel"` SenderID string `json:"sender_id"` + Sender SenderInfo `json:"sender"` ChatID string `json:"chat_id"` Content string `json:"content"` Media []string `json:"media,omitempty"` diff --git a/pkg/channels/base.go b/pkg/channels/base.go index c6a5f1cdc..418933af7 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -9,6 +9,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" ) @@ -20,6 +21,7 @@ type Channel interface { Send(ctx context.Context, msg bus.OutboundMessage) error IsRunning() bool IsAllowed(senderID string) bool + IsAllowedSender(sender bus.SenderInfo) bool } // BaseChannelOption is a functional option for configuring a BaseChannel. @@ -168,22 +170,58 @@ func (c *BaseChannel) IsAllowed(senderID string) bool { return false } +// IsAllowedSender checks whether a structured SenderInfo is permitted by the allow-list. +// It delegates to identity.MatchAllowed for each entry, providing unified matching +// across all legacy formats and the new canonical "platform:id" format. +func (c *BaseChannel) IsAllowedSender(sender bus.SenderInfo) bool { + if len(c.allowList) == 0 { + return true + } + + for _, allowed := range c.allowList { + if identity.MatchAllowed(sender, allowed) { + return true + } + } + + return false +} + func (c *BaseChannel) HandleMessage( ctx context.Context, peer bus.Peer, messageID, senderID, chatID, content string, media []string, metadata map[string]string, + senderOpts ...bus.SenderInfo, ) { - if !c.IsAllowed(senderID) { - return + // Use SenderInfo-based allow check when available, else fall back to string + var sender bus.SenderInfo + if len(senderOpts) > 0 { + sender = senderOpts[0] + } + if sender.CanonicalID != "" || sender.PlatformID != "" { + if !c.IsAllowedSender(sender) { + return + } + } else { + if !c.IsAllowed(senderID) { + return + } + } + + // Set SenderID to canonical if available, otherwise keep the raw senderID + resolvedSenderID := senderID + if sender.CanonicalID != "" { + resolvedSenderID = sender.CanonicalID } scope := BuildMediaScope(c.name, chatID, messageID) msg := bus.InboundMessage{ Channel: c.name, - SenderID: senderID, + SenderID: resolvedSenderID, + Sender: sender, ChatID: chatID, Content: content, Media: media, diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go index e56ad3ee9..6132b8bf9 100644 --- a/pkg/channels/base_test.go +++ b/pkg/channels/base_test.go @@ -3,6 +3,7 @@ package channels import ( "testing" + "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" ) @@ -175,3 +176,90 @@ func TestShouldRespondInGroup(t *testing.T) { }) } } + +func TestIsAllowedSender(t *testing.T) { + tests := []struct { + name string + allowList []string + sender bus.SenderInfo + want bool + }{ + { + name: "empty allowlist allows all", + allowList: nil, + sender: bus.SenderInfo{PlatformID: "anyone"}, + want: true, + }, + { + name: "numeric ID matches PlatformID", + allowList: []string{"123456"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + }, + want: true, + }, + { + name: "canonical format matches", + allowList: []string{"telegram:123456"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + }, + want: true, + }, + { + name: "canonical format wrong platform", + allowList: []string{"discord:123456"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + }, + want: false, + }, + { + name: "@username matches", + allowList: []string{"@alice"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + Username: "alice", + }, + want: true, + }, + { + name: "compound id|username matches by ID", + allowList: []string{"123456|alice"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + Username: "alice", + }, + want: true, + }, + { + name: "non matching sender denied", + allowList: []string{"654321"}, + sender: bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := NewBaseChannel("test", nil, nil, tt.allowList) + if got := ch.IsAllowedSender(tt.sender); got != tt.want { + t.Fatalf("IsAllowedSender(%+v) = %v, want %v", tt.sender, got, tt.want) + } + }) + } +} diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 7ab73b4d3..7a3aaca78 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -14,6 +14,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -182,8 +183,20 @@ func (c *DingTalkChannel) onChatBotMessageReceived( "preview": utils.Truncate(content, 50), }) + // Build sender info + sender := bus.SenderInfo{ + Platform: "dingtalk", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("dingtalk", senderID), + DisplayName: senderNick, + } + + if !c.IsAllowedSender(sender) { + return nil, nil + } + // Handle the message through the base channel - c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, "", senderID, chatID, content, nil, metadata, sender) // Return nil to indicate we've handled the message asynchronously // The response will be sent through the message bus diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 464a4db7b..dc49e7413 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -13,6 +13,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" @@ -263,7 +264,20 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag } // Check allowlist first to avoid downloading attachments for rejected users - if !c.IsAllowed(m.Author.ID) { + sender := bus.SenderInfo{ + Platform: "discord", + PlatformID: m.Author.ID, + CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID), + Username: m.Author.Username, + } + // Build display name + displayName := m.Author.Username + if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { + displayName += "#" + m.Author.Discriminator + } + sender.DisplayName = displayName + + if !c.IsAllowedSender(sender) { logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ "user_id": m.Author.ID, }) @@ -297,10 +311,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag } senderID := m.Author.ID - senderName := m.Author.Username - if m.Author.Discriminator != "" && m.Author.Discriminator != "0" { - senderName += "#" + m.Author.Discriminator - } mediaPaths := make([]string, 0, len(m.Attachments)) @@ -358,7 +368,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag } logger.DebugCF("discord", "Received message", map[string]any{ - "sender_name": senderName, + "sender_name": sender.DisplayName, "sender_id": senderID, "preview": utils.Truncate(content, 50), }) @@ -375,13 +385,13 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag metadata := map[string]string{ "user_id": senderID, "username": m.Author.Username, - "display_name": senderName, + "display_name": sender.DisplayName, "guild_id": m.GuildID, "channel_id": m.ChannelID, "is_dm": fmt.Sprintf("%t", m.GuildID == ""), } - c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata) + c.HandleMessage(c.ctx, peer, m.ID, senderID, m.ChannelID, content, mediaPaths, metadata, sender) } // startTyping starts a continuous typing indicator loop for the given chatID. diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 4b8eddd21..62bf69486 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -17,6 +17,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -189,7 +190,17 @@ func (c *FeishuChannel) handleMessageReceive(ctx context.Context, event *larkim. "preview": utils.Truncate(content, 80), }) - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata) + senderInfo := bus.SenderInfo{ + Platform: "feishu", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("feishu", senderID), + } + + if !c.IsAllowedSender(senderInfo) { + return nil + } + + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, senderInfo) return nil } diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 399617064..28d5ad8f7 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -17,6 +17,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" @@ -370,7 +371,17 @@ func (c *LINEChannel) processEvent(event lineEvent) { // Show typing/loading indicator (requires user ID, not group ID) c.sendLoading(senderID) - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata) + sender := bus.SenderInfo{ + Platform: "line", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("line", senderID), + } + + if !c.IsAllowedSender(sender) { + return + } + + c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) } // isBotMentioned checks if the bot is mentioned in the message. diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index dceaec4c5..142a4b7e7 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -11,6 +11,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -179,6 +180,16 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { "h": fmt.Sprintf("%.0f", h), } + sender := bus.SenderInfo{ + Platform: "maixcam", + PlatformID: "maixcam", + CanonicalID: identity.BuildCanonicalID("maixcam", "maixcam"), + } + + if !c.IsAllowedSender(sender) { + return + } + c.HandleMessage( c.ctx, bus.Peer{Kind: "channel", ID: "default"}, @@ -188,6 +199,7 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) { content, []string{}, metadata, + sender, ) } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index b47685397..a748acaa0 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" @@ -823,7 +824,13 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) { switch raw.PostType { case "message": if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 { - if !c.IsAllowed(strconv.FormatInt(userID, 10)) { + // Build minimal sender for allowlist check + sender := bus.SenderInfo{ + Platform: "onebot", + PlatformID: strconv.FormatInt(userID, 10), + CanonicalID: identity.BuildCanonicalID("onebot", strconv.FormatInt(userID, 10)), + } + if !c.IsAllowedSender(sender) { logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{ "user_id": userID, }) @@ -1040,7 +1047,21 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { } } - c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata) + senderInfo := bus.SenderInfo{ + Platform: "onebot", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("onebot", senderID), + DisplayName: sender.Nickname, + } + + if !c.IsAllowedSender(senderInfo) { + logger.DebugCF("onebot", "Message rejected by allowlist (senderInfo)", map[string]any{ + "sender": senderID, + }) + return + } + + c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, parsed.Media, metadata, senderInfo) } func (c *OneBotChannel) isDuplicate(messageID string) bool { diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 9809786e3..c646a3b0b 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -16,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -420,7 +421,17 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { } } - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata) + sender := bus.SenderInfo{ + Platform: "pico", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("pico", senderID), + } + + if !c.IsAllowedSender(sender) { + return + } + + c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, nil, metadata, sender) } // truncate truncates a string to maxLen runes. diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index c43c13655..85313efe5 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -16,6 +16,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" ) @@ -168,6 +169,16 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { // 转发到消息总线 metadata := map[string]string{} + sender := bus.SenderInfo{ + Platform: "qq", + PlatformID: data.Author.ID, + CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), + } + + if !c.IsAllowedSender(sender) { + return nil + } + c.HandleMessage(c.ctx, bus.Peer{Kind: "direct", ID: senderID}, data.ID, @@ -176,6 +187,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { content, []string{}, metadata, + sender, ) return nil @@ -224,6 +236,16 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { "group_id": data.GroupID, } + sender := bus.SenderInfo{ + Platform: "qq", + PlatformID: data.Author.ID, + CanonicalID: identity.BuildCanonicalID("qq", data.Author.ID), + } + + if !c.IsAllowedSender(sender) { + return nil + } + c.HandleMessage(c.ctx, bus.Peer{Kind: "group", ID: data.GroupID}, data.ID, @@ -232,6 +254,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { content, []string{}, metadata, + sender, ) return nil diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index c6b3c829e..90c4297ca 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -13,6 +13,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" @@ -252,7 +253,12 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { } // 检查白名单,避免为被拒绝的用户下载附件 - if !c.IsAllowed(ev.User) { + sender := bus.SenderInfo{ + Platform: "slack", + PlatformID: ev.User, + CanonicalID: identity.BuildCanonicalID("slack", ev.User), + } + if !c.IsAllowedSender(sender) { logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{ "user_id": ev.User, }) @@ -360,7 +366,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { "has_thread": threadTS != "", }) - c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(c.ctx, peer, messageTS, senderID, chatID, content, mediaPaths, metadata, sender) } func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { @@ -368,7 +374,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { return } - if !c.IsAllowed(ev.User) { + if !c.IsAllowedSender(bus.SenderInfo{ + Platform: "slack", + PlatformID: ev.User, + CanonicalID: identity.BuildCanonicalID("slack", ev.User), + }) { logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{ "user_id": ev.User, }) @@ -376,6 +386,11 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { } senderID := ev.User + mentionSender := bus.SenderInfo{ + Platform: "slack", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("slack", senderID), + } channelID := ev.Channel threadTS := ev.ThreadTimeStamp messageTS := ev.TimeStamp @@ -433,7 +448,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { "team_id": c.teamID, } - c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata) + c.HandleMessage(c.ctx, mentionPeer, messageTS, senderID, chatID, content, nil, metadata, mentionSender) } func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { @@ -446,7 +461,12 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { c.socketClient.Ack(*event.Request) } - if !c.IsAllowed(cmd.UserID) { + cmdSender := bus.SenderInfo{ + Platform: "slack", + PlatformID: cmd.UserID, + CanonicalID: identity.BuildCanonicalID("slack", cmd.UserID), + } + if !c.IsAllowedSender(cmdSender) { logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{ "user_id": cmd.UserID, }) @@ -476,7 +496,17 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) { "text": utils.Truncate(content, 50), }) - c.HandleMessage(c.ctx, bus.Peer{Kind: "channel", ID: channelID}, "", senderID, chatID, content, nil, metadata) + c.HandleMessage( + c.ctx, + bus.Peer{Kind: "channel", ID: channelID}, + "", + senderID, + chatID, + content, + nil, + metadata, + cmdSender, + ) } func (c *SlackChannel) downloadSlackFile(file slack.File) string { diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 31be4d489..6b5a84eda 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" @@ -289,21 +290,25 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes return fmt.Errorf("message sender (user) is nil") } - senderID := fmt.Sprintf("%d", user.ID) - if user.Username != "" { - senderID = fmt.Sprintf("%d|%s", user.ID, user.Username) + platformID := fmt.Sprintf("%d", user.ID) + sender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: platformID, + CanonicalID: identity.BuildCanonicalID("telegram", platformID), + Username: user.Username, + DisplayName: user.FirstName, } // 检查白名单,避免为被拒绝的用户下载附件 - if !c.IsAllowed(senderID) { + if !c.IsAllowedSender(sender) { logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{ - "user_id": senderID, + "user_id": platformID, }) return nil } chatID := message.Chat.ID - c.chatIDs[senderID] = chatID + c.chatIDs[platformID] = chatID content := "" mediaPaths := []string{} @@ -401,7 +406,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes } logger.DebugCF("telegram", "Received message", map[string]any{ - "sender_id": senderID, + "sender_id": sender.CanonicalID, "chat_id": fmt.Sprintf("%d", chatID), "preview": utils.Truncate(content, 50), }) @@ -451,11 +456,12 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes c.HandleMessage(c.ctx, peer, messageID, - fmt.Sprintf("%d", user.ID), + platformID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata, + sender, ) return nil } diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index e822e67b2..f1e764864 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -629,8 +630,15 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag "preview": utils.Truncate(content, 50), }) + // Build sender info + appSender := bus.SenderInfo{ + Platform: "wecom", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("wecom", senderID), + } + // Handle the message through the base channel - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender) } // tokenRefreshLoop periodically refreshes the access token diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 401c9c5ec..460997dab 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -398,8 +399,19 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag "preview": utils.Truncate(content, 50), }) + // Build sender info + sender := bus.SenderInfo{ + Platform: "wecom", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("wecom", senderID), + } + + if !c.IsAllowedSender(sender) { + return + } + // Handle the message through the base channel - c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata) + c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender) } // sendWebhookReply sends a reply using the webhook URL diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index b4599b5a0..106114090 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -12,6 +12,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -224,5 +225,18 @@ func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) { "preview": utils.Truncate(content, 50), }) - c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata) + sender := bus.SenderInfo{ + Platform: "whatsapp", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("whatsapp", senderID), + } + if display, ok := metadata["user_name"]; ok { + sender.DisplayName = display + } + + if !c.IsAllowedSender(sender) { + return + } + + c.HandleMessage(c.ctx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go new file mode 100644 index 000000000..6bc09c210 --- /dev/null +++ b/pkg/identity/identity.go @@ -0,0 +1,107 @@ +// Package identity provides unified user identity utilities for PicoClaw. +// It introduces a canonical "platform:id" format and matching logic +// that is backward-compatible with all legacy allow-list formats. +package identity + +import ( + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +// BuildCanonicalID constructs a canonical "platform:id" identifier. +// Both platform and platformID are lowercased and trimmed. +func BuildCanonicalID(platform, platformID string) string { + p := strings.ToLower(strings.TrimSpace(platform)) + id := strings.TrimSpace(platformID) + if p == "" || id == "" { + return "" + } + return p + ":" + id +} + +// ParseCanonicalID splits a canonical ID ("platform:id") into its parts. +// Returns ok=false if the input does not contain a colon separator. +func ParseCanonicalID(canonical string) (platform, id string, ok bool) { + canonical = strings.TrimSpace(canonical) + idx := strings.Index(canonical, ":") + if idx <= 0 || idx == len(canonical)-1 { + return "", "", false + } + return canonical[:idx], canonical[idx+1:], true +} + +// MatchAllowed checks whether the given sender matches a single allow-list entry. +// It is backward-compatible with all legacy formats: +// +// - "123456" → matches sender.PlatformID +// - "@alice" → matches sender.Username +// - "123456|alice" → matches PlatformID or Username +// - "telegram:123456" → exact match on sender.CanonicalID +func MatchAllowed(sender bus.SenderInfo, allowed string) bool { + allowed = strings.TrimSpace(allowed) + if allowed == "" { + return false + } + + // Try canonical match first: "platform:id" format + if platform, id, ok := ParseCanonicalID(allowed); ok { + // Only treat as canonical if the platform portion looks like a known platform name + // (not a pure-numeric string, which could be a compound ID) + if !isNumeric(platform) { + candidate := BuildCanonicalID(platform, id) + if candidate != "" && sender.CanonicalID != "" { + return strings.EqualFold(sender.CanonicalID, candidate) + } + // If sender has no canonical ID, try matching platform + platformID + return strings.EqualFold(platform, sender.Platform) && + sender.PlatformID == id + } + } + + // Strip leading "@" for username matching + trimmed := strings.TrimPrefix(allowed, "@") + + // Split compound "id|username" format + allowedID := trimmed + allowedUser := "" + if idx := strings.Index(trimmed, "|"); idx > 0 { + allowedID = trimmed[:idx] + allowedUser = trimmed[idx+1:] + } + + // Match against PlatformID + if sender.PlatformID != "" && sender.PlatformID == allowedID { + return true + } + + // Match against Username + if sender.Username != "" { + if sender.Username == trimmed || sender.Username == allowedUser { + return true + } + } + + // Match compound sender format against allowed parts + if allowedUser != "" && sender.PlatformID != "" && sender.PlatformID == allowedID { + return true + } + if allowedUser != "" && sender.Username != "" && sender.Username == allowedUser { + return true + } + + return false +} + +// isNumeric returns true if s consists entirely of digits. +func isNumeric(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go new file mode 100644 index 000000000..3d24bd794 --- /dev/null +++ b/pkg/identity/identity_test.go @@ -0,0 +1,229 @@ +package identity + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +func TestBuildCanonicalID(t *testing.T) { + tests := []struct { + platform string + platformID string + want string + }{ + {"telegram", "123456", "telegram:123456"}, + {"Discord", "98765432", "discord:98765432"}, + {"SLACK", "U123ABC", "slack:U123ABC"}, + {"", "123", ""}, + {"telegram", "", ""}, + {" telegram ", " 123 ", "telegram:123"}, + } + + for _, tt := range tests { + got := BuildCanonicalID(tt.platform, tt.platformID) + if got != tt.want { + t.Errorf("BuildCanonicalID(%q, %q) = %q, want %q", + tt.platform, tt.platformID, got, tt.want) + } + } +} + +func TestParseCanonicalID(t *testing.T) { + tests := []struct { + input string + wantPlatform string + wantID string + wantOk bool + }{ + {"telegram:123456", "telegram", "123456", true}, + {"discord:98765432", "discord", "98765432", true}, + {"slack:U123ABC", "slack", "U123ABC", true}, + {"nocolon", "", "", false}, + {"", "", "", false}, + {":missing", "", "", false}, + {"missing:", "", "", false}, + } + + for _, tt := range tests { + platform, id, ok := ParseCanonicalID(tt.input) + if ok != tt.wantOk || platform != tt.wantPlatform || id != tt.wantID { + t.Errorf("ParseCanonicalID(%q) = (%q, %q, %v), want (%q, %q, %v)", + tt.input, platform, id, ok, + tt.wantPlatform, tt.wantID, tt.wantOk) + } + } +} + +func TestMatchAllowed(t *testing.T) { + telegramSender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: "123456", + CanonicalID: "telegram:123456", + Username: "alice", + DisplayName: "Alice Smith", + } + + discordSender := bus.SenderInfo{ + Platform: "discord", + PlatformID: "98765432", + CanonicalID: "discord:98765432", + Username: "bob", + DisplayName: "bob#1234", + } + + noCanonicalSender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: "999", + Username: "carol", + } + + tests := []struct { + name string + sender bus.SenderInfo + allowed string + want bool + }{ + // Pure numeric ID matching + { + name: "numeric ID matches PlatformID", + sender: telegramSender, + allowed: "123456", + want: true, + }, + { + name: "numeric ID does not match", + sender: telegramSender, + allowed: "654321", + want: false, + }, + // Username matching + { + name: "@username matches Username", + sender: telegramSender, + allowed: "@alice", + want: true, + }, + { + name: "@username does not match", + sender: telegramSender, + allowed: "@bob", + want: false, + }, + // Compound format "id|username" + { + name: "compound matches by ID", + sender: telegramSender, + allowed: "123456|alice", + want: true, + }, + { + name: "compound matches by username", + sender: telegramSender, + allowed: "999|alice", + want: true, + }, + { + name: "compound does not match", + sender: telegramSender, + allowed: "654321|bob", + want: false, + }, + // Canonical format "platform:id" + { + name: "canonical matches exactly", + sender: telegramSender, + allowed: "telegram:123456", + want: true, + }, + { + name: "canonical case-insensitive platform", + sender: telegramSender, + allowed: "Telegram:123456", + want: true, + }, + { + name: "canonical wrong platform", + sender: telegramSender, + allowed: "discord:123456", + want: false, + }, + { + name: "canonical wrong ID", + sender: telegramSender, + allowed: "telegram:654321", + want: false, + }, + // Cross-platform canonical + { + name: "discord canonical match", + sender: discordSender, + allowed: "discord:98765432", + want: true, + }, + { + name: "telegram canonical does not match discord sender", + sender: discordSender, + allowed: "telegram:98765432", + want: false, + }, + // Sender without canonical ID + { + name: "canonical match falls back to platform+platformID", + sender: noCanonicalSender, + allowed: "telegram:999", + want: true, + }, + { + name: "platform mismatch on fallback", + sender: noCanonicalSender, + allowed: "discord:999", + want: false, + }, + // Empty allowed string + { + name: "empty allowed never matches", + sender: telegramSender, + allowed: "", + want: false, + }, + // Whitespace handling + { + name: "trimmed allowed matches", + sender: telegramSender, + allowed: " 123456 ", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MatchAllowed(tt.sender, tt.allowed) + if got != tt.want { + t.Errorf("MatchAllowed(%+v, %q) = %v, want %v", + tt.sender, tt.allowed, got, tt.want) + } + }) + } +} + +func TestIsNumeric(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"123456", true}, + {"0", true}, + {"", false}, + {"abc", false}, + {"12a34", false}, + {"telegram", false}, + } + + for _, tt := range tests { + got := isNumeric(tt.input) + if got != tt.want { + t.Errorf("isNumeric(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go index e12f0d1d8..eab592bec 100644 --- a/pkg/routing/session_key.go +++ b/pkg/routing/session_key.go @@ -163,6 +163,15 @@ func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID stri scopedCandidate := fmt.Sprintf("%s:%s", channel, strings.ToLower(peerID)) candidates[scopedCandidate] = true } + + // If peerID is already in canonical "platform:id" format, also add the + // bare ID part as a candidate for backward compatibility with identity_links + // that use raw IDs (e.g. "123" instead of "telegram:123"). + if idx := strings.Index(rawCandidate, ":"); idx > 0 && idx < len(rawCandidate)-1 { + bareID := rawCandidate[idx+1:] + candidates[bareID] = true + } + if len(candidates) == 0 { return "" } diff --git a/pkg/routing/session_key_test.go b/pkg/routing/session_key_test.go index 81e4ce018..ad7a1ca02 100644 --- a/pkg/routing/session_key_test.go +++ b/pkg/routing/session_key_test.go @@ -115,6 +115,51 @@ func TestBuildAgentPeerSessionKey_IdentityLink(t *testing.T) { } } +func TestResolveLinkedPeerID_CanonicalPeerID(t *testing.T) { + // When peerID is already in canonical "platform:id" format, + // it should match identity_links that use the bare ID. + links := map[string][]string{ + "john": {"123"}, + } + got := resolveLinkedPeerID(links, "telegram", "telegram:123") + if got != "john" { + t.Errorf("resolveLinkedPeerID with canonical peerID = %q, want %q", got, "john") + } +} + +func TestResolveLinkedPeerID_CanonicalInLinks(t *testing.T) { + // When identity_links contain canonical IDs and peerID is canonical too + links := map[string][]string{ + "john": {"telegram:123", "discord:456"}, + } + got := resolveLinkedPeerID(links, "telegram", "telegram:123") + if got != "john" { + t.Errorf("resolveLinkedPeerID canonical in links = %q, want %q", got, "john") + } +} + +func TestResolveLinkedPeerID_BarePeerIDMatchesCanonicalLink(t *testing.T) { + // When peerID is bare "123" and links have "telegram:123", + // the scoped candidate "telegram:123" should match. + links := map[string][]string{ + "john": {"telegram:123"}, + } + got := resolveLinkedPeerID(links, "telegram", "123") + if got != "john" { + t.Errorf("resolveLinkedPeerID bare peer matches canonical link = %q, want %q", got, "john") + } +} + +func TestResolveLinkedPeerID_NoMatch(t *testing.T) { + links := map[string][]string{ + "john": {"telegram:123"}, + } + got := resolveLinkedPeerID(links, "discord", "999") + if got != "" { + t.Errorf("resolveLinkedPeerID no match = %q, want empty", got) + } +} + func TestParseAgentSessionKey_Valid(t *testing.T) { parsed := ParseAgentSessionKey("agent:sales:telegram:direct:user123") if parsed == nil { From 26bee0b7915da0a685d4dfdb170afe31d205777b Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 08:20:15 +0800 Subject: [PATCH 025/104] refactor(loop): disable media cleanup to prevent premature file deletion --- pkg/agent/loop.go | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 99ca0eaec..e88136343 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -168,18 +168,20 @@ func (al *AgentLoop) Run(ctx context.Context) error { continue } - // Process message and ensure media is released afterward + // Process message func() { - defer func() { - if al.mediaStore != nil && msg.MediaScope != "" { - if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { - logger.WarnCF("agent", "Failed to release media", map[string]any{ - "scope": msg.MediaScope, - "error": releaseErr.Error(), - }) - } - } - }() + // TODO: Re-enable media cleanup after inbound media is properly consumed by the agent. + // Currently disabled because files are deleted before the LLM can access their content. + // defer func() { + // if al.mediaStore != nil && msg.MediaScope != "" { + // if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil { + // logger.WarnCF("agent", "Failed to release media", map[string]any{ + // "scope": msg.MediaScope, + // "error": releaseErr.Error(), + // }) + // } + // } + // }() response, err := al.processMessage(ctx, msg) if err != nil { From 81234f7e54146b9e95bceac81bc440701efeb813 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 18:20:24 -0800 Subject: [PATCH 026/104] Sanitize WhatsApp messages and remove extra log messages. --- pkg/channels/whatsapp_native.go | 3 +++ pkg/utils/string.go | 26 ++++++++++++++++++++++++++ pkg/utils/string_test.go | 24 ++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index cae89bb00..4f40dc18c 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -174,6 +174,9 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { if content == "" && evt.Message.ExtendedTextMessage != nil { content = evt.Message.ExtendedTextMessage.GetText() } + content = utils.SanitizeMessageContent(content) + + if content == "" { return } // ignore empty messages var mediaPaths []string // Optional: resolve media to local paths if needed; for now we only forward text to the bus. diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 62d9beee0..edc413972 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -1,5 +1,31 @@ package utils +import ( + "strings" + "unicode" +) + +// SanitizeMessage removes Unicode control characters, format characters (RTL overrides, +// zero-width characters), and other non-graphic characters that could confuse an LLM +// or cause display issues in the agent UI. +func SanitizeMessageContent(input string) string { + var sb strings.Builder + // Pre-allocate memory to avoid multiple allocations + sb.Grow(len(input)) + + for _, r := range input { + // unicode.IsGraphic returns true if the rune is a Unicode graphic character. + // This includes letters, marks, numbers, punctuation, and symbols. + // It excludes control characters (Cc), format characters (Cf), + // surrogates (Cs), and private use (Co). + if unicode.IsGraphic(r) || r == '\n' || r == '\r' || r == '\t' { + sb.WriteRune(r) + } + } + + return sb.String() +} + // Truncate returns a truncated version of s with at most maxLen runes. // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index a44ead228..fffa0cff3 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -104,3 +104,27 @@ func TestTruncate(t *testing.T) { }) } } + +func TestSanitizeMessageContent(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + {"empty", "", ""}, + {"plain text unchanged", "Hello world", "Hello world"}, + {"strip ZWSP", "Hello\u200bworld", "Helloworld"}, + {"strip RTL override", "Hi\u202eevil", "Hievil"}, + {"strip BOM", "\uFEFFcontent", "content"}, + {"strip multiple", "a\u200c\u202ab\u202cc", "abc"}, + {"unicode letters preserved", "café 日本語", "café 日本語"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SanitizeMessageContent(tt.input) + if got != tt.want { + t.Errorf("SanitizeMessageContent(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} From 91eff9b34cbc41bef4184cb26640f8192ffcb2bd Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 19:10:25 -0800 Subject: [PATCH 027/104] Changing the logging to use the logger package to be consistent. --- pkg/channels/whatsapp_native.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index 4f40dc18c..c3dcceeb4 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -9,7 +9,6 @@ import ( "context" "database/sql" "fmt" - "log" "os" "path/filepath" "strings" @@ -26,6 +25,7 @@ import ( "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" "go.mau.fi/whatsmeow/proto/waE2E" @@ -62,7 +62,7 @@ func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, st } func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { - log.Printf("Starting WhatsApp native channel (whatsmeow), store: %s", c.storePath) + logger.InfoCF("channels", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) if err := os.MkdirAll(c.storePath, 0700); err != nil { return fmt.Errorf("create session store dir: %w", err) @@ -116,14 +116,14 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } for evt := range qrChan { if evt.Event == "code" { - log.Println("Scan this QR code with WhatsApp (Linked Devices):") + logger.InfoCF("channels", "Scan this QR code with WhatsApp (Linked Devices):", nil) qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ Level: qrterminal.L, Writer: os.Stdout, HalfBlocks: true, }) } else { - log.Printf("WhatsApp login event: %s", evt.Event) + logger.InfoCF("channels", "WhatsApp login event", map[string]any{"event": evt.Event}) } } } else { @@ -134,12 +134,12 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } c.setRunning(true) - log.Println("WhatsApp native channel connected") + logger.InfoCF("channels", "WhatsApp native channel connected", nil) return nil } func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { - log.Println("Stopping WhatsApp native channel...") + logger.InfoCF("channels", "Stopping WhatsApp native channel", nil) c.mu.Lock() client := c.client container := c.container @@ -195,7 +195,7 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { metadata["peer_id"] = senderID } - log.Printf("WhatsApp message from %s: %s...", senderID, utils.Truncate(content, 50)) + logger.InfoCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) } From 76f8ab827f7fc6aef092a8aa104fc6cb309250cf Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 19:18:02 -0800 Subject: [PATCH 028/104] Handle dis --- pkg/channels/whatsapp_native.go | 83 ++++++++++++++++++++++++++++++--- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index c3dcceeb4..cbd02561f 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -13,6 +13,7 @@ import ( "path/filepath" "strings" "sync" + "time" "github.com/mdp/qrterminal/v3" _ "modernc.org/sqlite" @@ -35,16 +36,24 @@ import ( const ( sqliteDriver = "sqlite" whatsappDBName = "store.db" + + reconnectInitial = 5 * time.Second + reconnectMax = 5 * time.Minute + reconnectMultiplier = 2.0 ) // WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge). type WhatsAppNativeChannel struct { *BaseChannel - config config.WhatsAppConfig - storePath string - client *whatsmeow.Client - container *sqlstore.Container - mu sync.Mutex + config config.WhatsAppConfig + storePath string + client *whatsmeow.Client + container *sqlstore.Container + mu sync.Mutex + runCtx context.Context + runCancel context.CancelFunc + reconnectMu sync.Mutex + reconnecting bool } // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. @@ -133,6 +142,7 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } } + c.runCtx, c.runCancel = context.WithCancel(ctx) c.setRunning(true) logger.InfoCF("channels", "WhatsApp native channel connected", nil) return nil @@ -140,6 +150,9 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { logger.InfoCF("channels", "Stopping WhatsApp native channel", nil) + if c.runCancel != nil { + c.runCancel() + } c.mu.Lock() client := c.client container := c.container @@ -158,9 +171,65 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { } func (c *WhatsAppNativeChannel) eventHandler(evt interface{}) { - switch v := evt.(type) { + switch evt.(type) { case *events.Message: - c.handleIncoming(v) + c.handleIncoming(evt.(*events.Message)) + case *events.Disconnected: + logger.InfoCF("channels", "WhatsApp disconnected, will attempt reconnection", nil) + c.reconnectMu.Lock() + if c.reconnecting { + c.reconnectMu.Unlock() + return + } + c.reconnecting = true + c.reconnectMu.Unlock() + go c.reconnectWithBackoff() + } +} + +func (c *WhatsAppNativeChannel) reconnectWithBackoff() { + defer func() { + c.reconnectMu.Lock() + c.reconnecting = false + c.reconnectMu.Unlock() + }() + + backoff := reconnectInitial + for { + select { + case <-c.runCtx.Done(): + return + default: + } + + c.mu.Lock() + client := c.client + c.mu.Unlock() + if client == nil { + return + } + + logger.InfoCF("channels", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()}) + err := client.Connect() + if err == nil { + logger.InfoCF("channels", "WhatsApp reconnected", nil) + return + } + + logger.WarnCF("channels", "WhatsApp reconnect failed", map[string]any{"error": err.Error()}) + + select { + case <-c.runCtx.Done(): + return + case <-time.After(backoff): + if backoff < reconnectMax { + next := time.Duration(float64(backoff) * reconnectMultiplier) + if next > reconnectMax { + next = reconnectMax + } + backoff = next + } + } } } From 25362ec7632be24a3fb0867c35b33f28a183f9e0 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 19:22:32 -0800 Subject: [PATCH 029/104] Add new build tag for WhatsApp native support to keep the binary smaller. --- Makefile | 8 ++++++++ README.md | 2 +- pkg/channels/whatsapp_native.go | 9 ++++++--- pkg/channels/whatsapp_native_stub.go | 16 ++++++++++++++++ 4 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 pkg/channels/whatsapp_native_stub.go diff --git a/Makefile b/Makefile index ba8168617..99a633c0b 100644 --- a/Makefile +++ b/Makefile @@ -87,6 +87,14 @@ build: generate @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) +## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary +build-whatsapp-native: generate + @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." + @mkdir -p $(BUILD_DIR) + @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + @echo "Build complete: $(BINARY_PATH)" + @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) + ## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit) build-linux-arm: generate @echo "Building for linux/arm (GOARM=7)..." diff --git a/README.md b/README.md index 35c62434f..31495176a 100644 --- a/README.md +++ b/README.md @@ -391,7 +391,7 @@ picoclaw gateway PicoClaw can connect to WhatsApp in two ways: -- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). +- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`). - **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false. **Configure (native)** diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index cbd02561f..3099afdcc 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -1,3 +1,5 @@ +//go:build whatsapp_native + // PicoClaw - Ultra-lightweight personal AI agent // License: MIT // @@ -58,16 +60,17 @@ type WhatsAppNativeChannel struct { // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (*WhatsAppNativeChannel, error) { +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) { base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) if storePath == "" { storePath = "whatsapp" } - return &WhatsAppNativeChannel{ + c := &WhatsAppNativeChannel{ BaseChannel: base, config: cfg, storePath: storePath, - }, nil + } + return c, nil } func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { diff --git a/pkg/channels/whatsapp_native_stub.go b/pkg/channels/whatsapp_native_stub.go new file mode 100644 index 000000000..b4a826fa4 --- /dev/null +++ b/pkg/channels/whatsapp_native_stub.go @@ -0,0 +1,16 @@ +//go:build !whatsapp_native + +package channels + +import ( + "fmt" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native. +// Build with: go build -tags whatsapp_native ./cmd/... +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) { + return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native") +} From 16a36ea416117e1b86429cc5505f389ac362e408 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 20:58:59 -0800 Subject: [PATCH 030/104] Adding a new target to the Makefile to build for multiple platforms with WhatsApp native support. --- Makefile | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 99a633c0b..c7375a544 100644 --- a/Makefile +++ b/Makefile @@ -89,11 +89,19 @@ build: generate ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary build-whatsapp-native: generate - @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." +## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." + @echo "Building for multiple platforms..." @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) - @echo "Build complete: $(BINARY_PATH)" - @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) + GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) + GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR) + GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) + GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) + GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) + GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) +## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) + @echo "Build complete" +## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) ## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit) build-linux-arm: generate From 071505e797a7ee8d38c753f1dec91312a008d8a7 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Sun, 22 Feb 2026 21:03:42 -0800 Subject: [PATCH 031/104] Removing the agentMu mutex from the AgentLoop --- pkg/agent/loop.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 572561d0e..fc87c6fe5 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -321,8 +321,6 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") } - al.agentMu.Lock() - defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: "heartbeat", Channel: channel, @@ -397,10 +395,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "agent_id": agent.ID, "session_key": sessionKey, "matched_by": route.MatchedBy, - }) + }) - al.agentMu.Lock() - defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: msg.Channel, @@ -460,8 +456,6 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe // Use the origin session for context sessionKey := routing.BuildAgentMainSessionKey(agent.ID) - al.agentMu.Lock() - defer al.agentMu.Unlock() return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: originChannel, From 6852f240251a3d410d89d1b352acf2666dae3d04 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Mon, 23 Feb 2026 21:34:37 +0800 Subject: [PATCH 032/104] fix: address PR #662 review comments (bus drain, context timeouts, onebot leak) - Drain buffered messages in MessageBus.Close() so they aren't silently lost - Replace all context.TODO() with context.WithTimeout(5s) across 7 call sites - Fix OneBot pending channel leak: send nil sentinel in Stop() and handle nil response in sendAPIRequest() to unblock waiting goroutines --- pkg/agent/loop.go | 5 +++-- pkg/bus/bus.go | 38 +++++++++++++++++++++++++++++++++++ pkg/channels/onebot/onebot.go | 9 ++++++++- pkg/devices/service.go | 5 ++++- pkg/heartbeat/service.go | 4 +++- pkg/tools/cron.go | 8 ++++++-- pkg/tools/subagent.go | 4 +++- 7 files changed, 65 insertions(+), 8 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e88136343..7a4e9077f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -122,12 +122,13 @@ func registerSharedTools( // Message tool messageTool := tools.NewMessageTool() messageTool.SetSendCallback(func(channel, chatID, content string) error { - msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: content, }) - return nil }) agent.Tools.Register(messageTool) diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 6a1c987b7..d2b6838c5 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -4,6 +4,8 @@ import ( "context" "errors" "sync/atomic" + + "github.com/sipeed/picoclaw/pkg/logger" ) // ErrBusClosed is returned when publishing to a closed MessageBus. @@ -104,5 +106,41 @@ func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMedia func (mb *MessageBus) Close() { if mb.closed.CompareAndSwap(false, true) { close(mb.done) + + // Drain buffered channels so messages aren't silently lost. + // Channels are NOT closed to avoid send-on-closed panics from concurrent publishers. + drained := 0 + for { + select { + case <-mb.inbound: + drained++ + default: + goto doneInbound + } + } + doneInbound: + for { + select { + case <-mb.outbound: + drained++ + default: + goto doneOutbound + } + } + doneOutbound: + for { + select { + case <-mb.outboundMedia: + drained++ + default: + goto doneMedia + } + } + doneMedia: + if drained > 0 { + logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{ + "count": drained, + }) + } } } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index a748acaa0..feb198d7d 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -306,6 +306,9 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D select { case resp := <-ch: + if resp == nil { + return nil, fmt.Errorf("API request %s: channel stopped", action) + } return resp, nil case <-time.After(timeout): return nil, fmt.Errorf("API request %s timed out after %v", action, timeout) @@ -353,7 +356,11 @@ func (c *OneBotChannel) Stop(ctx context.Context) error { } c.pendingMu.Lock() - for echo := range c.pending { + for echo, ch := range c.pending { + select { + case ch <- nil: // non-blocking wake for blocked sendAPIRequest goroutines + default: + } delete(c.pending, echo) } c.pendingMu.Unlock() diff --git a/pkg/devices/service.go b/pkg/devices/service.go index 408e1c8aa..1bafe6085 100644 --- a/pkg/devices/service.go +++ b/pkg/devices/service.go @@ -4,6 +4,7 @@ import ( "context" "strings" "sync" + "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/constants" @@ -127,7 +128,9 @@ func (s *Service) sendNotification(ev *events.DeviceEvent) { } msg := ev.FormatMessage() - msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: platform, ChatID: userID, Content: msg, diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index 3e58dbc7a..ce14ed77c 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -308,7 +308,9 @@ func (hs *HeartbeatService) sendResponse(response string) { return } - msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: platform, ChatID: userID, Content: response, diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 3c13f5968..52f914622 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -294,7 +294,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { output = fmt.Sprintf("Scheduled command '%s' executed:\n%s", job.Payload.Command, result.ForLLM) } - t.msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: output, @@ -304,7 +306,9 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // If deliver=true, send message directly without agent processing if job.Payload.Deliver { - t.msgBus.PublishOutbound(context.TODO(), bus.OutboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, ChatID: chatID, Content: job.Payload.Message, diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 081a02872..69f1a49a2 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -218,7 +218,9 @@ After completing the task, provide a clear summary of what was done.` // Send announce message back to main agent if sm.bus != nil { announceContent := fmt.Sprintf("Task '%s' completed.\n\nResult:\n%s", task.Label, task.Result) - sm.bus.PublishInbound(context.TODO(), bus.InboundMessage{ + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + sm.bus.PublishInbound(pubCtx, bus.InboundMessage{ Channel: "system", SenderID: fmt.Sprintf("subagent:%s", task.ID), // Format: "original_channel:original_chat_id" for routing back From 04806bffe3df969d14ecacf23e968dc86cd56334 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Mon, 23 Feb 2026 15:50:55 -0800 Subject: [PATCH 033/104] Moving logging from INFO to DEBUG for messages Removing extrnaeous comments about mutex in loop.go Made-with: Cursor --- pkg/agent/loop.go | 2 +- pkg/channels/whatsapp_native.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index fc87c6fe5..aacc28093 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -218,6 +218,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { } else { logger.DebugCF("agent", "Skipped outbound (message tool already sent)", map[string]any{"channel": msg.Channel}) } + } }() } } @@ -315,7 +316,6 @@ func (al *AgentLoop) ProcessDirectWithChannel( // ProcessHeartbeat processes a heartbeat request without session history. // Each heartbeat is independent and doesn't accumulate context. -// It uses the same mutex as processMessage so heartbeat and user messages never run concurrently. func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { agent := al.registry.GetDefaultAgent() if agent == nil { diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index 3099afdcc..b9ab2c188 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -267,7 +267,7 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { metadata["peer_id"] = senderID } - logger.InfoCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) + logger.DebugCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) } From 0ede643e7859658c58345e9b10e9ef22e27d3c15 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Tue, 24 Feb 2026 12:17:11 +0800 Subject: [PATCH 034/104] chore: apply PR #697 comment translations to refactored channel subpackages Translate Chinese comments to English in qq, slack, and telegram channel implementations, following the translation work done in PR #697. The original PR modified the old parent package files, but these have been moved to subpackages during the refactor, so translations are applied to the new locations. --- pkg/channels/qq/qq.go | 36 +++++++++++++++---------------- pkg/channels/slack/slack.go | 2 +- pkg/channels/telegram/telegram.go | 2 +- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 85313efe5..1e2cc2354 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -51,31 +51,31 @@ func (c *QQChannel) Start(ctx context.Context) error { logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") - // 创建 token source + // create token source credentials := &token.QQBotCredentials{ AppID: c.config.AppID, AppSecret: c.config.AppSecret, } c.tokenSource = token.NewQQBotTokenSource(credentials) - // 创建子 context + // create child context c.ctx, c.cancel = context.WithCancel(ctx) - // 启动自动刷新 token 协程 + // start auto-refresh token goroutine if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil { return fmt.Errorf("failed to start token refresh: %w", err) } - // 初始化 OpenAPI 客户端 + // initialize OpenAPI client c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second) - // 注册事件处理器 + // register event handlers intent := event.RegisterHandlers( c.handleC2CMessage(), c.handleGroupATMessage(), ) - // 获取 WebSocket 接入点 + // get WebSocket endpoint wsInfo, err := c.api.WS(c.ctx, nil, "") if err != nil { return fmt.Errorf("failed to get websocket info: %w", err) @@ -85,10 +85,10 @@ func (c *QQChannel) Start(ctx context.Context) error { "shards": wsInfo.Shards, }) - // 创建并保存 sessionManager + // create and save sessionManager c.sessionManager = botgo.NewSessionManager() - // 在 goroutine 中启动 WebSocket 连接,避免阻塞 + // start WebSocket connection in goroutine to avoid blocking go func() { if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil { logger.ErrorCF("qq", "WebSocket session error", map[string]any{ @@ -120,12 +120,12 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return channels.ErrNotRunning } - // 构造消息 + // construct message msgToCreate := &dto.MessageToCreate{ Content: msg.Content, } - // C2C 消息发送 + // send C2C message _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) if err != nil { logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ @@ -137,15 +137,15 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } -// handleC2CMessage 处理 QQ 私聊消息 +// handleC2CMessage handles QQ private messages func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { - // 去重检查 + // deduplication check if c.isDuplicate(data.ID) { return nil } - // 提取用户信息 + // extract user info var senderID string if data.Author != nil && data.Author.ID != "" { senderID = data.Author.ID @@ -154,7 +154,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return nil } - // 提取消息内容 + // extract message content content := data.Content if content == "" { logger.DebugC("qq", "Received empty message, ignoring") @@ -194,15 +194,15 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { } } -// handleGroupATMessage 处理群@消息 +// handleGroupATMessage handles QQ group @ messages func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { - // 去重检查 + // deduplication check if c.isDuplicate(data.ID) { return nil } - // 提取用户信息 + // extract user info var senderID string if data.Author != nil && data.Author.ID != "" { senderID = data.Author.ID @@ -211,7 +211,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return nil } - // 提取消息内容(去掉 @ 机器人部分) + // extract message content (remove @ bot part) content := data.Content if content == "" { logger.DebugC("qq", "Received empty group message, ignoring") diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 90c4297ca..7128980e4 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -252,7 +252,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { return } - // 检查白名单,避免为被拒绝的用户下载附件 + // check allowlist to avoid downloading attachments for rejected users sender := bus.SenderInfo{ Platform: "slack", PlatformID: ev.User, diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 6b5a84eda..005b311a2 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -299,7 +299,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes DisplayName: user.FirstName, } - // 检查白名单,避免为被拒绝的用户下载附件 + // check allowlist to avoid downloading attachments for rejected users if !c.IsAllowedSender(sender) { logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{ "user_id": platformID, From 4ada4063d7862cf4a1fc4637afce2da7ff722d2c Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Tue, 24 Feb 2026 21:24:32 +0900 Subject: [PATCH 035/104] feat(media): integrate TTL cleanup into FileMediaStore Add background TTL-based cleanup (L2 safety net) directly into FileMediaStore so file deletion and in-memory ref removal happen atomically under the same mutex, preventing dangling references. - Add storedAt timestamp and refToScope reverse map to mediaEntry - Add CleanExpired() for atomic TTL-based expiration - Add Start()/Stop() for background goroutine lifecycle - Add MediaCleanupConfig (enabled, max_age, interval) to config - Wire up in cmd_gateway.go with config-driven defaults - Add 8 new tests including concurrent cleanup safety Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/internal/gateway/helpers.go | 10 +- pkg/config/config.go | 15 +- pkg/config/defaults.go | 5 + pkg/media/store.go | 109 ++++++++++- pkg/media/store_test.go | 235 ++++++++++++++++++++++- 5 files changed, 363 insertions(+), 11 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index dd93087f4..8255fb6e8 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -114,8 +114,13 @@ func gatewayCmd(debug bool) error { return tools.SilentResult(response) }) - // Create media store for file lifecycle management - mediaStore := media.NewFileMediaStore() + // Create media store for file lifecycle management with TTL cleanup + mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ + Enabled: cfg.Tools.MediaCleanup.Enabled, + MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, + Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, + }) + mediaStore.Start() channelManager, err := channels.NewManager(cfg, msgBus, mediaStore) if err != nil { @@ -195,6 +200,7 @@ func gatewayCmd(debug bool) error { deviceService.Stop() heartbeatService.Stop() cronService.Stop() + mediaStore.Stop() agentLoop.Stop() fmt.Println("✓ Gateway stopped") diff --git a/pkg/config/config.go b/pkg/config/config.go index 56453cd33..64cdf6eac 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -519,11 +519,18 @@ type ExecConfig struct { CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` } +type MediaCleanupConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_MEDIA_CLEANUP_ENABLED"` + MaxAge int `json:"max_age_minutes" env:"PICOCLAW_MEDIA_CLEANUP_MAX_AGE"` + Interval int `json:"interval_minutes" env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL"` +} + type ToolsConfig struct { - Web WebToolsConfig `json:"web"` - Cron CronToolsConfig `json:"cron"` - Exec ExecConfig `json:"exec"` - Skills SkillsToolsConfig `json:"skills"` + Web WebToolsConfig `json:"web"` + Cron CronToolsConfig `json:"cron"` + Exec ExecConfig `json:"exec"` + Skills SkillsToolsConfig `json:"skills"` + MediaCleanup MediaCleanupConfig `json:"media_cleanup"` } type SkillsToolsConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 8445510e2..d19ce1d38 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -291,6 +291,11 @@ func DefaultConfig() *Config { Port: 18790, }, Tools: ToolsConfig{ + MediaCleanup: MediaCleanupConfig{ + Enabled: true, + MaxAge: 30, + Interval: 5, + }, Web: WebToolsConfig{ Proxy: "", Brave: BraveConfig{ diff --git a/pkg/media/store.go b/pkg/media/store.go index 2df4420e9..6851f9512 100644 --- a/pkg/media/store.go +++ b/pkg/media/store.go @@ -2,8 +2,10 @@ package media import ( "fmt" + "log" "os" "sync" + "time" "github.com/google/uuid" ) @@ -35,8 +37,16 @@ type MediaStore interface { // mediaEntry holds the path and metadata for a stored media file. type mediaEntry struct { - path string - meta MediaMeta + path string + meta MediaMeta + storedAt time.Time +} + +// MediaCleanerConfig configures the background TTL cleanup. +type MediaCleanerConfig struct { + Enabled bool + MaxAge time.Duration + Interval time.Duration } // FileMediaStore is a pure in-memory implementation of MediaStore. @@ -45,13 +55,33 @@ type FileMediaStore struct { mu sync.RWMutex refs map[string]mediaEntry scopeToRefs map[string]map[string]struct{} + refToScope map[string]string + + cleanerCfg MediaCleanerConfig + stop chan struct{} + once sync.Once + nowFunc func() time.Time // for testing } -// NewFileMediaStore creates a new FileMediaStore. +// NewFileMediaStore creates a new FileMediaStore without background cleanup. func NewFileMediaStore() *FileMediaStore { return &FileMediaStore{ refs: make(map[string]mediaEntry), scopeToRefs: make(map[string]map[string]struct{}), + refToScope: make(map[string]string), + nowFunc: time.Now, + } +} + +// NewFileMediaStoreWithCleanup creates a FileMediaStore with TTL-based background cleanup. +func NewFileMediaStoreWithCleanup(cfg MediaCleanerConfig) *FileMediaStore { + return &FileMediaStore{ + refs: make(map[string]mediaEntry), + scopeToRefs: make(map[string]map[string]struct{}), + refToScope: make(map[string]string), + cleanerCfg: cfg, + stop: make(chan struct{}), + nowFunc: time.Now, } } @@ -66,11 +96,12 @@ func (s *FileMediaStore) Store(localPath string, meta MediaMeta, scope string) ( s.mu.Lock() defer s.mu.Unlock() - s.refs[ref] = mediaEntry{path: localPath, meta: meta} + s.refs[ref] = mediaEntry{path: localPath, meta: meta, storedAt: s.nowFunc()} if s.scopeToRefs[scope] == nil { s.scopeToRefs[scope] = make(map[string]struct{}) } s.scopeToRefs[scope][ref] = struct{}{} + s.refToScope[ref] = scope return ref, nil } @@ -115,9 +146,79 @@ func (s *FileMediaStore) ReleaseAll(scope string) error { // Log but continue — best effort cleanup } delete(s.refs, ref) + delete(s.refToScope, ref) } } delete(s.scopeToRefs, scope) return nil } + +// CleanExpired removes all entries older than MaxAge. +// Both the file on disk and the in-memory references are deleted atomically +// under the same mutex, preventing dangling references. +func (s *FileMediaStore) CleanExpired() int { + s.mu.Lock() + defer s.mu.Unlock() + + cutoff := s.nowFunc().Add(-s.cleanerCfg.MaxAge) + removed := 0 + + for ref, entry := range s.refs { + if entry.storedAt.Before(cutoff) { + if err := os.Remove(entry.path); err != nil && !os.IsNotExist(err) { + // Log but continue — best effort cleanup + } + + scope := s.refToScope[ref] + if scopeRefs, ok := s.scopeToRefs[scope]; ok { + delete(scopeRefs, ref) + if len(scopeRefs) == 0 { + delete(s.scopeToRefs, scope) + } + } + + delete(s.refs, ref) + delete(s.refToScope, ref) + removed++ + } + } + + return removed +} + +// Start begins the background cleanup goroutine if cleanup is enabled. +func (s *FileMediaStore) Start() { + if !s.cleanerCfg.Enabled || s.stop == nil { + return + } + + log.Printf("[media] cleanup enabled: interval=%s, max_age=%s", + s.cleanerCfg.Interval, s.cleanerCfg.MaxAge) + + go func() { + ticker := time.NewTicker(s.cleanerCfg.Interval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if n := s.CleanExpired(); n > 0 { + log.Printf("[media] cleanup: removed %d expired entries", n) + } + case <-s.stop: + return + } + } + }() +} + +// Stop terminates the background cleanup goroutine. +func (s *FileMediaStore) Stop() { + if s.stop == nil { + return + } + s.once.Do(func() { + close(s.stop) + }) +} diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go index 95bd1eb7a..e934bf9c0 100644 --- a/pkg/media/store_test.go +++ b/pkg/media/store_test.go @@ -1,11 +1,13 @@ package media import ( + "fmt" "os" "path/filepath" "strings" "sync" "testing" + "time" ) func createTempFile(t *testing.T, dir, name string) string { @@ -140,7 +142,8 @@ func TestStoreNonexistentFile(t *testing.T) { t.Error("Store should fail for nonexistent file") } // Error message should include the underlying os error, not just "file does not exist" - if !strings.Contains(err.Error(), "no such file or directory") { + if !strings.Contains(err.Error(), "no such file or directory") && + !strings.Contains(err.Error(), "cannot find") { t.Errorf("Error should contain OS error detail, got: %v", err) } } @@ -221,3 +224,233 @@ func TestConcurrentSafety(t *testing.T) { wg.Wait() } + +// --- TTL cleanup tests --- + +func newTestStoreWithCleanup(maxAge time.Duration) *FileMediaStore { + s := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ + Enabled: true, + MaxAge: maxAge, + Interval: time.Hour, // won't tick in tests + }) + return s +} + +func TestCleanExpiredRemovesOldEntries(t *testing.T) { + dir := t.TempDir() + now := time.Now() + store := newTestStoreWithCleanup(10 * time.Minute) + store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) } + + path := createTempFile(t, dir, "old.jpg") + ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + // Advance clock to present + store.nowFunc = func() time.Time { return now } + removed := store.CleanExpired() + + if removed != 1 { + t.Errorf("expected 1 removed, got %d", removed) + } + if _, err := store.Resolve(ref); err == nil { + t.Error("expired ref should be unresolvable") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("expired file should be deleted") + } +} + +func TestCleanExpiredKeepsNonExpired(t *testing.T) { + dir := t.TempDir() + now := time.Now() + store := newTestStoreWithCleanup(10 * time.Minute) + store.nowFunc = func() time.Time { return now } + + path := createTempFile(t, dir, "fresh.jpg") + ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + removed := store.CleanExpired() + if removed != 0 { + t.Errorf("expected 0 removed, got %d", removed) + } + + if _, err := store.Resolve(ref); err != nil { + t.Errorf("fresh ref should still resolve: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Error("fresh file should still exist") + } +} + +func TestCleanExpiredMixedAges(t *testing.T) { + dir := t.TempDir() + now := time.Now() + store := newTestStoreWithCleanup(10 * time.Minute) + + // Store old entry + store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) } + oldPath := createTempFile(t, dir, "old.jpg") + oldRef, _ := store.Store(oldPath, MediaMeta{Source: "test"}, "scope1") + + // Store fresh entry + store.nowFunc = func() time.Time { return now } + freshPath := createTempFile(t, dir, "fresh.jpg") + freshRef, _ := store.Store(freshPath, MediaMeta{Source: "test"}, "scope1") + + removed := store.CleanExpired() + if removed != 1 { + t.Errorf("expected 1 removed, got %d", removed) + } + + if _, err := store.Resolve(oldRef); err == nil { + t.Error("old ref should be gone") + } + if _, err := store.Resolve(freshRef); err != nil { + t.Errorf("fresh ref should still resolve: %v", err) + } +} + +func TestCleanExpiredCleansEmptyScopes(t *testing.T) { + dir := t.TempDir() + now := time.Now() + store := newTestStoreWithCleanup(10 * time.Minute) + + // Store old entry as the only one in scope + store.nowFunc = func() time.Time { return now.Add(-20 * time.Minute) } + path := createTempFile(t, dir, "only.jpg") + store.Store(path, MediaMeta{Source: "test"}, "lonely_scope") + + store.nowFunc = func() time.Time { return now } + store.CleanExpired() + + store.mu.RLock() + defer store.mu.RUnlock() + if _, ok := store.scopeToRefs["lonely_scope"]; ok { + t.Error("empty scope should be cleaned up") + } +} + +func TestStartStopLifecycle(t *testing.T) { + store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ + Enabled: true, + MaxAge: time.Minute, + Interval: 50 * time.Millisecond, + }) + + // Start and stop should not panic + store.Start() + time.Sleep(100 * time.Millisecond) + store.Stop() + + // Double stop should not panic + store.Stop() +} + +func TestStartDisabledIsNoop(t *testing.T) { + store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ + Enabled: false, + MaxAge: time.Minute, + Interval: time.Minute, + }) + // Should not start any goroutine or panic + store.Start() + store.Stop() +} + +func TestConcurrentCleanupSafety(t *testing.T) { + dir := t.TempDir() + store := newTestStoreWithCleanup(50 * time.Millisecond) + store.nowFunc = time.Now + + const workers = 10 + const ops = 20 + var wg sync.WaitGroup + wg.Add(workers * 4) + + // Store workers + for w := 0; w < workers; w++ { + go func(wIdx int) { + defer wg.Done() + scope := fmt.Sprintf("scope-%d", wIdx) + for i := 0; i < ops; i++ { + p := createTempFile(t, dir, fmt.Sprintf("w%d-f%d.tmp", wIdx, i)) + store.Store(p, MediaMeta{Source: "test"}, scope) + } + }(w) + } + + // Resolve workers + for w := 0; w < workers; w++ { + go func() { + defer wg.Done() + for i := 0; i < ops; i++ { + store.Resolve("media://nonexistent") + } + }() + } + + // ReleaseAll workers + for w := 0; w < workers; w++ { + go func(wIdx int) { + defer wg.Done() + for i := 0; i < ops; i++ { + store.ReleaseAll(fmt.Sprintf("scope-%d", wIdx)) + } + }(w) + } + + // CleanExpired workers + for w := 0; w < workers; w++ { + go func() { + defer wg.Done() + for i := 0; i < ops; i++ { + store.CleanExpired() + } + }() + } + + wg.Wait() +} + +func TestRefToScopeConsistency(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + // Store entries in two scopes + ref1, _ := store.Store(createTempFile(t, dir, "a.jpg"), MediaMeta{Source: "test"}, "s1") + ref2, _ := store.Store(createTempFile(t, dir, "b.jpg"), MediaMeta{Source: "test"}, "s1") + ref3, _ := store.Store(createTempFile(t, dir, "c.jpg"), MediaMeta{Source: "test"}, "s2") + + store.mu.RLock() + checkRef := func(ref, expectedScope string) { + t.Helper() + if scope, ok := store.refToScope[ref]; !ok || scope != expectedScope { + t.Errorf("refToScope[%s] = %q, want %q", ref, scope, expectedScope) + } + } + checkRef(ref1, "s1") + checkRef(ref2, "s1") + checkRef(ref3, "s2") + store.mu.RUnlock() + + // Release s1 and verify refToScope is cleaned + store.ReleaseAll("s1") + + store.mu.RLock() + defer store.mu.RUnlock() + if _, ok := store.refToScope[ref1]; ok { + t.Error("refToScope should not contain ref1 after ReleaseAll") + } + if _, ok := store.refToScope[ref2]; ok { + t.Error("refToScope should not contain ref2 after ReleaseAll") + } + if _, ok := store.refToScope[ref3]; !ok { + t.Error("refToScope should still contain ref3") + } +} From 72e897f95adfb9924e7472c020be44ac7991cc97 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Tue, 24 Feb 2026 22:30:22 +0800 Subject: [PATCH 036/104] fix(channels): fix memory hazards in channel abstraction layer Address 7 memory/architecture issues affecting long-running gateway processes on embedded devices (<10MB RAM): - Fix dispatcher busy-wait: remove select+default pattern that caused CPU spin after context cancellation; SubscribeOutbound handles ctx internally - Add TTL janitor for typingStops/placeholders sync.Map entries to prevent unbounded accumulation when outbound paths fail - Reduce queue buffers from 100 to 16 slots (~84% memory reduction) - Optimize SplitMessage with index-based rune operations to reduce intermediate string/rune allocations - Replace uuid.New() with atomic counter + random prefix for media scope IDs (eliminates per-call crypto/rand syscall) - Lazy channel worker creation: defer goroutine+buffer allocation until channel.Start() succeeds --- pkg/bus/bus.go | 8 +- pkg/bus/bus_test.go | 4 +- pkg/channels/base.go | 31 ++++- pkg/channels/manager.go | 203 ++++++++++++++++++++----------- pkg/channels/manager_test.go | 230 +++++++++++++++++++++++++++++++++++ pkg/channels/split.go | 161 +++++++++++++----------- 6 files changed, 483 insertions(+), 154 deletions(-) diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index d2b6838c5..507a87d05 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -11,6 +11,8 @@ import ( // ErrBusClosed is returned when publishing to a closed MessageBus. var ErrBusClosed = errors.New("message bus closed") +const defaultBusBufferSize = 16 + type MessageBus struct { inbound chan InboundMessage outbound chan OutboundMessage @@ -21,9 +23,9 @@ type MessageBus struct { func NewMessageBus() *MessageBus { return &MessageBus{ - inbound: make(chan InboundMessage, 100), - outbound: make(chan OutboundMessage, 100), - outboundMedia: make(chan OutboundMediaMessage, 100), + inbound: make(chan InboundMessage, defaultBusBufferSize), + outbound: make(chan OutboundMessage, defaultBusBufferSize), + outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize), done: make(chan struct{}), } } diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go index 47826824e..cc3f6961f 100644 --- a/pkg/bus/bus_test.go +++ b/pkg/bus/bus_test.go @@ -67,7 +67,7 @@ func TestPublishInbound_ContextCancel(t *testing.T) { // Fill the buffer ctx := context.Background() - for i := 0; i < 100; i++ { + for i := 0; i < defaultBusBufferSize; i++ { if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } @@ -194,7 +194,7 @@ func TestPublishInbound_FullBuffer(t *testing.T) { ctx := context.Background() // Fill the buffer - for i := 0; i < 100; i++ { + for i := 0; i < defaultBusBufferSize; i++ { if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil { t.Fatalf("fill failed at %d: %v", i, err) } diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 418933af7..1aae47faa 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -2,10 +2,13 @@ package channels import ( "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "strconv" "strings" "sync/atomic" - - "github.com/google/uuid" + "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" @@ -14,6 +17,28 @@ import ( "github.com/sipeed/picoclaw/pkg/media" ) +var ( + fastIDCounter uint64 + fastIDPrefix string +) + +func init() { + // One-time read from crypto/rand for a unique prefix (single syscall). + var b [8]byte + if _, err := rand.Read(b[:]); err != nil { + // fallback to time-based prefix + binary.BigEndian.PutUint64(b[:], uint64(time.Now().UnixNano())) + } + fastIDPrefix = hex.EncodeToString(b[:]) +} + +// fastID generates a unique ID using a random prefix and an atomic counter. +// Much cheaper than uuid.New() which calls crypto/rand.Read on every invocation. +func fastID() string { + n := atomic.AddUint64(&fastIDCounter, 1) + return fastIDPrefix + strconv.FormatUint(n, 16) +} + type Channel interface { Name() string Start(ctx context.Context) error @@ -264,7 +289,7 @@ func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder { func BuildMediaScope(channel, chatID, messageID string) string { id := messageID if id == "" { - id = uuid.New().String() + id = fastID() } return channel + ":" + chatID + ":" + id } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 07c2ce1e2..ae6eb3ce4 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -26,14 +26,30 @@ import ( ) const ( - defaultChannelQueueSize = 100 + defaultChannelQueueSize = 16 defaultRateLimit = 10 // default 10 msg/s maxRetries = 3 rateLimitDelay = 1 * time.Second baseBackoff = 500 * time.Millisecond maxBackoff = 8 * time.Second + + janitorInterval = 10 * time.Second + typingStopTTL = 5 * time.Minute + placeholderTTL = 10 * time.Minute ) +// typingEntry wraps a typing stop function with a creation timestamp for TTL eviction. +type typingEntry struct { + stop func() + createdAt time.Time +} + +// placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction. +type placeholderEntry struct { + id string + createdAt time.Time +} + // channelRateConfig maps channel name to per-second rate limit. var channelRateConfig = map[string]float64{ "telegram": 20, @@ -73,14 +89,14 @@ type asyncTask struct { // Implements PlaceholderRecorder. func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { key := channel + ":" + chatID - m.placeholders.Store(key, placeholderID) + m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()}) } // RecordTypingStop registers a typing stop function for later invocation. // Implements PlaceholderRecorder. func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { key := channel + ":" + chatID - m.typingStops.Store(key, stop) + m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()}) } // preSend handles typing stop and placeholder editing before sending a message. @@ -90,16 +106,16 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess // 1. Stop typing if v, loaded := m.typingStops.LoadAndDelete(key); loaded { - if stop, ok := v.(func()); ok { - stop() // idempotent, safe + if entry, ok := v.(typingEntry); ok { + entry.stop() // idempotent, safe } } // 2. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { - if placeholderID, ok := v.(string); ok && placeholderID != "" { + if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { - if err := editor.EditMessage(ctx, msg.ChatID, placeholderID, msg.Content); err == nil { + if err := editor.EditMessage(ctx, msg.ChatID, entry.id, msg.Content); err == nil { return true // edited successfully, skip Send } // edit failed → fall through to normal Send @@ -156,7 +172,6 @@ func (m *Manager) initChannel(name, displayName string) { setter.SetPlaceholderRecorder(m) } m.channels[name] = ch - m.workers[name] = newChannelWorker(name, ch) logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ "channel": displayName, }) @@ -285,11 +300,11 @@ func (m *Manager) StartAll(ctx context.Context) error { "channel": name, "error": err.Error(), }) + continue } - } - - // Start per-channel workers - for name, w := range m.workers { + // Lazily create worker only after channel starts successfully + w := newChannelWorker(name, channel) + m.workers[name] = w go m.runWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w) } @@ -298,6 +313,9 @@ func (m *Manager) StartAll(ctx context.Context) error { go m.dispatchOutbound(dispatchCtx) go m.dispatchOutboundMedia(dispatchCtx) + // Start the TTL janitor that cleans up stale typing/placeholder entries + go m.runTTLJanitor(dispatchCtx) + // Start shared HTTP server if configured if m.httpServer != nil { go func() { @@ -342,17 +360,25 @@ func (m *Manager) StopAll(ctx context.Context) error { // Close all worker queues and wait for them to drain for _, w := range m.workers { - close(w.queue) + if w != nil { + close(w.queue) + } } for _, w := range m.workers { - <-w.done + if w != nil { + <-w.done + } } // Close all media worker queues and wait for them to drain for _, w := range m.workers { - close(w.mediaQueue) + if w != nil { + close(w.mediaQueue) + } } for _, w := range m.workers { - <-w.mediaDone + if w != nil { + <-w.mediaDone + } } // Stop all channels @@ -487,40 +513,39 @@ func (m *Manager) dispatchOutbound(ctx context.Context) { logger.InfoC("channels", "Outbound dispatcher started") for { - select { - case <-ctx.Done(): + msg, ok := m.bus.SubscribeOutbound(ctx) + if !ok { logger.InfoC("channels", "Outbound dispatcher stopped") return - default: - msg, ok := m.bus.SubscribeOutbound(ctx) - if !ok { - continue - } + } - // Silently skip internal channels - if constants.IsInternalChannel(msg.Channel) { - continue - } + // Silently skip internal channels + if constants.IsInternalChannel(msg.Channel) { + continue + } - m.mu.RLock() - _, exists := m.channels[msg.Channel] - w, wExists := m.workers[msg.Channel] - m.mu.RUnlock() + m.mu.RLock() + _, exists := m.channels[msg.Channel] + w, wExists := m.workers[msg.Channel] + m.mu.RUnlock() - if !exists { - logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{ - "channel": msg.Channel, - }) - continue - } + if !exists { + logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{ + "channel": msg.Channel, + }) + continue + } - if wExists { - select { - case w.queue <- msg: - case <-ctx.Done(): - return - } + if wExists && w != nil { + select { + case w.queue <- msg: + case <-ctx.Done(): + return } + } else if exists { + logger.WarnCF("channels", "Channel has no active worker, skipping message", map[string]any{ + "channel": msg.Channel, + }) } } } @@ -529,40 +554,39 @@ func (m *Manager) dispatchOutboundMedia(ctx context.Context) { logger.InfoC("channels", "Outbound media dispatcher started") for { - select { - case <-ctx.Done(): + msg, ok := m.bus.SubscribeOutboundMedia(ctx) + if !ok { logger.InfoC("channels", "Outbound media dispatcher stopped") return - default: - msg, ok := m.bus.SubscribeOutboundMedia(ctx) - if !ok { - continue - } + } - // Silently skip internal channels - if constants.IsInternalChannel(msg.Channel) { - continue - } + // Silently skip internal channels + if constants.IsInternalChannel(msg.Channel) { + continue + } - m.mu.RLock() - _, exists := m.channels[msg.Channel] - w, wExists := m.workers[msg.Channel] - m.mu.RUnlock() + m.mu.RLock() + _, exists := m.channels[msg.Channel] + w, wExists := m.workers[msg.Channel] + m.mu.RUnlock() - if !exists { - logger.WarnCF("channels", "Unknown channel for outbound media message", map[string]any{ - "channel": msg.Channel, - }) - continue - } + if !exists { + logger.WarnCF("channels", "Unknown channel for outbound media message", map[string]any{ + "channel": msg.Channel, + }) + continue + } - if wExists { - select { - case w.mediaQueue <- msg: - case <-ctx.Done(): - return - } + if wExists && w != nil { + select { + case w.mediaQueue <- msg: + case <-ctx.Done(): + return } + } else if exists { + logger.WarnCF("channels", "Channel has no active worker, skipping media message", map[string]any{ + "channel": msg.Channel, + }) } } } @@ -644,6 +668,40 @@ func (m *Manager) sendMediaWithRetry(ctx context.Context, name string, w *channe }) } +// runTTLJanitor periodically scans the typingStops and placeholders maps +// and evicts entries that have exceeded their TTL. This prevents memory +// accumulation when outbound paths fail to trigger preSend (e.g. LLM errors). +func (m *Manager) runTTLJanitor(ctx context.Context) { + ticker := time.NewTicker(janitorInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + m.typingStops.Range(func(key, value any) bool { + if entry, ok := value.(typingEntry); ok { + if now.Sub(entry.createdAt) > typingStopTTL { + if _, loaded := m.typingStops.LoadAndDelete(key); loaded { + entry.stop() // idempotent, safe + } + } + } + return true + }) + m.placeholders.Range(func(key, value any) bool { + if entry, ok := value.(placeholderEntry); ok { + if now.Sub(entry.createdAt) > placeholderTTL { + m.placeholders.Delete(key) + } + } + return true + }) + } + } +} + func (m *Manager) GetChannel(name string) (Channel, bool) { m.mu.RLock() defer m.mu.RUnlock() @@ -680,13 +738,12 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { m.mu.Lock() defer m.mu.Unlock() m.channels[name] = channel - m.workers[name] = newChannelWorker(name, channel) } func (m *Manager) UnregisterChannel(name string) { m.mu.Lock() defer m.mu.Unlock() - if w, ok := m.workers[name]; ok { + if w, ok := m.workers[name]; ok && w != nil { close(w.queue) <-w.done close(w.mediaQueue) @@ -712,7 +769,7 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten Content: content, } - if wExists { + if wExists && w != nil { select { case w.queue <- msg: return nil diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 0573c0a8e..45590584b 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -632,3 +632,233 @@ func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) { t.Fatal("expected Send to NOT be called when placeholder was edited") } } + +// --- Dispatcher exit tests (Step 1) --- + +func TestDispatcherExitsOnCancel(t *testing.T) { + mb := bus.NewMessageBus() + defer mb.Close() + + m := &Manager{ + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + bus: mb, + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + + go func() { + m.dispatchOutbound(ctx) + close(done) + }() + + // Cancel context and verify the dispatcher exits quickly + cancel() + + select { + case <-done: + // success + case <-time.After(2 * time.Second): + t.Fatal("dispatchOutbound did not exit within 2s after context cancel") + } +} + +func TestDispatcherMediaExitsOnCancel(t *testing.T) { + mb := bus.NewMessageBus() + defer mb.Close() + + m := &Manager{ + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + bus: mb, + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + + go func() { + m.dispatchOutboundMedia(ctx) + close(done) + }() + + cancel() + + select { + case <-done: + // success + case <-time.After(2 * time.Second): + t.Fatal("dispatchOutboundMedia did not exit within 2s after context cancel") + } +} + +// --- TTL Janitor tests (Step 2) --- + +func TestTypingStopJanitorEviction(t *testing.T) { + m := newTestManager() + + var stopCalled atomic.Bool + // Store a typing entry with a creation time far in the past + m.typingStops.Store("test:123", typingEntry{ + stop: func() { stopCalled.Store(true) }, + createdAt: time.Now().Add(-10 * time.Minute), // well past typingStopTTL + }) + + // Run janitor with a short-lived context + ctx, cancel := context.WithCancel(context.Background()) + + // Manually trigger the janitor logic once by simulating a tick + go func() { + // Override janitor to run immediately + now := time.Now() + m.typingStops.Range(func(key, value any) bool { + if entry, ok := value.(typingEntry); ok { + if now.Sub(entry.createdAt) > typingStopTTL { + if _, loaded := m.typingStops.LoadAndDelete(key); loaded { + entry.stop() + } + } + } + return true + }) + cancel() + }() + + <-ctx.Done() + + if !stopCalled.Load() { + t.Fatal("expected typing stop function to be called by janitor eviction") + } + + // Verify entry was deleted + if _, loaded := m.typingStops.Load("test:123"); loaded { + t.Fatal("expected typing entry to be deleted after eviction") + } +} + +func TestPlaceholderJanitorEviction(t *testing.T) { + m := newTestManager() + + // Store a placeholder entry with a creation time far in the past + m.placeholders.Store("test:456", placeholderEntry{ + id: "msg_old", + createdAt: time.Now().Add(-20 * time.Minute), // well past placeholderTTL + }) + + // Simulate janitor logic + now := time.Now() + m.placeholders.Range(func(key, value any) bool { + if entry, ok := value.(placeholderEntry); ok { + if now.Sub(entry.createdAt) > placeholderTTL { + m.placeholders.Delete(key) + } + } + return true + }) + + // Verify entry was deleted + if _, loaded := m.placeholders.Load("test:456"); loaded { + t.Fatal("expected placeholder entry to be deleted after eviction") + } +} + +func TestPreSendStillWorksWithWrappedTypes(t *testing.T) { + m := newTestManager() + var stopCalled bool + var editCalled bool + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + }, + editFn: func(_ context.Context, chatID, messageID, content string) error { + editCalled = true + if messageID != "ph_id" { + t.Fatalf("expected messageID ph_id, got %s", messageID) + } + return nil + }, + } + + // Use the new wrapped types via the public API + m.RecordTypingStop("test", "chat1", func() { + stopCalled = true + }) + m.RecordPlaceholder("test", "chat1", "ph_id") + + msg := bus.OutboundMessage{Channel: "test", ChatID: "chat1", Content: "response"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if !stopCalled { + t.Fatal("expected typing stop to be called via wrapped type") + } + if !editCalled { + t.Fatal("expected EditMessage to be called via wrapped type") + } + if !edited { + t.Fatal("expected preSend to return true") + } +} + +// --- Lazy worker creation tests (Step 6) --- + +func TestLazyWorkerCreation(t *testing.T) { + m := newTestManager() + + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + return nil + }, + } + + // RegisterChannel should NOT create a worker + m.RegisterChannel("lazy", ch) + + m.mu.RLock() + _, chExists := m.channels["lazy"] + _, wExists := m.workers["lazy"] + m.mu.RUnlock() + + if !chExists { + t.Fatal("expected channel to be registered") + } + if wExists { + t.Fatal("expected worker to NOT be created by RegisterChannel (lazy creation)") + } +} + +// --- FastID uniqueness test (Step 5) --- + +func TestBuildMediaScope_FastIDUniqueness(t *testing.T) { + seen := make(map[string]bool) + + for i := 0; i < 1000; i++ { + scope := BuildMediaScope("test", "chat1", "") + if seen[scope] { + t.Fatalf("duplicate scope generated: %s", scope) + } + seen[scope] = true + } + + // Verify format: "channel:chatID:id" + scope := BuildMediaScope("telegram", "42", "") + parts := 0 + for _, c := range scope { + if c == ':' { + parts++ + } + } + if parts != 2 { + t.Fatalf("expected scope to have 2 colons (channel:chatID:id), got: %s", scope) + } +} + +func TestBuildMediaScope_WithMessageID(t *testing.T) { + scope := BuildMediaScope("discord", "chat99", "msg123") + expected := "discord:chat99:msg123" + if scope != expected { + t.Fatalf("expected %s, got %s", expected, scope) + } +} diff --git a/pkg/channels/split.go b/pkg/channels/split.go index 27d76df1b..1c951a31f 100644 --- a/pkg/channels/split.go +++ b/pkg/channels/split.go @@ -19,6 +19,7 @@ func SplitMessage(content string, maxLen int) []string { } runes := []rune(content) + totalLen := len(runes) var messages []string // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible @@ -30,9 +31,11 @@ func SplitMessage(content string, maxLen int) []string { codeBlockBuffer = maxLen / 2 } - for len(runes) > 0 { - if len(runes) <= maxLen { - messages = append(messages, string(runes)) + start := 0 + for start < totalLen { + remaining := totalLen - start + if remaining <= maxLen { + messages = append(messages, string(runes[start:totalLen])) break } @@ -42,77 +45,88 @@ func SplitMessage(content string, maxLen int) []string { effectiveLimit = maxLen / 2 } + end := start + effectiveLimit + // Find natural split point within the effective limit - msgEnd := findLastNewlineRunes(runes[:effectiveLimit], 200) - if msgEnd <= 0 { - msgEnd = findLastSpaceRunes(runes[:effectiveLimit], 100) + msgEnd := findLastNewlineInRange(runes, start, end, 200) + if msgEnd <= start { + msgEnd = findLastSpaceInRange(runes, start, end, 100) } - if msgEnd <= 0 { - msgEnd = effectiveLimit + if msgEnd <= start { + msgEnd = end } // Check if this would end with an incomplete code block - candidate := runes[:msgEnd] - unclosedIdx := findLastUnclosedCodeBlockRunes(candidate) + unclosedIdx := findLastUnclosedCodeBlockInRange(runes, start, msgEnd) if unclosedIdx >= 0 { // Message would end with incomplete code block // Try to extend up to maxLen to include the closing ``` - if len(runes) > msgEnd { - closingIdx := findNextClosingCodeBlockRunes(runes, msgEnd) - if closingIdx > 0 && closingIdx <= maxLen { + if totalLen > msgEnd { + closingIdx := findNextClosingCodeBlockInRange(runes, msgEnd, totalLen) + if closingIdx > 0 && closingIdx-start <= maxLen { // Extend to include the closing ``` msgEnd = closingIdx } else { // Code block is too long to fit in one chunk or missing closing fence. // Try to split inside by injecting closing and reopening fences. - fenceRunes := runes[unclosedIdx:] - headerEnd := findNewlineInRunes(fenceRunes) + headerEnd := findNewlineFrom(runes, unclosedIdx) var header string if headerEnd == -1 { header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+3])) } else { - header = strings.TrimSpace(string(runes[unclosedIdx : unclosedIdx+headerEnd])) + header = strings.TrimSpace(string(runes[unclosedIdx:headerEnd])) } headerEndIdx := unclosedIdx + len([]rune(header)) if headerEnd != -1 { - headerEndIdx = unclosedIdx + headerEnd + headerEndIdx = headerEnd } // If we have a reasonable amount of content after the header, split inside if msgEnd > headerEndIdx+20 { // Find a better split point closer to maxLen - innerLimit := maxLen - 5 // Leave room for "\n```" - betterEnd := findLastNewlineRunes(runes[:innerLimit], 200) + innerLimit := start + maxLen - 5 // Leave room for "\n```" + if innerLimit > totalLen { + innerLimit = totalLen + } + betterEnd := findLastNewlineInRange(runes, start, innerLimit, 200) if betterEnd > headerEndIdx { msgEnd = betterEnd } else { msgEnd = innerLimit } - chunk := strings.TrimRight(string(runes[:msgEnd]), " \t\n\r") + "\n```" + chunk := strings.TrimRight(string(runes[start:msgEnd]), " \t\n\r") + "\n```" messages = append(messages, chunk) - remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:])) + remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:totalLen])) + // Replace the tail of runes with the reconstructed remaining runes = []rune(remaining) + totalLen = len(runes) + start = 0 continue } // Otherwise, try to split before the code block starts - newEnd := findLastNewlineRunes(runes[:unclosedIdx], 200) - if newEnd <= 0 { - newEnd = findLastSpaceRunes(runes[:unclosedIdx], 100) + newEnd := findLastNewlineInRange(runes, start, unclosedIdx, 200) + if newEnd <= start { + newEnd = findLastSpaceInRange(runes, start, unclosedIdx, 100) } - if newEnd > 0 { + if newEnd > start { msgEnd = newEnd } else { // If we can't split before, we MUST split inside (last resort) - if unclosedIdx > 20 { + if unclosedIdx-start > 20 { msgEnd = unclosedIdx } else { - msgEnd = maxLen - 5 - chunk := strings.TrimRight(string(runes[:msgEnd]), " \t\n\r") + "\n```" + splitAt := start + maxLen - 5 + if splitAt > totalLen { + splitAt = totalLen + } + chunk := strings.TrimRight(string(runes[start:splitAt]), " \t\n\r") + "\n```" messages = append(messages, chunk) - remaining := strings.TrimSpace(header + "\n" + string(runes[msgEnd:])) + remaining := strings.TrimSpace(header + "\n" + string(runes[splitAt:totalLen])) runes = []rune(remaining) + totalLen = len(runes) + start = 0 continue } } @@ -120,29 +134,30 @@ func SplitMessage(content string, maxLen int) []string { } } - if msgEnd <= 0 { - msgEnd = effectiveLimit + if msgEnd <= start { + msgEnd = start + effectiveLimit } - messages = append(messages, string(runes[:msgEnd])) - remaining := strings.TrimSpace(string(runes[msgEnd:])) - runes = []rune(remaining) + messages = append(messages, string(runes[start:msgEnd])) + // Advance start, skipping leading whitespace of next chunk + start = msgEnd + for start < totalLen && (runes[start] == ' ' || runes[start] == '\t' || runes[start] == '\n' || runes[start] == '\r') { + start++ + } } return messages } -// findLastUnclosedCodeBlockRunes finds the last opening ``` that doesn't have a closing ``` -// Returns the rune position of the opening ``` or -1 if all code blocks are complete -func findLastUnclosedCodeBlockRunes(runes []rune) int { +// findLastUnclosedCodeBlockInRange finds the last opening ``` that doesn't have a closing ``` +// within runes[start:end]. Returns the absolute rune index or -1. +func findLastUnclosedCodeBlockInRange(runes []rune, start, end int) int { inCodeBlock := false lastOpenIdx := -1 - for i := 0; i < len(runes); i++ { - if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { - // Toggle code block state on each fence + for i := start; i < end; i++ { + if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { if !inCodeBlock { - // Entering a code block: record this opening fence lastOpenIdx = i } inCodeBlock = !inCodeBlock @@ -156,36 +171,21 @@ func findLastUnclosedCodeBlockRunes(runes []rune) int { return -1 } -// findNextClosingCodeBlockRunes finds the next closing ``` starting from a rune position -// Returns the rune position after the closing ``` or -1 if not found -func findNextClosingCodeBlockRunes(runes []rune, startIdx int) int { - for i := startIdx; i < len(runes); i++ { - if i+2 < len(runes) && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { +// findNextClosingCodeBlockInRange finds the next closing ``` starting from startIdx +// within runes[startIdx:end]. Returns the absolute index after the closing ``` or -1. +func findNextClosingCodeBlockInRange(runes []rune, startIdx, end int) int { + for i := startIdx; i < end; i++ { + if i+2 < end && runes[i] == '`' && runes[i+1] == '`' && runes[i+2] == '`' { return i + 3 } } return -1 } -// findNewlineInRunes finds the first newline character in a rune slice. -// Returns the rune index of the newline or -1 if not found. -func findNewlineInRunes(runes []rune) int { - for i, r := range runes { - if r == '\n' { - return i - } - } - return -1 -} - -// findLastNewlineRunes finds the last newline character within the last N runes -// Returns the rune position of the newline or -1 if not found -func findLastNewlineRunes(runes []rune, searchWindow int) int { - searchStart := len(runes) - searchWindow - if searchStart < 0 { - searchStart = 0 - } - for i := len(runes) - 1; i >= searchStart; i-- { +// findNewlineFrom finds the first newline character starting from the given index. +// Returns the absolute index or -1 if not found. +func findNewlineFrom(runes []rune, from int) int { + for i := from; i < len(runes); i++ { if runes[i] == '\n' { return i } @@ -193,17 +193,32 @@ func findLastNewlineRunes(runes []rune, searchWindow int) int { return -1 } -// findLastSpaceRunes finds the last space character within the last N runes -// Returns the rune position of the space or -1 if not found -func findLastSpaceRunes(runes []rune, searchWindow int) int { - searchStart := len(runes) - searchWindow - if searchStart < 0 { - searchStart = 0 +// findLastNewlineInRange finds the last newline within the last searchWindow runes +// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found). +func findLastNewlineInRange(runes []rune, start, end, searchWindow int) int { + searchStart := end - searchWindow + if searchStart < start { + searchStart = start } - for i := len(runes) - 1; i >= searchStart; i-- { + for i := end - 1; i >= searchStart; i-- { + if runes[i] == '\n' { + return i + } + } + return start - 1 +} + +// findLastSpaceInRange finds the last space/tab within the last searchWindow runes +// of the range runes[start:end]. Returns the absolute index or start-1 (indicating not found). +func findLastSpaceInRange(runes []rune, start, end, searchWindow int) int { + searchStart := end - searchWindow + if searchStart < start { + searchStart = start + } + for i := end - 1; i >= searchStart; i-- { if runes[i] == ' ' || runes[i] == '\t' { return i } } - return -1 + return start - 1 } From a4b6cea103cf533f14e064c9c6e4766fe39f728a Mon Sep 17 00:00:00 2001 From: Nikita Nafranets Date: Mon, 23 Feb 2026 16:13:37 +0300 Subject: [PATCH 037/104] fix: distinguish network timeouts from context window errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HTTP timeouts (context deadline exceeded, Client.Timeout) were incorrectly classified as context window errors, triggering useless history compression. Replace broad substring checks ("context", "token", "length") with specific patterns for real context limit errors and explicitly exclude timeout errors from that path. Additionally, timeout errors were not retried at all — the retry loop only handled context window errors. Now timeouts are retried up to 2 times with exponential backoff (5s, 10s). --- pkg/agent/loop.go | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index dbc4a9b87..58dce8cc4 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -9,6 +9,7 @@ package agent import ( "context" "encoding/json" + "errors" "fmt" "strings" "sync" @@ -554,10 +555,35 @@ func (al *AgentLoop) runLLMIteration( } errMsg := strings.ToLower(err.Error()) - isContextError := strings.Contains(errMsg, "token") || - strings.Contains(errMsg, "context") || + + // Check if this is a network/HTTP timeout — not a context window error. + isTimeoutError := errors.Is(err, context.DeadlineExceeded) || + strings.Contains(errMsg, "deadline exceeded") || + strings.Contains(errMsg, "client.timeout") || + strings.Contains(errMsg, "timed out") || + strings.Contains(errMsg, "timeout exceeded") + + // Detect real context window / token limit errors, excluding network timeouts. + isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || + strings.Contains(errMsg, "context window") || + strings.Contains(errMsg, "maximum context length") || + strings.Contains(errMsg, "token limit") || + strings.Contains(errMsg, "too many tokens") || + strings.Contains(errMsg, "max_tokens") || strings.Contains(errMsg, "invalidparameter") || - strings.Contains(errMsg, "length") + strings.Contains(errMsg, "prompt is too long") || + strings.Contains(errMsg, "request too large")) + + if isTimeoutError && retry < maxRetries { + backoff := time.Duration(retry+1) * 5 * time.Second + logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ + "error": err.Error(), + "retry": retry, + "backoff": backoff.String(), + }) + time.Sleep(backoff) + continue + } if isContextError && retry < maxRetries { logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ From c241c55a0d7cc1fb8a0b5ca78bb04004ab15e93e Mon Sep 17 00:00:00 2001 From: Hoshina Date: Wed, 25 Feb 2026 11:51:21 +0800 Subject: [PATCH 038/104] fix(channels): address PR #734 review comments Rename fastID() to uniqueID() with a security caveat comment clarifying the ID is not cryptographically secure, and add unit tests for the refactored index-based split helper functions. --- pkg/channels/base.go | 19 ++-- pkg/channels/split_test.go | 185 +++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 9 deletions(-) diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 1aae47faa..6bf4a0d56 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -18,8 +18,8 @@ import ( ) var ( - fastIDCounter uint64 - fastIDPrefix string + uniqueIDCounter uint64 + uniqueIDPrefix string ) func init() { @@ -29,14 +29,15 @@ func init() { // fallback to time-based prefix binary.BigEndian.PutUint64(b[:], uint64(time.Now().UnixNano())) } - fastIDPrefix = hex.EncodeToString(b[:]) + uniqueIDPrefix = hex.EncodeToString(b[:]) } -// fastID generates a unique ID using a random prefix and an atomic counter. -// Much cheaper than uuid.New() which calls crypto/rand.Read on every invocation. -func fastID() string { - n := atomic.AddUint64(&fastIDCounter, 1) - return fastIDPrefix + strconv.FormatUint(n, 16) +// uniqueID generates a process-unique ID using a random prefix and an atomic counter. +// This ID is intended for internal correlation (e.g. media scope keys) and is NOT +// cryptographically secure — it must not be used in contexts where unpredictability matters. +func uniqueID() string { + n := atomic.AddUint64(&uniqueIDCounter, 1) + return uniqueIDPrefix + strconv.FormatUint(n, 16) } type Channel interface { @@ -289,7 +290,7 @@ func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder { func BuildMediaScope(channel, chatID, messageID string) string { id := messageID if id == "" { - id = fastID() + id = uniqueID() } return channel + ":" + chatID + ":" + id } diff --git a/pkg/channels/split_test.go b/pkg/channels/split_test.go index d6356bdb9..a922f9558 100644 --- a/pkg/channels/split_test.go +++ b/pkg/channels/split_test.go @@ -147,6 +147,191 @@ func TestSplitMessage(t *testing.T) { } } +// --- Helper function tests for index-based rune operations --- + +func TestFindLastNewlineInRange(t *testing.T) { + runes := []rune("aaa\nbbb\nccc") + // Indices: 0123 4567 89 10 + + tests := []struct { + name string + start, end int + searchWindow int + want int + }{ + {"finds last newline in full range", 0, 11, 200, 7}, + {"finds newline within search window", 0, 11, 4, 7}, + {"narrow window misses newline outside window", 4, 11, 3, 3}, // returns start-1 (not found) + {"no newline in range", 0, 3, 200, -1}, // start-1 = -1 + {"range limited to first segment", 0, 4, 200, 3}, + {"search window of 1 at newline", 0, 8, 1, 7}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := findLastNewlineInRange(runes, tc.start, tc.end, tc.searchWindow) + if got != tc.want { + t.Errorf("findLastNewlineInRange(runes, %d, %d, %d) = %d, want %d", + tc.start, tc.end, tc.searchWindow, got, tc.want) + } + }) + } +} + +func TestFindLastSpaceInRange(t *testing.T) { + runes := []rune("abc def\tghi") + // Indices: 0123 4567 89 10 + + tests := []struct { + name string + start, end int + searchWindow int + want int + }{ + {"finds tab as last space/tab", 0, 11, 200, 7}, + {"finds space when tab out of window", 0, 7, 200, 3}, + {"no space in range", 0, 3, 200, -1}, + {"narrow window finds tab", 5, 11, 4, 7}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := findLastSpaceInRange(runes, tc.start, tc.end, tc.searchWindow) + if got != tc.want { + t.Errorf("findLastSpaceInRange(runes, %d, %d, %d) = %d, want %d", + tc.start, tc.end, tc.searchWindow, got, tc.want) + } + }) + } +} + +func TestFindNewlineFrom(t *testing.T) { + runes := []rune("hello\nworld\n") + + tests := []struct { + name string + from int + want int + }{ + {"from start", 0, 5}, + {"from after first newline", 6, 11}, + {"from past all newlines", 12, -1}, + {"from newline itself", 5, 5}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := findNewlineFrom(runes, tc.from) + if got != tc.want { + t.Errorf("findNewlineFrom(runes, %d) = %d, want %d", tc.from, got, tc.want) + } + }) + } +} + +func TestFindLastUnclosedCodeBlockInRange(t *testing.T) { + tests := []struct { + name string + content string + start, end int + want int + }{ + { + name: "no code blocks", + content: "hello world", + start: 0, end: 11, + want: -1, + }, + { + name: "complete code block", + content: "```go\ncode\n```", + start: 0, end: 14, + want: -1, + }, + { + name: "unclosed code block", + content: "text\n```go\ncode here", + start: 0, end: 20, + want: 5, + }, + { + name: "closed then unclosed", + content: "```a\n```\n```b\ncode", + start: 0, end: 17, + want: 9, + }, + { + name: "search within subrange", + content: "```a\n```\n```b\ncode", + start: 9, end: 17, + want: 9, + }, + { + name: "subrange with no code blocks", + content: "```a\n```\nhello", + start: 9, end: 14, + want: -1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + runes := []rune(tc.content) + got := findLastUnclosedCodeBlockInRange(runes, tc.start, tc.end) + if got != tc.want { + t.Errorf("findLastUnclosedCodeBlockInRange(%q, %d, %d) = %d, want %d", + tc.content, tc.start, tc.end, got, tc.want) + } + }) + } +} + +func TestFindNextClosingCodeBlockInRange(t *testing.T) { + tests := []struct { + name string + content string + startIdx int + end int + want int + }{ + { + name: "finds closing fence", + content: "code\n```\nmore", + startIdx: 0, end: 13, + want: 8, // position after ``` + }, + { + name: "no closing fence", + content: "just code here", + startIdx: 0, end: 14, + want: -1, + }, + { + name: "fence at start of search", + content: "```end", + startIdx: 0, end: 6, + want: 3, + }, + { + name: "fence outside range", + content: "code\n```", + startIdx: 0, end: 4, + want: -1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + runes := []rune(tc.content) + got := findNextClosingCodeBlockInRange(runes, tc.startIdx, tc.end) + if got != tc.want { + t.Errorf("findNextClosingCodeBlockInRange(%q, %d, %d) = %d, want %d", + tc.content, tc.startIdx, tc.end, got, tc.want) + } + }) + } +} + func TestSplitMessage_CodeBlockIntegrity(t *testing.T) { // Focused test for the core requirement: splitting inside a code block preserves syntax highlighting From 49612adf8ae67621ec7cce63d46bfd6989f08911 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Wed, 25 Feb 2026 20:50:40 -0800 Subject: [PATCH 039/104] Rebuilt after the refactoring of the base channel implementation. --- pkg/channels/whatsapp_native.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native.go index b9ab2c188..72666cb35 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native.go @@ -146,7 +146,7 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } c.runCtx, c.runCancel = context.WithCancel(ctx) - c.setRunning(true) + c.SetRunning(true) logger.InfoCF("channels", "WhatsApp native channel connected", nil) return nil } @@ -169,7 +169,7 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { if container != nil { _ = container.Close() } - c.setRunning(false) + c.SetRunning(false) return nil } @@ -267,8 +267,21 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { metadata["peer_id"] = senderID } + peerKind := "direct" + if evt.Info.Chat.Server == types.GroupServer { + peerKind = "group" + } + peer := bus.Peer{Kind: peerKind, ID: chatID} + messageID := evt.Info.ID + sender := bus.SenderInfo{ + Platform: "whatsapp", + PlatformID: senderID, + CanonicalID: "whatsapp:" + senderID, + DisplayName: evt.Info.PushName, + } + logger.DebugCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) - c.HandleMessage(senderID, chatID, content, mediaPaths, metadata) + c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { From b705e58528cb85e15e4ce80468684f56a2dfe160 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Thu, 26 Feb 2026 16:22:49 +0900 Subject: [PATCH 040/104] fix(media): address review comments on TTL cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CleanExpired: split into two phases — collect expired entries under lock, then delete files after releasing the lock to minimize contention - CleanExpired: guard against zero MaxAge (no-op if unconfigured) - CleanExpired: log file removal errors instead of silently ignoring - Start: protect with startOnce to prevent multiple goroutines - Stop: rename once -> stopOnce for clarity - cmd_gateway: call mediaStore.Stop() on error path after Start() - Add TestCleanExpiredZeroMaxAge and double-Start test Co-Authored-By: Claude Opus 4.6 --- cmd/picoclaw/internal/gateway/helpers.go | 1 + pkg/media/store.go | 70 +++++++++++++++--------- pkg/media/store_test.go | 23 ++++++++ 3 files changed, 68 insertions(+), 26 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 8255fb6e8..360073f01 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -124,6 +124,7 @@ func gatewayCmd(debug bool) error { channelManager, err := channels.NewManager(cfg, msgBus, mediaStore) if err != nil { + mediaStore.Stop() return fmt.Errorf("error creating channel manager: %w", err) } diff --git a/pkg/media/store.go b/pkg/media/store.go index 6851f9512..c6a6502a1 100644 --- a/pkg/media/store.go +++ b/pkg/media/store.go @@ -59,7 +59,8 @@ type FileMediaStore struct { cleanerCfg MediaCleanerConfig stop chan struct{} - once sync.Once + startOnce sync.Once + stopOnce sync.Once nowFunc func() time.Time // for testing } @@ -155,20 +156,26 @@ func (s *FileMediaStore) ReleaseAll(scope string) error { } // CleanExpired removes all entries older than MaxAge. -// Both the file on disk and the in-memory references are deleted atomically -// under the same mutex, preventing dangling references. +// Phase 1 (under lock): identify expired entries and remove from maps. +// Phase 2 (no lock): delete files from disk to minimize lock contention. func (s *FileMediaStore) CleanExpired() int { - s.mu.Lock() - defer s.mu.Unlock() + if s.cleanerCfg.MaxAge <= 0 { + return 0 + } + // Phase 1: collect expired entries under lock + type expiredEntry struct { + ref string + path string + } + + s.mu.Lock() cutoff := s.nowFunc().Add(-s.cleanerCfg.MaxAge) - removed := 0 + var expired []expiredEntry for ref, entry := range s.refs { if entry.storedAt.Before(cutoff) { - if err := os.Remove(entry.path); err != nil && !os.IsNotExist(err) { - // Log but continue — best effort cleanup - } + expired = append(expired, expiredEntry{ref: ref, path: entry.path}) scope := s.refToScope[ref] if scopeRefs, ok := s.scopeToRefs[scope]; ok { @@ -180,45 +187,56 @@ func (s *FileMediaStore) CleanExpired() int { delete(s.refs, ref) delete(s.refToScope, ref) - removed++ + } + } + s.mu.Unlock() + + // Phase 2: delete files without holding the lock + for _, e := range expired { + if err := os.Remove(e.path); err != nil && !os.IsNotExist(err) { + log.Printf("[media] cleanup: failed to remove %s: %v", e.path, err) } } - return removed + return len(expired) } // Start begins the background cleanup goroutine if cleanup is enabled. +// Safe to call multiple times; only the first call starts the goroutine. func (s *FileMediaStore) Start() { if !s.cleanerCfg.Enabled || s.stop == nil { return } - log.Printf("[media] cleanup enabled: interval=%s, max_age=%s", - s.cleanerCfg.Interval, s.cleanerCfg.MaxAge) + s.startOnce.Do(func() { + log.Printf("[media] cleanup enabled: interval=%s, max_age=%s", + s.cleanerCfg.Interval, s.cleanerCfg.MaxAge) - go func() { - ticker := time.NewTicker(s.cleanerCfg.Interval) - defer ticker.Stop() + go func() { + ticker := time.NewTicker(s.cleanerCfg.Interval) + defer ticker.Stop() - for { - select { - case <-ticker.C: - if n := s.CleanExpired(); n > 0 { - log.Printf("[media] cleanup: removed %d expired entries", n) + for { + select { + case <-ticker.C: + if n := s.CleanExpired(); n > 0 { + log.Printf("[media] cleanup: removed %d expired entries", n) + } + case <-s.stop: + return } - case <-s.stop: - return } - } - }() + }() + }) } // Stop terminates the background cleanup goroutine. +// Safe to call multiple times; only the first call closes the channel. func (s *FileMediaStore) Stop() { if s.stop == nil { return } - s.once.Do(func() { + s.stopOnce.Do(func() { close(s.stop) }) } diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go index e934bf9c0..e45212671 100644 --- a/pkg/media/store_test.go +++ b/pkg/media/store_test.go @@ -345,6 +345,8 @@ func TestStartStopLifecycle(t *testing.T) { // Start and stop should not panic store.Start() + // Double start should not spawn a second goroutine + store.Start() time.Sleep(100 * time.Millisecond) store.Stop() @@ -352,6 +354,27 @@ func TestStartStopLifecycle(t *testing.T) { store.Stop() } +func TestCleanExpiredZeroMaxAge(t *testing.T) { + store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ + Enabled: true, + MaxAge: 0, + Interval: time.Hour, + }) + + dir := t.TempDir() + path := createTempFile(t, dir, "file.jpg") + ref, _ := store.Store(path, MediaMeta{Source: "test"}, "scope1") + + // Zero MaxAge should be a no-op + removed := store.CleanExpired() + if removed != 0 { + t.Errorf("expected 0 removed with zero MaxAge, got %d", removed) + } + if _, err := store.Resolve(ref); err != nil { + t.Errorf("ref should still resolve: %v", err) + } +} + func TestStartDisabledIsNoop(t *testing.T) { store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ Enabled: false, From d804f9cb3fde9b213647e4b4217f6441469c6823 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Thu, 26 Feb 2026 16:33:32 +0900 Subject: [PATCH 041/104] fix(media): guard Interval<=0 panic, two-phase ReleaseAll Address Codex (GPT-5.2) review feedback: - Start: guard against Interval<=0 or MaxAge<=0 to prevent time.NewTicker panic on misconfiguration - ReleaseAll: split into two phases (collect under lock, delete after unlock) matching CleanExpired pattern - ReleaseAll: log file removal errors - Add TestStartZeroIntervalNoPanic and TestStartZeroMaxAgeNoPanic Co-Authored-By: Claude Opus 4.6 --- pkg/media/store.go | 27 +++++++++++++++++++++------ pkg/media/store_test.go | 21 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/pkg/media/store.go b/pkg/media/store.go index c6a6502a1..c4cd816d2 100644 --- a/pkg/media/store.go +++ b/pkg/media/store.go @@ -132,26 +132,36 @@ func (s *FileMediaStore) ResolveWithMeta(ref string) (string, MediaMeta, error) } // ReleaseAll removes all files under the given scope and cleans up mappings. +// Phase 1 (under lock): remove entries from maps. +// Phase 2 (no lock): delete files from disk. func (s *FileMediaStore) ReleaseAll(scope string) error { - s.mu.Lock() - defer s.mu.Unlock() + // Phase 1: collect paths and remove from maps under lock + var paths []string + s.mu.Lock() refs, ok := s.scopeToRefs[scope] if !ok { + s.mu.Unlock() return nil } for ref := range refs { if entry, exists := s.refs[ref]; exists { - if err := os.Remove(entry.path); err != nil && !os.IsNotExist(err) { - // Log but continue — best effort cleanup - } + paths = append(paths, entry.path) delete(s.refs, ref) delete(s.refToScope, ref) } } - delete(s.scopeToRefs, scope) + s.mu.Unlock() + + // Phase 2: delete files without holding the lock + for _, p := range paths { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + log.Printf("[media] release: failed to remove %s: %v", p, err) + } + } + return nil } @@ -207,6 +217,11 @@ func (s *FileMediaStore) Start() { if !s.cleanerCfg.Enabled || s.stop == nil { return } + if s.cleanerCfg.Interval <= 0 || s.cleanerCfg.MaxAge <= 0 { + log.Printf("[media] cleanup: skipped (interval=%s, max_age=%s)", + s.cleanerCfg.Interval, s.cleanerCfg.MaxAge) + return + } s.startOnce.Do(func() { log.Printf("[media] cleanup enabled: interval=%s, max_age=%s", diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go index e45212671..b14e1ff9a 100644 --- a/pkg/media/store_test.go +++ b/pkg/media/store_test.go @@ -386,6 +386,27 @@ func TestStartDisabledIsNoop(t *testing.T) { store.Stop() } +func TestStartZeroIntervalNoPanic(t *testing.T) { + store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ + Enabled: true, + MaxAge: time.Minute, + Interval: 0, + }) + // Zero interval should not panic (time.NewTicker panics on <= 0) + store.Start() + store.Stop() +} + +func TestStartZeroMaxAgeNoPanic(t *testing.T) { + store := NewFileMediaStoreWithCleanup(MediaCleanerConfig{ + Enabled: true, + MaxAge: 0, + Interval: time.Minute, + }) + store.Start() + store.Stop() +} + func TestConcurrentCleanupSafety(t *testing.T) { dir := t.TempDir() store := newTestStoreWithCleanup(50 * time.Millisecond) From e450e9e053fa7f3cb36ec2873f12ffc5686145a8 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Thu, 26 Feb 2026 16:43:41 +0900 Subject: [PATCH 042/104] feat(line): add StartTyping and PlaceholderRecorder integration Implement TypingCapable interface for LINE channel using the loading animation API (1:1 chats only, no group support). - Add StartTyping() with 50s periodic refresh and context-based stop - Integrate PlaceholderRecorder.RecordTypingStop in processEvent - Skip RecordPlaceholder (LINE has no message edit API) - Change sendLoading to accept context and return error - Relax callAPI status check from 200 to 2xx range Design consulted with Codex (GPT-5.2). Co-Authored-By: Claude Opus 4.6 --- pkg/channels/line/line.go | 74 +++++++++++++++++++++++++++++++++------ 1 file changed, 64 insertions(+), 10 deletions(-) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 28d5ad8f7..91ebc7f72 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -368,9 +368,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { "preview": utils.Truncate(content, 50), }) - // Show typing/loading indicator (requires user ID, not group ID) - c.sendLoading(senderID) - sender := bus.SenderInfo{ Platform: "line", PlatformID: senderID, @@ -381,6 +378,30 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } + // Thinking indicator (LINE loading animation is 1:1 only). + // For group/room chats, LINE provides no equivalent API. + if !isGroup { + typingCtx, typingCancel := context.WithTimeout(c.ctx, 5*time.Minute) + stop, err := c.StartTyping(typingCtx, chatID) + if err == nil { + var stopOnce sync.Once + stopFn := func() { + stopOnce.Do(func() { + stop() + typingCancel() + }) + } + if rec := c.GetPlaceholderRecorder(); rec != nil { + rec.RecordTypingStop("line", chatID, stopFn) + } else { + // No recorder — stop immediately to avoid goroutine leaks. + stopFn() + } + } else { + typingCancel() + } + } + c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) } @@ -577,17 +598,50 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri return c.callAPI(ctx, linePushEndpoint, payload) } +// StartTyping implements channels.TypingCapable using LINE's loading animation. +// +// NOTE: The LINE loading animation API only works for 1:1 chats. Callers must ensure +// the provided chatID is a user chat ID (not a group/room ID). +// There is no explicit "stop" API; we periodically re-send start requests to keep +// the indicator alive, and stop by canceling the context. +func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + if chatID == "" { + return func() {}, nil + } + + typingCtx, cancel := context.WithCancel(ctx) + var once sync.Once + stop := func() { once.Do(cancel) } + + // Send immediately, then refresh periodically for long-running tasks. + if err := c.sendLoading(typingCtx, chatID); err != nil { + stop() + return stop, err + } + + ticker := time.NewTicker(50 * time.Second) + go func() { + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + _ = c.sendLoading(typingCtx, chatID) + } + } + }() + + return stop, nil +} + // sendLoading sends a loading animation indicator to the chat. -func (c *LINEChannel) sendLoading(chatID string) { +func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { payload := map[string]any{ "chatId": chatID, "loadingSeconds": 60, } - if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil { - logger.DebugCF("line", "Failed to send loading indicator", map[string]any{ - "error": err.Error(), - }) - } + return c.callAPI(ctx, lineLoadingEndpoint, payload) } // callAPI makes an authenticated POST request to the LINE API. @@ -612,7 +666,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBody, _ := io.ReadAll(resp.Body) return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody))) } From e268ea82b934b3bcc1820d5b1ec8d7fc852a6deb Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Thu, 26 Feb 2026 20:17:45 +0900 Subject: [PATCH 043/104] Revert "feat(line): add StartTyping and PlaceholderRecorder integration" This reverts commit ad736d71cb603cf1f1d20ea17758f95be26952cc. --- pkg/channels/line/line.go | 74 ++++++--------------------------------- 1 file changed, 10 insertions(+), 64 deletions(-) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 91ebc7f72..28d5ad8f7 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -368,6 +368,9 @@ func (c *LINEChannel) processEvent(event lineEvent) { "preview": utils.Truncate(content, 50), }) + // Show typing/loading indicator (requires user ID, not group ID) + c.sendLoading(senderID) + sender := bus.SenderInfo{ Platform: "line", PlatformID: senderID, @@ -378,30 +381,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } - // Thinking indicator (LINE loading animation is 1:1 only). - // For group/room chats, LINE provides no equivalent API. - if !isGroup { - typingCtx, typingCancel := context.WithTimeout(c.ctx, 5*time.Minute) - stop, err := c.StartTyping(typingCtx, chatID) - if err == nil { - var stopOnce sync.Once - stopFn := func() { - stopOnce.Do(func() { - stop() - typingCancel() - }) - } - if rec := c.GetPlaceholderRecorder(); rec != nil { - rec.RecordTypingStop("line", chatID, stopFn) - } else { - // No recorder — stop immediately to avoid goroutine leaks. - stopFn() - } - } else { - typingCancel() - } - } - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) } @@ -598,50 +577,17 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri return c.callAPI(ctx, linePushEndpoint, payload) } -// StartTyping implements channels.TypingCapable using LINE's loading animation. -// -// NOTE: The LINE loading animation API only works for 1:1 chats. Callers must ensure -// the provided chatID is a user chat ID (not a group/room ID). -// There is no explicit "stop" API; we periodically re-send start requests to keep -// the indicator alive, and stop by canceling the context. -func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { - if chatID == "" { - return func() {}, nil - } - - typingCtx, cancel := context.WithCancel(ctx) - var once sync.Once - stop := func() { once.Do(cancel) } - - // Send immediately, then refresh periodically for long-running tasks. - if err := c.sendLoading(typingCtx, chatID); err != nil { - stop() - return stop, err - } - - ticker := time.NewTicker(50 * time.Second) - go func() { - defer ticker.Stop() - for { - select { - case <-typingCtx.Done(): - return - case <-ticker.C: - _ = c.sendLoading(typingCtx, chatID) - } - } - }() - - return stop, nil -} - // sendLoading sends a loading animation indicator to the chat. -func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { +func (c *LINEChannel) sendLoading(chatID string) { payload := map[string]any{ "chatId": chatID, "loadingSeconds": 60, } - return c.callAPI(ctx, lineLoadingEndpoint, payload) + if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil { + logger.DebugCF("line", "Failed to send loading indicator", map[string]any{ + "error": err.Error(), + }) + } } // callAPI makes an authenticated POST request to the LINE API. @@ -666,7 +612,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) } defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { + if resp.StatusCode != http.StatusOK { respBody, _ := io.ReadAll(resp.Body) return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody))) } From 61eae92b38e51a7cb46680ee9fbd5ba0d959e3b3 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Thu, 26 Feb 2026 20:29:09 +0900 Subject: [PATCH 044/104] fix(line): log loading refresh errors, skip typing without recorder Address review feedback from @alexhoshina and Codex: - Log sendLoading errors in ticker goroutine instead of discarding - Only start typing indicator when PlaceholderRecorder is available to avoid wasted API calls and unnecessary goroutine creation Co-Authored-By: Claude Opus 4.6 --- pkg/channels/line/line.go | 74 ++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 9 deletions(-) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 28d5ad8f7..1a5aa8ac8 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -368,9 +368,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { "preview": utils.Truncate(content, 50), }) - // Show typing/loading indicator (requires user ID, not group ID) - c.sendLoading(senderID) - sender := bus.SenderInfo{ Platform: "line", PlatformID: senderID, @@ -381,6 +378,28 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } + // Thinking indicator (LINE loading animation is 1:1 only). + // For group/room chats, LINE provides no equivalent API. + // Only start if PlaceholderRecorder is available to avoid wasted API calls. + if !isGroup { + if rec := c.GetPlaceholderRecorder(); rec != nil { + typingCtx, typingCancel := context.WithTimeout(c.ctx, 5*time.Minute) + stop, err := c.StartTyping(typingCtx, chatID) + if err == nil { + var stopOnce sync.Once + stopFn := func() { + stopOnce.Do(func() { + stop() + typingCancel() + }) + } + rec.RecordTypingStop("line", chatID, stopFn) + } else { + typingCancel() + } + } + } + c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) } @@ -577,17 +596,54 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri return c.callAPI(ctx, linePushEndpoint, payload) } +// StartTyping implements channels.TypingCapable using LINE's loading animation. +// +// NOTE: The LINE loading animation API only works for 1:1 chats. Callers must ensure +// the provided chatID is a user chat ID (not a group/room ID). +// There is no explicit "stop" API; we periodically re-send start requests to keep +// the indicator alive, and stop by canceling the context. +func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + if chatID == "" { + return func() {}, nil + } + + typingCtx, cancel := context.WithCancel(ctx) + var once sync.Once + stop := func() { once.Do(cancel) } + + // Send immediately, then refresh periodically for long-running tasks. + if err := c.sendLoading(typingCtx, chatID); err != nil { + stop() + return stop, err + } + + ticker := time.NewTicker(50 * time.Second) + go func() { + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + if err := c.sendLoading(typingCtx, chatID); err != nil { + logger.DebugCF("line", "Failed to refresh loading indicator", map[string]any{ + "error": err.Error(), + }) + } + } + } + }() + + return stop, nil +} + // sendLoading sends a loading animation indicator to the chat. -func (c *LINEChannel) sendLoading(chatID string) { +func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error { payload := map[string]any{ "chatId": chatID, "loadingSeconds": 60, } - if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil { - logger.DebugCF("line", "Failed to send loading indicator", map[string]any{ - "error": err.Error(), - }) - } + return c.callAPI(ctx, lineLoadingEndpoint, payload) } // callAPI makes an authenticated POST request to the LINE API. From 94aa2b1788032299bb798c912fd1e3607430248f Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Thu, 26 Feb 2026 22:39:58 +0900 Subject: [PATCH 045/104] fix(media): use project logger and harden map cleanup - Replace stdlib log.Printf with logger.InfoCF/WarnCF for consistency with the rest of the codebase (addresses @nikolasdehor review point #3) - ReleaseAll: clean refToScope/refs mappings even if refs entry is missing - CleanExpired: guard refToScope lookup before scope cleanup - Add TestReleaseAllCleansMappingsIfRefsMissing for robustness Co-Authored-By: Claude Opus 4.6 --- pkg/media/store.go | 43 +++++++++++++++++++++++++++-------------- pkg/media/store_test.go | 30 ++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 15 deletions(-) diff --git a/pkg/media/store.go b/pkg/media/store.go index c4cd816d2..902bfa540 100644 --- a/pkg/media/store.go +++ b/pkg/media/store.go @@ -2,12 +2,12 @@ package media import ( "fmt" - "log" "os" "sync" "time" "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/logger" ) // MediaMeta holds metadata about a stored media file. @@ -148,9 +148,9 @@ func (s *FileMediaStore) ReleaseAll(scope string) error { for ref := range refs { if entry, exists := s.refs[ref]; exists { paths = append(paths, entry.path) - delete(s.refs, ref) - delete(s.refToScope, ref) } + delete(s.refs, ref) + delete(s.refToScope, ref) } delete(s.scopeToRefs, scope) s.mu.Unlock() @@ -158,7 +158,10 @@ func (s *FileMediaStore) ReleaseAll(scope string) error { // Phase 2: delete files without holding the lock for _, p := range paths { if err := os.Remove(p); err != nil && !os.IsNotExist(err) { - log.Printf("[media] release: failed to remove %s: %v", p, err) + logger.WarnCF("media", "release: failed to remove file", map[string]any{ + "path": p, + "error": err.Error(), + }) } } @@ -187,11 +190,12 @@ func (s *FileMediaStore) CleanExpired() int { if entry.storedAt.Before(cutoff) { expired = append(expired, expiredEntry{ref: ref, path: entry.path}) - scope := s.refToScope[ref] - if scopeRefs, ok := s.scopeToRefs[scope]; ok { - delete(scopeRefs, ref) - if len(scopeRefs) == 0 { - delete(s.scopeToRefs, scope) + if scope, ok := s.refToScope[ref]; ok { + if scopeRefs, ok := s.scopeToRefs[scope]; ok { + delete(scopeRefs, ref) + if len(scopeRefs) == 0 { + delete(s.scopeToRefs, scope) + } } } @@ -204,7 +208,10 @@ func (s *FileMediaStore) CleanExpired() int { // Phase 2: delete files without holding the lock for _, e := range expired { if err := os.Remove(e.path); err != nil && !os.IsNotExist(err) { - log.Printf("[media] cleanup: failed to remove %s: %v", e.path, err) + logger.WarnCF("media", "cleanup: failed to remove file", map[string]any{ + "path": e.path, + "error": err.Error(), + }) } } @@ -218,14 +225,18 @@ func (s *FileMediaStore) Start() { return } if s.cleanerCfg.Interval <= 0 || s.cleanerCfg.MaxAge <= 0 { - log.Printf("[media] cleanup: skipped (interval=%s, max_age=%s)", - s.cleanerCfg.Interval, s.cleanerCfg.MaxAge) + logger.WarnCF("media", "cleanup: skipped due to invalid config", map[string]any{ + "interval": s.cleanerCfg.Interval.String(), + "max_age": s.cleanerCfg.MaxAge.String(), + }) return } s.startOnce.Do(func() { - log.Printf("[media] cleanup enabled: interval=%s, max_age=%s", - s.cleanerCfg.Interval, s.cleanerCfg.MaxAge) + logger.InfoCF("media", "cleanup enabled", map[string]any{ + "interval": s.cleanerCfg.Interval.String(), + "max_age": s.cleanerCfg.MaxAge.String(), + }) go func() { ticker := time.NewTicker(s.cleanerCfg.Interval) @@ -235,7 +246,9 @@ func (s *FileMediaStore) Start() { select { case <-ticker.C: if n := s.CleanExpired(); n > 0 { - log.Printf("[media] cleanup: removed %d expired entries", n) + logger.InfoCF("media", "cleanup: removed expired entries", map[string]any{ + "count": n, + }) } case <-s.stop: return diff --git a/pkg/media/store_test.go b/pkg/media/store_test.go index b14e1ff9a..989f90d7c 100644 --- a/pkg/media/store_test.go +++ b/pkg/media/store_test.go @@ -134,6 +134,36 @@ func TestReleaseAllIdempotent(t *testing.T) { } } +func TestReleaseAllCleansMappingsIfRefsMissing(t *testing.T) { + dir := t.TempDir() + store := NewFileMediaStore() + + path := createTempFile(t, dir, "file.jpg") + ref, err := store.Store(path, MediaMeta{Source: "test"}, "scope1") + if err != nil { + t.Fatalf("Store failed: %v", err) + } + + // Simulate internal inconsistency: scopeToRefs/refToScope contains ref but refs map doesn't. + store.mu.Lock() + delete(store.refs, ref) + store.mu.Unlock() + + if err := store.ReleaseAll("scope1"); err != nil { + t.Fatalf("ReleaseAll failed: %v", err) + } + + // ReleaseAll should still clean mappings (even if it can't delete the file without the path). + store.mu.RLock() + defer store.mu.RUnlock() + if _, ok := store.refToScope[ref]; ok { + t.Error("refToScope should not contain ref after ReleaseAll") + } + if _, ok := store.scopeToRefs["scope1"]; ok { + t.Error("scopeToRefs should not contain scope1 after ReleaseAll") + } +} + func TestStoreNonexistentFile(t *testing.T) { store := NewFileMediaStore() From 0a7c929905e38b6fddc33871ee5ffa232ab8eec2 Mon Sep 17 00:00:00 2001 From: ex-takashima Date: Thu, 26 Feb 2026 22:45:59 +0900 Subject: [PATCH 046/104] fix(media): separate import groups for gci linter Co-Authored-By: Claude Opus 4.6 --- pkg/media/store.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/media/store.go b/pkg/media/store.go index 902bfa540..30220986c 100644 --- a/pkg/media/store.go +++ b/pkg/media/store.go @@ -7,6 +7,7 @@ import ( "time" "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/logger" ) From 1d4fe4652a4641a4f59410354395a23a29e1c434 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Thu, 26 Feb 2026 22:46:57 +0800 Subject: [PATCH 047/104] fix(bus): increase message bus buffer size from 16 to 64 Prevents potential backpressure under load when multiple channels publish concurrently in gateway mode, where SDK callbacks blocking on a full buffer can cause message loss or timeouts. --- pkg/bus/bus.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index 507a87d05..f2bf0c379 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -11,7 +11,7 @@ import ( // ErrBusClosed is returned when publishing to a closed MessageBus. var ErrBusClosed = errors.New("message bus closed") -const defaultBusBufferSize = 16 +const defaultBusBufferSize = 64 type MessageBus struct { inbound chan InboundMessage From 35a035bddabcd19408e227d10b6343e1a41242e2 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Thu, 26 Feb 2026 23:24:35 +0800 Subject: [PATCH 048/104] fix: port main branch changes to channel subpackages after rebase Port changes that were applied to the old pkg/channels/*.go files on main to their new locations in channel subpackages: - telegram: precompile regex, var transcribedText, GetModelName() - discord: var transcribedText declaration - onebot: resp.Body.Close(), "canceled" spelling, remove empty line - slack: named return values in parseSlackChatID - wecom: remove sendMarkdownMessage dead code - whatsapp: resp.Body.Close() after Dial - gateway/helpers: remove unused errors import --- cmd/picoclaw/internal/gateway/helpers.go | 1 - go.mod | 2 +- go.sum | 2 - pkg/channels/onebot/onebot.go | 8 +-- pkg/channels/slack/slack.go | 2 +- pkg/channels/telegram/telegram.go | 38 +++++++++----- pkg/channels/telegram/telegram_commands.go | 4 +- pkg/channels/wecom/app.go | 60 ---------------------- pkg/channels/whatsapp/whatsapp.go | 5 +- 9 files changed, 37 insertions(+), 85 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 360073f01..c4a26c6a1 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -176,7 +176,6 @@ func gatewayCmd(debug bool) error { fmt.Printf("Error starting channels: %v\n", err) } - fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) go agentLoop.Run(ctx) diff --git a/go.mod b/go.mod index 2d7624cf7..9bca4c127 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 golang.org/x/oauth2 v0.35.0 + golang.org/x/time v0.14.0 ) require ( @@ -26,7 +27,6 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - golang.org/x/time v0.14.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bd5165d7e..dfb477e51 100644 --- a/go.sum +++ b/go.sum @@ -234,8 +234,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index feb198d7d..cddd374f8 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -172,7 +172,10 @@ func (c *OneBotChannel) connect() error { header["Authorization"] = []string{"Bearer " + c.config.AccessToken} } - conn, _, err := dialer.Dial(c.config.WSUrl, header) + conn, resp, err := dialer.Dial(c.config.WSUrl, header) + if resp != nil { + resp.Body.Close() + } if err != nil { return err } @@ -313,7 +316,7 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D case <-time.After(timeout): return nil, fmt.Errorf("API request %s timed out after %v", action, timeout) case <-c.ctx.Done(): - return nil, fmt.Errorf("context cancelled") + return nil, fmt.Errorf("context canceled") } } @@ -815,7 +818,6 @@ func (c *OneBotChannel) parseMessageSegments( textParts = append(textParts, "[forward message]") default: - } } diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 7128980e4..1733ccee1 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -539,5 +539,5 @@ func parseSlackChatID(chatID string) (channelID, threadTS string) { if len(parts) > 1 { threadTS = parts[1] } - return + return channelID, threadTS } diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 005b311a2..74642a796 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -25,6 +25,19 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +var ( + reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`) + reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) + reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) + reBoldUnder = regexp.MustCompile(`__(.+?)__`) + reItalic = regexp.MustCompile(`_([^_]+)_`) + reStrike = regexp.MustCompile(`~~(.+?)~~`) + reListItem = regexp.MustCompile(`^[-*]\s+`) + reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") + reInlineCode = regexp.MustCompile("`([^`]+)`") +) + type TelegramChannel struct { *channels.BaseChannel bot *telego.Bot @@ -522,19 +535,18 @@ func markdownToTelegramHTML(text string) string { inlineCodes := extractInlineCodes(text) text = inlineCodes.text - text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1") + text = reHeading.ReplaceAllString(text, "$1") - text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1") + text = reBlockquote.ReplaceAllString(text, "$1") text = escapeHTML(text) - text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `$1`) + text = reLink.ReplaceAllString(text, `$1`) - text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "$1") + text = reBoldStar.ReplaceAllString(text, "$1") - text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "$1") + text = reBoldUnder.ReplaceAllString(text, "$1") - reItalic := regexp.MustCompile(`_([^_]+)_`) text = reItalic.ReplaceAllStringFunc(text, func(s string) string { match := reItalic.FindStringSubmatch(s) if len(match) < 2 { @@ -543,9 +555,9 @@ func markdownToTelegramHTML(text string) string { return "" + match[1] + "" }) - text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "$1") + text = reStrike.ReplaceAllString(text, "$1") - text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ") + text = reListItem.ReplaceAllString(text, "• ") for i, code := range inlineCodes.codes { escaped := escapeHTML(code) @@ -570,8 +582,7 @@ type codeBlockMatch struct { } func extractCodeBlocks(text string) codeBlockMatch { - re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") - matches := re.FindAllStringSubmatch(text, -1) + matches := reCodeBlock.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, match := range matches { @@ -579,7 +590,7 @@ func extractCodeBlocks(text string) codeBlockMatch { } i := 0 - text = re.ReplaceAllStringFunc(text, func(m string) string { + text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string { placeholder := fmt.Sprintf("\x00CB%d\x00", i) i++ return placeholder @@ -594,8 +605,7 @@ type inlineCodeMatch struct { } func extractInlineCodes(text string) inlineCodeMatch { - re := regexp.MustCompile("`([^`]+)`") - matches := re.FindAllStringSubmatch(text, -1) + matches := reInlineCode.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, match := range matches { @@ -603,7 +613,7 @@ func extractInlineCodes(text string) inlineCodeMatch { } i := 0 - text = re.ReplaceAllStringFunc(text, func(m string) string { + text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string { placeholder := fmt.Sprintf("\x00IC%d\x00", i) i++ return placeholder diff --git a/pkg/channels/telegram/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go index f17912260..ee3bfef51 100644 --- a/pkg/channels/telegram/telegram_commands.go +++ b/pkg/channels/telegram/telegram_commands.go @@ -81,7 +81,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error { switch args { case "model": response = fmt.Sprintf("Current Model: %s (Provider: %s)", - c.config.Agents.Defaults.Model, + c.config.Agents.Defaults.GetModelName(), c.config.Agents.Defaults.Provider) case "channel": response = "Current Channel: telegram" @@ -120,7 +120,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error { provider = "configured default" } response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", - c.config.Agents.Defaults.Model, provider) + c.config.Agents.Defaults.GetModelName(), provider) case "channels": var enabled []string diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index f1e764864..287017c1c 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -766,66 +766,6 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user return nil } -// sendMarkdownMessage sends a markdown message to a user -func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error { - apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken) - - msg := WeComMarkdownMessage{ - ToUser: userID, - MsgType: "markdown", - AgentID: c.config.AgentID, - } - msg.Markdown.Content = content - - jsonData, err := json.Marshal(msg) - if err != nil { - return fmt.Errorf("failed to marshal message: %w", err) - } - - // Use configurable timeout (default 5 seconds) - timeout := c.config.ReplyTimeout - if timeout <= 0 { - timeout = 5 - } - - reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) - defer cancel() - - req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData)) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{Timeout: time.Duration(timeout) * time.Second} - resp, err := client.Do(req) - if err != nil { - return channels.ClassifyNetError(err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("wecom_app API error: %s", string(body))) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("failed to read response: %w", err) - } - - var sendResp WeComSendMessageResponse - if err := json.Unmarshal(body, &sendResp); err != nil { - return fmt.Errorf("failed to parse response: %w", err) - } - - if sendResp.ErrCode != 0 { - return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode) - } - - return nil -} - // handleHealth handles health check requests func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { status := map[string]any{ diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 106114090..76c60b8c7 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -49,7 +49,10 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error { dialer := websocket.DefaultDialer dialer.HandshakeTimeout = 10 * time.Second - conn, _, err := dialer.Dial(c.url, nil) + conn, resp, err := dialer.Dial(c.url, nil) + if resp != nil { + resp.Body.Close() + } if err != nil { c.cancel() return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err) From ba98069a000b6e32892845e78137158429657b66 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Thu, 26 Feb 2026 23:36:06 +0800 Subject: [PATCH 049/104] fix: resolve wastedassign lint warnings in channel subpackages Remove wasted initial assignments before switch statements in onebot (segType), telegram (filename), and wecom (mediaType). --- cmd/picoclaw/internal/gateway/helpers.go | 2 +- pkg/bus/bus_test.go | 6 +++--- pkg/channels/manager.go | 2 +- pkg/channels/manager_test.go | 2 +- pkg/channels/onebot/onebot.go | 2 +- pkg/channels/telegram/telegram.go | 5 ----- pkg/channels/wecom/app.go | 2 +- 7 files changed, 8 insertions(+), 13 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index c4a26c6a1..b1735c7ab 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -192,7 +192,7 @@ func gatewayCmd(debug bool) error { msgBus.Close() // Use a fresh context with timeout for graceful shutdown, - // since the original ctx is already cancelled. + // since the original ctx is already canceled. shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second) defer shutdownCancel() diff --git a/pkg/bus/bus_test.go b/pkg/bus/bus_test.go index cc3f6961f..a50586df1 100644 --- a/pkg/bus/bus_test.go +++ b/pkg/bus/bus_test.go @@ -73,13 +73,13 @@ func TestPublishInbound_ContextCancel(t *testing.T) { } } - // Now buffer is full; publish with a cancelled context + // Now buffer is full; publish with a canceled context cancelCtx, cancel := context.WithCancel(context.Background()) cancel() err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"}) if err == nil { - t.Fatal("expected error from cancelled context, got nil") + t.Fatal("expected error from canceled context, got nil") } if err != context.Canceled { t.Fatalf("expected context.Canceled, got %v", err) @@ -115,7 +115,7 @@ func TestConsumeInbound_ContextCancel(t *testing.T) { _, ok := mb.ConsumeInbound(ctx) if ok { - t.Fatal("expected ok=false when context is cancelled") + t.Fatal("expected ok=false when context is canceled") } } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index ae6eb3ce4..23a38c699 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -455,7 +455,7 @@ func (m *Manager) runWorker(ctx context.Context, name string, w *channelWorker) func (m *Manager) sendWithRetry(ctx context.Context, name string, w *channelWorker, msg bus.OutboundMessage) { // Rate limit: wait for token if err := w.limiter.Wait(ctx); err != nil { - // ctx cancelled, shutting down + // ctx canceled, shutting down return } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index 45590584b..6b9f151c3 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -245,7 +245,7 @@ func TestSendWithRetry_ContextCancelled(t *testing.T) { m.sendWithRetry(ctx, "test", w, msg) - // Should have called Send once, then noticed ctx cancelled during backoff + // Should have called Send once, then noticed ctx canceled during backoff if callCount != 1 { t.Fatalf("expected 1 Send call before context cancellation, got %d", callCount) } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index cddd374f8..f8042b248 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -469,7 +469,7 @@ func (c *OneBotChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMess continue } - segType := "image" + var segType string switch part.Type { case "image": segType = "image" diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 74642a796..3e4e4d398 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -243,11 +243,6 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe continue } - filename := part.Filename - if filename == "" { - filename = "file" - } - switch part.Type { case "image": params := &telego.SendPhotoParams{ diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 287017c1c..073848bf3 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -221,7 +221,7 @@ func (c *WeComAppChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe } // Map part type to WeCom media type - mediaType := "file" + var mediaType string switch part.Type { case "image": mediaType = "image" From fb96645ea90c265891d845dd61c6c396a781ea70 Mon Sep 17 00:00:00 2001 From: Yiliu Date: Thu, 26 Feb 2026 23:50:33 +0800 Subject: [PATCH 050/104] fix(providers): support lookup-based fallback candidate resolution --- pkg/providers/fallback.go | 17 ++++++++- pkg/providers/fallback_test.go | 69 ++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index ecd451ec9..7ba563b66 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -43,11 +43,26 @@ func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain { // ResolveCandidates parses model config into a deduplicated candidate list. func ResolveCandidates(cfg ModelConfig, defaultProvider string) []FallbackCandidate { + return ResolveCandidatesWithLookup(cfg, defaultProvider, nil) +} + +func ResolveCandidatesWithLookup( + cfg ModelConfig, + defaultProvider string, + lookup func(raw string) (resolved string, ok bool), +) []FallbackCandidate { seen := make(map[string]bool) var candidates []FallbackCandidate addCandidate := func(raw string) { - ref := ParseModelRef(raw, defaultProvider) + candidateRaw := strings.TrimSpace(raw) + if lookup != nil { + if resolved, ok := lookup(candidateRaw); ok { + candidateRaw = resolved + } + } + + ref := ParseModelRef(candidateRaw, defaultProvider) if ref == nil { return } diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index ebba054ef..1783ebcb5 100644 --- a/pkg/providers/fallback_test.go +++ b/pkg/providers/fallback_test.go @@ -453,6 +453,75 @@ func TestResolveCandidates_EmptyPrimary(t *testing.T) { } } +func TestResolveCandidatesWithLookup_AliasResolvesToNestedModel(t *testing.T) { + cfg := ModelConfig{ + Primary: "step-3.5-flash", + Fallbacks: nil, + } + + lookup := func(raw string) (string, bool) { + if raw == "step-3.5-flash" { + return "openrouter/stepfun/step-3.5-flash:free", true + } + return "", false + } + + candidates := ResolveCandidatesWithLookup(cfg, "", lookup) + if len(candidates) != 1 { + t.Fatalf("candidates = %d, want 1", len(candidates)) + } + if candidates[0].Provider != "openrouter" { + t.Fatalf("provider = %q, want openrouter", candidates[0].Provider) + } + if candidates[0].Model != "stepfun/step-3.5-flash:free" { + t.Fatalf("model = %q, want stepfun/step-3.5-flash:free", candidates[0].Model) + } +} + +func TestResolveCandidatesWithLookup_DeduplicateAfterLookup(t *testing.T) { + cfg := ModelConfig{ + Primary: "step-3.5-flash", + Fallbacks: []string{"openrouter/stepfun/step-3.5-flash:free"}, + } + + lookup := func(raw string) (string, bool) { + if raw == "step-3.5-flash" { + return "openrouter/stepfun/step-3.5-flash:free", true + } + return "", false + } + + candidates := ResolveCandidatesWithLookup(cfg, "", lookup) + if len(candidates) != 1 { + t.Fatalf("candidates = %d, want 1", len(candidates)) + } +} + +func TestResolveCandidatesWithLookup_AliasWithoutProtocolUsesDefaultProvider(t *testing.T) { + cfg := ModelConfig{ + Primary: "glm-5", + Fallbacks: nil, + } + + lookup := func(raw string) (string, bool) { + if raw == "glm-5" { + return "glm-5", true + } + return "", false + } + + candidates := ResolveCandidatesWithLookup(cfg, "openai", lookup) + if len(candidates) != 1 { + t.Fatalf("candidates = %d, want 1", len(candidates)) + } + if candidates[0].Provider != "openai" { + t.Fatalf("provider = %q, want openai", candidates[0].Provider) + } + if candidates[0].Model != "glm-5" { + t.Fatalf("model = %q, want glm-5", candidates[0].Model) + } +} + func TestFallbackExhaustedError_Message(t *testing.T) { e := &FallbackExhaustedError{ Attempts: []FallbackAttempt{ From 3a3862340a765e1fd5a0dd5e64da7eab4c2aa7cf Mon Sep 17 00:00:00 2001 From: Yiliu Date: Thu, 26 Feb 2026 23:50:40 +0800 Subject: [PATCH 051/104] fix(agent): resolve fallback model aliases from model_list --- pkg/agent/instance.go | 42 +++++++++++++++++++++- pkg/agent/instance_test.go | 74 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index a6fd365c7..65a1fe04d 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -92,7 +92,47 @@ func NewAgentInstance( Primary: model, Fallbacks: fallbacks, } - candidates := providers.ResolveCandidates(modelCfg, defaults.Provider) + resolveFromModelList := func(raw string) (string, bool) { + ensureProtocol := func(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if strings.Contains(model, "/") { + return model + } + return "openai/" + model + } + + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false + } + + if cfg != nil { + if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { + return ensureProtocol(mc.Model), true + } + + for i := range cfg.ModelList { + fullModel := strings.TrimSpace(cfg.ModelList[i].Model) + if fullModel == "" { + continue + } + if fullModel == raw { + return ensureProtocol(fullModel), true + } + _, modelID := providers.ExtractProtocol(fullModel) + if modelID == raw { + return ensureProtocol(fullModel), true + } + } + } + + return "", false + } + + candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) return &AgentInstance{ ID: agentID, diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index fcc8e9bea..af1bf2ead 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -93,3 +93,77 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7) } } + +func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "step-3.5-flash", + }, + }, + ModelList: []config.ModelConfig{ + { + ModelName: "step-3.5-flash", + Model: "openrouter/stepfun/step-3.5-flash:free", + APIBase: "https://openrouter.ai/api/v1", + }, + }, + } + + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + if len(agent.Candidates) != 1 { + t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) + } + if agent.Candidates[0].Provider != "openrouter" { + t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter") + } + if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" { + t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free") + } +} + +func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "glm-5", + }, + }, + ModelList: []config.ModelConfig{ + { + ModelName: "glm-5", + Model: "glm-5", + APIBase: "https://api.z.ai/api/coding/paas/v4", + }, + }, + } + + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + if len(agent.Candidates) != 1 { + t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) + } + if agent.Candidates[0].Provider != "openai" { + t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai") + } + if agent.Candidates[0].Model != "glm-5" { + t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5") + } +} From 99582bbd91524c29744c34ed60604b866440c669 Mon Sep 17 00:00:00 2001 From: Yiliu Date: Thu, 26 Feb 2026 23:50:48 +0800 Subject: [PATCH 052/104] docs: add issue 783 investigation and execution plan --- ...issue-783-investigation-and-fix-plan.zh.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/design/issue-783-investigation-and-fix-plan.zh.md diff --git a/docs/design/issue-783-investigation-and-fix-plan.zh.md b/docs/design/issue-783-investigation-and-fix-plan.zh.md new file mode 100644 index 000000000..1c9fc1e70 --- /dev/null +++ b/docs/design/issue-783-investigation-and-fix-plan.zh.md @@ -0,0 +1,61 @@ +# Issue #783 调研与修复执行文档 + +## 1. 问题澄清(已确认) + +- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。 +- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。 +- 影响: + - fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`。 + - `defaults.provider` 为空时,日志出现 `provider=` 空值。 + +## 2. 本次目标 + +- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。 +- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。 +- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。 +- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。 + +## 3. 联网最佳实践调研结论(已完成) + +- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。 +- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。 +- [x] 将外部建议映射为本仓库可执行约束。 + +外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档): + +- 优先显式配置,不依赖字符串切分推断 provider。 +- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。 +- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。 + +参考链接: + +- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection +- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks +- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion +- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture +- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/ + +与本仓库对应的可执行约束: + +- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。 +- 未命中映射时保留旧解析行为,保证兼容性。 +- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。 + +## 4. 实施步骤(顺序执行) + +- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。 +- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。 +- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。 +- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。 +- [x] Step 5: 运行质量门禁(LSP + `make check`)。 + +## 5. 执行记录 + +- 状态:已完成 +- 已完成改动: + - `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。 + - `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。 + - `pkg/providers/fallback_test.go`:新增别名解析与去重测试。 + - `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。 +- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。 +- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。 From 29ed65010769ac901eaf077dee3f680d80e14aa6 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 27 Feb 2026 03:02:40 +0800 Subject: [PATCH 053/104] feat(channels): auto-orchestrate Placeholder/Typing/Reaction via capability interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Define PlaceholderCapable, TypingCapable, and ReactionCapable interfaces and have BaseChannel.HandleMessage auto-detect and trigger all three as independent pipelines on inbound messages. This replaces the scattered manual orchestration code in each channel's handleMessage with a single unified dispatch in the framework layer. Changes: - Add PlaceholderCapable interface to interfaces.go - Add ReactionCapable + RecordReactionUndo to interfaces.go - BaseChannel.HandleMessage auto-triggers Typing → Reaction → Placeholder - Manager gains reactionUndos sync.Map with TTL janitor cleanup - Telegram: extract SendPlaceholder from manual code, add StartTyping - Discord: add SendPlaceholder + StartTyping - Pico: add SendPlaceholder (uses Pico Protocol message.create) - Slack: extract ReactToMessage from manual code - OneBot: extract ReactToMessage, remove leaked pendingEmojiMsg sync.Map - LINE: move group-chat guard into StartTyping, remove manual orchestration - Config: add Placeholder to PicoConfig; remove from Slack/LINE/OneBot (no MessageEditor, so placeholder config was dead code) --- pkg/channels/base.go | 30 +++++++++++ pkg/channels/discord/discord.go | 35 ++++++++++--- pkg/channels/interfaces.go | 17 +++++++ pkg/channels/line/line.go | 34 +++---------- pkg/channels/manager.go | 61 ++++++++++++++++++----- pkg/channels/onebot/onebot.go | 57 +++++++++++---------- pkg/channels/pico/pico.go | 34 ++++++++++--- pkg/channels/slack/slack.go | 56 +++++++++------------ pkg/channels/telegram/telegram.go | 82 ++++++++++++++++++++++--------- pkg/config/config.go | 4 +- 10 files changed, 268 insertions(+), 142 deletions(-) diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 6bf4a0d56..2ba450291 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -82,6 +82,7 @@ type BaseChannel struct { groupTrigger config.GroupTriggerConfig mediaStore media.MediaStore placeholderRecorder PlaceholderRecorder + owner Channel // the concrete channel that embeds this BaseChannel } func NewBaseChannel( @@ -257,6 +258,29 @@ func (c *BaseChannel) HandleMessage( Metadata: metadata, } + // Auto-trigger typing indicator, message reaction, and placeholder before publishing. + // Each capability is independent — all three may fire for the same message. + if c.owner != nil && c.placeholderRecorder != nil { + // Typing — independent pipeline + if tc, ok := c.owner.(TypingCapable); ok { + if stop, err := tc.StartTyping(ctx, chatID); err == nil { + c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) + } + } + // Reaction — independent pipeline + if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { + if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { + c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) + } + } + // Placeholder — independent pipeline + if pc, ok := c.owner.(PlaceholderCapable); ok { + if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { + c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) + } + } + } + if err := c.bus.PublishInbound(ctx, msg); err != nil { logger.ErrorCF("channels", "Failed to publish inbound message", map[string]any{ "channel": c.name, @@ -286,6 +310,12 @@ func (c *BaseChannel) GetPlaceholderRecorder() PlaceholderRecorder { return c.placeholderRecorder } +// SetOwner injects the concrete channel that embeds this BaseChannel. +// This allows HandleMessage to auto-trigger TypingCapable / ReactionCapable / PlaceholderCapable. +func (c *BaseChannel) SetOwner(ch Channel) { + c.owner = ch +} + // BuildMediaScope constructs a scope key for media lifecycle tracking. func BuildMediaScope(channel, chatID, messageID string) string { id := messageID diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index dc49e7413..46fbaecfb 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -224,6 +224,27 @@ func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, message return err } +// SendPlaceholder implements channels.PlaceholderCapable. +// It sends a placeholder message that will later be edited to the actual +// response via EditMessage (channels.MessageEditor). +func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + if !c.config.Placeholder.Enabled { + return "", nil + } + + text := c.config.Placeholder.Text + if text == "" { + text = "Thinking... 💭" + } + + msg, err := c.session.ChannelMessageSend(chatID, text) + if err != nil { + return "", err + } + + return msg.ID, nil +} + func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) @@ -360,13 +381,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag content = "[media only]" } - // Start typing after all early returns — guaranteed to have a matching Send() - c.startTyping(m.ChannelID) - // Register typing stop with Manager for outbound orchestration - if rec := c.GetPlaceholderRecorder(); rec != nil { - rec.RecordTypingStop("discord", m.ChannelID, func() { c.stopTyping(m.ChannelID) }) - } - logger.DebugCF("discord", "Received message", map[string]any{ "sender_name": sender.DisplayName, "sender_id": senderID, @@ -440,6 +454,13 @@ func (c *DiscordChannel) stopTyping(chatID string) { } } +// StartTyping implements channels.TypingCapable. +// It starts a continuous typing indicator and returns an idempotent stop function. +func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + c.startTyping(chatID) + return func() { c.stopTyping(chatID) }, nil +} + func (c *DiscordChannel) downloadAttachment(url, filename string) string { return utils.DownloadFile(url, filename, utils.DownloadOptions{ LoggerPrefix: "discord", diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index 32bfe95f8..74caeeac5 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -15,10 +15,27 @@ type MessageEditor interface { EditMessage(ctx context.Context, chatID string, messageID string, content string) error } +// ReactionCapable — channels that can add a reaction (e.g. 👀) to an inbound message. +// ReactToMessage adds a reaction and returns an undo function to remove it. +// The undo function MUST be idempotent and safe to call multiple times. +type ReactionCapable interface { + ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) +} + +// PlaceholderCapable — channels that can send a placeholder message +// (e.g. "Thinking... 💭") that will later be edited to the actual response. +// The channel MUST also implement MessageEditor for the placeholder to be useful. +// SendPlaceholder returns the platform message ID of the placeholder so that +// Manager.preSend can later edit it via MessageEditor.EditMessage. +type PlaceholderCapable interface { + SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) +} + // PlaceholderRecorder is injected into channels by Manager. // Channels call these methods on inbound to register typing/placeholder state. // Manager uses the registered state on outbound to stop typing and edit placeholders. type PlaceholderRecorder interface { RecordPlaceholder(channel, chatID, placeholderID string) RecordTypingStop(channel, chatID string, stop func()) + RecordReactionUndo(channel, chatID string, undo func()) } diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 1a5aa8ac8..25c29c217 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -378,28 +378,6 @@ func (c *LINEChannel) processEvent(event lineEvent) { return } - // Thinking indicator (LINE loading animation is 1:1 only). - // For group/room chats, LINE provides no equivalent API. - // Only start if PlaceholderRecorder is available to avoid wasted API calls. - if !isGroup { - if rec := c.GetPlaceholderRecorder(); rec != nil { - typingCtx, typingCancel := context.WithTimeout(c.ctx, 5*time.Minute) - stop, err := c.StartTyping(typingCtx, chatID) - if err == nil { - var stopOnce sync.Once - stopFn := func() { - stopOnce.Do(func() { - stop() - typingCancel() - }) - } - rec.RecordTypingStop("line", chatID, stopFn) - } else { - typingCancel() - } - } - } - c.HandleMessage(c.ctx, peer, msg.ID, senderID, chatID, content, mediaPaths, metadata, sender) } @@ -598,15 +576,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri // StartTyping implements channels.TypingCapable using LINE's loading animation. // -// NOTE: The LINE loading animation API only works for 1:1 chats. Callers must ensure -// the provided chatID is a user chat ID (not a group/room ID). -// There is no explicit "stop" API; we periodically re-send start requests to keep -// the indicator alive, and stop by canceling the context. +// NOTE: The LINE loading animation API only works for 1:1 chats. +// Group/room chat IDs (starting with "C" or "R") are detected automatically; +// for these, a no-op stop function is returned without calling the API. func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { if chatID == "" { return func() {}, nil } + // Group/room chats: LINE loading animation is 1:1 only. + if strings.HasPrefix(chatID, "C") || strings.HasPrefix(chatID, "R") { + return func() {}, nil + } + typingCtx, cancel := context.WithCancel(ctx) var once sync.Once stop := func() { once.Do(cancel) } diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 23a38c699..38b408f5e 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -44,6 +44,12 @@ type typingEntry struct { createdAt time.Time } +// reactionEntry wraps a reaction undo function with a creation timestamp for TTL eviction. +type reactionEntry struct { + undo func() + createdAt time.Time +} + // placeholderEntry wraps a placeholder ID with a creation timestamp for TTL eviction. type placeholderEntry struct { id string @@ -68,17 +74,18 @@ type channelWorker struct { } type Manager struct { - channels map[string]Channel - workers map[string]*channelWorker - bus *bus.MessageBus - config *config.Config - mediaStore media.MediaStore - dispatchTask *asyncTask - mux *http.ServeMux - httpServer *http.Server - mu sync.RWMutex - placeholders sync.Map // "channel:chatID" → placeholderID (string) - typingStops sync.Map // "channel:chatID" → func() + channels map[string]Channel + workers map[string]*channelWorker + bus *bus.MessageBus + config *config.Config + mediaStore media.MediaStore + dispatchTask *asyncTask + mux *http.ServeMux + httpServer *http.Server + mu sync.RWMutex + placeholders sync.Map // "channel:chatID" → placeholderID (string) + typingStops sync.Map // "channel:chatID" → func() + reactionUndos sync.Map // "channel:chatID" → reactionEntry } type asyncTask struct { @@ -99,7 +106,14 @@ func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()}) } -// preSend handles typing stop and placeholder editing before sending a message. +// RecordReactionUndo registers a reaction undo function for later invocation. +// Implements PlaceholderRecorder. +func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) { + key := channel + ":" + chatID + m.reactionUndos.Store(key, reactionEntry{undo: undo, createdAt: time.Now()}) +} + +// preSend handles typing stop, reaction undo, and placeholder editing before sending a message. // Returns true if the message was edited into a placeholder (skip Send). func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMessage, ch Channel) bool { key := name + ":" + msg.ChatID @@ -111,7 +125,14 @@ func (m *Manager) preSend(ctx context.Context, name string, msg bus.OutboundMess } } - // 2. Try editing placeholder + // 2. Undo reaction + if v, loaded := m.reactionUndos.LoadAndDelete(key); loaded { + if entry, ok := v.(reactionEntry); ok { + entry.undo() // idempotent, safe + } + } + + // 3. Try editing placeholder if v, loaded := m.placeholders.LoadAndDelete(key); loaded { if entry, ok := v.(placeholderEntry); ok && entry.id != "" { if editor, ok := ch.(MessageEditor); ok { @@ -171,6 +192,10 @@ func (m *Manager) initChannel(name, displayName string) { if setter, ok := ch.(interface{ SetPlaceholderRecorder(r PlaceholderRecorder) }); ok { setter.SetPlaceholderRecorder(m) } + // Inject owner reference so BaseChannel.HandleMessage can auto-trigger typing/reaction + if setter, ok := ch.(interface{ SetOwner(ch Channel) }); ok { + setter.SetOwner(ch) + } m.channels[name] = ch logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ "channel": displayName, @@ -690,6 +715,16 @@ func (m *Manager) runTTLJanitor(ctx context.Context) { } return true }) + m.reactionUndos.Range(func(key, value any) bool { + if entry, ok := value.(reactionEntry); ok { + if now.Sub(entry.createdAt) > typingStopTTL { + if _, loaded := m.reactionUndos.LoadAndDelete(key); loaded { + entry.undo() // idempotent, safe + } + } + } + return true + }) m.placeholders.Range(func(key, value any) bool { if entry, ok := value.(placeholderEntry); ok { if now.Sub(entry.createdAt) > placeholderTTL { diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index f8042b248..7666c039f 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -23,21 +23,20 @@ import ( type OneBotChannel struct { *channels.BaseChannel - config config.OneBotConfig - conn *websocket.Conn - ctx context.Context - cancel context.CancelFunc - dedup map[string]struct{} - dedupRing []string - dedupIdx int - mu sync.Mutex - writeMu sync.Mutex - echoCounter int64 - selfID int64 - pending map[string]chan json.RawMessage - pendingMu sync.Mutex - lastMessageID sync.Map - pendingEmojiMsg sync.Map + config config.OneBotConfig + conn *websocket.Conn + ctx context.Context + cancel context.CancelFunc + dedup map[string]struct{} + dedupRing []string + dedupIdx int + mu sync.Mutex + writeMu sync.Mutex + echoCounter int64 + selfID int64 + pending map[string]chan json.RawMessage + pendingMu sync.Mutex + lastMessageID sync.Map } type oneBotRawEvent struct { @@ -129,6 +128,22 @@ func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) }() } +// ReactToMessage implements channels.ReactionCapable. +// It adds an emoji reaction (ID 289) to group messages and returns an undo function. +// Private messages return a no-op since reactions are only meaningful in groups. +func (c *OneBotChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { + // Only react in group chats + if !strings.HasPrefix(chatID, "group:") { + return func() {}, nil + } + + c.setMsgEmojiLike(messageID, 289, true) + + return func() { + c.setMsgEmojiLike(messageID, 289, false) + }, nil +} + func (c *OneBotChannel) Start(ctx context.Context) error { if c.config.WSUrl == "" { return fmt.Errorf("OneBot ws_url not configured") @@ -1044,18 +1059,6 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) { c.lastMessageID.Store(chatID, messageID) - if raw.MessageType == "group" && messageID != "" && messageID != "0" { - c.setMsgEmojiLike(messageID, 289, true) - c.pendingEmojiMsg.Store(chatID, messageID) - // Register emoji stop with Manager for outbound orchestration - if rec := c.GetPlaceholderRecorder(); rec != nil { - capturedMsgID := messageID - rec.RecordTypingStop("onebot", chatID, func() { - c.setMsgEmojiLike(capturedMsgID, 289, false) - }) - } - } - senderInfo := bus.SenderInfo{ Platform: "onebot", PlatformID: senderID, diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index c646a3b0b..2ae82d8da 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -171,6 +171,32 @@ func (c *PicoChannel) StartTyping(ctx context.Context, chatID string) (func(), e }, nil } +// SendPlaceholder implements channels.PlaceholderCapable. +// It sends a placeholder message via the Pico Protocol that will later be +// edited to the actual response via EditMessage (channels.MessageEditor). +func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + if !c.config.Placeholder.Enabled { + return "", nil + } + + text := c.config.Placeholder.Text + if text == "" { + text = "Thinking... 💭" + } + + msgID := uuid.New().String() + outMsg := newMessage(TypeMessageCreate, map[string]any{ + "content": text, + "message_id": msgID, + }) + + if err := c.broadcastToSession(chatID, outMsg); err != nil { + return "", err + } + + return msgID, nil +} + // broadcastToSession sends a message to all connections with a matching session. func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error { // chatID format: "pico:" @@ -413,14 +439,6 @@ func (c *PicoChannel) handleMessageSend(pc *picoConn, msg PicoMessage) { "preview": truncate(content, 50), }) - // Register typing with Manager - if rec := c.GetPlaceholderRecorder(); rec != nil { - stop, err := c.StartTyping(c.ctx, chatID) - if err == nil { - rec.RecordTypingStop("pico", chatID, stop) - } - } - sender := bus.SenderInfo{ Platform: "pico", PlatformID: senderID, diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 1733ccee1..488eb1296 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -200,6 +200,28 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa return nil } +// ReactToMessage implements channels.ReactionCapable. +// It adds an "eyes" (👀) reaction to the inbound message and returns an undo function +// that removes the reaction. +func (c *SlackChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { + channelID, _ := parseSlackChatID(chatID) + if channelID == "" { + return func() {}, nil + } + + c.api.AddReaction("eyes", slack.ItemRef{ + Channel: channelID, + Timestamp: messageID, + }) + + return func() { + c.api.RemoveReaction("eyes", slack.ItemRef{ + Channel: channelID, + Timestamp: messageID, + }) + }, nil +} + func (c *SlackChannel) eventLoop() { for { select { @@ -275,23 +297,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { chatID = channelID + "/" + threadTS } - c.api.AddReaction("eyes", slack.ItemRef{ - Channel: channelID, - Timestamp: messageTS, - }) - - // Register typing stop (remove "eyes" reaction) with Manager - if rec := c.GetPlaceholderRecorder(); rec != nil { - capturedChannelID := channelID - capturedMessageTS := messageTS - rec.RecordTypingStop("slack", chatID, func() { - c.api.RemoveReaction("eyes", slack.ItemRef{ - Channel: capturedChannelID, - Timestamp: capturedMessageTS, - }) - }) - } - c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, @@ -402,23 +407,6 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) { chatID = channelID + "/" + messageTS } - c.api.AddReaction("eyes", slack.ItemRef{ - Channel: channelID, - Timestamp: messageTS, - }) - - // Register typing stop (remove "eyes" reaction) with Manager - if rec := c.GetPlaceholderRecorder(); rec != nil { - capturedChannelID := channelID - capturedMessageTS := messageTS - rec.RecordTypingStop("slack", chatID, func() { - c.api.RemoveReaction("eyes", slack.ItemRef{ - Channel: capturedChannelID, - Timestamp: capturedMessageTS, - }) - }) - } - c.pendingAcks.Store(chatID, slackMessageRef{ ChannelID: channelID, Timestamp: messageTS, diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 3e4e4d398..4834c7d19 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -191,6 +191,36 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return nil } +// StartTyping implements channels.TypingCapable. +// It sends ChatAction(typing) immediately and then repeats every 4 seconds +// (Telegram's typing indicator expires after ~5s) in a background goroutine. +// The returned stop function is idempotent and cancels the goroutine. +func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + cid, err := parseChatID(chatID) + if err != nil { + return func() {}, err + } + + // Send the first typing action immediately + _ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + + typingCtx, cancel := context.WithCancel(ctx) + go func() { + ticker := time.NewTicker(4 * time.Second) + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + _ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) + } + } + }() + + return cancel, nil +} + // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { cid, err := parseChatID(chatID) @@ -208,6 +238,33 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag return err } +// SendPlaceholder implements channels.PlaceholderCapable. +// It sends a placeholder message (e.g. "Thinking... 💭") that will later be +// edited to the actual response via EditMessage (channels.MessageEditor). +func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + phCfg := c.config.Channels.Telegram.Placeholder + if !phCfg.Enabled { + return "", nil + } + + text := phCfg.Text + if text == "" { + text = "Thinking... 💭" + } + + cid, err := parseChatID(chatID) + if err != nil { + return "", err + } + + pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text)) + if err != nil { + return "", err + } + + return fmt.Sprintf("%d", pMsg.MessageID), nil +} + // SendMedia implements the channels.MediaSender interface. func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { @@ -419,30 +476,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "preview": utils.Truncate(content, 50), }) - // Thinking indicator - err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) - if err != nil { - logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{ - "error": err.Error(), - }) - } - - // Create cancel function for thinking state and register with Manager - _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) - if rec := c.GetPlaceholderRecorder(); rec != nil { - rec.RecordTypingStop("telegram", chatIDStr, thinkCancel) - } else { - // No recorder — cancel immediately to avoid context leak - thinkCancel() - } - - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) - if err == nil { - pID := pMsg.MessageID - if rec := c.GetPlaceholderRecorder(); rec != nil { - rec.RecordPlaceholder("telegram", chatIDStr, fmt.Sprintf("%d", pID)) - } - } + // Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable peerKind := "direct" peerID := fmt.Sprintf("%d", user.ID) diff --git a/pkg/config/config.go b/pkg/config/config.go index 64cdf6eac..bdd4d8823 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -288,7 +288,6 @@ type SlackConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type LINEConfig struct { @@ -301,7 +300,6 @@ type LINEConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type OneBotConfig struct { @@ -313,7 +311,6 @@ type OneBotConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type WeComConfig struct { @@ -354,6 +351,7 @@ type PicoConfig struct { WriteTimeout int `json:"write_timeout,omitempty"` MaxConnections int `json:"max_connections,omitempty"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_ALLOW_FROM"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` } type HeartbeatConfig struct { From e5788e7f955976761a5ba92d5021cdffc813476c Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 27 Feb 2026 03:12:02 +0800 Subject: [PATCH 054/104] docs(channels): add Chinese README for channel system architecture --- pkg/channels/README.zh.md | 1331 +++++++++++++++++++++++++++++++++++++ 1 file changed, 1331 insertions(+) create mode 100644 pkg/channels/README.zh.md diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md new file mode 100644 index 000000000..0a9487cd0 --- /dev/null +++ b/pkg/channels/README.zh.md @@ -0,0 +1,1331 @@ +# PicoClaw Channel System 重构:完整开发指南 + +> **分支**: `refactor/channel-system` +> **状态**: 活跃开发中(约 40 commits) +> **影响范围**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/` + +--- + +## 目录 + +- [第一部分:架构总览](#第一部分架构总览) +- [第二部分:迁移指南——从 main 分支迁移到重构分支](#第二部分迁移指南从-main-分支迁移到重构分支) +- [第三部分:新 Channel 开发指南——从零实现一个新 Channel](#第三部分新-channel-开发指南从零实现一个新-channel) +- [第四部分:核心子系统详解](#第四部分核心子系统详解) +- [第五部分:关键设计决策与约定](#第五部分关键设计决策与约定) +- [附录:完整文件清单与接口速查表](#附录完整文件清单与接口速查表) + +--- + +## 第一部分:架构总览 + +### 1.1 重构前后对比 + +**重构前(main 分支)**: + +``` +pkg/channels/ +├── telegram.go # 每个 channel 直接放在 channels 包内 +├── discord.go +├── slack.go +├── manager.go # Manager 直接引用各 channel 类型 +├── ... +``` + +- Channel 实现全部在 `pkg/channels/` 包的顶层 +- Manager 通过 `switch` 或 `if-else` 链条直接构造各 channel +- Peer、MessageID 等路由信息埋在 `Metadata map[string]string` 中 +- 消息发送没有速率限制和重试 +- 没有统一的媒体文件生命周期管理 +- 各 channel 各自启动 HTTP 服务器 +- 群聊触发过滤逻辑分散在各 channel 中 + +**重构后(refactor/channel-system 分支)**: + +``` +pkg/channels/ +├── base.go # BaseChannel 共享抽象层 +├── interfaces.go # 可选能力接口(TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder) +├── media.go # MediaSender 可选接口 +├── webhook.go # WebhookHandler, HealthChecker 可选接口 +├── errors.go # 错误哨兵值(ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed) +├── errutil.go # 错误分类帮助函数 +├── registry.go # 工厂注册表(RegisterFactory / getFactory) +├── manager.go # 统一编排:Worker 队列、速率限制、重试、Typing/Placeholder、共享 HTTP +├── split.go # 长消息智能分割(保留代码块完整性) +├── telegram/ # 每个 channel 独立子包 +│ ├── init.go # 工厂注册 +│ ├── telegram.go # 实现 +│ └── telegram_commands.go +├── discord/ +│ ├── init.go +│ └── discord.go +├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ maixcam/ pico/ +│ └── ... + +pkg/bus/ +├── bus.go # MessageBus(缓冲区 64,安全关闭+排水) +├── types.go # 结构化消息类型(Peer, SenderInfo, MediaPart, InboundMessage, OutboundMessage, OutboundMediaMessage) + +pkg/media/ +├── store.go # MediaStore 接口 + FileMediaStore 实现(两阶段释放,TTL 清理) + +pkg/identity/ +├── identity.go # 统一用户身份:规范 "platform:id" 格式 + 向后兼容匹配 +``` + +### 1.2 消息流转全景图 + +``` +┌────────────┐ InboundMessage ┌───────────┐ LLM + Tools ┌────────────┐ +│ Telegram │──┐ │ │ │ │ +│ Discord │──┤ PublishInbound() │ │ PublishOutbound() │ │ +│ Slack │──┼──────────────────────▶ │ MessageBus │ ◀─────────────────── │ AgentLoop │ +│ LINE │──┤ (buffered chan, 64) │ │ (buffered chan, 64) │ │ +│ ... │──┘ │ │ │ │ +└────────────┘ └─────┬─────┘ └────────────┘ + │ + SubscribeOutbound() │ SubscribeOutboundMedia() + ▼ + ┌───────────────────┐ + │ Manager │ + │ ├── dispatchOutbound() 路由到 Worker 队列 + │ ├── dispatchOutboundMedia() + │ ├── runWorker() 消息分割 + sendWithRetry() + │ ├── runMediaWorker() sendMediaWithRetry() + │ ├── preSend() 停止 Typing + 撤销 Reaction + 编辑 Placeholder + │ └── runTTLJanitor() 清理过期 Typing/Placeholder + └────────┬──────────┘ + │ + channel.Send() / SendMedia() + │ + ▼ + ┌────────────────┐ + │ 各平台 API/SDK │ + └────────────────┘ +``` + +### 1.3 关键设计原则 + +| 原则 | 说明 | +|------|------| +| **子包隔离** | 每个 channel 一个独立 Go 子包,依赖 `channels` 父包提供的 `BaseChannel` 和接口 | +| **工厂注册** | 各子包通过 `init()` 自注册,Manager 通过名字查找工厂,消除 import 耦合 | +| **能力发现** | 可选能力通过接口(`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`)声明,Manager 运行时类型断言发现 | +| **结构化消息** | Peer、MessageID、SenderInfo 从 Metadata 提升为 InboundMessage 的一等字段 | +| **错误分类** | Channel 返回哨兵错误(`ErrRateLimit`, `ErrTemporary` 等),Manager 据此决定重试策略 | +| **集中编排** | 速率限制、消息分割、重试、Typing/Reaction/Placeholder 全部由 Manager 和 BaseChannel 统一处理,Channel 只负责 Send | + +--- + +## 第二部分:迁移指南——从 main 分支迁移到重构分支 + +### 2.1 如果你有未合并的 Channel 修改 + +#### 步骤 1:确认你修改了哪些文件 + +在 main 分支上,Channel 文件直接位于 `pkg/channels/` 顶层,例如: +- `pkg/channels/telegram.go` +- `pkg/channels/discord.go` + +重构后,这些文件已被删除,代码移动到了对应子包: +- `pkg/channels/telegram/telegram.go` +- `pkg/channels/discord/discord.go` + +#### 步骤 2:理解结构变化映射 + +| main 分支文件 | 重构分支位置 | 变化 | +|---|---|---| +| `pkg/channels/telegram.go` | `pkg/channels/telegram/telegram.go` + `init.go` | 包名从 `channels` 变为 `telegram` | +| `pkg/channels/discord.go` | `pkg/channels/discord/discord.go` + `init.go` | 同上 | +| `pkg/channels/manager.go` | `pkg/channels/manager.go` | 大幅重写 | +| _(不存在)_ | `pkg/channels/base.go` | 新增共享抽象层 | +| _(不存在)_ | `pkg/channels/registry.go` | 新增工厂注册表 | +| _(不存在)_ | `pkg/channels/errors.go` + `errutil.go` | 新增错误分类体系 | +| _(不存在)_ | `pkg/channels/interfaces.go` | 新增可选能力接口 | +| _(不存在)_ | `pkg/channels/media.go` | 新增 MediaSender 接口 | +| _(不存在)_ | `pkg/channels/webhook.go` | 新增 WebhookHandler/HealthChecker | +| _(不存在)_ | `pkg/channels/split.go` | 新增消息分割(从 utils 迁入) | +| _(不存在)_ | `pkg/bus/types.go` | 新增结构化消息类型 | +| _(不存在)_ | `pkg/media/store.go` | 新增媒体文件生命周期管理 | +| _(不存在)_ | `pkg/identity/identity.go` | 新增统一用户身份 | + +#### 步骤 3:迁移你的 Channel 代码 + +以 Telegram 为例,主要改动项: + +**3a. 包声明和导入** + +```go +// 旧代码(main 分支) +package channels + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// 新代码(重构分支) +package telegram + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" // 引用父包 + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" // 新增 + "github.com/sipeed/picoclaw/pkg/media" // 新增(如需媒体) +) +``` + +**3b. 结构体嵌入 BaseChannel** + +```go +// 旧代码:直接持有 bus、config 等字段 +type TelegramChannel struct { + bus *bus.MessageBus + config *config.Config + running bool + allowList []string + // ... +} + +// 新代码:嵌入 BaseChannel,它提供 bus、running、allowList 等 +type TelegramChannel struct { + *channels.BaseChannel // 嵌入共享抽象 + bot *telego.Bot + config *config.Config + // ... 只保留 channel 特有字段 +} +``` + +**3c. 构造函数** + +```go +// 旧代码:直接赋值 +func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { + return &TelegramChannel{ + bus: bus, + config: cfg, + allowList: cfg.Channels.Telegram.AllowFrom, + // ... + }, nil +} + +// 新代码:使用 NewBaseChannel + 功能选项 +func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { + base := channels.NewBaseChannel( + "telegram", // 名称 + cfg.Channels.Telegram, // 原始配置(any 类型) + bus, // 消息总线 + cfg.Channels.Telegram.AllowFrom, // 允许列表 + channels.WithMaxMessageLength(4096), // 平台消息长度上限 + channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // 群聊触发配置 + ) + return &TelegramChannel{ + BaseChannel: base, + bot: bot, + config: cfg, + }, nil +} +``` + +**3d. Start/Stop 生命周期** + +```go +// 新代码:使用 SetRunning 原子操作 +func (c *TelegramChannel) Start(ctx context.Context) error { + // ... 初始化 bot、webhook 等 + c.SetRunning(true) // 必须在就绪后调用 + go bh.Start() + return nil +} + +func (c *TelegramChannel) Stop(ctx context.Context) error { + c.SetRunning(false) // 必须在清理前调用 + // ... 停止 bot handler、取消 context + return nil +} +``` + +**3e. Send 方法的错误返回** + +```go +// 旧代码:返回普通 error +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.running { return fmt.Errorf("not running") } + // ... + if err != nil { return err } +} + +// 新代码:必须返回哨兵错误,供 Manager 判断重试策略 +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning // ← Manager 不会重试 + } + // ... + if err != nil { + // 使用 ClassifySendError 根据 HTTP 状态码包装错误 + return channels.ClassifySendError(statusCode, err) + // 或手动包装: + // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + } + return nil +} +``` + +**3f. 消息接收(Inbound)** + +```go +// 旧代码:直接构造 InboundMessage 并发布 +msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: senderID, + ChatID: chatID, + Content: content, + Metadata: map[string]string{ + "peer_kind": "group", // 路由信息埋在 metadata + "peer_id": chatID, + "message_id": msgID, + }, +} +c.bus.PublishInbound(ctx, msg) + +// 新代码:使用 BaseChannel.HandleMessage,传入结构化字段 +sender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: strconv.FormatInt(from.ID, 10), + CanonicalID: identity.BuildCanonicalID("telegram", strconv.FormatInt(from.ID, 10)), + Username: from.Username, + DisplayName: from.FirstName, +} + +peer := bus.Peer{ + Kind: "group", // 或 "direct" + ID: chatID, +} + +// HandleMessage 内部调用 IsAllowedSender 检查权限,构建 MediaScope,发布到 bus +c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, sender) +``` + +**3g. 添加工厂注册(必需)** + +为你的 channel 创建 `init.go`: + +```go +// pkg/channels/telegram/init.go +package telegram + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewTelegramChannel(cfg, b) + }) +} +``` + +**3h. 在 Gateway 中导入子包** + +```go +// cmd/picoclaw/internal/gateway/helpers.go +import ( + _ "github.com/sipeed/picoclaw/pkg/channels/telegram" // 触发 init() 注册 + _ "github.com/sipeed/picoclaw/pkg/channels/discord" + _ "github.com/sipeed/picoclaw/pkg/channels/your_new_channel" // 新增 +) +``` + +#### 步骤 4:迁移 Bus 消息使用方式 + +如果你的代码直接读取 `InboundMessage.Metadata` 中的路由字段: + +```go +// 旧代码 +peerKind := msg.Metadata["peer_kind"] +peerID := msg.Metadata["peer_id"] +msgID := msg.Metadata["message_id"] + +// 新代码 +peerKind := msg.Peer.Kind // 一等字段 +peerID := msg.Peer.ID // 一等字段 +msgID := msg.MessageID // 一等字段 +sender := msg.Sender // bus.SenderInfo 结构体 +scope := msg.MediaScope // 媒体生命周期作用域 +``` + +#### 步骤 5:迁移允许列表检查 + +```go +// 旧代码 +if !c.isAllowed(senderID) { return } + +// 新代码:优先使用结构化检查 +if !c.IsAllowedSender(sender) { return } +// 或回退到字符串检查: +if !c.IsAllowed(senderID) { return } +``` + +`BaseChannel.HandleMessage` 方法内部已经处理了这个逻辑,无需在 channel 中重复检查。 + +### 2.2 如果你有 Manager 的修改 + +Manager 已被完全重写。你的修改需要理解新架构: + +| 旧 Manager 职责 | 新 Manager 职责 | +|---|---| +| 直接构造 channel(switch/if-else) | 通过工厂注册表查找并构造 | +| 直接调用 channel.Send | 通过 per-channel Worker 队列 + 速率限制 + 重试 | +| 无消息分割 | 自动根据 MaxMessageLength 分割长消息 | +| 各 channel 自建 HTTP 服务器 | 统一共享 HTTP 服务器 | +| 无 Typing/Placeholder 管理 | 统一 preSend 处理 Typing 停止 + Reaction 撤销 + Placeholder 编辑;入站侧 BaseChannel.HandleMessage 自动编排 Typing/Reaction/Placeholder | +| 无 TTL 清理 | runTTLJanitor 定期清理过期 Typing/Reaction/Placeholder 条目 | + +### 2.3 如果你有 Agent Loop 的修改 + +Agent Loop 的主要变化: + +1. **MediaStore 注入**:`agentLoop.SetMediaStore(mediaStore)` — Agent 通过 MediaStore 解析工具产生的媒体引用 +2. **ChannelManager 注入**:`agentLoop.SetChannelManager(channelManager)` — Agent 可查询 channel 状态 +3. **OutboundMediaMessage**:Agent 现在通过 `bus.PublishOutboundMedia()` 发送媒体消息,而非嵌入文本回复 +4. **extractPeer**:路由使用 `msg.Peer` 结构化字段而非 Metadata 查找 + +--- + +## 第三部分:新 Channel 开发指南——从零实现一个新 Channel + +### 3.1 最小实现清单 + +要添加一个新的聊天平台(例如 `matrix`),你需要: + +1. ✅ 创建子包目录 `pkg/channels/matrix/` +2. ✅ 创建 `init.go` — 工厂注册 +3. ✅ 创建 `matrix.go` — Channel 实现 +4. ✅ 在 Gateway helpers 中添加 blank import +5. ✅ 在 Manager.initChannels() 中添加配置检查 +6. ✅ 在 `pkg/config/` 中添加配置结构体 + +### 3.2 完整模板 + +#### `pkg/channels/matrix/init.go` + +```go +package matrix + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewMatrixChannel(cfg, b) + }) +} +``` + +#### `pkg/channels/matrix/matrix.go` + +```go +package matrix + +import ( + "context" + "fmt" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// MatrixChannel implements channels.Channel for the Matrix protocol. +type MatrixChannel struct { + *channels.BaseChannel // 必须嵌入 + config *config.Config + ctx context.Context + cancel context.CancelFunc + // ... Matrix SDK 客户端等 +} + +func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChannel, error) { + matrixCfg := cfg.Channels.Matrix // 假设配置中有此字段 + + base := channels.NewBaseChannel( + "matrix", // channel 名称(全局唯一) + matrixCfg, // 原始配置 + msgBus, // 消息总线 + matrixCfg.AllowFrom, // 允许列表 + channels.WithMaxMessageLength(65536), // Matrix 消息长度限制 + channels.WithGroupTrigger(matrixCfg.GroupTrigger), + ) + + return &MatrixChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +// ========== 必须实现的 Channel 接口方法 ========== + +func (c *MatrixChannel) Start(ctx context.Context) error { + c.ctx, c.cancel = context.WithCancel(ctx) + + // 1. 初始化 Matrix 客户端 + // 2. 开始监听消息 + // 3. 标记为运行中 + c.SetRunning(true) + + logger.InfoC("matrix", "Matrix channel started") + return nil +} + +func (c *MatrixChannel) Stop(ctx context.Context) error { + c.SetRunning(false) + + if c.cancel != nil { + c.cancel() + } + + logger.InfoC("matrix", "Matrix channel stopped") + return nil +} + +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + // 1. 检查运行状态 + if !c.IsRunning() { + return channels.ErrNotRunning + } + + // 2. 发送消息到 Matrix + err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) + if err != nil { + // 3. 必须使用错误分类包装 + // 如果你有 HTTP 状态码: + // return channels.ClassifySendError(statusCode, err) + // 如果是网络错误: + // return channels.ClassifyNetError(err) + // 如果需要手动分类: + return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + } + + return nil +} + +// ========== 消息接收处理 ========== + +func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content string, msgID string) { + // 1. 构造结构化发送者身份 + sender := bus.SenderInfo{ + Platform: "matrix", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("matrix", senderID), + Username: senderID, + DisplayName: displayName, + } + + // 2. 确定 Peer 类型(直聊 vs 群聊) + peer := bus.Peer{ + Kind: "group", // 或 "direct" + ID: roomID, + } + + // 3. 群聊过滤(如适用) + isGroup := peer.Kind == "group" + if isGroup { + isMentioned := false // 根据平台特性检测 @提及 + shouldRespond, cleanContent := c.ShouldRespondInGroup(isMentioned, content) + if !shouldRespond { + return + } + content = cleanContent + } + + // 4. 处理媒体附件(如有) + var mediaRefs []string + store := c.GetMediaStore() + if store != nil { + // 下载附件到本地 → store.Store() → 获取 ref + // mediaRefs = append(mediaRefs, ref) + } + + // 5. 调用 HandleMessage 发布到 bus + // HandleMessage 内部会: + // - 检查 IsAllowedSender/IsAllowed + // - 构建 MediaScope + // - 发布 InboundMessage + c.HandleMessage( + c.ctx, + peer, + msgID, // 平台消息 ID + senderID, // 原始发送者 ID + roomID, // 聊天/房间 ID + content, // 消息内容 + mediaRefs, // 媒体引用列表 + nil, // 额外 metadata(通常 nil) + sender, // SenderInfo(variadic 参数) + ) +} + +// ========== 内部方法 ========== + +func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { + // 实际的 Matrix SDK 调用 + return nil +} +``` + +### 3.3 可选能力接口 + +根据平台能力,你的 Channel 可以选择性实现以下接口: + +#### MediaSender — 发送媒体附件 + +```go +// 如果平台支持发送图片/文件/音频/视频 +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("matrix", "Failed to resolve media", map[string]any{ + "ref": part.Ref, "error": err.Error(), + }) + continue + } + + // 根据 part.Type ("image"|"audio"|"video"|"file") 调用对应 API + switch part.Type { + case "image": + // 上传图片到 Matrix + default: + // 上传文件到 Matrix + } + } + return nil +} +``` + +#### TypingCapable — Typing 指示器 + +```go +// 如果平台支持 "正在输入..." 提示 +func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (stop func(), err error) { + // 调用 Matrix API 发送 typing 指示器 + // 返回的 stop 函数必须是幂等的 + stopped := false + return func() { + if !stopped { + stopped = true + // 调用 Matrix API 停止 typing + } + }, nil +} +``` + +#### ReactionCapable — 消息反应指示器 + +```go +// 如果平台支持对入站消息添加 emoji 反应(如 Slack 的 👀、OneBot 的表情 289) +func (c *MatrixChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) { + // 调用 Matrix API 添加反应到消息 + // 返回的 undo 函数移除反应,必须是幂等的 + err = c.addReaction(chatID, messageID, "eyes") + if err != nil { + return func() {}, err + } + return func() { + c.removeReaction(chatID, messageID, "eyes") + }, nil +} +``` + +#### MessageEditor — 消息编辑 + +```go +// 如果平台支持编辑已发送的消息(用于 Placeholder 替换) +func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { + // 调用 Matrix API 编辑消息 + return nil +} +``` + +#### WebhookHandler — HTTP Webhook 接收 + +```go +// 如果 channel 通过 webhook 接收消息(而非长轮询/WebSocket) +func (c *MatrixChannel) WebhookPath() string { + return "/webhook/matrix" // 路径会被注册到共享 HTTP 服务器 +} + +func (c *MatrixChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // 处理 webhook 请求 +} +``` + +#### HealthChecker — 健康检查端点 + +```go +func (c *MatrixChannel) HealthPath() string { + return "/health/matrix" +} + +func (c *MatrixChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { + if c.IsRunning() { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } else { + w.WriteHeader(http.StatusServiceUnavailable) + } +} +``` + +### 3.4 入站侧 Typing/Reaction/Placeholder 自动编排 + +`BaseChannel.HandleMessage` 在发布入站消息**之前**,自动检测 channel 是否实现了 `TypingCapable`、`ReactionCapable` 和/或 `PlaceholderCapable`,并触发相应的指示器。三条管道完全独立,互不干扰: + +```go +// BaseChannel.HandleMessage 内部自动执行(无需 channel 手动调用): +if c.owner != nil && c.placeholderRecorder != nil { + // Typing — 独立管道 + if tc, ok := c.owner.(TypingCapable); ok { + if stop, err := tc.StartTyping(ctx, chatID); err == nil { + c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) + } + } + // Reaction — 独立管道 + if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { + if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { + c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) + } + } + // Placeholder — 独立管道 + if pc, ok := c.owner.(PlaceholderCapable); ok { + if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { + c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) + } + } +} +``` + +**这意味着**: +- 实现 `TypingCapable` 的 channel(Telegram、Discord、LINE、Pico)无需在 `handleMessage` 中手动调用 `StartTyping` + `RecordTypingStop` +- 实现 `ReactionCapable` 的 channel(Slack、OneBot)无需在 `handleMessage` 中手动调用 `AddReaction` + `RecordTypingStop` +- 实现 `PlaceholderCapable` 的 channel(Telegram、Discord、Pico)无需在 `handleMessage` 中手动发送占位消息并调用 `RecordPlaceholder` +- Channel 只需实现对应接口,`HandleMessage` 会自动完成编排 +- 不实现这些接口的 channel 不受影响(类型断言会失败,跳过) +- `PlaceholderCapable` 的 `SendPlaceholder` 方法内部根据配置的 `PlaceholderConfig.Enabled` 决定是否发送;返回 `("", nil)` 时跳过注册 + +**Owner 注入**:Manager 在 `initChannel` 中自动调用 `SetOwner(ch)` 将具体 channel 注入 BaseChannel,无需开发者手动设置。 + +当 Agent 处理完消息后,Manager 的 `preSend` 会自动: +1. 调用已记录的 `stop()` 停止 Typing +2. 调用已记录的 `undo()` 撤销 Reaction +3. 如果有 Placeholder,且 channel 实现了 `MessageEditor`,尝试编辑 Placeholder 为最终回复(跳过 Send) + +### 3.5 注册配置和 Gateway 接入 + +#### 在 `pkg/config/config.go` 中添加配置 + +```go +type ChannelsConfig struct { + // ... 现有 channels + Matrix MatrixChannelConfig `yaml:"matrix" json:"matrix"` +} + +type MatrixChannelConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + HomeServer string `yaml:"home_server" json:"home_server"` + Token string `yaml:"token" json:"token"` + AllowFrom []string `yaml:"allow_from" json:"allow_from"` + GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"` +} +``` + +#### 在 Manager.initChannels() 中添加入口 + +```go +// pkg/channels/manager.go 的 initChannels() 方法中 +if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { + m.initChannel("matrix", "Matrix") +} +``` + +#### 在 Gateway 中添加 blank import + +```go +// cmd/picoclaw/internal/gateway/helpers.go +import ( + _ "github.com/sipeed/picoclaw/pkg/channels/matrix" +) +``` + +--- + +## 第四部分:核心子系统详解 + +### 4.1 MessageBus + +**文件**:`pkg/bus/bus.go`、`pkg/bus/types.go` + +```go +type MessageBus struct { + inbound chan InboundMessage // 缓冲区 = 64 + outbound chan OutboundMessage // 缓冲区 = 64 + outboundMedia chan OutboundMediaMessage // 缓冲区 = 64 + done chan struct{} // 关闭信号 + closed atomic.Bool // 防止重复关闭 +} +``` + +**关键行为**: + +| 方法 | 行为 | +|------|------| +| `PublishInbound(ctx, msg)` | 检查 closed → 发送到 inbound channel → 阻塞/超时/关闭 | +| `ConsumeInbound(ctx)` | 从 inbound 读取 → 阻塞/关闭/取消 | +| `PublishOutbound(ctx, msg)` | 发送到 outbound channel | +| `SubscribeOutbound(ctx)` | 从 outbound 读取(Manager dispatcher 调用) | +| `PublishOutboundMedia(ctx, msg)` | 发送到 outboundMedia channel | +| `SubscribeOutboundMedia(ctx)` | 从 outboundMedia 读取(Manager media dispatcher 调用) | +| `Close()` | CAS 关闭 → close(done) → 排水所有 channel(**不关闭 channel 本身**,避免并发 send-on-closed panic) | + +**设计要点**: +- 缓冲区从 16 增至 64,减少突发负载下的阻塞 +- `Close()` 不关闭底层 channel(只关闭 `done` 信号通道),因为可能有正在并发 `Publish` 的 goroutine +- 排水循环确保 buffered 消息不被静默丢弃 + +### 4.2 结构化消息类型 + +**文件**:`pkg/bus/types.go` + +```go +// 路由对等体 +type Peer struct { + Kind string `json:"kind"` // "direct" | "group" | "channel" | "" + ID string `json:"id"` +} + +// 发送者身份信息 +type SenderInfo struct { + Platform string `json:"platform,omitempty"` // "telegram", "discord", ... + PlatformID string `json:"platform_id,omitempty"` // 平台原始 ID + CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" 规范格式 + Username string `json:"username,omitempty"` + DisplayName string `json:"display_name,omitempty"` +} + +// 入站消息 +type InboundMessage struct { + Channel string // 来源 channel 名称 + SenderID string // 发送者 ID(优先使用 CanonicalID) + Sender SenderInfo // 结构化发送者信息 + ChatID string // 聊天/房间 ID + Content string // 消息文本 + Media []string // 媒体引用列表(media://...) + Peer Peer // 路由对等体(一等字段) + MessageID string // 平台消息 ID(一等字段) + MediaScope string // 媒体生命周期作用域 + SessionKey string // 会话键 + Metadata map[string]string // 仅用于 channel 特有扩展 +} + +// 出站文本消息 +type OutboundMessage struct { + Channel string + ChatID string + Content string +} + +// 出站媒体消息 +type OutboundMediaMessage struct { + Channel string + ChatID string + Parts []MediaPart +} + +// 媒体片段 +type MediaPart struct { + Type string // "image" | "audio" | "video" | "file" + Ref string // "media://uuid" + Caption string + Filename string + ContentType string +} +``` + +### 4.3 BaseChannel + +**文件**:`pkg/channels/base.go` + +BaseChannel 是所有 channel 的共享抽象层,提供以下能力: + +| 方法/特性 | 说明 | +|---|---| +| `Name() string` | Channel 名称 | +| `IsRunning() bool` | 原子读取运行状态 | +| `SetRunning(bool)` | 原子设置运行状态 | +| `MaxMessageLength() int` | 消息长度限制(rune 计数),0 = 无限制 | +| `IsAllowed(senderID string) bool` | 旧格式允许列表检查(支持 `"id\|username"` 和 `"@username"` 格式) | +| `IsAllowedSender(sender SenderInfo) bool` | 新格式允许列表检查(委托给 `identity.MatchAllowed`) | +| `ShouldRespondInGroup(isMentioned, content) (bool, string)` | 统一群聊触发过滤逻辑 | +| `HandleMessage(...)` | 统一入站消息处理:权限检查 → 构建 MediaScope → 自动触发 Typing/Reaction → 发布到 Bus | +| `SetMediaStore(s) / GetMediaStore()` | Manager 注入的媒体存储 | +| `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | Manager 注入的占位符记录器 | +| `SetOwner(ch) ` | Manager 注入的具体 channel 引用(用于 HandleMessage 内部的 Typing/Reaction 类型断言) | + +**功能选项**: + +```go +channels.WithMaxMessageLength(4096) // 设置平台消息长度限制 +channels.WithGroupTrigger(groupTriggerCfg) // 设置群聊触发配置 +``` + +### 4.4 工厂注册表 + +**文件**:`pkg/channels/registry.go` + +```go +type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) + +func RegisterFactory(name string, f ChannelFactory) // 子包 init() 中调用 +func getFactory(name string) (ChannelFactory, bool) // Manager 内部调用 +``` + +工厂注册表使用 `sync.RWMutex` 保护,在 `init()` 阶段注册(进程启动时完成)。Manager 在 `initChannel()` 中通过名字查找工厂并调用它。 + +### 4.5 错误分类与重试 + +**文件**:`pkg/channels/errors.go`、`pkg/channels/errutil.go` + +#### 哨兵错误 + +```go +var ( + ErrNotRunning = errors.New("channel not running") // 永久:不重试 + ErrRateLimit = errors.New("rate limited") // 固定延迟:1s 后重试 + ErrTemporary = errors.New("temporary failure") // 指数退避:500ms * 2^attempt,最大 8s + ErrSendFailed = errors.New("send failed") // 永久:不重试 +) +``` + +#### 错误分类帮助函数 + +```go +// 根据 HTTP 状态码自动分类 +func ClassifySendError(statusCode int, rawErr error) error { + // 429 → ErrRateLimit + // 5xx → ErrTemporary + // 4xx → ErrSendFailed +} + +// 网络错误统一包装为临时错误 +func ClassifyNetError(err error) error { + // → ErrTemporary +} +``` + +#### Manager 重试策略(`sendWithRetry`) + +``` +最大重试次数: 3 +速率限制延迟: 1 秒 +基础退避: 500 毫秒 +最大退避: 8 秒 + +重试逻辑: + ErrNotRunning → 立即失败,不重试 + ErrSendFailed → 立即失败,不重试 + ErrRateLimit → 等待 1s → 重试 + ErrTemporary → 等待 500ms * 2^attempt(最大 8s) → 重试 + 其他未知错误 → 等待 500ms * 2^attempt(最大 8s) → 重试 +``` + +### 4.6 Manager 编排 + +**文件**:`pkg/channels/manager.go` + +#### Per-channel Worker 架构 + +```go +type channelWorker struct { + ch Channel // channel 实例 + queue chan bus.OutboundMessage // 出站文本队列(缓冲 16) + mediaQueue chan bus.OutboundMediaMessage // 出站媒体队列(缓冲 16) + done chan struct{} // 文本 worker 完成信号 + mediaDone chan struct{} // 媒体 worker 完成信号 + limiter *rate.Limiter // per-channel 速率限制器 +} +``` + +#### Per-channel 速率限制配置 + +```go +var channelRateConfig = map[string]float64{ + "telegram": 20, // 20 msg/s + "discord": 1, // 1 msg/s + "slack": 1, // 1 msg/s + "line": 10, // 10 msg/s +} +// 默认: 10 msg/s +// burst = max(1, ceil(rate/2)) +``` + +#### 生命周期管理 + +``` +StartAll: + 1. 遍历已注册 channels → channel.Start(ctx) + 2. 为每个启动成功的 channel 创建 channelWorker + 3. 启动 goroutines: + - runWorker (per-channel 出站文本) + - runMediaWorker (per-channel 出站媒体) + - dispatchOutbound (从 bus 路由到 worker 队列) + - dispatchOutboundMedia (从 bus 路由到 media worker 队列) + - runTTLJanitor (每 10s 清理过期 typing/placeholder) + 4. 启动共享 HTTP 服务器(如已配置) + +StopAll: + 1. 关闭共享 HTTP 服务器(5s 超时) + 2. 取消 dispatcher context + 3. 关闭 text worker 队列 → 等待排水完成 + 4. 关闭 media worker 队列 → 等待排水完成 + 5. 停止每个 channel(channel.Stop) +``` + +#### Typing/Reaction/Placeholder 管理 + +```go +// Manager 实现 PlaceholderRecorder 接口 +func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) +func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) +func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) + +// 入站侧:BaseChannel.HandleMessage 自动编排 +// BaseChannel.HandleMessage 在 PublishInbound 之前,通过 owner 类型断言自动触发: +// - TypingCapable.StartTyping → RecordTypingStop +// - ReactionCapable.ReactToMessage → RecordReactionUndo +// - PlaceholderCapable.SendPlaceholder → RecordPlaceholder +// 三者独立,互不干扰。Channel 无需手动调用。 + +// 出站侧:发送前处理 +func (m *Manager) preSend(ctx, name, msg, ch) bool { + key := name + ":" + msg.ChatID + // 1. 停止 Typing(调用存储的 stop 函数) + // 2. 撤销 Reaction(调用存储的 undo 函数) + // 3. 尝试编辑 Placeholder(如果 channel 实现了 MessageEditor) + // 成功 → return true(跳过 Send) + // 失败 → return false(继续 Send) +} +``` + +Manager 存储完全分离,三条管道互不干扰: + +```go +Manager { + typingStops sync.Map // "channel:chatID" → typingEntry ← 管 TypingCapable + reactionUndos sync.Map // "channel:chatID" → reactionEntry ← 管 ReactionCapable + placeholders sync.Map // "channel:chatID" → placeholderEntry +} +``` + +TTL 清理: +- Typing 停止函数:5 分钟 TTL(到期后自动调用 stop 并删除) +- Reaction 撤销函数:5 分钟 TTL(到期后自动调用 undo 并删除) +- Placeholder ID:10 分钟 TTL(到期后删除) +- 清理间隔:10 秒 + +### 4.7 消息分割 + +**文件**:`pkg/channels/split.go` + +`SplitMessage(content string, maxLen int) []string` + +智能分割策略: +1. 计算有效分割点 = maxLen - 10% 缓冲区(为代码块闭合留空间) +2. 优先在换行符处分割 +3. 其次在空格/制表符处分割 +4. 检测未闭合的代码块(` ``` `) +5. 如果代码块未闭合: + - 尝试扩展到 maxLen 以包含闭合围栏 + - 如果代码块太长,注入闭合/重开围栏(`\n```\n` + header) + - 最后手段:在代码块开始前分割 + +### 4.8 MediaStore + +**文件**:`pkg/media/store.go` + +```go +type MediaStore interface { + Store(localPath string, meta MediaMeta, scope string) (ref string, err error) + Resolve(ref string) (localPath string, err error) + ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error) + ReleaseAll(scope string) error +} +``` + +**FileMediaStore 实现**: +- 纯内存映射,不复制/移动文件 +- 引用格式:`media://` +- Scope 格式:`channel:chatID:messageID`(由 `BuildMediaScope` 生成) +- **两阶段操作**: + - Phase 1(持锁):从 map 中收集并删除条目 + - Phase 2(无锁):从磁盘删除文件 + - 目的:最小化锁争用 +- **TTL 清理**:`NewFileMediaStoreWithCleanup` → `Start()` 启动后台清理协程 +- 清理间隔和最大存活时间由配置控制 + +### 4.9 Identity + +**文件**:`pkg/identity/identity.go` + +```go +// 构建规范 ID +func BuildCanonicalID(platform, platformID string) string +// → "telegram:123456" + +// 解析规范 ID +func ParseCanonicalID(canonical string) (platform, id string, ok bool) + +// 匹配允许列表(向后兼容) +func MatchAllowed(sender bus.SenderInfo, allowed string) bool +``` + +`MatchAllowed` 支持的允许列表格式: +| 格式 | 匹配方式 | +|------|----------| +| `"123456"` | 匹配 `sender.PlatformID` | +| `"@alice"` | 匹配 `sender.Username` | +| `"123456\|alice"` | 匹配 PlatformID 或 Username(旧格式兼容) | +| `"telegram:123456"` | 精确匹配 `sender.CanonicalID`(新格式) | + +### 4.10 共享 HTTP 服务器 + +**文件**:`pkg/channels/manager.go` 的 `SetupHTTPServer` + +Manager 创建单一 `http.Server`,自动发现和注册: +- 实现 `WebhookHandler` 的 channel → 挂载到 `wh.WebhookPath()` +- 实现 `HealthChecker` 的 channel → 挂载到 `hc.HealthPath()` +- Health 全局端点由 `health.Server.RegisterOnMux` 注册 + +超时配置:ReadTimeout = 30s, WriteTimeout = 30s + +--- + +## 第五部分:关键设计决策与约定 + +### 5.1 必须遵守的约定 + +1. **错误分类是合约**:Channel 的 `Send` 方法**必须**返回哨兵错误(或包装它们)。Manager 的重试策略完全依赖 `errors.Is` 检查。如果返回未分类的错误,Manager 会按"未知错误"处理(指数退避重试)。 + +2. **SetRunning 是生命周期信号**:`Start` 成功后**必须**调用 `c.SetRunning(true)`,`Stop` 开始时**必须**调用 `c.SetRunning(false)`。`Send` 中**必须**检查 `c.IsRunning()` 并返回 `ErrNotRunning`。 + +3. **HandleMessage 包含权限检查**:不要在调用 `HandleMessage` 之前自行进行权限检查(除非你需要在检查前做平台特定的预处理)。`HandleMessage` 内部已经调用 `IsAllowedSender`/`IsAllowed`。 + +4. **消息分割由 Manager 处理**:Channel 的 `Send` 方法不需要处理长消息分割。Manager 会在调用 `Send` 之前根据 `MaxMessageLength()` 自动分割。Channel 只需通过 `WithMaxMessageLength` 声明限制。 + +5. **Typing/Reaction/Placeholder 由 BaseChannel + Manager 自动处理**:Channel 的 `Send` 方法不需要管理 Typing 停止、Reaction 撤销或 Placeholder 编辑。`BaseChannel.HandleMessage` 在入站侧自动触发 `TypingCapable`、`ReactionCapable` 和 `PlaceholderCapable`(通过 `owner` 类型断言);Manager 的 `preSend` 在出站侧自动停止 Typing、撤销 Reaction、编辑 Placeholder。Channel 只需实现对应接口即可。 + +6. **工厂注册在 init() 中**:每个子包必须有 `init.go` 文件调用 `channels.RegisterFactory`。Gateway 必须通过 blank import(`_ "pkg/channels/xxx"`)触发注册。 + +### 5.2 Metadata 字段使用约定 + +**不要再把以下信息放入 Metadata**: +- `peer_kind` / `peer_id` → 使用 `InboundMessage.Peer` +- `message_id` → 使用 `InboundMessage.MessageID` +- `sender_platform` / `sender_username` → 使用 `InboundMessage.Sender` + +**Metadata 仅用于**: +- Channel 特有的扩展信息(如 Telegram 的 `reply_to_message_id`) +- 不适合放入结构化字段的临时信息 + +### 5.3 并发安全约定 + +- `BaseChannel.running`:使用 `atomic.Bool`,线程安全 +- `Manager.channels` / `Manager.workers`:使用 `sync.RWMutex` 保护 +- `Manager.placeholders` / `Manager.typingStops` / `Manager.reactionUndos`:使用 `sync.Map` +- `MessageBus.closed`:使用 `atomic.Bool` +- `FileMediaStore`:使用 `sync.RWMutex`,两阶段操作减少持锁时间 +- Channel Worker queue:Go channel,天然并发安全 + +### 5.4 测试约定 + +已有测试文件: +- `pkg/channels/base_test.go` — BaseChannel 单元测试 +- `pkg/channels/manager_test.go` — Manager 单元测试 +- `pkg/channels/split_test.go` — 消息分割测试 +- `pkg/channels/errors_test.go` — 错误类型测试 +- `pkg/channels/errutil_test.go` — 错误分类测试 + +为新 channel 添加测试时: +```bash +go test ./pkg/channels/matrix/ -v # 子包测试 +go test ./pkg/channels/ -run TestSpecific -v # 框架测试 +make test # 全量测试 +``` + +--- + +## 附录:完整文件清单与接口速查表 + +### A.1 框架层文件 + +| 文件 | 职责 | +|------|------| +| `pkg/channels/base.go` | BaseChannel 结构体、Channel 接口、MessageLengthProvider、BaseChannelOption、HandleMessage | +| `pkg/channels/interfaces.go` | TypingCapable、MessageEditor、ReactionCapable、PlaceholderCapable、PlaceholderRecorder 接口 | +| `pkg/channels/media.go` | MediaSender 接口 | +| `pkg/channels/webhook.go` | WebhookHandler、HealthChecker 接口 | +| `pkg/channels/errors.go` | ErrNotRunning、ErrRateLimit、ErrTemporary、ErrSendFailed 哨兵 | +| `pkg/channels/errutil.go` | ClassifySendError、ClassifyNetError 帮助函数 | +| `pkg/channels/registry.go` | RegisterFactory、getFactory 工厂注册表 | +| `pkg/channels/manager.go` | Manager:Worker 队列、速率限制、重试、preSend、共享 HTTP、TTL janitor | +| `pkg/channels/split.go` | SplitMessage 长消息分割 | +| `pkg/bus/bus.go` | MessageBus 实现 | +| `pkg/bus/types.go` | Peer、SenderInfo、InboundMessage、OutboundMessage、OutboundMediaMessage、MediaPart | +| `pkg/media/store.go` | MediaStore 接口、FileMediaStore 实现 | +| `pkg/identity/identity.go` | BuildCanonicalID、ParseCanonicalID、MatchAllowed | + +### A.2 Channel 子包 + +| 子包 | 注册名 | 可选接口 | +|------|--------|----------| +| `pkg/channels/telegram/` | `"telegram"` | MessageEditor, MediaSender, TypingCapable, PlaceholderCapable | +| `pkg/channels/discord/` | `"discord"` | MessageEditor, TypingCapable, PlaceholderCapable | +| `pkg/channels/slack/` | `"slack"` | ReactionCapable | +| `pkg/channels/line/` | `"line"` | WebhookHandler, HealthChecker, TypingCapable | +| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable | +| `pkg/channels/dingtalk/` | `"dingtalk"` | WebhookHandler | +| `pkg/channels/feishu/` | `"feishu"` | WebhookHandler (架构特定 build tags) | +| `pkg/channels/wecom/` | `"wecom"` + `"wecom_app"` | WebhookHandler | +| `pkg/channels/qq/` | `"qq"` | — | +| `pkg/channels/whatsapp/` | `"whatsapp"` | — | +| `pkg/channels/maixcam/` | `"maixcam"` | — | +| `pkg/channels/pico/` | `"pico"` | WebhookHandler (Pico Protocol), TypingCapable, PlaceholderCapable | + +### A.3 接口速查表 + +```go +// ===== 必须实现 ===== +type Channel interface { + Name() string + Start(ctx context.Context) error + Stop(ctx context.Context) error + Send(ctx context.Context, msg bus.OutboundMessage) error + IsRunning() bool + IsAllowed(senderID string) bool + IsAllowedSender(sender bus.SenderInfo) bool +} + +// ===== 可选实现 ===== +type MediaSender interface { + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error +} + +type TypingCapable interface { + StartTyping(ctx context.Context, chatID string) (stop func(), err error) +} + +type ReactionCapable interface { + ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) +} + +type PlaceholderCapable interface { + SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) +} + +type MessageEditor interface { + EditMessage(ctx context.Context, chatID, messageID, content string) error +} + +type WebhookHandler interface { + WebhookPath() string + http.Handler +} + +type HealthChecker interface { + HealthPath() string + HealthHandler(w http.ResponseWriter, r *http.Request) +} + +type MessageLengthProvider interface { + MaxMessageLength() int +} + +// ===== 由 Manager 注入 ===== +type PlaceholderRecorder interface { + RecordPlaceholder(channel, chatID, placeholderID string) + RecordTypingStop(channel, chatID string, stop func()) + RecordReactionUndo(channel, chatID string, undo func()) +} +``` + +### A.4 Gateway 启动序列(完整引导流程) + +```go +// 1. 创建核心组件 +msgBus := bus.NewMessageBus() +provider := providers.CreateProvider(cfg) +agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + +// 2. 创建媒体存储(带 TTL 清理) +mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig) +mediaStore.Start() + +// 3. 创建 Channel Manager(触发 initChannels → 工厂查找 → 构造 → 注入 MediaStore/PlaceholderRecorder/Owner) +channelManager := channels.NewManager(cfg, msgBus, mediaStore) + +// 4. 注入引用 +agentLoop.SetChannelManager(channelManager) +agentLoop.SetMediaStore(mediaStore) + +// 5. 配置共享 HTTP 服务器 +channelManager.SetupHTTPServer(addr, healthServer) + +// 6. 启动 +channelManager.StartAll(ctx) // 启动 channels + workers + dispatchers + HTTP server +go agentLoop.Run(ctx) // 启动 Agent 消息循环 + +// 7. 关闭(信号触发) +cancel() // 取消 context +msgBus.Close() // 信号关闭 + 排水 +channelManager.StopAll(shutdownCtx) // 停止 HTTP + workers + channels +mediaStore.Stop() // 停止 TTL 清理 +agentLoop.Stop() // 停止 Agent +``` + +### A.5 Per-channel 速率限制参考 + +| Channel | 速率 (msg/s) | Burst | +|---------|-------------|-------| +| telegram | 20 | 10 | +| discord | 1 | 1 | +| slack | 1 | 1 | +| line | 10 | 5 | +| _其他_ | 10 (默认) | 5 | + +### A.6 已知限制和注意事项 + +1. **媒体清理暂时禁用**:Agent loop 中的 `ReleaseAll` 调用被注释掉了(`refactor(loop): disable media cleanup to prevent premature file deletion`),因为会话边界尚未明确定义。TTL 清理仍然有效。 + +2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。 + +3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式)和 `"wecom_app"`(应用模式)分别注册。 + +4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 webhook 接收消息。 \ No newline at end of file From 779e4dfc381f4b556cad545d2e650f81216bb1d0 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 27 Feb 2026 03:30:58 +0800 Subject: [PATCH 055/104] docs(channels): add English README for channel system architecture --- pkg/channels/README.md | 1331 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1331 insertions(+) create mode 100644 pkg/channels/README.md diff --git a/pkg/channels/README.md b/pkg/channels/README.md new file mode 100644 index 000000000..52b9f98f4 --- /dev/null +++ b/pkg/channels/README.md @@ -0,0 +1,1331 @@ +# PicoClaw Channel System Refactor: Complete Development Guide + +> **Branch**: `refactor/channel-system` +> **Status**: Active development (~40 commits) +> **Scope**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/` + +--- + +## Table of Contents + +- [Part 1: Architecture Overview](#part-1-architecture-overview) +- [Part 2: Migration Guide — From main Branch to Refactored Branch](#part-2-migration-guide--from-main-branch-to-refactored-branch) +- [Part 3: New Channel Development Guide — Implementing a Channel from Scratch](#part-3-new-channel-development-guide--implementing-a-channel-from-scratch) +- [Part 4: Core Subsystem Details](#part-4-core-subsystem-details) +- [Part 5: Key Design Decisions and Conventions](#part-5-key-design-decisions-and-conventions) +- [Appendix: Complete File Listing and Interface Quick Reference](#appendix-complete-file-listing-and-interface-quick-reference) + +--- + +## Part 1: Architecture Overview + +### 1.1 Before and After Comparison + +**Before Refactor (main branch)**: + +``` +pkg/channels/ +├── telegram.go # Each channel directly in the channels package +├── discord.go +├── slack.go +├── manager.go # Manager directly references each channel type +├── ... +``` + +- All channel implementations lived at the top level of `pkg/channels/` +- Manager constructed each channel via `switch` or `if-else` chains +- Routing info like Peer and MessageID was buried in `Metadata map[string]string` +- No rate limiting or retry on message sending +- No unified media file lifecycle management +- Each channel ran its own HTTP server +- Group chat trigger filtering logic was scattered across channels + +**After Refactor (refactor/channel-system branch)**: + +``` +pkg/channels/ +├── base.go # BaseChannel shared abstraction layer +├── interfaces.go # Optional capability interfaces (TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder) +├── media.go # MediaSender optional interface +├── webhook.go # WebhookHandler, HealthChecker optional interfaces +├── errors.go # Sentinel errors (ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed) +├── errutil.go # Error classification helpers +├── registry.go # Factory registry (RegisterFactory / getFactory) +├── manager.go # Unified orchestration: Worker queues, rate limiting, retries, Typing/Placeholder, shared HTTP +├── split.go # Smart long-message splitting (preserves code block integrity) +├── telegram/ # Each channel in its own sub-package +│ ├── init.go # Factory registration +│ ├── telegram.go # Implementation +│ └── telegram_commands.go +├── discord/ +│ ├── init.go +│ └── discord.go +├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ maixcam/ pico/ +│ └── ... + +pkg/bus/ +├── bus.go # MessageBus (buffer 64, safe close + drain) +├── types.go # Structured message types (Peer, SenderInfo, MediaPart, InboundMessage, OutboundMessage, OutboundMediaMessage) + +pkg/media/ +├── store.go # MediaStore interface + FileMediaStore implementation (two-phase release, TTL cleanup) + +pkg/identity/ +├── identity.go # Unified user identity: canonical "platform:id" format + backward-compatible matching +``` + +### 1.2 Message Flow Overview + +``` +┌────────────┐ InboundMessage ┌───────────┐ LLM + Tools ┌────────────┐ +│ Telegram │──┐ │ │ │ │ +│ Discord │──┤ PublishInbound() │ │ PublishOutbound() │ │ +│ Slack │──┼──────────────────────▶ │ MessageBus │ ◀─────────────────── │ AgentLoop │ +│ LINE │──┤ (buffered chan, 64) │ │ (buffered chan, 64) │ │ +│ ... │──┘ │ │ │ │ +└────────────┘ └─────┬─────┘ └────────────┘ + │ + SubscribeOutbound() │ SubscribeOutboundMedia() + ▼ + ┌───────────────────┐ + │ Manager │ + │ ├── dispatchOutbound() Route to Worker queues + │ ├── dispatchOutboundMedia() + │ ├── runWorker() Message split + sendWithRetry() + │ ├── runMediaWorker() sendMediaWithRetry() + │ ├── preSend() Stop Typing + Undo Reaction + Edit Placeholder + │ └── runTTLJanitor() Clean up expired Typing/Placeholder + └────────┬──────────┘ + │ + channel.Send() / SendMedia() + │ + ▼ + ┌────────────────┐ + │ Platform APIs │ + └────────────────┘ +``` + +### 1.3 Key Design Principles + +| Principle | Description | +|-----------|-------------| +| **Sub-package Isolation** | Each channel is a standalone Go sub-package, depending on `BaseChannel` and interfaces from the `channels` parent package | +| **Factory Registration** | Sub-packages self-register via `init()`, Manager looks up factories by name, eliminating import coupling | +| **Capability Discovery** | Optional capabilities are declared via interfaces (`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`), discovered by Manager via runtime type assertions | +| **Structured Messages** | Peer, MessageID, and SenderInfo promoted from Metadata to first-class fields on InboundMessage | +| **Error Classification** | Channels return sentinel errors (`ErrRateLimit`, `ErrTemporary`, etc.), Manager uses these to determine retry strategy | +| **Centralized Orchestration** | Rate limiting, message splitting, retries, and Typing/Reaction/Placeholder management are all handled by Manager and BaseChannel; channels only need to implement Send | + +--- + +## Part 2: Migration Guide — From main Branch to Refactored Branch + +### 2.1 If You Have Unmerged Channel Changes + +#### Step 1: Identify which files you modified + +On the main branch, channel files were directly in `pkg/channels/` top level, e.g.: +- `pkg/channels/telegram.go` +- `pkg/channels/discord.go` + +After refactoring, these files have been removed and code moved to corresponding sub-packages: +- `pkg/channels/telegram/telegram.go` +- `pkg/channels/discord/discord.go` + +#### Step 2: Understand the structural change mapping + +| main branch file | Refactored branch location | Changes | +|---|---|---| +| `pkg/channels/telegram.go` | `pkg/channels/telegram/telegram.go` + `init.go` | Package name changed from `channels` to `telegram` | +| `pkg/channels/discord.go` | `pkg/channels/discord/discord.go` + `init.go` | Same as above | +| `pkg/channels/manager.go` | `pkg/channels/manager.go` | Extensively rewritten | +| _(did not exist)_ | `pkg/channels/base.go` | New shared abstraction layer | +| _(did not exist)_ | `pkg/channels/registry.go` | New factory registry | +| _(did not exist)_ | `pkg/channels/errors.go` + `errutil.go` | New error classification system | +| _(did not exist)_ | `pkg/channels/interfaces.go` | New optional capability interfaces | +| _(did not exist)_ | `pkg/channels/media.go` | New MediaSender interface | +| _(did not exist)_ | `pkg/channels/webhook.go` | New WebhookHandler/HealthChecker | +| _(did not exist)_ | `pkg/channels/split.go` | New message splitting (migrated from utils) | +| _(did not exist)_ | `pkg/bus/types.go` | New structured message types | +| _(did not exist)_ | `pkg/media/store.go` | New media file lifecycle management | +| _(did not exist)_ | `pkg/identity/identity.go` | New unified user identity | + +#### Step 3: Migrate your channel code + +Using Telegram as an example, the main changes are: + +**3a. Package declaration and imports** + +```go +// Old code (main branch) +package channels + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// New code (refactored branch) +package telegram + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" // Reference parent package + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" // New + "github.com/sipeed/picoclaw/pkg/media" // New (if media support needed) +) +``` + +**3b. Struct embeds BaseChannel** + +```go +// Old code: directly held bus, config, etc. fields +type TelegramChannel struct { + bus *bus.MessageBus + config *config.Config + running bool + allowList []string + // ... +} + +// New code: embed BaseChannel, which provides bus, running, allowList, etc. +type TelegramChannel struct { + *channels.BaseChannel // Embed shared abstraction + bot *telego.Bot + config *config.Config + // ... only channel-specific fields +} +``` + +**3c. Constructor** + +```go +// Old code: direct assignment +func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { + return &TelegramChannel{ + bus: bus, + config: cfg, + allowList: cfg.Channels.Telegram.AllowFrom, + // ... + }, nil +} + +// New code: use NewBaseChannel + functional options +func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) { + base := channels.NewBaseChannel( + "telegram", // Name + cfg.Channels.Telegram, // Raw config (any type) + bus, // Message bus + cfg.Channels.Telegram.AllowFrom, // Allow list + channels.WithMaxMessageLength(4096), // Platform message length limit + channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // Group trigger config + ) + return &TelegramChannel{ + BaseChannel: base, + bot: bot, + config: cfg, + }, nil +} +``` + +**3d. Start/Stop lifecycle** + +```go +// New code: use SetRunning atomic operation +func (c *TelegramChannel) Start(ctx context.Context) error { + // ... initialize bot, webhook, etc. + c.SetRunning(true) // Must be called after ready + go bh.Start() + return nil +} + +func (c *TelegramChannel) Stop(ctx context.Context) error { + c.SetRunning(false) // Must be called before cleanup + // ... stop bot handler, cancel context + return nil +} +``` + +**3e. Send method error returns** + +```go +// Old code: returns plain error +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.running { return fmt.Errorf("not running") } + // ... + if err != nil { return err } +} + +// New code: must return sentinel errors for Manager to determine retry strategy +func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning // ← Manager will not retry + } + // ... + if err != nil { + // Use ClassifySendError to wrap error based on HTTP status code + return channels.ClassifySendError(statusCode, err) + // Or manually wrap: + // return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + // return fmt.Errorf("%w: %v", channels.ErrRateLimit, err) + // return fmt.Errorf("%w: %v", channels.ErrSendFailed, err) + } + return nil +} +``` + +**3f. Message reception (Inbound)** + +```go +// Old code: directly construct InboundMessage and publish +msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: senderID, + ChatID: chatID, + Content: content, + Metadata: map[string]string{ + "peer_kind": "group", // Routing info buried in metadata + "peer_id": chatID, + "message_id": msgID, + }, +} +c.bus.PublishInbound(ctx, msg) + +// New code: use BaseChannel.HandleMessage with structured fields +sender := bus.SenderInfo{ + Platform: "telegram", + PlatformID: strconv.FormatInt(from.ID, 10), + CanonicalID: identity.BuildCanonicalID("telegram", strconv.FormatInt(from.ID, 10)), + Username: from.Username, + DisplayName: from.FirstName, +} + +peer := bus.Peer{ + Kind: "group", // or "direct" + ID: chatID, +} + +// HandleMessage internally calls IsAllowedSender for permission checks, builds MediaScope, and publishes to bus +c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, sender) +``` + +**3g. Add factory registration (required)** + +Create `init.go` for your channel: + +```go +// pkg/channels/telegram/init.go +package telegram + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("telegram", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewTelegramChannel(cfg, b) + }) +} +``` + +**3h. Import sub-package in Gateway** + +```go +// cmd/picoclaw/internal/gateway/helpers.go +import ( + _ "github.com/sipeed/picoclaw/pkg/channels/telegram" // Triggers init() registration + _ "github.com/sipeed/picoclaw/pkg/channels/discord" + _ "github.com/sipeed/picoclaw/pkg/channels/your_new_channel" // New addition +) +``` + +#### Step 4: Migrate bus message usage + +If your code directly reads routing fields from `InboundMessage.Metadata`: + +```go +// Old code +peerKind := msg.Metadata["peer_kind"] +peerID := msg.Metadata["peer_id"] +msgID := msg.Metadata["message_id"] + +// New code +peerKind := msg.Peer.Kind // First-class field +peerID := msg.Peer.ID // First-class field +msgID := msg.MessageID // First-class field +sender := msg.Sender // bus.SenderInfo struct +scope := msg.MediaScope // Media lifecycle scope +``` + +#### Step 5: Migrate allow-list checks + +```go +// Old code +if !c.isAllowed(senderID) { return } + +// New code: prefer structured check +if !c.IsAllowedSender(sender) { return } +// Or fall back to string check: +if !c.IsAllowed(senderID) { return } +``` + +`BaseChannel.HandleMessage` already handles this logic internally — no need to duplicate the check in your channel. + +### 2.2 If You Have Manager Modifications + +The Manager has been completely rewritten. Your modifications will need to account for the new architecture: + +| Old Manager Responsibility | New Manager Responsibility | +|---|---| +| Directly construct channels (switch/if-else) | Look up and construct via factory registry | +| Directly call channel.Send | Per-channel Worker queues + rate limiting + retries | +| No message splitting | Automatic splitting based on MaxMessageLength | +| Each channel runs its own HTTP server | Unified shared HTTP server | +| No Typing/Placeholder management | Unified preSend handles Typing stop + Reaction undo + Placeholder edit; inbound-side BaseChannel.HandleMessage auto-orchestrates Typing/Reaction/Placeholder | +| No TTL cleanup | runTTLJanitor periodically cleans up expired Typing/Reaction/Placeholder entries | + +### 2.3 If You Have Agent Loop Modifications + +Main changes to the Agent Loop: + +1. **MediaStore injection**: `agentLoop.SetMediaStore(mediaStore)` — Agent resolves media references produced by tools via MediaStore +2. **ChannelManager injection**: `agentLoop.SetChannelManager(channelManager)` — Agent can query channel state +3. **OutboundMediaMessage**: Agent now sends media messages via `bus.PublishOutboundMedia()` instead of embedding them in text replies +4. **extractPeer**: Routing uses `msg.Peer` structured fields instead of Metadata lookups + +--- + +## Part 3: New Channel Development Guide — Implementing a Channel from Scratch + +### 3.1 Minimum Implementation Checklist + +To add a new chat platform (e.g., `matrix`), you need to: + +1. ✅ Create sub-package directory `pkg/channels/matrix/` +2. ✅ Create `init.go` — factory registration +3. ✅ Create `matrix.go` — channel implementation +4. ✅ Add blank import in Gateway helpers +5. ✅ Add config check in Manager.initChannels() +6. ✅ Add config struct in `pkg/config/` + +### 3.2 Complete Template + +#### `pkg/channels/matrix/init.go` + +```go +package matrix + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewMatrixChannel(cfg, b) + }) +} +``` + +#### `pkg/channels/matrix/matrix.go` + +```go +package matrix + +import ( + "context" + "fmt" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// MatrixChannel implements channels.Channel for the Matrix protocol. +type MatrixChannel struct { + *channels.BaseChannel // Must embed + config *config.Config + ctx context.Context + cancel context.CancelFunc + // ... Matrix SDK client, etc. +} + +func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChannel, error) { + matrixCfg := cfg.Channels.Matrix // Assumes this field exists in config + + base := channels.NewBaseChannel( + "matrix", // Channel name (globally unique) + matrixCfg, // Raw config + msgBus, // Message bus + matrixCfg.AllowFrom, // Allow list + channels.WithMaxMessageLength(65536), // Matrix message length limit + channels.WithGroupTrigger(matrixCfg.GroupTrigger), + ) + + return &MatrixChannel{ + BaseChannel: base, + config: cfg, + }, nil +} + +// ========== Required Channel Interface Methods ========== + +func (c *MatrixChannel) Start(ctx context.Context) error { + c.ctx, c.cancel = context.WithCancel(ctx) + + // 1. Initialize Matrix client + // 2. Start listening for messages + // 3. Mark as running + c.SetRunning(true) + + logger.InfoC("matrix", "Matrix channel started") + return nil +} + +func (c *MatrixChannel) Stop(ctx context.Context) error { + c.SetRunning(false) + + if c.cancel != nil { + c.cancel() + } + + logger.InfoC("matrix", "Matrix channel stopped") + return nil +} + +func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + // 1. Check running state + if !c.IsRunning() { + return channels.ErrNotRunning + } + + // 2. Send message to Matrix + err := c.sendToMatrix(ctx, msg.ChatID, msg.Content) + if err != nil { + // 3. Must use error classification wrapping + // If you have an HTTP status code: + // return channels.ClassifySendError(statusCode, err) + // If it's a network error: + // return channels.ClassifyNetError(err) + // If manual classification is needed: + return fmt.Errorf("%w: %v", channels.ErrTemporary, err) + } + + return nil +} + +// ========== Incoming Message Handling ========== + +func (c *MatrixChannel) handleIncoming(roomID, senderID, displayName, content string, msgID string) { + // 1. Construct structured sender identity + sender := bus.SenderInfo{ + Platform: "matrix", + PlatformID: senderID, + CanonicalID: identity.BuildCanonicalID("matrix", senderID), + Username: senderID, + DisplayName: displayName, + } + + // 2. Determine Peer type (direct vs group) + peer := bus.Peer{ + Kind: "group", // or "direct" + ID: roomID, + } + + // 3. Group chat filtering (if applicable) + isGroup := peer.Kind == "group" + if isGroup { + isMentioned := false // Detect @mentions based on platform specifics + shouldRespond, cleanContent := c.ShouldRespondInGroup(isMentioned, content) + if !shouldRespond { + return + } + content = cleanContent + } + + // 4. Handle media attachments (if any) + var mediaRefs []string + store := c.GetMediaStore() + if store != nil { + // Download attachment locally → store.Store() → get ref + // mediaRefs = append(mediaRefs, ref) + } + + // 5. Call HandleMessage to publish to bus + // HandleMessage internally will: + // - Check IsAllowedSender/IsAllowed + // - Build MediaScope + // - Publish InboundMessage + c.HandleMessage( + c.ctx, + peer, + msgID, // Platform message ID + senderID, // Raw sender ID + roomID, // Chat/room ID + content, // Message content + mediaRefs, // Media reference list + nil, // Extra metadata (usually nil) + sender, // SenderInfo (variadic parameter) + ) +} + +// ========== Internal Methods ========== + +func (c *MatrixChannel) sendToMatrix(ctx context.Context, roomID, content string) error { + // Actual Matrix SDK call + return nil +} +``` + +### 3.3 Optional Capability Interfaces + +Depending on platform capabilities, your channel can optionally implement the following interfaces: + +#### MediaSender — Send Media Attachments + +```go +// If the platform supports sending images/files/audio/video +func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + store := c.GetMediaStore() + if store == nil { + return fmt.Errorf("no media store: %w", channels.ErrSendFailed) + } + + for _, part := range msg.Parts { + localPath, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("matrix", "Failed to resolve media", map[string]any{ + "ref": part.Ref, "error": err.Error(), + }) + continue + } + + // Call the appropriate API based on part.Type ("image"|"audio"|"video"|"file") + switch part.Type { + case "image": + // Upload image to Matrix + default: + // Upload file to Matrix + } + } + return nil +} +``` + +#### TypingCapable — Typing Indicator + +```go +// If the platform supports "typing..." indicators +func (c *MatrixChannel) StartTyping(ctx context.Context, chatID string) (stop func(), err error) { + // Call Matrix API to send typing indicator + // The returned stop function must be idempotent + stopped := false + return func() { + if !stopped { + stopped = true + // Call Matrix API to stop typing + } + }, nil +} +``` + +#### ReactionCapable — Message Reaction Indicator + +```go +// If the platform supports adding emoji reactions to inbound messages (e.g., Slack's 👀, OneBot's emoji 289) +func (c *MatrixChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) { + // Call Matrix API to add reaction to message + // The returned undo function removes the reaction, must be idempotent + err = c.addReaction(chatID, messageID, "eyes") + if err != nil { + return func() {}, err + } + return func() { + c.removeReaction(chatID, messageID, "eyes") + }, nil +} +``` + +#### MessageEditor — Message Editing + +```go +// If the platform supports editing sent messages (used for Placeholder replacement) +func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { + // Call Matrix API to edit message + return nil +} +``` + +#### WebhookHandler — HTTP Webhook Reception + +```go +// If the channel receives messages via webhook (rather than long-polling/WebSocket) +func (c *MatrixChannel) WebhookPath() string { + return "/webhook/matrix" // Path will be registered on the shared HTTP server +} + +func (c *MatrixChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Handle webhook request +} +``` + +#### HealthChecker — Health Check Endpoint + +```go +func (c *MatrixChannel) HealthPath() string { + return "/health/matrix" +} + +func (c *MatrixChannel) HealthHandler(w http.ResponseWriter, r *http.Request) { + if c.IsRunning() { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } else { + w.WriteHeader(http.StatusServiceUnavailable) + } +} +``` + +### 3.4 Inbound-side Typing/Reaction/Placeholder Auto-orchestration + +`BaseChannel.HandleMessage` automatically detects whether the channel implements `TypingCapable`, `ReactionCapable`, and/or `PlaceholderCapable` **before** publishing the inbound message, and triggers the corresponding indicators. The three pipelines are completely independent and do not interfere with each other: + +```go +// Automatically executed inside BaseChannel.HandleMessage (no manual calls needed): +if c.owner != nil && c.placeholderRecorder != nil { + // Typing — independent pipeline + if tc, ok := c.owner.(TypingCapable); ok { + if stop, err := tc.StartTyping(ctx, chatID); err == nil { + c.placeholderRecorder.RecordTypingStop(c.name, chatID, stop) + } + } + // Reaction — independent pipeline + if rc, ok := c.owner.(ReactionCapable); ok && messageID != "" { + if undo, err := rc.ReactToMessage(ctx, chatID, messageID); err == nil { + c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) + } + } + // Placeholder — independent pipeline + if pc, ok := c.owner.(PlaceholderCapable); ok { + if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { + c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) + } + } +} +``` + +**This means**: +- Channels implementing `TypingCapable` (Telegram, Discord, LINE, Pico) do not need to manually call `StartTyping` + `RecordTypingStop` in `handleMessage` +- Channels implementing `ReactionCapable` (Slack, OneBot) do not need to manually call `AddReaction` + `RecordTypingStop` in `handleMessage` +- Channels implementing `PlaceholderCapable` (Telegram, Discord, Pico) do not need to manually send placeholder messages and call `RecordPlaceholder` in `handleMessage` +- Channels only need to implement the corresponding interface; `HandleMessage` handles orchestration automatically +- Channels that don't implement these interfaces are unaffected (type assertions will fail and be skipped) +- `PlaceholderCapable`'s `SendPlaceholder` method internally decides whether to send based on the configured `PlaceholderConfig.Enabled`; returning `("", nil)` skips registration + +**Owner Injection**: Manager automatically calls `SetOwner(ch)` in `initChannel` to inject the concrete channel into BaseChannel — no manual setup required from developers. + +When the Agent finishes processing a message, Manager's `preSend` automatically: +1. Calls the recorded `stop()` to stop Typing +2. Calls the recorded `undo()` to undo Reaction +3. If there is a Placeholder and the channel implements `MessageEditor`, attempts to edit the Placeholder with the final reply (skipping Send) + +### 3.5 Register Configuration and Gateway Integration + +#### Add configuration in `pkg/config/config.go` + +```go +type ChannelsConfig struct { + // ... existing channels + Matrix MatrixChannelConfig `yaml:"matrix" json:"matrix"` +} + +type MatrixChannelConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + HomeServer string `yaml:"home_server" json:"home_server"` + Token string `yaml:"token" json:"token"` + AllowFrom []string `yaml:"allow_from" json:"allow_from"` + GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"` +} +``` + +#### Add entry in Manager.initChannels() + +```go +// In the initChannels() method of pkg/channels/manager.go +if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { + m.initChannel("matrix", "Matrix") +} +``` + +#### Add blank import in Gateway + +```go +// cmd/picoclaw/internal/gateway/helpers.go +import ( + _ "github.com/sipeed/picoclaw/pkg/channels/matrix" +) +``` + +--- + +## Part 4: Core Subsystem Details + +### 4.1 MessageBus + +**Files**: `pkg/bus/bus.go`, `pkg/bus/types.go` + +```go +type MessageBus struct { + inbound chan InboundMessage // buffer = 64 + outbound chan OutboundMessage // buffer = 64 + outboundMedia chan OutboundMediaMessage // buffer = 64 + done chan struct{} // Close signal + closed atomic.Bool // Prevents double-close +} +``` + +**Key Behaviors**: + +| Method | Behavior | +|--------|----------| +| `PublishInbound(ctx, msg)` | Check closed → send to inbound channel → block/timeout/close | +| `ConsumeInbound(ctx)` | Read from inbound → block/close/cancel | +| `PublishOutbound(ctx, msg)` | Send to outbound channel | +| `SubscribeOutbound(ctx)` | Read from outbound (called by Manager dispatcher) | +| `PublishOutboundMedia(ctx, msg)` | Send to outboundMedia channel | +| `SubscribeOutboundMedia(ctx)` | Read from outboundMedia (called by Manager media dispatcher) | +| `Close()` | CAS close → close(done) → drain all channels (**does not close the channels themselves** to avoid concurrent send-on-closed panic) | + +**Design Notes**: +- Buffer size increased from 16 to 64 to reduce blocking under burst load +- `Close()` does not close the underlying channels (only closes the `done` signal channel), because there may be concurrent `Publish` goroutines +- Drain loop ensures buffered messages are not silently dropped + +### 4.2 Structured Message Types + +**File**: `pkg/bus/types.go` + +```go +// Routing peer +type Peer struct { + Kind string `json:"kind"` // "direct" | "group" | "channel" | "" + ID string `json:"id"` +} + +// Sender identity information +type SenderInfo struct { + Platform string `json:"platform,omitempty"` // "telegram", "discord", ... + PlatformID string `json:"platform_id,omitempty"` // Platform-native ID + CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" canonical format + Username string `json:"username,omitempty"` + DisplayName string `json:"display_name,omitempty"` +} + +// Inbound message +type InboundMessage struct { + Channel string // Source channel name + SenderID string // Sender ID (prefer CanonicalID) + Sender SenderInfo // Structured sender info + ChatID string // Chat/room ID + Content string // Message text + Media []string // Media reference list (media://...) + Peer Peer // Routing peer (first-class field) + MessageID string // Platform message ID (first-class field) + MediaScope string // Media lifecycle scope + SessionKey string // Session key + Metadata map[string]string // Only for channel-specific extensions +} + +// Outbound text message +type OutboundMessage struct { + Channel string + ChatID string + Content string +} + +// Outbound media message +type OutboundMediaMessage struct { + Channel string + ChatID string + Parts []MediaPart +} + +// Media part +type MediaPart struct { + Type string // "image" | "audio" | "video" | "file" + Ref string // "media://uuid" + Caption string + Filename string + ContentType string +} +``` + +### 4.3 BaseChannel + +**File**: `pkg/channels/base.go` + +BaseChannel is the shared abstraction layer for all channels, providing the following capabilities: + +| Method/Feature | Description | +|---|---| +| `Name() string` | Channel name | +| `IsRunning() bool` | Atomically read running state | +| `SetRunning(bool)` | Atomically set running state | +| `MaxMessageLength() int` | Message length limit (rune count), 0 = unlimited | +| `IsAllowed(senderID string) bool` | Legacy allow-list check (supports `"id\|username"` and `"@username"` formats) | +| `IsAllowedSender(sender SenderInfo) bool` | New allow-list check (delegates to `identity.MatchAllowed`) | +| `ShouldRespondInGroup(isMentioned, content) (bool, string)` | Unified group chat trigger filtering logic | +| `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction → publish to Bus | +| `SetMediaStore(s) / GetMediaStore()` | MediaStore injected by Manager | +| `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | PlaceholderRecorder injected by Manager | +| `SetOwner(ch)` | Concrete channel reference injected by Manager (used for Typing/Reaction type assertions in HandleMessage) | + +**Functional Options**: + +```go +channels.WithMaxMessageLength(4096) // Set platform message length limit +channels.WithGroupTrigger(groupTriggerCfg) // Set group trigger configuration +``` + +### 4.4 Factory Registry + +**File**: `pkg/channels/registry.go` + +```go +type ChannelFactory func(cfg *config.Config, bus *bus.MessageBus) (Channel, error) + +func RegisterFactory(name string, f ChannelFactory) // Called in sub-package init() +func getFactory(name string) (ChannelFactory, bool) // Called internally by Manager +``` + +The factory registry is protected by `sync.RWMutex` and registrations occur during `init()` phase (completed at process startup). Manager looks up factories by name in `initChannel()` and calls them. + +### 4.5 Error Classification and Retries + +**Files**: `pkg/channels/errors.go`, `pkg/channels/errutil.go` + +#### Sentinel Errors + +```go +var ( + ErrNotRunning = errors.New("channel not running") // Permanent: do not retry + ErrRateLimit = errors.New("rate limited") // Fixed delay: retry after 1s + ErrTemporary = errors.New("temporary failure") // Exponential backoff: 500ms * 2^attempt, max 8s + ErrSendFailed = errors.New("send failed") // Permanent: do not retry +) +``` + +#### Error Classification Helpers + +```go +// Automatically classify based on HTTP status code +func ClassifySendError(statusCode int, rawErr error) error { + // 429 → ErrRateLimit + // 5xx → ErrTemporary + // 4xx → ErrSendFailed +} + +// Wrap network errors as temporary +func ClassifyNetError(err error) error { + // → ErrTemporary +} +``` + +#### Manager Retry Strategy (`sendWithRetry`) + +``` +Max retries: 3 +Rate limit delay: 1 second +Base backoff: 500 milliseconds +Max backoff: 8 seconds + +Retry logic: + ErrNotRunning → Fail immediately, no retry + ErrSendFailed → Fail immediately, no retry + ErrRateLimit → Wait 1s → retry + ErrTemporary → Wait 500ms * 2^attempt (max 8s) → retry + Other unknown → Wait 500ms * 2^attempt (max 8s) → retry +``` + +### 4.6 Manager Orchestration + +**File**: `pkg/channels/manager.go` + +#### Per-channel Worker Architecture + +```go +type channelWorker struct { + ch Channel // Channel instance + queue chan bus.OutboundMessage // Outbound text queue (buffered 16) + mediaQueue chan bus.OutboundMediaMessage // Outbound media queue (buffered 16) + done chan struct{} // Text worker completion signal + mediaDone chan struct{} // Media worker completion signal + limiter *rate.Limiter // Per-channel rate limiter +} +``` + +#### Per-channel Rate Limit Configuration + +```go +var channelRateConfig = map[string]float64{ + "telegram": 20, // 20 msg/s + "discord": 1, // 1 msg/s + "slack": 1, // 1 msg/s + "line": 10, // 10 msg/s +} +// Default: 10 msg/s +// burst = max(1, ceil(rate/2)) +``` + +#### Lifecycle Management + +``` +StartAll: + 1. Iterate registered channels → channel.Start(ctx) + 2. Create channelWorker for each successfully started channel + 3. Start goroutines: + - runWorker (per-channel outbound text) + - runMediaWorker (per-channel outbound media) + - dispatchOutbound (route from bus to worker queues) + - dispatchOutboundMedia (route from bus to media worker queues) + - runTTLJanitor (every 10s clean up expired typing/placeholder) + 4. Start shared HTTP server (if configured) + +StopAll: + 1. Shut down shared HTTP server (5s timeout) + 2. Cancel dispatcher context + 3. Close text worker queues → wait for drain to complete + 4. Close media worker queues → wait for drain to complete + 5. Stop each channel (channel.Stop) +``` + +#### Typing/Reaction/Placeholder Management + +```go +// Manager implements PlaceholderRecorder interface +func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) +func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) +func (m *Manager) RecordReactionUndo(channel, chatID string, undo func()) + +// Inbound side: BaseChannel.HandleMessage auto-orchestrates +// BaseChannel.HandleMessage, before PublishInbound, auto-triggers via owner type assertions: +// - TypingCapable.StartTyping → RecordTypingStop +// - ReactionCapable.ReactToMessage → RecordReactionUndo +// - PlaceholderCapable.SendPlaceholder → RecordPlaceholder +// All three are independent and do not interfere with each other. Channels don't need to call these manually. + +// Outbound side: pre-send processing +func (m *Manager) preSend(ctx, name, msg, ch) bool { + key := name + ":" + msg.ChatID + // 1. Stop Typing (call stored stop function) + // 2. Undo Reaction (call stored undo function) + // 3. Attempt to edit Placeholder (if channel implements MessageEditor) + // Success → return true (skip Send) + // Failure → return false (proceed with Send) +} +``` + +Manager storage is fully separated; three pipelines do not interfere: + +```go +Manager { + typingStops sync.Map // "channel:chatID" → typingEntry ← manages TypingCapable + reactionUndos sync.Map // "channel:chatID" → reactionEntry ← manages ReactionCapable + placeholders sync.Map // "channel:chatID" → placeholderEntry +} +``` + +TTL Cleanup: +- Typing stop functions: 5-minute TTL (auto-calls stop and deletes on expiry) +- Reaction undo functions: 5-minute TTL (auto-calls undo and deletes on expiry) +- Placeholder IDs: 10-minute TTL (deletes on expiry) +- Cleanup interval: 10 seconds + +### 4.7 Message Splitting + +**File**: `pkg/channels/split.go` + +`SplitMessage(content string, maxLen int) []string` + +Smart splitting strategy: +1. Calculate effective split point = maxLen - 10% buffer (to reserve space for code block closure) +2. Prefer splitting at newlines +3. Otherwise split at spaces/tabs +4. Detect unclosed code blocks (` ``` `) +5. If a code block is unclosed: + - Attempt to extend to maxLen to include the closing fence + - If the code block is too long, inject close/reopen fences (`\n```\n` + header) + - Last resort: split before the code block starts + +### 4.8 MediaStore + +**File**: `pkg/media/store.go` + +```go +type MediaStore interface { + Store(localPath string, meta MediaMeta, scope string) (ref string, err error) + Resolve(ref string) (localPath string, err error) + ResolveWithMeta(ref string) (localPath string, meta MediaMeta, err error) + ReleaseAll(scope string) error +} +``` + +**FileMediaStore Implementation**: +- Pure in-memory mapping, no file copy/move +- Reference format: `media://` +- Scope format: `channel:chatID:messageID` (generated by `BuildMediaScope`) +- **Two-phase operation**: + - Phase 1 (holding lock): collect and delete entries from map + - Phase 2 (no lock): delete files from disk + - Purpose: minimize lock contention +- **TTL Cleanup**: `NewFileMediaStoreWithCleanup` → `Start()` launches background cleanup goroutine +- Cleanup interval and max TTL are controlled by configuration + +### 4.9 Identity + +**File**: `pkg/identity/identity.go` + +```go +// Build canonical ID +func BuildCanonicalID(platform, platformID string) string +// → "telegram:123456" + +// Parse canonical ID +func ParseCanonicalID(canonical string) (platform, id string, ok bool) + +// Match against allow list (backward-compatible) +func MatchAllowed(sender bus.SenderInfo, allowed string) bool +``` + +`MatchAllowed` supported allow-list formats: +| Format | Matching | +|--------|----------| +| `"123456"` | Matches `sender.PlatformID` | +| `"@alice"` | Matches `sender.Username` | +| `"123456\|alice"` | Matches PlatformID or Username (legacy format compatibility) | +| `"telegram:123456"` | Exact match on `sender.CanonicalID` (new format) | + +### 4.10 Shared HTTP Server + +**File**: `pkg/channels/manager.go`'s `SetupHTTPServer` + +Manager creates a single `http.Server` and auto-discovers and registers: +- Channels implementing `WebhookHandler` → mounted at `wh.WebhookPath()` +- Channels implementing `HealthChecker` → mounted at `hc.HealthPath()` +- Global health endpoint registered by `health.Server.RegisterOnMux` + +Timeout configuration: ReadTimeout = 30s, WriteTimeout = 30s + +--- + +## Part 5: Key Design Decisions and Conventions + +### 5.1 Mandatory Conventions + +1. **Error classification is a contract**: A channel's `Send` method **must** return sentinel errors (or wrap them). Manager's retry strategy relies entirely on `errors.Is` checks. Returning unclassified errors will cause Manager to treat them as "unknown errors" (exponential backoff retry). + +2. **SetRunning is a lifecycle signal**: **Must** call `c.SetRunning(true)` after successful `Start`, and **must** call `c.SetRunning(false)` at the beginning of `Stop`. **Must** check `c.IsRunning()` in `Send` and return `ErrNotRunning`. + +3. **HandleMessage includes permission checks**: Do not perform your own permission checks before calling `HandleMessage` (unless you need platform-specific preprocessing before the check). `HandleMessage` already calls `IsAllowedSender`/`IsAllowed` internally. + +4. **Message splitting is handled by Manager**: A channel's `Send` method does not need to handle long message splitting. Manager automatically splits based on `MaxMessageLength()` before calling `Send`. Channels only need to declare the limit via `WithMaxMessageLength`. + +5. **Typing/Reaction/Placeholder is handled by BaseChannel + Manager automatically**: A channel's `Send` method does not need to manage Typing stop, Reaction undo, or Placeholder editing. `BaseChannel.HandleMessage` auto-triggers `TypingCapable`, `ReactionCapable`, and `PlaceholderCapable` on the inbound side (via `owner` type assertions); Manager's `preSend` auto-stops Typing, undoes Reaction, and edits Placeholder on the outbound side. Channels only need to implement the corresponding interfaces. + +6. **Factory registration belongs in init()**: Each sub-package must have an `init.go` file calling `channels.RegisterFactory`. Gateway must trigger registration via blank imports (`_ "pkg/channels/xxx"`). + +### 5.2 Metadata Field Usage Conventions + +**Do NOT put the following information in Metadata anymore**: +- `peer_kind` / `peer_id` → Use `InboundMessage.Peer` +- `message_id` → Use `InboundMessage.MessageID` +- `sender_platform` / `sender_username` → Use `InboundMessage.Sender` + +**Metadata should only be used for**: +- Channel-specific extension information (e.g., Telegram's `reply_to_message_id`) +- Temporary information that doesn't fit into structured fields + +### 5.3 Concurrency Safety Conventions + +- `BaseChannel.running`: Uses `atomic.Bool`, thread-safe +- `Manager.channels` / `Manager.workers`: Protected by `sync.RWMutex` +- `Manager.placeholders` / `Manager.typingStops` / `Manager.reactionUndos`: Uses `sync.Map` +- `MessageBus.closed`: Uses `atomic.Bool` +- `FileMediaStore`: Uses `sync.RWMutex`, two-phase operation to minimize lock-hold time +- Channel Worker queue: Go channel, inherently concurrent-safe + +### 5.4 Testing Conventions + +Existing test files: +- `pkg/channels/base_test.go` — BaseChannel unit tests +- `pkg/channels/manager_test.go` — Manager unit tests +- `pkg/channels/split_test.go` — Message splitting tests +- `pkg/channels/errors_test.go` — Error type tests +- `pkg/channels/errutil_test.go` — Error classification tests + +To add tests for a new channel: +```bash +go test ./pkg/channels/matrix/ -v # Sub-package tests +go test ./pkg/channels/ -run TestSpecific -v # Framework tests +make test # Full test suite +``` + +--- + +## Appendix: Complete File Listing and Interface Quick Reference + +### A.1 Framework Layer Files + +| File | Responsibility | +|------|---------------| +| `pkg/channels/base.go` | BaseChannel struct, Channel interface, MessageLengthProvider, BaseChannelOption, HandleMessage | +| `pkg/channels/interfaces.go` | TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder interfaces | +| `pkg/channels/media.go` | MediaSender interface | +| `pkg/channels/webhook.go` | WebhookHandler, HealthChecker interfaces | +| `pkg/channels/errors.go` | ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed sentinels | +| `pkg/channels/errutil.go` | ClassifySendError, ClassifyNetError helpers | +| `pkg/channels/registry.go` | RegisterFactory, getFactory factory registry | +| `pkg/channels/manager.go` | Manager: Worker queues, rate limiting, retries, preSend, shared HTTP, TTL janitor | +| `pkg/channels/split.go` | SplitMessage long-message splitting | +| `pkg/bus/bus.go` | MessageBus implementation | +| `pkg/bus/types.go` | Peer, SenderInfo, InboundMessage, OutboundMessage, OutboundMediaMessage, MediaPart | +| `pkg/media/store.go` | MediaStore interface, FileMediaStore implementation | +| `pkg/identity/identity.go` | BuildCanonicalID, ParseCanonicalID, MatchAllowed | + +### A.2 Channel Sub-packages + +| Sub-package | Registered Name | Optional Interfaces | +|-------------|----------------|-------------------| +| `pkg/channels/telegram/` | `"telegram"` | MessageEditor, MediaSender, TypingCapable, PlaceholderCapable | +| `pkg/channels/discord/` | `"discord"` | MessageEditor, TypingCapable, PlaceholderCapable | +| `pkg/channels/slack/` | `"slack"` | ReactionCapable | +| `pkg/channels/line/` | `"line"` | WebhookHandler, HealthChecker, TypingCapable | +| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable | +| `pkg/channels/dingtalk/` | `"dingtalk"` | WebhookHandler | +| `pkg/channels/feishu/` | `"feishu"` | WebhookHandler (architecture-specific build tags) | +| `pkg/channels/wecom/` | `"wecom"` + `"wecom_app"` | WebhookHandler | +| `pkg/channels/qq/` | `"qq"` | — | +| `pkg/channels/whatsapp/` | `"whatsapp"` | — | +| `pkg/channels/maixcam/` | `"maixcam"` | — | +| `pkg/channels/pico/` | `"pico"` | WebhookHandler (Pico Protocol), TypingCapable, PlaceholderCapable | + +### A.3 Interface Quick Reference + +```go +// ===== Required ===== +type Channel interface { + Name() string + Start(ctx context.Context) error + Stop(ctx context.Context) error + Send(ctx context.Context, msg bus.OutboundMessage) error + IsRunning() bool + IsAllowed(senderID string) bool + IsAllowedSender(sender bus.SenderInfo) bool +} + +// ===== Optional ===== +type MediaSender interface { + SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error +} + +type TypingCapable interface { + StartTyping(ctx context.Context, chatID string) (stop func(), err error) +} + +type ReactionCapable interface { + ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) +} + +type PlaceholderCapable interface { + SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) +} + +type MessageEditor interface { + EditMessage(ctx context.Context, chatID, messageID, content string) error +} + +type WebhookHandler interface { + WebhookPath() string + http.Handler +} + +type HealthChecker interface { + HealthPath() string + HealthHandler(w http.ResponseWriter, r *http.Request) +} + +type MessageLengthProvider interface { + MaxMessageLength() int +} + +// ===== Injected by Manager ===== +type PlaceholderRecorder interface { + RecordPlaceholder(channel, chatID, placeholderID string) + RecordTypingStop(channel, chatID string, stop func()) + RecordReactionUndo(channel, chatID string, undo func()) +} +``` + +### A.4 Gateway Startup Sequence (Complete Bootstrap Flow) + +```go +// 1. Create core components +msgBus := bus.NewMessageBus() +provider := providers.CreateProvider(cfg) +agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + +// 2. Create media store (with TTL cleanup) +mediaStore := media.NewFileMediaStoreWithCleanup(cleanerConfig) +mediaStore.Start() + +// 3. Create Channel Manager (triggers initChannels → factory lookup → construct → inject MediaStore/PlaceholderRecorder/Owner) +channelManager := channels.NewManager(cfg, msgBus, mediaStore) + +// 4. Inject references +agentLoop.SetChannelManager(channelManager) +agentLoop.SetMediaStore(mediaStore) + +// 5. Configure shared HTTP server +channelManager.SetupHTTPServer(addr, healthServer) + +// 6. Start +channelManager.StartAll(ctx) // Start channels + workers + dispatchers + HTTP server +go agentLoop.Run(ctx) // Start Agent message loop + +// 7. Shutdown (signal-triggered) +cancel() // Cancel context +msgBus.Close() // Signal close + drain +channelManager.StopAll(shutdownCtx) // Stop HTTP + workers + channels +mediaStore.Stop() // Stop TTL cleanup +agentLoop.Stop() // Stop Agent +``` + +### A.5 Per-channel Rate Limit Reference + +| Channel | Rate (msg/s) | Burst | +|---------|-------------|-------| +| telegram | 20 | 10 | +| discord | 1 | 1 | +| slack | 1 | 1 | +| line | 10 | 5 | +| _others_ | 10 (default) | 5 | + +### A.6 Known Limitations and Caveats + +1. **Media cleanup temporarily disabled**: The `ReleaseAll` call in the Agent loop is commented out (`refactor(loop): disable media cleanup to prevent premature file deletion`) because session boundaries are not yet clearly defined. TTL cleanup remains active. + +2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). + +3. **WeCom has two factories**: `"wecom"` (Bot mode) and `"wecom_app"` (App mode) are registered separately. + +4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via webhook. \ No newline at end of file From a8644ca1c504792fe610293a0458dee407edab09 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Thu, 26 Feb 2026 22:35:20 -0800 Subject: [PATCH 056/104] Refactor whatsapp native channel based on the new channel interface --- pkg/channels/manager.go | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index dfc4fd9f9..7fae0d060 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -220,14 +220,19 @@ func (m *Manager) initChannels() error { if storePath == "" { storePath = filepath.Join(m.config.WorkspacePath(), "whatsapp") } - ch, err := NewWhatsAppNativeChannel(waCfg, m.bus, storePath) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WhatsApp native channel", map[string]any{ - "error": err.Error(), - }) + newNative := getWhatsAppNativeFactory() + if newNative == nil { + logger.ErrorCF("channels", "WhatsApp native not linked; import _ github.com/sipeed/picoclaw/pkg/channels/whatsapp or build with -tags whatsapp_native", nil) } else { - m.channels["whatsapp"] = ch - logger.InfoC("channels", "WhatsApp native channel enabled successfully") + ch, err := newNative(waCfg, m.bus, storePath) + if err != nil { + logger.ErrorCF("channels", "Failed to initialize WhatsApp native channel", map[string]any{ + "error": err.Error(), + }) + } else { + m.channels["whatsapp"] = ch + logger.InfoC("channels", "WhatsApp native channel enabled successfully") + } } } else if waCfg.BridgeURL != "" { m.initChannel("whatsapp", "WhatsApp") From 42ee9ab1e317c899229cc9c94d7a02688df76bd9 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Thu, 26 Feb 2026 22:35:52 -0800 Subject: [PATCH 057/104] Complete the whatsapp native channel implementation based on the new channel interface --- .gitignore | 3 + cmd/picoclaw/internal/gateway/helpers.go | 1 + pkg/channels/manager.go | 24 +------ pkg/channels/whatsapp_native/init.go | 20 ++++++ .../{ => whatsapp_native}/whatsapp_native.go | 69 +++++++++++-------- .../whatsapp_native_stub.go | 5 +- 6 files changed, 69 insertions(+), 53 deletions(-) create mode 100644 pkg/channels/whatsapp_native/init.go rename pkg/channels/{ => whatsapp_native}/whatsapp_native.go (82%) rename pkg/channels/{ => whatsapp_native}/whatsapp_native_stub.go (78%) diff --git a/.gitignore b/.gitignore index 3ff195fbf..06a8fce77 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ tasks/ # Added by goreleaser init: dist/ +akalro-dietpi.pub +akalro-dietpi +.gitignore diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index b1735c7ab..baa489b92 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -24,6 +24,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/telegram" _ "github.com/sipeed/picoclaw/pkg/channels/wecom" _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp" + _ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/devices" diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 7fae0d060..31af9672c 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -12,7 +12,6 @@ import ( "fmt" "math" "net/http" - "path/filepath" "sync" "time" @@ -213,27 +212,8 @@ func (m *Manager) initChannels() error { if m.config.Channels.WhatsApp.Enabled { waCfg := m.config.Channels.WhatsApp - useNative := waCfg.UseNative - if useNative { - logger.DebugC("channels", "Attempting to initialize WhatsApp native channel (whatsmeow)") - storePath := waCfg.SessionStorePath - if storePath == "" { - storePath = filepath.Join(m.config.WorkspacePath(), "whatsapp") - } - newNative := getWhatsAppNativeFactory() - if newNative == nil { - logger.ErrorCF("channels", "WhatsApp native not linked; import _ github.com/sipeed/picoclaw/pkg/channels/whatsapp or build with -tags whatsapp_native", nil) - } else { - ch, err := newNative(waCfg, m.bus, storePath) - if err != nil { - logger.ErrorCF("channels", "Failed to initialize WhatsApp native channel", map[string]any{ - "error": err.Error(), - }) - } else { - m.channels["whatsapp"] = ch - logger.InfoC("channels", "WhatsApp native channel enabled successfully") - } - } + if waCfg.UseNative { + m.initChannel("whatsapp_native", "WhatsApp Native") } else if waCfg.BridgeURL != "" { m.initChannel("whatsapp", "WhatsApp") } diff --git a/pkg/channels/whatsapp_native/init.go b/pkg/channels/whatsapp_native/init.go new file mode 100644 index 000000000..df13e8539 --- /dev/null +++ b/pkg/channels/whatsapp_native/init.go @@ -0,0 +1,20 @@ +package whatsapp + +import ( + "path/filepath" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("whatsapp_native", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + waCfg := cfg.Channels.WhatsApp + storePath := waCfg.SessionStorePath + if storePath == "" { + storePath = filepath.Join(cfg.WorkspacePath(), "whatsapp") + } + return NewWhatsAppNativeChannel(waCfg, b, storePath) + }) +} diff --git a/pkg/channels/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go similarity index 82% rename from pkg/channels/whatsapp_native.go rename to pkg/channels/whatsapp_native/whatsapp_native.go index 72666cb35..254f15918 100644 --- a/pkg/channels/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -5,7 +5,7 @@ // // Copyright (c) 2026 PicoClaw contributors -package channels +package whatsapp import ( "context" @@ -24,29 +24,30 @@ import ( "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" "google.golang.org/protobuf/proto" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" - - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/types" ) const ( sqliteDriver = "sqlite" whatsappDBName = "store.db" - reconnectInitial = 5 * time.Second - reconnectMax = 5 * time.Minute + reconnectInitial = 5 * time.Second + reconnectMax = 5 * time.Minute reconnectMultiplier = 2.0 ) // WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge). type WhatsAppNativeChannel struct { - *BaseChannel + *channels.BaseChannel config config.WhatsAppConfig storePath string client *whatsmeow.Client @@ -60,8 +61,8 @@ type WhatsAppNativeChannel struct { // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) { - base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom) +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) { + base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) if storePath == "" { storePath = "whatsapp" } @@ -74,7 +75,7 @@ func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, st } func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { - logger.InfoCF("channels", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) + logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) if err := os.MkdirAll(c.storePath, 0700); err != nil { return fmt.Errorf("create session store dir: %w", err) @@ -83,7 +84,6 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { dbPath := filepath.Join(c.storePath, whatsappDBName) connStr := "file:" + dbPath + "?_foreign_keys=on" - // Open DB and enable foreign keys explicitly (modernc.org/sqlite does not set them from URI). db, err := sql.Open(sqliteDriver, connStr) if err != nil { return fmt.Errorf("open whatsapp store: %w", err) @@ -128,14 +128,14 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } for evt := range qrChan { if evt.Event == "code" { - logger.InfoCF("channels", "Scan this QR code with WhatsApp (Linked Devices):", nil) + logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil) qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ Level: qrterminal.L, Writer: os.Stdout, HalfBlocks: true, }) } else { - logger.InfoCF("channels", "WhatsApp login event", map[string]any{"event": evt.Event}) + logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event}) } } } else { @@ -147,12 +147,12 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { c.runCtx, c.runCancel = context.WithCancel(ctx) c.SetRunning(true) - logger.InfoCF("channels", "WhatsApp native channel connected", nil) + logger.InfoC("whatsapp", "WhatsApp native channel connected") return nil } func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { - logger.InfoCF("channels", "Stopping WhatsApp native channel", nil) + logger.InfoC("whatsapp", "Stopping WhatsApp native channel") if c.runCancel != nil { c.runCancel() } @@ -178,7 +178,7 @@ func (c *WhatsAppNativeChannel) eventHandler(evt interface{}) { case *events.Message: c.handleIncoming(evt.(*events.Message)) case *events.Disconnected: - logger.InfoCF("channels", "WhatsApp disconnected, will attempt reconnection", nil) + logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil) c.reconnectMu.Lock() if c.reconnecting { c.reconnectMu.Unlock() @@ -212,14 +212,14 @@ func (c *WhatsAppNativeChannel) reconnectWithBackoff() { return } - logger.InfoCF("channels", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()}) + logger.InfoCF("whatsapp", "WhatsApp reconnecting", map[string]any{"backoff": backoff.String()}) err := client.Connect() if err == nil { - logger.InfoCF("channels", "WhatsApp reconnected", nil) + logger.InfoC("whatsapp", "WhatsApp reconnected") return } - logger.WarnCF("channels", "WhatsApp reconnect failed", map[string]any{"error": err.Error()}) + logger.WarnCF("whatsapp", "WhatsApp reconnect failed", map[string]any{"error": err.Error()}) select { case <-c.runCtx.Done(): @@ -248,11 +248,11 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { } content = utils.SanitizeMessageContent(content) - if content == "" { return } // ignore empty messages + if content == "" { + return + } var mediaPaths []string - // Optional: resolve media to local paths if needed; for now we only forward text to the bus. - _ = mediaPaths metadata := make(map[string]string) metadata["message_id"] = evt.Info.ID @@ -276,21 +276,34 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { sender := bus.SenderInfo{ Platform: "whatsapp", PlatformID: senderID, - CanonicalID: "whatsapp:" + senderID, + CanonicalID: identity.BuildCanonicalID("whatsapp", senderID), DisplayName: evt.Info.PushName, } - logger.DebugCF("channels", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) + if !c.IsAllowedSender(sender) { + return + } + + logger.DebugCF("whatsapp", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + c.mu.Lock() client := c.client c.mu.Unlock() if client == nil || !client.IsConnected() { - return fmt.Errorf("whatsapp connection not established") + return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } to, err := parseJID(msg.ChatID) @@ -302,9 +315,8 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag Conversation: proto.String(msg.Content), } - _, err = client.SendMessage(ctx, to, waMsg) - if err != nil { - return fmt.Errorf("send message: %w", err) + if _, err = client.SendMessage(ctx, to, waMsg); err != nil { + return fmt.Errorf("whatsapp send: %w", channels.ErrTemporary) } return nil } @@ -318,6 +330,5 @@ func parseJID(s string) (types.JID, error) { if strings.Contains(s, "@") { return types.ParseJID(s) } - // Assume phone number for user chat. return types.NewJID(s, types.DefaultUserServer), nil } diff --git a/pkg/channels/whatsapp_native_stub.go b/pkg/channels/whatsapp_native/whatsapp_native_stub.go similarity index 78% rename from pkg/channels/whatsapp_native_stub.go rename to pkg/channels/whatsapp_native/whatsapp_native_stub.go index b4a826fa4..d57a3b6e7 100644 --- a/pkg/channels/whatsapp_native_stub.go +++ b/pkg/channels/whatsapp_native/whatsapp_native_stub.go @@ -1,16 +1,17 @@ //go:build !whatsapp_native -package channels +package whatsapp import ( "fmt" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" ) // NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native. // Build with: go build -tags whatsapp_native ./cmd/... -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (Channel, error) { +func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) { return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native") } From 222d1a30864ab451221a54309747d73794fbb90c Mon Sep 17 00:00:00 2001 From: Petrichor Date: Fri, 27 Feb 2026 16:35:07 +0800 Subject: [PATCH 058/104] refactor(modernize): apply safe modernize fixes --- pkg/agent/context.go | 7 +++---- pkg/agent/context_cache_test.go | 4 ++-- pkg/agent/loop_test.go | 17 +++-------------- pkg/agent/memory.go | 2 +- pkg/channels/onebot.go | 9 +++------ pkg/channels/wecom.go | 2 +- pkg/channels/wecom_app_test.go | 2 +- pkg/channels/wecom_test.go | 2 +- pkg/config/model_config_test.go | 12 +++++------- pkg/health/server.go | 5 ++--- pkg/providers/anthropic/provider.go | 10 +++++----- pkg/providers/codex_provider.go | 4 ++-- pkg/providers/cooldown_test.go | 4 ++-- pkg/providers/openai_compat/provider.go | 8 ++++---- pkg/routing/agent_id_test.go | 10 ++++++---- pkg/skills/loader.go | 2 +- pkg/skills/search_cache.go | 4 ++-- pkg/skills/search_cache_test.go | 4 ++-- pkg/state/state_test.go | 4 ++-- pkg/tools/cron.go | 8 +++++--- pkg/tools/registry_test.go | 2 +- pkg/tools/web.go | 8 ++++---- pkg/utils/message.go | 20 ++++---------------- 23 files changed, 62 insertions(+), 88 deletions(-) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index b7c6e1108..6fccbaf53 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "sync" "time" @@ -249,10 +250,8 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool { } // Check tracked source files (bootstrap + memory). - for _, p := range cb.sourcePaths() { - if cb.fileChangedSince(p) { - return true - } + if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) { + return true } // --- Skills directory (handled separately from sourcePaths) --- diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index ba70d4c0d..0905e8a46 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -404,11 +404,11 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { var wg sync.WaitGroup errs := make(chan string, goroutines*iterations) - for g := 0; g < goroutines; g++ { + for g := range goroutines { wg.Add(1) go func(id int) { defer wg.Done() - for i := 0; i < iterations; i++ { + for i := range iterations { result := cb.BuildSystemPromptWithCache() if result == "" { errs <- "empty prompt returned" diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4414398b1..aa9f823b7 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "testing" "time" @@ -175,13 +176,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) { toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list - found := false - for _, name := range toolsList { - if name == "mock_custom" { - found = true - break - } - } + found := slices.Contains(toolsList, "mock_custom") if !found { t.Error("Expected custom tool to be registered") } @@ -250,13 +245,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) { toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list - found := false - for _, name := range toolsList { - if name == "mock_custom" { - found = true - break - } - } + found := slices.Contains(toolsList, "mock_custom") if !found { t.Error("Expected custom tool to be registered") } diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 87a687479..01e682f3b 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -111,7 +111,7 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { var sb strings.Builder first := true - for i := 0; i < days; i++ { + for i := range days { date := time.Now().AddDate(0, 0, -i) dateStr := date.Format("20060102") // YYYYMMDD monthDir := dateStr[:6] // YYYYMM diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go index 4576a11ce..1b0cbc4ab 100644 --- a/pkg/channels/onebot.go +++ b/pkg/channels/onebot.go @@ -318,10 +318,7 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D } func (c *OneBotChannel) reconnectLoop() { - interval := time.Duration(c.config.ReconnectInterval) * time.Second - if interval < 5*time.Second { - interval = 5 * time.Second - } + interval := max(time.Duration(c.config.ReconnectInterval)*time.Second, 5*time.Second) for { select { @@ -975,8 +972,8 @@ func (c *OneBotChannel) checkGroupTrigger( if prefix == "" { continue } - if strings.HasPrefix(content, prefix) { - return true, strings.TrimSpace(strings.TrimPrefix(content, prefix)) + if after, ok := strings.CutPrefix(content, prefix); ok { + return true, strings.TrimSpace(after) } } diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go index f8daf89de..e24157f5c 100644 --- a/pkg/channels/wecom.go +++ b/pkg/channels/wecom.go @@ -596,7 +596,7 @@ func pkcs7UnpadWeCom(data []byte) ([]byte, error) { return nil, fmt.Errorf("padding size larger than data") } // Verify all padding bytes - for i := 0; i < padding; i++ { + for i := range padding { if data[len(data)-1-i] != byte(padding) { return nil, fmt.Errorf("invalid padding byte at position %d", i) } diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go index abf15c52b..ba911d49f 100644 --- a/pkg/channels/wecom_app_test.go +++ b/pkg/channels/wecom_app_test.go @@ -46,7 +46,7 @@ func encryptTestMessageApp(message, aesKey string) (string, error) { // Prepare message: random(16) + msg_len(4) + msg + corp_id random := make([]byte, 0, 16) - for i := 0; i < 16; i++ { + for i := range 16 { random = append(random, byte(i+1)) } diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go index 8afa7e8c3..88aed8d2b 100644 --- a/pkg/channels/wecom_test.go +++ b/pkg/channels/wecom_test.go @@ -45,7 +45,7 @@ func encryptTestMessage(message, aesKey string) (string, error) { // Prepare message: random(16) + msg_len(4) + msg + receiveid random := make([]byte, 0, 16) - for i := 0; i < 16; i++ { + for i := range 16 { random = append(random, byte(i)) } diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go index 084f50a82..da6e506f8 100644 --- a/pkg/config/model_config_test.go +++ b/pkg/config/model_config_test.go @@ -64,7 +64,7 @@ func TestGetModelConfig_RoundRobin(t *testing.T) { // Test round-robin distribution results := make(map[string]int) - for i := 0; i < 30; i++ { + for range 30 { result, err := cfg.GetModelConfig("lb-model") if err != nil { t.Fatalf("GetModelConfig() error = %v", err) @@ -94,17 +94,15 @@ func TestGetModelConfig_Concurrent(t *testing.T) { var wg sync.WaitGroup errors := make(chan error, goroutines*iterations) - for i := 0; i < goroutines; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for j := 0; j < iterations; j++ { + for range goroutines { + wg.Go(func() { + for range iterations { _, err := cfg.GetModelConfig("concurrent-model") if err != nil { errors <- err } } - }() + }) } wg.Wait() diff --git a/pkg/health/server.go b/pkg/health/server.go index 77b36034d..d1acfb662 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "net/http" "sync" "time" @@ -122,9 +123,7 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { s.mu.RLock() ready := s.ready checks := make(map[string]Check) - for k, v := range s.checks { - checks[k] = v - } + maps.Copy(checks, s.checks) s.mu.RUnlock() if !ready { diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 9162174c9..1bb15f771 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -212,14 +212,14 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { } func parseResponse(resp *anthropic.Message) *LLMResponse { - var content string + var content strings.Builder var toolCalls []ToolCall for _, block := range resp.Content { switch block.Type { case "text": tb := block.AsText() - content += tb.Text + content.WriteString(tb.Text) case "tool_use": tu := block.AsToolUse() var args map[string]any @@ -246,7 +246,7 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { } return &LLMResponse{ - Content: content, + Content: content.String(), ToolCalls: toolCalls, FinishReason: finishReason, Usage: &UsageInfo{ @@ -264,8 +264,8 @@ func normalizeBaseURL(apiBase string) string { } base = strings.TrimRight(base, "/") - if strings.HasSuffix(base, "/v1") { - base = strings.TrimSuffix(base, "/v1") + if before, ok := strings.CutSuffix(base, "/v1"); ok { + base = before } if base == "" { return defaultBaseURL diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index dcc740ba4..47618300a 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -163,8 +163,8 @@ func resolveCodexModel(model string) (string, string) { return codexDefaultModel, "empty model" } - if strings.HasPrefix(m, "openai/") { - m = strings.TrimPrefix(m, "openai/") + if after, ok := strings.CutPrefix(m, "openai/"); ok { + m = after } else if strings.Contains(m, "/") { return codexDefaultModel, "non-openai model namespace" } diff --git a/pkg/providers/cooldown_test.go b/pkg/providers/cooldown_test.go index 47f43ad5c..b517e7feb 100644 --- a/pkg/providers/cooldown_test.go +++ b/pkg/providers/cooldown_test.go @@ -138,7 +138,7 @@ func TestCooldown_FailureWindowReset(t *testing.T) { ct, current := newTestTracker(now) // 4 errors → 1h cooldown - for i := 0; i < 4; i++ { + for range 4 { ct.MarkFailure("openai", FailoverRateLimit) *current = current.Add(2 * time.Second) // small advance between errors } @@ -230,7 +230,7 @@ func TestCooldown_ConcurrentAccess(t *testing.T) { ct := NewCooldownTracker() var wg sync.WaitGroup - for i := 0; i < 100; i++ { + for range 100 { wg.Add(3) go func() { defer wg.Done() diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 7dace71f2..cd606d533 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -307,8 +307,8 @@ func stripSystemParts(messages []Message) []openaiMessage { } func normalizeModel(model, apiBase string) string { - idx := strings.Index(model, "/") - if idx == -1 { + before, after, ok := strings.Cut(model, "/") + if !ok { return model } @@ -316,10 +316,10 @@ func normalizeModel(model, apiBase string) string { return model } - prefix := strings.ToLower(model[:idx]) + prefix := strings.ToLower(before) switch prefix { case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral": - return model[idx+1:] + return after default: return model } diff --git a/pkg/routing/agent_id_test.go b/pkg/routing/agent_id_test.go index 050fe0645..ea3b8b6aa 100644 --- a/pkg/routing/agent_id_test.go +++ b/pkg/routing/agent_id_test.go @@ -1,5 +1,7 @@ package routing +import "strings" + import "testing" func TestNormalizeAgentID_Empty(t *testing.T) { @@ -57,11 +59,11 @@ func TestNormalizeAgentID_AllInvalid(t *testing.T) { } func TestNormalizeAgentID_TruncatesAt64(t *testing.T) { - long := "" - for i := 0; i < 100; i++ { - long += "a" + var long strings.Builder + for range 100 { + long.WriteString("a") } - got := NormalizeAgentID(long) + got := NormalizeAgentID(long.String()) if len(got) > MaxAgentIDLength { t.Errorf("length = %d, want <= %d", len(got), MaxAgentIDLength) } diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index 67d3e70e0..fcbcf934b 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -240,7 +240,7 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { normalized := strings.ReplaceAll(content, "\r\n", "\n") normalized = strings.ReplaceAll(normalized, "\r", "\n") - for _, line := range strings.Split(normalized, "\n") { + for line := range strings.SplitSeq(normalized, "\n") { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue diff --git a/pkg/skills/search_cache.go b/pkg/skills/search_cache.go index 5d7d2797e..1686e3f98 100644 --- a/pkg/skills/search_cache.go +++ b/pkg/skills/search_cache.go @@ -1,7 +1,7 @@ package skills import ( - "sort" + "slices" "strings" "sync" "time" @@ -183,7 +183,7 @@ func buildTrigrams(s string) []uint32 { } // Sort and Deduplication - sort.Slice(trigrams, func(i, j int) bool { return trigrams[i] < trigrams[j] }) + slices.Sort(trigrams) n := 1 for i := 1; i < len(trigrams); i++ { if trigrams[i] != trigrams[i-1] { diff --git a/pkg/skills/search_cache_test.go b/pkg/skills/search_cache_test.go index 816bdfb93..6bbb0e6eb 100644 --- a/pkg/skills/search_cache_test.go +++ b/pkg/skills/search_cache_test.go @@ -153,7 +153,7 @@ func TestSearchCacheConcurrency(t *testing.T) { // Concurrent writes go func() { - for i := 0; i < 100; i++ { + for i := range 100 { cache.Put("query-write-"+string(rune('a'+i%26)), []SearchResult{{Slug: "x"}}) } done <- struct{}{} @@ -161,7 +161,7 @@ func TestSearchCacheConcurrency(t *testing.T) { // Concurrent reads go func() { - for i := 0; i < 100; i++ { + for range 100 { cache.Get("query-write-a") } done <- struct{}{} diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index f717a5bb4..70117ad61 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -135,7 +135,7 @@ func TestConcurrentAccess(t *testing.T) { // Test concurrent writes done := make(chan bool, 10) - for i := 0; i < 10; i++ { + for i := range 10 { go func(idx int) { channel := fmt.Sprintf("channel-%d", idx) sm.SetLastChannel(channel) @@ -144,7 +144,7 @@ func TestConcurrentAccess(t *testing.T) { } // Wait for all goroutines to complete - for i := 0; i < 10; i++ { + for range 10 { <-done } diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 562fffc84..3140e5e25 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -3,6 +3,7 @@ package tools import ( "context" "fmt" + "strings" "sync" "time" @@ -218,7 +219,8 @@ func (t *CronTool) listJobs() *ToolResult { return SilentResult("No scheduled jobs") } - result := "Scheduled jobs:\n" + var result strings.Builder + result.WriteString("Scheduled jobs:\n") for _, j := range jobs { var scheduleInfo string if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil { @@ -230,10 +232,10 @@ func (t *CronTool) listJobs() *ToolResult { } else { scheduleInfo = "unknown" } - result += fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo) + result.WriteString(fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)) } - return SilentResult(result) + return SilentResult(result.String()) } func (t *CronTool) removeJob(args map[string]any) *ToolResult { diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index 8ae13b20c..8fe88ca78 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -329,7 +329,7 @@ func TestToolRegistry_ConcurrentAccess(t *testing.T) { r := NewToolRegistry() var wg sync.WaitGroup - for i := 0; i < 50; i++ { + for i := range 50 { wg.Add(1) go func(n int) { defer wg.Done() diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 8ba2a723a..de0c991ff 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -285,7 +285,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query maxItems := min(len(matches), count) - for i := 0; i < maxItems; i++ { + for i := range maxItems { urlStr := matches[i][1] title := stripTags(matches[i][2]) title = strings.TrimSpace(title) @@ -293,9 +293,9 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query // URL decoding if needed if strings.Contains(urlStr, "uddg=") { if u, err := url.QueryUnescape(urlStr); err == nil { - idx := strings.Index(u, "uddg=") - if idx != -1 { - urlStr = u[idx+5:] + _, after, ok := strings.Cut(u, "uddg=") + if ok { + urlStr = after } } } diff --git a/pkg/utils/message.go b/pkg/utils/message.go index 1d05950d9..a65506edc 100644 --- a/pkg/utils/message.go +++ b/pkg/utils/message.go @@ -13,10 +13,7 @@ func SplitMessage(content string, maxLen int) []string { var messages []string // Dynamic buffer: 10% of maxLen, but at least 50 chars if possible - codeBlockBuffer := maxLen / 10 - if codeBlockBuffer < 50 { - codeBlockBuffer = 50 - } + codeBlockBuffer := max(maxLen/10, 50) if codeBlockBuffer > maxLen/2 { codeBlockBuffer = maxLen / 2 } @@ -28,10 +25,7 @@ func SplitMessage(content string, maxLen int) []string { } // Effective split point: maxLen minus buffer, to leave room for code blocks - effectiveLimit := maxLen - codeBlockBuffer - if effectiveLimit < maxLen/2 { - effectiveLimit = maxLen / 2 - } + effectiveLimit := max(maxLen-codeBlockBuffer, maxLen/2) // Find natural split point within the effective limit msgEnd := findLastNewline(content[:effectiveLimit], 200) @@ -151,10 +145,7 @@ func findNextClosingCodeBlock(text string, startIdx int) int { // findLastNewline finds the last newline character within the last N characters // Returns the position of the newline or -1 if not found func findLastNewline(s string, searchWindow int) int { - searchStart := len(s) - searchWindow - if searchStart < 0 { - searchStart = 0 - } + searchStart := max(len(s)-searchWindow, 0) for i := len(s) - 1; i >= searchStart; i-- { if s[i] == '\n' { return i @@ -166,10 +157,7 @@ func findLastNewline(s string, searchWindow int) int { // findLastSpace finds the last space character within the last N characters // Returns the position of the space or -1 if not found func findLastSpace(s string, searchWindow int) int { - searchStart := len(s) - searchWindow - if searchStart < 0 { - searchStart = 0 - } + searchStart := max(len(s)-searchWindow, 0) for i := len(s) - 1; i >= searchStart; i-- { if s[i] == ' ' || s[i] == '\t' { return i From 9f95aad5f3cc2a57be7cd7a699ba3e554535475d Mon Sep 17 00:00:00 2001 From: Avisek Date: Thu, 26 Feb 2026 10:54:51 +0530 Subject: [PATCH 059/104] feat: Introduce LLM reasoning fields to LLM responses and enable routing reasoning output to dedicated channels. --- pkg/agent/loop.go | 40 ++++++ pkg/agent/loop_test.go | 167 ++++++++++++++++++++++++ pkg/channels/base.go | 11 ++ pkg/channels/dingtalk/dingtalk.go | 1 + pkg/channels/discord/discord.go | 1 + pkg/channels/feishu/feishu_64.go | 1 + pkg/channels/line/line.go | 1 + pkg/channels/maixcam/maixcam.go | 8 +- pkg/channels/onebot/onebot.go | 1 + pkg/channels/qq/qq.go | 1 + pkg/channels/slack/slack.go | 1 + pkg/channels/telegram/telegram.go | 1 + pkg/channels/wecom/app.go | 1 + pkg/channels/wecom/bot.go | 1 + pkg/channels/whatsapp/whatsapp.go | 9 +- pkg/config/config.go | 147 +++++++++++---------- pkg/providers/openai_compat/provider.go | 9 +- pkg/providers/protocoltypes/types.go | 19 ++- 18 files changed, 345 insertions(+), 75 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 7a4e9077f..ad2611365 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -523,6 +523,34 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt return finalContent, nil } +func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) { + if al.channelManager == nil { + return "" + } + if ch, ok := al.channelManager.GetChannel(channelName); ok { + return ch.ReasoningChannelID() + } + return "" +} + +func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) { + if reasoningContent == "" || channelName == "" || channelID == "" { + return + } + + // Check context cancellation before attempting to publish, + // since PublishOutbound's select may race between send and ctx.Done(). + if ctx.Err() != nil { + return + } + + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: channelName, + ChatID: channelID, + Content: reasoningContent, + }) +} + // runLLMIteration executes the LLM call loop with tool handling. func (al *AgentLoop) runLLMIteration( ctx context.Context, @@ -649,6 +677,18 @@ func (al *AgentLoop) runLLMIteration( return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } + go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) + + logger.DebugCF("agent", "LLM response", + map[string]any{ + "agent_id": agent.ID, + "iteration": iteration, + "content_chars": len(response.Content), + "tool_calls": len(response.ToolCalls), + "reasoning": response.Reasoning, + "target_channel": al.targetReasoningChannelID(opts.Channel), + "channel": opts.Channel, + }) // Check if no tool calls - we're done if len(response.ToolCalls) == 0 { finalContent = response.Content diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 4414398b1..6dfc7ef3e 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -9,11 +9,23 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/tools" ) +type fakeChannel struct{ id string } + +func (f *fakeChannel) Name() string { return "fake" } +func (f *fakeChannel) Start(ctx context.Context) error { return nil } +func (f *fakeChannel) Stop(ctx context.Context) error { return nil } +func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } +func (f *fakeChannel) IsRunning() bool { return true } +func (f *fakeChannel) IsAllowed(string) bool { return true } +func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } +func (f *fakeChannel) ReasoningChannelID() string { return f.id } + func TestRecordLastChannel(t *testing.T) { // Create temp workspace tmpDir, err := os.MkdirTemp("", "agent-test-*") @@ -631,3 +643,158 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) } } + +func TestTargetReasoningChannelID_AllChannels(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) + chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil) + if err != nil { + t.Fatalf("Failed to create channel manager: %v", err) + } + for name, id := range map[string]string{ + "whatsapp": "rid-whatsapp", + "telegram": "rid-telegram", + "feishu": "rid-feishu", + "discord": "rid-discord", + "maixcam": "rid-maixcam", + "qq": "rid-qq", + "dingtalk": "rid-dingtalk", + "slack": "rid-slack", + "line": "rid-line", + "onebot": "rid-onebot", + "wecom": "rid-wecom", + "wecom_app": "rid-wecom-app", + } { + chManager.RegisterChannel(name, &fakeChannel{id: id}) + } + al.SetChannelManager(chManager) + tests := []struct { + channel string + wantID string + }{ + {channel: "whatsapp", wantID: "rid-whatsapp"}, + {channel: "telegram", wantID: "rid-telegram"}, + {channel: "feishu", wantID: "rid-feishu"}, + {channel: "discord", wantID: "rid-discord"}, + {channel: "maixcam", wantID: "rid-maixcam"}, + {channel: "qq", wantID: "rid-qq"}, + {channel: "dingtalk", wantID: "rid-dingtalk"}, + {channel: "slack", wantID: "rid-slack"}, + {channel: "line", wantID: "rid-line"}, + {channel: "onebot", wantID: "rid-onebot"}, + {channel: "wecom", wantID: "rid-wecom"}, + {channel: "wecom_app", wantID: "rid-wecom-app"}, + {channel: "unknown", wantID: ""}, + } + + for _, tt := range tests { + t.Run(tt.channel, func(t *testing.T) { + got := al.targetReasoningChannelID(tt.channel) + if got != tt.wantID { + t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID) + } + }) + } +} + +func TestHandleReasoning(t *testing.T) { + newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) { + t.Helper() + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + msgBus := bus.NewMessageBus() + return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus + } + + t.Run("skips when any required field is empty", func(t *testing.T) { + al, msgBus := newLoop(t) + al.handleReasoning(context.Background(), "reasoning", "telegram", "") + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if msg, ok := msgBus.SubscribeOutbound(ctx); ok { + t.Fatalf("expected no outbound message, got %+v", msg) + } + }) + + t.Run("publishes one message for non telegram", func(t *testing.T) { + al, msgBus := newLoop(t) + al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1") + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + msg, ok := msgBus.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected an outbound message") + } + if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" { + t.Fatalf("unexpected outbound message: %+v", msg) + } + }) + + t.Run("publishes one message for telegram", func(t *testing.T) { + al, msgBus := newLoop(t) + reasoning := "hello telegram reasoning" + al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat") + + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + msg, ok := msgBus.SubscribeOutbound(ctx) + if !ok { + t.Fatal("expected outbound message") + } + + if msg.Channel != "telegram" { + t.Fatalf("expected telegram channel message, got %+v", msg) + } + if msg.ChatID != "tg-chat" { + t.Fatalf("expected chatID tg-chat, got %+v", msg) + } + if msg.Content != reasoning { + t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning) + } + }) + t.Run("expired ctx", func(t *testing.T) { + al, msgBus := newLoop(t) + reasoning := "hello telegram reasoning" + ctx, cancel := context.WithCancel(context.Background()) + cancel() + al.handleReasoning(ctx, reasoning, "telegram", "tg-chat") + + ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + msg, ok := msgBus.SubscribeOutbound(ctx) + if ok { + t.Fatalf("expected no outbound message, got %+v", msg) + } + }) +} diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 2ba450291..063a66523 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -48,6 +48,7 @@ type Channel interface { IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool + ReasoningChannelID() string } // BaseChannelOption is a functional option for configuring a BaseChannel. @@ -65,6 +66,11 @@ func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption { return func(c *BaseChannel) { c.groupTrigger = gt } } +// WithReasoningChannelID sets the reasoning channel ID where thoughts should be sent. +func WithReasoningChannelID(id string) BaseChannelOption { + return func(c *BaseChannel) { c.reasoningChannelID = id } +} + // MessageLengthProvider is an opt-in interface that channels implement // to advertise their maximum message length. The Manager uses this via // type assertion to decide whether to split outbound messages. @@ -83,6 +89,7 @@ type BaseChannel struct { mediaStore media.MediaStore placeholderRecorder PlaceholderRecorder owner Channel // the concrete channel that embeds this BaseChannel + reasoningChannelID string } func NewBaseChannel( @@ -154,6 +161,10 @@ func (c *BaseChannel) Name() string { return c.name } +func (c *BaseChannel) ReasoningChannelID() string { + return c.reasoningChannelID +} + func (c *BaseChannel) IsRunning() bool { return c.running.Load() } diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 7a3aaca78..8642ad362 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -42,6 +42,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(20000), channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &DingTalkChannel{ diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 46fbaecfb..cd6a2560f 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -43,6 +43,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(2000), channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &DiscordChannel{ diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 62bf69486..1db1bf669 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -35,6 +35,7 @@ type FeishuChannel struct { func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom, channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &FeishuChannel{ diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 25c29c217..9fac2831c 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -63,6 +63,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(5000), channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &LINEChannel{ diff --git a/pkg/channels/maixcam/maixcam.go b/pkg/channels/maixcam/maixcam.go index 142a4b7e7..ff9a3ed1a 100644 --- a/pkg/channels/maixcam/maixcam.go +++ b/pkg/channels/maixcam/maixcam.go @@ -33,7 +33,13 @@ type MaixCamMessage struct { } func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { - base := channels.NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom) + base := channels.NewBaseChannel( + "maixcam", + cfg, + bus, + cfg.AllowFrom, + channels.WithReasoningChannelID(cfg.ReasoningChannelID), + ) return &MaixCamChannel{ BaseChannel: base, diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 7666c039f..89cba4ae0 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -99,6 +99,7 @@ type oneBotMessageSegment struct { func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom, channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) const dedupSize = 1024 diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 1e2cc2354..112964143 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -35,6 +35,7 @@ type QQChannel struct { func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &QQChannel{ diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 488eb1296..024b1b023 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -51,6 +51,7 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(40000), channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &SlackChannel{ diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 4834c7d19..a11cf53b8 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -84,6 +84,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann telegramCfg.AllowFrom, channels.WithMaxMessageLength(4096), channels.WithGroupTrigger(telegramCfg.GroupTrigger), + channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID), ) return &TelegramChannel{ diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 073848bf3..42a74e8c9 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -126,6 +126,7 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) ( base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048), channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &WeComAppChannel{ diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 460997dab..4c576b84b 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -90,6 +90,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(2048), channels.WithGroupTrigger(cfg.GroupTrigger), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &WeComBotChannel{ diff --git a/pkg/channels/whatsapp/whatsapp.go b/pkg/channels/whatsapp/whatsapp.go index 76c60b8c7..70b3e02bf 100644 --- a/pkg/channels/whatsapp/whatsapp.go +++ b/pkg/channels/whatsapp/whatsapp.go @@ -29,7 +29,14 @@ type WhatsAppChannel struct { } func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { - base := channels.NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) + base := channels.NewBaseChannel( + "whatsapp", + cfg, + bus, + cfg.AllowFrom, + channels.WithMaxMessageLength(65536), + channels.WithReasoningChannelID(cfg.ReasoningChannelID), + ) return &WhatsAppChannel{ BaseChannel: base, diff --git a/pkg/config/config.go b/pkg/config/config.go index bdd4d8823..dae213bdb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -223,71 +223,80 @@ type PlaceholderConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` } type FeishuConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` - EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` - VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` + EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` + VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` } type DiscordConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` - MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` - Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` + MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"` } type MaixCamConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` - Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` - Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` + Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` + Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"` } type QQConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` - AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` - AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` + AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` + AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } type DingTalkConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` - ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` - ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` + ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` + ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"` } type SlackConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` - BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` - AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` - Typing TypingConfig `json:"typing,omitempty"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` + BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` + AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` } type LINEConfig struct { @@ -300,6 +309,8 @@ type LINEConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"` } type OneBotConfig struct { @@ -311,34 +322,38 @@ type OneBotConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"` } type WeComConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` - WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` + WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"` } type WeComAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` - CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` - CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` - AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` - EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` - WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` - WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` - WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` - ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` - GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` + CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` + CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` + AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` + EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` + WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` + WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` + WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` + ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` + GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"` } type PicoConfig struct { diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 7dace71f2..5dab9b03e 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -25,6 +25,7 @@ type ( ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ExtraContent = protocoltypes.ExtraContent GoogleExtra = protocoltypes.GoogleExtra + ReasoningDetail = protocoltypes.ReasoningDetail ) type Provider struct { @@ -198,8 +199,10 @@ func parseResponse(body []byte) (*LLMResponse, error) { var apiResponse struct { Choices []struct { Message struct { - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content"` + Reasoning string `json:"reasoning"` + ReasoningDetails []ReasoningDetail `json:"reasoning_details"` ToolCalls []struct { ID string `json:"id"` Type string `json:"type"` @@ -274,6 +277,8 @@ func parseResponse(body []byte) (*LLMResponse, error) { return &LLMResponse{ Content: choice.Message.Content, ReasoningContent: choice.Message.ReasoningContent, + Reasoning: choice.Message.Reasoning, + ReasoningDetails: choice.Message.ReasoningDetails, ToolCalls: toolCalls, FinishReason: choice.FinishReason, Usage: apiResponse.Usage, diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go index 33f052c5a..99f13334e 100644 --- a/pkg/providers/protocoltypes/types.go +++ b/pkg/providers/protocoltypes/types.go @@ -25,11 +25,20 @@ type FunctionCall struct { } type LLMResponse struct { - Content string `json:"content"` - ReasoningContent string `json:"reasoning_content,omitempty"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - Usage *UsageInfo `json:"usage,omitempty"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + Usage *UsageInfo `json:"usage,omitempty"` + Reasoning string `json:"reasoning"` + ReasoningDetails []ReasoningDetail `json:"reasoning_details"` +} + +type ReasoningDetail struct { + Format string `json:"format"` + Index int `json:"index"` + Type string `json:"type"` + Text string `json:"text"` } type UsageInfo struct { From f96cf3f8cc2792bc2f8b55c231ae00304f821b98 Mon Sep 17 00:00:00 2001 From: Avisek Date: Thu, 26 Feb 2026 11:33:01 +0530 Subject: [PATCH 060/104] feat: Add `reasoning_channel_id` to communication platform configurations and improve message bus context cancellation handling. --- config/config.example.json | 38 +++++++++++++++++++++++++------------- pkg/bus/bus.go | 9 +++++++++ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 9575039f8..a5bb96774 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -52,30 +52,35 @@ "proxy": "", "allow_from": [ "YOUR_USER_ID" - ] + ], + "reasoning_channel_id": "" }, "discord": { "enabled": false, "token": "YOUR_DISCORD_BOT_TOKEN", "allow_from": [], - "mention_only": false + "mention_only": false, + "reasoning_channel_id": "" }, "qq": { "enabled": false, "app_id": "YOUR_QQ_APP_ID", "app_secret": "YOUR_QQ_APP_SECRET", - "allow_from": [] + "allow_from": [], + "reasoning_channel_id": "" }, "maixcam": { "enabled": false, "host": "0.0.0.0", "port": 18790, - "allow_from": [] + "allow_from": [], + "reasoning_channel_id": "" }, "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", - "allow_from": [] + "allow_from": [], + "reasoning_channel_id": "" }, "feishu": { "enabled": false, @@ -83,19 +88,22 @@ "app_secret": "", "encrypt_key": "", "verification_token": "", - "allow_from": [] + "allow_from": [], + "reasoning_channel_id": "" }, "dingtalk": { "enabled": false, "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET", - "allow_from": [] + "allow_from": [], + "reasoning_channel_id": "" }, "slack": { "enabled": false, "bot_token": "xoxb-YOUR-BOT-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN", - "allow_from": [] + "allow_from": [], + "reasoning_channel_id": "" }, "line": { "enabled": false, @@ -104,7 +112,8 @@ "webhook_host": "0.0.0.0", "webhook_port": 18791, "webhook_path": "/webhook/line", - "allow_from": [] + "allow_from": [], + "reasoning_channel_id": "" }, "onebot": { "enabled": false, @@ -112,7 +121,8 @@ "access_token": "", "reconnect_interval": 5, "group_trigger_prefix": [], - "allow_from": [] + "allow_from": [], + "reasoning_channel_id": "" }, "wecom": { "_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats", @@ -124,7 +134,8 @@ "webhook_port": 18793, "webhook_path": "/webhook/wecom", "allow_from": [], - "reply_timeout": 5 + "reply_timeout": 5, + "reasoning_channel_id": "" }, "wecom_app": { "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md", @@ -138,7 +149,8 @@ "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", "allow_from": [], - "reply_timeout": 5 + "reply_timeout": 5, + "reasoning_channel_id": "" } }, "providers": { @@ -251,4 +263,4 @@ "host": "127.0.0.1", "port": 18790 } -} +} \ No newline at end of file diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go index f2bf0c379..f5ff9587d 100644 --- a/pkg/bus/bus.go +++ b/pkg/bus/bus.go @@ -34,6 +34,9 @@ func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) er if mb.closed.Load() { return ErrBusClosed } + if err := ctx.Err(); err != nil { + return err + } select { case mb.inbound <- msg: return nil @@ -59,6 +62,9 @@ func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) if mb.closed.Load() { return ErrBusClosed } + if err := ctx.Err(); err != nil { + return err + } select { case mb.outbound <- msg: return nil @@ -84,6 +90,9 @@ func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMedi if mb.closed.Load() { return ErrBusClosed } + if err := ctx.Err(); err != nil { + return err + } select { case mb.outboundMedia <- msg: return nil From d429dcdd7627f23f0c828be0f6c3db188d3e0d20 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 27 Feb 2026 17:35:50 +0800 Subject: [PATCH 061/104] chore: fix go fmt formatting issues after rebase Apply go fmt to files that had formatting inconsistencies (alignment, indentation) after rebasing onto refactor/channel-system. --- pkg/agent/loop.go | 12 ++++++---- .../whatsapp_native/whatsapp_native.go | 23 ++++++++++++------- .../whatsapp_native/whatsapp_native_stub.go | 6 ++++- pkg/config/config.go | 4 ++-- pkg/utils/string.go | 6 ++--- 5 files changed, 33 insertions(+), 18 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index aacc28093..f9dead544 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -211,12 +211,16 @@ func (al *AgentLoop) Run(ctx context.Context) error { }) logger.InfoCF("agent", "Published outbound response", map[string]any{ - "channel": msg.Channel, - "chat_id": msg.ChatID, + "channel": msg.Channel, + "chat_id": msg.ChatID, "content_len": len(response), }) } else { - logger.DebugCF("agent", "Skipped outbound (message tool already sent)", map[string]any{"channel": msg.Channel}) + logger.DebugCF( + "agent", + "Skipped outbound (message tool already sent)", + map[string]any{"channel": msg.Channel}, + ) } } }() @@ -395,7 +399,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "agent_id": agent.ID, "session_key": sessionKey, "matched_by": route.MatchedBy, - }) + }) return al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 254f15918..23115bda7 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -18,15 +18,14 @@ import ( "time" "github.com/mdp/qrterminal/v3" - _ "modernc.org/sqlite" - "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" waLog "go.mau.fi/whatsmeow/util/log" - "go.mau.fi/whatsmeow/proto/waE2E" - "go.mau.fi/whatsmeow/types" "google.golang.org/protobuf/proto" + _ "modernc.org/sqlite" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -61,7 +60,11 @@ type WhatsAppNativeChannel struct { // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. // storePath is the directory for the SQLite session store (e.g. workspace/whatsapp). -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) { +func NewWhatsAppNativeChannel( + cfg config.WhatsAppConfig, + bus *bus.MessageBus, + storePath string, +) (channels.Channel, error) { base := channels.NewBaseChannel("whatsapp_native", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) if storePath == "" { storePath = "whatsapp" @@ -77,7 +80,7 @@ func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, st func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) - if err := os.MkdirAll(c.storePath, 0700); err != nil { + if err := os.MkdirAll(c.storePath, 0o700); err != nil { return fmt.Errorf("create session store dir: %w", err) } @@ -173,7 +176,7 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { return nil } -func (c *WhatsAppNativeChannel) eventHandler(evt interface{}) { +func (c *WhatsAppNativeChannel) eventHandler(evt any) { switch evt.(type) { case *events.Message: c.handleIncoming(evt.(*events.Message)) @@ -284,7 +287,11 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) { return } - logger.DebugCF("whatsapp", "WhatsApp message received", map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}) + logger.DebugCF( + "whatsapp", + "WhatsApp message received", + map[string]any{"sender_id": senderID, "content_preview": utils.Truncate(content, 50)}, + ) c.HandleMessage(c.runCtx, peer, messageID, senderID, chatID, content, mediaPaths, metadata, sender) } diff --git a/pkg/channels/whatsapp_native/whatsapp_native_stub.go b/pkg/channels/whatsapp_native/whatsapp_native_stub.go index d57a3b6e7..984af23e7 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native_stub.go +++ b/pkg/channels/whatsapp_native/whatsapp_native_stub.go @@ -12,6 +12,10 @@ import ( // NewWhatsAppNativeChannel returns an error when the binary was not built with -tags whatsapp_native. // Build with: go build -tags whatsapp_native ./cmd/... -func NewWhatsAppNativeChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus, storePath string) (channels.Channel, error) { +func NewWhatsAppNativeChannel( + cfg config.WhatsAppConfig, + bus *bus.MessageBus, + storePath string, +) (channels.Channel, error) { return nil, fmt.Errorf("whatsapp native not compiled in; build with -tags whatsapp_native") } diff --git a/pkg/config/config.go b/pkg/config/config.go index 9a6fda58a..01511864b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -224,9 +224,9 @@ type PlaceholderConfig struct { type WhatsAppConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` - SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` } diff --git a/pkg/utils/string.go b/pkg/utils/string.go index edc413972..6a78f59fe 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -5,8 +5,8 @@ import ( "unicode" ) -// SanitizeMessage removes Unicode control characters, format characters (RTL overrides, -// zero-width characters), and other non-graphic characters that could confuse an LLM +// SanitizeMessage removes Unicode control characters, format characters (RTL overrides, +// zero-width characters), and other non-graphic characters that could confuse an LLM // or cause display issues in the agent UI. func SanitizeMessageContent(input string) string { var sb strings.Builder @@ -16,7 +16,7 @@ func SanitizeMessageContent(input string) string { for _, r := range input { // unicode.IsGraphic returns true if the rune is a Unicode graphic character. // This includes letters, marks, numbers, punctuation, and symbols. - // It excludes control characters (Cc), format characters (Cf), + // It excludes control characters (Cc), format characters (Cf), // surrogates (Cs), and private use (Co). if unicode.IsGraphic(r) || r == '\n' || r == '\r' || r == '\t' { sb.WriteRune(r) From 7276a2d651cd62806336d84d0c20e7680c2aa2b0 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Fri, 27 Feb 2026 20:15:14 +0800 Subject: [PATCH 062/104] Fix lint errors --- pkg/config/config.go | 10 +++++----- pkg/utils/string.go | 2 +- pkg/utils/string_test.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 46ffa2ecf..2e0215278 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -224,11 +224,11 @@ type PlaceholderConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` - SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` + SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 6a78f59fe..02f346db4 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -5,7 +5,7 @@ import ( "unicode" ) -// SanitizeMessage removes Unicode control characters, format characters (RTL overrides, +// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides, // zero-width characters), and other non-graphic characters that could confuse an LLM // or cause display issues in the agent UI. func SanitizeMessageContent(input string) string { diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index fffa0cff3..e3b5af052 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -117,7 +117,7 @@ func TestSanitizeMessageContent(t *testing.T) { {"strip RTL override", "Hi\u202eevil", "Hievil"}, {"strip BOM", "\uFEFFcontent", "content"}, {"strip multiple", "a\u200c\u202ab\u202cc", "abc"}, - {"unicode letters preserved", "café 日本語", "café 日本語"}, + {"unicode letters preserved", "café \u65e5\u672c\u8a9e", "café \u65e5\u672c\u8a9e"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 6fcc80bf44119d990200ad2d1728b6e09fb8d726 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:20:00 +0800 Subject: [PATCH 063/104] Reformat WhatsAppConfig struct fields alignment --- pkg/config/config.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 46ffa2ecf..a774aadd3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -224,11 +224,11 @@ type PlaceholderConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` - SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` + SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } From 3ad937f05bda61668a8db5e10e524e2dce49ce5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:20:25 +0800 Subject: [PATCH 064/104] Update function comment for SanitizeMessageContent --- pkg/utils/string.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 6a78f59fe..02f346db4 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -5,7 +5,7 @@ import ( "unicode" ) -// SanitizeMessage removes Unicode control characters, format characters (RTL overrides, +// SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides, // zero-width characters), and other non-graphic characters that could confuse an LLM // or cause display issues in the agent UI. func SanitizeMessageContent(input string) string { From 67e1dab4089685f5717feb13c2ffe68671e0e3b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:20:58 +0800 Subject: [PATCH 065/104] Update test case for unicode letters preservation --- pkg/utils/string_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index fffa0cff3..e3b5af052 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -117,7 +117,7 @@ func TestSanitizeMessageContent(t *testing.T) { {"strip RTL override", "Hi\u202eevil", "Hievil"}, {"strip BOM", "\uFEFFcontent", "content"}, {"strip multiple", "a\u200c\u202ab\u202cc", "abc"}, - {"unicode letters preserved", "café 日本語", "café 日本語"}, + {"unicode letters preserved", "café \u65e5\u672c\u8a9e", "café \u65e5\u672c\u8a9e"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 6b427afa4433cbed53643beff2665c2736996b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:22:40 +0800 Subject: [PATCH 066/104] Remove ignored files from .gitignore --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 06a8fce77..3ff195fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,3 @@ tasks/ # Added by goreleaser init: dist/ -akalro-dietpi.pub -akalro-dietpi -.gitignore From 75a86ebe036373a6a1f5c7dfe29e474c519c9ce3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:23:20 +0800 Subject: [PATCH 067/104] Resolve merge conflict in config.example.json Removed conflicting lines and kept 'allow_from' as an empty array. --- config/config.example.json | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index f9c12f61b..55a823009 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -79,14 +79,10 @@ "whatsapp": { "enabled": false, "bridge_url": "ws://localhost:3001", -<<<<<<< main "use_native": false, "session_store_path": "", - "allow_from": [] -======= "allow_from": [], "reasoning_channel_id": "" ->>>>>>> refactor/channel-system }, "feishu": { "enabled": false, @@ -269,4 +265,4 @@ "host": "127.0.0.1", "port": 18790 } -} \ No newline at end of file +} From f6c275f70c1db95daa3a0e3543d89dc2a426f5dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Fri, 27 Feb 2026 20:25:02 +0800 Subject: [PATCH 068/104] Fix formatting of WhatsAppConfig struct fields --- pkg/config/config.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index a774aadd3..2e0215278 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -224,11 +224,11 @@ type PlaceholderConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` - BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` - UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` - SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` + BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` + UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` + SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } From f2a71ca8247c66d789fda19a29a52f3da91ba292 Mon Sep 17 00:00:00 2001 From: Petrichor Date: Fri, 27 Feb 2026 21:20:56 +0800 Subject: [PATCH 069/104] fix(lint): format imports in agent_id_test --- pkg/routing/agent_id_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pkg/routing/agent_id_test.go b/pkg/routing/agent_id_test.go index ea3b8b6aa..f9a65c969 100644 --- a/pkg/routing/agent_id_test.go +++ b/pkg/routing/agent_id_test.go @@ -1,8 +1,9 @@ package routing -import "strings" - -import "testing" +import ( + "strings" + "testing" +) func TestNormalizeAgentID_Empty(t *testing.T) { if got := NormalizeAgentID(""); got != DefaultAgentID { From 1161aee8729bf44659dc70fc1060869b7c4c1208 Mon Sep 17 00:00:00 2001 From: mosir Date: Sat, 28 Feb 2026 00:18:10 +0800 Subject: [PATCH 070/104] fix(config): keep empty agents.defaults.model in saved config --- pkg/config/config.go | 2 +- pkg/config/config_test.go | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 2e0215278..d84772d2b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -172,7 +172,7 @@ type AgentDefaults struct { RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` - Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead + Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead ModelFallbacks []string `json:"model_fallbacks,omitempty"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 45cdd8ec8..12fd10b50 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" ) @@ -324,6 +325,25 @@ func TestSaveConfig_FilePermissions(t *testing.T) { } } +func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "config.json") + + cfg := DefaultConfig() + if err := SaveConfig(path, cfg); err != nil { + t.Fatalf("SaveConfig failed: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile failed: %v", err) + } + + if !strings.Contains(string(data), `"model": ""`) { + t.Fatalf("saved config should include empty legacy model field, got: %s", string(data)) + } +} + // TestConfig_Complete verifies all config fields are set func TestConfig_Complete(t *testing.T) { cfg := DefaultConfig() From ebfa72a286cc83ebf706fb2b90ffd4f64a2d3475 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Sat, 28 Feb 2026 00:36:39 +0800 Subject: [PATCH 071/104] Update config file reference from config.yaml to config.json Closes: #565 --- pkg/channels/telegram/telegram_commands.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/channels/telegram/telegram_commands.go b/pkg/channels/telegram/telegram_commands.go index ee3bfef51..496fc5e4f 100644 --- a/pkg/channels/telegram/telegram_commands.go +++ b/pkg/channels/telegram/telegram_commands.go @@ -119,7 +119,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error { if provider == "" { provider = "configured default" } - response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", + response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.json", c.config.Agents.Defaults.GetModelName(), provider) case "channels": From c7d75a18f89f443f5bf1e1a3de214680c0957773 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 01:39:06 +0800 Subject: [PATCH 072/104] fix(whatsapp_native): fix goroutine and resource leak in Start/Stop lifecycle - Move runCtx/runCancel creation before event handler registration and QR loop so Stop() can cancel at any point during startup - Replace blocking QR event loop in Start() with a background goroutine that selects on runCtx.Done(), preventing Start() from hanging indefinitely when waiting for QR scan - Track all background goroutines (QR handler, reconnect) with sync.WaitGroup; Stop() waits for them to finish before releasing client/container resources - Cancel runCtx on error paths in Start() to avoid leaked contexts Fixes resource leak introduced in #655. --- .../whatsapp_native/whatsapp_native.go | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 23115bda7..1ac3c4c9c 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -56,6 +56,7 @@ type WhatsAppNativeChannel struct { runCancel context.CancelFunc reconnectMu sync.Mutex reconnecting bool + wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect) } // NewWhatsAppNativeChannel creates a WhatsApp channel that uses whatsmeow for connection. @@ -112,6 +113,12 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { } client := whatsmeow.NewClient(deviceStore, waLogger) + + // Create runCtx/runCancel BEFORE registering event handler and starting + // goroutines so that Stop() can cancel them at any time, including during + // the QR-login flow. + c.runCtx, c.runCancel = context.WithCancel(ctx) + client.AddEventHandler(c.eventHandler) c.mu.Lock() @@ -122,33 +129,50 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { if client.Store.ID == nil { qrChan, err := client.GetQRChannel(ctx) if err != nil { + c.runCancel() _ = container.Close() return fmt.Errorf("get QR channel: %w", err) } if err := client.Connect(); err != nil { + c.runCancel() _ = container.Close() return fmt.Errorf("connect: %w", err) } - for evt := range qrChan { - if evt.Event == "code" { - logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil) - qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ - Level: qrterminal.L, - Writer: os.Stdout, - HalfBlocks: true, - }) - } else { - logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event}) + // Handle QR events in a background goroutine so Start() returns + // promptly. The goroutine is tracked via c.wg and respects + // c.runCtx for cancellation. + c.wg.Add(1) + go func() { + defer c.wg.Done() + for { + select { + case <-c.runCtx.Done(): + return + case evt, ok := <-qrChan: + if !ok { + return + } + if evt.Event == "code" { + logger.InfoCF("whatsapp", "Scan this QR code with WhatsApp (Linked Devices):", nil) + qrterminal.GenerateWithConfig(evt.Code, qrterminal.Config{ + Level: qrterminal.L, + Writer: os.Stdout, + HalfBlocks: true, + }) + } else { + logger.InfoCF("whatsapp", "WhatsApp login event", map[string]any{"event": evt.Event}) + } + } } - } + }() } else { if err := client.Connect(); err != nil { + c.runCancel() _ = container.Close() return fmt.Errorf("connect: %w", err) } } - c.runCtx, c.runCancel = context.WithCancel(ctx) c.SetRunning(true) logger.InfoC("whatsapp", "WhatsApp native channel connected") return nil @@ -159,6 +183,11 @@ func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { if c.runCancel != nil { c.runCancel() } + + // Wait for background goroutines (QR handler, reconnect) to finish so + // they don't reference the client/container after cleanup. + c.wg.Wait() + c.mu.Lock() client := c.client container := c.container @@ -189,7 +218,11 @@ func (c *WhatsAppNativeChannel) eventHandler(evt any) { } c.reconnecting = true c.reconnectMu.Unlock() - go c.reconnectWithBackoff() + c.wg.Add(1) + go func() { + defer c.wg.Done() + c.reconnectWithBackoff() + }() } } From 1d0220f9fdc0af1b5c18bef6f5b79be7f4aedb48 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 01:39:17 +0800 Subject: [PATCH 073/104] fix(agent): prevent reasoning goroutine accumulation on full bus Add a 5-second timeout to handleReasoning's PublishOutbound call so fire-and-forget goroutines do not block indefinitely when the outbound bus channel is full. Reasoning output is best-effort; on timeout the publish is abandoned with a warning log instead of holding the goroutine alive. Fixes goroutine leak introduced in #802. --- pkg/agent/loop.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 29827d0b2..d7daf3775 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -574,11 +574,22 @@ func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, chan return } - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + // Use a short timeout so the goroutine does not block indefinitely when + // the outbound bus is full. Reasoning output is best-effort; dropping it + // is acceptable to avoid goroutine accumulation. + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + defer pubCancel() + + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channelName, ChatID: channelID, Content: reasoningContent, - }) + }); err != nil { + logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + } } // runLLMIteration executes the LLM call loop with tool handling. From a9a307584b02d97503368f3902781a4370a4b585 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Fri, 27 Feb 2026 18:56:02 +0100 Subject: [PATCH 074/104] fix: max payload size in web fetch --- pkg/tools/web.go | 11 ++++++++++- pkg/tools/web_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 8ba2a723a..bac05a862 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -14,7 +15,8 @@ import ( ) const ( - userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + MaxFetchLimitBytes = int64(10 * 1024 * 1024) // 10MB limit ) // Pre-compiled regexes for HTML text extraction @@ -605,10 +607,17 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe if err != nil { return ErrorResult(fmt.Sprintf("request failed: %v", err)) } + + resp.Body = http.MaxBytesReader(nil, resp.Body, MaxFetchLimitBytes) + defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return ErrorResult(fmt.Sprintf("failed to read response: size exceeded %d bytes limit", MaxFetchLimitBytes)) + } return ErrorResult(fmt.Sprintf("failed to read response: %v", err)) } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 2cd79eb24..6735d8b05 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -1,8 +1,10 @@ package tools import ( + "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -174,6 +176,46 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { } } +func TestWebFetchTool_PayloadTooLarge(t *testing.T) { + // Create a mock HTTP server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + + // Generate a payload intentionally larger than our limit. + // Limit: 10 * 1024 * 1024 (10MB). We generate 10MB + 100 bytes of the letter 'A'. + largeData := bytes.Repeat([]byte("A"), int(MaxFetchLimitBytes)+100) + + w.Write(largeData) + })) + // Ensure the server is shut down at the end of the test + defer ts.Close() + + // Initialize the tool + tool := NewWebFetchTool(50000) + + // Prepare the arguments pointing to the URL of our local mock server + args := map[string]any{ + "url": ts.URL, + } + + // Execute the tool + ctx := context.Background() + result := tool.Execute(ctx, args) + + // Assuming ErrorResult sets the ForLLM field with the error text. + if result == nil { + t.Fatal("expected a ToolResult, got nil") + } + + // Search for the exact error string we set earlier in the Execute method + expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", MaxFetchLimitBytes) + + if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) { + t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result) + } +} + // TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing func TestWebTool_WebSearch_NoApiKey(t *testing.T) { tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) From 9b80fdf885facf0d1b6067446a8fa67a18393762 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 03:05:19 +0800 Subject: [PATCH 075/104] fix(whatsapp_native,agent): address PR #884 review feedback - Use c.runCtx for GetQRChannel so the QR producer is canceled on Stop() - Add atomic stopping guard to prevent wg.Add/wg.Wait race in eventHandler - Make Stop() context-aware: disconnect client before waiting, respect ctx deadline - Reduce reasoning publish log noise: use debug level for expected ctx errors - Add test for handleReasoning when outbound bus is full (timeout path) --- pkg/agent/loop.go | 17 +++++-- pkg/agent/loop_test.go | 49 +++++++++++++++++++ .../whatsapp_native/whatsapp_native.go | 43 +++++++++++++--- 3 files changed, 98 insertions(+), 11 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d7daf3775..53f411452 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -585,10 +585,19 @@ func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, chan ChatID: channelID, Content: reasoningContent, }); err != nil { - logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ - "channel": channelName, - "error": err.Error(), - }) + // Only log unexpected errors; context deadline/cancel are expected when + // the bus is full under load and would be too noisy to warn about. + if ctx.Err() == nil { + logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + } else { + logger.DebugCF("agent", "Reasoning publish skipped (context done)", map[string]any{ + "channel": channelName, + "error": err.Error(), + }) + } } } diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 6dfc7ef3e..29293477e 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -797,4 +797,53 @@ func TestHandleReasoning(t *testing.T) { t.Fatalf("expected no outbound message, got %+v", msg) } }) + + t.Run("returns promptly when bus is full", func(t *testing.T) { + al, msgBus := newLoop(t) + + // Fill the outbound bus buffer (default size is 64) so that the + // next PublishOutbound will block until the context deadline. + for i := 0; i < 64; i++ { + err := msgBus.PublishOutbound(context.Background(), bus.OutboundMessage{ + Channel: "filler", + ChatID: "filler", + Content: fmt.Sprintf("filler-%d", i), + }) + if err != nil { + t.Fatalf("failed to fill bus: %v", err) + } + } + + // Use a short-deadline parent context to bound the test. + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + start := time.Now() + al.handleReasoning(ctx, "should timeout", "slack", "channel-full") + elapsed := time.Since(start) + + // handleReasoning uses a 5s internal timeout, but the parent ctx + // expires in 500ms. It should return within ~500ms, not 5s. + if elapsed > 2*time.Second { + t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed) + } + + // Drain the bus and verify the reasoning message was NOT published + // (it should have been dropped due to timeout). + drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer drainCancel() + foundReasoning := false + for { + msg, ok := msgBus.SubscribeOutbound(drainCtx) + if !ok { + break + } + if msg.Content == "should timeout" { + foundReasoning = true + } + } + if foundReasoning { + t.Fatal("expected reasoning message to be dropped when bus is full, but it was published") + } + }) } diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 1ac3c4c9c..c475276ba 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -15,6 +15,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "time" "github.com/mdp/qrterminal/v3" @@ -56,6 +57,7 @@ type WhatsAppNativeChannel struct { runCancel context.CancelFunc reconnectMu sync.Mutex reconnecting bool + stopping atomic.Bool // set once Stop begins; prevents new wg.Add calls wg sync.WaitGroup // tracks background goroutines (QR handler, reconnect) } @@ -127,7 +129,7 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { c.mu.Unlock() if client.Store.ID == nil { - qrChan, err := client.GetQRChannel(ctx) + qrChan, err := client.GetQRChannel(c.runCtx) if err != nil { c.runCancel() _ = container.Close() @@ -180,24 +182,47 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { logger.InfoC("whatsapp", "Stopping WhatsApp native channel") + + // Mark as stopping so no new goroutines are spawned via eventHandler. + c.stopping.Store(true) + if c.runCancel != nil { c.runCancel() } - // Wait for background goroutines (QR handler, reconnect) to finish so - // they don't reference the client/container after cleanup. - c.wg.Wait() - + // Disconnect the client first so any blocking Connect()/reconnect loops + // can be interrupted before we wait on the goroutines. c.mu.Lock() client := c.client container := c.container - c.client = nil - c.container = nil c.mu.Unlock() if client != nil { client.Disconnect() } + + // Wait for background goroutines (QR handler, reconnect) to finish in a + // context-aware way so Stop can be bounded by ctx. + done := make(chan struct{}) + go func() { + c.wg.Wait() + close(done) + }() + + select { + case <-done: + // All goroutines have finished. + case <-ctx.Done(): + // Context canceled or timed out; log and proceed with best-effort cleanup. + logger.WarnC("whatsapp", fmt.Sprintf("Stop context canceled before all goroutines finished: %v", ctx.Err())) + } + + // Now it is safe to clear and close resources. + c.mu.Lock() + c.client = nil + c.container = nil + c.mu.Unlock() + if container != nil { _ = container.Close() } @@ -211,6 +236,10 @@ func (c *WhatsAppNativeChannel) eventHandler(evt any) { c.handleIncoming(evt.(*events.Message)) case *events.Disconnected: logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil) + // Prevent new goroutines once Stop() has begun. + if c.stopping.Load() { + return + } c.reconnectMu.Lock() if c.reconnecting { c.reconnectMu.Unlock() From fc28c2660a5f709b7f6c763a9d9b1328dd4acaf0 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 12:37:49 +0800 Subject: [PATCH 076/104] fix(whatsapp_native): close TOCTOU race between eventHandler and Stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the stopping check and wg.Add(1) inside reconnectMu in eventHandler, and set the stopping flag under the same lock in Stop(). This makes the two operations atomic with respect to each other, preventing the race where: 1. eventHandler checks stopping (false) 2. Stop() sets stopping=true and enters wg.Wait() (wg is 0) 3. eventHandler calls wg.Add(1) → panic or goroutine leak --- .../whatsapp_native/whatsapp_native.go | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index c475276ba..748ee29d7 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -183,8 +183,14 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { func (c *WhatsAppNativeChannel) Stop(ctx context.Context) error { logger.InfoC("whatsapp", "Stopping WhatsApp native channel") - // Mark as stopping so no new goroutines are spawned via eventHandler. + // Mark as stopping under reconnectMu so the flag is visible to + // eventHandler atomically with respect to its wg.Add(1) call. + // This closes the TOCTOU window where eventHandler could check + // stopping (false), then Stop sets it true + enters wg.Wait, + // then eventHandler calls wg.Add(1) — causing a panic. + c.reconnectMu.Lock() c.stopping.Store(true) + c.reconnectMu.Unlock() if c.runCancel != nil { c.runCancel() @@ -236,18 +242,21 @@ func (c *WhatsAppNativeChannel) eventHandler(evt any) { c.handleIncoming(evt.(*events.Message)) case *events.Disconnected: logger.InfoCF("whatsapp", "WhatsApp disconnected, will attempt reconnection", nil) - // Prevent new goroutines once Stop() has begun. - if c.stopping.Load() { - return - } c.reconnectMu.Lock() if c.reconnecting { c.reconnectMu.Unlock() return } + // Check stopping while holding the lock so the check and wg.Add + // are atomic with respect to Stop() setting the flag + calling + // wg.Wait(). This prevents the TOCTOU race. + if c.stopping.Load() { + c.reconnectMu.Unlock() + return + } c.reconnecting = true - c.reconnectMu.Unlock() c.wg.Add(1) + c.reconnectMu.Unlock() go func() { defer c.wg.Done() c.reconnectWithBackoff() From d1b10a0004e87d6f3857cbb22287b16786c49279 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 12:54:05 +0800 Subject: [PATCH 077/104] fix(whatsapp_native,agent): address second round of review feedback - WhatsApp Send(): detect unpaired state (Store.ID == nil) and return ErrTemporary instead of attempting to send while QR login is pending - handleReasoning: check the returned error type (DeadlineExceeded / Canceled) instead of ctx.Err() to decide log level, so pubCtx timeouts on a full bus are correctly classified as expected - Test: fill bus with a short-timeout loop instead of hardcoding the buffer size (64), making the test resilient to buffer size changes --- pkg/agent/loop.go | 13 ++++++++----- pkg/agent/loop_test.go | 14 +++++++++----- pkg/channels/whatsapp_native/whatsapp_native.go | 6 ++++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 53f411452..ba0254076 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -9,6 +9,7 @@ package agent import ( "context" "encoding/json" + "errors" "fmt" "path/filepath" "strings" @@ -585,15 +586,17 @@ func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, chan ChatID: channelID, Content: reasoningContent, }); err != nil { - // Only log unexpected errors; context deadline/cancel are expected when - // the bus is full under load and would be too noisy to warn about. - if ctx.Err() == nil { - logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ + // Treat context.DeadlineExceeded / context.Canceled as expected + // (bus full under load, or parent cancelled). Check the error + // itself rather than ctx.Err(), because pubCtx may time out + // (5 s) while the parent ctx is still active. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ "channel": channelName, "error": err.Error(), }) } else { - logger.DebugCF("agent", "Reasoning publish skipped (context done)", map[string]any{ + logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ "channel": channelName, "error": err.Error(), }) diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 29293477e..801b6a46e 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -801,16 +801,20 @@ func TestHandleReasoning(t *testing.T) { t.Run("returns promptly when bus is full", func(t *testing.T) { al, msgBus := newLoop(t) - // Fill the outbound bus buffer (default size is 64) so that the - // next PublishOutbound will block until the context deadline. - for i := 0; i < 64; i++ { - err := msgBus.PublishOutbound(context.Background(), bus.OutboundMessage{ + // Fill the outbound bus buffer until a publish would block. + // Use a short timeout to detect when the buffer is full, + // rather than hardcoding the buffer size. + for i := 0; ; i++ { + fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{ Channel: "filler", ChatID: "filler", Content: fmt.Sprintf("filler-%d", i), }) + fillCancel() if err != nil { - t.Fatalf("failed to fill bus: %v", err) + // Buffer is full (timed out trying to send). + break } } diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 748ee29d7..6b8ddbffd 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -384,6 +384,12 @@ func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessag return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) } + // Detect unpaired state: the client is connected (to WhatsApp servers) + // but has not completed QR-login yet, so sending would fail. + if client.Store.ID == nil { + return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) + } + to, err := parseJID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat id %q: %w", msg.ChatID, err) From 7f425f1d11fef11607c9abbf2469f9bdbe8bf546 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 13:00:21 +0800 Subject: [PATCH 078/104] fix(agent): correct misspelling of 'canceled' --- pkg/agent/loop.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index ba0254076..6d581465e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -587,7 +587,7 @@ func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, chan Content: reasoningContent, }); err != nil { // Treat context.DeadlineExceeded / context.Canceled as expected - // (bus full under load, or parent cancelled). Check the error + // (bus full under load, or parent canceled). Check the error // itself rather than ctx.Err(), because pubCtx may time out // (5 s) while the parent ctx is still active. if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { From 871b2d7342bfcfec07c0723ad6614c23ff24c24c Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 14:13:24 +0800 Subject: [PATCH 079/104] fix(whatsapp_native,agent): fixes for resource leak and log noise - WhatsApp Start(): use deferred cleanup to nil out c.client/c.container and disconnect/close resources on any error after struct fields are assigned, preventing stale references and double-close in Stop() - handleReasoning: treat bus.ErrBusClosed as an expected condition (DEBUG level) alongside context timeout/cancel, avoiding WARN noise during normal shutdown --- pkg/agent/loop.go | 5 ++- .../whatsapp_native/whatsapp_native.go | 42 ++++++++++++++++--- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 6d581465e..eae5bdc15 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -590,7 +590,10 @@ func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, chan // (bus full under load, or parent canceled). Check the error // itself rather than ctx.Err(), because pubCtx may time out // (5 s) while the parent ctx is still active. - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + // Also treat ErrBusClosed as expected — it occurs during normal + // shutdown when the bus is closed before all goroutines finish. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + errors.Is(err, bus.ErrBusClosed) { logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ "channel": channelName, "error": err.Error(), diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 6b8ddbffd..188a7c8fa 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -83,6 +83,14 @@ func NewWhatsAppNativeChannel( func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { logger.InfoCF("whatsapp", "Starting WhatsApp native channel (whatsmeow)", map[string]any{"store": c.storePath}) + // Reset lifecycle state from any previous Stop() so a restarted channel + // behaves correctly. Use reconnectMu to be consistent with eventHandler + // and Stop() which coordinate under the same lock. + c.reconnectMu.Lock() + c.stopping.Store(false) + c.reconnecting = false + c.reconnectMu.Unlock() + if err := os.MkdirAll(c.storePath, 0o700); err != nil { return fmt.Errorf("create session store dir: %w", err) } @@ -128,22 +136,45 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { c.client = client c.mu.Unlock() + // cleanupOnError clears struct references and releases resources when + // Start() fails after fields are already assigned. This prevents + // Stop() from operating on stale references (double-close, disconnect + // of a partially-initialized client, or stray event handler callbacks). + startOK := false + defer func() { + if startOK { + return + } + c.runCancel() + client.Disconnect() + c.mu.Lock() + c.client = nil + c.container = nil + c.mu.Unlock() + _ = container.Close() + }() + if client.Store.ID == nil { qrChan, err := client.GetQRChannel(c.runCtx) if err != nil { - c.runCancel() - _ = container.Close() return fmt.Errorf("get QR channel: %w", err) } if err := client.Connect(); err != nil { - c.runCancel() - _ = container.Close() return fmt.Errorf("connect: %w", err) } // Handle QR events in a background goroutine so Start() returns // promptly. The goroutine is tracked via c.wg and respects // c.runCtx for cancellation. + // Guard wg.Add with reconnectMu + stopping check (same protocol + // as eventHandler) so a concurrent Stop() cannot enter wg.Wait() + // while we call wg.Add(1). + c.reconnectMu.Lock() + if c.stopping.Load() { + c.reconnectMu.Unlock() + return fmt.Errorf("channel stopped during QR setup") + } c.wg.Add(1) + c.reconnectMu.Unlock() go func() { defer c.wg.Done() for { @@ -169,12 +200,11 @@ func (c *WhatsAppNativeChannel) Start(ctx context.Context) error { }() } else { if err := client.Connect(); err != nil { - c.runCancel() - _ = container.Close() return fmt.Errorf("connect: %w", err) } } + startOK = true c.SetRunning(true) logger.InfoC("whatsapp", "WhatsApp native channel connected") return nil From 6c8866de6f2b32c55a569dadb22f59d34167ef74 Mon Sep 17 00:00:00 2001 From: wenjie Date: Sat, 28 Feb 2026 16:04:44 +0800 Subject: [PATCH 080/104] fix: propagate error when no channels are enabled during startup (#897) --- cmd/picoclaw/internal/gateway/helpers.go | 1 + pkg/channels/manager.go | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index baa489b92..da87f0adf 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -175,6 +175,7 @@ func gatewayCmd(debug bool) error { if err := channelManager.StartAll(ctx); err != nil { fmt.Printf("Error starting channels: %v\n", err) + return err } fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 31af9672c..155e50b39 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -313,7 +313,7 @@ func (m *Manager) StartAll(ctx context.Context) error { if len(m.channels) == 0 { logger.WarnC("channels", "No channels enabled") - return nil + return errors.New("no channels enabled") } logger.InfoC("channels", "Starting all channels") From 172e6ebe5fb063777f6d15a7e690c4ebd020071c Mon Sep 17 00:00:00 2001 From: Mauro Date: Sat, 28 Feb 2026 09:24:26 +0100 Subject: [PATCH 081/104] fix(exec) fail close on invalid deny pattern (#781) * fix(exec) fail close on invalid deny pattern * fix: error check * resolve conflicts --- cmd/picoclaw/internal/gateway/helpers.go | 7 ++- pkg/agent/instance.go | 8 +++- pkg/tools/cron.go | 10 ++-- pkg/tools/shell.go | 9 ++-- pkg/tools/shell_test.go | 59 +++++++++++++++++++----- pkg/tools/shell_timeout_unix_test.go | 6 ++- 6 files changed, 77 insertions(+), 22 deletions(-) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index da87f0adf..747f7d44e 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -3,6 +3,7 @@ package gateway import ( "context" "fmt" + "log" "os" "os/signal" "path/filepath" @@ -223,7 +224,11 @@ func setupCronTool( cronService := cron.NewCronService(cronStorePath, nil) // Create and register CronTool - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) + cronTool, err := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) + if err != nil { + log.Fatalf("Critical error during CronTool initialization: %v", err) + } + agentLoop.RegisterTool(cronTool) // Set the onJob handler diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 65a1fe04d..e597542e4 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -1,6 +1,7 @@ package agent import ( + "log" "os" "path/filepath" "strings" @@ -51,7 +52,12 @@ func NewAgentInstance( toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewListDirTool(workspace, restrict)) - toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg)) + execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) + if err != nil { + log.Fatalf("Critical error: unable to initialize exec tool: %v", err) + } + toolsRegistry.Register(execTool) + toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 52f914622..f91397c5d 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -33,15 +33,19 @@ type CronTool struct { func NewCronTool( cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config, -) *CronTool { - execTool := NewExecToolWithConfig(workspace, restrict, config) +) (*CronTool, error) { + execTool, err := NewExecToolWithConfig(workspace, restrict, config) + if err != nil { + return nil, fmt.Errorf("unable to configure exec tool: %w", err) + } + execTool.SetTimeout(execTimeout) return &CronTool{ cronService: cronService, executor: executor, msgBus: msgBus, execTool: execTool, - } + }, nil } // Name returns the tool name diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index ad1664b5b..b52433b6f 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -69,11 +69,11 @@ var defaultDenyPatterns = []*regexp.Regexp{ regexp.MustCompile(`\bsource\s+.*\.sh\b`), } -func NewExecTool(workingDir string, restrict bool) *ExecTool { +func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) { return NewExecToolWithConfig(workingDir, restrict, nil) } -func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool { +func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) { denyPatterns := make([]*regexp.Regexp, 0) if config != nil { @@ -86,8 +86,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf for _, pattern := range execConfig.CustomDenyPatterns { re, err := regexp.Compile(pattern) if err != nil { - fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err) - continue + return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err) } denyPatterns = append(denyPatterns, re) } @@ -106,7 +105,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf denyPatterns: denyPatterns, allowPatterns: nil, restrictToWorkspace: restrict, - } + }, nil } func (t *ExecTool) Name() string { diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index 6d35815e8..1a179547a 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -11,7 +11,10 @@ import ( // TestShellTool_Success verifies successful command execution func TestShellTool_Success(t *testing.T) { - tool := NewExecTool("", false) + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } ctx := context.Background() args := map[string]any{ @@ -38,7 +41,10 @@ func TestShellTool_Success(t *testing.T) { // TestShellTool_Failure verifies failed command execution func TestShellTool_Failure(t *testing.T) { - tool := NewExecTool("", false) + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } ctx := context.Background() args := map[string]any{ @@ -65,7 +71,11 @@ func TestShellTool_Failure(t *testing.T) { // TestShellTool_Timeout verifies command timeout handling func TestShellTool_Timeout(t *testing.T) { - tool := NewExecTool("", false) + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } + tool.SetTimeout(100 * time.Millisecond) ctx := context.Background() @@ -93,7 +103,10 @@ func TestShellTool_WorkingDir(t *testing.T) { testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("test content"), 0o644) - tool := NewExecTool("", false) + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } ctx := context.Background() args := map[string]any{ @@ -114,7 +127,10 @@ func TestShellTool_WorkingDir(t *testing.T) { // TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands func TestShellTool_DangerousCommand(t *testing.T) { - tool := NewExecTool("", false) + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } ctx := context.Background() args := map[string]any{ @@ -135,7 +151,10 @@ func TestShellTool_DangerousCommand(t *testing.T) { // TestShellTool_MissingCommand verifies error handling for missing command func TestShellTool_MissingCommand(t *testing.T) { - tool := NewExecTool("", false) + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } ctx := context.Background() args := map[string]any{} @@ -150,7 +169,10 @@ func TestShellTool_MissingCommand(t *testing.T) { // TestShellTool_StderrCapture verifies stderr is captured and included func TestShellTool_StderrCapture(t *testing.T) { - tool := NewExecTool("", false) + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } ctx := context.Background() args := map[string]any{ @@ -170,7 +192,10 @@ func TestShellTool_StderrCapture(t *testing.T) { // TestShellTool_OutputTruncation verifies long output is truncated func TestShellTool_OutputTruncation(t *testing.T) { - tool := NewExecTool("", false) + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } ctx := context.Background() // Generate long output (>10000 chars) @@ -198,7 +223,11 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { t.Fatalf("failed to create outside dir: %v", err) } - tool := NewExecTool(workspace, true) + tool, err := NewExecTool(workspace, true) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } + result := tool.Execute(context.Background(), map[string]any{ "command": "pwd", "working_dir": outsideDir, @@ -232,7 +261,11 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { t.Skipf("symlinks not supported in this environment: %v", err) } - tool := NewExecTool(workspace, true) + tool, err := NewExecTool(workspace, true) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } + result := tool.Execute(context.Background(), map[string]any{ "command": "cat secret.txt", "working_dir": link, @@ -249,7 +282,11 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { // TestShellTool_RestrictToWorkspace verifies workspace restriction func TestShellTool_RestrictToWorkspace(t *testing.T) { tmpDir := t.TempDir() - tool := NewExecTool(tmpDir, false) + tool, err := NewExecTool(tmpDir, false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } + tool.SetRestrictToWorkspace(true) ctx := context.Background() diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go index 04ef8e441..357e1276e 100644 --- a/pkg/tools/shell_timeout_unix_test.go +++ b/pkg/tools/shell_timeout_unix_test.go @@ -22,7 +22,11 @@ func processExists(pid int) bool { } func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { - tool := NewExecTool(t.TempDir(), false) + tool, err := NewExecTool(t.TempDir(), false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } + tool.SetTimeout(500 * time.Millisecond) args := map[string]any{ From 5e028a847c5ee1fa957d94cd20e1b7acadb83a27 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:38:38 +0800 Subject: [PATCH 082/104] feat: add picoclaw-launcher with web UI for configuration and gateway management (#904) A standalone web-based tool for managing picoclaw configuration, OAuth authentication providers, and gateway process lifecycle. Features include a sidebar layout with i18n (en/zh) and theme support, real-time gateway log streaming, startup prerequisites checks, and Windows icon embedding. Co-authored-by: wj-xiao Co-authored-by: Claude Opus 4.6 --- .gitignore | 3 + .goreleaser.yaml | 33 +- cmd/picoclaw-launcher/README.md | 290 +++ cmd/picoclaw-launcher/README.zh.md | 287 +++ cmd/picoclaw-launcher/icon.ico | Bin 0 -> 44671 bytes .../internal/server/auth_config.go | 147 ++ .../internal/server/auth_config_test.go | 222 ++ .../internal/server/auth_handlers.go | 312 +++ .../internal/server/logbuffer.go | 99 + .../internal/server/logbuffer_test.go | 116 + .../internal/server/process.go | 232 ++ .../internal/server/server.go | 196 ++ .../internal/server/server_test.go | 247 ++ .../internal/server/utils.go | 28 + cmd/picoclaw-launcher/internal/ui/index.html | 1999 +++++++++++++++++ cmd/picoclaw-launcher/main.go | 127 ++ cmd/picoclaw-launcher/winres/winres.json | 22 + pkg/auth/oauth.go | 72 +- pkg/auth/oauth_test.go | 4 +- 19 files changed, 4425 insertions(+), 11 deletions(-) create mode 100644 cmd/picoclaw-launcher/README.md create mode 100644 cmd/picoclaw-launcher/README.zh.md create mode 100644 cmd/picoclaw-launcher/icon.ico create mode 100644 cmd/picoclaw-launcher/internal/server/auth_config.go create mode 100644 cmd/picoclaw-launcher/internal/server/auth_config_test.go create mode 100644 cmd/picoclaw-launcher/internal/server/auth_handlers.go create mode 100644 cmd/picoclaw-launcher/internal/server/logbuffer.go create mode 100644 cmd/picoclaw-launcher/internal/server/logbuffer_test.go create mode 100644 cmd/picoclaw-launcher/internal/server/process.go create mode 100644 cmd/picoclaw-launcher/internal/server/server.go create mode 100644 cmd/picoclaw-launcher/internal/server/server_test.go create mode 100644 cmd/picoclaw-launcher/internal/server/utils.go create mode 100644 cmd/picoclaw-launcher/internal/ui/index.html create mode 100644 cmd/picoclaw-launcher/main.go create mode 100644 cmd/picoclaw-launcher/winres/winres.json diff --git a/.gitignore b/.gitignore index 3ff195fbf..02ef18d1f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ tasks/ # Added by goreleaser init: dist/ + +# Windows Application Icon/Resource +*.syso diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 90bdc8437..d893b07a8 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -5,7 +5,9 @@ version: 2 before: hooks: - go mod tidy - - go generate ./cmd/picoclaw/... + - go generate ./... + - go install github.com/tc-hib/go-winres@latest + - go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }} builds: - id: picoclaw @@ -37,6 +39,32 @@ builds: - goos: windows goarch: arm + - id: picoclaw-launcher + binary: picoclaw-launcher + env: + - CGO_ENABLED=0 + tags: + - stdjson + ldflags: + - -s -w + goos: + - linux + - windows + - darwin + - freebsd + goarch: + - amd64 + - arm64 + - riscv64 + - loong64 + - arm + goarm: + - "7" + main: ./cmd/picoclaw-launcher + ignore: + - goos: windows + goarch: arm + dockers_v2: - id: picoclaw dockerfile: docker/Dockerfile.goreleaser @@ -72,6 +100,9 @@ archives: nfpms: - id: picoclaw + builds: + - picoclaw + - picoclaw-launcher package_name: picoclaw file_name_template: >- {{ .PackageName }}_ diff --git a/cmd/picoclaw-launcher/README.md b/cmd/picoclaw-launcher/README.md new file mode 100644 index 000000000..641279bb1 --- /dev/null +++ b/cmd/picoclaw-launcher/README.md @@ -0,0 +1,290 @@ +# PicoClaw Launcher + +> [!WARNING] +> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable. + +A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management. + +## Features + +- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor +- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation +- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links +- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth) +- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies +- 🌍 **i18n** — Chinese/English language switching with browser auto-detection +- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence + +## Quick Start + +```bash +# Build +go build -o picoclaw-launcher ./cmd/picoclaw-launcher/ + +# Run with default config path (~/.picoclaw/config.json) +./picoclaw-launcher + +# Specify a config file +./picoclaw-launcher ./config.json + +# Allow LAN access +./picoclaw-launcher -public +``` + +Open `http://localhost:18800` in your browser. + +## CLI Options + +``` +Usage: picoclaw-config [options] [config.json] + +Arguments: + config.json Path to the configuration file (default: ~/.picoclaw/config.json) + +Options: + -public Listen on all interfaces (0.0.0.0), allowing access from other devices +``` + +## API Reference + +Base URL: `http://localhost:18800` + +--- + +### Static Files + +#### GET / + +Serves the embedded frontend (`index.html`). + +--- + +### Config API + +#### GET /api/config + +Reads the current configuration file. + +**Response** `200 OK` + +```json +{ + "config": { ... }, + "path": "/Users/xiao/.picoclaw/config.json" +} +``` + +--- + +#### PUT /api/config + +Saves the configuration. The request body must be a complete Config JSON object. + +**Request Body** — `application/json` + +```json +{ + "agents": { "defaults": { "model_name": "gpt-5.2" } }, + "model_list": [ + { + "model_name": "gpt-5.2", + "model": "openai/gpt-5.2", + "auth_method": "oauth" + } + ] +} +``` + +**Response** `200 OK` + +```json +{ "status": "ok" } +``` + +**Error** `400 Bad Request` — Invalid JSON + +--- + +### Auth API + +#### GET /api/auth/status + +Returns the authentication status of all providers and any in-progress device code login. + +**Response** `200 OK` + +```json +{ + "providers": [ + { + "provider": "openai", + "auth_method": "oauth", + "status": "active", + "account_id": "user-xxx", + "expires_at": "2026-03-01T00:00:00Z" + } + ], + "pending_device": { + "provider": "openai", + "status": "pending", + "device_url": "https://auth.openai.com/activate", + "user_code": "ABCD-1234" + } +} +``` + +`status` values: `active` | `expired` | `needs_refresh` + +`pending_device` is only present when a device code login is in progress. + +--- + +#### POST /api/auth/login + +Initiates a provider login. + +**Request Body** — `application/json` + +```json +{ "provider": "openai" } +``` + +Supported `provider` values: `openai` | `anthropic` | `google-antigravity` + +##### OpenAI (Device Code Flow) + +Returns device code info. The server polls for completion in the background. + +```json +{ + "status": "pending", + "device_url": "https://auth.openai.com/activate", + "user_code": "ABCD-1234", + "message": "Open the URL and enter the code to authenticate." +} +``` + +The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`. + +##### Anthropic (API Token) + +Requires a `token` field in the request: + +```json +{ "provider": "anthropic", "token": "sk-ant-xxx" } +``` + +**Response:** + +```json +{ "status": "success", "message": "Anthropic token saved" } +``` + +##### Google Antigravity (Browser OAuth) + +Returns an authorization URL for the frontend to open in a new tab: + +```json +{ + "status": "redirect", + "auth_url": "https://accounts.google.com/o/oauth2/auth?...", + "message": "Open the URL to authenticate with Google." +} +``` + +After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI. + +--- + +#### POST /api/auth/logout + +Logs out from a provider. + +**Request Body** — `application/json` + +```json +{ "provider": "openai" } +``` + +Omit or leave `provider` empty to log out from all providers. + +**Response** `200 OK` + +```json +{ "status": "ok" } +``` + +--- + +#### GET /auth/callback + +OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**. + +**Query Parameters:** +- `state` — OAuth state for CSRF validation +- `code` — Authorization code + +On success, redirects to `/#auth`. + + +### Process API + +#### GET /api/process/status + +Gets the running status of the `picoclaw gateway` process. + +**Response** `200 OK` (Running) + +```json +{ + "process_status": "running", + "status": "ok", + "uptime": "1.010814s" +} +``` + +**Response** `200 OK` (Stopped) + +```json +{ + "process_status": "stopped", + "error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused" +} +``` + +--- + +#### POST /api/process/start + +Starts the `picoclaw gateway` process in the background. + +**Response** `200 OK` + +```json +{ + "status": "ok", + "pid": 12345 +} +``` + +--- + +#### POST /api/process/stop + +Stops the running `picoclaw gateway` process. + +**Response** `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## Testing + +```bash +go test -v ./cmd/picoclaw-launcher/ +``` diff --git a/cmd/picoclaw-launcher/README.zh.md b/cmd/picoclaw-launcher/README.zh.md new file mode 100644 index 000000000..320de75a5 --- /dev/null +++ b/cmd/picoclaw-launcher/README.zh.md @@ -0,0 +1,287 @@ +# PicoClaw Launcher + +> [!WARNING] +> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。 + +PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。 + +## 功能 + +- 📝 **配置编辑** — 侧边栏式设置 UI,支持模型管理、通道配置表单和原始 JSON 编辑器 +- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离 +- 📡 **通道配置** — 12 种通道类型(Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接 +- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录 +- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖 +- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言 +- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage + +## 快速开始 + +```bash +# 编译 +go build -o picoclaw-launcher ./cmd/picoclaw-launcher/ + +# 运行(使用默认配置路径 ~/.picoclaw/config.json) +./picoclaw-launcher + +# 指定配置文件 +./picoclaw-launcher ./config.json + +# 允许局域网访问 +./picoclaw-launcher -public +``` + +启动后在浏览器中打开 `http://localhost:18800`。 + +## 命令行参数 + +``` +Usage: picoclaw-launcher [options] [config.json] + +Arguments: + config.json 配置文件路径(默认: ~/.picoclaw/config.json) + +Options: + -public 监听所有网络接口(0.0.0.0),允许局域网设备访问 +``` + +## API 文档 + +Base URL: `http://localhost:18800` + +### 静态文件 + +#### GET / + +提供嵌入式前端页面(`index.html`)。 + +--- + +### Config API + +#### GET /api/config + +读取当前配置文件内容。 + +**Response** `200 OK` + +```json +{ + "config": { ... }, + "path": "/Users/xiao/.picoclaw/config.json" +} +``` + +--- + +#### PUT /api/config + +保存配置。请求体为完整的 Config JSON。 + +**Request Body** — `application/json` + +```json +{ + "agents": { "defaults": { "model_name": "gpt-5.2" } }, + "model_list": [ + { + "model_name": "gpt-5.2", + "model": "openai/gpt-5.2", + "auth_method": "oauth" + } + ] +} +``` + +**Response** `200 OK` + +```json +{ "status": "ok" } +``` + +**Error** `400 Bad Request` — 无效 JSON + +--- + +### Auth API + +#### GET /api/auth/status + +获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。 + +**Response** `200 OK` + +```json +{ + "providers": [ + { + "provider": "openai", + "auth_method": "oauth", + "status": "active", + "account_id": "user-xxx", + "expires_at": "2026-03-01T00:00:00Z" + } + ], + "pending_device": { + "provider": "openai", + "status": "pending", + "device_url": "https://auth.openai.com/activate", + "user_code": "ABCD-1234" + } +} +``` + +`status` 可选值: `active` | `expired` | `needs_refresh` + +`pending_device` 仅在有进行中的 Device Code 登录时返回。 + +--- + +#### POST /api/auth/login + +发起 Provider 登录。 + +**Request Body** — `application/json` + +```json +{ "provider": "openai" } +``` + +支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity` + +##### OpenAI (Device Code Flow) + +返回 Device Code 信息,后台自动轮询认证结果: + +```json +{ + "status": "pending", + "device_url": "https://auth.openai.com/activate", + "user_code": "ABCD-1234", + "message": "Open the URL and enter the code to authenticate." +} +``` + +用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status` 的 `pending_device.status` 变为 `success` 通知前端。 + +##### Anthropic (API Token) + +需在请求中附带 token: + +```json +{ "provider": "anthropic", "token": "sk-ant-xxx" } +``` + +**Response:** + +```json +{ "status": "success", "message": "Anthropic token saved" } +``` + +##### Google Antigravity (Browser OAuth) + +返回授权 URL,前端打开新标签页: + +```json +{ + "status": "redirect", + "auth_url": "https://accounts.google.com/o/oauth2/auth?...", + "message": "Open the URL to authenticate with Google." +} +``` + +认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。 + +--- + +#### POST /api/auth/logout + +登出 Provider。 + +**Request Body** — `application/json` + +```json +{ "provider": "openai" } +``` + +传空字符串或省略 `provider` 则登出所有 Provider。 + +**Response** `200 OK` + +```json +{ "status": "ok" } +``` + +--- + +#### GET /auth/callback + +OAuth Browser 回调端点(Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。 + +**Query Parameters:** +- `state` — OAuth state 校验 +- `code` — 授权码 + +认证成功后重定向到 `/#auth`。 + +### Process API + +#### GET /api/process/status + +获取 `picoclaw gateway` 进程的运行状态。 + +**Response** `200 OK` (运行中) + +```json +{ + "process_status": "running", + "status": "ok", + "uptime": "1.010814s" +} +``` + +**Response** `200 OK` (未运行) + +```json +{ + "process_status": "stopped", + "error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused" +} +``` + +--- + +#### POST /api/process/start + +在后台启动 `picoclaw gateway` 进程。 + +**Response** `200 OK` + +```json +{ + "status": "ok", + "pid": 12345 +} +``` + +--- + +#### POST /api/process/stop + +停止正在运行的 `picoclaw gateway` 进程。 + +**Response** `200 OK` + +```json +{ + "status": "ok" +} +``` + +--- + +## 测试 + +```bash +go test -v ./cmd/picoclaw-launcher/ +``` diff --git a/cmd/picoclaw-launcher/icon.ico b/cmd/picoclaw-launcher/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4f65394147f95a863892e39c48d3386170e4f245 GIT binary patch literal 44671 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 Kl9l2{!T$$h!av>s literal 0 HcmV?d00001 diff --git a/cmd/picoclaw-launcher/internal/server/auth_config.go b/cmd/picoclaw-launcher/internal/server/auth_config.go new file mode 100644 index 000000000..f75e8fff0 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/auth_config.go @@ -0,0 +1,147 @@ +package server + +import ( + "log" + "strings" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +// updateConfigAfterLogin updates config.json after a successful provider login. +func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + log.Printf("Warning: could not load config to update auth_method: %v", err) + return + } + + switch provider { + case "openai": + cfg.Providers.OpenAI.AuthMethod = "oauth" + found := false + for i := range cfg.ModelList { + if isOpenAIModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "oauth" + found = true + break + } + } + if !found { + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "gpt-5.2", + Model: "openai/gpt-5.2", + AuthMethod: "oauth", + }) + } + cfg.Agents.Defaults.ModelName = "gpt-5.2" + + case "anthropic": + cfg.Providers.Anthropic.AuthMethod = "token" + found := false + for i := range cfg.ModelList { + if isAnthropicModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "token" + found = true + break + } + } + if !found { + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "claude-sonnet-4.6", + Model: "anthropic/claude-sonnet-4.6", + AuthMethod: "token", + }) + } + cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6" + + case "google-antigravity": + cfg.Providers.Antigravity.AuthMethod = "oauth" + found := false + for i := range cfg.ModelList { + if isAntigravityModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "oauth" + found = true + break + } + } + if !found { + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "gemini-flash", + Model: "antigravity/gemini-3-flash", + AuthMethod: "oauth", + }) + } + cfg.Agents.Defaults.ModelName = "gemini-flash" + } + + if err := config.SaveConfig(configPath, cfg); err != nil { + log.Printf("Warning: could not update config: %v", err) + } +} + +// clearAuthMethodInConfig clears auth_method for a specific provider in config.json. +func clearAuthMethodInConfig(configPath, provider string) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return + } + + for i := range cfg.ModelList { + switch provider { + case "openai": + if isOpenAIModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "" + } + case "anthropic": + if isAnthropicModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "" + } + case "google-antigravity", "antigravity": + if isAntigravityModel(cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = "" + } + } + } + + switch provider { + case "openai": + cfg.Providers.OpenAI.AuthMethod = "" + case "anthropic": + cfg.Providers.Anthropic.AuthMethod = "" + case "google-antigravity", "antigravity": + cfg.Providers.Antigravity.AuthMethod = "" + } + + config.SaveConfig(configPath, cfg) +} + +// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json. +func clearAllAuthMethodsInConfig(configPath string) { + cfg, err := config.LoadConfig(configPath) + if err != nil { + return + } + for i := range cfg.ModelList { + cfg.ModelList[i].AuthMethod = "" + } + cfg.Providers.OpenAI.AuthMethod = "" + cfg.Providers.Anthropic.AuthMethod = "" + cfg.Providers.Antigravity.AuthMethod = "" + config.SaveConfig(configPath, cfg) +} + +// ── Model identification helpers ───────────────────────────────── + +func isOpenAIModel(model string) bool { + return model == "openai" || strings.HasPrefix(model, "openai/") +} + +func isAnthropicModel(model string) bool { + return model == "anthropic" || strings.HasPrefix(model, "anthropic/") +} + +func isAntigravityModel(model string) bool { + return model == "antigravity" || model == "google-antigravity" || + strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/") +} diff --git a/cmd/picoclaw-launcher/internal/server/auth_config_test.go b/cmd/picoclaw-launcher/internal/server/auth_config_test.go new file mode 100644 index 000000000..92158d011 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/auth_config_test.go @@ -0,0 +1,222 @@ +package server + +import ( + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +// ── Model identification helpers ───────────────────────────────── + +func TestIsOpenAIModel(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"openai", true}, + {"openai/gpt-4o", true}, + {"openai/gpt-5.2", true}, + {"anthropic", false}, + {"anthropic/claude-sonnet-4.6", false}, + {"openai-compatible", false}, + {"", false}, + } + for _, tt := range tests { + if got := isOpenAIModel(tt.model); got != tt.want { + t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want) + } + } +} + +func TestIsAnthropicModel(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"anthropic", true}, + {"anthropic/claude-sonnet-4.6", true}, + {"openai", false}, + {"openai/gpt-4o", false}, + {"", false}, + } + for _, tt := range tests { + if got := isAnthropicModel(tt.model); got != tt.want { + t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want) + } + } +} + +func TestIsAntigravityModel(t *testing.T) { + tests := []struct { + model string + want bool + }{ + {"antigravity", true}, + {"google-antigravity", true}, + {"antigravity/gemini-3-flash", true}, + {"google-antigravity/gemini-3-flash", true}, + {"openai", false}, + {"antigravity-custom", false}, + {"", false}, + } + for _, tt := range tests { + if got := isAntigravityModel(tt.model); got != tt.want { + t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want) + } + } +} + +// ── Config update helpers ──────────────────────────────────────── + +func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + if err := config.SaveConfig(path, cfg); err != nil { + t.Fatalf("save config: %v", err) + } + return path +} + +func loadTempConfig(t *testing.T, path string) *config.Config { + t.Helper() + cfg, err := config.LoadConfig(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + return cfg +} + +func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "gpt-4o", Model: "openai/gpt-4o"}, + }, + } + path := writeTempConfigViaSave(t, cfg) + + cred := &auth.AuthCredential{AuthMethod: "oauth"} + updateConfigAfterLogin(path, "openai", cred) + + result := loadTempConfig(t, path) + + // Model-level auth_method persists through serialization + if len(result.ModelList) != 1 { + t.Fatalf("expected 1 model, got %d", len(result.ModelList)) + } + if result.ModelList[0].AuthMethod != "oauth" { + t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod) + } +} + +func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"}, + }, + } + path := writeTempConfigViaSave(t, cfg) + + cred := &auth.AuthCredential{AuthMethod: "oauth"} + updateConfigAfterLogin(path, "openai", cred) + + result := loadTempConfig(t, path) + + if len(result.ModelList) != 2 { + t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList)) + } + if result.ModelList[1].Model != "openai/gpt-5.2" { + t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model) + } + if result.Agents.Defaults.ModelName != "gpt-5.2" { + t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName) + } +} + +func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) { + cfg := &config.Config{} + path := writeTempConfigViaSave(t, cfg) + + cred := &auth.AuthCredential{AuthMethod: "token"} + updateConfigAfterLogin(path, "anthropic", cred) + + result := loadTempConfig(t, path) + + // Model should be added with correct auth_method + if len(result.ModelList) != 1 { + t.Fatalf("expected 1 model added, got %d", len(result.ModelList)) + } + if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" { + t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model) + } + if result.ModelList[0].AuthMethod != "token" { + t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod) + } +} + +func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) { + cfg := &config.Config{} + path := writeTempConfigViaSave(t, cfg) + + cred := &auth.AuthCredential{AuthMethod: "oauth"} + updateConfigAfterLogin(path, "google-antigravity", cred) + + result := loadTempConfig(t, path) + + // Model should be added with correct auth_method + if len(result.ModelList) != 1 { + t.Fatalf("expected 1 model added, got %d", len(result.ModelList)) + } + if result.ModelList[0].Model != "antigravity/gemini-3-flash" { + t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model) + } + if result.ModelList[0].AuthMethod != "oauth" { + t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod) + } +} + +func TestClearAuthMethodInConfig(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"}, + {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"}, + }, + } + path := writeTempConfigViaSave(t, cfg) + + clearAuthMethodInConfig(path, "openai") + + result := loadTempConfig(t, path) + + // Openai model auth_method should be cleared + if result.ModelList[0].AuthMethod != "" { + t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod) + } + // Anthropic model should be unchanged + if result.ModelList[1].AuthMethod != "token" { + t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod) + } +} + +func TestClearAllAuthMethodsInConfig(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"}, + {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"}, + {ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"}, + }, + } + path := writeTempConfigViaSave(t, cfg) + + clearAllAuthMethodsInConfig(path) + + result := loadTempConfig(t, path) + + for i, m := range result.ModelList { + if m.AuthMethod != "" { + t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod) + } + } +} diff --git a/cmd/picoclaw-launcher/internal/server/auth_handlers.go b/cmd/picoclaw-launcher/internal/server/auth_handlers.go new file mode 100644 index 000000000..1e9b8be0a --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/auth_handlers.go @@ -0,0 +1,312 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// oauthSession stores in-flight OAuth state for browser-based flows. +type oauthSession struct { + Provider string + PKCE auth.PKCECodes + State string + RedirectURI string + OAuthCfg auth.OAuthProviderConfig + ConfigPath string +} + +// deviceCodeSession stores in-flight device code flow state. +type deviceCodeSession struct { + mu sync.Mutex + Provider string + Info *auth.DeviceCodeInfo + OAuthCfg auth.OAuthProviderConfig + ConfigPath string + Status string // "pending", "success", "error" + Error string + Done bool +} + +var ( + oauthSessions = map[string]*oauthSession{} // keyed by state + oauthSessionsMu sync.Mutex + + activeDeviceSession *deviceCodeSession + activeDeviceSessionMu sync.Mutex +) + +// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend. +func handleOpenAILogin(w http.ResponseWriter, configPath string) { + // Check if there's already a pending device code session + activeDeviceSessionMu.Lock() + if activeDeviceSession != nil { + activeDeviceSession.mu.Lock() + if !activeDeviceSession.Done { + resp := map[string]any{ + "status": "pending", + "device_url": activeDeviceSession.Info.VerifyURL, + "user_code": activeDeviceSession.Info.UserCode, + "message": "Device code flow already in progress. Enter the code in your browser.", + } + activeDeviceSession.mu.Unlock() + activeDeviceSessionMu.Unlock() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + return + } + activeDeviceSession.mu.Unlock() + } + activeDeviceSessionMu.Unlock() + + // Request a device code + oauthCfg := auth.OpenAIOAuthConfig() + info, err := auth.RequestDeviceCode(oauthCfg) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError) + return + } + + session := &deviceCodeSession{ + Provider: "openai", + Info: info, + OAuthCfg: oauthCfg, + ConfigPath: configPath, + Status: "pending", + } + + activeDeviceSessionMu.Lock() + activeDeviceSession = session + activeDeviceSessionMu.Unlock() + + // Start background polling + go func() { + deadline := time.After(15 * time.Minute) + ticker := time.NewTicker(time.Duration(info.Interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-deadline: + session.mu.Lock() + session.Status = "error" + session.Error = "Authentication timed out after 15 minutes" + session.Done = true + session.mu.Unlock() + return + case <-ticker.C: + cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode) + if err != nil { + continue // Still pending + } + if cred != nil { + if saveErr := auth.SetCredential("openai", cred); saveErr != nil { + session.mu.Lock() + session.Status = "error" + session.Error = saveErr.Error() + session.Done = true + session.mu.Unlock() + return + } + updateConfigAfterLogin(configPath, "openai", cred) + session.mu.Lock() + session.Status = "success" + session.Done = true + session.mu.Unlock() + log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID) + return + } + } + } + }() + + // Return device code info to frontend + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "pending", + "device_url": info.VerifyURL, + "user_code": info.UserCode, + "message": "Open the URL and enter the code to authenticate.", + }) +} + +// handleAnthropicLogin saves a pasted API token for Anthropic. +func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) { + if token == "" { + http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest) + return + } + + cred := &auth.AuthCredential{ + AccessToken: token, + Provider: "anthropic", + AuthMethod: "token", + } + + if err := auth.SetCredential("anthropic", cred); err != nil { + http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError) + return + } + + updateConfigAfterLogin(configPath, "anthropic", cred) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "success", + "message": "Anthropic token saved", + }) +} + +// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend. +func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) { + oauthCfg := auth.GoogleAntigravityOAuthConfig() + + pkce, err := auth.GeneratePKCE() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError) + return + } + + state, err := auth.GenerateState() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError) + return + } + + // Build redirect URI pointing to picoclaw-launcher's own callback + scheme := "http" + redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host) + + authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI) + + // Store session for callback + oauthSessionsMu.Lock() + oauthSessions[state] = &oauthSession{ + Provider: "google-antigravity", + PKCE: pkce, + State: state, + RedirectURI: redirectURI, + OAuthCfg: oauthCfg, + ConfigPath: configPath, + } + oauthSessionsMu.Unlock() + + // Clean up stale sessions after 10 minutes + go func() { + time.Sleep(10 * time.Minute) + oauthSessionsMu.Lock() + delete(oauthSessions, state) + oauthSessionsMu.Unlock() + }() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "redirect", + "auth_url": authURL, + "message": "Open the URL to authenticate with Google.", + }) +} + +// handleOAuthCallback processes the OAuth callback from Google Antigravity. +func handleOAuthCallback(w http.ResponseWriter, r *http.Request) { + state := r.URL.Query().Get("state") + code := r.URL.Query().Get("code") + + oauthSessionsMu.Lock() + session, ok := oauthSessions[state] + if ok { + delete(oauthSessions, state) + } + oauthSessionsMu.Unlock() + + if !ok { + http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest) + return + } + + if code == "" { + errMsg := r.URL.Query().Get("error") + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf( + w, + `

Authentication failed

%s

You can close this window.

`, + errMsg, + ) + return + } + + cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI) + if err != nil { + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf( + w, + `

Authentication failed

%s

You can close this window.

`, + err.Error(), + ) + return + } + + cred.Provider = session.Provider + + // Fetch user info for Google Antigravity + if session.Provider == "google-antigravity" { + if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil { + cred.Email = email + } + if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil { + cred.ProjectID = projectID + } + } + + if err := auth.SetCredential(session.Provider, cred); err != nil { + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, `

Failed to save credentials

%s

`, err.Error()) + return + } + + updateConfigAfterLogin(session.ConfigPath, session.Provider, cred) + + // Redirect back to picoclaw-launcher UI + w.Header().Set("Content-Type", "text/html") + fmt.Fprintf(w, ` +

Authentication successful!

+

Redirecting back to Config Editor...

+ + `) +} + +// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint. +func fetchGoogleUserEmail(accessToken string) (string, error) { + req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("userinfo request failed: %s", string(body)) + } + + var userInfo struct { + Email string `json:"email"` + } + if err := json.Unmarshal(body, &userInfo); err != nil { + return "", err + } + return userInfo.Email, nil +} diff --git a/cmd/picoclaw-launcher/internal/server/logbuffer.go b/cmd/picoclaw-launcher/internal/server/logbuffer.go new file mode 100644 index 000000000..4d70f6466 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/logbuffer.go @@ -0,0 +1,99 @@ +package server + +import "sync" + +// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines. +// It supports incremental reads via LinesSince and tracks a runID that increments +// on each Reset (used to detect gateway restarts). +type LogBuffer struct { + mu sync.RWMutex + lines []string + cap int + total int // total lines ever appended in current run + runID int +} + +// NewLogBuffer creates a LogBuffer with the given capacity. +func NewLogBuffer(capacity int) *LogBuffer { + return &LogBuffer{ + lines: make([]string, 0, capacity), + cap: capacity, + } +} + +// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted. +func (b *LogBuffer) Append(line string) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.lines) < b.cap { + b.lines = append(b.lines, line) + } else { + b.lines[b.total%b.cap] = line + } + + b.total++ +} + +// Reset clears the buffer and increments the runID. Call this when starting a new gateway process. +func (b *LogBuffer) Reset() { + b.mu.Lock() + defer b.mu.Unlock() + + b.lines = b.lines[:0] + b.total = 0 + b.runID++ +} + +// LinesSince returns lines appended after the given offset, the current total count, and the runID. +// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned. +func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) { + b.mu.RLock() + defer b.mu.RUnlock() + + total = b.total + runID = b.runID + + if offset >= b.total { + return nil, total, runID + } + + buffered := len(b.lines) + + // How many new lines since offset + newCount := b.total - offset + if newCount > buffered { + newCount = buffered + } + + result := make([]string, newCount) + + if b.total <= b.cap { + // Buffer hasn't wrapped yet — simple slice + copy(result, b.lines[buffered-newCount:]) + } else { + // Buffer has wrapped — read from ring + start := (b.total - newCount) % b.cap + for i := range newCount { + result[i] = b.lines[(start+i)%b.cap] + } + } + + return result, total, runID +} + +// RunID returns the current run identifier. +func (b *LogBuffer) RunID() int { + b.mu.RLock() + defer b.mu.RUnlock() + + return b.runID +} + +// Total returns the total number of lines appended in the current run. +func (b *LogBuffer) Total() int { + b.mu.RLock() + defer b.mu.RUnlock() + + return b.total +} diff --git a/cmd/picoclaw-launcher/internal/server/logbuffer_test.go b/cmd/picoclaw-launcher/internal/server/logbuffer_test.go new file mode 100644 index 000000000..dc525be16 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/logbuffer_test.go @@ -0,0 +1,116 @@ +package server + +import ( + "fmt" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLogBuffer_Basic(t *testing.T) { + buf := NewLogBuffer(5) + + // Empty buffer + lines, total, runID := buf.LinesSince(0) + assert.Nil(t, lines) + assert.Equal(t, 0, total) + assert.Equal(t, 0, runID) + + // Append some lines + buf.Append("line1") + buf.Append("line2") + buf.Append("line3") + + lines, total, runID = buf.LinesSince(0) + assert.Equal(t, []string{"line1", "line2", "line3"}, lines) + assert.Equal(t, 3, total) + assert.Equal(t, 0, runID) + + // Incremental read + lines, total, _ = buf.LinesSince(2) + assert.Equal(t, []string{"line3"}, lines) + assert.Equal(t, 3, total) + + // No new lines + lines, total, _ = buf.LinesSince(3) + assert.Nil(t, lines) + assert.Equal(t, 3, total) +} + +func TestLogBuffer_Wrap(t *testing.T) { + buf := NewLogBuffer(3) + + buf.Append("a") + buf.Append("b") + buf.Append("c") + buf.Append("d") // evicts "a" + buf.Append("e") // evicts "b" + + lines, total, _ := buf.LinesSince(0) + assert.Equal(t, []string{"c", "d", "e"}, lines) + assert.Equal(t, 5, total) + + // Incremental after wrap + lines, total, _ = buf.LinesSince(3) + assert.Equal(t, []string{"d", "e"}, lines) + assert.Equal(t, 5, total) + + // Offset too old (before buffer start), get all buffered + lines, total, _ = buf.LinesSince(1) + assert.Equal(t, []string{"c", "d", "e"}, lines) + assert.Equal(t, 5, total) +} + +func TestLogBuffer_Reset(t *testing.T) { + buf := NewLogBuffer(5) + + buf.Append("before") + assert.Equal(t, 0, buf.RunID()) + + buf.Reset() + assert.Equal(t, 1, buf.RunID()) + assert.Equal(t, 0, buf.Total()) + + lines, total, runID := buf.LinesSince(0) + assert.Nil(t, lines) + assert.Equal(t, 0, total) + assert.Equal(t, 1, runID) + + buf.Append("after") + lines, total, runID = buf.LinesSince(0) + assert.Equal(t, []string{"after"}, lines) + assert.Equal(t, 1, total) + assert.Equal(t, 1, runID) +} + +func TestLogBuffer_Concurrent(t *testing.T) { + buf := NewLogBuffer(100) + var wg sync.WaitGroup + + // 10 writers + for i := range 10 { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := range 50 { + buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j)) + } + }(i) + } + + // 5 readers + for range 5 { + wg.Add(1) + go func() { + defer wg.Done() + for range 100 { + buf.LinesSince(0) + } + }() + } + + wg.Wait() + + assert.Equal(t, 500, buf.Total()) +} diff --git a/cmd/picoclaw-launcher/internal/server/process.go b/cmd/picoclaw-launcher/internal/server/process.go new file mode 100644 index 000000000..bc2129bf5 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/process.go @@ -0,0 +1,232 @@ +package server + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher. +var gatewayLogs = NewLogBuffer(200) + +// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway. +func RegisterProcessAPI(mux *http.ServeMux, absPath string) { + mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) { + handleStatusGateway(w, r, absPath) + }) + mux.HandleFunc("POST /api/process/start", handleStartGateway) + mux.HandleFunc("POST /api/process/stop", handleStopGateway) +} + +func handleStartGateway(w http.ResponseWriter, r *http.Request) { + // Locate picoclaw executable: + // 1. Try same directory as current executable + // 2. Fallback to just "picoclaw" (relies on $PATH) + execPath := "picoclaw" + + if exe, err := os.Executable(); err == nil { + dir := filepath.Dir(exe) + candidate := filepath.Join(dir, "picoclaw") + if runtime.GOOS == "windows" { + candidate += ".exe" + } + + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + execPath = candidate + } + } + + cmd := exec.Command(execPath, "gateway") + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + log.Printf("Failed to create stdout pipe: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) + return + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + log.Printf("Failed to create stderr pipe: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) + return + } + + // Clear old logs and increment runID before starting + gatewayLogs.Reset() + + if err := cmd.Start(); err != nil { + log.Printf("Failed to start picoclaw gateway: %v\n", err) + http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) + return + } + + // Read stdout and stderr into the log buffer + go scanPipe(stdoutPipe, gatewayLogs) + go scanPipe(stderrPipe, gatewayLogs) + + // Wait for the process to exit in the background to avoid zombies + go func() { + if err := cmd.Wait(); err != nil { + log.Printf("Gateway process exited: %v\n", err) + } + }() + + log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": cmd.Process.Pid, + }) +} + +// scanPipe reads lines from r and appends them to buf. It returns when r reaches EOF. +func scanPipe(r io.Reader, buf *LogBuffer) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // up to 1MB per line + + for scanner.Scan() { + buf.Append(scanner.Text()) + } +} + +func handleStopGateway(w http.ResponseWriter, r *http.Request) { + var err error + if runtime.GOOS == "windows" { + // Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe) + // Alternatively, we use powershell to kill processes with commandline containing 'gateway' + psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }` + err = exec.Command("powershell", "-Command", psCmd).Run() + } else { + // Linux/macOS + err = exec.Command("pkill", "-f", "picoclaw gateway").Run() + } + + if err != nil { + log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err) + // We still return 200 OK because pkill returns an error if no process was found + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", // or "not_found" + "msg": "Stop command executed, but returned error (process might not be running).", + "error": err.Error(), + }) + return + } + + log.Printf("Stopped picoclaw gateway processes.\n") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + }) +} + +func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) { + cfg, cfgErr := config.LoadConfig(absPath) + host := "127.0.0.1" + port := 18790 + if cfgErr == nil && cfg != nil { + if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" { + host = cfg.Gateway.Host + } + if cfg.Gateway.Port != 0 { + port = cfg.Gateway.Port + } + } + + url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port))) + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) + + // Build the response data map + data := map[string]any{} + + if err != nil { + data["process_status"] = "stopped" + data["error"] = err.Error() + } else { + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + data["process_status"] = "error" + data["status_code"] = resp.StatusCode + } else { + var healthData map[string]any + if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil { + data["process_status"] = "error" + data["error"] = "invalid response from gateway" + } else { + // Gateway is running and responded properly — merge health data + for k, v := range healthData { + data[k] = v + } + data["process_status"] = "running" + } + } + } + + // Append log data from the buffer + appendLogData(r, data) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +// appendLogData reads log_offset and log_run_id query params from the request and +// populates the response data map with incremental log lines. +func appendLogData(r *http.Request, data map[string]any) { + clientOffset := 0 + clientRunID := -1 + + if v := r.URL.Query().Get("log_offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + clientOffset = n + } + } + + if v := r.URL.Query().Get("log_run_id"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + clientRunID = n + } + } + + runID := gatewayLogs.RunID() + + // If runID is 0 (never reset = never launched from this launcher), report no source + if runID == 0 { + data["logs"] = []string{} + data["log_total"] = 0 + data["log_run_id"] = 0 + data["log_source"] = "none" + return + } + + // If the client's runID doesn't match, send all buffered lines (gateway restarted) + offset := clientOffset + if clientRunID != runID { + offset = 0 + } + + lines, total, runID := gatewayLogs.LinesSince(offset) + if lines == nil { + lines = []string{} + } + + data["logs"] = lines + data["log_total"] = total + data["log_run_id"] = runID + data["log_source"] = "launcher" +} diff --git a/cmd/picoclaw-launcher/internal/server/server.go b/cmd/picoclaw-launcher/internal/server/server.go new file mode 100644 index 000000000..4fc68f04c --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/server.go @@ -0,0 +1,196 @@ +package server + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +const DefaultPort = "18800" + +// providerStatus represents the auth status of a single provider in API responses. +type providerStatus struct { + Provider string `json:"provider"` + AuthMethod string `json:"auth_method"` + Status string `json:"status"` + AccountID string `json:"account_id,omitempty"` + Email string `json:"email,omitempty"` + ProjectID string `json:"project_id,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +// ── Route registration ─────────────────────────────────────────── + +func RegisterConfigAPI(mux *http.ServeMux, absPath string) { + // GET /api/config — read config + mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(absPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "config": cfg, + "path": absPath, + } + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(resp); err != nil { + log.Printf("Failed to encode response: %v", err) + } + }) + + // PUT /api/config — save config + mux.HandleFunc("PUT /api/config", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var cfg config.Config + if err := json.Unmarshal(body, &cfg); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if err := config.SaveConfig(absPath, &cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) +} + +func RegisterAuthAPI(mux *http.ServeMux, absPath string) { + // GET /api/auth/status — all authenticated providers + pending login state + mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) { + store, err := auth.LoadStore() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError) + return + } + + result := []providerStatus{} + for name, cred := range store.Credentials { + status := "active" + if cred.IsExpired() { + status = "expired" + } else if cred.NeedsRefresh() { + status = "needs_refresh" + } + ps := providerStatus{ + Provider: name, + AuthMethod: cred.AuthMethod, + Status: status, + AccountID: cred.AccountID, + Email: cred.Email, + ProjectID: cred.ProjectID, + } + if !cred.ExpiresAt.IsZero() { + ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339) + } + result = append(result, ps) + } + + // Include pending device code state + var pendingDevice map[string]any + activeDeviceSessionMu.Lock() + if activeDeviceSession != nil { + activeDeviceSession.mu.Lock() + pendingDevice = map[string]any{ + "provider": activeDeviceSession.Provider, + "status": activeDeviceSession.Status, + "device_url": activeDeviceSession.Info.VerifyURL, + "user_code": activeDeviceSession.Info.UserCode, + } + if activeDeviceSession.Error != "" { + pendingDevice["error"] = activeDeviceSession.Error + } + if activeDeviceSession.Done { + activeDeviceSession.mu.Unlock() + activeDeviceSession = nil + } else { + activeDeviceSession.mu.Unlock() + } + } + activeDeviceSessionMu.Unlock() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "providers": result, + "pending_device": pendingDevice, + }) + }) + + // POST /api/auth/login — initiate provider login + mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + Token string `json:"token,omitempty"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + switch req.Provider { + case "openai": + handleOpenAILogin(w, absPath) + case "anthropic": + handleAnthropicLogin(w, req.Token, absPath) + case "google-antigravity", "antigravity": + handleGoogleAntigravityLogin(w, r, absPath) + default: + http.Error( + w, + fmt.Sprintf( + "Unsupported provider: %s (supported: openai, anthropic, google-antigravity)", + req.Provider, + ), + http.StatusBadRequest, + ) + } + }) + + // POST /api/auth/logout — logout a provider + mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Provider string `json:"provider"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Provider == "" { + if err := auth.DeleteAllCredentials(); err != nil { + http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError) + return + } + clearAllAuthMethodsInConfig(absPath) + } else { + if err := auth.DeleteCredential(req.Provider); err != nil { + http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError) + return + } + clearAuthMethodInConfig(absPath, req.Provider) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + }) + + // GET /auth/callback — OAuth browser callback for Google Antigravity + mux.HandleFunc("GET /auth/callback", handleOAuthCallback) +} diff --git a/cmd/picoclaw-launcher/internal/server/server_test.go b/cmd/picoclaw-launcher/internal/server/server_test.go new file mode 100644 index 000000000..c87e93d8c --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/server_test.go @@ -0,0 +1,247 @@ +package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// ── Config API tests ───────────────────────────────────────────── + +func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + t.Fatalf("marshal config: %v", err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + + mux := http.NewServeMux() + RegisterConfigAPI(mux, path) + RegisterAuthAPI(mux, path) + return mux, path +} + +func TestGetConfig(t *testing.T) { + cfg := &config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "gpt-4o", Model: "openai/gpt-4o"}, + }, + } + mux, path := setupConfigMux(t, cfg) + + req := httptest.NewRequest("GET", "/api/config", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Config config.Config `json:"config"` + Path string `json:"path"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + if resp.Path != path { + t.Errorf("expected path %q, got %q", path, resp.Path) + } + if len(resp.Config.ModelList) != 1 { + t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList)) + } +} + +func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) { + mux := http.NewServeMux() + RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json") + + req := httptest.NewRequest("GET", "/api/config", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + // LoadConfig returns a default empty config when file is missing + if w.Code != http.StatusOK { + t.Errorf("expected 200 for missing file (default config), got %d", w.Code) + } +} + +func TestPutConfig(t *testing.T) { + cfg := &config.Config{} + mux, path := setupConfigMux(t, cfg) + + newCfg := config.Config{ + ModelList: []config.ModelConfig{ + {ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"}, + }, + } + body, _ := json.Marshal(newCfg) + + req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + saved, err := config.LoadConfig(path) + if err != nil { + t.Fatalf("load saved config: %v", err) + } + if len(saved.ModelList) != 1 { + t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList)) + } + if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" { + t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model) + } +} + +func TestPutConfig_InvalidJSON(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid JSON, got %d", w.Code) + } +} + +// ── Auth API tests ─────────────────────────────────────────────── + +func TestAuthStatus(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("GET", "/api/auth/status", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp struct { + Providers []providerStatus `json:"providers"` + PendingDevice map[string]any `json:"pending_device"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + // providers should be a non-nil list (could be empty) + if resp.Providers == nil { + t.Error("providers should not be nil") + } +} + +func TestAuthLogin_UnsupportedProvider(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + body := `{"provider": "unsupported"}` + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for unsupported provider, got %d", w.Code) + } +} + +func TestAuthLogin_AnthropicNoToken(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + body := `{"provider": "anthropic"}` + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for anthropic without token, got %d", w.Code) + } +} + +func TestAuthLogin_InvalidBody(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid JSON body, got %d", w.Code) + } +} + +func TestAuthLogout_InvalidBody(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad")) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid body, got %d", w.Code) + } +} + +func TestOAuthCallback_InvalidState(t *testing.T) { + cfg := &config.Config{} + mux, _ := setupConfigMux(t, cfg) + + req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil) + w := httptest.NewRecorder() + mux.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400 for invalid state, got %d", w.Code) + } +} + +// ── Utility tests ──────────────────────────────────────────────── + +func TestDefaultConfigPath(t *testing.T) { + path := DefaultConfigPath() + if path == "" { + t.Error("defaultConfigPath should not return empty") + } + if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) { + t.Errorf("expected path ending with .picoclaw/config.json, got %q", path) + } +} + +func TestGetLocalIP(t *testing.T) { + // Just ensure it doesn't panic; IP may or may not be available + ip := GetLocalIP() + if ip != "" { + // If returned, should look like an IP + if !strings.Contains(ip, ".") { + t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip) + } + } +} diff --git a/cmd/picoclaw-launcher/internal/server/utils.go b/cmd/picoclaw-launcher/internal/server/utils.go new file mode 100644 index 000000000..a46adbece --- /dev/null +++ b/cmd/picoclaw-launcher/internal/server/utils.go @@ -0,0 +1,28 @@ +package server + +import ( + "net" + "os" + "path/filepath" +) + +func DefaultConfigPath() string { + home, err := os.UserHomeDir() + if err != nil { + return "config.json" + } + return filepath.Join(home, ".picoclaw", "config.json") +} + +func GetLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + return "" +} diff --git a/cmd/picoclaw-launcher/internal/ui/index.html b/cmd/picoclaw-launcher/internal/ui/index.html new file mode 100644 index 000000000..93893fd75 --- /dev/null +++ b/cmd/picoclaw-launcher/internal/ui/index.html @@ -0,0 +1,1999 @@ + + + + + + + + PicoClaw Config + + + + + + + + + +
+
+ +

PicoClaw Config

+
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + + + +
+
+
Models
+
Manage LLM model configurations. Models without an API key are grayed out. Only available models can be set as primary.
+
+ +
+
+
+ +
+
Provider Authentication
+
+
+
+
+ OpenAI + Not logged in +
+
+
+ +
+
+
+
+ Anthropic + Not logged in +
+
+
+ +
+
+
+
+ Google Antigravity + Not logged in +
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
Gateway Logs
+
Real-time output from the gateway process.
+
+
+ +
+
+ +
+
+
+
No logs available. Start the gateway to see output here.
+
+
+ + +
+
Raw JSON
+
Directly edit the configuration file.
+
+ config.json + - +
+
+ + + +
+
+ +
+
+
+
+
+
+
+
+ + + + +
+ PicoClaw Config + - +
+ + + + + diff --git a/cmd/picoclaw-launcher/main.go b/cmd/picoclaw-launcher/main.go new file mode 100644 index 000000000..3323c31a8 --- /dev/null +++ b/cmd/picoclaw-launcher/main.go @@ -0,0 +1,127 @@ +// PicoClaw Launcher - Standalone HTTP service +// +// Provides a web-based JSON editor for picoclaw config files, +// with OAuth provider authentication support. +// +// Usage: +// +// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/ +// ./picoclaw-launcher [config.json] +// ./picoclaw-launcher -public config.json + +package main + +import ( + "embed" + "flag" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "time" + + "github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server" +) + +//go:embed internal/ui/index.html +var staticFiles embed.FS + +func main() { + public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n") + fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Arguments:\n") + fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0]) + fmt.Fprintf( + os.Stderr, + " %s -public ./config.json Allow access from other devices on the network\n", + os.Args[0], + ) + } + flag.Parse() + + configPath := server.DefaultConfigPath() + if flag.NArg() > 0 { + configPath = flag.Arg(0) + } + + absPath, err := filepath.Abs(configPath) + if err != nil { + log.Fatalf("Failed to resolve config path: %v", err) + } + + var addr string + if *public { + addr = "0.0.0.0:" + server.DefaultPort + } else { + addr = "127.0.0.1:" + server.DefaultPort + } + + mux := http.NewServeMux() + server.RegisterConfigAPI(mux, absPath) + server.RegisterAuthAPI(mux, absPath) + server.RegisterProcessAPI(mux, absPath) + + staticFS, err := fs.Sub(staticFiles, "internal/ui") + if err != nil { + log.Fatalf("Failed to create sub filesystem: %v", err) + } + mux.Handle("/", http.FileServer(http.FS(staticFS))) + + // Print startup banner + fmt.Println("=============================================") + fmt.Println(" PicoClaw Launcher") + fmt.Println("=============================================") + fmt.Printf(" Config file : %s\n", absPath) + fmt.Printf(" Listen addr : %s\n\n", addr) + fmt.Println(" Open the following URL in your browser") + fmt.Println(" to view and edit the configuration:") + fmt.Println() + fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort) + if *public { + if ip := server.GetLocalIP(); ip != "" { + fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort) + } + } + fmt.Println() + // fmt.Println("=============================================") + + go func() { + // Wait briefly to ensure the server is ready before opening the browser + time.Sleep(500 * time.Millisecond) + url := "http://localhost:" + server.DefaultPort + if err := openBrowser(url); err != nil { + log.Printf("Warning: Failed to auto-open browser: %v\n", err) + } + }() + + if err := http.ListenAndServe(addr, mux); err != nil { + log.Fatalf("Server failed: %v", err) + } +} + +// openBrowser automatically opens the given URL in the default browser. +func openBrowser(url string) error { + var err error + switch runtime.GOOS { + case "linux": + err = exec.Command("xdg-open", url).Start() + case "windows": + err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + err = exec.Command("open", url).Start() + default: + err = fmt.Errorf("unsupported platform") + } + return err +} diff --git a/cmd/picoclaw-launcher/winres/winres.json b/cmd/picoclaw-launcher/winres/winres.json new file mode 100644 index 000000000..01ea7364c --- /dev/null +++ b/cmd/picoclaw-launcher/winres/winres.json @@ -0,0 +1,22 @@ +{ + "RT_GROUP_ICON": { + "APP": { + "0000": "../icon.ico" + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "PicoClaw Launcher", + "version": "0.0.0.0" + }, + "description": "PicoClaw Launcher - Web-based configuration editor", + "minimum-os": "win7", + "execution-level": "asInvoker", + "dpi-awareness": "system", + "use-common-controls-v6": true + } + } + } +} diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index ba757ffd4..91c9e25c5 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -66,7 +66,8 @@ func decodeBase64(s string) string { return string(data) } -func generateState() (string, error) { +// GenerateState generates a random state string for OAuth CSRF protection. +func GenerateState() (string, error) { buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil { return "", err @@ -80,7 +81,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { return nil, fmt.Errorf("generating PKCE: %w", err) } - state, err := generateState() + state, err := GenerateState() if err != nil { return nil, fmt.Errorf("generating state: %w", err) } @@ -127,7 +128,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) - if err := openBrowser(authURL); err != nil { + if err := OpenBrowser(authURL); err != nil { fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) } @@ -153,7 +154,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { if result.err != nil { return nil, result.err } - return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI) + return ExchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI) case manualInput := <-manualCh: if manualInput == "" { return nil, fmt.Errorf("manual input canceled") @@ -169,7 +170,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { if code == "" { return nil, fmt.Errorf("could not find authorization code in input") } - return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI) + return ExchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI) case <-time.After(5 * time.Minute): return nil, fmt.Errorf("authentication timed out after 5 minutes") } @@ -186,6 +187,59 @@ type deviceCodeResponse struct { Interval int } +// DeviceCodeInfo holds the device code information returned by the OAuth provider. +type DeviceCodeInfo struct { + DeviceAuthID string `json:"device_auth_id"` + UserCode string `json:"user_code"` + VerifyURL string `json:"verify_url"` + Interval int `json:"interval"` +} + +// RequestDeviceCode requests a device code from the OAuth provider. +// Returns the info needed for the user to authenticate in a browser. +func RequestDeviceCode(cfg OAuthProviderConfig) (*DeviceCodeInfo, error) { + reqBody, _ := json.Marshal(map[string]string{ + "client_id": cfg.ClientID, + }) + + resp, err := http.Post( + cfg.Issuer+"/api/accounts/deviceauth/usercode", + "application/json", + strings.NewReader(string(reqBody)), + ) + if err != nil { + return nil, fmt.Errorf("requesting device code: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("device code request failed: %s", string(body)) + } + + deviceResp, err := parseDeviceCodeResponse(body) + if err != nil { + return nil, fmt.Errorf("parsing device code response: %w", err) + } + + if deviceResp.Interval < 1 { + deviceResp.Interval = 5 + } + + return &DeviceCodeInfo{ + DeviceAuthID: deviceResp.DeviceAuthID, + UserCode: deviceResp.UserCode, + VerifyURL: cfg.Issuer + "/codex/device", + Interval: deviceResp.Interval, + }, nil +} + +// PollDeviceCodeOnce makes a single poll attempt to check if the user has authenticated. +// Returns (credential, nil) on success, (nil, nil) if still pending, or (nil, err) on failure. +func PollDeviceCodeOnce(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) { + return pollDeviceCode(cfg, deviceAuthID, userCode) +} + func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) { var raw struct { DeviceAuthID string `json:"device_auth_id"` @@ -318,7 +372,7 @@ func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*Au } redirectURI := cfg.Issuer + "/deviceauth/callback" - return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI) + return ExchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI) } func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) { @@ -410,7 +464,8 @@ func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU return cfg.Issuer + "/oauth/authorize?" + params.Encode() } -func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) { +// ExchangeCodeForTokens exchanges an authorization code for tokens. +func ExchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) { data := url.Values{ "grant_type": {"authorization_code"}, "code": {code}, @@ -552,7 +607,8 @@ func base64URLDecode(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) } -func openBrowser(url string) error { +// OpenBrowser opens the given URL in the user's default browser. +func OpenBrowser(url string) error { switch runtime.GOOS { case "darwin": return exec.Command("open", url).Start() diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go index 0cb589069..230ac7c2a 100644 --- a/pkg/auth/oauth_test.go +++ b/pkg/auth/oauth_test.go @@ -219,9 +219,9 @@ func TestExchangeCodeForTokens(t *testing.T) { Port: 1455, } - cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback") + cred, err := ExchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback") if err != nil { - t.Fatalf("exchangeCodeForTokens() error: %v", err) + t.Fatalf("ExchangeCodeForTokens() error: %v", err) } if cred.AccessToken != "mock-access-token" { From 08599f8736035f715a38adfc3da25f8e92fa8de9 Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Sat, 28 Feb 2026 18:54:23 +0800 Subject: [PATCH 083/104] build: add armv6 support to goreleaser (#905) Add GOARM=6 targets for both picoclaw and picoclaw-launcher builds to support older ARM devices like Raspberry Pi Zero/1. Co-authored-by: Claude Opus 4.6 --- .goreleaser.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d893b07a8..6dcaf681c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -33,6 +33,7 @@ builds: - loong64 - arm goarm: + - "6" - "7" main: ./cmd/picoclaw ignore: @@ -59,6 +60,7 @@ builds: - loong64 - arm goarm: + - "6" - "7" main: ./cmd/picoclaw-launcher ignore: From 27e988c4845c5358b66770fa92f2df0a39109361 Mon Sep 17 00:00:00 2001 From: taorye Date: Sat, 28 Feb 2026 19:37:18 +0800 Subject: [PATCH 084/104] feat(tui): Add configurable Launcher and Gateway process management (#909) - Implement POSIX-specific gateway process management in gateway_posix.go - Implement Windows-specific gateway process management in gateway_windows.go - Create a menu system in menu.go for user interaction - Develop model management functionality in model.go, including adding, deleting, and testing models - Introduce a style configuration in style.go for consistent UI appearance - Set up the main application entry point in main.go - Update go.mod and go.sum to include necessary dependencies for tcell and tview --- .goreleaser.yaml | 28 + .../internal/config/store.go | 49 ++ cmd/picoclaw-launcher-tui/internal/ui/app.go | 506 +++++++++++++++ .../internal/ui/channel.go | 574 ++++++++++++++++++ .../internal/ui/gateway_posix.go | 16 + .../internal/ui/gateway_windows.go | 16 + cmd/picoclaw-launcher-tui/internal/ui/menu.go | 72 +++ .../internal/ui/model.go | 343 +++++++++++ .../internal/ui/style.go | 37 ++ cmd/picoclaw-launcher-tui/main.go | 15 + go.mod | 5 + go.sum | 10 + 12 files changed, 1671 insertions(+) create mode 100644 cmd/picoclaw-launcher-tui/internal/config/store.go create mode 100644 cmd/picoclaw-launcher-tui/internal/ui/app.go create mode 100644 cmd/picoclaw-launcher-tui/internal/ui/channel.go create mode 100644 cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go create mode 100644 cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go create mode 100644 cmd/picoclaw-launcher-tui/internal/ui/menu.go create mode 100644 cmd/picoclaw-launcher-tui/internal/ui/model.go create mode 100644 cmd/picoclaw-launcher-tui/internal/ui/style.go create mode 100644 cmd/picoclaw-launcher-tui/main.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6dcaf681c..d531d106b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -67,6 +67,33 @@ builds: - goos: windows goarch: arm + - id: picoclaw-launcher-tui + binary: picoclaw-launcher-tui + env: + - CGO_ENABLED=0 + tags: + - stdjson + ldflags: + - -s -w + goos: + - linux + - windows + - darwin + - freebsd + goarch: + - amd64 + - arm64 + - riscv64 + - loong64 + - arm + goarm: + - "6" + - "7" + main: ./cmd/picoclaw-launcher-tui + ignore: + - goos: windows + goarch: arm + dockers_v2: - id: picoclaw dockerfile: docker/Dockerfile.goreleaser @@ -105,6 +132,7 @@ nfpms: builds: - picoclaw - picoclaw-launcher + - picoclaw-launcher-tui package_name: picoclaw file_name_template: >- {{ .PackageName }}_ diff --git a/cmd/picoclaw-launcher-tui/internal/config/store.go b/cmd/picoclaw-launcher-tui/internal/config/store.go new file mode 100644 index 000000000..0236de19f --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/config/store.go @@ -0,0 +1,49 @@ +package configstore + +import ( + "errors" + "os" + "path/filepath" + + picoclawconfig "github.com/sipeed/picoclaw/pkg/config" +) + +const ( + configDirName = ".picoclaw" + configFileName = "config.json" +) + +func ConfigPath() (string, error) { + dir, err := ConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, configFileName), nil +} + +func ConfigDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, configDirName), nil +} + +func Load() (*picoclawconfig.Config, error) { + path, err := ConfigPath() + if err != nil { + return nil, err + } + return picoclawconfig.LoadConfig(path) +} + +func Save(cfg *picoclawconfig.Config) error { + if cfg == nil { + return errors.New("config is nil") + } + path, err := ConfigPath() + if err != nil { + return err + } + return picoclawconfig.SaveConfig(path, cfg) +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go new file mode 100644 index 000000000..4947d6aea --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go @@ -0,0 +1,506 @@ +package ui + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config" + picoclawconfig "github.com/sipeed/picoclaw/pkg/config" +) + +type appState struct { + app *tview.Application + pages *tview.Pages + stack []string + config *picoclawconfig.Config + configPath string + gatewayCmd *exec.Cmd + menus map[string]*Menu + original []byte + hasOriginal bool + backupPath string + dirty bool + logPath string +} + +func Run() error { + applyStyles() + cfg, err := configstore.Load() + if err != nil { + return err + } + path, err := configstore.ConfigPath() + if err != nil { + return err + } + + if cfg == nil { + cfg = picoclawconfig.DefaultConfig() + } + + originalData, hasOriginal := loadOriginalConfig(path) + backupPath := path + ".bak" + if hasOriginal { + _ = writeBackupConfig(backupPath, originalData) + } + + logPath := filepath.Join(filepath.Dir(path), "gateway.log") + state := &appState{ + app: tview.NewApplication(), + pages: tview.NewPages(), + config: cfg, + configPath: path, + menus: map[string]*Menu{}, + original: originalData, + hasOriginal: hasOriginal, + backupPath: backupPath, + logPath: logPath, + } + + state.push("main", state.mainMenu()) + + root := tview.NewFlex().SetDirection(tview.FlexRow) + root.AddItem(bannerView(), 6, 0, false) + root.AddItem(state.pages, 0, 1, true) + + if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil { + return err + } + return nil +} + +func (s *appState) push(name string, primitive tview.Primitive) { + s.pages.AddPage(name, primitive, true, true) + s.stack = append(s.stack, name) + s.pages.SwitchToPage(name) + if menu, ok := primitive.(*Menu); ok { + s.menus[name] = menu + } +} + +func (s *appState) pop() { + if len(s.stack) == 0 { + return + } + last := s.stack[len(s.stack)-1] + s.pages.RemovePage(last) + s.stack = s.stack[:len(s.stack)-1] + if len(s.stack) == 0 { + s.app.Stop() + return + } + current := s.stack[len(s.stack)-1] + s.pages.SwitchToPage(current) + if menu, ok := s.menus[current]; ok { + s.refreshMenu(current, menu) + } +} + +func (s *appState) mainMenu() tview.Primitive { + menu := NewMenu("Config Menu", nil) + refreshMainMenu(menu, s) + menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEsc: + s.requestExit() + return nil + } + if event.Rune() == 'q' { + s.requestExit() + return nil + } + return event + }) + + return menu +} + +func (s *appState) refreshMenu(name string, menu *Menu) { + switch name { + case "main": + refreshMainMenu(menu, s) + case "model": + refreshModelMenuFromState(menu, s) + case "channel": + refreshChannelMenuFromState(menu, s) + } +} + +func refreshMainMenuIfPresent(s *appState) { + if menu, ok := s.menus["main"]; ok { + refreshMainMenu(menu, s) + } +} + +func refreshMainMenu(menu *Menu, s *appState) { + selectedModel := s.selectedModelName() + modelReady := selectedModel != "" + channelReady := s.hasEnabledChannel() + gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() + + gatewayLabel := "Start Gateway" + gatewayDescription := "Launch gateway for channels" + if gatewayRunning { + gatewayLabel = "Stop Gateway" + gatewayDescription = "Gateway running" + } + + items := []MenuItem{ + { + Label: rootModelLabel(selectedModel), + Description: rootModelDescription(selectedModel), + Action: func() { + s.push("model", s.modelMenu()) + }, + MainColor: func() *tcell.Color { + if modelReady { + return nil + } + color := tcell.ColorGray + return &color + }(), + }, + { + Label: rootChannelLabel(channelReady), + Description: rootChannelDescription(channelReady), + Action: func() { + s.push("channel", s.channelMenu()) + }, + MainColor: func() *tcell.Color { + if channelReady { + return nil + } + color := tcell.ColorGray + return &color + }(), + }, + { + Label: "Start Talk", + Description: "Open picoclaw agent in terminal", + Action: func() { + s.requestStartTalk() + }, + Disabled: !modelReady, + }, + { + Label: gatewayLabel, + Description: gatewayDescription, + Action: func() { + if gatewayRunning { + s.stopGateway() + } else { + s.requestStartGateway() + } + refreshMainMenu(menu, s) + }, + Disabled: !gatewayRunning && (!modelReady || !channelReady), + }, + { + Label: "View Gateway Log", + Description: "Open gateway.log", + Action: func() { + s.viewGatewayLog() + }, + }, + { + Label: "Exit", + Description: "Exit the TUI", + Action: func() { + s.requestExit() + }, + }, + } + menu.applyItems(items) +} + +func (s *appState) applyChangesValidated() bool { + if err := s.config.ValidateModelList(); err != nil { + s.showMessage("Validation failed", err.Error()) + return false + } + if err := s.validateAgentModel(); err != nil { + s.showMessage("Validation failed", err.Error()) + return false + } + if err := configstore.Save(s.config); err != nil { + s.showMessage("Save failed", err.Error()) + return false + } + if data, err := os.ReadFile(s.configPath); err == nil { + s.original = data + s.hasOriginal = true + _ = writeBackupConfig(s.backupPath, data) + } + return true +} + +func (s *appState) requestExit() { + if s.dirty { + s.confirmApplyOrDiscard(func() { + s.app.Stop() + }, func() { + s.discardChanges() + s.app.Stop() + }) + return + } + s.app.Stop() +} + +func (s *appState) requestStartTalk() { + if s.dirty { + s.confirmApplyOrDiscard(func() { + s.startTalk() + }, func() { + s.startTalk() + }) + return + } + s.startTalk() +} + +func (s *appState) requestStartGateway() { + if s.dirty { + s.confirmApplyOrDiscard(func() { + s.startGateway() + }, func() { + s.startGateway() + }) + return + } + s.startGateway() +} + +func (s *appState) viewGatewayLog() { + data, err := os.ReadFile(s.logPath) + if err != nil { + s.showMessage("Log not found", "gateway.log not found") + return + } + text := tview.NewTextView() + text.SetBorder(true).SetTitle("Gateway Log") + text.SetText(string(data)) + text.SetDoneFunc(func(key tcell.Key) { + s.pages.RemovePage("log") + }) + text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc { + s.pages.RemovePage("log") + return nil + } + return event + }) + s.pages.AddPage("log", text, true, true) +} + +func (s *appState) selectedModelName() string { + modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) + if modelName == "" { + return "" + } + if !s.isActiveModelValid() { + return "" + } + return modelName +} + +func rootModelLabel(selected string) string { + if selected == "" { + return "Model (no model selected)" + } + return "Model (" + selected + ")" +} + +func rootModelDescription(selected string) string { + if selected == "" { + return "no model selected" + } + return "selected" +} + +func rootChannelLabel(valid bool) string { + if !valid { + return "Channel (no channel enabled)" + } + return "Channel" +} + +func rootChannelDescription(valid bool) string { + if !valid { + return "no channel enabled" + } + return "enabled" +} + +func (s *appState) startTalk() { + if !s.isActiveModelValid() { + s.showMessage("Model required", "Select a valid model before starting talk") + return + } + if !s.applyChangesValidated() { + return + } + s.app.Suspend(func() { + cmd := exec.Command("picoclaw", "agent") + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + _ = cmd.Run() + }) +} + +func (s *appState) startGateway() { + if !s.isActiveModelValid() { + s.showMessage("Model required", "Select a valid model before starting gateway") + return + } + if !s.hasEnabledChannel() { + s.showMessage("Channel required", "Enable at least one channel before starting gateway") + return + } + if !s.applyChangesValidated() { + return + } + _ = stopGatewayProcess() + cmd := exec.Command("picoclaw", "gateway") + logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + s.showMessage("Gateway failed", err.Error()) + return + } + cmd.Stdout = logFile + cmd.Stderr = logFile + if err := cmd.Start(); err != nil { + s.showMessage("Gateway failed", err.Error()) + _ = logFile.Close() + return + } + _ = logFile.Close() + s.gatewayCmd = cmd +} + +func (s *appState) stopGateway() { + _ = stopGatewayProcess() + if s.gatewayCmd != nil && s.gatewayCmd.Process != nil { + _ = s.gatewayCmd.Process.Kill() + } + s.gatewayCmd = nil +} + +func (s *appState) isGatewayRunning() bool { + return isGatewayProcessRunning() +} + +func (s *appState) validateAgentModel() error { + modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) + if modelName == "" { + return nil + } + _, err := s.config.GetModelConfig(modelName) + return err +} + +func (s *appState) isActiveModelValid() bool { + modelName := strings.TrimSpace(s.config.Agents.Defaults.Model) + if modelName == "" { + return false + } + cfg, err := s.config.GetModelConfig(modelName) + if err != nil { + return false + } + hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth" + hasModel := strings.TrimSpace(cfg.Model) != "" + return hasKey && hasModel +} + +func (s *appState) hasEnabledChannel() bool { + c := s.config.Channels + return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled || + c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled || + c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled +} + +func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) { + if s.pages.HasPage("apply") { + return + } + modal := tview.NewModal(). + SetText("Apply changes or discard before continuing?"). + AddButtons([]string{"Cancel", "Discard", "Apply"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + s.pages.RemovePage("apply") + switch buttonLabel { + case "Discard": + s.discardChanges() + if onDiscard != nil { + onDiscard() + } + case "Apply": + if s.applyChangesValidated() { + s.dirty = false + if onApply != nil { + onApply() + } + } + } + }) + modal.SetBorder(true) + s.pages.AddPage("apply", modal, true, true) +} + +func (s *appState) discardChanges() { + if s.hasOriginal { + _ = writeOriginalConfig(s.configPath, s.original) + } else { + _ = os.Remove(s.configPath) + } + _ = os.Remove(s.backupPath) + if cfg, err := configstore.Load(); err == nil && cfg != nil { + s.config = cfg + } + s.dirty = false + refreshMainMenuIfPresent(s) +} + +func (s *appState) showMessage(title, message string) { + if s.pages.HasPage("message") { + return + } + modal := tview.NewModal(). + SetText(strings.TrimSpace(message)). + AddButtons([]string{"OK"}). + SetDoneFunc(func(_ int, _ string) { + s.pages.RemovePage("message") + }) + modal.SetTitle(title).SetBorder(true) + modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor) + modal.SetTextColor(tview.Styles.PrimaryTextColor) + modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255)) + modal.SetButtonTextColor(tview.Styles.PrimaryTextColor) + s.pages.AddPage("message", modal, true, true) +} + +func loadOriginalConfig(path string) ([]byte, bool) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, false + } + return nil, false + } + return data, true +} + +func writeOriginalConfig(path string, data []byte) error { + return os.WriteFile(path, data, 0o600) +} + +func writeBackupConfig(path string, data []byte) error { + return os.WriteFile(path, data, 0o600) +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go new file mode 100644 index 000000000..ad9171424 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go @@ -0,0 +1,574 @@ +package ui + +import ( + "fmt" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + picoclawconfig "github.com/sipeed/picoclaw/pkg/config" +) + +func (s *appState) channelMenu() tview.Primitive { + items := []MenuItem{ + {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, + channelItem( + "Telegram", + "Telegram bot settings", + s.config.Channels.Telegram.Enabled, + func() { s.push("channel-telegram", s.telegramForm()) }, + ), + channelItem( + "Discord", + "Discord bot settings", + s.config.Channels.Discord.Enabled, + func() { s.push("channel-discord", s.discordForm()) }, + ), + channelItem( + "QQ", + "QQ bot settings", + s.config.Channels.QQ.Enabled, + func() { s.push("channel-qq", s.qqForm()) }, + ), + channelItem( + "MaixCam", + "MaixCam gateway", + s.config.Channels.MaixCam.Enabled, + func() { s.push("channel-maixcam", s.maixcamForm()) }, + ), + channelItem( + "WhatsApp", + "WhatsApp bridge", + s.config.Channels.WhatsApp.Enabled, + func() { s.push("channel-whatsapp", s.whatsappForm()) }, + ), + channelItem( + "Feishu", + "Feishu bot settings", + s.config.Channels.Feishu.Enabled, + func() { s.push("channel-feishu", s.feishuForm()) }, + ), + channelItem( + "DingTalk", + "DingTalk bot settings", + s.config.Channels.DingTalk.Enabled, + func() { s.push("channel-dingtalk", s.dingtalkForm()) }, + ), + channelItem( + "Slack", + "Slack bot settings", + s.config.Channels.Slack.Enabled, + func() { s.push("channel-slack", s.slackForm()) }, + ), + channelItem( + "LINE", + "LINE bot settings", + s.config.Channels.LINE.Enabled, + func() { s.push("channel-line", s.lineForm()) }, + ), + channelItem( + "OneBot", + "OneBot settings", + s.config.Channels.OneBot.Enabled, + func() { s.push("channel-onebot", s.onebotForm()) }, + ), + channelItem( + "WeCom", + "WeCom bot settings", + s.config.Channels.WeCom.Enabled, + func() { s.push("channel-wecom", s.wecomForm()) }, + ), + channelItem( + "WeCom App", + "WeCom App settings", + s.config.Channels.WeComApp.Enabled, + func() { s.push("channel-wecomapp", s.wecomAppForm()) }, + ), + } + + menu := NewMenu("Channels", items) + menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc { + s.pop() + return nil + } + if event.Rune() == 'q' { + s.pop() + return nil + } + return event + }) + return menu +} + +func refreshChannelMenuFromState(menu *Menu, s *appState) { + items := []MenuItem{ + {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, + channelItem( + "Telegram", + "Telegram bot settings", + s.config.Channels.Telegram.Enabled, + func() { s.push("channel-telegram", s.telegramForm()) }, + ), + channelItem( + "Discord", + "Discord bot settings", + s.config.Channels.Discord.Enabled, + func() { s.push("channel-discord", s.discordForm()) }, + ), + channelItem( + "QQ", + "QQ bot settings", + s.config.Channels.QQ.Enabled, + func() { s.push("channel-qq", s.qqForm()) }, + ), + channelItem( + "MaixCam", + "MaixCam gateway", + s.config.Channels.MaixCam.Enabled, + func() { s.push("channel-maixcam", s.maixcamForm()) }, + ), + channelItem( + "WhatsApp", + "WhatsApp bridge", + s.config.Channels.WhatsApp.Enabled, + func() { s.push("channel-whatsapp", s.whatsappForm()) }, + ), + channelItem( + "Feishu", + "Feishu bot settings", + s.config.Channels.Feishu.Enabled, + func() { s.push("channel-feishu", s.feishuForm()) }, + ), + channelItem( + "DingTalk", + "DingTalk bot settings", + s.config.Channels.DingTalk.Enabled, + func() { s.push("channel-dingtalk", s.dingtalkForm()) }, + ), + channelItem( + "Slack", + "Slack bot settings", + s.config.Channels.Slack.Enabled, + func() { s.push("channel-slack", s.slackForm()) }, + ), + channelItem( + "LINE", + "LINE bot settings", + s.config.Channels.LINE.Enabled, + func() { s.push("channel-line", s.lineForm()) }, + ), + channelItem( + "OneBot", + "OneBot settings", + s.config.Channels.OneBot.Enabled, + func() { s.push("channel-onebot", s.onebotForm()) }, + ), + channelItem( + "WeCom", + "WeCom bot settings", + s.config.Channels.WeCom.Enabled, + func() { s.push("channel-wecom", s.wecomForm()) }, + ), + channelItem( + "WeCom App", + "WeCom App settings", + s.config.Channels.WeComApp.Enabled, + func() { s.push("channel-wecomapp", s.wecomAppForm()) }, + ), + } + menu.applyItems(items) +} + +func (s *appState) telegramForm() tview.Primitive { + cfg := &s.config.Channels.Telegram + form := baseChannelForm("Telegram", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { + cfg.Token = strings.TrimSpace(text) + }) + form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) { + cfg.Proxy = strings.TrimSpace(text) + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) discordForm() tview.Primitive { + cfg := &s.config.Channels.Discord + form := baseChannelForm("Discord", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { + cfg.Token = strings.TrimSpace(text) + }) + form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) { + cfg.MentionOnly = checked + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) qqForm() tview.Primitive { + cfg := &s.config.Channels.QQ + form := baseChannelForm("QQ", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { + cfg.AppID = strings.TrimSpace(text) + }) + form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { + cfg.AppSecret = strings.TrimSpace(text) + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) maixcamForm() tview.Primitive { + cfg := &s.config.Channels.MaixCam + form := baseChannelForm("MaixCam", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("Host", cfg.Host, 64, nil, func(text string) { + cfg.Host = strings.TrimSpace(text) + }) + addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) whatsappForm() tview.Primitive { + cfg := &s.config.Channels.WhatsApp + form := baseChannelForm("WhatsApp", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) { + cfg.BridgeURL = strings.TrimSpace(text) + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) feishuForm() tview.Primitive { + cfg := &s.config.Channels.Feishu + form := baseChannelForm("Feishu", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) { + cfg.AppID = strings.TrimSpace(text) + }) + form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) { + cfg.AppSecret = strings.TrimSpace(text) + }) + form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) { + cfg.EncryptKey = strings.TrimSpace(text) + }) + form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) { + cfg.VerificationToken = strings.TrimSpace(text) + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) dingtalkForm() tview.Primitive { + cfg := &s.config.Channels.DingTalk + form := baseChannelForm("DingTalk", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) { + cfg.ClientID = strings.TrimSpace(text) + }) + form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) { + cfg.ClientSecret = strings.TrimSpace(text) + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) slackForm() tview.Primitive { + cfg := &s.config.Channels.Slack + form := baseChannelForm("Slack", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) { + cfg.BotToken = strings.TrimSpace(text) + }) + form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) { + cfg.AppToken = strings.TrimSpace(text) + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) lineForm() tview.Primitive { + cfg := &s.config.Channels.LINE + form := baseChannelForm("LINE", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) { + cfg.ChannelSecret = strings.TrimSpace(text) + }) + form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) { + cfg.ChannelAccessToken = strings.TrimSpace(text) + }) + form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { + cfg.WebhookHost = strings.TrimSpace(text) + }) + addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) + form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { + cfg.WebhookPath = strings.TrimSpace(text) + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) onebotForm() tview.Primitive { + cfg := &s.config.Channels.OneBot + form := baseChannelForm("OneBot", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) { + cfg.WSUrl = strings.TrimSpace(text) + }) + form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) { + cfg.AccessToken = strings.TrimSpace(text) + }) + addIntField( + form, + "Reconnect Interval", + cfg.ReconnectInterval, + func(value int) { cfg.ReconnectInterval = value }, + ) + form.AddInputField( + "Group Trigger Prefix", + strings.Join(cfg.GroupTriggerPrefix, ","), + 128, + nil, + func(text string) { + cfg.GroupTriggerPrefix = splitCSV(text) + }, + ) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + return wrapWithBack(form, s) +} + +func (s *appState) wecomForm() tview.Primitive { + cfg := &s.config.Channels.WeCom + form := baseChannelForm("WeCom", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { + cfg.Token = strings.TrimSpace(text) + }) + form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) { + cfg.EncodingAESKey = strings.TrimSpace(text) + }) + form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) { + cfg.WebhookURL = strings.TrimSpace(text) + }) + form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { + cfg.WebhookHost = strings.TrimSpace(text) + }) + addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) + form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { + cfg.WebhookPath = strings.TrimSpace(text) + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + addIntField( + form, + "Reply Timeout", + cfg.ReplyTimeout, + func(value int) { cfg.ReplyTimeout = value }, + ) + return wrapWithBack(form, s) +} + +func (s *appState) wecomAppForm() tview.Primitive { + cfg := &s.config.Channels.WeComApp + form := baseChannelForm("WeCom App", cfg.Enabled, func(v bool) { + cfg.Enabled = v + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["channel"]; ok { + refreshChannelMenuFromState(menu, s) + } + }) + form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) { + cfg.CorpID = strings.TrimSpace(text) + }) + form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) { + cfg.CorpSecret = strings.TrimSpace(text) + }) + addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value }) + form.AddInputField("Token", cfg.Token, 128, nil, func(text string) { + cfg.Token = strings.TrimSpace(text) + }) + form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) { + cfg.EncodingAESKey = strings.TrimSpace(text) + }) + form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) { + cfg.WebhookHost = strings.TrimSpace(text) + }) + addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value }) + form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) { + cfg.WebhookPath = strings.TrimSpace(text) + }) + form.AddInputField("Allow From", strings.Join(cfg.AllowFrom, ","), 128, nil, func(text string) { + cfg.AllowFrom = splitCSV(text) + }) + addIntField( + form, + "Reply Timeout", + cfg.ReplyTimeout, + func(value int) { cfg.ReplyTimeout = value }, + ) + return wrapWithBack(form, s) +} + +func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form { + form := tview.NewForm() + form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title)) + form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123)) + form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22)) + form.AddCheckbox("Enabled", enabled, func(checked bool) { + onEnabled(checked) + }) + return form +} + +func wrapWithBack(form *tview.Form, s *appState) tview.Primitive { + form.AddButton("Back", func() { + s.pop() + }) + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc { + s.pop() + return nil + } + return event + }) + return form +} + +func splitCSV(input string) picoclawconfig.FlexibleStringSlice { + parts := strings.Split(strings.TrimSpace(input), ",") + cleaned := make([]string, 0, len(parts)) + for _, part := range parts { + value := strings.TrimSpace(part) + if value == "" { + continue + } + cleaned = append(cleaned, value) + } + return cleaned +} + +func addIntField(form *tview.Form, label string, value int, onChange func(int)) { + form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { + var parsed int + if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { + onChange(parsed) + } + }) +} + +func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) { + form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { + var parsed int64 + if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { + onChange(parsed) + } + }) +} + +func channelItem(label, description string, enabled bool, action MenuAction) MenuItem { + item := MenuItem{ + Label: label, + Description: description, + Action: action, + } + if !enabled { + color := tcell.ColorGray + item.MainColor = &color + } + return item +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go new file mode 100644 index 000000000..bc874f7f2 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/gateway_posix.go @@ -0,0 +1,16 @@ +//go:build !windows +// +build !windows + +package ui + +import "os/exec" + +func isGatewayProcessRunning() bool { + cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1") + return cmd.Run() == nil +} + +func stopGatewayProcess() error { + cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1") + return cmd.Run() +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go b/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go new file mode 100644 index 000000000..7067a5c13 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/gateway_windows.go @@ -0,0 +1,16 @@ +//go:build windows +// +build windows + +package ui + +import "os/exec" + +func isGatewayProcessRunning() bool { + cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe") + return cmd.Run() == nil +} + +func stopGatewayProcess() error { + cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe") + return cmd.Run() +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/menu.go b/cmd/picoclaw-launcher-tui/internal/ui/menu.go new file mode 100644 index 000000000..9f2132c5a --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/menu.go @@ -0,0 +1,72 @@ +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type MenuAction func() + +type MenuItem struct { + Label string + Description string + Action MenuAction + Disabled bool + MainColor *tcell.Color + DescColor *tcell.Color +} + +type Menu struct { + *tview.Table + items []MenuItem +} + +func NewMenu(title string, items []MenuItem) *Menu { + table := tview.NewTable().SetSelectable(true, false) + table.SetBorder(true).SetTitle(title) + table.SetBorders(false) + menu := &Menu{Table: table, items: items} + menu.applyItems(items) + menu.SetSelectedFunc(func(row, _ int) { + if row < 0 || row >= len(menu.items) { + return + } + item := menu.items[row] + if item.Disabled || item.Action == nil { + return + } + item.Action() + }) + menu.SetSelectedStyle( + tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor). + Background(tcell.NewRGBColor(189, 147, 249)), + ) + return menu +} + +func (m *Menu) applyItems(items []MenuItem) { + m.items = items + m.Clear() + for row, item := range items { + label := item.Label + if item.Disabled && label != "" { + label = label + " (disabled)" + } + left := tview.NewTableCell(label) + right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight) + if item.MainColor != nil { + left.SetTextColor(*item.MainColor) + } + if item.DescColor != nil { + right.SetTextColor(*item.DescColor) + } else { + right.SetTextColor(tview.Styles.TertiaryTextColor) + } + if item.Disabled { + left.SetTextColor(tcell.ColorGray) + right.SetTextColor(tcell.ColorGray) + } + m.SetCell(row, 0, left) + m.SetCell(row, 1, right) + } +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/model.go b/cmd/picoclaw-launcher-tui/internal/ui/model.go new file mode 100644 index 000000000..ba91f5b09 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/model.go @@ -0,0 +1,343 @@ +package ui + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + picoclawconfig "github.com/sipeed/picoclaw/pkg/config" +) + +func (s *appState) modelMenu() tview.Primitive { + items := make([]MenuItem, 0, 2+len(s.config.ModelList)) + items = append(items, + MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, + MenuItem{ + Label: "Add model", + Description: "Append a new model entry", + Action: func() { + s.addModel( + picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"}, + ) + s.push( + fmt.Sprintf("model-%d", len(s.config.ModelList)-1), + s.modelForm(len(s.config.ModelList)-1), + ) + }, + }, + ) + currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) + for i := range s.config.ModelList { + index := i + model := s.config.ModelList[i] + isValid := isModelValid(model) + desc := model.APIBase + if desc == "" { + desc = model.AuthMethod + } + if desc == "" { + desc = "api_key required" + } + label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) + if model.ModelName == currentModel && currentModel != "" { + label = "* " + label + } + isSelected := model.ModelName == currentModel && currentModel != "" + items = append(items, MenuItem{ + Label: label, + Description: desc, + MainColor: modelStatusColor(isValid, isSelected), + Action: func() { + s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) + }, + }) + } + + menu := NewMenu("Models", items) + menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc { + s.pop() + return nil + } + if event.Rune() == 'q' { + s.pop() + return nil + } + if event.Rune() == ' ' { + row, _ := menu.GetSelection() + if row > 0 && row <= len(s.config.ModelList) { + model := s.config.ModelList[row-1] + if !isModelValid(model) { + s.showMessage( + "Invalid model", + "Select a model with api_key or oauth auth_method", + ) + return nil + } + s.config.Agents.Defaults.Model = model.ModelName + s.dirty = true + refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList) + refreshMainMenuIfPresent(s) + } + return nil + } + return event + }) + return menu +} + +func (s *appState) modelForm(index int) tview.Primitive { + model := &s.config.ModelList[index] + form := tview.NewForm() + form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) + form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123)) + form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22)) + + addInput(form, "Model Name", model.ModelName, func(value string) { + model.ModelName = value + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["model"]; ok { + refreshModelMenuFromState(menu, s) + } + }) + addInput(form, "Model", model.Model, func(value string) { + model.Model = value + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["model"]; ok { + refreshModelMenuFromState(menu, s) + } + }) + addInput(form, "API Base", model.APIBase, func(value string) { + model.APIBase = value + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["model"]; ok { + refreshModelMenuFromState(menu, s) + } + }) + addInput(form, "API Key", model.APIKey, func(value string) { + model.APIKey = value + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["model"]; ok { + refreshModelMenuFromState(menu, s) + } + }) + addInput(form, "Proxy", model.Proxy, func(value string) { + model.Proxy = value + }) + addInput(form, "Auth Method", model.AuthMethod, func(value string) { + model.AuthMethod = value + s.dirty = true + refreshMainMenuIfPresent(s) + if menu, ok := s.menus["model"]; ok { + refreshModelMenuFromState(menu, s) + } + }) + addInput(form, "Connect Mode", model.ConnectMode, func(value string) { + model.ConnectMode = value + }) + addInput(form, "Workspace", model.Workspace, func(value string) { + model.Workspace = value + }) + addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) { + model.MaxTokensField = value + }) + addIntInput(form, "RPM", model.RPM, func(value int) { + model.RPM = value + }) + addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) { + model.RequestTimeout = value + }) + + form.AddButton("Delete", func() { + s.deleteModel(index) + }) + form.AddButton("Test", func() { + s.testModel(model) + }) + form.AddButton("Back", func() { + s.pop() + }) + + form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + if event.Key() == tcell.KeyEsc { + s.pop() + return nil + } + return event + }) + return form +} + +func addInput(form *tview.Form, label, value string, onChange func(string)) { + form.AddInputField(label, value, 128, nil, func(text string) { + onChange(strings.TrimSpace(text)) + }) +} + +func addIntInput(form *tview.Form, label string, value int, onChange func(int)) { + form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) { + var parsed int + if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil { + onChange(parsed) + } + }) +} + +func (s *appState) addModel(model picoclawconfig.ModelConfig) { + s.config.ModelList = append(s.config.ModelList, model) +} + +func (s *appState) deleteModel(index int) { + if index < 0 || index >= len(s.config.ModelList) { + return + } + s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...) + s.pop() +} + +func modelStatusColor(valid bool, selected bool) *tcell.Color { + if valid { + color := tview.Styles.PrimaryTextColor + return &color + } + color := tcell.ColorGray + return &color +} + +func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) { + for i, model := range models { + row := i + 1 + label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) + isValid := isModelValid(model) + if model.ModelName == currentModel && currentModel != "" { + label = "* " + label + } + cell := menu.GetCell(row, 0) + if cell != nil { + cell.SetText(label) + isSelected := model.ModelName == currentModel && currentModel != "" + color := modelStatusColor(isValid, isSelected) + if color != nil { + cell.SetTextColor(*color) + } + } + } +} + +func refreshModelMenuFromState(menu *Menu, s *appState) { + items := make([]MenuItem, 0, 2+len(s.config.ModelList)) + items = append(items, + MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, + MenuItem{ + Label: "Add model", + Description: "Append a new model entry", + Action: func() { + s.addModel( + picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"}, + ) + s.push( + fmt.Sprintf("model-%d", len(s.config.ModelList)-1), + s.modelForm(len(s.config.ModelList)-1), + ) + }, + }, + ) + currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) + for i := range s.config.ModelList { + index := i + model := s.config.ModelList[i] + isValid := isModelValid(model) + desc := model.APIBase + if desc == "" { + desc = model.AuthMethod + } + if desc == "" { + desc = "api_key required" + } + label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) + if model.ModelName == currentModel && currentModel != "" { + label = "* " + label + } + isSelected := model.ModelName == currentModel && currentModel != "" + items = append(items, MenuItem{ + Label: label, + Description: desc, + MainColor: modelStatusColor(isValid, isSelected), + Action: func() { + s.push(fmt.Sprintf("model-%d", index), s.modelForm(index)) + }, + }) + } + menu.applyItems(items) +} + +func isModelValid(model picoclawconfig.ModelConfig) bool { + hasKey := strings.TrimSpace(model.APIKey) != "" || + strings.TrimSpace(model.AuthMethod) == "oauth" + hasModel := strings.TrimSpace(model.Model) != "" + return hasKey && hasModel +} + +func (s *appState) testModel(model *picoclawconfig.ModelConfig) { + if model == nil { + return + } + if strings.TrimSpace(model.APIKey) == "" { + s.showMessage("Missing API Key", "Set api_key before testing") + return + } + base := strings.TrimSpace(model.APIBase) + if base == "" { + s.showMessage("Missing API Base", "Set api_base before testing") + return + } + modelID := strings.TrimSpace(model.Model) + if modelID == "" { + s.showMessage("Missing Model", "Set model before testing") + return + } + if !strings.HasPrefix(modelID, "openai/") { + s.showMessage("Unsupported model", "Only openai/* models are supported for test") + return + } + modelName := strings.TrimPrefix(modelID, "openai/") + endpoint := strings.TrimRight(base, "/") + "/chat/completions" + + payload := fmt.Sprintf( + `{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`, + modelName, + ) + client := &http.Client{Timeout: 10 * time.Second} + request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload)) + if err != nil { + s.showMessage("Test failed", err.Error()) + return + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey)) + + resp, err := client.Do(request) + if err != nil { + s.showMessage("Test failed", err.Error()) + return + } + defer resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + s.showMessage("Test OK", resp.Status) + return + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + s.showMessage( + "Test failed", + fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))), + ) +} diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go new file mode 100644 index 000000000..ff4f8b1a8 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/internal/ui/style.go @@ -0,0 +1,37 @@ +package ui + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func applyStyles() { + tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22) + tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53) + tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32) + tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255) + tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198) + tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253) + tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255) + tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123) + tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253) + tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22) + tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249) +} + +func bannerView() *tview.TextView { + text := tview.NewTextView() + text.SetDynamicColors(true) + text.SetTextAlign(tview.AlignCenter) + text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) + text.SetText( + "[::b][#84aaff]██████╗ ██╗ ██████╗ ██████╗ ██████╗██╗ █████╗ ██╗ ██╗\n" + + "[#84aaff]██╔══██╗██║██╔════╝██╔═══██╗██╔════╝██║ ██╔══██╗██║ ██║\n" + + "[#84aaff]██████╔╝██║██║ ██║ ██║██║ ██║ ███████║██║ █╗ ██║\n" + + "[#84aaff]██╔═══╝ ██║██║ ██║ ██║██║ ██║ ██╔══██║██║███╗██║\n" + + "[#84aaff]██║ ██║╚██████╗╚██████╔╝╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + + "[#84aaff]╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝", + ) + text.SetBorder(false) + return text +} diff --git a/cmd/picoclaw-launcher-tui/main.go b/cmd/picoclaw-launcher-tui/main.go new file mode 100644 index 000000000..0e8cce415 --- /dev/null +++ b/cmd/picoclaw-launcher-tui/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "fmt" + "os" + + "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui" +) + +func main() { + if err := ui.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index d7f9b1901..7892cade6 100644 --- a/go.mod +++ b/go.mod @@ -33,13 +33,18 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect + github.com/gdamore/tcell/v2 v2.13.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rivo/tview v0.42.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/vektah/gqlparser/v2 v2.5.27 // indirect diff --git a/go.sum b/go.sum index 941ab67ce..d1ee1d629 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,10 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU= +github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= @@ -113,6 +117,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -148,6 +154,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= From 8207c1c7e6006c0664dfec99fd6bbbda34d2550a Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Sat, 28 Feb 2026 19:59:17 +0800 Subject: [PATCH 085/104] Feat/update migrate (#910) * * update migrate * * rename handlers to sources * * delete dead code * * fix go test error --- cmd/picoclaw/internal/migrate/command.go | 18 +- cmd/picoclaw/internal/migrate/command_test.go | 6 +- pkg/migrate/config.go | 414 ------ .../{workspace.go => internal/common.go} | 86 +- pkg/migrate/internal/common_test.go | 195 +++ pkg/migrate/internal/types.go | 52 + pkg/migrate/migrate.go | 350 ++--- pkg/migrate/migrate_test.go | 1174 +++++------------ pkg/migrate/sources/openclaw/common.go | 29 + .../sources/openclaw/openclaw_config.go | 1074 +++++++++++++++ .../sources/openclaw/openclaw_config_test.go | 714 ++++++++++ .../sources/openclaw/openclaw_handler.go | 148 +++ .../sources/openclaw/openclaw_handler_test.go | 247 ++++ 13 files changed, 3035 insertions(+), 1472 deletions(-) delete mode 100644 pkg/migrate/config.go rename pkg/migrate/{workspace.go => internal/common.go} (55%) create mode 100644 pkg/migrate/internal/common_test.go create mode 100644 pkg/migrate/internal/types.go create mode 100644 pkg/migrate/sources/openclaw/common.go create mode 100644 pkg/migrate/sources/openclaw/openclaw_config.go create mode 100644 pkg/migrate/sources/openclaw/openclaw_config_test.go create mode 100644 pkg/migrate/sources/openclaw/openclaw_handler.go create mode 100644 pkg/migrate/sources/openclaw/openclaw_handler_test.go diff --git a/cmd/picoclaw/internal/migrate/command.go b/cmd/picoclaw/internal/migrate/command.go index fb1cee164..76352c9db 100644 --- a/cmd/picoclaw/internal/migrate/command.go +++ b/cmd/picoclaw/internal/migrate/command.go @@ -11,19 +11,21 @@ func NewMigrateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "migrate", - Short: "Migrate from OpenClaw to PicoClaw", + Short: "Migrate from xxxclaw(openclaw, etc.) to picoclaw", Args: cobra.NoArgs, Example: ` picoclaw migrate + picoclaw migrate --from openclaw picoclaw migrate --dry-run picoclaw migrate --refresh picoclaw migrate --force`, RunE: func(cmd *cobra.Command, _ []string) error { - result, err := migrate.Run(opts) + m := migrate.NewMigrateInstance(opts) + result, err := m.Run(opts) if err != nil { return err } if !opts.DryRun { - migrate.PrintSummary(result) + m.PrintSummary(result) } return nil }, @@ -31,6 +33,8 @@ func NewMigrateCommand() *cobra.Command { cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "Show what would be migrated without making changes") + cmd.Flags().StringVar(&opts.Source, "from", "openclaw", + "Source to migrate from (e.g., openclaw)") cmd.Flags().BoolVar(&opts.Refresh, "refresh", false, "Re-sync workspace files from OpenClaw (repeatable)") cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false, @@ -39,10 +43,10 @@ func NewMigrateCommand() *cobra.Command { "Only migrate workspace files, skip config") cmd.Flags().BoolVar(&opts.Force, "force", false, "Skip confirmation prompts") - cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "", - "Override OpenClaw home directory (default: ~/.openclaw)") - cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "", - "Override PicoClaw home directory (default: ~/.picoclaw)") + cmd.Flags().StringVar(&opts.SourceHome, "source-home", "", + "Override source home directory (default: ~/.openclaw)") + cmd.Flags().StringVar(&opts.TargetHome, "target-home", "", + "Override target home directory (default: ~/.picoclaw)") return cmd } diff --git a/cmd/picoclaw/internal/migrate/command_test.go b/cmd/picoclaw/internal/migrate/command_test.go index 1948aa327..5110249a2 100644 --- a/cmd/picoclaw/internal/migrate/command_test.go +++ b/cmd/picoclaw/internal/migrate/command_test.go @@ -13,7 +13,7 @@ func TestNewMigrateCommand(t *testing.T) { require.NotNil(t, cmd) assert.Equal(t, "migrate", cmd.Use) - assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short) + assert.Equal(t, "Migrate from xxxclaw(openclaw, etc.) to picoclaw", cmd.Short) assert.Len(t, cmd.Aliases, 0) @@ -33,6 +33,6 @@ func TestNewMigrateCommand(t *testing.T) { assert.NotNil(t, cmd.Flags().Lookup("config-only")) assert.NotNil(t, cmd.Flags().Lookup("workspace-only")) assert.NotNil(t, cmd.Flags().Lookup("force")) - assert.NotNil(t, cmd.Flags().Lookup("openclaw-home")) - assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home")) + assert.NotNil(t, cmd.Flags().Lookup("source-home")) + assert.NotNil(t, cmd.Flags().Lookup("target-home")) } diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go deleted file mode 100644 index ea91565e8..000000000 --- a/pkg/migrate/config.go +++ /dev/null @@ -1,414 +0,0 @@ -package migrate - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "unicode" - - "github.com/sipeed/picoclaw/pkg/config" -) - -var supportedProviders = map[string]bool{ - "anthropic": true, - "openai": true, - "openrouter": true, - "groq": true, - "zhipu": true, - "vllm": true, - "gemini": true, - "qwen": true, - "deepseek": true, - "github_copilot": true, - "mistral": true, -} - -var supportedChannels = map[string]bool{ - "telegram": true, - "discord": true, - "whatsapp": true, - "feishu": true, - "qq": true, - "dingtalk": true, - "maixcam": true, -} - -func findOpenClawConfig(openclawHome string) (string, error) { - candidates := []string{ - filepath.Join(openclawHome, "openclaw.json"), - filepath.Join(openclawHome, "config.json"), - } - for _, p := range candidates { - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome) -} - -func LoadOpenClawConfig(configPath string) (map[string]any, error) { - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("reading OpenClaw config: %w", err) - } - - var raw map[string]any - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parsing OpenClaw config: %w", err) - } - - converted := convertKeysToSnake(raw) - result, ok := converted.(map[string]any) - if !ok { - return nil, fmt.Errorf("unexpected config format") - } - return result, nil -} - -func ConvertConfig(data map[string]any) (*config.Config, []string, error) { - cfg := config.DefaultConfig() - var warnings []string - - if agents, ok := getMap(data, "agents"); ok { - if defaults, ok := getMap(agents, "defaults"); ok { - // Prefer model_name, fallback to model for backward compatibility - if v, ok := getString(defaults, "model_name"); ok { - cfg.Agents.Defaults.ModelName = v - } else if v, ok := getString(defaults, "model"); ok { - cfg.Agents.Defaults.Model = v - } - if v, ok := getFloat(defaults, "max_tokens"); ok { - cfg.Agents.Defaults.MaxTokens = int(v) - } - if v, ok := getFloat(defaults, "temperature"); ok { - cfg.Agents.Defaults.Temperature = &v - } - if v, ok := getFloat(defaults, "max_tool_iterations"); ok { - cfg.Agents.Defaults.MaxToolIterations = int(v) - } - if v, ok := getString(defaults, "workspace"); ok { - cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v) - } - } - } - - if providers, ok := getMap(data, "providers"); ok { - for name, val := range providers { - pMap, ok := val.(map[string]any) - if !ok { - continue - } - apiKey, _ := getString(pMap, "api_key") - apiBase, _ := getString(pMap, "api_base") - - if !supportedProviders[name] { - if apiKey != "" || apiBase != "" { - warnings = append(warnings, fmt.Sprintf("Provider '%s' not supported in PicoClaw, skipping", name)) - } - continue - } - - pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase} - switch name { - case "anthropic": - cfg.Providers.Anthropic = pc - case "openai": - cfg.Providers.OpenAI = config.OpenAIProviderConfig{ - ProviderConfig: pc, - WebSearch: getBoolOrDefault(pMap, "web_search", true), - } - case "openrouter": - cfg.Providers.OpenRouter = pc - case "groq": - cfg.Providers.Groq = pc - case "zhipu": - cfg.Providers.Zhipu = pc - case "vllm": - cfg.Providers.VLLM = pc - case "gemini": - cfg.Providers.Gemini = pc - } - } - } - - if channels, ok := getMap(data, "channels"); ok { - for name, val := range channels { - cMap, ok := val.(map[string]any) - if !ok { - continue - } - if !supportedChannels[name] { - warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in PicoClaw, skipping", name)) - continue - } - enabled, _ := getBool(cMap, "enabled") - allowFrom := getStringSlice(cMap, "allow_from") - - switch name { - case "telegram": - cfg.Channels.Telegram.Enabled = enabled - cfg.Channels.Telegram.AllowFrom = allowFrom - if v, ok := getString(cMap, "token"); ok { - cfg.Channels.Telegram.Token = v - } - case "discord": - cfg.Channels.Discord.Enabled = enabled - cfg.Channels.Discord.AllowFrom = allowFrom - if v, ok := getString(cMap, "token"); ok { - cfg.Channels.Discord.Token = v - } - case "whatsapp": - cfg.Channels.WhatsApp.Enabled = enabled - cfg.Channels.WhatsApp.AllowFrom = allowFrom - if v, ok := getString(cMap, "bridge_url"); ok { - cfg.Channels.WhatsApp.BridgeURL = v - } - if v, ok := getBool(cMap, "use_native"); ok { - cfg.Channels.WhatsApp.UseNative = v - } - if v, ok := getString(cMap, "session_store_path"); ok { - cfg.Channels.WhatsApp.SessionStorePath = v - } - case "feishu": - cfg.Channels.Feishu.Enabled = enabled - cfg.Channels.Feishu.AllowFrom = allowFrom - if v, ok := getString(cMap, "app_id"); ok { - cfg.Channels.Feishu.AppID = v - } - if v, ok := getString(cMap, "app_secret"); ok { - cfg.Channels.Feishu.AppSecret = v - } - if v, ok := getString(cMap, "encrypt_key"); ok { - cfg.Channels.Feishu.EncryptKey = v - } - if v, ok := getString(cMap, "verification_token"); ok { - cfg.Channels.Feishu.VerificationToken = v - } - case "qq": - cfg.Channels.QQ.Enabled = enabled - cfg.Channels.QQ.AllowFrom = allowFrom - if v, ok := getString(cMap, "app_id"); ok { - cfg.Channels.QQ.AppID = v - } - if v, ok := getString(cMap, "app_secret"); ok { - cfg.Channels.QQ.AppSecret = v - } - case "dingtalk": - cfg.Channels.DingTalk.Enabled = enabled - cfg.Channels.DingTalk.AllowFrom = allowFrom - if v, ok := getString(cMap, "client_id"); ok { - cfg.Channels.DingTalk.ClientID = v - } - if v, ok := getString(cMap, "client_secret"); ok { - cfg.Channels.DingTalk.ClientSecret = v - } - case "maixcam": - cfg.Channels.MaixCam.Enabled = enabled - cfg.Channels.MaixCam.AllowFrom = allowFrom - if v, ok := getString(cMap, "host"); ok { - cfg.Channels.MaixCam.Host = v - } - if v, ok := getFloat(cMap, "port"); ok { - cfg.Channels.MaixCam.Port = int(v) - } - } - } - } - - if gateway, ok := getMap(data, "gateway"); ok { - if v, ok := getString(gateway, "host"); ok { - cfg.Gateway.Host = v - } - if v, ok := getFloat(gateway, "port"); ok { - cfg.Gateway.Port = int(v) - } - } - - if tools, ok := getMap(data, "tools"); ok { - if web, ok := getMap(tools, "web"); ok { - // Migrate old "search" config to "brave" if api_key is present - if search, ok := getMap(web, "search"); ok { - if v, ok := getString(search, "api_key"); ok { - cfg.Tools.Web.Brave.APIKey = v - if v != "" { - cfg.Tools.Web.Brave.Enabled = true - } - } - if v, ok := getFloat(search, "max_results"); ok { - cfg.Tools.Web.Brave.MaxResults = int(v) - cfg.Tools.Web.DuckDuckGo.MaxResults = int(v) - } - } - } - } - - return cfg, warnings, nil -} - -func MergeConfig(existing, incoming *config.Config) *config.Config { - if existing.Providers.Anthropic.APIKey == "" { - existing.Providers.Anthropic = incoming.Providers.Anthropic - } - if existing.Providers.OpenAI.APIKey == "" { - existing.Providers.OpenAI = incoming.Providers.OpenAI - } - if existing.Providers.OpenRouter.APIKey == "" { - existing.Providers.OpenRouter = incoming.Providers.OpenRouter - } - if existing.Providers.Groq.APIKey == "" { - existing.Providers.Groq = incoming.Providers.Groq - } - if existing.Providers.Zhipu.APIKey == "" { - existing.Providers.Zhipu = incoming.Providers.Zhipu - } - if existing.Providers.VLLM.APIKey == "" && existing.Providers.VLLM.APIBase == "" { - existing.Providers.VLLM = incoming.Providers.VLLM - } - if existing.Providers.Gemini.APIKey == "" { - existing.Providers.Gemini = incoming.Providers.Gemini - } - if existing.Providers.DeepSeek.APIKey == "" { - existing.Providers.DeepSeek = incoming.Providers.DeepSeek - } - if existing.Providers.GitHubCopilot.APIBase == "" { - existing.Providers.GitHubCopilot = incoming.Providers.GitHubCopilot - } - if existing.Providers.Qwen.APIKey == "" { - existing.Providers.Qwen = incoming.Providers.Qwen - } - - if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled { - existing.Channels.Telegram = incoming.Channels.Telegram - } - if !existing.Channels.Discord.Enabled && incoming.Channels.Discord.Enabled { - existing.Channels.Discord = incoming.Channels.Discord - } - if !existing.Channels.WhatsApp.Enabled && incoming.Channels.WhatsApp.Enabled { - existing.Channels.WhatsApp = incoming.Channels.WhatsApp - } - if !existing.Channels.Feishu.Enabled && incoming.Channels.Feishu.Enabled { - existing.Channels.Feishu = incoming.Channels.Feishu - } - if !existing.Channels.QQ.Enabled && incoming.Channels.QQ.Enabled { - existing.Channels.QQ = incoming.Channels.QQ - } - if !existing.Channels.DingTalk.Enabled && incoming.Channels.DingTalk.Enabled { - existing.Channels.DingTalk = incoming.Channels.DingTalk - } - if !existing.Channels.MaixCam.Enabled && incoming.Channels.MaixCam.Enabled { - existing.Channels.MaixCam = incoming.Channels.MaixCam - } - - if existing.Tools.Web.Brave.APIKey == "" { - existing.Tools.Web.Brave = incoming.Tools.Web.Brave - } - - return existing -} - -func camelToSnake(s string) string { - var result strings.Builder - for i, r := range s { - if unicode.IsUpper(r) { - if i > 0 { - prev := rune(s[i-1]) - if unicode.IsLower(prev) || unicode.IsDigit(prev) { - result.WriteRune('_') - } else if unicode.IsUpper(prev) && i+1 < len(s) && unicode.IsLower(rune(s[i+1])) { - result.WriteRune('_') - } - } - result.WriteRune(unicode.ToLower(r)) - } else { - result.WriteRune(r) - } - } - return result.String() -} - -func convertKeysToSnake(data any) any { - switch v := data.(type) { - case map[string]any: - result := make(map[string]any, len(v)) - for key, val := range v { - result[camelToSnake(key)] = convertKeysToSnake(val) - } - return result - case []any: - result := make([]any, len(v)) - for i, val := range v { - result[i] = convertKeysToSnake(val) - } - return result - default: - return data - } -} - -func rewriteWorkspacePath(path string) string { - path = strings.Replace(path, ".openclaw", ".picoclaw", 1) - return path -} - -func getMap(data map[string]any, key string) (map[string]any, bool) { - v, ok := data[key] - if !ok { - return nil, false - } - m, ok := v.(map[string]any) - return m, ok -} - -func getString(data map[string]any, key string) (string, bool) { - v, ok := data[key] - if !ok { - return "", false - } - s, ok := v.(string) - return s, ok -} - -func getFloat(data map[string]any, key string) (float64, bool) { - v, ok := data[key] - if !ok { - return 0, false - } - f, ok := v.(float64) - return f, ok -} - -func getBool(data map[string]any, key string) (bool, bool) { - v, ok := data[key] - if !ok { - return false, false - } - b, ok := v.(bool) - return b, ok -} - -func getBoolOrDefault(data map[string]any, key string, defaultVal bool) bool { - if v, ok := getBool(data, key); ok { - return v - } - return defaultVal -} - -func getStringSlice(data map[string]any, key string) []string { - v, ok := data[key] - if !ok { - return []string{} - } - arr, ok := v.([]any) - if !ok { - return []string{} - } - result := make([]string, 0, len(arr)) - for _, item := range arr { - if s, ok := item.(string); ok { - result = append(result, s) - } - } - return result -} diff --git a/pkg/migrate/workspace.go b/pkg/migrate/internal/common.go similarity index 55% rename from pkg/migrate/workspace.go rename to pkg/migrate/internal/common.go index f45748fac..c77ab9f26 100644 --- a/pkg/migrate/workspace.go +++ b/pkg/migrate/internal/common.go @@ -1,24 +1,50 @@ -package migrate +package internal import ( + "fmt" + "io" "os" "path/filepath" ) -var migrateableFiles = []string{ - "AGENTS.md", - "SOUL.md", - "USER.md", - "TOOLS.md", - "HEARTBEAT.md", +func ResolveTargetHome(override string) (string, error) { + if override != "" { + return ExpandHome(override), nil + } + if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" { + return ExpandHome(envHome), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, ".picoclaw"), nil } -var migrateableDirs = []string{ - "memory", - "skills", +func ExpandHome(path string) string { + if path == "" { + return path + } + if path[0] == '~' { + home, _ := os.UserHomeDir() + if len(path) > 1 && path[1] == '/' { + return home + path[1:] + } + return home + } + return path } -func PlanWorkspaceMigration(srcWorkspace, dstWorkspace string, force bool) ([]Action, error) { +func ResolveWorkspace(homeDir string) string { + return filepath.Join(homeDir, "workspace") +} + +func PlanWorkspaceMigration( + srcWorkspace, dstWorkspace string, + migrateableFiles []string, + migrateableDirs []string, + force bool, +) ([]Action, error) { var actions []Action for _, filename := range migrateableFiles { @@ -50,7 +76,7 @@ func planFileCopy(src, dst string, force bool) Action { return Action{ Type: ActionSkip, Source: src, - Destination: dst, + Target: dst, Description: "source file not found", } } @@ -60,7 +86,7 @@ func planFileCopy(src, dst string, force bool) Action { return Action{ Type: ActionBackup, Source: src, - Destination: dst, + Target: dst, Description: "destination exists, will backup and overwrite", } } @@ -68,7 +94,7 @@ func planFileCopy(src, dst string, force bool) Action { return Action{ Type: ActionCopy, Source: src, - Destination: dst, + Target: dst, Description: "copy file", } } @@ -91,7 +117,7 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) { if info.IsDir() { actions = append(actions, Action{ Type: ActionCreateDir, - Destination: dst, + Target: dst, Description: "create directory", }) return nil @@ -104,3 +130,33 @@ func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) { return actions, err } + +func RelPath(path, base string) string { + rel, err := filepath.Rel(base, path) + if err != nil { + return filepath.Base(path) + } + return rel +} + +func CopyFile(src, dst string) error { + srcFile, err := os.Open(src) + if err != nil { + return err + } + defer srcFile.Close() + + info, err := srcFile.Stat() + if err != nil { + return err + } + + dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) + if err != nil { + return err + } + defer dstFile.Close() + + _, err = io.Copy(dstFile, srcFile) + return err +} diff --git a/pkg/migrate/internal/common_test.go b/pkg/migrate/internal/common_test.go new file mode 100644 index 000000000..a089157f5 --- /dev/null +++ b/pkg/migrate/internal/common_test.go @@ -0,0 +1,195 @@ +package internal + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExpandHome(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", ""}, + {"/absolute/path", "/absolute/path"}, + {"relative/path", "relative/path"}, + } + + for _, tt := range tests { + result := ExpandHome(tt.input) + assert.Equal(t, tt.expected, result) + } +} + +func TestExpandHomeWithTilde(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + + result := ExpandHome("~/path") + assert.Equal(t, home+"/path", result) + + result = ExpandHome("~") + assert.Equal(t, home, result) +} + +func TestResolveWorkspace(t *testing.T) { + result := ResolveWorkspace("/home/user/.picoclaw") + assert.Equal(t, "/home/user/.picoclaw/workspace", result) +} + +func TestRelPath(t *testing.T) { + result := RelPath("/home/user/.picoclaw/workspace/file.txt", "/home/user/.picoclaw") + assert.Equal(t, "workspace/file.txt", result) +} + +func TestRelPathError(t *testing.T) { + result := RelPath("relative/path", "/different/base") + assert.Equal(t, "path", result) +} + +func TestResolveTargetHome(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + + result, err := ResolveTargetHome("") + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".picoclaw"), result) +} + +func TestResolveTargetHomeWithOverride(t *testing.T) { + result, err := ResolveTargetHome("/custom/path") + require.NoError(t, err) + assert.Equal(t, "/custom/path", result) +} + +func TestCopyFile(t *testing.T) { + tmpDir := t.TempDir() + + sourceFile := filepath.Join(tmpDir, "source.txt") + err := os.WriteFile(sourceFile, []byte("test content"), 0o644) + require.NoError(t, err) + + dstFile := filepath.Join(tmpDir, "dest.txt") + err = CopyFile(sourceFile, dstFile) + require.NoError(t, err) + + content, err := os.ReadFile(dstFile) + require.NoError(t, err) + assert.Equal(t, "test content", string(content)) +} + +func TestCopyFileSourceNotFound(t *testing.T) { + tmpDir := t.TempDir() + + err := CopyFile(filepath.Join(tmpDir, "nonexistent.txt"), filepath.Join(tmpDir, "dest.txt")) + require.Error(t, err) +} + +func TestPlanWorkspaceMigration(t *testing.T) { + tmpDir := t.TempDir() + srcWorkspace := filepath.Join(tmpDir, "src", "workspace") + dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") + + err := os.MkdirAll(srcWorkspace, 0o755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("content"), 0o644) + require.NoError(t, err) + + err = os.MkdirAll(filepath.Join(srcWorkspace, "subdir"), 0o755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(srcWorkspace, "subdir", "file2.txt"), []byte("content"), 0o644) + require.NoError(t, err) + + actions, err := PlanWorkspaceMigration( + srcWorkspace, + dstWorkspace, + []string{"file1.txt"}, + []string{"subdir"}, + false, + ) + require.NoError(t, err) + + assert.GreaterOrEqual(t, len(actions), 1) +} + +func TestPlanWorkspaceMigrationWithExistingDestination(t *testing.T) { + tmpDir := t.TempDir() + srcWorkspace := filepath.Join(tmpDir, "src", "workspace") + dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") + + err := os.MkdirAll(srcWorkspace, 0o755) + require.NoError(t, err) + + err = os.MkdirAll(dstWorkspace, 0o755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644) + require.NoError(t, err) + + actions, err := PlanWorkspaceMigration( + srcWorkspace, + dstWorkspace, + []string{"file1.txt"}, + []string{}, + false, + ) + require.NoError(t, err) + + require.GreaterOrEqual(t, len(actions), 1) + assert.Equal(t, ActionBackup, actions[0].Type) +} + +func TestPlanWorkspaceMigrationForce(t *testing.T) { + tmpDir := t.TempDir() + srcWorkspace := filepath.Join(tmpDir, "src", "workspace") + dstWorkspace := filepath.Join(tmpDir, "dst", "workspace") + + err := os.MkdirAll(srcWorkspace, 0o755) + require.NoError(t, err) + + err = os.MkdirAll(dstWorkspace, 0o755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(srcWorkspace, "file1.txt"), []byte("source"), 0o644) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(dstWorkspace, "file1.txt"), []byte("existing"), 0o644) + require.NoError(t, err) + + actions, err := PlanWorkspaceMigration( + srcWorkspace, + dstWorkspace, + []string{"file1.txt"}, + []string{}, + true, + ) + require.NoError(t, err) + + require.GreaterOrEqual(t, len(actions), 1) + assert.Equal(t, ActionCopy, actions[0].Type) +} + +func TestPlanWorkspaceMigrationNonExistentSource(t *testing.T) { + tmpDir := t.TempDir() + + actions, err := PlanWorkspaceMigration( + filepath.Join(tmpDir, "nonexistent"), + filepath.Join(tmpDir, "dst", "workspace"), + []string{"file1.txt"}, + []string{}, + false, + ) + require.NoError(t, err) + require.Len(t, actions, 1) + assert.Equal(t, ActionSkip, actions[0].Type) + assert.Contains(t, actions[0].Description, "source file not found") +} diff --git a/pkg/migrate/internal/types.go b/pkg/migrate/internal/types.go new file mode 100644 index 000000000..e86a4dea1 --- /dev/null +++ b/pkg/migrate/internal/types.go @@ -0,0 +1,52 @@ +package internal + +type Options struct { + DryRun bool + ConfigOnly bool + WorkspaceOnly bool + Force bool + Refresh bool + Source string + SourceHome string + TargetHome string +} + +type Operation interface { + GetSourceName() string + GetSourceHome() (string, error) + GetSourceWorkspace() (string, error) + GetSourceConfigFile() (string, error) + ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error + GetMigrateableFiles() []string + GetMigrateableDirs() []string +} + +type HandlerFactory func(opts Options) Operation + +type ActionType int + +const ( + ActionCopy ActionType = iota + ActionSkip + ActionBackup + ActionConvertConfig + ActionCreateDir + ActionMergeConfig +) + +type Action struct { + Type ActionType + Source string + Target string + Description string +} + +type Result struct { + FilesCopied int + FilesSkipped int + BackupsCreated int + ConfigMigrated bool + DirsCreated int + Warnings []string + Errors []error +} diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go index cfa82b7d7..51fecf438 100644 --- a/pkg/migrate/migrate.go +++ b/pkg/migrate/migrate.go @@ -2,53 +2,73 @@ package migrate import ( "fmt" - "io" "os" "path/filepath" "strings" - "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/migrate/internal" + "github.com/sipeed/picoclaw/pkg/migrate/sources/openclaw" ) -type ActionType int +type ( + Options = internal.Options + Operation = internal.Operation + ActionType = internal.ActionType + Action = internal.Action + Result = internal.Result + HandlerFactory = internal.HandlerFactory +) const ( - ActionCopy ActionType = iota - ActionSkip - ActionBackup - ActionConvertConfig - ActionCreateDir - ActionMergeConfig + ActionCopy = internal.ActionCopy + ActionSkip = internal.ActionSkip + ActionBackup = internal.ActionBackup + ActionConvertConfig = internal.ActionConvertConfig + ActionCreateDir = internal.ActionCreateDir + ActionMergeConfig = internal.ActionMergeConfig ) -type Options struct { - DryRun bool - ConfigOnly bool - WorkspaceOnly bool - Force bool - Refresh bool - OpenClawHome string - PicoClawHome string +type MigrateInstance struct { + options Options + handlers map[string]Operation } -type Action struct { - Type ActionType - Source string - Destination string - Description string +func NewMigrateInstance(opts Options) *MigrateInstance { + instance := &MigrateInstance{ + options: opts, + handlers: make(map[string]Operation), + } + + openclaw_handler, err := openclaw.NewOpenclawHandler(opts) + if err == nil { + instance.Register(openclaw_handler.GetSourceName(), openclaw_handler) + } + + return instance } -type Result struct { - FilesCopied int - FilesSkipped int - BackupsCreated int - ConfigMigrated bool - DirsCreated int - Warnings []string - Errors []error +func (m *MigrateInstance) Register(moduleName string, module Operation) { + m.handlers[moduleName] = module } -func Run(opts Options) (*Result, error) { +func (m *MigrateInstance) getCurrentHandler() (Operation, error) { + source := m.options.Source + if source == "" { + source = "openclaw" + } + handler, ok := m.handlers[source] + if !ok { + return nil, fmt.Errorf("Source '%s' not found", source) + } + return handler, nil +} + +func (m *MigrateInstance) Run(opts Options) (*Result, error) { + handler, err := m.getCurrentHandler() + if err != nil { + return nil, err + } + if opts.ConfigOnly && opts.WorkspaceOnly { return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive") } @@ -57,28 +77,28 @@ func Run(opts Options) (*Result, error) { opts.WorkspaceOnly = true } - openclawHome, err := resolveOpenClawHome(opts.OpenClawHome) + sourceHome, err := handler.GetSourceHome() if err != nil { return nil, err } - picoClawHome, err := resolvePicoClawHome(opts.PicoClawHome) + targetHome, err := internal.ResolveTargetHome(opts.TargetHome) if err != nil { return nil, err } - if _, err = os.Stat(openclawHome); os.IsNotExist(err) { - return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome) + if _, err = os.Stat(sourceHome); os.IsNotExist(err) { + return nil, fmt.Errorf("Source installation not found at %s", sourceHome) } - actions, warnings, err := Plan(opts, openclawHome, picoClawHome) + actions, warnings, err := m.Plan(opts, sourceHome, targetHome) if err != nil { return nil, err } - fmt.Println("Migrating from OpenClaw to PicoClaw") - fmt.Printf(" Source: %s\n", openclawHome) - fmt.Printf(" Destination: %s\n", picoClawHome) + fmt.Println("Migrating from Source to PicoClaw") + fmt.Printf(" Source: %s\n", sourceHome) + fmt.Printf(" Target: %s\n", targetHome) fmt.Println() if opts.DryRun { @@ -95,19 +115,23 @@ func Run(opts Options) (*Result, error) { fmt.Println() } - result := Execute(actions, openclawHome, picoClawHome) + result := m.Execute(actions, sourceHome, targetHome) result.Warnings = warnings return result, nil } -func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string, error) { +func (m *MigrateInstance) Plan(opts Options, sourceHome, targetHome string) ([]Action, []string, error) { var actions []Action var warnings []string + handler, err := m.getCurrentHandler() + if err != nil { + return nil, nil, err + } force := opts.Force || opts.Refresh if !opts.WorkspaceOnly { - configPath, err := findOpenClawConfig(openclawHome) + configPath, err := handler.GetSourceConfigFile() if err != nil { if opts.ConfigOnly { return nil, nil, err @@ -117,91 +141,95 @@ func Plan(opts Options, openclawHome, picoClawHome string) ([]Action, []string, actions = append(actions, Action{ Type: ActionConvertConfig, Source: configPath, - Destination: filepath.Join(picoClawHome, "config.json"), - Description: "convert OpenClaw config to PicoClaw format", + Target: filepath.Join(targetHome, "config.json"), + Description: "convert Source config to PicoClaw format", }) - - data, err := LoadOpenClawConfig(configPath) - if err == nil { - _, configWarnings, _ := ConvertConfig(data) - warnings = append(warnings, configWarnings...) - } } } if !opts.ConfigOnly { - srcWorkspace := resolveWorkspace(openclawHome) - dstWorkspace := resolveWorkspace(picoClawHome) + srcWorkspace, err := handler.GetSourceWorkspace() + if err != nil { + return nil, nil, fmt.Errorf("getting source workspace: %w", err) + } + dstWorkspace := internal.ResolveWorkspace(targetHome) if _, err := os.Stat(srcWorkspace); err == nil { - wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force) + wsActions, err := internal.PlanWorkspaceMigration(srcWorkspace, dstWorkspace, + handler.GetMigrateableFiles(), + handler.GetMigrateableDirs(), + force) if err != nil { return nil, nil, fmt.Errorf("planning workspace migration: %w", err) } actions = append(actions, wsActions...) } else { - warnings = append(warnings, "OpenClaw workspace directory not found, skipping workspace migration") + warnings = append(warnings, "Source workspace directory not found, skipping workspace migration") } } return actions, warnings, nil } -func Execute(actions []Action, openclawHome, picoClawHome string) *Result { +func (m *MigrateInstance) Execute(actions []Action, sourceHome, targetHome string) *Result { result := &Result{} + handler, err := m.getCurrentHandler() + if err != nil { + return result + } for _, action := range actions { switch action.Type { case ActionConvertConfig: - if err := executeConfigMigration(action.Source, action.Destination, picoClawHome); err != nil { + if err := handler.ExecuteConfigMigration(action.Source, action.Target); err != nil { result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err)) fmt.Printf(" ✗ Config migration failed: %v\n", err) } else { result.ConfigMigrated = true - fmt.Printf(" ✓ Converted config: %s\n", action.Destination) + fmt.Printf(" ✓ Converted config: %s\n", action.Target) } case ActionCreateDir: - if err := os.MkdirAll(action.Destination, 0o755); err != nil { + if err := os.MkdirAll(action.Target, 0o755); err != nil { result.Errors = append(result.Errors, err) } else { result.DirsCreated++ } case ActionBackup: - bakPath := action.Destination + ".bak" - if err := copyFile(action.Destination, bakPath); err != nil { - result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err)) - fmt.Printf(" ✗ Backup failed: %s\n", action.Destination) + bakPath := action.Target + ".bak" + if err := internal.CopyFile(action.Target, bakPath); err != nil { + result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Target, err)) + fmt.Printf(" ✗ Backup failed: %s\n", action.Target) continue } result.BackupsCreated++ fmt.Printf( " ✓ Backed up %s -> %s.bak\n", - filepath.Base(action.Destination), - filepath.Base(action.Destination), + filepath.Base(action.Target), + filepath.Base(action.Target), ) - if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil { result.Errors = append(result.Errors, err) continue } - if err := copyFile(action.Source, action.Destination); err != nil { + if err := internal.CopyFile(action.Source, action.Target); err != nil { result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) fmt.Printf(" ✗ Copy failed: %s\n", action.Source) } else { result.FilesCopied++ - fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome)) + fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome)) } case ActionCopy: - if err := os.MkdirAll(filepath.Dir(action.Destination), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(action.Target), 0o755); err != nil { result.Errors = append(result.Errors, err) continue } - if err := copyFile(action.Source, action.Destination); err != nil { + if err := internal.CopyFile(action.Source, action.Target); err != nil { result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) fmt.Printf(" ✗ Copy failed: %s\n", action.Source) } else { result.FilesCopied++ - fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome)) + fmt.Printf(" ✓ Copied %s\n", internal.RelPath(action.Source, sourceHome)) } case ActionSkip: result.FilesSkipped++ @@ -211,31 +239,6 @@ func Execute(actions []Action, openclawHome, picoClawHome string) *Result { return result } -func executeConfigMigration(srcConfigPath, dstConfigPath, picoClawHome string) error { - data, err := LoadOpenClawConfig(srcConfigPath) - if err != nil { - return err - } - - incoming, _, err := ConvertConfig(data) - if err != nil { - return err - } - - if _, err := os.Stat(dstConfigPath); err == nil { - existing, err := config.LoadConfig(dstConfigPath) - if err != nil { - return fmt.Errorf("loading existing PicoClaw config: %w", err) - } - incoming = MergeConfig(existing, incoming) - } - - if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil { - return err - } - return config.SaveConfig(dstConfigPath, incoming) -} - func Confirm() bool { fmt.Print("Proceed with migration? (y/n): ") var response string @@ -243,49 +246,7 @@ func Confirm() bool { return strings.ToLower(strings.TrimSpace(response)) == "y" } -func PrintPlan(actions []Action, warnings []string) { - fmt.Println("Planned actions:") - copies := 0 - skips := 0 - backups := 0 - configCount := 0 - - for _, action := range actions { - switch action.Type { - case ActionConvertConfig: - fmt.Printf(" [config] %s -> %s\n", action.Source, action.Destination) - configCount++ - case ActionCopy: - fmt.Printf(" [copy] %s\n", filepath.Base(action.Source)) - copies++ - case ActionBackup: - fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Destination)) - backups++ - copies++ - case ActionSkip: - if action.Description != "" { - fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description) - } - skips++ - case ActionCreateDir: - fmt.Printf(" [mkdir] %s\n", action.Destination) - } - } - - if len(warnings) > 0 { - fmt.Println() - fmt.Println("Warnings:") - for _, w := range warnings { - fmt.Printf(" - %s\n", w) - } - } - - fmt.Println() - fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n", - copies, configCount, backups, skips) -} - -func PrintSummary(result *Result) { +func (m *MigrateInstance) PrintSummary(result *Result) { fmt.Println() parts := []string{} if result.FilesCopied > 0 { @@ -316,83 +277,44 @@ func PrintSummary(result *Result) { } } -func resolveOpenClawHome(override string) (string, error) { - if override != "" { - return expandHome(override), nil - } - if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" { - return expandHome(envHome), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolving home directory: %w", err) - } - return filepath.Join(home, ".openclaw"), nil -} +func PrintPlan(actions []Action, warnings []string) { + fmt.Println("Planned actions:") + copies := 0 + skips := 0 + backups := 0 + configCount := 0 -func resolvePicoClawHome(override string) (string, error) { - if override != "" { - return expandHome(override), nil - } - if envHome := os.Getenv("PICOCLAW_HOME"); envHome != "" { - return expandHome(envHome), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolving home directory: %w", err) - } - return filepath.Join(home, ".picoclaw"), nil -} - -func resolveWorkspace(homeDir string) string { - return filepath.Join(homeDir, "workspace") -} - -func expandHome(path string) string { - if path == "" { - return path - } - if path[0] == '~' { - home, _ := os.UserHomeDir() - if len(path) > 1 && path[1] == '/' { - return home + path[1:] + for _, action := range actions { + switch action.Type { + case ActionConvertConfig: + fmt.Printf(" [config] %s -> %s\n", action.Source, action.Target) + configCount++ + case ActionCopy: + fmt.Printf(" [copy] %s\n", filepath.Base(action.Source)) + copies++ + case ActionBackup: + fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Target)) + backups++ + copies++ + case ActionSkip: + if action.Description != "" { + fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description) + } + skips++ + case ActionCreateDir: + fmt.Printf(" [mkdir] %s\n", action.Target) } - return home } - return path -} - -func backupFile(path string) error { - bakPath := path + ".bak" - return copyFile(path, bakPath) -} - -func copyFile(src, dst string) error { - srcFile, err := os.Open(src) - if err != nil { - return err - } - defer srcFile.Close() - - info, err := srcFile.Stat() - if err != nil { - return err - } - - dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) - if err != nil { - return err - } - defer dstFile.Close() - - _, err = io.Copy(dstFile, srcFile) - return err -} - -func relPath(path, base string) string { - rel, err := filepath.Rel(base, path) - if err != nil { - return filepath.Base(path) - } - return rel + + if len(warnings) > 0 { + fmt.Println() + fmt.Println("Warnings:") + for _, w := range warnings { + fmt.Printf(" - %s\n", w) + } + } + + fmt.Println() + fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n", + copies, configCount, backups, skips) } diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index 9216442bb..fc9c2c3a7 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -1,875 +1,411 @@ package migrate import ( - "encoding/json" "os" "path/filepath" "testing" - "github.com/sipeed/picoclaw/pkg/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestCamelToSnake(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - {"simple", "apiKey", "api_key"}, - {"two words", "apiBase", "api_base"}, - {"three words", "maxToolIterations", "max_tool_iterations"}, - {"already snake", "api_key", "api_key"}, - {"single word", "enabled", "enabled"}, - {"all lower", "model", "model"}, - {"consecutive caps", "apiURL", "api_url"}, - {"starts upper", "Model", "model"}, - {"bridge url", "bridgeUrl", "bridge_url"}, - {"client id", "clientId", "client_id"}, - {"app secret", "appSecret", "app_secret"}, - {"verification token", "verificationToken", "verification_token"}, - {"allow from", "allowFrom", "allow_from"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := camelToSnake(tt.input) - if got != tt.want { - t.Errorf("camelToSnake(%q) = %q, want %q", tt.input, got, tt.want) - } - }) +func TestNewMigrateInstance(t *testing.T) { + opts := Options{ + Source: "openclaw", } + instance := NewMigrateInstance(opts) + require.NotNil(t, instance) + assert.Equal(t, "openclaw", instance.options.Source) } -func TestConvertKeysToSnake(t *testing.T) { - input := map[string]any{ - "apiKey": "test-key", - "apiBase": "https://example.com", - "nested": map[string]any{ - "maxTokens": float64(8192), - "allowFrom": []any{"user1", "user2"}, - "deeperLevel": map[string]any{ - "clientId": "abc", - }, - }, - } +func TestMigrateInstanceRegister(t *testing.T) { + instance := NewMigrateInstance(Options{}) + require.NotNil(t, instance) - result := convertKeysToSnake(input) - m, ok := result.(map[string]any) - if !ok { - t.Fatal("expected map[string]interface{}") - } + mockHandler := &mockOperation{} + instance.Register("test-source", mockHandler) - if _, ok = m["api_key"]; !ok { - t.Error("expected key 'api_key' after conversion") - } - if _, ok = m["api_base"]; !ok { - t.Error("expected key 'api_base' after conversion") - } - - nested, ok := m["nested"].(map[string]any) - if !ok { - t.Fatal("expected nested map") - } - if _, ok = nested["max_tokens"]; !ok { - t.Error("expected key 'max_tokens' in nested map") - } - if _, ok = nested["allow_from"]; !ok { - t.Error("expected key 'allow_from' in nested map") - } - - deeper, ok := nested["deeper_level"].(map[string]any) - if !ok { - t.Fatal("expected deeper_level map") - } - if _, ok := deeper["client_id"]; !ok { - t.Error("expected key 'client_id' in deeper level") - } + handler, ok := instance.handlers["test-source"] + require.True(t, ok) + assert.Equal(t, mockHandler, handler) } -func TestLoadOpenClawConfig(t *testing.T) { +func TestMigrateInstanceGetCurrentHandler(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) - openclawConfig := map[string]any{ - "providers": map[string]any{ - "anthropic": map[string]any{ - "apiKey": "sk-ant-test123", - "apiBase": "https://api.anthropic.com", - }, - }, - "agents": map[string]any{ - "defaults": map[string]any{ - "maxTokens": float64(4096), - "model": "claude-3-opus", - }, - }, - } + instance := NewMigrateInstance(Options{SourceHome: tmpDir}) + require.NotNil(t, instance) - data, err := json.Marshal(openclawConfig) - if err != nil { - t.Fatal(err) - } - if err = os.WriteFile(configPath, data, 0o644); err != nil { - t.Fatal(err) - } - - result, err := LoadOpenClawConfig(configPath) - if err != nil { - t.Fatalf("LoadOpenClawConfig: %v", err) - } - - providers, ok := result["providers"].(map[string]any) - if !ok { - t.Fatal("expected providers map") - } - anthropic, ok := providers["anthropic"].(map[string]any) - if !ok { - t.Fatal("expected anthropic map") - } - if anthropic["api_key"] != "sk-ant-test123" { - t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"]) - } - - agents, ok := result["agents"].(map[string]any) - if !ok { - t.Fatal("expected agents map") - } - defaults, ok := agents["defaults"].(map[string]any) - if !ok { - t.Fatal("expected defaults map") - } - if defaults["max_tokens"] != float64(4096) { - t.Errorf("max_tokens = %v, want 4096", defaults["max_tokens"]) - } + handler, err := instance.getCurrentHandler() + require.NoError(t, err) + require.NotNil(t, handler) + assert.Equal(t, "openclaw", handler.GetSourceName()) } -func TestConvertConfig(t *testing.T) { - t.Run("providers mapping", func(t *testing.T) { - data := map[string]any{ - "providers": map[string]any{ - "anthropic": map[string]any{ - "api_key": "sk-ant-test", - "api_base": "https://api.anthropic.com", - }, - "openrouter": map[string]any{ - "api_key": "sk-or-test", - }, - "groq": map[string]any{ - "api_key": "gsk-test", - }, - }, - } - - cfg, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 0 { - t.Errorf("expected no warnings, got %v", warnings) - } - if cfg.Providers.Anthropic.APIKey != "sk-ant-test" { - t.Errorf("Anthropic.APIKey = %q, want %q", cfg.Providers.Anthropic.APIKey, "sk-ant-test") - } - if cfg.Providers.OpenRouter.APIKey != "sk-or-test" { - t.Errorf("OpenRouter.APIKey = %q, want %q", cfg.Providers.OpenRouter.APIKey, "sk-or-test") - } - if cfg.Providers.Groq.APIKey != "gsk-test" { - t.Errorf("Groq.APIKey = %q, want %q", cfg.Providers.Groq.APIKey, "gsk-test") - } - }) - - t.Run("unsupported provider warning", func(t *testing.T) { - data := map[string]any{ - "providers": map[string]any{ - "unknown_provider": map[string]any{ - "api_key": "sk-test", - }, - }, - } - - _, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 1 { - t.Fatalf("expected 1 warning, got %d", len(warnings)) - } - if warnings[0] != "Provider 'unknown_provider' not supported in PicoClaw, skipping" { - t.Errorf("unexpected warning: %s", warnings[0]) - } - }) - - t.Run("channels mapping", func(t *testing.T) { - data := map[string]any{ - "channels": map[string]any{ - "telegram": map[string]any{ - "enabled": true, - "token": "tg-token-123", - "allow_from": []any{"user1"}, - }, - "discord": map[string]any{ - "enabled": true, - "token": "disc-token-456", - }, - }, - } - - cfg, _, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if !cfg.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled") - } - if cfg.Channels.Telegram.Token != "tg-token-123" { - t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token, "tg-token-123") - } - if len(cfg.Channels.Telegram.AllowFrom) != 1 || cfg.Channels.Telegram.AllowFrom[0] != "user1" { - t.Errorf("Telegram.AllowFrom = %v, want [user1]", cfg.Channels.Telegram.AllowFrom) - } - if !cfg.Channels.Discord.Enabled { - t.Error("Discord should be enabled") - } - }) - - t.Run("unsupported channel warning", func(t *testing.T) { - data := map[string]any{ - "channels": map[string]any{ - "email": map[string]any{ - "enabled": true, - }, - }, - } - - _, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 1 { - t.Fatalf("expected 1 warning, got %d", len(warnings)) - } - if warnings[0] != "Channel 'email' not supported in PicoClaw, skipping" { - t.Errorf("unexpected warning: %s", warnings[0]) - } - }) - - t.Run("agent defaults", func(t *testing.T) { - data := map[string]any{ - "agents": map[string]any{ - "defaults": map[string]any{ - "model": "claude-3-opus", - "max_tokens": float64(4096), - "temperature": 0.5, - "max_tool_iterations": float64(10), - "workspace": "~/.openclaw/workspace", - }, - }, - } - - cfg, _, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if cfg.Agents.Defaults.Model != "claude-3-opus" { - t.Errorf("Model = %q, want %q", cfg.Agents.Defaults.Model, "claude-3-opus") - } - if cfg.Agents.Defaults.MaxTokens != 4096 { - t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096) - } - if cfg.Agents.Defaults.Temperature == nil { - t.Fatalf("Temperature is nil, want %f", 0.5) - } - if *cfg.Agents.Defaults.Temperature != 0.5 { - t.Errorf("Temperature = %f, want %f", *cfg.Agents.Defaults.Temperature, 0.5) - } - if cfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" { - t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.picoclaw/workspace") - } - }) - - t.Run("empty config", func(t *testing.T) { - data := map[string]any{} - - cfg, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 0 { - t.Errorf("expected no warnings, got %v", warnings) - } - if cfg.Agents.Defaults.Model != "" { - t.Errorf("default model should be nil, got %q", cfg.Agents.Defaults.Model) - } - }) -} - -func TestSupportedProvidersCompatibility(t *testing.T) { - expected := []string{ - "anthropic", - "openai", - "openrouter", - "groq", - "zhipu", - "vllm", - "gemini", - } - - for _, provider := range expected { - if !supportedProviders[provider] { - t.Fatalf("supportedProviders missing expected key %q", provider) - } - } -} - -func TestMergeConfig(t *testing.T) { - t.Run("fills empty fields", func(t *testing.T) { - existing := config.DefaultConfig() - incoming := config.DefaultConfig() - incoming.Providers.Anthropic.APIKey = "sk-ant-incoming" - incoming.Providers.OpenRouter.APIKey = "sk-or-incoming" - - result := MergeConfig(existing, incoming) - if result.Providers.Anthropic.APIKey != "sk-ant-incoming" { - t.Errorf("Anthropic.APIKey = %q, want %q", result.Providers.Anthropic.APIKey, "sk-ant-incoming") - } - if result.Providers.OpenRouter.APIKey != "sk-or-incoming" { - t.Errorf("OpenRouter.APIKey = %q, want %q", result.Providers.OpenRouter.APIKey, "sk-or-incoming") - } - }) - - t.Run("preserves existing non-empty fields", func(t *testing.T) { - existing := config.DefaultConfig() - existing.Providers.Anthropic.APIKey = "sk-ant-existing" - - incoming := config.DefaultConfig() - incoming.Providers.Anthropic.APIKey = "sk-ant-incoming" - incoming.Providers.OpenAI.APIKey = "sk-oai-incoming" - - result := MergeConfig(existing, incoming) - if result.Providers.Anthropic.APIKey != "sk-ant-existing" { - t.Errorf("Anthropic.APIKey should be preserved, got %q", result.Providers.Anthropic.APIKey) - } - if result.Providers.OpenAI.APIKey != "sk-oai-incoming" { - t.Errorf("OpenAI.APIKey should be filled, got %q", result.Providers.OpenAI.APIKey) - } - }) - - t.Run("merges enabled channels", func(t *testing.T) { - existing := config.DefaultConfig() - incoming := config.DefaultConfig() - incoming.Channels.Telegram.Enabled = true - incoming.Channels.Telegram.Token = "tg-token" - - result := MergeConfig(existing, incoming) - if !result.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled after merge") - } - if result.Channels.Telegram.Token != "tg-token" { - t.Errorf("Telegram.Token = %q, want %q", result.Channels.Telegram.Token, "tg-token") - } - }) - - t.Run("preserves existing enabled channels", func(t *testing.T) { - existing := config.DefaultConfig() - existing.Channels.Telegram.Enabled = true - existing.Channels.Telegram.Token = "existing-token" - - incoming := config.DefaultConfig() - incoming.Channels.Telegram.Enabled = true - incoming.Channels.Telegram.Token = "incoming-token" - - result := MergeConfig(existing, incoming) - if result.Channels.Telegram.Token != "existing-token" { - t.Errorf("Telegram.Token should be preserved, got %q", result.Channels.Telegram.Token) - } - }) -} - -func TestPlanWorkspaceMigration(t *testing.T) { - t.Run("copies available files", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644) - os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0o644) - os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0o644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - copyCount := 0 - skipCount := 0 - for _, a := range actions { - if a.Type == ActionCopy { - copyCount++ - } - if a.Type == ActionSkip { - skipCount++ - } - } - if copyCount != 3 { - t.Errorf("expected 3 copies, got %d", copyCount) - } - if skipCount != 2 { - t.Errorf("expected 2 skips (TOOLS.md, HEARTBEAT.md), got %d", skipCount) - } - }) - - t.Run("plans backup for existing destination files", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0o644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - backupCount := 0 - for _, a := range actions { - if a.Type == ActionBackup && filepath.Base(a.Destination) == "AGENTS.md" { - backupCount++ - } - } - if backupCount != 1 { - t.Errorf("expected 1 backup action for AGENTS.md, got %d", backupCount) - } - }) - - t.Run("force skips backup", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0o644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0o644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, true) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - for _, a := range actions { - if a.Type == ActionBackup { - t.Error("expected no backup actions with force=true") - } - } - }) - - t.Run("handles memory directory", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - memDir := filepath.Join(srcDir, "memory") - os.MkdirAll(memDir, 0o755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0o644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - hasCopy := false - hasDir := false - for _, a := range actions { - if a.Type == ActionCopy && filepath.Base(a.Source) == "MEMORY.md" { - hasCopy = true - } - if a.Type == ActionCreateDir { - hasDir = true - } - } - if !hasCopy { - t.Error("expected copy action for memory/MEMORY.md") - } - if !hasDir { - t.Error("expected create dir action for memory/") - } - }) - - t.Run("handles skills directory", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - skillDir := filepath.Join(srcDir, "skills", "weather") - os.MkdirAll(skillDir, 0o755) - os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0o644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - hasCopy := false - for _, a := range actions { - if a.Type == ActionCopy && filepath.Base(a.Source) == "SKILL.md" { - hasCopy = true - } - } - if !hasCopy { - t.Error("expected copy action for skills/weather/SKILL.md") - } - }) -} - -func TestFindOpenClawConfig(t *testing.T) { - t.Run("finds openclaw.json", func(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(configPath, []byte("{}"), 0o644) - - found, err := findOpenClawConfig(tmpDir) - if err != nil { - t.Fatalf("findOpenClawConfig: %v", err) - } - if found != configPath { - t.Errorf("found %q, want %q", found, configPath) - } - }) - - t.Run("falls back to config.json", func(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "config.json") - os.WriteFile(configPath, []byte("{}"), 0o644) - - found, err := findOpenClawConfig(tmpDir) - if err != nil { - t.Fatalf("findOpenClawConfig: %v", err) - } - if found != configPath { - t.Errorf("found %q, want %q", found, configPath) - } - }) - - t.Run("prefers openclaw.json over config.json", func(t *testing.T) { - tmpDir := t.TempDir() - openclawPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(openclawPath, []byte("{}"), 0o644) - os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0o644) - - found, err := findOpenClawConfig(tmpDir) - if err != nil { - t.Fatalf("findOpenClawConfig: %v", err) - } - if found != openclawPath { - t.Errorf("should prefer openclaw.json, got %q", found) - } - }) - - t.Run("error when no config found", func(t *testing.T) { - tmpDir := t.TempDir() - - _, err := findOpenClawConfig(tmpDir) - if err == nil { - t.Fatal("expected error when no config found") - } - }) -} - -func TestRewriteWorkspacePath(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - {"default path", "~/.openclaw/workspace", "~/.picoclaw/workspace"}, - {"custom path", "/custom/path", "/custom/path"}, - {"empty", "", ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := rewriteWorkspacePath(tt.input) - if got != tt.want { - t.Errorf("rewriteWorkspacePath(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestRunDryRun(t *testing.T) { - openclawHome := t.TempDir() - picoClawHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0o755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0o644) - - configData := map[string]any{ - "providers": map[string]any{ - "anthropic": map[string]any{ - "apiKey": "test-key", - }, - }, - } - data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) +func TestMigrateInstanceGetCurrentHandlerWithSource(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) opts := Options{ - DryRun: true, - OpenClawHome: openclawHome, - PicoClawHome: picoClawHome, + Source: "openclaw", + SourceHome: tmpDir, } + instance := NewMigrateInstance(opts) - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - picoWs := filepath.Join(picoClawHome, "workspace") - if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) { - t.Error("dry run should not create files") - } - if _, err := os.Stat(filepath.Join(picoClawHome, "config.json")); !os.IsNotExist(err) { - t.Error("dry run should not create config") - } - - _ = result + handler, err := instance.getCurrentHandler() + require.NoError(t, err) + require.NotNil(t, handler) + assert.Equal(t, "openclaw", handler.GetSourceName()) } -func TestRunFullMigration(t *testing.T) { - openclawHome := t.TempDir() - picoClawHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0o755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0o644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0o644) - os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0o644) - - memDir := filepath.Join(wsDir, "memory") - os.MkdirAll(memDir, 0o755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0o644) - - configData := map[string]any{ - "providers": map[string]any{ - "anthropic": map[string]any{ - "apiKey": "sk-ant-migrate-test", - }, - "openrouter": map[string]any{ - "apiKey": "sk-or-migrate-test", - }, - }, - "channels": map[string]any{ - "telegram": map[string]any{ - "enabled": true, - "token": "tg-migrate-test", - }, - }, - } - data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) - - opts := Options{ - Force: true, - OpenClawHome: openclawHome, - PicoClawHome: picoClawHome, +func TestMigrateInstanceGetCurrentHandlerNotFound(t *testing.T) { + instance := &MigrateInstance{ + options: Options{}, + handlers: make(map[string]Operation), } - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - picoWs := filepath.Join(picoClawHome, "workspace") - - soulData, err := os.ReadFile(filepath.Join(picoWs, "SOUL.md")) - if err != nil { - t.Fatalf("reading SOUL.md: %v", err) - } - if string(soulData) != "# Soul from OpenClaw" { - t.Errorf("SOUL.md content = %q, want %q", string(soulData), "# Soul from OpenClaw") - } - - agentsData, err := os.ReadFile(filepath.Join(picoWs, "AGENTS.md")) - if err != nil { - t.Fatalf("reading AGENTS.md: %v", err) - } - if string(agentsData) != "# Agents from OpenClaw" { - t.Errorf("AGENTS.md content = %q", string(agentsData)) - } - - memData, err := os.ReadFile(filepath.Join(picoWs, "memory", "MEMORY.md")) - if err != nil { - t.Fatalf("reading memory/MEMORY.md: %v", err) - } - if string(memData) != "# Memory notes" { - t.Errorf("MEMORY.md content = %q", string(memData)) - } - - picoConfig, err := config.LoadConfig(filepath.Join(picoClawHome, "config.json")) - if err != nil { - t.Fatalf("loading PicoClaw config: %v", err) - } - if picoConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" { - t.Errorf("Anthropic.APIKey = %q, want %q", picoConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test") - } - if picoConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" { - t.Errorf("OpenRouter.APIKey = %q, want %q", picoConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test") - } - if !picoConfig.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled") - } - if picoConfig.Channels.Telegram.Token != "tg-migrate-test" { - t.Errorf("Telegram.Token = %q, want %q", picoConfig.Channels.Telegram.Token, "tg-migrate-test") - } - - if result.FilesCopied < 3 { - t.Errorf("expected at least 3 files copied, got %d", result.FilesCopied) - } - if !result.ConfigMigrated { - t.Error("config should have been migrated") - } - if len(result.Errors) > 0 { - t.Errorf("expected no errors, got %v", result.Errors) - } + _, err := instance.getCurrentHandler() + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") } -func TestRunOpenClawNotFound(t *testing.T) { - opts := Options{ - OpenClawHome: "/nonexistent/path/to/openclaw", - PicoClawHome: t.TempDir(), +func TestMigrateInstancePlanWithInvalidSource(t *testing.T) { + instance := &MigrateInstance{ + options: Options{}, + handlers: make(map[string]Operation), } - _, err := Run(opts) - if err == nil { - t.Fatal("expected error when OpenClaw not found") - } + _, _, err := instance.Plan(Options{}, "/tmp/source", "/tmp/target") + require.Error(t, err) } -func TestRunMutuallyExclusiveFlags(t *testing.T) { - opts := Options{ +func TestMigrateInstancePlanConfigOnlyAndWorkspaceOnlyMutuallyExclusive(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + instance := NewMigrateInstance(Options{SourceHome: tmpDir}) + require.NotNil(t, instance) + + _, err = instance.Run(Options{ ConfigOnly: true, WorkspaceOnly: true, - } - - _, err := Run(opts) - if err == nil { - t.Fatal("expected error for mutually exclusive flags") - } + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") } -func TestBackupFile(t *testing.T) { +func TestMigrateInstancePlanRefreshSetsWorkspaceOnly(t *testing.T) { + opts := Options{ + Refresh: true, + SourceHome: "/tmp/nonexistent", + } + instance := NewMigrateInstance(opts) + require.NotNil(t, instance) + + _, err := instance.Run(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestMigrateInstancePlanSourceNotFound(t *testing.T) { + opts := Options{ + SourceHome: "/tmp/nonexistent-source-home", + } + instance := NewMigrateInstance(opts) + + _, err := instance.Run(opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestMigrateInstanceExecute(t *testing.T) { tmpDir := t.TempDir() - filePath := filepath.Join(tmpDir, "test.md") - os.WriteFile(filePath, []byte("original content"), 0o644) + sourceDir := filepath.Join(tmpDir, "source") + targetDir := filepath.Join(tmpDir, "target") + workspaceDir := filepath.Join(sourceDir, "workspace") - if err := backupFile(filePath); err != nil { - t.Fatalf("backupFile: %v", err) + err := os.MkdirAll(workspaceDir, 0o755) + require.NoError(t, err) + + err = os.WriteFile(filepath.Join(workspaceDir, "test.txt"), []byte("test"), 0o644) + require.NoError(t, err) + + instance := &MigrateInstance{ + options: Options{Source: "mock"}, + handlers: make(map[string]Operation), } + instance.Register("mock", &mockOperation{sourceHome: sourceDir, sourceWs: workspaceDir}) - bakPath := filePath + ".bak" - bakData, err := os.ReadFile(bakPath) - if err != nil { - t.Fatalf("reading backup: %v", err) - } - if string(bakData) != "original content" { - t.Errorf("backup content = %q, want %q", string(bakData), "original content") - } -} - -func TestCopyFile(t *testing.T) { - tmpDir := t.TempDir() - srcPath := filepath.Join(tmpDir, "src.md") - dstPath := filepath.Join(tmpDir, "dst.md") - - os.WriteFile(srcPath, []byte("file content"), 0o644) - - if err := copyFile(srcPath, dstPath); err != nil { - t.Fatalf("copyFile: %v", err) - } - - data, err := os.ReadFile(dstPath) - if err != nil { - t.Fatalf("reading copy: %v", err) - } - if string(data) != "file content" { - t.Errorf("copy content = %q, want %q", string(data), "file content") - } -} - -func TestRunConfigOnly(t *testing.T) { - openclawHome := t.TempDir() - picoClawHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0o755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644) - - configData := map[string]any{ - "providers": map[string]any{ - "anthropic": map[string]any{ - "apiKey": "sk-config-only", - }, + actions := []Action{ + { + Type: ActionCopy, + Source: filepath.Join(workspaceDir, "test.txt"), + Target: filepath.Join(targetDir, "workspace", "test.txt"), + Description: "copy file", }, } - data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) - opts := Options{ - Force: true, - ConfigOnly: true, - OpenClawHome: openclawHome, - PicoClawHome: picoClawHome, - } + result := instance.Execute(actions, workspaceDir, targetDir) + require.NotNil(t, result) + assert.Equal(t, 1, result.FilesCopied) - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if !result.ConfigMigrated { - t.Error("config should have been migrated") - } - - picoWs := filepath.Join(picoClawHome, "workspace") - if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) { - t.Error("config-only should not copy workspace files") - } + _, err = os.Stat(filepath.Join(targetDir, "workspace", "test.txt")) + assert.NoError(t, err) } -func TestRunWorkspaceOnly(t *testing.T) { - openclawHome := t.TempDir() - picoClawHome := t.TempDir() +func TestMigrateInstanceExecuteWithInvalidSource(t *testing.T) { + tmpDir := t.TempDir() + sourceDir := filepath.Join(tmpDir, "source") + err := os.MkdirAll(sourceDir, 0o755) + require.NoError(t, err) - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0o755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0o644) + instance := &MigrateInstance{ + options: Options{Source: "mock"}, + handlers: make(map[string]Operation), + } + instance.Register("mock", &mockOperation{sourceHome: sourceDir}) - configData := map[string]any{ - "providers": map[string]any{ - "anthropic": map[string]any{ - "apiKey": "sk-ws-only", - }, + actions := []Action{ + { + Type: ActionCopy, + Source: filepath.Join(sourceDir, "nonexistent.txt"), + Target: filepath.Join(tmpDir, "target.txt"), + Description: "copy file", }, } - data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0o644) - opts := Options{ - Force: true, - WorkspaceOnly: true, - OpenClawHome: openclawHome, - PicoClawHome: picoClawHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if result.ConfigMigrated { - t.Error("workspace-only should not migrate config") - } - - picoWs := filepath.Join(picoClawHome, "workspace") - soulData, err := os.ReadFile(filepath.Join(picoWs, "SOUL.md")) - if err != nil { - t.Fatalf("reading SOUL.md: %v", err) - } - if string(soulData) != "# Soul" { - t.Errorf("SOUL.md content = %q", string(soulData)) - } + result := instance.Execute(actions, sourceDir, tmpDir) + require.NotNil(t, result) + assert.Equal(t, 0, result.FilesCopied) + assert.Greater(t, len(result.Errors), 0) +} + +func TestMigrateInstanceExecuteCreateDir(t *testing.T) { + tmpDir := t.TempDir() + + instance := &MigrateInstance{ + options: Options{Source: "mock"}, + handlers: make(map[string]Operation), + } + instance.Register("mock", &mockOperation{}) + + actions := []Action{ + { + Type: ActionCreateDir, + Target: filepath.Join(tmpDir, "new", "dir"), + Description: "create directory", + }, + } + + result := instance.Execute(actions, "", "") + require.NotNil(t, result) + assert.Equal(t, 1, result.DirsCreated) + + _, err := os.Stat(filepath.Join(tmpDir, "new", "dir")) + assert.NoError(t, err) +} + +func TestMigrateInstanceExecuteBackup(t *testing.T) { + tmpDir := t.TempDir() + + sourceFile := filepath.Join(tmpDir, "source.txt") + targetFile := filepath.Join(tmpDir, "target.txt") + + err := os.WriteFile(sourceFile, []byte("source"), 0o644) + require.NoError(t, err) + + err = os.WriteFile(targetFile, []byte("target"), 0o644) + require.NoError(t, err) + + instance := &MigrateInstance{ + options: Options{Source: "mock"}, + handlers: make(map[string]Operation), + } + instance.Register("mock", &mockOperation{}) + + actions := []Action{ + { + Type: ActionBackup, + Source: sourceFile, + Target: targetFile, + Description: "backup and overwrite", + }, + } + + result := instance.Execute(actions, tmpDir, tmpDir) + require.NotNil(t, result) + assert.Equal(t, 1, result.BackupsCreated) + assert.Equal(t, 1, result.FilesCopied) + + bakFile := targetFile + ".bak" + _, err = os.Stat(bakFile) + assert.NoError(t, err) + + content, err := os.ReadFile(targetFile) + assert.NoError(t, err) + assert.Equal(t, "source", string(content)) +} + +func TestMigrateInstanceExecuteSkip(t *testing.T) { + instance := &MigrateInstance{ + options: Options{Source: "mock"}, + handlers: make(map[string]Operation), + } + instance.Register("mock", &mockOperation{}) + + actions := []Action{ + { + Type: ActionSkip, + Source: "/tmp/source.txt", + Target: "/tmp/target.txt", + Description: "skip file", + }, + } + + result := instance.Execute(actions, "", "") + require.NotNil(t, result) + assert.Equal(t, 1, result.FilesSkipped) +} + +func TestMigrateInstancePrintSummary(t *testing.T) { + instance := NewMigrateInstance(Options{}) + + result := &Result{ + FilesCopied: 5, + ConfigMigrated: true, + BackupsCreated: 2, + FilesSkipped: 3, + Warnings: []string{"warning 1"}, + Errors: []error{}, + } + + instance.PrintSummary(result) +} + +func TestMigrateInstancePrintSummaryWithErrors(t *testing.T) { + instance := NewMigrateInstance(Options{}) + + result := &Result{ + FilesCopied: 0, + ConfigMigrated: false, + BackupsCreated: 0, + FilesSkipped: 0, + Warnings: []string{}, + Errors: []error{assert.AnError}, + } + + instance.PrintSummary(result) +} + +func TestMigrateInstancePrintSummaryNoActions(t *testing.T) { + instance := NewMigrateInstance(Options{}) + + result := &Result{ + FilesCopied: 0, + ConfigMigrated: false, + BackupsCreated: 0, + FilesSkipped: 0, + Warnings: []string{}, + Errors: []error{}, + } + + instance.PrintSummary(result) +} + +func TestPrintPlan(t *testing.T) { + actions := []Action{ + { + Type: ActionConvertConfig, + Source: "/source/config.json", + Target: "/target/config.json", + Description: "convert config", + }, + { + Type: ActionCopy, + Source: "/source/file.txt", + Target: "/target/file.txt", + Description: "copy file", + }, + { + Type: ActionBackup, + Source: "/source/existing.txt", + Target: "/target/existing.txt", + Description: "backup and overwrite", + }, + { + Type: ActionSkip, + Source: "/source/skipped.txt", + Target: "/target/skipped.txt", + Description: "skip file", + }, + { + Type: ActionCreateDir, + Target: "/target/newdir", + Description: "create directory", + }, + } + + warnings := []string{ + "Warning: source directory not found", + } + + PrintPlan(actions, warnings) +} + +func TestPrintPlanEmpty(t *testing.T) { + PrintPlan([]Action{}, []string{}) +} + +type mockOperation struct { + sourceHome string + sourceConfig string + sourceWs string + migrateFiles []string + migrateDirs []string +} + +func (m *mockOperation) GetSourceName() string { return "mock" } +func (m *mockOperation) GetSourceHome() (string, error) { + if m.sourceHome != "" { + return m.sourceHome, nil + } + return "/tmp/mock", nil +} + +func (m *mockOperation) GetSourceWorkspace() (string, error) { + if m.sourceWs != "" { + return m.sourceWs, nil + } + if m.sourceHome != "" { + return filepath.Join(m.sourceHome, "workspace"), nil + } + return "/tmp/mock/workspace", nil +} + +func (m *mockOperation) GetSourceConfigFile() (string, error) { + if m.sourceConfig != "" { + return m.sourceConfig, nil + } + return "/tmp/mock/config.json", nil +} +func (m *mockOperation) ExecuteConfigMigration(src, dst string) error { return nil } +func (m *mockOperation) GetMigrateableFiles() []string { + if m.migrateFiles != nil { + return m.migrateFiles + } + return []string{} +} + +func (m *mockOperation) GetMigrateableDirs() []string { + if m.migrateDirs != nil { + return m.migrateDirs + } + return []string{} } diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go new file mode 100644 index 000000000..dddd98089 --- /dev/null +++ b/pkg/migrate/sources/openclaw/common.go @@ -0,0 +1,29 @@ +package openclaw + +var migrateableFiles = []string{ + "AGENTS.md", + "SOUL.md", + "USER.md", + "TOOLS.md", + "HEARTBEAT.md", +} + +var migrateableDirs = []string{ + "memory", + "skills", +} + +var supportedChannels = map[string]bool{ + "whatsapp": true, + "telegram": true, + "feishu": true, + "discord": true, + "maixcam": true, + "qq": true, + "dingtalk": true, + "slack": true, + "line": true, + "onebot": true, + "wecom": true, + "wecom_app": true, +} diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go new file mode 100644 index 000000000..39ad48fad --- /dev/null +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -0,0 +1,1074 @@ +package openclaw + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type OpenClawConfig struct { + Auth *OpenClawAuth `json:"auth"` + Models *OpenClawModels `json:"models"` + Agents *OpenClawAgents `json:"agents"` + Tools *OpenClawTools `json:"tools"` + Channels *OpenClawChannels `json:"channels"` + Cron json.RawMessage `json:"cron"` + Hooks json.RawMessage `json:"hooks"` + Skills *OpenClawSkills `json:"skills"` + Memory json.RawMessage `json:"memory"` + Session json.RawMessage `json:"session"` +} + +type OpenClawAuth struct { + Profiles json.RawMessage `json:"profiles"` + Order json.RawMessage `json:"order"` +} + +type OpenClawModels struct { + Providers map[string]json.RawMessage `json:"providers"` +} + +type ProviderConfig struct { + BaseUrl string `json:"baseUrl"` + Api string `json:"api"` + Models []ModelConfig `json:"models"` + ApiKey string `json:"apiKey"` +} + +type OpenClawModelConfig struct { + ID string `json:"id"` + Name string `json:"name"` + Reasoning bool `json:"reasoning"` + Input []string `json:"input"` + Cost Cost `json:"cost"` + ContextWindow int `json:"contextWindow"` + MaxTokens int `json:"maxTokens"` + Api string `json:"api,omitempty"` +} + +type Cost struct { + Input float64 `json:"input"` + Output float64 `json:"output"` + CacheRead float64 `json:"cacheRead"` + CacheWrite float64 `json:"cacheWrite"` +} + +type OpenClawTools struct { + Profile *string `json:"profile"` + Allow []string `json:"allow"` + Deny []string `json:"deny"` +} + +type OpenClawAgents struct { + Defaults *OpenClawAgentDefaults `json:"defaults"` + List []OpenClawAgentEntry `json:"list"` +} + +type OpenClawAgentDefaults struct { + Model *OpenClawAgentModel `json:"model"` + Workspace *string `json:"workspace"` + Tools *OpenClawAgentTools `json:"tools"` + Identity *string `json:"identity"` +} + +type OpenClawAgentModel struct { + Simple string `json:"-"` + Primary *string `json:"primary"` + Fallbacks []string `json:"fallbacks"` +} + +func (m *OpenClawAgentModel) GetPrimary() string { + if m.Simple != "" { + return m.Simple + } + if m.Primary != nil { + return *m.Primary + } + return "" +} + +func (m *OpenClawAgentModel) GetFallbacks() []string { + return m.Fallbacks +} + +type OpenClawAgentEntry struct { + ID string `json:"id"` + Name *string `json:"name"` + Model *OpenClawAgentModel `json:"model"` + Tools *OpenClawAgentTools `json:"tools"` + Workspace *string `json:"workspace"` + Skills []string `json:"skills"` + Identity *string `json:"identity"` +} + +type OpenClawAgentTools struct { + Profile *string `json:"profile"` + Allow []string `json:"allow"` + Deny []string `json:"deny"` + AlsoAllow []string `json:"alsoAllow"` +} + +type OpenClawChannels struct { + Telegram *OpenClawTelegramConfig `json:"telegram"` + Discord *OpenClawDiscordConfig `json:"discord"` + Slack *OpenClawSlackConfig `json:"slack"` + WhatsApp *OpenClawWhatsAppConfig `json:"whatsapp"` + Signal *OpenClawSignalConfig `json:"signal"` + Matrix *OpenClawMatrixConfig `json:"matrix"` + GoogleChat *OpenClawGoogleChatConfig `json:"googlechat"` + Teams *OpenClawTeamsConfig `json:"msteams"` + IRC *OpenClawIrcConfig `json:"irc"` + Mattermost *OpenClawMattermostConfig `json:"mattermost"` + Feishu *OpenClawFeishuConfig `json:"feishu"` + IMessage *OpenClawIMessageConfig `json:"imessage"` + BlueBubbles *OpenClawBlueBubblesConfig `json:"bluebubbles"` + QQ *OpenClawQQConfig `json:"qq"` + DingTalk *OpenClawDingTalkConfig `json:"dingtalk"` + MaixCam *OpenClawMaixCamConfig `json:"maixcam"` +} + +type OpenClawTelegramConfig struct { + BotToken *string `json:"botToken"` + AllowFrom []string `json:"allowFrom"` + GroupPolicy *string `json:"groupPolicy"` + DmPolicy *string `json:"dmPolicy"` + Enabled *bool `json:"enabled"` +} + +type OpenClawDiscordConfig struct { + Token *string `json:"token"` + Guilds json.RawMessage `json:"guilds"` + DmPolicy *string `json:"dmPolicy"` + GroupPolicy *string `json:"groupPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawSlackConfig struct { + BotToken *string `json:"botToken"` + AppToken *string `json:"appToken"` + DmPolicy *string `json:"dmPolicy"` + GroupPolicy *string `json:"groupPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawWhatsAppConfig struct { + AuthDir *string `json:"authDir"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + GroupPolicy *string `json:"groupPolicy"` + Enabled *bool `json:"enabled"` + BridgeURL *string `json:"bridgeUrl"` +} + +type OpenClawSignalConfig struct { + HttpUrl *string `json:"httpUrl"` + HttpHost *string `json:"httpHost"` + HttpPort *int `json:"httpPort"` + Account *string `json:"account"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawMatrixConfig struct { + Homeserver *string `json:"homeserver"` + UserID *string `json:"userId"` + AccessToken *string `json:"accessToken"` + Rooms []string `json:"rooms"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawGoogleChatConfig struct { + ServiceAccountFile *string `json:"serviceAccountFile"` + WebhookPath *string `json:"webhookPath"` + BotUser *string `json:"botUser"` + DmPolicy *string `json:"dmPolicy"` + Enabled *bool `json:"enabled"` +} + +type OpenClawTeamsConfig struct { + AppID *string `json:"appId"` + AppPassword *string `json:"appPassword"` + TenantID *string `json:"tenantId"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawIrcConfig struct { + Host *string `json:"host"` + Port *int `json:"port"` + TLS *bool `json:"tls"` + Nick *string `json:"nick"` + Password *string `json:"password"` + Channels []string `json:"channels"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawMattermostConfig struct { + BotToken *string `json:"botToken"` + BaseURL *string `json:"baseUrl"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawFeishuConfig struct { + AppID *string `json:"appId"` + AppSecret *string `json:"appSecret"` + Domain *string `json:"domain"` + DmPolicy *string `json:"dmPolicy"` + Enabled *bool `json:"enabled"` + VerificationToken *string `json:"verificationToken"` + EncryptKey *string `json:"encryptKey"` + AllowFrom []string `json:"allowFrom"` +} + +type OpenClawIMessageConfig struct { + CliPath *string `json:"cliPath"` + DbPath *string `json:"dbPath"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawBlueBubblesConfig struct { + ServerURL *string `json:"serverUrl"` + Password *string `json:"password"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawQQConfig struct { + AppID *string `json:"appId"` + AppSecret *string `json:"appSecret"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawDingTalkConfig struct { + AppID *string `json:"appId"` + AppSecret *string `json:"appSecret"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawMaixCamConfig struct { + Host *string `json:"host"` + Port *int `json:"port"` + DmPolicy *string `json:"dmPolicy"` + AllowFrom []string `json:"allowFrom"` + Enabled *bool `json:"enabled"` +} + +type OpenClawSkills struct { + Entries map[string]json.RawMessage `json:"entries"` + Load json.RawMessage `json:"load"` +} + +type OpenClawProviderConfig struct { + APIKey string `json:"api_key"` + BaseURL string `json:"base_url"` +} + +func (c *OpenClawConfig) GetEnabled() bool { + return true +} + +func LoadOpenClawConfig(path string) (*OpenClawConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config: %w", err) + } + + var config OpenClawConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + return &config, nil +} + +func LoadOpenClawConfigFromDir(dir string) (*OpenClawConfig, error) { + candidates := []string{ + filepath.Join(dir, "openclaw.json"), + filepath.Join(dir, "config.json"), + } + + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return LoadOpenClawConfig(p) + } + } + + return nil, fmt.Errorf("no config file found in %s", dir) +} + +func GetProviderConfig(models *OpenClawModels) map[string]OpenClawProviderConfig { + result := make(map[string]OpenClawProviderConfig) + if models == nil || models.Providers == nil { + return result + } + + for name, raw := range models.Providers { + var prov OpenClawProviderConfig + if err := json.Unmarshal(raw, &prov); err != nil { + continue + } + mappedName := mapProvider(name) + result[mappedName] = prov + } + + return result +} + +func GetProviderConfigFromDir(dir string) map[string]ProviderConfig { + result := make(map[string]ProviderConfig) + p := filepath.Join(dir, "agents", "main", "agent", "models.json") + + if _, err := os.Stat(p); err != nil { + return result + } + + data, err := os.ReadFile(p) + if err != nil { + return result + } + var models OpenClawModels + if err := json.Unmarshal(data, &models); err != nil { + return result + } + + for name, raw := range models.Providers { + var prov ProviderConfig + if err := json.Unmarshal(raw, &prov); err != nil { + continue + } + mappedName := mapProvider(name) + result[mappedName] = prov + } + return result +} + +func (c *OpenClawConfig) IsChannelEnabled(name string) bool { + switch name { + case "telegram": + return c.Channels.Telegram == nil || c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled + case "discord": + return c.Channels.Discord == nil || c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled + case "slack": + return c.Channels.Slack == nil || c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled + case "whatsapp": + return c.Channels.WhatsApp == nil || c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled + case "feishu": + return c.Channels.Feishu == nil || c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled + default: + return false + } +} + +func GetChannelAllowFrom(ch any) []string { + switch c := ch.(type) { + case *OpenClawTelegramConfig: + if c == nil { + return nil + } + return c.AllowFrom + case *OpenClawDiscordConfig: + if c == nil { + return nil + } + return c.AllowFrom + case *OpenClawSlackConfig: + if c == nil { + return nil + } + return c.AllowFrom + case *OpenClawWhatsAppConfig: + if c == nil { + return nil + } + return c.AllowFrom + case *OpenClawFeishuConfig: + if c == nil { + return nil + } + return c.AllowFrom + default: + return nil + } +} + +func (c *OpenClawConfig) GetDefaultModel() (provider, model string) { + if c.Agents == nil || c.Agents.Defaults == nil || c.Agents.Defaults.Model == nil { + return "anthropic", "claude-sonnet-4-20250514" + } + + primary := c.Agents.Defaults.Model.GetPrimary() + if primary == "" { + return "anthropic", "claude-sonnet-4-20250514" + } + + parts := strings.Split(primary, "/") + if len(parts) > 1 { + return mapProvider(parts[0]), parts[1] + } + + return "anthropic", primary +} + +func (c *OpenClawConfig) GetDefaultWorkspace() string { + if c.Agents == nil || c.Agents.Defaults == nil || c.Agents.Defaults.Workspace == nil { + return "" + } + return rewriteWorkspacePath(*c.Agents.Defaults.Workspace) +} + +func (c *OpenClawConfig) GetAgents() []OpenClawAgentEntry { + if c.Agents == nil { + return nil + } + return c.Agents.List +} + +func (c *OpenClawConfig) HasSkills() bool { + return c.Skills != nil && c.Skills.Entries != nil && len(c.Skills.Entries) > 0 +} + +func (c *OpenClawConfig) HasMemory() bool { + return c.Memory != nil && len(c.Memory) > 0 +} + +func (c *OpenClawConfig) HasCron() bool { + return c.Cron != nil && len(c.Cron) > 0 +} + +func (c *OpenClawConfig) HasHooks() bool { + return c.Hooks != nil && len(c.Hooks) > 0 +} + +func (c *OpenClawConfig) HasSession() bool { + return c.Session != nil && len(c.Session) > 0 +} + +func (c *OpenClawConfig) HasAuthProfiles() bool { + return c.Auth != nil && c.Auth.Profiles != nil && len(c.Auth.Profiles) > 0 +} + +func (c *OpenClawConfig) ConvertToPicoClaw(sourceHome string) (*PicoClawConfig, []string, error) { + cfg := &PicoClawConfig{} + var warnings []string + + provider, modelName := c.GetDefaultModel() + cfg.Agents.Defaults.Workspace = c.GetDefaultWorkspace() + cfg.Agents.Defaults.ModelName = modelName + + providerConfigs := GetProviderConfigFromDir(sourceHome) + defaultAPIKey := "" + defaultBaseURL := "" + + if provCfg, ok := providerConfigs[provider]; ok { + defaultAPIKey = provCfg.ApiKey + defaultBaseURL = provCfg.BaseUrl + } + + cfg.ModelList = []ModelConfig{ + { + ModelName: modelName, + Model: fmt.Sprintf("%s/%s", provider, modelName), + APIKey: defaultAPIKey, + APIBase: defaultBaseURL, + }, + } + + for provName, provCfg := range providerConfigs { + if provName == provider { + continue + } + if provCfg.ApiKey != "" { + continue + } + cfg.ModelList = append(cfg.ModelList, ModelConfig{ + ModelName: fmt.Sprintf("%s", provName), + Model: fmt.Sprintf("%s/%s", provName, provName), + APIKey: provCfg.ApiKey, + APIBase: provCfg.BaseUrl, + }) + } + + cfg.Channels = c.convertChannels(&warnings) + + agentList := c.convertAgents(&warnings) + if len(agentList) > 0 { + cfg.Agents.List = agentList + } + + if c.HasSkills() { + warnings = append( + warnings, + fmt.Sprintf( + "Skills (%d entries) not automatically migrated - reinstall via picoclaw CLI", + len(c.Skills.Entries), + ), + ) + } + if c.HasMemory() { + warnings = append(warnings, "Memory backend config not migrated - PicoClaw uses SQLite with vector embeddings") + } + if c.HasCron() { + warnings = append( + warnings, + "Cron job scheduling not supported in PicoClaw - consider using external schedulers", + ) + } + if c.HasHooks() { + warnings = append(warnings, "Webhook hooks not supported in PicoClaw - use event system instead") + } + if c.HasSession() { + warnings = append(warnings, "Session scope config differs - PicoClaw uses per-agent sessions by default") + } + if c.HasAuthProfiles() { + warnings = append( + warnings, + "Auth profiles (API keys, OAuth tokens) not migrated for security - set env vars manually", + ) + } + + return cfg, warnings, nil +} + +type ModelConfig struct { + ModelName string `json:"model_name"` + Model string `json:"model"` + APIBase string `json:"api_base,omitempty"` + APIKey string `json:"api_key"` + Proxy string `json:"proxy,omitempty"` +} + +type PicoClawConfig struct { + Agents AgentsConfig `json:"agents"` + Bindings []AgentBinding `json:"bindings,omitempty"` + Channels ChannelsConfig `json:"channels"` + ModelList []ModelConfig `json:"model_list"` + Gateway GatewayConfig `json:"gateway"` + Tools ToolsConfig `json:"tools"` +} + +type AgentsConfig struct { + Defaults AgentDefaults `json:"defaults"` + List []AgentConfig `json:"list,omitempty"` +} + +type AgentDefaults struct { + Workspace string `json:"workspace"` + RestrictToWorkspace bool `json:"restrict_to_workspace"` + Provider string `json:"provider"` + ModelName string `json:"model_name"` + Model string `json:"model,omitempty"` + ModelFallbacks []string `json:"model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` + MaxTokens int `json:"max_tokens"` + Temperature *float64 `json:"temperature,omitempty"` + MaxToolIterations int `json:"max_tool_iterations"` +} + +type AgentConfig struct { + ID string `json:"id"` + Default bool `json:"default,omitempty"` + Name string `json:"name,omitempty"` + Workspace string `json:"workspace,omitempty"` + Model *AgentModelConfig `json:"model,omitempty"` + Skills []string `json:"skills,omitempty"` +} + +type AgentModelConfig struct { + Primary string `json:"primary,omitempty"` + Fallbacks []string `json:"fallbacks,omitempty"` +} + +type AgentBinding struct { + AgentID string `json:"agent_id"` + Match BindingMatch `json:"match"` +} + +type BindingMatch struct { + Channel string `json:"channel"` + AccountID string `json:"account_id,omitempty"` + Peer *PeerMatch `json:"peer,omitempty"` + GuildID string `json:"guild_id,omitempty"` + TeamID string `json:"team_id,omitempty"` +} + +type PeerMatch struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +type ChannelsConfig struct { + WhatsApp WhatsAppConfig `json:"whatsapp"` + Telegram TelegramConfig `json:"telegram"` + Feishu FeishuConfig `json:"feishu"` + Discord DiscordConfig `json:"discord"` + MaixCam MaixCamConfig `json:"maixcam"` + QQ QQConfig `json:"qq"` + DingTalk DingTalkConfig `json:"dingtalk"` + Slack SlackConfig `json:"slack"` + LINE LINEConfig `json:"line"` +} + +type WhatsAppConfig struct { + Enabled bool `json:"enabled"` + BridgeURL string `json:"bridge_url"` + AllowFrom []string `json:"allow_from"` +} + +type TelegramConfig struct { + Enabled bool `json:"enabled"` + Token string `json:"token"` + Proxy string `json:"proxy"` + AllowFrom []string `json:"allow_from"` +} + +type FeishuConfig struct { + Enabled bool `json:"enabled"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + EncryptKey string `json:"encrypt_key"` + VerificationToken string `json:"verification_token"` + AllowFrom []string `json:"allow_from"` +} + +type DiscordConfig struct { + Enabled bool `json:"enabled"` + Token string `json:"token"` + MentionOnly bool `json:"mention_only"` + AllowFrom []string `json:"allow_from"` +} + +type MaixCamConfig struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` + Port int `json:"port"` + AllowFrom []string `json:"allow_from"` +} + +type QQConfig struct { + Enabled bool `json:"enabled"` + AppID string `json:"app_id"` + AppSecret string `json:"app_secret"` + AllowFrom []string `json:"allow_from"` +} + +type DingTalkConfig struct { + Enabled bool `json:"enabled"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + AllowFrom []string `json:"allow_from"` +} + +type SlackConfig struct { + Enabled bool `json:"enabled"` + BotToken string `json:"bot_token"` + AppToken string `json:"app_token"` + AllowFrom []string `json:"allow_from"` +} + +type LINEConfig struct { + Enabled bool `json:"enabled"` + ChannelSecret string `json:"channel_secret"` + ChannelAccessToken string `json:"channel_access_token"` + WebhookHost string `json:"webhook_host"` + WebhookPort int `json:"webhook_port"` + WebhookPath string `json:"webhook_path"` + AllowFrom []string `json:"allow_from"` +} + +type GatewayConfig struct { + Host string `json:"host"` + Port int `json:"port"` +} + +type ToolsConfig struct { + Web WebToolsConfig `json:"web"` + Cron CronConfig `json:"cron"` + Exec ExecConfig `json:"exec"` +} + +type WebToolsConfig struct { + Brave BraveConfig `json:"brave"` + Tavily TavilyConfig `json:"tavily"` + DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` + Perplexity PerplexityConfig `json:"perplexity"` + Proxy string `json:"proxy,omitempty"` +} + +type BraveConfig struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + MaxResults int `json:"max_results"` +} + +type TavilyConfig struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + BaseURL string `json:"base_url"` + MaxResults int `json:"max_results"` +} + +type DuckDuckGoConfig struct { + Enabled bool `json:"enabled"` + MaxResults int `json:"max_results"` +} + +type PerplexityConfig struct { + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + MaxResults int `json:"max_results"` +} + +type CronConfig struct { + ExecTimeoutMinutes int `json:"exec_timeout_minutes"` +} + +type ExecConfig struct { + EnableDenyPatterns bool `json:"enable_deny_patterns"` + CustomDenyPatterns []string `json:"custom_deny_patterns"` +} + +func (c *OpenClawConfig) convertChannels(warnings *[]string) ChannelsConfig { + channels := ChannelsConfig{} + + if c.Channels == nil { + return channels + } + + if c.Channels.Telegram != nil { + enabled := c.Channels.Telegram.Enabled == nil || *c.Channels.Telegram.Enabled + channels.Telegram = TelegramConfig{ + Enabled: enabled, + AllowFrom: c.Channels.Telegram.AllowFrom, + } + if c.Channels.Telegram.BotToken != nil { + channels.Telegram.Token = *c.Channels.Telegram.BotToken + } + } + + if c.Channels.Discord != nil { + enabled := c.Channels.Discord.Enabled == nil || *c.Channels.Discord.Enabled + channels.Discord = DiscordConfig{ + Enabled: enabled, + AllowFrom: c.Channels.Discord.AllowFrom, + } + if c.Channels.Discord.Token != nil { + channels.Discord.Token = *c.Channels.Discord.Token + } + } + + if c.Channels.Slack != nil { + enabled := c.Channels.Slack.Enabled == nil || *c.Channels.Slack.Enabled + channels.Slack = SlackConfig{ + Enabled: enabled, + AllowFrom: c.Channels.Slack.AllowFrom, + } + if c.Channels.Slack.BotToken != nil { + channels.Slack.BotToken = *c.Channels.Slack.BotToken + } + if c.Channels.Slack.AppToken != nil { + channels.Slack.AppToken = *c.Channels.Slack.AppToken + } + } + + if c.Channels.WhatsApp != nil { + enabled := c.Channels.WhatsApp.Enabled == nil || *c.Channels.WhatsApp.Enabled + channels.WhatsApp = WhatsAppConfig{ + Enabled: enabled, + AllowFrom: c.Channels.WhatsApp.AllowFrom, + } + if c.Channels.WhatsApp.BridgeURL != nil { + channels.WhatsApp.BridgeURL = *c.Channels.WhatsApp.BridgeURL + } + } + + if c.Channels.Feishu != nil { + enabled := c.Channels.Feishu.Enabled == nil || *c.Channels.Feishu.Enabled + channels.Feishu = FeishuConfig{ + Enabled: enabled, + AllowFrom: c.Channels.Feishu.AllowFrom, + } + if c.Channels.Feishu.AppID != nil { + channels.Feishu.AppID = *c.Channels.Feishu.AppID + } + if c.Channels.Feishu.AppSecret != nil { + channels.Feishu.AppSecret = *c.Channels.Feishu.AppSecret + } + if c.Channels.Feishu.EncryptKey != nil { + channels.Feishu.EncryptKey = *c.Channels.Feishu.EncryptKey + } + if c.Channels.Feishu.VerificationToken != nil { + channels.Feishu.VerificationToken = *c.Channels.Feishu.VerificationToken + } + } + + if c.Channels.QQ != nil && supportedChannels["qq"] { + channels.QQ = QQConfig{ + Enabled: true, + AllowFrom: c.Channels.QQ.AllowFrom, + } + if c.Channels.QQ.AppID != nil { + channels.QQ.AppID = *c.Channels.QQ.AppID + } + if c.Channels.QQ.AppSecret != nil { + channels.QQ.AppSecret = *c.Channels.QQ.AppSecret + } + } + + if c.Channels.DingTalk != nil && supportedChannels["dingtalk"] { + channels.DingTalk = DingTalkConfig{ + Enabled: true, + AllowFrom: c.Channels.DingTalk.AllowFrom, + } + if c.Channels.DingTalk.AppID != nil { + channels.DingTalk.ClientID = *c.Channels.DingTalk.AppID + } + if c.Channels.DingTalk.AppSecret != nil { + channels.DingTalk.ClientSecret = *c.Channels.DingTalk.AppSecret + } + } + + if c.Channels.MaixCam != nil && supportedChannels["maixcam"] { + channels.MaixCam = MaixCamConfig{ + Enabled: true, + AllowFrom: c.Channels.MaixCam.AllowFrom, + } + if c.Channels.MaixCam.Host != nil { + channels.MaixCam.Host = *c.Channels.MaixCam.Host + } + if c.Channels.MaixCam.Port != nil { + channels.MaixCam.Port = *c.Channels.MaixCam.Port + } + } + + if c.Channels.Signal != nil { + *warnings = append(*warnings, "Channel 'signal': No PicoClaw adapter available") + } + if c.Channels.Matrix != nil { + *warnings = append(*warnings, "Channel 'matrix': No PicoClaw adapter available") + } + if c.Channels.IRC != nil { + *warnings = append(*warnings, "Channel 'irc': No PicoClaw adapter available") + } + if c.Channels.Mattermost != nil { + *warnings = append(*warnings, "Channel 'mattermost': No PicoClaw adapter available") + } + if c.Channels.IMessage != nil { + *warnings = append(*warnings, "Channel 'imessage': macOS-only channel - requires manual setup") + } + if c.Channels.BlueBubbles != nil { + *warnings = append( + *warnings, + "Channel 'bluebubbles': No PicoClaw adapter available - consider iMessage instead", + ) + } + + return channels +} + +func (c *OpenClawConfig) convertAgents(warnings *[]string) []AgentConfig { + var agents []AgentConfig + + if c.Agents == nil { + return agents + } + + for _, entry := range c.Agents.List { + agentID := entry.ID + if agentID == "" { + continue + } + + agentName := agentID + if entry.Name != nil { + agentName = *entry.Name + } + + agentCfg := AgentConfig{ + ID: agentID, + Name: agentName, + Default: len(agents) == 0, + } + + if entry.Workspace != nil { + agentCfg.Workspace = rewriteWorkspacePath(*entry.Workspace) + } + + if entry.Model != nil { + primary := entry.Model.GetPrimary() + if primary != "" { + agentCfg.Model = &AgentModelConfig{ + Primary: primary, + Fallbacks: entry.Model.GetFallbacks(), + } + } + } + + if len(entry.Skills) > 0 { + agentCfg.Skills = entry.Skills + } + + agents = append(agents, agentCfg) + } + + return agents +} + +func (c *PicoClawConfig) ToStandardConfig() *config.Config { + cfg := config.DefaultConfig() + + cfg.Agents.Defaults.Workspace = c.Agents.Defaults.Workspace + cfg.Agents.Defaults.Provider = c.Agents.Defaults.Provider + cfg.Agents.Defaults.ModelName = c.Agents.Defaults.ModelName + cfg.Agents.Defaults.ModelFallbacks = c.Agents.Defaults.ModelFallbacks + + for _, m := range c.ModelList { + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: m.ModelName, + Model: m.Model, + APIBase: m.APIBase, + APIKey: m.APIKey, + Proxy: m.Proxy, + }) + } + + cfg.Channels = c.Channels.ToStandardChannels() + cfg.Gateway = c.Gateway.ToStandardGateway() + cfg.Tools = c.Tools.ToStandardTools() + + cfg.Agents.List = make([]config.AgentConfig, len(c.Agents.List)) + for i, a := range c.Agents.List { + cfg.Agents.List[i] = config.AgentConfig{ + ID: a.ID, + Default: a.Default, + Name: a.Name, + Workspace: a.Workspace, + Skills: a.Skills, + } + if a.Model != nil { + cfg.Agents.List[i].Model = &config.AgentModelConfig{ + Primary: a.Model.Primary, + Fallbacks: a.Model.Fallbacks, + } + } + } + + return cfg +} + +func (c ChannelsConfig) ToStandardChannels() config.ChannelsConfig { + return config.ChannelsConfig{ + WhatsApp: config.WhatsAppConfig{ + Enabled: c.WhatsApp.Enabled, + BridgeURL: c.WhatsApp.BridgeURL, + }, + Telegram: config.TelegramConfig{ + Enabled: c.Telegram.Enabled, + Token: c.Telegram.Token, + Proxy: c.Telegram.Proxy, + }, + Feishu: config.FeishuConfig{ + Enabled: c.Feishu.Enabled, + AppID: c.Feishu.AppID, + AppSecret: c.Feishu.AppSecret, + EncryptKey: c.Feishu.EncryptKey, + VerificationToken: c.Feishu.VerificationToken, + }, + Discord: config.DiscordConfig{ + Enabled: c.Discord.Enabled, + Token: c.Discord.Token, + MentionOnly: c.Discord.MentionOnly, + }, + MaixCam: config.MaixCamConfig{ + Enabled: c.MaixCam.Enabled, + Host: c.MaixCam.Host, + Port: c.MaixCam.Port, + }, + QQ: config.QQConfig{ + Enabled: c.QQ.Enabled, + AppID: c.QQ.AppID, + AppSecret: c.QQ.AppSecret, + }, + DingTalk: config.DingTalkConfig{ + Enabled: c.DingTalk.Enabled, + ClientID: c.DingTalk.ClientID, + ClientSecret: c.DingTalk.ClientSecret, + }, + Slack: config.SlackConfig{ + Enabled: c.Slack.Enabled, + BotToken: c.Slack.BotToken, + AppToken: c.Slack.AppToken, + }, + LINE: config.LINEConfig{ + Enabled: c.LINE.Enabled, + ChannelSecret: c.LINE.ChannelSecret, + ChannelAccessToken: c.LINE.ChannelAccessToken, + WebhookHost: c.LINE.WebhookHost, + WebhookPort: c.LINE.WebhookPort, + WebhookPath: c.LINE.WebhookPath, + }, + } +} + +func (c GatewayConfig) ToStandardGateway() config.GatewayConfig { + return config.GatewayConfig{ + Host: c.Host, + Port: c.Port, + } +} + +func (c ToolsConfig) ToStandardTools() config.ToolsConfig { + return config.ToolsConfig{ + Web: config.WebToolsConfig{ + Brave: config.BraveConfig{ + Enabled: c.Web.Brave.Enabled, + APIKey: c.Web.Brave.APIKey, + MaxResults: c.Web.Brave.MaxResults, + }, + Tavily: config.TavilyConfig{ + Enabled: c.Web.Tavily.Enabled, + APIKey: c.Web.Tavily.APIKey, + BaseURL: c.Web.Tavily.BaseURL, + MaxResults: c.Web.Tavily.MaxResults, + }, + DuckDuckGo: config.DuckDuckGoConfig{ + Enabled: c.Web.DuckDuckGo.Enabled, + MaxResults: c.Web.DuckDuckGo.MaxResults, + }, + Perplexity: config.PerplexityConfig{ + Enabled: c.Web.Perplexity.Enabled, + APIKey: c.Web.Perplexity.APIKey, + MaxResults: c.Web.Perplexity.MaxResults, + }, + Proxy: c.Web.Proxy, + }, + Cron: config.CronToolsConfig{ + ExecTimeoutMinutes: c.Cron.ExecTimeoutMinutes, + }, + Exec: config.ExecConfig{ + EnableDenyPatterns: c.Exec.EnableDenyPatterns, + CustomDenyPatterns: c.Exec.CustomDenyPatterns, + }, + } +} diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go new file mode 100644 index 000000000..7d884522c --- /dev/null +++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go @@ -0,0 +1,714 @@ +package openclaw + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestLoadOpenClawConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + + testConfig := `{ + "agents": { + "defaults": { + "model": { + "primary": "anthropic/claude-sonnet-4-20250514" + }, + "workspace": "~/.openclaw/workspace" + }, + "list": [ + { + "id": "main", + "name": "Main Agent", + "model": { + "primary": "openai/gpt-4o", + "fallbacks": ["claude-3-opus"] + } + } + ] + }, + "channels": { + "telegram": { + "enabled": true, + "botToken": "test-token", + "allowFrom": ["user1", "user2"] + }, + "discord": { + "enabled": true, + "token": "discord-token" + } + }, + "models": { + "providers": { + "anthropic": { + "api_key": "sk-ant-test", + "base_url": "https://api.anthropic.com" + }, + "openai": { + "api_key": "sk-test" + } + } + } + }` + + err := os.WriteFile(configPath, []byte(testConfig), 0o644) + if err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + cfg, err := LoadOpenClawConfig(configPath) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + if cfg.Agents == nil { + t.Error("agents should not be nil") + } + + if cfg.Agents.Defaults == nil { + t.Error("agents.defaults should not be nil") + } + + provider, model := cfg.GetDefaultModel() + if provider != "anthropic" { + t.Errorf("expected provider 'anthropic', got '%s'", provider) + } + if model != "claude-sonnet-4-20250514" { + t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", model) + } + + workspace := cfg.GetDefaultWorkspace() + if workspace != "~/.picoclaw/workspace" { + t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", workspace) + } + + agents := cfg.GetAgents() + if len(agents) != 1 { + t.Errorf("expected 1 agent, got %d", len(agents)) + } + if agents[0].ID != "main" { + t.Errorf("expected agent id 'main', got '%s'", agents[0].ID) + } + + if cfg.Channels == nil { + t.Error("channels should not be nil") + } + if cfg.Channels.Telegram == nil { + t.Error("telegram channel should not be nil") + } + if cfg.Channels.Telegram.BotToken == nil || *cfg.Channels.Telegram.BotToken != "test-token" { + t.Error("telegram bot token not parsed correctly") + } +} + +func TestGetProviderConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + + testConfig := `{ + "models": { + "providers": { + "anthropic": { + "api_key": "sk-ant-test", + "base_url": "https://api.anthropic.com", + "max_tokens": 4096 + }, + "openai": { + "api_key": "sk-test", + "base_url": "https://api.openai.com" + } + } + } + }` + + err := os.WriteFile(configPath, []byte(testConfig), 0o644) + if err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + cfg, err := LoadOpenClawConfig(configPath) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + providers := GetProviderConfig(cfg.Models) + if len(providers) != 2 { + t.Errorf("expected 2 providers, got %d", len(providers)) + } + + if anthropic, ok := providers["anthropic"]; ok { + if anthropic.APIKey != "sk-ant-test" { + t.Errorf("expected anthropic api_key 'sk-ant-test', got '%s'", anthropic.APIKey) + } + if anthropic.BaseURL != "https://api.anthropic.com" { + t.Errorf("expected anthropic base_url 'https://api.anthropic.com', got '%s'", anthropic.BaseURL) + } + } else { + t.Error("anthropic provider not found") + } + + if openai, ok := providers["openai"]; ok { + if openai.APIKey != "sk-test" { + t.Errorf("expected openai api_key 'sk-test', got '%s'", openai.APIKey) + } + } else { + t.Error("openai provider not found") + } +} + +func TestConvertToPicoClaw(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + + testConfig := `{ + "agents": { + "defaults": { + "model": { + "primary": "anthropic/claude-sonnet-4-20250514" + }, + "workspace": "~/.openclaw/workspace" + }, + "list": [ + { + "id": "main", + "name": "Main Agent" + }, + { + "id": "assistant", + "name": "Assistant", + "skills": ["skill1", "skill2"] + } + ] + }, + "channels": { + "telegram": { + "enabled": true, + "botToken": "test-token", + "allowFrom": ["user1", "user2"] + }, + "discord": { + "enabled": false, + "token": "discord-token" + }, + "whatsapp": { + "enabled": true, + "bridgeUrl": "http://localhost:3000" + }, + "feishu": { + "enabled": true, + "appId": "app-id", + "appSecret": "app-secret", + "allowFrom": ["user3"] + }, + "signal": { + "enabled": true + } + }, + "models": { + "providers": { + "anthropic": { + "api_key": "sk-ant-test" + }, + "openai": { + "api_key": "sk-test" + } + } + }, + "skills": { + "entries": { + "skill1": {} + } + }, + "memory": {"enabled": true}, + "cron": {"enabled": true} + }` + + err := os.WriteFile(configPath, []byte(testConfig), 0o644) + if err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + cfg, err := LoadOpenClawConfig(configPath) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + picoCfg, warnings, err := cfg.ConvertToPicoClaw("") + if err != nil { + t.Fatalf("failed to convert config: %v", err) + } + + if picoCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" { + t.Errorf("expected model 'claude-sonnet-4-20250514', got '%s'", picoCfg.Agents.Defaults.ModelName) + } + if picoCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" { + t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", picoCfg.Agents.Defaults.Workspace) + } + + if len(picoCfg.Agents.List) != 2 { + t.Errorf("expected 2 agents, got %d", len(picoCfg.Agents.List)) + } + if picoCfg.Agents.List[0].ID != "main" { + t.Errorf("expected first agent id 'main', got '%s'", picoCfg.Agents.List[0].ID) + } + if picoCfg.Agents.List[1].Skills == nil || len(picoCfg.Agents.List[1].Skills) != 2 { + t.Errorf("expected 2 skills for assistant agent") + } + + if !picoCfg.Channels.Telegram.Enabled { + t.Error("telegram should be enabled") + } + if picoCfg.Channels.Telegram.Token != "test-token" { + t.Errorf("expected telegram token 'test-token', got '%s'", picoCfg.Channels.Telegram.Token) + } + + if picoCfg.Channels.WhatsApp.BridgeURL != "http://localhost:3000" { + t.Errorf("expected whatsapp bridge URL 'http://localhost:3000', got '%s'", picoCfg.Channels.WhatsApp.BridgeURL) + } + + if picoCfg.Channels.Feishu.AppID != "app-id" { + t.Errorf("expected feishu app ID 'app-id', got '%s'", picoCfg.Channels.Feishu.AppID) + } + + if len(picoCfg.ModelList) != 1 { + t.Errorf("expected 1 model config (no models.json provided), got %d", len(picoCfg.ModelList)) + } + + foundWarning := false + for _, w := range warnings { + if len(w) > 0 { + foundWarning = true + break + } + } + if !foundWarning { + t.Log("warnings should be generated for skills, memory, cron, and unsupported channels") + } +} + +func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + + testConfig := `{ + "agents": { + "defaults": { + "model": { + "primary": "anthropic/claude-sonnet-4-20250514" + } + } + }, + "channels": { + "qq": { + "enabled": true, + "appId": "qq-app-id", + "appSecret": "qq-app-secret" + }, + "dingtalk": { + "enabled": true, + "appId": "ding-app-id", + "appSecret": "ding-app-secret" + }, + "maixcam": { + "enabled": true, + "host": "192.168.1.100", + "port": 9000 + }, + "slack": { + "enabled": true, + "botToken": "xoxb-test", + "appToken": "xapp-test" + } + } + }` + + err := os.WriteFile(configPath, []byte(testConfig), 0o644) + if err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + cfg, err := LoadOpenClawConfig(configPath) + if err != nil { + t.Fatalf("failed to load config: %v", err) + } + + picoCfg, _, err := cfg.ConvertToPicoClaw("") + if err != nil { + t.Fatalf("failed to convert config: %v", err) + } + + if !picoCfg.Channels.QQ.Enabled { + t.Error("qq should be enabled") + } + if picoCfg.Channels.QQ.AppID != "qq-app-id" { + t.Errorf("expected qq app ID 'qq-app-id', got '%s'", picoCfg.Channels.QQ.AppID) + } + + if !picoCfg.Channels.DingTalk.Enabled { + t.Error("dingtalk should be enabled") + } + if picoCfg.Channels.DingTalk.ClientID != "ding-app-id" { + t.Errorf("expected dingtalk client ID 'ding-app-id', got '%s'", picoCfg.Channels.DingTalk.ClientID) + } + + if !picoCfg.Channels.MaixCam.Enabled { + t.Error("maixcam should be enabled") + } + if picoCfg.Channels.MaixCam.Host != "192.168.1.100" { + t.Errorf("expected maixcam host '192.168.1.100', got '%s'", picoCfg.Channels.MaixCam.Host) + } + if picoCfg.Channels.MaixCam.Port != 9000 { + t.Errorf("expected maixcam port 9000, got %d", picoCfg.Channels.MaixCam.Port) + } + + if !picoCfg.Channels.Slack.Enabled { + t.Error("slack should be enabled") + } + if picoCfg.Channels.Slack.BotToken != "xoxb-test" { + t.Errorf("expected slack bot token 'xoxb-test', got '%s'", picoCfg.Channels.Slack.BotToken) + } + if picoCfg.Channels.Slack.AppToken != "xapp-test" { + t.Errorf("expected slack app token 'xapp-test', got '%s'", picoCfg.Channels.Slack.AppToken) + } +} + +func TestOpenClawAgentModel(t *testing.T) { + model := &OpenClawAgentModel{ + Primary: strPtr("anthropic/claude-3-opus"), + Fallbacks: []string{"claude-3-sonnet", "claude-3-haiku"}, + } + + primary := model.GetPrimary() + if primary != "anthropic/claude-3-opus" { + t.Errorf("expected primary 'anthropic/claude-3-opus', got '%s'", primary) + } + + fallbacks := model.GetFallbacks() + if len(fallbacks) != 2 { + t.Errorf("expected 2 fallbacks, got %d", len(fallbacks)) + } + + model2 := &OpenClawAgentModel{ + Simple: "claude-3-opus", + } + + primary2 := model2.GetPrimary() + if primary2 != "claude-3-opus" { + t.Errorf("expected primary 'claude-3-opus' from Simple, got '%s'", primary2) + } +} + +func TestChannelEnabled(t *testing.T) { + cfg := &OpenClawConfig{ + Channels: &OpenClawChannels{ + Telegram: &OpenClawTelegramConfig{ + Enabled: boolPtr(true), + }, + Discord: &OpenClawDiscordConfig{ + Enabled: boolPtr(false), + }, + Slack: &OpenClawSlackConfig{ + Enabled: boolPtr(true), + }, + }, + } + + if !cfg.IsChannelEnabled("telegram") { + t.Error("telegram should be enabled") + } + if cfg.IsChannelEnabled("discord") { + t.Error("discord should be disabled") + } + if !cfg.IsChannelEnabled("slack") { + t.Error("slack should be enabled (explicitly set)") + } + if cfg.IsChannelEnabled("line") { + t.Error("line should return false (not in switch cases)") + } +} + +func TestGetDefaultModel(t *testing.T) { + cfg := &OpenClawConfig{ + Agents: &OpenClawAgents{ + Defaults: &OpenClawAgentDefaults{ + Model: &OpenClawAgentModel{ + Primary: strPtr("openai/gpt-4"), + }, + }, + }, + } + + provider, model := cfg.GetDefaultModel() + if provider != "openai" { + t.Errorf("expected provider 'openai', got '%s'", provider) + } + if model != "gpt-4" { + t.Errorf("expected model 'gpt-4', got '%s'", model) + } +} + +func TestGetDefaultModelWithNoDefaults(t *testing.T) { + cfg := &OpenClawConfig{} + + provider, model := cfg.GetDefaultModel() + if provider != "anthropic" { + t.Errorf("expected default provider 'anthropic', got '%s'", provider) + } + if model != "claude-sonnet-4-20250514" { + t.Errorf("expected default model 'claude-sonnet-4-20250514', got '%s'", model) + } +} + +func TestHasFunctions(t *testing.T) { + cfg := &OpenClawConfig{ + Skills: &OpenClawSkills{Entries: map[string]json.RawMessage{"skill1": nil}}, + Memory: json.RawMessage(`{"enabled": true}`), + Cron: json.RawMessage(`{"enabled": true}`), + Hooks: json.RawMessage(`{"enabled": true}`), + Session: json.RawMessage(`{"enabled": true}`), + Auth: &OpenClawAuth{Profiles: json.RawMessage(`{"profile1": {}}`)}, + } + + if !cfg.HasSkills() { + t.Error("should have skills") + } + if !cfg.HasMemory() { + t.Error("should have memory") + } + if !cfg.HasCron() { + t.Error("should have cron") + } + if !cfg.HasHooks() { + t.Error("should have hooks") + } + if !cfg.HasSession() { + t.Error("should have session") + } + if !cfg.HasAuthProfiles() { + t.Error("should have auth profiles") + } + + cfg2 := &OpenClawConfig{} + if cfg2.HasSkills() { + t.Error("should not have skills") + } + if cfg2.HasMemory() { + t.Error("should not have memory") + } +} + +func TestLoadOpenClawConfigFromDir(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + + testConfig := `{"agents": {}}` + err := os.WriteFile(configPath, []byte(testConfig), 0o644) + if err != nil { + t.Fatalf("failed to write test config: %v", err) + } + + cfg, err := LoadOpenClawConfigFromDir(tmpDir) + if err != nil { + t.Fatalf("failed to load config from dir: %v", err) + } + + if cfg.Agents == nil { + t.Error("agents should not be nil") + } + + _, err = LoadOpenClawConfigFromDir("/nonexistent/dir") + if err == nil { + t.Error("should return error for nonexistent dir") + } +} + +func TestToStandardConfig(t *testing.T) { + picoCfg := &PicoClawConfig{ + Agents: AgentsConfig{ + Defaults: AgentDefaults{ + Provider: "anthropic", + ModelName: "claude-sonnet-4-20250514", + Workspace: "~/.picoclaw/workspace", + }, + List: []AgentConfig{ + { + ID: "main", + Name: "Main Agent", + Default: true, + }, + }, + }, + ModelList: []ModelConfig{ + { + ModelName: "claude-sonnet-4-20250514", + Model: "anthropic/claude-sonnet-4-20250514", + APIKey: "sk-ant-test", + }, + }, + Channels: ChannelsConfig{ + Telegram: TelegramConfig{ + Enabled: true, + Token: "test-token", + AllowFrom: []string{"user1"}, + }, + WhatsApp: WhatsAppConfig{ + Enabled: true, + BridgeURL: "http://localhost:3000", + }, + }, + Gateway: GatewayConfig{ + Host: "0.0.0.0", + Port: 8080, + }, + } + + stdCfg := picoCfg.ToStandardConfig() + + if stdCfg.Agents.Defaults.Provider != "anthropic" { + t.Errorf("expected provider 'anthropic', got '%s'", stdCfg.Agents.Defaults.Provider) + } + if stdCfg.Agents.Defaults.ModelName != "claude-sonnet-4-20250514" { + t.Errorf("expected model name 'claude-sonnet-4-20250514', got '%s'", stdCfg.Agents.Defaults.ModelName) + } + if stdCfg.Agents.Defaults.Workspace != "~/.picoclaw/workspace" { + t.Errorf("expected workspace '~/.picoclaw/workspace', got '%s'", stdCfg.Agents.Defaults.Workspace) + } + + if len(stdCfg.Agents.List) != 1 { + t.Errorf("expected 1 agent, got %d", len(stdCfg.Agents.List)) + } + if stdCfg.Agents.List[0].ID != "main" { + t.Errorf("expected agent id 'main', got '%s'", stdCfg.Agents.List[0].ID) + } + + foundModel := false + var foundAPIKey string + for _, m := range stdCfg.ModelList { + if m.ModelName == "claude-sonnet-4-20250514" { + foundModel = true + foundAPIKey = m.APIKey + break + } + } + if !foundModel { + t.Error("expected to find claude-sonnet-4-20250514 model config") + } + if foundAPIKey != "sk-ant-test" { + t.Errorf("expected api key 'sk-ant-test', got '%s'", foundAPIKey) + } + + if !stdCfg.Channels.Telegram.Enabled { + t.Error("telegram should be enabled") + } + if stdCfg.Channels.Telegram.Token != "test-token" { + t.Errorf("expected token 'test-token', got '%s'", stdCfg.Channels.Telegram.Token) + } + + if stdCfg.Gateway.Port != 8080 { + t.Errorf("expected gateway port 8080, got %d", stdCfg.Gateway.Port) + } +} + +func TestLoadProviderConfigFromAgentsDir(t *testing.T) { + tmpDir := t.TempDir() + + agentsDir := filepath.Join(tmpDir, "agents", "main", "agent") + err := os.MkdirAll(agentsDir, 0o755) + if err != nil { + t.Fatalf("failed to create agents dir: %v", err) + } + + modelsJSON := `{ + "providers": { + "anthropic": { + "baseUrl": "https://api.anthropic.com", + "api": "anthropic", + "apiKey": "sk-ant-from-models", + "models": [ + { + "id": "claude-sonnet-4-20250514", + "name": "Claude Sonnet 4" + } + ] + }, + "openai": { + "baseUrl": "https://api.openai.com", + "api": "openai", + "apiKey": "sk-from-models", + "models": [ + { + "id": "gpt-4o", + "name": "GPT-4o" + } + ] + }, + "zhipu": { + "baseUrl": "https://open.bigmodel.cn/api/paas/v4", + "api": "openai", + "apiKey": "zhipu-key", + "models": [] + } + } + }` + + err = os.WriteFile(filepath.Join(agentsDir, "models.json"), []byte(modelsJSON), 0o644) + if err != nil { + t.Fatalf("failed to write models.json: %v", err) + } + + providers := GetProviderConfigFromDir(tmpDir) + if len(providers) != 3 { + t.Errorf("expected 3 providers, got %d", len(providers)) + } + + if anthropic, ok := providers["anthropic"]; ok { + if anthropic.ApiKey != "sk-ant-from-models" { + t.Errorf("expected anthropic apiKey 'sk-ant-from-models', got '%s'", anthropic.ApiKey) + } + if anthropic.BaseUrl != "https://api.anthropic.com" { + t.Errorf("expected anthropic baseUrl 'https://api.anthropic.com', got '%s'", anthropic.BaseUrl) + } + } else { + t.Error("anthropic provider not found") + } + + if openai, ok := providers["openai"]; ok { + if openai.ApiKey != "sk-from-models" { + t.Errorf("expected openai apiKey 'sk-from-models', got '%s'", openai.ApiKey) + } + if openai.BaseUrl != "https://api.openai.com" { + t.Errorf("expected openai baseUrl 'https://api.openai.com', got '%s'", openai.BaseUrl) + } + } else { + t.Error("openai provider not found") + } + + if zhipu, ok := providers["zhipu"]; ok { + if zhipu.ApiKey != "zhipu-key" { + t.Errorf("expected zhipu apiKey 'zhipu-key', got '%s'", zhipu.ApiKey) + } + if zhipu.BaseUrl != "https://open.bigmodel.cn/api/paas/v4" { + t.Errorf("expected zhipu baseUrl 'https://open.bigmodel.cn/api/paas/v4', got '%s'", zhipu.BaseUrl) + } + } else { + t.Error("zhipu provider not found") + } +} + +func TestGetProviderConfigFromDirNotExist(t *testing.T) { + providers := GetProviderConfigFromDir("/nonexistent/path") + if len(providers) != 0 { + t.Errorf("expected 0 providers for nonexistent path, got %d", len(providers)) + } +} + +func strPtr(s string) *string { + return &s +} + +func boolPtr(b bool) *bool { + return &b +} diff --git a/pkg/migrate/sources/openclaw/openclaw_handler.go b/pkg/migrate/sources/openclaw/openclaw_handler.go new file mode 100644 index 000000000..aaff119f1 --- /dev/null +++ b/pkg/migrate/sources/openclaw/openclaw_handler.go @@ -0,0 +1,148 @@ +package openclaw + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/migrate/internal" +) + +var providerMapping = map[string]string{ + "anthropic": "anthropic", + "claude": "anthropic", + "openai": "openai", + "gpt": "openai", + "groq": "groq", + "ollama": "ollama", + "openrouter": "openrouter", + "deepseek": "deepseek", + "together": "together", + "mistral": "mistral", + "fireworks": "fireworks", + "google": "google", + "gemini": "google", + "xai": "xai", + "grok": "xai", + "cerebras": "cerebras", + "sambanova": "sambanova", +} + +type OpenclawHandler struct { + opts Options + sourceConfigFile string + sourceWorkspace string +} + +type ( + Options = internal.Options + Action = internal.Action + Result = internal.Result + Operation = internal.Operation +) + +func NewOpenclawHandler(opts Options) (Operation, error) { + home, err := resolveSourceHome(opts.SourceHome) + if err != nil { + return nil, err + } + opts.SourceHome = home + + configFile, err := findSourceConfig(home) + if err != nil { + return nil, err + } + return &OpenclawHandler{ + opts: opts, + sourceWorkspace: filepath.Join(opts.SourceHome, "workspace"), + sourceConfigFile: configFile, + }, nil +} + +func (o *OpenclawHandler) GetSourceName() string { + return "openclaw" +} + +func (o *OpenclawHandler) GetSourceHome() (string, error) { + return o.opts.SourceHome, nil +} + +func (o *OpenclawHandler) GetSourceWorkspace() (string, error) { + return o.sourceWorkspace, nil +} + +func (o *OpenclawHandler) GetSourceConfigFile() (string, error) { + return o.sourceConfigFile, nil +} + +func (o *OpenclawHandler) GetMigrateableFiles() []string { + return migrateableFiles +} + +func (o *OpenclawHandler) GetMigrateableDirs() []string { + return migrateableDirs +} + +func (o *OpenclawHandler) ExecuteConfigMigration(srcConfigPath, dstConfigPath string) error { + openclawCfg, err := LoadOpenClawConfig(srcConfigPath) + if err != nil { + return err + } + + picoCfg, warnings, err := openclawCfg.ConvertToPicoClaw(o.opts.SourceHome) + if err != nil { + return err + } + + for _, w := range warnings { + fmt.Printf(" Warning: %s\n", w) + } + + incoming := picoCfg.ToStandardConfig() + if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0o755); err != nil { + return err + } + + return config.SaveConfig(dstConfigPath, incoming) +} + +func resolveSourceHome(override string) (string, error) { + if override != "" { + return internal.ExpandHome(override), nil + } + if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" { + return internal.ExpandHome(envHome), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolving home directory: %w", err) + } + return filepath.Join(home, ".openclaw"), nil +} + +func findSourceConfig(sourceHome string) (string, error) { + candidates := []string{ + filepath.Join(sourceHome, "openclaw.json"), + filepath.Join(sourceHome, "config.json"), + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", sourceHome) +} + +func rewriteWorkspacePath(path string) string { + path = strings.Replace(path, ".openclaw", ".picoclaw", 1) + return path +} + +func mapProvider(provider string) string { + if mapped, ok := providerMapping[strings.ToLower(provider)]; ok { + return mapped + } + return strings.ToLower(provider) +} diff --git a/pkg/migrate/sources/openclaw/openclaw_handler_test.go b/pkg/migrate/sources/openclaw/openclaw_handler_test.go new file mode 100644 index 000000000..35bd09be0 --- /dev/null +++ b/pkg/migrate/sources/openclaw/openclaw_handler_test.go @@ -0,0 +1,247 @@ +package openclaw + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewOpenclawHandler(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + handler, err := NewOpenclawHandler(Options{ + SourceHome: tmpDir, + }) + require.NoError(t, err) + require.NotNil(t, handler) +} + +func TestNewOpenclawHandlerNoConfig(t *testing.T) { + tmpDir := t.TempDir() + + _, err := NewOpenclawHandler(Options{ + SourceHome: tmpDir, + }) + require.Error(t, err) +} + +func TestOpenclawHandlerGetSourceName(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + handler, err := NewOpenclawHandler(Options{ + SourceHome: tmpDir, + }) + require.NoError(t, err) + + assert.Equal(t, "openclaw", handler.GetSourceName()) +} + +func TestOpenclawHandlerGetSourceHome(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + handler, err := NewOpenclawHandler(Options{ + SourceHome: tmpDir, + }) + require.NoError(t, err) + + home, err := handler.GetSourceHome() + require.NoError(t, err) + assert.Equal(t, tmpDir, home) +} + +func TestOpenclawHandlerGetSourceWorkspace(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + handler, err := NewOpenclawHandler(Options{ + SourceHome: tmpDir, + }) + require.NoError(t, err) + + workspace, err := handler.GetSourceWorkspace() + require.NoError(t, err) + assert.Equal(t, filepath.Join(tmpDir, "workspace"), workspace) +} + +func TestOpenclawHandlerGetSourceConfigFile(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + handler, err := NewOpenclawHandler(Options{ + SourceHome: tmpDir, + }) + require.NoError(t, err) + + configFile, err := handler.GetSourceConfigFile() + require.NoError(t, err) + assert.Equal(t, configPath, configFile) +} + +func TestOpenclawHandlerGetSourceConfigFileWithConfigJson(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + handler, err := NewOpenclawHandler(Options{ + SourceHome: tmpDir, + }) + require.NoError(t, err) + + configFile, err := handler.GetSourceConfigFile() + require.NoError(t, err) + assert.Equal(t, configPath, configFile) +} + +func TestOpenclawHandlerGetMigrateableFiles(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + handler, err := NewOpenclawHandler(Options{ + SourceHome: tmpDir, + }) + require.NoError(t, err) + + files := handler.GetMigrateableFiles() + assert.NotEmpty(t, files) + assert.Contains(t, files, "AGENTS.md") + assert.Contains(t, files, "SOUL.md") + assert.Contains(t, files, "USER.md") +} + +func TestOpenclawHandlerGetMigrateableDirs(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + handler, err := NewOpenclawHandler(Options{ + SourceHome: tmpDir, + }) + require.NoError(t, err) + + dirs := handler.GetMigrateableDirs() + assert.NotEmpty(t, dirs) + assert.Contains(t, dirs, "memory") + assert.Contains(t, dirs, "skills") +} + +func TestResolveSourceHome(t *testing.T) { + result, err := resolveSourceHome("/custom/path") + require.NoError(t, err) + assert.Equal(t, "/custom/path", result) +} + +func TestResolveSourceHomeWithEnvVar(t *testing.T) { + t.Setenv("OPENCLAW_HOME", "/env/path") + + result, err := resolveSourceHome("") + require.NoError(t, err) + assert.Equal(t, "/env/path", result) +} + +func TestResolveSourceHomeWithTilde(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + + result, err := resolveSourceHome("~/openclaw") + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, "openclaw"), result) +} + +func TestFindSourceConfig(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "openclaw.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + result, err := findSourceConfig(tmpDir) + require.NoError(t, err) + assert.Equal(t, configPath, result) +} + +func TestFindSourceConfigWithConfigJson(t *testing.T) { + tmpDir := t.TempDir() + configPath := filepath.Join(tmpDir, "config.json") + err := os.WriteFile(configPath, []byte("{}"), 0o644) + require.NoError(t, err) + + result, err := findSourceConfig(tmpDir) + require.NoError(t, err) + assert.Equal(t, configPath, result) +} + +func TestFindSourceConfigNotFound(t *testing.T) { + tmpDir := t.TempDir() + + _, err := findSourceConfig(tmpDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "no config file found") +} + +func TestMapProvider(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"anthropic", "anthropic"}, + {"claude", "anthropic"}, + {"openai", "openai"}, + {"gpt", "openai"}, + {"groq", "groq"}, + {"ollama", "ollama"}, + {"openrouter", "openrouter"}, + {"deepseek", "deepseek"}, + {"together", "together"}, + {"mistral", "mistral"}, + {"fireworks", "fireworks"}, + {"google", "google"}, + {"gemini", "google"}, + {"xai", "xai"}, + {"grok", "xai"}, + {"cerebras", "cerebras"}, + {"sambanova", "sambanova"}, + {"unknown", "unknown"}, + {"", ""}, + } + + for _, tt := range tests { + result := mapProvider(tt.input) + assert.Equal(t, tt.expected, result, "mapProvider(%q)", tt.input) + } +} + +func TestRewriteWorkspacePath(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"~/.openclaw/workspace", "~/.picoclaw/workspace"}, + {"/home/user/.openclaw/workspace", "/home/user/.picoclaw/workspace"}, + {"/path/without/openclaw/change", "/path/without/openclaw/change"}, + {"", ""}, + } + + for _, tt := range tests { + result := rewriteWorkspacePath(tt.input) + assert.Equal(t, tt.expected, result, "rewriteWorkspacePath(%q)", tt.input) + } +} From b88e590c6ce0040805c2e3045c30dfcae0da1ae9 Mon Sep 17 00:00:00 2001 From: afjcjsbx Date: Sat, 28 Feb 2026 13:34:33 +0100 Subject: [PATCH 086/104] moved fetch limit bytes in config file --- pkg/agent/loop.go | 2 +- pkg/config/config.go | 3 ++- pkg/config/defaults.go | 3 ++- pkg/tools/web.go | 30 +++++++++++++++++++----------- pkg/tools/web_test.go | 28 +++++++++++++++------------- 5 files changed, 39 insertions(+), 27 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 29827d0b2..504ce5c38 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -115,7 +115,7 @@ func registerSharedTools( }); searchTool != nil { agent.Tools.Register(searchTool) } - agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy)) + agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes)) // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms agent.Tools.Register(tools.NewI2CTool()) diff --git a/pkg/config/config.go b/pkg/config/config.go index d84772d2b..55d0cfb2c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -523,7 +523,8 @@ type WebToolsConfig struct { Perplexity PerplexityConfig `json:"perplexity"` // Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h). // For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config. - Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` + Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"` + FetchLimitBytes int64 `json:"fetch_limit_bytes,omitempty" env:"PICOCLAW_TOOLS_WEB_FETCH_LIMIT_BYTES"` } type CronToolsConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index ebb924859..a2977b17e 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -299,7 +299,8 @@ func DefaultConfig() *Config { Interval: 5, }, Web: WebToolsConfig{ - Proxy: "", + Proxy: "", + FetchLimitBytes: 10 * 1024 * 1024, // 10MB by default Brave: BraveConfig{ Enabled: false, APIKey: "", diff --git a/pkg/tools/web.go b/pkg/tools/web.go index bac05a862..695cc07c5 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -15,8 +15,7 @@ import ( ) const ( - userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - MaxFetchLimitBytes = int64(10 * 1024 * 1024) // 10MB limit + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" ) // Pre-compiled regexes for HTML text extraction @@ -508,26 +507,35 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR } type WebFetchTool struct { - maxChars int - proxy string + maxChars int + proxy string + fetchLimitBytes int64 } -func NewWebFetchTool(maxChars int) *WebFetchTool { +func NewWebFetchTool(maxChars int, fetchLimitBytes int64) *WebFetchTool { if maxChars <= 0 { maxChars = 50000 } + if fetchLimitBytes <= 0 { + fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback + } return &WebFetchTool{ - maxChars: maxChars, + maxChars: maxChars, + fetchLimitBytes: fetchLimitBytes, } } -func NewWebFetchToolWithProxy(maxChars int, proxy string) *WebFetchTool { +func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) *WebFetchTool { if maxChars <= 0 { maxChars = 50000 } + if fetchLimitBytes <= 0 { + fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback + } return &WebFetchTool{ - maxChars: maxChars, - proxy: proxy, + maxChars: maxChars, + proxy: proxy, + fetchLimitBytes: fetchLimitBytes, } } @@ -608,7 +616,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult(fmt.Sprintf("request failed: %v", err)) } - resp.Body = http.MaxBytesReader(nil, resp.Body, MaxFetchLimitBytes) + resp.Body = http.MaxBytesReader(nil, resp.Body, t.fetchLimitBytes) defer resp.Body.Close() @@ -616,7 +624,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe if err != nil { var maxBytesErr *http.MaxBytesError if errors.As(err, &maxBytesErr) { - return ErrorResult(fmt.Sprintf("failed to read response: size exceeded %d bytes limit", MaxFetchLimitBytes)) + return ErrorResult(fmt.Sprintf("failed to read response: size exceeded %d bytes limit", t.fetchLimitBytes)) } return ErrorResult(fmt.Sprintf("failed to read response: %v", err)) } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 6735d8b05..299b911fd 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -12,6 +12,8 @@ import ( "time" ) +const testFetchLimit = int64(10 * 1024 * 1024) + // TestWebTool_WebFetch_Success verifies successful URL fetching func TestWebTool_WebFetch_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -21,7 +23,7 @@ func TestWebTool_WebFetch_Success(t *testing.T) { })) defer server.Close() - tool := NewWebFetchTool(50000) + tool := NewWebFetchTool(50000, testFetchLimit) ctx := context.Background() args := map[string]any{ "url": server.URL, @@ -57,7 +59,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { })) defer server.Close() - tool := NewWebFetchTool(50000) + tool := NewWebFetchTool(50000, testFetchLimit) ctx := context.Background() args := map[string]any{ "url": server.URL, @@ -78,7 +80,7 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { // TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL func TestWebTool_WebFetch_InvalidURL(t *testing.T) { - tool := NewWebFetchTool(50000) + tool := NewWebFetchTool(50000, testFetchLimit) ctx := context.Background() args := map[string]any{ "url": "not-a-valid-url", @@ -99,7 +101,7 @@ func TestWebTool_WebFetch_InvalidURL(t *testing.T) { // TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { - tool := NewWebFetchTool(50000) + tool := NewWebFetchTool(50000, testFetchLimit) ctx := context.Background() args := map[string]any{ "url": "ftp://example.com/file.txt", @@ -120,7 +122,7 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { // TestWebTool_WebFetch_MissingURL verifies error handling for missing URL func TestWebTool_WebFetch_MissingURL(t *testing.T) { - tool := NewWebFetchTool(50000) + tool := NewWebFetchTool(50000, testFetchLimit) ctx := context.Background() args := map[string]any{} @@ -148,7 +150,7 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { })) defer server.Close() - tool := NewWebFetchTool(1000) // Limit to 1000 chars + tool := NewWebFetchTool(1000, testFetchLimit) // Limit to 1000 chars ctx := context.Background() args := map[string]any{ "url": server.URL, @@ -184,7 +186,7 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { // Generate a payload intentionally larger than our limit. // Limit: 10 * 1024 * 1024 (10MB). We generate 10MB + 100 bytes of the letter 'A'. - largeData := bytes.Repeat([]byte("A"), int(MaxFetchLimitBytes)+100) + largeData := bytes.Repeat([]byte("A"), int(testFetchLimit)+100) w.Write(largeData) })) @@ -192,7 +194,7 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { defer ts.Close() // Initialize the tool - tool := NewWebFetchTool(50000) + tool := NewWebFetchTool(50000, testFetchLimit) // Prepare the arguments pointing to the URL of our local mock server args := map[string]any{ @@ -209,7 +211,7 @@ func TestWebFetchTool_PayloadTooLarge(t *testing.T) { } // Search for the exact error string we set earlier in the Execute method - expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", MaxFetchLimitBytes) + expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit) if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) { t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result) @@ -257,7 +259,7 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { })) defer server.Close() - tool := NewWebFetchTool(50000) + tool := NewWebFetchTool(50000, testFetchLimit) ctx := context.Background() args := map[string]any{ "url": server.URL, @@ -358,7 +360,7 @@ func TestWebFetchTool_extractText(t *testing.T) { // TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain func TestWebTool_WebFetch_MissingDomain(t *testing.T) { - tool := NewWebFetchTool(50000) + tool := NewWebFetchTool(50000, testFetchLimit) ctx := context.Background() args := map[string]any{ "url": "https://", @@ -480,7 +482,7 @@ func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) { } func TestNewWebFetchToolWithProxy(t *testing.T) { - tool := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890") + tool := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit) if tool.maxChars != 1024 { t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024) } @@ -488,7 +490,7 @@ func TestNewWebFetchToolWithProxy(t *testing.T) { t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890") } - tool = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890") + tool = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", testFetchLimit) if tool.maxChars != 50000 { t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000) } From 62f59f76e3aaea62e7e9066926db010366af1ca5 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 21:30:58 +0800 Subject: [PATCH 087/104] fix(wecom): use channel context instead of HTTP request context for async message processing The HTTP request context is canceled as soon as the handler returns the response, causing PublishInbound to fail with "context canceled" when processMessage runs asynchronously in a goroutine. Use the channel's long-lived context (c.ctx) instead. --- pkg/channels/wecom/app.go | 5 +++-- pkg/channels/wecom/bot.go | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 42a74e8c9..3a25e12e1 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -567,8 +567,9 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp return } - // Process the message with context - go c.processMessage(ctx, msg) + // Process the message with the channel's long-lived context (not the HTTP + // request context, which is canceled as soon as we return the response). + go c.processMessage(c.ctx, msg) // Return success response immediately // WeCom App requires response within configured timeout (default 5 seconds) diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 4c576b84b..d904389f6 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -292,8 +292,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp return } - // Process the message asynchronously with context - go c.processMessage(ctx, msg) + // Process the message with the channel's long-lived context (not the HTTP + // request context, which is canceled as soon as we return the response). + go c.processMessage(c.ctx, msg) // Return success response immediately // WeCom Bot requires response within configured timeout (default 5 seconds) From 8e06e2adbd08395f7cce100e164c314ec9a37a68 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 21:45:05 +0800 Subject: [PATCH 088/104] fix(wecom): initialize context in constructors to prevent nil panic in tests The ctx field was only set in Start(), so tests calling handleMessageCallback without Start() caused a nil pointer dereference in MessageBus.PublishInbound. --- pkg/channels/wecom/app.go | 3 +++ pkg/channels/wecom/bot.go | 3 +++ 2 files changed, 6 insertions(+) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 3a25e12e1..771603f3e 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -129,9 +129,12 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) ( channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) + ctx, cancel := context.WithCancel(context.Background()) return &WeComAppChannel{ BaseChannel: base, config: cfg, + ctx: ctx, + cancel: cancel, processedMsgs: make(map[string]bool), }, nil } diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index d904389f6..e99c710ef 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -93,9 +93,12 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) + ctx, cancel := context.WithCancel(context.Background()) return &WeComBotChannel{ BaseChannel: base, config: cfg, + ctx: ctx, + cancel: cancel, processedMsgs: make(map[string]bool), }, nil } From e9b48865734008f22284b19bdce7c6aadc9b2700 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 22:08:06 +0800 Subject: [PATCH 089/104] fix(wecom): fix context leak in Start() and data race in processedMsgs Cancel the constructor-created context before overwriting in Start() to prevent the original cancel function from becoming unreachable. Move len(processedMsgs) check inside the write lock to eliminate a data race, and re-insert the current msgID after map reset to prevent duplicate processing of the in-flight message. Applies to both WeComBotChannel and WeComAppChannel. --- pkg/channels/wecom/app.go | 14 +++++++++----- pkg/channels/wecom/bot.go | 14 +++++++++----- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 771603f3e..7a23f9617 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -148,6 +148,10 @@ func (c *WeComAppChannel) Name() string { func (c *WeComAppChannel) Start(ctx context.Context) error { logger.InfoC("wecom_app", "Starting WeCom App channel...") + // Cancel the context created in the constructor to avoid a resource leak. + if c.cancel != nil { + c.cancel() + } c.ctx, c.cancel = context.WithCancel(ctx) // Get initial access token @@ -601,14 +605,14 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag return } c.processedMsgs[msgID] = true - c.msgMu.Unlock() - - // Clean up old messages periodically (keep last 1000) + // Clean up old messages while still holding the lock to avoid a data race + // on len(). Reset the map but re-insert the current msgID so it remains + // deduplicated. if len(c.processedMsgs) > 1000 { - c.msgMu.Lock() c.processedMsgs = make(map[string]bool) - c.msgMu.Unlock() + c.processedMsgs[msgID] = true } + c.msgMu.Unlock() senderID := msg.FromUserName chatID := senderID // WeCom App uses user ID as chat ID for direct messages diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index e99c710ef..39f84d55c 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -112,6 +112,10 @@ func (c *WeComBotChannel) Name() string { func (c *WeComBotChannel) Start(ctx context.Context) error { logger.InfoC("wecom", "Starting WeCom Bot channel...") + // Cancel the context created in the constructor to avoid a resource leak. + if c.cancel != nil { + c.cancel() + } c.ctx, c.cancel = context.WithCancel(ctx) c.SetRunning(true) @@ -326,14 +330,14 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag return } c.processedMsgs[msgID] = true - c.msgMu.Unlock() - - // Clean up old messages periodically (keep last 1000) + // Clean up old messages while still holding the lock to avoid a data race + // on len(). Reset the map but re-insert the current msgID so it remains + // deduplicated. if len(c.processedMsgs) > 1000 { - c.msgMu.Lock() c.processedMsgs = make(map[string]bool) - c.msgMu.Unlock() + c.processedMsgs[msgID] = true } + c.msgMu.Unlock() senderID := msg.From.UserID From c57a9c14e7ae6e1119c6b35a5a8e639be08ffbb4 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sat, 28 Feb 2026 22:25:31 +0800 Subject: [PATCH 090/104] docs: sync READMEs, examples, and channel docs to match current config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update config.example.json to remove dead webhook_host/webhook_port and unused typing/placeholder fields - Sync all READMEs (en/zh/ja/pt-br/fr/vi) with current channel config - Update Discord docs: mention_only → group_trigger - Update LINE, WeCom, WeComApp channel docs --- README.fr.md | 18 ++-- README.ja.md | 18 ++-- README.md | 52 ++++++++---- README.pt-br.md | 18 ++-- README.vi.md | 16 ++-- README.zh.md | 2 + config/config.example.json | 10 +-- docs/channels/discord/README.zh.md | 6 +- docs/channels/line/README.zh.md | 10 +-- docs/channels/wecom/wecom_app/README.zh.md | 6 +- docs/channels/wecom/wecom_bot/README.zh.md | 6 +- docs/wecom-app-configuration.md | 8 +- pkg/channels/README.md | 95 +++++++++++++++++----- pkg/channels/README.zh.md | 94 ++++++++++++++++----- 14 files changed, 232 insertions(+), 127 deletions(-) diff --git a/README.fr.md b/README.fr.md index c452b71ac..7d1ca3e57 100644 --- a/README.fr.md +++ b/README.fr.md @@ -456,8 +456,6 @@ picoclaw gateway "enabled": true, "channel_secret": "VOTRE_CHANNEL_SECRET", "channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/line", "allow_from": [] } @@ -470,12 +468,14 @@ picoclaw gateway LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel : ```bash -# Exemple avec ngrok -ngrok http 18791 +# Exemple avec ngrok (tunnel vers le serveur Gateway partagé) +ngrok http 18790 ``` Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**. +> **Note** : Le webhook LINE est servi par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Si vous utilisez ngrok ou un proxy inverse, faites pointer le tunnel vers le port `18790`. + **4. Lancer** ```bash @@ -484,7 +484,7 @@ picoclaw gateway > Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original. -> **Docker Compose** : Ajoutez `ports: ["18791:18791"]` au service `picoclaw-gateway` pour exposer le port du webhook. +> **Docker Compose** : Si vous avez besoin d'exposer le webhook LINE via Docker, mappez le port du Gateway partagé (par défaut `18790`) vers l'hôte, par exemple `ports: ["18790:18790"]`. Notez que le serveur Gateway sert les webhooks de tous les canaux à partir de ce port.
@@ -515,8 +515,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18793, "webhook_path": "/webhook/wecom", "allow_from": [] } @@ -535,7 +533,7 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour **2. Configurer la réception des messages** * Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API" -* Définissez l'URL sur `http://your-server:18792/webhook/wecom-app` +* Définissez l'URL sur `http://your-server:18790/webhook/wecom-app` * Générez le **Token** et l'**EncodingAESKey** **3. Configurer** @@ -550,8 +548,6 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", "allow_from": [] } @@ -565,7 +561,7 @@ Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour picoclaw gateway ``` -> **Note** : WeCom App nécessite l'ouverture du port 18792 pour les callbacks webhook. Utilisez un proxy inverse pour HTTPS en production. +> **Note** : Les callbacks webhook WeCom App sont servis par le serveur Gateway partagé (par défaut `127.0.0.1:18790`). Assurez-vous que le port `18790` est accessible ou utilisez un proxy inverse HTTPS en production. diff --git a/README.ja.md b/README.ja.md index 6d5d09451..553e8ab63 100644 --- a/README.ja.md +++ b/README.ja.md @@ -421,8 +421,6 @@ picoclaw gateway "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/line", "allow_from": [] } @@ -436,11 +434,13 @@ LINE の Webhook には HTTPS が必要です。リバースプロキシまた ```bash # ngrok の例 -ngrok http 18791 +ngrok http 18790 ``` LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。 +> **注意**: LINE の Webhook は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、リバースプロキシを設定してください。 + **4. 起動** ```bash @@ -449,7 +449,7 @@ picoclaw gateway > グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。 -> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。 +> **Docker Compose**: Gateway HTTP サーバーは共有の `127.0.0.1:18790` で Webhook を提供します。ホストからアクセスするには `picoclaw-gateway` サービスに `ports: ["18790:18790"]` を追加してください。 @@ -480,13 +480,13 @@ PicoClaw は2種類の WeCom 統合をサポートしています: "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18793, "webhook_path": "/webhook/wecom", "allow_from": [] } } } + +> **注意**: WeCom Bot の Webhook 受信は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、HTTPS 用のリバースプロキシを設定してください。 ``` **クイックセットアップ - WeCom App:** @@ -500,7 +500,7 @@ PicoClaw は2種類の WeCom 統合をサポートしています: **2. メッセージ受信を設定** * アプリ詳細で "メッセージを受信" → "APIを設定" をクリック -* URL を `http://your-server:18792/webhook/wecom-app` に設定 +* URL を `http://your-server:18790/webhook/wecom-app` に設定 * **Token** と **EncodingAESKey** を生成 **3. 設定** @@ -515,8 +515,6 @@ PicoClaw は2種類の WeCom 統合をサポートしています: "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", "allow_from": [] } @@ -530,7 +528,7 @@ PicoClaw は2種類の WeCom 統合をサポートしています: picoclaw gateway ``` -> **注意**: WeCom App は Webhook コールバック用にポート 18792 を開放する必要があります。本番環境では HTTPS 用のリバースプロキシを使用してください。 +> **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。 diff --git a/README.md b/README.md index b040d0605..fd73f2338 100644 --- a/README.md +++ b/README.md @@ -295,6 +295,8 @@ That's it! You have a working AI assistant in 2 minutes. Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom +> **Note**: All webhook-based channels (LINE, WeCom, Feishu, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. + | Channel | Setup | | ------------ | ---------------------------------- | | **Telegram** | Easy (just a token) | @@ -364,8 +366,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allow_from": ["YOUR_USER_ID"], - "mention_only": false + "allow_from": ["YOUR_USER_ID"] } } } @@ -378,9 +379,31 @@ picoclaw gateway * Bot Permissions: `Send Messages`, `Read Message History` * Open the generated invite URL and add the bot to your server -**Optional: Mention-only mode** +**Optional: Group trigger mode** -Set `"mention_only": true` to make the bot respond only when @-mentioned. Useful for shared servers where you want the bot to respond only when explicitly called. +By default the bot responds to all messages in a server channel. To restrict responses to @-mentions only, add: + +```json +{ + "channels": { + "discord": { + "group_trigger": { "mention_only": true } + } + } +} +``` + +You can also trigger by keyword prefixes (e.g. `!bot`): + +```json +{ + "channels": { + "discord": { + "group_trigger": { "prefixes": ["!bot"] } + } + } +} +``` **6. Run** @@ -501,8 +524,6 @@ picoclaw gateway "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/line", "allow_from": [] } @@ -510,13 +531,15 @@ picoclaw gateway } ``` +> LINE webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). + **3. Set up Webhook URL** LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel: ```bash -# Example with ngrok -ngrok http 18791 +# Example with ngrok (gateway default port is 18790) +ngrok http 18790 ``` Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**. @@ -529,8 +552,6 @@ picoclaw gateway > In group chats, the bot responds only when @mentioned. Replies quote the original message. -> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port. -
@@ -560,8 +581,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18793, "webhook_path": "/webhook/wecom", "allow_from": [] } @@ -569,6 +588,8 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile } ``` +> WeCom webhook is served on the shared Gateway server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). + **Quick Setup - WeCom App:** **1. Create an app** @@ -576,10 +597,11 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile * Go to WeCom Admin Console → App Management → Create App * Copy **AgentId** and **Secret** * Go to "My Company" page, copy **CorpID** + **2. Configure receive message** * In App details, click "Receive Message" → "Set API" -* Set URL to `http://your-server:18792/webhook/wecom-app` +* Set URL to `http://your-server:18790/webhook/wecom-app` * Generate **Token** and **EncodingAESKey** **3. Configure** @@ -594,8 +616,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", "allow_from": [] } @@ -609,7 +629,7 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile picoclaw gateway ``` -> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS. +> **Note**: WeCom webhook callbacks are served on the Gateway port (default 18790). Use a reverse proxy for HTTPS.
diff --git a/README.pt-br.md b/README.pt-br.md index 61663e363..027970b97 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -450,8 +450,6 @@ picoclaw gateway "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/line", "allow_from": [] } @@ -465,11 +463,13 @@ O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel: ```bash # Exemplo com ngrok -ngrok http 18791 +ngrok http 18790 ``` Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**. +> **Nota**: O webhook do LINE é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel (como ngrok) para expor o Gateway de forma segura quando necessário. + **4. Executar** ```bash @@ -478,7 +478,7 @@ picoclaw gateway > Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original. -> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook. +> **Docker Compose**: Se você usa Docker Compose, exponha o Gateway (padrão 127.0.0.1:18790) se precisar acessar o webhook LINE externamente, por exemplo `ports: ["18790:18790"]`. @@ -509,8 +509,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18793, "webhook_path": "/webhook/wecom", "allow_from": [] } @@ -518,6 +516,8 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para } ``` +> **Nota**: O webhook do WeCom Bot é atendido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Use um proxy reverso/HTTPS ou túnel para expor o Gateway em produção. + **Configuração Rápida - WeCom App:** **1. Criar um aplicativo** @@ -529,7 +529,7 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para **2. Configurar recebimento de mensagens** * Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API" -* Defina a URL como `http://your-server:18792/webhook/wecom-app` +* Defina a URL como `http://your-server:18790/webhook/wecom-app` * Gere o **Token** e o **EncodingAESKey** **3. Configurar** @@ -544,8 +544,6 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", "allow_from": [] } @@ -559,7 +557,7 @@ Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para picoclaw gateway ``` -> **Nota**: O WeCom App requer a abertura da porta 18792 para callbacks de webhook. Use um proxy reverso para HTTPS em produção. +> **Nota**: O WeCom App (callbacks de webhook) é servido pelo Gateway compartilhado (padrão 127.0.0.1:18790). Em produção use um proxy reverso HTTPS para expor a porta do Gateway, ou atualize `PICOCLAW_GATEWAY_HOST` para `0.0.0.0` se necessário. diff --git a/README.vi.md b/README.vi.md index f8ece7eda..9aae23503 100644 --- a/README.vi.md +++ b/README.vi.md @@ -424,8 +424,6 @@ picoclaw gateway "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/line", "allow_from": [] } @@ -439,7 +437,7 @@ LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel: ```bash # Ví dụ với ngrok -ngrok http 18791 +ngrok http 18790 ``` Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**. @@ -452,7 +450,7 @@ picoclaw gateway > Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc. -> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook. +> **Docker Compose**: Nếu bạn cần mở port webhook cục bộ, hãy thêm một rule chuyển tiếp từ port Gateway (mặc định 18790) tới host. Lưu ý: LINE webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). @@ -483,8 +481,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18793, "webhook_path": "/webhook/wecom", "allow_from": [] } @@ -492,6 +488,8 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ } ``` +> **Lưu ý:** WeCom Bot incoming webhook endpoints are served by the shared Gateway HTTP server (mặc định 127.0.0.1:18790). Nếu bạn cần truy cập từ bên ngoài, đặt reverse proxy hoặc mở port Gateway phù hợp. + **Thiết lập Nhanh - WeCom App:** **1. Tạo ứng dụng** @@ -503,7 +501,7 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ **2. Cấu hình nhận tin nhắn** * Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API" -* Đặt URL thành `http://your-server:18792/webhook/wecom-app` +* Đặt URL thành `http://your-server:18790/webhook/wecom-app` * Tạo **Token** và **EncodingAESKey** **3. Cấu hình** @@ -518,8 +516,6 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", "allow_from": [] } @@ -533,7 +529,7 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ picoclaw gateway ``` -> **Lưu ý**: WeCom App yêu cầu mở cổng 18792 cho callback webhook. Sử dụng proxy ngược cho HTTPS trong môi trường sản xuất. +> **Lưu ý**: WeCom App callback webhook được phục vụ bởi Gateway HTTP chung (mặc định 127.0.0.1:18790). Sử dụng proxy ngược để cung cấp HTTPS trong môi trường production nếu cần. diff --git a/README.zh.md b/README.zh.md index 7c9351cb4..145d81fa5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -290,6 +290,8 @@ picoclaw agent -m "2+2 等于几?" PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 +> **注意**: 所有 Webhook 类渠道(LINE、WeCom、飞书等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。 + ### 核心渠道 | 渠道 | 设置难度 | 特性说明 | 文档链接 | diff --git a/config/config.example.json b/config/config.example.json index 55a823009..d885ef94b 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -59,7 +59,9 @@ "enabled": false, "token": "YOUR_DISCORD_BOT_TOKEN", "allow_from": [], - "mention_only": false, + "group_trigger": { + "mention_only": false + }, "reasoning_channel_id": "" }, "qq": { @@ -111,8 +113,6 @@ "enabled": false, "channel_secret": "YOUR_LINE_CHANNEL_SECRET", "channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/line", "allow_from": [], "reasoning_channel_id": "" @@ -132,8 +132,6 @@ "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18793, "webhook_path": "/webhook/wecom", "allow_from": [], "reply_timeout": 5, @@ -147,8 +145,6 @@ "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", "allow_from": [], "reply_timeout": 5, diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md index 5b597eced..6d3c502cf 100644 --- a/docs/channels/discord/README.zh.md +++ b/docs/channels/discord/README.zh.md @@ -11,7 +11,9 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用 "enabled": true, "token": "YOUR_BOT_TOKEN", "allow_from": ["YOUR_USER_ID"], - "mention_only": false + "group_trigger": { + "mention_only": false + } } } } @@ -22,7 +24,7 @@ Discord 是一个专为社区设计的免费语音、视频和文本聊天应用 | enabled | bool | 是 | 是否启用 Discord 频道 | | token | string | 是 | Discord 机器人 Token | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | -| mention_only | bool | 否 | 是否仅响应提及机器人的消息 | +| group_trigger | object | 否 | 群组触发设置(示例: { "mention_only": false }) | ## 设置流程 diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md index fd3aa80da..0c7321705 100644 --- a/docs/channels/line/README.zh.md +++ b/docs/channels/line/README.zh.md @@ -11,8 +11,6 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的 "enabled": true, "channel_secret": "YOUR_CHANNEL_SECRET", "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", - "webhook_host": "0.0.0.0", - "webhook_port": 18791, "webhook_path": "/webhook/line", "allow_from": [] } @@ -25,17 +23,17 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的 | enabled | bool | 是 | 是否启用 LINE Channel | | channel_secret | string | 是 | LINE Messaging API 的 Channel Secret | | channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token | -| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) | -| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) | | webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | +| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | ## 设置流程 1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel 2. 获取 Channel Secret 和 Channel Access Token 3. 配置Webhook: - - Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网 - - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line` + - LINE 要求 Webhook 必须使用 HTTPS 协议,因此需要部署一个支持 HTTPS 的服务器,或者使用反向代理工具如 ngrok 将本地服务器暴露到公网 + - PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790 + - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`,然后将外部域名反向代理到本机的 Gateway(默认端口 18790) - 启用 Webhook 并验证 URL 4. 将 Channel Secret 和 Channel Access Token 填入配置文件中 diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md index 1e6a0e2b3..0a9858107 100644 --- a/docs/channels/wecom/wecom_app/README.zh.md +++ b/docs/channels/wecom/wecom_app/README.zh.md @@ -14,8 +14,6 @@ "agent_id": 1000002, "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", "allow_from": [], "reply_timeout": 5 @@ -31,8 +29,6 @@ | agent_id | int | 是 | 应用程序代理 ID | | token | string | 是 | 回调验证令牌 | | encoding_aes_key | string | 是 | 43 字符 AES 密钥 | -| webhook_host | string | 否 | HTTP 服务器绑定地址 | -| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) | | webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) | | allow_from | array | 否 | 用户 ID 白名单 | | reply_timeout | int | 否 | 回复超时时间(秒) | @@ -45,3 +41,5 @@ 4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey 5. 设置回调 URL 为 `http://:/webhook/wecom-app` 6. 将 CorpID, Secret, AgentID 等信息填入配置文件 + + 注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。 diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md index c4bb1c87e..63d9b84d6 100644 --- a/docs/channels/wecom/wecom_bot/README.zh.md +++ b/docs/channels/wecom/wecom_bot/README.zh.md @@ -12,8 +12,6 @@ "token": "YOUR_TOKEN", "encoding_aes_key": "YOUR_ENCODING_AES_KEY", "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY", - "webhook_host": "0.0.0.0", - "webhook_port": 18793, "webhook_path": "/webhook/wecom", "allow_from": [], "reply_timeout": 5 @@ -27,8 +25,6 @@ | token | string | 是 | 签名验证代币 | | encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 | | webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL | -| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) | -| webhook_port | int | 否 | HTTP 服务器端口(默认:18793) | | webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) | | allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) | | reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) | @@ -39,3 +35,5 @@ 2. 获取 Webhook URL 3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey 4. 将相关信息填入配置文件 + + 注意: PicoClaw 现在使用共享的 Gateway HTTP 服务器来接收所有渠道的 webhook 回调,默认监听地址为 127.0.0.1:18790。如需从公网接收回调,请把外部域名反向代理到 Gateway(默认端口 18790)。 diff --git a/docs/wecom-app-configuration.md b/docs/wecom-app-configuration.md index 3b17d37a7..3c720ecd1 100644 --- a/docs/wecom-app-configuration.md +++ b/docs/wecom-app-configuration.md @@ -26,7 +26,7 @@ 1. 在应用详情页,点击"接收消息"的"设置API接收" 2. 填写以下信息: - - **URL**: `http://your-server:18792/webhook/wecom-app` + - **URL**: `http://your-server:18790/webhook/wecom-app` - **Token**: 随机生成或自定义(用于签名验证) - **EncodingAESKey**: 点击"随机生成"生成43字符的密钥 3. 点击"保存"时,企业微信会发送验证请求 @@ -45,8 +45,6 @@ "agent_id": 1000002, // 应用AgentId "token": "your_token", // 接收消息配置的Token "encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey - "webhook_host": "0.0.0.0", - "webhook_port": 18792, "webhook_path": "/webhook/wecom-app", "allow_from": [], "reply_timeout": 5 @@ -62,7 +60,7 @@ **症状**: 企业微信保存API接收消息时提示验证失败 **检查项**: -- 确认服务器防火墙已开放 18792 端口 +- 确认服务器防火墙已开放 Gateway 端口(默认 18790) - 确认 `corp_id`、`token`、`encoding_aes_key` 配置正确 - 查看 PicoClaw 日志是否有请求到达 @@ -78,7 +76,7 @@ **症状**: 启动时提示端口已被占用 -**解决**: 修改 `webhook_port` 为其他端口,如 18794 +**解决**: 修改 `gateway.port` 为其他端口(所有 Webhook 渠道共享同一个 Gateway HTTP 服务器) ## 技术细节 diff --git a/pkg/channels/README.md b/pkg/channels/README.md index 52b9f98f4..6fbf2bb34 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -1,7 +1,5 @@ -# PicoClaw Channel System Refactor: Complete Development Guide +# PicoClaw Channel System: Complete Development Guide -> **Branch**: `refactor/channel-system` -> **Status**: Active development (~40 commits) > **Scope**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/` --- @@ -46,6 +44,8 @@ pkg/channels/ pkg/channels/ ├── base.go # BaseChannel shared abstraction layer ├── interfaces.go # Optional capability interfaces (TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder) +├── README.md # English documentation +├── README.zh.md # Chinese documentation ├── media.go # MediaSender optional interface ├── webhook.go # WebhookHandler, HealthChecker optional interfaces ├── errors.go # Sentinel errors (ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed) @@ -60,7 +60,7 @@ pkg/channels/ ├── discord/ │ ├── init.go │ └── discord.go -├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ maixcam/ pico/ +├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/ │ └── ... pkg/bus/ @@ -111,7 +111,7 @@ pkg/identity/ |-----------|-------------| | **Sub-package Isolation** | Each channel is a standalone Go sub-package, depending on `BaseChannel` and interfaces from the `channels` parent package | | **Factory Registration** | Sub-packages self-register via `init()`, Manager looks up factories by name, eliminating import coupling | -| **Capability Discovery** | Optional capabilities are declared via interfaces (`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`), discovered by Manager via runtime type assertions | +| **Capability Discovery** | Optional capabilities are declared via interfaces (`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`, `HealthChecker`), discovered by Manager via runtime type assertions | | **Structured Messages** | Peer, MessageID, and SenderInfo promoted from Metadata to first-class fields on InboundMessage | | **Error Classification** | Channels return sentinel errors (`ErrRateLimit`, `ErrTemporary`, etc.), Manager uses these to determine retry strategy | | **Centralized Orchestration** | Rate limiting, message splitting, retries, and Typing/Reaction/Placeholder management are all handled by Manager and BaseChannel; channels only need to implement Send | @@ -145,6 +145,7 @@ After refactoring, these files have been removed and code moved to corresponding | _(did not exist)_ | `pkg/channels/interfaces.go` | New optional capability interfaces | | _(did not exist)_ | `pkg/channels/media.go` | New MediaSender interface | | _(did not exist)_ | `pkg/channels/webhook.go` | New WebhookHandler/HealthChecker | +| _(did not exist)_ | `pkg/channels/whatsapp_native/` | New WhatsApp native mode (whatsmeow) | | _(did not exist)_ | `pkg/channels/split.go` | New message splitting (migrated from utils) | | _(did not exist)_ | `pkg/bus/types.go` | New structured message types | | _(did not exist)_ | `pkg/media/store.go` | New media file lifecycle management | @@ -220,6 +221,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann cfg.Channels.Telegram.AllowFrom, // Allow list channels.WithMaxMessageLength(4096), // Platform message length limit channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // Group trigger config + channels.WithReasoningChannelID(cfg.Channels.Telegram.ReasoningChannelID), // Reasoning chain routing ) return &TelegramChannel{ BaseChannel: base, @@ -466,6 +468,7 @@ func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChanne matrixCfg.AllowFrom, // Allow list channels.WithMaxMessageLength(65536), // Matrix message length limit channels.WithGroupTrigger(matrixCfg.GroupTrigger), + channels.WithReasoningChannelID(matrixCfg.ReasoningChannelID), // Reasoning chain routing (optional) ) return &MatrixChannel{ @@ -666,6 +669,32 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, cont } ``` +#### PlaceholderCapable — Placeholder Messages + +```go +// If the platform supports sending placeholder messages (e.g. "Thinking... 💭"), +// and the channel also implements MessageEditor, then Manager's preSend will +// automatically edit the placeholder into the final response on outbound. +// SendPlaceholder checks PlaceholderConfig.Enabled internally; +// returning ("", nil) means skip. +func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + cfg := c.config.Channels.Matrix.Placeholder + if !cfg.Enabled { + return "", nil + } + text := cfg.Text + if text == "" { + text = "Thinking... 💭" + } + // Call Matrix API to send placeholder message + msg, err := c.sendText(ctx, chatID, text) + if err != nil { + return "", err + } + return msg.ID, nil +} +``` + #### WebhookHandler — HTTP Webhook Reception ```go @@ -755,6 +784,8 @@ type MatrixChannelConfig struct { Token string `yaml:"token" json:"token"` AllowFrom []string `yaml:"allow_from" json:"allow_from"` GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"` + Placeholder PlaceholderConfig `yaml:"placeholder" json:"placeholder"` + ReasoningChannelID string `yaml:"reasoning_channel_id" json:"reasoning_channel_id"` } ``` @@ -767,6 +798,15 @@ if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { } ``` +> **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), branch in initChannels based on config: +> ```go +> if cfg.UseNative { +> m.initChannel("matrix_native", "Matrix Native") +> } else { +> m.initChannel("matrix", "Matrix") +> } +> ``` + #### Add blank import in Gateway ```go @@ -882,19 +922,21 @@ BaseChannel is the shared abstraction layer for all channels, providing the foll | `IsRunning() bool` | Atomically read running state | | `SetRunning(bool)` | Atomically set running state | | `MaxMessageLength() int` | Message length limit (rune count), 0 = unlimited | +| `ReasoningChannelID() string` | Reasoning chain routing target channel ID (empty = no routing) | | `IsAllowed(senderID string) bool` | Legacy allow-list check (supports `"id\|username"` and `"@username"` formats) | | `IsAllowedSender(sender SenderInfo) bool` | New allow-list check (delegates to `identity.MatchAllowed`) | | `ShouldRespondInGroup(isMentioned, content) (bool, string)` | Unified group chat trigger filtering logic | -| `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction → publish to Bus | +| `HandleMessage(...)` | Unified inbound message handling: permission check → build MediaScope → auto-trigger Typing/Reaction/Placeholder → publish to Bus | | `SetMediaStore(s) / GetMediaStore()` | MediaStore injected by Manager | | `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | PlaceholderRecorder injected by Manager | -| `SetOwner(ch)` | Concrete channel reference injected by Manager (used for Typing/Reaction type assertions in HandleMessage) | +| `SetOwner(ch)` | Concrete channel reference injected by Manager (used for Typing/Reaction/Placeholder type assertions in HandleMessage) | **Functional Options**: ```go channels.WithMaxMessageLength(4096) // Set platform message length limit channels.WithGroupTrigger(groupTriggerCfg) // Set group trigger configuration +channels.WithReasoningChannelID(id) // Set reasoning chain routing target channel ``` ### 4.4 Factory Registry @@ -998,7 +1040,7 @@ StartAll: - runMediaWorker (per-channel outbound media) - dispatchOutbound (route from bus to worker queues) - dispatchOutboundMedia (route from bus to media worker queues) - - runTTLJanitor (every 10s clean up expired typing/placeholder) + - runTTLJanitor (every 10s clean up expired typing/reaction/placeholder) 4. Start shared HTTP server (if configured) StopAll: @@ -1206,18 +1248,20 @@ make test # Full test suite | Sub-package | Registered Name | Optional Interfaces | |-------------|----------------|-------------------| -| `pkg/channels/telegram/` | `"telegram"` | MessageEditor, MediaSender, TypingCapable, PlaceholderCapable | -| `pkg/channels/discord/` | `"discord"` | MessageEditor, TypingCapable, PlaceholderCapable | -| `pkg/channels/slack/` | `"slack"` | ReactionCapable | -| `pkg/channels/line/` | `"line"` | WebhookHandler, HealthChecker, TypingCapable | -| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable | -| `pkg/channels/dingtalk/` | `"dingtalk"` | WebhookHandler | -| `pkg/channels/feishu/` | `"feishu"` | WebhookHandler (architecture-specific build tags) | -| `pkg/channels/wecom/` | `"wecom"` + `"wecom_app"` | WebhookHandler | +| `pkg/channels/telegram/` | `"telegram"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender | +| `pkg/channels/discord/` | `"discord"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender | +| `pkg/channels/slack/` | `"slack"` | ReactionCapable, MediaSender | +| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler | +| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | +| `pkg/channels/dingtalk/` | `"dingtalk"` | — | +| `pkg/channels/feishu/` | `"feishu"` | — (architecture-specific build tags: `feishu_32.go` / `feishu_64.go`) | +| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker | +| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker | | `pkg/channels/qq/` | `"qq"` | — | -| `pkg/channels/whatsapp/` | `"whatsapp"` | — | +| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) | +| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) | | `pkg/channels/maixcam/` | `"maixcam"` | — | -| `pkg/channels/pico/` | `"pico"` | WebhookHandler (Pico Protocol), TypingCapable, PlaceholderCapable | +| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler | ### A.3 Interface Quick Reference @@ -1231,6 +1275,7 @@ type Channel interface { IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool + ReasoningChannelID() string } // ===== Optional ===== @@ -1324,8 +1369,16 @@ agentLoop.Stop() // Stop Agent 1. **Media cleanup temporarily disabled**: The `ReleaseAll` call in the Agent loop is commented out (`refactor(loop): disable media cleanup to prevent premature file deletion`) because session boundaries are not yet clearly defined. TTL cleanup remains active. -2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). +2. **Feishu architecture-specific compilation**: The Feishu channel uses build tags to distinguish 32-bit and 64-bit architectures (`feishu_32.go` / `feishu_64.go`). Feishu uses the SDK's WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`. -3. **WeCom has two factories**: `"wecom"` (Bot mode) and `"wecom_app"` (App mode) are registered separately. +3. **WeCom has two factories**: `"wecom"` (Bot mode, webhook only) and `"wecom_app"` (App mode, supports MediaSender) are registered separately. Both implement `WebhookHandler` and `HealthChecker`. -4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via webhook. \ No newline at end of file +4. **Pico Protocol**: `pkg/channels/pico/` implements a custom PicoClaw native protocol channel that receives messages via WebSocket webhook (`/pico/ws`). + +5. **WhatsApp has two modes**: `"whatsapp"` (Bridge mode, communicates via external bridge URL) and `"whatsapp_native"` (native whatsmeow mode, connects directly to WhatsApp). Manager selects which to initialize based on `WhatsAppConfig.UseNative`. + +6. **DingTalk uses Stream mode**: DingTalk uses the SDK's Stream/WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`. + +7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields. + +8. **ReasoningChannelID**: All 12 channel configs have a `ReasoningChannelID` field, used to route LLM reasoning/thinking output to a designated channel. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method. \ No newline at end of file diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index 0a9487cd0..bbd9a4321 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -1,7 +1,5 @@ -# PicoClaw Channel System 重构:完整开发指南 +# PicoClaw Channel System:完整开发指南 -> **分支**: `refactor/channel-system` -> **状态**: 活跃开发中(约 40 commits) > **影响范围**: `pkg/channels/`, `pkg/bus/`, `pkg/media/`, `pkg/identity/`, `cmd/picoclaw/internal/gateway/` --- @@ -46,6 +44,8 @@ pkg/channels/ pkg/channels/ ├── base.go # BaseChannel 共享抽象层 ├── interfaces.go # 可选能力接口(TypingCapable, MessageEditor, ReactionCapable, PlaceholderCapable, PlaceholderRecorder) +├── README.md # 英文文档 +├── README.zh.md # 中文文档 ├── media.go # MediaSender 可选接口 ├── webhook.go # WebhookHandler, HealthChecker 可选接口 ├── errors.go # 错误哨兵值(ErrNotRunning, ErrRateLimit, ErrTemporary, ErrSendFailed) @@ -60,7 +60,7 @@ pkg/channels/ ├── discord/ │ ├── init.go │ └── discord.go -├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ maixcam/ pico/ +├── slack/ line/ onebot/ dingtalk/ feishu/ wecom/ qq/ whatsapp/ whatsapp_native/ maixcam/ pico/ │ └── ... pkg/bus/ @@ -111,7 +111,7 @@ pkg/identity/ |------|------| | **子包隔离** | 每个 channel 一个独立 Go 子包,依赖 `channels` 父包提供的 `BaseChannel` 和接口 | | **工厂注册** | 各子包通过 `init()` 自注册,Manager 通过名字查找工厂,消除 import 耦合 | -| **能力发现** | 可选能力通过接口(`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`)声明,Manager 运行时类型断言发现 | +| **能力发现** | 可选能力通过接口(`MediaSender`, `TypingCapable`, `ReactionCapable`, `PlaceholderCapable`, `MessageEditor`, `WebhookHandler`, `HealthChecker`)声明,Manager 运行时类型断言发现 | | **结构化消息** | Peer、MessageID、SenderInfo 从 Metadata 提升为 InboundMessage 的一等字段 | | **错误分类** | Channel 返回哨兵错误(`ErrRateLimit`, `ErrTemporary` 等),Manager 据此决定重试策略 | | **集中编排** | 速率限制、消息分割、重试、Typing/Reaction/Placeholder 全部由 Manager 和 BaseChannel 统一处理,Channel 只负责 Send | @@ -145,6 +145,7 @@ pkg/identity/ | _(不存在)_ | `pkg/channels/interfaces.go` | 新增可选能力接口 | | _(不存在)_ | `pkg/channels/media.go` | 新增 MediaSender 接口 | | _(不存在)_ | `pkg/channels/webhook.go` | 新增 WebhookHandler/HealthChecker | +| _(不存在)_ | `pkg/channels/whatsapp_native/` | 新增 WhatsApp 原生模式(whatsmeow) | | _(不存在)_ | `pkg/channels/split.go` | 新增消息分割(从 utils 迁入) | | _(不存在)_ | `pkg/bus/types.go` | 新增结构化消息类型 | | _(不存在)_ | `pkg/media/store.go` | 新增媒体文件生命周期管理 | @@ -220,6 +221,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann cfg.Channels.Telegram.AllowFrom, // 允许列表 channels.WithMaxMessageLength(4096), // 平台消息长度上限 channels.WithGroupTrigger(cfg.Channels.Telegram.GroupTrigger), // 群聊触发配置 + channels.WithReasoningChannelID(cfg.Channels.Telegram.ReasoningChannelID), // 思维链路由 ) return &TelegramChannel{ BaseChannel: base, @@ -466,6 +468,7 @@ func NewMatrixChannel(cfg *config.Config, msgBus *bus.MessageBus) (*MatrixChanne matrixCfg.AllowFrom, // 允许列表 channels.WithMaxMessageLength(65536), // Matrix 消息长度限制 channels.WithGroupTrigger(matrixCfg.GroupTrigger), + channels.WithReasoningChannelID(matrixCfg.ReasoningChannelID), // 思维链路由(可选) ) return &MatrixChannel{ @@ -666,6 +669,31 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID, messageID, cont } ``` +#### PlaceholderCapable — 占位消息 + +```go +// 如果平台支持发送占位消息(如 "Thinking... 💭"),并且实现了 MessageEditor, +// 则 Manager 的 preSend 会在出站时自动将占位消息编辑为最终回复。 +// SendPlaceholder 内部根据 PlaceholderConfig.Enabled 决定是否发送; +// 返回 ("", nil) 表示跳过。 +func (c *MatrixChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + cfg := c.config.Channels.Matrix.Placeholder + if !cfg.Enabled { + return "", nil + } + text := cfg.Text + if text == "" { + text = "Thinking... 💭" + } + // 调用 Matrix API 发送占位消息 + msg, err := c.sendText(ctx, chatID, text) + if err != nil { + return "", err + } + return msg.ID, nil +} +``` + #### WebhookHandler — HTTP Webhook 接收 ```go @@ -755,6 +783,8 @@ type MatrixChannelConfig struct { Token string `yaml:"token" json:"token"` AllowFrom []string `yaml:"allow_from" json:"allow_from"` GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"` + Placeholder PlaceholderConfig `yaml:"placeholder" json:"placeholder"` + ReasoningChannelID string `yaml:"reasoning_channel_id" json:"reasoning_channel_id"` } ``` @@ -767,6 +797,15 @@ if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { } ``` +> **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native),需要在 initChannels 中根据配置分支: +> ```go +> if cfg.UseNative { +> m.initChannel("matrix_native", "Matrix Native") +> } else { +> m.initChannel("matrix", "Matrix") +> } +> ``` + #### 在 Gateway 中添加 blank import ```go @@ -882,19 +921,21 @@ BaseChannel 是所有 channel 的共享抽象层,提供以下能力: | `IsRunning() bool` | 原子读取运行状态 | | `SetRunning(bool)` | 原子设置运行状态 | | `MaxMessageLength() int` | 消息长度限制(rune 计数),0 = 无限制 | +| `ReasoningChannelID() string` | 思维链路由目标 channel ID(空 = 不路由) | | `IsAllowed(senderID string) bool` | 旧格式允许列表检查(支持 `"id\|username"` 和 `"@username"` 格式) | | `IsAllowedSender(sender SenderInfo) bool` | 新格式允许列表检查(委托给 `identity.MatchAllowed`) | | `ShouldRespondInGroup(isMentioned, content) (bool, string)` | 统一群聊触发过滤逻辑 | -| `HandleMessage(...)` | 统一入站消息处理:权限检查 → 构建 MediaScope → 自动触发 Typing/Reaction → 发布到 Bus | +| `HandleMessage(...)` | 统一入站消息处理:权限检查 → 构建 MediaScope → 自动触发 Typing/Reaction/Placeholder → 发布到 Bus | | `SetMediaStore(s) / GetMediaStore()` | Manager 注入的媒体存储 | | `SetPlaceholderRecorder(r) / GetPlaceholderRecorder()` | Manager 注入的占位符记录器 | -| `SetOwner(ch) ` | Manager 注入的具体 channel 引用(用于 HandleMessage 内部的 Typing/Reaction 类型断言) | +| `SetOwner(ch) ` | Manager 注入的具体 channel 引用(用于 HandleMessage 内部的 Typing/Reaction/Placeholder 类型断言) | **功能选项**: ```go channels.WithMaxMessageLength(4096) // 设置平台消息长度限制 channels.WithGroupTrigger(groupTriggerCfg) // 设置群聊触发配置 +channels.WithReasoningChannelID(id) // 设置思维链路由目标 channel ``` ### 4.4 工厂注册表 @@ -998,7 +1039,7 @@ StartAll: - runMediaWorker (per-channel 出站媒体) - dispatchOutbound (从 bus 路由到 worker 队列) - dispatchOutboundMedia (从 bus 路由到 media worker 队列) - - runTTLJanitor (每 10s 清理过期 typing/placeholder) + - runTTLJanitor (每 10s 清理过期 typing/reaction/placeholder) 4. 启动共享 HTTP 服务器(如已配置) StopAll: @@ -1206,18 +1247,20 @@ make test # 全量测试 | 子包 | 注册名 | 可选接口 | |------|--------|----------| -| `pkg/channels/telegram/` | `"telegram"` | MessageEditor, MediaSender, TypingCapable, PlaceholderCapable | -| `pkg/channels/discord/` | `"discord"` | MessageEditor, TypingCapable, PlaceholderCapable | -| `pkg/channels/slack/` | `"slack"` | ReactionCapable | -| `pkg/channels/line/` | `"line"` | WebhookHandler, HealthChecker, TypingCapable | -| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable | -| `pkg/channels/dingtalk/` | `"dingtalk"` | WebhookHandler | -| `pkg/channels/feishu/` | `"feishu"` | WebhookHandler (架构特定 build tags) | -| `pkg/channels/wecom/` | `"wecom"` + `"wecom_app"` | WebhookHandler | +| `pkg/channels/telegram/` | `"telegram"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender | +| `pkg/channels/discord/` | `"discord"` | TypingCapable, PlaceholderCapable, MessageEditor, MediaSender | +| `pkg/channels/slack/` | `"slack"` | ReactionCapable, MediaSender | +| `pkg/channels/line/` | `"line"` | TypingCapable, MediaSender, WebhookHandler | +| `pkg/channels/onebot/` | `"onebot"` | ReactionCapable, MediaSender | +| `pkg/channels/dingtalk/` | `"dingtalk"` | — | +| `pkg/channels/feishu/` | `"feishu"` | — (架构特定 build tags: `feishu_32.go` / `feishu_64.go`) | +| `pkg/channels/wecom/` | `"wecom"` | WebhookHandler, HealthChecker | +| `pkg/channels/wecom/` | `"wecom_app"` | MediaSender, WebhookHandler, HealthChecker | | `pkg/channels/qq/` | `"qq"` | — | -| `pkg/channels/whatsapp/` | `"whatsapp"` | — | +| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) | +| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) | | `pkg/channels/maixcam/` | `"maixcam"` | — | -| `pkg/channels/pico/` | `"pico"` | WebhookHandler (Pico Protocol), TypingCapable, PlaceholderCapable | +| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler | ### A.3 接口速查表 @@ -1231,6 +1274,7 @@ type Channel interface { IsRunning() bool IsAllowed(senderID string) bool IsAllowedSender(sender bus.SenderInfo) bool + ReasoningChannelID() string } // ===== 可选实现 ===== @@ -1324,8 +1368,16 @@ agentLoop.Stop() // 停止 Agent 1. **媒体清理暂时禁用**:Agent loop 中的 `ReleaseAll` 调用被注释掉了(`refactor(loop): disable media cleanup to prevent premature file deletion`),因为会话边界尚未明确定义。TTL 清理仍然有效。 -2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。 +2. **Feishu 架构特定编译**:Feishu channel 使用 build tags 区分 32 位和 64 位架构(`feishu_32.go` / `feishu_64.go`)。Feishu 使用 SDK 的 WebSocket 模式(非 HTTP webhook),因此不实现 `WebhookHandler`。 -3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式)和 `"wecom_app"`(应用模式)分别注册。 +3. **WeCom 有两个工厂**:`"wecom"`(Bot 模式,纯 webhook)和 `"wecom_app"`(应用模式,支持 MediaSender)分别注册。两者都实现了 `WebhookHandler` 和 `HealthChecker`。 -4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 webhook 接收消息。 \ No newline at end of file +4. **Pico Protocol**:`pkg/channels/pico/` 实现了一个自定义的 PicoClaw 原生协议 channel,通过 WebSocket webhook (`/pico/ws`) 接收消息。 + +5. **WhatsApp 有两种模式**:`"whatsapp"`(Bridge 模式,通过外部 bridge URL 通信)和 `"whatsapp_native"`(原生 whatsmeow 模式,直接连接 WhatsApp)。Manager 根据 `WhatsAppConfig.UseNative` 决定初始化哪个。 + +6. **DingTalk 使用 Stream 模式**:DingTalk 使用 SDK 的 Stream/WebSocket 模式(非 HTTP webhook),因此不实现 `WebhookHandler`。 + +7. **PlaceholderConfig 的配置与实现**:`PlaceholderConfig` 出现在 6 个 channel config 中(Telegram、Discord、Slack、LINE、OneBot、Pico),但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel(Telegram、Discord、Pico)能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。 + +8. **ReasoningChannelID**:所有 channel config(12 个)都有 `ReasoningChannelID` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。 \ No newline at end of file From cadcdc0b4155d0b43916445e0c5d6fe164e4e053 Mon Sep 17 00:00:00 2001 From: DM Date: Sat, 28 Feb 2026 22:20:20 -0600 Subject: [PATCH 091/104] fix(skills): use registry-backed search for skills discovery (#929) * fix(skills): use registry-backed search for skills discovery Signed-off-by: dwizzle204 <25712917+dwizzle204@users.noreply.github.com> * fix(skills): address review comments for registry search Signed-off-by: dwizzle204 <25712917+dwizzle204@users.noreply.github.com> --------- Signed-off-by: dwizzle204 <25712917+dwizzle204@users.noreply.github.com> Co-authored-by: dwizzle204 <25712917+dwizzle204@users.noreply.github.com> --- cmd/picoclaw/internal/skills/command.go | 2 +- cmd/picoclaw/internal/skills/helpers.go | 37 ++++++++++++------- cmd/picoclaw/internal/skills/search.go | 17 ++++----- cmd/picoclaw/internal/skills/search_test.go | 4 +- pkg/skills/installer.go | 41 --------------------- 5 files changed, 35 insertions(+), 66 deletions(-) diff --git a/cmd/picoclaw/internal/skills/command.go b/cmd/picoclaw/internal/skills/command.go index 7f8bd011d..65eb127b9 100644 --- a/cmd/picoclaw/internal/skills/command.go +++ b/cmd/picoclaw/internal/skills/command.go @@ -71,7 +71,7 @@ func NewSkillsCommand() *cobra.Command { newInstallBuiltinCommand(workspaceFn), newListBuiltinCommand(), newRemoveCommand(installerFn), - newSearchCommand(installerFn), + newSearchCommand(), newShowCommand(loaderFn), ) diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index 439b81a4f..a59a2013a 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -15,6 +15,8 @@ import ( "github.com/sipeed/picoclaw/pkg/utils" ) +const skillsSearchMaxResults = 20 + func skillsListCmd(loader *skills.SkillsLoader) { allSkills := loader.ListSkills() @@ -215,34 +217,43 @@ func skillsListBuiltinCmd() { } } -func skillsSearchCmd(installer *skills.SkillInstaller) { +func skillsSearchCmd(query string) { fmt.Println("Searching for available skills...") + cfg, err := internal.LoadConfig() + if err != nil { + fmt.Printf("✗ Failed to load config: %v\n", err) + return + } + + registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ + MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, + ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + }) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - availableSkills, err := installer.ListAvailableSkills(ctx) + results, err := registryMgr.SearchAll(ctx, query, skillsSearchMaxResults) if err != nil { fmt.Printf("✗ Failed to fetch skills list: %v\n", err) return } - if len(availableSkills) == 0 { + if len(results) == 0 { fmt.Println("No skills available.") return } - fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills)) + fmt.Printf("\nAvailable Skills (%d):\n", len(results)) fmt.Println("--------------------") - for _, skill := range availableSkills { - fmt.Printf(" 📦 %s\n", skill.Name) - fmt.Printf(" %s\n", skill.Description) - fmt.Printf(" Repo: %s\n", skill.Repository) - if skill.Author != "" { - fmt.Printf(" Author: %s\n", skill.Author) - } - if len(skill.Tags) > 0 { - fmt.Printf(" Tags: %v\n", skill.Tags) + for _, result := range results { + fmt.Printf(" 📦 %s\n", result.DisplayName) + fmt.Printf(" %s\n", result.Summary) + fmt.Printf(" Slug: %s\n", result.Slug) + fmt.Printf(" Registry: %s\n", result.RegistryName) + if result.Version != "" { + fmt.Printf(" Version: %s\n", result.Version) } fmt.Println() } diff --git a/cmd/picoclaw/internal/skills/search.go b/cmd/picoclaw/internal/skills/search.go index 53bc99109..54f72259f 100644 --- a/cmd/picoclaw/internal/skills/search.go +++ b/cmd/picoclaw/internal/skills/search.go @@ -2,20 +2,19 @@ package skills import ( "github.com/spf13/cobra" - - "github.com/sipeed/picoclaw/pkg/skills" ) -func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command { +func newSearchCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "search", + Use: "search [query]", Short: "Search available skills", - RunE: func(_ *cobra.Command, _ []string) error { - installer, err := installerFn() - if err != nil { - return err + Args: cobra.MaximumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + query := "" + if len(args) == 1 { + query = args[0] } - skillsSearchCmd(installer) + skillsSearchCmd(query) return nil }, } diff --git a/cmd/picoclaw/internal/skills/search_test.go b/cmd/picoclaw/internal/skills/search_test.go index 19f63a9ff..ed92e25cc 100644 --- a/cmd/picoclaw/internal/skills/search_test.go +++ b/cmd/picoclaw/internal/skills/search_test.go @@ -8,11 +8,11 @@ import ( ) func TestNewSearchSubcommand(t *testing.T) { - cmd := newSearchCommand(nil) + cmd := newSearchCommand() require.NotNil(t, cmd) - assert.Equal(t, "search", cmd.Use) + assert.Equal(t, "search [query]", cmd.Use) assert.Equal(t, "Search available skills", cmd.Short) assert.Nil(t, cmd.Run) diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go index 20f6a49d9..c9f19f25d 100644 --- a/pkg/skills/installer.go +++ b/pkg/skills/installer.go @@ -2,7 +2,6 @@ package skills import ( "context" - "encoding/json" "fmt" "io" "net/http" @@ -18,14 +17,6 @@ type SkillInstaller struct { workspace string } -type AvailableSkill struct { - Name string `json:"name"` - Repository string `json:"repository"` - Description string `json:"description"` - Author string `json:"author"` - Tags []string `json:"tags"` -} - func NewSkillInstaller(workspace string) *SkillInstaller { return &SkillInstaller{ workspace: workspace, @@ -89,35 +80,3 @@ func (si *SkillInstaller) Uninstall(skillName string) error { return nil } - -func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableSkill, error) { - url := "https://raw.githubusercontent.com/sipeed/picoclaw-skills/main/skills.json" - - client := &http.Client{Timeout: 15 * time.Second} - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - resp, err := utils.DoRequestWithRetry(client, req) - if err != nil { - return nil, fmt.Errorf("failed to fetch skills list: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("failed to fetch skills list: HTTP %d", resp.StatusCode) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - var skills []AvailableSkill - if err := json.Unmarshal(body, &skills); err != nil { - return nil, fmt.Errorf("failed to parse skills list: %w", err) - } - - return skills, nil -} From b3c3b02666bd188a7f0f62a7a086b23d1b52d634 Mon Sep 17 00:00:00 2001 From: Owen Wu <1449069+yubing744@users.noreply.github.com> Date: Sat, 28 Feb 2026 21:25:31 -0800 Subject: [PATCH 092/104] fix(onboard): use AGENTS.md template instead of AGENT.md (#931) * fix(onboard): use AGENTS.md workspace template * test(onboard): move AGENTS template regression test to new package --- cmd/picoclaw/internal/onboard/helpers_test.go | 25 +++++++++++++++++++ workspace/{AGENT.md => AGENTS.md} | 0 2 files changed, 25 insertions(+) create mode 100644 cmd/picoclaw/internal/onboard/helpers_test.go rename workspace/{AGENT.md => AGENTS.md} (100%) diff --git a/cmd/picoclaw/internal/onboard/helpers_test.go b/cmd/picoclaw/internal/onboard/helpers_test.go new file mode 100644 index 000000000..f3e0c92e0 --- /dev/null +++ b/cmd/picoclaw/internal/onboard/helpers_test.go @@ -0,0 +1,25 @@ +package onboard + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) { + targetDir := t.TempDir() + + if err := copyEmbeddedToTarget(targetDir); err != nil { + t.Fatalf("copyEmbeddedToTarget() error = %v", err) + } + + agentsPath := filepath.Join(targetDir, "AGENTS.md") + if _, err := os.Stat(agentsPath); err != nil { + t.Fatalf("expected %s to exist: %v", agentsPath, err) + } + + legacyPath := filepath.Join(targetDir, "AGENT.md") + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err) + } +} diff --git a/workspace/AGENT.md b/workspace/AGENTS.md similarity index 100% rename from workspace/AGENT.md rename to workspace/AGENTS.md From 44a52c0cf646beee1522b85c67777df5fdba0386 Mon Sep 17 00:00:00 2001 From: Tong Niu Date: Sun, 1 Mar 2026 16:55:46 +1100 Subject: [PATCH 093/104] fix(tools): close resp.Body on retry cancel and cache http.Client instances (#940) * fix(tools): close resp.Body on retry cancel and cache http.Client instances Fix resp.Body leak in DoRequestWithRetry where req.Body (request) was incorrectly closed instead of resp.Body (response) on context cancel. Cache http.Client on web search/fetch provider structs and channel adapters (WeCom, LINE) to avoid per-call allocation overhead. * fix(channels): preserve original http client timeouts for LINE and WeCom Split LINE single 60s client into infoClient (10s) for bot info lookups and apiClient (30s) for messaging API calls. Lower WeCom cached client base timeout from 60s to 30s (matching uploadMedia), and ensure it is always >= the configured ReplyTimeout so the per-request context deadline remains the effective limit. * refactor(tools): extract timeout consts and deduplicate WebFetchTool constructors Address PR review feedback from xiaket: - Define searchTimeout, perplexityTimeout, fetchTimeout, defaultMaxChars, and maxRedirects as package-level consts instead of magic numbers. - Remove misleading "No proxy" comment in NewWebFetchTool. - Deduplicate NewWebFetchTool by delegating to NewWebFetchToolWithProxy. * test(utils): add context cancellation test for DoRequestWithRetry Verify that resp.Body is properly closed when the context is canceled during retry sleep, covering the C8 resp.Body leak fix. * fix(utils): close resp in test to satisfy bodyclose linter * fix(utils): eliminate flakiness in context cancellation retry test Synchronize cancellation using an onRoundTrip callback from the transport wrapper instead of a timing-based context timeout. This ensures the first client.Do completes before cancel fires, so cancellation always hits during sleepWithCtx. --- pkg/agent/loop.go | 14 ++++- pkg/channels/line/line.go | 20 ++++--- pkg/channels/wecom/app.go | 18 ++++-- pkg/channels/wecom/bot.go | 12 +++- pkg/tools/web.go | 109 +++++++++++++++++++---------------- pkg/tools/web_test.go | 45 ++++++++++++--- pkg/utils/http_retry.go | 3 + pkg/utils/http_retry_test.go | 88 ++++++++++++++++++++++++++++ 8 files changed, 230 insertions(+), 79 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 8fd7328d1..a72f95bb1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -99,7 +99,7 @@ func registerSharedTools( } // Web tools - if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{ + searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ BraveAPIKey: cfg.Tools.Web.Brave.APIKey, BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, BraveEnabled: cfg.Tools.Web.Brave.Enabled, @@ -113,10 +113,18 @@ func registerSharedTools( PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, Proxy: cfg.Tools.Web.Proxy, - }); searchTool != nil { + }) + if err != nil { + logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) + } else if searchTool != nil { agent.Tools.Register(searchTool) } - agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy)) + fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } else { + agent.Tools.Register(fetchTool) + } // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms agent.Tools.Register(tools.NewI2CTool()) diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index 9fac2831c..398f12e6b 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -45,11 +45,13 @@ type replyTokenEntry struct { type LINEChannel struct { *channels.BaseChannel config config.LINEConfig - botUserID string // Bot's user ID - botBasicID string // Bot's basic ID (e.g. @216ru...) - botDisplayName string // Bot's display name for text-based mention detection - replyTokens sync.Map // chatID -> replyTokenEntry - quoteTokens sync.Map // chatID -> quoteToken (string) + infoClient *http.Client // for bot info lookups (short timeout) + apiClient *http.Client // for messaging API calls + botUserID string // Bot's user ID + botBasicID string // Bot's basic ID (e.g. @216ru...) + botDisplayName string // Bot's display name for text-based mention detection + replyTokens sync.Map // chatID -> replyTokenEntry + quoteTokens sync.Map // chatID -> quoteToken (string) ctx context.Context cancel context.CancelFunc } @@ -69,6 +71,8 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha return &LINEChannel{ BaseChannel: base, config: cfg, + infoClient: &http.Client{Timeout: 10 * time.Second}, + apiClient: &http.Client{Timeout: 30 * time.Second}, }, nil } @@ -104,8 +108,7 @@ func (c *LINEChannel) fetchBotInfo() error { } req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) - client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Do(req) + resp, err := c.infoClient.Do(req) if err != nil { return err } @@ -644,8 +647,7 @@ func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken) - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + resp, err := c.apiClient.Do(req) if err != nil { return channels.ClassifyNetError(err) } diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 7a23f9617..292a71fd2 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -32,6 +32,7 @@ const ( type WeComAppChannel struct { *channels.BaseChannel config config.WeComAppConfig + client *http.Client accessToken string tokenExpiry time.Time tokenMu sync.RWMutex @@ -129,10 +130,18 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) ( channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) + // Client timeout must be >= the configured ReplyTimeout so the + // per-request context deadline is always the effective limit. + clientTimeout := 30 * time.Second + if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout { + clientTimeout = d + } + ctx, cancel := context.WithCancel(context.Background()) return &WeComAppChannel{ BaseChannel: base, config: cfg, + client: &http.Client{Timeout: clientTimeout}, ctx: ctx, cancel: cancel, processedMsgs: make(map[string]bool), @@ -306,8 +315,7 @@ func (c *WeComAppChannel) uploadMedia(ctx context.Context, accessToken, mediaTyp } req.Header.Set("Content-Type", writer.FormDataContentType()) - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) + resp, err := c.client.Do(req) if err != nil { return "", channels.ClassifyNetError(err) } @@ -364,8 +372,7 @@ func (c *WeComAppChannel) sendImageMessage(ctx context.Context, accessToken, use } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: time.Duration(timeout) * time.Second} - resp, err := client.Do(req) + resp, err := c.client.Do(req) if err != nil { return channels.ClassifyNetError(err) } @@ -746,8 +753,7 @@ func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, user } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: time.Duration(timeout) * time.Second} - resp, err := client.Do(req) + resp, err := c.client.Do(req) if err != nil { return channels.ClassifyNetError(err) } diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 39f84d55c..0d0426c0d 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -25,6 +25,7 @@ import ( type WeComBotChannel struct { *channels.BaseChannel config config.WeComConfig + client *http.Client ctx context.Context cancel context.CancelFunc processedMsgs map[string]bool // Message deduplication: msg_id -> processed @@ -93,10 +94,18 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) + // Client timeout must be >= the configured ReplyTimeout so the + // per-request context deadline is always the effective limit. + clientTimeout := 30 * time.Second + if d := time.Duration(cfg.ReplyTimeout) * time.Second; d > clientTimeout { + clientTimeout = d + } + ctx, cancel := context.WithCancel(context.Background()) return &WeComBotChannel{ BaseChannel: base, config: cfg, + client: &http.Client{Timeout: clientTimeout}, ctx: ctx, cancel: cancel, processedMsgs: make(map[string]bool), @@ -450,8 +459,7 @@ func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content } req.Header.Set("Content-Type", "application/json") - client := &http.Client{Timeout: time.Duration(timeout) * time.Second} - resp, err := client.Do(req) + resp, err := c.client.Do(req) if err != nil { return channels.ClassifyNetError(err) } diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 8ba2a723a..834e7bfc7 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -15,6 +15,14 @@ import ( const ( userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + + // HTTP client timeouts for web tool providers. + searchTimeout = 10 * time.Second // Brave, Tavily, DuckDuckGo + perplexityTimeout = 30 * time.Second // Perplexity (LLM-based, slower) + fetchTimeout = 60 * time.Second // WebFetchTool + + defaultMaxChars = 50000 + maxRedirects = 5 ) // Pre-compiled regexes for HTML text extraction @@ -74,6 +82,7 @@ type SearchProvider interface { type BraveSearchProvider struct { apiKey string proxy string + client *http.Client } func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { @@ -88,11 +97,7 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in req.Header.Set("Accept", "application/json") req.Header.Set("X-Subscription-Token", p.apiKey) - client, err := createHTTPClient(p.proxy, 10*time.Second) - if err != nil { - return "", fmt.Errorf("failed to create HTTP client: %w", err) - } - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) } @@ -143,6 +148,7 @@ type TavilySearchProvider struct { apiKey string baseURL string proxy string + client *http.Client } func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { @@ -174,11 +180,7 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", userAgent) - client, err := createHTTPClient(p.proxy, 10*time.Second) - if err != nil { - return "", fmt.Errorf("failed to create HTTP client: %w", err) - } - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) } @@ -226,7 +228,8 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i } type DuckDuckGoSearchProvider struct { - proxy string + proxy string + client *http.Client } func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { @@ -239,11 +242,7 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou req.Header.Set("User-Agent", userAgent) - client, err := createHTTPClient(p.proxy, 10*time.Second) - if err != nil { - return "", fmt.Errorf("failed to create HTTP client: %w", err) - } - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) } @@ -322,6 +321,7 @@ func stripTags(content string) string { type PerplexitySearchProvider struct { apiKey string proxy string + client *http.Client } func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { @@ -356,11 +356,7 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou req.Header.Set("Authorization", "Bearer "+p.apiKey) req.Header.Set("User-Agent", userAgent) - client, err := createHTTPClient(p.proxy, 30*time.Second) - if err != nil { - return "", fmt.Errorf("failed to create HTTP client: %w", err) - } - resp, err := client.Do(req) + resp, err := p.client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) } @@ -415,43 +411,60 @@ type WebSearchToolOptions struct { Proxy string } -func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { +func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { var provider SearchProvider maxResults := 5 // Priority: Perplexity > Brave > Tavily > DuckDuckGo if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { - provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy} + client, err := createHTTPClient(opts.Proxy, perplexityTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err) + } + provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy, client: client} if opts.PerplexityMaxResults > 0 { maxResults = opts.PerplexityMaxResults } } else if opts.BraveEnabled && opts.BraveAPIKey != "" { - provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy} + client, err := createHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err) + } + provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy, client: client} if opts.BraveMaxResults > 0 { maxResults = opts.BraveMaxResults } } else if opts.TavilyEnabled && opts.TavilyAPIKey != "" { + client, err := createHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) + } provider = &TavilySearchProvider{ apiKey: opts.TavilyAPIKey, baseURL: opts.TavilyBaseURL, proxy: opts.Proxy, + client: client, } if opts.TavilyMaxResults > 0 { maxResults = opts.TavilyMaxResults } } else if opts.DuckDuckGoEnabled { - provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy} + client, err := createHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err) + } + provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client} if opts.DuckDuckGoMaxResults > 0 { maxResults = opts.DuckDuckGoMaxResults } } else { - return nil + return nil, nil } return &WebSearchTool{ provider: provider, maxResults: maxResults, - } + }, nil } func (t *WebSearchTool) Name() string { @@ -508,25 +521,34 @@ func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolR type WebFetchTool struct { maxChars int proxy string + client *http.Client } func NewWebFetchTool(maxChars int) *WebFetchTool { - if maxChars <= 0 { - maxChars = 50000 - } - return &WebFetchTool{ - maxChars: maxChars, - } + // createHTTPClient cannot fail with an empty proxy string. + tool, _ := NewWebFetchToolWithProxy(maxChars, "") + return tool } -func NewWebFetchToolWithProxy(maxChars int, proxy string) *WebFetchTool { +func NewWebFetchToolWithProxy(maxChars int, proxy string) (*WebFetchTool, error) { if maxChars <= 0 { - maxChars = 50000 + maxChars = defaultMaxChars + } + client, err := createHTTPClient(proxy, fetchTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) + } + client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("stopped after %d redirects", maxRedirects) + } + return nil } return &WebFetchTool{ maxChars: maxChars, proxy: proxy, - } + client: client, + }, nil } func (t *WebFetchTool) Name() string { @@ -588,20 +610,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe req.Header.Set("User-Agent", userAgent) - client, err := createHTTPClient(t.proxy, 60*time.Second) - if err != nil { - return ErrorResult(fmt.Sprintf("failed to create HTTP client: %v", err)) - } - - // Configure redirect handling - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if len(via) >= 5 { - return fmt.Errorf("stopped after 5 redirects") - } - return nil - } - - resp, err := client.Do(req) + resp, err := t.client.Do(req) if err != nil { return ErrorResult(fmt.Sprintf("request failed: %v", err)) } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 2cd79eb24..db3c08ba6 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -176,13 +176,19 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { // TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing func TestWebTool_WebSearch_NoApiKey(t *testing.T) { - tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) + tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } if tool != nil { t.Errorf("Expected nil tool when Brave API key is empty") } // Also nil when nothing is enabled - tool = NewWebSearchTool(WebSearchToolOptions{}) + tool, err = NewWebSearchTool(WebSearchToolOptions{}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } if tool != nil { t.Errorf("Expected nil tool when no provider is enabled") } @@ -190,7 +196,10 @@ func TestWebTool_WebSearch_NoApiKey(t *testing.T) { // TestWebTool_WebSearch_MissingQuery verifies error handling for missing query func TestWebTool_WebSearch_MissingQuery(t *testing.T) { - tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) + tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } ctx := context.Background() args := map[string]any{} @@ -438,7 +447,10 @@ func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) { } func TestNewWebFetchToolWithProxy(t *testing.T) { - tool := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890") + tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890") + if err != nil { + t.Fatalf("NewWebFetchToolWithProxy() error: %v", err) + } if tool.maxChars != 1024 { t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024) } @@ -446,7 +458,10 @@ func TestNewWebFetchToolWithProxy(t *testing.T) { t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890") } - tool = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890") + tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890") + if err != nil { + t.Fatalf("NewWebFetchToolWithProxy() error: %v", err) + } if tool.maxChars != 50000 { t.Fatalf("default maxChars = %d, want %d", tool.maxChars, 50000) } @@ -454,12 +469,15 @@ func TestNewWebFetchToolWithProxy(t *testing.T) { func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { t.Run("perplexity", func(t *testing.T) { - tool := NewWebSearchTool(WebSearchToolOptions{ + tool, err := NewWebSearchTool(WebSearchToolOptions{ PerplexityEnabled: true, PerplexityAPIKey: "k", PerplexityMaxResults: 3, Proxy: "http://127.0.0.1:7890", }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } p, ok := tool.provider.(*PerplexitySearchProvider) if !ok { t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider) @@ -470,12 +488,15 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { }) t.Run("brave", func(t *testing.T) { - tool := NewWebSearchTool(WebSearchToolOptions{ + tool, err := NewWebSearchTool(WebSearchToolOptions{ BraveEnabled: true, BraveAPIKey: "k", BraveMaxResults: 3, Proxy: "http://127.0.0.1:7890", }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } p, ok := tool.provider.(*BraveSearchProvider) if !ok { t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider) @@ -486,11 +507,14 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { }) t.Run("duckduckgo", func(t *testing.T) { - tool := NewWebSearchTool(WebSearchToolOptions{ + tool, err := NewWebSearchTool(WebSearchToolOptions{ DuckDuckGoEnabled: true, DuckDuckGoMaxResults: 3, Proxy: "http://127.0.0.1:7890", }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } p, ok := tool.provider.(*DuckDuckGoSearchProvider) if !ok { t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider) @@ -542,12 +566,15 @@ func TestWebTool_TavilySearch_Success(t *testing.T) { })) defer server.Close() - tool := NewWebSearchTool(WebSearchToolOptions{ + tool, err := NewWebSearchTool(WebSearchToolOptions{ TavilyEnabled: true, TavilyAPIKey: "test-key", TavilyBaseURL: server.URL, TavilyMaxResults: 5, }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } ctx := context.Background() args := map[string]any{ diff --git a/pkg/utils/http_retry.go b/pkg/utils/http_retry.go index e90fa2129..135ea0ef5 100644 --- a/pkg/utils/http_retry.go +++ b/pkg/utils/http_retry.go @@ -37,6 +37,9 @@ func DoRequestWithRetry(client *http.Client, req *http.Request) (*http.Response, if i < maxRetries-1 { if err = sleepWithCtx(req.Context(), retryDelayUnit*time.Duration(i+1)); err != nil { + if resp != nil { + resp.Body.Close() + } return nil, fmt.Errorf("failed to sleep: %w", err) } } diff --git a/pkg/utils/http_retry_test.go b/pkg/utils/http_retry_test.go index 1c2dbe115..d64cd5eda 100644 --- a/pkg/utils/http_retry_test.go +++ b/pkg/utils/http_retry_test.go @@ -1,8 +1,11 @@ package utils import ( + "context" + "io" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -77,6 +80,91 @@ func TestDoRequestWithRetry(t *testing.T) { } } +func TestDoRequestWithRetry_ContextCancel(t *testing.T) { + // Use a long retry delay so cancellation always hits during sleepWithCtx. + retryDelayUnit = 10 * time.Second + t.Cleanup(func() { retryDelayUnit = time.Second }) + + bodyClosed := false + firstRoundTripDone := make(chan struct{}, 1) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("error")) + })) + defer server.Close() + + client := server.Client() + client.Timeout = 30 * time.Second + client.Transport = &bodyCloseTracker{ + rt: client.Transport, + onClose: func() { bodyClosed = true }, + // Signal after the first round-trip response is fully constructed on the client side. + onRoundTrip: func() { + select { + case firstRoundTripDone <- struct{}{}: + default: + } + }, + trackURL: server.URL, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Cancel the context after the first round-trip completes on the client side. + // This ensures client.Do has returned a valid resp (with body) and the retry + // loop is about to enter sleepWithCtx, where the cancel will be detected. + go func() { + <-firstRoundTripDone + cancel() + }() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil) + require.NoError(t, err) + + resp, err := DoRequestWithRetry(client, req) + if resp != nil { + resp.Body.Close() + } + require.Error(t, err, "expected error from context cancellation") + assert.Nil(t, resp, "expected nil response when context is canceled") + assert.True(t, bodyClosed, "expected resp.Body to be closed on context cancellation") +} + +// bodyCloseTracker wraps an http.RoundTripper and records when response bodies are closed. +type bodyCloseTracker struct { + rt http.RoundTripper + onClose func() + onRoundTrip func() // called after each successful round-trip + trackURL string +} + +func (t *bodyCloseTracker) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.rt.RoundTrip(req) + if err != nil { + return resp, err + } + if strings.HasPrefix(req.URL.String(), t.trackURL) { + resp.Body = &closeNotifier{ReadCloser: resp.Body, onClose: t.onClose} + if t.onRoundTrip != nil { + t.onRoundTrip() + } + } + return resp, nil +} + +// closeNotifier wraps an io.ReadCloser to detect Close calls. +type closeNotifier struct { + io.ReadCloser + onClose func() +} + +func (c *closeNotifier) Close() error { + c.onClose() + return c.ReadCloser.Close() +} + func TestDoRequestWithRetry_Delay(t *testing.T) { retryDelayUnit = time.Millisecond t.Cleanup(func() { retryDelayUnit = time.Second }) From 9efdde25ad7fc63ccb869c4601c469491b6d31c7 Mon Sep 17 00:00:00 2001 From: winterfx Date: Sun, 1 Mar 2026 16:23:05 +0800 Subject: [PATCH 094/104] fix: preserve reasoning_content in multi-turn conversation history The openaiMessage struct and stripSystemParts() were not carrying over the ReasoningContent field when serializing conversation history for API requests. This caused thinking models (e.g. kimi-k2.5) to receive incomplete assistant messages on subsequent turns, resulting in 400 errors from the Moonshot API. Add the ReasoningContent field to openaiMessage and copy it in stripSystemParts(). Also add a test to verify reasoning_content is preserved when sending conversation history. Fixes #588 Related: #876 Co-Authored-By: Claude Opus 4.6 --- pkg/providers/openai_compat/provider.go | 18 +++---- pkg/providers/openai_compat/provider_test.go | 50 ++++++++++++++++++++ 2 files changed, 60 insertions(+), 8 deletions(-) diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index d922ed5f7..74e612046 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -289,10 +289,11 @@ func parseResponse(body []byte) (*LLMResponse, error) { // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` } // stripSystemParts converts []Message to []openaiMessage, dropping the @@ -302,10 +303,11 @@ func stripSystemParts(messages []Message) []openaiMessage { out := make([]openaiMessage, len(messages)) for i, m := range messages { out[i] = openaiMessage{ - Role: m.Role, - Content: m.Content, - ToolCalls: m.ToolCalls, - ToolCallID: m.ToolCallID, + Role: m.Role, + Content: m.Content, + ReasoningContent: m.ReasoningContent, + ToolCalls: m.ToolCalls, + ToolCallID: m.ToolCallID, } } return out diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 7247fea3e..d9e6ba871 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -146,6 +146,56 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) { } } +func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + + // Simulate a multi-turn conversation where the assistant's previous + // reply included reasoning_content (e.g. from kimi-k2.5). + messages := []Message{ + {Role: "user", Content: "What is 1+1?"}, + {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, + {Role: "user", Content: "What about 2+2?"}, + } + + _, err := p.Chat(t.Context(), messages, nil, "kimi-k2.5", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_content is preserved in the serialized request. + reqMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + assistantMsg, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1]) + } + if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" { + t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"]) + } +} + func TestProviderChat_HTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) From 25f26f305b60ddf1fae47b5b4f8b0203364b010d Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Sun, 1 Mar 2026 18:27:37 +0800 Subject: [PATCH 095/104] docs: update wechat qrcode (#955) Signed-off-by: Guoguo --- assets/wechat.png | Bin 375293 -> 143484 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/wechat.png b/assets/wechat.png index 1900c75567b452e8a4cebe8543645d76e0421181..1c0b88295e1d34de1a67757e6d7f8067cfa3337a 100644 GIT binary patch literal 143484 zcmeFZ2UHZ_)-PD(OiPpu3J6M0N@!>hNh%;pj*^q))Wjx8PJ)7h5+q6%BuLI#a#Auh zL5bajrb*qe|M$MR-mhg~BqZdd<_5hr?c@p9M?EwDsfrn3kn=c6|894>+hPoR7J{|!9J|O`S5g{S& z?qJ+?fRKiW_Li6uF`bSj$!!mM@vxN7q}&gyI~kr&Ab2FKJj2Pz8E-N%v+&;GKcz<=<4Yk7#bN{+t}LKJ2*OddHeYKLHz?FUPnen$Hc~^zI~UL z{{BNoW?p_lVNvmylG2)QwRQCk-y55{x_f&2`UeJwCa0!nX6JtXnqOOoZ)|RD|NgUs zJUTu(MV+C~FaDAX4Qhbf<{JIS~uo+B8nJSWH*c_i0(k$;KyH_86*2^RkUO0s_l_HS~{1C#`K zxQ|Cb13-X_qB#Cs{C|yqy@P-0z`u0hUpnwF9r%|H{7VP^r33%tbl}63YBAMqf&&%n zm6h70=iIN^ubsiE0plc^s1K0sT8p!v`TL@^Heu>$yTGd=7f@uGoKlHTPeK5o0%|Tan1E3 zEs|l+tose*#qs~sjnaVfC^_Y%Z3fS8r(@`2i?jYCNxbOOys*hQD{}t4(EsW%e#{RX zW}i+OK{A|KIlfogH3^Aez*bkVj9dy`9hF;=^g4<}|3m|?{Mrixp1Vd~B|5N-%|YNJ zuocdMabl)FDg=e}J+g6kL5~4rf%iz&<~y;U%`Hlq&3Vtc33#fxK-)Qmo-5=E-1k5G zW~#+8uxzRJNZd;k?oA#F^aDIz-2KulVXVuiPhFrNbIKy%D)7YM*)VMyH&?hL7O<+v z0@>Eb5V)?bo!5zecF}xr*0_2@WLy=5}S#?{3m=msWh^(V2M^r0A zG@N1~9z^Ekm!8UWaJ#`?4PayQ>8#3ztl6R)FcIRgl@HgjrOHw!8GX&7gl#>=i=?>o za99jRBz&ppt;Oy}MXIECY%Kio~!0Qi?GL?#8&}M6)nb)gso|hl=AO!N}=de^f|)Bxu8Y63K9! zo(5l`13?(_y3F*?Y?xsf+httx`ngGjDbGBaf4Z%zMi{I5?(V0#K=`lBDed|e z{t>E{-pyNI0siik6`rulpkDr#wzH0>^ z<#6AstX-2x(1LYE$}4BAw(f~X3`puJ)7+nY(8tHrPd8AKsb>PcR5dYR)*S)Bec@3+iot1{C3{S zVfR{1G{CfeJU4g^_0mi=v`wMOuzsd-#$zzG3XA653M#s1S}&btb+j`yWDzjJTz~FIO5Kzi0Q+8Jg;-;Kv%@~w|!K(EU$if`2U_G4I}W-i7Y-QCUdx~Tg_ZN~mB$PoxjE z&6eNhab`k3T{_}@vX7Zb58Y3rpM|G+0+PJeqBUQ*Y#U$sd;uH5sSKSB>J9Fvi#g6_ z>txx*46WXmXh_L!o7sU-V{R(v^#()FB+93hHp}{_?{d`x+Yo-{vm)nqkYtwUPmQ9w zokd0WeGkI0!hTK(X+k)|XvxI()^A_%qeg$$>2)v-T)@SvCsQ>ZrPuIwnr52jV>@Rm7cgm2E;aQ7D*4IlisjHd)c@`zw`B^G$>Xfst&^fA^pmc4_} zGG-&hXHKbesgolFlA~qsX~BvG2Huxqfw^z6dq@%LfoBXPRmP|lcI0ZS$=_@&6zzGy z3%%N3&^~is7{&t8A}>1nz2(U<`$Av|n82r3k?mY3>gpnkLFOA8o_ZG3+nJ{@ zmK{nAYoLIqu}K3B*Wrjn&xPcDbJ7WyHHx-*=+Xyje>U9Y`e`&q?F=MKF=@mlA7M*URdN=HK~J z$D1vlrqC}to)H^MLu+0ug}h`%@6=R_g){^-3h)%MbVZl5CxA=x#@R_yp(vO102#I`?Q6dHl@$ zPrcu4s`2td0qQ6L>`58%XuX(KyhKZP)7#b%I>SIaQ#)KZL(Z7=1I9s0;_Szkl%aw9>mWax)fi^L4F-QV}$!vJ+6i!iBYJ9fz(N% zj}dSzf}FwXQ5T$8Y_6omcNr8yQ@Rmzjjj9p4Q zb#in_rb4t;QLV&FzWpBVx`wmEHi6ya*_LBXw9FbS#ctrRb6}?=t>72U&c02q#CvWoVP1l^sIQVMIacp>*~ zXWzq>oT0WvWK@>FpI3qD&!)0@EweD`MbbW;UPe%1vOE~;=w&Qx2V{hjq7fseev(Cd zW0|**76}y}vL3wnzHpFZHlvZXKZuQLXsu5=hht>AJUCD%K~7=%epg_J{o3Dak6s>hpu*wX7_hcavEeX zk$vhP(c?D)^l6Y$)vt!$ny=I}Aws736jlP7T==@fTI#3q5@^n1hA<(gyl2tS=DOy{ zpwG-uk7Hqypwse2Eq@+A3t-MXxle-q@skokzG_FSV_SRX<(y@{Z089DE;CKy!dG91 zd7P{U=X?C5hObEVpZ}rXm9Od2^Xl&Nl;%FJz{MsOM|8DVpb~N^RteUdd0_Rjikn6R zr^wqoa14fpdq$YDnQ?y@_2TYesaOMpd;;T}rNWVZ*9R8_=E1D3zsmROR6l%t{>a|n zZUGCJfjBEvMJhjH%hQa^(rSU@lERD1XF>%y_`8i9oiQ#=6&dId+Z zgp~)wlfJ-cp;}%`q0XS0cS?gL zguNyEQWNC;(N3RVZL(RO4+G}LMe~((U6#GChlTyf776AO=KKrUe)cDQ!tE>7JJWHd z`|y7k)1`JgXVE1u*GmSH>Uz;tJy_r;OVD^Hl!JX}s2HN%=&?!sHpR!0 z*0uBgxu$!0>0omh%l6!4{Ch0$#8>}3ny4wzJ5M0R`SxOse+4Tsa6w%+C!aJZBxw4v zZ@w7y{MUI-<3a=Fr<51yz;&wS4@Nc#rZ%t4wj&32b*19V0#S5@HJuzDo7)Q&UIvWi zsX@h?kV4BlUXS^vR`3dMvRseMk1$D?NHFEQ!(#opa1h__oEcIA^RPbWwEH6S81&2a zV6$#vLB=(rBmIZIQ2V~fNY0-_^N&dR_H}0*OkoN^S(`$u*1qvOO1w`_6&s)Br~{`L zsmNu3oR+@bV}A09zeI|Et?4|UT)mQB{O%hbmg25O%%zkYEsp|tGK z*zWBXTYp_QcX_FFCJwMVG|grpDL@c+39SxR0a^wWvkcLs#{(mx|g#&0zD{O+gIxgk$tbzmgM05 z5v!_<}b^p_|C1!_UOt=`)R2_3ad=N>|K_y z34)gfSwpYgyGbtej4Sokg{)nW$e$({jVwPHBusx6yR; zh5QQ2=4ddOlULdK??{RzlewQ7! ziw&#IVO&?!6koF=$B^dN>W(pMgCK~zP>PT+vymsmz;fJr%^mJ(mn992Cmz+&6QS{7rQ0IX>f%zjni7fbGW5BD8kcz}obU6D*|=08WH;a&Lj$B($+ zu26`w86BGH%V9{g<=PvoGm3nU@_5DLLs<{~NU8rCA@LhQZR-QS5ir-;yi3MSg z86yA0^&MZU)vlh;!Nb{Qdje6PUIb*NmGmtRX4QXQPu`DRDs(BKOH&VnMJXK9jtxO4uUnaHC zg}OJ^B=gmvZY)6hb}Z^79}BqdVi=|;RC<4mnqU~tBCx=3deGpy^)6&T^MyOt$@ z1@PWq36$oAZrQRwP@Ag#qAw&nL}57XLP1Le1bCb0m_nJXq%FWI<)j^wB-(D}mhZ+rx}0RyD+n%$Cd~v-Mw! z2(3ut%RWpuoxFO{0U*Rq3iIC3S9(_{{Q2>o(fx6Mi8o>9qlwy$Td6}!hMuCKFK2f6 zt)DwvmWJ{JL>%E|gT?btn3GJPSs*=ILY6ue?R$F){t87dMbU-Erv9_@!R0TkS!YG5 z9KO=tN>U*rhRWY{C}kKz>DSNd`1`u65&AFHNOg==_{jbPnmhHD)&9>g-QNNsMS2|- z;GbzQC>t}^T!>1As z4pnUE45XSq0@5==qCG9RpNFVh9bWPrJwG?xSQYNli(ww%&W28*&ykGUcu=invwP}V z)xrEvo~_D8KBs78?-Ok|pvT*x*XIm7^f7zVWvq7?k`ZahGwdcwe0T6zJFnT+M4H^hq`GzYnr09YJ8i{8IgZlK@^*$bykc5+s00DXo@N5uzvv`L^1yj9VMs`AV z%k}y4=w@EJ&8M2`g*4@3;Pod;$|PqC%*}@GKqc9^sWo{l$iq^)0M`wbTgY3i5SdhQImTq&R&E)lqqasUm@hx|1k}M z;p4i|DXSZ3hwgWZvO+OEB?|eJOoNRwQkv^{vZWstgw78{5>uJ4n3_18%4sk@GQ5#j zD=5~%lg-58b@eb*{_nnXz3pC*@)U;s&;9pJku7tYKGIRrR?<8^c7yP+g~kqe>isL; z^6toL&y z6k;Qw+mtb!@sjcZb?D2K4APG6Iw0uR8iWIC@EF}t35|a?v7MvTVx-cVH{UbDZ1=qV zmP@6A_M_VCJ^1e;@pt`0Xbw8(~ilH9iFTqWnvkE0$`XOGgW z*~UH8Nc1fJ6rzjwpr?usdbZA6;Cp-pX?m}ow7st{A)MyTD|Xxpmm%ZNBf^@@9}?bNY-ko`<~5|0vf8J>OJS$NFVHzvub;T1oe-RK@E(X$BU<= z!tP{mEyhD{9rZ1XHooydlOwgmIq6O;@SV+hTW0osnDeHWSZJJC#LUV!4iSuIa>Rp{*l;NMRK@eKMlT1M4 zdd;5T<_vMoGma+$6%5&B^{0m=}A+=ZjnHlHHSq9j*HLuLXL zQu6wFd_B4jN)JpoD|q?d8s`wgHeR87ao|=V78u{orH1q`OHK!QA@CP;WLz?IKIA^jv$QR)omU|_Ii29uW=gs``vVTa>7ZY(i_I??Kmbu%Ijd``} z#Fa~X^N*JrSYVJF!8s{%%}0-SGDB~Wju}rk7ivSaeaa>d))bjVNVdQHF4~xE?Q<)d zxgY0xbPcEw`U~c@X#e8w^{D`#(4&mRjUpp=8>>bu?N`>B2M$2)giB*})kG{1(^U+1r%OIvP<+wB1^Qm>W_|xkLsA$AiqIH>BXW^o@Rn2_<8XGuQlzY*` zyc;Re=W9-g$Qu0?L|9p%=H}MynEm2Rmt%>H?93umKTYN+ z8X8a38xD89M=!s@+12IGM~ovZ(8Y+iJ&cUYw;#wJWO!ptJu-|p?Rb*7Js*@^+{Mu* zg3VMsumBC%%W<`xu&vdPGD=O)Bv3;^EmfbHG+1m#hnhp1EudAl=gX`<$3_~MlLUWxi7<5L;B{K< z+B#><{9KHWpr)52%{vKUSg-_J`y3t?kiD1=_I~2#QhtI>x1S3b zWeg7%sB&mOxl>iD5>#=?3LAj+$wM5}-WIDIUzsgE{7rW!(OEqVn-L~Sl1s20pi*JdcI1Qx*W^~w2mi1UH^ za|bP)aLtj@jwH)Wm4#b25U{J<8yS)bEiXlmYn7mpbuo91!s}Bfmmi1I$%tAsB@?tnOUd|-=<%%L(8FtExV3_21gZAq zz0s`eih6U4*s#;shZI|0UF(S_cdrEYG__Fd4>X(Gn%&b;6X3~e48t=G$eUaWNrRD< zZ+dVoCRGzIA26HR2HS}DAbdh{vb@F^l%}Ha= zzaO=fY^3DtF5Hu_eMc)^sz%TXCAs2nkHrEf_kY1whj0YL#JL%U)fJ|;`~AgoXjsRj z(7`uue{H`T$Be{a7p2j2iT>O(@H3K+CP9+Oin&Us%&>Rw~_Dr_{_<4}uya zya*c@7!|)Kq`eA@zv3-k-t|-9o$&r`nUGP*-Q1|p&7GVsn5rdx;9(wrDSHK;+H-TQ zTXMaiQtNg9D&BF2wl4M!-DUT(KI^yaPf3kccF5$YlN^TMH(AWvNz;4#j{IE2QYN~6 zxp54cULa^VKj=0(qTgh|hf(6*C3NdU~iWA_#g6k@^9G5OURJ;r+ZC7x=$M5cl^EluqQYt&P%!rD$=dL68 z=0;!o9HE^)0fY;D!0su-Gy=uMA{fm;rbvoE3b(%3Pgd>m)Hfv1wX%0|wh-WT`IsvW z?kD+%fP;ivkv+bdmC;{@iN6)!x}kVv#mBsKl(QNH-#QGX7E6goM6dHD9D-%mogp_I z-kLduaMt@8ZT@hPjBC6xAU6ETp8HE*K)51!Y(2T#BqzFwcEyQ9?)&$~jCvDe9$^l< z!?BF6r0qY8VLDK{)yhUVh#ExeEc~{O1;^;JR_Z$zyf!_G^>Eh_h7M-xW`Sa?2qpn_8?zhI^{02qo zT7^|5E}eqznJ_pVngk9OLR*M|e6UI`lppRVpA54)?Vp_+n0CTq&2 zP9a{6-!Y^0Ih)d52Yyt2@^a&Z7Ni*N?ln#8K2V{f-}}s{n(oVc^Q<0R%PbE|cgm?1 zeAYYzrh@d=Mc7TzrBi*G+hN9&yPA)#kzZTFY3gbcwxJtzNm`_<5oVCRh3j~QMtHEN zi}=!N^@6`+8=;8n$3nNI?MP|BT}{atLZ={)w&=kS&Pr-3bQ>q_9Nl7b1B(%oxPwz} z$+uDg{{3N6IqbjCdAog0+L6LO3kz_O3)4)W9%PB#d4iod0A|>67Z=z~j*=a1s6yW# z(?P0cU1yXIO_I4JfT%mzBG1{>P&zw8Be9VRNX|#7j zH&Fq}=aZ@;@Po32-3#KZvR?%yF2VSwsx_f(0)XO6GUn~+agwAfD6&1F9IRu#CW3D) zXSVdKVt9YPPpCG&oGhZ9>_Nw7Gc* z#uLuAT6b5q6Yn7VLxm1;c!27>YHia?vDRsPR*s#akjBbDR4&pbI~IZ3Ru!!JvV)G3 zZc0lK72F8$2s;Q)d~zv_4%k*kTe6*fnHVlvlv>ZHq+!<6z3Mu+5X}Xd|Bp;pR>`Ax zgY*7afPARbOmHsm4_q~_*L8pLl@^>_S5&%xS$eOimApk8o)cP^%!VHBb{9P2i@p`^ z=vt4sZOirc;0O7av8Q6ExgZ*}8XSTTwRqSR(VNYLnV-J%U0krrmT;IYP?4N6F*`S> zuhqE&r`N1b*`iZ|wIW0+MLRDV4J&&iYt41$J^k(uz`2u+w_vtdfF5eyjbWL+g`_UD zvLZ zP3vcsIu?ksMc{Cf793myTf+iyy$uV(HYSHQx!E8M$Fe0S(f*`#Gz_?bHWcW4!fmd6@gjP0L|=%nCEdV|^=BSfI<|@{ys#!^1t; zt8a-o2Ajx_Uv)Na%_%v}Nv*rTLRhXi$3r-G-|jTYFPBL5%#Jt>`v|`J{6v5jKkw5f z3CtLAc*7CKfXh0?9-%)zMV!>nCw^?Lt6g7Ip;1{3>&S?aq15uH2iouvF$j`~CYq)&n+j6I(;odo#4RgVTrw+I5`hGVBg(iSZy`}bGOe>0Ldqmfn`lOJH z&}pL2Q|eos;IQ`DfnVFMWPmqOD1Io|F3>IlW#8b-729Ia>hTBlyajX58E4CmkaM08 zf(sQaP_SMd!s8$T^+VYpA`5>Cc-<*_FJ9)ZJIHo`#~$+>?~J&vHYY3q7anX)9-LOu z#sb9UU%1p z(J;7fSH8-u!vc0I=eL(}RKSMh>$~Zp;MYO;NKAtV3;KOe`lLz8=(+1wqwN`a2WZ-$ zHAoB{To|M@{Z-N5B@Jhls;lE%CSHK=aF{^&P=K*9$*1AgWyYDsKRbf2wDB~ z$?r_(Ru0v)d~?IDRg#Va)oF;L1Hp6VSj_;VR|~+MwLp5swj3iPlaZZ-r0t=p?JK<#>8(H&)%f{sNZpT6c?1XW7guFY_tP zsns9|YrU1C_JNczp@dY6jEN*Od9}SS2V{y??S6_B&gaM~{n_(X!ZfQy{7ct&*Ni@K zkj!8%&JyQC`Y75Xn#zxJ9ff$q9SoE75=vhuqba^#Ig6ltj_cgQK-R+Wk)`s$WJG;H zplzZ%7N|AobrI>Ae`-#TcsKIJ|Crl!jFz?{A}vvRnD(a(n>z^@z7RqUHMbzW0#ACE zeU9%<7iKn!q30#L}Fe=vJhT#!oP4{}pe6Q0s?QJ8O!=mqAuxS=lC#DXWt z9I^Ec@on7-I@g`jI3>*YfHjntJ7sW%4xU%9C7(ocyEUSVlLLIS(y(Xl3=90a*{qg9 zH2((gu&!fXZI0yUGm^3ib2_8|Dt!E8>0Y24$qyfPeU*D}&uuM3bJpcT7u9m%)2Pbh z)xOqs5n3q6ln&B1!MDK^R^~(W6p4CMq+p6^RRrPyB)O)ZtF(lXwHGus&=bb=6%<(-PeWAG*=VRdSi zzqDCI4)*sI{6TrIIsK3wRi8o_cT>^tQK?g>8SA2T`_spyV~CPSR5I*6+gUD1{rYPV z*mhFVf@YzF)_BO}j_qz8#s(6B4_VcNt-B>-<}UC5+R+(mcbQ#_>;0;6eohA(p)hZvRxnA;MuSr;xa8d6V{E^WD$X@DI(R z1YXphxShXJ$l*@zYnAKAFvpvqy~@xb^StCJRXOjzhfS3I=i*hH7IY4NvN>^6-3pY$ zq}FXduPLvJ>=b*FQNGnznn1A8r8v~5vLN<022(w`e1-<*yI339&c(J}^O0e@=pOY3Y+A1uHbBJ{I8Qs_jRbctf2 z)e|r_q<;FW9RaUIZMf_ZdnE~Ep?i_8!~L@mL>&5s^84pEX@!Sl;R<%h@ouK`u-(Ca z=JoG#7L+*F9b+?YxiUuV8R#R|6?xpmrGFx$Y$4o9Jsb8#9*Uf5K{wDIZfn{AtES!P zT_1M6N13lZb6?)%6jYn$=R*n^!$25@-y9@J>XI@?id(4&!dM=;$l72d`ctkqGT>Fb z(;oPlMK4oS3zw1=fkCN_6#@lbCyN_(R8TWV-}+hKgz^R&Tca~t8Y54oTL!5h%l_N7 z?G9Bpl0iWNGxzI-r6>(mMkU(i+L0ZniI>uU>X+%zvQojClQSYLPtG^rz-^Q_p9j|$ z9ikZkFDyVUm8badIQwy=d*!L2Of}LC9|q}dqnXn!BsleSbxsscumB+r zC>*)dVNM01*2^;6jHFnnN-z5|;$>?I z9d;%NeWWpR?psUmU^~%g z_yNUbN4oa8yEAQZP-9p+2DQAzoxNoyJCkVyoPQM5kdfKdM6a!~TWMb>%L^o+Rn|sC`rGcI-xrxKj!F&>hus@-7%JB_l_+_x#3aq$q+i@~ z7}~~!Xba(DQs{3uBVMcECUDs76c&Nln4l_!U4b_2(@s`vflA z)<0aD8?Khb5>UN?V^LMLCi_ApQI#jVS+a}g6xTVo!8&h92JX9ylETO1&dXL0J3&g%+;KF_Z_&xdG5p>d*dTH1cn2f;*uZw>_x8oGLkEbLDRa%`=& zd4G%P#zDfW()RiN0{$qR9Jas5qU|H(wfZJZ1W-Y z6)VCZx~|O3;PK>U)Wxrco~<2y0M1VF^0Z1BSLKcz3&jE;sWHOcZ2qR%-mmdG(0R}u zvu*N8x@_souGXYDfIlw|IC?S`9WcF8%aD_ppFZFy+GTBP@Ad#<55*jas&27g7(B?m zVwtV0ikWM>g-(Gn<`cE{x#Yb_`>r!&8^B`?@oFTIVQXlgP~@<@R7CUXA;%&FYextb zT`k+6q|<49eMK`+oMZaE^0M1E^(Qp&$+z!-rkgXpcI}3%F0gv7;xM5|1>DV zUywVY0=$GSbOl%>oLKBtc*^k99y6M%Zc?v(@>HmUyuOmRT(bbpJIu?1eeXA4)%gjl zZVUS>>^WQg{SCmSR-%^K=fIY>>w#!K7xRhrR6e5&U8V)k%>WMP8p+ycB9EwlS`+CWvN(wv$7>EM zfBk0M=H#YsriNvG>r}S#C!?`CC6qoT$!3!C9DgoI4AIaDB1HS{3xA%q=5-i%vMMT#Q-xzq?bVzJkruV#Q^~(F(c)y*$bC?%n4c(tpe}>G3vzqc?e@GC-9zP_|_ zZSNOH-Q+@$2x6ov@old@s;0k;`~CBp6eiyLFZrIjz!?^CT@~C})Vkcr9nD2Kc&opq zNr_3m4Y#w3J78iH(O#EkFKfr?^r|=HbsOyj*buB{X?t(SN`v?Ll*H|C;bat3=p=c8 zw!b%bkkO7~QLU~(^XYs;j9j5un)w}gqbvlg32iBCthu>m;1y6<6fV{qUf4|ugeL}P z+si;*$Wq3%YQFC#Dg3#ZoAQ1}BDOk)hj#0%$5=$4d>1O!6aI#lco*)j$@)nKbA{J75z{ACxnFWY0_a1;f*v~Fwn({XCmj`QGrC2V zQa>;f%IZEUir3FRqJwTnOqnAUqSy9Gx{V9&8#8=KqWGxFghvn5*my1|UVie5I6>Xx zCm`v-Fif>DuQi*osz2mm`l~{vk^TXSdlPZn8Q0evr1%@_cai|m(=t=s#Ky!3#%7IZD z@h^s?$Ke44^WRq}F=NX#5sKBxJuH6nQnoP^8M!SVxZ6Ejk|22IBPxKW0x8;Wt%`7Q zNqcRWuReYA;bUXJw_I$=En2eEIrOyxW3*5LOA^hZUP{~ zL#xdN`m_{ZZO4=9sd1>tS*mNWX-kiHQLGZ8uY)`F{=YgkGBR57T1;)lz;jl|2~R0eILby(`OBIs08q`Z2;EAI_Z& zGqJBqn2gx4Kit3U?3S?SN}x%2c{&DgKGd<`g|48ok&GGZBl_Jc6DKK_&T>l3%^Do4 zKYddle`kB64i8JJTMQXnVe`?B$>wdDkXpJwL^ip&*e&(J*7Po3T7(4KRW~6=U#L7P z?)+vitd1|HB1q%Q!tbX0sZHJzlFzOyc>LAP$*d~#Q^`8m?3twpPawZ`;dX^cPV;GR z7cZ@vhNh^WOT9uLY#)ZD85v5@eQJ86bb6&Izx=m=41H*LT?SU4FC3hxHM=b@=YKtDeJf>ruTQHH z;dvyh4>AYmt2;I7sTMm9u*tob@^H z!y7sLpw734Vdle#cKFk_J7~*w5bZ9Dv6}zqvRdSPHO(k~x>od5IN(VBVoB zffH4ROoun9ArQ=~Om?`uSKSF;2PBs6>IvfYMdI~4{r9K_;$;u&pkirCW+k2^CVVnn zABz)QkE#FM2d&a%JvyEw?24oe8ojsT zY>y*?CsX5y;2G`c{Xkt@EuA?ebOOEaU5goibN$YQ@p}CG;tN01y#YSvQR%S&02Lsx zj;jE1YG;9s^L4kMt-E35%xB7jobJQ$Uj_LSzhgn6uQLSPaJ!66rf(7^v=vS2mjcSkHh;9bQw?`YIIy>ZENzoy^;c!*ZYje0&AsLxSJJo{xN5Rc#IUg#A^Cmc-8}4$DzkFwk8fj_Q8Yn?JY~+ z6BuZczTyq_!Ue#GQzIJFo~rtlhX)E)1n zkm1}h(9a{-M`-4>XYPS`{hvD-!_7I*`%|47b3rq*0!KA*!1ar~b^%>yuiknF?QZ@VpN8NkJSYV79a`;y57mi|| zjq3_`r~Sq6T+lG+-yXwhpM!x8;wk|%ueI|+A%Y5HkSpX{%(-N6HtZbEcYWl1st^ZU zeZ2&)9QSEoZcK4Jc&~5#RS}Hz`OO6~lNTm#}T#=BKk44U*Zv)AmtIJe9q`vO;_yja9; zHlUD-TRaJlqv6DFzx-LD2kS8Ge?qimCieVh)*fLU_>ll|S0Zi&q2wp4w$O#V7sfwc z?F()g6$wN>M3a-rk5ZFVpGZvyDP0$VHE`rnqsbFZKGjQx#2@LUa~?xSVygFkJRZm= zj0j6go4F!D?^obD@Ac9CHs_l1{vF>jr6d*CH4z)V^?qgIWW|_8bc>a4gBBTp`;_dKRx3C+Iuw0S`%~_94}-DIws%a;KN~Vw)Y`y{R6SC1Du3eu&wB!m|B1Kv3~RDmw?=~? zpfu^dND-B$0@AC}M5L&Iw5Wi9bP#EQ07{jPfFex+K`D_gEs-uDgpL#`2}*|qAp{8V zOy0HLwb$DF`mS?+eEa8L^aeFA%ntq$d1P(%DR2JUT)wb;dhtZ)IS1Rpy-=2*fpuDnK>UblOW4N#tyYBYT zrDF3*Q^|wapr)FeY)l`78=om;yYu4hdPnFf^0XU+skiY2ve5A&8REWH7}AbpSYMIFi924 zzP0mTE_!zEhq^2EjrFabEK@ES!!|#-7Ls+i`q&MyPlf#3O8J5<3MVe$Z+AMi3P8q4 z+8J%DhH^$JtYHJ2B@Kj=;p(6n{wgUJwe&J?uLn^qsj5^~zwQ_8CbyPXW9TI_ES0CC z^-Co1)mlP+{YY*)i1F=r1Lnx#b|6N62?QWuH&_bM6&#B^fyUMkW5`31$lgH-!SnqM zx8XMHeU$Y|8@W3afN7ZoJ)_5Qo*Mb42{%xMs_d_!xz(U>%J(RL$Zfwvz+T~X;^(Pr zAxbMYdn9=aP)l0oTtJg(K#ESXsGfnjG$hUQ*(uvfvwqN@bF`!rJa6>*J-J~rC8yyp zOhOD^{|)~ur9h2{N$os?M~OQU(Dl9oKNp{gkv3KtJ_gZmq7oClww4E!X<>{?Tg1l* za1(QQ)j5;jR^M!coKtk{RD_yN1 zVsbghH~v5z5U1{;-`f<;<^|RJo22j0$0CQK99K2Q-|Ly?YPNQd`z=8Q)P-hXS8>X# zgsa}Rvop40g>;wItz;$YenBF1&){em!8+aB7XRm@qIAfoT<6^nYFgVj9hwk?qBJvw!i zOOhx8bl}rx>Te*{IX4C*`aXO=v8_@;Vu9>ictf5F6pMz1h8+Dfe>)$7vh-i6dtx<& zjbi@+0W7Gcc(4!){)G>qA9hS=en$TbB_#b9J_s!y(iF`2FO={%K9E1i1JiDyY9`dz zwW`*2A^jj2gJN{ay*Rk}B*$Y++Fji2Qtv60Q5jv(tvnxsrh?+M5mBix-5$=yG&>$Bi--p5oPI>kRH1FB)Ats(lY1ywOMg11Tm> z{JK^B(ax18z3XksjDfeG*DnLnlou=pJ&Q+=b0DE_;qESZJ1kY8OKycM_wPT*?+1fv z`;XtttCjDTPd;$*qxu=Tmnq~H;tkD6pC!?aZ|xYzk-6NJ{bOt&pRu|XmQ$rC^8CJD zC|Am|mow##U4!6@x$PS;`#+HTFHlD;V-pr)_+YYuaXfsT+5ZpZofQZ=68{ZnRqy~P zhT67(iT!~*;DYa5gdFkywG~9(BX%EZ6Vuw}&d5vAX?+^um}<>97hE!{oAT+D>XmsP zYPFBh2viMctg0=Z&UjFMjsMd09F0!uxI#jKqxQbiqp$n=-Tcm!-AE0ht5qGb31c8O zyrtSyn&wScrjwyRU2x(G?W$Ug(6)pV!3y`QYL5F!F!sHzSWcX=SjVugsXYT#OmntF zh9ei>-ZDeTNEX0YBUt|^`6i7xvbgMuMYxA&QDbC=aG$7|lH+|{Eh@n;i+pEvo}j8w zQMJXy&Z5VHlbHsN5BRoi`b8Z%E)6!yNX4gqqoy{#J;IBAJG0 z8!4}$PpTdC(2>e;*c;I7v*tEQfbb`g!HOg**I<=S+Vi@ds)cLrt=_->70PHR3o(<%S1 z%6+Pr_WrlYfrXXfFm)R|DoXx`3P6G7n`yy(D}LuJdLD5}>I)^(3V{U85UjUupTAl+ zt2!>PlB52MmuJuSFL@p zTG4D;2zv*keF{B8`h73G8wZ{Qi6MDIN=)$KH1CUGrH(V*qNOz|7297Z(G<~3P*ZSS z4~bK~SW}!wi~Yk~#~=OAacytHW;S?Fa;q}xuUX##5%s7myv*9h@MKpyoNef`o?g9I z@0a^hqr3;p4o|14VM=-G&w}+#9hvCG$?rb zWh>HLajl#Ew-ZTTJQ3uV5iNjRTr`M3gUw^{xrDuW>SFAhgsT)ER<`NPBe`M3FRe8thj5m1#OWoA8jNNY|+z>pE-Lx>-Q2M>Aaz{ zC_jU4Go?-+IBqmo1=#ed0Rb3AA>ez`H#qXRw;YI+8i{#E_2ki5^4AB9umb5_BjQ^9 zFB%WuX|-ia%-p)|ZE|=$YHtDPnUOCWaqpY{KunUAkqPt!iOkDvJnBi0;oeDPoL2?W!(Y4A~YTLB&FaX)7$g<1UZUv zi>_6=11O+YS%WoVbf+fP$Vn6{+&mH3C z7NBoYR5eXMI2SCU%H$25YFnZP1Cx8&&x$6~u6yb6>ji;o;Yq}R1^|^HzgUC(atfXk znrN{k*!2gpFLBJCeR_@>2vZC?5+edAC|b}uN1z2-;~_)no4W^wxMySoy=SO(W{{}g zWrQiOk8)aMjE5VIeY-?`h_sJHkI1Wq`oVlB1!{%o4W{|L8+0H~ z7j#2z89_py!QEE=nS9sCa{q7*DEo(NK&Y*$reMi`{FmTgt^)u5`wv$S($l}?+SdN8 z8MarkPq)M_Ggu?~5(IBI5A8|>A6>y%yME?(NYwxSfr>9tZy06#j{`I%~rxEoWJj*-B z9u^NuWsU^Wdo3(*dE^^;&*1Cwj(;Ep!(kdXTvLf)rHdAKX%J|^cWFq<*4+oniCTO~ z>?XHz!VknsD1SfvZ=mDVmhV}F@(#X%koQOvl;OMI}CTB~{ODiRL zy8ZRL-&VR)Ty;K|_bQVfXU->equ7WDjC4*uF@pD0x#zQYHJ1a8crVIEZRIt^gX}&g z_s^O`XfW&n4jSuDK2I!qb8S>WCFcfal{1by#ZF1|kuE)`V(|I#Gs#bqdqY|9CsCcQ zAvf0aTk0#T>oW(k0&YGo8vK~_hFg@ty#;jzYcE%Wox~0N;9~8>rQSZimG)Q`&D-fQ zfB>vf+35%+DvNw_k2>2#d8oL0QDEWh)TH&juT_Ss=Pq&M*ciU|D5|MRtWN=4RDe-< zrfTSd?GIIwlhRKMmW#8mcVSP@u1f9&nOb5L?0%T&)-s^Xp0yGi7q`c|RA?fLKF4$^ zRKGP*xSz|mvr|IyKGY}n_H{P4yiezN3Vv$FgTd`l|P?=OQQknYg2YX_8lZLVe_f!DHG`H8Z7QD#;f z7mwiO6g>#X*PgvE2Norv{V49*xflVF(HdiB>nYhzXHVhJ;jreDPqZTg2mGI9|Dz6b zaW$6)ubWviV>!FblQpCHlTWNkc`@ip1nnMG+}_srxptn}9E`2+ol&`_CPP+dsSLxI zZ^4DL2*$i~mq`>K6h z@v^Q?Gq!zz4w$4ck-En z-yww>j*3+S#X1ltRxV#3xS>aQIL)al?e7cvC!pbZ-12bF0F5r?=orb3qDCC)#4<1Z z$ElZv37-<5ru-BLJp7@!NI6@Ah~7lGpapuoMOu&2*=}}}!5yV)oWfIQutmX~t)2;1 z^>M09SgG4~pXxKV+B4q&e!}pRF7krJ^Df3=)prWMdyU>-X;7w6#{R=w0sBiS1@PQ6y;SSH3VmxxufC#9yZOR` zJK1qFc{LMBv%2S>q?-jmtClFq2Z(nXPE4^pc^#f~tE_9unwfP9C(dP1)H={BFlAQq zEC1FMXwYFFAYI3^5{+AV2AbF#XAJB;94&L|qEso;R=@#5cP!fBxDognjTVrX=RsC- zgc_33cIu<*!T~I{#+DJZ2zP|QfNu0zhc=O@X_BfUSImYJV|%SbgQi*M z{qi{4FGN^-1=xv381dH0j&7J^KP)RDt^=C-GHQVZ&v^v#^CVHG0l>gSA0W|jji85T zd^u&*yJ}Hy7<{r6A{vmP&y`c0?9F?INt}{0CGR89^nCLqUMJ~*ti}LR6a)|rrw^{Q zw;ns1MCmt=FsAv)b4!QLWOTv<9m4M1w6E4iP7XR<$5wj51D<( zapIXOXtSb^^#rtwqYcpe>iKwDeV2AP^O_F4^DPa3Na<=X__}hEnT?s@gEZy<6e(%6 z9lKgYz^=m`Lg);6jCw}bHxt&!^B-N0>5-+Z@y*sRrcBAzqecS9JdMln1vWg>zixC3 zI=Q7Ys;YqtZf|^A`IwM7R5*Y`Dx422^czs-2WUd?T7Qrf8|024^njiu{TruM=D1jTKfuSu;zM{VkR6^eoL7q|eJPOtWc_xn3h!{Oq2Ysb6I&Px} zt>4U=oNL={Yh-mWeK63ke`Jj4qeBo6-W>fVEuXk}bO+Jfcy#A~Pip$lJjc}Am^DFG ze{gJvs2uekAd;k4;P9V;Fpb+E6H$L4LQ;C<xHXKvX`FTm~{GC;jQf(9KIBzz>2)V$nEWL$LY5JJPlBCkZFl*Vmuy zCsdVciaAM)^?2U??gilw6xW;Q?ABl>mSS`g!!hS-n`%)8+Ev=Z&aXvW6ud{zJimXi zq|7_6y`N~g}HSvd1m$AX28q6K9_o5lr$Q09G{-b478sEdGEgqX< z2&Zj7LBXZoA&?&ya!m)kN`w%Jugk->_!bm~ zP4FRtj(Be3tUFUjf~y~WUB8@9_nFvt4r^ZoFA44K2<$^}h!{8{=>bim-%kOaadEwt zjO!V*!v?15ua0xND)Vd$2yZxO%nl;PqVawCySWvDsimse=Df4NO4S+2{Zk9Zx6;$F zh$)F~4&>!=shZbi>+9~Dg%RgN2dd66$1PUVr+Vid%l0P`SixABH|FpMYe%`eSDpjN z>WODr&zhU)LRxA37vdlXW^u&4PIwJGF*?>yZbYoAsw&YxkxPA`%I&V=&zK%@Zt+nZ z1k<8Ga3;25H28_dw})Ui+>4nf-+Xn7yUcA=5pyTJmF63bTHPKHS6mHvv4BdDJMuXg zhSR&UsQP@8Ia!$6HM7X>N5#C!5DZ<1&!d=K1mwnQL!clAuj}THhgV>nUl+QFc(Gfa z&^@!bvjV*bcHGC2#e7WIWoP@b8A=;k|xtINEG(4+|?L=zCO$Px&h{MER_@0#`!y5K@I{fLHIDHms_C%0lfp; z!7ebV@|xs^g!rds%BhTs_dkowc)8Aj*gkhMAg;ZgtmJNJ(D;cwRLxP>=g%Ek=vC$P z(~S?dhQ0yLK8BFS8;K!UUf0+6)7qvMQpL|tOFBMC_VUu6Px9^zIe91_AlUIVmxB0$ z<86`6_rB7}^71U(x3PuZeDk>PLXUJ7@e{+y;<7E~Rrq(Lz?l3s&TL6{$A;~C_qj?s z_W?%PcqhQ$_IcwDyG)EFWfqK{G<%rVeZSEzG(R6HbfgnPai~J1>=*}fQqlI1BAa_g z6_&gzT$`D_fuhW}iZ6b@C@UPF?T=8;Bcu_dMYolqmK|jsF~j|*^U+$_*-So0!N#pj z@dw?ps;96Xhx8<(Qxo zVOMbz6A_t_W?Fz=3q_Nu@M6Vn8Zl4zrffTN25KJ>#!Y4rFSixr?)^2xF@JvbLHR3IhBYVaMp*oCc%mvOSQU({_d_jn@aLTIS3cKS) zI~B;U#pl4Hsjdiz5Ze?S;HTH7!&^Z|F%0Ze9T8q+nr_(D=l zO^dXkI^ox7nP~)teX=)t7^&?!bq#X?O}Z%=h9>Rnb+15gkOu_M_(5aT85)}=%mnKu zfABudI9qe$Y7`aXp@MzV4G6K2$kf>zh^qscF=7Qp%sS*H+{A5`FQB6QFS};QTB7|* zdi?lqOHXWn^DYrH7J2Ex5NzawVj~$VJX05RwK^(l_UGg%NfrLylg&vuGx44yw%PwGPq3d<|s<$5h z`WsAU-(khqPJfDN#Q_x)nm`@)B|*VvZ}>b@blT%aRg)G{by1nIf&}yZ4&)q0=)#wk z)Kg!%gandwV1hvHau&}^j<^aXo)jcnd7e8&x+bxx-sYZ??YLI!ZVHA$71#zd3=K-JBW%RIpp86S`+P5c`B9|$d{Kt;MQL^dHC~CBR-2HSh!4d?4a@aNM_e8;%6LP4UFP7>p+r?4chm{(q{|*!~+; z@t<{?G3vLTgeJN-_owNHL^OaZYnH}>O#qhs ze2xV^UjZkdL)`%Tg?>+>g@7V}T?!p}M2S2QYby5UquV^W>|pwXTYd~oro(P>eP2Bv zx47_h+~u-0e=bCL}{_7`i1D*#}`h z#3if|9p+YPK|s(pzjD`?va>qPKCh2qUt>0F6Bplo#{CcKCrF<7yqHiimnrcWgz%{3 zR!7SGbT{ku-uQ)Z9qz-=t-JY*GFU`AoD0=y6uOCS52`&X+Gw|FS4%HTD-aR5V!|iD zsMSiM6^9ZYf#WNt1z%*DmwzC_%|^;3S)8)0*PO!mam|l819%f{yi^|5AZ20x__`E4 zr{!=B-FLY)2KC!+C3W~-2Z^!C9D^RTRv3hT`&Q=qKEA$7;2bgCK0hF1lV2NcVxV20 zoFXTgQWGB4$vyDxOo;DvGLtG1jT`B*$G~GMq?#2z&RNac8TwNZ%n@D*ybTX}DW83R z_VwU??hAqg(I3+%^Mc0%@zI$l@lEjx{@ZY0hzHIIX3X=Pks6z^F~}I~v;lfbfVUKg zHmZ;ESYbx;p!5_x?b@Op{Mo6hCN*G6Nz@7YT8q22m0zm@MX(`;V9Hf6&z0*+^H-JK zBXgSH8Gr_JhR(r{k&G1fYl%7dlvl`$BpALkx`V;rcyMb0%!-Uyb)r~(oY}1POX+)~ z*_Bk-{FTihPf+I2djY$Rr+6aBYSUU`ZQHh)8hC-V?Q2ohRS2y}1XCrI4=qhP$(-nl zl_#cP?%l_ARr-7SmKgi8jc526@fN1aR&axB) zMC%hg&pO+F=kIuapYf8OVz)=PfCcc_onw}+<$b-rt1nqzm|XL;$AvB?Q&swx_AND| zVE^?7{OyiVm8S8UE!IAUnT(Q5W}7lYR>-a4=jS1bR~Th`*190-5ywT^vLr5-->b9C z<2!3cD3_+n+*zq^%HXcvE5YQirpqn&X}U?h5j6EEx<>zHH}LFF%5sb_!6vQ=qH;es1uc4~+L;w{{9 zF1B$F*dp81~-W|y4eW#RB<}O`*njTq_9R8cE0V`3ZLo6jO zHS5VhVUq4?ErEFqvgjxoCWrv`Ia3?x7`e%vt@?h>6o%fHFJsyxZV23Q{5&8VztgIr z3AO{i!29z}OYp){Oij5whv9V7g$I$`bx{wS=?N9N0FO_4MhhrVJ)7TR@m_g<3r?^) zm;m^zu6iMfVlE-HE%=nD%*~-|UXh}-%odWx=|7F_#x~_ZN0dg_4{D&=n4^Pf6*}Wj zAvVuKFABH%r~AQ91oWKt&fT4{5XW10>w!_(E)~hk@)b8^0<9-sYa8IWTlV=mGW){K^E3QjL zsuw8s2kMdJ6gp4`Ox*xjTm_tiPH=ss`js_*Xmwo!mUr6PWw-nycKs}J_2&8mI*Idq z_5)NU^BY+1dCH%8L<@BVkgrt!&R2*6Re9W22cF#9NQjhvEo#+&^aUB!KOpv9fTNW^ zyUcw%HxM$9w*#aY0|Q~0`t@PSelP9VLEEThWKxp6B%Dg48-^yy_DWLHzLOKZREwzDvdQ*r7&{1<=V#W z?cI%uxNRAtGdwnL=aM)XG7UEIjeBo2;+5CP{SyM00?KbS(MR<4PjAa^-T8gv$&cSo zg8ph@m@svV|CzN9{RzG}a?wKG^C_dKdbjcVqQcj2L%G>!KGCc23FtlE*CX@rVw{fX znE@Sn5(P*CyIo)WGaKW{MnNu7^S+8+a1QhDr=;+`msi%iWW#e>WH&k?Ary87hb93| z=6K__6{#9tg_fqe#>AEUc*h@XmsIrdf^YH{2|XC(eLx&W5iK(onQ z6*yUgt7hWOy%A4_qt0wSSj1JAficRhORUm&d|SJ`p(cR+c_WCcQ1t}TS^roKoP`Fs zE9iCN;Q~4qy^dN)IL&{>I>G5?6Q@5%a_F{ZG)J+9jIp;N9hl^&~ob9zW6JRSWnK+>IC2sE40c8h(9&%BNWUyd+Z1J8)&&5E&ObMC|=LOib0?5eQ01c)L zSy90Dbx=9RxYf3bIzZa9n*nMS?ovMQoZs z4es)rx8H8gBAi-xx4Tc3Xj4i>_2FbC2p~zGO(&%wp|1bq2LGN^&woxTE3`%KAHgib97G4A(RT-wMzX zM*wRR@O0XwVdBXT>6NI-4H*w}&O}WLArBFs-|dV{qh}N}79J6K$x)#H9(?t>1j!ds zS4;AlhM~lJNDyJ)qCk8hUIyEcxB@bVXEdl3T;|bpaFK(h zQ5E36#A+s>T(ArL&F&)Q+xpssyM32Q5OExkv|EAh5_KT}+Ia)ou$VeHyhy3p|$W*J67pABp*n>C!Z0WsM}#?-`#bVyl+!;D*tpZ4pzkHiQl%FaQEdDrn{{J@$Z}P#U*B)Dh{u=3M0&+h(0oIS0N>nybkR*x znpUH)MrzKX>s?g-dgR zXF@(oYSP-WzV%Z@$x78`r*;uH%L=Fj ztq$l2nmCP^W|b1xgZC{(wk~zQuV)O2&A78Ae%TZifjpg03~~z3<_nQGfuaf4yf4Bi zF6*M7%~8rxsIlX8{y;XE@#lgkf7VUd*GB$$EtJ^xBjb|sX}YryX>J+FZ$YcNJLZ@J zw$*~roeIpeM)p4q*}udJKNR%3s15nddkma`hCZY$kJDx3F_~?=X4)(Qv)vmU(3gGQ zDdDBl^tswng^q(X=UJYyT73*lOL}3w1h*oiDV@DKs6`GO=0$=&kjAW8V_Mfv3_#44 zsXC&3UcIM3?NL3OLgpMLtFj`97KV8Ky_hHaHTaU|Q&gO%&1`gjc|6j-LU9Em+tZ5W z5^t+Oft=_w(;pzD(ll{T8_KkJ`mW~Ls|}0rQ9C5Oq6Bco zZ3udz?@$|N6v&_1;mT7fD*WC0K}eLn_;2|g#CkiP#u~pBDbI-)O_{O#ZbOS4t}J_W z_r4{#7A<~7%6R|9uwW@R3t7Bu2PZg_fTO zml=wyTyLX~^6RdX_wM_J6g~|p-3wa8BG`Avyr6ufdm;0AQ9%{8g)!tgfv;~}sI_H3 z+kRixAJA0w?uWo+{u*23pJNXLFaic&YENRxo4mz9>#{yIdgZ8j^4Z~!H+>s)M@Pk* zyI;HQB)U)w`AG0o275JdP#_5ET3Z8NQlU~PJfhu5=H6Hb+#|W!tj4uQNmrtYpF**R zG9IWNqEHJ~$D|klnDF^JDES-`OFRkAVQd3HYlmOrUi4Q~tZ1O{zzxsz#nT~Qyi>ma z0(PrtLKMEDcwQq~_HodeSkGUd4K05Hfay;y)|PvUH-IKSS8G{Q?MfVxlFUPc@js%U z+duo)C9NZ#sb6e5z9LrW=(|LfzQPnoW?WIKUi>Hi>T1P}EXamyx5i_{=`{gKq9Z-e z$?VK}bASA-Dl`38e9YptNn~6y`%zVOd?(y5D8p4YAAX+A zUy?22)c$2O-ylur9IjjypgC>-7!=MHpiow5Fidz5(JYUf(;Sv}(BIl{kN6hX8c`{V z6?qnqO_abg5uIiJeLd77xdNh=Izu5t@_pdM+*QJi!9|^%@G7mYKyVtu=)`~1za3ZL z3$UFv<0vke1%HP-Z|#~sCF930SMoZ}z(d*3QtG@R zpwjc)RXBgDBK|v#5PkSp^_htdpfO$1AW0%9!9!hG*dlRt{(*Fw!P~y!6pk1L1ORG} zhm!7wx|@O5DUc-5l<-09-!B8}lTP-_+>RnK7&IqbhU4$`)U1X~YX4G-6d*f7ZSAnyy$EOBPCR^h|gY7NRBOPopZBYrPAdW5gm?qC zN;~J*G>G(L2TK1x4x&@sx)q~fak*9Sd99AWBDH06t)RIZ1u^TOi4a1R%wxo+nU5F6 zt#$Y_zp7?sWp8$PR_jA;Iqz86t<_~f$ogv=LWxOALeZ%@h2U>nkJhxD-ZH5~`sH3I zH~D(GMw+SML~&9v5bUXtr16Pw@Yecce(sE0w?2JhF_f&=H{t90${upd(tDY~inxR? z?K+U@=(pD&ti3z3E@}HfA6uWWN6OCtkoH!v_m>Yy2d#qraF>en@WD4fWCuiR}1qX`Fuuh##Ij!Fb#PYHc zOx|6(rtYBiAH{odsO<|~)pW)SujH2dg>t})>H9x4(lnsd z!-ixb&X9ERe1u#9WJ_%$GHaE4!~TkhXrhgeO4xUi@us{<--Y@Ya>g-Lt~|ezeBXbc zwrX~)cv+y2{z9v=qqIZP@rAOgD-#b!Ff=ieH93xMl5Sg%YU8-d-#w^PIDbmIFx5K^ z9kikGDCtG03?7=~I0Q2+sVD&M^1Vg#*PUN$G@8iR{LbFt0`vqpDGRKqz4%b30K#;q zRgE|CF?my?=Ie@D*(3c$^M_jI0v*AL(bO%jgqwicPGSr|11BS`BIEZ{A>rlB9$I=S zQ~Glkkzu+%A>DS~yK2WWK5CE=YM45#6CM)`jojVbzm@--Zg*wYALWD4U9cx+?V=|= zG4*jUCrtTm9lRPi0WHHQknfjR!eCjVsQTAc^gxGj2K zye_7PE&qvwn-7y+_b6p&aP)d6h;JQ8Zaj5$X}~kX%v*J-->teb1tn9J{2prWPC-_^ zd$5X%2LWb-6nAUSTP+zkgwrxf8grp1StOn#M>;n%_ohjw;v97O-i5;d_b+ z)6kGsKLgW@a1nu@S}g(;X7Z+)UIBfCT5Bt!2<`wqu?7xSQ`HH?sGI|OfdCd-G0b1c z(C*8A7^83NJU#2Yf`%Yc%uqE|CYDEN-BLFr+1!mp5Ocnv8WerYM!wQl*{?}VX z598b5g=yJfDg@UxY5Ug%#hdh$XBHu$_8Xdl`s0F0a!m=otpaC<%i`!tH`FmxB4^vS z0w#5hAEZhwd_ZcDZlqfH;YD=II-XnWUqZHD$hK&Lu@EaUM*a()3A?sFhQVt`Cnh(A zUKh|h9L*%Mx6R*YTc@E8Q}=;C57p3f1nwM$=3)j^WmHLD5^Hy5)^q0j{oB-8#@=+8 z8`(?k4mX<^c7)$y7s~{PH6Igf3g6h1SV@}n?Vj37Y!?53 zighw9ZQ@PgnW1fyi);7)#z~o&O#zyIjeOu7auRc_Fhx9dQW5Rc!RA;hdrDoD+p~DW zz-6@HjkWUtmtVtWb{|#(KdFq$yn{XY-HG+;1%7+*{bX3G$1{AWX=+BLZrr#3{f;ex zdf|KObL?PJQ7{8g!kxmS+pVHsAlY4~uEtlkkXCp*Qm+pFQZm!dKS+K~k59d#`KzfB zsP!_*?1D)^on<-(2tB@u@6c+6=e3?RB-unXw4S5BUct;ZhRzYIdl#Zn0Bh)0q;jGW z3hML%*brtEGQSl$wb@X(5BI3k9~TkcJZ*O;>8Z-RlE{n+jklfep%&5A4DZxd4mBJ) z%Q~0F*TdljwT}`Cp65HgPebOM1EeqaBH&*Q;Q%??hN44dMoI3tarB)DZc&th-K>G> ziT$R_zt1RAEoI*iJ`EwF$n4_BS8E20WcenhG^!_}{4`F(B!+8#_%M5hG#X6tEq3wu zo%GagU3_|MM|OHUZ_2(c2$+&_^*QBRzbKt{E!iZ6eGg>5c7k;b>4rrzUh!fo1r%d; z9_|C|uN(&EJM0&7j>x&ta;*(|`UwN?z3WLP5lRAgbZ5@=NPK0>GvT8I#fnpW6M=Sj z4-JS|bDUuN*DaFzP4qHgi>?Wo>QdVE}M zt{^kQv}+quX>jk~Z*jLe7qDD7xRS~=|5%222YyCl+4QgHusn7w)d2}U2}1(_K{uHa z6qij&nt-Cx10u>VzR&^y-j-xY7d2u80RRvrG6F3}q#mA<9LaAFT3Y%;6ONw%3RglF zf&;(+pI0gX>#~FCfXy$7L?eG7572@SfE)$H@MDzm*Aq(mf4KyjHklYI@N(Kj1iFkG zr3<*YEF>fpy=TWeHjfuiXs#@=u3IytEo2n-c(eGRRXvPFB`ud`xCZF1{#JGVZ~e{w zTYTdlUZqoj1+Sa_j_7Hk3}bST5lt1P)_dfClf`#C?DZGQRNvgo*dWesXpDJKOW`&# z!us>Dj*O>I+|XB*()*N2Z$Z=o-6Nn@_6})%^nol+ENvspe;R65`cRkWCYkYbN|g7< z>mPyQs(?-0mH!27C?~O7#G5;oTkd z>)X{eqIGeEk}1DJ^Vkd9rvCcq>`igCtN3{T3Z{k3t~>!F?9qvFaA0o+VR(}u;qu_K z&c!~FNbxX6T_2@2O~EHaP_fYAJa!Uv0Tc;_owFD}9{8n~H@nAxu?F78KGZ_zcLBY? zJ$!MuzaX=!ic`s2Ds*yo?wXS%CvDS>9^tnlkTbeIOeH{f?`;Y_OCCaTknG=*j9P}| z)b7@-@zP>W3+a4|#9BypJWI>?(nU=(I-!C~!x|@6sEZJTx^Iu?nN%U0;|83iMQocK zyo#a*X&+=i%d60VgtX|u;D5gw+qM%pdfgC;rsuQMobye6|e8mU6p4FIK z2#>9H*Y3D;QN5!gujZ7N2o<|Vk>Q~_NfiG+32&@Fwp-3&tKQcAEyF;XTX!8mLrrmU zNv1)b!;Am$COwacLCV0K@%{B3o@XpqvPQE)(jyn^Qm%Q#ak_a}cz+eG>@pzRN(2+U ziS<~1Urc0Fbj50a$@jVLi&Xb z&98V_7HVZXXXHmezFu@g|L2#*XUUATyA@c%eIoF424DKLdwA-X2YxR(5S&}zJyP=W zO=Pq0FDY)=7Xm=3++%YQaApjV7k_%f1{op9gbw7n zG3_ChW2Ho)bkaZwyGnt0c=VFY9Z=d9DmmtdU|#9IRp)xHTEEE4;TY6;QHnUz`WWz~ z#Q#nbnjkB~v>V3}#V?>40}8L}rl)ly6u-=$q+(4}Np%Ia6i4G&@_D?|3qK>)j-c>S zsqs#PNa~#v`jZX`muSCCUCX6a4UhLtW-2ZL@cI~HpMO`fHUX5tK?&>3KIhYorDOV!A=T8sAl1-6AQfxatt%lC^G%3EII~kj)&q?7tLKT(zJxOu7ndOJ z^QR9a07?zV2=aUPq5r5Ww z^GFs%&47NKF-Fv^*{N+Wp(cGM{yCpNjXx-M&N~54gJnhVG|BJ%+SMUTs4D^C?n;mT zeRjK06CFR#f*r*LtmG9*uy!k#9dJg}9@!0z9`AiN|#Kjrcb&Vt|>@uh_k>5 ztH_XqC;B6F@4Tg1_Jtagdvj?gVB8C87GxGf<4U6tAJIf=x33>d>m_L@#f#;C^8p#Q za8otB?RG#@tRn3zL0}R!(K;7rcWx9(7tm)pCjG8uwB~xs#!{BL^`ayt7BauH0=5An zI-!B}U2`#I1_BqFZ3w3XGv83%fw?5ZxZkuT$9(b)^% z_WDLmA=0n7@#U-PBIWX`#P4o0r&EXL0S^Mc_3BtYbuHZh`EE1+ji8O)){q)M?YgT|`<|3^KFACnN9kpG@i`4xE#Ts_c(h4j- zso4AsO;i^};gZ@~f~|%oS;#+xEpsNk%(e?1u%k4h!Ps&L>@G;J78seM)0yDImyVf< z`{aRK_A!_st_^)_;+>^TVU}UW^Q$)>d~K^gwDFMukb`KO2lRT6fglqJvMk1RooRBq zGpjfr$$f9tYW;)SwvkxVO-`5Ovs{n5xtB^hAwhm@7FnS~0up1YytuZ*+k-{=o>`U~ zSbJ`R=GKpxWuy8j|-s54Gy}bvL2@VrEL7M{r6LO`TR(&;*EFxTa8H_~2Lw zXnz1?M8^b*hz0GHpy2svj)C}YI6$hl#q6j1IS}%f1)kp1n!mgqT`vyt4r3}PL7)YR zwy3`+ROEO548{Cy3JU9ZWDvq?Bg!S)m@be_z@Z_aYbclbpyYD&|dX;n= ze>DY{_hl+K$&WpO+~SeRdApNqrnpz{tbzl?Q1JGbrE*BfS)I@zJ$ySx&Zhw2Bh#nT zmi*;>4t!5VJa6nl6Tv!ZHZFiE;ZAPGdNV{W#kh6;93AY=&}D9$zWr3w%u#8;m@>Bk za$prl+Kt9WcQ`vGUc&?ze{Zc*^-gyac<#Ppw*`oHGyK))d1Hzn%NC|9d&;{Xni?|L z_Fu*axgpW#jA6+P5X*mNk$>QzV=VN)^M~?R-c;TKG!Ev7 z36ZeEb*4f)`CnCMu&0|{zqombd`Lm1uBh}C!9S$o1n`c(RZAhKGIv$@;t9joOEHD6 zGs$HJ2NZUQ!+S72yj~QrLRiif7+oB3xB6~5jFIc=#wZ$4rxwcyc zj7pI^>IPyjigfjEPO8LwJ$vX>6j&b~dJ?7y)e zSMz@Id%((jyM99+_oj1J0>7?25iPe+V%7IJ`BS2Uc|vktGhbrU=dK_gj5-gD9=}na zR72Ndtb3KiJhRf)P_?>C({e_M%UoQXi_cGfJaiPr0<*{`U5nNmtbLJiwU_RCCP(*! zlsqNL;2o|Vl4}If9`D~Ri2DRnJ2?^KDdS#&cw3i2mpL0>rFgBof>-EZkS3oz45e*` zVvZT6Q2voj)9S_uIbo#~o^v{?>EE_ELUto~t+##OYvy+j2_~g9A{8?c zvf~X~&*vy%fsS}i%~AOfA(!#1oGEsOawA<|VrH%mGRMw%hj&SAfN5Veo)Q70k^Udv zzWbl*_y7NhQd!x1WJH9Jy(=prB%35Fd*$G8j90c}g~*nWy|VYQ5=YsabByeB%#-#0 zUS8kt=X3jh|A5c;2fx+zyw3F;*L8n9?n4>(L~l|~@hh2X*Tvn})@gT;dfYK!w6ipf z98*7D606x~;)9H((3^M^4BFRof@4Wqa?bIEyI^uUtCkMBO8@5oe;bJE=1#EMdsyw=%60H_{)Y#TfJw|l;z(9P1;dcl8EVU z;oKjWPe+KS4}p{yuqJ($d=PtqT#hqgabIFpyDoP%8@`4?#|XISW}Z}X#4H_`El$ra zt;e*;<7B$capdWbW|?oU^8QR?EwGEk+|IqV(abBgVV2sf7~LkJ6KQd`rgE~p_Dq&} zz-c+;S4-_J=47Ii2kwbFp5TiT`0Bl;wE6s;Dq8T z9L-E38)}_hjk5JBTk7Mh%~@9&X)52!D{uSX@q2p>BE1-yO$ZC{#t|av{cNL;&twOu z%k#1rF)v=J3Ef}}29*j#9yp19UgnK%5ybozub^`1)~*W~N9%GI?FcBc^F8*n&cGB^ zd@BN~J3JSf0_2b@#(MXB#{rK!N()U=H@7d4LCk;7`0Y@mA`rd5zO_|K0^>gmj?^*t- zcfcCuvnH!a5zZxTYOJ*m9zVl>K)X_JolYd71;Jj^GOkW`@tfjFmKwuA zKk?{CU4tXWnOU8ttcN5$)G}ck@we839vHB`A39>Ceo-Bp%-#4sk=FJP$$d!naJ#}@ zpy;`BQ%ja7I#ZQhLKQB=D8c!5{kAN@lwnKY&qXRZt+GD~Voa-N?{o@gNtzvmJbp5i zrs;WzwsTEjPp$1JW#QHYp##Qhv({Sw3e&^oKP&88V-7+qW2GzJCe#779TQV%DCfnf*>h#=Vz zo~G}!;J^ihM+(B!0#5)zc+UkpBrF;tr#D~vDPgTti zg7H-L4$oWb&twv?c_v(19Kl`r8yUS?h@EO zsjmYb`4rw?C3#5%s+2vGEqLl>#mEPMq;E+HH93{_cXoW$;8>LFE6UXoe)Fd&fr9Qv zyr}%vpC14Ru3yrqAuCR4*Feqa6CB@kT7T?-RjpXk2~V8Dhe$2AHMjbA-)^v``WSny zip6dPMTBsiu+O>H;2OKY*oF)5n)i9~TdoG~mQed_ogl{J%V*E2_V@XKlxMFljeTfArxC3=(ldAw2@ zc($xi;C0!l+W#14gF6G*mgueR#oz^ob}YPWhvMlt^3CP^TVDfafDKwcdu|d!0(yK5 zRxNAWqrKt8Fww-5?t^H@+32Djxr;{o@lY!LO4n3CK9pJ~g7}sBs_h6aWm@*Z$uX7K zQ#HY(f=!E?=HJr{z!ByPE_=Z)k@!X%s3?7C1%aC3y~AMq*46uheZ+JbmqEj7`}ThfIWEpOPnsU z;=xYWvp3S_)7(M+7^#YX6qlX=5!IKmiV|-%(qV_v}-k8rAQ1;~5{19Y`)a`Nx4aZ1LlFVpd|ZI!7sO*GCsn2GVghjnhVN;P2S9&m3ltsomn4y2UsHtLGs}28*9{(qxXBin zBVG$_?zpTI;?+tgCG5?5ZC-FaO!`_H{C$Km$?Mm&dk8YvW;T5w2iFZY!SQNx-%kmD znNEo@g-8_jM*Gc{TSX3rB~}6DvwiM9@=ZH#Pu0&}Wb9o3oOMo+@%9b{W%!aXGL7e{ z0#o9{pEuSU!%24CE(GQkWMJGlp+@Xh$9pE47mJHfzn{i)j}wVRsag2V!DSiZ^e8yn ze4{b+NQM`@P)a2qN z#n{)07M{F!shUh?7E_&`oV_<+pqQ#|-vUBi#(O~uG~e;WfVTfB4^A$}ze_W~^HcT( zb!;EGHn9akh1*5mB`wJH%wolQK>cRqA3JkS&FE06Q$`9f%r!?a{H1{HOI3U+S+|^7 z?8~}~&9Gpp#;)bw%Myld*ru>?%Su3Un<+BZ0pIaf4W1CkiQZ)Rg55Ixf}kx(Tw^V| zb#`^3%8J&*!S}VJ&Vm^s*sg>`87c^i;d)(RuVsWpz1y&ye$kINZf876eiGzv-3>>y zyNh+~NI2CWiA&n=Bi^?j{R(h$z31&l;QmafK`pt5Hk!CrYfup2Kbr z2@ZE?|1KB69Pq(~?<6MS*u+J%2s7!B)sr3$_O8J>_Ks6Z%iKc^uQb zgG~Y_ky-||m=HKZj2I8Pu5)XDFrQN}3sPQ0G-3)18|+z3i(t`ztR^@i>tXo_7h9|HByQ)U)LZ7emr`ycZN$7?X0)U+l=4^ zgRVx7Pu;(+(YchJ+et%k)nl&jVyA~T_r-5nFjU6reoq;{>qSqA{3U8very(+C6W~` zg^|ey)Gr`5bHz{(#WN=g;c~;@M*P&64ekqc+=&;t;+^)v!2{z; zyOB-JuJ-Gwd0wJxctJGMM&Pk|<`Xe#{S?WZFMc} zA;b2N(zU0ZI3Pr6}a*BI`~5WdEIX)M|i_g!Q zUciUz0^wab-GsfcokM&+TMihJfXH(;mm??ub;SedTVx3&#xI)UlS{VP*hk-tISEhO z8ESsY<9q*s?`cdSmy3t!L$wBK7$N>X#**Uv`KWjWr9%|}b8K%vq_5q7{!c!K0cDNn zfgLYl)Ox!;dt{c-cYH~i!)ai9k;CyTp~@#@8GE;PW|+C}uSQxA9J5~6Jd*}N<=!Vf znOx7uj&`2tR<%*7JdjMP?I2*55@^7BRq|fyGf6?)7ve zrPHtFqx6q2OyN${Sg%)tOkAX0azpE1;A_6xpO!ZV6g!-TJWuTN7t^YLtHW9CqImyD z@b0bS4g#Fxk|g4YA|idaAa^6-ok_sOSZc!0R9%(&pKoRv%{%*trIv7q%!@UsPD@6p zBU&q^9c{``J1cv0^{xq8+Qb$*{V-3$$@;y9)G^A18l%>u$OP{i#*vj}Urp|J@4luK zM|I}1iuzm;__1+%I@0n%5HH0VVPre~J9gUI3+7i7d*1Sjx`?+avePEcA2@w!I5>IbcbGMvtmaGY!4UB}a~=z^YNBg+L%Uz%U@PuPeMmJWt)9%6CNHrwqez{wR` zk9;%<)3Tz=bZ{h!`Zh`V<3Oy~jDT4JvZ>MMWS8OF2l|{*fsb4x1G(yJadZV%j&^YQ zjEBZ^aw6Zvoxls>*}X4~Un? zQcI!z{Qz)AUOg_|mvVRl+zm@cNBs>GIlm9FJ!45d?yPf^VTNy}W_B4UGguJ$zgww~ z-{95Is2kAXGgF+_M?VIg4?U)Jf=HgVI{3zO=8%go^~?pUJm99sdM3;0_& z8~(E;E~_lxv8i>U!0z})v%U}4vn$95E7exwe3G!Oc!xp`N4&jPzH(#4=ePIJkK0Tz4ss`d#{Bl=GV^D&x;mW#=o&i=O$ZKSUs8 zpSF^+U0@{0YWv6kz=c zX7;1?lR$&jA!lp$u_@jF4JUFmX{$hgHY$ak!K-_~eo($BTIKN~nJ~s1WkJmNchU&o zj2Y6=YixC;c{lOZM|j1=TuxV2mY+|KVD#_GfyK_*UHk#E?e1h==5j!INso z08*X!C9`oUt8VLP0A@TX3}~6~NNzDGh1M*88O4X-_FPK$R%$g?q!UKCT_m&7(jH=eu0#A;X#mk> z6zLcK&n3;)>2S;jTP~6N7d@HXskg~o4~UD%)lrOu90)$b=Dc=E3Q+!Mg^w(w$>AH9 zmbi+%b4i>4a{uzj%zA`P`+;|vf|V5+ru;4c@5i4%ow)HxxxUuc)_|P8X%I$a(X<0L zxS*-(Z&J8R(qIZt{0_Npw=+sfTO_Hk3?>@Li{HBpCxe4?EhxyMOa3&oM=IQNoz~v7 z^xvPE6AK7-Rliv4=KP9=x7SrQS1@i3L7=E}u{|)SQ#$y5AZSe@CMOrK$?+5cW_MOv z{+5DK`xEZpUz{6M&7+cEpXae|dI0uKEKPkc&E=vS|+Y-CAx2nOFX!g22RUpTD z_i;?t`XhpUKovSc?)~?VFl@pXF6~9Q0L0Hq+(qZ=DA8D8_IWKrVf#J8=AJKryztyIz4B56}JM^Se)QJh=g(8ZE2&HYw(Kofa#6{$TO{ zS%6=bG$2YjOL%&=%VfMf$JyK2;%U_8=A5sKk|B37yH{eUR3Gg13*u3647_%)`@*%a zjcPO>JEseT2XlYFPJc1_`K{Yw=I7%T?-hjDBINv^cIp{WKnIWf4MR!gHG~zifx_BwsjO^*rh}1 zxJV+$nE!`!;Y=5M(PTP!GhS`|a_3I9*pi!}6u0|cPXGn@4iQsqAK)Z3blJO$scInR zdpni+W9Y|j^1fhhqhF#66Nnu5ENr@moj5I=C_kc%EVMfzT~3W?@uds(UXF(Du2mjbC(ivysG_Ldz20v|_Nmu%$bX)O z04rYzb}xSPKEu}$y%}D&vPY@kSFMTmkq`cfm%-kuaJ=|(c~z*agua85cG8}wdso$_ zvx{@O^g)G``WSjW`%n6Q)xIF^H1Pph8JugU3(T41vQpe`74AIS7ydtxh1jJo$JlSlX=31a9Q+nPJC!(LCC+n4NjCN zK5oS=Mm`dz+hCFiFIjL%fm@mON_f~N$3kpS~t!Ig#Wr%w$SsiBPlhM!$ zlLnDz6iMFx+`ptycN0E^+ys@4*9S`M`Hu{LSk^5fS~PRt1nTe-_K}< zNLpgKBYZ^T)d_A8YimLGLV>L)y01qDXlt>_j*=W^oVAiZ)jHJ0Qeec;P6bR+Cy1MG z2^@vrY%#zn(oby#($_^@Dpm`YR{kBJRy9ZqwVAp`emO6)XW_o2Gkwyxfg$5`Hr0&Q zt_s<>m$JH>8_%CRf1*1`Xg_i^lVytM69_v z#P}l!J$TKQF_{8Wz-MCY=X@P)=rv3s16%gwDR_VeZ;m3%*y0gPMm;3GWbjKcp1 za$JsEkMhcn7}=0fZ7gyC*&7a=1f&G3@B~)vB~W72l!Ns`XwjcL;75& z)I!-EPF7oKVWA=Ck|?G6qlPkrjlx=qt?EqeoB`((1%r9-k(#I!&6+Dka=(G1p~esk zpGqPS+cXkFOtjyAyNv+9JBX$WF4{_WGI7ba@}=EQlK-i zOG^*}D>!=!qsI9}wsFowL%Sua-bvKs`dW#8bzwzSi$hz3I6m}q&E0u;YrT!?obbws zLy$smvh?ogqYH&i*}mj*&X4UWogw`BNr|eWZjgcXApPSkd^oUMkE)+hNUeCs1g=l> zm~Y#KjjRWw_NGVufrtMgA>uDUg|O$i^mA?OYAY&I+;q!ycFJizP^Fl1yLNkOhi>(eyA*c+w`y30>+=BYhpmd{6F z%EcSU5&|^tPi(xfLQLEqrdEv7)&o0Hg=dNYICw469?CNBQNBz6ki1pbGGZ~WAw$cN z5wx-#tlPg{^hXQgmCe#d44pK?r+(XFV*?@DmF)sj-VIS)j59R(#IHT~*SWZeeEP2t zCJ#o30;W0Y-A*~BQfO>+i+j*a<$iM7?dt>#m_0jQgg`1##BxG`g;ROGwG~=WuWNW< z;-*z~6O=}1Wcu#Sfk8GMd157g%js@3d$h}?p#7;!0h(1Gs0$7_)0t-rGu^oCu0Kht zT~*^+2ok>(JHLr*1EE4j<1j@l@&c=(XD%r!S2J@d`ms%}DiafOb1(i8*nQi<8Uuf( zW>z@zyRO5;`&$AKJH)HBEQuJtsXm6qM4hj)xbAard=iETUr;eUjgK>^tgUWvw0{le zMyadAcvDq}m-_$@xlzA8grn$5U8Fl~<<*}&5(KTzdm3-r8htyaN%{+XsB2e=xvCx! zSflqkbXeZ8Q;N6hC|8E-jUJJtwrIRsU2BoOpO;BWc9DgqKg)n8jVY{8=tW6`w4Qrbc<8oF_H^G8D*9Xi zN8r6jJ>6YsGNbe-A?>$eX67?oRRqMFJ7E^Gul5P{8W?X$;?vMZf-RaQ#-_%b)H6@6 zC(p&J7PG`e@0UQdiFu2~rve8p@4yU-&UK>I$Ln^~{_SQCupV`z>Jl{XKVC_e_e4;@ zmR=KXM_uLf2^?V1!pKD?6sF9p4;}mFx6~fb2aej7M1};=ilj6m=wP?_3j7f@aFdZo zaqN~GOcV9+;%SXH2Rp5q>uzGI+En<1UISe=hQjW1uJ=7__f`l-T15R@V2?g`3C~TdU>#4q!o@KJM=|(_&Ut1TW z>J>G9DY4&%CLMd>Ke(1Js(syv-+c8(?@be8lK~m!OYGS2+P9No6Y!$5O)gSFr1|Gg z1+H;%6iqj6Wa6*V+&Zc++WnMsW5QfGa|;G7Zo?DaD?yg&Vr>BoZ1a z&HH>v-xzGqns?tnB5wDbX#EV(p)T5zaB~Kr!%ugs9hT_gXChx2S~VIq@&T_m$gqwy zA>q|g<$qImU%?|>Rx7cWg}$!!!}WD3Z|6iM`KiS4QIL_+J*tgDW9(WCcQH@+QtbHD$$Xit$Q(=~0kZ>84|PzXd8IN{V-BH5|l zu@T@yh02OiMT2=WW%FO^5w_m8h&Rf%(gL4SL!j#uL2_UBj(zZ{WIwA^a#h?SdH@2< zA>klq4qGP+os6d_qCJ${Zp5O1XHtP?*mgyA2_{afvkV!zX(<&TZ)JMl{?6u+4G;J`DKaq@PMvnoohTVyrpEs@q7|f=%L}-&vvR7 zckY<=UN+9_k-RPiqH!HJI@r3}!tp#f3gqZHDXup^;G32|Jnu`Kox2xon&zY6f!N#c z6wlXLWv))lM?@`4#5SJH$v_gzS*bYD+{w8^yo{PMT`*~q=9Gt-g+Fl z@Fnpq9QWfi|JVg91fo&Hs~t}9vBJ9*Y`A4rvXzjCV9jgFip?k%PsI4 z4nMoxR=0e1D=%bs=MHi|wBAF#8sa{}`U&NvR zLnlcM*M%F}lbWUGhb05%PWr?T$-p6<{akDkxp+!X55VqDJAhQNSktb_EE2q3p1sM( zgx1w2*}>KQZzwKxLwQ^?<6abqO1Ks!aHV6Vb!4N#+t8d|$4r|oSoYZ-Al*4ttrHNv zn}vKg)TD+%g6Py1FsN~0Cw$kYfQP0B25|*E{*4o+qJQ1?e$0!HA)er3%>#;I-9lI3 zjTR&UvKs`>KaW7SG^!#$kOQWQlerwcWSGnu%UT39Qjof~OR(Di!uPzG-8=4MBNwZ- zNIC?!hqtSUY(EtGKTpUBDpK*L_`KSZVc5CImmz2068rMR>Kjuvi43CoA`t69Wv92~ z(>+&9M#9_+G>p^@=$iNvK8usW!wDHCrdtnh3&cy?c-`?$i@iBb0*c2aB9;nwa zqoJH(-Tn@;uY4wd{+yd|d4+B5axv~a($Wd0FtQ42@LpPUYxe6|y}zZ{)H`qg%uf^> z7C2Y|RFejdI0k%DoVm8=e;_r6BcS_pc^99~9?W)otn^6Q>4?nc#r)h%Hvf}<$o8w3 z%GKAw>GL08Xd4!FJ{5=7L2xCQ!>rMwFGD2X>4SEf6Vt^_e84xetw18|ZgHSN`JUIA ze`Hk5wLPYqJF*^H5Z7NSu%olr&_N4qZ^H7dFvX@JlgB2N_UMq)nzVY{;7o4kkGPC= zRySGDV9W&{lRNCCnhICxP4!N+DUsaw)f)!?JIZ^OO27;Zd{3;gXUp3& zwuKUz3iY-wdpy;iz-MduHEl5P*>VvBPSLu$mY=`3Q^P!gSuhUYjU>fMc~fmt_5Tx^ zAzE{%d+chgJT}IbR9);svcG%%aAR$h-ONKt(QEGbbja_#sK2Sl-N3dFrs%W0Bda`K z@o0)yLx{&w+JyeS9?i8M%CjJ;H9IGV;%iaGFqtem8&N8}vzw2zZ4~HQMtUx4K5!2G zmGCKy4QbeNImsr2yBA$z&tw}}V!n}5V{-BBRdsT1!lI`TNinetv38oYbVmpA*qGru zc@oaB&&0OS*`Ge@sBwmvoU^Gd#Xfr)40WSjt>}M>iTGdR5`reB=Mo?rw)mjQgiW!1 z)4LV|x_08F2u>S<`R*|55c2(-OaCH6E#hNm+{)?DN57AB@9As}4$8H`-Uj!dr6dL^cwKyLy915uv)~;SZznBlMWxz4SgTJ|iTFC{Lr%hS2i7Gr>k7lI zF3??MoO=UJJX`uq2Hh(O4H>z~8>`$?-#pyDdFU_@j>W`FSJZ>OJsrHuI-zYmxW4EI z-tA<9tv^V_>rxoGDMe{)Ur&P>0)qhB^{nd`ndhFw(Y4XBCNmSgy(FL3Q{c7S9}LL1 zuj?mji{OFIWXB!jO69<&Q2E;{_8lNbR%;S9OxwvmHlCiN>QU$Yz(8E!YZ#=!4IN(W zZDLg5o#0h%iIR+3+tw81q~F{i*?LmLM&`DI;Y%vQj8SnBzwuXi@X?)5^vvda(fX_pSXph{xmLW;PNG)v zH96Df`0$r4H7y@IA4&QMi;5=m=2M5pO9NX3J6!#ePK=x~jvi=+6=}CYYm!fuZn z%xKcdspZKxW8>+5&`2y8?FxPz+OH=HmIt+}A2`e=k27EF8KT#2!`&zhu<>8(c_f)C zEE>;cOLT*mm|vQP;1|paPlyq8LC#oPG6CHACYx#KNB1h zcmrSG)lTIEQ$|0ktEsKjc$W9Py7nio)=G7>gJ59bkdS4?ce2klLP5|nDvP7;JC3~A z&e6_eVx-%_3!9gBxU5dJ@1Y}JaV82q=#hle*JcX8n5+}nB00>w7q-8=#{NKP)6iX3 zdzA$-_u@uHPu%oLZ+an8(DUU;UTymoyld7V-J-sm5KUuaJw~Ofs>~qwHn{Oj5k`)) z{1C`FgAYZPINLZ|vt*e$@c$TX&cCOizaPYqsFTC6l5nI#Lx5}VVV~aXZHkLK+R=O( z_4~B@MN7)DG#FI))9Mde>v0V(pb22?*Z@)QA%XnZ)VNU#9H+_jtVzcPivwTsn%#9b z32Y8&qE01bC1ItVzKs*wdU+3;&ex*$ZKl(q$js#G4sqLGC?NJbgkj~`qj?<5FT;KjS!~VJRX_d zq@L}eEt`wa<90DnGT`tc%sN`U{GlL)lPCbDPOXM!W8x&t(Hg9nmIjB)fsg`C>iXi1 zNwE2&aEseJn9p&Hj*=!?t(iOjwjd>Es6?MsIni3jtGeSs7S0fySWasxj=^FpkVn)1 z?HQM;3aLkF*>RS@|#t4IG zX@aC!KL97tP>dy&qk{CfSEijw`ejkh*Ye^ar-t7H`xPW{_i8ZarfL)3(etYP8#`Wf`?hI` zDv9FPjUzd%hSjt`5*)?;mrPQB!l}Ks)_;p#?8n(lD&=;Uo#u}*m^Lv(z8s2wcH3=H z5ea5PG!hc}NQ8lnDe{^U1t|f?zl}9VJPFp{3|k)xv#~-;=hQagh_3yj;=H$Spr^8+ zE+s9RovPS9nWYd6JjN&+FFv<%cDr-CPZ|A;Lf+ClepoIxc7tQ-EC}Sm5W|=!V3JGh zOdD;SjbXn}kSV?WYImpu(uh2A;^WQL3d?%<>u4+5-w`;%C>{SuxI8qiNcqDUA;q1_OIXRW5-)!V z%#rcDJ|tuMX;VAC%%FRDe0}?5$krsRW-12s3l~!n4(Fm272=! zvd6mslopaDj}z?cK(Q{)Z%)$t%slmw&+HlWt-dRgvig~)(wA{q;UmEsFAxXDH3S#T zu=t!g!7l3#QQNcFGXuQ;Qgexx?=t;lz3Osq#4aUSuF?Z3?j00c9nKN<7?(t)6Ae>N znq-RUU_bY3YrIG}*DA#)bua}_x~{h;4&%FN>d7s%KG0Sg?ax(hDY31p&~~4@OzfRY zV=Is{<#CGBdpk>({4a7oKG^&c1RZ7=A$C^@1Y34jC+-vbz9cjp9G&%n1Iw}Sc0M}5 zvWK0t@Uum0$}t-CBJsC$fow)ES7e9nCX(JoM+yk#1>J!`jDRP`GOTGK*y@qn{Jd%` zwYq}d0B1kV={0@e_<9gyXa`yy%mC0VG6{j>;&iZ0l<~#m7i+a10C3J zd1MK_0Hf!L*!ffIq*3W!zij1#D`av7II{1&0}e?#aNj)hK|3GrU&`xG-8M_zW_1jt zcBNU12*>#Kevm_x1NIrN4wG=CVmLhsw7uyvCI7~$G#{qN@UBl5I=i` z&o;ugck}U%2p3c}G>>uBCfaLH6WK}|eQ^2a5#7HIoO(0bmopRq*K$N?6yUEn|A8=_ zxH6ryOt^xn%oBG`(7&(QVtPLC9v&wB`J_3)=&&!tt*?9`R1O9(5(!B!{7@1Kz~v23 za0gCOJLY{}fZ4}UylVoTNc;9Ida55v*MIc)-|B<$p;{p@K=~uZ{MVwg<0qS7s(9(L zciDt3lFQCQn-iEw4yC|qqa8-pVug9 zlQ2(Vn_}T9~bur@G^YG}Qaw*NUS(mHwiAVq)-dKmKhBO+v&GKN$q3`1Nuq`;L(C&T>mlZOwNd5}t0e z{J)M_rtdN`$h%ro0qIKx&CarDpPm{P+loy2zV_P@#Bw}jjwU9`pd27_Q)3v`RtT#| zfjbE^Lc>*XH^K|-n_Aa0?T^2%_IxR$mff>i1^pRfu)D0!7;cYawlbg1FrWF$3aG{3 za?$#svC8{?hTI5~v$rWTId}B8zTo^h?t5C%?1C~n^PL8V+h)?jzyoK*&lq*9ZI!^p z|110Rs$K9u@dryjdxNjB7=_v8Z!o!(_}E!pWYw$6`jYy8ap2a|Fr)4w2yv4>svsR{ z@kh6c8P+z};K+-3!whT|J@)S_d@zbvtD+$|)m4GTo>}2*%tqzhzii07FY&c|)8sMt z_xVArQF> z{M%omh?6t6E5m!3YCvjDjbCS;SARDC^iXbH7R6%CcC@}ga`74F;EZnN-!cf0`Tb*I zl{e+-=b_hkb~|)ql488~ENy>XrP8+VIHHFFQD`$0XGDd+gSD$2OG1yU*i%Ub&;W?Z zA~rdw_>d)mlVhiXJ)*Z5%d{Qi2zi>a{KZf$Uf^1(`ie*nh4)2HATdzA1j;)5caIut zks)s;s)+s7qUIo(%~=CVaSC0n&^s42do^e3l~(NJ1lLEk}1nw*BLfd z#j?b*{9f7Z7@~o@p2@lXXYHEtJ!hvfL9~bpm<@pqDRPI4JKJoM5gXcjc`qCt{$cdY zM=udX87@p1tu;7MViHx5?3geUWjn-K7CvgUm^j4bQu zW6o9Rds;Q0f1hwID}7-7fg^0eHbMxWFqPRb@SrbgTOb85hJjNnpFr!~V3bndd`Y@;_ZGp9&6)e;g{^5XrV!xDeP%H-Y@s$Aovl|IL8R zt$+q<-rsRek$9V;b3e~UMXUATL>bj4`%>{nAOu>|olW_u%+(^XYhk!EX0a)C$bp&Y zI^bRn2b4r#e3i3A^94S1d_^}ieo>eCDN5>YeP*5k8I|<1`T$Sdz2ZQWg$Rxk-4cK(O+T9ogMffv8_LnPuGaD`wC$7L@OM*%7&{mY3%Mkk>MM>mY!AGGPK&MNV% zNpEDFjkObAUIRWB?UaW*K9-5SB$|+2y8E!+$9)UK0j*n`-+=rsJ9@EZJ-Ti$ zLZ+=MSHsHN27bTshB54GO#Mrab*ZDhta$;oCB=?78oVZ2K0E^Ugy$pO#I5^A%Sl5H z#u$6ttUdDpN?v4BCWKMBfBhhbq8_MU_hKz1aG?1H^yrk6;tc4z zw)6>x<;=X7u`J($Xo5$)H~rE#04Y$n0C%c_l~@zNEi#;B zD9jJkS|YwyhcO$Ob44-AO(bVNJHpr#W*kgeH=MeFeRT`XyS>dhzeNlCHKlHaiYODd3ssX{v;ErppgvZZtF6}-z>BHH1i_^{CwPCeH zfyne1#?pF{c0_Lvfq7ZYm9qlAzDKb}E%q&rt>9jo&}VNO?mcdOxl3Ie>nuZJB0E-{ zXNMKj^-HJ-7y&v~%>+j?;jjjniaEjOU9*`iRW2fN!VG&^Q|o_#924#A57hWAemyfA z)ee&>7FHtKqrNI*`?sEC7S8f+I*tK>H!2j|${;zFVn++E^a*L6S_yYkr3KriVT4&d z#QHTu=djLm&qMJmXRvg?ia0sL;!R2VNsGJVqz8>Y`@0t61BV721luZw4C z^V-EKKpffU!Nzhs99_9>+F+w;BMp_dHbjbD8Kf-rD)3BrzsJcVN{Y=bW@jVrMVP6- z<@;$S!Eg{lg~&67wU&H5*i{ zoUW%_k``Lf{7XV~)!Exn?kwmKh~<)oE+Rc(BKS0(;WBs6d(nj#>`kcf0@efDk`{tc;NP%V$_5$kDQE*0RiUVLYJSjMOa6-n-#u+OI(I_)um#loqbjO_xp9F1%gHtdv$h(4@$HnRkKcc3 zh`G2kAb2531FN$YWf17yi*yB!|3FZz!r+`vj5i#$tXT}I2xP^R9k#w&yT4cNvT$q= z0jr{WsukQG9nm>VZ1#TOaOMCtLt;7xlf{N2WwN3@ChBqA_gVUHtWZ3%pF%yUi<4K= z1tm0A+P?{){0fhR|FHrVOcFY{iu@L^u~24Db0L|E$6c89tDERvn2Y=@rq;Zc(6Fz~ z?Vi++A_k5fFMnC4v13!Ash44_mSAdc|K%&5Q7}OxF8k}#bH~fI3uybM^!{Wo+S~!L zvWew*JeT0%Osc^Fa-Q?14`Ky+A2w&HdJvaOTFc|I1P=Btsep9}fIywVD9S1O+#q22 zOm>U@^rUl<;vG}#;Jk&*5c4zqT<1M6v(wCPH~i8ehkge-GbA1Ki}0nIqEIC3c&+#Ty^ld8 zRV+z>9Lty22EC}3Q!BL(WF8T$4NzU{l^u+P9k`q^c3tNOKx06Ta0mj(eu1`X7)5Fo zPW3F#i6{3-DQ4fw#q|Q7ao)KrTI!PS{88efLBsb{lS+D@Jr~&5bY&V+X|p?GTr8yI z8E$-po2NL-8SCZuRXBDX(3d)&KyPXJ28Vdos#k9hkWIxsa7XxfGpu9OD9g`CH+>a++FdQd( z>ReMXh8s4`^dSN=yq4Yb9SW9DkWW`1kDtylg7@MeksWhiku9bcGx`WMQ#hNu-e6>k`U$vjLRM*I)y^fmiAXf>Jza(f-Lg;C=gHXJJ~Apl|# z-`khZX36UB9H}}X$BD}OrP1=5AYDRa_ z&8{{5zhys7NxiroJOHtiGn_blLHu)(H4yk{is3*I5KJ+k@MDWQvT|Nl)%vZ9atOiP zO|#=Yqju7(+6DEWHkn^_0q@VWqEI@Gh&{UfM~LkhM#Jk$kc0u$l4IP(Hz0=3jy_Gv zYATp}FCp?cWu)h>iV`-zo{*8})4uN?83Kg$Qg&3`tZ-Bt!<~1Nxk9eXb!kj`Tikk5 zuk@I?;^BlL50xGfoYDhl{xdyjs)=iiUu(!_s{6Fj7s}agmJf6fM^txC+!KvIt*jjX zx&8)5j8kZK#yNI7(3SaDZ*Dk$RZGi!8#{bE-h;w57lOEgG`)L4Y}`0FQvQkMh^rUI z6~<+YSW}J7w^4acdcVIbG}|w9>0}%DACkh9M#YD~?=#iBA$I+H!WUlvLRJk4GuE>* z1vdTo2<$6D6zx&eTYBdflRLx3&`J~a$|o!yA73ivED<2Kbuh0KZ7r#`G;wiHYh?;% zXDxiIbZ8P&jcK0#Bp{X_V>vdV7M%J*Z;y9-&0fb2yd&#TsyU8qK5S}dhT&?wMlkmqBH+N^ z=8JDc2e|B^?V`wfOZ=Y8B=@vf+49ppN=SZ55F7-d{bO0p9#I#mz^9NcRNyU`Jd_dp zKvdn_=yuz_% z`WKna8FN$4JOQKMF_pNxXt$)6evomFbqTb4<|N94~`T3vq^fGO^ z-206Q^X2RhkSj^3;)-JUn@rD_W3|A`Y5E=g28LUoFRrG|L5_)_dffa_k4J-b6xNLFnkf#thL5@ z*8SYSJ057(#o4mp^QRuosS5kyZ$V8^<8dxB^uDf_+#aiz3C1m>tfAT=E{;3emO*ku z{C^C)kd`Z1xvSDRn2uM|u8*ar5#pv`9WhtmwGU*K`I%ua-O5Hu2NgUx4MaiJ8pnKa z5C-z9seLUqmD4<(gOHP~OXeC-Bd{)`u$|+9!VeOCM z`K6c-&x9ZKcGI_AVYlO@oqKr#Zs$*^3s$5|uviWx!#Gwi<4AYOUL7fx*bs44TDUl` zCwe8i7-`>hQy&x+d$@NBc_m+WO}2hU(YU6aeK`x~#FAJ^hSKpTEr1BN{=eeLt+t1l zkC^&)vF@N$?AgN{Ue53Cwd}n|#<$eNizB%<1w}THoa#uJ$Qg-u!U~Z}J2cA7o^OI* z-vW3a3EY9*ZLvtL7>O6~DqsM-HCZmTSh!3;pcf>Y_}HMVU9HnnB;_^lx|wk_A;dWw z322CaAlaD&AoT(8^fA-5R@t|CO4}edS+}#*19rSZvTdxB6(dETcei8hXr0N}_qcTV zyuWTO0Wete9|-s}hAZ3s;aPFr30HW?&PDC|bfWH3pB>rYe444ovj>t2G_m|6#vdVS z&=8=x;0AUjzZ#w^d=R{AIBAKrxZ#<-@W)a7G5-(5<~4Zng-WRpw1~t>x@|Z$(;df7 z9=ub7b{c{66bI)SL-JqeS+n5tV1fqi8};+gzkl1qHh1-_u%GXMYh$Z?$*Gwh1N#U@ zc3@x;W{6t8y4v=~0OSt3vUEV&or?#<>Sn^~Wl2awLyZ=nLO>|z5q~`Y3`Ms$qWKOA zJ_pyGs8J0$l1lOv%um%yn16+$D;=$BG#`GGnJ3aiMTeh5V)hNr<6rOUe{p9S#LPbj z_s;kHQo@!xtQC~crH8>>Sr>QnM_a~BU2xN1=x1%&{OAJ_m28g!B&k6tCU#2)Itb(L zi(+V>y6{VKz16y3Hnz~s)~hI`sh%I5&|xVe$uYj$4*#HR`({jv?hoouYLaf*#zlwp zXSY#=pkw-qP8Xk~;UWBVK2TOmP^lTCmTmme7fa}^4Cd|=Z}Jo8ih0^FI6HzTyyMqaec#unJ$*9dM%Tdq(d1xbI-pxuHy|R zIp)w{`aBOkIbEyF zuY&v@o9QcAR<@AJrxIVc%$s*qXBO)yTu&zoGLcSN$tO-&)#m{Y8}ZVlvt)p*z`1#%|5KIm`lH3(*#XKEXd zag!^Vw!+T*p9kId-@Jr)Nbi#%-@d~nfI_J5&>|hZMOk|;)-YsMuFT+Sask`D7xaJIQE~5)fH(uy z$FMkQJ1)yNs`OTKx`waQeY|WIO7Z5kep{Psn#LC5`w3>fW# zeq5HZz9rHP)kpPuK+zT`siZYJhPP9-c2plq%k!Gv+p<(CbWuK4qZXu%#}X~~Mt4jJ zQebk40$Vb4=5^#oe$m_s?OTwc8yX$1IDrXc0Dvg z;sfm?Z)g}p>70Uhv!1q5`~A*(IO5}!w@ZzNk~E#OeOuHhNcW(q;7SYGSOss0+7}C< zsD`Y6VoH3eB?k?{lMZaB4Y0ylZWF#4zSxTK@pVUV;k+Gx!=o-O25$7@KI(3vWVzml zvRjm*rOTm4)o~F%qHC=jy3@4=9DC_L-&R3TB#4mqhxth58$J~|0NEyhKFm5@1pC!3o`e}n7B0nj6^%!Tuj{}IPm{2@% zf(8zb90>jj37= z|L62T2NUY(evqM-q-1I9pL@R zxu5mi6V=eb34kzsV#!hz9KY!oTPn0e#l5ZwLc3POW6hdE>eSWZo6BVnsAO?nCO)!| zk~D1tX8fE7@nCNlVQ1OhG*$9#XHK*V-juliav?E4!VEaBv}cU($%w9N=^&viDzz&l zlmq3v7b%%fl-$B7Kcp=?Ikaduaq|X7TZ&5Z4v+qwb^3kqQJNN%R05gJ_B^oiM_>2H zq-M*3=i4-47#Kdmddk6eJ2YRRptxf+} z;Ou#aa;x$~y5i-y7U@-&pO6=gx}n! zD}_~aE4QO!t=x0_G0xuKn!r1Ms{#ck{LNJpY>I@imzS9}#in|iGJjV3 zVn|`WgY$%nAyXF||7r7evV=+fTLNXHj>8XhA4T`Qs-$7G@BI0E->nAV|2hGuqPn1<8 zb#LYl{(-0^)4!};Fo}M&lEV+t&YYDXQJIyhmE-+SL4uF+4!IQ$DhmhaMt)G?K z)2RuION#vZLnv%!E#sWxtwhM1J{+mw?_Re7T8iWt@V72|b@mD)ST5h97ti6@q_tmCSTEa%4;q#Q4SPd;n zB;-EmMfiDTkE8{pEP+szwI|*8x!3hiC2~Gns{Q0JjVE5~h0-~CY55xRU+UUSU#R|4 zl&b8Vn(|1~ud@6BwHgwO4sK@(ka&JWrPaNBLXKR%!BqwL+ja*`q`}<@ zq4_B$AFjK#uWAw3X52t{u+MDlW!_8wQ)}~Vo*NGAHJ5wSV{HN`H#pngr&Yb4NqgnR zYg9#3w)^3uK;3m*uIYRQ{2dulHa12Ey-_0|T4(|oI1PTd3L6;?5GzC1BZE7%E@~(0 z-K(DM9QQjP*2&9gpNYKbTR#;Qz}hC?G-7C@HMn|(3~kFSSdz8%wsqz<5vDo<;|+}n z+*rzt057cqfWpY*X5V(7Q%(8^$6a8+HUPwlN#T!Z{kpk3CLoZpKtNztfBb6AZ!~(X z)(+9;d0hi+mJB%C=sI`x_FEf&^g?3lK0PbnfKFgz_57g!jCCSNer`*5y5P*2urO4f z&sWv^Gf0fo#VCK7h^O3DHQbOA4o@p=X&UD$NY~ss7=2LNk#DQ5Z5ub#XB~Y{8}(@LsvjTM)0Ea-a8K}&dG=2h`S`y5M%d8)fms%d z$UOlQuC&8$^je|RuK(T zbCInJxu}c0Y8@BMRDC_cby&)CVBB0EuXz8{!~P1<+il_3)u&~OuEWIECEk46UwOd8 zt*+>EHTMzpZD3$lnn<^bj1-8I;Himme>Zw6ZYQq{Z>Vpn0|zMHV(-{(r|$_85xfZg z@9*>fgZubD*(*U$4xGOCC;V7!=G-cCc7#&SrjWjg+71Mqc9a&z{r#ox<`In1r1H$P z=l(!)XYPNmyw+lFsWPvppfs@1R+QD#E!HtN?@iJ;3o2t}?UuitulfmQ`sG;6Abhs3 zu&jSR?3ZU5OQe46)*WcQR6z^0V=nU`rfk-RT4S)TzV0YI(&{T=Vd1(QsQhFBMF?!@ zFbqf0mHhO_eamMPANCJc>L?OBymZuZEM3jnOI=bKgLubL$M8F#(TE|-@G>65S&5oy0TTlh~FOY+JeLtb(vgEDVxUcG+xi;8+d)`{p? z46FX&^Iz1tPHT!&?y>I2;5XRDJbGoBx0AjJ%O{|r`P^h)?MW|ZX5616VDnS~l5z6e z*BmT>yQr;|N3bd^h3L{65xkc#qL6a0;N{F`K9d;k8P5zZb0P zNsagmx?jAzb%(=@v?VzD?y`i55ASt)?LI*dwOloVPEYBQK3mKnV$V=MQSojtd**fy zw>(=?H}8;q=FOCD+5T3yL|i>tl?adnC>I zYD8}DzIXLKTQao&1We#CYKi)iXt2V!V4tf1Z|2^MPJ3{WpCXb!fi1y%xEnrutdGi$ zgGtD)UcXh2<(Pafpao;P@2Zx~J@FyvKE2bB4g43$?6e^g7FV@43E#dTIK9E!OZ=%l_WD(EARY<^Cjq_B6;uSL}Q0EbYOYK!~?1bZHBb!E?X z7@~yZ1SMTMF6vUac$SAf4$~EuL`jz3R8o>}%dQ+pJ07ZoaS zCaN;gP`EE0#8536Jb>J0i(X$V^vAu<73BD>v7di_!k~Q(^Wa$TU;=V(?j5>i@2xd5a6ANvY%swa0Uxs{8Hg@<008&!h?h#EfWe2BlageDsQeG_Ni8<7G)$mUNvW(<_3^ zPSVPXl|-yzW;!t~r%Ndxl1V>$kMv_bUYs5tiuSA^yM&k{8!mQusUm z#DdpKI@7K+98pF4G5NeY6lQn^25C|385{Q@&@!LkwM@BV4ODEkXOaGZD*&c}-;?X_ z;gI8OLmwUTvl5V;nKy^+S#rdi%PK_>nO@_g2^rg#<6iOKu zEWuo4J3X)|Z%t($AvDgBMUJ(f6qrE`k+59jg0`o7EqNrp3F~KD+$f=K2{Ccm9Fos7Cw)IqYP(a*M;*z;F4Fuyy*MAxLh-jmQBmu<`HEz;N6I z{-x32r2eQm>DA&Z@_!&Q*?0eeEJsLV_I0c~B}|bOIZ72)|*kK^2Aw zEP8eMG?cF`j5;isICVUfM9NbAyto6|w))nxjEU|!q&E=(|mIZVTI zO|E@E+2U_Jlh;e~bDiwt^vi7Yi-|*5o{#{eMI}crp8UGt-zME6$;|8l7eqZc6>B;7 zvG$XDuJn-J9)~~0vI6OPYtvUax;O}bd^GN-nMnwa+ymT6l=Nz{gdHIMjEKNzb`Hqt z-@eb0;q5Ui43Ixfxb@F=Jf15GL_Td^NHK0j2RY)euSt9??{5i4mrghc90sRHX)h>Z zWm%PNJa*I-X5q^=5s}J^gq&Wi+z4XuLDUF^FU1n-!@-zFV|~K(V>2}wt)7P}2tGb} zeOlw2kJH=2d-FdQcd~D_h`RpJS9Fq^{dUYEYF8u>ktfxk;|JE|o-00B42YI`vI@V# z`xkxIVB%I1;25g!7>aQX?6cQN`;Pkk4Y7)S@fF5c4@uHjOj#T~+o5wM9sVDbRnAl} zZ?LuX2kh&cKD*V^>-41EjnsS$GWdqedB+8?*5JtgEU;n8frSBroXP)&-$)D8lvR0f zw2Ntle`~U>G^Khg&b_!ds1qwrSow1++SzM*Guc-dxZBfz5AP+daul6%q&C7^_4JCA zGRHgNR=*l|c7D`O8&s2+L$H}{4Gdum7RVp^aFx1}8(!=reYZv% z?FJi@4ta2ebD^bLSCyT?jUR{7jz&{Yh|p7c{Lu;j}?N z^Pf*oFHwxHALq`as zn$DQeRO4s4tui2ZKZE{PfK=i)IOoH(m2}Y@_gL*2F)<-31yS6)Bh!BzI=ts&lvcMw z4`JV}%MU)5=oBP3HP^7~#7!hx;e3NVy#D5Q-|$LhTp&2#NN;Y3+P1IKz8)`MQOLee zO@Y~DEqJLF%+N9Y!)OhZi^0$OrlDZEO4oZWe6J6RvcD96`DqIJfB1p2dd5pR(K=lt zGvxI!UFj2m%^$vd%(fg^3+f~h@EWaOMDhroG42!bOC{S~E|h?KXo^rwW3RcUcpy%+ zaqxs`whb%^Znt}x>%W>QqOZWI`^26aviu~T6c|rrSp3+g0(fiSvszusKjve!v`5w| z`A8)9zbCKbXSsz%WBcW6mE4TLZ-M;FX)%@DoE^=&vpOf=f>~mIR~)aoc*2IQ_k5TS=^63QkJ!miXKT=`~L2StR- z5b;x+o4Je7S=3ZiAx)a!&nBA%PMX+uAQDgQDZfv6sTHv8e&(fFY3WgBSokQhmSMK-b01s-0}ke3x>I)vX6b|$SwDJMLv$<- zFr$td)|Kjmf_x+uh7}EAdhVp~lM}gHHs{J5X6ShiVuYqNlYXW%b)6x7`|_ zkegg$)wehq2;|N_+|2v3**^+hSD~*+S3BAA%Okm>>Xw}Og80gjDMr7+^zQmK zNh8hC+Ez)TUcGl`RLcS5zB)jFXXNu& zSeS>N$m7oXrlqoyxh0jU%{j_4VLPlZ6r9>P*`JL`yv{ig%BNGgYrqXK>@Sf@cHL&i z!%QKf#SR^T00(2)O}B9U6O_=+km$OeV$C0xM8;)~s-{~kUNL`JO<6?ttffv1iayUZ z-*(P_hAs1ZgaOB=cf3P1Ht`B9(XkCf&{roUU~{*Uywzdg{bowgPJQWM$J?RmCEX9Z z$k623FBMSHmpJs-Ec3vDXoeI+oVaXjP$1$&UGY+V*fj5#o)1z z2440=gYW(`7ChH?B>^4xzcZs4+4*%pxHpU}K#S^E$(AV58iSzyFW)|pbOG8P#pPRc zN$%grHR->{&vO$I6d)B)Zi&zeL7;j9PaTMd9nk~c)S+n*RysNZeCUsbl7wzK8RoK& zlbi6Guw3^7A957eRHh48ZLb3Jz0NLej$!JjxLzf9F(c67Wn_OF3-G%|J-=E9a!%Xr z!=N^724Wu~;^;HDT~nfFQ$w;G*`V=lQr9_bFjmdT;txC@Q)EeWR`wmZd}w5_?8A(G zw5ik8dQ$OxQ|OTU>5pe5?~?)#W#|`Ql5a@Y67BzvYqxV&5?v2m%>9%u)c$zvhxrAm zg0wE=RC)J$<7zh=)3K6-g8p=Gy6w%Y$2H{V>MQxvOtUlN}-dMm^D#&LR?p=xsf z_gQ-;-630|d(392uH1qK#WQmG3LNYJ3Hci;5PEz9dYMH1F_9PktL`DGKbLUEd?dJ) z`>N%ZpHMCw?Cl5sX^fSuG|=!U+;{hPH$}#TW_DH(eBI9ZI$+XfX0HEPWY3XLd<0tK zS#-St^a2hYt0<0ksC?}4p1b2vz1vttzh`7=gH!ZA`~G;mqh~kCmYrYl&F{I;r9#j! zp`{zzSucF+QFI>%g-MY_L>}AdJCa`k643wq_Q?jdIzuhH}>`y+ zD>nU0;u9NRHxvA3?qIdKMHU&`@0jSE&ro=>AETAS*TDxd6bbyl{!c>J;&tzfy+ z9N?fv0zTpkwn5cH*p{IIXp>v@fs=hC!23{3x#zI=i0_4kx=DgIq`k6N0vm7m8K6Xd zKHi`dtA;`!2DaaBOS%45+xiN$BX+1tq1|srs$*>{^lz>^d7tv^4v-D|Sz$*TRTV}= z|6oN?L{ugU^PNY_9LUV^`Bri4#*tX7S<%yt3q)Dd?f=DP%$^Dh1Jt;;Dg!gH=HNVt z1Vl2+r#Vs5FwM6?`5zfCrpFj+3hImXeI8*ntCv-Fi>!ZbA|Hq}#6P!xE?so%ZwW7W zOnu2-HX6z_N=E)>A!8Y7mgoFHM~6?C?~KQs3mfNw7^wQLs1hwM_K)ii7tAYhMmrnK zv9e@4E9MaG{GErOvggQFu6sJ`$Lvpjs{l;EILfyWywPGeBnyfE8Mr6AAM2am_WHUW zR-USk?25>ewHO)T4dI>Be~MMP8zjFbbN}P=td_2w+@dSVU2CS{L3;Puvos;g%$#!m zINgiu2PW*X9e@pf@99#w)b4I7k#eRck|=6wR~juwYJ2)F_d~Pg4`w>v;S8(gs+be6vXY%8 zmO5MVc!2>s0(>fYbFQCk6TxxM$J#2t1u!)>11Sa zLKFL|{Oq69m}B%*bAv-flVn=fTU+|iiKXJPKlcT4D(Lv0{TQx*ksvb{yW%1RzJ3+m zsBehf;Y)ncrK7Ss5(&AM_y)WsKm8%K%wUt>6v2%sY4E3|;p1 zvpWFY^Yc#@-xM;{(bu}~@p5fzyAc6@vUE6J*m=?pP zYgk0t$63oz({!4yb43}*V_CYU;AOakOE$5;CC^?QjWu=IVdNeU)A+%?Jy{F5c*_SW zIYnu1nPECfu{eqA^74a6m=0G5fg=eDbVY=z1*<7xKxST@>Z1mu;8b@CfASV4bkYD$ zTZt{fmzg3^Gozy-7KD%M4uk~q-1cSrZr24Kxr>=?rlG^*0`uPUO5w8(uDG!1eMKBv zKXPRKzJaHU6RJ=v$orF7&>Y9dAR!Y?)~9)-c@>zwpwEOT*f8ja4eULms_D}>*nLTY;>)^c#Dkf1JQb`XO;3jNJ znxo=k=1%yBIpgQKS{(CuIjFyS%!JYfTA21Y+Y+p>E_M}yL@NRso%N6bj|9ca$V0i+ z&uc$`CH{iW$=mjWOk-o%Jvo_#U+aDXDbrK+Dw=bf_+JPUXai%xTLgh|_+`<7l#oZ5 z=w{-J;J#Q^3l_PyS3(Z}ggXTmx1m)2`6~mslT=DLoD|kA1qQ(utS<_=Jir&MO;j(wMcpW)z+m#=19W)|^Tj;?_J%%->1Ty{bxK0T^anEu5h~iUZrf|H2 z)M8m{6A|rJ`3KUj8bF4DcH zr7gPKQ1(UM=7>eHIfA0OmFFJP9-%`BVq&qRxDQ$mNSu`>3_)jV(_%N=u4u^acd64H zAyb&Kb|p*3RUV!AbT4?qm6!8K_#&Q^ZzfvD=8B~zMA72o;joQBH~=a#r6~!)Wc%7A zDb$Vqsgg*zEv}P1Zq3|C6SrxFvv2cvnf(9YCtoZh>6^LbBc&SHB_a6Ozp>?zME=JD z)=IDfj26F55t^ka9fqnuDPT@|?8~&FhSp%{>>00EzYGA`AxbWM&mHb34sP8^D6%l! zyX6H7{kOo8{$agd@b^cl_8shVpR<&NMS4;P;tbzmbL=^U8xgkfuh8Izzo>Zb8n?7G zh%@}8ThzW|#@O50Wo`u8Q{mTlar~={C*q_tsVO2e;L4`F@RHr5055R-H1pA)%aX$g zlAhabgP>qtM8XnT-)%_DeX$wtoKlj6{sBn`1}p9~f^4~VbU8cxUH;32J0gUoPv1oc zNfLP&bu-NF%As_Xiad0TM^D>y;tBN@7|@@6JF*dP7S^dDn6Sv!GHb0l`VyCdovA8a zBR$brZ8J)C`QwXN5}UmLo=W{MxUr1`Rx51+J7;ZzU55el&M2uGbcFo3j{@>L9Ju<6 zJ-iMOdfKqNP68gRRCER^BySq!Hz5JhpexeIK`rSMaVvk|TB5!XUs(=Z!MLti3538eT_j>$1-r3x-EhPuA3$8$ zB>tkvQOxw%()~$?akL;xq6G8@2c6n|d;hTi#kgKDrR3*ur#N(68N~Pfby=bR@O9xe z$a61uN2G@AUwN-^J%8c7T=l)bv~q!)>Rky`-B$~&!EmABR^KOuHo$O$>`vWB&&LdS za3!~NBs|Cn9)Mmpu$UGQLEZww)U**m-udTOahU0GBm5SI2Z25Wg;r{dUxyzH#7RMj z50vQOd+Vruqp!0LZ_lTf;8m@YUCZshbmaw!7K)EPy^hu{=8Ptn^TzrK+Fxore9V!Y z(xfdUPI&`9A6OJAcBp!9kxwwRNjHaywo|U4+Yf9z;kK8}QAQ!JxwcZsvo0Q+->o#_ zz0)}7D<`uy7a#!~REfJc;RYl}(rZTQSAxiVdOW05^6IW$8h2i`+~9D4LFVjCfEdxB zqd4JT(k#XHga&Qh(g??}t`;5Ivo?JF@TX0ab6yki@=ekVdw?XEz5l7*0to^VI1HVt zxL`Ucl_PL&cc6YiwR{VwxN7yfVt<@=ZQB>Sg#kU-+)#_rFiRQlyQf;*bw3xZI-YNZ zrcS9eL{0&~uD+p)1=;ro1b@#?10(#b7HJe*^P56EC1qYUD$%9@N#{j4KOm_B)6@F{g@Hw|jVbB4OI<2Ndc_n2lna2MpEljwHd2&ai#b1&a7= zfrukvua%3DTi}2e94QGJEw;HU@x+rXH#mZOz@jTf@Dg!|<>B6O;a9?1I!99PuYwx z-t4yovzRnGJaco)(1EWs+o+D3t|3;3~7zhOHwsh zr^PmI_r&tu>q_?=i>r!QE{JUw{j{EQ#`h=w+Nb@|o-ndfL>ok6YE` zpQTDi=3viRC)!34@OpwJJ#A7~bV&%8KU$h%-?W#s!=pdN*VxAl+ds#K)^W^O<&Y!- zr}V7wo6C<3b5g*K>)n&V7~1ly6}=UGZM$nV!NeXL3HkmXVxhl+q5sJjhtUp&*GHvC zV0>mvLzlaitVXttFKJU-4$oWCXQ4>1%a%AiGmYG!`U;28rk~TGXBq*b6ywTMeNwB; zWv;E18M)7y`-MkFK?C<}PLkti&DXTOQ*ouQqTlNq!!!~_O`J=k@!D~n@qqie!B5nO zRP|H(WLTL)DV^BS((+z8J%b?q)4_oNJjS5?6l&gNR}lgukhzl?}+^O?mj9L4JE}#2&jz8Xm$?lArkjk&tt3OXkLkh zuk4D_W2S_#7i-;{Ool<<#|y(>fEn(rYg$-(Qa5tI65jicx0p z^7DkXyf3iTO^s-qrwbWyp(UO7e3`F)XCq3OiZz&3_T9YAMIsC?=iI1DI|7= z_L)`)LOdzEn&gZiC4U%pdy45WCfM+@6tSQ|IL6lKp9<{4^vFAo#lEV*LDkQHFO zd)#z;QOPOgXsiMyTS2w?21xzTG7nq5zKbEprRsvRaQ8B4epT-ulK6xY-iHr*qgO0* z`q=36FD7|rr#y2o!>e&jI?Tu1gbtMv3*TwjohG~iMgS`*5V`SSUbk5L!e%@Iw6E() zIQiy18yug$itmWV>H(-%`BIz&*JEn9^T8YjZu@kT(xm<<46WX1qP_k;j#g_#;NM7= zTQvKYug5p<8tn*y#Lb0!){~TR`eft5D&wch(r>s0E_d~+zAQ1OnayXVwXc2mbN8`r z%-TgvM`i6;TbNC3eR>_MUCfcJLACeq)Ym|GugKL2+PzQw_)((B0dqsFnbt$IH@ML_ z#B0t)?FU%#}^S4@5w71LCUO7t8h~QLFWce52)&bqrU$u|VKafjX|32<9_#Xd* zn~1Eo02r93**ToE&S_nTnE-S}HUjPX2;|~+7>`G9 z)Q=MRhRTqi5)Al?{X(G88evmQLMb{l--@G?&b2;y2%{5`XP+dji9;^TH=-Y?p9|Gj ziB-6<5wyxL3{O^ot{mps(?#xOy-`@rz$BNgCqesTC~gTh$uTTjg6xu4HrHcpT4Ta> z)w^(ZkS%Q1u1+9?HekyMg7as3Ed z_#LJ+Dz}|_cZX>&4Sdd#g=iO}TbQwVqX|LO58sH&`Y;Yt3hiin6YYz7ocXNDof}R} z)G81Dp@F+WRb3j^Bht%_wYsjpVdiKxXo}yI5z*@O6&G9N$&56@;k$eqn%JTqx;X@8 z75#8wkA_8-9EuOe-rR!+S$%Bf=}g}*jZl}b7@Hl(H4bip9l=11$oB>7rVxa()J^~F zcZY`#53QCh{Pefl9^_>|S|r@LQZEZ4={Z1ot9(%P1> z7lvR?7_H@;_KwtPCidIE;>r#n>HL-qM8eCpf0YC$%fGVmbunx|=j@|Q-5g9}UKgya z-Q&;fESC;vi*3=`T;e!Xoz+Cx#syu-Hl!%(ux1HzcSXEmhU7^G03q=7ZWN|@MMR1T z!)HhA-N?CqOyOavu|V+>muO(YIE#;?21s3%03d(W1_1egUrC-1&T0t{#N&bL2%^~% zbO$(}k;ON14j%MW(0sXuZW%}d4@R1+lLNA#58!E*hRJV{=JM<;s_YhXGdsfU8T2dk zAoxcb`Pqu$gl1eC$u5m5cUfcW*!E-f5V0CFZ2erR&ox0-e2Dd{>Z&6;THKwkjy{c=nW#_>{6&{A(^HqSfZF;`@e*{p==e-v0_B4N|`? z5hU&2JokM>MkTnci@W z{R8ox9>1X(V;m765>}{j_x2{R+Bdc4Z76=MrXs$gne{ja7siRXMD}+eK=eoJW<`@^ zclv=z!)F|dS)njOeA>*Y-~`NOt9qN^b;ChnY|k%>B8d_^&Qx_pJm}-`)#JOH=h?p9AdmkTwxjY^A0;E;co?tt+OE-5*|f2N zoYaRP8XxiXtKY}=)9A+bg68Kso3qV*RNIAawt1g|H_2<8LPp9kNtu{~S7S z9(--1$O4S2W&gWj%>TiEhsIyoVOT9u;_8(XVU^se{&pm@vsZDFEYbW=Y23N-I6a3~ z^@*b{*>vV?>Z*4PIa7{?L~;5Y$ioxIu&%_frshxEL8{}%eJpuE^Zce~Nz$75u*d9D zOTFV7!FzHm;`iI}7M;~al_POwaU6Vx<|bM>@Y- z+~^gZ3)zLwV*=y(dsIYrja~sjM1VS5B@RRYen@9mH#M5eN7$7`>N!EJoX#)PTfz!o znB674Vl&Tq!Wpn7ILy}!*wH)@O@6gp83WEKnxCj8qQjk!9=Qa_u*C+~I%e0gjKRCw zX?pi*-AMP;J45?Ethm91FggMiKChAa@d_wubXoC%9J`~rnw-1|KErbv28}BZjQHvy zxkmw!%NtZOEbO4%SRnfAyO>R$}Ycn@H697&&z-M&}dW9kT#&!bHFPX%b|RXIUGE zhvfc7BAZxisd>Wx8)y1`vbi`&$P-X1wW*`H{o&kXXO(F=+L9(>h)-H9N(sdsUZVfi zjzedhryM(#SI+b055V$aE|yWqo!kY7X1{xe@j?1^^15Vg#WhvFTD+zWvMI->{478 zbJ#5`#aM?TOgEc;mF1&sxfN!vmQ%FUuqsbGAx-FSaS5LL-2+C=DBAQSosHE%&a1$d zXzZTB4*~dVIQ4>uC;vjo+nQwvNiz8cZ9zLz<;u)duO63=3B39V@sz>QLHv8xALB^3 z=LQqy^KIsQPZDB=>ZYGOd9(8@De~$Db1G1M^&VxauW%%`EQUR(a^D!jKLb>i^_QHg z0!FkgQVFlNleqd7iaVii9GHzSqKh-U0ssPTypp|6E4_M)*q7I+~87D5Jn)7!J-O*A-rvB?|gO^;tl&VF$ z-IAhLPQiSC7-eY9hRH-sr^_dFmwo)2Sx@&(6XF6c15LiYT)8*agYqCm@%H) zKFZaJ7e*Y)6ZdBq8YLdB9r2OZ3FWnQ|Wuo1~R7ni< zF>ilq_e3YFh>*7mA$ioB8d~EDYrQ}$+ zC{ungwM3(2Rkm-UV7Dqu>(rgAai8!O#1TSk(o%vj>Tw|QfOh6Rhu2Iv@Q{;lUa~*b z4kipxdfL}b0tiI*;=Bztsh~uKv3F0{If^JBhRKq@eKE-;*vd5gcHI2l>|$s2i?ybT z-clHY2V4m$9A@v@YPX>G;u)t?bW6DxQ*f;65$*_-Y%Mz=3yOMekF+kzGfW80R*}6I zd`Nk&x=zyS6OJ-k)r2FL{(;yRQ8tck#DA~L#go!+F~gP)tsv?5CC0}QM92LyilS!g z?1CFGEgelE2CNfllJ%26tOlyf(o*mFD1OuSNP5;fCg}aKp|Ux;hvftR0p1B?fb1Sf z5;^yN>FR%9g2r`4ntF$teEyRr#WL8Fg{?CzcO2Z)}& z_RhNt_rTQN07PXChrqY3NBoiRNkEQ*-qbi4gpqgERQ|>-rWa*4?%$D<#dVaW+db-F zo0wi;sGK-fM#fB8n9<{9rRH|`odzb==@hNXV>dv z*PrO*Q9a&y{-nn#o=;@B_9IAi>0d^Ik;kdH{|4JXkb12V1|kU>+ZAc3_BMsqJ^k+u z7M}T(eB$?XDrd|x2s6CWcdT--^BS`_Y&|U-Q$&nEDfO{LlfRMaW66Txv?>}hT)tQ3 zl*XGhNf>3Fc&|vV9k5$F51lZ7w{nIv79c5pLzayX!T?5d{a1eR+^|>OaC+S7L0E#B zS>*wYrM9!g)y{>K_?}ED4teiFgWDQ2O!o=_8?G(`C52_ivvjyVy0|`1-azcLn<-DM9)Iax8)LoQqgKitA zruZV}wBV~H>r$TUt?#8C%K}Guqs}r^ra$`_{RB11)jS$us+zgGK& zDL`RcfpA8$3IxzCy+^@93zC__JDXS%n{T~P!hACEfDkRDZ}4;_`eEP|N7dF zSo+bJDMOY-)sOEw?%XQOhA#6xLC2D%6;p%$Tq4TY zIGfT_1enfZE{XT6CG%hQORrw$>Fd90SAPuq=H~1LQs>g)CBV28XJ1d)^Wx4RR;|gW=btw${-S4`oPB+u0OhFfLPYPq;e0rkk8E7b@Y4F~!;_-m$Ks*$ z%}rkH`;Ub}=xq&LKgPfRk!Bbu+puPRUZ6O=HrndBlRQT8%CYJ$pDEQqLWp*20^>ay zQ9s*<7y|q)lz0d+^~zJVB=;1HxIzY;$eVP|sgm)`SX;VRNQrqtdgNVT&N@aNuRaL% zd5LJKU3}X;B}LKN68E9V=S|9*u?At_`s??O#C&2OMTZ!ENe8tNRiGxr^6XFquTflG zftT+zg&wimZOdxeRW-5~LMdt2*r=fWaGS=B)HS!d#NNG1`40&}ig<0$grGSCycbFO zh3$>z;RtvLH5I*(SXmW?9OJ$!&dzdFcJ)ST+?4@O1Xt~>KuJl+%SO4{ z%V)j)x#ZzP&3+n?SeIzjkkysY^;b@ii+aiB_79{}eaE8ryh0`=+mha@d&**I76=th z*=Sct{er#)@Sei|FW%lWs_C!W77j(K5CM^1{sJOhK%@l%7K*4KDpiOIf)HsU(i4hQ z=?EwtQ4kPN=`|o7lwPEh1f)xXlmJP5H_vm=dG9^mv?{-tTN}^;wmc`Dh}$f1HcL4Y03I7$e^yr*3_C7HR+F)0WuVF&fAh z7Q;>hzI|@Oz^1B94-wNIJp1#XV4zYQTGVsbVwC(-jIL-e=jn7~^>yv3Uo4vPNVxqK zS_KJQDg~Cl_1weONIzU~xz7*cUGk#k&g)J(E$tWajMmFx&y-2>6Weo4Z2>yGL5iz18+}!c( z!RQ(F7G4O7?iiByoDFzJ*w))9IXhDl=gxTD7|^r4Rb@)v<6LY7fHU>PQQhT zW$%tv`wU!pjFpqH9)Hsm?S|o0#f94g4ZBOii;Ipg7$=oD9N5Y6@5b>Xrf?cGd%@+k4f@J-xJD?^)Xvxe6dianCPi|KmH_o~U>)Q{| zN992h*HlHmgI*{VQ1sm_1^BPw`Z4}P9G>+%q-{o1sxXUbPKZ6eSAb4623^v}q`mnB zFiS&plDssLa3)ecPR)zACHxz)f2{UCxf0OO!t)|T9Iy!u2Cu-?Rn<^=&boAp}B0)1*pM!_VTjD;HoH>OU>;` zzb41Tm-*=Ff`9+ah29$9N;4WN-_cXVGx4f}iV-#xES4M#5Z9>(usM;|5~eXDo=3n>p*|1FY(eHIPhp+m@<&>);q z9f)lsFT~NwKENA;8Kmo*#y?A_hOQ-lArIf8cdi0wpc4;B>)-aYl?mDTSjye*>a>sC z>0;q}W+y*s#}s=E;?w5<$qAByaJ|2fqGJRKr_?w7!8!&%1wOiZY*6EDWTtw$@6tk_ z41^Czx0n_l`nCurJu|ie9V$ZYL~H9lt?xxAIi!5W6GMv6^NA<7K)j1&04#T|6NZQQ z^~6pxr_LJQi3ybkEeBtKM_=qh{Gnig%3nyR0i0C4y6G6cHYM}Q$u6DwF6^0JYsx5? zue%VUIk6NLxPByMB#t@pJV!|{u;Nxp?J~q*CJ^x2C5fcM1*AeG>QwDu74uT{%(o}< zVtk6%b2lGh+SNtN`REryLp0XT(=8!L_UOYb^mg)J$aKjHg87M`aoarIl>3gpEy{* z@_%^~D@e#i z&dR{Py6);9Ztn*d?7L@}gmXh^229YJ$elv$#jnN$%s0~;o8qzWr&3?2%LaT{1S$@K z0o*=v+~+%0+3BB?ti?_l7G=JWgF;3j0xG;YF18ZQ=;P4On$hQ?X)v-7J+AJ$Y%#>G zbM?f};B3fp=~MH*oEpKer8S`sIn{!*H|H*NAupWzcjBQ9M#5?xPIpn+>+7QDraQ7l z(tYpi3wo@XRA_PqP`8v+1XdhicyB* z*`SA)|Bpy4hG|p#{FKwTo40N%?R8}IW?osA((7E3szcW|F30#!6oO+b{}a_?HiWS5 z3B>K!R$6j@2-ni=bK-)1bi24I?vW|zXYO^R6QD#`j@~IfgSQ~6)P(c5P|^iTs_$^+ z-9Pa0HqKb07n-G6*cBc^h@+;5dnk2SpPHJKJ*Nx@pZ+Dju2RSy!hQ>aZWft(LA4{0 z=dO8*?$^p~Gs}ac=&KvL+P%neFzP#8TaXyQZ}y>-ls(9 zC0)_*6#-EKm@eC(f41>y_u3U1saE>uq7$I7T%Gx~<`ql@FuDAzA3{>a8I%Mw{W785 zCMV)q$qWRQiAdRh(9u?xKF5mw2zwhQQA?Kv-d8U$7!foIB<~Il*Dqe>vxw4|K6fC9bmU<0QosNCkvkKk5D{_twhkZvR#c@3v{9-Y z>F4m~53QYb-$$%GqbVInrv3BcL;V=C;-|S+S|aUMDwN3+FE7;$U0-A`bpE2oO^n|} zieWlkbS6#VFwmbE;|Xi3hA~g-;;((BKwzNd>@bZ;ZY7G4H5H)p(cPUqe4kFlNIf z{DV;O)qBL7M6)btADpcO#XO2JVYpO>D~GOdU1M)3bMkxowQSL-uNLbdq(sM&cw?yX zA-41xVd_cqKf@}lxgWk?O8e4#q<^Q%P;Jq33vmiH`b;yxlgQstAOf+RMpHU2&`oz$0*LaIGUOF47Eo5>W0Nb!aT+YV+2hvtY#X50amEWbK_k3;aLoS)G zL3!a8E(bcidmn*kSbsVqglywfezSDO+0=@5@Y{n^!T<1{G_cF0_1WzjJo+F9@iA{7 zm>juIEPqwDOR8$fx@7&i6n2?LI*m>sSXMECQ5chANT}KK878yh)&o&Do$;o%)n-R9 z2O5_A&(apyIFzX#AdZ2YDqZ_RfC%B-2Ykt_ktuO0EkeOfr#8KJ%_2(n8Ee_TDfgF= zY|qd*hKLqSm@vyA%t0lKr?VO*G5QqiCM{rTN9iP(G|<10Tzb?<1TDLN&|MLZq&D2Q zjqlg~1X(Nx$FNLMQnQlrg%R8qVII#u(X46zkZRpsg!Tq$Q;zrH@|G~_`=N0Z*|G@v zB7L(zWADHUXN|6i3QCEy2|YtkYpAwi*u_hS#6nwcDq!cM5cgfI@OPUmV$87bPQACT zbr4mYF<5xP2jdFS0=8ubo;b(Wcp4kh{JrKTQbgesbJb@VZ$HU9ysXzGe@8i;4lVW!g;g2+m}O+RjUo@1`t~(!du2 zzRzWyh69FgSauLUDU+xRXG>0%+Ff{<4V}G6vo8lE93qk=Fr>b12seBCJt@BMNx68Z z-pFrJN_y{^$$H_-1pZQC0fOs06=Zv|#jEV~Mm%Qy|PCb1}$ zGJnKnqI+V+#M*(~REN>DPPiJOC&LK8cZj2w}g_SACHYSy+=KsA?jqQUdQVHdP3{m zOVq;@kPw#}##I_ps=rj-{JlAPcwXpdHstqB!~FGhv&y9E#E(-5*&DZiH1?ev7t(Dl ze}zW6om%(CaQ)t~x$MY)Go*9e{k-%`m~+R#euhN2za0>sEaT8IWsUxLn=7`Bx~JZ} z3_oSaaW$W%alcD)G|64VTD2kzTsouNtvAu!aDg2v8W4=S_;0X`= z*YN(~e8_Gq_d(r{K%R%N973l?uo9d<=El+Jg%c1GrCuuuhGpf`kh#2b>Ca z1FwOxbXMjs8`(arYOr-TevX4Hx`sw@rD?_lh3GM>Lp-9%M&N6hG?&EljvnelXj zMaeTr!u34e&*Od{nFLbNf}AE!_2uG_ilIa(XK6=MJHn6e?kNEw-iL2a`Ev~}#5IE> z9}093UDWXP95M|8=v~a)0h3xOhr~XP2*#!V8Xqv0k|^!p+!oJ&VG}`9;}#Id_@ncJ z;wmtw1z856#|(_On?Wzo0*?ena}d>D-LSr>&z2=G$p)-DPY5Daa$^N%Mk4FAlp9OMnSVGge-z(cV0Q*h6Xt2;K!Rih%n944 z4!ilOBTs-YD%2$P+TEL^lQDcDS<~jty0_z$pdZcq5H+$%tZ0T#p_ex%V!EO9@sjlM zx04UjAuV4>0)*elX&=xZ_jJ{qX7S#3Ug`|&E!@jE-B*i|F}t(5VR!=!9#tAK{u19? zSqY|1X?0Y;`*;r|Izfzg(#h35rYuQJiyVjo5q5E3lrZU$p=Gw-@TTS4IKy)ii|LS+ z;_#|cCqJR$nRUOE)U2o9PZ@b@Tn=jjTra_=WOp0A`E7G=i>Ux#(O0kO+3Cr1k!I&+ zO}hxA%}j?I64ylQA7vr#(ed%y_YEHiWK#UlToGtL*mH02mOkh&qU7!9#;QYjAXa-c(xf5%`A%=rHxLbzR@#23WU_9S)pz5B<@B>Uu>QQ@* zx7kPRhhUeh4%Zk{W*hO^wdI#deFVoFq_^j?JmvE|3g#!JUK;WH788YEq=-L*iiXZC z!|cY+gS&7iXYz`E&FE&qXn?+#zoTIAhw3xm6jbyHTkHmm1^U`=j+F=h^Omr_-k>QZ zlwYB}Z(ZhSx}k*a@)TUGKlG%qY%0s$9_akT@sRT&=w^awA?Kej$B z@JYK~-&0BW2BGVB952s9 zAWZ^WK;Dt#amrk3ujGF5qk(WvV`^h)%VIqLlM{x3l%A(YPh87|jzoi73WmmET4K3y zB9X~f8Mhlp%5PkjYRnaSJpCFfE3@UEl3z^zu*&UJ+iY}U`qV(y*DgPt(&phnT@rp`Sipt z_vfL2D+LJKuc;OfYR#T8nlc8Mcc80`xQJctkR%=J2g*MKvy}n;yJt@-S=fFb;^luk zapf_%_YncNNpyLn{SKw|fsb=e6BX}DTcWPMlDfxI8V;A5%Jt|HuKS??LUu)5C{~2q z5*c5DaORw>c}s74KK5#8K4)X5XIGi>N?>p5zw!+Afm1%q->5>(XZ(I+3VzzW=JZwN zVTr9=W@KD(3edu%Px{2|ZeA4fzd0(Oaxz@TckHLajfEGB=HGHEjmfDw+~|p3pc$+J z-aZcDVt8~oZIZHh%MlfK9kK^>eIQ++3Lt#e_KvKG@BJ9^edzy8!MdMy!PVHh`<3JS zq6bNQgc0oTc)VhXk6XtO7=c|87IRy?=qRftU!BGGz#`6Pd4l_ocb9`Cp7GT)!wzz2 zvNhuCq!_+DJV@MeQ}pOBB;QC!1PxeK$T73JkgY^Xhrw}ht~HHbB!Ac|#El8g%aLVy zKbIoq2E>!&CIN%@a-Md_IKpaYs-rs3_oHp49&b#`*>>n0)-Sos_D%Wye~bCG{fW?x zI_&Hn>n2R~copmUvd2oVysjna8H+Q(5;J!x$j>BQg0&vWsc3G^%zMInfBzIE)9?a| zSWH64DY;}`9e zv+9?RcN^>O2|-eVL`pCD_m#Yy*809(p5JYOs57$;zc?zLeyB%CKv0MJE2=-<0M50t^A> z|CJ!ih33lebY}C9kRM_Lujr0*PxbE7Vp5kZ4IcEi#9>p6VS>+?x=V}C)BUPAl3a0w z6HT4Ps6QqA9MT98ZQ~-W@{KI~zl>NhbN6oz9Cc+#f7AW>;N3%g-rJem#_z$_RzbI& zTgll`c1G-DWky_@Px+uk!Z>}n0{bj_VO_NfM{Sp`_`X@uexPmphIhZ*0Ni^>fH`gr z!B-xSVz$1S*9E`{DG|YBsmHmi=k#i->C+Ow4r>Zlh}=l>5o7^!RoY|yD<~euW+xX8 zpo?v;I6bEQo$8x;R&-ZUQQ z^^5O`l-CyX7k_h`OVTB8nU^fbPuoP2{NF(g81M4!Q$}CYticJ@Fgk#dUVyg@ma!?; z=#E}#O$@(6m!SW#ymyKsKoag~<{%kKN*-y5P#A(VqX#Ccnv~lPJrV2Pd*f%ETfsJ` zzn>i{+4RKxJGzD${&S9tPwnVhmr(PX31ej&(YPh~`j-ye55BC(d$g;*;tOUxU6>Q3 zI~{X^6&W3eUsrk_OC4z)ygqM3nt7?(>+K?pcN6+^u#dQm8tU)D!dn|Fgr@Y2wTu&y zI%hUe903xkdvkD-<=~IzYY3^wFL`>7a`y&qOpK|fhleiYKrIXM{E;74&AL-RSG7;y zJ#k+;q)U9kAI!%RZjPiBynjJ*A-q&pWc$z{?3Z(HI%kynoMvaNrbC3z=6(mgu*}e$ z)BUsO>+V-@1;I7GlUfP+hNKLa2Py(3uVhNjxk>w=+)}>OUC(BJQ8ZvH^QRMYtMPP4 zqd;zjVDdv(Ly(Lhb%ePbkEEV(*tAIuM5;(Fn4csvC8~aemFeHu`QcwyRkmWwG2)V8 zIr?d*4;J|k^6h0>9jvZoa}9KSQL7_Tw`Ky~< zY23x^;2fu}qj+(Mx$Ry1^ICcy*&fmOzmTVMD4EINi6dzR3ZI-WGz7EgF<3FO3GRYJ zMPSp811FH(tplY&=z3Wg0_9jY^9%S>(v(+W@6~#O^^au$+q&BgQRuiy1_=9b9Kem zTplh?;>G!X0j(`8AENPU15Xo!ZFP_C3X-1@p+~9nhsHwBa+s7sNU&}N&pNlMkQ!?R z0ryXbwicl#OdeN7JX7cR&{Nts>hvdXZr~3%ElRxy4X28cuC!0R8L3n_WidCelxA_e z+kvi(hE8oAI1N<=SRdU+_jnQI8Oeu#A*-_YA_$t)>-#S;8)7`vFddQny8s3T%o7V+ zL|EM#Jv;+(ZrpwEoF^24oTck*R#)ybmTG2*$7ZIdF-c-UA?(mQmdk`t6a9Wd#orN7`U{yb1Mhe}=_?E5L(fPH;?oqzZHD=j6N|jgL*-Qg?ShqoAi*qZGdUq)#=@ z2c}+XxyIF!w-_gRkYd;gyh;3z;%_5z&}Yz#MWjAzn`+LX)_M&k8NOJxir(Gw0M(lG zAnn$P=5r)vow0KxRq-4aR`XZlm3SUmDk+BsuQ+do!91|#XZk#NB2+SNUbgmPlfK#| z4TUW1;V@b#pmyppBDzt+fZO$s=R`iL8@_D;+6Kz`{z4LRp`~7!Xh1*h8t!bGx2K+& z7!>;Y?YrTXn1uzeUzGt;c<|+`%6Qt+I|_357WmB0Ea|Se`+Fd=v|*s30pAYaJ5>uB zXku-vj`+bFS%V)w-=9Aed``1#RzWms<(Dt?D0I#iAKBb;nYRC;oOHX1oe|+WR)jU? z^YQL@w?J|;uNp-S#9zETDV2#A1^#*W4jOQ72fVw8!$Z)qaYbbl1KKrG&>_@*dU#l_RDQPEF58mWuVko}%O3ZxzBYV#9GOvMD&S+%BOW&m zWQj?}4oFx40_pQT^P|g9XAwL{S(Z{n5B|*>{!^D)KIzF^~ zLsuPSurnlsq5vnYzlDeCAvy(8+AEzt>L>Ls_)X;Qm4kYmOU{Ahlq4mL zks6&81p7nf#{BEuMfQi$MDE>z@XBd65#7%tJMQ_ns>9CKOdWNlLRx3%*AZz|hpHg% z8A4y8H-w zj5@W8kW-ig;zI5R$g^<_8;ZGUR0cfKGdP`ohR(#(s#?t3q{E47(CMxatRh19WREJ{ zOl==|o{&1uBPGu-A{EW+aE}@Xa<>)=P*lZ?(eEtIE4D^gM;-{I0HIu9>=l#>Bxx6h; ze|KNoFz(we578A3VP`7O!#K6&M2IjBP%a~YTwrSOM5!t^r`52E^+{K|)Uup(`Rna> zj6Fvms!!65sPmSWKi$l$s0wPkMZ>%9rEJBV#559X&rU?w#f60P|d*%Tsd*4(ICn=XX@o zpqW4Ab$VLXJUWe^f=K=L-j4C+J5SIa!RGlFasjYxi+Z`x_dDg$)DvS~0{*N}E~h^> zGiq_?+Y)Tzb(A#Hf6Hu_Veb8Bj`QC}mH#aV`v3axbXmms*Z|FiZn+Oc?GlFUG}%5T z_wW68Y2AyNTe^EX8T3DX(h|V)1bLO3PTv}7G;)$#>Wjq2CK>G4t>k_UPV%9HB|#P^ ze&hgR`}M3CwUqdlhxonqXH8YZfa61cX=12 zj^-r+R}d;&`jiOg`YDSa?jLZmDOqoITh&Yq_>g?Ag`2nteXyFrxZVYy?b{tk*|*760iBDf+=KZP4K(Q4NO~`F}!QS{y(kz0Zx!VJInQElKj(o9S-fP;ANaYLVA)EN7>u#Jr?~V+{meJa4nee%WW`cZ^XQ?wmclh^>i%c`hXAhhbp3%VYVgqLE9cc0&DLpgRy7`o> z3|u_oBhuTa3KHe>y#mEirak`Bs;>*n1|##cl{0tng3F_T^5i6{ZcHwe`r9^*+1HJK zT5L%8PM8|tjZs`o;J+j$DOrwEwBh--6>7^*@+`i;#(yzA?8hR}SKDQ=c!^6iA@82P zID6fyZ3;lch4^y10M__U{;zARxU0&mdAWz#)^C-?ZzpHvy^!p^9-1j^UK(%@hdAJ^`ajXt%{$bVum6Fr zz`Cn%@%t$25DT)aQQ{*Ho1&DLDo(<#?w)T;S<&!g*+ya}9f4akXbPU9d{yj@){KlU z{5;DCf2*To#RQ5je(1{HE_|ULa8bU9Z=(>ThGf#q(#scZUm#2XFC`G_Uo7E?t`WFJuM(0Psg4_i*+9w+I zK|utZd;Gl}HgG!k$x4O5Mq3WpXoD&RFt*93^4$SG+Gl`|Heh|hC^~b{+WtTN>57qX zur`DD-zOuG`{xVKNy(pOnQQ;_fO^%uX-G$KKQw?Z*GJhH31?gVJb9I*q!iQm^0{nR z>Y88n?w~0U5kK@tv?7?YUlj6p(DWT@8=KcpVQo-@>#dScTN)}sq`Qgb(VnHC+~dF*!YkW zA1X2SLp!7?%QAU{tbauvUOc-6s-tOc84a-ZR4sm?pnQ6N9Hhi&OCio6rtgqk0C>NN zp^!2_5GxC9w^EI0KJUXjWqhG=sETRa!c}hmWMOfArhCht80cxnsFZBMCpD=CWq>ra z0S6Lk!oc`npJ=(|$WyWwVWg#pF-JHnchw_ccm}Qmv~dl}s=7ULLF%;iegw?42-Q#! zvBwnwHd9iNMh?j~hYjp)ry8))xBxOk-k579E-5@QN1yI5zXq9 zsx1I^tz8FghFP?+qK&{Wb^*y38@~qvCIH3^LH&SlUm+i3%z}So%tCV+qF9TcYP$k7 zRt&-QN#=aIy334gVr_SC64Q#oqHxt1`)d=%pyMMVMh!W({cHig@<05R_VTa;5w@Jw zsuLHStR#{?R9@>*+bCv~?OII^GS)%ntNd}>Yyb}-J{;t@AphO5f%usQc#vFw7sKv2 zvkIPUJ1_F~28&~t4QtEc{!6)K;$A)_=OKNsOZlU;r@_-ClZQ3G=@Kgm%y>SL?6DpF zO>mOL^|3sZP$2QVdPRq8iX^r`XLA*f)f}6b*R~XAipYLR$$a3K8UL{yNgBc`L`sE_|p8>uAid6}?Nt|Jd4HIyAO0Y=HMi04qWlHH6u|uFZ!2DFP-kS`D6< zhSCQ>{MWqzU5z(ORysckYy+pWW9BYzBJLyx&v=0s$6}2mCYqtcXNgVgD^o8rzp*lx z&LeZB8B=ph>`%SQc}G{_qS{T7~4ms38|W^dE) z(n>9Hbduv!GP-Sw{kUo$cncxdkBoduXZ9_w={Dq{~apy|46YNs*t7@wQ3=a70r@w4B(%zb5SLbWp#Usw5 zK(O9cBq69xAbA_?fG9+ePY^f*pM8@-^R0O&!+MKcJVgzbo)SI?tq9Y@g|4?jiP=sjV^YkxdR2IF` znahS?^?@#^3`Fj%$iWar&Z?ZZN`yX)20zNG_@=mRUW6A2_cI^gJ+MR+KR>P={7?R( zS;m~Ea*&X?8`YlcIZtaEt4?tK>b+*jr>-gR?6XXA_FG0ZSSImMWTR&xP{zEf*?0F9 z(G_Q0Ve>=a+CM_7Ct?kO_S@Z~U+)?iDfu6tl;#)uG>VmEhm$XIDRSOII|ZI}IG5Uc z^@{W!w9dk#A54l-$Md2_o*F2b8*mg&Jzst>oQ*12wTsd-KZM zI>#`ZuCG~we2P%m&gP*Kc)N;KlM!T;P^5? zA6aT`Sd5sp$|6YY3{`q`YpAijN(B&0$RmLX3@4*|MJ(?SVIawvM7On1&AjkVl6>>| zlhXHAe@-#8>Fmi)XDAc-K0)=(FV=n%rF z(PJnCcCjnw(1`mXnH`W=O<3W1qT(x{MP3;@b~QPc#V7|j69!tH0{p#HU%~Xz?~!-( zzTR&wWJoj;ByUC9Pc7PsiNy#8OlpYGz|;xh6P-#nBmqRzxJs?49ltXqFUq+02d|=j za)|DFkiSE-8)+Q?(9n+gy;I2t|It9uEkSLj$(2jzrNtb`jJ@N9t>ueXZjaJr{79 zP~P&GSnx>&&b{X6vY(al>ZX>J#iN8E3mGOsM|E*mHSsvb;1zU5Sp=c$R>PPpN@$&j zzvGX0+@G;duFku{; zTJD^WNIzHVRj{Tl9;G0c)0gt7I{mTPZ`HHh;?LD!f}!0iO$+fNK6YIkGnlZdC*{=d zIw`Q4*byyea})PKk2p4zt1}$jz#tX1u|Ak+M0KFI@2Vh<=!^h8p9?VUZcdyCdu8CQ zWn&Cc9O>oyOWjPBpdTNgjL-0oYLqMP2&n$ed5?)$&#A<$PxIMd z@WDb1!Q-GnV`4}IaB%U${{W<52|jy~j__Ydaex=e^TV3zCDm;4nETUpak@gEwm&`` zWwby8twU zZ=8W=m@s1I$=)QlhyET;o;V9KN&Lx-z7ya_U<_`DhH}d3QJQX>fB=${it=lm#(m4MSUD$ja6D|zA^qn^X z#PC9N9fz80*+Qe(#xJqahN36Kl{CW5Xwu?*>Eis%f7$p0VI&sZlofb5B{+f_Sr6)m zzqR23$|1gI(OmgTil%lIwO-|K+0 z3FJ;3>VaD8if;M$bP(cwz)?^jqN%N=;0*p5WNb154;%a1w9y6UCHp?atam9w_@#d6 zF87R&eutH0%*C)VnCi!Cf%f0;_|bl_5Ioh}0%nsi=rZOpU=)!X(4Ud$4jG&+dT9?% zp$*c0{Vo_t^Vv3Mo5C_Dyeoi+gqBYt-JB^h*S>Jl{T$%DdLxi4uryuTl>Mx{w zXz+v^=?Oes>v*DI5)XpxTJHjm>o;w6l9#WSx37~w*1F+^Q=!B8=wi?0%+rrWh23?9 zs}8CVIzGzO8`E9rJ-&DV3@dW!!9{Pq2i7fbt378Qtlhzx<~O8gFdnRNxY@ofZ*!2( zqw}$S^RTz_9)B8N26+=hYUw#@=pg3H*8;h)=mTKh^Pilie+~H}v`yiV53_xF`FLQr zd66Z_8;1_IRdYk(6S ze*6A~q;&({lMO76LO6U!8NC<=@RC4jP9&NT=2L-9`7s|=zBv%~>*am9HN(ohu5Mzs zp}R3_h~dGD%mu2a`H=S6zmQ}F0)`R>0FnNElM`r?9^5hKP?!v=dnoXCiO5w$Z4r`c zJK*|0!Bm_$jj-iX_Fq${bS$Cb*W-as&w_s?J%%5V64*W5|;%5}vY(RblR^Sop8ksScX|zb*4~=*IAh5kiSrFpEoeR2H8t8b}kpA<4YwP2%2-Ei=>U{L4e6(xD zJ^_Y_0X}Kn%6hiK8Fkj3Nx}*zsQRVdvRt1y0B)X>q`q&dRt)(r5=~h_*D8{RLJXgt zrg zyShhJtEF}g@9Y`KN>+8J3PZN>CZ8<0Zv*sOhvyS6MaZIN@|Os=cT_P)mxI)sl)s@X zM%FGxf%f(4x=X7jzTQm4tK>K#YJbDsea$qw%($u@bh}6xm5;a|Q8}w%8g1<#2!nnx zdej@w@_?<_;9+H$?FB#Rb@Fog2sAtb&K@8$Pn9ssIh1%p*Xsg*jG=AyJ~@o=3Biha zy$H8{6JkKqV>W&2sc;0n$Yq(?(uL@$x$|;EcHt6Ck@+-tPeR}*}&ExtQQCh^UKE5=G5z{AcuYM1hW&ci=AR5 zM?9ZDJb3jNav8Wn%i-Okgt-V;#|fkyi@087n1bLVDT!zKF)7!?p`Kf-7KhNzfr$FO za+d<9?6a|3uGaR35|u*?u5F&VCdN;*jk*6IHZuT>vF)R_0waKx9%MHRBq(Cp!GK0% zMN@byX%0RAH(w;iH{@%8u@Vtp&$k%EH`a`ve57t)kz)5J2x}BlD9)}t0by_BhCvBj zttcpwySd6vh%&oWeS>!U@$=XtH5&cz>Ybi3%OC0ez;M4dAV9~21PGO&9eiHdM2~}S z>B-V6+o8MK@Tht|ueAdl5gr&DZua5DQaVK3FUn~BRRVyw8bLOvxi;AS4*5ybdvC$g z<8kqatBlmhxFR6g-nML%`U^pzkHo;3vu1fx%>tTfZS%wW;f~qFO5W7z5XT|GcR}FQ9mx#%iNfN4YZs&*&HQoj5NzzrLmuU4(=jU9$sIluQ0TT$N zXKrMcp=Nd~Qb!>{zQUtuy@<}&C_u*-W0&!PtgRe?MK4pT zj^sgQn=nCXLa)jK(^jA(21;w_x2g7e+Zr5GE+#_Q{&uVEH1o!FF-TxVdes6Z8g-Ib zkS`GtzFS+i#Q|$$y&^8#H}f&k{Dmf7Z41Ff22nTZ3m$sJYJ*-GFU#2j;45hrJHPLW z-bn|gG9WqAfVND+yo-!*Hhx>T#_bPXqq>Oge^p4@ROEs!)ziy-^OY8Nc{yZpZl}z5UMzEpSeax}H zi#$HAB+mZuzlbtP{e8`ob6Nb{X*-zxw$EmNAV1J@l=|ZlZL^H#bt5Wp4t=4`il-@J z)!8Pct97S}#G0Xu(c=W;_IPHWIUH`jjlbQyV)~<#n4%%q39S|{`gjjSHdXU~RiHU) z!KaP2tvTXH_gK~aX94>6+&`4e0Orzl@AQna4-WgLe73BHV`t5@UKCx{yLC%BIQxQH zSq?Vs8{%YudR=4v;MuyQ?Nxd=4=>TgG3Bgiko^T2moes+QGpOzv(r|XlA|A81+ON> z?RBpApR;z+688~9$h)KRD7ZP_WG{9{N$JeDs~ZHv8DC4=LZm#9A}8K<3h zSLq+7Tg;-*bb1!-unj=OO$8scES9`J2Y-}vAgxjJpGiR_V9zSdgLdUi*LKZKEB-7h z`JSmLz6LBOqD>b39qDpgaP?c^IV=Gxgbmy3;FDXP-obJ|Mxw-jYKV$Lcwhu|EzjV% z9pK|7Rz&?i2O^ieeAjy+o~P-MiW5V`?!5d2*5Z)=67ivO$hrNYxQB>+R|fr?Vn&D&p5`a!(@Ef z>h$eu7rnKRQ&d(b1e;tVS8>-u#v2v3voE|x3mQdWuPS3J81MZS;o=hPyg(<+&ngoY#6jeoj}Yz*)ScOu=EBp_bT zPke#z_N!?F6B( zCJJPTt^lt)*RN~Np9I6N1AdKLsKhc*_?h+iba&|ml`bsAy>!=8Q#mL?IQ^f^!nv$; zdkDfg+|ACqouliZw7z2_f~^VL!O+}XSb4sbd^O7;OF}c-QH_FEH9cSr#|xPAPAN`; zEyxY zd1)Kgw#%m?s0IiCwKk^#c+Ja&vQS$nS@0E?3G~+JJUJe5D?qA((2*8=vAdB?)^o*Q z^_o(Wm7e4;$8JVnEnPaH3lIp()qLhJWo!Ug3!c%xWU@Onqd<11OI8F)Q3lxJ@@Uem6Zwwz z38F}wLmd&7=Nh0QWr%Hx+CN3!d=edE^x$&~{le!iEAsF1cPPMRWRqB_-*U)lI;@o7 zBMtQ6Y#DmK2xL~m&6R}G=0q3ltcD01CG*KpONrCoiTO}&?I|6HQ%7I3(gQofOFcll;5hV{dd>4sfC0Iu{%P#S5KF*w!Dv@ zB0;(WzGX@4{2o&!ILJmiLN31XT}_%Ts#M-8IH&7)@0>bj$klEjyUe3jkJ~a*OdyZF47Gq#}&ew%ffzlIpSTA8X^+ILY5=aA}z_fj{nqG-{Ci=J8-?DK4t9A zEPnht1$94I0nf@FTQcQ*VbW&C$VD_!tgcpp@8QTZe5SC+NE~tkq%rK2+|p)lqvF+4 zBByWO7%b`RrQq)cvP8@ixV*O=eDhH)8e&PN6;>R+mF({;|Lz>atU_Zeb27=>)kFi& zw-t@YJl#5w-b3uM08p-HWUISSejr#01$x9#@G}d9LPMPIFlHk0AF?WNIHRu3blY?2 zb6xLk{&ZTBIBCcXnXE|i>l z=3iT2&-ssAIC}dwSq7YPYXn8Gl4id=4khDrrIkO%OjvZ3T++k!efv?=2YcI&>d+dk z3d=cO((HmU=X&blt+$^+iKh*qh+ zn5AG2cg=}R@&PYT8T<~B^=GG)EY43AQjOv;)TJ6~au;aJ01-r*3OMM`P38a6faZ4s z>IEjSPc*+gFOC{O z^6<6CMK$f03-5{AMLeB$kl!5`2jNO9if!WE_{9>XXOV9kTlwJa`6B*&eEa1$ zfpWl&G=wDFYxI0JccH#6)J8F{?u_t5`aOjz1K{A!xVNdDNAcGVjA#m;dzDSOm!bKV*M?B(R1b7dsDg^AEc)=$+X?<{W| zhPMfN+8F(M6TGm|ePAfaHNhvuJn(KvN7x)U5H!aCx--2$A%4!Z`97bspMU(OGmi5A z5clTcP`-cv@Gux#h9ub)QdE+C9a=1@C|R;iMWM-_42H3bEa4Mn8x^Up@(&fllK+4$6 z*74F{37R-LI%MmXiVjb^dGNxok3;8PUb!5w+jhuEKZN4iVIDdG3MmN1pM4*^6dM$@%pd}hl;hYd|!MOGM+$P zNJ6sEu7g2&`3^jN^NFVnIqt0+IxP`w8y4e_nAnoR#ViOOPeQ)$e0lyjE3xy=Hq&jcpG4I_&% zXll^w#UUFYSMBtA$gqpz@|U?fUVD#4^wCsBH7n?3P@|nbD{$c!w|ZAFk&0c*lsIRN z_b8EO4Ai!kS1wN9OzL3iOd6NysQEfmJQNU=jp|5<477Ju7l%dS$mgex>pJ-EbUf=bB+&AihqJ z>iZUbjWZM_U`c;IhpOYEGO)yLll#>hUQj+`1iCx5?7E4p#JR^pa}Ax zhmPjbU)P9A0iDXjYo11}C4Yr)YN4vhrg5bR5m(p^^7#7rsQinu+XZiUI`WeimHMq_ z?*(D^_yBDUj6HX0fOpo*f#;!Qo4ghdqJ6HL9^~?QbHsK9>GC(k>eqDU-;m6?e3e3n zpglcon=r+h?vJChgD#6WCwdlk{%8@9r6>XUz-C$<8Y2b|%|S*dB)c?Qouci;D~QCs zoO`UgwNg0XhdW~Vq#7es3}OSos0-EqZ;0+Lj=_o}Sj$sJQQ? z3v{3$hT0++ah!a5il*;kZZMXsh5dNTf8=h97Nu%+BYt>VWmK3fF=`6X^UHsu=UIf~ z{!GdId!{n8dC15(hRDM1>B7RYmdFlQ8Qicfv$iu&e81W$4(-hHfOOgnzGuHIz$iKr zNGARw?CM^I07YGtGc^zht$a;sTnLgwbm5YN5Xj4)5N8-YDirQNUCnnaWz*t%a?5)i z(;KLFofyjALu{LFFJk#PV)N!-Oas`4OEguWfof`DKstdB-H_uHXcP-F;vP|_i35BJ zr3;V8Z0%~Pcd{=?C0=g|cC(rvs)O%HJAmc-=^U0r82dQrB&X z-InLl3es(!=xRO`HaMXYd*aQJo0pDo!g84N0q42_Pke@E_Bb#i5pz!3>{QEgqIhtR zNc@CDk9C}*-;uq_8P3h!9mnloV7U_szw!IX`S#DBjIOuqV`7F@9uo@u9QEP>ia*cl z_={SVVcg28E)3q@=mha2%KehA@lIz+kK>+%LD$`eGY=5_Mrwe*7vW0S0n&zj#0>2A z_Yw0M*Hm1|tNzo5qh*%IZ|=CW)GM`;x_TqGX1tKbR63bF| znNG!aOl$Ve2{p&4jDb%MotXYTY_03V1UU92T`dUG|-trrFYhvhl!Vp@7Q3qD$ zy&SmDCwfke=%hm(O1w)83b zA&3jrLFB6XZ0)b*Un%0*_bV%I#3BZKWdH2P5vuv)jWJ z^*_4SKd9a3Y!MPmbnFapesBUBN&%<_Mx8XjYUuETJFPu~yQjTZIpooJY5dQ5jyX{a z0L~AR6(YC32O&-RL{`=TSZLIdDY^u0??bZHUAf_OIn9??SYHl}EG|7c&kTibQ*j>XtdyB893& zrsF;KWG~jdKz$!} zz=Q&IiRi((G^DBf2CH3B-tw@1kYBEOyyw%j=c<-CTtmRG7IB0oHl1~+wD2I~65*%) z(aI-{_n;{y2y|$h7?4|wVi1(4jLR)?h~?A1KpJ856+7(AFv*W%q#<^%o`wYNft(5K zJC;A6%B$^Da3FxIQpHPA@P11N-wvVaw~G5>(xwSPeG9RAWD&dbg(7c(>NE`+_!hA2Z%(Cwf!l3m*FYm9pQbbs|HXTiYg>BENO6UM_? zl`TP)D@Xxq3c~=DMb&`)yr(nJW7%q-y}t=ow=y`yI3e>iN3T6y3I0XI!?0BooJRHF z4_dD~vZgcNT`4;=z{??N4`EEMoE;D(H3mDpDisO!ty*C_l~`4sQlj3a-Zi$3kM@OA z|Av6+C@Q3N2Dbe9gbNlDTYu22zMlBfhu~fWymaOKRA(7iL26{XkXz0~ospUs}88IPk9#Koujd4W7~M5->40 zLUanQ)8FWd4{2^|e#8a3wCL0;-BWFTtWD&-rGXGP+|1C+6eO9W zmbTGUUoYQNEr#hzQfZ@NV;>4qGMTDESM1ZmY9!F8y7a3PS+87+Tinn--p7wyzPKI# z(N@EbzvY_g`=ZaWkac<7@F=NcjB+FIS$e=umj(#OAj()LFtt^-y1|Uc569yuaeG6xzMH1Y z6hNoh0-Qk)2MFJIE(bO?#^@rrq|o_oq`W}DaDQ~DqO%V`YT}h_*0g#!KqMq)>zEgI zr(SL}fc^#J-Pf{;sY7#l05BO}6o+)V+w@}VzAPv* zk^KZM^SG`D%H=eJShb}-s6Flc)Dt1Qf^==}tULm=7tSJHUCWM{mMi|ql}q4`L`tsg~inmiw1qdL}t)`M&-MVG-7E_aDleDA){4T8mys_~R^xO!- z*>q=f2&+M$e%M8rP^`kVWyqf!Y<{?}tYA)xO4=TYGD$XV6@35f)5C;HZqxIqjn_3X z6bahP#7M&O2>`e>XG3(N8N#-tIU4-|lFByFhdDolVJf1XVhmC)br#M=uGW{HgQodw zKrSV|FgjZs+S=d@Q3*y5_2>eKm7+#gphx9`;jEotZW+Nkd+codYdFT$cZdWwn%bv9 zw=;!qv@=ph%QYS6fSKu&Zzkl%@U9Eaukc+Db_J(kw3l-sgk^aUu03K}Mw0soaKzjk z*zal$fCzu{fX^jzcjF|7zS#PO4=%s;-6d1iq~_Zjp6lqZ^$P=^A^=Q=e>04RA`weN zAc%Yb0USY;H`N0yy8;630O&bdLUE48D3QN69PV38HoWt;seEK)f_=S3On~<>G-biw z7c;!)j&02+5q9Y*Am`fyv&5C+8p>}Un9ZqFZ!3AoxmtaPBGMp!kUXAUS@6JkTcqc0 zPpx+I7j7n2$l_W3?rVncx@X6M8VzhiZ3n4fiQL}YugP(N4&R~YGQ`@B9j)%?_k#M2 zs0xnlS*EpcO-2hk@cwYG4w4qM32KN@{$1|Y*UHXPdWq*U@6BYM2`fYc5@OYZEcKuz zCx!!D1yu5fOPLhh@g%3d1vbO{GSv;G!5Ua(DdZo)&`F_5CSJXS!%(^YJH+A~%c9et{-r!Y?lY zI3Zm^&(;+k8NZ-8zhTs??)iD#Xz`~;-3?E}GT&@%suw9y%=T8p)M<_ThogTh@E^%y zQ>^eNu52$f`EHuQk{1A08fvH#nLqQUPbP`vxtL!9nBt*zfu1v z@zveye?`cKaMVc>#q!1P5m)@SG2$nF=(god437)-U-EjVoyg)rlWDm8J#ISrtY#)t zdL6wC1lMm}CAQiB170g)s}Wg{;3V`MsXG$mJ=i@JbX=>=t&Yz~8l4Fp^*$>dC@v4b zL=_{i84R5E6_Ma+KC^=Bh|jA`x8T+kOZat7GcKWeC>PLRK@|C^xk!fCgw60$iCvq& zXVi7pk9UUo)wzeIAeo^{;dZ~Hur4?#YIh~|Id^(|?vOY8o8(1SqXzMiPmXb!@b`E& zdI@&sqYd__oW#DD0W1tVC`{z=jM2^~(dZlic9hy#w>0`Q?Gxuh~GUkSwp> z7{wbrk9o!<^-Ob`g~*pK&Q0AT;tr#uMh!~RxBJ#-Rw5?OOBqXiX5$Wa993^4dg~$1 zFa{*n?W*xl7qv=s6NpL7?<;{b>2~(`Fe0ypVjL-SmkyO!Xn0UlZ2NvoI zaZ(r^D<*g~d3R#_o+EsO#QvGDtLK75hLHAjei1A1cmlG^HZKZdkBnW=_BGkpPbPPh zOn3LdCMq0)xVUeVLDarQ4y>(_AIu#n>*P32cz(X^l+Sk_n&0g>Cf&JgSe_|yGE$t2 zeE;1@V)Ub%|79uUKz;R*#+le+ z^8p=w;akn%!K-OL*2+(X{t6;KZqNCo&7Y9-8yH5iy(gzGHL8(mx_xs$%EBSi4r$KG zXZ_c|YfXzfaq}k)iDJ?yCXCA-PYKJ404cr6{wA`09^85xoR03MrH1Iv?yLEKDTt%6 z5G?8JfI)uFq{Ae~lk6a!TqAM)1_3U)gvvH3%gjb5?mRua3=uix7w{3mqrC8lu6OKP zy?v^Z_O0fdCB;8Egdkf|QhAu`Q>K5ak=9T>l7;`IoP6w7lm`I26qM6%NH{lPf|mb<>XRUU_Wllc;(@s(l%zU)d%Z2#s9?fm1TZ zqKMHAnx4c|^(Yk0ZDe$0*X7#!i)g>7Z-3(+7#iMh^**@!E3QH0=ax&2$j`FKzUQcS zMdAL-Sud1I4!&%y)fUfjXQ4?3?5nd)X^Fa$U{T)!8KL)j!n#sJSm(DxYaI;nes`IT z49wtFx!RTN%S>|dH7_6mRt1di-TVfVrw8mXnx$?UZ#;h|ji+wJPDhO`w>UdcVcOgq zWPbq79Ve*_N(DWvWkZ>1X=t*vCB#j^xdLYKg-^p&Q{>(Ws(O2J?Xy5H`|bwQ7+?ba z6y>K7{Whbc^md89XQ;GUfGQK>e|5II}A3vuz@1nyL6j4?A9R!PZi-&0+UCBf3$%wO-=nKrKhqVEitu7{I==<__iXG$f7w4 zzy1KQ(9>J<4uaI@q(naIxw)ppep6y{J&2L`+7YtAOw{#kqdJJQ#*w*L+Bp8TE?_U( ziuM)4MJ)g?#fIo08N$)E$c+|TfWJU2IJMLZB44Qn;mOc^id#+2r}^@_`0rh+)vR96 z?+IyD+A$^jol#0cX83MeH4t``;S$)Nrn@#humXvZ@Y)Lu>m_H#IKXmG@t4W7mEaLG zxB@d2a?N;@~)als_As^6}m;^?& zhhu2iAFj+rE7cLWx3~_RuOIwT|F}m&mpLz-S#RyT#>3llUJrfJS5WQP2Z)`c!GK$= zK<;~9Xv^>U>_h)oNIOu*SAwPN?uyjcoFXekGY;0_UCv!{CfQoo+_ccWOSmAy?0xTa zi}Xz9On5qtjDP)*F!Oy2zQ+lti-|!32sCAY__1@@0ELGX06&|dJ5wHqsUnRM?5iqt z-|27U!hQ|(I2PTR$zBUPrZ?tkz}}@Ngzx6uJwC^{QUgftXuy%FKxDTkmqA=|zP##h z2=@N|ysZgOx9_+ua1VXcauC&>l@fxkvj6JY?>eR!JYZzHC&J6T3Hw&l^dxL!VGkDx?E}q_zU-M@^?mL^S_?94ero1cu2v*D)^_HR9MvOliaFa zX9g9z2A-T`QH6?zsdF1Qqk5KVv2Lb2GU>>FH$;Akbr0?$Y|5huzqdUY2&B=232ibq zJXm~TWYMm%Mm$N%+KRaj9$w7D<8)JqT1aSHyy zV3vmRla@dycJ&&n%+YjAy)_1rQln zD-alnU}wgI1+iZcn--8$nsdu5v-vc0;SpNHtp9N!^qtF0tR4C>G8s`VJB{PCzBTE( zowj>4LE|Cq^>vH0oG*sid;nc@B?*!tifW?v321 zq>kseYgJYnfKifDo%n!ki!DC-t5w9uJJd)=v8KfIgYA9emph+BbsYw8d|*4L56#ih z)6LtLhvOEj1sPj&jGi2BE-}AtU)#~gx~Kt zH4}*rk^(m>*}BNApJSUWFUYaNydio&nH+o)top6yWP&*Mu43EN{hxq9fk%!d`dYigp3>SAb?J*)Byr+n&@>ldVNoN#)iVF`PtFLo)|%4%T8u)lH{INY4o_h zXvn;N-Da2Oer(r2YD(RVrZ+KeGR^Kf*Cs{_yT9@XM3EXdh+fD1E6m%gqOTNam{Y?Y zGX7@^691SbPED3xt0Z@uGTO_jPxG!8IApeYux4MRG<$O$=6P}6;H+)3rszTKmO61c zDpc^Pa?59OQGUKXNAz>QcS^=A3SEP@oxO&RXkFuhA8Bo-gGMO-!I(3FTJgT`5FNs+;ka?{&$6v(R7pu0=k!naw~^pa#Hm`DWxx)~M^GxTHkIh@_c= zJZ#s_1~_M5AbG^pz$C<6jdRe>=6zevDfbEo_LLew^FuprOZ|&qnxk)_owFKD%{tXR*t}c(08iFoBk0X`_c!9 ztrZYNyl(O&aDeaMQ`Hy^s>ofP6VOF$;)EpC5H*#7LW<5?*>qJLLTLPGGmPZn{PdpEJ6_&3NU<{N1!2GuDw+kX98`j! z7d4s<&!Z~;!N=N9TL`7F@M@tc<4u#+Et941p4uCZoaYSYs2qNFpN_~1iB4;@9u5|v z`H*pm)%x_OZ;!um4xV?Au8HBjT6(bVa+Nnk7A6W^K#F0&;h0=EwYJA@Oj|;2+k@|x zNVq+KmjAeE?Eb4NLwm20z*Y^NvcErVIfy}s4*g~kx@QveYQn4kgSyb~=BqALDH1*c z1NZguAq5F*$v=(ckbdhbb2n!W(%Nw|U|OiQ%TxZ-zwrGPYWNf`q;OitdNEjFKm~9> z>QA43(@9sCdB*hR9e+-jH^<01eeF5fUWBuo4=L)E?}t&0X7B2w404$9^tbOuqX~=Xzlvv`y(1a+Fxg9lg9J(DxIm-HH1f&W=*RKtZ&sSRL}H3np7x^!hytyUt1gqxDB@^6N=RQNx>%CCllvIqOs{ z+pkIa-akgP47u~Yz8|B%^2~oHVkj0F2w2*W7~TF-x62CAnh!_rHl0Nkpgz9rGHn0# zv{JqfI!g?%?MARz{Q&TAjTVUk6KQvvuzB(Xbm_8zS-VAuoB_L(WUS2zXvdjgW{h%W zWxBt%;t_wlD)!Y?DKq0P0ZY9@0T75-K>>L#3Y0o+Gz?vL8BE=^e9j(N5PhaIgzeV& zSgJa-lE}Fr|D0M37}gS?`Dd#JF9}>v+#a!VmFSY6to(y^;aq{hL77s0J7~ZAUw?ph zfOCWClK{a+`@>fa8`iJXnC$qB+T|F((w_EYiQ_%TF8z3sT_VF`t72K_Z-^v85IcnO zptAv=y?}S9-!{IfF<76%Qu(|lu5WEG{bW;NonL&C5(a|5;NmCOjjF+|-X11w3PW~c zO%DZk2RmY$4on?W8o2KX?+JQm)r(|vuZB;-qiYPq9`%QxSCCdatfsi?>-am>zGF0o zMf}zm$v}AbX8#@o;zqUO*0NJHAykuGp2oECeY!*1??C2T} zpFJPh;yC0NG(4W|lZ|=|5*P%k^BSmDJoPu^*ZKXMv}|caP|sJ(keg?R8V3(Ak_tVjTQ9#Z(-Z2Jy!gaur+JX&pki4`96vHkWfLApnCaYCUEg_$*wku= zY$2%+@GRIKg+?3|4g>l2qM<3nSQ;#loWF-^tWrU8AtTmrsA$dkB!38(vuGY%&2@V? z%|_5c&0{6NBlshPCIBHcFSh>ro({GrX3BJr@i&C_+yQveOCYJaSMw{S_28(#ct!#kpgB!knvZCoK-N7-h2RMTj6~#&r(^ z@t_KL%Wg7>5x~V>X*)A`J$Q?bV7lc$Cj=yIrzEX}BQK-BPnkwyIEX7+x2j&|{#fm0 zjW~Qg%D*SXpw`Ijm63)tM14^YktMK+SOE`A+GO9G1FQjKQV!fKeETg~xuiv8fpIkQ zR@h7Nk%R9z7QIfYi`U{FYdgZk5t?ph?A;xjp&%(Uu_g+%|szyV7hQFZta>$WuMQT4Y2xYncSfB@!ZH~o$4orBC_Lh z2KwSZ@!vV=nq*U2OgfAn4x+C=UT{7RYM|3H&CAupZ7S=G*8Iw3$CEVlFwa@f)(f|H zong=(E0aqQ|Lm7!c~W!6JKKp7^aDjN_bH=}H1qaX(Nc0V9(rTky zRt^9m(C)3ikHJS5l$HdM%G*6Q`D=GCtf!v-b~d+2u`2nSdg}&)4LwIxA;dmv`-qW! z?^=PBb)* zx=#xJ!Z?$E*Vm||k9hFUlP9mb=N)@i^+qlw^PZ8kb8*)>ul_;5Xk9+4U_-#Sn)h$e z0TUC9$>S&EJ{y@O#f5Yj_%Fq-0@jL!x;KT3wA7U@`AnIJ^!>OwMvZC2)KAp4^l(f1F)8EcX@Sq*d`g~(1+seLHU!9ls`0Io&ij<)a6Wfg&O8aj@ zW^4oy0{=_Ntgx?S-ZE{$3(#asDObNGCX~t-I$Kvnu%VP2&c{btJl^2f@rJ3lEo6J? z@}a*Iiz(v~po` zuxwu^N=Q#M2JnNdTwb>vSA;ri5zFr~#Y;up=zQ z1X1?t#W^Wr%`sS>0)KyZSmr~L!9~ZeKKr4}+hoOV2Y`vAapKi7(|Eq7*RG!y%l&@H zD<9iFWbrICIpaeWe5Nbbb8f; zbVjI$?>kL7?YfC`#7E=BQP~8Hz2-)wl?am%cgMD*DW8C~hM$rUsm6ZRV6jdXxuZGjPaj?>M&Y z0Z{!cO-zGWI32Rryl;%YpJez8<3H^YdV|_v5oA?wlbVN}We;9iU#;v!JQsezN zE;HLFWgby7851nDnD#X4eD&zRZS+BV)z5$i9FHQEie$JI|h z%@(Gn$joR;jJmC^=sA~H$6Rk0JurK2}`9?nH=G2#leE>-Ck)%i+PJqNDi+0pxF zem0vZO4F5^JbM38rm?WG@Um<3x`2W9VMDP8X0v`_xklN%ts~NJnwjSCK*a06W{DV( z*BaKi?OO6i02b2u(B`LcB@=S4tHZm)Q#1J*L<}O(Yb8rT?F$Km+xko zQ(sC#W5FQVy{C3(~4oqOS(|6^G!4;$fsJasqOrKE) z&T!bMl_jaRiDq5<8}jnAk>s2gG4Tq#tNmZt5WbHM)Aq69u3SmAVc}1-&xPuNt(c~7 zk+1!e+rm6SMNpgcGsu?88wb}tV{F?&Kty{#1+<@`p6DI=2Mn*~f&++EYS9SlR>1Li zih2DAzTrDg_=2CbjZTpz(L`J(O26Xw!yMd;)GN22`SE~OjNgEN9bx@GLmPZHXs0Tc z1qrC2saSyykpfeuG#H4-6RpsbyfiUz{tHLPthV7A9Lz8Iu~6>Sshjg3_xh4*cGhcy z86L1-?u7yQv*Sn*?s3X863TMefCq>39s=7FSp?(}d;a)*hQQ*4=%6;&TRZd_J`Z5% zZfCitsw^EebEmrlZl03tlae_!+ZoiocNE(u9?c;9WAOV4<1^K%Q0E}>b#SXq+Qf)W zwYe(WK!?u|Dmxasc*%p4;xV$uFdFZs2iE~IRv6L_lJ)O_Z3c5SfUqIJ$8T|^`qng# zFeHTR9I)mM}+iCQ-6JmKSEo({{q<<%mI7u(l( z0>w)j0$AcXGbXT`A8a5Xrr`upES-~#C0+rj&)$JrfhA6I(KOK2$b3VE=+J~gdG=!x ziqI2D@8k}J z-P^%q1d9IBo9z4~AZAfnB^;t%CvU@}goEZ{J~ro*swU-^Rm*$t%+#`8ag;CNTCH~S ztE2MNL{mq>rB=ulj7mHsHV0NWjTok06#~ef-`4KzPNw_y?i;#2-Uz6}FG_eLP&2yK zh`Cnip7|_X6pj65a>vc%u!DZ7fXj~k3(J{R2M*CW7*TFlb{nj>U95U(6?2!^o6fho=C6%;`i z({B2E(qvRHH+i<;aNcWlKzkoU^8A&MRJ%A)r%bP=#~_8PG(})SBbzGB?5K$sO9@{h zxm{1i`muC9p5aT%%oKmAzWAUbiXlwZYS4WBz(z27`QuvB<=lCWd%*KddRi(jp)p}= z2sl;}VgYN7JnYcqH+A}87h_R+z3*^`hfsacc)8u$S=5)S0@3;{AWrfAdn@||P!uL`%6d~?-rk^n*Xe$Q{jrt5-koaaEUx3^m=lwqK_pDBTVrfNSiWvK zv|P?XT_R=j;2v}{m;tqVnL524OU{qfHt~P`Eju5plkwth@-XkQRvsbn5zo-FM>-v- z&Xnl}U0uq-`BclHZMAhNZLUX8W6Hxim~MEU5A$!iOr4x&mzpBPL75R#ZjZp}T2yF{ zSpLjGog^=bR7MHB$;M?k7huj>QdUbokt3(=C#>^ z5=;x;7=T z$36z&)*#-N9uIIqWQn&|%Qomoa847ln|6z8K@39OtnBwi9I!SG^c?;5*ZUhQi=KLI ztASgENDit(LpGlvv?1UUaN3CTe=F%t?K*eOR0xsvNZE{OUmx72D@T1vzP8k86Y*P| z<4gZ+@_py(SeZ*-9Oy^W@v znb;{s7p)YXYTx-}_l{ejq%4#rPLyw`EZBFbU*gjuZUrHz=5hQdcDfIw;eA=@W5Pl4 zoxf(nZ=-=)rexb` zi9IL)ZS!e4@RzdG7_xS^lti>ue5$PHMaV`!TrDv#5L@3ZOyyZ3W#?f@qfB`&HZITc zZqm^&);VN~vqqyUrxOOqVnO_5b!@XX?IvRL50qrZJ|VDh$C)-{G<2K2Qv$ppY91HL zI7Y@l!5qR9pF-a*Z2wR;z28>$`WdZ7T~<1b#~V})x&^>X8Y&-rZ!DE`LH_*_$?!Us$L~R?0YG2mY4ZG>?x9b>MjUwSmaEK?LFX26 z2d+`0)7nb++#QpLkB!A8JYF81^wHH=v`y(BvU0H6yUrZUN__z_J=~JGy9@H*QjEh2 z;AGvE6~z5+$5KXiFOU-QND4wU&}y8=m10PmH7HFnP3C>Al$fn5#kT!x;$y5D(fZWSGR}H4iL+ z)YD~~laa^^r#>j14WV!>>^sOg2k1A{d-g^3Z+3#p~l3^6==>t~eS7u6uyJE`~tJ$pJ znBZ>0x&h`Skh|gbw9LS+#EAsWH#pP%W+rJCz5#1drEb`ZKzyb=iYh$o)>QRY;4yvt z%R-|27|;@*fOeY`TN)aD?~&i)o@)q=VXq8XOi3J=pOETfODl3MANq>EcQ5SX&Q1LJ zFMGL8P>6UtH4sN(yqHD znZBS%ec0&7zwm~=Yh!5QT8=?wmPv9vD)mDUC z_7?)3SqDni!q9`(qkHWQk_-dFDb(g#on9i_yj~pN9Y_nk#0D}9l0yG~nFEtIRe?y(LmmBAOi3cD$em<^vWKs%3IhbxDHC+mEduq5P&F_}ao?QSt0I z`#<)-L9UdiQwIt=d|IYDuV(S+rJ5?54p{SI`H+^u-8fIoBLNHv3h-!XeeltKa1hy&85w~A?E?AfkPf|hjx|edH6s21`tFn~4e{FA zb>m8vbn*o6$M2cbQstRBcD8u$N9AEzrh6!t^McSE*&pfiE;&3VKuJTSJ6})#8)8A8cBRMXRsA@mb^X^iqTipJ~)V1NIwr(NNaEZtqeiYWOJU zKcmwut!{ig^NWQqOxI^WBP%ZvD_jt%WsoQ-E6RL7TA~$*JGQexVWTJ@0=nyLieBje zm-2YIsny2{r||B1S&27-mTVhz1$$@;EIrgflhPlrUxv4qQ&=YSYGuVx_hTU^Q`(>D zOZS!koW8N_1-K;FEmK?8QWTjFq0lZZuIU&eq-JCq7^+UR2(pB-|+lM$p zcx$(v)#u>u8Wl1&R=M-qP~C{|{8iMkHl5eahtA%K4EkQTm+Sbh3By}kAaGkB#v(qj z1z*+@;Q|g88s-)4<#wRh>Ke$c3Ildd{d;0$Hfv?J@muoSgud3M;hK&&(wQmt@aRo3 z`G&99pN~%Kj@19~!pe;^pLFae`Yr5O0(WYQ)8339mwWMBV#eh^NA#sP%KLuC-z@Re z(1teI7nx7hm(Ua_0moAB_pU)8PAM~G5KI%I$s^+almP$*clg5^R}~wTy6qn0v7k9M zo(o-7*N2bTeBSqQ<*iHpCq-8F&Mq{mf(Ls6nVm05B=6g}##p0G8{=+9sudHb!$H@g zB5eIzGMvt-iC{nreT9#MCh0bh9t*4db=dOy%*V5m7D5`@oP>YvlkZk<$1aZ82EORC zmN}nnFby1e*rmQ412335mU^=W0?trFFtpk`HV(%I9+^gqvsU(t54$}IIwQlyT8f6A z60p$Qx)0Ue(dm3N0BYBnOE6p%^-HI`9(uB&1r_;s&0ye|JK&fDQmz#ru#-QRhPC;p z+ytjQ!uYS3m!Dhs9osWzGj5pmQQhfI58Y5;rv&+k(R%qi`7v|59;&Uib@AK@C`dm= zniAo=haP#**ldtbcJWcwaPkCYD_-^FqZ4;+@BNsLIFzD&>P`UDYJhLiWGn?cLNdVs zgFB9SC_M}40><<*2A_e!BA0pso$*!n># zsA#%aj@X-$+a0RcY|g+_ov#ik>ne}F5FjEI?hhc7_z=^)MI*+V8lOi~?YYzMYwcW_JawJ>Fgd|V$5b{1GMB!xG`lwHWlc3~ zh+`U2z{!fj$9o{SvZxR{HuCH{LG7ed&dfCty((JvD8u3b<=^LSP1L6G7T?Hy^X|5h zb^uWp#0i`SMVA$W%Cro~R6{B^FB6Yo2b2dGrcDyh#00Tz-~3FJRlTQ#%@ZjO-cgo`oW& z1I{Wl_9E4X9F1|WQm!y;Pb#a@TkM)0|jdp*(#~3bt2EB^y_9kQR}T+_#Xte0@(;<6;F%EI7rx8{q?~R z)%}Fo|JE0+AiuF{=3OU41;+8j|3g1l z4cxQp2%Ppb#DI#u;FXfzEwSSxCZ&Pu7g2JrSDk$ApwIGQ?{tR+R=3(PAT^(C0n9F0 z_T#+N9C&B!>i*5OA<2pKh`S8m1}mTak_te4?kCegJF4EV`zaNrDNrtyi7)*tr?$F`NQ9r( zR(tPovHqmn>%`xCOpgst#2U8$0V7Hq*p>)1<1F=)(Z`5MEl7h9j76eRc#NJZm28?2 zEaqfzFW&uDs}QGdw`AL?vvoN%gHOh{(8ptBf-Fc2QEpJNMtBnl12z%?v_Y3w^&zs9^o}oQv7ESD+H%^ z;|vwok54*^N9~lJojc_3NI;v=dWl0$j(R7OYAw5KW?8qdaJj zi>DVp`gtcjT!|OUtB=1(Pn`CnHi&#sI%eHw1vQLQX9RI=z}t z$~wHh?)XwRyIIXoz1c*djmY^ehw5{YynZE}Cn z5_~gMt*N}2TcgVlJ>SSe!mxi7KmQkdZy6Qk+wOl4p|mv8AR*n|pi&|rNVjx2bk`6P z(jlOFs8Skb-QhWa!6z#U^(=DvbB@DJZ)!i6L(>yXbX`lfA#K%9rGFlDX%Lk80@UnJB4< z4wK>MoL_u@_;`N!O8PSj-92|sc;|&uUC!MlfU^#iuL(k;IwV3lN7ChZ4bLQ}C6vrq2=zYl++Y=8I`Wy9C;v&O??){}8@hXIa zsthtr0+@3(MKZMWXd%isGuA_iya#T3RNC411aJ<{Us3(|oK-|N1ZYg$55~A1o8~ME zU4UnaA-zwRkcVnQ#Ls|NJYU|s^_xUDg|!4yL8cg{WlmQCE|IdzzZp@xImWng93RUC zeQwOHrN{NbJciLH_w!k2%eU9U>TlQvyfMxXJ7VB8ZM|1~ux?vRDxv3zHN{~9M(^XR zSH!Mq>rjBNw^tw7MK9P^fdd5)sD+ar8tf17;2-2*e`s?rb(6}16B;a|b1+V%KIV4` zThe^fsGhJbMsbg*`sQzA=PF|XK|g8y;^SNX`sU?XzSP>hbkK@~3a zZ!dmeB7*p~sf5H|LICYdb+E4DoaaltkFXG{gp(hRWZiyg@F(!{H^8O;{k5$FUt9d$HO8Mi0+nQh9v~TE;~C|@Hq+Ew zgDu1Kg-J~^USBbiZ^wxaRsCRpAVHu|4Z<9Hq8a!_es=fbQj?kE@3_g*25Vi$a;VQ& zFjdcUf~zWa=-Zz1aYpu0$CRQwE7

kDnrJxtp)0E|3s*Tay_g_^Sqg*Vvp@9y=E# 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 literal 375293 zcmeFY_dlC`_&%JDyF;sX6)mM|RcXvx(P}7a)Cf(jN+mWmW9wa7n-VK#OO3|fB|=-9 zh^jr))(CWIpd|FV|wAjg{j|$zW{)n03Gl{Q@^a$nI4qOln?)I z-RNvlU%5<4;|!Vjh?w+2@{Nr66PNF|?r2iuJl}a z&MD3gczF8e!$<(Xbs%xA#7%x8%Z-9dJzs=zo1ycv*0$4LABKd*TBJZl4H0J3lf((< zwSI&ap=w?yLRJe+N@nKL5AAnEKxj`QMoQZ=U?`y#x6Fu@oHtt!b}w`49+1nvD##%TH#* z_T9k3R+~)CZxx|0tmgn5ANN*l?lWp!E;0?ss9eX3_sju+4U_h35k`x$FZD00siRF( z%7c{CWY5jc8S(?ZMtrwC1^7DPQf%;0vv+_^?&YlrkX*oHg5FkRv|y_-??KxjuJQ+=Fkp>p9Qtv-Q4YyJhb7^g=mo$lu0=)xCsd6|!qU7@R2p^wYHOW7_{F^oev%DTj@5!$H!aPqdKri^m^WFozw!g{f2dePMvzh$} zvFmw74RS4q=VkN77C6X994g>X7{Rmpj;j15|M<7?Q-E>}MoYNgg%>ZVcO^HuO(}%G zl0OslYwpC&ko8!robdZ{0XU6Fp@*bvU5nF#Lxsjp0WAN%$1jiEG~zT;zczB@Q9!ng#KjR>0|c-0RZ8Q=)Q7nw7kNdl*?llGG5H~ z`4?*W$eGMc(Mg<*joIWun?wQP>dWiCSq~Unxyg9TxJFy$u~XnzM9qYYf=*OqgvxTy zQtn6&`p$!IJ))DS&pAb{Hp%%P^B;^vc6XOv3f6{Nn~rn`3g#upq9d+FCh!32nHYO? z!6qBWZlaG*?R9ve&sEgl%!DcvCmu+xANd(_0DjH=%h->Fz1U5jV>QzxM%w04`_8jo zaH9q2N~-xfE1>Gu&^`g@F?4o<2Mam|F704caF5vP>mh z&OX=rK4RK*P6xraT}r{9O51vd_It$GtYWv; z4t=N{hS>s#fBJqGNrm{{@3J@C{G%5YHKHC|tCW?JH6)`C794xf1!7W6g2I_F)M`k$UCt1A=I0yhmiyHuN_ z`@#L6PVjA@h$ji2`mtp7T{wU*A)mk?{ zR{WdWlzcLOS{;=ofZ_nM-EL8hvoAkpaSYG>R7evQXlkbPaF7=qflhIZ^4#~|?Ii@e zC0N~8_{RR+k~z~$Da+4s{xah!3B{kd>92+IN^*D-Y+}>ZSJKT57MpYI?-NDscjjyN z51+h*2^z}?U6m2>LLo1} zu@36@gm5zS&B54b3ox^X6{=XPo-^ECyaeimI*?Rx+*VGj=^gque2S z#~nu1XZw5coL_eu%!N+b;Am~;Vod?A#}m#gE&@KwhbtTA=yq+LB^|{zS!U)mTqAdZ z3io-%(U`|83kg7*Y-PVBvDF|zPI>be79XF=@}SLT!|Z+8yH`U|PfiTiaraxUFmsu^ zp91`%0RC1_C6K@|%fbVa$+@)rpTC`}?0d-r*%kWWw3&9HT|KTA%QY_kyti8>h93I` z*52|z_Lr8wP{Z92O znB>Yr8oJ_r%&&Shnd@g`4p0x4W{bME{M6NFVM5xf+5F(Q=w$#Pl<)8TOll{X3mA(8 z9vrCgF^5?JlVXl%?!-7URq;l#E~DV@a+c5UiE|SwM8faH+z5VP^35L=#$3i)x)|B+ zSF8{Xj_+Y)V{wy=bY~B0prR7UkGZlnG^mWo1qs3 zJG)o6n(@_=1#;pNz(2!Y7f^Ud6VfL_-dm7YDX^|zD_jR9VlrYIcoxwTqzr*TOm5ed zWQxk^-x3lpC@0HMVpPXINs`pljaTTP&Tbe!%ec21W}-OP)6DY8^uGFfzOzNt=SWA+ zA+cq#@%!Uq6Za?XJL;&4@Q9~i8l7y-B+P*eiMmviEt3?TN{OxRY5cSv93vB_%4D|l z+*h1zY#UN2XU2Q}?i5II*0$kUL!L4_PeV3`qH%wdLP57(OTx#s%)D-eOvt)ymk{1{ z$YJ<9ckqlWVf3vXx-n$NpDSv9#N%F67B==whjOGRaXbE~_ z-gPl3aX}ocASC}}Qr>kbK$=h$UY-!#o#j5|>!+UWWi=M19Sbb5GYq}n)~X(Wyaae{ zqngf0@B2lP@#u7Yc_*2g;#A|}Xyfj90X03>qsbWQAUa>@;bFhU`LW%;&EhAUE{*KW zLzv!byuVQjMl7Y9na(WBat0z~jH^CBp?Tk^aMP~T&TOh!jI2MiZ58VQBt8MMB^t5I zZmwo=z+l`knO(un{I;QyCR(|0PrJR<6pPwLLm11QOeIoy@_;$ph+xiHv#t1!7(WCM zE{pKha|r-RDkzLNq~c@ox=?3r^g#0Ma51;FteDS8gKn5b?wXW{G|sy((4E(K6*1&L zQ`$Wo9q7vPcOv>t;aC> z;7Ja;pT-Thz9FFQrdW2-pRiT!ps=&n;=ZEXexEqOM#|ETG)m8=Wf%|DXC&QEgF}rt zM~Wwj>e}LOS@Dts)N6CZ8EGECS5dCNx9~rUzqdkUbam+0qUb+795TvhW5aEle2xN% z$DJoz(kBb*2Nj`fp~tz$kKM@GVMi0iLcXf#=f+-2J^NQ3%teLMhwU08az(DcggrrG- ztg21fvOV0Gl3k$fGFyXG{q&|=7oJSKhAI~H=JcmfnyHIdQ%_f51P?6E*?g|w^+&2-`C(>xg1F3Z0Q zt(e#1n4P5PX7+@g=leghQSZGBvR?jaVwQZXUGFxu44v}*6QpS=PSVPW@*98NAE_O8 z7b;_*fw2SfTx!(oRV8Q3l6tblf%bZY*tkLD~krJ86O|wa}!4csVBkjN6jXl zoen-xAwot#9eLd(Y)r@OVI#M--+qd@WzG$d>ZFT8$;irNKcCGWo%>b~55{Sn{t9TgW-5I;U9zB%JX$j1giBH-(+RO6sGg_bWaBOV(DSDNk$;<=)bbA( zyo`&4i(a7?q7gY-C;FJB^+@1&1J^LSw;D6BytO(Ny8YM(*R(%fH#Q%WOq!3rhBuyX zwo6N_Ox&nUxOc}!ByAHY-b$(v7qykso6|3jQ1{=7rqq)g@fA%}q+Y=%Lkn6~*n2 zz$D6lmT&}X$FD8b;D$p6 z`DJmvHGV!yUSJux8i>u^pSCZjSe>jEf#^lV!$qgMtG8_I8mR`Vw`KDqXzL`1(r2wC z`y62??6xQ<{FR@dpSRza370G{%yTn;XsVA@sAa1s0FZnAZ*v8M6bQfC`k0c1%-QL*ai8c zBh$O9EN9$fq8uEQ5VvGa%+_9-rNX=`u}I3g+T$enev@--N}RS{cry)g-SlO(-XqhB zMd3`&KXJWa-fo_%>PU_*mXAwF5@ zwhU0_XRN$&bfJrcUDD~AzAmbBV1qTQjY8C@zC}ciPRE@zMyVgP-2YYHBJO zdLs6}y+OGRW4t=G>K32Ouub~oRj-K}g*SJ;XE)ADe_Je(!C?4ZG}03Ko&s4)lU@26HsE za3}5u8G!YNidUAG9dk$mhbpH4mHPi-MxLsqYiR4w09fI89ZIOR!CC<1w^6`tlNA6( zjb!@#T(uw`b{^4LLfU?kN)nS_^5{PcViNR#ZQ}7Rc-2*|d!?g{eA9J8bS?*V-b6yN zvxmPbjwQk?CtR-h(uNzSRoHEXU{YIoVobhoi|;g=@^{6LSn3Ve>&VLmdvc-e*+fXq zvc5tv{QP6k2>FM6b~)B>)c5YbmZCE8hK@C&po;x5OhjZe(L_cDE)OZNOODmMtrLsy z^xZh2E;zGKDx^plUIy!0%b#M@e9_6zr)(X$miF-tdYwX`|XUCxX7sc zV{b!1*+oVxSg9&yk3QczWvj&e4}#LvW=Z?Gi-C{9uEcs*8%PQVV-YwlE35N!zNVRs zt&EakPlM0(?Co>shAxko(+uK+78$XIKzv+P+H2}vfef34U?>=&m$WyCIkphiXD5=Tv@!DPr4BdL1FdO9cc<3P{WFf`e zdYUM0JgFM8s?#|l)YsRke8-Z*Q6@WZ>n+U%=Xn84JRu9rgnB2jcd<74ejdI}etVZ1 z4~>!=sOc%Sy8^jwg+y%?+{QZ(F(#67&N39iL%4~?e@&sC+)VHJD?pGOV_3UcQ{uAjI(|9e>B2gb4A?DAo z7eu8faaQP99g;Gu7PK7+J&?M$k13V0@yq4%Uw*gnxUaWcPZ-mgzKF%z9DYx0-6$9s zKq4A)0@sHsnwuJ0)pH4v;vK)h>FH72Sg)q>df491-q+x}06>V5Z8-q&OMD>qEXrYV zdUG!Sd;{^0$lOexm`qHtmiWFM)Oz9P-GnQx)HwIxxCQBuEkqr1W4fdwc$wU)kR6-P z!{(|nFt3H?5aQN(6j|kFC?JrVU7EnZn;_sp3?&g%GKYL(ffzNA3OOZBv~LE-ZHm58 zQpqlwsMlfI^Cq&=FNO84){f-Lb)S#693ow21+UlF4&v zu#yj0rA#s~Gz{HB<(fi#f=^!5%gPqm94u7SlHF!TD_VA$?##3XH)N&e(x*wYL343k zqFoGR0;@_lD3b7A`}IJ+9EYj@2sG7_!R!Z!E z<)kgR)nMIL+8B@g{5cfrd;|+k;QH#Sp6q9|5^`3lOHd3Q`Jzn*0QgHwei251B{~rz za+ZG-i-g-59B9qNhMnmm6zu`I3Wu~4BH?mV{$DI-8iVam8mAhm>c@tmxcm2SLNMf- z5l_lN+>KmCpZ;%9ZVHaNn@yYb58D28I4ZB&?VjEs%f_bU zkd)?6m}_@M@@3|X7HjfYe|OsvGW`f!*J_b!JZVBP6YH>+ZmNu0fCCv(+!S?n;-V58 zn`gY9PVOYT-%_4bpC?W=cyHtP;Gof%Pn-PSK+s}Mg20WjzW#4s^qA}!^|jM?5~@AS z=B)(K&*l$Vh8F67pnB|@Wlfm7N`}hG(RsZ(n!+9F2M%caR2iycy3jp7$cUd@25Qlt zU!HyN_wPAHVi2{&=4fjj=0RQTuaUg5{r1fO2^{BPUm%0+uu!}&=wb($D4YCKvJ*FZ+g;{rf``fTcUfoQ1IQ}( z`eOd~9w8ct`c_HcS@*?2d+X~;kZj3CDh>yF2Qi@q%RNbr%pZTx)SN#*QDBPPTlfw9BNAws_}wouK0eV|2_g@YQ<;iOHL0%!e7NB_qodjByc_ zJ<-qCknC5dTt+^~M$SSdeXWCcQuc$SgnX=2NLxbgQ&$NW_tShIPzZoWX)tPV$G&O(X!Oy?%)&w>9nElX)Bo7U71{%Q;wp;V# z;UoCS=}Ij6S`<5qN+6-_NjE$^gAu8dQtiVz*>?>MGM1mIiZbgvW&Ei97s>Kd8-dgyoD=fva$@4fahp*bY9grP|sw zAc=4qRbb}R*FyC>`J5aFi%e#XBG;Q~-}|{qLyiyKWM&&%LW529&H4f+X+=jm#I9Gq zE3&yM6EKiKf`koxZc|vAiDSxYRYW&<$;6akhpbY}UN0`(34p|E(F4!>Ol9R{yDIR> zX7+QVCWOPq+B;2CCvbFH(4tMiaslorm_!(h?vO$-6#ql_3yfwfcNQRI%cpQ%1jr#I zvJ?Lp4u|(xk+S#_JjtI|vLOH03sC1aiT7gwp&=SZ`9_=;YDN9<_=MUh9lR`kvdX6( zGS@%iMBTb0J@X}`rklzD;YYs(4(S##9p1@DZ8irm2)xRbj_NWg%pBY80;#Kko+JiA z|4Du4KrFXg1G0O$c!<_335uS`_xWG5Q#Z}2Ao;$bi{>J!X!X8zDJ zxt}dF(oh(jx7u#sj_#B3wM(^BlIX(}W7y*2eC_-F>aJ(2ws4F-oYH=%)2~7Zk^=5j zl_aoa?ilMJ-B&5rDz7wu#|3)%2y_REazfJ9F!9-U+6(k#3gh+gXf)z_Pvq?m&RaDk z+rY(`cWZfKSwAX*)||V#x*(9XwY4~E4`Z^~Pkw_+mh=s_o>yU{hrNAW&9dSS#=2~b zEaj_d6waB^qMtn68)SOlBi~3qciqod6M}$BefQ6yP$-7J2-m54D?W`1jG&f`RFDxQ zUzKR8S(m;x+wA{iXN^E0jHof0a&dwGkFusS+rSye%EogW0vRyoY_a2u(`xZdNi9pN zg5*mYTB|Nd9UTzqy=`5Ol?u|_B@Mg~i!<3tXnh~jzO%P0Mg3@pWvafa+UFBI^{&Xv z_+fdgPN?v5|5~i*$n0z;qslA=s_)&9eOW;DJ$;K^#sh4Z7}*~i1qF{M`f+iXrw;@! zzDQmev9q`Tjcjs5Zhd%`tLkEyfE}uNRloe`MJne@C5*yMVVQZymsN5dDm^W}Bduk) zN#0ZVdca7sHr_tR_$oA8$%rCT=a7m8px)6;@N+e5sb=U&yzstWI)6}~03tmx7Xo;8v_ST?r_+E^Og92_L6 z(?4H(v|^k>f&-tASqa-LK)PcL8fDG(?4_u1sBR)*veUliJq*pIME@#(+lzokQ_<*z zs@&MZ)aAlUHJg6QR^dOu?h7uf;sOIt)~uC-Xl2OI?ff5FhSp(q!OoxNj5I$IB@+_( zXK;=F{;E8gPI{_2u?N<1#bVJdF{yIz7^p`^l>PCaXEM1cYNK}_9A*n+W3%-&5WeOG zP5rJaX;F*CT0lnfBNEDqUs_E1n+jD_)TXBQrKqUSN)Nn zZ*E_cojt)t?oHYSrbSWAu-KEat2IAi4c4wXR_3N?r zHKSkFpUY!z9cvZYE1l!!3~$F6R#mM%|j?-d&gCdy=bxgmkX z{G%{udT=bl+tYJvJ$s7Ma;Ykd;CSR_GRL_ zTJVPxZTWNOSZE|7_ zBCpp(nYbJ8{p8^`V9&0*p+rG(Fxa!0w$ZbN2*fO1vQJ~G%$&SaO=u3??0k}kPf_|) zPS%*}X!)q^pEIynqmLEITlSU^O8o9r$MjJh7#g0MVtpFZf! zA!gS^kQCK_l>PHc0IsDzRG$NP+-WhVGZOJY z2j}&6pjxsIH;{FiygSwLTq{aD@|~-9j0D5fA;+_iheK1-{s|=Y{p6aP>#|R^|5F1Y z8nW#Ax=sJVm&}0?;NCPkSJv2CO*;2hd|X^q7B^d;*8tjFBsDguFo~l`8B2?xG7||btPb1B3Yj|& zs^WbxB(iEP zo7@!iZ-EJo^{!p0&8E*41GLPI?#LjhDST(k9@H^n2RRQjD3l=GDa4@BQOq^hI$eac z2p6I2Wn^=>Jh#0L$9}I)XKIuv1u~J<*SBe>kSi^qwsauX@yrfA;03=^va%l*7E!X3 zO>=9lR}I-2-2v-jG=GOJZ~or=y*9-npgIk`o}3RgN;Zg$l`AO5iNJh=2!s1M?ZWX< zRz#oDPA3gj{sg4R#4ODHc^p}P=o3rlGHWvtGhATp$rL+{kkTNI-zW&ZS52wc9DE6uO^)Zg~4Q2T{tVXzs+o!=WQ>{G1GbXJ-0nouXQU$T3vTH>}0RYyMC6Ktwd4u zQ7IQe4X!RNZG6j*2*j$;>0Yw3KkZ09D_Y=3!7RYU1^*Dz@l0NKjEpmRck)}^+~WlE zow@33t(%L6wHd5Nx}UFe_4UgkrlXptwTr7mCQs#5kr) z+1h&8Wu%L7T>K9F=V!8p!(`lZ%dt$ikvOFN+~&B_L~9FHmdKdPYb2kDGvB%YmbIBC zCUp8g-`MLXu(o=*44z$>{r0WCctd^)_pQEa0S5V+X44EY)lBra{oLzW_xxxuH;62K z{3pWZXvO~ccg4wyxAGP7=t>|nu2bt1iTaPXx967oaX%%u%$Z^Zxq5P#dxT^V6wA}r zq0||ydoy)eI;5vs;|98$0sa0gUh31A6Eg8|0Rb;nefBrg>2%XRgXgJ|)me0GPS@v- z0r6Ih@iu|pH$LSos0!$Un{`%mQc_~WgLCZ5mkz|lwJ!NXgAY>7Sk; zF&u0I$EBdaSCo(39zxbKf(YLn{g*N(`oj%3&~SL7(Ws9Vv8Yh5T9*6~veF)`@cnMz5AwX|k>uQ&CZw{}glM zA_}xjItLhk{yzelo`iVhfN3@7-Kt7E7qrL>+QyJPWN`R=d-2XdLJ?dyIE5QYX{9|T z?(J^Q4Sn!1LE4uD*tdwuR`6!D2#$()i$fuCrjEvOnhUK**~DXptv`YNBkjHp{@=M>g9)QD3%oXF9;7q$1CFK|*#>d4Z$%lH);LU;0)lCB0Ty zBCw!2nVA;Rx3DsU+#2j|&iKdoIVZjxzNWPx_BIt~lGnsi&MWK1*Oc?8I76DOFLxtB zB=>lmJD`UcK+cY!v8bsCPVsQ%`Y$)4#;l|J=Jc&dGg}9eEOu7hH4{toenX07NeHQ- z77s!f=qglv^gCVY)?qLpU-W?QuCH0g!rM1YSV}p(K9y2IJ4RA0K^-ACkhd(>hJJ(MU7T|vX<05~5lQVGRLhaRe zA=Yrg+cMF>b;>GNjiUl)T`lBrtAD`2^NPL+B;P_>wG|t>Hrd}FM;?oT+2fYG=H7S} zeS9hUwlZ>nn&+y)T`N)8Asn(}-okGj;O&=1@-G6t3VB7|A0smK&(J-A&^g6Wa>p2G zL^*eS%&J>PL8gL$w`4aGGFxaD68SWrtcsTX(=MXtLxhUSZAamSl?S9`b3Ye%aNd7c zP8szX>iCs%T2yxJCDF%f3|Xqc6X!nDtx_;WnOR%Wio|^xt}9{@aETWv>hGLvQBeW; zzf#PkT=e=uP!dFf>b`^ZG};Q8>=4Zpa@O~`Da~GzuEf$Y@}>i)$dG<1w%jJC{EC2p ze2IRnGLL>dz2F?PRqpA>F|#sMEetOQ5U2u^=&LU6Ri&=J)mTk8Hip1OORs{_Vs3ey z@s~vMI8BH&*RfiCF9zuv78aJN=$^p<;!v{`IF#4u!tc4`8OAWV_ZO#F+IkM&EdlG6 zf`@lp+Za;E$x6j>1~F?lu~ZC!r7CPnpY(q7le-i=J?t}9OWglP_W#|93Q`q!n`!2M zdCya8-_wfN7&vA>(zJ|3(g+Hf)syJ8b#|ja8sZCc`%}o4Bg66;$xf9_g-XtjFR@^7 zyJVZMQ<@5wi77Gz+JpSZ?!Q~&fS7>WLc$M{Ea&??X1wd^1-c+ z_m^E%gVzt@27(v8x)QD=bk2qCmrf0bZjy-mJ!JJfPI|eT8YqAsH|8F^LT$3r$?|{F=R0(`r%L zU9CqdqVBEGw}fs5HGvU*d8l0GD;nWwEOZ+c5y5hNIB=YIvt@JZm`Gn3u)!W{Jk9G4 zuAFK}I3}te?^-mlB%G-4u9x;ylXWjsd3QmiX(bXq<2CQof2tq60!#>FN7!!Iby%tcJ_K;`>c0Uze0}q&7knWLsoXXjShf)1 zC2RB|%q;wFBlay-bEKoc)FMTcf=j$lE_JK|f0ZN$)3L-azB?;BxI3Ad_a@S(1J;gl zqSB+jt+VN9~Du~=mXIfryOAEc>bx^%UqRpj65JP|@R|Ml)c0 z<9B|9J>2hPuUS=1BAqsaw}HU%(VbpRLs8e!UDY|7v~Z^5npWhn+lUKGxSf9Rwt4UA`tO z9A_{G1K#O_U#Kg*FA-4X1mn2P&H9g#UPe`Z>9cLjrh|8F>&cK!NPCh@*O7H7P>qrx zs$X1E;~f%^m7s7h^Iqw>L?NDL(+Kz1PKB>w7>sz~f~QANQ1uDyUsc0egEs+Q1c)_e9B-iy5}$Opf7YN*&4V3 z3R9glH4u;EHC>@n0|Y`nvcb@BVDc^^QX5>$>o>XVt!(3KW%`4$-l=7}_?$`XL7~$- zrx)ej0h*L=b0qhZSpf~GKvtI1C_N_kWNW|Yq&iD_fG}VZ$->_X*f{ll5Snri>pMr- z?L=HyUS6H}?LBHS6+#YdJzAZJc^qNix3@W1y9y60rVc?NJ-_QzDy&;QWG$xdv)DG3 zicu#dgr8aMwv^s9rk8#w93|L^ zUBjHNeWb<4_HnFlY; z>i47JVIQm1g&1yW(Ba-(F7D)8PY?8&!esXduH*=&J&3TcalEX>Yr+{tP_oki+`PG{ ziX2@$rT1uNwRK#&FUiYBG35iM9u>iP`P=2h?3ZWQqdv2r@jwYt4KBl6tZ_cnS?7x2 zwKzqsd?RS;uk`e$_kZ*E`TvCW?&$~j4qbHNftV*|UFjTzw2>G00#4czi2KXuha(4K z-tXJzs@_MBF63FJ!Y_QZb_z{ud^4)}4oL|O4wjpIW|NyckoLr=J8*6%6WLlFyD`T| z*30PeI0x%T{k{B>BL@il^2wI z6DjWRg81#C07;R&bFLk^K5d2Xdu+&W4spo~|HM|SQxz)@pXgjSNdD3S>V=F}AAXAv zxEdCMD-G}mPE4K(?{G<_DUL|Ux?wL^GhH%E2Z2D!%F5%RRZ5dz zc6!(7v~~9~SN#M+$mZ`ZhBkEL*3Hv`GJ-5b}wOg3HJH&VwjY( z4eT}9tesTqVi*kOpk4KS8-7Jkzkn8>g_W(1!;Sf2_gKR#_I#Nt9S*+&a6>h7u$o*N zb>Yw9L758i_?RZw8GHERK1exJc0S8q`bxq;ku0eiud*AWtY%|jqpn=q=h%3%>VH|E zRq*uVFX{S$`=k)?iF){12Dy`JI`RIW>y|nBnb2b1Z>8m!eHS*AUv9#?Bfg4RP-g#? z0sEnqYZ*^%d5IoS*s|7OOz-{TWah)p#l_OWCO2bUOk(8> z;YSEi<0km*gR^=%Cb|&F=?6I^=S)8H3TqW55U+lxh02t9xiyq8_9c`){qdIfSA4Wy zWOT*@)5|x6yqI*75VMIdh4ge-Z{71t6Mv>B^tnIxgBKxoelW)1{EAkqIHl5?V!@wWQb9C`$yI`l|3dGJ>DCZxCrm+YT)h;AVvfcVgW}%J z6%Fw322RvDV|RiYLD#0oC$e&GV6*!lCDD--hC;sl!o9*#V_s5VXX-nRHAAwGoLsFc zD+@L?(&*9n=gUDlhmQH8dn9c>5G!7mtS_5MjqR3mrZ_1Ph&e z&8iVu*HhfZio^GrdR?H|4AO1^Wl5|_lC{*rryLF;1YNZK;PE+6eMqCyL zEd?*h=B&CL{}~J`bj&FB{|)&{8`a7`?pYpu6>MrjjR-v!bw8O@nK`Szk}PVs}Ap9>*?O41+n|aht3e`nr7PJwqKw;P;g~&iO9J#%)I9l z;z1`X@J^8qu1Pufv}3Y zT1MwyFOD8Km3iPS@oodgtqxvfk1Ig^D=SQ-iUO-?sZz5oqJm6 zTXHY5emP~}3Og`r-oK^3N(o)cd_HW@!SsG8b+pW4%qC7h;CK(JbeHH)zX5cG{SXF% zVZGzGZn2j*C0&D{qu$4|nrh0UWn`4uM-=#8Yd?sLED4b25)%5Zko|L)gM;n$Th^7P zRNu${7NgmGn7gtKiq8Dd{D-knJ1+M;`}*}Vn`m3lM80S%jowNRYiJpg>pQ;LTGim= z+1#qOvNLTq%g1k6G&R&isJj#QZmczcRMyZ^g>9gAuEu4T;riqBFMhyeeVd&<=xW`Y zpI@r&-n`R5ffpB@k5Iu*F(SzB{9$G=ofcezGSy01cd zIICVVmH%K`?k4=)bq%?vuC%jb;=K5xn6{%wBDru)!ai(*;Q+l5&n*1;qeCnkxJU2Q zRPpN{tl#K*BMki`De~0Wl&tL6^7^>a5*EF%d=yfcnr6%3&1|52SG*7p`M1vxQ_0Txy1knp1TZpQ z_nahiYi)<~K>>cs66eB#w3m3%pJ7}Kr_{e<&y$lmA-IoLZVKl#!M@Y= zyY;9CQ}}nZ7P07}^M;}O%c7LS4i&1CwZh03b>Das88%>zUGxV#?xP#w5_uAN2+Yso zQ^PLt#jX;piS=(Huc3q4xpW52EUraH4mQT#U=!MtJOhAef`yIAr^6UwJGJy*)Ee#l zCgd*Jg=?*O&g3l>4eN=Z+=Iz{1ldbbMN6+fT*WwRiiiw5K1iAA>2!FB?4x<5 zc)tqT+MQD0ry^&|dJ!^9tvhvC_vt`a`8LOp{jv5gB^e6jDO4GhkO z{SE>VLp|=#PAYtoRyAl(qcfR8ZbyBZm(>PJrLmtTp$BAP6dm9Pwa`QS*{#7>q!nfY zb-CK^!POmxUr5BB*Ygo;fbm3ZvMxc(^Z_;oQxjwxn@|{jMAvNPOk$`X>wP zqVU6pBfv*Fn~9LmPAblp3%~T;gAh zRtaX|8#VZxR1jrlR-UrO+0J_R;wkPhkPth}B!!11ns>wz7>@lHP}*WXe7xDy!Wd4Poe)~!6+M~03 zE>GmycDG=zGW-?#uRVfYX98^$cAU0LSK!Gbe@9Tn|IA5%wI-SHm}L;ti_1g(jh(K`PPJ;l|3lPw2SWYF|G%Ae zWZW6q;YdjKjLb8$IZ_!Jhs(&$J|jE3vR7rCBD-uKdlRxUijdL88QH&gpU?OE{oSAb zI`4bGp5w7zt!;igo4F4f$_W;u4tk}{M>dGmxT2YsYCGSz#@-+I6nci6uY4PplG@7YEL6*U&ofFMyz1EG+Z&S|?eg-8zlUa{5vewh4Ithl+ z*L1sVidQ0z=kATH+kBn>O4-|cu5w8mdP%$ZXCV09%cMM|Exj+{eeaw6qU8hQBw3$l z=EhkiXfp!DqH2J$bo4UIb5l_%W-PXa*vyCQ9Yr^QqE>?-v-9%%Cspmi41rCmTpf1Gp+{?5QQVS?>7}% zxE@>qWf4^EhExoZV{?gHsQ71Pnf1d^tw>~!!>o-gJ56+p6@GU73K~*ejVpM5{lmij zCq^mHc5rd87D+9y7Eb&Oa`rY-0U@ii`%zdxFD`C5g{;$~r(%<7%9H3vP#}I_!XTvV ze9^|$^|7D41%o~58zJJt>W8c6Ds4+g=NH@FaX{}Wd771b29suV@0804`Q=)Y%I;ZE zW7DUaz?B!pRA9lQlasgn(*4wRmgJuyELA^4m`L#k004eJDQ4m@pZ6G#hH;$vc|+QTuXSI|VJ!i^G=v9i5LQwH_Zpu>Q3&`Xh|bn)l- zf&hMgy3{M)58Qg+z`h46l0l`pFp*dqhRw`I-qh4o*y@VQge(q&!BNbAA(hg@^p9}^ z9~=QKZDTq#4YzK|1Soq$4Ssv_v0mCaTWrSfl6)I1sH5IW^zym-V8D}M2do{+KrPIc zWX|Tx$S_5JY?Uu@Eun`2XOOSpkME?#&u%r~shq~FbqzTD^Bvbq2pvynZfXd`;t&3* zzSvJQpvl97c9e6aPxVb+!R|+g&$otc5C4qUgr1z`53dtIR~+7)BIy<9IXZNe%uX{A z@bD>Q?a-R{*J66@IW{C2&T`L22WjgppeG5J)E`T>SGD2V*<1>^k zRSUct?}c}gvowuAb`Gso+9sCs*4jRau!^gemET>qO&Pu}Cr2T_{Ww8_W@Gm? zbDEuThi{$$5hcJ~AKd8#M3!$Il{<7pZ0Xe5NP&S^cJ9KX#UW!|LDq*g4}}M-g|e%a zSX}YHT-;nh(YPuXLMm9!{4db=f7KRnQa&Ul`HY!;_*L%h5Ene!SrB+O$|8 z(|5UZs&u&?`ujDXS?E?n>*eOo12(oT<%|9O?a-6_%d){Uu-#dQXjSNfI$)Q9xY%!SJ-X zvI(^Sg+(VbvoU>nI@e8EN=$^5ZECu4nlYW;56?VaOa`|~-NRawGZaq&oLf3owdUc= zpI^B@6!94yxe%felxU44&|`5srCAVkCIdy9-7 z(33cZoxR00NKSh#CqG|?J7K!lyyD^Up#q0L>!3(uVL z;*Al55y86|7@xy(fU@hZuVn2WxN^d%;1sOS0?>KPxf1p&e7`q5NAG;6>Zh0O&9IFf zD5ee<6=bOmZdQrRoEIxV{M^`B9-p{D_~9wZ4rtrN;G~U0l;TK9QOw=*c|k#CE)q3O5@=*3GLa&=UDtG8iUb@@yoXkHpWYuyN=gEa z#H!HpN5#Kvk3GPG8uZiW-@^LtEGxd}s;w&WI#P8++|USHQ0=kv;>Z5OxW$p-`Fs0a z#}gBsuaD23TyjOO$+R7{$g~|En1fjU^UAMFU+09@-`Xi{Clw8X>{eN#OfS8?#vzNm zih{!Jkk+;~y{~K)w1J&5zuw+LlPKG+0duT3xI7D3%LDvdGj7>JY%y7sW$&Y_>iftj zn1>~xPH|Fnvcz}8hwM{gb?Mn0BlWR*6ge1EdmfZMV1gXy4R>Kr1?0GBJ8s^J^(vx* z^AvWGbw7RwO$~!UQe(ArIvf?FN^yEF*X{%d1qHofwQllTA3n7XB@D|P9abow>~|0% z998})Z*bF(dJQs+f|5QaQ$)zLBmL{|_K2C*@sR)tE;6A!1Avw8(8{2uQ2Es_QE#D5 z^YTrBfj8D~#8)dQDKWv3rY5Qw{AF~g*WYo~+V8&!D_;eWR2GG(nJAMi4+R>b#tv8M zW0a-Jgl*a1xtAX4z1|8m4}J1uAb#CI&GO>=ih;g?zUE5Z(y>8k?@`p4X;qv|#e(c@ z*?aSIE-$C19#0$4(C`mBI^4+?S^RU7jom-n&_%^J8g6IF98=1#%AogNP%wjgZYpKBy7~6CF^g9gjKg z$#!q`zkGhvyu#JZt%sTOPO!M;5TOX<)#jcmNY|thB<>f%dG&Jq-xYCVGwVfQ2mk?q z*fbo$1u$oTe|e_So=BmL%<+tkw<~BkY ze8GqNcFK8ulX!|B2kUcS%1`+azzj>R8jMO)I-O8DalD+r6x2JflW9Gf%YE?R@ng_^ z>_^fP1TzB zYA$`L<9jf$IQ*cp_2Q)Ef#0u4ShbJ|D!QVDa5-^W{`V2H{zBTvH4%7Jli`I5V@L?L+XsWZ^kq2|Y8o_~IUV z`C?0^TE!a1gt~KUP$yE2-Cn)r-o>91{L7-h*#s?tJNFHRzTAj$ii-RqO{RK=%uY<_;>BgqV!d z+vA<4UI6w<;jx>6mxwr7e(+k&gf+Jw4N<$mQ=-b{)x9KT z=qiHO$$g$k&uPapT-sDzJn~T}OjWt7uK#`;{}>k;1(y>g02017V_VeCN37s11!^9R zwFI*myKl<4ySca|^seMS&+R;C(wq1iy7IiCmGC}E@#4?#-^uLsmb#RU_wKkB>tJ=` zl*vMm&Dwc+iM+HlKFV1%g`9&gZ@`4))_iu0l}HcjI63G`^Z4Ik2{SWn1vYkTI+4qd zk4MGlEe=3ox@gT|xEO-f@br;<8acdQUXl!p~!S z$$pMu$C}Bm-KRMo()pvoB3nlRH9uJpMXE24yKTj;C7E!fJ#rs2FE}ppP#h(=+B{r&X+$AAH$g)EY@;NrkGcLbQz%Y^bCZLUj6W=1#A~eF)tY{UUu^pr!!mX zXrstGACb9P2%G4D|Mh4PopaXhGTGjqzNnq|bQxvgES75;iY4zK>IlR_;dPZRZA-6U z>bzTw+Yc>X+Q!kC=nN&qR4z0++t@qdv`Ek&F!%n_MIu&UYmU_2VRpDq@hl;x39LZaVO$V z0FsnPiq>RcSPF#2tcp$&VIOe_Sl!l;i~y&-;!Kg3xMD=Av3vyQ@>^B}cCZ~A3s?)M zI-er2!vHpuU66;HpPxklMug)Qz)3(1oBtfnf7~GO7e{d*QoSm0n+l`-VvzmQb0I&m zzY#mi$PL+*q0}-#@xl}$bW3;)dlO%=(CDle{Z=x_O$Fz@E(_!02I%PTHPwgp2?bZY z*^tnKsvE&-h-BC20=VJM`PWg;eQqK8kjNe43+pz=zpR=P>wm5H=1%R!p&}6-Bz#=( zIoB)49-w@HMGpr>f9c{2ub0|%h)EQUT+SIJsKR;p5r)5Tv$lP9=Bp~#yU?=iw?Ut@ z|JoI$zV=;lGv#zz%4q$x0w{MqIaw6t7fkQYw2vOrRk%Y=bI971ch$FJPs|YNFyVnIWvg5vXuHCpY=Ugs~?`uyFaGQ zP>9rYB%?^Rut?j+U(Uec%fH-#S4SUgjAZwFx=EU)kV0{Nh=vH~l@^6~K{Q_xVl$&f z1bGh`)ybY|df*c98Be=sCF1;bZo$D|xSAnFP*P3l=fUaV4{;r#xI6)A0r4L=<1aos zW&D)Y^>cjy#jx)Dfz4_#4PddRe2WB#V5U!<81*D&B9C)*+^LMBa?QuisdHAUo~C@D z3@;RfWM&bCSBG=IiO*C5bjlYgNdYsd&U7TQ=W#HKM0x$;M7maHSXJj?YES}b3EiT$ zO(2Ou7HbcWDFBjsEGPw!wLsxfcA_jGLI5L=)tZ0bv-81J`Q&?zf4*2j9Iupm4X?o4 zNPG2{#LCH&70Op9G7u7%q5gw^VZuPo>PxRwB@kOIzv?yliAa z(SXis1~oj_OH4z;3+YNLBTvGE8V1XYA!_9KA4h?=m{069J!A;i8qN8out=!D+gCSV zE1o6}+tM5BAXkIXE&!NNp>B9f8ze@}#7xzqOGOy1f-4?wEp^J{{RzvON9fxDsf~WBB-U`#hb~p=D%q6A&0XKFoj5_G5CmE zFhv+o{EQE-v*$y{k$e%p(zHz}Dp|8aeAhUOr9j48Fz9roCBy6$UQM)jm3c3O2;eiK zieV3&#vcJyRC*6&IB|S~HTTSe^IvbAdC%b)R~8H8vIl?a4xG&HezTA(LDb|e&CIuW z;p=cTwzsb(CH3>%MD zx^xm{r;6r55mrHcNGS$JDyh5=su_xLeV=geP*Q_nFxTBE-OND~`b`HtlDL<;)E#mf zs#EDdEG8tYC~%v(gf0Y~Dt}yf?38Kvr17+vDT)GUYLaJrur*js#hpHQ{6s*mA8d9C&nVFl~2|H9dFVJW$^cAC%PDYVdlJvwSYE{nzM zThO=a@sl2nHS#FM!N8b}nE0ZN15dODJphgkI+(|H>Dbr|xf=tsUgKA49E3VYY;4xws@>v)W4Ebnm%kq$- zz5A2rg93N7-UOHJOF2FU6TnhPM?zjsE?a-SWidUbC*qsKr6e&+IWI*a5wPCOl9d_D zr)B@fbwx!yMHv~#RooM$1tQ8ZNTysGF2Q~z(hlLcLDZF1ts{WayOTYP8*N2{vYJ$fOf}_N{(pwVG_<9hg!Yb(NQr}Mjc}w*`;FiwefnB7@W4cI#ON7(%zq_7?)*?p_Mf~n7CnJtOE*<6|V8v z1||;$&AvMG)Gy%B{a0Fc?SERIgGIpT;&m_q0v#JKAiiyJRdm=(@9iOyDoJ=gq3)5WD!tQ zjKlFE;0(zrNEB+;%&`}J)z}`bNu{lax~>v46ml|NfZA}lddgiwgP`A9WV9_dBU5EE6`&{;5X_1dWQE#73(GK`2{DxIn=@4q zM7R#bNT(RXB`8n_@2-9ahRt0uc&AmdQK(`$eJXNjm*C-EdCL`(RC9h4?aM;UWh;{o zszQP&{s;7!>GpA}VNeD2IcEElM=SvBcenos?xMnO-hKvMMyd`|6=NM{6JvY}@yH!E zZa5YbBSkODDgoy0Oq^qJzo#mu@fd+It z`O`VnRF3t!b`35w|7^GI%p89)D}T^GIlyN!M+kl;DrvkL;@<2}IBa<=q(~?XDQ4KDvDR_ERXI6T3 zV)gWNx_dg#RT=W@zz_q_pDY>sDYEM`62?u8fRMtT@pf%zNb$k0E7MCMd$(Kgj4gtZ zINY|UtJL=&!8NF{OGV^?LV?kC_fz~K%GX;r-m3##hDgtM zl-&0%P}-uSPGg*hnriEFX}*Ws%8Tq)#~_VPztVo7{+SZbCP zuZY6N>CSkMyc`y@~6s*C$or^%A(OEsuT7CS!FyJ+t;aq>k2$##|lg7+UZY%Lgv=Q<|173 zTL$qPKkhuXya08aL9_o+Jnuv$N+EPS4NUb$@JZtsr*@4vEi6_8h%&q@j+S^RluaOr z$0YW$C6Y&6D_1r(A{$_a>uFOD8}=u_W5#)~wdQRmI%FC#*vLLI01DO8U^=hWK(aIm z4VqdYhkjQVZ=`a6^esh;zt5h%7;g$aJZw8zy7UhgpZ6eUig4c_SZj1I2$Hl`rT0_e zRk#HK5?e|G48u|XSx@4rDiNSGDLQ@k&IpmZj3L16oJ^+%%k$yBA& z7dNO|aem9k0OcG^b zer1fJej*zS$~#mThM32xprXX7s7-a~xc`#m^XuH}M8L2@lfvkYwEpUfJ%yvihSS27 zi#fHReOiZKN+0i!Cpdf!Y^D z#@MgHk_}A@1&O^R*khnswkVWCi*kel!Rbq+;G1^aOjVlXlpA~!h9*RkJkKdhb(JH_ zRk@^WDfI>{g$2aCphWG&DPD_1b2YB0>(PksGbIe;$tfdA`WKa#1naYI{H4)-Q_~xr zWVFw)rfiQ(fe|t}|6FPAQ9?+b$X2nop1-L*ye<=BC&~Upod(c)BLFu_fCF(wP(6A~|BSz__{`!A6*pJAXBAfw zVpNKPNJn?7ngp9}T)MgYlnM>9X43N3Y)LeS`Hq>7ah|STcGB65Ev;o@L>b|5){N3H zs0|e;m_c?OJe7O!WDQAT!cG+>7FGQ)z?Yi@!AeE86E1-|7?IJN&=CJ^e7SEu6K zlut`|TC#}z%d>U-#Xgw9*dr6N(zL{p)H@<4VYgl_6fagE5nQdnV@WL_7>9V9?r8Bh zcCjS=!u{~q>0tFrAAMlPQ1jX+&z*0qd1TA5HyVzkBZ;+|pSL$JUnY(?eND|_p@}78 zEe<|*tmq$#nc;Lpy(Xetwc$PILMUwz#w-^~{Z4&3ynBZG++oY{5M7j=(vV}#^0}zD zS_4Pp!c~;#*Te?I#>Biu-Ulcv#dsZ!``&w9Tt5(ww`^j2s?jZhY&E&Idykw`Xe_-9q*p zm39}~o^~m^{sS{wd90lY%Pgg2q%TxqVB1 z*ZTK8U1J@O^ql_oN#=3-^&F#NQ=|m{1061l@1+8)kM5M`*x8Glq(pASh^~L74PH7j z88jKm+?#q>v$Vj;Z!#2D^ruw-rDDX;Jw1l^P8*#3r=l3@jbg)QhQ+`emvbv|#br2u z%i)Km=p6+5(OUUOHa>&eWyVup>9rgx24#=i_g(CrsA488K-HLuiDCBQ>5n=AM970m zd5G3kzuy^(xYetT`ef4FSkwb{?r1pdwTOU|_FwKICP?Rw;_(YDVl~^l1Ad>bVwXX#Y{lQtID@5 zTAb_2{xn7AL{Ik=0V8fmoU@qKS|9 zdHxrPP29vEQBq3xo^YIqRWvQEeWyzma-4#ql=xn_h^|UK_j=`*!bGhnrie4RrgX8@ zcCmG|6neR_u>mH!#M%wXP5a#85Y_6$0uvM~Rh$(A0Nk`jh)YK+jvpZhhIgCQ_->FH zX3OmCWNQw#9Agx{z>AvJDZ1(5iDI!(33`vj74KJ``j*prgTd=L4%!0U%pC3BeDdp( zNZb}Hyd>)Sdd|_cSgV{{0^l2zck|yjh|?BIi6*0p0m7x&0&H_CR-Z zr>M83%ufEKqVAPlXZ~uOZ_~ob+4qiK&irdij%%;k0xK7q{GQe}2Ckbe9j#)=1baJ< zbG`XuiC9Dp2uaudn%r8f+#4ROv-@l2fi^3sOPu-u!sL@N>RY)s!%&|jN7&Qjt0BSb z|CXqte@oQTKt)nMqCj`_7o4c09o8xPJ)#1y-bIo7%sl1 z;?lFqs`11|ZgNPEnb%cjE2GY$mOMI8CXuFFOooxsgJ? zS!h=0ces(=K?0?fS*mvY|COOZQ+Qu-_aDh`z8E$LKwMB>)&+&MFJ?L?z;nf>huG7o z)Ee)(+YPK_6z8#cOAp%RYl>A0;%0S$=R_q)jtLhs<#23iLQH{)%H9;S4bQW3X`aa~ zyD<$LC>dpYoH-*2>%HoOfaxPUlR)X!;%m|AjDZY12UeRes~zb-YS}&Xvq*!R#bZ2b z6%=HlJ}}XWuN@Z{-&)K!M z(1iL0>ZY&j3vMz8`w}p?yk}lZ7-oP@Q^E`Cy5nus9A$&g{`1>_qKEPb_5$G35cbF+vEM z3VkMK?CezLUbTrKL-&zM8D(4%KVQHL5fU48)_8STR$(EfC=6bZ#SQR_L*krtQmdIy z47sa0qdv~=TQ0f!EmYwBf@ve8y*I8NJozej8>vmA6Q*_RZ68a9^Zg7A8L|M2Emf9t zM3~;)wj7#`=F|2~biKW5!-6fAyMhznp}(iww=KK+4xP^UTs5O%eO{3oK4s75-rOql z^dKi;dGN+)Pl)~KPEp=y){k#ibxNF~v;rL4_@peL4%b<+G}Up7iNLYaa_UaiX*e~7 z2~~>xJ;zqrcG<~!Hr;k2;`eLO^1T`QrE!*Vu(3`}vEsaMpS=Yy+Av*lUGd=6LeQ7m z9Yk>jzWzn@`T6-NctX9NJn=o_HGMSxThbHGQgDsh3m3r_O|A0Fck7BTZWE?W zIQ;(5_P*&0oLM2?ckb)`YpPsDt$5`(Wn1Zl{wfy`O~Aaa8cC=bMl!9Z z5`D0TF{*w7P&M^+?yct-rBI5iI4g+ViCC;~k{9w;mq58(A{enzXYAn7_G+||iLcCDM6ACTm z{z%5k27}oph_8WV5D>&;pke+e3OY8&kEypOh*t1(y|#c@Vh6h%P0gH4om}#}!hb?q z2v}$NNYYRKA3bVidJ0+Q)=Sm{r!H*846f$i2?z-a@m;a$ha4vw@2u^7_hi1NsMN|F zZFQaO4YP`YA)aSv_MaLW6l9x^*3L!CPh0zo%}t7^=j-n%waw6U+PX_wW@N=` z^>f~eE6Pa6{p}cAt=D$#pr!lYSy~|a2EX9RF|e4~iogkK7|fOVB0H!Au28xT*kO{_;8N5sii7V zJ>Y8c$Mg4oa|uy!t52R-vwi+jvNT)XGb6@c@#;LTGI}d+d**_YogDkKbW#Mg>j4 zuWi3SJWQf-TMYVh_J*uND66m$_7(4IF%AO%yWg1~=WN1}879y|=PZG=Nvq9>fN^RmS9lCGY_Q;^1mX+#ev>Sf8f(w(+OS+ZH#SZa zk*cEQn@Qk-_|+8slWGr-;=nA5g^|H-lZK@iGd*l7&$!|iEB5u}o zWORDnv2d?*2F!Q^-<;oT*_GF>ZC9G@V5TnIFSkN(-&R;{ADlXSZtl)q8qJ1p20^xU z>#wahSu#GLv>gbbmz3m6_a>qkMpN#clgU zme*FTHeDmZZX_l9i*76cZ>m_@@wJ~!>J+6atf&~}oJX+~-=~WKr>}y4Pn*bc?N?2E z2$~NeC27K^A*lu-mNNM$cqQDunO2~bnfqD&xLaRSNS~mFlN04<$+|)~kJ#ESrmwP{>>6~S87s5o!2&(XSE<0MYpfU6#yY#E|F;}J`K-4ZcXpTxxE zh~g0aDBLXvM&%#b2m0|a3l!D>7We3dc`Q=e|)cF za`-hm28$-+k5Z25(-H!t0b+{ zXYr%L7^@-Gog=&SK|)QM<0x1fb~?}NskgI^o5vLt0~aYy1`m#d8hXYeVSjEn^vaRP zoZQOQN53q|;fyGMCJV@|Pd!eQbAXSRg}_*P;C)yMU6q&^s*WLSY>paISsY1Z!pNvL z!JDZtHM9CI#glXluo%jNlOD;$6aj}WORr!abQ_*nKupC)tcY^w;kF~1I2wt zqGCu3Bh4)F+?Q+o=Z3)Cb zJ||;R;Y-xWlf&{BOfZ7nsQGBJzH%vaWp*MOOddI0n0Ma()#8coe;vI7;|w`|8d|4t zTOnxcKqhL3@9_NfXzTWu$LqiM&bH6?6=+sKpL6SK&iB$(Fb`_gGJb=uGHLc!!F!TZu(&NW9J2B+yV@Tm_i; zU-B98_$qk&C5y4kCQOyo?n}Z8Bh`N9Xy)?zJBoLLLHm{Oe4wq~C(eM6!W6|pyicoc zm}p!D;g|nLA<`zy$~co!PN@KbKLdgYgXo>Bk4%9-IIt@Fv}7?`5DQPMgw5WQLWCXV zDd^<~GyIIcH;qC~LzBHEfogX?PNH^B#kD7vA0N`Hll7%?AV${}5-Xc8?!V+dw48`i zI$hSJ**0%G>vn4abHKGNQSrxn%NrXvl+KnX43sWbK{Lr|_7dz{(BjvY;J-8VubrLq zP4hOz7WQkL`4so%v!@0lIIH(_kxPNkzzfn?dyQhpD|BiCH!ATtP;eC{eu>~~=PqKuUiW+RD=DPT;cW2KK;HWZ^} zVE~0XyRSSd1>_>pcgD?+>WlI;r6Yhl&zgQzcRoom)q0 zs>w@jPjY=lT8$(!GU;M@&{w-7*6 zkY&+O^Vpgl*PqHsv34`ps6452yjo(y%TLGBkzp%CRcLFYYa@2$rZ`-;Ef_#ZN`UuX zs*)fyRBhGG##o;<%RI5~iV81k5_5ouEj&5yer4Um{OUlpdNn7<%YSZ{sJ*i7|zfbYXr z_wBH?gqS!jU^ydUObjP1=#J)v(q!5s$cst*BN#=BWCL%vr13_Y}^zma0vqYPPENvwgv6Yo19QM6~mA@_vBP^qie(6 z?^lt1Q<8DaF~w>mm{MqQu`*OkOB6+kF@dE&R=tK`7C)BkSpe%y*wX=3DG+b>e98P; zO_dmZwfQ8i?v-u9>U@Jd*uz_u{-+fMj2F8XV~d^ap;R`|1Z-3(+T`#epoz#139;`3 znJd!6M2_wp&%~~KP3;MoR7vNdNEmxb&h=go_M4m;eOO>Pd1`VDNPv}$qtPzyluqrG zZjG|U0Ey^FojDl@sB|ylH+-@UJqeKq!@4@>E87m&(nPMcoqjpj%n$wIbB@;496S#_ zDFGka>R{&45xy;8Yi+#3I&`;ty|-l4GtotBjh4pE<+Pb)F!9@*51|0F9Z9mU*+jf{CxCT|!{MNmQQoK|0azCE zLn%MGjxr7kpf{twsg+GbnO0*FmPwUgF zi&G3kyOe~b4LPTEJwFzsg*F1OmlT0L@2< zTDQ#EUlL}~gX)Tq|J4FKEGfK#+<3U;I=xuu`dH&N6kfyw|2T3gBZ>HpyM|4+5PS2^ zb`^J zJ5yjhYc(V@d-$PFc7@-FYrQ=F;qRd#l42ZG1DUXjwJeQRR0`VLCWu_i^qdf$!_?0w z=AF6KaP*2v)`d~Vakp)7G{zwif`os;ynk_$kE&!4Y#%00Ya2smLtgMtLZqY*U1nyn z5R0cY39&CWM%ltPT_IQyPLT*B&d@fFkGQ`##4IR5JWisp@6q&gjgUzQt4N#?m*<55 z#-tn+9bqYmGFwi1U-?&&5C7bcNYT4>;RJc9Qu9G#NOkpP&5uBkNI0E3#kU?D;(C6a z{aNil-c56q2pt=FwUD87arTHc+G64?DfIAL{`m%Zd2uyEk^#tx$q=ZNpRV)_n=b`A zw%)rpur~GkM7CpJ_j0}|r&w*)!h!iVK%J<}?Mn>s6diGY6hxGTqx(IW8Cw6WhLPWC z^tn028e`#wCN*fz7`CEfR<>K)BK~JprH3#$I8hUymUe~liDE{${S&ZciaU6GXD=(e zr{N8?&XtIImDL3v-MD}E65T$k4p86J?lmzfLH+`)e{G-;LU37>s>n`PO@~F7Q9>Mk zo2z-#4ZI&+Lth})A|bYegdw-u4_Bn))VlTkM|Xwm%fW6;+?Ejv(VKwIsF7|(T^9xA z4G-U&jhJgG-fW+|Bl$kj2+mw$iim8aF|X1P8)RlPz=I;I~;V4sO-Jc*=+tOjk~HP%h@h{ zkBCG~4#BOVyw4xMl3d^9YBDO5tv5A^G-1OyvJYNZ z(S7vrU(C3IS4C1ayU{bqWV9t>E^Vbukc%?Mgzu|QgzkbOE{hr#E0=7CsNZ^q9)aA> zz~RXF#AUq8+_aG>Y~Q*Vts2?#)aIB607%$@{J2EC7@aIV37S;bLba5tPZxk=iS`3( z!}U!@Vg06hX_?qq2RWrXav>M1hvOH|r(7x z0J*@B%gn%RJS(zrOMeLK!2y+HeZ^fJO;mnZm`>Y!CE_S-ut|2OP zUsy0TU}IIoug^2_^nQwgT1ptlGU^W2M)n1H1_l4mjJFMw)7$+nULL+4CudK@9xIr) zH_#saer{fB;1hDT8{x>*r0;pP%s^P=V%J(R#5d&H#Gf+|wUf!|fg4v);SEu@l6p$l zrfM!53?7nUf10^8zE`7c2QTc!<8MzNyv}+$`XZ_r$81z{PbY9kf!fk9piE3#x8eKF z)VD{dXOlk5cgPLevu^dw94v@-jVguibu-aUWY8qJ4WnHLSbHVWWr9N%#WB2Gk|ey} zm(?qKGW1>@eJ0rgpVWWM)-)_8xm}qm6(emUrTaY@%REjo$1k4f%x)W(IiBO?QyR-3 z+XK!I40hfla=mM6zaL@jIeXK4J*w^>EF=@sBVy6aCLxF0-eB0oIzdnpSPnKOLcgjZ zI|Nw5k~3IRGIWV{x8$-;zO$vu)f>trQ&j&F*5_0@J3CvQ1qFwua)TE4qX*;Mf8APr zksbMqDPM7REyoUY%>y2 z6a(#u##OKaGdm|YlJa-(y~jaeG2A2|H`}AzE4iAJdB;4fI&q~G)0Hqn41`@ zZE}g=a0Im)=*X8%9HD26k0~%+HQZrar>r=n)Oibn14jQYH%vneDXoh5?ObFuFX#OC!Kp4RI#EcN>DDiNZiA!8E2f$g(T?$;FO}%WETj4(T65r@gy&e0 zzoTS5Vy;OJ?n8KT&ftBcCQ3U;XUV-K*JIrc;InKOH`Q%(By;rtWA8ousea%1aaq|M zJ7k>fy)sJXF{A8}kzFL&AtNJ>%^`b4s3<$Kv&jxoc4UN*J^NnXpYQjt`29Q{Js#D0 zIp;q2eU0b!ysrDY&fT8*oP0Ak=;uwQq3MgKXe96bF3NCNr>GKx(fqbd2Q381S!8$j zNHTc&_SIiAh$a@_xg+G|Z#%bFokG%7f>WQ56d`Sk=Qt^{uTH8&uk6W$J|W>2uACL? z_%Gl5=7Lf1+dQ3VF+k(vs)v8M*`KENpi*VW^6i`^|8nNjY3KUM^<%+b-1hS9MS5h~ zG>R@`2Cv9mqFvFh>#yzIgA8oxyF`|qtcez<&*x|ShKqg-_qX*%k&3cMEEd@tR&te7 zH;^+~RuwtuC3EWAtF$9U3!##2WB|qfhTczJ8+-D-5MA#P9r^jMl)!&A8HvPL6Q%-d zoQq6tQ@6zpboy(aT>UV};*)G=>c9K?V1E2>)a-eoiQ!HN++_6M5jy`H7yv!fBr>LJ zT{l0}%=qk_*Z+7l_O(h6x1=jc|7B`0u}Q@OZ%1F> z$fC`rzXstpd3!M}YAlt{W(~kIl4R?joBsG3IAn?&fE# zc#cjBwx==_*Td2uD|B!hP?`;=(ErHdi%rZXwR}D~dp0c;cqEyzQ_0wS#&SC6zw)a2 zQ^V9c_L0x8jOwe^S&Ri~A2XayZ<16Vza|e9t}j1b7Gjz5+nWhI?`=N4e}2%6{kO$v z$9`CXn2Z$b^TSB|y{BrN%GbOGetqs^H9-x@3*Z>PJ@cg+bHfa4I^Mso)kd&q{R9<> z6g9>?w8W)ndf<9r#!m*jVSC_Nl#jQcSAbV7=jdj-o{L)ANyx~e4h{5OFbim!ib?g#@+sIwY@$&1s)sAB;8^ z(9K6rnq_JmG+$5Ge-}Jh-k2f!JbqHYy$Zt`e~>u@)}FRBd^b6sdmwi*7d?6 zFauT)8`dR)c$~|qL-TN~`5Vp8ZWy_2gp>A9{SMcSVjfRTMTiOC!{< zUVg@>j=@?X2ulsiHGR6he#lOEOHNk0e#%X(cv;$hdP72qiKC;g1l8V0Mn=XYdW{W9 ziA>8sGx0@Llh-HWni1~E#*o$?3 zy0=$+7E;2f%WyX*XEAdmHQY5?>CW(!sFV=^M*GzMpU&f5`8uMhJVnvhUPU~-^W(r~ zfFYRpWpU9x_T|iQt7s94#~U)m++s)^F;NPUawT>w7ak7#{q@2d3hX&dEOOpkb4JaF zzglLX{`F_`Ory`vDjj%~n@yF0Cm}fjyQ@#m-$i069BC{RPj;lCh?!+&EJ}m z35r)ra6nmK#ai9o(XV4`suS_pcWe0KDb|K~%coA&Sg)M&9I@DdFD>qMfKRY`+V0UmSz?|tZ!*b6p2GR$UeF@q%23q!*)Z3NpFf2o5aBM z(j#blV9;TeDe%C5_A*hot&zaJkz|r-d0Ou;SK#tuWw=1@0O?QZu4aP6z=S=wy_u4$-P%5DGm*mC;R;> zzqiv9>WJDRp0ArVd@>&2>!92pp=HoW3s zi*`ru8Z1I79CT)cvJfh4l6nEQ)Sy&>2e<#0$<)+=5=CwIR7p=S1EJ5CPzT8+~^dqzP|KXkI`E0^Zu%YPR&K$SX}hpd?X zu-xxgn_0|@1Jw(gUT{kv$9K8$+WluU!qmHeWmmp3?EnyEI)L_%@lTYuIla zTumuVeHjfJAodC zUZ{F9-$eXP>hHQ4nCIetu;1UtxO3!WC^=9|;fV0rU;qBP3;LMEH4jVa--`eVwBY`) zE|JFIx(=)TKAX6w7?-%}3R5xodR8oSP%jdh7jaco-H`cr^fbLEy&k3f_0R9+qqBoc z%`Np%UWN2s`9JYH7>WPl_Y zPaN~5EUGeBCNlCk+8t$r+*zklEX_f)8g%)-f_O+eUYD74DqgptQ2^TAcA8X2LD%Eu zO_`~T2!jQw#U#CS<~cEc1F;FytLq=guxLmNZ5dO);EUnmT!(0+T7-0R z!%J<5Fxrt&gw3;D?~;L7tFKeuXKRttwcqoJUFPE)J(C*}1&d5Zo&+L(Tl04}XKDjo z$|PfLZm!jp5VO5Etg%YxiuM@8EycwrL7}Fri3k4ew&iG!H*H8j_gQoE-DzlwrA&rH z5PWF&F@1VnVlAulm*bPH{5K|^RnVgIVCH-q;?}K>((@yhwfX~~pTG7=nL?x03?k6TL zEKEFwyP{cFuP&KPdk;q5>rZ!XoGw5-sI;dyBsF57xySK${j(6H3?=+U7uzzP{u#S| zLoRyt=g+@KtE(Np%hzX{d^`dIW<1a4=UZS%`vSS_;oP?k(-9e|WhNw<>(t)lcenG` z?8yQr&aZ=$9POV*m!ii>m&MCGw6+?oK!Z+rcy6q+Bv1G{PBZgyET)D7AnTz2u>O*gEDayTWfY(#gJ4z zrcSnA0{#{2=r&>RO>#2wkEVuWag?`{SS|6+2sQZMw!bwlYfpb`d6^2OjehlqIMBBlM}Fd2yKu+JrEc(K&-#Av%=-G%rk$*`H*&Ulv0P12 zgIWoTL}RVO9!OD=Hph>JyC~7LDr5c%eh_$W4bdxuO!5m>>rb^YR%)m+pTvZ3L~5KP z!X$ckdr;i-cf&4ua?p&?sDFHzp7$;?Zv~U4R)F#4eW{^dq*#%k!P!IUTe4c0?TF-~ zd)+^pWou$c&5^D71!ky-!nKL?LY~C@FBJ~ywyDsdT-{ega-T;HlZQx9ugexC6??}h z$^-M^_!;wW7v^UaA{{D_?}|8k!uT)S5JvUKt_Bz4;(cB{z4NT)ElJyu)`)dw*H2fi z5i~btgz*Q%jDA|O@Py#0gpb%^Ruzn~tk(2lN6a2)&%c)0mK&>y?yDc+W5;aWzv_!E zp(LU?B97KX_vYc>xhE-18G);gZ~I7DXDfoe2Zf)Y2@_)&F8jUI#AuGhljtvUC8~*b zCwYo$VWZp4VB5vE|Os4+-D8%ZWj2swaT9vU^6r zWH7z25!OAS{KXZ>+mVV%e>OR8d%d}qtY-ULks_;rGyef#4>D40NuoDDHoVZ&t%=X| z?@N(_B(V1+v5$y*CSnSCtY+9nIYz{_vbgQkM0<)l92oGg;LB=;yQGmt;qj_!jBAaI zio+;I10BtEiL3qv8JuKy3=G=Db7d@-4nD?N=14=q%OgCJHsQa$RA$WwIzB6<+W;GM+hnL(+b(SNcAvw%E=;+!W=YEVeCB>7*Tae(l+)h&cr4v zoaLIFvY)Zy*vtAbf}_G?7ePZ)G#mvh8MvWqWDceYC2MlaavgWwcd}X++l@|g$}>Na=CP+#LdUA z_Xa&&?^`HJ$;F03QsdM6D`7!-!Thh1Ij%2pd|jwwp1{PlAy zdi?(@r389ahq#+m5x9t+iu7TU+l8CRSO%|dMb~Ui90^6|_lA6kcSVsxc}GUV^c9nt zG0NX?QVcjWjN;W*^Utv`u*vptX=qwV-yll}Vj8m*zx5wdyHt2dKVIeSEN@e{1pi9b6! zVu83fi%b|@DEZ{)Q#M{)YJ%@h`SkQ(pGLOiqD0k^I8QVknvK3EG5BGa#m31l4B&Um z53uP=s_qoJ?>wHz+FTJ|6N!)JG$*~>n#Ax2C1Iywx>C^D>GLj^@0uRV=gBjVFix$d z%ZAq&0wd4fbdAi#h1pW7uiha(Y1D|3WlOZxXl54g&mj)8N_`(jDga9opy#H!eBx{Q zj;=z=`QW56{iKmulujXJmZlj5LKDNzLu#QcQqX7Qcms(Bru_&}R(i1{3M`*9)hpHW zm!#f~hgF~Y?|bVd4@+qxdy;G;*c0~~zdkL+u8dgum&0r(6u`0b^x&)x)xRR7)$k|f z?N%dGayK`u;Xd(u8ex*QDwpVxfX9^Rg`b|Yj6WVcd6MaVdV9o01_cfW#o2=vb~)gl%B3?jMcO9+^6IgSO6QwH+-*sW6=tgYTXx!Qm*)%$ zazUCMueW3E5=kO7)g3YooItT7m{#yh;j8xz5-{EaZ4F?OTR5xKaf;@7uy~2Z4;AA* z{lqCIJtKiFjXh1VOhU|p{F43aS<(DWS?O-ecS_fdVd+jm{$RjtuRe6)tDEGFq>SpI zR#s489JV6~>|eR-_u2ot9=X1Q7xl?M12D+V&$RxDBc>2=4Ef?y=k??Mb}z^#vR=K* zWy&|)()%&#SGwH7p68#dC7PzPR6M$pxVtt8oso))7{cCh>+;%hM@A>(We)TSYZ|uP z155LrxGvR>7mIX;rA{`kaBAzNX!{m^;7hV9|By|I^A}5j|5Rg7x<+<|&PjZz<&ygB zKW;UDwRLnOdk`H|1O zz6}(DSl_?+ZqeFU`S>03X-UNZ@INJrDd^sqvQI}u~2{{-5iT_qz9Q6EcQBjBW@JMT*JxfSu#XQ@F&F51;`H@FSimVRl$!X35 zU$9_>gcsh+_9`i^zp{>#L`KP4!b2ttY&IbYgAjCoce6@pMR=tqba0znSK<@alC6Y? zg)T%r(Np}PC4XR%+Ts!6ig%g{C{`=vLDCQcgTp2|A<1|MO#bg_ZX z{x6y>b$fcwM^<15QrGeBT|zjdtDtD3IMOtK*9n^84pDp!v(@9qzx>|zOO;Ez4~jdX zs7NBCf8K@Q<#W;2aN}G5ZQaFhzgOx$KprHasg=toA7j{C%&}U^TO`ts@cT;15-5

r@?0y7GMU3dqEz3wPF|8!9QUL+xi>`CV%ywEk6)BiFaLH?u) zJ7di82&MEEIHXTI*BJ8`eYsQxzj*SWM)k=uu#?M6suk$ASW;jL5`rGa z`)!rk9S_Ar&d7zni`ELq zP>|r0FFZ^uEHmaGx-700Vk&Q7kjsVcmkPB&!a$YaX6 zX8vO^j7%i!|E)%KLEb%|0xj{NuQa`Hr?&~vbn5?{HefDzf3o^!I9RZHpivh>b@4Yi z=&S#&>i_@m|IhD9k0WJ7sCSMg=Qbxb(e)jgauaq+1WP)ME#gE#k3%F;c-!SEnt(gw zo^8VI_}kv6&$`ky3fjpdE}?}Yn+tW{>GRUWb#+J_Dmy(t_-R^KVk0I3n#WmrDa;Ce ztssQH+KL`DWnH6>a6o6l-(D~*InE&+e#<@2!~D%|)f|0>9MXxW5D^vrHSr0(%2E!Q zw@L+*`FID(LIouqbs?X7?KuI1S$2PBF#forI75duYo4Vr+C1W!{tjKNL%MYc3Yp+j z&Lji+A5D9Z)+xz%YAC}@H_qyu5oTq3$4QJmv8T^QWWL!A*V!br_~9E z7K66Z6k$#hlPyo4q!$)Cgjsc0M03>?IwYu}#yx8$uabpl-Qhx5!6)PvIkHo_)|#Of z0W(=VS(t`+v3TsqXk6r*lssB&bu3e4wh_!XrkYY(r;ga+SG-$`Ln~&2dBWfz}yRdU{?+;ErUXl>l-^u_) zT2%DIdm_G(fj=KcXjXntPtVck<>;-jq6fRb=T1>AYGjm*^O8`EQ!vL;c*U!pM_@dX zDs=Bi>3}7wgNf|&{uKgT8RomlgoiEy#Jca2ii*JA9f+9y_?e7Ufums}>pD31g(nJh zKSiV!UWw{h_c|eGkoz1Y*;E-p*n+U~hMt|j+|)7bWR>qs3l5>*9dG&1Y|hWM4^L|k zokMyrX>^d^x=*r`q2RNU8%}mWPru{>?j=4vZu zon%4tI_K#+s=G1?XmKs7uh02=%Z;5nqpwWXJQ)}N@ah#aW@nSc?UMULghKxzb(^CZ+=vGnMQQ$Zr4Ix4I?B5P)Ri{Zw(^^AvkT-O3^lrkoN zd!Z}kZV(}3m<>m!BaGR>%0{Jq6SrI4xp*J#^=@s>G1KiNF-la1@T#(odU%g1*UuA(XB zYEM=_KG|IrbDoltS>V}_r*j4>k%uTkKufLk3OFKt=Ux`p}m2k8O1K7KF zf9~Mp5BD7fB@Eh4!olKIiYdxEPK_|mZ&a59Kk5$e$D0}jL+WBd%+3-V;uGp)BDa2po zkqPnagyesJ{J4h<&fR{FdadL1e3>cFGJ=~S2FMwcoo3`EY(C~KJwd-o-gTXLq z;g=c)Mn=%Ur;pWKYs5){kqHkZdud+j=GtYv=%gN7U0fvJ$+c>yE$t_BcUkj*d&xc% zE>SWZ67QdP`P@WSI>07Xl7nY8PJKB_8G8^5(P9`Q=f8PMK=J;iMnPzl3hryMG&Kxx za&?|-)potfDCGQ}?;}UYbIHIa{$9oBVg2V_FN!h>YyD65dxe@$`z2@Dk#9yK^hQSa z(QPdf5vbN*ZW}*7!ng<+E#bE}X7uaVuRCw_+HXh%Dhkj>jX7!+=pvtdu`Z{Vi??=QF-Ld!f} z_?hFC14HK9ZEvQoSjnXQW%0nWGz&b9a5h)3ONsseDL~M$roV9)7LLPg{BK1EYk~r@vqL*4EY#?afZ#D{SI!8KO2oPIlM7Q;`d|30wGW zcQpB*w4w>zKhAFE$OWE<5>mypP9(XrT%6yK_(lia{q?b8@CmuvKy)_j|9Aw`rcIjz z8pj;R#6L7O$;!zE4(BP?*4Fy?_*hR6v?*h75Nv}A6Cr^SwP!rIAqwGbLGf4!%x8*L3C{!;r|#)%E&4>TvY;7sL_FaeR@;r7hJ@#J_*94cv5bOr{Uj$teYo$Yji{}<_H?+!x+Oz8N+ZX5$K6g1)Z>&w`<3h zgR(cBr#`WWdn1nr1J6fZtCIJ9hNlK@ZpuJHPLtElBw*v?c%8>;a0_?j-;J7C3zrr= zf^LsmvxTm>=0(iyvcx4ii>3GWCB_8DQlB+*1b`jHnO#eJU-;>h6j-{~ItbCAq4Ywh zediieOuoe9ABSfF8U4u`1$9q11AaKXk!B*RpN3Ar&;rNm6A~o*sFhfVHBVkA368D$ zZjNk{#8KPnCZx>;Q?zfVADc(_JHmYSAGPkwSvQ{!WZ!zWHP`Yc)9uSdxlQxoPvVIk z#oTyk;axv%TJ5w7o=9C?Js=BUZy@Z=5r&WIHQX+_X+IHY^3TJdZ7LJ9I~o{9HWq%w3gKF*_6J zJ+C;{5^fwo8-+JO0Z5|nl)#8&N+XHPsrOV`g84cpL(C1P*h%O57<5^H*ZRcvH`6DG zgl_+?%ztPNRAshL=jH*FTp)(8#mTs}kxDwvHZ`WLQAaV=P~-At`|p4M6r4A_()xnf zraZN~#6e~Bo2U81u=o#;B{Hn&-?6COxf5Gd1Tc(=3}(q5ec>w|^}GcpaeN&5<@65O zh%EW+ERB8pwzRaw8~GJX&27P2TsF-X%Q7Ex{e0Zdsn+R*+=rY^6QAxF+33EbZYWGk z9O~N#s;jW){k5;}CA4xIo zANdJ29sV>hG9UZ+X#Ao0?}HPryV)c{KB8%-C$s;U$->PA>6pV6F;N}X1Zoc{By%G2 zxVu>haIryekGtf~%i&!OKirE5_TAF2dNls$cX4SGsIcirM^D%lxHGfDOX`)kb_~bu z=GU}D?7^f0`vQ^*saDg8+SkJmd0$pcZb+%NRzssah$4^t3JMB5Ua@-96GwE5YxT8t z#xQopFdl$pFD@_7y7dZNIOQ?Nj@m<-D;*sQRxP97zCm?u7o()}6xfCm^LmigBu&o8 z5~hRMw{AZDW2UR?_T4T;he{nAslNcK&?ymSmFe1fg*i^uwxMc~*C3oYye&bE{i1M_ zO(_f(jmF$g5xjM_JEEMG<1O>YMJk|u6nd((jNxP)e|gCVKdP}z-|L@l;yB0U$S+}8 zQxD!)d*2a72d%Z$*c~4|l6(S}%$_|})=3vq!qs6emqh;jqdJ9zQ{lZopZ9Do=H&`)QImxoJ#M>qDNz93t|7Cm zT~+{!6M|0~sq)$d!GFI=hJs-3oy#|q2kIDoZSCiWKkmw%?Scbo4*)Nj{q61V#DyJF zW*Kil7j-4iWU`mR32b+C>C zb2!=6lDgkTmvHdC8!Z;aCY-8mMI*8-#TF%4iU16otoiGN3^z?G&M z%Ow5xb)2l*jjVv?fAirie@5Qig^!Mxov{}9siIKhZb1ddqkB(C6NR*@{ODA1 zMg}pTth4VYg#z7orp~nmAoSg8L4A+uRo>jmf*n*teH!;aoDYAtzplXRGTY=&OiaA? z`?tP<0rf8!6k<~2oHe--+qF=^Kj|bfWrgMjlm>d5De;)0a65u4J5|I6AJLzFomhnm z$txgm>)+{4?(J8!MQG2e$qfuU_!t8Nt8;mdhwOMDJ&(aJ-<3ULFKC-riDUK$m9}|& zZJ$!|QtoZ~Fi=kb#<1*@pHV`{^AX37vatVM%+J=@Oq@wZ6z3bTIr-#oeZe0dlW)%o zSImWN5Hc=fh3#HkcdBA)(FYe`lLRxk@ku8<~(Q_F$lgD~H6Y4*bij zSC(Ki-sA+_z%`8O_zSRn`PVPc^$En_YC}T<=-A8TWLjF<)~n=<^!pTXT}rt_P{0o; zGt~70%?k(!ywDec3OX7-LlhE`GgxEQhF?5({43@D{@S<@{7?`u`79=Y#&@JW$Trc{ z4VF=KeQsXoL5PbTnMX}SlLZHl_5`UtfJHDwb@0>nCJUINTGEp331Oq#!pQ;j$adlf z5-27zCei14TO-F_In*#va&M?Mxxu3&5 z&%fQ*v?bDo$ew+ATU z%sx6>=aHI|o|zfHN1TXFC&7p30R|RHEa4?nZY>6@E> zDB_)I%L)$TO4WS08t>um=JrYlf-QpTBdG4$zF%(5*6cjnbOyruqrpc^^PHRBcgnTP z+`a$$!SvJnNH}Ur0B0co3_|U$?Lvf=%2G_#-<`NGhic$}p(u19Qq|_BuK4c69JgGM zV)6UihYb3RgkeV9C|=5l(~T#ArnPPy`U$HeZ~m2-N)~ruFSmh(1ehNcodpOQ9NoaY z3+pCc_5?)0i1|S0nr9%S@N;%@0DTrAIAMqpyx-LD=@WfjN!vVyEa@-{r~8b{h#((d z2JFG3(#FCyUmqXo>TpAs{_Ep&9&_9j_i=yaps|BKzc;Cb)M#*Pzsm{5a1xK+D{K+X zZ+}gjhQ*>kJJ($MNR4lg+YABbE(#bDQBedcudIw>7ARR*Nu$L?vcAEGq9P4; zN^iHeq?b84LcMO_AK*Nf06|at?ewz*99lU$|CwtE(GebXuF3KJ{jRJSfuN*13uvBz zE)hMwz3#PUFJHdIu)k=t0zmsiNKH-KU~G8@RPL}{=bnX5k}#$r)O zmcY|(2=`|E56qFlIusp5U~X;!@PS>tnP=!IX|bj4~rtb08}CVbWfB4Xw9b9SWd z!<9M3VRddSq9S7d1=K4L~Mqu(+ zJ&;uxDc~fW?-@>DwVk>VRK6uB>36aS>*7Lbwja065lyn89&nQcG_xR2mijYF>wfdf zjOyGo9jOAdNvN1ElTwmkCU-v z2mH>m4~BdB(>P^ zL$I4N3SwXmB!O=Dws(eNyA12K7igf@AOZvLe!P_NgqE5*wQzOY8vq(~r=NC~TS~R5 z^V;x*?I|j%172uQ?%yedR^V%E9MWZTN=r%xHoTrpl+6R$_&45s&cR7V@RF7IYImM( zOxwJ?M}WWot>`uIT|hX!(8*{kq1gV7M&Yqt7A-`-Nb;hyG!eKqzY&6nI8o6_GBlhN>x9{Gye89oJ z^);MA3s>tJH4y{^;C+x`E}Jur($dmkH?%S&Ku`Mz2TMy!yBhKGs@c`w4o7eBnA6dR zv|q(Z46zIQ@j6JJ^`!=cYYA_k``tG%0P-MK>L(gpt?lRMm&zW_If6R}Z|x19-re2( z(W6JMs$V_*AzlUEho7JSeQBxc)G$DgnOgS;{K{gaB?l0k+}az}gOBcz#R-ykb{32* zvg0Iu5d5kB;w3m5cAY+Qhh;rnmn4qsC#nk)L~I#_I5%45HSCaXU5N}f8WBt$ciAnaJkaX5 z(ySmiIcX6^H8;xSt2TG&dK84Ck)cim9A(v=*5%sVRp8`#^a>%=*!*6}B`3!s8>I7E zegb|i<2`jC#^RSY|{i^<(MLX z+)CR9d3>=kbQL`qABN&!ncMEh@TaY8pjMTVuwpV_zpA*_zk>0;qH5z*ARldzWIO z5q#%tt*rYSOSFQ zvvj3>j%fM$m?Ky|fDV#VE+Mm<;k)Fj)g^17-__Oo&|PoGTZZE9EoSncxZtuLKYk2c zJ-De*froUkxAn@{$e(d+ZxmVNSHGf=L5fU69If+ZT?-1#Y`VLOxfDz*^Dgv3>hS1l z94gdI$$XscqkO(f^d*f_#r2Rfq}T0L+ZEckjI3mr)HBPC-z#K4$NGyZwP+NqTge0O z+I)Vz2AbT|G~-+|{_WcZF)~qR7%pCMk>=SrLHi^NHUT0Xb7onNHzU&E z4S)5f7#SLZhZdoXSX^4l_Wl`hO*K~LC;D0$>FDhQmMIW82!<^HR$UaWMqd$|qlxIS z28?fBZms<9{45vWmXpnvZHQj(c;y;8|Ltvjgc#HY^3$4~qw=}G02lz9 z4?3vaIzL=cE_Bjc{ZYYhq_4kt=(nXfhoL)xv`pDmsjLBrLEw_ueOabQD*vw*z&XyN?|0BeHLom)sG>m_Gi z`N(AQ4rP^H$v*}8tbR03=GjTH8Ga?q_xX>~vN$x3YyC73phFxo%#iyAy_MSO6T1i$ zqZJ>~t2}=Ep&ybwP5_<2df?&VDJv^qND-{s)hOSCEX0cbJ)^M6?Wiaw%hdbW=HuJl zh!Q{QQs6^ezPC9%*L{xel2;X5A%T(r$B)R?%PnU>2XE6u24}-7in&cT=dMl?M58b< z4M+-rHuP0Lx22F)mXuU9{XCDe6sw~7dj(S-L7W5ekdhX;)Ro_BYgU6Qgh}2qg1^Hr z${1hlLWZ0&0;Q!zx;{IBb=R5+`|p1HeT49|Xote#B}s8(zCNgq9Jr~2^EuRY%~j@bHHLf}Ef90V`iCv^%I$6|bxmNz z38^@{E2iG*aec~%kbWPCBhS^Zcm+lYcjLC4*&<;49RaRN48wK%KKiP0{|x2gkO+d8NtZb-*ouGE zpSMN%$}6+OIw($DQnCgt8t|w&l24d_VrA3@*qICr4npVvek(H10|>nYRc0Tc+5`Wc z%(f*&nQHJ(!=(XJ2a=Ii2iIzmup^G=<~eAJ4V)B@J9q8?^8uw5xY%UjI0Xl2ZZ0k^ z$_Q4}=fD1;Tu-Rxu)> zifk#pFS;zasTB(?*(s0K?*AhS40cE-fCd(%PWR$xEUR{W8+&Xii3o99fOSqu3Gd2C z5^U2z9rCrtfEe1p=D0lEnu2{I*(x@dgCL^G=fKsaLaf(AgnSB-+X0a9F)Gu}2Xy!S z${~&deP_0U?&0zzG^5f4R>J9#Mf_l)EQg(I;^;-NY3{ueI)kV<=lUaBCautRb)~V0#Y1VS-n9B-Vs|N`awpsd#U= zwgZR`B(6VGy0WT@+L7=#gTlHB?o|(Si0;RX?#IxJ1fwZhZEGeU3}k*Uw^2Q8V(q`1 zEn~9yN9cZ&-{@PNIN4+FQwRf<)@TL#xXr7GPP(S?B_+9ek+>|!gdSLn3N6mTng>BR zm+z#D6BaPE4>EUUvT6}f*K9vTZ-_YBVn5&Af zUqx5z(c^e2qP*`Z#j5sP%awVxNzYs(oEs6zjV5b-jxggx*`5+*CvACu#^+S0bfXzc zl@=S#f9KOipi@zp#>*miGnGos{w-+-;zUV=3wK7Z{PSW_G&PSGMsr-^t?P6V#v z>MmvG%|#9+3pQk?$@ZG?>z^;t+M}L8UapOo&elIIH5S|Av|_Y1O<$G$j$&_>cDaKS z^cmueaR{E>etm&6^8=(~{mFs=I>0TIywuzkmnxH5WgT<*Hi33o?s z(39kOXfD}*LWAI|ka^a$I4ykcaI);R6i$7Y_gRabL8mP}_8?XKy{^?kLDktOh2o`7 zbWc}R>MGF((4k_NV9_`!Lb3YOiQTvGe3^`Rvok*fWSdG8gLvrXzrIJ z9=E!8!lMD2^-`J%?$K=~A05ZqBd9J|{3j|OkSARk z5zq1a+XKOCpUP@vp3K%~9C*X<^7}eTM?1@dnbK6-ifVUWMa%N0UN)1>GcT9IbIE^2 z!f+l{<+P{|o!+)wNTDpkddG#8pcgesiujA$d>Kw`i z*i$RpeIXswzucxh$IZbU#-Wgqh*7RbL?mbw2tryRoY|KE!`;rm%LjAP0hIy^ypB+1 zxK};G{pJa z!<8_vg`$`PJS9}WUSZMbOZb|p2>M!is!{+!7C{#RVkLdOQ{9u<@jl>?h_wt6>^mzNjh@~>~>wznT- z5B!gJ5nKcZv?JmdBZq-DzC9}Tp`_#jHN%t6jeFlP7|KOYwA*KEYlILCEB?~ks~W>V z6V>4-%km@b@zf_omL`Rz5-OFuN(wOa&6>xPU^1LlAq9QWRsDr2ZE1q_&Wah7%4Qil3uhheho`<7UB@~XABwbgs`X%(kR?=7tW{jezS#fSKzkv zj~sbcZ3~NR@TfyP!`mMUE~%2<(Qt5Z0E&?5ruG=CtYRzFx%!^%jkCR%Vv2=;5`nlw zL?%obvvlZJ=-~XLY88T&G(Z!MDhl+$-$j|@E~FOH;boH>dT;LBI$5twXlAo6Wovqk z^#|s&d#-(`NlrBAxW8%@MF0oZz^?jS&kqY{3?yP`H2j=Q)FALW83zvderg zlQM*^-$jq2cE#+~Tix-3$gu@&!QX)2T!01TZqvG{U)KWKFb9&CKA$lB z$P0}C-s$h}f8i4S83U3CnssIG1|R`wCoJ$IC?emHzQ5(F&5jNwoEh#_!1qh)C`3SOV_`^ua}z;L(pN2Q~c{ ztzKPMpHBI%eMp-3ZT0pr-yDA3h2glZ63cW1A`W#SG(^9H@rausPYN7=j@uU=Md_9n zlvFqLquS|h-od-ZUnXy(W^0-Y;%g(%$Jujv5#dH|y{*wYi# zCBzWZ1R*(y#F#i~nvU?ywfk}u1Q3wQo^G}E*j}X0Rm^?@*2&f=_*1tgNTRY*g{-H0 zT!wf<7o+0YCeLVTJ`VA7S>sA}vKqTfeH01_ktPg17x%)RmTxz<{-<3-!GyLZN!5IB zC6%)7RsXgP|KCf3IqH@>$keiY9ed{?f<%zJg*w*?>;&Jae|d{uzjsx=+8JrIUQ&Us+*9zJ9-ET2$>beRFmt^UzO5chHvrEeyv2wrqDU01SEJH9U zN!3sO9l9J2NU!E6ULX!&aL*vWhsqS(D5Yl#K73+69w+m++g8*B7<&hX&QKT6md^4k)+|EZ4mPm^Df5R(rWIgJJqwB2PbEs24{asGF~QQl5$&A8?97b!RyN*3%3EWfNG_iyQKOKp{;XX^^iclN_H7p z+1uL-nWkg#GG|~c!@7e*9r9nN7Md6t9Y7%mnji;qG7GUcW6>0{p-P8WUP3l2=%pqq z&jU*Mz>6Ql;lzsL*GxYCcyBnRZSx={IazU1;m#?T{n^*5alq}g?#4J-@*^XOJoxBthza`D zPs>`hhC%}I0(=4~XkQ7&cyDd|B87cTC7NE!g;oFR6mI4nt^rZE^s8iD0Prsk1OUnl zMK*#dP_vs5n?gF?4@#GKBQXm*S~;@*kdJkdWOFEjk}u$3E})DidolvtBdtz+pwA z50Q8j7%E5CIl^U+8#-br9crN4MVCd1u_dr7c%om&(`b|4a%Y@hlf&hXtZ-kxvr7QE zI~Tidfzq`r1Jo6Gpue}M<6od>s~?*p&hs4@e%Jk^2we;^2yzQYRY3|2eEj3@6&ze# z;a{ckA)!MX1&NTHK~)sQ{$W;&zkYpps)lqu`PCa$@84^xRiQuoKz$$#*phqAC%{CI zUI@?4KZPoW0*8Bcb{th*!NI}TAALIoN2*AIU!d$BUHSe!i74$)k#p!9N*<2&0l%nw zP%5YO(`^U$XW|V0@NVHGSCD2_K!nmc7AE*mx;rUo3l-@jZFgE#Lwv63Un!rC0mzIqHnFa3LxRt>`b}+oqEeh(MFaAXEGE zr*-bRf2jfeyPNTk)_$*vwALLTLvpGQfT6+D_=g@Xcyv=wXZ_K8T`!`EX-`@nBDODz zCoU`@7`wL$p&HttBH2=|$ky*x3~xM`WDpT<#^7A`qJ)|zE|L{8uy)T{vkNN^aCKq( z@MlL9f)5~o%d=rme+ag5K+$Eqw=M>gK?Y|4(!J-WM;EM9gZDNtoqxez|Jc}vPg8iH zB*o;G1OIL+Z8|0@NYpvvVvajG3y6kX&pdvrCMVdMA^s`*O3%%it50!sf(d(R_M;w( z6Zzxw;S!o}CHy3d7)i;b7m$Y?&7=DrHPsp)!ReGa+Fmg%CnWk|;?s zEOUj-NyZ{&R*96c2xUm9R7jB|$<*ZCf7i1;-`_vr>C3%s*WI|*c^>C+?9-8#A~i|y z(!0*x>zjP3-1T`*X9?zAHvD6WFz^+2yp>saKKDozIbYU z)-N0NQ41PRIhe?rs*h%w{PEmQ|%Eh z^JBH0j}GpO85)_i4l5g*2WW*2;)KrQNe8wY_5q(@o!?~do^AD%aZ5nEazdGEC-Yu> z#tT$wThitVHfG%mwXut<%+wg`qqNXCL_mZIG82gV+5`w-t?}Jph$c^8+75;b9hSmM z`MvAdMk3#*`z?=x1K+TI60M&ye~XVpXE(3UB`c z0YIYb+T)K=V-#K5$PzL4yoY~*Wq(uY%2hmm$M!C#40*o@z^S#wQw^57rJ-SaZt)jD zGbiV|RF6a|9WrgJB=m|e`}=cq$~~^8+4&lSOmlD${o*F6VW^hOOA^>YA3xU|$pdY~ zp<2JqKYA33*~NmsYbV+v(QWsoQ8c^@8wG3pz_;c zz}voW?#B0}~);Xk$mx3^O`FF}C6sZ=cS%<-_?O_^MwXD#&jpgfNKyWnYI{lZZ zfd&5GhN{@vlm^qc@0FF4qw@>5Unu_(TZ;s|)RdGFa}as7jqZ2Xkyro?e4Qe%pFK!< zGy%%rGMv7PJMVf?PGG~G8ojZtZ7*J2_}Q}?kIQ+?1>g*Wfq?)7-C`lD?X>jc#}B}Q zUI29yjjGWH-n@AeWgal;AR8Bhv{TzH-G|x?%gxd5FB(rCx;$fKhDSW_9i{;M7O99kL z%vZ+MZgbbmR46-0R=SDVL}7)5%BIrASTUosqodhX&9NaQ)4SHAzt{?$KHODpw){4$ zmIT{cW&@ChL)=|gS66(x#QRWI_f7rn`W&%G-!PkBSdj-Vx)7FOY84d?~)Y z6>`IWe}CuuUqx(FU_B}#G&(O5cPE-kI~7K~=4e&RFeQ%GzkN2Tgh;bb88n#u2Xr~$ zB&7ZsNik)w3l_QG0!$k>yl$BH5-SfU1Z2ITEfF5j9c_&!i5yWB3Cw$&q;^ub|HQwW zEE_JD^Mqy()IX7b%maI_3zy^!dB;G!NhQ~1!KjLNRBAN^6sUu3YU$AaT?^}CsDyb?yd zJ<8bqHwh`CWQiS%DEO6km`IRb)S0f(+S*FYWSE;`NEL?oil8RC09@VQ|1K!yUlaL> zTTCW>_~{mqg#%8&aD~4*;JjxLdVM!xHxlNQRUBT6ITb{`$$F>z^UoI+^ zlJc-4ZQQsKbao&FJ16R>6o4J^Ixho4(rzw?!XIwF+p{tRmIzE!M#{Bd}My$u-56X*4Zy`5?_F-&Z{_RV>4uPG;2G+2F>%oYKFa= z<@MA1X9i;di0W=?wTkmvu~kQhjIl&gMV8E;lzdT@vMHOEkXbA!cyb zYnOe3=mmeZ^*{!Dgvrmpi_?8VDC$dHv$BJ^MSH^6uvIIjxY|U^>C+{Dwlyp5t%5LX zP=YC`TlrG2vzTbJd_m*wkgOxs34swWw|@21GAknwJG6@O+VDuSn8x{LThalBABp3W0!!V3vmrk2DKzrHEkY00tESaE;p?e-J89w zZ7lVa*EXp=4VjP6ebrDSzV++dOHN5RW#{0)t3Vl19+e|)ko`;dIzoGH&~}KNt}lAI$eaG&U^%OE zHm)~2VVH2OKzDm*1g~#S>grc6AfN$^YOee_B?+-_845TiG`^Ub znAF&lOL~;kn_gz5chhWaJEL8cxhQU4caIWIpOSl!9XTZ0e}-H z9&oU;)9~n(f+)v&v;Ie|g~20${j{Elx9t71{%XXfb*j9(lv4rd13W<8JP)Lpcwm&= zO!2ZQ3mX*wJbr)p9-KJnhQ@UDoVj1gNM0^i+kJ7(NuV&T!R+N9aiI)FEuv2Wb0a?JjQCK0hDtVb0v_Y+_LYTw9!H}@H;LcM%@AqqH zForVgc3Ii|Aur5sCbfMk-hto4B%?TQo>}toIXn8!8WebkN{Y#dP%QaEVtA-)Bz*>T zwIa3iZ(Kj8k44@uA-8=+N1fMl%^ylW+S))JR*l>cz2>F@0d93c>7xL%5UfjJAJCqy zWn^|g;L|YOGrOB26hu7hmxNjWGQLQMr6Xq5rstWeeB+i7G6-t?;7^n^-iK0#!b7My zR~!7b+nUzprgxS+s~r(Ss$ekJq>+u2Swi2hk8i-sAEO(}6DAv6ADuS?S$*wPsqD6G zyUQ8fm{&>Gwq@L$oLhFTNPj;wGgvWpS!Ad5-m5Rg3oVSe)2$M{LX7*AkB>A(0Pgj- zzh?G%?a^yJ54CT3yxv@RElhl4?g^tS)d924lDcWi2DHG%Z(Dj@_>7E=+tDx8IW{sa z3E_>`LeT@a-K5FsacDisH_bqaG12zBl6tayk}gY&x#M-(uE+(91RaWhw)hAq0~O%W zURQ}vk|jyp=Qg|v#D$W%PmKLInW`8~CaMl&0(Sh6kz%{`xqu8QX-2geu(h%EB7n*xYp6d-%j&0?;mUBz06T@^o5HCSGdeM9HlV}oPXQ>>-dAflJ|@fgX6== zo0-?lj>@E5g*ir#Z&xy?vYLsZs?QCf%sc(RpwHhVktBL`6My5#P3p4(<0PNews48y z55;3BWeh6*!S8Q%xxwpwG*9+_m;$Q5I&EF*w`}a42JbrN_WSj*vb)FM2%YA6%U5CE z^Dx6HU6124PTwWyDzbl}*ceo_e);mn(tMYd2b0&RfyV!80g~-^`v)0car~R-H&7U$ zaXmXbKIF*DEjm)n{15E5*6>0lkk{jmVVf<4ZeV>=B7HPUpMQ-_YEQGE78a7bUuQUU z$Kxt7KBF_0GA=GDF{o&3R?XMqBZ=rNY#QZR_%{5e*8kXgUpXOQaPrTZUPXs|g2qQa z6BFnb>++lnEB~lV=?{)N+3j|YN&d#aN54`IRbl=1^Z-ayoazD55?)UUbCC8e;!;RZN0GJK5ToZG}SWI&>4d@KKbD)dI_jZ7z< zDfrM&*NO0-Y4d4F6^(y7?kl14#I1@oTqk`Rln+OVpntG|)Z)yAm2(s8x+@6zP-mb? zrQGBBSBI|N9Erk_w}&7cBc`t4&)9XtW;VOadZEpD=I?W9eMt8JniyX3Iw+7*!XnCHyku6N;qli=Xt^J-D z`mH7M{IzXt)=HP-%=&nZ7C45Z`vYs4^Jwi=0bQQwUbay++f7yx3IS48>hIcT7sZQNzA=p~MLrf~Q}XL* zT21?==S~W!lx#U%u*2-S#&Q_fR_nUc$2CMzns)aq9SKtsG-}YYnk@S}| zOLcbs)4S6;aC2{q?g|Y0*SZ>lxKYNsRvqirVVa-Blp@{CjW{;u0a!YJ;vzc)7EYGdshyr}Q7%DP|Bp7}%zqG}ELw z7#Foh6Zpr10bd}&Ltjz>jLK?<0LJm<+~u9?FSCmVG>pwc(LX}z%ur&V0WAB|TJox* zE2r6cW#JUpWe_sD_gkQTNAnHo>3rY{ddS1)rtcEW_)7zY`_8czDijG9Jj4XM3+`JD zx?=pF(b2v^np=ja2HdJ7)~!j6c9)CnKAS86{|R-Au(9gahUYxZB)0I_yqupx&X*E2 zwj9`YBW_z%^9V3r=VRHyzi#zSYCxcwvw1tK!1&2CUEIzsHhQs2sqEk_QuT7lr8^(L z&(6%0Z9?kuj$~x5Bu&$Gb3p;=v)oyMV2>yUmV8qBZvjly!qeO`Bbj`Smomr`rKZ2$ zNkFfI$?t(=9%>b!(6*XT?HrtEf_jDnAO_ySw|K)LmWtTUpC9IP(fM+k{ppPNGrLa~ zz>^YO^f)xYhJNwLy`***Fc_%okWEV5Z4k}a@n*!Q2J}QO3NJ02K&c#qM8p zW|(Pwz6!Tq^`$*h0_a2_5`ofv1dke6jL$i}TYKLl{nTC3?}$w*e-T>}%WI9XWT#I( z@vLzwWRl|+rye(%hBa%RCzno%#vetCL6xxuc7?w(baj!s5`IN$A6$e41vkfD!LfPW z9LJK&7JW@9aW?+;P3VF)TO-!PgdktC0yH#TpHcMX$UT7h0CG})iEU?Ccuhc0pv(=6 zIAoYdb>ep4c{&z)NSm@&N|K`4bPjTOb(w@^$As|uwX<5q#k%9#WenDa3L1UN5H>Ks zk#|n*R!~hb$(0!uTUpvYAsXfQC3r`UtJ8wx!)%q~SLHg4ju%u%$6VUmr-2sMV9ScW zB;yb%Wo^V@=%v;RxwfPdhOA7-qg~vj+;D(gZt_6_G*{OfRD0In)RwYOi{aHtaPZ9s z=J!nC_A4DT2Z+s2lSv8VUIg|S6WL|$&z@v)?mMZFKA{-de~3m?0F#cUcF6PYJ&!(V zGU?^Zmo;`wkALh8&e1qo_W?PFPY0-EnqH}RIEUvvf`&lHt z!PN$ljyt&r@ibvJ2F@Yq*Gh=A;5Z$81G}6-*DD}FDapxyqHl$KhVL0RDqazF@xFsb zpY`c};v6UKL9v>yJG``U0e4^ z3~5N5{P*NCSEH~j%dyJRe=9@hX4GV#RjsUCxBT?;(fKsqu>BDss1wDklFkMg9%b<;$zE5z<>Dp{=y&o*^6pGrMDKudQK+GM8s|Okapz`Xa*2xcNidQ!Dth*L&PbWwAk58Xe5zqcT%qCKZpL+F zn-4)Jjvqe{tWDyjWNq{G>3mm#FZ6`Pm4rwE&k+fQ4AUf#P8iQYNIivY6c-uj{$;8! zXbZ`)u-a5k2_#FrEHUF0sJ>-(gZ}-JdoN3KiS1XvLxI;`hXmgk%voJbYCohF^35?# z`^rtpJ#E(_Z?l9Apvi=`z{Mu=;$48P3k#=9Ob?8HtY=HhZg=td!Kd}-J5*+eF_0=l z?rCV?3p>5@+gtYp9;*AyYhNwk=$V>FYpwx=Wpm2f7)IUbCr_RLRukMU#2JBpQ*d*u zVg4+DEp2AG@WyiAag+6r2_Rves;(@R!im>4vg=Xd!PxX&G_Qx^bZzHqYHIE7BS&fq z&^K%Ri~}S00istSIqQTMpWnQBbE0O1Ez2xQbWlC(+v>+ltvLNTPxDk5dJ_^B^b9iE zw!}PWsy_GDTTOzv-s?gg%Fd{*2M`h@B@n6}>Vo*Di>d;Kac>w?n2(0=NhKO}AG`GH zciv&fP9EY**ShNxsc9&9$wg`1NS%i~xA3Pd-%Q^c?H%aJD z%7DaqNsG{r`#qiejVrfNI4Mzjo2$CJAlWr$v!bv@NZ-9NyBK**rMz6%Si6@r^y6-* z_HY*aD@*;CP%cWefIMnrbyCcLHv>P$X4MYt!4E105|Xy(l&gN9|1v!{&RuCU|2w|z z0<@B$xNNY?H}Mm%FDpn*441ni@w!txpQnrI`2GW5peEFyY!aX zRI#znAE~fDawLCXYOj<)hN&A~O0o-fseY>&gHj+;DF}}RIS5)7wpo6zU;t`)Yku|S zPgzk~-Ksn~m@Trz2ucE5@7~%m^v4^K;?bphVpMihzq)Pmqn*T*8 z55HEucr`UosWdOS)x&==Y^b_1>gS+yBZV^puYPVV=vvTkdHtR@K1k9Rr35%guR^s$ zf_6S}7IWUeJLD$4F62Jhizi_wsa;AYq3KV2_f9E~4^9tI4|_JRZ#Of{hx8kVXymWBz9 z0xCQ5dzLoH%ZlT2p^9 z>R^v*vuU)7OZ5z5)0Fo%N3x%#w4N%$B1Wc0DLAX@>qT}jlPHGo^FXk_`8>z7UhVsn zbtgatIE#}*RJACaBBweP@~9=c`k>*Ua*JtqiEVY3Jy`m3YCgeFVh56sq*zaLv}(EE zU->(@<@Qeb;AqR&@3`!P%MN^-q14F?z>^5wjjOEH%T`i8mN?C803J7b@e+TQ&F zsq%ECQb@Nc)7QlUjU2;E4rRL+Iq(mI%peO>yP9YWXEa+QQ_h^Sii+fyI~G*DdBBbm zqy&5*Gx~Y^@Lv;>6F6MW_gGl+3%}jSIVXmjToOk~&IaF=Bp>!W%c>jE<@-kC93#yO z`^tsjIJ4uIY{JXek2Vc4YEU~%MQQz;jR*S=n{hOQ&iQqJ4%yr9HFUm4|MF`r6y`p{ zPz@9;J~3z8ATTcgp&R3$u8M2zbeltLfRo+Rbig&^z7H_lfrMfUC+;#(U;=E;Pdl)C z_fufNgfea4lRy@sm;-D#SmQf`t$C<>Mt8}A20{UY+YM_~|HJ)tQW*b_!3cBnMn7@F!(YEb)nC8Rr+QB~H z%|zTKrkob%NSd(?W&0{QhWws@0sAv|@88cfm3IGRRN_$W-UBymg+4+_rK^D19{%?u z&Dv~Y4yGB%_J4|X^1T%fc;6Jmm|cjkM9oGeGvXWLh5ct1_iRyFzPVaN$mz^0a-cy_ z{$jk-v-Ph~*%+8s89N&BSwk?{%%^qD6vW;Sj$JOjp&t8}&P5F8S!-2kjp zTa7MSeWuGrk)$8#pCI@5^-F6n$MV&lzx_lj1d96Fxg0 z-CNiPCJ8KQT;HUrbP~+SkML;}sI9$t>zE+D3DX7VV!{L34N+T3jxZRUz2w)xfpx;m zYrmC0e&Gp7^YAF=Jdns)-x0&Ix6LYH@AT=N&+c(bbh;@W*w$iMl3*et(9PE2t~{!0 zzH6Rh829QPcn>wqQdiogs=4&f*Vu1u87=Ia{LdsgV88A}X4lQWNhqucb`9w)W`zgK z9CkrV1{hUrN-49;EM1<_Tui9Ac|;AJ>0umIsysNWHCn-cz(lin^t)Acr7JL(oI00# z_XgoHk_bU#@7OFROCUDC z;q0Pmm%DrBznDGDS#4#tT`2CXhA55l0B&TJOoMjX;P0MKLXvSIRl(@=#4E+ zCDp)<12i10tRhn6kH1!qemUWo(B+2b7(~lkZE@%nAk+90yn@d@?#g%ht}9<8=^^zh zN$nO_%krpa4IY3~L;D0^=m6)|O}8#P-?rEi)%!Mroa+;S2%@&W1St?g=1iom zoS{{6M@VIuLV;WhXb$?qjW=IA%3lLUS#%9^^?@>nzYsD80cRzRDrR<}D}A+9CA?7{ zAwr68%IqHeAkfAn7X2UBfP+&0^u--A*JLc}EB6_Tgu$938o_<(ckoNvZsKUR>TO7j zhV}P23iLNLwn^;;PsbE8yYRW%Fb3>tU^r1>ZIWhg(4J3JaPG+cG7(`KkZFZ@hOATvOs{xhcC$I%`U60t=$l>A>g*h$dN7F35+VP9j!Ij2R_m8XzfJ7 z1gTmj1~E2>l7?Lr@szCNhMMm@;i+FFPW;0k%NoM{PPeGBzE9R3B~F52e$UwHql#;n zP7@LtIi;7q4k8Gv({ZI8a~m!n zCiMU!dCBT$5<1QNTI8LIdX_h+ZYT{{J6Az6eSETQj4^`!ozaAsP~12>rUA_LnAJh^ z-aGo@I;?i3haW#es1{x*1O}*Pc1`-do|-3=wV+1eta*yEhpz&skr3zh_97#w%;zyv zF=_c2&OH7tQXosE6WY&Rfy%Y}D4eX(<C>B~X?aP&e0 zN(QEGS!{O8DO|Mg^?MiLpr5N&egQY+@q6S}?AgyCdz~%kc;&wkk z2?!*6dwOP*vj1T5$@681y7+mb7nz?U`o?i)o5FK16~avp0|EE_ZHY!iktgRzkjsY% zi7~nFlHnLq1mdeEx9{cKQ(*UW>|`KU?!7xb?mq+Td3bngoiDfoSV9wnyiVg@7*qc> zi~W<&?Aq6Na(d{)f!=r9sFx-zBh4k)xLpM;_;qNM$M4Iq(<1}HZOZF_y=R%7s$zCnz*<&{wcIMMDua1^ud$Qb51Xy2)8&P{@!xYDkT+_KNb!QPga87B<0CpE}KA zK3Vjm6h+El6-!QF8iYKp`NPJJ)0=%-dKP>0bNePA;R$^I=aVWBbc78=SB15}egb-> zlgNF9atF5Y`g(g3KZynK$OYg9=3w~2FhNzg%E)08pJ~h=_IigEERwoi9Q%i*-r;?B zH^ZA9%F9sp;^K|=WBex)VhnV*ESuamME=4Wx}&S&T2sYd75O#dNdnxa0jpF$Yj#5U)9hbDTm@5@O++aB$QmM8u?ybn( zX9*1Z8S8Q9jb4{<5fGBGfQC&rmbHUc@f#DZE{BmjtIugw6&2@g6kY9&+UIC%y!k^z zy3()y5Py=k;6dv=oE&@U1nmVylhY=ZP?O@dXm(Zw_WJcQZu2;bAkx)94IuCJ-X-VV zW`%RuS&2sC(0bmeJSqZNUzR&H=XiWW>eu-m#AcleKFiV5Mbk-vSr*t2Q~c>}X>RlUH3x%6)FktzgD2DH6ECK{wPmOg}|kwuM9hvEqz0szn8@8Mz( zaj2qmjvYT<50nU<%SRahVUm?hYWIRm4POtuGa?7}C}!LN5y2Kd+@(}d@29>CE<9MX zxOz;Ma(dj?YX&VLwW>lhH^U;O0LCzJM&`Njgj~p{kS-N3^|jaS$JQRsi2?y+PZb=< zm(xM#4l_C6#Ny&GO|ck#;%OvM3HVboycW`rTz&uzETLaaYTpl;;F?^Rr=cx`tMNE$ zo7S(Mc*K(Myu-_J1NNXmNt88py-Zu-(7Ha*yRZ+q3yIe%ekK`3GF`+nY z-#=fp=fg(r=L`;;y9Gx!w0`yX^ScNkY0(jpQ~#?4fQezi;oO!RRbyRq(BA4CX{;m7 zR6{M~$na7oR65CbwxXB1J8rAcHle`H68&mhV@y)xvUsr5e#2YV7CSkE++ z$5KfYkF#HH6(Sncz^FfA=G230!`?F!6WR~@+4hRajfZ z8!%qIIjK>WeKKEmKP<^jE65p};Jv>3Q?#ETH^SI)BnA2zz%9k$1sWiHkl zsc4Z?DLx0m^_ao?;1V- z74GMDwjI;FR~@c?L=Z5hDT2arzY?M;U?i_?6c6PkqGgF0X8)!n*RvRH5nj{+wDbas zwM2@T&8luOsMIUq1IC}bH$!n8i2j zg9xSN}<`Gk>~#^a0F`1^N9 zbsO@oN_Tu*A;cNDP5g<7I$M)U;5o6tkb8glyBy5TdpBnuAcZ;#($y^j_;huAF5=s( z8NRU8EC$xfj&|{$WY1dvJ^ULkGn8Q5cn2vTa^kKwkHGk~KlzoT{L6s>!U70>HoaM)irt)LtI`8r!FR049?io~E z<8M(bk>6|c5I@zX!nqZPE`nTBRX}|XL4n5>qi~%+nSGP=9F?G@@1RHQu1F!2z1_va znyuuQe#KKI$@hfb@8_Gae<4PG`a?9YU6Nx8_lQwdggfUx)HWE;BsMuvR)tbW4__%b z62D6*n9t?zm-%1l8Zy55a?om6e3~4Mi9x&u@+WDtYc)P2O=x{q9|>R0+E+w<2LB-R ze;~ZmEGPH1tiNh{lQ?h?d*G&*BFbgXk>&8nRu(Jnvyr(+Z(g_NF=LilSQL}ts@~|pgUzu{ynN77 zCvPu$b6i=tU5mHsz?pRd1+}m;FLNMx&z$BpCPUZ0Aa=aFyuc&hQn$AC$>vyAbIejw z0aAeo)5n&M;xE8c&Lb13Nsc|^NNcQMLKOB&sq7GNIeDe`{TmUat0CLWRei7Ys^Y5) zKc(u$GrN$BZ6t4)&#$mpU(Psor9Cj#+Q;`FAc6g7!yMTaSjFxX6)nLuWovK$7fG9d zdW5?s$>*kh)IQWEx}L?mig9Ka#tjK2n#E3!T=0w!M`LA?J-LJ)6r$cleW4(c$!kQC z^3v?s=gy1`bfcq+@7-l1KKO&_AuPVfUfns1p7~X&eO#D^R8HTxp1NJRV&I0&R}36awYKDrVAmX+jn2OF^C88QUrb-pB-%#igQIG zSJA|cd7ja6dF((x1hlvKc4;77*+fk|{zhfrlAuB3)W-F^2oU-Ou?<<*4x$T$@rEs3 zw!TW7Fkd_#gw);G;@|gXK0?r<&j*4O@7UY(i^Kb6F8Mj$7`$&yOGXj39h@j1nOjy@ zSI6@SiF^>hAz6+2WX9`-<3D?~pRot#I^O;8`P-{aUzYbJ$_83FUkfnLo0YsHx@)^~ zLe=WU(z|yBf9#T5jgXsNhkTBiJX}OHQpY$v?d4nzYkT8_4{)CvY`C4h`54uR`H-%9 z7HCP^YIpt=RdtMv< zCTL0(p$uL#o1vu;-d6!Z@c6lK=)QHXizc)uNmL(;?9G=2 z5*VQX&~{%i*($6V6oA-6e0m6UdNdelb47~9nb5^{&$c+c+acAxNq__7V1+ed)xr^S zcvF_Ms;G9)JDHIAkloCed1-fkzs=7yINJQX%lkf`nC73W$4ViAsFXvv8eu(tt>1LC zH)}!Bxg{F&x8W5jy5l7=EeBFh-NGREsQ`r9S-ukY-$OK(x1jdR%Ca6OS}K*%$((+Lv`*wl2lh%OLIF3mWMtc9h%43Vpb3{D_JJO-8tze{Kl@5;Amz$rCbb!1M?&sv@!j5SUa)>YPIe#-F8GFbZG*CxW43!$`KuWDGP5?re)d zSds~4-5Zb|r|?sRbQ{Wa@9Ol_KA-eMlr<>9O$UKZmKKOEH`F?;^15>+j zcf_A<(Bzy{@Hv|ja2alYbhVcXQyjeOt+j{}ic|JYTo}$lTC_~9rTakztzoOF^KKda zQW!*{yQ|i2CP%k~Y_BMsW9DDGTvl}Hl+QC(-K*wO0yUe@rCs>DuqjlB@??)G6Wy&g zMb}cCb;Y2GLv2oZ0gK}?}Ho? zZRmJp zy@p+GN)+ao3887+9#7TQWZ#g<+y9;2mgR{lhf=_4@ySsYopp_l7XDGG$}XDRdX$v8 zcSF|^aN!4;AmMp-C_{)QD(t_B-C9@Wox)yxo~r%?l613uvWH9KVFI% zU}+w@_vGO(p$c3H4kp6ngrI4-azh?F|3mG9>IbdkKcY*R%U(g$3aF~|48niAu^6Zw zSPw0yCZIg1bchg~++=YcanOVed;jWmmZ1SxC zuGO*(km#fa>`jb5+{DSeQ7q$C+x}45n`iW#)0-J4*R@h2cU%m87(L8twHNtDL(2=1 z1&m)8ZD)Ba>m14s*9ZE`ub~I+IQB{|t;0Y?DcXJGm*hT#k-wT*#O;!x18dL-kZ1c- z1~dh!r}Hpc4Rm*Rw+EXa4S)!Yd`=0C%`MBq1@GYMp`xtk7qo}oZ0>1a`a7NLG2^E) zo6Y$2^+%&=r~MkSqnt%o)b*VTL@b}{rG2B^7!=q;>gnmB(P#*-psqhXI88aG@4M+i zF16|CiRvnyJqEH5b)}otB)(0=Zc&?%qJ)Ru4nKZfn%&nbt*>-%UXDl4bE`sw-bpF< zGMy5QT8+rkuq$6ZjFwhC%IjNAYcnxVqq0>&UIW}@s#EF3`W?Q6Ko=s&4_w>WdoR;& zq-m1I>HGRU{_$5uSw#uiTj(U-xRkw9U7auiZ@8@HjECDj@X`TyzaL0x8h2T!Ftx&#CB-w@@`d#2>;um@< zza@De{Cq!j#{=L}1z*CtaRH$jNCe$w^^tu4mi;tEy?^r6;>&ttVgL9i&GJz^aPqkF zT)Z{vzRxNJ&qe z4-L?GD_yhqLrnwIlgC-t-R$nuJ@!v5 zZa9w|G$nnC%6r|$p?B;MrDL5qbNVZpF0Hd%vrV~NlcfvVo3av+Q)~Ev>^ZO7Kbb|j zoZF?86PQnKVyQvj~b8>eskkXWuUeEMDFFzpC zTl{RvvCx6jLR8&e@xY^nSAJJlM0E-clGxksw25^Gem8Q>JJwTt*;a&Gb@i$UB1WDXD`l*1|AL!mjl^HoL?bM%@lyu~H(QTTT@rE>upMLDllMEJ zWGUU4Wp>0F7JjMCS!QFeXAev0=UOz}E$s8i?2@KK0stUwg|mwV$R^Bt9pk3btIj6` zH*i>4b&dO0%I(gl0ebH)UHr~G6)Pm*us1?C)O-CdUMRgKCgwNNbLvc0Po4mO-RRgb z+UZtRTs&HJo@BTu{OL}{gZEW$?SDpWfy|?FzH3Jy0pH>00opzTl8MnB<4xP3YzRb$ zqJ_XI*zm_@p`Y+lIaW1Pp82(Fr1r6+7$UxPqme;O|GpdqMcVkY6t}7e7|78aoH?92 ze*F=Btzq2D;X~n0k9f$3%k!)6-zR+7gm4V91^Cs0`2Lq=1|{USlX0ziRFlJe%Q;~g zp)Gjv>13>snO=bM@}lUjUkC_Ek>dD$#?sPKPfu^#qcHuW*VtDge(EWn*_zfjH9zSW zc-ZCGLs=6kX*3}Vk`~O`){Tn zHZBV9OF6j{N<)a*>vpEUG^2X_eE)4>_q6T}R&Ddq#@(`gV_APL@x{HLf!9y`ZtBbM zN31U*;H`vk2wdWYM|0120&Yn$;gDF*yF1rn!c2_eeZ(F$a)Q5c*}xGMXaHNCF?9nC zBVs9@W0w@%cI(#@3q*Gx=1tp?jMc$I2it-*U?7Ev*NQmNJ?;RQiM6K}SJ#u9+^R5C zJ`$bL*_hdd#bsC~dGNv0v39pA?EixmyX(_!fb8hy{W+38-q)ceL&UE1(f2`sM0> zg!8@gL-^#@5dG3(Pnnc`R#+b?on(A*Ri$)>QiK~HM(fx->ylUKqT^cawyr*~^V4p3 z?m=kQXi}>gcWlH-l`xRK(K~&U>U%E_?)kkd9pQ>p&g5zExlpp7MUsm{nd%yD zPKmFlcXEGRva#y-ofAw@%(3Do0i?i=N>FR|B%Q6>A}2-AiTnnt-|=}4;464Ctnjg5 z2Ie6p)spJiK^gxdOE%cfv9nsPgDqTwOsW~iGO!>pk9MHLDC)g^b4dokX+w3R@j>0S zh0N|vIr?AJhu;WdAQr#n-9Ish>>}_=5X!V8%`iwadOO(y^)~D}Jk_;`f`Bd5p@s{n zP!hI5pr%_;rRYV-5gGTza$?d5Trt}(pkNY-3aie=n?dnT2(#g)6{m2X)wpg!1>pMO zY{S)C#l^xZUW0JrD+7@Y7x#w_YC3wuWt7o^dp+50z7HUAf?jfhu-{%^2FviGJ%X8_PeGRi>~)pN6_(kb zNPb&`4Bel#GfjUbQyd`{1pXB%KRll3eg8L!l|qwDyHDvf*8uF4Rid{DkzekVQs0W$n^9yvXTi<2g(#Y^4?ES z8bssu4!OI#58gd?a2rehewls<4UtXC8g9p;OZs>O#Ia2(0X}zZt@|pzMiKile&bK+ zyKu69IY?6|&PGls`vsy`Kl-S_2R9n`A!*W2fPQSu0q-VZRE2`T^v1qPQ@6GcjcTF; zvh)xJD1RL}=L0kd@eDwfV-vlkL(YMCi~q~K*%yuE(>_4=_-QB{ugoOq;9_Y`pz`4* z89ND2^n767q+g*04=pI=fn6!=scxO=*xj)kBL4Y6`lMLrv9BzXAK~%<3yIrMG352l zEbzHo6%pt}=;|5{P$|@*#SfQ%t!zr`xLaLK&{or@HzgSdXeAuU-@9qT#bL5#q_zjP zMruITX{!m}0440Vyj2`X39BrrUSmnhXR`#c6;1I+AqaUnq1&aUW;gaBTuwBe z{ka&+F()M?tKyHGny`{29EBw;j3>Tx4{mR(yhDv0m_idl=*dfREg&|2v%qAdj^3kEZo^u( zMkDY0b>?N(37OBGE5sE`N=o*==YdHX{Ev?er$o++#p42TJ?!`pA-x>&%BkE?2Z&b?@>SUva<_1ew^+c2Y(&bE)aGud85a!<9`S zRSdxG++r&(K#45!-LpyDQQ+0<$NZ|>=x|%!N| zqj6}DB%SDFh&@m`Krl^Cwku^^M7yW9KEu?I5;rRGabRq=A=>Q1v;i5uq@p`_^gpaM zJa+#Le=T#&S|=K|soF%c+xnoNiqLKJyo+dR6%=A;l#SGBsKR6Yw;G6 zgt()VK!PLwGT&wcfaXglp~*yA#Z^HQ*(a!a8oVdj`TLh{_f58N?r590=G&CO&XfYu z8LP|>AASszL7+OFO!97-9r6;I99yyk)#CYet01QeC|?u)pXZgK-zj96KAV;@DDS1+r_ zz2yY&4l&((QQ{{0zh^-b#I=$Wu@*|4gr^s~Tno-fZri_2g8qI+HN%vAQz9!tu&Shl z=iG5~oieOoVY8&(`-uOU=WBZUPGaqQhCo`E4bFNj)L$tBLi!W4>ws~zSLWDp;s zHrUexsgdKufl0qX*V$?K#lB^MAPYLy8AJW$DG$yuD6j70%FEe0+b(x15-UOMsm3CalsKu>{)1sPwuCp zL=xv;X%ODJxtiJEnOTmnUpjF{A>-qv!mZk`5A8tudbpzXr|#9f+&JmNz%~HHb8np`mEIlU@f%amG&cWavmj z)pZ9c80TOL{CYFjBHAl~T9l2=te&3YwE6uB@L(nTg_iS3umrIVeg}36dWf0J9dnu` zxEGk`4Jt+pUP*C8u^A_z3L}a0zyY+joyM46*uo*IM0uldHXao3sJ!R6QAk6et6lDR z)sUaMJv?pVnJ+%mDzjvhzHzy)gJ2WHgSSo$rwZz#@J=E5*54^=j~g9x*fv=F`Spw@ z9=ko7YEoWV`2%Ve@J|~ZcDqRDDAt{?8N`P!B_MH1K#|i#xa>V#Ir7Ywd*nzhQMJ>b*X{Y_&(XCe@HG5g z*I#`PtynvLIMIc>o~b`d{H3%22NF-}gg4{4Ee%4&`F}KBcRZE-`?nn&95Z{b5Ryd6 zI7apgAu|+06f(1poxPR4DSNNXWRx9cCs`FTqx8GZ^Lu@tKYCuT=jr9R?{nYR=epk4 zd#LGy)~u||!Pz-=3KRd{<=>W45@~t{?$-7rvVcBiF zbmYDZovULa*9B$Tg^kY}E6}xu(CAHqRXiwR@6uMV_Xy#w^77$w3cPy-hZ?9Q9y&QW zJ$eLlrmhXgOW^Lq+4izTS3v%g;@5QH;i*$nOVS70u&%KPzaNnNcIbej@Ch`v!V79i zeAo!KBDr8M$%vEc+^11EJRBV>Dz^h34%teb#B0VqAW#q9IPZY46nO04wdDO6n&Ho# zNJHUW;=`ay_YA*qyHdIP46tpGS z4bcLsU$~4*=f#*0_%HlnfTcyuu_CVJr^%KV9JtFsVd|*Cpps|cz|Ehl`}C^YD$ok^ zO{-&!6kzcQ>oW0+Z%N2;^>={suza++`MaiqF-FB1OJjiC#11lOdcem(i3`OTGnHaL{X;l4RZ zMzdp3@PyckgVc=7u{Y1<f3&6Y-3`o644G2vC-~I3_=-D)KnEal6X#;R>m1@C<(swGvbP ziD#<;sw+?rkwwFtu7t~*SEiIbx?XeR%S*s@R$L)|D`cz=vL`_q(h1`*x|7e0o*1EbA}Xk;FE59B2A$FFI?%S}-TN#-&Bm?B&D-2-9!FU#$-Y?~(oA zr`6Z0Q9Y}kU|V2dWQ3+dH*EZ*D7NB+;;}g5>Uo$_6@nx^_+D_q>*2XEh{tJf7U z11=d>%_neOa$K;m4I%JL+E<1R!YpHgUFP$mO$ii~0EO+-{!Btp@Fhfe=5hxX&d52D z)X!XS0pBj~R2WZI_dtZV3KGPy0vbBDv+B~NXSiPgbM^((HV_0Y!MP#}Ss^xg`d&!X zha0faPZ#thNO|1Df`0c=<#Vou;8oMD>Hb3;rx70Ou{3>2o>z$x!#knx$W}83ZG)eS zsOih*47`a)F1G-|@hx0XPqCVN7v;g(;(JPE*?0k!Hwxts0i;%i)@Bp4shzmC$VI^_ zuh?Jo!~ABLtcDQoaEOn&0{_KJgcBpnsy>jyrYuR;8T^d0KRuPpzgkzx(D3y^u>k2u zoS$i)^=D+JF}fxiX5tsE(rq&Zpq{M>!L)Z!S!`VO$Y5ooa)qjk#j`}iYH!SZ_%JMD-{eQl#nMw>L!vL4Afz?@U^ZIWxk9Iv~oqX6F&G-bT`vy?9Z@18;4SQgtFhSh2 zVHEAZCRSR7tN|(~(8bQNDdv#NqHjQXaQyADv|1uyb7chO<(colfkWo#I@PDpw!lPT zVjmfw4p~|Xm`9swQAFHPpW;K&xLkxDBHI_^g(0)^<#+HXuoBB9rlAN_4X+B!(@R|g z$gpW{ocm(ZAjc3H`t^d#fHhpF_1~Mper9mAjbC(S9LL>=Ng1>Sf@Y^3Ij^&Y)MGku z+l5n*0MKr=Z>e^m?7YE3VFm)>UWR3lZnG6)Rgw%%J_h8M)gr@SKUp1h+So!a8%XJ- zV)&znND486E>Ao|f++?dNGv-b_oJW-1_cUb*zg+ogmU5je)O-LAVOECNZ(5mTyV&V z@H0;wLC3z45FQ%ELdjsir%88Cqj_J3?UtUEo}a(oryLQ^8VfyI8NB;-j6d5%6zr_$ zF9M`w#OdnqPEtu#0%P$>6ty6v12=1-5$+4{TQkLYK7BUJB<3}tf3*63>!Q~pmTC*~ z1Sot)v}ZO)EAdBUQ|zoe;Qz7o4@yU%W|r73nz$?F9D{YKO9j}>XI)O8Xl&d zjJi}2#{w9R7N}N=$~P0jjBUHq3Wmj>TG(HV?=mn9Mcx{uhAh*0utX)!pp-(QuV5Zm zOqWk?a!BUsqkOtaImhT*Z{@zfWan;$BVY%j$%#z~(yxldB6=!ncRSVeXblb3-qD~L zkVIgQO+we7yQ`tBhdRT}gQihl1>SUC6Ao8J#}Qj`h-ThC{Pws7=vkaqZF|e;`r$#B zCdH1R#n4|yJg9>4M`;Hv4##`C!P+0{#GbTyQHpdUon*1pE9Ia1b%LfT2bh=cCDm-FGG;N-&sQ4yS>#p6f{Mmmny}PU0X`s#t+2?+ z43%cQzYm~5%;ef*;LU)Klg07T7F9J0P|s4(WtU7&PfbA}L%?1y07vu)0;_K_V_u+Bcs%x(>A*h)8-SCIF(jwL?m;>1zNx!14Z$xd$D!$cp*qrWZ&) z$*;|e0l^U55BGmV3jtdo&{P4e&8t>kS_*(=FhX_hR@)8Azes6A1fS_VGMw(Ej)Ke|D0rnyHxC25W`N<}Xq95{?o~ zuZ=1`If-!2b|WLLF6l{F7weZnmRc#K$XJOT>@2ZgH4d8GeD&(pA9!Qjcv#3l`v#%h zpjiX&(OTh|nHfqJsp`DEyz=s)?L&y*0MHrQJ_*jS>cz?5qL*`~1Y^sWAQfWXu_0er zScIJc83rDn;DG{wB4~|~U@(DoOFE1OhIF$Vbex!5u)Dln>6ZpLwUiJB1;uRp5JWX# zyi_)PMI%uH;OP^g-2|APCnm%p^x*b2Y+@rc?a5{6HTG8gAPdyxyiKfWx)_W5Ac)6T z3WM)+VY;6Fs5+aztzTvU$Q83PW#VYMx z9X9D7!BwzJ_s>1pn&>P8Q5EP*!M~Cge!+@zcsLR+ga%MLpf2VKs6Tmf<=H;a+Pm=X z;I)6DQu6?{=JC%8uy^`6HU^RGG1T(1IdMZ?x0iR|Z?!|F4mToSx?orroDm*_R~_7CRi4f@ zFF1%Km<$aYii+$2b>>|}x-|Jh5BTERBNh2^_=X4K*y9|&Q*-ti-Z2kV@hvZZw)!3j zsd}`{*#?Fkuo4YLg%FW%{rm%+_+4;r`BGJ_?z^{}$~FS=UP?9ep~AyFxKAMwsDE;)p90P#)5ruH-iqAvZ*~Pv;U9hp& zlOu*v;$2m|`o-mSP^K%0J=Gb-nt3I@YikCzfWO%xjo4Py>3M??W9&c9+U;ACy2x2wWWx}viW~k2E zV`+Fu@7|qTxCO;&W*gFh1;6U4t`yn1{g`Zmtira1N`L+9Fj9W^JOi35W)&7&M#2Uy z%t|$Sk>%c0DGZbnjPpybQf9Dwq`Uj&54hR%Wl$|t{+xO0rPF}N*$3;@m=_ft`Mo5q zkSW!EJHgE7wDrZyPFCqBs=ZW7Be=LCA3ALAa=z+P30;D-NcKQ!N7TTu$V^^OQ$z-_ z1Y-I>&;EhPJQO8B2PEzmvoZrg1|)1kLb?M3VP)uts-1+yUMQxZPH?9f=0cZaOA^kR ziFk&Rv7gQ;dyEPodQh%)&*&c0`Lp&f^hkU`l?hY3aqy({~#2_Y8o&1 zd2w+ui#B`EHlCas&aXYGVp<2Zxjs0NDYM!$I361@gJM#@1ynh&jry(w4I0|AoY8Qj zko;L?;{%T-VJBWRLjMm%Tm;k5*F2@Nj(tP1{cs;fqi<~_021NiR$XatuV#fai~t3t zT4J^*LDg-$H}B_#_^;t&c6v@jb?idN9>nNdPD;|{b zfD{>)aECrLk(mB_d8s3jO2OZkI^OwEhWGk(u+4b_66yL}q(6Nh_K5$|HKS`=EZ|RFtI{- zn71-5F%USnwM_|PtFBV8|KLD%W%Y{zwc&m)Y;+nM>Gi?H6%4Ao_h^f_A70m9CiX)a zy2NL6Sjy80BRXLb0UBOES?8FG_Ro`D%^-RWK>$C0bUj26o_Rmn9HX(mN#G9Ew8jrx z=;;MbMQb;dT*nn#)UXy=C_2j5wiihQUoHS{jl!t)z2JR@%KGr3kiJ6v z7faLOL_5a8Ni<}TQc;B8H8pL$0WmJHc-x>Bh*RWEJSBZ~lc$;c-x-JTO$t1({=9h_ z@xwfJI6Hk}tEo?ogls78Q2hQZ3X1F3m+@~1DL>DRxRJQ!T7B?ZD5<h&>)0ClyT zTIM&it$zcHY2g~$(+)X>f@SKw9!XO^O%@yX>)aG1-K^PI<*A5rtd*3y{go0Dx*6GI zpX$0PFcPxf3Zkb~=Mott`5tz6#(F>sSA1%~G5&GK~Rr|7eB zR6TSbyd1Q3Y+(9fZLX&qjamPlJIbC?t%SV~CWbm%VZop2$gGQ*hBZ z{8n1gbu4u=-c3p4Q@7z-E9<9i=I*f+gu4XQ-{HyZWc>Gr zus`8b?<0n=1iK7A{bd7#3PYyiLm5$qm~Y-}{GpbQS!R}a`U~*y`LPUG-?L~w$`FW( zQNdTziCuMXTk!w(QN3tlc9!8J!tKxbDMPi?xDznqX_fc<*+*mgak_SIV9Yb);cNb><9}QL;D9|QS76{hka8u7>gVbiDfJArRN`*i^Ln`zqAwbJ zkiMx_)y9#ce)YwBT;y#AeOOWyJ2+-SFTZkM_ZWYmZ{+MQzL;5iwmviRX|fxLzZe?o zM@T4eHVvy*i2o3^Uykr53x=5Lt6IGj5EqD)&&lZe9x_N5H$YX5ypCU;r&k(wweL>7 z>Fa-Ye)fn7826MY>De;yZSe2kl%&(Vn4zg3`U;DK|IwrhDt;rr6Dt=eglX z*-u6lgclzQR)(8itpA#rO{q5c0C(t@5@LMou%0K>-0~I4hEGjn_nfs_X}S7E`xU+9 z;We>a6c$g5^ck6%qYp82r_BVZb~VBpSu|!@P{Ta`fSJjrd;D5KMyssFdmsE>F#(>C zEAs3iXPX8{_O1F^U|^|0 zlqxO3YmxNAQW;JRPtu?vV&^h4MB?ddBWNV{6_mweoMPhQb{5*M(MOY81#@p%R?XO% zfEZm${5-{u(NG8zf4`0-&up$y&m&2dXDljE((cWzAi_Apnj>7n&SgEX>}%Jqg-dtw za$uS(Vf_WKNw3(nBYaCoGsL-URqNjMQ`yfjr(y@xt2)6P)xyz;%SeBBs`%`4p-pLK zSeoxW!o*VvQfS-@44c@OF`ucKk`j+OUESWbOs?$KNZ=tkM<&y>2M#e`4fDzdY@#%$ zxOePd_59%u{z=!Yja9&{39G?w=!Nyma<%#;-h*1kQ6dx97?sxVOgJcpRu!hd?HhCM z&lj>DX=hUJDf2zqjd)KcuKBSHn`qkb)TkL!ResEI#9ur~|61+jX0Ak$ewpw)Ji^P@ zuY<_hZD}%xig<$&<72_GWFruUMY!1DD z)Qfo>4EKAFgH7x@QYx9ktTXw{@Qi~t1nec4#N1JFwkDpowuMF&OHcc}@2 zjJV}9bB!wUa_#Ql{|2_BkZLM3Dtf23YhQ*z#r_V?pnl2Zo(!cWzZ!Mab3GrJG)ph> zMsYpw$=!rpwKrgL>r{=a$s+s+YG4%36M2T&eHkwV%RruKN1R=tF&~kx&G7MGV_`l7 zFdIt_@x!dG7{^ni_#Dz%Bc8zN&9H5JH+I#Y&=VGP?Cb-Ji|hBh{Hn9+eX`c2dc{x% zd>tPtoTQE4{-;P9Gtd-FA+3Be#&YYaD5p}Y-K2z@&Y|d26OmwEk;?5~GCA+>V@0V} z9?xEt2&?!kOcd#pSdjkGC@-Jrx#X32@@Zd&CluO_vDaHPHwJ9w<>DSr{qS^PoVd4q zoXE4HK&%5=rSO{p!oxjuL1(y>b)$Y8lgLPl`tIDd`-6KhF9PR{Ys*vYM@-Qbd>xLB zi=1P16S&17eUrIzt`V9)(|9h3#>hsr2P3wb4lYHAQ8rW`7E3Uq8^>J6mvSQk5mI@64B*S7z(r^HPdbT#mpxsO40dxa68eIbHxvJ0C zfA8(NmEPi>B2B|_W;mb z6-DPccV4-2h2hmfjB|k>f*e~5iF&Nk7^9enJcwJHuBZRqc=_yw{y&;8)4P()BEUlwR;mm2M@b-JFVLYlweQot_Z zBwMZS7F+D#d7ZUaEcnog3=T^fww%n&(~38zLg;q~2NKRG)5kLU7Q6Lj{h!u32Wy?` zJ`qG4G|gc{$=t&^!P%Z7{s6tqjOea?=H%3Tx56rG_2b(+Rrd*fZ0jUt&gX=T}+z7Y91zdB^{z`qCg4xOLcSXCe?j3m(<#zPIh0LR$*9uirbW zmb4P+%_Z>oh7;-G6q1p_$nZphJLq#5yyegCU4I4)Em-dblQKm4?OtrwjA*7om;P=x zGfpK;f}ZAL;gTfo-cf-IEPpz07Bxw6GMOA~?ZTh8tJT_42p% zb$)*SvPK*clN_@kqbfylJm0`osqb@Q1Hg(&9kv?@*MCWVt)ng>#{SJJg1F0uI9&z?5Dal#stoOy0Z-YmE~Kv0;y5;?ofu zXPoK*mc=o#u@i4ZP}#Q)4`J0-GKZ7@rk7BHrmXs)uaDs1dB~Lu%6f@~(2ftU!7Ck2 ziKZ5UnaJTUeYCRs(W=j8Yo{Ew#}CgJq`l@~mh`t_RJ858xZluzJNRIIyY?mnQ}G>S^gEbS{98$S>Ftk<5tQIY=@|ZDPW95K9$+B z`%70zyynf8_pzp?=E2Qv6DS8nbpt*X#8;6vfOrC9+@ooZP*fK0S)>wrQo>+*eg8M? ziPOKcb1&lJ@d{z}!Bs8am=sXDn-rD$?q_>4SlT*U%~#ym(c14tX%?S1-Qnr8sbN67 zRc;3_@MdU&r39t_hGOA`=0lksSit%X+*llUl2q{f+A4A?OC>G`;V0NIO#>Us_&{oc zmAgx)A7MlHu;H^qNfr_F1%my~ojPIcBI_(#eViVj`NhF!KfQ-M!@mG-%`M*oLw~~8 z5(XVMLO_l;6L|{u6m69nB(a^x9XlQCH+L?k4M%7Kuj}`3Z-FxJ?Bnt~9a~_wA}w5a zuV$mw#keoIm`aSsmQ6_y8@7P8s&s4^6G0N`*iJzFAuLl7oXsqNz=6}zZ;%O}V!bjx zd;onn5TYnhD0MJy$9nEbtwD{Ed5elZ(0z;_iSzOu-1ZlX2Yl_nH^d5+SO!{Pl{qjl zpztBYVICZ*u)s~oK;h)R%nopQZ&kniMta)!t)_eTV9diju@GFzSeTeJVu1|>5VK*^ z9JtVLEdQHG>r~pm61@liKKSt{;D&0wtKPfLAsc&-8b3`IHaCQi0(!}>#b-~j zOp7k*bS@m?BvZCjwn1Jk*!J0adZt=4s3jV;(vr?BxUme^K5m%@D+vneukk6`?zbXb z<&E)mczP5t)&33hX1Fm09J?fZN(2iK{nP`WpqFY#ARFFqKFfYWPx}RHrU!hLv-7^j zO_SV7_Z+G${874PW~xbL2GNtcb?=ir{jwAM{v+KyrGU5BzHpre$+LFSDtw{~-ENg4 zW8%2=b@bT3SIo5*hLC`skv)wlD>P(Hjpu|joS0oTPNmbNJ?XH!tVMIDPvX5*G2WZL zbV7ES-HfsKI#-Znd@2LEh%w5q(XM)7H+VJi4X(IT=>9DjOyhtd)tt6_&@MrOF@P1KLj|NwIaqd&2ocYkUo))~N zW0){RpY288d2>2lnD%5lr-G3_`1c67{d~*X@#D{;Hp-T@I^&_qH&ehEDkeiqErN0y zi$ zFZ?Py3(fTYiHgL{vzu)(+9{}Dcde$m{uFd@H1Y+yYp2M*t4c65ngoSbFVcsFt_a!( zcdnCiU8h<+>VOs}tf6qWsdHyFy>k1g1ozOF*L8WhXY+sr{N69mA2s z?v{0$KTBUs3`uyVz{Z!M*;8fQt5mV>oua>~P|dyYP&p{sykZ*7Jv23C zAfC|vp5h%V zqaF*)Phz0sh+Pdd2baINxH#~qVBk``*}{u+`+HtETV{H?Jj1jYQcyHB%%=W6^gIwu z$M6>{TwqrTIFcPC;xI+`8yNnmpM$vgeQ%T0yRVIO=j{LDf8=*zRC-W$XMaqQGVhS_ zx*5yBW~)dPq2wujLh_rM7=5b#Qo)~J*<$Y2%sbc9}Ezf z*RGf<8*|Ngn4~JV0}D!9ODjP+!2qoa(hGvC$*a{F4*|H)wZw^C<>Tk)oTRAmZ$1z= z=wK!c()B%o<=fZr!Uv!klFfNwLeIKx?}9Zz>Zt@Z5HO_}<~Q9zZKhh(^WiBhom@#! zBHfp+T)Va&rs~Q9#&8B?-n?d=YdwuWux|oKd+?R&bf$-)@@@ZC;_nEiU|sux6D#{? zx0h-%uT$YGos#hd9ZM)rZ3sZ%q0>M1&5~|<9{rTZpVd?DO_cP}o0C0xB^(rPMi zp;Bfv;8#-4{DX?M>SgamD9;pN0SWE0raY~(p<#^TVmtF$h7^r?0YY&wf?JW$A+sg{3{{@}p_?AfTS z2dQJ&b-UvCV54#~_i^RUow-IWcCG&s+t+)+3en+tA)UD5l3eBXR%`6AJ8(#_;*T8I zk!?cO!ybN-8TQ_~Jr$08+P=z~F7x_GVOwu@4}N;4c8TJ4EJ#wcZ~Txrl{bElP46^} zK0*Aglzq@TL8pX?hkG$kYm7OkT+g!#65~3*D?s}UOx{G&Wj+t}^06DDl#Q<{wek1h za(*w9khko<_u{blg$t=H>BUh^AQSldl9Sm|J)zii;5+Ev@+Hh(|GOJ5Cj;$~70YUM zwNJ*NGrW_Bfm!hFi0fj4J*BfZ^wp<|DT8l1T^% zn3DBP6L}2yZ{I?rFuAQ`xLm@Ulx3!{@p@>@@OEQkZ(*Jc{3PJ%2!y5q_IPv2t=5$Avpy(g-Hq1Yz2fjWJtm)fx$zPG*0TZWr;st zv4iZvn4)f74?Qe+$aKpL*uY~8z+4FjtUocp0<2dxVjoE`VY~NIAkv?} zf?NYnoFm%W1za$HCMp6oyukzO%KW0n~W>7iD6!GT!*$BB6!6La;27D8Il++~A& zfQLg-B#LWvzw}~$qGP@Q?7*5IgjsjJCY;%_I;*1;NU0VcZmql!-Cs>Ksl;V%l3MJr zp}~hi1dk2$g=ZbZd~*efpB8xZoPtbO3ly0@Jehx>fI+5HiJ!PA)?Kr5CbiX^rM9C2 z_cbaXrdFQ}dYq2_lNQt0J*`Dtfzg03yq|zQ0<7D8DIu__j8)Y zah8~md}lxD67pee#Tl*Fq&-u~W{szKVJo^3^Y&42g0}jbIy3lRGaePwf?Bk@H|@;& zme;?1(`~&{Uq>45kXb8yn;mBe=Y%mquYHqq0gOhr53gqyf?{Ro%$V2xJRzInGFK*# z{KOkh9A>7PySp7Hos`^^ZNRlgQ!p}h0Mg^>Nzcy^HxMqQxqX~T3g2E4y)fr1u>rRe zi&VRisNq`SRnJtOi-3yiy}}u#Ik?uV?9gCpMS)!es4r3d~SV~XHoT_?o&bn z)x?!0;?!kh>WwZHCp4|HE>FS8+qD>cgLd@B)z;V#ALj(`G;$*8N#gJ)ymww8ROoq9w5qc zrTxS1a(*q3yIog}f|0Tg9~$F-&p$;`NooT(l;f18ubIkE$uUYhM*Ri1o|&s3OKCVp83DE!dsLtt<*Cf<994Qj@gLInBL2 z4oY36=8e`SPQNHBfVJoSl)*813ZTo(y zH8);X^-3^a_J(*(e_4Le>VVJBe&c9&uHY|!_KBd_RgS;ZlG*FtYKigb`HmqK`w_;} zUM}!Mv)A29!l#YMS?j$hYo1$aQN3rdDp3|uz#InJUr>pt4hpgdJyN&Ehel^*J*&SG zOyuYY4umQzBu(KO|29blK|b_Id>9T54g{V>1$JeGf16pa(lEk9I+h!O7n7c<&O71z zqQ}{WfiGv2`^brb=tRxBGgqU}?Io-Um_N9&01Iq1>|02=fY9}S*0|1e*d<5dguL(w zMRRa#<>ZqRZ7R}mc~9wAzsD>%oKz2dzdn|h+V75N*g4xJuh!6Wp&ju>lA%s{JAMWO zjtZDUPCxt_z9id`k|DAd`Na;2Jl5|S!@4O7$%%+#2~RJ47#Ed4zbBNS%&G$}h+34@^ec!jHq z1C1d^CJ%D4#J-dg;Gl_AdTj*5j-g?nJu&^(=eh@R90(v=`S>t>Cd8n6#b}i?va&)I zgM(jAM|uQNu$K_sE?#iy9a5Mk(6l}v0o9~c8Hit(y*qUU0^>Uk1lRVxCu|y)1VnH- z38?Rj6?YRv8IZ6BZd}EX!PCORd60X1*dv1cOS(J|A)iJWdlcyhS;qwEwFhx=Iqviv zX%*>j&@vh6B{|Q*6=$z=`hWG#cF3H)q=8qt3c#(8m{cs8d4K=*q=b7+`1^szA0R6bJW?qL64pw$nY5QMFjW4tD26;(_!5hFLG5`5 z5YiVfK>w!t`oPVgd|J@UBR-2e=tB~MitE)~B?|=%f|;Q{jJQywRv31baC_4?Q|*H4 zZEbCpX!~*Rt$;ZMpnGu;1P=J>mx%Imu6fxTH*UzvLLd>W9cqkOT(W!^q+$z7B<18p zqqvewbYIE~K_o`KH3K44e~#ijaoxK}KXTohK|`wpcb%pp-8(DmAESv3rn2kc-`Ji`o78YZ$=foYSd;-lM>Rg%s+>0a-il! z-7A?~k^1?LI}T22;@(%b=_ha!vJ7xWqpzrH#13qBiC&_Q#>9h>S41$m-T4%+ywfvC z08ZpN+|#F3zL=t(qHgEz4)P@E1z$v`vgmB40CAQ{5I`5vo>*{-jz==GvKEmHR`0KQ z%VucadxD;e9m-IS0Dy}dfMO?AjK@Ogij_ZS5CfGQKXoLacgC_GiB+C7n7%d;1pB@~ zV=su$%gjV?vHR(kX36JVHUl0f5Do7PWUGjJh@`>eOQBZnd(}8Ukm8Qn(_N1bqocPA zP}?Ejhek$5Tp!*s!mVZs>AqvUSnV?2AASBor9eUG%+T;s+OU*~*X`bNFV2or#^mH` zVfc!Q9qhkr^HAq1qo--?)VSb$m0y>C_$=%y`$FU1RLdQi9c#_FK0Q@Wgxe`oIFze1=ZhWTcp|>lHgItFq#yp9!)H`^n)Pv(9mI zwu5^``jG6d)%>Ekq{M$I+-8jbPGGmhMTtJ6U)YC=<-gZ2CI%kXDLLnFu%0O7VHV5O zyD2xj2PPh;K)Pq5$A3kfy$%y34=V&rl^J}7_!U>JRxyyIJ4uq&(kh^M)Wnhamk5G6 zIG&Z2J3{-UK&6@V`}c3~{K({Mekz`U#2b8VB$NcLd@xrdef|4P@eC+`KjxYq zEwp+-B=;^-O^1SoLchb}4lQ8|-o`Uz8ZzNX{ zY?ubzSO(Pil7v>MVfE=J3)muP?RMIBI>m$-KfYd-a%X-Zo&k$bdaYdvUru@gj*jd@&&XtHRa7h*Wq_2d)rvOsyH?3#u8@jG)J#I?y-cH%KQ&@`P zFYn)^RVi%tL$B1e)V5J#Dec`VsRaMZw_6XH&pID5yj_<)#>PLAa>X+wX7^m?{0ct7 zU~iK4hMlw^8qiPwB`g(?S1q_zkWQOknW|l&%5P1G_{DFlWyY*p76kL) zzYt5&=!~@_Fq2^*GUH2<2AfK(`|R|~e<K#$zlgAW4~$92UhXQ z$*klBuvg)G2d2Hmzon;8RecWnQn61y*`;Y|Yj3quyuTv@(Wx5`N7f!ccgiyf?CUMHMlyz@{i;z6= z8%9xh;48N~S6O#*F@;j0oG#B4P_CM01K?+`^Y1{X)X4NP)|~PWM8BA!2qgS&#O{Z% z|5?V)|IFCh#MRR6#*%px`yoqkf2=XM>p20koPGN;+3TzW8R_ZR^e=FgVDd7;ou`H?HwISimUN=c?2wNbWk{{TdG{=ZoQyUWt&Qu55JhM@& zzI374wzNE)Ea)Sl6SbWD7m~65AjM#f9dC=-eNy~tq0&9la6u<^1F4LAITl8)_ys=v zPJXV;-`BhE;HJy{!2DdQ);7#{yL7svGYS;copw-Fjfdm#JnYHN3FeRiellEMw5UG= zLs$^sbqh@eg^uX~cwzx(Vi|eadG&O;x|gE2g62{Fa+`yilBYQ)h$;n$7%1yYD^jt} zbBNe{wZH?3YSpSPrur28W`fS068$o0cqMK=8x%u2E|nE-SVbmt1~d7kGxPjm2m`&e z>RI4%%gWjnGeSs^4c`gr}4ZF-{LTBlz zFQgm)<6_aHO&BV$b4n;<;_NxSWuQRIiN;*T{}O?X`vCLN(l1v>q#}50gwIZA-pHn? z{|L|(Loj%Qz=~|R7N$JB)`(k=S?*NLSRUkG=-3Sv<-r8Lba$R4v?34QSL_YP1_j^0 zf4^&kl8T77Qa@TAZ zk-Z$2O>7gaR9s3MaezmoID0aK)_=nzv0uRccLSC`zNZ=pF`&b{k~(C10w5+|D6Q$+ zKqkQI?sOIecWzm@^4@6-+qy(f7#rRdXYWP=5Xh z#nx{bpB=L6D1VOidjUYUbXfTmPAw7_{7d99K%}7hC{1zRq>3E`F0nx%?X@?*-`CZF z)tESzGACaO6j%b>#0n@r#9-_#_or75xlG*->$=}f0cY3V`6@Wyp;eZ`&vjb&K8O4n z+)c*@Q^Gr=TlhpYPDaVYbB%uoKy?YVOs2kw9yMrVcmKfy(dOmg$jC@&JRtqDKjkMe zdFdM^^J1(?d%X;@cli}#m_i0AeVu`z^HOKWxP9)LkGuO+;}5X`uvwdHBu@hfd4n!+ z++%RB#`rrVo%um{9(>E%f4)6B|MmzV!VFnKE9?S?b)W@u>73`zVflFu-vbX@TsYl| zrj8CaIsz!_@U-0)tGVh+rl*G3wKhQqZ2o1K;0$qM!@^5kKVf^0-qMigePUPOJK zIlKmKpB^#Z9cuypt2n{d2BrdrB5Bt4JxK~b+KL@AG(ic1_<#P|zCXKr4RHlb+z*3= zah3S7*8#1jqy>r~!c>=>z`ZSOjkFqJIBp3ZIQkY#`*5f0; znaYjXm64N_l)QR%dfkss`BU4|M|Gc+d`U=8XR3n{_f~*^xaDQ+z11Ze379SEIr6;i z`sa1HE-Cr{#v7`S$ks}rNo226wS?CYQU+0nfrZS{@!i*{0#UTKbu~X5`w2hjSiwK6(i>c)+2ZwtW`(_UBM=jL97 zxf;R#aV}H9WZrZ~_Ntal3J*&%k!P=an1|2OOsyFxnG2K>LxwR^=SV<+M{XebcsMarNCf^L#9H5 z0q_(}q&CNmDy^kxc=e?e)FUEsUJDOTJJLDC0Qzww@ogb<%-ilPcAU~z1cjGWtK@tK zTRq>Uj*gBNJVw5hUjB%@A-m@G*!+SrsPYc1eZcrFD&H%6=7*bf_?#q+mMnKGvog5)M83=KV4N+{P;c+|SvNEj0Z%%Q@c-f~Y< z!Cne%7AGfRYo(T$J8CFQPu>gOG`ZIj>{<*2^WqxcxQ-@@Aq(by4Ap-3&bDS)_Wp_m zIFglC+q|-g1HO#uxXRV!QYG1$NU+xlYd8ao8~|cN!!_^hEyc+EbnO!Jnb7?w)v%pz zh73|gJ1Atcu#*N6hga5ue| zu?1%|V#52s!jrxrGP=*f38L-#!F2j?uk18c9+<*Y6iw+thH^0yr;Y;wpFX*1pGj2C z_?bV7D?BfI{#W2Bsd}Cw0(YHns%m^fow#5DBI+CFoCPvj=Ltj+1_ga&k*t#vdTI?{ zE8nf}hgowJpbb6F9?}(DVDuIQa9W?v=vt0;iAE9i$QM9G-Gy_n@gcGB@B9zX7?-qD zX6w7xQ{Xv#eZOwgo6XVVU8eXb*BzWCoEfec@5PZJPK)=T4|i#bcCpDEjv5MwYx)=Q za{@d5GU|@S>60!UTenq|ldcy0C)qO-;U5-vtWCR%(S2<+_e1hKL-I`y!aRW}hs?}k z2$Snhe<=APx$3^wa;u1AcrfQzczx81(A%L_T5s>wfOC%&#bZLKa~|SkLy{D-R(5cY zq=I#a-h2gjHcf;=b7pGeu?Y(gSC=MuX&9$A2v_J_b1gH_rzPwV*p=u}!IK#U_bJB) zAkaf>F$^O%{z+#brKuOeq3hBF zl}VR_c%hybZfLPXj0$+VIKi{xPoi!I*+>2s0l7#9+|8&2rT8c|D=MbTdBjOL& zWyC&i&V((MAvZ-QBWfo4|<8o5iuO!5s@K->yv^<;R7t4B*E zQ_Qms5`+Acp*x*G1g^(WtiK|{dx^_LeIx@J=-;7f;Kayh1s=3Y0(LpNzY*&H;Yl`} zFg4=VYb?aL_Io5|Qge-1C7}&P~?L;jy!4V`0x1{e}XDN zVS~1?!FRv@$b0ziY5ZOR^9i;wOYD|-4?)}b&GPrqov8fMa>urXj~?cRhUF?Rhu0u?@Horkqe;jbsYomMfx;i) z7|4NHUKZm5=hR|{%;GF?F$7xI)mLw;xqeOKdJrKv1)8M3#w%haq@Y{RM%`lw6CM@_ zeQbU$OB*&GkSgN`>tb05soXWm)R#JuH|LwUo#S=*=>c}}j${^9n_z^&80<18y!^y7 zzT2cvP!Mc{0)LhHQTtw^hREiXA~IkLakKwMINDH0_F;jC|L}ls`>!AQZ?9E(5jqA* zf{7D2H#zn5xj59@n!(>M}T&7hfr|N1zGFD4E;c~IChYT<3VDzARv(S5&P=P>6fp7GTi!gP9sQ+XUbJ&(XQk{bi^ zhVEBKPdm2F;YW%eBz99cn9viTG$)gJAeSpCJNt9I&Lio-_qRe@jZ@*`*fIask_`wzW=*awL@BRBT9yRW;AG4b$X17WdNA|k0_{172OT!wO&1)Gq%-UXn ziB~Z~2aXzp@A7-(0pw4RJZi9LxFjMo%8kDZF;}NRB3e&eHJ@3iu_2zSb^8jpecrW~ z-1;?MqcfO=uo^nXu0m~t46`F>oyi+cF2pUtU;UA!l5gX@{c9!Rb7Rb(#Ro8A0M#MH zdK>5(x2Vyde@OCjqZNA`wWZ)0!sh^hRB5(L{(x3eg0pbiF)tP`K-rZ-)MmC%VbN`t z#T|*%GB$|7Ltl4nxt*`C zahwc^X#!=40f|%N56bz0RcwwY-ahZ_cyx~S1q$7D09OeWaIR5k&({jiR$)+r8SOer ztRHYH&0U#bz;XXaqKlW$*-9{r%wBJ))S#gy+xoTnsH=4^TL6hsW*2oOzAW_7tkrp9D+%jr{DFU7X_)sD=1c?{J$z`r=S; z?!fap@XC3oAuOG}NXX-Q_i$4&*YxvAcdf9yO}Ia%HX6G?W;)I5HI20Tk) z^4I~lhluDoPpX|BUXcB@4dJ#05eTv!$kS(6QIlcY@oW=7gei+7388FRNiq)E zo9rlLhmb9WY{%YYWN(qI$~eeMR@sseLRNON`Mu8P{{H&o^SJ-0+i{)idS9>Cb8g50 z8DrJ?_e=5^&@BSzn0bWIk6u)zwZ2iwaxvik-(ySInbI^&l?b;N#86 zU-YXie6-aD<5AbefbBV8p?8b->n} zmCqIQ%%+I#WyKm+d4!b~pPZa@cOz)xF%JQ{d}4M?&+}tE^YE#m3nNjWskmc@031Tt zW+^O^3ECc82&6E%gz|#h=k5JtP)w3OOtGE-dswJ1c~#zArVOIofG@rHFr}`^8jBxi zLw@E>-<7*?JDlL6#xAH>@ew-^TgAIPglXp{d`w9HI7po5P{n|&8Z*sb`yAw&MY<3a zV)}jCY$q`loAB~CGz2*bpY2Cg=x=OXsNsM&&8K4*Cde~w`O)D1IIYnQ%IszE@K#8D z`hNVIQ-EvFfpt!?I+(*8Xfj^e{O<=ABL<0^;Fb6Xgk7*Lx-w$buDms5r?Y^7Zvehn z!fHqd1EyFkgcLRIeS3)gXvX}aIJ*&R7$9cg_}Wj|yUz@gga+*q`w~2Q)Am zQM7sd9uax6DHqN$wEnCT;(4K%l=ynoNdQ+QoORa|2bQ2)H%#g;i(YX2d6pKPxIv2V zM4xFfcGPDteh;P0atPbU?5Y@O@dVKSE<#q#q5vVLBPqN}Wq!M9MZ-BgD~kgSzf70w z>yI#!LJ|-}nn78b%J|=s6=S>BbWyU7+I1KN5rHs`h4gF-lI>1!`q9Sn+OLQD6$R~i z0#=H>hL8kAX=4C1LG1h)V4;`y2?lnZ{f{SGK=K=M^#nw^Lx6N^fw1U(@4-j#mw=zu z4KHdNUOy0*@Jk3XBf%RCXygIN9>Tn2vH4BH8kg{KRpY!1SeQoD>nT}JI$1Gq*x>Zs z@;#S6o0kRyw_BQZigg=DJUel@RD-b-yjh@n&<}Vo-P| z8M80EK37=AGq4NaG`73baT0!BK|MYi`CcjaHDS!9+%L}9T@-4^ReUW3dc!0J;5NAf zSjFH3kSE6i4=RMTo7fR}!i`vY@_xexregl3s`Eqo&|AjEMn-D`u2T@xjzzUX{rW8R z-i$dz!#Ni4J6V9rv)@?!In5J~x((YU2=LvU*!uesAq^jo8z1z%0V@Rv-%EbJ=TevoP;x?hR!>ilrvwn_f4k{pLO}$@ zM$f>|VHRBNV{~m=`aK1ajgJrZz%&uY!avitLPl6D0!)YA-bUkl98Xwm?JW(e929-oUB^z^RC37iw1|73ecAS zA~dFgsD-0=*w|k*#@VNpmBqU&y?aqGlm;x7Ml(BK9~C|>>n}*t!}Q6P#uEr=T;jR1 z1&SyGSuUJiHCe4_$QBrqvP;_2eKp{C#h1n6b__A0FpO@p zeT?nLmjhfOa8VpMobS9d&lpDq0ML4p{u@)lt{TJ~9UBXjB()=aK20sHf{|*i{e{F< zF_8?7PkH+K1Rp4Wl-pgUy4HuH-3CMz|`OmF7+KqyHP5z z2r~y2PPDiS>`n`V4?9XJ{-DUGoJSY@=BqPaX6`H6dT_Z2(qJ?-S;x!` zY1$*wX!qC+h?AkV~; z9;d9N1Y@6q&-fpF@AqkdA*eTEDhm3nd)nZDwXq@e{fzhm%mT{>)BE#$wXpem%@PQ} z#g|>aSEyjbSg5Z9 z>4;J}X2P+R*bQ3{9@chm-0v4GGyt)xvH4yufU8qscj_x!&c=+UYj~=TDQQ+HiH$JEj5!J z$pP8lyR-)vEH-ct3|xLi<^xp4Q3{s5t3jkhJ1Lz+SG<>QebML;sJhM~9qr~gvtJEc zQAm0l;VN&yf&7+etz%=wq^;0wpUzE$2@&cPsDnfi7n!$hO&40ZbGx#TmXoy>bQTXW zlj}4j1fEapeM1}dUH0H=RxQEkvhlHM!?dqPyBqzq?a!Y-|HJ5VwpFBFW20mG76o~H zj?m{2LJVi~Wgp^*wo zwu^M8?jJXu%fsZ+!3;x6(N+8bU91RRyNm*?IG!NTarh1Qb;*dF%MflFkQ}gdfQgB0 zsK`)Zr&h!rx@aFo+1QEG6bKk-w>$*)z~F;{o&_+*G6B=cMW91X@FZ4uXR*IafGov& zyP}hE)^JFKP!QfxVsmKWhj0R7T6Omf3;z%7z&u0qt-%_+@jfJ=+*+Ia(Z4f#`Hg(le}Q%!uW z@@@5k7<^QY8>wc0U(si+kzg_J=ZT*h821|WF0cr6toHGhaKzBUikQA+Ght$*qv+n zXW>wC;B(Gahs7{j7BsM{#`}SKQEdR&)`RrmY(hgdE&)08nDT#;^jt#LmroHa#dfR} zN_!15C|KlW?!3$2O|ZUlnLyzApjHDa0P`Ef*2&E6Y>V2S6YZ%w(3&jJ@v4YVF`8t9fX>w$K|)`i>z_rmMK6$}(z zfM=SW_+dUcDak=b1uuiS7oe~J8=YDsK2EVLLbmd)1>N`CiR>#FctsVh1-@+}xkpbD zAIgf>ZZMNY#z)9y;A$ozp{hKd7sQURiwStE$Q(HA;W8mDdkEgk|G*5BV> zYjopBQHSdk4p!}Xo7>hD;tD8rzI)n+0VL;x9A|Zq*l;yiU0w$1SO0F}yldd=B?_~v zE8$g#qxQm|m{sB!nA6rCE`cCc=fVNh%xjFmlCEwtbvej_PUR6al`HDJW(snm`J)?d zpOoiTpcpP7fIR~+G6G6?`E&!jpft8N1fxBSi1ouHsZt2u$Ura{zLH9NG`l$4NSO;o3qC0Ie#zGVE(@rAV-<+9ui+Qk+CaKXwz+} z#duJ6&%2piM7OU-K}Ug7jLxV!k)fL-H-&_!yZSe>G1r#>-n1!A9aE)a_MsNq$-%O7E#RTz`IE_K@a%}qz@=24 zFIIZeE5&<#JKA0(Baxlu!M7zHVjjG|Z1Ci&6+t*ZApGA?AzlTJYE4|ra4Y$}9&?IE zsrh^1NUGW^p$J(d5?tmK>~@}<;3DajlwrH6lMm;s%>QoTka6ud1Q$*rQpy*MLmNLn zL?K!G1TVJ9c|9#ELF2f5FiOUfb3TWCW~LpH;Q}s{Y6$$f-*xv2$>3zLi!dl9 zcWU}*(2HGN2igqs0}O}1U#FRgwWP&{LXKQ^I?j#q3rJ})&OG1O?cJ_{O)7v zO|O9hQ%gN5Y75`CIOH4-vbJ*TAIa}Vuk4vx$BglgU-EhU2%qoq-P;nIcV-SXs|~2} zahn3)gu?tj&NKWKKq*a)k$(;JX%aT3{Hgn|OuKNP{Y3SsvE{h2jlT`McLIbNcnwK# z4Mp(d+p}+mkFkZxX+I%HvS(M~jJ;3r6yev(jb&%ui*yZl1lKPy9kdd`+`DplzvDL8 zXAd@)mm#Vb+l;bt{Lyi;Px1g3jpy+HG*`Inu;Tqo6dOtj=Db_2Ux->^IjZ(Oeug=) zHo z9#1l?$6=bJ_YqH_e`En-pEoSdGEkPo%ValS{ z&rQGm#t!m39)U1DHcq3;UrhIn4Vk(|P=?#UGe@qh|-w;a}Bi^r*w-4*6 zfT92ZDfbokHd$YV~yi7%gE_UfGj_4(6?8Cdj3guUk5T)IX-PtxummC5}JP zACo?rqkzN_HL!Z=XM9Hn_Fu%RvrfNvNx_RlM6MhD9yD0Hq(|n%`cE8G^v7S zPaJLYa4x5m!wg8|?)>qCb@X)8Us~#e+;P}7n$TlxL2)m433}!7ALx?>W69=~s89+;+K4_#c)8W|Xj0pwF&)0%ejoo?xV2(ZX%noymcv$Qo z&W<(q)1NVEcw+HwzOnvY39bseIlh5i&>DbJ=u;xoUtgx8FYm8YAZXTGwPwBiE|2CK zr0l(Du6=*QAt(3Uo^K1x+Un|mY2Dg+SX}m?2I(Z_p_Ac;gx$(*A#3haQsVf#`Q-J} zP5=(f?92W|hvKKdQ#zw}=4^5!bc*S83p{!Gm*=8(0$wcjdr|!B(yfQ!xco`sbM+r& zI20&SzUjICB;5u=Qa>$|L1-9jLhdo+>CBtkITwgT{Hw}v3665lyU6nx$hIB0{2`Wx z@A!uZ6xc0??3z0%;YmgP!eN}b~69&hXR+@)@mWT_H>3X!GzYr z+#wCqiXakQzTn#SY^S9saWyXm)z-sr?l(}rUu8O?@OueuorR1pusDX^;_D-k z)Um>mc4t&J+eMMDCUU?_rM-;>f9?^Uka zXqt7X5I#usKL%eLtS$!9e0|Rvr(ydz!pCR36>0+kOL;}gqw0mjI<<}ucrK%e;|=I@ z8NSy#>g!;pL4kCetYBmXYlg}0P;zA?%KEsx$@ok6L^1Hk%dIxg>?IVr-&G%YjfO1$ zh6$eUt*1n`S`qW>B~&a|WbV@DQ*l^V9|mpU8>r}gCar}~6}ov$gK!=37{$D$lAvwW)I!_O8?VehPw zNrIuS%p~S%SE9@%f|UrXCwtqq1LjY zm8a?>C*YWGfY?i@8sRlgEUffhe*z9$Ypi{0+!eyELCK3RJB#WUhz5^2{*_(aM1k2T z2{?7I$u8K0)h}NF7679q;6cE+tN^VC{1fkBdPCsB=s1hEmSCbL+jVW8>n!t?R*hF`CH+Z^ck0Ed?7srzBx5=jWIJ9J;sa$FwDE`K5ba$%&o7?#O=J&fg zL5c{kHd50;py{$eWA`%T+isIwo(r_=nL)Mo>j9?^Mzf0RxBaPO+1|{eG(|Ez7?%Kv z{gxOkIdJ@0^yBs~+Ln3%v`1Ee`UO=j_F%;7Zr#8_Mygxb(6lGyr!}*3OH2TeN-wJAfCMm&`(AAr8dmh`$UL9qVcT+rR~)C0 zH-+MRYThk^sR>y_Fe1jX0CobjB?Rf+^y;~*Ed#c(&$&EoseLecwEuoFy=JQXuQRir z9ed61Y}5xMp{epyA99MLS=72z4+9-vXU8q)4ArX^6`f{O0LZI_YJ12dK=t$(tKqGk zbVPp!$pH)GTjgp(TFAqcqOw&i#C!D7Io;;XnD^SlnKHX{W`ub`P>n*fY1p9b+xv}B zVJ5X5p;e##pi~b=qnVSFCFyaYuda-U2L%k*q};0w;I_uzFK}-vWIQ}FHq&bY%q$=C zW&af7HyX6L{?26%d7MIwgsl8qW&}iUkPC9OY8D0DdkRSxEG$Y0;t{qxs9EfU_r7I0 z#@UC>O1>SHkPS)6*t)eS)qfq4SlUxu_J3MT?N)qnlEV#y6{nn-N(ZcApfa)HULw1F zzHIqp@CJZV5c0TXOF6q0A;<#=ua}qi>~f8%U_ldnmR=0e1lz&`yYPN?$tWxPxXBwC zgt!0N(n28jlo}reb089Z<6*6KwFFG|vfnQYe1sA#W&})7)^wa$8VS@C*2^Vs-9gN) z7QuHc(u>Qu!gW|OTLLTzuI9t%T6y}ThEJPC=cqtHMjbX^p;ucHEpUj0G5Mh!Bu1Bk zPwWtQ>I92i|G+Oo2m*t2Uj4C}a&^uNiC21QpKb+O&}GuAMJVV{<3oPOmgC8a{CYHOEEe6l)Mi z1UGIT!zHB&O#^ybbQ;BkBm zIs{k{U5-Unw@5d^8VtE^4bhgchhOXn(4a8NTUoIiywqkZLx?lyBc}qzN8@V}1NZt< zAjV`H4ixweS%PLOxFTJMwdX10x;#u$tql{|g;3-kx6W#Cl$f=DjEG;c{p`_bf*3YT z=@f{zpaYem9*1V|H;gR~6rA^6Ln=gTw2!%c!! z{9Phat~BhZt|Ff_rPomVrzso$-%*vg=LpkC_{{4@IME=LX#GbP4nXe8N)$5kWG?Y7aSSRV@{{TgnpAf3ajVfw(@RzP5=oO(jj z<;vHW&rj0j%2h0G5cn0?fU8|?>(Qi?-%5t;KUf)lULE3TJ5#Ue!+S=p$LaFD?UQPn z2ch__EvOo4-40p0cDjaqASj0>2*Q-9gDVh^7JQ6=44t9sXt z)?k2v(sR7GhO7V-CvL!Sq%0~G;JU{n6CqnL*MN^o4g{r?EH;KTkDw<@oWxuhB5t;~ zw*eA^r1`~vm%D4C^%W^eCA40OEdOe>@E!_O|t zA*c|c7F>}KrOHf0h#3Y=X(XsoTrLCp(QLm=EZZjwksp)bX@+jUA=}o!fs$5x0hN$# z4o8M7<3XV3Q?$mlW0E2@#xCGk!MHOP(z)}Qug@}6#nL<#>X+H;+iAcj*ZQ=h0La>) zn|lE?zRh@qZ`LqK0q7OM4HDR;uG!FT;*`W~WceAbZ3_O=Mf;aC&k9@AQoX3 z!3!LSAr05>oOi{44!oPHmKD7XQ#v_svKrJwD=H#nhs12dTXief#2GJOPa5FjKuB@n zO9{k_{7>I&bKxZlgpsi%ET06(K*`i^T+v6#5>62FuE$DO=Oy$)VF}#Z8ke52tSnwM zS7!(fWMCp-2PP2XW=q^^+G`~^*vrxU=hBK79x0>U4?Aeh)pjE+zAGVfG+_2-Ja1J4 zp+jx$c3b|Kqj70bRxOV5fz-9@AvxJM1k`mk?0Of|cZ3*N53=U!*9HjbXtJil^ z4xOxoMX9*?UeCb3UAqo|&MfGVS{rdmii_=Hqod13`NJ0hMMPh=h!P;fzB~yzXr4%Z zxBw|Y057FqSSC^Yi4%*vDx+-;1*tlsiJD+(s~oeREnt^?6D3koR<`POjqku(Bh-2V z{y2z0$V&AraInB{gmA$T-)R^B@Y?JFB@VtBi(FW9%#mNY#Aj{I0R>i_xA(7eazZ>8 z=`(t|s6(G@>j_{A2_#Fui-gEoSX$zVN>OiDNe(7!#<(2;mr5BO7BAqy$%%f~xqp81 zh4VH39{ut2oS%)DGN%W7Zu@sYLe>)>Pp2*rh`c1vsvskPP_RT^q;EM$KG3IO@$t&f zm%Elvcqy8M9?iUigZnv+=oXg}GU87Jr>2TcgL~@F{nawl)%~uu$)yKV4JNnu`?GrZ z_Rbz1fFrxt1$SWWH$@hpULSgU`Fb_{qjO0LW`bbG{ zoEE+KPb31{uF1G+D{3`RT9&1+hZu@$^ctuCecrrEqNoF3Td4<1Qt}3R`B~$`A}R!n zp8n~#x{n_~f39F{cl8oW!cxa}T8_=1}B(`MNa5CMIJ-Hz4unCjN^@ zP_@`~H9}$QRqKo#?hjj`NV^u@Qlzm$-fImNVgS!#k*xGF`8Ley;R)DG85ea8aO)Yq z3z2s5aT>tx&|C0($@0_uDzA((xyvN~(G2kh}egY_o>_suZ=u=0P|!-FLZ8VE&*q zCI-0c`oF_>t+gk(bCY|X2VP84SO@)E0^aVxvrW8A8q~^6KF)PM@@dcp|7$tvYdPty zJRy-y8kTk;VJsS4F)#N9bAj1j*#pq(I_Ek5nSiLx^Qe}CC^%P#Bs0EJO~k30r*on= zu*yHg1qSjkZ1*itrC)m&1)ruc+L4ly;-^eOosi(jQ+bfrI)k9$!Gi~s%3vme{+Rq% z>|$O6FMd8s=d{>8ck`%nG}JtVshm31@p1?JCr^Sz_|(1|e0(Pxn`iv~-jL99^rYqAlb^Xa?YOn6 zize5hj1xp7E77<6fkZHiKW9UCSkOEy)q^%HP;HF+C>UsrFm_=hhz^`#&W5#&ds2cN zDsVRe+uYXBeQ4wGkKXYvCZjQ52bJ^_Y^#kuSL zJJ+Q_^uTzW?L-J4Hi(oT=Nw5&Db2%7}v7;0&GFNvTuU&{&^o zf$mwDyTOs{V>c`p4ybuRVpG40{XrV#b-s2m#!zFSfH% z>o#+2bIExt!xb|zJe-KcBPksW7wc7yze*m4ty#J*eW<#FUq5ax zuyrebM0eyp)<30q)0@)rZ%;*a&7pCm43STZy|l*UDZ4#zMPg)l z5iWXL6CoV8xswB$9@3>`yP(K&dP*t%wZ1nOTIo|0I9Pm2i;!b)F)P#5*~fZVly4Q#0~prRJWY;rHD`5#!vpC0;aUN z|MW8ZsfvWW{%Xs10`V$k#%wS9jxLwG@a>lq1v%Cqq_gW$#amXVm8USblw1pO_|xLI zT=gZOq*~~M+g8td)MGS3qOrXJs*CvOX|#>cZ8|bd+;3>t_>S<8S7Ve;C;S%d6W-UJ zq0Je_NEZ5u?_RD}KMUIFArL(78QP&w!+XozTK&1SA%p=G8#RUJi2E3i^$xkKLJ>5Ta}d8ryvn(;_MlsaFMxIKgK~R15IpR6 z$@mesSZjZom3Ncfq*e9|u}vmTB)p+yAIJFlYv9`54Q@SZXOq|tez$Cse%gXws@47D z#1>;IO486PVFbhHM=x_}u9;p{ectU%d9aXtSur7kRSv=Kz=F>cNVj;cTYqSC|1g4% zK#nX-ezG=Ux6p|@NAFwB)+(8?whnblBy|l`G6%9cpmzA>DU2x!Ck$wAD>;+JwXP=WEn7#(b;U{_-Q?jUzhkaM56y6fyeoY++U1NVmzo+Tti<=QhH!=(SxY}Vp@@AO@>Gd(g9-%V@#aQT)&Jps#+_4&%%j7I(9%vi z!4I*8)<6YT5a-nTWkM{7JPr3^b_^b-2mIiSgKK*N2d-Q0UqB)D-sr~4;RG_*0e+qC zwQZebUhcKNLfy$)(L5C8;~)<(;U^9bukCRKaiYOa?B*gO`)mW_R5>nhoj;%gTsQ4u zDX7_G+c!4cxl1PzEL;N%Ks=X$^qxKj>|Y>-Hl$I=0ZyDIO8*H6&NRx&$*QDI!F*tB zoc={MY5l1sh@TL>A{|7r8>DVuhBpJ1Rzu|igY`=Tk+u6& z2DeeBuJ@wpVmO+{a;+8}Dn~cwzb_oYeCuFqV0C3om ziwGn6t9y7|;{-f3R4j)RXF`qmdv7Z$dTl;Trc;P11MaaV}(bXTs86d$vC__O3SJ0i>T=+fe0c^OT zOyq9dw;id=%C4bYW}%MdRYpdimEhwn4{WQ0(2b0*>BXMeTn1c$84iS1hoz1f_ zOUMCkF?Uu)y%*MAQx4HN3L{vzzBU(hv;S{Umd_i64A91(dlk@{tit{+?Oz-c8d3NC zHuu3k3v~_^Yo&T>`eX1zVWx$Dh;1P-dNa)#{ZpC8%^$UE`ood}rkwQ;<2yz$C4d0# zlV!CY4?*>S2k243qvv4)XR+zy_WFu;LN+D2s(IA;Kj-sWaD%#_KwVz}FBIk-T}4kw z3IF+GbNyN4RP2{{SK2UP#pnf(4uS79KMy{On!5FHz&B_tlC0)s{k&xS%{Tj3>YqKP z$Hw%}w-_qtuFU-KKi~JSym#%{UqlOpJVU_=>l22%W?l7ByRxB~Ai{3kz(52%fOnsR;F3u0IpBaTr#wkIG1bJY^Z-9%B73)^4eY5`% zV|ors)6Wx+b8cLTRC%>%SRYbW25AIu;>Nx)VOdjX7h}P8f0BjZuY#R!rul{F&>bn} zPf-**Grq_FOaQu;Ll{!Z1#y4#I2Eq+`U+em^nAt^*6r}mV#9+UBz1rV@v!wFV_X-+ zylvcnvxuoUM~cOE5H%2ncFeu4`-xG~(W4G&rL zwEs9kM^I|3$25GYl3-vcvaotM%_1=2gQ z4d*uygh@%qvCo_2D_(iH_Hd*MpnmSnzLO>k>%e9(f&ef}^WEq;-vN%4xqA}3!}4of zJO}c?`^eeX%o4`Pon7vGKC@|ZMxDqItj5~Gi6njON~3%s2W`3w3_?Dfs4 zub)T3zz#Lhy6O^tWg$Kh+``ix#58M`c6P*9`tQ&e+d*L3mpLCV;MJY;cPrM;K0BX; zBIWe;fqJ982wsF_Keu#o-&sn~eLyNI$>pmgh+qDtp7qIkp$$ zqP)ZF=rEb`hM*H?%S>ZLePq<*l;EZ(ZbyIc-7#B6go(S!ew5h>YoF593q1b*kQwn< z;#ARooXw{|%*<;sxvUJpYQ(KOclu`QE8q>b$aVD}f2U=_#NU=P7%D|7X)sD`lGM_N zNz$cu^p#~>T(5Cs@gwuQn^o|?{lrlY%#Ab*D8O5w03}WQVC&M0h4?un_{E5w>$iEN zQ>@{rPcLq$ukRLx+bYEx5=Taa>>yv2EgwoYduWr93k_+fJJ+by#JM3!=C{QL2^AkjuF~6 z@@x%paL$Fi2V+X1@e~)eB5*;8PNcu6sfeGNIE!SA-VYw#t9rop&qVh~CGr%AB4|e- zPtYvUVL;rpFo3r@f9h^SIoW9Fobt$fK))cr8va}$A{QVHkqc!11LvasWw^QgTBPa7 z5X##tVqzO*Wr-zi=D&9jEvvbr7^*lgE3(9cr<_T`GoH)?5B)jh3?ixPtX(nMTncWh z>Gga+0da$%CdgXJ)#6aiLeIONre><(k3nCH_~vdmY`T0^ZRRfD_n=^7X!6a6AFv2{ zOnnvxn;m$sLgD8r_tiS)Y$p)l))TBvmHbV%ONZK@9;?K^#GHNCH-qZp2x0n%)_1~~ zC%|g_mvcxJWLkN>hxl8k3(9IwSJ)7rPh6Sodn8|9q9Vh|%m@zd38gIoZY+r?IFWKr zCvsE+<#*hq8k@zpp&yX%k?M2~WYIWXvYMnDg`_>!X|VWG3m3}tM581hfJIsTtc$Hjj!+l}OpLUWT*AVRB#DA{7X?*|weONva zLhuFxJGPo;hB+h;HkLp0w0roLCN|xG@3TC_vRHC-zLj>0ydv&=VK`M-1M=G-V4}_2 z&8=qIiOmci-7owJZY>mjPd1iAhy|xMa=Ziz^3wTBE zLl(TvSFB^t_vcNUuqMhxgMNwKFQ6!~nR=;1o7vfMHh;deljfFOQBkpTTEBKD7Sy?1 zc6?~_vBUCnV2$oc@7<%2{2PD5*)1X^*S*K>^~!IX)_>b@T_+%k;lBfM3#=-O*xb{1 z6KHkq;WT^{ANwSxtPE1i!EM*c`5v!_EtU+lKvnNAhRLg%Tt9ybuK=gNM}t*-H*RQX zX+bTt`Sa9#vVTS_pUzH?IrNu9nN{t`It)xrL%pdI6cK@MukvI-0VuI7M20%7hD$Y@ zQm|y(d)sP=te(IijXb12GGfrH3DmR_p673LoRgCTXG zg6!jaM-Wyd9rI>bY=?B%XWqekc#}e9OyGCaP>M%!b;6T$XtD_}SyFY&3v{}H(z2a01(xg>H5!2qs4YRqr>{YcANg9 zcY;8`jST?K8F#F-GkQ1i-jN|2=hO4^hiEyLvmf>!K*$X(oMZO$74TR=*%oIKm)q8* zgV9t(h#XV~n6M^*WcCYnq?5V1IG>Af2N4IFjy&vq|NLy`ze&Ur-$*u~pdDt3HPd~J zmhb94AnfO%7^MAO%f>5TjF@OwmOZMM(u*yjd%bQZTOw#HQ64HP>n0WdijozKuic`A z_coHChlq0 zFAyzCN2XnyP*xswQ zg51iUQ#a6EkAedh3#ex4^W(1=1)g_O)`QrH576~(YV?+a4Vw@Fq@dH6_jAHcr53|}PBni0XU{>h^- zVaQ*zZNX4E@?*Kvanea71FQj3f51~di|Rxc4j7Gl;5WvQd}}rZF7jSy(i%uf7^wwL z@Acxyz6E2u-W&EraJ;ZLLB)g$I}I(Z>T44d6OKik4S%+*pXgtKw2FwlGJB{{?wSv^ z5E%OO9Nd&&C^`eWOs)%+5s>aTAz72vT(6q)UxUg&>P_gk<4Hy%7u^oQD;(6|Hg%M6 zuVYFFS|*|v-5Q=XYOHXrEYEJFSX;j{6ikD|eQJY46Y1=Zvm-tSra> zK6I_iz3`GftY|bt^BI@=h!0q)Rd|rf$Upsq(@#etYSdFR8J*gp$e`$JiTiN z^gq9_wba|CAMbC{vORKv6`C4U*2KdC00ac$w+yW%O>xhwtB-tQH-<+f9y+IElz>Xf z_*xv-jUop-(4_)>c*@{>B;yHgGxzWSN0KrtCExaMn^(w)Ll)eC#P=|KY%u`QoF3+X z-b|8w68*C~;GJh^2MhPvf=c}J?eluJ&v#>L?De7A(hWd2`1|U%{M|ep&%f31jR;kL zWScw;Z~7I+du*xj;2w8-N0VX}vB&IPy>vhZP7)m_rRGOA_h03Xjg6I-ax3cOc66z7 z_|=otAVGuKnY1^_sS%D3?G?*~^eN2U-9OG3LSxP@%*v>zsK-ieQig7lhX#V87SwFt@S|b5;AUlZO`C!ighz2%C{`0 zJj`DABS1C$Z)$m&dK5Er5Tn5A3K33r>9rWlDyBm3!nfI{w|BcqDhnl8S>|}5=@5p{ zoTJeath9v%{pHIf@^$kE*l3B*5}3uBtp5n7u&etrU_Q>HVw4~@;!)jv1oBr;NKr3c z@tVdUKBPP?T&sX%-_=M;7kEnSKNNaGnujaNE$YJ0ps&UYJh_Pe8^**s z6V@@O&Iwuk51>MPjOUlIn*{gl)KlQ}z6%Hn`B23cnX4(F@KN|Z-EUhNh}t80lGQrq zv~^xWr%6G*Qh|=;7;HJ9J2yvt656&^ww2QIKU^PLwr8e{S+R9qllo08#viX zvx@KY{nGWI^>+Ii`|T{WGm?2PV?b;vGtTdC0d|0d*=KRzk~%re(x1Kz|4S5E(k}n; zvqnj2X)`WrY;Zvj-qnmPFG2(k7Rk}Uyzn;pUacVBXD8=Wl$ z$(k|z?DNlch*8mC^Ku&q<-bNA#!(qd7xIc#xxKW#+9Lh81)Iz_0+w0)$?$|fW1IUN zm8@h(?9@)IpvuFB8c0CoU|AWs7#Gnst11I zxBXxQidhMVB3c2jmESPGwxH*PO^ZGHU# zD4q|1p`npGaQ7lWxOg&pbOz&;2zR61l`p>pehT0pF;US}%@WZzQ~y=mSRXv9_yc`? z2ut?gf9}6@^)u{r-fEPN2%uYTID3&=$hF_QY>>STdlA#oV3?1Z@nL`&$iY@I{G=4_ z)}SsCpZ@6ExQ5yDHsKFtQ*Jgr2g$TjkJaj?Hfv-nJBxx;&N;lN7d7;R2~(^kLG_;j zdrFg5H=3ZK5DzTK$`!2wAXB`m0wg;wM!ct#r+>HQl*adf&NmUBr2A z_^J51@av4?L(smL81P{Veo&D`iy(o+QTC`S*D>5`zSXU$J%wacw-21y#vMBX~p*XAz9rNXv??d<@FVJ zf2KE<(r@A7shmiQyn)dv#=Dt6IJxM2zn44?QP)KQ(?DEFP=w%mHoNj(sLLMIbLjR-et*Z^)M;Bzm0&J zh4I@167L`c#IGest*A=%ksyp2*m?v0SE?b-&EdikRqFR!X8a*ZGaxIQs?$3QsXRN{U6tX|vm zt2E@t!v2y&KYo^!b5b~uqK->4J35wYW?-(9k2?Y_EwGtb1Y28QpX#zze*emaagT|C z&et-sFmWms?Eidm(+R)fpH+1e+&W?ZP2Dag57clHHFY-3zLSfQYdx&v#N!f-p(aOa z(=k&2{W@eBDL;&CW)x(%c4NCqFqa^IKQp=gbzv8nb@Yr>q$%zax9}U>a49Jl8~7$M zo-im_K`O?q@bAT1$o8fS3jJ}V>PvE1yT>#PyH{-VW^9Fco|v#yQ5}D3_W6nDwQpLf zT#3Ly@fsdjp1^=<|JH_{P%DrQ%;E8GjGigfi*JvbgEkw28vq@gcBwqdTuN5%hynG*8}=SZb_gUcuCEZzz) zZXL7!N_lgybFN#}P6mCME&7?z#u0$Vs~&FU4K)KNYF}e4o|@2dxJ(^tZb(I3d7nyM zJug0R!%7QOLUAd-VuMZ>?ygr}-VKs5=XoF)m5I+#e%ldZZ8z-#a0bL-PLK&0A&256Fi}VS zs9pDw(mHVFPlaW?$I9*5vuExPl#Jn20~*P$!*+CSVMlj404Fgdgn(i#SN z)wLncj=3$6pALHXT3Y7o@mTbw7N*>JzJaC&4B*;*>m9X7)7~tYoneNLQMzR^e+t1( z-&_38A)T0tB!nx|`0dzx%Il}0+1AsWI*16t_NTpSuuA=LM*pZK&CW`QJaKZxK?-Ig=9Xdee*N zraVD6_Q%_;_(k*2&C{QoZ(rEfcy2WBn*#LF1s#7@UQVo&XA|u%3ch!#PRs=k-p(A| zp#I~reYW4?Z)a!s6>rOo;^El9qsT|o=Qy~U;#eF8r-b(GAnu$_R{JwT_DOL47txz9 zCi+C4FFZ`~fDXL{H^5M@S8}$AhFUZoPjCJ{Vvy{F7NZ2F<7%dPdrZX(;?#w8Y?2o6 z9f@q`*evD4rJS4OEQBGP;Y??Lwa$f(I!Z5czsnZ{8|utq)=a_=%0H@PFYVI#A|M+F zQmM_I_OFXrh%{SP&!YgVqAu2}i~Sh-ngR>m-81afwvy=P&Y3<-01zHX_{`SFaIu`xB4oyQ{e zP|Z2&G3{Y;<>vNSrJo0i@#tP!WfVk8Lu?r&Gr+=!JL-bUR4an^C_ZuQkeK6lWXBaC zLLUa@;UBkSoFPws&V_4DUG^)vyFZH253;IYsTumI1v%iqez6MA+m_oINnC@S9EJ35 z{z?0Bi$L(=!C~l;JC&?%|L%-a-7)+u9ecn;beH&Fse=k)&ILtBriabo$4-pTfwsB# zviJI4`&H|;UJ=l_z^mEv6L*hWP(Y6NFth+b%uJ^xrr;SYlv==VsrjI%LLc6_KQSv1-8Wpyeym~j<{q23W)-SG^KwO~16<9NJ zv-WUzmpP`v##}=;g8^NcAhE2@OBHsvt=TqVu@5!63{fQ$4r&_a7p=b$lfSM;BO(9) zTHH;3e%P&qIy@%VFyMoQz@1V8pA|yY2wc2OHGM`}f_7G(Hv{yEjTW)ZWj?s@y@F&=5>`jN8Cay|0Y zn%n1qcNIi1#cKB!y1xCZp`}+F!=9J@sI{xnZ14!O#6y&LV+0|nPHCL#`_gWJO z4CrB}8dI{Cg;sNZC<=0GC|~n#HeJ#}mO(l~hX8XDr5rLL=@M_a-8#C|N>I!2VH^SG#1JXbNh| zM{#KWA5CW&R%M%hed!P>5fB0Ck`|PbZUJFSOG}HCgoK2&ba$6Df*>i~0s>M3($Xyg zQt!E+dH-MLn0dw-Vek9Ct~1v9Ey<6?AC^(W>4o715{QUPZDYMn zPy2vUK>?o&+U0ev1T(7?h+_Rt9ze)jDqnk2|26d)-N*ma0zB5V5TN9(hN-L}ipsqS zAHw&%1~5kd+5AH!p^Nabzr&Vv814!VEp%y}HOq?j`%TdG!G@8m?|ymqlFmbtk3xqb zDs-&LAB;Rz@)6rNn(ATOFrJ^EZ_2Aci%pAuXEnlE>~Vvb4M|VKHT>pI|N8)B#r1M^ zla0y26|y+RjR)vn%!cy&3gd-R@W@s2yhz}G_^=w5MJK@-lEEgQ>MAS2J(Z%^Ef)+> z5qvOx|0nNFRAiifP3ylbur4Uy40JVaa-gfJviOrqFG+vWPD@X(d7WwyLiFyOi6lD~ z6`FDj><=i;_bP)!qdDfcWM~DGqDv>NWre~CQ1W+G7by2*rU`G$(ynF@WgW z+S;0%9QOQxUbcAzs*p!SBiUBE*{VsInQ-su85l6Vm&aD)OoUEz!BfP3p>;LUaM#X* z8f%~`?-|-dj>j@mtt+guA=TIcJ1#FM_@iAp2Kvpbq00G?BX7)uG=79TAolw3QN`q$ zy3vQ4$OBQ*S;iz9+$ttPbD5WEodQ)od*+p&6!xvmPh&mH4!;2lEgv6-BDVlTdKk3u z*N4Qo1c6Q?t{jq67+S+t9X>|io8wJafj`VT{S@nGw)o9)zFQTjoJ4lV^SAGpZ-I2B z$>P5?Ce{;+x$cJY9upqrv9|#_#1aO`N`w0S7a=$kU=<@B81pz)xyNB42#%c5>ZF1V z0nl~+LTARSQ6nQG13DR7e2jWwft6y6oQ4Kf4-9$GAKR_}ODVV$+NH$wQYRtKh_VN; z((3H+EOwcUIHZ{(H&N%8Ua;p3bO0&^d4MX$-NX^`>o%jN5jjy-pcaC&C`O7Gus9=@ z+!DM#zxb5ZQDxLH{ZPWnE77b{0t4R8Z9VjPOAAazJ_lg1jvKv61zx3VmHU;HOem3PI=Y zDCls#e?fIUr?;8R`5#j$6YZ=)r>ryySiYbnO2|%UfvwoWC>ChX67S z{$wSWM*Xl$XiHq%bS%>ODe>W{TuTaukv4ot0+gKbit?>~ZLL+MV0rgyd8X$9pVzwJ zu@BEoDkmJ8TtHG?T{X*l0c`3w5B#~6W0&p#P)U#WPvmnPhEu_qoUH~BlK|aq+}fF& z93bXXHYtPV48&?AK!O3hT^bJ58#k>Kx>zKcD8h-!0~>&7tH9|hhY@df9v^?_B?&P- zqkgi{8QvT-b*3ONUP%qwb$qsdhsfS z{K_6cyOWRpF}S$fxQ8_K+nOLfvMTiZ@RvM+HofY=i2Lo#%rBPXut>1ccuU$BeWe*U%~|ZYa-!_;h3q4La%Z zS!aF7Pj0z5ba?Vu`KbRv#mds`fQ7w^QfX=F+XskDKrsfDqlW;$0tlX9-Sez3_u#9r zM}8{W{;R_^Uz*ywNImCj67opjgOgJG!d|VI9=%{1 z8Cba(bf1a3gF6f^H#qAgv$-L_#`cR{{VJ)3hE;ut9E@6_wlt)Rrm=3C+`5H{*5jbA z0eIN*E#x$*d<#TAa074@dqXL{=_J$(U>YDN&D4VU&xpJ3cgm{_K=}zpK;{8qK=TSi zD!K1%($3wZX=t$^$0l#9A@l5;1t|J+vP&hE6&m3ebMJ|uD&LB+Sn__iq@aq&Fg!j9 zrd(KAT^CJm5l-66;pWKDML%v@yj7$Q=jb2z=Cb2Ey>aC2zS=gIUe`dA;C|IwvWG(p zp=BVpJ5E~@k;C@g%bDMU?PCAK`yXJ(@)Y)FwXZ#5E1^vG=)P-Vv-q4+xV4eZpM^A( z@*>DM+Rnh+-= z2?<0rsj%#5?Q5laN$h?+sJ+glgy?UM5t}|UYHo_YC_#$Lq?4gsi(^_fB9^tP-T0vp z)-QkEn?7Qx-t9Jq$%S%4*MHwwe<;P%1@+Df%}s(AO(d5n??sEsKf{b;9WUlVpeU>p z$Q=RFe*1P?oVfx20=5RK-CccZ>A!~9K;Clu$f0)~hSSb6uWBO^rYR587?jEjy-C=T zt(+dJ9v(~ICd_q01@`dJbNT14EF|yuSg0eJWm~6)@3qAc=3>-xzGXS9iW=G-bKBGFy{@t-59PlX z;CfJTlh$9)_i*issl}y{BN0d|Ao&rMz<{-9V9*kWYuF+=_OqJXUPEgRJ8t|dH+ds) zt;V84_O}1d_Bv3y(N9ZprE^jlBz||fl{DS<@ee;emxY`1({_@8wa!HY*nYR*4xis# z>2+$<_Zr}suu0OBD^Eb~@msY(E$DNs_3!)j%Cjqmctu)QNy+!o0;z);kZS_M;0cHI z7(RQ^{Z=%2Ot6C>{dXjj&X6eyn-%kFB=nUh^1!ZM57L-hl=yQbv&AzQdt{kn>sMl) zg3NP+zYuO@7^_Cn=^To~89aYB2l6J9%Q(AsreS z^dwEUn7B4T?2D!C&xWKr%w&W|_Lhb-gowIDP>kAwfy?^UHLg&ij;1vR=Hma?$> z2E#us^Ok^8TeM#vg!l%5R!t}!AZe5x5zBB573?9`rrFXAw%PN0^DqwrGQ4XDiOPX) z(r?3)hDnEDx*4ih$RK90y=*xN$z#UU&pXfQmbpLxb)aOnSIfLj4Ss4_qniASn#r{5 z>0z&0J|@tB!7ICZ1r~!KICwG&5Q>o+G;#KnYsB)8h*7W^H!KM*l&Kd2{&M{%w}L{6zkN zukk@e=C5|<0Vcp(G_~)b0l`1OW74;8!+0TJiJ~HWnuLXlK1GPU*I=?2 z=FI@~4yM<>E&DfH>?!{NpyJ;p`23OaH`8zUl^-gPapS2-e4T}OEk6R$sl5}xAt1L| zm%zXM@;M^4AX%l*Dj7Kt+wLMWynu=RykDD-r}Ct8MdPl}o(IG50F2zQX+e6MXjIno zqq(Wc^LSiqpqsZOQrv~M6PaHFdP;{?fLg#PMIVU7qwd99pb@)ZgKKDP0~WH)+}(BM zFG|LDftL<`yJ=&uuH~cxq?8#+Utecnrn!PuLVB9|wDA}p<6?+0ISlYCzFZ=Mcx)a$ z==si#_dE3d(BPn3b$BE{q|{h6k(+6Ss=c$!SSRyZBr|>gn^Xnmw|$ zFo8|d@6q2vr=xNT1ZKRwgG01oAK$$5CymQlN26!X`mQjy0X@JuWc7MBg7J?pJkO1` z6JoaD$K9*X{jnAwA8+wx&4y}Q1#V<`=D|`pRQJXWp!&d{4;EI$pvJ@e?{0n*(E|GJ ztI@=-3`fQJEk6pyU=_4~bplV5+4@9RiNP^j%5?1&FLaUNa=QEz|)e?I-HW?>Dd+>dR#i_fe*SOPKNwgWCYqw_ODlTMdbrYDin# z(m`GM7R1cAIjkcJ$Lg=I&fg#{U}Jn7v3-@!Ab6Y=KN$iR6j0>i77mdxZFx1A7@On? zV$&`(EJ`5t)ATJ17+i#s5QSs&=xI4M!O(9Njy#BwnxUfFgn8tn#-;)*U^^U=g4#C} zLKqFtYYdn5boBJ{Zq0i{{sK^6F}I*#JxtnVa{(lc9I!b+X?ke0{1#Ym=N(8T)qKk} zdfZ?5>-R2tBQJsp0bmFqMVhT>)(?(*%_nqSUcVn^0{E=Bm?9ALO_0CyFR2s6d*?fL z-i?T|^5};V7%MjYX5waii)v>EU@a7ips%SbL*B*KaOA|l0P+U)p?d+Z3mq2p?4C_-#-25L|CoTq$A$r*@{s zH4cOxNz)Md?uSKyc<~$7?g@MRFYbeU;`GN4k}ovMEaE+v zsxUz7?70bU2IlTuaI`ERSn>%CT{Z`RR&hHFL@t@b1H^f}s&xhx;9#^DxLApOhRlt+ z^b(7frxbZx3AOjK1Dji)I70HXS(Gf4dgfGrFHJsuWC4uB{y}5;zzGNkgv$ZKBR2=? znA$XLc2EY$kFaQHgc!qB3Ep}xE|_g@qH}O^4t@Iu{|V|-m3OoK31$$)F~)Z@puN8z zH@OPH4YkMi0!l$C#oe@GJXuY@;{k3q*dA2Ry7V zANaR@?2e?j`J!Qw$k1v?VH>BIH_ib&3!IhTOnITn18$Qft)p1PGyic-UjZRn^fvN% z#ntaIro^YuNMv-hwX^D~9*gXqO3AhhAVW^U_LA|$1Nd)sNzPSC>y=>*u&`LJdWY9> zS^-NIP!tHLfUT?b7^lTSZlhhA61=0CnRq=6+F(=ygJvQH&4nU14?llbge!}wig(F4 z3?`8tO!BgB=NVYBC^6BmI@YT4ig!POB!koEBGg~lOGYf=c3Xki0_BY-kM83ZL)U0t zT@PqL9^856?;|Y_{oSh!)h=JcwjWJx*BlfjgXTvdmIa=pN!$?7k6)*v1s~*HZf+JE zlYhv8XzQiKrQG+spa+8RoK$5I-w!!IwvIs$uh;*Jqz($M5C2=?QZmOY5{+>BLsEQP zsC6W`nAOfBmQUYCeZd7YTD+o!@^f}lkiUh@zWy@P`&|-SNmMeptE=m1SZv-Q&Agg* zU{ys~8TR~iMQ=_a{l`-jR{yO1NtPHZNZ!l;*45CM zsRa=;1F<0f`wEgU7R;U?bv6wa5>-bM8DGK#y0#b2hLSr(aYVl>U73h+99f9k$rUs- zZU($3Z0E+C2qUCRR?4tELlf1G#rU;%0!XZ|^TTH%6!h?m>P;#$#g>*Y&s*S>;(*Nh zPK3`F*<7CTgi1nG1pA{XP@@c}RLmqTK@VChOf|+07fVHPw`ibp-kj}#vR#@u4p(fV zqV`YK2^p}PAw7QbW1riMe6U4T0Xu(5%|%*`Mc(K))M-IZzd>qpPbc>6VKpqDip%4|YD5eEnGKKiCJVUZb1O&zM?RK4Vat zZp7wMG#BGJdf;6|9ZY6mIz$vZ%UdBr`>Y2>59ZZ6fKf}v6HJE}PLK1MiV7GpJVjeR zf9|xf{g5@kb)1lp0727tUNopi4Xt`Z5ryYhrWbSX?>VrZ4cSS}m~ucvBSsww@N3<& zK!mTaUP?6KtyLPB;IY5?Pvgq9_&Ws6w9&eTO+k&vhv$b#sHdzn%rugVY`+X(nHuJ- z@$f(!8u9JroVWfYI6GVB!9fDT0C$`HGj+Cj=;1Zk?zE%TCdAC2JFS=nO40_KE#>Ak z#e@bP=We;T%!5Dg;I0e^V~g8KGp& znq>YxQvfbXWrQ@{ob9As9;qbQL4M$wlO`D7-ym$16`WfXEdGwxAHWI_*jQg+RmH$kPA8k{ z=Ba*`de*;o1QSxacap(QLLAMVun##4J zXcGxOJ$T!s$^saAvQQb(H#*-sU=i%;vmiglhwBg6rhub@CBY>R=8>u-fmtVrnEZV|?;f5oiy$Wg*oI_K#PL1672GWEvIa z4sL;cfjsC8@YFt&LFwbhdV}XQt?@8Vq=3ez6g;+Au^-Z`Qs8q*9o&VZS>sEM+%Y`T z2DEGh63L9p_O;6bh#xSPjQNVQN=V!hE6)h`C=7aI-r>+zN_or}xzki+>2bpC1z32u z#&PQ>jeIb<$5TkJqo_!#<5bsxlW^K*qV_eV!8qPIHW2VVJNq>DhJ8yu;50`uN1pwj*LK$W^6 z^{-0FUR$s-V5!$l7A3?=C?;@gZ7;V!#og2#V#lA6h$KRss9bX)qd=*bFY+| ziYZ^q!<^VPONge(BkfhP#E(>E>C6D5bCG(C5Ih<$Vmk&yfbDUbF=Q{oeF{6zBuYEW zZ63;h$`Xk4fd$TFTVL{U`MJH55s@>E>S@y{er5VBLqz0D^_S{jCLXnL>OH&5UJw{! zub(JQGIR{KDt1Z?lDymWMTv|OXm_PMvp|O_qXzG0^)v$?CD$~m%SO_Bhu;A*2Brrd zgF90Y{`6>gh^<{4^41bBFHg~;(*tqBYb-w$Hd0q7IcG7; zGEydoCq-hvUG09zC)Hqi%Ta&$+KoOTk?9S0CjokjS?$zDA86hCz{fDqnY ze|2_y|6>TLV10$i4GJcCACIHPtvpu6z9M4E>T+!_z?W%z7_WweZM2pCcz$f(B`6Id26ln&mX?#{51eMLEm0Pfg1qFvOgguUgr(KUs93* zz4u6?VG%meVyx3=+74$7L)Smxa#TreHX(_(vU#ed#QS0#3IhTFWhOu zBbyXUP9Umgz^kG}A4;O_T<4(|^lU|7m6nAwY}nzUCJcR2VT_ObvI>kqE^2 z5dEVUJM1%NN^Lm99256I_HI&TN%JPTvsW|#_mjpl=>ng0r91R>IU_8h@AM}jZm6mk zqK%+h9mT(q(3z2XR^sl18l=RkBTn2#b@m$JK9{^)cEj((l9g&P&Ne7_#ayaq9(KViL| z%IJMFkmElJ4Zcy&ucjo4Esb#dwojX`!o|*>=A-e=25DfKwn>mn(5Wud97xp~j9v0z zCBcP%w4^5)?A5Y&r%aw%oK6rwd!kK(d|82e-Qr#vR6~|Va*kvAL>P$|#P*kT9mNAb4_J5{nUKqMng*H(S*|N9}??nn800nV?v4nAoa@1Mg; zWo;-O^6BkaLZV-yOQhW(&a^RtZmmXFQH`dly?l{gW1wYelteP>mr(f=dYop7+!0VO zK=HFHxFxA8Mfhj2gKCoe;Rn5B(^$J~O&>+^g$RN(h0`xR z3j=ola}t@!<3;=6AX9gDcL7Rb_I@TX77Hf?XcFQFePYkT`Vj2^fnpI42dW^%8o(S3 z#J)PduCsi~MZ{TaSz44q7tJ>cxmDPSXj>JqIt3)jVB5L9ZBsY~tun)Q>W-D8V@&D% za@5WT<=}KOok65kWCT=(!U2SktdJpLTT;H&d)fJ~v0NDTZF?uHJPKZ@#BALg08wRO zrH_^`8BfW`fN#W~Q~oO%C#Y#t2LWD*AOT8&eFRibo%}qwEXA57P(VX$=;Np$NVOzd zJOyqAF+Hn4OX9$|^_{*tJx<7oBo-8i4Q9=GsvQ^FMc1VWsB4d8tXCc#GW%o=^p zN71D(9!HfX|Kb>zJCfWFC(=A5#{<@RAQ29DboCUSXep8KeR~Nrk$%YMpQG1MKvzum z3~~{fK|aU}Ms3fu>`MXh?pKSF0F;6x+A~kDw5+gNslx{H@h^vcmQ2<~CdIj(tmn)K@d&^LO=2M6oix^{q*_?`}|9F%SK1=4M zgEk}a*C^D_IWd!Oo6S6O#(~dNupf5A$ep6q#`FR?-LDw$cf|v+G0{#lCC$CTOtJq7 z3RAF#=Sv2|KC|f;M191T!B>o2Wg_9DUZv+RDKPVX!i0gE`BlZ_3w`~7S>?Yv*bZl_ ziO?A%ypZk+2n#iJ?Qwv~FH8}-KuFxiSy;STGimTjC!u_+*8lb5L0dd%SiQ-}d&x0;_ zG;j#kFcCBRq~4g@krS)tf`@sshUJ~cvb9S_LdN&1(mbY4dpRTNFM*z(W47M z!zP$KwW7&tYc<@$7qkA%TYQ1$K9r&?S~WfS#GQHpTU>$724OoFnSWVJQK*Gq<7 zk1f6d@-YF8R53Z@c-Zq2_fQ&VWkG)m@MfUK?>6h9+Xf)R8!Ib#Ey@NB=2)_RB_7U$ z|MI5csbo>f{SQt;&2QFJW^evS+5Aut`3m%A6?IFv()R1O_aB}*;m{@_?j;(yv)-ZZ z(Qnb?H1yc1F(H}sg86Foc-SjDoe4lYC{r52?)_-A_ZKqR@|a0Sag zi;`}R){WqBGSQoa3)fiqw$|FeTxMRAKLyw-9@+#|!t~0@3KCSiton)+wui&-cHd0J z7u{zM=aURpVtO-emiPps{dPZYH*GZ_-4;_C%46Tmt3i%{e_wwRNg;!Q?7XX-vlI-d zJboQf-vu+~cuE-8;)AXS%}Vc=!{PYMbU4R@>JxE-_XB_seM@5wYOFlp<-k~h&* ze9_Mx28|xX#Sy#!Z8m_(AdJo%(w)|h4i%bF=OeGyBd><9FCw=Z)IUEN*{`X1cdN_i zV8r=J=CH@0K7q*o4GtVqq|992m-Z~IyH_}mrGF@0_`m7t-<}2_VILtWx=*x77*hxP zGmdaU=>840}1q- z(O#8m0QyHV+`=NErvW>**fHd}AHC`2%j~+65?*fLRKhIZ@&Xq@J-jM$JlfjYr|?s; zc-!hs0Kdy>jIXB#nca2<H4d1YW#yKrP<71Ag?(Ifz(;*{=cX%r7Gmg^M78bhKc`5NB zdSTB*FGv=rBQ=KwWSX#vLfXqp@|l@HsR}@WWdY#r5CGdsW)|*Mz%~M7zR0$*vXYyJ z$M;J*t|odstlSmzc^^N<>R>uxfP`BJmt2`@iv~9lGDG7bp!1R`Lc6d@XrE6z&p|eC zhqFGaRVBU`JhWh-h9NsK&ZIM3p!ta8jEsWb0|;(tvUvR7g!1haP#$0o`8?Q2fU=!B zNiiRWdAhpD<0XJF@2-*P#osS2I|4&s8Pn;CSpHbdcGJetW!emu29sL~`G2O`oabN& z>mo$eM7lyi9thWYQp8QpI-k3OhacqSQ0?NX6Pds$E%I+Y7jDnZV(D-gfW9TZ)gb^6 zU@*5Jk6=-^Z?@F!zq-*aFdQ$P$#{GM78n?Slbc1pw3rM^t8#Cq4(&n|{eaY0v^qS%+Esn%+e?;`V(F_P=|PuV>1)h?hQY{ewZKZrOxDN*(x0p5xQk zbbr6qU1K;^JIIG;M>htON&E8hnJYNt_pcJ28w0Alvs;ITf1LxG9MB6nUt0HX;|Dnq zJ&b9;EeevI^^-kfjm`GOeOMVi`Fo#*QE$?$8(fN84S*&&vg8WKfG7w59F|qb$26xsz7454&USSo(8o<&UyM9P5VFd%P9 zctz0v59@;gd`vXa4qk`ZrJsVE*o6r$B;QK@_-9#5Y5@(IeWYVCs=u>}i3jT_rNNcOLYu_8x#jus9z9kV4i$*Nh8zOw<*@NOn*9qhrMS_fsR>T<{P@5U!6|)~1 zMa0Crp9(&SRwCnV{dXF69gT*@%^Jf_AT2H!qm)ll`s%pBiX3)rcNvr^jiYJfpB@O< z4}G^Ftv5AVDfqQL1!L_1bZHX?lLqQyQa8h^l`uAEY+CqC;&ExS@`Z>Uzums|ib4O( zt86A|lf}_xMcBDXs8MvEe4j2?AtH7zU)1NJYJx2FBnW;qUh&hX2U5o7bX_%|c$?u6 zEtu*|)SUzj9BTH9cN$bYwiRR6CwCVjj+;)o5PmsRyK*3MD2XD6&&e|132D`LSj%sG zUAc&1#u&0M*slD*x3>Tz^xw>f{iirY*CeThJY7=2yMUBG3fgG--WP9QeLN@@%I%~@ zHZsQYMQ>>$F-cn|Z23`C9{(rZV3v^5xgr$BMS2okT9)TU%DDdk+H%4?wJ6V6IOM0^DsYuK=U96W1Fg%c zF;`N*_O&bf03Tis#GD@|;xe2QQ2dS=x5 z_cb=QkR>a$+xUhP97_d!X9MO#!SA0_s)WAud^^gK=+X2vf7}|1Uv2h@9hdBwm5RfI z{9gfxeu8}L2;N~fnaEZBEZ@g|6G6Ab%pLE_<9MW%>3-Ei^oW)=ikII$!@k;;W{p>z z35twUdZ>v{5fJClQ?tgioa^X+#g?X}n$W@W1dn{d%~KHn7eBp>_bwbvv~NK&UFWh& zzks*M6Pebra~i6_oEUh2L0T4uBcK3ZLW6Js@Kzb?XaeE}xWoZViNKe*KKH)fgF`!7 z4B#_RsEO|T67*Wq`(+LYG?j9k`mVaQA7_$iAnz+;(Y%QlaICIzgj=Rp7qz%SmUXKhA6R z$N&@+74G9D{ykFTJ1)r61`%|i+60H`EM!_}>{MEKA49G}koPH8UqUq|EQ}xLrYkY! z+wSE$4j9J`dI9O|8oam^lh+=wiB!FuFx+Zwh|CkE#D{S6Zs@(71UJ6J3=>vyNUI3! z_kpmRm%IJq`aZCN%@lOzuf?1v%NzIRo{UxV(xVI7$An<=aQaH&=Qu1@Vt32)jmS&O zwsxYggbuXTb#kz!u-G$TIlf<_HVIy8`}p>TU#W4jZ}M*P?$>jKYm5Hz`EvZfi~ilO zt_>dUFe%Rz`wh_GFPqbwZlYV~mnDXC5PJoSDLiDDG3fANpH+M&!Iq)xm6$A+nf>@z6DoC~c+2r=n?xQLN7Agv>oX48*ybcRVotLex zEy%)PaZDY#{71j@y@;To8;C2Y(-F&Xc(9N>vY7$_L>_9AIY11*Gqh@ep_c*|_f^eL z5Cvwc6oP)0F8ccps+oKL?H1@Z#Q(XV|8vbl*BGs0@5eo}gV5!fmdhC+Ic?DAAMw@$ z$~5z8BU7GG4#+r4n6$Bd@{Ej@A&RilEQ0OWb&gMrVY*{KYd>e0OU6wSVd!mbpRzU` zNj&X3(-NKcI)|0A-Lt7}HEa$7bX17RgAF(ef5ATA<=p(`T&1--g*R-@K}6$*OTF$) zWGkbcYfy1yNSH>Bt3O#pd27maQ5pZk~VAV0HBs(2DDp9Xayq+Nacwl7`O zNyXGunWP;cxj1~aJ>1?Q!|Duunr;H)vXsmfw?aNbkCnfQInBO3c9?8Es zNswH++2951=@+j?HzB~9GoJ)Y468mYtb9uZ%4X54)8$u<*GG(wpF^);41vUljq!_{ zCqFlqAQoN(&n=kqjg5`riK(*rZuYz=?)kCtK^7zgIFBkYA6y3Bx~KXR#4~l6MmMhX zY268OclhjDbEVThva0OtgZN@~2`Pf_=FOI6u@$=EAI|m)^U_f|7U~SZ z3?n|-+%*_sPmG6{;lNOy4Kq3j@T*z)<+*e_Q?>SIL#fSFrG$C0ZrN7|ssU@?6!(B4 zT{Ix|@)0o?o_UzsSb^ec*tF4wpa62{5 z?OG=K;?1?Uw}al`CSYdbf9qZ2A?4Y14)iLd<-q&N>VtW4{jlBTgx$_uqZaAI+O~eG zTjd%H{Ra(V77eFX5-u|X%-VZIlqK~tLcp-X=nXHRA_b4 z&?>wh>RgbM4OCec=V^n4>f7*euK4XIa36g~HM!s(fC391DF$bd_J9Ipcx~hBkD?Bb z;yBa}#i}FV-JYJF!fdh456g`uuh&=n2IgmA%nX`;lEU=!{ZSKX*$RYR{f+k+jPkrJ5o63i43V?ub%i2mre@bb&oVYwPkI(L6B z+IraZ^5x4K-Ebb?7?I53vFB?q%u|>;=@*6!R>zr6+uIN%`V5huM99BEUbC82Tq@*fWH`@=YdDQULR;*(X;j|2U`U)u6f z$Yp=@VS>aQl|nxQ#zH$9LKYI-i<)ukvRjXS?48*CYI3{2y1a3e{K$~qI<}IgWIPU& z`X(J_7|l(d7E{NY%#X=V+@Ji0KMLcu*2nQ6-jB`(G31m!-cxRJ=>ZV6t&7XI%&)bK zSUN#CIyd?LJcK7~{|u0n;P#XyIkiraB^9u~{qHvWz>dpI-$JNyZXDzRKurNhm=zy| zXeL*@;w?-+q6V6?`5!0`^1+>ko`rAdQ`3dW$0Reb{-e7&$wN>lN*xJ7<&aC0%(J-D zp=bE_3g$DKw>r4FF=byKvgC^=t1H(0+_`n{(Rzc*(s>oxGH+Ho6rj|RYe#Oi(PkBe zsuI(iUmXdz@NWzFsBN8%uUnfJ;kH(wNFP>)0@pJMz3N@~M2qqmN&gw_bIeB}&BAkZ zgYOTMBCElDon{*fa!}PDY1xii@`1-GTQ%!dN`YFMU%j(@rIwbS8%nRk8|#Lp{%azl zNYP?y)?4t5`iQK@dNfn7d{u-^AtXKvwn98C!NNmZUE(;Id%`)WrdU`oD1=oSVE!N8 z<1e4w%60&gmHwpGtB;^$^S<`VC&B$xRr5GsU4+_DTbq!*V0oZ~858d#e}Y+dB#4cy zDEKI}{Y;3nPM#Jt)?NR1c%x$xhe+4x%4h)F==2rFOUQ{e;tsx-y4r^NfHX8(9{n0( z;#$q{oBsr#5qJ3P-tgiYZm;V4&4cHs1IG@_64VN9MwKI$0)msIb9!_}p4Ab)sAj1= zIYqEq!SprOV}ob(#|7|uQ<&mb;(S6)inoMTyOM5q-hdAKDLgHaa1)-9qUa?0*tP7%QWMA7GW-a!$m84S0F_3dbK8(t6 zQegGhy_r7TY5T*2;l?^m-v{NCQgCNMA@F4ej6jkvG>g;)mgCFTAPaBbT4^lrPpxK& z+B?v1)?+e1**!9-NE|dz{}?PM{~Zn65RwmLuDW`x$Ym=g;iLJ_^p`Bv`dbb=f%rny z>9z(Q9$h|3FKxf?THRMFk(~GDTvJsr>C6COX7A%Tea`o&;pKs(UA?)pSvTK*&xr%F zJb)qoP;cMLSEnH)4B|_S9)F31XE2aCJZ`m@U4PH}1At20gEuNtDvVSogQ(`YD0 zcV$!daySmL&ZJM+JbG0_;Vp@?aPBEOoQ&&#pSwU#cRv+A$FxpUTm@ovZt~715Cb^+ zS17K_K!QeqB%JD|OaECK;i>D5QDj|MjLfx1>0svv^WdpxU9ct(RCp}g(9ZgUq!&;dMhl%wi_9XS-O9r{q;&-C=ZH+RA$*|FB zEC4MnN!CTN@C=MZ6yadiQPQ-<&rf`QlSJ&Xta+KH->ZqLv+aq;^bw^T%40_A_U1qS zP=ZfPE&W!9kwynsVD@ekt?`ql!#~lF;Pq+xnLBBLq>*VQ(FF<~#=quxgYp+h#Pq*Q z;ODB7iN^VbMcP;O{#o|SPJ3Z@d~OH-MLZs*=<{cESqM7q<8MqOcfQ`|5j9CuEom+9 zeGGzwacdsAAH0jCZT^~y$ps2(31Q4kC+(8Y-3Vs6 zln3Qa3tfDJa9G~JQnA0GRlX7mH+%6qpYqXL5mO%);ZNlH_3CVke`6J&J4QrCB!n%g zT+*e?o}LQG;upWBA@reizx4^#>-{T2j%sx_hmTJcT|pK8*!+oyv%kz|+5EWqJ?6;w zrl|nxssN8D-gFFm1f2{n`i5*iLIF|SNdmxsqZQu>A~X5&o^qP=U@V9Hs_IYavlboF zP9}&XK4toEckqKC%atXxM?=Y7sbUgbZKjM-a{kH8ib*W#dOknH)g*qrdKXVyWDwBv zfez)@SDBl4s?l|Mf?@H)70b*1GDy!m*zt_X=;3<~k9Y9$2S~Qx(Ecx6MHU@#cqTH< ztH|2Hec>fW&lLHBq0Q(!pYmq$6Y6Io)PG2f=dbK+rYD<=m=WgE!P{(AY;;tUA%)M(VEjcO1&+2qKjU8ok7zrYg6Q!Ikj#mgRi8^;cAL=lM=wd*H2Ej=B-x~H>uv?6FhhHBBR_j9I?>ow3)G=J72^ z7cN_Ru*U;syi61H2xoHB*=S~kH7xns_l7)UY`>IPQBVRFH(MmyDceS!)XDuT=w$k& zO`P<7xwRh%zYA4Agy0R#7m$d^UVZ;N;k$g zIr)^$mnbk_oek1c@LnF%odJ9s{_R2#K02zp6_-b|f2F@6C~0m4*O#LENd&LOAyNv? zF1>Vl>GtN=jx^ZfVeQ-TLhwdg0G78h#bGnP`SM0OzddyWN5{u`b5osZ-?Y> zGsp=o7?Yzg9|+UPCd9pQrD)c{G5>LKA#z%O_|?8coQ9^BazQfn(d+hov>rV92W`&s zOtCi4K_Kh#ciN&)4$8;Tz~zWLBX_hWn4oC>@SrCus>8?Ke_p-Ve`FkJXpd4kNN}km zd)AJ8kATv}#LNu2b3Mz`jjk0&ucF$C@GyZMyb?>PCNWn!ZjCC%utOH_WNeI5IQMgt zve%`OJ=9*}lQsidM#BuKZO*?hA5d~J`36s6rW8PUNtUmbON~R}7{nLC$M23<#64tZ zQ*Sm%H1PojB{m1bMtl&*!29!&DK8FpT-C#8ok99V>e$a0s7bTFnPFfM{b9%_={l&p z?pU-JN;lBf1|y(4-UAOFctAgW`eek7UHL?w%e2rkMONsxGUR<+xY*c)4dr|;o9tc2 z>~1czqTm29{Tu7Wp~rUaAFp+>`OM&cy00`s3y zR?`{SBTvTpI)~-E7HKeS7ojE)dZN_O&&j=ha*m9{d2II!k7gW$OwWfvG+q&&a`WmC z1s`VcB0_c;4CY(64p{Q}ls#Z@Efq*max?hOdy#eVe1sKrvr3d^ZMid%Poy%Qfy@(_ zCLg_F?1PRThd4D0NJRoP!S2^)v=70kX9EU*d5ioZqr8Q`wLs|)66|_#;2;}_xUsoM7qU}=D--ZI~Ex)f@)Qw6)+m!GCyK}3!bw>CTJ**JE& zA-62DIRJnMav#)&O4-c|g%)T13Nalsct^k$;tK9RK8itF1Y5i)Y)o7l^arW{Z;X__ zhkY{M88)v*+HeG(0z}H%p9q;{G_w6pbRGtO$i`Nj^h^@lRTGIgdNU-5B1l@&T!nj5kV<{o-QNDUU!VNYn z_d{jll^@ea--6nGabPoKSkIv=o}Q6A5C(TAu;{9eqI9r~UIIY<*v22Q3)xNElmdti z%t+6lKZo8$4WHB$QfiuZohwo^GHNwD2J6jvl-~fI6G(Gi5GjCQqr|^GujR6uiP-#l zzAKz2Cj!F^mP^Z$S;NoYw7wq3BmEtDJX<4$xwKhr`#PfFZv{GI%L*e`^mOMNxB$qf zAa}=kPeSa__5ZX0znb;*%GKEi+%FD#beP*8d~^Tq{3Kn)(0wES%=1@ODFAMtd%+mg z@e+2)rYWj}k72Zi%*`2XH~A#caI?Uw|GmGQ3%FF)GQF?hP3lbtJI(DqdDa>TFsD^v z`ds6%ESqg6K7S|k0BkE6o`(vY<(efE4USp^2nUx%E*TT8eb8sa%f0~+kPF^54ulF>(i1CA_kdt~uHN0ZA}gCB z=ugd;KSp6yRaV}R7jyclgoVZ;NFI|mx8Wq2{eAt1bp-JT)ntdOr-!ffz3Gs0r45Sh-bhxxEVBeA{fG&`7Ls9p3nz8k~w3>eVxx5c#AaF6&Bo{92AutylcYx0Pr~_zQ>pQwB_}Qo0+eWPdyNk_1#!QCGT5I9&9J9Jn_lq@N~|QAP9O^J$Oq`EYRzP^f*O zXV>KxWOsj)_!`d}74rwqn%FiGb!3q6fb{*Uh~US;t9i1A1V)8@6Q5&7Ul4a_bF0j} z?0lAoGIU3{^vMFd%#O(Y!}(6iI~<0)O!-_X1vswMLnPCDD^x4Yhrd|m-c>_wUfEUl zz>y2}7nFQO1U4+GsW@{9!-;;)7JW=LCOJXH0+Ca7HOkhP+3e&x9l6 z^c3;k!kPps#x;sv8U{sq7Lui~&aR=vkEft1Q!!IxU%9tGmp&<65f=n5z@L5=>7QbT zbB_XpLil8V+$^LHDQ14Bm)Z}73}Y!*fBxHB0Xd<&3j!-GamM?}AusPjyQc{;tsuvA z^QQr>+`?T9VHr*OqZ+46pN%04+ucr|`{{|pgKAqGsl6dKUJeK_<=^mBtr#&^oi&p` z>#o8vky*R##c9jvOYfr^|59k*IJ7#!nV(``jx@$n<;P%d#;C4uJ~JKdZ`_8OZ|U=U zJvK&OXA4JXV8Iu3hV8qwe#Pe=K{@)UbDWl53(?*eVO8rS(3uNk`ANX9Dp045Tiw%FKKuH zGx{a^UC(Qr4t_M;QW_`1zloWiP&LHbjUj z&N193!w*6_2$83}dAPBd2)LE^-tBpu?98~f<1j}J0-*zJ@)3Q2psJhSgITeZ5QRhB zQhnkd$fU4e3?LH)zC+yeAW2%A_%@@gt$$X9=`Lf+kE6HVbG@1J^2$q0cylZyr2&EN zs}Np|T=69pD5Q9_%vVfq!TxRB`WT`)3Z^eWg|Xk*a^6V(S9|2JZSQ37XU!G_i{x;p zgT@g4s(%}Wxf4%~eX5LkDw+=ap^^ivd8l0dxb@D@nx}%Fy$kQ|10!n_%sx|wCmT^4FIKh% z&s?7gEczR7EpCBL5AIbKT(W2V4OkOz3tQ0}!R71yw>s0apa6rU8seBN8XfRR0W=GQo-i^8Vbqr*J=8CQRce}Y!TxVT?BH3m ztB0S;DD6g3JrJSqT|mgvUnD;7CPAPyhCVO4R>YmV2s>me^Wl$sGkg-jj$5EYf26|7 zFDg1j0e+4}dnom|etogKF9wQPGK06Y-tpF2g*NkFFUK;k{?Ok1nSk4P4Z;%J0q81A zob(g_)xtc1CI9dCw4WFtTV`P$Ick$Vqh9;m1^79ho=tj1rP)tP?!0w_8d_#K+6a}| z@V_)cz4KhaeIXDBr6Ui!AvzJ7X-&-QY%ahn5Y*?=HWQKEyF7r`advl{w~B7vM_^KD(1o zO0IYXj$z~$beI0pnMn!^p|GfZI-PG;GV*k__gl9ec0K3l&4DVUaPQ&J&=)Ci;Wz)u z=emQs;F#~Yk)NpO%PvzMS^ytjIj}ZhllhswM4LUs>)!_W_Mkh;ReOYD;aq{(g!-6b z4D=8%0bLzZ3HjN6JpAND51?6@VJz4Nd|-Y$nRsDGrK#?d21Z4s1|@793aPY#RXQnY zR%cLAKCBiAU0A97OXy`&qYFz5$~$4{#^b{zJkRsKewGVj40Y1w*O?M^{B$mLf^sc^^z_|IptWp>;;HxLLy+>2jiRwMz#3U zJ*}31W^TvhW^t8 zMA0iM+6j!-bn;y5JWg_Pjx-dL?{1KRktjys#*S;ogb$iyhR&m^ejY~TqaA;%KMoRh zNt55vC|h1b4fu2G7%()37hQ(Ed43I8L=p+DD z8zv|;gi+@Y!@w=rP(H@6-BCRxG$2Ic6SBVRsM9hJ-?BI7K*poo-kiA)8JA#&Cca&6 zP$6ch>ARAFGo%3k3c@%9^lI^nJffd7AG5^v=>hRp?;6?B=%7)LIHzaMzB`$lz4*6P zao^<=9z_TpGFrspr&VG2{^f)Gx|Tl>LO_LhU}lrd`z!tb(R7vpQFY-Krn?=H?gkYQ zkdjbx2w`YMx7s@WV2Ysa@~>^T!+TJBL&DGrqaPcQvMq zY2?`O;%&Q{=Upju4ruQ#`nHo@1fgDi*(b-t9*UcF;p*crp{Aw=QgeQC5G5@Wp7-mV zvG_Io{4-D_p$KXDB!>Prx~A21qa%f}YyHsmMg7mQgyhE&HbyLFLS^uKyWNOW?5Q04 z5n_Q+G*qkW=}xIiL`4udMXP{CWdlw1#4oLS{d=5Mr`{mEUmM6@kIqn#vXqAmgxH4S zSQlRa`7=Qpt@zpwep=1jb!q^1&#Fa5S?iX-xk5N;(Ej^@j9y(~Aaf@B=5KyakT4HhVPngVGJddTb#`igFX z77N*4w6B6!7&H9~d{)kHRkIIH{ZP*<9XQ3{^Yy3aW49^q^V<{)L#BSdiyv8sqoU&?z8=jUlBBZNk{*!_ZX}FLrT~Re>ixaA zxcuqw6gXWXDIe!-a3>&rdChyx!hr8no5LGpp3M5xFblj|-8R#kM#m$8HLW)+CGrhm zcgL0hh_4@$x^%qy3=xhrsoS^Jy5<%8+Q4LV@Qb%3aoD=^%S{B4`B8mdt(wXT0f~L; z$+vARiWs@zv8|slKTbgolXc}lmxl!nNmDbcVfdqR*C#OiU`1O3gsy|EX|}L)jFA^e zd5bCTN~InW9t;oS?D|w2XkJ?9h7qD%%#|0q!~~E(HZ~u9EU(NR`+ZzPA-;P#PB0(3yw9bm(`{ z*JelDZN;fJm>$7wCCNQMHGWIW%)>;~RU8)Sw@!;#x#T?iJ2GGSujzV~fc%8B8buj) z-+)&U8TYLZ43^t3xGhPr%oBKGAsXRXHNO)mz#G_P5r(<{3|dN+iVVMoSwvhfV-PeN zAtM!9$NIs$zhB>@fg>xDpc3)d$A}Ow*vYK5`pS9oZvlQFRHScyD_ywT%SNMsml&x~ zGzEIZ>r9IiZdVv^%EGox9JUvV{UhP*YrA$rw}b`0T7(hF&{;lp$lcAA6F-ffBc{R zn1s}#_0lx7UOLzc`YgrTf!!9`wh(DKIIbClUq<)tXQ@V(!cWj5N*%-ie^J6FNhIq* z?Od%TFe}K?s@<-k0xVyT*EB1XaIADF%A>{Vl+_#hHv`|b-~OsX0!@CuLD}+ zHAHAgg1TAej78{a6tM3A3zoC9W?L;=pq!lj z9jrhMj>*QTry8~Fp&WtCsYp({4vlw9USD%=Ue(UW4Pzcd2`f<(l2hH3Q56NeSdeTq z&R9xBA0#Q^AWt;emjvthUpGrt~o$1v)*7vcCr z_R5zpWlhlV9v&se6VR)*Bi@uH1H-b^Va`!BR)l*+sY<_>;QO(!efEeZt+0m98lkd$ zNrOOi!IhX0Kl;LD6YNQD=Oy-~+$O@zJFn<{r86lWf=B?FV!!*1b|tznMbqV5Gf(AY z^<{Nrd&;sNeYncYQvospT}+{f>x%{SzW&uc$bgDv3Fy)RJ<_4T7!x;S(kMO?V#WWB zeZiRzBekZe2^5ojEgKDqOsDsj@Jr|&PBM3LA|ck`orPaTFU&*0vVOQjvV5KpKc}T4fvwrWQ_OXc@?5Xg0#pxU{VN&1?N84l|7|)F-8)6Jyfc4 zmXw;$JszHTOM6p*8RbdNn5~=nArUc?$dxVh&YXB{fXGCmw14N=-93IbM!#=W!@{xz zWrS$v?v|4vC|22qYzIq#){Qi6-nm+0I8r;ursdF6)u%Os$WP_K7` zA2z&+O+k-u6*g3+z*0>^0u~Gllr-76qqpe;$D;lLyi%RTP3xTnj^Wpj-hP5bMmR;6 zZ}Zr<4o?*WH1sYi09|rL6VRlqXomHb^yC@d0@m|%BTVNX3n58+!Gr~6O8Y0oZq2vI z$4UfTHw`In>!TN69z*!v#TWUX3LX|rt*PrFfm-wVVUsUF{4D`Jc;zX;{DJaeK^>{k zRZe|!?)WMAKJm*Ur6SBW7UTo5EY8Jyi>h_!Qet9CA0$qxW@ZV;1Z~m)vj~U`WmYSE zSvH$A;ly;DKA|pB5F;O{)JPJccn~Wk(CH0Fq;xNMHFDnLE!mMYru0V!NkDxpOsc2| zQ;BfD^oT{ZQb+d-jAGpeiZcb`?gUt3VD~@2W3lgc#eEXs7sGeY`EoS5lT>>JAw`_~ z9V(&j+|ghDsT|UaWt}sxAHhy=x;C>*x#xJ!4yPCtq#25YUoaq=-^ny~}9cf~Ck6$l2>rg;V z8Lzsrp=L4)qJUT9HP+)@*kVmB`g+#|v7DfWHFeZx=sWlmMJsyMu}YFHXy7t~6UrLK z;T%678(CaH_L%JKA`mrxy6U;yG;RWwM@#t(Q9-U?hv^5k4A?7xUn z0rX_y;Fzhw&_Dc!wKGeUV!lxRxy|IpEd_*?3W*kPzq*hSl2`hyDCx4mY5i+oMHi6{ z5++|zR)OSgfuGqRVzw@7zOD==h2sC6vUk9y1e@zc^+!#{;LjQx5Q5HR6%x>vIeIy+ zMEbUCR&k$Y*Q-&f@=t92484m44N`v1&yK|y5D~l zO(9)^zM zLfavX$!eYiZ%*itt(j-PCC{t)27{Gu{41(L1cFVadN43FoOjM5)FUc zYW^GVJtQAi&I;-S3Ry%0ZV&LYdTYGoPsOy?UBiEOc!Q=}@C&rm^M_pGBRY}_jpn7>C`r&HWdYQ4{q zHVV*W^s$mI2DwxQV??+<`T}4pZn(Ze5Z7-BGNOGhYUG_lgfT_ThFe=_ujG#I15pjQ zxG*9NdiF+lNG)=3oyJ9BRcV0;74N=8dOSk!5KRV;0Y3p-*#Vt^Gfn}PmV6dD zny`e4Eu$n1!)e>OE&FtXhx6^GE(Ka$g2B1u_vOVIzD`=m0)-fUfYRU?dLRD+ED%Xv z*finb!;SH)M2p7{49tV48^vrvyteBDG4K%qj7>=%s`Z?_dXn~(A+%OMX_pN<49!v0~m{_)~hNF zxbJ9bZEoGE;3OJ>@@-#4dCeRPn80DQ`hL@00Gn3tkanllA7)bbjIE8RJqN_gY&ONE zvPL)2!vvN4*GE5s+u=R$k5dIU)-MMUXcigdk}WLIWJmYW>*i+lG$Fg{iwIj}1$VAD z7Nfn#d@7l@OrGq&^Pkt2G$h1zl7=^+Wy=AS9Nn`U{5#U5Y6|H_q5bbg#vnQt-1vlY z@d;l_(BO8mjwYr_`VAA1eKXXRe7T8i`N!`$k024XvB#i zAs9!X)u-@Gr!HVtb{{w>3Tbz!UzTr3EU*ergw!!ex`+-HZIZhFPC&KvN+ znE!5G;ZP!zUPH%kKM#caX^<(?oWiMksWjEjCN?f^v<0Jo1M+Pf1~?u13$+j@t*ma1 zdfugqs$h7XcI}}@!sk5EuN$~e`%|_Ia&NyU0(($o{8&ZuE8WWd)6vfJsW^Xy4!~wV z5}|{DIZV@F;sOpIFzDMWVy)Igyd`e}Un9}R$iI`>J`*ZCKuYyHUuS;mXw07mhMeyn z&?^h$@gLxOKImFcpV*Q=`-`p|0rNuQmWJF4)OWo{v*RlIaay&Hyae!r{hIHt zY6(`iY<6^a{4!`8<%pTwLd>U$(woPC7VjyD zCmFJr;}E-I`)1Y=UEOvD7K{5#ANRZ}Dz%Yg6BC^<2kr#QU2&l?@8+TRTze->7j6F= zBVkj17(;i3;(?=Y!}Y3-+VAHxQYAAzJ$FG0n&rBZ)4))cS-n%ZP$f^Wm{f`-bhmv< zg^MbvorifN0Cog>n%o>G?`I@hm&cjBFOXHdZuNr<}QbxZX@NJH7Q&i1;O;7v16jlGmKMUuhE?uWWD~b2KFi_3@tQ zt?1%eN6h;jR8`Rf`8@fu!@i~228%`%3j-s8#ikYdSIBDGbYdIzSPb)!ymBg>GF^GF z1+rXd8Mg>9&E#QgN*8`47a!>SU0shZ)w;(J=FavaG$qI;x@aX_Rom|Y;(CquzE-=& ztURnoD4)kytQIt5cWKk;xA$U<;3+;;>P$?2ASg4_7xyEm{)?OQGNf~b9F!SEdNR6% zS$MTqPOpy=Eu420J0*m(znt3P{8gvR2M0mL>Aug}w6KG|CO@~GB#YNScc`1uvf0mi@Q~&4n!fi;hf=j-hky8 z&aqv^uYd0*a7#WW_ZzM+i$-osj{*YDx>4$W*4MEl<{VA+BFE^onDQOn!>>PuzncuWTaT!ho0baCC%nSB}rumUzu~o zd_YtKs#~*43AI1gYDO{%FekG=X5TnqIUnv zwP!CWlU(y6(j9@~ooV;#_prR#N2z?VQ$jB07*Trgg0+$M*~h!x<|&L z;%C^-;c-i3Dume6R0hdEp;B1UUAx#Up|=&4cu}(Z(B}JLAMS^4#oI=a65CPy%Fs3N zZDf^*Fzu_0JC=ASyePw6{J0s-^a;e=~mBDHe;Gz-D{p_w=u3Oi&a|0 zY*E(R{@Jh1D!S6p6Z%bVG3&g($5P8Hq1}Fj z(B*CW~P)?AXD9W9x0=_{UEI4#F<`uC6 zXkj7+1ta!Ut$ae83e@(yFBdl;iI5VctPHgW)Awbol0VklL|Z?tO*A5e#>aOiFTY+u z-zklEk`1nwCW2)s@e8mNj<{*%LUxPXOW0TX7-*4r#2IzSXg^v00gPE(rnohxQ_L zXqD6RA(WHYvjTy3_gdNSpZotV_aAxpf%zOkguWOXPH+xtt(NMxfOsKrIYwX)ZP_#^ zy)}3L#hH%wDCnyNqAPAIsZNXan-Jj+INt`Jfu#2-&ja&`;kow~zroR%#1K6DiV;3n zw9j%RiG>bE3tP14eIJbNw|MMVb2PWWjmb*hzn`Mc1!C?7z0-1=M1sm^*uc*f2%=(% zPl35{HEyu~D~@^c_y@O=8Av=o7kLFYIv5DrzA4^ZvoyyyA^~nKz@jMM0Vze>61<9l z{kS3eBdClJ|GLWvq$5ATq!n8#*tz|+c!9qHZ7b|Y-a0Sw4RObF^L(;aj9UaJpWzWY z>-0pJTU)@x}=fu9Asd*`9$-r%d+}asgM`vIP7z=(=P;z(6YLhnC-Y z+fXL;=ysok!mOj-WJziYw1mQk1U0Gi^{@Rq(WH{yk4=yIj*%6x_a3QVB0^V~ev3{) z!NKRzyCqxf&hMpJWEt`vgN#oN-weA!T?h|;`8aB^)pG}Wg72L@Tj|0Sun3eYGBI7G zzRQ;2y*O%$$DC(l1ZI@UVeUK<W7_=^g1e2YLi_J)z7Db(Hlje#vp7A1l&n~eA1t4f2}!R8Sz z4wlF*I_T0v1D#7DSBAdjAjuoFOOvjrNqqp>Vl&8;R~l!4$pL|0^XB;894ZIy8IGi}}T|30(%5{7ypDC6-MB zrlN&W@FMfnzMqn_2r_hRPOaPeKvM-H?6yg9u+lL>A%}5_liVznZRNg7J{!2*;pXA@ z6I}FGIO6gW{`PT(Ht+x_7jP=egNF$*Mbh^?w)JMazF1|ljZq>wS6GM+O&!`(@YxSs zNS~GC^)|!CL!ZJ3!;s8iO;DZ|>%6c2wQu_+9DqQKFVJ)4w-dPQSAE{L=#=ZbT@ff} zZfO}b3e(eef`y@6KNllv9IT>G_dZZo32RKN>IxvCO2@WO%w<_!>dRs5(}O2n)_LU7 z=AJcM9~BV#Gw=THcbf5n`E|ge!;5+t2=Z2=_3#2k^JkP?`i_C;21!ZjO*CvjjfOhf z+|*EFWA4`X-s*`>_p)qvq9nB@kw*>-z+n5cU0EZh0c1L~kEwnp(y#C^nhiuMNV$8! zi)`u-kd!oI$@43l)h$z`4Fnz+nudQSL2Dv1JP-bE;lj)Ph#K7a-qN7?dp9ee8gnA^+c$yCgfg`^6x<4|{2{Q}*(VBo$zEJ!59 z%}EM-a50HeAte(D)4qCv#wRM#ipmH^KUm`Bx@15k8jx;RPm z{(aDXEGio3T)pvMF{zHm4z6>gfc*CA;=^|j!2D51AIv5_L&l2Qr$ZwOOpgCc<9$GF zZEdY)e1uAX!k)Dhl@eo!+ys2?Zo%6ubI639cYg|mG`7)%h>IPw&R1DA@=!y^LFEGv zC!Dt-kzpoG2L~CD!e$I0AYE$7!V5LPaUoXublk# zq+h&F<*khNdO<4-ZW-uoyt_u{Hb;vj@*; zZv+B6P);V#P7355T`3H!rrqNy`$qjrm~l;pYu#t{HvN4D67f3I@fXmn`JERoAm{^0 z9XjC~qqYrqN#5#$We}nop?6<h8k?z|#3>4a!T zh93q5t7BiN-)u6r$(9gTA`j)Seo29T1Jx=ZzCbYBy&AA^-WDwwSA8rk??%<>bFzq~ zmkvkXm4LHfza`~ANDM$W81&)1iJQkwhT*SXY+~hG$q^KEw^qY^4|=wN{^zHk-qnN1#Bj1qg&Fhf0NG)IOC`puTTD7QPR4Ov8?+qcVhK){l8 zU6JETqK$dcd4nptzuesF?*E!FBmY|c1J$!vuyD#=IDy9EIpfV9Du|Pmvx63wlD|s{ zv99K4aQF2$$>s2su-t=XyIjM4fj&&W#0m-zH^2z>U!{4SF2`){%hkF%Aw&-4ezc8I zAxWKp4eH2}`j;18&Lv~XWIgCv$* zg%1pJIK!i+?`5vQyi!hMnxr?LVhm;B1M)^`_~?Mc{Pjc-b@A!406PWkozNfk`i?l# z&>ZndG)seM(q^gN{0|_bH}$|e3M2BuO#YGxwOVhw7`#&Pz5{;lGCRy?(z!4;bZq+O z&zG|8_3l4~4q}=waen^;o6B1K(cgkj+*~?o0RDeaoW129L(A28ap+Z|04O?jfxkQbT@D$C{|Mes zkWr-iCKl3}?>kK+W%dy-Ul=z=RgkQ;-a4R`d5$8XELehkvft7EhYEom(xz22$U>W}cpz8Jw)O_Pn16 z`h+>&Tp_@TkXV^X7V18Zh{B#Br}F_PS%~LD{3LuDWP!3FUrta8(d_MpwOe!XJ{0HCoUF$_03SYm`kRI&3r)7?2tozjtza zHPX*%9{Td^#W=+sGQZltP~bseaZ56gJxJ|W8SxL*4#|*jewh&zftS?fb5>ic{mtzQ zflsKrpiAuUpakcwlfRsq*CG|tg@p*}eu6t0KDJKSHMHiow6wtSD3V)*4vL;IcX}ei zAxY2rzk6gjPZtU`2=UT?rpV)bkc$FnMr^qzJy-TUx!MB$UWv(_V>lJ~NEP0wk>`ZI zH*3}z7Pn@{Ofe}bH9pE8b&#_p!GlOOpwLC=c;FVK?CfwaKJji}hIr9V@u)MjPAF0bKN?uRpqNd+Qt=};ht0-=xR{tardqxfRBJV{0b|gK6Ah^;*+Dsq2%$?vl3bM_)j$y8QD6+abM3HNyFXVNAC0U&hY|1i zdAx_@ouFJ=4T4t`GiK^1##LO0fEFA)DK#b(#hNqUPU5|owlJwn@3$O8g8e^~6sNB2BmxDrnb}Gl}DPegIhD2rjoJ z7L(HXVQZ=h=5QYD{(>gF%MdFKaQgn&*;m*Mb`x-nxpwMTs)pTJ#8nr%hD`@4SS+y= z(MZ61^onSl$67yd!&hBQ^HyV_FmHTNMQ!51o*A{s(&6<0X;%;H zeq!qwtSi)qB6#7Kzr$cY0A7lXiz^T0Y#0s+{AgRNv3n=dG_i2X@*VHtFFQ8DKCD8Q zF2N@#=`IgqvP{_!_byhmO4T(wdEEu2qQIpfqfvZ~X=DjXIAi1f5f_o7xp0PwH4CB5 zQSEPgv16hPmF?RJ*diKMk~|Y#Z_31i=c1lO{iX0(se+!6;&4@K=o^yPpMJV;8`G3B%xQC9*PAR?(V*z zb;_s>m^NR=^|7MEesHl6(om?HHs?r+4SlLKVI=IlJV?mC3Y{x4#3ZO}WZ2~zDHM8y z^lklg!%L#N$7fKbU(`3Ye^T@ST7^XWf?`GGAJ|M$6XC8sG?e-;id|T-^6}T-3$PH) ztBpTxSS;?)=Phxd`>CQZ3Nz1JYJ3F!-DqPv;81xEaJkBL`LvB$&KF0CMsD9gjoSVI z^#)c~)FNBX8aDsn{>i<63^7VjhbqnhJI@7gcg`d!QBjz|1WOVOjMj26|B^Pv zt!d8l!HM)Sk(-IWQ!u{+sz*U?ht>{JGAfZAE>fTlz4le49{<(V5iZ7F{|CQ)w`-Ee zUX8|X2zVKDjJIC16z5nZzXdG}T+dhrWdFL#&BgIt>7LLZ9Y{fJh4_=Kc`sEV} za37>NKA6qZ|6BU`-BXix3X?O_(**$l`w|NHDu%t+44yq{GXq!*>jX$4(C#HDq(YK?K&}>Xi8At1s9KFvw$6A>wC=4g&sZ<6=N~H%>c;6T? z1o|zZc0FD-^bW@6WXJQr$uXk{0vsS=5uPPozY?Pa5B2dNth}_)X)m;~myeznFba*;vAatf$6bAFmwI~six0P#avY zH{D0T=ph(N{;>AbUg9L+g}^rWLw(K8B{?6{6Ob zJ-hAioHr4kBVa5Vgv&-~n}Q6L18S_{*Ilvn!E>A}0PLY#OnyICV3H*aY^#OE%hnd} zwyabZRD{^Ld=Z2|IVBA-(H2p3q?g^C?A-S6{~Rcuyz+Tef+EQ#S5(BsEUxNJasOe3dD_5Se%HG z2GY0`Zo|6yvijk+gxW?>}alOy?^`hvRXf=+Z>)^ zGs{8@c!0gXe*Jo|l^`k1R&ydwm7V-&nMVNj2(WeVq^@xUe^|feh5wiJ!=e6fk6(hZ z{M4*^4egV`em;tVwf0}u9=fevAQJ)JPak`Fu>fegfbr5!vH-D%lYl!lAF&Xt6V8pqov&-*Ry%pmFWFx)n8we;VwIlXIO&f zTzFjv%5@FJoaehG^jD9zj2lnlg!o^t>^b%WA=aCZZ2AW73gP`rYh&+O;~qT* z+Cs|gAJC=`z0@@|0mbwi^K$gBKM?eU(vO4zcnqk|p=0%|3E6*s-vsHg5@N^>I1gYmw^8Vp-sK zgUM%&%*FTn>%IahNJubwb^zW54iac_MHh3F>&YK9%@k0eQN7AS3KLPi{{amk)4k%rAvU$qut6Lo6KJCj;ihTt*rz9eE3&S8smP*LV z{(}Ud?i%@{{Bx;}5L}i?k8KoYMe@+x3J3=~ha92kgVvpDsqe)=MX+qY12IvElkU}W)S-K9>BXE}?`|#wa`pAB zO-(0+TVXn>Dut}@ZTAPb=OysUPP?hz1+BNE@ zGyztIEbJfIWv&HX6;H8GK}9{+vuDf3qgSSq4IeXJ4E;LdW&$G!y1xHpx^7$o-4^de zYi`e1^5K0hruxm?e{>Ty>w0~^{tuiViiWQ39GHiv&^nbq2wW5j7&Iq|sHrSk+A}(O5Ie$M^U+ zlYzuQ(&rpPpe~_L(4|u-=X#GzQ`bvKJOwpFukx8%%eZEKlqTw7yOUHnBmgk;yN7+7K-rXeU-A3{F^nH5+1Q_o z>lCY@Yom(vmncjQy2g^LXJ11r4HqkOen?%&kJLqCk^~s-6qkCv37j81G%%_*q65#Eit+oM&j^Lx) zzTI>He$p`G+gtgo&3h+*;SD4%#;DTh=<7?@Nq5g~@%s!#&IhAIIHz5f<@wXSkY2X& z&`7j~$vjL6vsFbaQ=nuSUgb+SZefX$`M9AuWW$prZg$ksid^G@FM;YufHzu_2)FrW z0Ew-$;(GT7PHOp{r%lF;BzQ1^GP#jd)@}bXSGbH!*-<=t6}Zk=#8*4^Y#SVm^u-x~ zQwQt0d9I~TVK-FFDHl%3cbe7A*WLXt#W7|cR&C?>&@Ey^|Ikdg3+B2csgU-9A=2(` z4;$C)Fo)IrZ(WI8qqZTbFT{JY4Ea)|O&pw)OmS7PY?l03J2k1WG|37uA5`>3!eNzMcE~ zaT7JaTRmF&RLg=0n*I&F(#>lHnPB{moovV7cd*uLoS;9#@&80s%_ zo-RvBO1?5R`GPS2go)391}cEm)Gs3d@TGt1zwZjtB%f=ix&b8NXoTNE3G&rXF3w8` zZl!=FnjF3Rn5%xcv(FX|h34qMe!yGfD)@Q8=%~a-*&dDN7@%3~_C|A{MZF!}bbr3v zrhcdC6b!iNB59PFu5;&w7pyn9K)OXY;A$%~_6tAIg)hgNo812b>=whqrtz85l9H{q zW0~;Kew2Bo&;7Xd!M{LV)eq-rB*GJ$0#`VJJEcb|tXVPtrMV95wmVkMojc5f;WkE4 zTmBEqJJ1R6neoS6;nSK@dms%LeKvNFefmy<#F|AdDNs)^K23MB1dI+R@4j5;Q{$kL z=MNGI-X{nBm)^PA!lmZsd)a3Txf=g|=KYfnr+5m$=R#ID-9HzX5Jr!F4yl0`8|=*8 z_yWd7PHRp({Q9q(4|<4M-5`|<^a-hcrxohPIHe-#KS!IRHE_Fmodazn*W-h#NU(&0 zhZ%1|gYWV7C+Byh$G^}W@L*HeSV7>uSK(lz`^j6DUGKRw*UtDpO%ARb8k&C=smDS- z-T-kzs2sWk!bhz@rO6Fh5UZhQyRn||`X2ulkedA9K~9~^8@<{2`A0?)CmjJ!dbb>l zE8I7e3nF80+xH$D`Wg!Vvi9~Cr;e@*fsPM!<%}WYl}&Vs4Wk$dHYW=M**(6V2{6>! z3sFMa>#cv`l?A<2@QZ-8qW0w~i!e!W2lQgDfQtw&Eonc51RsK#xSZXqJUHhZ2wI?u zw_MELG;6ZFU3rNLlPIyUh=F#94g60%pO z%a;SLk9Mh|rR~ha8@sEyE}36LubB?L8}1jf(jB}~oE)W8^bgP)Z&ord z7}e|U0|EaB)CF{|Jq-T!@|qw6*(j6ud!fOI`wFdfIBA1&c=g=EwM2MFi9~dSdJZsg zkY*aJBQn8U;!jY?4@6g|FMQew?l7#i`n@a%eq?~e1sn{Sc0eazW82UJ;s|PDe>C_L*8oTtvt zY2H6V9IP2h6W#Q|2hJ_H^??&WZx^U_?(pF{JKf5AD-a#joN7a;?PR9Vc0KV^mvc)w zO&Iz`Hkow&!J8o>a*qU?(NuKJ!Ppj%eNs`Nz**?=Dhm{1kH|D&8ue_Amncj!Hc7%kOqsY+A?#+?2{b zlrvq#!Y-=bf#NMg0iIQbcD>AkSOH9Y7tj1g{jz}#=NET<@^$&(?FKtd0qS7ih&53p zFRx+n4@Cp^1cFU&UN1FyX00CGN>1V`sQA6h8uh1ZO?lI*wyHc5xJ&yd!4goFo17m+ zkar*%ti*cDx6mPTeg_x|h~&^>*iB%ZvmD0{knqwHsHy!~u7OEUSXda0g>|cLAc(rw zp3CGRGT_0>G#IVlwT{Rv4fF*h4va@dJV2o&zya$&)vLC}=qx_S{Btj9jnJsxN1tLK z{OTujU(f2{{P5`$$(+_|C`$O&O4d@ZrlWoU9T-p1%gS~O+;C3d#K z9r4oL8i;*jn=b)D%cwFL$(6Lh5ApacXm(3p)gAh_Z=_ur z&FgsqYcPi(!2>*11vT2{A9oL}Dq`+cI2_#&f2z`JPR~6X&cNs2AuFteCrlw){#ZIb z%}^^n83kE&*z>(;+2*O?vCtnR{uJC>@OHOF73dB|_K-{<$^?1(h#1McAa;Ee?1018 z&=e85?e3rdgTJJ_JU{uaMsX0AGNs>5Y2p5D2`*kVU7}&}HKG@Im>e00W((%c1_fFy z*#H?=pYFqB*{7{a7Va(lR}q`ql|huX{bAtAKf*W&N@qNR{zV=HPsz(+5lKl&35nt1 z>wb*j4A^2;V0$Ueh2s+sF&hVodP4-!jU}g4iy6-xkwbtx2(u~Ik8WC4XAryx*Np7E0YgB{W9Izc^Lp@JI)rvZ61C(t<$uI|LQu_YMmeepma1eH1zA&ufn~^TvcYCnWgnJ=#?73g*As zr|YAJ0i|8ZcxGU3?nT|N*k3QO+7QlCuU|>|jbhZbKk6zNDxhC3(YD~|!P`$c6I+D5 z2lKAt>La!+6oMK6XMl$k%2rYLf#OH59`u~@&tv$2A(A+Lqmdrj2cQc-`h3_ zBr4K3EW@-P1M!se_{+7rq>!(~xYVY%ljL~eHi2?ZDQf}hUKOP+)5bf0ByaG$n6;bQ zMQG(es7hVB(KA);&74JBJtl{CZJjR}u!0B-!5LvuT0Ui)X$v>MImIvd z3>z`~LiaWfy(1d61PysZN8W{8n#WTR@lZ!&_{qKWT*EH^;ul$w$6#LJ=J+?v3Z$Fa*$ny^PcQ$q8Mpeq6EsR*Pq6;Mk{S=zi~QM2ym&R9mS-qITC5muX@kN%6k$ z!Y2L^N)3Rzb3p?yZ}th2Or>%JO!mIDewwtf#Qp3OZBzsp4dKKIR>4Hy6co&8N5o&P z3(kqeICgvvebTdk0zwEwBk-(?K~*Q%6(X6Of!l%nL4|IF8L@LQ6mYB`0$LO`2c~?r zqx3&@;wGEt^t80BIS5Dg1I1SWFk>j1b(B8}Uas8=^*0snwu0J*?td6;iQ?}+f6hkN z69lM1fo<1?stAWkesWJW26ieiT_Y8spq-8&9cBOO*4OOM*5Ujh7x%K@Y$)Xjznf0` zl9(MoY;8ygcNl`GVB_?{fB3};H}Ed#R)5$uTyT*{)eXX zj;H#6-#FPLAz9fg2iYW9Wu#-v-ef165JDu`dvC|gUfCoo4YK!6R*@a?yU*wM`2O`7 zeMaXv=l!~0_jO&*i|$$YTQ)NU$~pPhvzO}SfV_2t^21K8&MROYQF!A-9PE+zjf$f>AS}?YeyzS_JKwh9B@9iu`$#SJvC8gzO3N<8yPF z8)uApllK~~FxGSr4cH%^`s$TyDon4r22NYtCzK_l*@0@mZJK7X&VoSgD$D!q;86l) zODH8HUjF9F1J5QSlS*AdWgk5pBtp;@!KSS0%vi5SU|O{edyEpTxMX*Y*iLJ~R2a7s zOnxQl%EU!v$~^KoXOgQh^NO#+q9e8hA-x!9xLcHoM<(Fw?_-$BW8UL1J#KLP?Am~o zAQ5Bbuima-J=OrxcY-Z8qWmV~^?tSG+j7LpBeP@cAvhvljCkLl)9B$~c|QtS`6B$q zT|9DG{vQWK=YgG`K+C|8B*#rB+Sb#q%WEF~(Jgu_LFRT_P_{5BK8Si;D7c{lP$CB? zjqrKiOWJeO+!Aww!`?0pexMLIKB9$Px@e0x8i*bRY|4~AfXjquw7V`9WcpVFI*O%&X*IxI0w zfYzw3BDORk?gpbwMVHXEg#oJ21huG^ewK<7RxZPUVr|kko<@g#^QMF}k1apKzl$ z!gJ_e)!xF%zLVFd1|RemxOY8V(4Koj1j%KvHV23Yz0l&DU)qa+x^fnjYu8+Ja4;EA z+*jO5S?tJgGrlER?L}d6vlPtsweq|pBKWjdpjrfjSN9rh_HnGhZ&!6b7XCOAkE2WG zDEy$=HU3GxPv0;2MS-mWo;dVnzPSl*NgjGWH!Jc*IZF)*!T<0G>z-C z=|Hy3`Txufg*1|OQTt7;lp~u{bVm3^SRT}%S;OFY)CZ=i?_#OS{;(;XO6Gkw->N@_ z9NlQJuphQPfar4}%^{FUeCpm`E{Plax82 z^7<`&g9mF0(l+wqAjke_s)GmeQh=I8NtHzqoH!UyT$4WkZ>itQa|Q}@?dMh9kMFCL zqogmqf^h2?wFG9UVu!mo(1K#2W{IOW4%`=8YuPrEkSKWh6`|&yCrhCVP*|vGt1KEX zg={kV@psMD8(;wrwimpAAT|%@Yq2X77o!7pVR9tEx^?_^7Q*Kn*kV~T1(ZWriEtVk zOItlD0q1F%PI)urura?*J-=Y6B>sh!8Zyywaaj7eG0T;+X2~|~=*OLoR8uG#r|hue zULL24O$z{R6_B+oV_$ZzbES8K7=0F>eJ;nKVsd&hZotqlBbRig2mWv*55lro9y&_~ z0dObt*@v~aNHdhdMfU^5i6M8Y8(Te-%=KD*1V4A&#pww{U~Rpb$gQjCOK1Xee5`6w zXlQ7(*zy|?Jyj9xW0H+d3;xG*&*92<l;oU#?BNw9BjQP*sh=zi3vb zQ}%q`dmT2yqyA=X;C|=TQyH0u*ZnjN@&ief)Td}bEjr`{EH7f|80qGU_&ctE<19CB zX_#|VO4vXrP~gx73~fn6pdnR*dgF{A{G6%;m#74Z|062HPknDIzYR3qKZu!~BUmL%ka^f=>m4)0YWo|<0m_01_2jP6qRl|9pFkdvYW76hx2tfTe*C6%`MrLJ>eK?b- zvxO0yQrXdWgy~2a0b2WXq5@osX@!R&q}$ru%9xiARG|6@UDfGW@)*7Y&zxQWXlD5q zvIItEG>qU~g|XF_G!fWc|INFzqjZTQ!AgNbN9`H?PnI12cBA`88>9j)V+fx7&kHc? z3mY&W*xokNh@!K88@yUTSG8X_|8WNpJw^&}efUgSQwz7Nc?d$qaCP1!?EaX=giCN2 zx)qTKtN)x!CH#{PqEPgFIQkem5@Ws&WG$G|vNZ68b_+;Lzl1a%)?tJ~KKiaxaf)HR;CxyBx&5k}^NS3kLw?+qQEnQm+ z06SjsS6@~?rl@xbL1m*G?yf(U{$NI%Q}w7}~f{w;9`dYQjZyaGH%~y7Zft{rG{to#1)&zmZP!osH*jh=%S@; zS1PTLhA>30I|W!5I0OWLNxK;(8|d8zT#{gF zdx=Zt%CMJ>nDPAV-?uk(yM;*J2J_Z2c^#$y2N$JSLE{M}RNTytLdQq_kcDu`G`Gev z``p^nsK4w-SP!Mt0)H?3`jYrB^M3J|JrUR9+ui{GY@siTVxLXd*qHQHqV9~|7ogrQ zeB+LheLOh6GKhuDvnzlCbD%cxnJ~x2?Vo zfQwQyi$7I3914v68AA~H(qWkS6i=Ax+F}^ggud-C>k2o_jgG?5w@X+|DQBs66(D#X5-C>*CUHtla2nUTAGIVHB34DaBa#yU%fS|20pep!7bM&=;YiSE4y}g zm2_zMzE+J~V*e~V?R-JH^HiFIVHspY6X!36PHpu!tuBE4tW#lx_(7NE z7=46}E^|_l7l|cE!Sh|M@vGC{mX7YJ=$%bxF(^2#hGE5XMNS+HGHd|@6lk_`++a&t zhD$GvFNVydW1~~sp^kUHIgXVFfT8qwj?yMtacwga_*>v)ZK6j?V@vf|K)#C6NBEspwn=ByDw83)48~C*QT#`*Xdkr zI8hKZn|r?SOkam19`gh{go*+HrYpebZ#-*-CHgo2BO#TM?;CYvh`JBpOmSj~JxT94 zz3H;oNiB*`L2N1g$;5^Qu2+08Nm%HEVtxg=+=a*3 z%5+z46|8fuI}J0or-Z8gE5qu-YlFU0m$}#cY=JkK5ykfL@xC6y15QHezd!SZ=c}JQ zTzCOW84?v&FeU|IYe;HV>qQit+X^EI3>T*%x&i8@Z?5{{eYSEpjU~x?2RCsM5f7;2 zo}Rctuu#J<>Nx8KX2<>;>P0cEs5=KH;C>y-|vb zBS!poxN~=>Zp2Hb&<-ZGP&!w|=AwpviC@QO`NKcqvkcEDWMqDDe|u@z+qpg%jOh1JluWg||wd;-jLX+Ash8zV$p?|Kiv4j_^yR2O;2n zY<>;{i2sI?Xi^5JfnaOd7GOc7vf5|qRsPI;#`Mw?eTAF4Wgh>@ zvbK-TjW``@R+ZVvKa7LnS>qpAzF|PsM1q_*}@RxMdy|?;=pZK`>7(znc89Y{hqe%4G7M3liX9D zqK=1a0u}@%717Z)G@I(@qZjPb=S$a;`AjRsGuZy)|03X?$TX-hQuhkR!{;4m#!gqt zI^6FwE*oQn8go)WL4`Ptxnd}#898KTxDw&{l4p6W!~`AyFvkNn71Hi=5qAg^fdP#+ zq#iB&(&4-Y!U!5GpZ|S8M1!%XgEUMo3IhJn42fi&Y3WO((bR(MTNmz_O4$$3-#!y9 zCp(T)c961sn{TBL^$&MCPQSo&`^85}7(78V|K0Li%HQ?h;Y96ykC9T4HQ^>C0<~dh z=&{qo;e)*V#35uKrWD~F1p9YmZ(5-|ODuYl76#J|%xpJ$v*g)nBTYQUwCg$mS5Q(_ zC@z(3Yz3Mux^n}jD=_MU=fbBG6K_&v1;>I~0W5RVWpj?EUP~dq*Zfj>Flgq%bun}w zj{t9Y_#xvLm7W*%W-PEt0po&K*%Q&=2<4Uz|BWg@r+7Q1@-{-Zv#0S%UEkH=3M4^NPEu*Sq=_jTZb?i&Y% z4@)pS8kBlGs8g%MahlG0Xe1AE$v&Jrf zV*1HhQy7EGQS=`e>W8w$Tj~uU6C(``Se!JYR7AM1Bn0PR@ts!qA93RYQ)Ty(*ZE#w zqNdy~Y)%pmmR}XxK~#r}RB2HWAQ-TJ;U|9e*QLpb#EX2Eho{IU+mG^I`ERx3KV;*# z3DSy5Ahij+03I`y5m++h+$(O2R1g#q!O@DIP4Pz&PIgHbdz6HKgm39VF+21S$FH}Y1 zm$kF4ZmaMDX8Wc9i`yRbbONogaWiJdTPy8a_{4HIsXEQ z-75o@f-nD$WMecZAlXyKA$HdoM+E40!WSwkD$f?$jS*|kVx0NRLwuVzpppT^YFIyg zKPW0HDk%~L$?q#hc68rLuCi;J2~a}Rso~s%LXP_kk4J?2Kf)|4GD;Y7#n+!@e!QxAiB}{2|DzI4C{i#LFnhPqjwPVk zLoz{3SmO$G>27mN33V)}kue_KSEX{lav}*0)_I|l-}~m&HKh|ZeTu$r0kz$n`oFSG z5-ijxb12;d zk&Q3bk1=K=a7E&TRe+f}Y@1N0bnm5xsT_1W$rRi%OSwHndp~r498y@DgL#qAB*?%K z=;PwUP7BuW_)QO@V6x5`!m61p`L;5%RD5>8kKbF3k01$jg~Wf=YGR z&l=Zb{BLh?V~~9L{25le&ihDk$ljHZ zfP74~TJygG&5{C5L~q}kYh+ZE#pJQAw757j4bNr8pd8m_KX0ex1Oj{jrhvzq3qK}h zqD60A!SpLldu!_-fH{19(Xt5Un_V*1( zmcTa7I=$X$Ec^u#Z6a(HTIL+O%x)@=2&)Lukvz7XSlN@<|15Mqu;dJ167lHo5TNF~ zt^Dn-Akc>Pbkjk52kl%gP*GeL88*^9=NRtuX{sqWvDC;&`n#*F@ZsA-wVn?u65*5N12^y_ilMzaDDfEHe&tbwey`gO z;SFp$RZ!>JWad=%swzcJ3tysUh|W?LjVv6Bw0Ins}&ss%M|OYi`2x zrRqj}I`hH`Uxqi7X$^9x>u42*g3pSMue*w*)-|#vH5k($-#CJD8ocS z*mYH<k3nw;#KY$-Nn&Zh|P zfkl!d$(ih<({NpLF(0NgLtGu$LOz;2gk4J###^SYEoZUMh`}t?E5~T3VpZ>b?gnpgUHqeL{j{zgpRy^~^Pe5`& zG6npsP&m?|y!28Z8anj}pd-@W9_G&6%m%x8j0S zF)ulqk6rbCTAwzu0sOev<`{$qb$%sU7NWxt4Hs*DIHZ}I%tG8bRt3R6Vpkn`iXEagf<*u&)u6VJF=jY~y-!>ziv3%Oi z+cy|IZ<0rx0 z4;-k2P}qu?eOxG@z1y zy4i>D?#^yG`N(2>#ul=~L39ugSgEiT?5qNGa;ox=F>7*8)SGl@fXBI!CgwuWQl?64 z+SnR9_jcj;!xBw;d7$dYbiVZt%k~(UxC|%FigJB{M9fny-6kW$$VyQQ!rG{;j7_jW zX)%%J6q^D;viIh2HVhvnT5wM=siYEt;G?=k?(_GZCM@b6Lrmcfqr%{Z3v7oHm?MKg zN$hk}^waA~piE<{VF8#K0h*v$f<4hq2%rlI`mrr9BE=39tyNuzln?N+mxs4H5-lst$!^QHFB(Gwt;Y+GVpq^S;{^v1{bMq`_RMWA?-n}YCN zBv^wS>h*ZY>wC>VJ`NvKc}6H+m;j6vI`cTS=&2m>CcK&$G1}F48c6*l^cO0B@>Y0) zgjF}IKzZwmPSIbR=q|>75T;(~{r41}Q_mkLBwrv#n4Y+@#Ns2dT7;wq&G=z45|-gA zG6eicqd#r*#(rcfBQ;HQ#UNvt-zcrb1qg?ql6}o)fJ+LU;4# zmeaH04*bAO*$+I#OrIEk8U7d0{b~GRG3S@8rDfwRMwN?So_L$fd?f~RpT;J8KEB`; zOCvLfSLRhbVA$=N3kz-UI6MpxcK+hPOGOaMd}+?!DW}ClDKAT&<0--LFcmj!nDs_5 z-YR9ePWb0P8yj|iXtt59#V&_F^n%rrh;aa&L(S82x+WC0fEUpbvLjoW+1ZRVG+p^> zl|HrunjSaLZUHnu{r4W>d!q_4E7}a1f>-Elr{X~`D6jH^pv|2$v>0bU52Xq=06ZGl zojkh#;l1htiHKOkh%E;1K!)=+r@`<2Q%Ls~NPi}~WEbm3bvizxu;{9Z-KOvdbG-7% z=k9Vq#Vl-w~OL1 z1MgKp%B%o$Kxy^^2|KyvyMiSF9gFOMA1oyS^MpR0q%8k)rTLK~b)gm0b_Lvk8Q8E0 zFtXi_fIpm^iPd2b4)XF<&}Xpks{M8%z`X*kogdmu9~blrtAuuAzD2|jObUr~FYT_b z7Es@YE*@3YKXOK)p*}Yp7lsyNj>mr%^e=CeG)DyMQI z3ePjNp`M3g#ss|QEptc8(Z;AbN1{h$t<^@YH}^{l?bcjVzjCACtz>@h_|N=p*O)V( zi|+HY0hnk*cA;r1K7QoOThQtufYS*u+!-iUao=+Mr<>{Z6^;j7uIam1*}VGemMx)~ zFo^vz5<)K3L0+CMA&Z_(&^l&LJ($dQ32ltGUook=pN6G~&mO>%H6=YAfI)FwU{-zG z!}3E;Jd-2r$`eqo-Wk-}1Flf<%2elTb4B~W%>u%zi|(bWRpw6hBJs*xwr^&rc&e^D zaekr9%)zClkK4^AJkF4FQ@DR@l65o31!LNYer15Y={Bb=R6aUSR;fJ9A3ofgP>MW; zrNUS)c28zWf`!mpL8!g;%jXPEL;d}VJe2!BZ7?J}M&8X-{Z8!KL^qoT2)nUT?IASy z9`Pw2iGr_pQB+P53>W6q+`1LMuqD>I-rXsr;^yV`UXSMiQjvxU+NmTYW&!j4N|^En z6mGNYsFh%f}`oU1hcQlNHjNL<7vM88PChOXQkT;rl3R4p1I(ZbNdCVm3@ z7vdIMHb1g^71y-Eh7AlSr2K=i!27i~Nf)RY8H?U92Zk_AtlyVg0&d*l=9p(CW;I!b zGPzr>x?Y1rDyicyfxf7&5cgTn1ex52}4-1k$Wc4ZD<$0j4gekEtvi7 zj<`52xBSx6%X(>@BMG8n?JnI`9{~$;?WU zKXYCsXa?V*x`7&n1?Ia1E;MKaflA^toqy&FTOP89beL1caF{YM6vk`5cI{dw2zkKt z$yaJ&a*|r;=>iKBR`2dW<{>{y{8J5&@+Yt-^&cYvi9lYf9&SZ%}$K86wD zXz@+X`(T9y_@|ql<_`oj{21Pn2cwW zeZ>j`sbs)T0myLZ2ynvbsEyQq+l;KhAwl- zsISn98*kJAV4!Zm^sW2_wkHX!WT3Ye(#3zkD)7s5PmAlYHv6A4t!~-aZ~x;KSlTuB zqZRU*g^Tfvi0qS(x0=UrbJ)qG%o94y4fm9$HRso8g7s^mJow0nd_)BpV=3C?_z+pW(MuxDGgxwvt}Y}CVM zM)SHFvi40)cT*&94+dT04XKum88kW?zbxbNxZYW~;|aaZd<{E+ zpsq#qo}x-Ks$7o;Y$W;q9Y8s{PppPXpHl(-v!{R7{8nFGDv@=5(Yh9*3>Aam8vbza zmuJiV={c-8%eVf5N#Ev-wmpKF}F4P>tw~0#zLQR zgBOTKnlorJWPU29A=P{qtm!Oz-eQr6$#^`t`cLJyI=Y6==+703;1^l6+UEqV*TYxg zAulOX>aF54(X4RwyLf#l<~a~><^BI$Mhk~xeV|Mp^nftj_?8E$Na?FRy@O@fr_QJ6 z_ar6%0ZW-@I_td!OKi+hsqDAuC(iu}+L>Wz8)`47Ra}lwec>|n7#Y~aVzUA)-pgj= zb1>du)k0PrtaSII@fdxp49~znQFHT7y2@t#`u-^}Xx(M$=plVqdR-q!MjH4%ckbL0 zD8s37oD(;m=+c3!qB>_CJmrqOUkGt1A>_^OdssKGzK2N@Uhv>BERxyrgcl;OdL z`Ch3xe*+A_2`@P*z=iXwA0z3ixsT6ZP$po+m+4^(30T=izx;#3^8)x<2&O{YLd^i& z_!N~&wK#D8=LG=6qiFhV%5XFxmefWl09`>_Z@}Kw3~LcNi|4Llbg)llYc<@qhFIgp z*>5$JW{z6!_bZ zk&yjTxiRPgKF=VZUWEe@g@DW4(>K3l%u26qZ+ zi$+qvtX-k6PcLfVDKmUgw~ix4!V}{-ku2eLCWkK__eNc{R?(7%pY@0xemf}MKr4NQ zXuMx|VwsEeGaiaCAYs>1OK^fWwRV?zGUV4c4^KW5aoc&VdIRXkdWJM7oO8}-3ADx32pB*OVCj5 z-kVMi!nC$rLrG85aBbG009*!<+O`u%7s`0r{}9Hv_Lp?=#XPD-`@7xOE<5q0n9_N| zO3^Ja?yfLZVjBCqLJ>*(FOSOFk!du^8I8Ia8iIEl>vN~e^8P893v8s%^lKQjL(dPU z;NYHqTB!myJfo;5H(#>d8abRWRXaXPdiQuy4Sld-e06Pbai9wC(c|$1DbWPla6T#Q zIqHu?gLH)Gif7Ax1pfti3IJCu_Urx1$FP=0(9{^b76a2An}L=a_3Z71a~Fse^_6~C zKMpdKJ|!y9DKmEaBBJ#mQLvfK52EbQg$d!$4E2kwT@7B4lwhaZ{wJ~0VW#1Bsxoqj z73z0Ce+ESc-OmFf_d|lkTj)&hFMxW$Nu!`f7@X@d2i4-P=g0h7t7<=a^P(X%0R-*t zkOy7s`>~9~Zd6REAmI@!t&S`>t>GeR^c$nnyAz^&YkV(h$RF8b9p7o_9^3ZUDH`z}mZw_Ew-U)K z%+E1}P$?dR7ZVr954qo^mM^$#4hL5KA@Dc{EU&ysD7E$`n#A%&ls(lozlE8D5|8)j z!l81Xk>+dunFlG^n)Tgyc;@f^NO$_x5PSit;5IF;9opk94u_`_g53Ksl97MSHeLg` zIeGVk6_Anux04adqHz|4 z^Nu6McIwQIKcqP-KoIT>B?-mSW*B6q3^(@O>w#%`&6Q?Fg@^Zwkz|&S?#2o?f&&nz zXp@{Vy#`Xl84`t25fK>>3x>I2mMMZC=1oVTYcO}tvkZ8`zYpfd0$PabXA)U3>aq6g z*79tj`RzQEH|go&F8kUX#Ox{OrVlWMBoEPO&MJ?zZr2RHhnOnN<~ z_J3)Y9PtCT!p(3bJ}sH5hK-;siq{DJ<=%0~@$t7218mJ%j<}pixNXq1R{la93Xbtt7G@;ob8(hk7Pr$)fMKQ)_m3 zKRj`DN%{wlgo3xT6<37xz@FN8>U$wl>3y|jA@6||uCfc^x8DP-LV)Q;Kf#P@ts^x{ zXAUcye$Xbll#Kyn&ugM(zCM6S$r?FvsKw<0j?ZsjzkV_XnfYor#Nz!sOck_`CgHBs z8P7d+3QBmro6>IOY-~Ql_!0XRWUTi5WI>3y&ddXonbl`KgVnBEZgRK4`6bAaAx%B<-7$l_g3H4_H7pL@Efx095dK zFy9@g`3T$wrfb?09>T6FBis1fWtHt9g{^FCKe?1|%FfPSN5Ea}y&Oy6Rb@t|ve>Uc zM0>Nk*61g!Whrrb4i~5IU#FUjc?=u2;c8goXFyQJ!^QRovUgx$sGo+CsRHq{-MkO` zdA8Fwr8kSzf%;i%eKpd`*GF&e0Vf1Mezif_*QDdWVd0&#g-icxJ%@S&wC6a4Owr$} z(mBr~=J?=}{tZ2(R=lB-vtI21*}pXDIh0td1Y zFCZwPa-4mu_fTE(@nf(iRDMNV14B++>OrYVB_kZ0 z;H;OpToV8-gVPyyGn@l;jn}x_f+|^I8%+~)1!&#iJAjr+A98|F;7ASV@qn)vhwcht zwz!l`*vU>s;arxYFl}aPDnMw#>EP9R~WoQ_lg^jjSi gGVCaC28_#$>Ln!( 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�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

RCj~ZUpX9^4Zp7P4hxpgdK-oxpFY^qEC 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 W6{R_{XjJ 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 BZe#!e From 1ebfbc1c6bd1551c267f3fc1801bc759ad5efc6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BE=8E=E9=9B=BB=E7=90=83?= Date: Sun, 1 Mar 2026 18:59:23 +0800 Subject: [PATCH 096/104] Update docs/channels/line/README.zh.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/channels/line/README.zh.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md index 0c7321705..db2e98e3e 100644 --- a/docs/channels/line/README.zh.md +++ b/docs/channels/line/README.zh.md @@ -25,7 +25,6 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的 | channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token | | webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | -| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | ## 设置流程 From 71bdeb41c988f0360e9bceca209a02f8e6ecd5a4 Mon Sep 17 00:00:00 2001 From: GhostC <129473798+CZH-THU@users.noreply.github.com> Date: Sun, 1 Mar 2026 19:58:41 +0800 Subject: [PATCH 097/104] fix: improve error handling in GitHub Copilot provider (#919) - Fix ignored error from SendAndWait call - Improve error message for unimplemented stdio mode with helpful guidance - Add TODO comment with reference link for future stdio implementation --- pkg/providers/github_copilot_provider.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go index 3fb15db2f..6d642b2b5 100644 --- a/pkg/providers/github_copilot_provider.go +++ b/pkg/providers/github_copilot_provider.go @@ -26,8 +26,9 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi switch connectMode { case "stdio": - // TODO: - return nil, fmt.Errorf("stdio mode not implemented") + // TODO: Implement stdio mode for GitHub Copilot provider + // See https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md for details + return nil, fmt.Errorf("stdio mode not implemented for GitHub Copilot provider; please use 'grpc' mode instead") case "grpc": client := copilot.NewClient(&copilot.ClientOptions{ CLIUrl: uri, @@ -100,9 +101,12 @@ func (p *GitHubCopilotProvider) Chat( return nil, fmt.Errorf("provider closed") } - resp, _ := session.SendAndWait(ctx, copilot.MessageOptions{ + resp, err := session.SendAndWait(ctx, copilot.MessageOptions{ Prompt: string(fullcontent), }) + if err != nil { + return nil, fmt.Errorf("failed to send message to copilot: %w", err) + } if resp == nil { return nil, fmt.Errorf("empty response from copilot") From d6e88da8ba22221a434c7d0d582acbf48320a53f Mon Sep 17 00:00:00 2001 From: Tong Niu Date: Sun, 1 Mar 2026 23:06:31 +1100 Subject: [PATCH 098/104] fix(pkg):do regex precompile insteadd on the fly (#911) * fix(pkg/providers):do regex precompile insteadd on the fly * fix(providers): replace HTTP-specific regex with standalone status code matcher The precompiled HTTP regex used uppercase "HTTP" which never matched because ClassifyError lowercases the input. Replace it with a case-insensitive word-boundary pattern that matches any standalone 3-digit status code (300-599), which also subsumes the HTTP/x.x case. Add test case for standalone status code extraction. * fix(providers): restore http regex and add standalone status code matcher Restore the http-prefixed regex (without unnecessary (?i) flag since input is already lowercased by ClassifyError) as a mid-priority pattern to reduce false positives. Add a standalone word-boundary matcher as a fallback for bare status codes like "429". Fix test to use lowercased input matching the actual calling convention. * perf(tools): move path regex compilation from per-call to package init The path regex in guardCommand was compiled on every call. Hoist it to a package-level var (absolutePathPattern) alongside defaultDenyPatterns in a single var block, so it is compiled once at init time. * style(tools): move inline comment to fix golines formatting error --- pkg/providers/error_classifier.go | 18 ++--- pkg/providers/error_classifier_test.go | 3 +- pkg/tools/shell.go | 99 ++++++++++++++------------ 3 files changed, 64 insertions(+), 56 deletions(-) diff --git a/pkg/providers/error_classifier.go b/pkg/providers/error_classifier.go index a0f003006..fd9bf1e81 100644 --- a/pkg/providers/error_classifier.go +++ b/pkg/providers/error_classifier.go @@ -6,6 +6,13 @@ import ( "strings" ) +// Common patterns in Go HTTP error messages +var httpStatusPatterns = []*regexp.Regexp{ + regexp.MustCompile(`status[:\s]+(\d{3})`), + regexp.MustCompile(`http[/\s]+\d*\.?\d*\s+(\d{3})`), + regexp.MustCompile(`\b([3-5]\d{2})\b`), +} + // errorPattern defines a single pattern (string or regex) for error classification. type errorPattern struct { substring string @@ -198,20 +205,13 @@ func classifyByMessage(msg string) FailoverReason { } // extractHTTPStatus extracts an HTTP status code from an error message. -// Looks for patterns like "status: 429", "status 429", "HTTP 429", or standalone "429". +// Looks for patterns like "status: 429", "status 429", "http/1.1 429", "http 429", or standalone "429". func extractHTTPStatus(msg string) int { - // Common patterns in Go HTTP error messages - patterns := []*regexp.Regexp{ - regexp.MustCompile(`status[:\s]+(\d{3})`), - regexp.MustCompile(`HTTP[/\s]+\d*\.?\d*\s+(\d{3})`), - } - - for _, p := range patterns { + for _, p := range httpStatusPatterns { if m := p.FindStringSubmatch(msg); len(m) > 1 { return parseDigits(m[1]) } } - return 0 } diff --git a/pkg/providers/error_classifier_test.go b/pkg/providers/error_classifier_test.go index 865aea57a..67d9af62b 100644 --- a/pkg/providers/error_classifier_test.go +++ b/pkg/providers/error_classifier_test.go @@ -305,7 +305,8 @@ func TestExtractHTTPStatus(t *testing.T) { }{ {"status: 429 rate limited", 429}, {"status 401 unauthorized", 401}, - {"HTTP/1.1 502 Bad Gateway", 502}, + {"http/1.1 502 bad gateway", 502}, + {"error 429", 429}, {"no status code here", 0}, {"random number 12345", 0}, } diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index b52433b6f..2fd22353f 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -24,50 +24,58 @@ type ExecTool struct { restrictToWorkspace bool } -var defaultDenyPatterns = []*regexp.Regexp{ - regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), - regexp.MustCompile(`\bdel\s+/[fq]\b`), - regexp.MustCompile(`\brmdir\s+/s\b`), - regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args) - regexp.MustCompile(`\bdd\s+if=`), - regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) - regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), - regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), - regexp.MustCompile(`\$\([^)]+\)`), - regexp.MustCompile(`\$\{[^}]+\}`), - regexp.MustCompile("`[^`]+`"), - regexp.MustCompile(`\|\s*sh\b`), - regexp.MustCompile(`\|\s*bash\b`), - regexp.MustCompile(`;\s*rm\s+-[rf]`), - regexp.MustCompile(`&&\s*rm\s+-[rf]`), - regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), - regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), - regexp.MustCompile(`<<\s*EOF`), - regexp.MustCompile(`\$\(\s*cat\s+`), - regexp.MustCompile(`\$\(\s*curl\s+`), - regexp.MustCompile(`\$\(\s*wget\s+`), - regexp.MustCompile(`\$\(\s*which\s+`), - regexp.MustCompile(`\bsudo\b`), - regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), - regexp.MustCompile(`\bchown\b`), - regexp.MustCompile(`\bpkill\b`), - regexp.MustCompile(`\bkillall\b`), - regexp.MustCompile(`\bkill\s+-[9]\b`), - regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bnpm\s+install\s+-g\b`), - regexp.MustCompile(`\bpip\s+install\s+--user\b`), - regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), - regexp.MustCompile(`\byum\s+(install|remove)\b`), - regexp.MustCompile(`\bdnf\s+(install|remove)\b`), - regexp.MustCompile(`\bdocker\s+run\b`), - regexp.MustCompile(`\bdocker\s+exec\b`), - regexp.MustCompile(`\bgit\s+push\b`), - regexp.MustCompile(`\bgit\s+force\b`), - regexp.MustCompile(`\bssh\b.*@`), - regexp.MustCompile(`\beval\b`), - regexp.MustCompile(`\bsource\s+.*\.sh\b`), -} +var ( + defaultDenyPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), + regexp.MustCompile(`\bdel\s+/[fq]\b`), + regexp.MustCompile(`\brmdir\s+/s\b`), + // Match disk wiping commands (must be followed by space/args) + regexp.MustCompile( + `\b(format|mkfs|diskpart)\b\s`, + ), + regexp.MustCompile(`\bdd\s+if=`), + regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) + regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), + regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), + regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`\$\{[^}]+\}`), + regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\|\s*sh\b`), + regexp.MustCompile(`\|\s*bash\b`), + regexp.MustCompile(`;\s*rm\s+-[rf]`), + regexp.MustCompile(`&&\s*rm\s+-[rf]`), + regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), + regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), + regexp.MustCompile(`<<\s*EOF`), + regexp.MustCompile(`\$\(\s*cat\s+`), + regexp.MustCompile(`\$\(\s*curl\s+`), + regexp.MustCompile(`\$\(\s*wget\s+`), + regexp.MustCompile(`\$\(\s*which\s+`), + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), + regexp.MustCompile(`\bchown\b`), + regexp.MustCompile(`\bpkill\b`), + regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bkill\s+-[9]\b`), + regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bnpm\s+install\s+-g\b`), + regexp.MustCompile(`\bpip\s+install\s+--user\b`), + regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), + regexp.MustCompile(`\byum\s+(install|remove)\b`), + regexp.MustCompile(`\bdnf\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bssh\b.*@`), + regexp.MustCompile(`\beval\b`), + regexp.MustCompile(`\bsource\s+.*\.sh\b`), + } + + // absolutePathPattern matches absolute file paths in commands (Unix and Windows). + absolutePathPattern = regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) +) func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) { return NewExecToolWithConfig(workingDir, restrict, nil) @@ -287,8 +295,7 @@ func (t *ExecTool) guardCommand(command, cwd string) string { return "" } - pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`) - matches := pathPattern.FindAllString(cmd, -1) + matches := absolutePathPattern.FindAllString(cmd, -1) for _, raw := range matches { p, err := filepath.Abs(raw) From cd3a4e1d1e52ba6f62742aa9369dc8adcaa178d0 Mon Sep 17 00:00:00 2001 From: Hoshina Date: Sun, 1 Mar 2026 22:20:57 +0800 Subject: [PATCH 099/104] docs: fix review feedback from PR #916 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Feishu from webhook channel list in README.md and README.zh.md; add clarifying note that Feishu uses WebSocket/SDK mode instead - Replace Chinese text in README.vi.md header with Vietnamese equivalent - Translate mixed-language WeCom note in README.vi.md to full Vietnamese - Mark webhook_path as optional (否) in docs/channels/line/README.zh.md - Remove incorrect yaml struct tags from new-channel example in pkg/channels/README.md and README.zh.md (config uses json tags only) - Fix multi-mode initChannel example to use whatsapp/whatsapp_native (matching the "WhatsApp Bridge vs Native" comment) instead of matrix - Correct ReasoningChannelID description: list the 12 channels that have the field and note that PicoConfig does not expose it --- README.md | 2 +- README.vi.md | 4 ++-- README.zh.md | 2 +- docs/channels/line/README.zh.md | 2 +- pkg/channels/README.md | 22 +++++++++++----------- pkg/channels/README.zh.md | 22 +++++++++++----------- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index fd73f2338..2a253401e 100644 --- a/README.md +++ b/README.md @@ -295,7 +295,7 @@ That's it! You have a working AI assistant in 2 minutes. Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom -> **Note**: All webhook-based channels (LINE, WeCom, Feishu, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. +> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. | Channel | Setup | | ------------ | ---------------------------------- | diff --git a/README.vi.md b/README.vi.md index 9aae23503..bfbacb0f4 100644 --- a/README.vi.md +++ b/README.vi.md @@ -3,7 +3,7 @@

PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go

-

Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!

+

Phần cứng $10 · RAM 10MB · Khởi động 1 giây · Nào, xuất phát!

Go @@ -488,7 +488,7 @@ Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) đ } ``` -> **Lưu ý:** WeCom Bot incoming webhook endpoints are served by the shared Gateway HTTP server (mặc định 127.0.0.1:18790). Nếu bạn cần truy cập từ bên ngoài, đặt reverse proxy hoặc mở port Gateway phù hợp. +> **Lưu ý:** Các endpoint webhook của WeCom Bot được phục vụ bởi máy chủ Gateway HTTP dùng chung (mặc định 127.0.0.1:18790). Nếu bạn cần truy cập từ bên ngoài, hãy cấu hình reverse proxy hoặc mở cổng Gateway tương ứng. **Thiết lập Nhanh - WeCom App:** diff --git a/README.zh.md b/README.zh.md index 145d81fa5..1d8db583e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -290,7 +290,7 @@ picoclaw agent -m "2+2 等于几?" PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。 -> **注意**: 所有 Webhook 类渠道(LINE、WeCom、飞书等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。 +> **注意**: 所有 Webhook 类渠道(LINE、WeCom 等)均挂载在同一个 Gateway HTTP 服务器上(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。注意:飞书(Feishu)使用 WebSocket/SDK 模式,不通过该共享 HTTP webhook 服务器接收消息。 ### 核心渠道 diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md index db2e98e3e..a36f622c2 100644 --- a/docs/channels/line/README.zh.md +++ b/docs/channels/line/README.zh.md @@ -23,7 +23,7 @@ PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的 | enabled | bool | 是 | 是否启用 LINE Channel | | channel_secret | string | 是 | LINE Messaging API 的 Channel Secret | | channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token | -| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) | +| webhook_path | string | 否 | Webhook 的路径 (默认为 /webhook/line) | | allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 | ## 设置流程 diff --git a/pkg/channels/README.md b/pkg/channels/README.md index 6fbf2bb34..b7c56660b 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -775,17 +775,17 @@ When the Agent finishes processing a message, Manager's `preSend` automatically: ```go type ChannelsConfig struct { // ... existing channels - Matrix MatrixChannelConfig `yaml:"matrix" json:"matrix"` + Matrix MatrixChannelConfig `json:"matrix"` } type MatrixChannelConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - HomeServer string `yaml:"home_server" json:"home_server"` - Token string `yaml:"token" json:"token"` - AllowFrom []string `yaml:"allow_from" json:"allow_from"` - GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"` - Placeholder PlaceholderConfig `yaml:"placeholder" json:"placeholder"` - ReasoningChannelID string `yaml:"reasoning_channel_id" json:"reasoning_channel_id"` + Enabled bool `json:"enabled"` + HomeServer string `json:"home_server"` + Token string `json:"token"` + AllowFrom []string `json:"allow_from"` + GroupTrigger GroupTriggerConfig `json:"group_trigger"` + Placeholder PlaceholderConfig `json:"placeholder"` + ReasoningChannelID string `json:"reasoning_channel_id"` } ``` @@ -801,9 +801,9 @@ if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { > **Note**: If your channel has multiple modes (like WhatsApp Bridge vs Native), branch in initChannels based on config: > ```go > if cfg.UseNative { -> m.initChannel("matrix_native", "Matrix Native") +> m.initChannel("whatsapp_native", "WhatsApp Native") > } else { -> m.initChannel("matrix", "Matrix") +> m.initChannel("whatsapp", "WhatsApp") > } > ``` @@ -1381,4 +1381,4 @@ agentLoop.Stop() // Stop Agent 7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields. -8. **ReasoningChannelID**: All 12 channel configs have a `ReasoningChannelID` field, used to route LLM reasoning/thinking output to a designated channel. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method. \ No newline at end of file +8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method. \ No newline at end of file diff --git a/pkg/channels/README.zh.md b/pkg/channels/README.zh.md index bbd9a4321..2c5e7356e 100644 --- a/pkg/channels/README.zh.md +++ b/pkg/channels/README.zh.md @@ -774,17 +774,17 @@ if c.owner != nil && c.placeholderRecorder != nil { ```go type ChannelsConfig struct { // ... 现有 channels - Matrix MatrixChannelConfig `yaml:"matrix" json:"matrix"` + Matrix MatrixChannelConfig `json:"matrix"` } type MatrixChannelConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - HomeServer string `yaml:"home_server" json:"home_server"` - Token string `yaml:"token" json:"token"` - AllowFrom []string `yaml:"allow_from" json:"allow_from"` - GroupTrigger GroupTriggerConfig `yaml:"group_trigger" json:"group_trigger"` - Placeholder PlaceholderConfig `yaml:"placeholder" json:"placeholder"` - ReasoningChannelID string `yaml:"reasoning_channel_id" json:"reasoning_channel_id"` + Enabled bool `json:"enabled"` + HomeServer string `json:"home_server"` + Token string `json:"token"` + AllowFrom []string `json:"allow_from"` + GroupTrigger GroupTriggerConfig `json:"group_trigger"` + Placeholder PlaceholderConfig `json:"placeholder"` + ReasoningChannelID string `json:"reasoning_channel_id"` } ``` @@ -800,9 +800,9 @@ if m.config.Channels.Matrix.Enabled && m.config.Channels.Matrix.Token != "" { > **注意**:如果你的 channel 有多种模式(如 WhatsApp Bridge vs Native),需要在 initChannels 中根据配置分支: > ```go > if cfg.UseNative { -> m.initChannel("matrix_native", "Matrix Native") +> m.initChannel("whatsapp_native", "WhatsApp Native") > } else { -> m.initChannel("matrix", "Matrix") +> m.initChannel("whatsapp", "WhatsApp") > } > ``` @@ -1380,4 +1380,4 @@ agentLoop.Stop() // 停止 Agent 7. **PlaceholderConfig 的配置与实现**:`PlaceholderConfig` 出现在 6 个 channel config 中(Telegram、Discord、Slack、LINE、OneBot、Pico),但只有实现了 `PlaceholderCapable` + `MessageEditor` 的 channel(Telegram、Discord、Pico)能真正使用占位消息编辑功能。其余 channel 的 `PlaceholderConfig` 为预留字段。 -8. **ReasoningChannelID**:所有 channel config(12 个)都有 `ReasoningChannelID` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。 \ No newline at end of file +8. **ReasoningChannelID**:大多数 channel config 都包含 `reasoning_channel_id` 字段,用于将 LLM 的思维链(reasoning/thinking)路由到指定 channel(WhatsApp、Telegram、Feishu、Discord、MaixCam、QQ、DingTalk、Slack、LINE、OneBot、WeCom、WeComApp)。注意:`PicoConfig` 目前不包含该字段。`BaseChannel` 通过 `WithReasoningChannelID` 选项和 `ReasoningChannelID()` 方法暴露此配置。 \ No newline at end of file From d4bc28c11366b9f9b787ecd0d540591214f97473 Mon Sep 17 00:00:00 2001 From: Keith Date: Sun, 1 Mar 2026 20:41:12 +0000 Subject: [PATCH 100/104] feat(config): Add support for env var configuration (#896) * feat(config): Add support for env var configuration This commit introduces support for two environment variables, allowing users to override the default paths for picoclaw's home directory and configuration file. - `PICOCLAW_CONFIG`: Directly specifies the path to the `config.json` file. This is initialised first, takes precedence over the hardcoded path, and is ideal for containerized deployments or custom config management. - `PICOCLAW_HOME`: Overrides the root directory for all picoclaw data, (except the config) (e.g., `~/.picoclaw`). This is useful for portable installations or placing data in non-standard locations. This change provides greater flexibility for running picoclaw in various environments without being tied to the default home directory structure. * `README.md` updated explain PICOCLAW_CONFIG and PICOCLAW_HOME * docs: translate environment variables section to multiple languages --------- Co-authored-by: picoclaw --- README.fr.md | 25 +++++++++++++++++++++++++ README.ja.md | 25 +++++++++++++++++++++++++ README.md | 25 +++++++++++++++++++++++++ README.pt-br.md | 25 +++++++++++++++++++++++++ README.vi.md | 25 +++++++++++++++++++++++++ README.zh.md | 25 +++++++++++++++++++++++++ cmd/picoclaw/internal/helpers.go | 3 +++ cmd/picoclaw/internal/helpers_test.go | 10 ++++++++++ pkg/config/config_test.go | 25 +++++++++++++++++++++++++ pkg/config/defaults.go | 18 +++++++++++++++++- 10 files changed, 205 insertions(+), 1 deletion(-) diff --git a/README.fr.md b/README.fr.md index 7d1ca3e57..2bec768fc 100644 --- a/README.fr.md +++ b/README.fr.md @@ -575,6 +575,31 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes Fichier de configuration : `~/.picoclaw/config.json` +### Variables d'Environnement + +Vous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de picoclaw en tant que service système. Ces variables sont indépendantes et contrôlent différents chemins. + +| Variable | Description | Chemin par Défaut | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | Remplace le chemin du fichier de configuration. Cela indique directement à picoclaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Remplace le répertoire racine des données picoclaw. Cela modifie l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` | + +**Exemples :** + +```bash +# Exécuter picoclaw en utilisant un fichier de configuration spécifique +# Le chemin du workspace sera lu à partir de ce fichier de configuration +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Exécuter picoclaw avec toutes ses données stockées dans /opt/picoclaw +# La configuration sera chargée à partir du fichier par défaut ~/.picoclaw/config.json +# Le workspace sera créé dans /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Utiliser les deux pour une configuration entièrement personnalisée +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + ### Structure du Workspace PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) : diff --git a/README.ja.md b/README.ja.md index 553e8ab63..15ed1f649 100644 --- a/README.ja.md +++ b/README.ja.md @@ -536,6 +536,31 @@ picoclaw gateway 設定ファイル: `~/.picoclaw/config.json` +### 環境変数 + +環境変数を使用してデフォルトのパスを上書きできます。これは、ポータブルインストール、コンテナ化されたデプロイメント、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。 + +| 変数 | 説明 | デフォルトパス | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | 設定ファイルへのパスを上書きします。これにより、picoclaw は他のすべての場所を無視して、指定された `config.json` をロードします。 | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。これにより、`workspace` やその他のデータディレクトリのデフォルトの場所が変更されます。 | `~/.picoclaw` | + +**例:** + +```bash +# 特定の設定ファイルを使用して picoclaw を実行する +# ワークスペースのパスはその設定ファイル内から読み込まれます +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# すべてのデータを /opt/picoclaw に保存して picoclaw を実行する +# 設定はデフォルトの ~/.picoclaw/config.json からロードされます +# ワークスペースは /opt/picoclaw/workspace に作成されます +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# 両方を使用して完全にカスタマイズされたセットアップを行う +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + ### ワークスペース構成 PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します: diff --git a/README.md b/README.md index 2a253401e..2fc60343b 100644 --- a/README.md +++ b/README.md @@ -643,6 +643,31 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message Config file: `~/.picoclaw/config.json` +### Environment Variables + +You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths. + +| Variable | Description | Default Path | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` | + +**Examples:** + +```bash +# Run picoclaw using a specific config file +# The workspace path will be read from within that config file +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Run picoclaw with all its data stored in /opt/picoclaw +# Config will be loaded from the default ~/.picoclaw/config.json +# Workspace will be created at /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Use both for a fully customized setup +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + ### Workspace Layout PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`): diff --git a/README.pt-br.md b/README.pt-br.md index 027970b97..611a61281 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -571,6 +571,31 @@ Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única men Arquivo de configuração: `~/.picoclaw/config.json` +### Variáveis de Ambiente + +Você pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou para executar o picoclaw como um serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes. + +| Variável | Descrição | Caminho Padrão | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso informa diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Substitui o diretório raiz dos dados do picoclaw. Isso altera o local padrão do `workspace` e de outros diretórios de dados. | `~/.picoclaw` | + +**Exemplos:** + +```bash +# Executar o picoclaw usando um arquivo de configuração específico +# O caminho do workspace será lido de dentro desse arquivo de configuração +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Executar o picoclaw com todos os seus dados armazenados em /opt/picoclaw +# A configuração será carregada do ~/.picoclaw/config.json padrão +# O workspace será criado em /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Use ambos para uma configuração totalmente personalizada +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + ### Estrutura do Workspace O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`): diff --git a/README.vi.md b/README.vi.md index bfbacb0f4..e836e30f0 100644 --- a/README.vi.md +++ b/README.vi.md @@ -543,6 +543,31 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một File cấu hình: `~/.picoclaw/config.json` +### Biến môi trường + +Bạn có thể ghi đè các đường dẫn mặc định bằng cách sử dụng các biến môi trường. Điều này hữu ích cho việc cài đặt di động, triển khai container hóa hoặc chạy picoclaw như một dịch vụ hệ thống. Các biến này độc lập và kiểm soát các đường dẫn khác nhau. + +| Biến | Mô tả | Đường dẫn mặc định | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Điều này trực tiếp yêu cầu picoclaw tải file `config.json` nào, bỏ qua tất cả các vị trí khác. | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | Ghi đè thư mục gốc cho dữ liệu picoclaw. Điều này thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác. | `~/.picoclaw` | + +**Ví dụ:** + +```bash +# Chạy picoclaw bằng một file cấu hình cụ thể +# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# Chạy picoclaw với tất cả dữ liệu được lưu trữ trong /opt/picoclaw +# Cấu hình sẽ được tải từ ~/.picoclaw/config.json mặc định +# Workspace sẽ được tạo tại /opt/picoclaw/workspace +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# Sử dụng cả hai để có thiết lập tùy chỉnh hoàn toàn +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + ### Cấu trúc Workspace PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`): diff --git a/README.zh.md b/README.zh.md index 1d8db583e..95984bbdf 100644 --- a/README.zh.md +++ b/README.zh.md @@ -317,6 +317,31 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方 配置文件路径: `~/.picoclaw/config.json` +### 环境变量 + +你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。 + +| 变量 | 描述 | 默认路径 | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| +| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` | +| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` | + +**示例:** + +```bash +# 使用特定的配置文件运行 picoclaw +# 工作区路径将从该配置文件中读取 +PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway + +# 在 /opt/picoclaw 中存储所有数据运行 picoclaw +# 配置将从默认的 ~/.picoclaw/config.json 加载 +# 工作区将在 /opt/picoclaw/workspace 创建 +PICOCLAW_HOME=/opt/picoclaw picoclaw agent + +# 同时使用两者进行完全自定义设置 +PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway +``` + ### 工作区布局 (Workspace Layout) PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`): diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go index 1f52df5dd..9655d3c08 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -19,6 +19,9 @@ var ( ) func GetConfigPath() string { + if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" { + return configPath + } home, _ := os.UserHomeDir() return filepath.Join(home, ".picoclaw", "config.json") } diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go index 9342d141d..47e2f8c07 100644 --- a/cmd/picoclaw/internal/helpers_test.go +++ b/cmd/picoclaw/internal/helpers_test.go @@ -95,3 +95,13 @@ func TestGetConfigPath_Windows(t *testing.T) { func TestGetVersion(t *testing.T) { assert.Equal(t, "dev", GetVersion()) } + +func TestGetConfigPath_WithEnv(t *testing.T) { + t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json") + t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred + + got := GetConfigPath() + want := "/tmp/custom/config.json" + + assert.Equal(t, want, got) +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 12fd10b50..6af7c209e 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -442,3 +442,28 @@ func TestDefaultConfig_DMScope(t *testing.T) { t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope) } } + +func TestDefaultConfig_WorkspacePath_Default(t *testing.T) { + // Unset to ensure we test the default + t.Setenv("PICOCLAW_HOME", "") + // Set a known home for consistent test results + t.Setenv("HOME", "/tmp/home") + + cfg := DefaultConfig() + want := filepath.Join("/tmp/home", ".picoclaw", "workspace") + + if cfg.Agents.Defaults.Workspace != want { + t.Errorf("Default workspace path = %q, want %q", cfg.Agents.Defaults.Workspace, want) + } +} + +func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { + t.Setenv("PICOCLAW_HOME", "/custom/picoclaw/home") + + cfg := DefaultConfig() + want := "/custom/picoclaw/home/workspace" + + if cfg.Agents.Defaults.Workspace != want { + t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) + } +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index ebb924859..9313623d1 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -5,12 +5,28 @@ package config +import ( + "os" + "path/filepath" +) + // DefaultConfig returns the default configuration for PicoClaw. func DefaultConfig() *Config { + // Determine the base path for the workspace. + // Priority: $PICOCLAW_HOME > ~/.picoclaw + var homePath string + if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" { + homePath = picoclawHome + } else { + userHome, _ := os.UserHomeDir() + homePath = filepath.Join(userHome, ".picoclaw") + } + workspacePath := filepath.Join(homePath, "workspace") + return &Config{ Agents: AgentsConfig{ Defaults: AgentDefaults{ - Workspace: "~/.picoclaw/workspace", + Workspace: workspacePath, RestrictToWorkspace: true, Provider: "", Model: "", From fc9f1ec921564c15922e76715cbddcfd0d710abb Mon Sep 17 00:00:00 2001 From: Luca Martinetti Date: Sun, 1 Mar 2026 21:48:11 +0100 Subject: [PATCH 101/104] fix: return fetched content to LLM in web_fetch tool (#833) * fix: return fetched content to LLM in web_fetch tool WebFetchTool.Execute was setting ForLLM to a summary string ("Fetched N bytes from URL ...") instead of the actual extracted text. This meant the LLM never saw the page content and could not answer questions based on fetched web pages. Return the extracted text in ForLLM so the model can use it. Co-Authored-By: Claude Opus 4.6 * fix: put full JSON result in ForLLM, summary in ForUser Accept suggestion from afjcjsbx: the LLM should receive the full JSON result (including extracted text) while the user sees a short summary. Update tests to match the new field assignment. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- pkg/tools/web.go | 4 ++-- pkg/tools/web_test.go | 34 +++++++++++++++++----------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 0f7dafee9..bf9144f18 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -661,14 +661,14 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe resultJSON, _ := json.MarshalIndent(result, "", " ") return &ToolResult{ - ForLLM: fmt.Sprintf( + ForLLM: string(resultJSON), + ForUser: fmt.Sprintf( "Fetched %d bytes from %s (extractor: %s, truncated: %v)", len(text), urlStr, extractor, truncated, ), - ForUser: string(resultJSON), } } diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index db3c08ba6..84ec10d96 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -32,14 +32,14 @@ func TestWebTool_WebFetch_Success(t *testing.T) { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } - // ForUser should contain the fetched content - if !strings.Contains(result.ForUser, "Test Page") { - t.Errorf("Expected ForUser to contain 'Test Page', got: %s", result.ForUser) + // ForLLM should contain the fetched content (full JSON result) + if !strings.Contains(result.ForLLM, "Test Page") { + t.Errorf("Expected ForLLM to contain 'Test Page', got: %s", result.ForLLM) } - // ForLLM should contain summary - if !strings.Contains(result.ForLLM, "bytes") && !strings.Contains(result.ForLLM, "extractor") { - t.Errorf("Expected ForLLM to contain summary, got: %s", result.ForLLM) + // ForUser should contain summary + if !strings.Contains(result.ForUser, "bytes") && !strings.Contains(result.ForUser, "extractor") { + t.Errorf("Expected ForUser to contain summary, got: %s", result.ForUser) } } @@ -68,9 +68,9 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } - // ForUser should contain formatted JSON - if !strings.Contains(result.ForUser, "key") && !strings.Contains(result.ForUser, "value") { - t.Errorf("Expected ForUser to contain JSON data, got: %s", result.ForUser) + // ForLLM should contain formatted JSON + if !strings.Contains(result.ForLLM, "key") && !strings.Contains(result.ForLLM, "value") { + t.Errorf("Expected ForLLM to contain JSON data, got: %s", result.ForLLM) } } @@ -159,9 +159,9 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } - // ForUser should contain truncated content (not the full 20000 chars) + // ForLLM should contain truncated content (not the full 20000 chars) resultMap := make(map[string]any) - json.Unmarshal([]byte(result.ForUser), &resultMap) + json.Unmarshal([]byte(result.ForLLM), &resultMap) if text, ok := resultMap["text"].(string); ok { if len(text) > 1100 { // Allow some margin t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text)) @@ -237,14 +237,14 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } - // ForUser should contain extracted text (without script/style tags) - if !strings.Contains(result.ForUser, "Title") && !strings.Contains(result.ForUser, "Content") { - t.Errorf("Expected ForUser to contain extracted text, got: %s", result.ForUser) + // ForLLM should contain extracted text (without script/style tags) + if !strings.Contains(result.ForLLM, "Title") && !strings.Contains(result.ForLLM, "Content") { + t.Errorf("Expected ForLLM to contain extracted text, got: %s", result.ForLLM) } - // Should NOT contain script or style tags - if strings.Contains(result.ForUser, "