From 676bd6d222509591614db840c2d36e94b2fdc3a2 Mon Sep 17 00:00:00 2001
From: PixelTux
Date: Thu, 19 Feb 2026 15:52:46 +0100
Subject: [PATCH 001/172] extra_hosts mapping to have enables container-to-host
connectivity
---
docker-compose.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/docker-compose.yml b/docker-compose.yml
index 32e8ee339..c268b01cd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,9 @@ services:
container_name: picoclaw-agent
profiles:
- agent
+ # Uncomment to access host network; leave commented unless needed.
+ #extra_hosts:
+ # - "host.docker.internal:host-gateway"
volumes:
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
@@ -29,6 +32,9 @@ services:
restart: unless-stopped
profiles:
- gateway
+ # Uncomment to access host network; leave commented unless needed.
+ #extra_hosts:
+ # - "host.docker.internal:host-gateway"
volumes:
# Configuration file
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
From dfcf15bfff98bb1779b1e59a501ad39c962888ca Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Fri, 20 Feb 2026 23:18:46 +0800
Subject: [PATCH 002/172] 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 003/172] 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 004/172] 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 005/172] 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 006/172] 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 007/172] 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 c2ace2561cfae81839e977f90da7e29017d7b8e7 Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Fri, 20 Feb 2026 22:09:36 +0200
Subject: [PATCH 008/172] feat(ci): Remove fmt from build step
---
.github/workflows/build.yml | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 499613625..9b89b69ae 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -2,7 +2,7 @@ name: build
on:
push:
- branches: ["main"]
+ branches: [ "main" ]
jobs:
build:
@@ -16,10 +16,5 @@ jobs:
with:
go-version-file: go.mod
- - name: fmt
- run: |
- make fmt
- git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
-
- name: Build
run: make build-all
From 02b4d9fbe2dea85fb032ceba7d4c64f1499ba95a Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Fri, 20 Feb 2026 22:35:16 +0200
Subject: [PATCH 009/172] feat(linter): Fix govet linter
---
.github/workflows/pr.yml | 19 -------------------
.golangci.yaml | 1 -
cmd/picoclaw/cmd_auth.go | 6 +++---
cmd/picoclaw/cmd_gateway.go | 3 ++-
cmd/picoclaw/cmd_skills.go | 4 ++--
pkg/channels/telegram.go | 4 ++--
pkg/channels/wecom.go | 2 +-
pkg/channels/wecom_app.go | 2 +-
pkg/channels/wecom_app_test.go | 13 -------------
pkg/channels/wecom_test.go | 4 +---
pkg/migrate/migrate.go | 2 +-
pkg/migrate/migrate_test.go | 10 +++++-----
pkg/providers/codex_cli_credentials.go | 2 +-
pkg/tools/edit.go | 2 +-
pkg/tools/filesystem.go | 8 +++++---
pkg/tools/i2c_linux.go | 2 +-
pkg/voice/transcriber.go | 6 +++---
17 files changed, 29 insertions(+), 61 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 27782ced2..be1c10c52 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -24,25 +24,6 @@ jobs:
with:
version: v2.10.1
- # TODO: Remove once linter is properly configured
- vet:
- name: Vet
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@v6
-
- - name: Setup Go
- uses: actions/setup-go@v6
- with:
- go-version-file: go.mod
-
- - name: Run go generate
- run: go generate ./...
-
- - name: Run go vet
- run: go vet ./...
-
test:
name: Tests
runs-on: ubuntu-latest
diff --git a/.golangci.yaml b/.golangci.yaml
index 6dafb6b56..d45d69e67 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -47,7 +47,6 @@ linters:
- godox
- goprintffuncname
- gosec
- - govet
- ineffassign
- lll
- maintidx
diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go
index 5bed7f116..729c56177 100644
--- a/cmd/picoclaw/cmd_auth.go
+++ b/cmd/picoclaw/cmd_auth.go
@@ -114,7 +114,7 @@ func authLoginOpenAI(useDeviceCode bool) {
os.Exit(1)
}
- if err := auth.SetCredential("openai", cred); err != nil {
+ if err = auth.SetCredential("openai", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
@@ -188,7 +188,7 @@ func authLoginGoogleAntigravity() {
fmt.Printf("Project: %s\n", projectID)
}
- if err := auth.SetCredential("google-antigravity", cred); err != nil {
+ if err = auth.SetCredential("google-antigravity", cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
@@ -265,7 +265,7 @@ func authLoginPasteToken(provider string) {
os.Exit(1)
}
- if err := auth.SetCredential(provider, cred); err != nil {
+ if err = auth.SetCredential(provider, cred); err != nil {
fmt.Printf("Failed to save credentials: %v\n", err)
os.Exit(1)
}
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go
index 00ec0f96d..9a3b6aa19 100644
--- a/cmd/picoclaw/cmd_gateway.go
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -98,7 +98,8 @@ func gatewayCmd() {
channel, chatID = "cli", "direct"
}
// Use ProcessHeartbeat - no session history, each heartbeat is independent
- response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
+ var response string
+ response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
}
diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go
index 2dd46756a..0814494b3 100644
--- a/cmd/picoclaw/cmd_skills.go
+++ b/cmd/picoclaw/cmd_skills.go
@@ -118,7 +118,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
workspace := cfg.WorkspacePath()
targetDir := filepath.Join(workspace, "skills", slug)
- if _, err := os.Stat(targetDir); err == nil {
+ if _, err = os.Stat(targetDir); err == nil {
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
os.Exit(1)
}
@@ -126,7 +126,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
- if err := os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
+ if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
os.Exit(1)
}
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index 2a971e147..a0a1c8d0a 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -267,10 +267,10 @@ 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]any{
"error": err.Error(),
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go
index 07bd8488c..f8daf89de 100644
--- a/pkg/channels/wecom.go
+++ b/pkg/channels/wecom.go
@@ -272,7 +272,7 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
AgentID string `xml:"AgentID"`
}
- if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
+ if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
logger.ErrorCF("wecom", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go
index 878504106..715c48707 100644
--- a/pkg/channels/wecom_app.go
+++ b/pkg/channels/wecom_app.go
@@ -348,7 +348,7 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
AgentID string `xml:"AgentID"`
}
- if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
+ if err = xml.Unmarshal(body, &encryptedMsg); err != nil {
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]any{
"error": err.Error(),
})
diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go
index 6778520f3..abf15c52b 100644
--- a/pkg/channels/wecom_app_test.go
+++ b/pkg/channels/wecom_app_test.go
@@ -852,19 +852,6 @@ func TestWeComAppMessageStructures(t *testing.T) {
}
})
- 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,
diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go
index 53cde2693..8afa7e8c3 100644
--- a/pkg/channels/wecom_test.go
+++ b/pkg/channels/wecom_test.go
@@ -198,10 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
}
- base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom)
chEmpty := &WeComBotChannel{
- BaseChannel: base,
- config: cfgEmpty,
+ config: cfgEmpty,
}
if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go
index ab2635890..cfa82b7d7 100644
--- a/pkg/migrate/migrate.go
+++ b/pkg/migrate/migrate.go
@@ -67,7 +67,7 @@ func Run(opts Options) (*Result, error) {
return nil, err
}
- if _, err := os.Stat(openclawHome); os.IsNotExist(err) {
+ if _, err = os.Stat(openclawHome); os.IsNotExist(err) {
return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome)
}
diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go
index ccc00f72c..b6b3d70aa 100644
--- a/pkg/migrate/migrate_test.go
+++ b/pkg/migrate/migrate_test.go
@@ -58,10 +58,10 @@ func TestConvertKeysToSnake(t *testing.T) {
t.Fatal("expected map[string]interface{}")
}
- if _, ok := m["api_key"]; !ok {
+ if _, ok = m["api_key"]; !ok {
t.Error("expected key 'api_key' after conversion")
}
- if _, ok := m["api_base"]; !ok {
+ if _, ok = m["api_base"]; !ok {
t.Error("expected key 'api_base' after conversion")
}
@@ -69,10 +69,10 @@ func TestConvertKeysToSnake(t *testing.T) {
if !ok {
t.Fatal("expected nested map")
}
- if _, ok := nested["max_tokens"]; !ok {
+ if _, ok = nested["max_tokens"]; !ok {
t.Error("expected key 'max_tokens' in nested map")
}
- if _, ok := nested["allow_from"]; !ok {
+ if _, ok = nested["allow_from"]; !ok {
t.Error("expected key 'allow_from' in nested map")
}
@@ -108,7 +108,7 @@ func TestLoadOpenClawConfig(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if err := os.WriteFile(configPath, data, 0o644); err != nil {
+ if err = os.WriteFile(configPath, data, 0o644); err != nil {
t.Fatal(err)
}
diff --git a/pkg/providers/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go
index 46ba24b12..40f3ee2a1 100644
--- a/pkg/providers/codex_cli_credentials.go
+++ b/pkg/providers/codex_cli_credentials.go
@@ -31,7 +31,7 @@ func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Ti
}
var auth CodexCliAuth
- if err := json.Unmarshal(data, &auth); err != nil {
+ if err = json.Unmarshal(data, &auth); err != nil {
return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err)
}
diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go
index 39d2642d4..c28ca6ca2 100644
--- a/pkg/tools/edit.go
+++ b/pkg/tools/edit.go
@@ -72,7 +72,7 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult(err.Error())
}
- if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
+ if _, err = os.Stat(resolvedPath); os.IsNotExist(err) {
return ErrorResult(fmt.Sprintf("file not found: %s", path))
}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index dd996bc0d..1bf50906e 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -34,17 +34,19 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
return "", fmt.Errorf("access denied: path is outside the workspace")
}
+ var resolved string
workspaceReal := absWorkspace
- if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil {
+ if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil {
workspaceReal = resolved
}
- if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
+ if resolved, err = filepath.EvalSymlinks(absPath); err == nil {
if !isWithinWorkspace(resolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
} else if os.IsNotExist(err) {
- if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
+ var parentResolved string
+ if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
if !isWithinWorkspace(parentResolved, workspaceReal) {
return "", fmt.Errorf("access denied: symlink resolves outside workspace")
}
diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go
index 2a0626340..4eaaf8f09 100644
--- a/pkg/tools/i2c_linux.go
+++ b/pkg/tools/i2c_linux.go
@@ -182,7 +182,7 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult {
if reg < 0 || reg > 255 {
return ErrorResult("register must be between 0x00 and 0xFF")
}
- _, err := syscall.Write(fd, []byte{byte(reg)})
+ _, err = syscall.Write(fd, []byte{byte(reg)})
if err != nil {
return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err))
}
diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go
index ad8767d40..f973e77fe 100644
--- a/pkg/voice/transcriber.go
+++ b/pkg/voice/transcriber.go
@@ -79,17 +79,17 @@ func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string)
logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied})
- if err := writer.WriteField("model", "whisper-large-v3"); err != nil {
+ if err = writer.WriteField("model", "whisper-large-v3"); err != nil {
logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write model field: %w", err)
}
- if err := writer.WriteField("response_format", "json"); err != nil {
+ if err = writer.WriteField("response_format", "json"); err != nil {
logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err})
return nil, fmt.Errorf("failed to write response_format field: %w", err)
}
- if err := writer.Close(); err != nil {
+ if err = writer.Close(); err != nil {
logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err})
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
From 244eb0b47d0f694df4bdc96c316b23698eab7407 Mon Sep 17 00:00:00 2001
From: Goksu Ceylan <79890826+GoCeylan@users.noreply.github.com>
Date: Fri, 20 Feb 2026 19:15:46 -0500
Subject: [PATCH 010/172] fix (security): ExecTool `working_dir` sandbox escape
(#478)
* fix (security) Shell working_dir bypass
* Feedback from @mengzhuo & Discord
- reuse internal security package to validate path
- add tests for workspace escape
---
pkg/tools/shell.go | 10 ++++++-
pkg/tools/shell_test.go | 60 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 69 insertions(+), 1 deletion(-)
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index d2adb6468..a1ee0b6e1 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -144,7 +144,15 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult
cwd := t.workingDir
if wd, ok := args["working_dir"].(string); ok && wd != "" {
- cwd = wd
+ if t.restrictToWorkspace && t.workingDir != "" {
+ resolvedWD, err := validatePath(wd, t.workingDir, true)
+ if err != nil {
+ return ErrorResult("Command blocked by safety guard (" + err.Error() + ")")
+ }
+ cwd = resolvedWD
+ } else {
+ cwd = wd
+ }
}
if cwd == "" {
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index f85b5a008..60f2b7b91 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -186,6 +186,66 @@ func TestShellTool_OutputTruncation(t *testing.T) {
}
}
+// TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly
+func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
+ root := t.TempDir()
+ workspace := filepath.Join(root, "workspace")
+ outsideDir := filepath.Join(root, "outside")
+ if err := os.MkdirAll(workspace, 0755); err != nil {
+ t.Fatalf("failed to create workspace: %v", err)
+ }
+ if err := os.MkdirAll(outsideDir, 0755); err != nil {
+ t.Fatalf("failed to create outside dir: %v", err)
+ }
+
+ tool := NewExecTool(workspace, true)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "command": "pwd",
+ "working_dir": outsideDir,
+ })
+
+ if !result.IsError {
+ t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "blocked") {
+ t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM)
+ }
+}
+
+// TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace
+// pointing outside cannot be used as working_dir to escape the sandbox.
+func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
+ root := t.TempDir()
+ workspace := filepath.Join(root, "workspace")
+ secretDir := filepath.Join(root, "secret")
+ if err := os.MkdirAll(workspace, 0755); err != nil {
+ t.Fatalf("failed to create workspace: %v", err)
+ }
+ if err := os.MkdirAll(secretDir, 0755); err != nil {
+ t.Fatalf("failed to create secret dir: %v", err)
+ }
+ os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0644)
+
+ // symlink lives inside the workspace but resolves to secretDir outside it
+ link := filepath.Join(workspace, "escape")
+ if err := os.Symlink(secretDir, link); err != nil {
+ t.Skipf("symlinks not supported in this environment: %v", err)
+ }
+
+ tool := NewExecTool(workspace, true)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "command": "cat secret.txt",
+ "working_dir": link,
+ })
+
+ if !result.IsError {
+ t.Fatalf("expected symlink working_dir escape to be blocked, got output: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "blocked") {
+ t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM)
+ }
+}
+
// TestShellTool_RestrictToWorkspace verifies workspace restriction
func TestShellTool_RestrictToWorkspace(t *testing.T) {
tmpDir := t.TempDir()
From 80c8b5753338dc75286d93d8d62fa01654b1a2f0 Mon Sep 17 00:00:00 2001
From: Luke Milby
Date: Fri, 20 Feb 2026 19:21:38 -0500
Subject: [PATCH 011/172] Fix Memory Write (#557)
* fix issue where memory will only trigger when asked to remember something
* updated prompt for memory usage
---
pkg/agent/context.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index e989ffaaf..a9db5afdd 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -80,7 +80,7 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
-3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
+3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
}
From 3df7f705408d5767e43a22975fd2d093f33b7705 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 21 Feb 2026 16:05:39 +0800
Subject: [PATCH 012/172] fix: golangci-lint fmt
---
pkg/tools/shell_test.go | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index 60f2b7b91..d0a300c6c 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -191,10 +191,10 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
root := t.TempDir()
workspace := filepath.Join(root, "workspace")
outsideDir := filepath.Join(root, "outside")
- if err := os.MkdirAll(workspace, 0755); err != nil {
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
- if err := os.MkdirAll(outsideDir, 0755); err != nil {
+ if err := os.MkdirAll(outsideDir, 0o755); err != nil {
t.Fatalf("failed to create outside dir: %v", err)
}
@@ -218,13 +218,13 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
root := t.TempDir()
workspace := filepath.Join(root, "workspace")
secretDir := filepath.Join(root, "secret")
- if err := os.MkdirAll(workspace, 0755); err != nil {
+ if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
- if err := os.MkdirAll(secretDir, 0755); err != nil {
+ if err := os.MkdirAll(secretDir, 0o755); err != nil {
t.Fatalf("failed to create secret dir: %v", err)
}
- os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0644)
+ os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0o644)
// symlink lives inside the workspace but resolves to secretDir outside it
link := filepath.Join(workspace, "escape")
From 00666022949fb993f9b07ae8c2bb844fde977b23 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 21 Feb 2026 16:20:15 +0800
Subject: [PATCH 013/172] fix: golangci-lint run --fix
---
pkg/tools/shell_test.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go
index d0a300c6c..6d35815e8 100644
--- a/pkg/tools/shell_test.go
+++ b/pkg/tools/shell_test.go
@@ -199,7 +199,7 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) {
}
tool := NewExecTool(workspace, true)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"command": "pwd",
"working_dir": outsideDir,
})
@@ -233,7 +233,7 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) {
}
tool := NewExecTool(workspace, true)
- result := tool.Execute(context.Background(), map[string]interface{}{
+ result := tool.Execute(context.Background(), map[string]any{
"command": "cat secret.txt",
"working_dir": link,
})
From b25b3c13246f7de7de12c37d2ec183c9e531f245 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 21 Feb 2026 16:35:56 +0800
Subject: [PATCH 014/172] 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 023b245a285780edfcfbe90ae81b4c9cd7e7913d Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 21 Feb 2026 15:33:35 +0800
Subject: [PATCH 015/172] docs: add Chinese channel documentation
---
README.zh.md | 458 ++++++---------------
docs/channels/dingtalk/README.zh.md | 33 ++
docs/channels/discord/README.zh.md | 35 ++
docs/channels/feishu/README.zh.md | 37 ++
docs/channels/line/README.zh.md | 41 ++
docs/channels/maixcam/README.zh.md | 31 ++
docs/channels/onebot/README.zh.md | 31 ++
docs/channels/qq/README.zh.md | 32 ++
docs/channels/slack/README.zh.md | 33 ++
docs/channels/telegram/README.zh.md | 33 ++
docs/channels/wecom/wecom_app/README.zh.md | 47 +++
docs/channels/wecom/wecom_bot/README.zh.md | 41 ++
12 files changed, 510 insertions(+), 342 deletions(-)
create mode 100644 docs/channels/dingtalk/README.zh.md
create mode 100644 docs/channels/discord/README.zh.md
create mode 100644 docs/channels/feishu/README.zh.md
create mode 100644 docs/channels/line/README.zh.md
create mode 100644 docs/channels/maixcam/README.zh.md
create mode 100644 docs/channels/onebot/README.zh.md
create mode 100644 docs/channels/qq/README.zh.md
create mode 100644 docs/channels/slack/README.zh.md
create mode 100644 docs/channels/telegram/README.zh.md
create mode 100644 docs/channels/wecom/wecom_app/README.zh.md
create mode 100644 docs/channels/wecom/wecom_bot/README.zh.md
diff --git a/README.zh.md b/README.zh.md
index ab896b6c0..4d739c5eb 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,7 +14,8 @@
- **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
+**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
+
---
@@ -42,14 +43,15 @@
> [!CAUTION]
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
-> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
-> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
-> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
-> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
-> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
-
+>
+> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
+> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
+> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
+> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
+> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
## 📢 新闻 (News)
+
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
@@ -69,12 +71,12 @@
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
-| | OpenClaw | NanoBot | **PicoClaw** |
-| --- | --- | --- | --- |
-| **语言** | TypeScript | Python | **Go** |
-| **RAM** | >1GB | >100MB | **< 10MB** |
-| **启动时间**(0.8GHz core) | >500s | >30s | **<1s** |
-| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板****低至 $10** |
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **语言** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **启动时间**(0.8GHz core) | >500s | >30s | **<1s** |
+| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板****低至 $10** |
@@ -101,9 +103,12 @@
### 📱 在手机上轻松运行
+
picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
+
1. 先去应用商店下载安装Termux
2. 打开后执行指令
+
```bash
# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
@@ -111,19 +116,17 @@ chmod +x picoclaw-linux-arm64
pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard
```
-然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
+
+然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
-
-
-
### 🐜 创新的低占用部署
PicoClaw 几乎可以部署在任何 Linux 设备上!
-* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
-* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
-* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
@@ -253,15 +256,14 @@ picoclaw onboard
}
}
}
-
```
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
**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)
+- **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
> **注意**: 完整的配置模板请参考 `config.example.json`。
@@ -278,260 +280,28 @@ picoclaw agent -m "2+2 等于几?"
## 💬 聊天应用集成 (Chat Apps)
-通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。
-
-| 渠道 | 设置难度 |
-| --- | --- |
-| **Telegram** | 简单 (仅需 token) |
-| **Discord** | 简单 (bot token + intents) |
-| **QQ** | 简单 (AppID + AppSecret) |
-| **钉钉 (DingTalk)** | 中等 (应用凭证) |
-| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
-
-
-Telegram (推荐)
-
-**1. 创建机器人**
-
-* 打开 Telegram,搜索 `@BotFather`
-* 发送 `/newbot`,按照提示操作
-* 复制 token
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
- }
- }
-}
-
-```
-
-> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
-
-**3. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-Discord
-
-**1. 创建机器人**
-
-* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
-* Create an application → Bot → Add Bot
-* 复制 bot token
-
-**2. 开启 Intents**
-
-* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
-* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
-
-**3. 获取您的 User ID**
-
-* Discord 设置 → Advanced → 开启 **Developer Mode**
-* 右键点击您的头像 → **Copy User ID**
-
-**4. 配置**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"],
- "mention_only": false
- }
- }
-}
-
-```
-
-**5. 邀请机器人**
-
-* OAuth2 → URL Generator
-* Scopes: `bot`
-* Bot Permissions: `Send Messages`, `Read Message History`
-* 打开生成的邀请 URL,将机器人添加到您的服务器
-
-**6. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-QQ
-
-**1. 创建机器人**
-
-* 前往 [QQ 开放平台](https://q.qq.com/#)
-* 创建应用 → 获取 **AppID** 和 **AppSecret**
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "app_id": "YOUR_APP_ID",
- "app_secret": "YOUR_APP_SECRET",
- "allow_from": []
- }
- }
-}
-
-```
-
-> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
-
-**3. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-钉钉 (DingTalk)
-
-**1. 创建机器人**
-
-* 前往 [开放平台](https://open.dingtalk.com/)
-* 创建内部应用
-* 复制 Client ID 和 Client Secret
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "client_id": "YOUR_CLIENT_ID",
- "client_secret": "YOUR_CLIENT_SECRET",
- "allow_from": []
- }
- }
-}
-
-```
-
-> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
-
-**3. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-企业微信 (WeCom)
-
-PicoClaw 支持两种企业微信集成方式:
-
-**选项1: 智能机器人 (WeCom Bot)** - 设置更简单,支持群聊
-**选项2: 自建应用 (WeCom App)** - 功能更丰富,支持主动推送消息
-
-详见 [企业微信自建应用配置指南](docs/wecom-app-configuration.md)。
-
-**快速设置 - 智能机器人:**
-
-**1. 创建机器人**
-
-* 前往企业微信管理后台 → 群聊 → 添加群机器人
-* 复制 Webhook URL (格式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "wecom": {
- "enabled": true,
- "token": "YOUR_TOKEN",
- "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
- "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
- "webhook_host": "0.0.0.0",
- "webhook_port": 18793,
- "webhook_path": "/webhook/wecom",
- "allow_from": []
- }
- }
-}
-```
-
-**快速设置 - 自建应用:**
-
-**1. 创建应用**
-
-* 前往企业微信管理后台 → 应用管理 → 创建应用
-* 复制 **AgentId** 和 **Secret**
-* 前往"我的企业"页面,复制 **CorpID**
-
-**2. 配置接收消息**
-
-* 在应用详情页,点击"接收消息" → "设置API"
-* 设置 URL 为 `http://your-server:18792/webhook/wecom-app`
-* 生成 **Token** 和 **EncodingAESKey**
-
-**3. 配置**
-
-```json
-{
- "channels": {
- "wecom_app": {
- "enabled": true,
- "corp_id": "wwxxxxxxxxxxxxxxxx",
- "corp_secret": "YOUR_CORP_SECRET",
- "agent_id": 1000002,
- "token": "YOUR_TOKEN",
- "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
- "webhook_host": "0.0.0.0",
- "webhook_port": 18792,
- "webhook_path": "/webhook/wecom-app",
- "allow_from": []
- }
- }
-}
-```
-
-**4. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-> **注意**: 自建应用需要开放 18792 端口用于接收 Webhook 回调。生产环境建议使用反向代理配置 HTTPS。
-
-
+PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
+
+### 核心渠道
+
+| 渠道 | 设置难度 | 特性说明 | 文档链接 |
+| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
+| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
+| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
+| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
+| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
+| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) |
+| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
+| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
+| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
+| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
##
加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
-**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
+\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
## ⚙️ 配置详解
@@ -567,7 +337,6 @@ PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md`
- Check my email for important messages
- Review my calendar for upcoming events
- Check the weather forecast
-
```
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
@@ -580,22 +349,23 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
# 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
-
```
**关键行为:**
-| 特性 | 描述 |
-| --- | --- |
-| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
-| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
+| 特性 | 描述 |
+| ---------------- | ---------------------------------------- |
+| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
+| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
-| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
+| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
#### 子 Agent 通信原理
@@ -625,35 +395,34 @@ Agent 读取 HEARTBEAT.md
"interval": 30
}
}
-
```
-| 选项 | 默认值 | 描述 |
-| --- | --- | --- |
-| `enabled` | `true` | 启用/禁用心跳 |
-| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
+| 选项 | 默认值 | 描述 |
+| ---------- | ------ | ---------------------------- |
+| `enabled` | `true` | 启用/禁用心跳 |
+| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
**环境变量:**
-* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
-* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
+- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
+- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
### 提供商 (Providers)
> [!NOTE]
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
-| 提供商 | 用途 | 获取 API Key |
-| --- | --- | --- |
-| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
-| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
-| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
-| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
-| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
-| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
+| 提供商 | 用途 | 获取 API Key |
+| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
+| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
### 模型配置 (model_list)
@@ -668,25 +437,25 @@ Agent 读取 HEARTBEAT.md
#### 📋 所有支持的厂商
-| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
-|------|-------------|---------------|------|--------------|
-| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
-| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
-| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
-| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
-| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
-| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
-| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
-| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
-| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
-| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
-| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
-| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
-| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
-| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
-| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
-| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
-| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
+| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
+| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
#### 基础配置示例
@@ -720,6 +489,7 @@ Agent 读取 HEARTBEAT.md
#### 各厂商配置示例
**OpenAI**
+
```json
{
"model_name": "gpt-5.2",
@@ -729,6 +499,7 @@ Agent 读取 HEARTBEAT.md
```
**智谱 AI (GLM)**
+
```json
{
"model_name": "glm-4.7",
@@ -738,6 +509,7 @@ Agent 读取 HEARTBEAT.md
```
**DeepSeek**
+
```json
{
"model_name": "deepseek-chat",
@@ -747,6 +519,7 @@ Agent 读取 HEARTBEAT.md
```
**Anthropic (使用 OAuth)**
+
```json
{
"model_name": "claude-sonnet-4.6",
@@ -754,9 +527,11 @@ Agent 读取 HEARTBEAT.md
"auth_method": "oauth"
}
```
+
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
**Ollama (本地)**
+
```json
{
"model_name": "llama3",
@@ -765,6 +540,7 @@ Agent 读取 HEARTBEAT.md
```
**自定义代理/API**
+
```json
{
"model_name": "my-custom-model",
@@ -802,6 +578,7 @@ Agent 读取 HEARTBEAT.md
旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
**旧配置(已弃用):**
+
```json
{
"providers": {
@@ -820,6 +597,7 @@ Agent 读取 HEARTBEAT.md
```
**新配置(推荐):**
+
```json
{
"model_list": [
@@ -844,7 +622,7 @@ Agent 读取 HEARTBEAT.md
**1. 获取 API key 和 base URL**
-* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
**2. 配置**
@@ -866,7 +644,6 @@ Agent 读取 HEARTBEAT.md
}
}
}
-
```
**3. 运行**
@@ -946,30 +723,29 @@ picoclaw agent -m "你好"
"interval": 30
}
}
-
```
## CLI 命令行参考
-| 命令 | 描述 |
-| --- | --- |
-| `picoclaw onboard` | 初始化配置和工作区 |
-| `picoclaw agent -m "..."` | 与 Agent 对话 |
-| `picoclaw agent` | 交互式聊天模式 |
-| `picoclaw gateway` | 启动网关 (Gateway) |
-| `picoclaw status` | 显示状态 |
-| `picoclaw cron list` | 列出所有定时任务 |
-| `picoclaw cron add ...` | 添加定时任务 |
+| 命令 | 描述 |
+| ------------------------- | ------------------ |
+| `picoclaw onboard` | 初始化配置和工作区 |
+| `picoclaw agent -m "..."` | 与 Agent 对话 |
+| `picoclaw agent` | 交互式聊天模式 |
+| `picoclaw gateway` | 启动网关 (Gateway) |
+| `picoclaw status` | 显示状态 |
+| `picoclaw cron list` | 列出所有定时任务 |
+| `picoclaw cron add ...` | 添加定时任务 |
### 定时任务 / 提醒 (Scheduled Tasks)
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
-* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
-* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
-* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
+- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
+- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
+- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
@@ -983,7 +759,7 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
用户群组:
-Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
+Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
@@ -997,6 +773,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
2. 添加到 `~/.picoclaw/config.json`:
+
```json
{
"tools": {
@@ -1013,11 +790,8 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
}
}
}
-
```
-
-
### 遇到内容过滤错误 (Content Filtering Errors)
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
@@ -1030,10 +804,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 等) |
\ No newline at end of file
+| 服务 | 免费层级 | 适用场景 |
+| ---------------- | -------------- | ----------------------------- |
+| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
+| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
+| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
+| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
+| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md
new file mode 100644
index 000000000..1e445d0b0
--- /dev/null
+++ b/docs/channels/dingtalk/README.zh.md
@@ -0,0 +1,33 @@
+# 钉钉
+
+钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用钉钉频道 |
+| client_id | string | 是 | 钉钉应用的 Client ID |
+| client_secret | string | 是 | 钉钉应用的 Client Secret |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
+2. 创建一个企业内部应用
+3. 从应用设置中获取 Client ID 和 Client Secret
+4. 配置OAuth和事件订阅(如需要)
+5. 将 Client ID 和 Client Secret 填入配置文件中
diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md
new file mode 100644
index 000000000..5b597eced
--- /dev/null
+++ b/docs/channels/discord/README.zh.md
@@ -0,0 +1,35 @@
+# Discord
+
+Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "mention_only": false
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 Discord 频道 |
+| token | string | 是 | Discord 机器人 Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| mention_only | bool | 否 | 是否仅响应提及机器人的消息 |
+
+## 设置流程
+
+1. 前往 [Discord 开发者门户](https://discord.com/developers/applications) 创建一个新的应用
+2. 启用 Intents:
+ - Message Content Intent
+ - Server Members Intent
+3. 获取 Bot Token
+4. 将 Bot Token 填入配置文件中
+5. 邀请机器人加入服务器并授予必要权限(例如发送消息、读取消息历史等)
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
new file mode 100644
index 000000000..310827723
--- /dev/null
+++ b/docs/channels/feishu/README.zh.md
@@ -0,0 +1,37 @@
+# 飞书
+
+飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用飞书频道 |
+| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
+| app_secret | string | 是 | 飞书应用的 App Secret |
+| encrypt_key | string | 否 | 事件回调加密密钥 |
+| verification_token | string | 否 | 用于Webhook事件验证的Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
+2. 获取 App ID 和 App Secret
+3. 配置事件订阅和Webhook URL
+4. 设置加密(可选,生产环境建议启用)
+5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md
new file mode 100644
index 000000000..fd3aa80da
--- /dev/null
+++ b/docs/channels/line/README.zh.md
@@ -0,0 +1,41 @@
+# Line
+
+PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| -------------------- | ------ | ---- | ------------------------------------------ |
+| enabled | bool | 是 | 是否启用 LINE Channel |
+| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
+| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
+| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) |
+| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) |
+| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
+2. 获取 Channel Secret 和 Channel Access Token
+3. 配置Webhook:
+ - Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网
+ - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`
+ - 启用 Webhook 并验证 URL
+4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md
new file mode 100644
index 000000000..8d53d4bef
--- /dev/null
+++ b/docs/channels/maixcam/README.zh.md
@@ -0,0 +1,31 @@
+# MaixCam
+
+MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "server_address": "0.0.0.0:8899",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| -------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 MaixCam 频道 |
+| server_address | string | 是 | TCP 服务器监听地址和端口 |
+| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 |
+
+## 使用场景
+
+MaixCam 通道使 PicoClaw 能够作为边缘设备的 AI 后端运行:
+
+- **智能监控** :MaixCAM 发送图像帧,PicoClaw 通过视觉模型进行分析
+- **物联网控制** :设备发送传感器数据,PicoClaw 协调响应
+- **离线AI** :在本地网络部署 PicoClaw 实现低延迟推理
diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md
new file mode 100644
index 000000000..6195f1c98
--- /dev/null
+++ b/docs/channels/onebot/README.zh.md
@@ -0,0 +1,31 @@
+# OneBot
+
+OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 OneBot 频道 |
+| ws_url | string | 是 | OneBot 服务器的 WebSocket URL |
+| access_token | string | 否 | 连接 OneBot 服务器的访问令牌 |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 部署一个 OneBot 兼容的实现(例如napcat)
+2. 配置 OneBot 实现以启用 WebSocket 服务并设置访问令牌(如果需要)
+3. 将 WebSocket URL 和访问令牌填入配置文件中
diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md
new file mode 100644
index 000000000..bd774960f
--- /dev/null
+++ b/docs/channels/qq/README.zh.md
@@ -0,0 +1,32 @@
+# QQ
+
+PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 QQ Channel |
+| app_id | string | 是 | QQ 机器人应用的 App ID |
+| app_secret | string | 是 | QQ 机器人应用的 App Secret |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人
+2. 通过仪表盘获取 App ID 和 App Secret
+3. 开启机器人沙箱模式, 将用户和群添加到沙箱中
+4. 将 App ID 和 App Secret 填入配置文件中
diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md
new file mode 100644
index 000000000..58ebcb566
--- /dev/null
+++ b/docs/channels/slack/README.zh.md
@@ -0,0 +1,33 @@
+# Slack
+
+Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | -------------------------------------------------------- |
+| enabled | bool | 是 | 是否启用 Slack 频道 |
+| bot_token | string | 是 | Slack 机器人的 Bot User OAuth Token (以 xoxb- 开头) |
+| app_token | string | 是 | Slack 应用的 Socket Mode App Level Token (以 xapp- 开头) |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [Slack API](https://api.slack.com/) 创建一个新的 Slack 应用
+2. 启用 Socket Mode 并获取 App Level Token
+3. 添加 Bot Token Scopes(例如`chat:write`、`im:history`等)
+4. 安装应用到工作区并获取 Bot User OAuth Token
+5. 将 Bot Token 和 App Token 填入配置文件中
diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md
new file mode 100644
index 000000000..d453c68fa
--- /dev/null
+++ b/docs/channels/telegram/README.zh.md
@@ -0,0 +1,33 @@
+# Telegram
+
+Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | --------------------------------------------------------- |
+| enabled | bool | 是 | 是否启用 Telegram 频道 |
+| token | string | 是 | Telegram 机器人 API Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |
+
+## 设置流程
+
+1. 在 Telegram 中搜索 `@BotFather`
+2. 发送 `/newbot` 命令并按照提示创建新机器人
+3. 获取 HTTP API Token
+4. 将 Token 填入配置文件中
+5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md
new file mode 100644
index 000000000..1e6a0e2b3
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.zh.md
@@ -0,0 +1,47 @@
+# 企业微信自建应用
+
+企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------------- | ------ | ---- | ---------------------------------------- |
+| corp_id | string | 是 | 企业 ID |
+| corp_secret | string | 是 | 应用程序密钥 |
+| agent_id | int | 是 | 应用程序代理 ID |
+| token | string | 是 | 回调验证令牌 |
+| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
+| webhook_host | string | 否 | HTTP 服务器绑定地址 |
+| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) |
+| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
+| allow_from | array | 否 | 用户 ID 白名单 |
+| reply_timeout | int | 否 | 回复超时时间(秒) |
+
+## 设置流程
+
+1. 登录 [企业微信管理后台](https://work.weixin.qq.com/)
+2. 进入“应用管理” -> “创建应用”
+3. 获取企业 ID (CorpID) 和应用 Secret
+4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
+5. 设置回调 URL 为 `http://:/webhook/wecom-app`
+6. 将 CorpID, Secret, AgentID 等信息填入配置文件
diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md
new file mode 100644
index 000000000..c4bb1c87e
--- /dev/null
+++ b/docs/channels/wecom/wecom_bot/README.zh.md
@@ -0,0 +1,41 @@
+# 企业微信机器人
+
+企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------------- | ------ | ---- | -------------------------------------------- |
+| token | string | 是 | 签名验证代币 |
+| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
+| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
+| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) |
+| webhook_port | int | 否 | HTTP 服务器端口(默认:18793) |
+| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) |
+| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
+| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) |
+
+## 设置流程
+
+1. 在企业微信群中添加机器人
+2. 获取 Webhook URL
+3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
+4. 将相关信息填入配置文件
From aea4f25c8387aee5e16b03a126beebbd712ff26d Mon Sep 17 00:00:00 2001
From: zepan
Date: Sat, 21 Feb 2026 22:45:47 +0800
Subject: [PATCH 016/172] 1. update wechat qrcode. 2. add CONTRIBUTING.md
---
CONTRIBUTING.md | 302 ++++++++++++++++++++++++++++++++++++++++++++
CONTRIBUTING.zh.md | 303 +++++++++++++++++++++++++++++++++++++++++++++
assets/wechat.png | Bin 144319 -> 144045 bytes
3 files changed, 605 insertions(+)
create mode 100644 CONTRIBUTING.md
create mode 100644 CONTRIBUTING.zh.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 000000000..88227f493
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,302 @@
+# Contributing to PicoClaw
+
+Thank you for your interest in contributing to PicoClaw! This project is a community-driven effort to build the lightweight and versatile personal AI assistant. We welcome contributions of all kinds: bug fixes, features, documentation, translations, and testing.
+
+PicoClaw itself was substantially developed with AI assistance — we embrace this approach and have built our contribution process around it.
+
+## Table of Contents
+
+- [Code of Conduct](#code-of-conduct)
+- [Ways to Contribute](#ways-to-contribute)
+- [Getting Started](#getting-started)
+- [Development Setup](#development-setup)
+- [Making Changes](#making-changes)
+- [AI-Assisted Contributions](#ai-assisted-contributions)
+- [Pull Request Process](#pull-request-process)
+- [Branch Strategy](#branch-strategy)
+- [Code Review](#code-review)
+- [Communication](#communication)
+
+---
+
+## Code of Conduct
+
+We are committed to maintaining a welcoming and respectful community. Be kind, constructive, and assume good faith. Harassment or discrimination of any kind will not be tolerated.
+
+---
+
+## Ways to Contribute
+
+- **Bug reports** — Open an issue using the bug report template.
+- **Feature requests** — Open an issue using the feature request template; discuss before implementing.
+- **Code** — Fix bugs or implement features. See the workflow below.
+- **Documentation** — Improve READMEs, docs, inline comments, or translations.
+- **Testing** — Run PicoClaw on new hardware, channels, or LLM providers and report your results.
+
+For substantial new features, please open an issue first to discuss the design before writing code. This prevents wasted effort and ensures alignment with the project's direction.
+
+---
+
+## Getting Started
+
+1. **Fork** the repository on GitHub.
+2. **Clone** your fork locally:
+ ```bash
+ git clone https://github.com//picoclaw.git
+ cd picoclaw
+ ```
+3. Add the upstream remote:
+ ```bash
+ git remote add upstream https://github.com/sipeed/picoclaw.git
+ ```
+
+---
+
+## Development Setup
+
+### Prerequisites
+
+- Go 1.25 or later
+- `make`
+
+### Build
+
+```bash
+make build # Build binary (runs go generate first)
+make generate # Run go generate only
+make check # Full pre-commit check: deps + fmt + vet + test
+```
+
+### Running Tests
+
+```bash
+make test # Run all tests
+go test -run TestName -v ./pkg/session/ # Run a single test
+go test -bench=. -benchmem -run='^$' ./... # Run benchmarks
+```
+
+### Code Style
+
+```bash
+make fmt # Format code
+make vet # Static analysis
+make lint # Full linter run
+```
+
+All CI checks must pass before a PR can be merged. Run `make check` locally before pushing to catch issues early.
+
+---
+
+## Making Changes
+
+### Branching
+
+Always branch off `main` and target `main` in your PR. Never push directly to `main` or any `release/*` branch:
+
+```bash
+git checkout main
+git pull upstream main
+git checkout -b your-feature-branch
+```
+
+Use descriptive branch names, e.g. `fix/telegram-timeout`, `feat/ollama-provider`, `docs/contributing-guide`.
+
+### Commits
+
+- Write clear, concise commit messages in English.
+- Use the imperative mood: "Add retry logic" not "Added retry logic".
+- Reference the related issue when relevant: `Fix session leak (#123)`.
+- Keep commits focused. One logical change per commit is preferred.
+- For minor cleanups or typo fixes, squash them into a single commit before opening a PR.
+- Refer to https://www.conventionalcommits.org/zh-hans/v1.0.0/
+
+### Keeping Up to Date
+
+Rebase your branch onto upstream `main` before opening a PR:
+
+```bash
+git fetch upstream
+git rebase upstream/main
+```
+
+---
+
+## AI-Assisted Contributions
+
+PicoClaw was built with substantial AI assistance, and we fully embrace AI-assisted development. However, contributors must understand their responsibilities when using AI tools.
+
+### Disclosure Is Required
+
+Every PR must disclose AI involvement using the PR template's **🤖 AI Code Generation** section. There are three levels:
+
+| Level | Description |
+|---|---|
+| 🤖 Fully AI-generated | AI wrote the code; contributor reviewed and validated it |
+| 🛠️ Mostly AI-generated | AI produced the draft; contributor made significant modifications |
+| 👨💻 Mostly Human-written | Contributor led; AI provided suggestions or none at all |
+
+Honest disclosure is expected. There is no stigma attached to any level — what matters is the quality of the contribution.
+
+### You Are Responsible for What You Submit
+
+Using AI to generate code does not reduce your responsibility as the contributor. Before opening a PR with AI-generated code, you must:
+
+- **Read and understand** every line of the generated code.
+- **Test it** in a real environment (see the Test Environment section of the PR template).
+- **Check for security issues** — AI models can generate subtly insecure code (e.g., path traversal, injection, credential exposure). Review carefully.
+- **Verify correctness** — AI-generated logic can be plausible-sounding but wrong. Validate the behavior, not just the syntax.
+
+PRs where it is clear the contributor has not read or tested the AI-generated code will be closed without review.
+
+### AI-Generated Code Quality Standards
+
+AI-generated contributions are held to the **same quality bar** as human-written code:
+
+- It must pass all CI checks (`make check`).
+- It must be idiomatic Go and consistent with the existing codebase style.
+- It must not introduce unnecessary abstractions, dead code, or over-engineering.
+- It must include or update tests where appropriate.
+
+### Security Review
+
+AI-generated code requires extra security scrutiny. Pay special attention to:
+
+- File path handling and sandbox escapes (see commit `244eb0b` for a real example)
+- External input validation in channel handlers and tool implementations
+- Credential or secret handling
+- Command execution (`exec.Command`, shell invocations)
+
+If you are unsure whether a piece of AI-generated code is safe, say so in the PR — reviewers will help.
+
+---
+
+## Pull Request Process
+
+### Before Opening a PR
+
+- [ ] Run `make check` and ensure it passes locally.
+- [ ] Fill in the PR template completely, including the AI disclosure section.
+- [ ] Link any related issue(s) in the PR description.
+- [ ] Keep the PR focused. Avoid bundling unrelated changes together.
+
+### PR Template Sections
+
+The PR template asks for:
+
+- **Description** — What does this change do and why?
+- **Type of Change** — Bug fix, feature, docs, or refactor.
+- **AI Code Generation** — Disclosure of AI involvement (required).
+- **Related Issue** — Link to the issue this addresses.
+- **Technical Context** — Reference URLs and reasoning (skip for pure docs PRs).
+- **Test Environment** — Hardware, OS, model/provider, and channels used for testing.
+- **Evidence** — Optional logs or screenshots demonstrating the change works.
+- **Checklist** — Self-review confirmation.
+
+### PR Size
+
+Prefer small, reviewable PRs. A PR that changes 200 lines across 5 files is much easier to review than one that changes 2000 lines across 30 files. If your feature is large, consider splitting it into a series of smaller, logically complete PRs.
+
+---
+
+## Branch Strategy
+
+### Long-Lived Branches
+
+- **`main`** — the active development branch. All feature PRs target `main`. The branch is protected: direct pushes are not permitted, and at least one maintainer approval is required before merging.
+- **`release/x.y`** — stable release branches, cut from `main` when a version is ready to ship. These branches are more strictly protected than `main`.
+
+### Requirements to Merge into `main`
+
+A PR can only be merged when all of the following are satisfied:
+
+1. **CI passes** — All GitHub Actions workflows (lint, test, build) must be green.
+2. **Reviewer approval** — At least one maintainer has approved the PR.
+3. **No unresolved review comments** — All review threads must be resolved.
+4. **PR template is complete** — Including AI disclosure and test environment.
+
+### Who Can Merge
+
+Only maintainers can merge PRs. Contributors cannot merge their own PRs, even if they have write access.
+
+### Merge Strategy
+
+We use **squash merge** for most PRs to keep the `main` history clean and readable. Each merged PR becomes a single commit referencing the PR number, e.g.:
+
+```
+feat: Add Ollama provider support (#491)
+```
+
+If a PR consists of multiple independent, well-separated commits that tell a clear story, a regular merge may be used at the maintainer's discretion.
+
+### Release Branches
+
+When a version is ready, maintainers cut a `release/x.y` branch from `main`. After that point:
+
+- **New features are not backported.** The release branch receives no new functionality after it is cut.
+- **Security fixes and critical bug fixes are cherry-picked.** If a fix in `main` qualifies (security vulnerability, data loss, crash), maintainers will cherry-pick the relevant commit(s) onto the affected `release/x.y` branch and issue a patch release.
+
+If you believe a fix in `main` should be backported to a release branch, note it in the PR description or open a separate issue. The decision rests with the maintainers.
+
+Release branches have stricter protections than `main` and are never directly pushed to under any circumstances.
+
+---
+
+## Code Review
+
+### For Contributors
+
+- Respond to review comments within a reasonable time. If you need more time, say so.
+- When you update a PR in response to feedback, briefly note what changed (e.g., "Updated to use `sync.RWMutex` as suggested").
+- If you disagree with feedback, engage respectfully. Explain your reasoning; reviewers can be wrong too.
+- Do not force-push after a review has started — it makes it harder for reviewers to see what changed. Use additional commits instead; the maintainer will squash on merge.
+
+### For Reviewers
+
+Review for:
+
+1. **Correctness** — Does the code do what it claims? Are there edge cases?
+2. **Security** — Especially for AI-generated code, tool implementations, and channel handlers.
+3. **Architecture** — Is the approach consistent with the existing design?
+4. **Simplicity** — Is there a simpler solution? Does this add unnecessary complexity?
+5. **Tests** — Are the changes covered by tests? Are existing tests still meaningful?
+
+Be constructive and specific. "This could have a race condition if two goroutines call this concurrently — consider using a mutex here" is better than "this looks wrong".
+
+
+### Reviewer List
+Once your PR is submitted, you can reach out to the assigned reviewers listed in the following table.
+
+|Function| Reviewer|
+|--- |--- |
+|Provider|@yinwm |
+|Channel |@yinwm |
+|Agent |@lxowalle|
+|Tools |@lxowalle|
+|SKill ||
+|MCP ||
+|Optimization|@lxowalle|
+|Security||
+|AI CI |@imguoguo|
+|UX ||
+|Document||
+
+---
+
+## Communication
+
+- **GitHub Issues** — Bug reports, feature requests, design discussions.
+- **GitHub Discussions** — General questions, ideas, community conversation.
+- **Pull Request comments** — Code-specific feedback.
+- **Wechat&Discord** — We will invite you when you have at least one merged PR
+
+When in doubt, open an issue before writing code. It costs little and prevents wasted effort.
+
+---
+
+## A Note on the Project's AI-Driven Origin
+
+PicoClaw's architecture was substantially designed and implemented with AI assistance, guided by human oversight. If you find something that looks odd or over-engineered, it may be an artifact of that process — opening an issue to discuss it is always welcome.
+
+We believe AI-assisted development done responsibly produces great results. We also believe humans must remain accountable for what they ship. These two beliefs are not in conflict.
+
+Thank you for contributing!
diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md
new file mode 100644
index 000000000..01a1abfd5
--- /dev/null
+++ b/CONTRIBUTING.zh.md
@@ -0,0 +1,303 @@
+# 参与贡献 PicoClaw
+
+感谢你对 PicoClaw 的关注!本项目是一个社区驱动的开源项目,目标是构建 轻量灵活,人人可用 的个人AI助手。我们欢迎一切形式的贡献:Bug 修复、新功能、文档、翻译和测试。
+
+PicoClaw 本身在很大程度上是借助 AI 辅助开发的——我们拥抱这种方式,并围绕它构建了贡献流程。
+
+## 目录
+
+- [行为准则](#行为准则)
+- [贡献方式](#贡献方式)
+- [快速开始](#快速开始)
+- [开发环境配置](#开发环境配置)
+- [提交修改](#提交修改)
+- [AI 辅助贡献](#ai-辅助贡献)
+- [Pull Request 流程](#pull-request-流程)
+- [分支策略](#分支策略)
+- [代码审查](#代码审查)
+- [沟通渠道](#沟通渠道)
+
+---
+
+## 行为准则
+
+我们致力于维护一个友好、互相尊重的社区环境。请保持善意、建设性的态度,并善意地理解他人。任何形式的骚扰或歧视均不被接受。
+
+---
+
+## 贡献方式
+
+- **Bug 反馈** — 使用 Bug 报告模板提交 Issue。
+- **功能建议** — 使用功能请求模板提交 Issue,建议在开始实现前先进行讨论。
+- **代码贡献** — 修复 Bug 或实现新功能,参见下方工作流程。
+- **文档改进** — 完善 README、文档、代码注释或翻译。
+- **测试与验证** — 在新硬件、新渠道或新 LLM 提供商上运行 PicoClaw 并反馈结果。
+
+对于较大的新功能,请先提交 Issue 讨论设计方案,再动手写代码。这能避免无效投入,也确保与项目方向保持一致。
+
+---
+
+## 快速开始
+
+1. 在 GitHub 上 **Fork** 本仓库。
+2. 将你的 Fork **克隆**到本地:
+ ```bash
+ git clone https://github.com/<你的用户名>/picoclaw.git
+ cd picoclaw
+ ```
+3. 添加上游远程仓库:
+ ```bash
+ git remote add upstream https://github.com/sipeed/picoclaw.git
+ ```
+
+---
+
+## 开发环境配置
+
+### 前置依赖
+
+- Go 1.25 或更高版本
+- `make`
+
+### 构建
+
+```bash
+make build # 构建二进制文件(会先执行 go generate)
+make generate # 仅执行 go generate
+make check # 完整的提交前检查:deps + fmt + vet + test
+```
+
+### 运行测试
+
+```bash
+make test # 运行所有测试
+go test -run TestName -v ./pkg/session/ # 运行单个测试
+go test -bench=. -benchmem -run='^$' ./... # 运行基准测试
+```
+
+### 代码风格
+
+```bash
+make fmt # 格式化代码
+make vet # 静态分析
+make lint # 完整的 lint 检查
+```
+
+所有 CI 检查通过后 PR 才能被合并。推送代码前请先在本地运行 `make check`,提前发现问题。
+
+---
+
+## 提交修改
+
+### 分支管理
+
+始终从 `main` 分支切出,并在 PR 中以 `main` 为目标分支。不要直接向 `main` 或任何 `release/*` 分支推送代码:
+
+```bash
+git checkout main
+git pull upstream main
+git checkout -b 你的功能分支名
+```
+
+请使用描述性的分支名,例如:`fix/telegram-timeout`、`feat/ollama-provider`、`docs/contributing-guide`。
+
+### Commit 规范
+
+- 使用英文撰写清晰、简洁的 commit 信息。
+- 使用祈使句:写 "Add retry logic",而不是 "Added retry logic"。
+- 有关联 Issue 时请引用:`Fix session leak (#123)`。
+- 保持 commit 专注,每个 commit 只做一件事。
+- 对于小的清理或拼写修正,提 PR 前请将其合并为一个 commit。
+- 按照 https://www.conventionalcommits.org/zh-hans/v1.0.0/ 规范来撰写
+
+### 保持与上游同步
+
+提 PR 前,请将你的分支变基到上游 `main`:
+
+```bash
+git fetch upstream
+git rebase upstream/main
+```
+
+---
+
+## AI 辅助贡献
+
+PicoClaw 在很大程度上借助 AI 辅助开发,我们完全拥抱这种开发方式。但贡献者必须清楚地了解自己在使用 AI 工具时所承担的责任。
+
+### 必须披露 AI 使用情况
+
+每个 PR 都必须通过 PR 模板中的 **🤖 AI 代码生成** 部分披露 AI 参与情况,共分三个级别:
+
+| 级别 | 说明 |
+|---|---|
+| 🤖 完全由 AI 生成 | AI 编写代码,贡献者负责审查和验证 |
+| 🛠️ 主要由 AI 生成 | AI 起草,贡献者做了较大修改 |
+| 👨💻 主要由人工编写 | 贡献者主导,AI 仅提供辅助或未使用 AI |
+
+我们期望你诚实填写。三种级别均可接受,没有任何歧视——重要的是贡献的质量。
+
+### 你对提交的代码负全责
+
+使用 AI 生成代码并不能减轻你作为贡献者的责任。在提交含有 AI 生成代码的 PR 之前,你必须:
+
+- **逐行阅读并理解**生成的代码。
+- **在真实环境中测试**(参见 PR 模板中的测试环境部分)。
+- **检查安全问题** — AI 模型可能生成存在安全隐患的代码(如路径穿越、注入攻击、凭据泄露等),请仔细审查。
+- **验证正确性** — AI 生成的逻辑可能听起来合理但实际上是错误的,请验证行为,而不仅仅是语法。
+
+如果明显可以看出贡献者没有阅读或测试 AI 生成的代码,该 PR 将被直接关闭,不予审查。
+
+### AI 生成代码的质量标准
+
+AI 生成的代码与人工编写的代码遵循**相同的质量要求**:
+
+- 必须通过所有 CI 检查(`make check`)。
+- 必须符合 Go 惯用写法,并与现有代码库的风格保持一致。
+- 不得引入不必要的抽象、死代码或过度设计。
+- 须在适当的地方包含或更新测试。
+
+### 安全审查
+
+AI 生成的代码需要格外仔细的安全审查。请特别关注以下方面:
+
+- 文件路径处理与沙箱逃逸(项目历史中的 commit `244eb0b` 就是真实案例)
+- channel 处理器和 tool 实现中的外部输入校验
+- 凭据或密钥的处理
+- 命令执行(`exec.Command`、shell 调用等)
+
+如果你不确定某段 AI 生成代码是否安全,请在 PR 中说明——审查者会帮助判断。
+
+---
+
+## Pull Request 流程
+
+### 提 PR 前的检查
+
+- [ ] 在本地运行 `make check` 并确认通过。
+- [ ] 完整填写 PR 模板,包括 AI 披露部分。
+- [ ] 在 PR 描述中关联相关 Issue。
+- [ ] 保持 PR 专注,避免将不相关的修改混在一起。
+
+### PR 模板各部分说明
+
+PR 模板要求填写:
+
+- **描述** — 这个改动做了什么,为什么要做?
+- **变更类型** — Bug 修复、新功能、文档或重构。
+- **AI 代码生成** — AI 参与情况披露(必填)。
+- **关联 Issue** — 此 PR 解决的 Issue 链接。
+- **技术背景** — 参考链接和设计理由(纯文档类 PR 可跳过)。
+- **测试环境** — 用于测试的硬件、操作系统、模型/提供商和渠道。
+- **验证证据** — 可选的日志或截图,用于证明改动有效。
+- **检查清单** — 自我审查确认。
+
+### PR 规模
+
+请尽量提交小而易于审查的 PR。一个涉及 5 个文件共 200 行改动的 PR,远比涉及 30 个文件共 2000 行改动的 PR 容易审查。如果你的功能较大,可以考虑将其拆分为一系列逻辑完整的小 PR。
+
+---
+
+## 分支策略
+
+### 长期分支
+
+- **`main`** — 活跃开发分支。所有功能 PR 均以 `main` 为目标。该分支受保护:禁止直接推送,合并前必须获得至少一名维护者的批准。
+- **`release/x.y`** — 稳定发布分支,在某个版本准备发布时从 `main` 切出。这些分支的保护级别高于 `main`。
+
+### 合并到 `main` 的前提条件
+
+PR 必须同时满足以下所有条件,才能被合并:
+
+1. **CI 全部通过** — 所有 GitHub Actions 工作流(lint、test、build)均为绿色。
+2. **获得审查者批准** — 至少一名维护者已批准该 PR。
+3. **无未解决的审查意见** — 所有审查讨论线程均已关闭。
+4. **PR 模板填写完整** — 包括 AI 披露和测试环境信息。
+
+### 谁可以合并
+
+只有维护者才能合并 PR。贡献者不能合并自己的 PR,即使拥有写权限也不行。
+
+### 合并策略
+
+为保持 `main` 历史清晰可读,我们对大多数 PR 使用 **Squash Merge**。每个合并的 PR 变为一个包含 PR 编号的单独 commit,例如:
+
+```
+feat: Add Ollama provider support (#491)
+```
+
+如果一个 PR 包含多个独立、结构清晰、能讲述完整故事的 commit,维护者可视情况使用普通 merge。
+
+### Release 分支
+
+当某个版本准备就绪时,维护者会从 `main` 切出 `release/x.y` 分支。此后:
+
+- **新功能不会被回溯(backport)。** Release 分支切出后,不再接收任何新功能。
+- **安全修复和关键 Bug 修复会被 cherry-pick 进来。** 若 `main` 上的某个修复属于安全漏洞、数据丢失或崩溃类问题,维护者会将相关 commit cherry-pick 到受影响的 `release/x.y` 分支,并发布补丁版本。
+
+如果你认为 `main` 上的某个修复应该被回溯到某个 release 分支,请在 PR 描述中注明,或单独开一个 Issue 说明。最终决定由维护者做出。
+
+Release 分支的保护级别高于 `main`,在任何情况下均不允许直接推送。
+
+---
+
+## 代码审查
+
+### 对贡献者的建议
+
+- 在合理时间内回复审查意见。如果需要更多时间,请告知。
+- 更新 PR 以响应反馈时,简要说明改动内容(例如:"按建议改用了 `sync.RWMutex`")。
+- 如果你不同意某条反馈,请礼貌地阐述你的理由——审查者也可能有判断失误的时候。
+- 审查开始后请不要 force push——这会让审查者难以追踪变化。请使用额外的 commit,维护者在合并时会进行 squash。
+
+### 对审查者的建议
+
+审查重点:
+
+1. **正确性** — 代码是否实现了其声称的功能?是否存在边界情况?
+2. **安全性** — 对 AI 生成代码、tool 实现和 channel 处理器尤其需要关注。
+3. **架构** — 实现方式是否与现有设计一致?
+4. **简洁性** — 是否有更简单的方案?是否引入了不必要的复杂度?
+5. **测试** — 改动是否有测试覆盖?现有测试是否仍然有意义?
+
+请给出建设性且具体的反馈。"如果两个 goroutine 同时调用这个函数可能会有竞态条件,建议在这里加一个 mutex" 远比 "这里看起来有问题" 更有帮助。
+
+### 审查者列表
+提交对应PR后,可以参考下表联系对应的审查人员沟通
+
+|Function| Reviewer|
+|--- |--- |
+|Provider|@yinwm |
+|Channel |@yinwm |
+|Agent |@lxowalle|
+|Tools |@lxowalle|
+|SKill ||
+|MCP ||
+|Optimization|@lxowalle|
+|Security||
+|AI CI |@imguoguo|
+|UX ||
+|Document||
+
+
+
+---
+
+## 沟通渠道
+
+- **GitHub Issues** — Bug 报告、功能建议、设计讨论。
+- **GitHub Discussions** — 一般性问题、想法交流、社区讨论。
+- **Pull Request 评论** — 与具体代码相关的反馈。
+- **Wechat&Discord** — 当你有至少一个已合并的PR后,我们会邀请你加入开发者交流群
+
+有疑问时,请先开 Issue 讨论,再动手写代码。这几乎没有成本,却能避免大量无效投入。
+
+---
+
+## 关于本项目的 AI 驱动起源
+
+PicoClaw 的架构在人工监督下,经由 AI 辅助完成了大量设计和实现工作。如果你发现某处看起来奇怪或过度设计,这可能是该过程留下的痕迹——欢迎提 Issue 讨论。
+
+我们相信,负责任地使用 AI 辅助开发能产生优秀的成果。我们同样相信,人类必须对自己提交的内容负责。这两点并不矛盾。
+
+感谢你的贡献!
diff --git a/assets/wechat.png b/assets/wechat.png
index 8fc41ea7d53cfc9e0ccb7b6fe5a4fd6d079cee7c..a34217c335542a13aace2103bd15ccebf94acde2 100644
GIT binary patch
literal 144045
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