From dfcf15bfff98bb1779b1e59a501ad39c962888ca Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Fri, 20 Feb 2026 23:18:46 +0800
Subject: [PATCH 001/144] 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/144] 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/144] 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
+
+
+
+
+ 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/144] 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
-
-
-
-
- 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/144] 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/144] 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/144] 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 d224397f40a798ae9ddf4e7a9f00aaf3d62620dd Mon Sep 17 00:00:00 2001
From: winterfx
Date: Sat, 21 Feb 2026 23:29:40 +0800
Subject: [PATCH 008/144] fix: preserve reasoning_content for OpenAI-compatible
reasoning models
Models like Moonshot kimi-k2.5 and DeepSeek-R1 return a
reasoning_content field in assistant messages. When thinking is enabled,
the API requires this field to be echoed back in subsequent requests.
PicoClaw was silently dropping it, causing 400 errors on tool-call
round-trips.
- Add ReasoningContent to Message and LLMResponse types
- Parse reasoning_content in openai_compat parseResponse()
- Carry reasoning_content through assistant tool-call messages
- Add unit test for reasoning_content parsing
Fixes #588
---
pkg/agent/loop.go | 5 ++-
pkg/providers/openai_compat/provider.go | 14 ++++---
pkg/providers/openai_compat/provider_test.go | 44 ++++++++++++++++++++
pkg/providers/protocoltypes/types.go | 18 ++++----
4 files changed, 65 insertions(+), 16 deletions(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index b36f4a0c4..92cede616 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -622,8 +622,9 @@ func (al *AgentLoop) runLLMIteration(
// Build assistant message with tool calls
assistantMsg := providers.Message{
- Role: "assistant",
- Content: response.Content,
+ Role: "assistant",
+ Content: response.Content,
+ ReasoningContent: response.ReasoningContent,
}
for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index b8528953a..f35d89c85 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -148,8 +148,9 @@ func parseResponse(body []byte) (*LLMResponse, error) {
var apiResponse struct {
Choices []struct {
Message struct {
- Content string `json:"content"`
- ToolCalls []struct {
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content"`
+ ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
@@ -221,10 +222,11 @@ func parseResponse(body []byte) (*LLMResponse, error) {
}
return &LLMResponse{
- Content: choice.Message.Content,
- ToolCalls: toolCalls,
- FinishReason: choice.FinishReason,
- Usage: apiResponse.Usage,
+ Content: choice.Message.Content,
+ ReasoningContent: choice.Message.ReasoningContent,
+ ToolCalls: toolCalls,
+ FinishReason: choice.FinishReason,
+ Usage: apiResponse.Usage,
}, nil
}
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index 42f9d42ab..594a48213 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -101,6 +101,50 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) {
}
}
+func TestProviderChat_ParsesReasoningContent(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]any{
+ "choices": []map[string]any{
+ {
+ "message": map[string]any{
+ "content": "The answer is 2",
+ "reasoning_content": "Let me think step by step... 1+1=2",
+ "tool_calls": []map[string]any{
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": map[string]any{
+ "name": "calculator",
+ "arguments": "{\"expr\":\"1+1\"}",
+ },
+ },
+ },
+ },
+ "finish_reason": "tool_calls",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ p := NewProvider("key", server.URL, "")
+ out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "1+1=?"}}, nil, "kimi-k2.5", nil)
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+ if out.ReasoningContent != "Let me think step by step... 1+1=2" {
+ t.Fatalf("ReasoningContent = %q, want %q", out.ReasoningContent, "Let me think step by step... 1+1=2")
+ }
+ if out.Content != "The answer is 2" {
+ t.Fatalf("Content = %q, want %q", out.Content, "The answer is 2")
+ }
+ if len(out.ToolCalls) != 1 {
+ t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls))
+ }
+}
+
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)
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index 3a089ca47..d6928e1ed 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -25,10 +25,11 @@ type FunctionCall struct {
}
type LLMResponse struct {
- Content string `json:"content"`
- 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"`
}
type UsageInfo struct {
@@ -38,10 +39,11 @@ type UsageInfo struct {
}
type Message 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"`
}
type ToolDefinition struct {
From cec6fd4cd4689bac068b4710aed0b26e98c77541 Mon Sep 17 00:00:00 2001
From: Yoftahe Abraham
Date: Sun, 22 Feb 2026 10:27:38 +0300
Subject: [PATCH 009/144] fix: should use fmt.Printf instead of
fmt.Print(fmt.Sprintf(...)) (#623)
---
cmd/picoclaw/cmd_agent.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go
index 6d6ff935f..8658c9d32 100644
--- a/cmd/picoclaw/cmd_agent.go
+++ b/cmd/picoclaw/cmd_agent.go
@@ -148,7 +148,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
reader := bufio.NewReader(os.Stdin)
for {
- fmt.Print(fmt.Sprintf("%s You: ", logo))
+ fmt.Printf("%s You: ", logo)
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
From 65422a16a4f9a04ecc55b066b800e92859b9f376 Mon Sep 17 00:00:00 2001
From: Edouard CLAUDE
Date: Fri, 20 Feb 2026 19:31:35 +0400
Subject: [PATCH 010/144] feat: add native Mistral AI provider support
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add Mistral as a first-class provider alongside the 17 existing ones.
Mistral uses the OpenAI-compatible API at https://api.mistral.ai/v1
with provider-specific model prefix stripping (mistral/model → model).
Changes:
- Add Mistral to ProvidersConfig, IsEmpty(), HasProvidersConfig()
- Add mistral entry in default model_list (defaults.go)
- Add mistral protocol in factory_provider.go and getDefaultAPIBase()
- Add mistral prefix stripping in openai_compat normalizeModel()
- Add mistral case in legacy factory.go resolveProviderSelection()
- Add mistral migration entry in ConvertProvidersToModelList()
- Add mistral to supported providers in migrate/config.go
- Add mistral section in config.example.json
- Update AllProviders test (17 → 18 providers)
Tested end-to-end with mistral-small-latest model.
---
config/config.example.json | 4 ++++
pkg/config/config.go | 7 +++++--
pkg/config/defaults.go | 8 ++++++++
pkg/config/migration.go | 16 ++++++++++++++++
pkg/config/migration_test.go | 7 ++++---
pkg/migrate/config.go | 1 +
pkg/providers/factory.go | 16 ++++++++++++++++
pkg/providers/factory_provider.go | 4 +++-
pkg/providers/openai_compat/provider.go | 2 +-
9 files changed, 58 insertions(+), 7 deletions(-)
diff --git a/config/config.example.json b/config/config.example.json
index 77a8c0683..e814fcbb8 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -196,6 +196,10 @@
"volcengine": {
"api_key": "",
"api_base": ""
+ },
+ "mistral": {
+ "api_key": "",
+ "api_base": "https://api.mistral.ai/v1"
}
},
"tools": {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 20556011a..440ac5436 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -324,6 +324,7 @@ type ProvidersConfig struct {
GitHubCopilot ProviderConfig `json:"github_copilot"`
Antigravity ProviderConfig `json:"antigravity"`
Qwen ProviderConfig `json:"qwen"`
+ Mistral ProviderConfig `json:"mistral"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@@ -345,7 +346,8 @@ func (p ProvidersConfig) IsEmpty() bool {
p.VolcEngine.APIKey == "" && p.VolcEngine.APIBase == "" &&
p.GitHubCopilot.APIKey == "" && p.GitHubCopilot.APIBase == "" &&
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
- p.Qwen.APIKey == "" && p.Qwen.APIBase == ""
+ p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
+ p.Mistral.APIKey == "" && p.Mistral.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
@@ -636,7 +638,8 @@ func (c *Config) HasProvidersConfig() bool {
v.VolcEngine.APIKey != "" || v.VolcEngine.APIBase != "" ||
v.GitHubCopilot.APIKey != "" || v.GitHubCopilot.APIBase != "" ||
v.Antigravity.APIKey != "" || v.Antigravity.APIBase != "" ||
- v.Qwen.APIKey != "" || v.Qwen.APIBase != ""
+ v.Qwen.APIKey != "" || v.Qwen.APIBase != "" ||
+ v.Mistral.APIKey != "" || v.Mistral.APIBase != ""
}
// ValidateModelList validates all ModelConfig entries in the model_list.
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 7654326e7..065273c28 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -255,6 +255,14 @@ func DefaultConfig() *Config {
APIKey: "ollama",
},
+ // Mistral AI - https://console.mistral.ai/api-keys
+ {
+ ModelName: "mistral-small",
+ Model: "mistral/mistral-small-latest",
+ APIBase: "https://api.mistral.ai/v1",
+ APIKey: "",
+ },
+
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 689e2312f..30eaa7474 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -324,6 +324,22 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
+ {
+ providerNames: []string{"mistral"},
+ protocol: "mistral",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.Mistral.APIKey == "" && p.Mistral.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "mistral",
+ Model: "mistral/mistral-small-latest",
+ APIKey: p.Mistral.APIKey,
+ APIBase: p.Mistral.APIBase,
+ Proxy: p.Mistral.Proxy,
+ }, true
+ },
+ },
}
// Process each provider migration
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index 1e8139e68..42165cb71 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -131,14 +131,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
GitHubCopilot: ProviderConfig{ConnectMode: "grpc"},
Antigravity: ProviderConfig{AuthMethod: "oauth"},
Qwen: ProviderConfig{APIKey: "key17"},
+ Mistral: ProviderConfig{APIKey: "key18"},
},
}
result := ConvertProvidersToModelList(cfg)
- // All 17 providers should be converted
- if len(result) != 17 {
- t.Errorf("len(result) = %d, want 17", len(result))
+ // All 18 providers should be converted
+ if len(result) != 18 {
+ t.Errorf("len(result) = %d, want 18", len(result))
}
}
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index 2237a1429..24ce33e94 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -22,6 +22,7 @@ var supportedProviders = map[string]bool{
"qwen": true,
"deepseek": true,
"github_copilot": true,
+ "mistral": true,
}
var supportedChannels = map[string]bool{
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
index b6f1b5e21..cda4753ea 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -172,6 +172,15 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
sel.model = "deepseek-chat"
}
}
+ case "mistral":
+ if cfg.Providers.Mistral.APIKey != "" {
+ sel.apiKey = cfg.Providers.Mistral.APIKey
+ sel.apiBase = cfg.Providers.Mistral.APIBase
+ sel.proxy = cfg.Providers.Mistral.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.mistral.ai/v1"
+ }
+ }
case "github_copilot", "copilot":
sel.providerType = providerTypeGitHubCopilot
if cfg.Providers.GitHubCopilot.APIBase != "" {
@@ -275,6 +284,13 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
if sel.apiBase == "" {
sel.apiBase = "http://localhost:11434/v1"
}
+ case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
+ sel.apiKey = cfg.Providers.Mistral.APIKey
+ sel.apiBase = cfg.Providers.Mistral.APIBase
+ sel.proxy = cfg.Providers.Mistral.Proxy
+ if sel.apiBase == "" {
+ sel.apiBase = "https://api.mistral.ai/v1"
+ }
case cfg.Providers.VLLM.APIBase != "":
sel.apiKey = cfg.Providers.VLLM.APIKey
sel.apiBase = cfg.Providers.VLLM.APIBase
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index 74fe8a36c..7d5566eef 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -88,7 +88,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
- "volcengine", "vllm", "qwen":
+ "volcengine", "vllm", "qwen", "mistral":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
@@ -186,6 +186,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://dashscope.aliyuncs.com/compatible-mode/v1"
case "vllm":
return "http://localhost:8000/v1"
+ case "mistral":
+ return "https://api.mistral.ai/v1"
default:
return ""
}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index b8528953a..236a048c4 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -240,7 +240,7 @@ func normalizeModel(model, apiBase string) string {
prefix := strings.ToLower(model[:idx])
switch prefix {
- case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu":
+ case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu", "mistral":
return model[idx+1:]
default:
return model
From 34a8ce5af05618057837db05828f3867e6cd4fdf Mon Sep 17 00:00:00 2001
From: Edouard CLAUDE
Date: Sat, 21 Feb 2026 05:32:18 +0400
Subject: [PATCH 011/144] fix: remove extra fields from ToolCall JSON
serialization
Mistral's API strictly validates tool_calls in assistant messages and
rejects non-standard fields. The ToolCall struct had Name and Arguments
as top-level JSON fields, duplicating data already in Function.Name
and Function.Arguments. OpenAI silently ignored these extras but
Mistral returns 422.
Change json tags to "-" so these internal fields are no longer
serialized to API payloads while remaining available in Go code.
---
pkg/providers/protocoltypes/types.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go
index 3a089ca47..5e1c6d397 100644
--- a/pkg/providers/protocoltypes/types.go
+++ b/pkg/providers/protocoltypes/types.go
@@ -4,8 +4,8 @@ type ToolCall struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Function *FunctionCall `json:"function,omitempty"`
- Name string `json:"name,omitempty"`
- Arguments map[string]any `json:"arguments,omitempty"`
+ Name string `json:"-"`
+ Arguments map[string]any `json:"-"`
ThoughtSignature string `json:"-"` // Internal use only
ExtraContent *ExtraContent `json:"extra_content,omitempty"`
}
From 6b55fb5f1df3ee6852c31d579b3dc04b742cc704 Mon Sep 17 00:00:00 2001
From: Ali Zulfiqar
Date: Sun, 22 Feb 2026 15:00:15 +0500
Subject: [PATCH 012/144] docs: fix typos, broken links and inconsistencies in
README (#608)
* docs: fix typos, broken links and inconsistencies in README
* docs: revert unintentional bullet style changes
* docs: fix changes
* docs: fixing issues
* docs: updating roadmap link
* docs: removing *
---
README.md | 142 ++++++++++++++++++++++++++++++------------------------
1 file changed, 78 insertions(+), 64 deletions(-)
diff --git a/README.md b/README.md
index 7bc7b1089..de6fd87ea 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,8 @@
- [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
+
---
@@ -42,16 +43,17 @@
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
>
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
+>
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
-
## 📢 News
+
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
-2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
+2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
@@ -100,9 +102,12 @@
### 📱 Run on old Android Phones
+
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
+
1. **Install Termux** (Available on F-Droid or Google Play).
2. **Execute cmds**
+
```bash
# Note: Replace v0.1.1 with the latest version from the Releases page
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
@@ -110,6 +115,7 @@ chmod +x picoclaw-linux-arm64
pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard
```
+
And then follow the instructions in the "Quick Start" section to complete the configuration!
@@ -323,7 +329,6 @@ picoclaw gateway
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID**
-
* Discord Settings → Advanced → enable **Developer Mode**
* Right-click your avatar → **Copy User ID**
@@ -425,7 +430,6 @@ picoclaw gateway
```bash
picoclaw gateway
```
-
@@ -521,7 +525,6 @@ 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"
@@ -605,23 +608,23 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
}
```
-| Option | Default | Description |
-|--------|---------|-------------|
-| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
-| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
+| Option | Default | Description |
+| ----------------------- | ----------------------- | ----------------------------------------- |
+| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
+| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
#### Protected Tools
When `restrict_to_workspace: true`, the following tools are sandboxed:
-| Tool | Function | Restriction |
-|------|----------|-------------|
-| `read_file` | Read files | Only files within workspace |
-| `write_file` | Write files | Only files within workspace |
-| `list_dir` | List directories | Only directories within workspace |
-| `edit_file` | Edit files | Only files within workspace |
-| `append_file` | Append to files | Only files within workspace |
-| `exec` | Execute commands | Command paths must be within workspace |
+| Tool | Function | Restriction |
+| ------------- | ---------------- | -------------------------------------- |
+| `read_file` | Read files | Only files within workspace |
+| `write_file` | Write files | Only files within workspace |
+| `list_dir` | List directories | Only directories within workspace |
+| `edit_file` | Edit files | Only files within workspace |
+| `append_file` | Append to files | Only files within workspace |
+| `exec` | Execute commands | Command paths must be within workspace |
#### Additional Exec Protection
@@ -674,11 +677,11 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
The `restrict_to_workspace` setting applies consistently across all execution paths:
-| Execution Path | Security Boundary |
-|----------------|-------------------|
-| Main Agent | `restrict_to_workspace` ✅ |
+| Execution Path | Security Boundary |
+| ---------------- | ---------------------------- |
+| Main Agent | `restrict_to_workspace` ✅ |
| Subagent / Spawn | Inherits same restriction ✅ |
-| Heartbeat tasks | Inherits same restriction ✅ |
+| Heartbeat tasks | Inherits same restriction ✅ |
All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.
@@ -704,21 +707,23 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
# Periodic Tasks
## Quick Tasks (respond directly)
+
- Report current time
## Long Tasks (use spawn for async)
+
- Search the web for AI news and summarize
- Check email and report important messages
```
**Key behaviors:**
-| Feature | Description |
-|---------|-------------|
-| **spawn** | Creates async subagent, doesn't block heartbeat |
-| **Independent context** | Subagent has its own context, no session history |
-| **message tool** | Subagent communicates with user directly via message tool |
-| **Non-blocking** | After spawning, heartbeat continues to next task |
+| Feature | Description |
+| ----------------------- | --------------------------------------------------------- |
+| **spawn** | Creates async subagent, doesn't block heartbeat |
+| **Independent context** | Subagent has its own context, no session history |
+| **message tool** | Subagent communicates with user directly via message tool |
+| **Non-blocking** | After spawning, heartbeat continues to next task |
#### How Subagent Communication Works
@@ -749,10 +754,10 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
}
```
-| Option | Default | Description |
-|--------|---------|-------------|
-| `enabled` | `true` | Enable/disable heartbeat |
-| `interval` | `30` | Check interval in minutes (min: 5) |
+| Option | Default | Description |
+| ---------- | ------- | ---------------------------------- |
+| `enabled` | `true` | Enable/disable heartbeat |
+| `interval` | `30` | Check interval in minutes (min: 5) |
**Environment variables:**
@@ -764,17 +769,17 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
> [!NOTE]
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
-| Provider | Purpose | Get API Key |
-| -------------------------- | --------------------------------------- | ------------------------------------------------------ |
-| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
-| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
-| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| Provider | Purpose | Get API Key |
+| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- |
+| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
+| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
-| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
-| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
+| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
### Model Configuration (model_list)
@@ -789,25 +794,25 @@ This design also enables **multi-agent support** with flexible provider selectio
#### 📋 All Supported Vendors
-| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
-|--------|----------------|------------------|----------|---------|
-| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
-| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
-| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
-| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
-| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
-| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
-| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
-| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
-| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
-| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
-| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
-| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
-| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
-| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
-| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
-| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
-| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
+| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
+| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
#### Basic Configuration
@@ -841,6 +846,7 @@ This design also enables **multi-agent support** with flexible provider selectio
#### Vendor-Specific Examples
**OpenAI**
+
```json
{
"model_name": "gpt-5.2",
@@ -850,6 +856,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**智谱 AI (GLM)**
+
```json
{
"model_name": "glm-4.7",
@@ -859,6 +866,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**DeepSeek**
+
```json
{
"model_name": "deepseek-chat",
@@ -868,6 +876,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**Anthropic (with API key)**
+
```json
{
"model_name": "claude-sonnet-4.6",
@@ -875,9 +884,11 @@ This design also enables **multi-agent support** with flexible provider selectio
"api_key": "sk-ant-your-key"
}
```
+
> Run `picoclaw auth login --provider anthropic` to paste your API token.
**Ollama (local)**
+
```json
{
"model_name": "llama3",
@@ -886,6 +897,7 @@ This design also enables **multi-agent support** with flexible provider selectio
```
**Custom Proxy/API**
+
```json
{
"model_name": "my-custom-model",
@@ -923,6 +935,7 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
**Old Config (deprecated):**
+
```json
{
"providers": {
@@ -941,6 +954,7 @@ The old `providers` configuration is **deprecated** but still supported for back
```
**New Config (recommended):**
+
```json
{
"model_list": [
@@ -1105,13 +1119,13 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
PRs welcome! The codebase is intentionally small and readable. 🤗
-Roadmap coming soon...
+See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
-Developer group building, Entry Requirement: At least 1 Merged PR.
+Developer group building, join after your first merged PR!
User Groups:
-discord:
+discord:
From cb0c8703fb9d5ce373bc0c4f770177ba66508b25 Mon Sep 17 00:00:00 2001
From: King Tai <109292982+CrisisAlpha@users.noreply.github.com>
Date: Sun, 22 Feb 2026 18:40:59 +0800
Subject: [PATCH 013/144] test(tools,utils): add ToolRegistry unit tests and
fix Truncate panic on negative maxLen (#517)
Add comprehensive unit tests for the ToolRegistry covering registration,
lookup, execution, context injection, async callbacks, schema generation,
provider definition conversion, and concurrent access.
Fix a defensive edge case in Truncate where a negative maxLen would cause
a slice bounds panic, and add table-driven tests covering boundary
conditions, zero/negative lengths, and Unicode handling.
Co-authored-by: Cursor
---
pkg/tools/registry_test.go | 350 +++++++++++++++++++++++++++++++++++++
pkg/utils/string.go | 3 +
pkg/utils/string_test.go | 106 +++++++++++
3 files changed, 459 insertions(+)
create mode 100644 pkg/tools/registry_test.go
create mode 100644 pkg/utils/string_test.go
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
new file mode 100644
index 000000000..33978e543
--- /dev/null
+++ b/pkg/tools/registry_test.go
@@ -0,0 +1,350 @@
+package tools
+
+import (
+ "context"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+// --- mock types ---
+
+type mockRegistryTool struct {
+ name string
+ desc string
+ params map[string]interface{}
+ result *ToolResult
+}
+
+func (m *mockRegistryTool) Name() string { return m.name }
+func (m *mockRegistryTool) Description() string { return m.desc }
+func (m *mockRegistryTool) Parameters() map[string]interface{} { return m.params }
+func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]interface{}) *ToolResult {
+ return m.result
+}
+
+type mockCtxTool struct {
+ mockRegistryTool
+ channel string
+ chatID string
+}
+
+func (m *mockCtxTool) SetContext(channel, chatID string) {
+ m.channel = channel
+ m.chatID = chatID
+}
+
+type mockAsyncRegistryTool struct {
+ mockRegistryTool
+ cb AsyncCallback
+}
+
+func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) {
+ m.cb = cb
+}
+
+// --- helpers ---
+
+func newMockTool(name, desc string) *mockRegistryTool {
+ return &mockRegistryTool{
+ name: name,
+ desc: desc,
+ params: map[string]interface{}{"type": "object"},
+ result: SilentResult("ok"),
+ }
+}
+
+// --- tests ---
+
+func TestNewToolRegistry(t *testing.T) {
+ r := NewToolRegistry()
+ if r.Count() != 0 {
+ t.Errorf("expected empty registry, got count %d", r.Count())
+ }
+ if len(r.List()) != 0 {
+ t.Errorf("expected empty list, got %v", r.List())
+ }
+}
+
+func TestToolRegistry_RegisterAndGet(t *testing.T) {
+ r := NewToolRegistry()
+ tool := newMockTool("echo", "echoes input")
+ r.Register(tool)
+
+ got, ok := r.Get("echo")
+ if !ok {
+ t.Fatal("expected to find registered tool")
+ }
+ if got.Name() != "echo" {
+ t.Errorf("expected name 'echo', got %q", got.Name())
+ }
+}
+
+func TestToolRegistry_Get_NotFound(t *testing.T) {
+ r := NewToolRegistry()
+ _, ok := r.Get("nonexistent")
+ if ok {
+ t.Error("expected ok=false for unregistered tool")
+ }
+}
+
+func TestToolRegistry_RegisterOverwrite(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("dup", "first"))
+ r.Register(newMockTool("dup", "second"))
+
+ if r.Count() != 1 {
+ t.Errorf("expected count 1 after overwrite, got %d", r.Count())
+ }
+ tool, _ := r.Get("dup")
+ if tool.Description() != "second" {
+ t.Errorf("expected overwritten description 'second', got %q", tool.Description())
+ }
+}
+
+func TestToolRegistry_Execute_Success(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(&mockRegistryTool{
+ name: "greet",
+ desc: "says hello",
+ params: map[string]interface{}{},
+ result: SilentResult("hello"),
+ })
+
+ result := r.Execute(context.Background(), "greet", nil)
+ if result.IsError {
+ t.Errorf("expected success, got error: %s", result.ForLLM)
+ }
+ if result.ForLLM != "hello" {
+ t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM)
+ }
+}
+
+func TestToolRegistry_Execute_NotFound(t *testing.T) {
+ r := NewToolRegistry()
+ result := r.Execute(context.Background(), "missing", nil)
+ if !result.IsError {
+ t.Error("expected error for missing tool")
+ }
+ if !strings.Contains(result.ForLLM, "not found") {
+ t.Errorf("expected 'not found' in error, got %q", result.ForLLM)
+ }
+ if result.Err == nil {
+ t.Error("expected Err to be set via WithError")
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockCtxTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil)
+
+ if ct.channel != "telegram" {
+ t.Errorf("expected channel 'telegram', got %q", ct.channel)
+ }
+ if ct.chatID != "chat-42" {
+ t.Errorf("expected chatID 'chat-42', got %q", ct.chatID)
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) {
+ r := NewToolRegistry()
+ ct := &mockCtxTool{
+ mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
+ }
+ r.Register(ct)
+
+ r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil)
+
+ if ct.channel != "" || ct.chatID != "" {
+ t.Error("SetContext should not be called with empty channel/chatID")
+ }
+}
+
+func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
+ r := NewToolRegistry()
+ at := &mockAsyncRegistryTool{
+ mockRegistryTool: *newMockTool("async_tool", "async work"),
+ }
+ at.result = AsyncResult("started")
+ r.Register(at)
+
+ called := false
+ cb := func(_ context.Context, _ *ToolResult) { called = true }
+
+ result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb)
+ if at.cb == nil {
+ t.Error("expected SetCallback to have been called")
+ }
+ if !result.Async {
+ t.Error("expected async result")
+ }
+
+ at.cb(context.Background(), SilentResult("done"))
+ if !called {
+ t.Error("expected callback to be invoked")
+ }
+}
+
+func TestToolRegistry_GetDefinitions(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("alpha", "tool A"))
+
+ defs := r.GetDefinitions()
+ if len(defs) != 1 {
+ t.Fatalf("expected 1 definition, got %d", len(defs))
+ }
+ if defs[0]["type"] != "function" {
+ t.Errorf("expected type 'function', got %v", defs[0]["type"])
+ }
+ fn, ok := defs[0]["function"].(map[string]interface{})
+ if !ok {
+ t.Fatal("expected 'function' key to be a map")
+ }
+ if fn["name"] != "alpha" {
+ t.Errorf("expected name 'alpha', got %v", fn["name"])
+ }
+ if fn["description"] != "tool A" {
+ t.Errorf("expected description 'tool A', got %v", fn["description"])
+ }
+}
+
+func TestToolRegistry_ToProviderDefs(t *testing.T) {
+ r := NewToolRegistry()
+ params := map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
+ r.Register(&mockRegistryTool{
+ name: "beta",
+ desc: "tool B",
+ params: params,
+ result: SilentResult("ok"),
+ })
+
+ defs := r.ToProviderDefs()
+ if len(defs) != 1 {
+ t.Fatalf("expected 1 provider def, got %d", len(defs))
+ }
+
+ want := providers.ToolDefinition{
+ Type: "function",
+ Function: providers.ToolFunctionDefinition{
+ Name: "beta",
+ Description: "tool B",
+ Parameters: params,
+ },
+ }
+ got := defs[0]
+ if got.Type != want.Type {
+ t.Errorf("Type: want %q, got %q", want.Type, got.Type)
+ }
+ if got.Function.Name != want.Function.Name {
+ t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name)
+ }
+ if got.Function.Description != want.Function.Description {
+ t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description)
+ }
+}
+
+func TestToolRegistry_List(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("x", ""))
+ r.Register(newMockTool("y", ""))
+
+ names := r.List()
+ if len(names) != 2 {
+ t.Fatalf("expected 2 names, got %d", len(names))
+ }
+
+ nameSet := map[string]bool{}
+ for _, n := range names {
+ nameSet[n] = true
+ }
+ if !nameSet["x"] || !nameSet["y"] {
+ t.Errorf("expected names {x, y}, got %v", names)
+ }
+}
+
+func TestToolRegistry_Count(t *testing.T) {
+ r := NewToolRegistry()
+ if r.Count() != 0 {
+ t.Errorf("expected 0, got %d", r.Count())
+ }
+
+ r.Register(newMockTool("a", ""))
+ r.Register(newMockTool("b", ""))
+ if r.Count() != 2 {
+ t.Errorf("expected 2, got %d", r.Count())
+ }
+
+ r.Register(newMockTool("a", "replaced"))
+ if r.Count() != 2 {
+ t.Errorf("expected 2 after overwrite, got %d", r.Count())
+ }
+}
+
+func TestToolRegistry_GetSummaries(t *testing.T) {
+ r := NewToolRegistry()
+ r.Register(newMockTool("read_file", "Reads a file"))
+
+ summaries := r.GetSummaries()
+ if len(summaries) != 1 {
+ t.Fatalf("expected 1 summary, got %d", len(summaries))
+ }
+ if !strings.Contains(summaries[0], "`read_file`") {
+ t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0])
+ }
+ if !strings.Contains(summaries[0], "Reads a file") {
+ t.Errorf("expected description in summary, got %q", summaries[0])
+ }
+}
+
+func TestToolToSchema(t *testing.T) {
+ tool := newMockTool("demo", "demo tool")
+ schema := ToolToSchema(tool)
+
+ if schema["type"] != "function" {
+ t.Errorf("expected type 'function', got %v", schema["type"])
+ }
+ fn, ok := schema["function"].(map[string]interface{})
+ if !ok {
+ t.Fatal("expected 'function' to be a map")
+ }
+ if fn["name"] != "demo" {
+ t.Errorf("expected name 'demo', got %v", fn["name"])
+ }
+ if fn["description"] != "demo tool" {
+ t.Errorf("expected description 'demo tool', got %v", fn["description"])
+ }
+ if fn["parameters"] == nil {
+ t.Error("expected parameters to be set")
+ }
+}
+
+func TestToolRegistry_ConcurrentAccess(t *testing.T) {
+ r := NewToolRegistry()
+ var wg sync.WaitGroup
+
+ for i := 0; i < 50; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ name := string(rune('A' + n%26))
+ r.Register(newMockTool(name, "concurrent"))
+ r.Get(name)
+ r.Count()
+ r.List()
+ r.GetDefinitions()
+ }(i)
+ }
+
+ wg.Wait()
+
+ if r.Count() == 0 {
+ t.Error("expected tools to be registered after concurrent access")
+ }
+}
diff --git a/pkg/utils/string.go b/pkg/utils/string.go
index 7a6aa37cc..62d9beee0 100644
--- a/pkg/utils/string.go
+++ b/pkg/utils/string.go
@@ -4,6 +4,9 @@ package utils
// Handles multi-byte Unicode characters properly.
// If the string is truncated, "..." is appended to indicate truncation.
func Truncate(s string, maxLen int) string {
+ if maxLen <= 0 {
+ return ""
+ }
runes := []rune(s)
if len(runes) <= maxLen {
return s
diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go
new file mode 100644
index 000000000..a44ead228
--- /dev/null
+++ b/pkg/utils/string_test.go
@@ -0,0 +1,106 @@
+package utils
+
+import "testing"
+
+func TestTruncate(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ maxLen int
+ want string
+ }{
+ {
+ name: "short string unchanged",
+ input: "hi",
+ maxLen: 10,
+ want: "hi",
+ },
+ {
+ name: "exact length unchanged",
+ input: "hello",
+ maxLen: 5,
+ want: "hello",
+ },
+ {
+ name: "long string truncated with ellipsis",
+ input: "hello world",
+ maxLen: 8,
+ want: "hello...",
+ },
+ {
+ name: "maxLen equals 4 leaves 1 char plus ellipsis",
+ input: "abcdef",
+ maxLen: 4,
+ want: "a...",
+ },
+ {
+ name: "maxLen 3 returns first 3 chars without ellipsis",
+ input: "abcdef",
+ maxLen: 3,
+ want: "abc",
+ },
+ {
+ name: "maxLen 2 returns first 2 chars",
+ input: "abcdef",
+ maxLen: 2,
+ want: "ab",
+ },
+ {
+ name: "maxLen 1 returns first char",
+ input: "abcdef",
+ maxLen: 1,
+ want: "a",
+ },
+ {
+ name: "maxLen 0 returns empty",
+ input: "hello",
+ maxLen: 0,
+ want: "",
+ },
+ {
+ name: "negative maxLen returns empty",
+ input: "hello",
+ maxLen: -1,
+ want: "",
+ },
+ {
+ name: "empty string unchanged",
+ input: "",
+ maxLen: 5,
+ want: "",
+ },
+ {
+ name: "empty string with zero maxLen",
+ input: "",
+ maxLen: 0,
+ want: "",
+ },
+ {
+ name: "unicode truncated correctly",
+ input: "\U0001f600\U0001f601\U0001f602\U0001f603\U0001f604",
+ maxLen: 4,
+ want: "\U0001f600...",
+ },
+ {
+ name: "unicode short enough",
+ input: "\u00e9\u00e8",
+ maxLen: 5,
+ want: "\u00e9\u00e8",
+ },
+ {
+ name: "mixed ascii and unicode",
+ input: "Go\U0001f680\U0001f525\U0001f4a5\U0001f30d",
+ maxLen: 5,
+ want: "Go...",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := Truncate(tt.input, tt.maxLen)
+ if got != tt.want {
+ t.Errorf("Truncate(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want)
+ }
+ })
+ }
+}
From 931093c19d79ed1d0ff6ad444b086b6c29d0298b Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sun, 22 Feb 2026 21:57:12 +0800
Subject: [PATCH 014/144] 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 015/144] 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 a849e02917e6c3c6a52a0e2c27e19552cc91f88d Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 22 Feb 2026 22:30:53 +0800
Subject: [PATCH 016/144] fix: better session management for
`github_copilot_provider`
---
cmd/picoclaw/cmd_gateway.go | 3 +
pkg/providers/github_copilot_provider.go | 74 ++++++++++++++++--------
pkg/providers/types.go | 5 ++
3 files changed, 59 insertions(+), 23 deletions(-)
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go
index 28ef76ad3..30d61aec3 100644
--- a/cmd/picoclaw/cmd_gateway.go
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -212,6 +212,9 @@ func gatewayCmd() {
fmt.Println("\nShutting down...")
cancel()
+ if cp, ok := provider.(providers.SessionProvider); ok {
+ cp.Close()
+ }
healthServer.Stop(context.Background())
deviceService.Stop()
heartbeatService.Stop()
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 6124881f7..8131b76fc 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -4,60 +4,75 @@ import (
"context"
"encoding/json"
"fmt"
+ "sync"
copilot "github.com/github/copilot-sdk/go"
)
type GitHubCopilotProvider struct {
uri string
- connectMode string // `stdio` or `grpc``
+ connectMode string // "stdio" or "grpc"
+ client *copilot.Client
session *copilot.Session
+
+ mu sync.Mutex
}
func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
- var session *copilot.Session
if connectMode == "" {
connectMode = "grpc"
}
- switch connectMode {
+ switch connectMode {
case "stdio":
- // todo
+ // TODO:
+ return nil, fmt.Errorf("stdio mode not implemented")
case "grpc":
client := copilot.NewClient(&copilot.ClientOptions{
CLIUrl: uri,
})
if err := client.Start(context.Background()); err != nil {
- return nil, fmt.Errorf(
- "Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details",
- )
+ return nil, fmt.Errorf("can't connect to Github Copilot: %w; `https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server` for details", err)
}
- defer client.Stop()
- session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
+
+ session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
Model: model,
Hooks: &copilot.SessionHooks{},
})
+ if err != nil {
+ client.Stop()
+ return nil, fmt.Errorf("create session failed: %w", err)
+ }
+
+ return &GitHubCopilotProvider{
+ uri: uri,
+ connectMode: connectMode,
+ client: client,
+ session: session,
+ }, nil
+ default:
+ return nil, fmt.Errorf("unknown connect mode: %s", connectMode)
}
-
- return &GitHubCopilotProvider{
- uri: uri,
- connectMode: connectMode,
- session: session,
- }, nil
}
-// Chat sends a chat request to GitHub Copilot
-func (p *GitHubCopilotProvider) Chat(
- ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]any,
-) (*LLMResponse, error) {
+func (p *GitHubCopilotProvider) Close() {
+ p.mu.Lock()
+ defer p.mu.Unlock()
+ if p.client != nil {
+ p.client.Stop()
+ p.client = nil
+ p.session = nil
+ }
+}
+
+func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
type tempMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
out := make([]tempMessage, 0, len(messages))
-
for _, msg := range messages {
out = append(out, tempMessage{
Role: msg.Role,
@@ -65,18 +80,31 @@ func (p *GitHubCopilotProvider) Chat(
})
}
- fullcontent, _ := json.Marshal(out)
+ fullcontent, err := json.Marshal(out)
+ if err != nil {
+ return nil, fmt.Errorf("marshal messages: %w", err)
+ }
+ p.mu.Lock()
+ defer p.mu.Unlock()
- content, _ := p.session.Send(ctx, copilot.MessageOptions{
+ resp, err := p.session.SendAndWait(ctx, copilot.MessageOptions{
Prompt: string(fullcontent),
})
+ if err != nil {
+ return nil, err
+ }
+
+ var content string
+ if resp != nil && resp.Data.Content != nil {
+ content = *resp.Data.Content
+ }
return &LLMResponse{
FinishReason: "stop",
Content: content,
}, nil
}
-
func (p *GitHubCopilotProvider) GetDefaultModel() string {
+
return "gpt-4.1"
}
diff --git a/pkg/providers/types.go b/pkg/providers/types.go
index f711e7803..40ff6f7c8 100644
--- a/pkg/providers/types.go
+++ b/pkg/providers/types.go
@@ -30,6 +30,11 @@ type LLMProvider interface {
GetDefaultModel() string
}
+type SessionProvider interface {
+ LLMProvider
+ Close()
+}
+
// FailoverReason classifies why an LLM request failed for fallback decisions.
type FailoverReason string
From a91de8546c74dbeddadbe39dffe0eecffe05c37e Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sun, 22 Feb 2026 22:46:29 +0800
Subject: [PATCH 017/144] 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 3d605a4f537507713706aa626b74ad8506e6e12a Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 22 Feb 2026 23:02:29 +0800
Subject: [PATCH 018/144] fix: run fmt and lint
---
pkg/providers/github_copilot_provider.go | 15 ++++++++++++---
pkg/tools/registry_test.go | 20 ++++++++++----------
2 files changed, 22 insertions(+), 13 deletions(-)
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 8131b76fc..c69658b44 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -33,7 +33,10 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*Gi
CLIUrl: uri,
})
if err := client.Start(context.Background()); err != nil {
- return nil, fmt.Errorf("can't connect to Github Copilot: %w; `https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server` for details", err)
+ return nil, fmt.Errorf(
+ "can't connect to Github Copilot: %w; `https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server` for details",
+ err,
+ )
}
session, err := client.CreateSession(context.Background(), &copilot.SessionConfig{
@@ -67,7 +70,13 @@ func (p *GitHubCopilotProvider) Close() {
}
}
-func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+func (p *GitHubCopilotProvider) Chat(
+ ctx context.Context,
+ messages []Message,
+ tools []ToolDefinition,
+ model string,
+ options map[string]any,
+) (*LLMResponse, error) {
type tempMessage struct {
Role string `json:"role"`
Content string `json:"content"`
@@ -104,7 +113,7 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to
Content: content,
}, nil
}
-func (p *GitHubCopilotProvider) GetDefaultModel() string {
+func (p *GitHubCopilotProvider) GetDefaultModel() string {
return "gpt-4.1"
}
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
index 33978e543..8ae13b20c 100644
--- a/pkg/tools/registry_test.go
+++ b/pkg/tools/registry_test.go
@@ -14,14 +14,14 @@ import (
type mockRegistryTool struct {
name string
desc string
- params map[string]interface{}
+ params map[string]any
result *ToolResult
}
-func (m *mockRegistryTool) Name() string { return m.name }
-func (m *mockRegistryTool) Description() string { return m.desc }
-func (m *mockRegistryTool) Parameters() map[string]interface{} { return m.params }
-func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]interface{}) *ToolResult {
+func (m *mockRegistryTool) Name() string { return m.name }
+func (m *mockRegistryTool) Description() string { return m.desc }
+func (m *mockRegistryTool) Parameters() map[string]any { return m.params }
+func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
return m.result
}
@@ -51,7 +51,7 @@ func newMockTool(name, desc string) *mockRegistryTool {
return &mockRegistryTool{
name: name,
desc: desc,
- params: map[string]interface{}{"type": "object"},
+ params: map[string]any{"type": "object"},
result: SilentResult("ok"),
}
}
@@ -109,7 +109,7 @@ func TestToolRegistry_Execute_Success(t *testing.T) {
r.Register(&mockRegistryTool{
name: "greet",
desc: "says hello",
- params: map[string]interface{}{},
+ params: map[string]any{},
result: SilentResult("hello"),
})
@@ -203,7 +203,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
if defs[0]["type"] != "function" {
t.Errorf("expected type 'function', got %v", defs[0]["type"])
}
- fn, ok := defs[0]["function"].(map[string]interface{})
+ fn, ok := defs[0]["function"].(map[string]any)
if !ok {
t.Fatal("expected 'function' key to be a map")
}
@@ -217,7 +217,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
func TestToolRegistry_ToProviderDefs(t *testing.T) {
r := NewToolRegistry()
- params := map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
+ params := map[string]any{"type": "object", "properties": map[string]any{}}
r.Register(&mockRegistryTool{
name: "beta",
desc: "tool B",
@@ -310,7 +310,7 @@ func TestToolToSchema(t *testing.T) {
if schema["type"] != "function" {
t.Errorf("expected type 'function', got %v", schema["type"])
}
- fn, ok := schema["function"].(map[string]interface{})
+ fn, ok := schema["function"].(map[string]any)
if !ok {
t.Fatal("expected 'function' to be a map")
}
From 038fdf500043456f17ff4734bebf309d82fc0214 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sun, 22 Feb 2026 23:27:55 +0800
Subject: [PATCH 019/144] 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 020/144] 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 c6865fe852f4e163767b78b6df72a06b5fdc6204 Mon Sep 17 00:00:00 2001
From: Vidish <57653368+ulolol@users.noreply.github.com>
Date: Sun, 22 Feb 2026 22:00:14 +0530
Subject: [PATCH 021/144] feat: integrate Tavily search (#340)
* feat: integrate Tavily search
* fix: set include_raw_content to false in Tavily search as wealready get relevant data inside content
* refactor: update Go type declarations to `any`, apply formatting fixes.
---
README.ja.md | 29 ++++++++++--
README.md | 9 +++-
README.zh.md | 27 ++++++-----
pkg/config/config.go | 8 ++++
pkg/tools/registry_test.go | 20 ++++----
pkg/tools/web.go | 97 +++++++++++++++++++++++++++++++++++++-
pkg/tools/web_test.go | 72 ++++++++++++++++++++++++++++
7 files changed, 232 insertions(+), 30 deletions(-)
diff --git a/README.ja.md b/README.ja.md
index bb0bdfb28..3506c77c2 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -162,7 +162,7 @@ docker compose --profile gateway up -d
> [!TIP]
> `~/.picoclaw/config.json` に API キーを設定してください。
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
+> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
**1. 初期化**
@@ -193,14 +193,34 @@ picoclaw onboard
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"allow_from": []
}
+ },
+ "tools": {
+ "web": {
+ "search": {
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
}
}
```
**3. API キーの取得**
-- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) · [Qwen](https://dashscope.console.aliyun.com)
-- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
+- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
@@ -985,7 +1005,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
Web 検索を有効にするには:
-1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
+1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
2. `~/.picoclaw/config.json` に追加:
```json
{
@@ -1023,5 +1043,6 @@ Web 検索を有効にするには:
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
+| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
diff --git a/README.md b/README.md
index de6fd87ea..825f57340 100644
--- a/README.md
+++ b/README.md
@@ -200,7 +200,7 @@ docker compose --profile gateway up -d
> [!TIP]
> Set your API key in `~/.picoclaw/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
+> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
**1. Initialize**
@@ -240,6 +240,11 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
+ },
"duckduckgo": {
"enabled": true,
"max_results": 5
@@ -254,7 +259,7 @@ picoclaw onboard
**3. Get API Keys**
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
+* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
> **Note**: See `config.example.json` for a complete configuration template.
diff --git a/README.zh.md b/README.zh.md
index 4d739c5eb..fd188567d 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -205,7 +205,7 @@ docker compose --profile gateway up -d
> [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
+> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
**1. 初始化 (Initialize)**
@@ -246,8 +246,9 @@ picoclaw onboard
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
- "duckduckgo": {
- "enabled": true,
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
"max_results": 5
}
},
@@ -262,8 +263,8 @@ picoclaw onboard
**3. 获取 API Key**
-- **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-- **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
+* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
> **注意**: 完整的配置模板请参考 `config.example.json`。
@@ -771,7 +772,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
启用网络搜索:
-1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
+1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
2. 添加到 `~/.picoclaw/config.json`:
```json
@@ -804,10 +805,10 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
## 📝 API Key 对比
-| 服务 | 免费层级 | 适用场景 |
-| ---------------- | -------------- | ----------------------------- |
-| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
-| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
-| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
-| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
-| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
+| 服务 | 免费层级 | 适用场景 |
+| --- | --- | --- |
+| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
+| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
+| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
+| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
+| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 20556011a..036021e49 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -418,6 +418,13 @@ type BraveConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
+type TavilyConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
+ BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
+}
+
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
@@ -431,6 +438,7 @@ type PerplexityConfig struct {
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
+ Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"`
}
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
index 33978e543..8ae13b20c 100644
--- a/pkg/tools/registry_test.go
+++ b/pkg/tools/registry_test.go
@@ -14,14 +14,14 @@ import (
type mockRegistryTool struct {
name string
desc string
- params map[string]interface{}
+ params map[string]any
result *ToolResult
}
-func (m *mockRegistryTool) Name() string { return m.name }
-func (m *mockRegistryTool) Description() string { return m.desc }
-func (m *mockRegistryTool) Parameters() map[string]interface{} { return m.params }
-func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]interface{}) *ToolResult {
+func (m *mockRegistryTool) Name() string { return m.name }
+func (m *mockRegistryTool) Description() string { return m.desc }
+func (m *mockRegistryTool) Parameters() map[string]any { return m.params }
+func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
return m.result
}
@@ -51,7 +51,7 @@ func newMockTool(name, desc string) *mockRegistryTool {
return &mockRegistryTool{
name: name,
desc: desc,
- params: map[string]interface{}{"type": "object"},
+ params: map[string]any{"type": "object"},
result: SilentResult("ok"),
}
}
@@ -109,7 +109,7 @@ func TestToolRegistry_Execute_Success(t *testing.T) {
r.Register(&mockRegistryTool{
name: "greet",
desc: "says hello",
- params: map[string]interface{}{},
+ params: map[string]any{},
result: SilentResult("hello"),
})
@@ -203,7 +203,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
if defs[0]["type"] != "function" {
t.Errorf("expected type 'function', got %v", defs[0]["type"])
}
- fn, ok := defs[0]["function"].(map[string]interface{})
+ fn, ok := defs[0]["function"].(map[string]any)
if !ok {
t.Fatal("expected 'function' key to be a map")
}
@@ -217,7 +217,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
func TestToolRegistry_ToProviderDefs(t *testing.T) {
r := NewToolRegistry()
- params := map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
+ params := map[string]any{"type": "object", "properties": map[string]any{}}
r.Register(&mockRegistryTool{
name: "beta",
desc: "tool B",
@@ -310,7 +310,7 @@ func TestToolToSchema(t *testing.T) {
if schema["type"] != "function" {
t.Errorf("expected type 'function', got %v", schema["type"])
}
- fn, ok := schema["function"].(map[string]interface{})
+ fn, ok := schema["function"].(map[string]any)
if !ok {
t.Fatal("expected 'function' to be a map")
}
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 301e00daf..059437889 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -1,6 +1,7 @@
package tools
import (
+ "bytes"
"context"
"encoding/json"
"fmt"
@@ -84,6 +85,88 @@ func (p *BraveSearchProvider) Search(ctx context.Context, query string, count in
return strings.Join(lines, "\n"), nil
}
+type TavilySearchProvider struct {
+ apiKey string
+ baseURL string
+}
+
+func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
+ searchURL := p.baseURL
+ if searchURL == "" {
+ searchURL = "https://api.tavily.com/search"
+ }
+
+ payload := map[string]any{
+ "api_key": p.apiKey,
+ "query": query,
+ "search_depth": "advanced",
+ "include_answer": false,
+ "include_images": false,
+ "include_raw_content": "false",
+ "max_results": count,
+ }
+
+ bodyBytes, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal payload: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes))
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("User-Agent", userAgent)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body))
+ }
+
+ var searchResp struct {
+ Results []struct {
+ Title string `json:"title"`
+ URL string `json:"url"`
+ Content string `json:"content"`
+ } `json:"results"`
+ }
+
+ if err := json.Unmarshal(body, &searchResp); err != nil {
+ return "", fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ results := searchResp.Results
+ if len(results) == 0 {
+ return fmt.Sprintf("No results for: %s", query), nil
+ }
+
+ var lines []string
+ lines = append(lines, fmt.Sprintf("Results for: %s (via Tavily)", query))
+ for i, item := range results {
+ if i >= count {
+ break
+ }
+ lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
+ if item.Content != "" {
+ lines = append(lines, fmt.Sprintf(" %s", item.Content))
+ }
+ }
+
+ return strings.Join(lines, "\n"), nil
+}
+
type DuckDuckGoSearchProvider struct{}
func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
@@ -256,6 +339,10 @@ type WebSearchToolOptions struct {
BraveAPIKey string
BraveMaxResults int
BraveEnabled bool
+ TavilyAPIKey string
+ TavilyBaseURL string
+ TavilyMaxResults int
+ TavilyEnabled bool
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
PerplexityAPIKey string
@@ -267,7 +354,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
var provider SearchProvider
maxResults := 5
- // Priority: Perplexity > Brave > DuckDuckGo
+ // Priority: Perplexity > Brave > Tavily > DuckDuckGo
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey}
if opts.PerplexityMaxResults > 0 {
@@ -278,6 +365,14 @@ func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults
}
+ } else if opts.TavilyEnabled && opts.TavilyAPIKey != "" {
+ provider = &TavilySearchProvider{
+ apiKey: opts.TavilyAPIKey,
+ baseURL: opts.TavilyBaseURL,
+ }
+ if opts.TavilyMaxResults > 0 {
+ maxResults = opts.TavilyMaxResults
+ }
} else if opts.DuckDuckGoEnabled {
provider = &DuckDuckGoSearchProvider{}
if opts.DuckDuckGoMaxResults > 0 {
diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go
index d999d8958..75e0d8d16 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/web_test.go
@@ -333,3 +333,75 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) {
t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM)
}
}
+
+// TestWebTool_TavilySearch_Success verifies successful Tavily search
+func TestWebTool_TavilySearch_Success(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != "POST" {
+ t.Errorf("Expected POST request, got %s", r.Method)
+ }
+ if r.Header.Get("Content-Type") != "application/json" {
+ t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
+ }
+
+ // Verify payload
+ var payload map[string]any
+ json.NewDecoder(r.Body).Decode(&payload)
+ if payload["api_key"] != "test-key" {
+ t.Errorf("Expected api_key test-key, got %v", payload["api_key"])
+ }
+ if payload["query"] != "test query" {
+ t.Errorf("Expected query 'test query', got %v", payload["query"])
+ }
+
+ // Return mock response
+ response := map[string]any{
+ "results": []map[string]any{
+ {
+ "title": "Test Result 1",
+ "url": "https://example.com/1",
+ "content": "Content for result 1",
+ },
+ {
+ "title": "Test Result 2",
+ "url": "https://example.com/2",
+ "content": "Content for result 2",
+ },
+ },
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(response)
+ }))
+ defer server.Close()
+
+ tool := NewWebSearchTool(WebSearchToolOptions{
+ TavilyEnabled: true,
+ TavilyAPIKey: "test-key",
+ TavilyBaseURL: server.URL,
+ TavilyMaxResults: 5,
+ })
+
+ ctx := context.Background()
+ args := map[string]any{
+ "query": "test query",
+ }
+
+ result := tool.Execute(ctx, args)
+
+ // Success should not be an error
+ if result.IsError {
+ t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
+ }
+
+ // ForUser should contain result titles and URLs
+ if !strings.Contains(result.ForUser, "Test Result 1") ||
+ !strings.Contains(result.ForUser, "https://example.com/1") {
+ t.Errorf("Expected results in output, got: %s", result.ForUser)
+ }
+
+ // Should mention via Tavily
+ if !strings.Contains(result.ForUser, "via Tavily") {
+ t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser)
+ }
+}
From afc7a1988f0af7479f3bc1625e4e3e24a360ea67 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Mon, 23 Feb 2026 00:44:45 +0800
Subject: [PATCH 022/144] 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 023/144] 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 024/144] 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 025/144] 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 026/144] 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 027/144] 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 028/144] 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 029/144] 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 4a73415e0516e0f954267c2ee797896d2ec770bb Mon Sep 17 00:00:00 2001
From: Kai Xia
Date: Mon, 23 Feb 2026 08:09:26 +1100
Subject: [PATCH 030/144] golangci-lint run --fix on master
Signed-off-by: Kai Xia
---
pkg/tools/registry_test.go | 20 ++++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go
index 33978e543..8ae13b20c 100644
--- a/pkg/tools/registry_test.go
+++ b/pkg/tools/registry_test.go
@@ -14,14 +14,14 @@ import (
type mockRegistryTool struct {
name string
desc string
- params map[string]interface{}
+ params map[string]any
result *ToolResult
}
-func (m *mockRegistryTool) Name() string { return m.name }
-func (m *mockRegistryTool) Description() string { return m.desc }
-func (m *mockRegistryTool) Parameters() map[string]interface{} { return m.params }
-func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]interface{}) *ToolResult {
+func (m *mockRegistryTool) Name() string { return m.name }
+func (m *mockRegistryTool) Description() string { return m.desc }
+func (m *mockRegistryTool) Parameters() map[string]any { return m.params }
+func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult {
return m.result
}
@@ -51,7 +51,7 @@ func newMockTool(name, desc string) *mockRegistryTool {
return &mockRegistryTool{
name: name,
desc: desc,
- params: map[string]interface{}{"type": "object"},
+ params: map[string]any{"type": "object"},
result: SilentResult("ok"),
}
}
@@ -109,7 +109,7 @@ func TestToolRegistry_Execute_Success(t *testing.T) {
r.Register(&mockRegistryTool{
name: "greet",
desc: "says hello",
- params: map[string]interface{}{},
+ params: map[string]any{},
result: SilentResult("hello"),
})
@@ -203,7 +203,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
if defs[0]["type"] != "function" {
t.Errorf("expected type 'function', got %v", defs[0]["type"])
}
- fn, ok := defs[0]["function"].(map[string]interface{})
+ fn, ok := defs[0]["function"].(map[string]any)
if !ok {
t.Fatal("expected 'function' key to be a map")
}
@@ -217,7 +217,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
func TestToolRegistry_ToProviderDefs(t *testing.T) {
r := NewToolRegistry()
- params := map[string]interface{}{"type": "object", "properties": map[string]interface{}{}}
+ params := map[string]any{"type": "object", "properties": map[string]any{}}
r.Register(&mockRegistryTool{
name: "beta",
desc: "tool B",
@@ -310,7 +310,7 @@ func TestToolToSchema(t *testing.T) {
if schema["type"] != "function" {
t.Errorf("expected type 'function', got %v", schema["type"])
}
- fn, ok := schema["function"].(map[string]interface{})
+ fn, ok := schema["function"].(map[string]any)
if !ok {
t.Fatal("expected 'function' to be a map")
}
From 5d304a9aebca2b10af84fcdcf813e1fd3d6ab261 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Mon, 23 Feb 2026 05:22:18 +0800
Subject: [PATCH 031/144] 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 032/144] 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 033/144] 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 8928f83c7ff254ec1c74bff1d03aca20220cb702 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Kai=20Xia=28=E5=A4=8F=E6=81=BA=29?=
Date: Mon, 23 Feb 2026 09:45:17 +1100
Subject: [PATCH 034/144] remove old roadmap (#632)
---
README.fr.md | 2 +-
README.md | 2 +-
README.pt-br.md | 2 +-
README.vi.md | 2 +-
README.zh.md | 2 +-
docs/picoclaw_community_roadmap_260216.md | 112 ----------------------
6 files changed, 5 insertions(+), 117 deletions(-)
delete mode 100644 docs/picoclaw_community_roadmap_260216.md
diff --git a/README.fr.md b/README.fr.md
index 7199f7098..a762870ff 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -50,7 +50,7 @@
## 📢 Actualités
-2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir !
+2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir !
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
diff --git a/README.md b/README.md
index 825f57340..955255f2e 100644
--- a/README.md
+++ b/README.md
@@ -51,7 +51,7 @@
## 📢 News
-2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board!
+2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/ROADMAP.md) —we can’t wait to have you on board!
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
diff --git a/README.pt-br.md b/README.pt-br.md
index ec8fe8e1c..900ee7932 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -50,7 +50,7 @@
## 📢 Novidades
-2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo!
+2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo!
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
diff --git a/README.vi.md b/README.vi.md
index 161842933..29ff12bb0 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -50,7 +50,7 @@
## 📢 Tin tức
-2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn!
+2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
diff --git a/README.zh.md b/README.zh.md
index fd188567d..17a736fec 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -52,7 +52,7 @@
## 📢 新闻 (News)
-2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
+2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
diff --git a/docs/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md
deleted file mode 100644
index 95de768c6..000000000
--- a/docs/picoclaw_community_roadmap_260216.md
+++ /dev/null
@@ -1,112 +0,0 @@
-## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal
-
-**Hello, PicoClaw Community!**
-
-First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs!
-
-PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum.
-
-This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**.
-
-### 🎁 Community Perks
-
-To show our appreciation, developers who officially join our community operations will receive:
-
-* **Exclusive AI Hardware:** Our upcoming, unreleased AI device.
-* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers).
-
-### 🎥 Calling All Content Creators!
-
-Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**.
-
-* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**.
-* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM.
-We will be rewarding high-quality content creators with the same perks as our community developers!
-
----
-
-## 🛠️ Urgent Volunteer Roles
-
-We are looking for experts in the following areas:
-
-1. **Issue/PR Reviewers**
-* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging.
-* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles.
-
-
-2. **Resource Optimization Experts**
-* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean.
-* **Focus:** Analyzing resource growth between releases and trimming redundancy.
-* **Priority:** **RAM usage optimization** > Binary size reduction.
-
-
-3. **Security Audit & Bug Fixes**
-* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management.
-* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes.
-
-
-4. **Documentation & DX (Developer Experience)**
-* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow.
-* **Focus:** Creating clear, user-friendly documentation for both setup and development.
-
-
-5. **AI-Powered CI/CD Optimization**
-* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it.
-* **Focus:** Automating builds with AI and exploring AI-driven issue resolution.
-
-**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role.
-Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors!
-
----
-
-## 📍 The Roadmap
-
-Interested in a specific feature? You can "claim" these tasks and start building:
-
-###
-* **Provider:**
- * **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days)
- * You can still submit code; Daming will merge it into the new implementation.
-* **Channels:**
- * Support for OneBot, additional platforms
- * attachments (images, audio, video, files).
-* **Skills:**
- * Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms.
-* **Operations:** * MCP Support.
- * Android operations (e.g., botdrop).
- * Browser automation via CDP or ActionBook.
-
-
-* **Multi-Agent Ecosystem:**
- * **Basic Model-Agent**
- * **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
- * **Swarm Mode.**
- * **AIEOS Integration.**
-
-
-* **Branding:**
- * **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch!
-
-
-We have officially created these tasks as GitHub Issues, all marked with the roadmap tag.
-This list will be updated continuously as we progress.
-If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue!
-
----
-
-## 🤝 How to Join
-
-**Everything is open to your creativity!** If you have a wild idea, just PR it.
-
-1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw.
-2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject:
-> `[Apply Join PicoClaw Dev Group] + Your GitHub Account`
-> Include the role you're interested in and any evidence of your development experience.
-
-
-
-### Looking Ahead
-
-Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential.
-
-**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎
From 56d80373eb7c4e41023c759dc88dc9e4c9c95393 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Mon, 23 Feb 2026 06:56:48 +0800
Subject: [PATCH 035/144] 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 036/144] 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 037/144] 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 038/144] 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 039/144] 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 040/144] 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 4cc8b90da94d9695d3edad52281cefdc58db8e58 Mon Sep 17 00:00:00 2001
From: Vidish <57653368+ulolol@users.noreply.github.com>
Date: Mon, 23 Feb 2026 09:42:34 +0530
Subject: [PATCH 041/144] Fix: missing Tavily config in loop.go, and the
invalid config param in web_search (#660)
---
pkg/agent/loop.go | 4 ++++
pkg/tools/web.go | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index b36f4a0c4..bf229ad74 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -97,6 +97,10 @@ func registerSharedTools(
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
+ TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
+ TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
+ TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
+ TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 059437889..452e95e0f 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -102,7 +102,7 @@ func (p *TavilySearchProvider) Search(ctx context.Context, query string, count i
"search_depth": "advanced",
"include_answer": false,
"include_images": false,
- "include_raw_content": "false",
+ "include_raw_content": false,
"max_results": count,
}
From 16a36ea416117e1b86429cc5505f389ac362e408 Mon Sep 17 00:00:00 2001
From: Aditya Kalro
Date: Sun, 22 Feb 2026 20:58:59 -0800
Subject: [PATCH 042/144] 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 043/144] 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 712f5a8300f0ce9d9d22352a1bc92bed3a0c8b88 Mon Sep 17 00:00:00 2001
From: yinwm
Date: Mon, 23 Feb 2026 16:55:06 +0800
Subject: [PATCH 044/144] refactor(config): rename model field to model_name
The configuration field for specifying the model has been renamed from
"model" to "model_name" for better clarity and consistency with the
model_list configuration.
A GetModelName() accessor method has been added to maintain backward
compatibility. Existing configurations using the old "model" field will
continue to work correctly.
This change affects:
- Configuration structure (AgentDefaults struct)
- All references across the codebase
- Documentation in all language variants
- Example configuration files
---
README.fr.md | 2 +-
README.ja.md | 2 +-
README.md | 2 +-
README.pt-br.md | 2 +-
README.vi.md | 2 +-
README.zh.md | 2 +-
cmd/picoclaw/cmd_agent.go | 4 +-
cmd/picoclaw/cmd_auth.go | 10 +--
cmd/picoclaw/cmd_gateway.go | 2 +-
cmd/picoclaw/cmd_status.go | 2 +-
config/config.example.json | 2 +-
pkg/agent/instance.go | 2 +-
pkg/channels/telegram_commands.go | 4 +-
pkg/config/config.go | 12 ++-
pkg/config/migration.go | 2 +-
pkg/config/model_config_test.go | 132 ++++++++++++++++++++++++++++++
pkg/migrate/config.go | 5 +-
pkg/providers/factory.go | 2 +-
pkg/providers/legacy_provider.go | 2 +-
19 files changed, 169 insertions(+), 24 deletions(-)
diff --git a/README.fr.md b/README.fr.md
index 7199f7098..04f40e022 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -222,7 +222,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"channels": {
diff --git a/README.ja.md b/README.ja.md
index bb0bdfb28..b379bc2a7 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -184,7 +184,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"channels": {
diff --git a/README.md b/README.md
index 7bc7b1089..058e16a2b 100644
--- a/README.md
+++ b/README.md
@@ -209,7 +209,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "gpt4",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
diff --git a/README.pt-br.md b/README.pt-br.md
index ec8fe8e1c..cfa5b801b 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -223,7 +223,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"tools": {
diff --git a/README.vi.md b/README.vi.md
index 161842933..1d0084aa3 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -203,7 +203,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
- "model": "gpt4"
+ "model_name": "gpt4"
}
},
"channels": {
diff --git a/README.zh.md b/README.zh.md
index 4d739c5eb..dca149fa9 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -221,7 +221,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "gpt4",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go
index 6d6ff935f..6331fdd4a 100644
--- a/cmd/picoclaw/cmd_agent.go
+++ b/cmd/picoclaw/cmd_agent.go
@@ -56,7 +56,7 @@ func agentCmd() {
}
if modelOverride != "" {
- cfg.Agents.Defaults.Model = modelOverride
+ cfg.Agents.Defaults.ModelName = modelOverride
}
provider, modelID, err := providers.CreateProvider(cfg)
@@ -66,7 +66,7 @@ func agentCmd() {
}
// Use the resolved model ID from provider creation
if modelID != "" {
- cfg.Agents.Defaults.Model = modelID
+ cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go
index 729c56177..55eb3cec3 100644
--- a/cmd/picoclaw/cmd_auth.go
+++ b/cmd/picoclaw/cmd_auth.go
@@ -144,7 +144,7 @@ func authLoginOpenAI(useDeviceCode bool) {
}
// Update default model to use OpenAI
- appCfg.Agents.Defaults.Model = "gpt-5.2"
+ appCfg.Agents.Defaults.ModelName = "gpt-5.2"
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
@@ -218,7 +218,7 @@ func authLoginGoogleAntigravity() {
}
// Update default model
- appCfg.Agents.Defaults.Model = "gemini-flash"
+ appCfg.Agents.Defaults.ModelName = "gemini-flash"
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
@@ -292,7 +292,7 @@ func authLoginPasteToken(provider string) {
})
}
// Update default model
- appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
+ appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList
@@ -312,7 +312,7 @@ func authLoginPasteToken(provider string) {
})
}
// Update default model
- appCfg.Agents.Defaults.Model = "gpt-5.2"
+ appCfg.Agents.Defaults.ModelName = "gpt-5.2"
}
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
fmt.Printf("Warning: could not update config: %v\n", err)
@@ -320,7 +320,7 @@ func authLoginPasteToken(provider string) {
}
fmt.Printf("Token saved for %s!\n", provider)
- fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
+ fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
}
func authLogoutCmd() {
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go
index 9a3b6aa19..bd1cdd7a8 100644
--- a/cmd/picoclaw/cmd_gateway.go
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -51,7 +51,7 @@ func gatewayCmd() {
}
// Use the resolved model ID from provider creation
if modelID != "" {
- cfg.Agents.Defaults.Model = modelID
+ cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
diff --git a/cmd/picoclaw/cmd_status.go b/cmd/picoclaw/cmd_status.go
index 07296784e..6a117bd17 100644
--- a/cmd/picoclaw/cmd_status.go
+++ b/cmd/picoclaw/cmd_status.go
@@ -41,7 +41,7 @@ func statusCmd() {
}
if _, err := os.Stat(configPath); err == nil {
- fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
+ fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
diff --git a/config/config.example.json b/config/config.example.json
index 77a8c0683..1795fed9f 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -3,7 +3,7 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
- "model": "gpt4",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index dfbef9fbc..c6a54c7d2 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -133,7 +133,7 @@ func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefau
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
return strings.TrimSpace(agentCfg.Model.Primary)
}
- return defaults.Model
+ return defaults.GetModelName()
}
// resolveAgentFallbacks resolves the fallback models for an agent.
diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go
index a084b641b..f28434f46 100644
--- a/pkg/channels/telegram_commands.go
+++ b/pkg/channels/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/config/config.go b/pkg/config/config.go
index 20556011a..c103963c8 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -170,7 +170,8 @@ type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
- Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
+ 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
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"`
@@ -179,6 +180,15 @@ type AgentDefaults struct {
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
+// GetModelName returns the effective model name for the agent defaults.
+// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
+func (d *AgentDefaults) GetModelName() string {
+ if d.ModelName != "" {
+ return d.ModelName
+ }
+ return d.Model
+}
+
type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"`
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 689e2312f..e51d4efb2 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -41,7 +41,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
// Get user's configured provider and model
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
- userModel := cfg.Agents.Defaults.Model
+ userModel := cfg.Agents.Defaults.GetModelName()
p := cfg.Providers
diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go
index 3c411dc0f..c89029e8c 100644
--- a/pkg/config/model_config_test.go
+++ b/pkg/config/model_config_test.go
@@ -6,6 +6,7 @@
package config
import (
+ "encoding/json"
"strings"
"sync"
"testing"
@@ -114,6 +115,137 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
}
}
+func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {
+ tests := []struct {
+ name string
+ defaults AgentDefaults
+ wantName string
+ }{
+ {
+ name: "new model_name field only",
+ defaults: AgentDefaults{ModelName: "new-model"},
+ wantName: "new-model",
+ },
+ {
+ name: "old model field only",
+ defaults: AgentDefaults{Model: "legacy-model"},
+ wantName: "legacy-model",
+ },
+ {
+ name: "both fields - model_name takes precedence",
+ defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
+ wantName: "new-model",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := tt.defaults.GetModelName(); got != tt.wantName {
+ t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
+ }
+ })
+ }
+}
+
+func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
+ tests := []struct {
+ name string
+ json string
+ wantName string
+ }{
+ {
+ name: "new model_name field",
+ json: `{"model_name": "gpt4"}`,
+ wantName: "gpt4",
+ },
+ {
+ name: "old model field",
+ json: `{"model": "gpt4"}`,
+ wantName: "gpt4",
+ },
+ {
+ name: "both fields - model_name wins",
+ json: `{"model_name": "new", "model": "old"}`,
+ wantName: "new",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var defaults AgentDefaults
+ if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+ if got := defaults.GetModelName(); got != tt.wantName {
+ t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
+ }
+ })
+ }
+}
+
+func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
+ // Test complete config with both old and new formats
+ oldFormat := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "gpt4",
+ "max_tokens": 4096
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-4o",
+ "api_key": "test-key"
+ }
+ ]
+ }`
+
+ newFormat := `{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model_name": "gpt4",
+ "max_tokens": 4096
+ }
+ },
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-4o",
+ "api_key": "test-key"
+ }
+ ]
+ }`
+
+ for name, jsonStr := range map[string]string{
+ "old format (model)": oldFormat,
+ "new format (model_name)": newFormat,
+ } {
+ t.Run(name, func(t *testing.T) {
+ cfg := &Config{}
+ if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
+ t.Fatalf("Unmarshal error: %v", err)
+ }
+
+ // Check that GetModelName returns correct value
+ if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
+ t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
+ }
+
+ // Check that GetModelConfig works
+ modelCfg, err := cfg.GetModelConfig("gpt4")
+ if err != nil {
+ t.Fatalf("GetModelConfig error: %v", err)
+ }
+ if modelCfg.Model != "openai/gpt-4o" {
+ t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
+ }
+ })
+ }
+}
+
func TestModelConfig_Validate(t *testing.T) {
tests := []struct {
name string
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index 2237a1429..d9552db9f 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -72,7 +72,10 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
if agents, ok := getMap(data, "agents"); ok {
if defaults, ok := getMap(agents, "defaults"); ok {
- if v, ok := getString(defaults, "model"); 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 {
diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go
index b6f1b5e21..b9abfdc61 100644
--- a/pkg/providers/factory.go
+++ b/pkg/providers/factory.go
@@ -36,7 +36,7 @@ type providerSelection struct {
}
func resolveProviderSelection(cfg *config.Config) (providerSelection, error) {
- model := cfg.Agents.Defaults.Model
+ model := cfg.Agents.Defaults.GetModelName()
providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
lowerModel := strings.ToLower(model)
diff --git a/pkg/providers/legacy_provider.go b/pkg/providers/legacy_provider.go
index eb13cec65..23f137538 100644
--- a/pkg/providers/legacy_provider.go
+++ b/pkg/providers/legacy_provider.go
@@ -16,7 +16,7 @@ import (
// The old providers config is automatically converted to model_list during config loading.
// Returns the provider, the model ID to use, and any error.
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
- model := cfg.Agents.Defaults.Model
+ model := cfg.Agents.Defaults.GetModelName()
// Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
From 19c698356c2aef8f618a110acef671def19f5261 Mon Sep 17 00:00:00 2001
From: 0x5487
Date: Mon, 23 Feb 2026 17:09:53 +0800
Subject: [PATCH 045/144] fix(security): workspace sandbox avoid
time-of-check/time-of-use (TOCTOU) races (#464)
* chore: Update default host bindings from 0.0.0.0 to 127.0.0.1 for various services and examples.
* config: Update default host bindings to 0.0.0.0 for improved Docker accessibility and add related documentation.
* refactor: reimplement filesystem tools with `os.OpenRoot` for enhanced security and simplified path validation.
* chore: revert other PR content from this branch
* docs: Update Chinese README.
* docs: Update Chinese README.
* docs: Update Chinese README.
* refactor: Reorder filesystem helper functions, extract directory entry formatting logic, and enhance `WriteFileTool`'s result message.
* feat: Enhance `mkdirAllInRoot` to prevent creating directories over existing files and add tests for directory creation functionality.
* Refactor filesystem tools to use a `fileReadWriter` interface for both host and sandboxed I/O, improving atomic writes and error handling.
* refactor: unify filesystem read/write operations with atomic write guarantees and clearer naming.
* refactor: rename `appendFileWithRW` function to `appendFile`
* refactor: unify filesystem access by introducing a `fileSystem` interface and updating tools to use it directly, removing `os.Root` dependency from `sandboxFs`.
* chore: run make fmt
* fix: `validatePath` now returns an error when the workspace is empty.
---
pkg/tools/edit.go | 118 ++++++++++--------
pkg/tools/edit_test.go | 160 +++++++++++++++++++++++-
pkg/tools/filesystem.go | 234 +++++++++++++++++++++++++++++------
pkg/tools/filesystem_test.go | 227 +++++++++++++++++++++++++++++++--
4 files changed, 630 insertions(+), 109 deletions(-)
diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go
index c28ca6ca2..d3ab267bf 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/edit.go
@@ -2,24 +2,27 @@ package tools
import (
"context"
+ "errors"
"fmt"
- "os"
+ "io/fs"
"strings"
)
// EditFileTool edits a file by replacing old_text with new_text.
// The old_text must exist exactly in the file.
type EditFileTool struct {
- allowedDir string
- restrict bool
+ fs fileSystem
}
// NewEditFileTool creates a new EditFileTool with optional directory restriction.
-func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool {
- return &EditFileTool{
- allowedDir: allowedDir,
- restrict: restrict,
+func NewEditFileTool(workspace string, restrict bool) *EditFileTool {
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
}
+ return &EditFileTool{fs: fs}
}
func (t *EditFileTool) Name() string {
@@ -67,49 +70,24 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("new_text is required")
}
- resolvedPath, err := validatePath(path, t.allowedDir, t.restrict)
- if err != nil {
+ if err := editFile(t.fs, path, oldText, newText); err != nil {
return ErrorResult(err.Error())
}
-
- if _, err = os.Stat(resolvedPath); os.IsNotExist(err) {
- return ErrorResult(fmt.Sprintf("file not found: %s", path))
- }
-
- content, err := os.ReadFile(resolvedPath)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
- }
-
- contentStr := string(content)
-
- if !strings.Contains(contentStr, oldText) {
- return ErrorResult("old_text not found in file. Make sure it matches exactly")
- }
-
- count := strings.Count(contentStr, oldText)
- if count > 1 {
- return ErrorResult(
- fmt.Sprintf("old_text appears %d times. Please provide more context to make it unique", count),
- )
- }
-
- newContent := strings.Replace(contentStr, oldText, newText, 1)
-
- if err := os.WriteFile(resolvedPath, []byte(newContent), 0o644); err != nil {
- return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("File edited: %s", path))
}
type AppendFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
- return &AppendFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &AppendFileTool{fs: fs}
}
func (t *AppendFileTool) Name() string {
@@ -148,20 +126,52 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *Tool
return ErrorResult("content is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
+ if err := appendFile(t.fs, path, content); err != nil {
return ErrorResult(err.Error())
}
-
- f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to open file: %v", err))
- }
- defer f.Close()
-
- if _, err := f.WriteString(content); err != nil {
- return ErrorResult(fmt.Sprintf("failed to append to file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("Appended to %s", path))
}
+
+// editFile reads the file via sysFs, performs the replacement, and writes back.
+// It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes.
+func editFile(sysFs fileSystem, path, oldText, newText string) error {
+ content, err := sysFs.ReadFile(path)
+ if err != nil {
+ return err
+ }
+
+ newContent, err := replaceEditContent(content, oldText, newText)
+ if err != nil {
+ return err
+ }
+
+ return sysFs.WriteFile(path, newContent)
+}
+
+// appendFile reads the existing content (if any) via sysFs, appends new content, and writes back.
+func appendFile(sysFs fileSystem, path, appendContent string) error {
+ content, err := sysFs.ReadFile(path)
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ return err
+ }
+
+ newContent := append(content, []byte(appendContent)...)
+ return sysFs.WriteFile(path, newContent)
+}
+
+// replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText.
+func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) {
+ contentStr := string(content)
+
+ if !strings.Contains(contentStr, oldText) {
+ return nil, fmt.Errorf("old_text not found in file. Make sure it matches exactly")
+ }
+
+ count := strings.Count(contentStr, oldText)
+ if count > 1 {
+ return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
+ }
+
+ newContent := strings.Replace(contentStr, oldText, newText, 1)
+ return []byte(newContent), nil
+}
diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go
index 6780dd9f6..83a7e778c 100644
--- a/pkg/tools/edit_test.go
+++ b/pkg/tools/edit_test.go
@@ -6,6 +6,8 @@ import (
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
// TestEditTool_EditFile_Success verifies successful file editing
@@ -151,14 +153,18 @@ func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) {
result := tool.Execute(ctx, args)
// Should return error result
- if !result.IsError {
- t.Errorf("Expected error when path is outside allowed directory")
- }
+ assert.True(t, result.IsError, "Expected error when path is outside allowed directory")
// Should mention outside allowed directory
- if !strings.Contains(result.ForLLM, "outside") && !strings.Contains(result.ForUser, "outside") {
- t.Errorf("Expected 'outside allowed' message, got ForLLM: %s", result.ForLLM)
- }
+ // Note: ErrorResult only sets ForLLM by default, so ForUser might be empty.
+ // We check ForLLM as it's the primary error channel.
+ assert.True(
+ t,
+ strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") ||
+ strings.Contains(result.ForLLM, "escapes"),
+ "Expected 'outside allowed' or 'access denied' message, got ForLLM: %s",
+ result.ForLLM,
+ )
}
// TestEditTool_EditFile_MissingPath verifies error handling for missing path
@@ -287,3 +293,145 @@ func TestEditTool_AppendFile_MissingContent(t *testing.T) {
t.Errorf("Expected error when content is missing")
}
}
+
+// TestReplaceEditContent verifies the helper function replaceEditContent
+func TestReplaceEditContent(t *testing.T) {
+ tests := []struct {
+ name string
+ content []byte
+ oldText string
+ newText string
+ expected []byte
+ expectError bool
+ }{
+ {
+ name: "successful replacement",
+ content: []byte("hello world"),
+ oldText: "world",
+ newText: "universe",
+ expected: []byte("hello universe"),
+ expectError: false,
+ },
+ {
+ name: "old text not found",
+ content: []byte("hello world"),
+ oldText: "golang",
+ newText: "rust",
+ expected: nil,
+ expectError: true,
+ },
+ {
+ name: "multiple matches found",
+ content: []byte("test text test"),
+ oldText: "test",
+ newText: "done",
+ expected: nil,
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := replaceEditContent(tt.content, tt.oldText, tt.newText)
+ if tt.expectError {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.expected, result)
+ }
+ })
+ }
+}
+
+// TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode
+// can append to a file that does not yet exist — it should silently create the file.
+// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW.
+func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewAppendFileTool(workspace, true)
+ ctx := context.Background()
+
+ args := map[string]any{
+ "path": "brand_new_file.txt",
+ "content": "first content",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(
+ t,
+ result.IsError,
+ "Expected success when appending to non-existent file in restricted mode, got: %s",
+ result.ForLLM,
+ )
+
+ // Verify the file was created with correct content
+ data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt"))
+ assert.NoError(t, err)
+ assert.Equal(t, "first content", string(data))
+}
+
+// TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode
+// correctly appends to an existing file within the sandbox.
+func TestAppendFileTool_Restricted_Success(t *testing.T) {
+ workspace := t.TempDir()
+ testFile := "existing.txt"
+ err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644)
+ assert.NoError(t, err)
+
+ tool := NewAppendFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": testFile,
+ "content": " appended",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+ assert.True(t, result.Silent)
+
+ data, err := os.ReadFile(filepath.Join(workspace, testFile))
+ assert.NoError(t, err)
+ assert.Equal(t, "initial appended", string(data))
+}
+
+// TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode
+// correctly edits a file using the single-open editFileInRoot path.
+func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) {
+ workspace := t.TempDir()
+ testFile := "edit_target.txt"
+ err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644)
+ assert.NoError(t, err)
+
+ tool := NewEditFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": testFile,
+ "old_text": "World",
+ "new_text": "Go",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+ assert.True(t, result.Silent)
+
+ data, err := os.ReadFile(filepath.Join(workspace, testFile))
+ assert.NoError(t, err)
+ assert.Equal(t, "Hello Go", string(data))
+}
+
+// TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper
+// error message when the target file does not exist.
+func TestEditFileTool_Restricted_FileNotFound(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewEditFileTool(workspace, true)
+ ctx := context.Background()
+ args := map[string]any{
+ "path": "no_such_file.txt",
+ "old_text": "old",
+ "new_text": "new",
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.True(t, result.IsError)
+ assert.Contains(t, result.ForLLM, "not found")
+}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 1bf50906e..37db8b4ae 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -3,15 +3,17 @@ package tools
import (
"context"
"fmt"
+ "io/fs"
"os"
"path/filepath"
"strings"
+ "time"
)
// validatePath ensures the given path is within the workspace if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" {
- return path, nil
+ return path, fmt.Errorf("workspace is not defined")
}
absWorkspace, err := filepath.Abs(workspace)
@@ -76,16 +78,21 @@ func resolveExistingAncestor(path string) (string, error) {
func isWithinWorkspace(candidate, workspace string) bool {
rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
- return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
+ return err == nil && filepath.IsLocal(rel)
}
type ReadFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
- return &ReadFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &ReadFileTool{fs: fs}
}
func (t *ReadFileTool) Name() string {
@@ -115,26 +122,25 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("path is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
+ content, err := t.fs.ReadFile(path)
if err != nil {
return ErrorResult(err.Error())
}
-
- content, err := os.ReadFile(resolvedPath)
- if err != nil {
- return ErrorResult(fmt.Sprintf("failed to read file: %v", err))
- }
-
return NewToolResult(string(content))
}
type WriteFileTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
- return &WriteFileTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &WriteFileTool{fs: fs}
}
func (t *WriteFileTool) Name() string {
@@ -173,30 +179,25 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
return ErrorResult("content is required")
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
+ if err := t.fs.WriteFile(path, []byte(content)); err != nil {
return ErrorResult(err.Error())
}
- dir := filepath.Dir(resolvedPath)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return ErrorResult(fmt.Sprintf("failed to create directory: %v", err))
- }
-
- if err := os.WriteFile(resolvedPath, []byte(content), 0o644); err != nil {
- return ErrorResult(fmt.Sprintf("failed to write file: %v", err))
- }
-
return SilentResult(fmt.Sprintf("File written: %s", path))
}
type ListDirTool struct {
- workspace string
- restrict bool
+ fs fileSystem
}
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
- return &ListDirTool{workspace: workspace, restrict: restrict}
+ var fs fileSystem
+ if restrict {
+ fs = &sandboxFs{workspace: workspace}
+ } else {
+ fs = &hostFs{}
+ }
+ return &ListDirTool{fs: fs}
}
func (t *ListDirTool) Name() string {
@@ -226,24 +227,179 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes
path = "."
}
- resolvedPath, err := validatePath(path, t.workspace, t.restrict)
- if err != nil {
- return ErrorResult(err.Error())
- }
-
- entries, err := os.ReadDir(resolvedPath)
+ entries, err := t.fs.ReadDir(path)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read directory: %v", err))
}
+ return formatDirEntries(entries)
+}
- result := ""
+func formatDirEntries(entries []os.DirEntry) *ToolResult {
+ var result strings.Builder
for _, entry := range entries {
if entry.IsDir() {
- result += "DIR: " + entry.Name() + "\n"
+ result.WriteString("DIR: " + entry.Name() + "\n")
} else {
- result += "FILE: " + entry.Name() + "\n"
+ result.WriteString("FILE: " + entry.Name() + "\n")
+ }
+ }
+ return NewToolResult(result.String())
+}
+
+// fileSystem abstracts reading, writing, and listing files, allowing both
+// unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface.
+type fileSystem interface {
+ ReadFile(path string) ([]byte, error)
+ WriteFile(path string, data []byte) error
+ ReadDir(path string) ([]os.DirEntry, error)
+}
+
+// hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem.
+type hostFs struct{}
+
+func (h *hostFs) ReadFile(path string) ([]byte, error) {
+ content, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, fmt.Errorf("failed to read file: file not found: %w", err)
+ }
+ if os.IsPermission(err) {
+ return nil, fmt.Errorf("failed to read file: access denied: %w", err)
+ }
+ return nil, fmt.Errorf("failed to read file: %w", err)
+ }
+ return content, nil
+}
+
+func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
+ return os.ReadDir(path)
+}
+
+func (h *hostFs) WriteFile(path string, data []byte) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create parent directories: %w", err)
+ }
+
+ // We use a "write-then-rename" pattern here to ensure an atomic write.
+ // This prevents the target file from being left in a truncated or partial state
+ // if the operation is interrupted, as the rename operation is atomic on Linux.
+ tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
+ if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
+ os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+
+ if err := os.Rename(tmpPath, path); err != nil {
+ os.Remove(tmpPath)
+ return fmt.Errorf("failed to replace original file: %w", err)
+ }
+ return nil
+}
+
+// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
+type sandboxFs struct {
+ workspace string
+}
+
+func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) error) error {
+ if r.workspace == "" {
+ return fmt.Errorf("workspace is not defined")
+ }
+
+ root, err := os.OpenRoot(r.workspace)
+ if err != nil {
+ return fmt.Errorf("failed to open workspace: %w", err)
+ }
+ defer root.Close()
+
+ relPath, err := getSafeRelPath(r.workspace, path)
+ if err != nil {
+ return err
+ }
+
+ return fn(root, relPath)
+}
+
+func (r *sandboxFs) ReadFile(path string) ([]byte, error) {
+ var content []byte
+ err := r.execute(path, func(root *os.Root, relPath string) error {
+ fileContent, err := root.ReadFile(relPath)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("failed to read file: file not found: %w", err)
+ }
+ // os.Root returns "escapes from parent" for paths outside the root
+ if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") ||
+ strings.Contains(err.Error(), "permission denied") {
+ return fmt.Errorf("failed to read file: access denied: %w", err)
+ }
+ return fmt.Errorf("failed to read file: %w", err)
+ }
+ content = fileContent
+ return nil
+ })
+ return content, err
+}
+
+func (r *sandboxFs) WriteFile(path string, data []byte) error {
+ return r.execute(path, func(root *os.Root, relPath string) error {
+ dir := filepath.Dir(relPath)
+ if dir != "." && dir != "/" {
+ if err := root.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create parent directories: %w", err)
+ }
+ }
+
+ // We use a "write-then-rename" pattern here to ensure an atomic write.
+ // This prevents the target file from being left in a truncated or partial state
+ // if the operation is interrupted, as the rename operation is atomic on Linux.
+ tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
+
+ if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil {
+ root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file
+ return fmt.Errorf("failed to write to temp file: %w", err)
+ }
+
+ if err := root.Rename(tmpRelPath, relPath); err != nil {
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to rename temp file over target: %w", err)
+ }
+ return nil
+ })
+}
+
+func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) {
+ var entries []os.DirEntry
+ err := r.execute(path, func(root *os.Root, relPath string) error {
+ dirEntries, err := fs.ReadDir(root.FS(), relPath)
+ if err != nil {
+ return err
+ }
+ entries = dirEntries
+ return nil
+ })
+ return entries, err
+}
+
+// Helper to get a safe relative path for os.Root usage
+func getSafeRelPath(workspace, path string) (string, error) {
+ if workspace == "" {
+ return "", fmt.Errorf("workspace is not defined")
+ }
+
+ rel := filepath.Clean(path)
+ if filepath.IsAbs(rel) {
+ var err error
+ rel, err = filepath.Rel(workspace, rel)
+ if err != nil {
+ return "", fmt.Errorf("failed to calculate relative path: %w", err)
}
}
- return NewToolResult(result)
+ if !filepath.IsLocal(rel) {
+ return "", fmt.Errorf("path escapes workspace: %s", path)
+ }
+
+ return rel, nil
}
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go
index 5daa3dcea..6f896e22d 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/filesystem_test.go
@@ -2,10 +2,13 @@ package tools
import (
"context"
+ "io"
"os"
"path/filepath"
"strings"
"testing"
+
+ "github.com/stretchr/testify/assert"
)
// TestFilesystemTool_ReadFile_Success verifies successful file reading
@@ -14,7 +17,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644)
- tool := &ReadFileTool{}
+ tool := NewReadFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -41,7 +44,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
- tool := &ReadFileTool{}
+ tool := NewReadFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_file_12345.txt",
@@ -84,7 +87,7 @@ func TestFilesystemTool_WriteFile_Success(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "newfile.txt")
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -123,7 +126,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "subdir", "newfile.txt")
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -149,7 +152,7 @@ func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path
func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"content": "test",
@@ -165,7 +168,7 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) {
// TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content
func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) {
- tool := &WriteFileTool{}
+ tool := NewWriteFileTool("", false)
ctx := context.Background()
args := map[string]any{
"path": "/tmp/test.txt",
@@ -192,7 +195,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644)
os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755)
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
args := map[string]any{
"path": tmpDir,
@@ -216,7 +219,7 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
// TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory
func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_directory_12345",
@@ -237,7 +240,7 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
// TestFilesystemTool_ListDir_DefaultPath verifies default to current directory
func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
- tool := &ListDirTool{}
+ tool := NewListDirTool("", false)
ctx := context.Background()
args := map[string]any{}
@@ -275,7 +278,211 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
if !result.IsError {
t.Fatalf("expected symlink escape to be blocked")
}
- if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") {
+ // os.Root might return different errors depending on platform/implementation
+ // but it definitely should error.
+ // Our wrapper returns "access denied or file not found"
+ if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
+ !strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
}
}
+
+func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
+ tool := NewReadFileTool("", true) // restrict=true but workspace=""
+
+ // Try to read a sensitive file (simulated by a temp file outside workspace)
+ tmpDir := t.TempDir()
+ secretFile := filepath.Join(tmpDir, "shadow")
+ os.WriteFile(secretFile, []byte("secret data"), 0o600)
+
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": secretFile,
+ })
+
+ // We EXPECT IsError=true (access blocked due to empty workspace)
+ assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
+
+ // Verify it failed for the right reason
+ assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
+}
+
+// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
+// single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path.
+func TestRootMkdirAll(t *testing.T) {
+ workspace := t.TempDir()
+ root, err := os.OpenRoot(workspace)
+ if err != nil {
+ t.Fatalf("failed to open root: %v", err)
+ }
+ defer root.Close()
+
+ // Case 1: Single directory
+ err = root.MkdirAll("dir1", 0o755)
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(workspace, "dir1"))
+ assert.NoError(t, err)
+
+ // Case 2: Deeply nested directory
+ err = root.MkdirAll("a/b/c/d", 0o755)
+ assert.NoError(t, err)
+ _, err = os.Stat(filepath.Join(workspace, "a/b/c/d"))
+ assert.NoError(t, err)
+
+ // Case 3: Already exists — must be idempotent
+ err = root.MkdirAll("a/b/c/d", 0o755)
+ assert.NoError(t, err)
+
+ // Case 4: A regular file blocks directory creation — must error
+ err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644)
+ assert.NoError(t, err)
+ err = root.MkdirAll("file_exists", 0o755)
+ assert.Error(t, err, "expected error when a file exists at the directory path")
+}
+
+func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) {
+ workspace := t.TempDir()
+ tool := NewWriteFileTool(workspace, true)
+ ctx := context.Background()
+
+ testFile := "deep/nested/path/to/file.txt"
+ content := "deep content"
+ args := map[string]any{
+ "path": testFile,
+ "content": content,
+ }
+
+ result := tool.Execute(ctx, args)
+ assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM)
+
+ // Verify file content
+ actualPath := filepath.Join(workspace, testFile)
+ data, err := os.ReadFile(actualPath)
+ assert.NoError(t, err)
+ assert.Equal(t, content, string(data))
+}
+
+// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors.
+func TestHostRW_Read_PermissionDenied(t *testing.T) {
+ if os.Getuid() == 0 {
+ t.Skip("skipping permission test: running as root")
+ }
+ tmpDir := t.TempDir()
+ protected := filepath.Join(tmpDir, "protected.txt")
+ err := os.WriteFile(protected, []byte("secret"), 0o000)
+ assert.NoError(t, err)
+ defer os.Chmod(protected, 0o644) // ensure cleanup
+
+ _, err = (&hostFs{}).ReadFile(protected)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "access denied")
+}
+
+// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path.
+func TestHostRW_Read_Directory(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ _, err := (&hostFs{}).ReadFile(tmpDir)
+ assert.Error(t, err, "expected error when reading a directory as a file")
+}
+
+// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory.
+func TestRootRW_Read_Directory(t *testing.T) {
+ workspace := t.TempDir()
+ root, err := os.OpenRoot(workspace)
+ assert.NoError(t, err)
+ defer root.Close()
+
+ // Create a subdirectory
+ err = root.Mkdir("subdir", 0o755)
+ assert.NoError(t, err)
+
+ _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir")
+ assert.Error(t, err, "expected error when reading a directory as a file")
+}
+
+// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically.
+func TestHostRW_Write_ParentDirMissing(t *testing.T) {
+ tmpDir := t.TempDir()
+ target := filepath.Join(tmpDir, "a", "b", "c", "file.txt")
+
+ err := (&hostFs{}).WriteFile(target, []byte("hello"))
+ assert.NoError(t, err)
+
+ data, err := os.ReadFile(target)
+ assert.NoError(t, err)
+ assert.Equal(t, "hello", string(data))
+}
+
+// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates
+// nested parent directories automatically within the sandbox.
+func TestRootRW_Write_ParentDirMissing(t *testing.T) {
+ workspace := t.TempDir()
+
+ relPath := "x/y/z/file.txt"
+ err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested"))
+ assert.NoError(t, err)
+
+ data, err := os.ReadFile(filepath.Join(workspace, relPath))
+ assert.NoError(t, err)
+ assert.Equal(t, "nested", string(data))
+}
+
+// TestHostRW_Write verifies the hostRW.Write helper function
+func TestHostRW_Write(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "atomic_test.txt")
+ testData := []byte("atomic test content")
+
+ err := (&hostFs{}).WriteFile(testFile, testData)
+ assert.NoError(t, err)
+
+ content, err := os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, testData, content)
+
+ // Verify it overwrites correctly
+ newData := []byte("new atomic content")
+ err = (&hostFs{}).WriteFile(testFile, newData)
+ assert.NoError(t, err)
+
+ content, err = os.ReadFile(testFile)
+ assert.NoError(t, err)
+ assert.Equal(t, newData, content)
+}
+
+// TestRootRW_Write verifies the rootRW.Write helper function
+func TestRootRW_Write(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ relPath := "atomic_root_test.txt"
+ testData := []byte("atomic root test content")
+
+ erw := &sandboxFs{workspace: tmpDir}
+ err := erw.WriteFile(relPath, testData)
+ assert.NoError(t, err)
+
+ root, err := os.OpenRoot(tmpDir)
+ assert.NoError(t, err)
+ defer root.Close()
+
+ f, err := root.Open(relPath)
+ assert.NoError(t, err)
+ defer f.Close()
+
+ content, err := io.ReadAll(f)
+ assert.NoError(t, err)
+ assert.Equal(t, testData, content)
+
+ // Verify it overwrites correctly
+ newData := []byte("new root atomic content")
+ err = erw.WriteFile(relPath, newData)
+ assert.NoError(t, err)
+
+ f2, err := root.Open(relPath)
+ assert.NoError(t, err)
+ defer f2.Close()
+
+ content, err = io.ReadAll(f2)
+ assert.NoError(t, err)
+ assert.Equal(t, newData, content)
+}
From 6d487a12b26ae2131a573915ee4f55a4da424c30 Mon Sep 17 00:00:00 2001
From: Zenix
Date: Mon, 23 Feb 2026 19:29:43 +0900
Subject: [PATCH 046/144] fix: make install should be aware of the textfile
busy since it tries to overwrite the file with non-atomic operation (#558)
---
Makefile | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/Makefile b/Makefile
index a5ad4a02d..29e2fc964 100644
--- a/Makefile
+++ b/Makefile
@@ -24,6 +24,7 @@ GOLANGCI_LINT?=golangci-lint
INSTALL_PREFIX?=$(HOME)/.local
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
+INSTALL_TMP_SUFFIX=.new
# Workspace and Skills
PICOCLAW_HOME?=$(HOME)/.picoclaw
@@ -99,8 +100,10 @@ build-all: generate
install: build
@echo "Installing $(BINARY_NAME)..."
@mkdir -p $(INSTALL_BIN_DIR)
- @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
- @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
+ # Copy binary with temporary suffix to ensure atomic update
+ @cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
+ @chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
+ @mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
@echo "Installation complete!"
From 6852f240251a3d410d89d1b352acf2666dae3d04 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Mon, 23 Feb 2026 21:34:37 +0800
Subject: [PATCH 047/144] 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 8a53cb96651fad13c665417aadbedddf3e14a284 Mon Sep 17 00:00:00 2001
From: Chujiang <110hqc@gmail.com>
Date: Tue, 24 Feb 2026 05:52:16 +0800
Subject: [PATCH 048/144] fix: align Docker Go version with go.mod and optimize
logger (#596)
- Update Dockerfile to use golang:1.25-alpine to match go.mod (go 1.25.7)
- Optimize logger by avoiding string concatenation in file writes
- Add explicit empty string assignment for fieldStr when no fields
These changes improve build consistency and reduce memory allocations
in the hot logging path, which is important for the project's goal
of running on resource-constrained devices (<10MB RAM).
Co-authored-by: Claude Sonnet 4.6
---
Dockerfile | 2 +-
pkg/logger/logger.go | 4 +++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 0360cfda6..480244127 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
-FROM golang:1.26.0-alpine AS builder
+FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git make
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 54de66bf9..c14fbd464 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -119,13 +119,15 @@ func logMessage(level LogLevel, component string, message string, fields map[str
if logger.file != nil {
jsonData, err := json.Marshal(entry)
if err == nil {
- logger.file.WriteString(string(jsonData) + "\n")
+ logger.file.Write(append(jsonData, '\n'))
}
}
var fieldStr string
if len(fields) > 0 {
fieldStr = " " + formatFields(fields)
+ } else {
+ fieldStr = ""
}
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
From 2fa51d7b868672ab2fd054396216fa68184b3ad6 Mon Sep 17 00:00:00 2001
From: 0x5487
Date: Tue, 24 Feb 2026 05:54:10 +0800
Subject: [PATCH 049/144] fix(security): change gateway default bind to
127.0.0.1 (#393)
* chore: Update default host bindings from 0.0.0.0 to 127.0.0.1 for various services and examples.
* config: Update default host bindings to 0.0.0.0 for improved Docker accessibility and add related documentation.
* chore: resolve conflict
* chore: remove link
* docs: Add a tip for Docker users regarding gateway host configuration to the French and Vietnamese READMEs.
* fix: typo issue
* docs: Update Chinese README.zh.md.
---
README.fr.md | 4 ++++
README.ja.md | 4 ++++
README.md | 4 ++++
README.pt-br.md | 4 ++++
README.vi.md | 4 ++++
README.zh.md | 3 +++
config/config.example.json | 2 +-
pkg/config/config_test.go | 4 ++--
pkg/config/defaults.go | 2 +-
9 files changed, 27 insertions(+), 4 deletions(-)
diff --git a/README.fr.md b/README.fr.md
index a762870ff..d09276c27 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -171,6 +171,10 @@ vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
# 3. Compiler & Démarrer
docker compose --profile gateway up -d
+> [!TIP]
+> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
+
+
# 4. Voir les logs
docker compose logs -f picoclaw-gateway
diff --git a/README.ja.md b/README.ja.md
index 3506c77c2..67eccddc2 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -133,6 +133,10 @@ vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キ
# 3. ビルドと起動
docker compose --profile gateway up -d
+> [!TIP]
+> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
+
+
# 4. ログ確認
docker compose logs -f picoclaw-gateway
diff --git a/README.md b/README.md
index 955255f2e..84d92115b 100644
--- a/README.md
+++ b/README.md
@@ -171,6 +171,10 @@ vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
# 3. Build & Start
docker compose --profile gateway up -d
+> [!TIP]
+> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
+
+
# 4. Check logs
docker compose logs -f picoclaw-gateway
diff --git a/README.pt-br.md b/README.pt-br.md
index 900ee7932..8d87333bc 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -172,6 +172,10 @@ vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
# 3. Build & Iniciar
docker compose --profile gateway up -d
+> [!TIP]
+> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
+
+
# 4. Ver logs
docker compose logs -f picoclaw-gateway
diff --git a/README.vi.md b/README.vi.md
index 29ff12bb0..1be58d9f6 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -152,6 +152,10 @@ vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
# 3. Build & Khởi động
docker compose --profile gateway up -d
+> [!TIP]
+> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
+
+
# 4. Xem logs
docker compose logs -f picoclaw-gateway
diff --git a/README.zh.md b/README.zh.md
index 17a736fec..74760b3b1 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -173,6 +173,9 @@ vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
# 3. 构建并启动
docker compose --profile gateway up -d
+> [!TIP]
+**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
+
# 4. 查看日志
docker compose logs -f picoclaw-gateway
diff --git a/config/config.example.json b/config/config.example.json
index e814fcbb8..555509732 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -247,7 +247,7 @@
"monitor_usb": true
},
"gateway": {
- "host": "0.0.0.0",
+ "host": "127.0.0.1",
"port": 18790
}
}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 0898217d6..f88c0269c 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -246,7 +246,7 @@ func TestDefaultConfig_Temperature(t *testing.T) {
func TestDefaultConfig_Gateway(t *testing.T) {
cfg := DefaultConfig()
- if cfg.Gateway.Host != "0.0.0.0" {
+ if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
@@ -343,7 +343,7 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.MaxToolIterations == 0 {
t.Error("MaxToolIterations should not be zero")
}
- if cfg.Gateway.Host != "0.0.0.0" {
+ if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 065273c28..b96ee4d89 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -272,7 +272,7 @@ func DefaultConfig() *Config {
},
},
Gateway: GatewayConfig{
- Host: "0.0.0.0",
+ Host: "127.0.0.1",
Port: 18790,
},
Tools: ToolsConfig{
From 09b1992dd79cac46b7a48176074eabae36b9c1bc Mon Sep 17 00:00:00 2001
From: Goksu Ceylan <79890826+GoCeylan@users.noreply.github.com>
Date: Mon, 23 Feb 2026 17:02:44 -0500
Subject: [PATCH 050/144] fix(security): ensure custom deny patterns extend
defaults instead of replacing them (#479)
* fix (security): custom deny patterns denying default patterns
* fix formatting whitespace
---
pkg/tools/shell.go | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index a1ee0b6e1..6883172cd 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -81,6 +81,7 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
execConfig := config.Tools.Exec
enableDenyPatterns = execConfig.EnableDenyPatterns
if enableDenyPatterns {
+ denyPatterns = append(denyPatterns, defaultDenyPatterns...)
if len(execConfig.CustomDenyPatterns) > 0 {
fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns)
for _, pattern := range execConfig.CustomDenyPatterns {
@@ -91,8 +92,6 @@ func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Conf
}
denyPatterns = append(denyPatterns, re)
}
- } else {
- denyPatterns = append(denyPatterns, defaultDenyPatterns...)
}
} else {
// If deny patterns are disabled, we won't add any patterns, allowing all commands.
From 6fe3920a4d836b97c838fd39874cc6a5d07d1d40 Mon Sep 17 00:00:00 2001
From: mattn
Date: Tue, 24 Feb 2026 08:07:09 +0900
Subject: [PATCH 051/144] perf: refactoring collecting skills (#688)
* perf: refactoring collecting skills
* Fix order to store dir.Name()
* Add tests
---
pkg/skills/loader.go | 140 +++++++++++---------------------------
pkg/skills/loader_test.go | 131 +++++++++++++++++++++++++++++++++++
2 files changed, 170 insertions(+), 101 deletions(-)
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index eb0d5f322..f4f55a698 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -71,112 +71,50 @@ func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string
func (sl *SkillsLoader) ListSkills() []SkillInfo {
skills := make([]SkillInfo, 0)
+ seen := make(map[string]bool)
- if sl.workspaceSkills != "" {
- if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil {
- for _, dir := range dirs {
- if dir.IsDir() {
- skillFile := filepath.Join(sl.workspaceSkills, dir.Name(), "SKILL.md")
- if _, err := os.Stat(skillFile); err == nil {
- info := SkillInfo{
- Name: dir.Name(),
- Path: skillFile,
- Source: "workspace",
- }
- metadata := sl.getSkillMetadata(skillFile)
- if metadata != nil {
- info.Description = metadata.Description
- info.Name = metadata.Name
- }
- if err := info.validate(); err != nil {
- slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
- continue
- }
- skills = append(skills, info)
- }
- }
+ addSkills := func(dir, source string) {
+ if dir == "" {
+ return
+ }
+ dirs, err := os.ReadDir(dir)
+ if err != nil {
+ return
+ }
+ for _, d := range dirs {
+ if !d.IsDir() {
+ continue
}
+ skillFile := filepath.Join(dir, d.Name(), "SKILL.md")
+ if _, err := os.Stat(skillFile); err != nil {
+ continue
+ }
+ info := SkillInfo{
+ Name: d.Name(),
+ Path: skillFile,
+ Source: source,
+ }
+ metadata := sl.getSkillMetadata(skillFile)
+ if metadata != nil {
+ info.Description = metadata.Description
+ info.Name = metadata.Name
+ }
+ if err := info.validate(); err != nil {
+ slog.Warn("invalid skill from "+source, "name", info.Name, "error", err)
+ continue
+ }
+ if seen[info.Name] {
+ continue
+ }
+ seen[info.Name] = true
+ skills = append(skills, info)
}
}
- // 全局 skills (~/.picoclaw/skills) - 被 workspace skills 覆盖
- if sl.globalSkills != "" {
- if dirs, err := os.ReadDir(sl.globalSkills); err == nil {
- for _, dir := range dirs {
- if dir.IsDir() {
- skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md")
- if _, err := os.Stat(skillFile); err == nil {
- // 检查是否已被 workspace skills 覆盖
- exists := false
- for _, s := range skills {
- if s.Name == dir.Name() && s.Source == "workspace" {
- exists = true
- break
- }
- }
- if exists {
- continue
- }
-
- info := SkillInfo{
- Name: dir.Name(),
- Path: skillFile,
- Source: "global",
- }
- metadata := sl.getSkillMetadata(skillFile)
- if metadata != nil {
- info.Description = metadata.Description
- info.Name = metadata.Name
- }
- if err := info.validate(); err != nil {
- slog.Warn("invalid skill from global", "name", info.Name, "error", err)
- continue
- }
- skills = append(skills, info)
- }
- }
- }
- }
- }
-
- if sl.builtinSkills != "" {
- if dirs, err := os.ReadDir(sl.builtinSkills); err == nil {
- for _, dir := range dirs {
- if dir.IsDir() {
- skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md")
- if _, err := os.Stat(skillFile); err == nil {
- // 检查是否已被 workspace 或 global skills 覆盖
- exists := false
- for _, s := range skills {
- if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") {
- exists = true
- break
- }
- }
- if exists {
- continue
- }
-
- info := SkillInfo{
- Name: dir.Name(),
- Path: skillFile,
- Source: "builtin",
- }
- metadata := sl.getSkillMetadata(skillFile)
- if metadata != nil {
- info.Description = metadata.Description
- info.Name = metadata.Name
- }
- if err := info.validate(); err != nil {
- slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
- continue
- }
- skills = append(skills, info)
- }
- }
- }
- }
- }
+ // Priority: workspace > global > builtin
+ addSkills(sl.workspaceSkills, "workspace")
+ addSkills(sl.globalSkills, "global")
+ addSkills(sl.builtinSkills, "builtin")
return skills
}
diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go
index aca901d33..9428bea62 100644
--- a/pkg/skills/loader_test.go
+++ b/pkg/skills/loader_test.go
@@ -1,9 +1,12 @@
package skills
import (
+ "os"
+ "path/filepath"
"testing"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func TestSkillsInfoValidate(t *testing.T) {
@@ -135,6 +138,134 @@ func TestExtractFrontmatter(t *testing.T) {
}
}
+// createSkillDir creates a skill directory with a SKILL.md file containing the given frontmatter.
+func createSkillDir(t *testing.T, base, dirName, name, description string) {
+ t.Helper()
+ dir := filepath.Join(base, dirName)
+ require.NoError(t, os.MkdirAll(dir, 0o755))
+ content := "---\nname: " + name + "\ndescription: " + description + "\n---\n\n# " + name
+ require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644))
+}
+
+func TestListSkillsWorkspaceOverridesGlobal(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ createSkillDir(t, filepath.Join(ws, "skills"), "my-skill", "my-skill", "workspace version")
+ createSkillDir(t, global, "my-skill", "my-skill", "global version")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "workspace", skills[0].Source)
+ assert.Equal(t, "workspace version", skills[0].Description)
+}
+
+func TestListSkillsGlobalOverridesBuiltin(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+ builtin := filepath.Join(tmp, "builtin")
+
+ createSkillDir(t, global, "my-skill", "my-skill", "global version")
+ createSkillDir(t, builtin, "my-skill", "my-skill", "builtin version")
+
+ sl := NewSkillsLoader(ws, global, builtin)
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "global", skills[0].Source)
+ assert.Equal(t, "global version", skills[0].Description)
+}
+
+func TestListSkillsMetadataNameDedup(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ // Different directory names but same metadata name
+ createSkillDir(t, filepath.Join(ws, "skills"), "dir-a", "shared-name", "workspace version")
+ createSkillDir(t, global, "dir-b", "shared-name", "global version")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "shared-name", skills[0].Name)
+ assert.Equal(t, "workspace", skills[0].Source)
+}
+
+func TestListSkillsMultipleDistinctSkills(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+ builtin := filepath.Join(tmp, "builtin")
+
+ createSkillDir(t, filepath.Join(ws, "skills"), "skill-a", "skill-a", "desc a")
+ createSkillDir(t, global, "skill-b", "skill-b", "desc b")
+ createSkillDir(t, builtin, "skill-c", "skill-c", "desc c")
+
+ sl := NewSkillsLoader(ws, global, builtin)
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 3)
+ names := map[string]string{}
+ for _, s := range skills {
+ names[s.Name] = s.Source
+ }
+ assert.Equal(t, "workspace", names["skill-a"])
+ assert.Equal(t, "global", names["skill-b"])
+ assert.Equal(t, "builtin", names["skill-c"])
+}
+
+func TestListSkillsInvalidSkillSkipped(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ // Invalid name (underscore)
+ createSkillDir(t, filepath.Join(ws, "skills"), "bad_skill", "bad_skill", "desc")
+ // Valid skill
+ createSkillDir(t, global, "good-skill", "good-skill", "desc")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "good-skill", skills[0].Name)
+}
+
+func TestListSkillsEmptyAndNonexistentDirs(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ emptyDir := filepath.Join(tmp, "empty")
+ require.NoError(t, os.MkdirAll(emptyDir, 0o755))
+
+ sl := NewSkillsLoader(ws, emptyDir, filepath.Join(tmp, "nonexistent"))
+ skills := sl.ListSkills()
+
+ assert.Empty(t, skills)
+}
+
+func TestListSkillsDirWithoutSkillMD(t *testing.T) {
+ tmp := t.TempDir()
+ ws := filepath.Join(tmp, "workspace")
+ global := filepath.Join(tmp, "global")
+
+ // Directory exists but has no SKILL.md
+ require.NoError(t, os.MkdirAll(filepath.Join(global, "no-skillmd"), 0o755))
+ // Valid skill alongside
+ createSkillDir(t, global, "real-skill", "real-skill", "desc")
+
+ sl := NewSkillsLoader(ws, global, "")
+ skills := sl.ListSkills()
+
+ assert.Len(t, skills, 1)
+ assert.Equal(t, "real-skill", skills[0].Name)
+}
+
func TestStripFrontmatter(t *testing.T) {
sl := &SkillsLoader{}
From 6fb61539d779dd155ba82422fe7d0f1094f0893e Mon Sep 17 00:00:00 2001
From: Kai Xia
Date: Tue, 24 Feb 2026 10:27:49 +1100
Subject: [PATCH 052/144] translate Chinese comments
Signed-off-by: Kai Xia
---
cmd/picoclaw/main.go | 2 +-
pkg/channels/qq.go | 46 ++++++++++++++++++++--------------------
pkg/channels/slack.go | 6 +++---
pkg/channels/telegram.go | 6 +++---
pkg/skills/loader.go | 12 +++++------
5 files changed, 36 insertions(+), 36 deletions(-)
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 1e4b393f8..25ad701ca 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -131,7 +131,7 @@ func main() {
workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace)
- // 获取全局配置目录和内置 skills 目录
+ // get global config directory and builtin skills directory
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go
index e66cac533..b10776db6 100644
--- a/pkg/channels/qq.go
+++ b/pkg/channels/qq.go
@@ -47,31 +47,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)
@@ -81,10 +81,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{
@@ -116,12 +116,12 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return fmt.Errorf("QQ bot not running")
}
- // 构造消息
+ // 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{
@@ -133,15 +133,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
@@ -150,7 +150,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil
}
- // 提取消息内容
+ // extract message content
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty message, ignoring")
@@ -162,7 +162,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
"length": len(content),
})
- // 转发到消息总线
+ // forward to message bus
metadata := map[string]string{
"message_id": data.ID,
"peer_kind": "direct",
@@ -175,15 +175,15 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
}
}
-// handleGroupATMessage 处理群@消息
+// 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
@@ -192,7 +192,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")
@@ -205,7 +205,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
"length": len(content),
})
- // 转发到消息总线(使用 GroupID 作为 ChatID)
+ // forward to message bus (use GroupID as ChatID)
metadata := map[string]string{
"message_id": data.ID,
"group_id": data.GroupID,
@@ -219,7 +219,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
}
}
-// isDuplicate 检查消息是否重复
+// isDuplicate checks if message is duplicate
func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock()
defer c.mu.Unlock()
@@ -230,9 +230,9 @@ func (c *QQChannel) isDuplicate(messageID string) bool {
c.processedIDs[messageID] = true
- // 简单清理:限制 map 大小
+ // simple cleanup: limit map size
if len(c.processedIDs) > 10000 {
- // 清空一半
+ // clear half
count := 0
for id := range c.processedIDs {
if count >= 5000 {
diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go
index f7359cd6d..f087aa8da 100644
--- a/pkg/channels/slack.go
+++ b/pkg/channels/slack.go
@@ -200,7 +200,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
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,
@@ -232,9 +232,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
content = c.stripBotMention(content)
var mediaPaths []string
- localFiles := []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 {
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index a0a1c8d0a..5cd51e8bc 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -208,7 +208,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
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,
@@ -221,9 +221,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content := ""
mediaPaths := []string{}
- localFiles := []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 {
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index f4f55a698..5749d8983 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -55,9 +55,9 @@ func (info SkillInfo) validate() error {
type SkillsLoader struct {
workspace string
- workspaceSkills string // workspace skills (项目级别)
- globalSkills string // 全局 skills (~/.picoclaw/skills)
- builtinSkills string // 内置 skills
+ workspaceSkills string // workspace skills (project-level)
+ globalSkills string // global skills (~/.picoclaw/skills)
+ builtinSkills string // builtin skills
}
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
@@ -120,7 +120,7 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
}
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
- // 1. 优先从 workspace skills 加载(项目级别)
+ // 1. load from workspace skills first (project-level)
if sl.workspaceSkills != "" {
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -128,7 +128,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
}
}
- // 2. 其次从全局 skills 加载 (~/.picoclaw/skills)
+ // 2. then load from global skills (~/.picoclaw/skills)
if sl.globalSkills != "" {
skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
@@ -136,7 +136,7 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
}
}
- // 3. 最后从内置 skills 加载
+ // 3. finally load from builtin skills
if sl.builtinSkills != "" {
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
From 04806bffe3df969d14ecacf23e968dc86cd56334 Mon Sep 17 00:00:00 2001
From: Aditya Kalro
Date: Mon, 23 Feb 2026 15:50:55 -0800
Subject: [PATCH 053/144] 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 054/144] 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 c56fcedcb14ce284e12c70bce7d5d1a76d1f2714 Mon Sep 17 00:00:00 2001
From: mosir
Date: Tue, 24 Feb 2026 13:22:52 +0800
Subject: [PATCH 055/144] refactor(pkg/utils): add unified atomic file write
utility
---
pkg/agent/memory.go | 13 +++--
pkg/auth/store.go | 11 ++---
pkg/config/config.go | 10 ++--
pkg/cron/service.go | 10 ++--
pkg/heartbeat/service.go | 3 +-
pkg/skills/installer.go | 6 ++-
pkg/state/state.go | 27 +++--------
pkg/tools/filesystem.go | 57 ++++++++++++----------
pkg/tools/skills_install.go | 3 +-
pkg/utils/file.go | 97 +++++++++++++++++++++++++++++++++++++
10 files changed, 166 insertions(+), 71 deletions(-)
create mode 100644 pkg/utils/file.go
diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go
index dd5f4441c..7e952be55 100644
--- a/pkg/agent/memory.go
+++ b/pkg/agent/memory.go
@@ -12,6 +12,8 @@ import (
"path/filepath"
"strings"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// MemoryStore manages persistent memory for the agent.
@@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error {
- return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
+ return utils.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
}
// ReadToday reads today's daily note.
@@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
// Ensure month directory exists
monthDir := filepath.Dir(todayFile)
- os.MkdirAll(monthDir, 0o755)
+ if err := os.MkdirAll(monthDir, 0o755); err != nil {
+ return err
+ }
var existingContent string
if data, err := os.ReadFile(todayFile); err == nil {
@@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
newContent = existingContent + "\n" + content
}
- return os.WriteFile(todayFile, []byte(newContent), 0o644)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return utils.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
}
// GetRecentDailyNotes returns daily notes from the last N days.
diff --git a/pkg/auth/store.go b/pkg/auth/store.go
index 64708421b..34d7d8a3f 100644
--- a/pkg/auth/store.go
+++ b/pkg/auth/store.go
@@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
type AuthCredential struct {
@@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
func SaveStore(store *AuthStore) error {
path := authFilePath()
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
-
data, err := json.MarshalIndent(store, "", " ")
if err != nil {
return err
}
- return os.WriteFile(path, data, 0o600)
+
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return utils.WriteFileAtomic(path, data, 0o600)
}
func GetCredential(provider string) (*AuthCredential, error) {
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 2595398c7..9d2860407 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -4,10 +4,10 @@ import (
"encoding/json"
"fmt"
"os"
- "path/filepath"
"sync/atomic"
"github.com/caarlos0/env/v11"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// rrCounter is a global counter for round-robin load balancing across models.
@@ -526,12 +526,8 @@ func SaveConfig(path string, cfg *Config) error {
return err
}
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
-
- return os.WriteFile(path, data, 0o600)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return utils.WriteFileAtomic(path, data, 0o600)
}
func (c *Config) WorkspacePath() string {
diff --git a/pkg/cron/service.go b/pkg/cron/service.go
index e699a44b5..7501117f5 100644
--- a/pkg/cron/service.go
+++ b/pkg/cron/service.go
@@ -7,11 +7,11 @@ import (
"fmt"
"log"
"os"
- "path/filepath"
"sync"
"time"
"github.com/adhocore/gronx"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
type CronSchedule struct {
@@ -330,17 +330,13 @@ func (cs *CronService) loadStore() error {
}
func (cs *CronService) saveStoreUnsafe() error {
- dir := filepath.Dir(cs.storePath)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
-
data, err := json.MarshalIndent(cs.store, "", " ")
if err != nil {
return err
}
- return os.WriteFile(cs.storePath, data, 0o600)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return utils.WriteFileAtomic(cs.storePath, data, 0o600)
}
func (cs *CronService) AddJob(
diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go
index 75d6248b9..2108fbf8c 100644
--- a/pkg/heartbeat/service.go
+++ b/pkg/heartbeat/service.go
@@ -19,6 +19,7 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
const (
@@ -275,7 +276,7 @@ This file contains tasks for the heartbeat service to check periodically.
Add your heartbeat tasks below this line:
`
- if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
+ if err := utils.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
hs.logError("Failed to create default HEARTBEAT.md: %v", err)
} else {
hs.logInfo("Created default HEARTBEAT.md template")
diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go
index 3210509df..12ffe33e6 100644
--- a/pkg/skills/installer.go
+++ b/pkg/skills/installer.go
@@ -9,6 +9,8 @@ import (
"os"
"path/filepath"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
type SkillInstaller struct {
@@ -64,7 +66,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
}
skillPath := filepath.Join(skillDir, "SKILL.md")
- if err := os.WriteFile(skillPath, body, 0o644); err != nil {
+
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ if err := utils.WriteFileAtomic(skillPath, body, 0o600); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
diff --git a/pkg/state/state.go b/pkg/state/state.go
index 1a92f82ed..efccc9332 100644
--- a/pkg/state/state.go
+++ b/pkg/state/state.go
@@ -8,6 +8,8 @@ import (
"path/filepath"
"sync"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// State represents the persistent state for a workspace.
@@ -124,33 +126,20 @@ func (sm *Manager) GetTimestamp() time.Time {
// saveAtomic performs an atomic save using temp file + rename.
// This ensures that the state file is never corrupted:
// 1. Write to a temp file
-// 2. Rename temp file to target (atomic on POSIX systems)
-// 3. If rename fails, cleanup the temp file
+// 2. Sync to disk (critical for SD cards/flash storage)
+// 3. Rename temp file to target (atomic on POSIX systems)
+// 4. If rename fails, cleanup the temp file
//
// Must be called with the lock held.
func (sm *Manager) saveAtomic() error {
- // Create temp file in the same directory as the target
- tempFile := sm.stateFile + ".tmp"
-
- // Marshal state to JSON
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
data, err := json.MarshalIndent(sm.state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
- // Write to temp file
- if err := os.WriteFile(tempFile, data, 0o644); err != nil {
- return fmt.Errorf("failed to write temp file: %w", err)
- }
-
- // Atomic rename from temp to target
- if err := os.Rename(tempFile, sm.stateFile); err != nil {
- // Cleanup temp file if rename fails
- os.Remove(tempFile)
- return fmt.Errorf("failed to rename temp file: %w", err)
- }
-
- return nil
+ return utils.WriteFileAtomic(sm.stateFile, data, 0o600)
}
// load loads the state from disk.
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 37db8b4ae..649707617 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/utils"
)
// validatePath ensures the given path is within the workspace if restrict is true.
@@ -276,25 +278,9 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
}
func (h *hostFs) WriteFile(path string, data []byte) error {
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return fmt.Errorf("failed to create parent directories: %w", err)
- }
-
- // We use a "write-then-rename" pattern here to ensure an atomic write.
- // This prevents the target file from being left in a truncated or partial state
- // if the operation is interrupted, as the rename operation is atomic on Linux.
- tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
- if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
- os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
- return fmt.Errorf("failed to write temp file: %w", err)
- }
-
- if err := os.Rename(tmpPath, path); err != nil {
- os.Remove(tmpPath)
- return fmt.Errorf("failed to replace original file: %w", err)
- }
- return nil
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
+ return utils.WriteFileAtomic(path, data, 0o600)
}
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
@@ -351,14 +337,33 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
}
}
- // We use a "write-then-rename" pattern here to ensure an atomic write.
- // This prevents the target file from being left in a truncated or partial state
- // if the operation is interrupted, as the rename operation is atomic on Linux.
- tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
+ // Use atomic write pattern with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
+ tmpRelPath := fmt.Sprintf(".tmp-%d.tmp", time.Now().UnixNano())
- if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil {
- root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file
- return fmt.Errorf("failed to write to temp file: %w", err)
+ tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
+ if err != nil {
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to open temp file: %w", err)
+ }
+
+ if _, err := tmpFile.Write(data); err != nil {
+ tmpFile.Close()
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+
+ // CRITICAL: Force sync to storage medium before rename.
+ // This ensures data is physically written to disk, not just cached.
+ if err := tmpFile.Sync(); err != nil {
+ tmpFile.Close()
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to sync temp file: %w", err)
+ }
+
+ if err := tmpFile.Close(); err != nil {
+ root.Remove(tmpRelPath)
+ return fmt.Errorf("failed to close temp file: %w", err)
}
if err := root.Rename(tmpRelPath, relPath); err != nil {
diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go
index 55c0b678d..57d29f355 100644
--- a/pkg/tools/skills_install.go
+++ b/pkg/tools/skills_install.go
@@ -197,5 +197,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
return err
}
- return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644)
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ return utils.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
}
diff --git a/pkg/utils/file.go b/pkg/utils/file.go
new file mode 100644
index 000000000..74a83712a
--- /dev/null
+++ b/pkg/utils/file.go
@@ -0,0 +1,97 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
+// License: MIT
+//
+// Copyright (c) 2026 PicoClaw contributors
+
+package utils
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern.
+//
+// This guarantees that the target file is either:
+// - Completely written with the new data
+// - Unchanged (if write fails or power loss during write)
+//
+// The function:
+// 1. Creates a temp file in the same directory
+// 2. Writes data to temp file
+// 3. Syncs to disk (critical for SD cards/flash storage)
+// 4. Sets file permissions
+// 5. Atomically renames temp file to target path
+//
+// Parameters:
+// - path: Target file path
+// - data: Data to write
+// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable)
+//
+// Returns:
+// - Error if any step fails, nil on success
+//
+// Example:
+//
+// // Secure config file (owner read/write only)
+// err := utils.WriteFileAtomic("config.json", data, 0o600)
+//
+// // Public readable file
+// err := utils.WriteFileAtomic("public.txt", data, 0o644)
+func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ // Create temp file in the same directory (ensures atomic rename works)
+ tmpFile, err := os.CreateTemp(dir, ".tmp-*.tmp")
+ if err != nil {
+ return fmt.Errorf("failed to create temp file: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+
+ // Cleanup on error: ensure temp file is removed if anything fails
+ cleanup := true
+ defer func() {
+ if cleanup {
+ _ = os.Remove(tmpPath)
+ }
+ }()
+
+ // Write data to temp file
+ if _, err := tmpFile.Write(data); err != nil {
+ tmpFile.Close()
+ return fmt.Errorf("failed to write temp file: %w", err)
+ }
+
+ // CRITICAL: Force sync to storage medium before rename.
+ // This ensures data is physically written to disk, not just cached.
+ // Essential for SD cards, eMMC, and other flash storage on edge devices.
+ if err := tmpFile.Sync(); err != nil {
+ tmpFile.Close()
+ return fmt.Errorf("failed to sync temp file: %w", err)
+ }
+
+ // Set file permissions
+ if err := tmpFile.Chmod(perm); err != nil {
+ tmpFile.Close()
+ return fmt.Errorf("failed to set permissions: %w", err)
+ }
+
+ // Close file before rename
+ if err := tmpFile.Close(); err != nil {
+ return fmt.Errorf("failed to close temp file: %w", err)
+ }
+
+ // Atomic rename: temp file becomes the target
+ if err := os.Rename(tmpPath, path); err != nil {
+ return fmt.Errorf("failed to rename temp file: %w", err)
+ }
+
+ // Success: skip cleanup
+ cleanup = false
+ return nil
+}
From 0434b49e8d9f85ae52b9b68efddd1147afd54d30 Mon Sep 17 00:00:00 2001
From: Guoguo <16666742+imguoguo@users.noreply.github.com>
Date: Tue, 24 Feb 2026 13:58:54 +0800
Subject: [PATCH 056/144] docs: update wechat qrcode (#705)
---
assets/wechat.png | Bin 144045 -> 150574 bytes
1 file changed, 0 insertions(+), 0 deletions(-)
diff --git a/assets/wechat.png b/assets/wechat.png
index a34217c335542a13aace2103bd15ccebf94acde2..e30c34e4ed81210338d63ee4486c4356d01ae30b 100644
GIT binary patch
literal 150574
zcmeFZcT`hd*Do3b2~~P8p-B+|=~9D*Ceo!hl@8KDI-y9BDj*;rAYE!iy7U@~^xh#+
zI+9QWgmChF@Atj$H_jRNjC=1N_uTP}cQaPlBYW+=*36o7uKAnmxAx8a%{qYcnTom!
z01poUz{C9kZk7N~0E7g$32ze+65b{R0ttyoC`m|&iAiYhP>@kF&@wVI(9+X0v)u(T
zvv9N0({tSC;O6BMxF^5_5_%xS|KKja0RLYP!2<$;Bt#@MBqTKa%=FCs|BsKGP5>3r
z?J@!&J{}j~78M>o72ZuR0EFx3ZM^?D0RMU5-NMK97f3`*LW-+UPYJk%hmU`Y0RQ%F
z0s>s^VBB>80o844PSHn%G`g>VTpqMyVafSK+>dLz==3I#JmRlC!-+}g85o(E@AC5T
z3*3{Cl#+fZBm3m3vWlvj`ZN6(28Kq)CZ^Ulws!Uoj!s_QK5u>f`~xE1MMg!(ypK&u
z{g{@X@hLN_ps=X8r1Wc9`S;qo`i91)<{#ZXy?y<#fx)54sp*;7x%q{~4LIW0@6E04
zon6$?@yY4gIr`%AFS+ml`2P?K_xTUO{wKMpaB|%uAiyU8{v{XQEni&0ry{t`DN0EF
zNEi6ZgN92ijEMGea(+!0F}JuLlJ2$V1PMKl#Kv9JU!whoWdHXB3;%y5**^vQmt2bg
zGJHJT;NepNz<|q=SpF}!{$2k48~j@b{;dQ5)`5TPz`u3i-#YMb9r!<|1D~c;O3AtK
z|0r9pt<@#!ald1`c9u^G7zb*e#Xz>|Am`(S2b4E}F^3z#VZM1AE#7o+3B|<$==y~J
z>N)6m5q!06#K$wb5gkKG|sKgG4<|G8E$WvsrprveerY^Oa{cX8kR>D%*v#&1-1wAfX$;qYA65?HfR(
zBePycclGn4!seYd#|Ug--why`L~#m;Igi6$(rsEHbJ4~(fS*ot*h}z+#tndYEegCX
z?u@GiLY91;hS*+r>pwpk`==z4pnpE)SR1W{5ydr?{g0-AuvJFTF>^g;46VC=4*n3W<6DDPN7YIu#aC
z5q!rvt(5T9M9d{~SdIlFCD|FfhA7Bn1%kXN$5S+5GJ7dvU
zmCy&+IvrWv=Z`1ZKkDFg0T0D>(z7wwkf<8#JaSElv|Hn>Qz4AANDxAg1v!1c`l4j<~Q#Rg)R0
z5^o3kk+`RU0?YQX>~mF;MH%}ku72)3ENcB;@iv|h_lyotNfl&4(mFq)7Qu%a97+&s
z=l%osKRxzmZwq}~3l9mFj_(Y4wgKI$to_PokByrW11rj0C(J>-dQyCkJ^(<~UfO%#
zG@dY|YYr}K2Pn3n7}Oi1Y#V+=7A;+s1br7h=?;fH8$hl$x>cLXH~guzc7cZaffM|H
zW&uBo*SA|X!++#O{qUdaFR7qZGc}l3HM~&qI@vMRsos6o71uRYtdXgpRSH`>;&KL`
z?ye2BKffKcUM&SZuh;{FAOmrgH-Nbc$i0i<=acXzi(1~_udLKF2EVFTZ;it3?W*l8
zjXzHQN?&spBMT?jUT<-%Clz4Usj5dR1Rv|8NG@k&E#Ao6$-;u&seC%?q05~|A_)=;1e|znG5h<99#&>X
z!=|>WJ@6dP*n^R2RphGRuIX{DNGxB?JKu!9f-q%&d!4kY1@+Cver!Q=>~>uT8X1Se
z+C_=2(CVuF-4G~X_Fv{vM{d8DhuZ8oO#0=3Pv%_^HW~YxgyI>$0zZBPI*tIk(b&{r
z!o0cgF1eh&D5q4hiA;OLJmK_DS;;oCA@8*hX;lAjJ0b=)^+9;s<0ISfp^cS7^WNQ(
zCt*$D45u7MBZ~=Z@!nOI@VD*P@J-MIt|t6ysSif+r~S^^RM7^!)nod?l?Y9!_-rM#
zsyfY3Am7m=J;BD%_A=Bds-t|^ntqA5CI!)|zf3frI1lAV+ZU}kv)`SrC)W%7l&{9_
zn6>cO_4Uv#%om@nu*8oy048(@e9E|KT5(M5x^PH}+PrKv+apD{#a&erb}BhG>^0%n
zCY-$IzG|)|WKd|J(Llnzl9KWk5TB^xKlaMvcO@4QwIs={uOJbx#QNj8?%`k?^k%=w
zFY%okaHbcFng<9Z&?(QZ1=OlPkr3ul+V!j&P^@2xE+~^O`?X2;TW@HQ95se{p9Q@R
zXgq?_AY0$AJ`MNa`!pPL`I0rU{vh|^4Io+E;06FS#2Rb_kToyGo_`Zr4k;bAKB-w|
z@y5bhM9$?K5J+f_gvdQ@+Qq_9s@H-vE+`IzXGwIsZC$uol8q2<0z`1^k`LzbwmH>AzWA=mXd@
zem4FRjxQ&L66_!GMxAHvbtbBX2oLkDH?)Wsa2A5nH9uJlGMX#Kj?&x9z5ND2je<@0
z4M5z#ohr(f#7)&X>rytK>KSldR_-7M&(7-X}HLCrL0g(c>~Vz
znP8vL6^HGr@XP^fQ?~9lYT(q`25`R%2L!`$hPKy@@DWMVeqd
zdqis9mL-DG_vtH^gia~Om$7RUbZiR`XP1<<*|xBh!B*HdvkRlqMY=0xu6;;$aWD;{
zH&+|q3ANaLG&=i}T0-w9j19o18B`#l&5(2&Kwq7wApq`fGnW#krO`8L{$*IO%hnj<
zYNoFt^iWN{xtKz9OqtEut#8uI-0$t|EE5;|)0#Ako<_Rg)PBDG!V6f~ocP|EBe?rh
zkRZGPBo$qiS5=p{Ve+EyvDOZgD#1bipA0N_EZc*D?}ZW`$d+cFJGcF9-h%9Yd~XeL
z<$dd`2&?ykbz=p&fsbcnPNXIwk!rahR4hVPvCd=4r>+!KOx346=Bh?9Ho~!=Z77Yn;SpzqKcUx*VfYQ9Hb6mhuc^G>GIJ#4F1E?;=
zMchzneakbTNTFryXQ)?SzxD1!If8|~PTs5N1~3J?zPbVIO+_cvrQQI*pz}kVleT;&
zzU{aRTm6AU9}ln)f7o@E1*C`lyS!s@fH`ChAA^HB@8+JtG?K0me^obRt65DrIMle}
zqu4tCSZlhf`psdLt7fKpadpOa^{dt9bFy0s*Si_<^3ElGx*wf%Ruz98<2A-S?Dtqs
z^sBbtXv9R@;yO!?nXPX%Z;-}vFxy_1XVi!hT93QFR6T~$uso)9I_j}r=rT$(wp3AW
z>|Y~Z7AsFs7IXLQBm|>&m+R4~8)nS}&Sfy#qII4Kg1HQI+{WhyslBhJ8yh-$hL$&g
zl~)I|tynjT9-*|KmGjL>U*uC%A*BI*k0iaO6X@;+=IIT>6GY`FSo`gtuF~HZg`E3u
zHftosTQW69+N3Kyb-nx6AKxAJb)_$8kAJ%I{_Bqt-^yGDp18nVS8m&>+xrLKPxPD|
z@)fs-hLn_kc&}G;(utnhc>8#mz9_gg@&*8}C_zdm)IpiemPZOnL(_j7^iJ%{$iTd|
zoe`OJ*>V1b&50^mb3aM%mEI0`3oQx3ccc&SNv!ZGZTTd^%(n>p^MM89#9Wj8Y*u!~
zt^bEsLYaoOpB;^A{QmPqCGc$R4Pe8@0rc0fby#DZBhcA*ZU78x(shu_TXQt`C5P`#
z#(fviZ$324RhXzQ5~Nt14>iW_y8{ub7`{vCYX!(!2e!@6CDq(a3TY@G^1cwdNz>WwvoX
zXyv`q;A!E~PlYVS=t)U_NoR1~;^EOb-9^+LWuMFcGGa>$WVMxCw-dIu;{>WZ2|;v=
zz%H_OBV48JgiPiem1zx#{!Y{5U$XpPO_A~)@Hd2kEF5?bKjbQ=b;3#;&zajA^j&XV
zm*pFb5k851%^Tm+(3VxRHvn}5CVD&a&m(K*Ed%D7f6X%UG9u_vWAWxd>?sZ*5XQ6@v~e8Bg+`9MUSqRFY^u^&q-D@
z_kLKE@u)dbM6}>#5zS|#(q3=$hC|;pw+xmOE5Y<;q_*$ww5mI74TLZ_y*T6Bb;nwN
zMADMp{*_-~`ysyv^gL0}%c5?oZ(n*Y`4*~ryr+z<Hz#KXys}`7Ssz>>)-#((IEE
zhjU!%0;_Y@k*E)w`bzHXEfR_SOuCts;=~9pT4L7$@a&Py80*}qqDaCUpSLc;wnsJ>
zA5l?kIKb)!-)tY0Ua)OTH(}YLst78lc!rvQY-VzQq=d<_G+nY!?^HZJ7wAcsmi{CE
zgud&Q4~-a2{d)w{N!5A##EG-F!&dAOu=^u8kidt)==0Lf)NV#NKhq<3HS6WXne1}o
zWmB!8wJsPP^;@xzu4AV4HMOmiheeaZ)>J{?2|khKpDXOmwUbyX`xLH5hP`Tz|MHP<
zMP7>N+8I%y`53;EY-mpvbrtqaa1XM8Jt7^sb*bUV>6WE16mRlz9l3C;)>H36mcv$v
zfK#%WU-u>7e%TlXPt)_8=2^STREUCLIXbB@!3r+)^RBx%lOp2voXG~1)IYh-@N3O|
z=N^%%GN?4wFtI7(UA2GoB0W2gIdc~AK{0G>L}=^XDQB7Gldo*3!&jU3rSgzx6&p7I
zk{lzw#&DBGiT5#{rA)VK-^R1fZ}3T_NU*(xkqIhViHvMiOrL`8ppBl@C(6}Rk2St~
zl#bDKmu)q5X-(xx{|$cs#QpKyeNoZW@m=qw`BS%dc}M(w*S@KjM&&zT)CL0rGvW{G
za_K-uCbTqMmuohk3_#5%JN{wEX999vtm1~L&;K&yl)qd0M;E?4CDvac>_nRV^){&Mef2huNK94}*4x@YMvXnN4uodj-VS{jQbCpbUf8jP$~P&7?VV0
zU~;Nj@*j3yeVXr-o<*#$(;XMiwo(Y0-jD8%c&hIu?`WpHZ0=VF7E}AuBy)F$TMU8M
zB~SA9t7O&l_dckEJ=Iqpm60LG(j6P5RE;bO^}NyZ*y{^czmiaPz3($u_u_#>^6g~p
zwa$>j20`A+SG>vy3#A2&X3HBS!K?OM)7YwYvjo$*UTH;(tAR>2j+`OjQ;xb5R!D3zymFF%RF9gjKYZX&Sabw<E1sZ5(=E%^lze>M2?efFr-*6@@@)K=C74|C3xm{N?H>X
zsr^<(noBxE8g*{~3BlZOA?GW4BAX-mL(vAr+vSj`8t{fR5Ni%O61f4G)|;e!X(r=~
zFvyckqOV6AA;Pc?-E?Eu@~~_2XKN%u#sS7kR@W8pZUD?@5VR%(HYn-(8W)i3LAo8H
zlW-CIFDq=lQ>|sc)q;n-K9){3feDQ0U#7VHlZT7i1a1IyHvqPrjtyCgJK5
zE9ko98BMW0U%5is3tDVU07MKeMxMK^?7*}Gb}K8heiN_N;U{KR+Q*z7e%>zDn6F@W
zEBxQ-vy)tuv0WHqtHYz9t-o|>G^JVm<_3_y
zhCZYl^h@nqBn%UL;`^1b*|J6@(``#_JE<{b5Za$%P%)p5{2j^wjtde+Ao{_y4T7hq(&n1-$`_IPIL|T>$(3!q=vE
zhYQ5ohP`!_ACT}Dg#MihzRBepCP3s0alt$;nO6;gW#O`U;N&k9qUbc-8j6(F>GTcY
z-o|UEKZ?9J09ZoOwGa4`++EGX(oPT^zf*`z+5eILM--4`rvhKb8Wi*UnIN3~v_S%Y
zoT-D=t3H)1gEn4G8%mg-?o9ViQSm62)Rzu3?!N!LXm3WiZepuhwWH0UuROV$-ej=U
zwE1yPezxTO3L8Zz45P(^!@+zvfX8&ZmwcD+!D~0$B
zOV2Uwyg2EIYiy@L*_D0ey8ga;EYoNmFXhRSh;y3e2G!rf58%z=nqU2m69ark-k_7c
z&6XY_9>6zkdLbe*=ati^-zEF6n9H@~avRsj7GJ{*XI|IOc~8Ag`A~8Hjme9xc7LeX
zbcs5mA&dLdg1Y|h!@i)>4T^;p{TaQm}UJb<<#7>sr&bJhbyV}
z$pR5{-xXb2Sh4xcRVP!a5sX5o7fzAaSE|;tNmS}wD_Uikmw4#d5!*Q3hNohTvyX-Y
zbXF2$oqp9?jUv_Qz8*8t@LUGqT=nFnjhIIAv>ys>v0Xh)OiL#XUUOEyIZB@pCtJUrNISNA-$SP;PN>zk-Iga75%dJkFI4Xx{!8oU
z9Rq9g#Auo7aF)72Qhg=@X~`m+%+H0^MqTB33wOT7-y6jQcv)aGa5_g7OmQr^g(Yo{
z-BVBTBc%Jo`i(6ifbc&qg@Auf*$WEA^3&=83S#;HR^`8$Ulq&c_Kg;;NV%iwO0Ltyu?{VkFy~HgT~FIJ5-y-3E=o|o0qFBxovuX3#lQKvE7y|ii)wrf`ga1XecYMr@WF2KK%9(ZX}Gqg~B$#})I6p>Wr-w`hv_UfKV
z_fnDZdE=GLC~(Iz8SdfRML$fA=JZ%>1@{d=8mn1STb!wO-NDcH3W{CS$^>(3
zk->3j8RJuFYRI!w7s3CGf(6jH@#XS_%=u!|b}xc
z%1VV0I_=fZ8=3rrN{QVC!ALf4<^uff>33;_507(sR3-HY&*smg%{FI)sou)h*`ARy
zgtb=6@$s%s-QJ&bhU54BUKn;8Qi?hlpTf&_sntuKze+QK+4ThqH?GgN>db!cp)G6?
zK^w}0C`2A{ZJy2Q1k=?TFdGW|JYKowaA@t@WM|0AaU!{s%R(c;cR)jL%h)d$F#BSS
zyk6X&=fj#YjD>dF
z{r=H?hoY399>(csWv)vR;<_I8Y|9gpgURb!Pn9r5m&vPj36q>HpRfYck{~LjaYu*Q
z%MgEm;E19e-hAMkG(wKZCzwQesjZN;Q2354rSoKw!=gBS=T}+0L_x0eXW=MD&GW<%
zTgHdg0d>oZ4u87m5~S^dYKr{`H8uLvfB3W)v!x{r-d*_d^$%c7@&wwg9YyI!Z5Edg
zIopeSH*d~6L;AswNihwkfvj3GXPDS!id3QJHK!Upxtz=Q6JFCFZ=H7&xxG>bYSGjB
z4c1?(^48yMY*pbcvK8#HZm$gXx_)EzM4#M5^ry?k4BNA9^R&g5x0f@YqoXj7S^}-8
zcg>_-S7^SRlXPsD$x8kLmGZ*D9
z+tZPN;J%N(loxvIsZ(??D~w_+T!zy8UOEq^r`U63L-c0G;{7q56(*1Lqe&6;j
zJC*=Xr?izRiy^y@K_g=wlWjmvw9c#Pr;Qmk>dx1_1T^S5YmfCRs(ognDmtEfFEo||
z=d67n`p@)doSzj=Dj`=WHc?6|665def+`shg&BQe
zEV36e-oULAas1t;QJg%t1lALMda8qW<97H%_TL4oIBUfM2C)M-fD#<$!m)Ug_QP<@
zu@2N4w#`{ri3vP^12|c_0kGl_%D8v@X&x>x#GTI8)8eIGC*$x4ZvMqp#aDx=|KseR
zH?95SriuTmHTEA5H2z0hH-P_aEa?(QRKrDwr+cRV)eqdYEnG(;itH;dZUCW>|GWeP
z9na%n_jLdmhj(`xu`)P#ROQ5v&vymFW~Drv{!Ym2-?IE~khI`u)D0kRU!G!P(5K3jrL4+2nOpEp`B?fxR#E&@C@!58m4$S>
z#+QF`0|?>7;jTKoKi)?1xW(8yqj&W#mN2cxsrs4RKi-WAMNyRbIpN_R0uKjID)QJu
zIQP~l{z$OIkt?pcFvHy&^&3pkGNO2$dc&XD1K0^=0H;3$!_b*1!l@oBvSJi(p1kLv
zFG<3~&s01qWce_6LgOT5N$d5^wrX@<=UZ9W`!5YqyT8(7lp=lmCd|XNh<~B_9|VpA
zU6lPnaf*t5R^3?cjtL)9awm-n8Tn>rqSyRdcf<
zb$V@%9>&7#5uzq0%AFwQC&TSM6#ZuR}M)BimotIu~V|6V>k+@lT51|+t9%4jkEBw>9ho2CsR8A3f*tTX3R=an%+6gMCuAs!Yjgx|
zI+@c&?jiW1F~QLG))-r8!r)pV=RhDK5XeJ)?LT4s=w3NyfFYuUI|mi;T$PJC6w7;Z3DxU6{i96^WA&bcZpnL9#5}ld26u
zqW52NfP{>Axt4$&jli<=FQj=?id=sB7O2KZNywcY273Ce7dt{Y{&*~3>YLa#eie?-
z0=vpSASw(50g4H**v!jEvmYIIHwLoL!Zn3AN-x}ffeAzQ?%eV5?tydS@HI-bct4o3
zmARb?x$Btj>`!att`PsA@~T9uf{8OqOy(5okFx4pBMy?98H*~J^kGb>a~jGDu6s;p
z$P%jC<}o)Js%SNHN=g+GTd>E$OCT(LM
zBAz@D9#B~~$Lr3Gcis_+vZ9r+iut}vwVnPWp8WOK$>3@U^TU;Rm$%rRlP?rJ*F|5Z
z_ObkEdbmT?%g9X=;UXIYBV93@=0Bpj+D-%LLa&WreS~(;FXYsYw4tVt3tRUB(W02<
zP(md`MAx>n^I1+!v_3j@orUz9X8m+H)8o(7Rbm2L+s=8Y^Kc1me9RkkpyS52T`bd~
z%iRy^0zr+7FMQ8v@fr*L+>~b_PTJItmnE6bUa#C7cw$$k&Ygx?0YPm;NyLZJqVmf1$2>Y4SLH>vBJp+|>_pS__)ScV(Gpcpdg
zI$Q{R^2{j8uL7${p>o-C11QXD8{2?jQbmYv0P|e9J#M`RA?^{YwjP{LPo^>d(^+uYGHS{uR)P+ng%9RCV6F<9(Qn@nKwM2L(CWd2Og@R
z^%s(F0wsZu)Piu~uO+UfC#HuLVQ5?ypC`I^?Q|b}BKd_PvzL?_8%V_zfQJS-VBPiHyP&|mvn9++6p&J
zei@t}^J+F!F>2}{GAFYBYw|D3{+dIt^fv&pV077@V4v(kMpO_uxY8-<`{bm;L1JaV
zZT4RufX&bh7s<6)Fw?1AnY^68aB|UO^!3PKIDe9jy>9ao0nJ=ZCp=WKv7lXJx&&R1
z3=a%`qC0Ifa}xD|7=QF!hZ^$k5HD3B2KI1ovOPAV|Lv?51u*tBP9sP0ZavMQv+O~d
zF_OFMk8!B14;w#zc|p5M%}l#Mki)d=<~Y>q*(B$OYOW?{KW=%-$d7Kx-#q;k0?5KN
z%7;%Yzxql(KKii=vqK++UmuCS|UKeBg-aKPo+7m8}Z9
zJ+3r%r58V$6g2#3Y9UhMh)EoX-Z%<*fmgU8van}?k|3=>Z<*yz!haFVrxON--vQ5x
zgFc=j@*vMr`#NfKEAk)vXcIW=o3AWk_V6iqUFBe0pXtv!FXyID>hOMpYRBGV4B#SU
z&vS*R{uor54M*r?O0gsla6}9W#=GJJckcu>ZU8Yk46z-1Te>*a+H5a_rnOT-1}AW~
z)9wShW_Q5f%@TZv9)bF0^sL^=I_$bMX>S`{;KBEAcTtcGOhcS0%Z(axyUUiz}~
z6?CqKOesrW>t+1JK&!`5L7JtUjdeRe{_YRKO(FuLTTei#~R#;m!;$8nCs8wK##%iqVv519V=F8WeaijJOukJO8;iJBmV^_V#J)@ZP+r5$15lG;ovot9JLvG)$Y7w=;;m?|M6-ACu1*<1ie|zIJymrl~#dd1hB8VTehw)katU
z41(1oo&>N}4Yprnb)3|$7=tU^Su{>7PDieeRu%%i7>;VEWMsZ#gpfV3kb9G5u?C@fhWe#(yk1Mr#KKzgY~9-Hv;kxDCw^aJ@yj%z6R~fVa~Iy_mpq
z%)bjupE61CQiy9D`7;1{57F)SP)9*GCeZIU23$BgQYOzv7R}sY4+eUzsVPeFqPh91
z)i_v7a*({_q;QLdmW>fZXEs*u*7O*dpX)@_A=4Pgalg!{Y7Y8v+ukG6E*h7RV$EbY{tL*~+e-
z@lH)j`tfqbbGtVi4GBE}Yl2JEJVLbxL?INU3J<&iP;BFDg4cOTKAy#dju*CiG?snr
za(EG+=v-TaZb2-WnK=uwYy^#)J`R7FuOS!yweSmozZ3cpC6$Syus{i*Tsk5|B+Ss+
zBVxu2n#A{85AXfTncr0cTrvYL!Rv=TxXpF|?eg|~IB=#9-e1lvWhLVH=G1PlWvSQ>
zqlK*vI0r=Z8gF!UgN41m@*2@5jXQ1h|SI$Hawk+i?(k0$s^Hc1&6h*Dw)A|bF`
zT4ZhwoF$KX`*>|>O-!;})tR=0gK2Q!Ex^5}B*5Sh81!u1TRVPn?tN9=(nj%`(zxlO
z*-}4`p_>Tci~AQ_?Z*MJV`ypI3O~M|WsjaVvtQtq-KM|67i;U=oe6+nb}9ta+HTKw
z+sUBO@@A+#;~(EATvX!j7NssOn49)BP?IT9M%*i6JvseCrVZM&q71V3Ds@uiMhD-!
z0oWdT@R-`}bCeQ9zr0u!{TxoTm8Yv?UKh1g`U21*Ebo9o0*n`{8OO^>jce?E
zat#CO@+o2u;k8&WMk%|~l0tRrS%S5W*X^=5p!;|kslbS^P`p1q@3Tg8y!x!(|t~PNy|5!2P
z(?Px^Ni=>vCe4l5adz)#g+H^D0@Qx4h{@jZ<3LT8VbstDRTy5iR)Y-%C%Bm!o1wZA
z)d(RRu#(6|+zHnGpd2iKY%%#h7XorH%JPmyk*XEg0(qqkNQQ#D2%Fayxy@=OL+JJ>
zSJiTZF%Y}PnB*U-JT_t2fniYTWT<5O9rPHMrsLMa?&Ld-8Q#?cnlEV{DGGSE&Da{P
zS6iHrkon6+2Mk{M7zYg;>;G~7K2Q!th-rF#WYrXr$$#mpAzEzIB^1O(VL*t<(zoMPpVzs+$D1a5u
zN;PEMl4?%g@zutPpmJu)sM&R6x#Dj1RZC;Enl
z4?`|4tH}5!b@Kq9PFlRSe2fv=Lb3K0c~U4>_^w9GB$JV5y*^7io*Bolm+Qa0EGw#3
zF%R0OH&ZQ|kQV)4qKDF;mNUO^`z`i0BN)4OEQL^Gy&>!69Nb;+RWhKMY=PsYv
ziiYWzu_`Us)@N^#>Cg%*CkEN5AGH=^cYDgUDRoBsVwhq++gy$V)6qCkg=N@!52*)l
z;8Mfy+-z{hb)}km_WV$@MLH-)JU$TK9)rxmrE-N17pJvnzV~m`}Ig*ewz@klLS`)Kz)&_ojAWG*L!Aq
zmKj*7;qTn>+FX-{b2UDiLxn;`0oR*c?930ySz{uGs+2e=ufcBV=TeK^g|4qP_rp<-
zEQF6$G#FW}D1s(|T^U_z9GE5)VeJ<%IUPGBq6s6pX_G-VQz4vaDEy75qas_DnuJ`~
zb)FAf^V1Kvs+lsEoGDcvb|Q*B*kz{_xmNv-d!%r5nDS3FKHg&r8Z>3-1U
z^*PZ2-^~JbFc*CTaG;=b%4PV*Ty;9`qPWUcxz>71lJ6zc8ws12){6D18#EriNGo_l
zq37|pKNZfvp
z=dIi~fR}OF*VJjpkVwTle%bvYG!xaYQCv%Y0r1-&Oh+WX`@0g8H;A-MQ_CDKwpUDe
zZ+j;&_h{CSE$km48k4{)$|T@NN(}tffv{DD>Un)#Y9CGM~!=j`QAGk$87R8%}A^js#bj3lCPWyahCm
z_L}EZd?5anqB>5&di@P9A)aq
zvUF4fUD|M3EZrRD)>PHW;k#UR4Fmkq&}B7ML*Lix;Nk7t;)tzxrKqHyz$H0G%a7_4ecdGF2lc)F2XZ>V
zNj;Sm;RPxS&pV7ZHmnh7N^(EngJ6FN5c$3k-0nrwbh1s?)lkhqC#Y8db8oHJEol{m
zyl(aIb5iped6eTzG#{fYK5Eqr1CX5#o=CUfcVqxlV@XM~U8FYWbcwPO+CH!Qhkay)
zvJHt_cW`sC0|@6j6%@(kHu$2cS8iS47?2Jh!p?K%Yxs^~<0KR4E^ERFe&
zF$a+i(iQw3>9Vh@yHpoqBNU>_Ojm2tkJSb`cX9)5ZduLm?wZKog_<@TlS!BFgLJnw->J30O>lD0j-nV)W1&MZb#ppUxHkyb!2QUmf
zC+XHQat63Eo^)|!q4I9+ExJX49j$0qo#)i8oz62*?`;YAg#-js=P6}ziJu}|9CNOM
zB#dvoTuvMj5Oi~)Bvk+8Lqg0-7N6QV@JC6J4!0f-Uok0GWJ10ZVRR`sv2EtqN?4LQ
zQgs@>t}yTD1>ad&ea}1ot0tlFXpBsLR?G!Auw~jK&P(1mfLO?#leIgK?nX71EEy|P
zQZ4ot+b987sPJ0Ak$|9{U^sbTuN8BUT)AKN^Q7m~*d@CX+OUlcTZ^jtK{uE}#WrX$
zuP1K_Og@@V8(8g~nU3J_`rSByI5osg1askj5q*XIYitt1c8oSrqneK=O;$Z)Z?b)oca1gI}Sy_tc
z^e{UbtGm4b#NMhurw`k3b^&i(mqzg!1p0iV_qvmRlx>dXslhnl4`ZZJ_ygCUe$T9$
zTbY#>6%{gRLWB8j
zm;qAUm0wclkolLm2BSj*qKPL_;f2wvBJp@3a_n7Q4)gujqYGTGWyKMjZhhY&%z&jL
zpF5Fea%x>L+BzNJ2S0{&!?2AGYKuLF?^31GxsxWUVwi?qtw(QC+hFHOb4;79O9-jx
zuNYx;raj`2ymw3c$EUVl=MQn5SEvTr`=KcccY<#Ri&yS3yS}%ZGwr*sN@&9MGlTja
z=uvLzVtX*U5jFRC#=8P~$In?)cF8h{v$!eYovJ)}T#vYYsg}En1Aaa_6$MkPN4vQ}
zHd?}jt146`mUJ`{NzrE+jUP|DhekF>Lcfs#T^@}iTkRnWYazI~*$B1dtS6k>
zB2P>5T^s2cbS#v51bM`O`X}76UFb~sZDeC;{m9FP1)-|!q4Tu%7ohVgA%J@b09_mm
zAB!_RjQ%nuq?KX68QG@yG@JWngu)Zs_uQrA2j+j8E1lk7894^L2}pqNj4-Fy(=>jB
zOe|mjM0^Dni|j|{U#E62j>WiUI9rbW>i+}z{n`3%?Y7A$(77{c<9YzKb^tE-iyf%@
zVV9L?{V}+^K!nrX1M{?&VyqtEg6^@L<+v_w1s6XmU+XI#W>wyw+?Zv!Ycs0D${ta#
z4p*?&k2@7BA^xP4$~MpuDUE{ceYCnHNVDz(9r;?~
zqA)0qI6NVS!x^%{ktI3!PhPo@*F8VrRQtejzc>9M+aS+eQpoh&B5(G(z4HiIyLnCo
zGV$hj?g~#j&w%hyudTaWMAs`VZx%t;yODw=Q)0dG^@4ASRLT9S_qBm#
zl0!j(NcLJd1%vaCnd{!do<++T*Q$YS9+Dft5BA?5YeA2J#Q+Zwxgtl9Eb{POmBbBz
z(of!Lv7okfPH%o`uqmAfV7>)-X;c*?j7~!y8=zRfIVdtUj0@|{su#C8n+5u)W!#RC
z{Tw!a>0tt@U5m$n3NYfe-{Q_&Y&p9nOJI~G0PPP{7H-s?p-uiN;9j|XewydtX(|p>(ln!BMufUnL!`dyIUX3zmqt3-B;67r5P43Y&^yReAX8Piy`Um;(
z56{ZD!9RBT+#z@&l-nT6AW6vAJ?VhYG-fN=K4$9N27)zae4f%W)~lh4`N+ugD8BE3
zVBpI3ZOLAeg*vG~xVl~KS&ZKs7bp)8qvHbz^U^bz$*K*e5{NqO9t8)wO*A!@W
z@|Qsm(AI@PGT2FMPxCb26(nu*x@w~4_hJ9a*N!!r(Lf^~C6M3^pgRSZIAMEihct@8
zYj`^#Y9T?^Q#zXGjo1w^O7lxqK?zqOPLXGY$D7(
z4^X_N2!q_MvZ}96uT-@0zS9+o)Wb
zmy(roAbh_buk&D1I4n{)=n=9-7r7M;Z;W-MCOUvy|L%`X
z>7U=}Bq^m^o={>f?QWw!?txI8xPbJJuuG0q>Ol?5&CA+ARCRSMKR!c0JIhq7*_W}x
zJ<-9tANx#ZSo4Rzy&5>bRgR|VGL;THS&C!3CGfg2*G-`}g+Z<%Z2Ah%;w~*fSD6-I
zX#d_@LxgJEH%sMf;+c{9cLSAsY6J3AQ^k(BmJh>PSqjnI>e-s(TF9&`!jyBv;_k#F!ytd!C7+l!wom{4N3$5jYdBt^FE`+hJPgZNSr%DDZae#70Q
zb8BmjtqJ-ZA0NM%RtNYQJ-0p<(P|cW_uwEtJ!3M(rl~e{*q*PMfR1mC>GZ2SC-O;G
z2xEKH8H>Q-pAF8!6zn73exO`A3TB;`yWK_@2oqU9o9XQ)cAUw>?S!*
zmE@N8#2S0s5~d7rH_hm_B3j#945_{heB|?zi+;zAGx1@sJ^!scvU351xc6KsvCdh(#<&5xTAQx-VOWBYL)=i$Eb1!Z
z0m6w0*k?A`rO=@DLs}+YGwxz5(ZSpvBH~klgwo}8P{7G0KmKMF@GJxcj0&0NhXpn~i`8GIhr
z^%J@^T_tvDI5euA6HHi_MC@m#7d7kbH!C+^TQA(mI(UrWaJ}!5s0y5H0Y&Blrm!Hi
zhxe7#@t@y4D=Tzenb@38?O+Gt?5P=QJOu3pVR^)pz#$({f%p|)@
zZxULPD3#qa%yt-g9HmytXGV73$wi1uIA5xa$r0qYyH6o_!oBwq+&dD2&o~j#YsFWN
z-?@iq`z|tNp_Gz3vhkS1f|SXalICaZ2fj=pR%h{{UuE>E4HBkzH2YoQ5!}vCZ5~JP
zg!o9`J>?p{ehGso=i0jl%d{30U%qo{;Rf{zy!SW69z*3sroz`}<*t6DP~QLKxn(mg
zyLOAT^{GzxZ;3TnWiNblbGpJ1e=UK<*rSUo;DK(+naHTLG>Xae-w*@cHBr&^og)!H
zr7QR8%n>73KB|Y@x)%u9fjrpx|D5If-zg>J|D4PJUa2qt=YIa5`}x1_89;g*-9oyI
zxYX+T&h?(2*8j9Tjx40wXWO_Bk9aUw|M>f70SJQ*RbmI_qTG`;(+I(|1Yq-ANn$-P
zy#)!#>xU)1zDvFG{(t&4*~r+3P41q>n1NW`4=vAUO}}^;KMTBquV#z|<79ww1sK!k
z&JiruJw7Knin?-b>aw^v%eLBBDa!lld)
z+Bk0DK1umlZ$&IV(uN0c5T4ek(05Y_m9HWSp}BZeK_CI)K~ii&d_
z%v4`LEfBXUr$;0x$9A-u)*IiY;Geb5@q$6fN>0;Ig6B9J=95XoCPnHroPUMgb((?W
zodVUPXlv>s1my7SKii4`57`1OHwaHOueCgPbHIAg=*CyN*7yZs`7TyBNw+%?oP1}S
zK@PbW#w9@(ge%6z6*-PnjD9sgF1q@(S=9Nwt)|hLmW{4FDsJZVH}jNJ|1u-yJEcVA
zI;QxPT8PCvU{e}0>dBskSbr`
z?AL2wIqsEAb;RR5L>)|Cq)WX}-f^b5AgU|UE+tunpo##beDR
zKcOar7)fH~+b^y`wv
zQCfX$%J0AQ-)>aweVs$r01j=Es1Jbz+G
zk8cBHSNN}$YLOq7Jpzlj588z>=|A1qvt}clxMnE8b~?*PoDtpN{P0vuw55$DPw-V(_YIE(RhKFb
z$X==lcJAmQVz54OZu4jxgn#g$b5v=G2lWC7P98gK_tQ;*q{1Qrjn|S_?wd+=+&|CF
zt0f@B!OyeKVtj#M-zL-$KsmI#Q^9_JB1HS6&}qw`!W(;3LGI&$iiaXVQYSj~a)53{
zfo9D9f-70cY7pA?R)9#y7dxtFuujW3mi)7L>*Z0T%j)~LT*Ya)=>1Ga`;Ya1+~xU%
z+~X5SCEzZ%1Mc$jbztSpvd3R;Z-3>ej&_Y3`&jOCJZ|umG!r8-2r+po!I3+H-VdzIDKbtJp4hz;V!}q6wv-3
zPpxMBKKj?RyruJL;Dtd5Wj81H+#AZcLdeBi0@2wn&7agRzu)uy4OxuN*zZISy3!JT
z^zjv8&e=;9ovx0jUo-^{a6>FRxQp8_SAYrK#6KN%fD=02ayU}{4cTrpIGP__@n@kp
zc>K>nvetmE*UJIjOoZiuRqU^@G2pUKmqg86jw=N#2W21<*Xsx5f~8?HpZz;Bq7%f|
zrCPTw$1HVa+>1QEkCs0Epe&Muh?@(*sAfIo+`LngyfHGTc2<*+hop?F_MVNMO%J1j
z1o#5Ga?`cnkfjF{hjPGOPYsU0E|lZMhC07{
zAntrQRw>tX_F3*(&Aaps`y5pM>iK*ndr2rCLWV|FWm&4WC~2%nkZ!9bROl|~Z1N2x
z!8ZYUvOT&N++R+|7)2J4^EPa%loRuyu*GV>+o(>*1*vAnW{!-mau+
z&Cf3;FIZNYBxT043r)R&D3UenB(Ux4d|19S#O(+D()1#_vdL$C&+O+3#tFn0$W{l|
zLCy0=6xN(`2KLa10FRF?lb2$drtK>%#2f;5h`QXNlI8A
zi969&0&gxC8|z1h_xMnQ(r0k{(cxksWATDt);2IF>`DYuX0y8lh_PRz&l$F&D^9^B|FcP1h`D4^#8i5Z*G41V4*BoepG(4m4>e0Akh;0KOWs08w70du)6uF4@!+MFL?%%7(8$r>kaM2LHgGoCipp_#
z>$i0unt2_$^`_z{G+RBs8&@cQ!ZU~wk3GI1p#<5C$IPi)n|jqj=Nl>gYHxp$L~sZ)
zz%vVG&N=ljsd5dMM|^aAGPr)J2GKWL6+_3Q(gVJb7vWr0mlHVF?IRt2W@WW$h)@R6
zsL-Hup!Npzt5PI3V*IaVFcr0o_vrcYbXBf1Tu9Qk*gbf2W~WE%u6qO&oP(5fD7`>)
z9&=SF2tBhO;koL@vjUs9|m&4z5KU|ti`
zGXpdd!WcGR_AOE~R?jznD9P4_KS(i1WDWMSQn$VYZ&d2*0~eJYt#opp8a;70@?qp*
zAforPYG~~^Y&^H^ljikhE1n0PbdN#oi>3LlSkmk8A
z4F#NXBFonf^uZhx{S(X)Y2ov)-!zph3QBCge)UX~(dr82+?*?vL`Wnb$m|QP#bwVd
z!)RZx7$us$s~Ho2bhc=Kz^aChtx~!pHS{Q!Mh1()4_nEv=OA1GYYt|7-0)6r1852B
zB5*Yn!{v-391)2!Wy{Q$IEJo@ew#mW-c6@HZ&i)=g_Y@%{&Gq+y0OrSUUppXZI(l>
zO|)%=VB`m~6gW
z6_HilIkFUS8WuKvqHa{o`RvNF>EloZS{j~MN?8SdFMcM2+p%R72mSvnnCM((t+^P7
zfcEFCm3y}R)n(t{%X73Tv8NL3&a$q?6czYWbT&|fHe8YnEC!S{_BjlR8g%2v84qQg
zRyLWI$1@~hImAK=Qk<~bujfUwN@P_c?rRliGIj$~3HVXY0M~E_6H|MssP*U~;{J%f
z@~uc{*QM#@}e5VU+<6jxK1KIbepa35Lj
zZRFe=L<_kv%Vfa)=LA;&bpm}eft(NbqVU1Z-kjwH6)EveYk1vjOPpHnRo?QC8|OXc
zWwz}}GRA*CJ?lR|eHpPC_h1~>$ivrm=$*tq;bm$xF5){`2IS=K*5aV9qfc$ud;yXh
z-@b(eq9*-A9P02S@t?PM7Y*7k&Pqg)II>qeU7Q1^Ag`Ouy_*wgdfi|7W-d-n
zGyaZkjtxErLPb@3|MoCT{{un6cU3{H{rXQF^e#lWUe6tB(Q
znj%f#)7qIQaQ8|o61%?DR)pTRf12r=w^SbQG0C0-gc{Do57c
ze2G~TU?hk95BT#BSwp?J*$y6IxX}zdQb(}baxum0nac&Q@RCk0&qtc-JX*z6ytkf`
zyHvSH%yF`DNYAda(qf(Ys#NXoqE3CO-Y=QxQZQwVJn6S+(NQLQIJvzV~pC~&1XG^F46
zsZH{uYm(2?1LOB){{Cpd1dtlXIm!>S5i@23JI+uYJrS;NVutI?%CT*-!X~d)6a?hk
zIf_bWsoTZ;+S8>HH3%n@(?6__JLHsU#yMJj2$r%};C<6x2SH8{1ez6x!{U(Y^dm{n
z6a1?kPhAu}U7CEKrh`9-{yBQhBXgw~sfih~8{BwO9A<#X2Iu+N$5Xg=R|xL8lOG^+
z{7KpPF}BXEU3+lnO+lfH70n1%D856ZQZS4%fp&SiCSweTbE(9Ee7k9fbpDdBH^j^q
zt`@Q;0#Dw3j#!T>k9J+iflnLx{HW(9`>JeZwf>q%&d^JA!jEevuhhAj)Tx+hDm!Rdt#PZu2WbY(I%X&00Y-2xxx)niMhy!-7?B=@|h
zG{xo)(1io6ou*kw%iiBvG#HYO`suQsef@j-y{Ff91o-1_&{BU!Xa0sfS*JECBZy`&B1RC3T^%-4HRO;fuaIT^%1S2XyGZMB@bW
zPSneSY%(WT_D+8#$3yQZ`VY^Z#G2UnJY3@l4m9XHy_+$%jRP)#esm-H=U7hLdq}m8
z80Ezh&+&&*9`U0phj*#lQ>M8`*3kClH3P0$RWHUBm>R~6sHr^BAE99FMh_R6o8j%c
z{9gIq^jMUh_iNt(n-4bMV}6nkw+GY9U!aZ*5uNDEa53o3*vW5O1KKv;4|^f6Z~z*L
zj6>}iqs-7P*(1)o6H3}H1abl>FS0(fH`dSvEm(Z7XMWFZNL?m&VIck
z#^uk!c~#&mu0OMG4(+T%TYW0`q$bR!>I&SH^DBQikfw;#>2hclg*};_?z@Cj+10Ya
zc}}hzNly!NzFiNFX=JL9nvl<=+wCQ33~rRWLJ;rn3}Uy@;yYdM5CW6z$hdRD(Fu@F
zfdqG$phdvYw5;#EgQ%xGR#f*wkf!jr*>klvxcZZRxof`&=J$wib62kxj7_NfeX2E0
z0Fg4La=2fDue6dqdKEyi!YY)BS#elh^{knXKc6pRUL~yFNF8|y-Sfd0RRz!^wT%YF
zt^KVA^x&-V-dtf!-?OjC<2*k1-k!G)jw=umX-%!d16gi2#`c30y=o72ss^x{(+u9wRmb6?$03u2(oYf$zof~Rwd#}80Q
zTqKcYROmGGX!h}Ir1_&%%if7{y2|SE%87_eF*iAcbJNJ{_`gtOKoKaQ;=duqmcSA^7_8N`Bf)A)J$?CxuilCQ{}HL#NSyfpsdH{^};<}uL~kFNL)vFT>N
z*B@~1%SwfZnac25%C+pS@>I#sl-8fA9oyXFgZqN50EGrTJLrS$J}92kfUE$!jie(?
zwYNYi+Vf}3TH(Ijo6eFgI$E};eXb}6`Y=ekgNqt+i?x;Xgb+PVN%YTyk5qJec${j9
z5QK^^KFX|6e36E$gLo&+qsqk-Q3c2gm?*unJN|8%JQeUW=F#ccJ&;^T!OkUMBWR1==*+DpyJ_c99nra=bjp^)ukiLrPSndd=cMT
zzJg14au(0fDpQx>4TZZEifQZ?IC*Ln3W16I9#s{j`ZuEMtnF~wk#-TrPk+S3@mqA}*)sLYlM4KS{*v?%&xZ%?pha1CGYYkK
zL1G)-|DA**g22+FMKFOJ|rkP$uV9jAF?kCp;fYumAjJ3
zFnOgnuQQXe&-E|RWAqmQO8FafO8Pr=>iTkpa+m$z{x9p_enbEDipw+iOTHy`Rn%6b
z0u1&npWB}z-|N6VYsDv3HOCe8$wJTBhq55PlZ%xJSp!5FS?HLa3WKLIB152}vDJQUd~!aV$sClOL^6@4lPpW+Qj
z2kQBdSBSzb@a?y61C+Rug$AwUbfCu-?FijDXZ$4=^Bcl1rbVvH2GqAcB*4;sAmQ&(
ziACr+IZ_hz6aaR1y}+iO^DJqYbon=*$?HuYl=#Aa%Z80@Zny0J_b=RJ5aK
zY!2p?&4Wv-*Fk^5tr>EJ6NL1$XmQq+lr%$&`l-oxe7eeMBLIxrX^Brce)$+2_cP^=
z%U`O^fff$BPJuBF&RoJ$p(`Hy+U=Nfh(Cbzz$$@^AQu}w^%)#{n&!e!id!klw
z1x;a#v}l{y_jQLOGYzmf-b(~ji--XuB69k>gG7RXxXHQi4yFS3z5Wd$ZSpG&h{iw8
z_XA3-{W=HsSfo95A?2e+uDQwb{
zbfr;TiuzE5TuDbEPfc|RfRr5SR7~oiq5hS!M3E`!X8jPl>|K}nl>%P2WURpX>7JJq
zHB|WA+#D@j+_*-Yk(Jo+y7Zf!2|p1yNcNu(`|*frz4K{wg64CdqU}8oVn3FT7g4=^
zlfWYDax&kdRi@h4R<|UGrnAQ<;5<-5Qc2dc8Ry0jXyYJ%O2g^O``kID)vrumXp6X!RVe*cQKdeL*w%GUQaFvH*8%MmV}?>2Jh;D(=dR
z28?9|Q??mnHza;(5Ig8BI@TF^D(r{D=vT4$s-szdkr9;LX0+L23y-1k0O^*i!@z|4
z-sjvGw~;QV@1ps4djjhGAjo&0iF~b`UdOHPN5gh+rBh$8GPie+Uus~|s**>ej^jxp
zMPtw?mrc)_d=sG(wqltkU)%Zl*;9>6S}F=f26urE*MMMYnKrBgYB#GgjCGLgEad}T}Y+Lv}|#PA>O6#v0b|D@Nh^A^JTTkbuQuPbZS8_
zbgVeWafVl=1$|=q-i;nzt-U7`?8;}77kSG!W=geM#jLTnVrzIYCrc)m(X^(STsNa6
z-ZonO+&WxLJ9YZ%)+BJ9l&?Y8Q@i^fQjl4jP|E(hqguQ9M^d<|;>Cme+
zseF6l(zo~C(xFf})OX*8>Du_<9!1W>J0&FJd{9oIbI#VL*Zi}N6no$pA@3Qft{X=l
z!O=L5#!PzM#Q*^lixmtbNiWw#)3&x|P~3Sgnn|vv<$IP;tPbne8BEzsoRzT+`TI>#
z-OTcy{^f*Wu(mAqX_+#_s)l`e?>#*wA-#O>cHfD7;d-oOUHAkL2x6(-7$XJoDdAa3
zIA`jI4B2^n)`>yT(}!}is9>a;>IuZ*fWGj8NZ~^sQEZa8a*O7JS#IWD?p^d3KvMkh
z#>YjlM!u&s>W~L>rvkL8zTx8Y?}l^CrHYw*AoX-K(Fw7E1x6vrRuQo07gWVet%)=%HhqB
z0TzBKs#LkB_Icw4?n67=qaT6x_6CEGM1+w8%__+hon+R%%C)S_mFJ92AJ|H5#nMLQ
zN-SErv*72)?w%6(+%wp?xQ(_cf?_u#7x7xnh4wA7$%IJ@vx_viS93nfQRjtzviB(A
zxVz3a`Q#sFQUf9#ki18;r*Xi;09$qt~nrEprz@xKOFCau(^p1oP272W0-+<~_Yo4EP%at7~yN%b4^*Czj;Evvs
zm)SLP1dyAH%nV}!C6Nv=dkx?j#Ha_*Im%V@HTBXjj~CXMTQxdS6<_DLp(8K986;Bn
zF^~--PX=0erzOGl_~j+C*P$FN4a1F^NB@(|o6Fxx0qGVAanz`v7lHh6ODw?Icv+%}
z@!%3K!=ECJCP$h0GT(UqM4_@=S}5C6%#4avx$Nyuif%d02F;rj<(AV`M73A(#3L=JM=
zy$7z!{K?ONiU$pmw1BTn{fGH}g?tXxX$ZpOLO}-q&U53dmaryQa7F0i>-Z^D6H|f_<|R3~_u%RF(iIB?HPi^1or8{Lp^~MS)PwC2(ztu)UWcF$JW&psmIc
zD8ri4+Iag=xVimXQ{5NC>W$yi8D*erbF(m(ags%UIoCS1VM$5JwrwiB#y&*tx?~_7
zMPf4d=~Y!(q%Y{FBMUfHON31i^n7njX0B&V%6867z7)pe{$kk>d4{-(ojHfp94wPy
zs>_*rPee3o8(oe_;%GjRl~EJT2ipB=Jz2nhxN3YSNOnX&><}>vYx)7dk>}y+<1aQO
zWj0>hL8wVK{6@jiIPb+e5b#5llAw~?S|{}o)3?jTMMtyXn^RNipw)ZsW60zwTr&5W
z(ea_X)D3*>^}L90szQi#lUju@Q|exyCoMH(3iE%&aVJRSO=d>VM#pB3S|5ob_*(Y(
zSZ0T1c!G7%Q}pYiPoIkaD%Bom
zO?ub$ETroi`66xmBeg{i6(|ruD_d2?30n@Tf^hRH@l2oCo8dS6O0K$0UgKv{-{Z%t
zLk=S&iKAHPIz7&ukq4g4DP7_1&1U>;yq%-c)L`T1hbJvwg7y^zHWu$^|N3a_|g4FDro&^mg$RdTO(p&03cBOF9xJ#&v`xOW6Q2
zqYhFU{T$FSO-JBOBAniTFkEJStomJvI~l{9?me`pI_VeLQ7N=d~t8{Bs7`TQf$YHB!F8RL1Z
ziS0qrtxD=pzP1bM(YaSVWvWi)7i_hC#E+0`F_R0ISvN50-}F4vDm@bBs)`ofVmE3q
zY80SE>&}&<#$oL~ib+4lJuAFr<1SpAIDT)lHTYFj(*&1?Qw`1m5%X)^1fLdF<>B%e
zk(PW{rHP5CDwih)qA#kh-pB&(5?-3cB29>@<&Y{j@q5#upH8hrLG9OXnhpi?@w7b6
zSNP-HVHyGLR%b_dEDc)}V@tEAmiGplk_pFO1kNdiPP!FPv|aDHZsnC1FhEwV0(i3|
zy=`E4UW|p$&UkCIWJ5qGc6S|C%Gc_T!k!!{&n3P#?-u(gNU*pWBvtl4=X5ON?M1W@
z`aER+dk^7KLFF{hu$b6u>Fx0@mPcu}Z)v!X^&N)wpX?tg>D1L++i7pwS1v-zHmWXx=&J>{ff{UZlzQ)mLtAH(qH~Ve8)=#~{Uu9N2iT){N|Q6{?Di
zR%o*=NO66O>z?#ZS34L@^RUwKInqVmNL?@v+GwJA-zBQ&5Y2S1Dd^c9MUQU3#kE#~
zk~uLfcl9eVT5aD9-eiOoq^+}^*-GMe|FyfB;i9He{T1o|X>wJSiopB!(U<*{YUl1U
z;U6sQh_?^UK7XbqQwIoIC;eH*^UWeHpq1Mvi&g3ArN^(_hr}7@V1@zh>FgsUl}tns
z&SKETbGI&C;)3|ywlyaCCwI3_nRs!J_LcpKNesc&xs#-3yW!@Mtm%4mI^t}Xoj-gp
zQUm%dJeJs;HNE;9a;ElH&hp)Z(NWo)Cvp5P3e+4>-Geja0X}9Op(D+XGdo8t$bh6B
z^x7PO@*y#JwyUPj_k!GrkLJ{;aOblzA6^K{Q@OMQrx`&V&lmLq?4D~5X5vQA8Irz#
z)X^oPj}d;cS?P0m7sr}?^^ncC*sG)P13US?v$;vXxPq;Q|F+RN>taS3ThwPn80Z8D
zFPq|>Dss)%et!X0J9jzeC!=Ax0ncjRY7DUzo1bwKDK}DnL}Q)0GAzI>8g`mv-lUn&
z!u4-3iZKu~M@a}fKArj-LZ$;W02aO;Hx{QhL{h+~e;+j~OXyi~oUdZ~er+WtrY7tz
zV#yc%7BiO}AOH}6ZWlmGS$`P4nptR!M6EA=rPpNmV}!)ZI9I7#O&x|r!q2qmIM|&)
z%D(*XD91X=`vmqT_>m>&fL_<ZJH3flDET}mcjKoi{Aj=Xx
zJ`#hrXx2B1aPGpIW0nA9nLIyX=&jJ^o*3OnZTr^}A1w1>X&Hae4zY&Z1YnBdF&DbW
zAQrl1Oag`XF$d~B#BH%KAOXn_>hZnV%_Ax^P>*xa-K8TK9o7HFJi)+iVuQ}-5xdCE
za+pJv)t!$R8L}t<0ECp|P*>Cr;Nh?S^SGbu`F=xQF}wqLcl}_@ix~0zf5$|3PagjN
zoD2%STX|#|(*4yr)FD;nXOARJ@2X+H8sB@~i^N;F!gIz|#jbR#>_M^@>`Tjhaq%9_
zr_{7m+|y!r!AYOSwe#&)7A@uFP#N=3iSE#Mau3V
zFTynfiZ_M=?+Yvk2z3jUB#}67RNp(S0f`lGOD&`Rc(D6Zw1ldlLrQgM#OGyMPTP68
zQUiY{e2{?K+ynWO5hAft(ep>gqo9efgY@t4s;KMAXcdF4Fo>kO$`Y>=4C4u_f?!
z9f;HeJ^O1k^pMqUc#;HATSCp8#1RdEt{?7FFM++e)c6u(Aen9FDd!iVvnsZHlCcV5;8Ebf9{Co|&VQ+WznKKldh9oND3{HX>sXJH(
zWuDgfY%&ue&_0HX_-L!?*6=aSwyT2Hp8CJxx
zC*L`X#?K&cU?aiz>ba(loT{G?{#Y8-7Um9q-iHL^Idt?O78-G>65m$r>*|4$`5`qn
z)~W20px^~;MLjefEA&2?l9U(9_i{+ui4RAl#kBN`E6Y3`C7h?}e~=&_8pQe*d6afp
z_fGz$w^AJ7aL`a*`BH*aAtUKx^>7Efc$ZAh>%^9ob~oXbY~~=<_Y5=_>-_dK@X!cP
z11_iW`fnd+9FsK+;1A`#N{enu$A29!px~!cfa>CEA0KHDNyLl?mJ;_W2iFqjaFJtT
zPhMTj8&|czuOr{7nmtSL!YbqF8q$IQjUnCp;*fqNovSa|Zj0;1*_)l^{ih#8sQ9E5
zR<6$|Ly)^ziI94Q!QF`?WBjLbxBef^J>@0I4r%uM`CndXG7@JAR-;E3i0m!Ze9
zE@R5_K@hzkdM#o$V)bd%~)#+e;w
z)nBl3N|ECX$Ypr%D3Hin;Qh7ZqE)lU1v}O3U6nB1J?K~zwl_lAHE&hXIMOz9cYT)K
zZAuh=?n=k~msd_}@dpQLDf9pY1s@7vBd#=&u>%IQ@RC~byPd9&MT;wx!09zl8uy`!
zL6<4%*4(icY^Z$d;`Z&z)GO;)_IPRWH7@t2Yt-ij(yS}3txWUC{TOC(FRr(1yV|0K
z*{{mHJ00o5Q*)6k=PAK=5s%<|N;`#@c
z8uS7gI{-_m3k8$Zde6PKi!w;QDaeg1_@coGbmVgX>qoo@4J-U`SVR#?J!i@>AGx%wyOjHRM)5&r^Oiq;H|DG2Ta5{O9N5PA8h?P0*V?uuSJW4p0Tjq(B@GPJWPbC
z2Xvv%?rw%Fde>1$q>DtomzlHqradCLD)DwJ=`J-d?B43S8t<=LtclolizQx`k@BRM
zcgDS5k3P9L;d9_%?&9X2W-Xsd?<6uC6*n$~yMQ3vOA;WQO36}las8#8F8^Tb=yL20
z>bxdyhQ$+gr-5q+g7`PMXCOy2jZ?_|0ZK#ld?Xdl*zkC;
zAhn#zd2OgoNh2rl%|H8bEK?vOhk`;I4fW@HuoEC#M*|6bJs(~`@uy}0zC=GKVA{l<
z_3rugAs^=gTMk^YcGbazewmI@&f`mkM~2`mH_pc}R!ueVWBp0cZ4gRK0h^P9=9a&|
zzg(4r7>QydGTt0@nTw^1ns674Z_!a3<6rNU?^yDPGaLz2bRx|YaX^bhwa)A@ryPtf
zO$9tdg1Vf5!EPSWbdi=Q!&KlqiT|A@N-sau!3UFdw1P2f%<$ikkI8|GD?|evstLtD
zM$#UY5S3QZdiZr{^KxB~AD8hP3V{^iMhao|rykKbfr*VqJpejq00MCRuLwX3)P61i
zaQ{{Om+v@q{~bJF{yTX1x3|J_6tbj&M{sWQrIq5yu7jvyGiTkK_&~=^F
z_oR)FEFAYgjQEbsW)DVm$M64Zz_(yPZluaT1|0h{AZE@E!9@<^V?_Nm9EjnWX@i~~
zM?#0C3)vr(jCk>wjqBaiQy94wVd-63Bd!L>EL#HLGi0Fcu<-=21pG{$s
zy=O*@HYL}`*{MFuKYD=okGmnelUlYVb-zt4+av8_QzbkJ&|mOq$_{|}WU
zD$i3276#m8IpTx{F(u!D%s2FLu;3}j;|F>UIg&gdqKe~978lx1or3Jk$Tb+mq)QVR
zTe_ug0pC4umUVz&biR2jbM#C5GdZl!0xTZQd#LeQ{$(tLohMlk3AjQimf>5j!}ByV
z=a7)0WFW!p+!C8@NROG_|%^?NK0@CChm
z{wTd>&w}WMt&_rfnzf-n%s5v2zdlRLd&m}8qnPwity%PWVl0F^{uSfTvb!hlkGAri
zC95C~d)vp4lVkF=a(|jmcfna`9nYj`{*q(!6ikdx_ZxLQl0iNq_(UOn#UrqOOS@c-
zh3OAGT)Fk>gmRPfUwQO+^jUMyQ?4ii0u`#F?1;~Am$ecd{=myVT$HjdzN(Uwop|eF
z@#N|1AVw;`*f1()tC}ML1YOHGd-SfQy=DKVbl3GqBT;)DsX{)E0;nXD6KXWi{I&Pn
z8NteOoDs`RQ2i`DWu>CpEzT=nm{k^}&Y3r{|DNuwdt%~H+9P+XtRo4MDZx0V);Mpe
z{z`4^@!3xF#djUr?@xDx8`ESmA%AR1wL<(w{xToR7UL7!pWEen*i5qxZ`@`u>?tox
zFac7yqp2nhlw8XzGKI2ng%IDVkL*sFTMf8)cA1I=baGB#h}7YGR(yd`NsY&VL)o61
z*ZK>&AI4k+(r}~1>_q4sxy!tZ#~N18?%nMPi5p7YiyQO6q9bbE3*ejvkuFc_uc?WD
z{Kk}h2XpQIOa9YZfaqB5#6kFES5T*sUb#-}Y_47Bz7lZM^YchQ+B
z8$K#TRP*RKIT!6{Z1(hATXO%UNoSVWh7Cah2xtL%e%4JYIPTLIx`Xb*nAQf!n=Jdr
z%@?VV;s?s=_Lo0ot{B}-Iy*U`cbZL}>n#n(-auD19$Nuq+s~^cUbdYrsYb`XESxx>a5?-|5Am$
zt9mWKAAq-3af2^Kb`w;y$K^uHo$t%PMB6%weQ&!%`TEAsUJ#wFTdeGrGYL6cDt@*B`&ks3)KICCS@P_rj1L4N@n*njML
zxmW(UqQw5>-z&JTyIS?<-lSa!44;eq4B^Y*hRA0!8h?z$sY)U~Z8oCly$b#GBNbf5
z?#qPh>}x1ToPVN(3vi!dp&09jK>(alN8V9U25e6P8;0ak|MmENt?L)W{@l3J^QG{a
zMz_5_srTa!#RAv3Wr
zqvt8xx%;U7tSpao5cUrU#!WS=Vhod0R;{8w7g>%rKI=Tns<8$_Cj$cg8Z(zk7DDy?
zfZ|U887F2G{Bfez6orF@KDR0@@U=VcY87d#xfyc4XpMnFzLOGYHbi?eYep<;$$-H4
zcUo=(8fe8kgp|N%s1KCCK}Hc1v=T(;KOI=9F;+21Dshi~Fn&w!o0b_OQR5e!4Q?=t
ze~@W1YZr~G(x|#F&VtvTXi&DK6^yZt
zr%@;AZwJTDa@)-Ey@AeU23!U(sQVvRu8Vjbn~&inN%zaF>&oM5t997!@trmmMEo6~
zW-VwsM!iv^5yy{0B@V2Km%#MPK2ukHLr8_Bt5H4k$oli=E&fEl7;^Q
z@3VPEjUYttpw};0Sw^gkD+N*|?k@gFi}iST#k8R31u65smMBXQ?eVn2`Gr7L2>=uv
zaTx%?1GP4283O?P#2j0Q|6E3BJzxK>jrQicbI|SZ#-L(BoB)vXV^zxuD3UZ|%jO{m
zYU{>dcVHZtut^#oSPaA=^%v>hEiXS8!K8~l4P*Q`Wn3g=MPxe>7e@k6pB?ytV2iLU
zuY*Ct#yA`nZ6&G~J}w7ectts7ekPYd#n%=nFECO+Nu(vjHKo89cM63}hegZ$wR9_D
z`*F2zG)si(zbthJK7({{2d{S{ZvhSpAor&4c<3<$FE1dWqG6tz6+$QOK
z>z`e$7lto^*P)tz;N^}PRR8fZK76S%V2Cv0!Z3JicHp3fy#rL$vfSU^jr+&D?}4+E
zYM9xPC;I({oVt!8JUl4n(O(hFcml
zLl3CCVP(_}bZF+4ZDL}hs<{(8ef36@=?Pl%PsFh*>Y=mLZXS&Xzv|B-j&-P;6dMoC
z6Jc4Nz>UvG5_aVSPk2@V0GCqWx}AR44i;BeR-wc*!-opsqPc^6^$9$~$EOv0$&@mD
z_`F&aSSrMp0ztnUmi797-5{Q3NM2&Fmj^d9QSX7VN1$y~*S%f42EGV%KE{3~s1v`T
zAmi+0QdFzu&JlI}`gQX*Z6#xV+t^d)+@9Pv8IFGoa#Ix|9^p&de89uwRi~xbSF}qf
zvS^A;9X=Bk^TEykJgc(5iW6!KH|G;AJrg;unU}xh^V~lqJi*kqTI0Mz%q!u;w6C~6
z&PqZr&NyZV7E#?6|G_Cg_oqp9Q+zv!c{orP)ywC8KUFpRU5#f@zy^@uY!o64s@qh6L<}~x2C!a-38$_gEs4ngk-=$NU=oFt#?>`fM
zUsIG~=W!a7U##Rc1C1kTk~kqCiEyXcY{U~DBV{m%dz08QTOyU
zzaL-Xt9>Eh5UDwEBIm>|fU?g4$+=_P%Ezjzh}i1FGKPyv$5?tEad9mdU_(V5(wt+y
zyIdMdOS9RITn07kHzb<(Isw|^07%o7tDncF)CEr9FG}BzG-18MUUbbPqbX1staCuR
z7gt93v_Se`^XOd$s^_4yRvalyu!|tD>04X$Z+K2{!HuKK
z2VOgJ5Gkkw!c;TDi{Mz-b{L--QVsNRBo-8g#Jpb{Db`>uyDi)Hi{jEy!W~K9P0QT1
zeu76US(rddm5A_S9dfoS_kB=H6LU^TnT0Yq-6MSp?DY}(2L(fGt?_>C71P4Q2~c|2
zRGW*9XZnbg;4_iy{Q3Sg`#cQv&qvqZf(dwLsq7!+w<5gz*xPajO
z7ya?Y9V&i&cNtkXpe3+CW&ODrl-Y2tdQMlaxz1BDYrVK+9q#Jl3(wafUh?yVG;UwE
zyd!net=b_Mak27ZTdG)7F>HBA!i6z)8$t5rx{N(bU>ZPitQ?M{VEw8#N2Vv%-ETiJ
znY#1qK87C>>oKUhL$UMTc&KoE^-gu=4a)9wZFHVgK?
zM4h$Zk~Pn3K*90OkCu~v%&;mOV224Zm!T
zNfI*LMDe
zwYat5VrSKG5XkV%4M9nDOld-v8qV%R@)fPJ5wc4i_s&gyRP`>^;<`r76Tw1JYGS1_
zP$bh!NetF0xgw4(JBo6M4x_8X*>b3aYNJCJh{ptB9m~-c=ks==%+CBxM`u3BAC3+{
zOL|*W{Mu95%F-{VE9Jjeb!f5(Z<5Lnm48%aC5u<%aiIL=t9lkeR^Z@jQv4?D=G|Zt
zK;;G+QpS`TQx{*>3VgwYKTU5lKguMiOPoD<_d&ANn2OT=PVcuT-)6#}Pql}V=!@X8
zj@&7-vIz#9o%?#i2~z87Jgm=9Y|`ZPFtujhI%pDyu}
zL0rb!3^(AkdfSxVG{8u{SpVVp4br0!vD(O9sVeYRTtak{zABAhx|_xwD<8rT^es_U%YvhlfJq55^ehjKHWXx
zE0CgdxRlUcMmCbjXqhvIwf_wS4iP`$;K7}D;jX{-jZ=tD4t@{IDHe)@j)1cW&{c}i
zG6CK#YuJ;3AN6O4NqqP=B?%_;D0tVqJn@#3C4?;DK)(KZ3tWY!+cWBXKz;i5u2av|
z8MLBKd-+S;3^zcwDI|#f6lZQ`swYxY);*W;1$~5ZtSh`{h}K_LIj`UPzj%A|c&Prr
ze|SWsFqUjtLs=tRb|Ym=5)~p#ku`f53?s7dgp#cg3RBtF3By>ji|ou;$~q&<#F)?h
z_W54F>%Q*${`b3-Gyhdp|kidlJuvzJgFfU2BEN5jNQ#opK_%IeC<3d}O~tMI
z*Ct!H=1ex@q+WLP<~=FHd$UB$IOKr{_x^c8nR>{{$Nvjh)Jp3diF2|4Mmvb5XXJc%
z(@6WwQlJTs*9{SrplOJf7n5?gvWTxO53>D`1PaL;>iHx(fE0GEZ_J0cinb85IvAnW
zKg?u9eA1*^E!w{r*l7IR%zbm14cU?eHHfS2nRWiFBsWG>bcNCEY%V##{ZxMLkVuSw
z(h@fnwm^e~vO|f2-MW7Q%a~E3@TXpEZEQ{Ye!H6&Q!L`s3#FlK^G1vf(P5FGX8Cv7
z|EXC55F$e;ggjxs<52TsY2A2c+vWT2Z5CbddDlT71^~%4lcou3&7Cww!
zdd-)dt34g|dC>_ho1ju%HJWdmkGWPScU?46@iKgXK9(jfG@&SBkM=z%>J9PRhv#dM
zM+CQB&+|{hU@EXhQ_>M7@(SQgsS-iug*e$o=E=qHLhAxO?0_-peyb(`7Y+Y?ob~?a
z12)_Uq>AYjc9~@KFVk@tfom8PcUjo^U2!)75`!C1^Fzqs{{PoWlgYFS$o695!SPv3
zEQR{QF%S4G{y)zzK^7qr1(EHS${liiJ#<170Y9dQfRL#C7W-|Wtni6GYwg)OOA8%A
zZ?@<2!c6qLkkpyHj^CDG@Z5jrJ^gR}{Wp2PoRp8tr-zr^8dg_A9_La|p6j_3OO-mA
zJL)1T>`XtzRJ5vY-?a-%nrGgqiC2q7TvuFj5z4HqPh{|6*r^C((tLRVat!(J#H$mF
zMXTX)ajPV$sbiicUuVcqZ}eG(JKj!EXI5A>>QXG7W&~HI-lB0=KHm&kkFc3pSZNUQ
zG&qDYXUOx#Pymt=z^n09x{_Oj2
z@mVsg`VZr&)3byV7^wD?LE9242ge=YY$8k6vd6L`6ZhJu4H?_Td93Qc(OlgCqKS|%
zIw0XpoBW8F7f&>PFuZH@#7MLK+s&91A@Wh7xfCWMJyZDC=?5fEVszJ33FesR_PAo1
z%!X%9n~41T1qZfzyHjU6Ap-oNEb!_3q{0fB2-PvBgM;QeEVV81*$w+mguSJM<7s-GJt18o1d(Ep?i%aEE2oQCC
z`GtxLRV3RJJEJkP0Y)Q!Wl~bloURSu@VxLMk|La9U_UdC+E^#Lby==JTpKiz^>(hl7t!*2~tFj1v68*|;KR#ADDvtS3RX|GlpgfsC6
z3X{xi@jGZNJ0v4^-QzG`3
zSG`}6OI3f7QpyFBX7afj8WpC3iUapn-A@UFWU+CX5~9LWp;%RB`c;2`s)y$4
zz!|Jbm!9GeO25F_X4?Y9t!T@M>a5Xncfk}kuWt$oYxilWRo2so>DPIN-L?Vmsrvj_Fa#J8=m{`$vuyvCD;c@PyW^ibXq?&2l*ClSef*dHj5i?x
zNu3|xY)Cw7#Dh`&F5UQf^L9-bX&S#w{7}c21ddd$
zZR0$QmG%PxE5lWe4y7ybHV~?(F7Qdy|N~l!G=F?OEIH%#;UQ}hhd3bBqF(#
zZmK1E{~pN&mwy&s^vOK$AC?opKNpY{m&7*ZAO@ArDDJMh&G{lHC^eM_k
z3xNNYE&kqS6_pUJed@dH07jeIH-H77=>tgBed6CO>tvqgdo8CkXlM&87bOdkk{Z^6yk#%m%Sud)xgr&ju+GXwJh#z+X
zX1|jHXP(ICYp4G7#EBKzIjAYI{^Ye^r5|)f4q(`1`b#hg?I<^R}fwqYwcDGtnf4vc37!>r*;~U0Iy_oE%@OU%oU;
zyLQ(i?2eqS7bD`LHxY|dm|!&oW=f5*;GeZ)NmoDReRE$hZy=ur1C2uL7w0kOW<79M
zbhkUkb@#_z(ixVp^3Otwp87I_gbnrGZ*(vF2!=QjpGIW6Oi(9R<%1Le-fbb2%~RusZ(WL$8CH8!+gq=mIZgMjO<3b9)o!G3KC6ON
zJ`3rb?eGS}ldb&$izjlt(krI4#;znw<3uzIzwTpPd?$l%SSjQXQL_t#vK9DT->z1+
zAFxH?M$Rk_B_V~JQl{R%CdMQt=pEI&bbZMLbKHdnTapecNn`YcFZ_spntyuThJm2C
zt0`2=k!7A0^*pTBnppzXpG0-Z;LyCvT#|Utx`}}8XhA+4MWAaZrxjAUIentKA*u5f
z5WoB8t65~}`55%Bq7CpM)1Roh#s#XN2{p+V$g0_YFiNLZ_DBA1(oFkT$FVA%{?Z0h
z;pSR^RzQM4FdvlIgqff~NmiBX*?O;JHb)d=i>%vjrxgW%S)&F{5QZghExu0nzsmr1
ztHLQA12xgXcO+1A`W{__cbE}T9S-P;6@zW}t-PV{Wi>?)9thSd&;GF&WXfwEqWfr`
zOGXm9iUD^NnO&RlzAJ71OVa(sw;snyT&0>tOp?QZL>vIzsp&im^4EPd`QJ-|%wG(M
z9o#@eqfVFGPN#nNG%8JBkzA4A7&Ibzyy*&_7*Slwuhxs(Gp0S6BepAU%CnNG=9iJ>
zouJb8bhBwxr2v|QUNl791l5!2KL8`=1@FIuFIV;rAEamA
z6G443)?T=!JRrwJkJmg6Hd=?1Ic79RA7D;4T1b$`G)Bj_a{LM!|4eMzT^G{-)pX;0
z#t?5%N#&S|smbANdHJ)D)saI+vBk6(IFsNb
zd?^#vW{Q{oN>k26dg!R8!nit=u%b2(HRR>23n+71{kr9xk)|j01|QH(CrB_xFjK8R
zShB65ivaf%p&T{XF^*f=8KJ0k&~7Ni2&bNTL@?f_YQsV%dgcjDp~A?a9{?9c=$$+U
z(@0vfK?rU;Du|=uQ=qL!V{G#%>!z8+#vAOj&7RG4EdgM4`LDHC`~z9ZCqD?m67sd1
z!NXQTr!z)I*G^f{ywaV1B?|&2h&krgHY!IZ@X?0^^A}QE>S{h$Jho-bW=p2HQ~l);
z4GRLVn4*C;^vkeqp6$t60QUHQFQ$cTee%OoTS$)ZtB_lfymHU#jE7T{ra686#(-AO
z`UC-t5Gq9ticyphx_Urj|MCOzvu~7sH_SG(tQSi7*-_~=pp
zd3&FHao(4+DO56BUaEWDbR*}@S3#H#MOaS)M!v-9UL8?Wm=Lj{JD0IF&qtAj=j)KX
z7kD;VaXjbH%n#%H%HAbQz0^CmX@m(Y>h;aZqto!A+O1*#yGe%?h#5BG%_qFF*6d=e
z!6$sO+ZFb_bznSo4woET4Lb#ATGwt&zSA(_ZZ!M-^M{A}NgO}lRz-d<86&8yr$CFH
zQG-^VNwzEn^>MZ&gRNqgdiOLT_Gi2ciOmtVA*7&vBSWoG&ktw6&P?mb({Mq)p7P%8
zhuHpiI%Fj&1BX20W+|Pq*z~8QL$RST(_)C5ua5m(dTO=3GId!2v{(!^SRs!`V+{GU
z&t(xACS2H}u0GO@Qh`u7zR}VBj5|L4zh0d~vth2t$WU=&R_tzzTgNp+{W$FIp8*6+Hiy0)z|kIO>a&4?-{4|I4j%yT%aW
zEwxWFUV887h9ZE5CGvly2~q7JH$@5Yos`R@n8W$#>b)^{>4L;F2B{Y=>zutoCyX*l
zm=GtR7Ce)sC_?a{qX)y3HhcRJ#-*x0^=H?xLJfD>V?{Q<;afaIN6~ButM}wvAp$qY
zMMs~xU%@}My7##W^a+FaeyKq;deoEKZR6*Zk+GUQHLVj{+nX|;i&fd@KFlSgHNI2c
z(A|hwqccg-(p(4kmwL%~3XBa%AYUimFqu$?eMr>pIu^3Cf5VJ11wsZo;iETr^$S>%
z62Ta&8h}ZJnvN^HD6y~@s53QEAl5FFY?YdciteDQ`vLB70J`ajJjDr$=vwyNi(*FY
zcGAtY;rO$GY*CFBXMyYG#wU!;+bv_cCE&?DrOAI*qyK5vIe>H}6?D
zj!AhIUEAq+K0QIh2JE~j+Asx86*3pG_6t$T;>Ye4G1qIHTUCXfa?@)~aFbnG7b?kbQW*j-@Kf_C|)l3k3+m
zktB}KHOARAl>M608k3SKQIhRrAXZW%>6yNT!(@Vf9Os2H!3TkC=e)u!$g;EpViQ)!
z%MVi%B60BcQeAcO-&NkThikwPR)U-V2C{DF$hs3XjKcaC5)+SSvb>ELh^41=uT)3d
z-C$VQuh^cPUsz3O)*xQPS`%rr71kxsEQ|KP;VuTy{i&CtA2z@sn@3q)GVysz$vE2>
zcPYj73eAD89V=zKYR@aDbUUwWmV2ie`*zVne!(2kahftTZDJ-~H^w<|7Hzne+z
zOP9f(MigWn=?IIRlOAuqtW)S_+22+CIMDN=L!(-+oVZ3G{ZD7MLKa1bz#Z
zt%C6IKevaCjx&~bCrpePh+$@W(o_uJ!sR16{n1r2?Y5jGD;!PsM6AMaiRE=yqFCB}
z|BU?PSK=D71Kwjm{d||M9b^QscNM;(s1HHgOHxbbIuH!QrqgZMr8nj}Ch
zKoK0tFGWaBL@A=ZU9Y?KJQ2va+$Xmp0$(19QXaNA*M=5)+q$=tF4I=K(L#7iWS
znjEH*VoM+D_M&_}MI`!%I7|!j2hf_^t#Syh#^yXcg*8!^O|t5*SI-wVQRTa;RKJ0i
zqI*t*$Qe%;^Gsc@w5PQAb)56l{5PDT3+4fSf3IW5!_=km3!<19r_A#N{P)u3o2fG$
zHQzLJAMpx!+u%XGsRU6bx|tpS7RG#?Bj2}j`brT?^NZdmDp|qq-Ca-lnBa=Leh#EM
zlec;RJujP<^3=$9H6dFbmLZjz2?@L2rB@2tQ)x{qvOyHiBCEqwOjkn0@U?%fT0*@t
z?A8JFg#f*};X5GO-;8QGuNtx)Q{JD^Y%J)@!t%Xorc#&w)pHSo+v+6cD*1XI&fsll
z_WPikdBp4B(xZ6IBGEqw@lFZy5k2~z^`*u-&BmqJ`kb<{^k>o*3SAVWuIIfzuYTu!i
zrL5g#SoXPUnDmk$x#|*n6>&Y&Jhl1ZM{$2CZNqAYL#l85?X9`T>FVAn61rn*hF`eF
z?cj4Z>~f&qwtk9=ME}gGW~V&;fvXU@fn)*?&a}5%T~^W#@6`}ZH-p&Q9o(o}xDxOU
zNSZh?wZC2bLVUhnsrTB5`&1`&KXuTC9@kQCu0#~IXe1njA+CS=-FbRB_f?g)b%2;%
zGppJVox!8J&MT+ZJ7G}2Sx#f;@vWVoSQg+AcVMO`%i5kncWw)0ic(nntzdi8?0W$l
zWq3Z%E`FNUbc~YY4z72%jdu$M?|opkqCv301K`*J{Lao1B`J}yNWx_(MY^@@_)Rl=
zIh=EGV_)-Pq#!_6)3&?K8SG6{3m9~DL$%?e;gaL)31F%=55N`4aua(M7YhxKEa=j9
zE-xeulr{0u^L&->(&`qDgKVrvlO`8~x498lCls!Tzvr9~=ea4OY$h&YP^VEjZBNQ9N^asLf=@U`Vd
zw$G7X0Cd+M)U(J>zl<5lcZurrU*{Td4=eHMk49HHWUaqGjjK+n+~0Uae+T+mge@m5
zL|iA-H$NWDtb&dA3q}5}38Awms$n*dq-NoXs0TIOjz?$b&i~zi!>*LqNth4*$q#y)
zks2_uQ}ksG>;girb!1n^Kqgavi}kX5t7Au6!EVoHo+KW$RrAcSUmidHfo$)Eoc0FC
z#P2_h#~`1XR2nG#Wc+EI@A>*3@`xl5JdsyTwKz|BuXzbMgo06M+JH`CVltnaEQ?$K
z*P)R=_ESvfabx$+$7>74%YoNc$7liUNQ$6vY-6B@JbuU{irX)DBrZA@cbui*$=VUf
z8??N;(q&bXymD3~BLHQ$q$?NF1L0)`bYGy}rsP7REfxtC{!BR3yRuijW^=~6{mlc@
zn-WFD{CxEkWmGa4aNvp10v%MH`9Bc!-v0KtZB|!)Y0`rNx1bK)g9Cb@Tc8hmy$tWL
zAA3oIPe>1=1NBG=Su8mgwkru#H7~9LUy|Q`N!vE#9Q?*&W#qdxcOIK5%$#SY&$~C1
z1SrA-yJ($w9#Vq`xzYzO_E5Yej)CcpluP}~xwx6v=OSG=?ZwaDp$&buqjzinEHN46
z(_WZ(sebyYXmn+NZs+FObn`D5Jci}%vPP@~ZHT;^a&xM-8@fXqXP;Tn?FR)
zMOdt&arhi`X!mtN%MS&e>+0fyw({cD8&|@-LiBO1egd4Df~U?9Jy6*4rHLUJSLzv+yw|MltF7JGbc-Db)WJ)b9)s3+v$yE!A4EegA8UK|Z2KRCl
zCIw?)+1l3Py06&v$T?z^GGCMt1fj(F%{bQdRt!R3f>zMO?X_3y
z>gsQsRpP{=Xsq90v?lfFuGl+tD`D?-RedDy#dKLr@envy%%0$mUyG~uO9bz1)2b3ew?RruQR()
z;TacQR!|yK;Jr3}Y}~=%lqAkG_IW-PiFRG5`GR{_ldGq6Q7z3?A!RF9);pYXb!7{_
z6N3C#+)q|8A$ya;3x6;po(8@p1)@Ajyg5wG)V6`YIE+6yTe`nYsNAm|{`S3v>tm!y
z=!3rp?!v^g{
zfwU1vY6#ti@hcZwhjF8ume*Hi4Fjpm4aagzmy?H>T65h-kZx!J(%TM-`0%bZ-GheF
zD~u`4{?)04X?jl2JFcoso$}H!5LLIwfC3@_o9$Tl6y7TOL%O-_{%h$i8|NbK<%b4s
zG!8)rG`xVXHSRaPIbVL!+0jv_8S@e1aaoCU7&N2R3@P5UwWb*zKHw}P&F?%Z
zl4??K-v(J?8A`q#2RRz}4^xEQ&jkH_KMdDeQuy0)c|Z4YNs;(@e_sW)I!v0p=I9^Z
z2L5kQ1RlXedWRP!6&6g{kfKe{RW0>a4+~myKWJA5g<+$(Xjme;R9Vl^SrHEwN)iz|
zM5TMsbI@YPA>dw
z;)XfQEH|HFMFZ}=XxnGcJ<=oV6aA7q6U_}r_BLH6JhK%HaKQQU?BVnUPf|^
zA0Oz*eBEd`?p2Gi7@MBo!K4|e@Be~a0HHy~n0$_Tvc;%EqLp>)SoQJs%Leu>Z$3~@j-m3EjS{$tsgeZ!*+Bb*w7{MX2=)8pH?1O|SSOV|E_6Md-70xpswdZ-
zRi!X}_U4Y{MbTxINBkJJ)Es6iD_jgN&K6MKAYhViDK(Jk3K;)RV->h!#=?Uidj
z@=32NKka*w6!8M*5I2&oiHa{R{fdiNB|bgUll(qV?DhWIGpe!lq@RPzhaWRRKE^_F
zJm|+==UEX3jCHY!YLaeNe=8n`4=gVxnktE*CGzSYrO#wyeS878#(+_f=SD6l8_v6hs<#gBwK)DU{hHRfB^xMttNT|k?YMnv?y4~QJk=hU
z@U)FLj-#kF6CF`VEVO`&dF9rZIRAsO+sl6j=z)9oc1Bn}~8f
z2+A$_d0~@rrb4o7UBqqYiwY!Kg}-B7P~ZW;Hu*1+>hLXkw&+Gpu^;Y;p4Tx~q;0qg
zAqx`kb084@)iRMZHvo4zAY=G1PmrxYIS1{3@-3S@jzpPMF1a`)j_Z$}#jLt8`Dkc$
zsdUq2uc50@7&jwb+}xmU9A!#LlJC2DicoLMYsvb@yFwtk&5mS(L!Ne>8YsiXJC}!!
zy6@{(ke4zBzn4FHbx8})H4wCmhv|;UP8H>KC{Pj1Kk?O(N|$^d^p50met|-2N1gL{%eHbB_(45<^)F-k4;xi!_SPYh&K9)#9%fB
zWmTrbl=pro3fv~QY
z0I;)-+NGDXR_aj5zPyvHs-j98J
zd-dj!)5D^46`^r5
z5Rz8}d{d>ZoVy$+>DHp{+&e85FRWWu|MZQ)quj}FqRF9jJ?Ugq;?f@jNm7S3!-mDn
zmeljCzU|=<{%=oOqe!QT0+>2bhadt2Kiby53oMxG@pIA`cR>6#)tq>?{7R1DzdF}Z
z?FQgKP!*XIA?P*~T6U+-*jgIUc>Du-E-y_R^6AgMah#@FnptYIZDrIGgXv(Qq!5U*
zKx7CDAh2Mum7%nx(R*WxbS+~FH&ZvIrIB#*yJsq}V7d>Xxey37mS-P9%I1zH3kPzq
z$!rFK(94Y7gN?og4X1B5^vyLyUhnI{f&L!qFH;|3u0>M4F)+O|Lohb66sck+W)<*%&_{mnBDE)bte}G8(ue}G>^-AU`{g>+UI#yHfQ5oiKM)a%&
z<>X{T{@$Z-|M?%MlAMzU!jl+Ezbsrwb6{%M=eW9U1#-?)W|{7&>9cpv<{$$2frhmN
zAjl3)@*U7*I{gC?-9&;U-MI!rG)4(l<(R&3