Add WeCom AIBot channel implementation and tests
- Introduced WeCom AIBot channel configuration in config.go with relevant fields. - Implemented WeCom AIBot channel factory registration in init.go. - Created unit tests for WeCom AIBot channel functionalities including initialization, start/stop behavior, webhook path handling, message encryption/decryption, and signature generation. - Set default values for WeCom AIBot configuration in defaults.go.
This commit is contained in:
parent
26d1b8e374
commit
6caee427bb
7 changed files with 1421 additions and 15 deletions
|
|
@ -127,7 +127,7 @@
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
"_comment": "WeCom Bot - Easier setup, supports group chats",
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_TOKEN",
|
"token": "YOUR_TOKEN",
|
||||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
|
@ -149,6 +149,17 @@
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5,
|
"reply_timeout": 5,
|
||||||
"reasoning_channel_id": ""
|
"reasoning_channel_id": ""
|
||||||
|
},
|
||||||
|
"wecom_aibot": {
|
||||||
|
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
|
||||||
|
"enabled": false,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18791,
|
||||||
|
"webhook_path": "/webhook/wecom-aibot",
|
||||||
|
"max_steps": 10,
|
||||||
|
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,10 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("wecom", "WeCom")
|
m.initChannel("wecom", "WeCom")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.WeComAIBot.Enabled && m.config.Channels.WeComAIBot.Token != "" {
|
||||||
|
m.initChannel("wecom_aibot", "WeCom AI Bot")
|
||||||
|
}
|
||||||
|
|
||||||
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
|
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
|
||||||
m.initChannel("wecom_app", "WeCom App")
|
m.initChannel("wecom_app", "WeCom App")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1143
pkg/channels/wecom/aibot.go
Normal file
1143
pkg/channels/wecom/aibot.go
Normal file
File diff suppressed because it is too large
Load diff
218
pkg/channels/wecom/aibot_test.go
Normal file
218
pkg/channels/wecom/aibot_test.go
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
package wecom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewWeComAIBotChannel(t *testing.T) {
|
||||||
|
t.Run("success with valid config", func(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
WebhookPath: "/webhook/test",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == nil {
|
||||||
|
t.Fatal("Expected channel to be created")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.Name() != "wecom_aibot" {
|
||||||
|
t.Errorf("Expected name 'wecom_aibot', got '%s'", ch.Name())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error with missing token", func(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing token, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error with missing encoding key", func(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
_, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Expected error for missing encoding key, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComAIBotChannelStartStop(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, err := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create channel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Test Start
|
||||||
|
if err := ch.Start(ctx); err != nil {
|
||||||
|
t.Fatalf("Failed to start channel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ch.IsRunning() {
|
||||||
|
t.Error("Expected channel to be running")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Stop
|
||||||
|
if err := ch.Stop(ctx); err != nil {
|
||||||
|
t.Fatalf("Failed to stop channel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch.IsRunning() {
|
||||||
|
t.Error("Expected channel to be stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComAIBotChannelWebhookPath(t *testing.T) {
|
||||||
|
t.Run("default path", func(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
expectedPath := "/webhook/wecom-aibot"
|
||||||
|
if ch.WebhookPath() != expectedPath {
|
||||||
|
t.Errorf("Expected webhook path '%s', got '%s'", expectedPath, ch.WebhookPath())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom path", func(t *testing.T) {
|
||||||
|
customPath := "/custom/webhook"
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
WebhookPath: customPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
if ch.WebhookPath() != customPath {
|
||||||
|
t.Errorf("Expected webhook path '%s', got '%s'", customPath, ch.WebhookPath())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateStreamID(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
// Generate multiple IDs and check they are unique
|
||||||
|
ids := make(map[string]bool)
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
id := ch.generateStreamID()
|
||||||
|
|
||||||
|
if len(id) != 10 {
|
||||||
|
t.Errorf("Expected stream ID length 10, got %d", len(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ids[id] {
|
||||||
|
t.Errorf("Duplicate stream ID generated: %s", id)
|
||||||
|
}
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncryptDecrypt(t *testing.T) {
|
||||||
|
// Use a valid 43-character base64 key (企业微信标准格式)
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG", // 43 characters
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
plaintext := "Hello, World!"
|
||||||
|
receiveid := ""
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
encrypted, err := ch.encryptMessage(plaintext, receiveid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to encrypt message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if encrypted == "" {
|
||||||
|
t.Fatal("Encrypted message is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
decrypted, err := decryptMessageWithVerify(encrypted, cfg.EncodingAESKey, receiveid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to decrypt message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decrypted != plaintext {
|
||||||
|
t.Errorf("Expected decrypted message '%s', got '%s'", plaintext, decrypted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateSignature(t *testing.T) {
|
||||||
|
cfg := config.WeComAIBotConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: "testkey1234567890123456789012345678901234567",
|
||||||
|
}
|
||||||
|
|
||||||
|
messageBus := bus.NewMessageBus()
|
||||||
|
ch, _ := NewWeComAIBotChannel(cfg, messageBus)
|
||||||
|
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
encrypt := "encrypted_msg"
|
||||||
|
|
||||||
|
signature := ch.generateSignature(timestamp, nonce, encrypt)
|
||||||
|
|
||||||
|
if signature == "" {
|
||||||
|
t.Error("Generated signature is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature using verifySignature function
|
||||||
|
if !verifySignature(cfg.Token, signature, timestamp, nonce, encrypt) {
|
||||||
|
t.Error("Generated signature does not verify correctly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,4 +13,7 @@ func init() {
|
||||||
channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
channels.RegisterFactory("wecom_app", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
return NewWeComAppChannel(cfg.Channels.WeComApp, b)
|
return NewWeComAppChannel(cfg.Channels.WeComApp, b)
|
||||||
})
|
})
|
||||||
|
channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,7 @@ type ChannelsConfig struct {
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
WeCom WeComConfig `json:"wecom"`
|
WeCom WeComConfig `json:"wecom"`
|
||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
|
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -360,6 +361,19 @@ type WeComAppConfig struct {
|
||||||
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WeComAIBotConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_TOKEN"`
|
||||||
|
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ENCODING_AES_KEY"`
|
||||||
|
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_HOST"`
|
||||||
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PORT"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_ALLOW_FROM"`
|
||||||
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REPLY_TIMEOUT"`
|
||||||
|
MaxSteps int `json:"max_steps" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_MAX_STEPS"` // Maximum streaming steps
|
||||||
|
WelcomeMessage string `json:"welcome_message" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_WELCOME_MESSAGE"` // Sent on enter_chat event; empty = no welcome
|
||||||
|
}
|
||||||
|
|
||||||
type PicoConfig struct {
|
type PicoConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
|
||||||
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`
|
||||||
|
|
@ -637,7 +651,8 @@ func (c *Config) migrateChannelConfigs() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// OneBot: group_trigger_prefix -> group_trigger.prefixes
|
// OneBot: group_trigger_prefix -> group_trigger.prefixes
|
||||||
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 && len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
|
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
|
||||||
|
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
|
||||||
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
|
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,6 +137,18 @@ func DefaultConfig() *Config {
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
ReplyTimeout: 5,
|
ReplyTimeout: 5,
|
||||||
},
|
},
|
||||||
|
WeComAIBot: WeComAIBotConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Token: "",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
WebhookHost: "0.0.0.0",
|
||||||
|
WebhookPort: 18791,
|
||||||
|
WebhookPath: "/webhook/wecom-aibot",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
ReplyTimeout: 5,
|
||||||
|
MaxSteps: 10,
|
||||||
|
WelcomeMessage: "Hello! I'm your AI assistant. How can I help you today?",
|
||||||
|
},
|
||||||
Pico: PicoConfig{
|
Pico: PicoConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Token: "",
|
Token: "",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue