feat(channels): add wecom_ws (WeCom WebSocket) channel support

Add WeCom WebSocket channel for enterprise WeChat AI Bot integration:
- Implement WebSocket connection with auto-reconnect
- Support text, image, video, file and quote messages
- Add group policies and allowlist management
- Add message deduplication with persistent storage
- Add event manager for connection state handling
- Add comprehensive tests

Update init.go to register wecom_ws channel factory
Add WeComWSConfig to config.go~
This commit is contained in:
王璐瑶 2026-03-11 10:28:13 +08:00
parent 9cd2d21800
commit 9fb9b423ec
9 changed files with 2843 additions and 0 deletions

View file

@ -273,6 +273,10 @@ func (m *Manager) initChannels() error {
m.initChannel("wecom_app", "WeCom App")
}
if m.config.Channels.WeComWS.Enabled && m.config.Channels.WeComWS.BotID != "" && m.config.Channels.WeComWS.Secret != "" {
m.initChannel("wecom_ws", "WeCom WebSocket")
}
if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" {
m.initChannel("pico", "Pico")
}

View file

@ -0,0 +1,116 @@
package wecom
import (
"github.com/sipeed/picoclaw/pkg/logger"
)
// EventType 事件类型
type EventType string
const (
// EventConnected WebSocket 连接成功
EventConnected EventType = "connected"
// EventDisconnected WebSocket 断开连接
EventDisconnected EventType = "disconnected"
// EventAuthenticated 认证成功
EventAuthenticated EventType = "authenticated"
// EventError 发生错误
EventError EventType = "error"
// EventReconnecting 正在重连
EventReconnecting EventType = "reconnecting"
// EventMessageReceived 收到消息
EventMessageReceived EventType = "message_received"
// EventMessageSent 发送消息
EventMessageSent EventType = "message_sent"
)
// Event 事件
type Event struct {
Type EventType
Payload interface{}
}
// EventHandler 事件处理器
type EventHandler func(event Event)
// EventManager 事件管理器
type EventManager struct {
handlers map[EventType][]EventHandler
}
// NewEventManager 创建新的事件管理器
func NewEventManager() *EventManager {
return &EventManager{
handlers: make(map[EventType][]EventHandler),
}
}
// On 注册事件处理器
func (em *EventManager) On(eventType EventType, handler EventHandler) {
em.handlers[eventType] = append(em.handlers[eventType], handler)
}
// Off 移除事件处理器(通过索引)
func (em *EventManager) Off(eventType EventType, index int) {
handlers := em.handlers[eventType]
if index >= 0 && index < len(handlers) {
em.handlers[eventType] = append(handlers[:index], handlers[index+1:]...)
}
}
// Emit 触发事件
func (em *EventManager) Emit(eventType EventType, payload interface{}) {
event := Event{
Type: eventType,
Payload: payload,
}
logger.DebugCF("wecom_ws", "Event emitted", map[string]any{
"type": eventType,
})
for _, handler := range em.handlers[eventType] {
go handler(event)
}
}
// EventPayloadConnected 连接成功事件载荷
type EventPayloadConnected struct {
URL string
}
// EventPayloadDisconnected 断开连接事件载荷
type EventPayloadDisconnected struct {
URL string
Error error
}
// EventPayloadAuthenticated 认证成功事件载荷
type EventPayloadAuthenticated struct {
BotID string
}
// EventPayloadError 错误事件载荷
type EventPayloadError struct {
Error error
}
// EventPayloadMessageReceived 收到消息事件载荷
type EventPayloadMessageReceived struct {
MsgType string
ChatID string
From string
}
// EventPayloadMessageSent 发送消息事件载荷
type EventPayloadMessageSent struct {
MsgType string
ChatID string
}
// EventPayloadReconnecting 正在重连事件载荷
type EventPayloadReconnecting struct {
URL string
Attempt int
BackoffMs int
}

View file

@ -0,0 +1,123 @@
package wecom
import (
"bytes"
"strings"
)
// FileType 文件类型
type FileType string
const (
FileTypeJPEG FileType = "image/jpeg"
FileTypePNG FileType = "image/png"
FileTypeGIF FileType = "image/gif"
FileTypeWebP FileType = "image/webp"
FileTypePDF FileType = "application/pdf"
FileTypeDOC FileType = "application/msword"
FileTypeDOCX FileType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
FileTypeUnknown FileType = "unknown"
)
// FileTypeInfo 文件类型信息
type FileTypeInfo struct {
Type FileType
Ext string
MIMEType string
}
// fileTypeSignatures 文件类型签名(魔数)
var fileTypeSignatures = []struct {
Signature []byte
Type FileType
Ext string
MIMEType string
}{
// JPEG: FF D8 FF
{[]byte{0xFF, 0xD8, 0xFF}, FileTypeJPEG, ".jpg", "image/jpeg"},
// PNG: 89 50 4E 47
{[]byte{0x89, 0x50, 0x4E, 0x47}, FileTypePNG, ".png", "image/png"},
// GIF: 47 49 46 38
{[]byte{0x47, 0x49, 0x46, 0x38}, FileTypeGIF, ".gif", "image/gif"},
// WebP: 52 49 46 46 ... 57 45 42 50
{[]byte{0x52, 0x49, 0x46, 0x46}, FileTypeWebP, ".webp", "image/webp"},
// PDF: 25 50 44 46
{[]byte{0x25, 0x50, 0x44, 0x46}, FileTypePDF, ".pdf", "application/pdf"},
// DOC: D0 CF 11 E0 (OLE Compound Document)
{[]byte{0xD0, 0xCF, 0x11, 0xE0}, FileTypeDOC, ".doc", "application/msword"},
// DOCX: 50 4B 03 04 (ZIP格式需要进一步检查)
{[]byte{0x50, 0x4B, 0x03, 0x04}, FileTypeDOCX, ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"},
}
// DetectFileType 检测文件类型
func DetectFileType(data []byte) FileTypeInfo {
if len(data) < 4 {
return FileTypeInfo{Type: FileTypeUnknown, Ext: "", MIMEType: "application/octet-stream"}
}
for _, sig := range fileTypeSignatures {
if len(data) >= len(sig.Signature) && bytes.HasPrefix(data, sig.Signature) {
// 对于 WebP 需要额外检查
if sig.Type == FileTypeWebP && len(data) >= 12 {
// WebP 的签名在 8-11 字节位置
if !bytes.Equal(data[8:12], []byte{0x57, 0x45, 0x42, 0x50}) {
continue
}
}
return FileTypeInfo{
Type: sig.Type,
Ext: sig.Ext,
MIMEType: sig.MIMEType,
}
}
}
return FileTypeInfo{Type: FileTypeUnknown, Ext: "", MIMEType: "application/octet-stream"}
}
// IsImage 检查是否为图片
func IsImage(fileType FileType) bool {
switch fileType {
case FileTypeJPEG, FileTypePNG, FileTypeGIF, FileTypeWebP:
return true
default:
return false
}
}
// IsDocument 检查是否为文档
func IsDocument(fileType FileType) bool {
switch fileType {
case FileTypePDF, FileTypeDOC, FileTypeDOCX:
return true
default:
return false
}
}
// GetFileTypeByExt 根据扩展名获取文件类型
func GetFileTypeByExt(ext string) FileTypeInfo {
ext = strings.ToLower(ext)
if !strings.HasPrefix(ext, ".") {
ext = "." + ext
}
switch ext {
case ".jpg", ".jpeg":
return FileTypeInfo{Type: FileTypeJPEG, Ext: ".jpg", MIMEType: "image/jpeg"}
case ".png":
return FileTypeInfo{Type: FileTypePNG, Ext: ".png", MIMEType: "image/png"}
case ".gif":
return FileTypeInfo{Type: FileTypeGIF, Ext: ".gif", MIMEType: "image/gif"}
case ".webp":
return FileTypeInfo{Type: FileTypeWebP, Ext: ".webp", MIMEType: "image/webp"}
case ".pdf":
return FileTypeInfo{Type: FileTypePDF, Ext: ".pdf", MIMEType: "application/pdf"}
case ".doc":
return FileTypeInfo{Type: FileTypeDOC, Ext: ".doc", MIMEType: "application/msword"}
case ".docx":
return FileTypeInfo{Type: FileTypeDOCX, Ext: ".docx", MIMEType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"}
default:
return FileTypeInfo{Type: FileTypeUnknown, Ext: ext, MIMEType: "application/octet-stream"}
}
}

110
pkg/channels/wecom/group.go Normal file
View file

@ -0,0 +1,110 @@
package wecom
import (
"sync"
"github.com/sipeed/picoclaw/pkg/config"
)
// GroupPolicy 群组策略
type GroupPolicy struct {
AllowFrom []string `json:"allow_from"` // 允许的用户列表
MentionOnly bool `json:"mention_only"` // 是否只在被@时响应
Prefixes []string `json:"prefixes"` // 触发前缀
}
// GroupManager 群组管理器
type GroupManager struct {
policies map[string]*GroupPolicy
mu sync.RWMutex
}
// NewGroupManager 创建新的群组管理器
func NewGroupManager(policies map[string]config.GroupPolicyConfig) *GroupManager {
gm := &GroupManager{
policies: make(map[string]*GroupPolicy),
}
// 转换配置
for groupID, policy := range policies {
gm.policies[groupID] = &GroupPolicy{
AllowFrom: policy.AllowFrom,
MentionOnly: policy.MentionOnly,
Prefixes: policy.Prefixes,
}
}
return gm
}
// GetPolicy 获取群组策略
func (gm *GroupManager) GetPolicy(groupID string) *GroupPolicy {
gm.mu.RLock()
defer gm.mu.RUnlock()
return gm.policies[groupID]
}
// IsAllowedInGroup 检查用户是否在群组白名单中
func (gm *GroupManager) IsAllowedInGroup(groupID, userID string) bool {
policy := gm.GetPolicy(groupID)
if policy == nil {
// 没有特定策略,允许所有
return true
}
if len(policy.AllowFrom) == 0 {
return true
}
for _, allowed := range policy.AllowFrom {
if allowed == userID {
return true
}
}
return false
}
// ShouldRespondInGroup 检查是否应该在群组中响应
func (gm *GroupManager) ShouldRespondInGroup(groupID string, isMentioned bool, content string) (bool, string) {
policy := gm.GetPolicy(groupID)
if policy == nil {
// 没有特定策略,使用默认行为
return true, content
}
// 检查是否被@或提及
if isMentioned {
return true, content
}
// 如果设置了 mention_only且没有被@,则不响应
if policy.MentionOnly {
return false, content
}
// 检查前缀
if len(policy.Prefixes) > 0 {
for _, prefix := range policy.Prefixes {
if len(content) >= len(prefix) && content[:len(prefix)] == prefix {
return true, content[len(prefix):]
}
}
return false, content
}
return true, content
}
// SetPolicy 设置群组策略
func (gm *GroupManager) SetPolicy(groupID string, policy *GroupPolicy) {
gm.mu.Lock()
defer gm.mu.Unlock()
gm.policies[groupID] = policy
}
// RemovePolicy 移除群组策略
func (gm *GroupManager) RemovePolicy(groupID string) {
gm.mu.Lock()
defer gm.mu.Unlock()
delete(gm.policies, groupID)
}

View file

@ -16,4 +16,7 @@ func init() {
channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b)
})
channels.RegisterFactory("wecom_ws", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewWeComWSChannel(cfg.Channels.WeComWS, b)
})
}

View file

@ -0,0 +1,187 @@
package wecom
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
)
// ReqIDStore req_id 存储
type ReqIDStore struct {
data map[string]time.Time
mu sync.RWMutex
filePath string
ttl time.Duration
}
// NewReqIDStore 创建新的 req_id 存储
func NewReqIDStore(persistencePath string) *ReqIDStore {
if persistencePath == "" {
persistencePath = filepath.Join(os.TempDir(), "picoclaw", "wecom_ws")
}
filePath := filepath.Join(persistencePath, "req_ids.json")
store := &ReqIDStore{
data: make(map[string]time.Time),
filePath: filePath,
ttl: 24 * time.Hour, // 默认24小时过期
}
// 加载历史数据
store.Load()
return store
}
// Add 添加 req_id
func (s *ReqIDStore) Add(reqID string) {
s.mu.Lock()
defer s.mu.Unlock()
s.data[reqID] = time.Now()
}
// Exists 检查 req_id 是否存在
func (s *ReqIDStore) Exists(reqID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
timestamp, exists := s.data[reqID]
if !exists {
return false
}
// 检查是否过期
if time.Since(timestamp) > s.ttl {
return false
}
return true
}
// Save 保存到磁盘
func (s *ReqIDStore) Save() error {
s.mu.RLock()
defer s.mu.RUnlock()
// 清理过期数据
s.cleanupLocked()
// 创建目录
dir := filepath.Dir(s.filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// 序列化数据
data := struct {
ReqIDs map[string]time.Time `json:"req_ids"`
LastCleanup time.Time `json:"last_cleanup"`
}{
ReqIDs: s.data,
LastCleanup: time.Now(),
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal data: %w", err)
}
// 写入文件
if err := os.WriteFile(s.filePath, jsonData, 0644); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
logger.DebugCF("wecom_ws", "ReqID store saved", map[string]any{
"count": len(s.data),
"path": s.filePath,
})
return nil
}
// Load 从磁盘加载
func (s *ReqIDStore) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
// 检查文件是否存在
if _, err := os.Stat(s.filePath); os.IsNotExist(err) {
logger.DebugC("wecom_ws", "ReqID store file not found, starting fresh")
return nil
}
// 读取文件
jsonData, err := os.ReadFile(s.filePath)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
// 解析数据
var data struct {
ReqIDs map[string]time.Time `json:"req_ids"`
LastCleanup time.Time `json:"last_cleanup"`
}
if err := json.Unmarshal(jsonData, &data); err != nil {
return fmt.Errorf("failed to unmarshal data: %w", err)
}
s.data = data.ReqIDs
if s.data == nil {
s.data = make(map[string]time.Time)
}
// 清理过期数据
s.cleanupLocked()
logger.DebugCF("wecom_ws", "ReqID store loaded", map[string]any{
"count": len(s.data),
"path": s.filePath,
})
return nil
}
// Cleanup 清理过期数据
func (s *ReqIDStore) Cleanup() {
s.mu.Lock()
defer s.mu.Unlock()
s.cleanupLocked()
}
// cleanupLocked 清理过期数据(需要持有锁)
func (s *ReqIDStore) cleanupLocked() {
now := time.Now()
for reqID, timestamp := range s.data {
if now.Sub(timestamp) > s.ttl {
delete(s.data, reqID)
}
}
}
// StartAutoSave 启动自动保存
func (s *ReqIDStore) StartAutoSave(interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for range ticker.C {
if err := s.Save(); err != nil {
logger.ErrorCF("wecom_ws", "Failed to auto-save req_id store", map[string]any{
"error": err.Error(),
})
}
}
}()
}
// Stop 停止并保存
func (s *ReqIDStore) Stop() error {
return s.Save()
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,413 @@
package wecom
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewWeComWSChannel(t *testing.T) {
tests := []struct {
name string
cfg config.WeComWSConfig
wantErr bool
errMsg string
}{
{
name: "success with valid config",
cfg: config.WeComWSConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
},
wantErr: false,
},
{
name: "error with missing bot_id",
cfg: config.WeComWSConfig{
Enabled: true,
Secret: "test_secret",
},
wantErr: true,
errMsg: "bot_id and secret are required",
},
{
name: "error with missing secret",
cfg: config.WeComWSConfig{
Enabled: true,
BotID: "test_bot_id",
},
wantErr: true,
errMsg: "bot_id and secret are required",
},
{
name: "success with default values",
cfg: config.WeComWSConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
messageBus := bus.NewMessageBus()
ch, err := NewWeComWSChannel(tt.cfg, messageBus)
if tt.wantErr {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.errMsg)
assert.Nil(t, ch)
} else {
require.NoError(t, err)
assert.NotNil(t, ch)
assert.Equal(t, "wecom_ws", ch.Name())
assert.Equal(t, tt.cfg.BotID, ch.config.BotID)
assert.Equal(t, tt.cfg.Secret, ch.config.Secret)
// 验证默认值被正确设置
if tt.cfg.WSURL == "" {
assert.Equal(t, defaultWSURL, ch.config.WSURL)
}
}
})
}
}
func TestWeComWSChannelStartStop(t *testing.T) {
cfg := config.WeComWSConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComWSChannel(cfg, messageBus)
require.NoError(t, err)
ctx := context.Background()
// 测试 Start
err = ch.Start(ctx)
require.NoError(t, err)
assert.True(t, ch.IsRunning())
// 等待一段时间让 goroutine 启动
time.Sleep(100 * time.Millisecond)
// 测试 Stop
err = ch.Stop(ctx)
require.NoError(t, err)
assert.False(t, ch.IsRunning())
}
func TestWeComWSChannelName(t *testing.T) {
cfg := config.WeComWSConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComWSChannel(cfg, messageBus)
require.NoError(t, err)
assert.Equal(t, "wecom_ws", ch.Name())
}
func TestWeComWSChannelIsAllowed(t *testing.T) {
tests := []struct {
name string
allowFrom []string
senderID string
want bool
}{
{
name: "empty allowlist allows all",
allowFrom: []string{},
senderID: "any_user",
want: true,
},
{
name: "allowlist restricts users",
allowFrom: []string{"allowed_user"},
senderID: "allowed_user",
want: true,
},
{
name: "not in allowlist",
allowFrom: []string{"allowed_user"},
senderID: "other_user",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.WeComWSConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
AllowFrom: tt.allowFrom,
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComWSChannel(cfg, messageBus)
require.NoError(t, err)
got := ch.IsAllowed(tt.senderID)
assert.Equal(t, tt.want, got)
})
}
}
func TestWeComWSChannelReasoningChannelID(t *testing.T) {
cfg := config.WeComWSConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
ReasoningChannelID: "reasoning_channel_123",
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComWSChannel(cfg, messageBus)
require.NoError(t, err)
assert.Equal(t, "reasoning_channel_123", ch.ReasoningChannelID())
}
func TestShouldRespondInGroup(t *testing.T) {
tests := []struct {
name string
groupTrigger config.GroupTriggerConfig
isMentioned bool
content string
wantRespond bool
wantContent string
}{
{
name: "mentioned always responds",
isMentioned: true,
content: "@bot hello",
wantRespond: true,
wantContent: "@bot hello", // BaseChannel.ShouldRespondInGroup 不会去除 mention
},
{
name: "mention only without mention",
groupTrigger: config.GroupTriggerConfig{
MentionOnly: true,
},
isMentioned: false,
content: "hello",
wantRespond: false,
wantContent: "hello", // 当不响应时,返回原始内容
},
{
name: "prefix match",
groupTrigger: config.GroupTriggerConfig{
Prefixes: []string{"/bot", "@bot"},
},
isMentioned: false,
content: "/bot hello",
wantRespond: true,
wantContent: "hello",
},
{
name: "prefix no match",
groupTrigger: config.GroupTriggerConfig{
Prefixes: []string{"/bot"},
},
isMentioned: false,
content: "hello",
wantRespond: false,
wantContent: "hello", // 当不响应时,返回原始内容
},
{
name: "no group trigger config",
isMentioned: false,
content: "hello",
wantRespond: true,
wantContent: "hello",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.WeComWSConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
GroupTrigger: tt.groupTrigger,
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComWSChannel(cfg, messageBus)
require.NoError(t, err)
respond, content := ch.ShouldRespondInGroup(tt.isMentioned, tt.content)
assert.Equal(t, tt.wantRespond, respond)
assert.Equal(t, tt.wantContent, content)
})
}
}
func TestWeComWSMessageStructure(t *testing.T) {
tests := []struct {
name string
msg WeComWSMessage
}{
{
name: "subscribe message",
msg: WeComWSMessage{
Cmd: string(CmdSubscribe),
Headers: MessageHeaders{
ReqID: "test_req_id",
},
Body: mustMarshal(t, SubscribeBody{
Secret: "test_secret",
BotID: "test_bot_id",
}),
},
},
{
name: "ping message",
msg: WeComWSMessage{
Cmd: string(CmdPing),
Headers: MessageHeaders{
ReqID: "test_req_id",
},
Body: json.RawMessage("{}"),
},
},
{
name: "response message",
msg: WeComWSMessage{
Cmd: string(CmdAIBotResponse),
Headers: MessageHeaders{
ReqID: "test_req_id",
},
Body: mustMarshal(t, ResponseMessage{
MsgType: "stream",
Stream: &StreamContent{
ID: "test_stream_id",
Finish: true,
Content: "Hello",
},
}),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证可以正确序列化和反序列化
data, err := json.Marshal(tt.msg)
require.NoError(t, err)
var decoded WeComWSMessage
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, tt.msg.Cmd, decoded.Cmd)
assert.Equal(t, tt.msg.Headers.ReqID, decoded.Headers.ReqID)
})
}
}
func TestCallbackMessageStructure(t *testing.T) {
tests := []struct {
name string
msg CallbackMessage
}{
{
name: "text message",
msg: CallbackMessage{
MsgID: "msg_123",
AIBotID: "bot_456",
ChatID: "chat_789",
ChatType: "single",
From: From{UserID: "user_001"},
ResponseURL: "https://example.com/response",
MsgType: "text",
Text: &Text{Content: "Hello"},
},
},
{
name: "image message",
msg: CallbackMessage{
MsgID: "msg_123",
AIBotID: "bot_456",
ChatID: "chat_789",
ChatType: "group",
From: From{UserID: "user_001"},
ResponseURL: "https://example.com/response",
MsgType: "image",
Image: &Image{
URL: "https://example.com/image.jpg",
MD5: "abc123",
},
},
},
{
name: "mixed message",
msg: CallbackMessage{
MsgID: "msg_123",
AIBotID: "bot_456",
ChatID: "chat_789",
ChatType: "group",
From: From{UserID: "user_001"},
ResponseURL: "https://example.com/response",
MsgType: "mixed",
Mixed: &Mixed{
MsgItem: []MixedItem{
{MsgType: "text", Text: &Text{Content: "Hello"}},
{MsgType: "image", Image: &Image{URL: "https://example.com/image.jpg"}},
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 验证可以正确序列化和反序列化
data, err := json.Marshal(tt.msg)
require.NoError(t, err)
var decoded CallbackMessage
err = json.Unmarshal(data, &decoded)
require.NoError(t, err)
assert.Equal(t, tt.msg.MsgID, decoded.MsgID)
assert.Equal(t, tt.msg.MsgType, decoded.MsgType)
})
}
}
func TestWeComWSConfigDefaults(t *testing.T) {
cfg := config.WeComWSConfig{
Enabled: true,
BotID: "test_bot_id",
Secret: "test_secret",
// 其他字段使用零值
}
messageBus := bus.NewMessageBus()
ch, err := NewWeComWSChannel(cfg, messageBus)
require.NoError(t, err)
// 验证默认值
assert.Equal(t, defaultWSURL, ch.config.WSURL)
assert.Equal(t, int(defaultReconnectInterval.Seconds()), ch.config.ReconnectInterval)
assert.Equal(t, int(defaultHeartbeatInterval.Seconds()), ch.config.HeartbeatInterval)
assert.Equal(t, int(defaultReplyTimeout.Seconds()), ch.config.ReplyTimeout)
assert.Equal(t, defaultMaxReconnectAttempts, ch.config.MaxReconnectAttempts)
}
// Helper function
func mustMarshal(t *testing.T, v interface{}) json.RawMessage {
data, err := json.Marshal(v)
require.NoError(t, err)
return data
}

View file

@ -242,6 +242,7 @@ type ChannelsConfig struct {
WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
WeComWS WeComWSConfig `json:"wecom_ws"`
Pico PicoConfig `json:"pico"`
IRC IRCConfig `json:"irc"`
}
@ -430,6 +431,32 @@ type WeComAIBotConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_AIBOT_REASONING_CHANNEL_ID"`
}
type WeComWSConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_WS_ENABLED"`
BotID string `json:"bot_id" env:"PICOCLAW_CHANNELS_WECOM_WS_BOT_ID"`
Secret string `json:"secret" env:"PICOCLAW_CHANNELS_WECOM_WS_SECRET"`
WSURL string `json:"ws_url" env:"PICOCLAW_CHANNELS_WECOM_WS_WS_URL"`
ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_WECOM_WS_RECONNECT_INTERVAL"`
HeartbeatInterval int `json:"heartbeat_interval" env:"PICOCLAW_CHANNELS_WECOM_WS_HEARTBEAT_INTERVAL"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_WS_REPLY_TIMEOUT"`
MaxReconnectAttempts int `json:"max_reconnect_attempts" env:"PICOCLAW_CHANNELS_WECOM_WS_MAX_RECONNECT_ATTEMPTS"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_WS_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
GroupPolicies map[string]GroupPolicyConfig `json:"group_policies,omitempty"`
SendThinkingMessage bool `json:"send_thinking_message" env:"PICOCLAW_CHANNELS_WECOM_WS_SEND_THINKING_MESSAGE"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_WS_REASONING_CHANNEL_ID"`
MediaMaxSize int `json:"media_max_size" env:"PICOCLAW_CHANNELS_WECOM_WS_MEDIA_MAX_SIZE"`
MediaCachePath string `json:"media_cache_path" env:"PICOCLAW_CHANNELS_WECOM_WS_MEDIA_CACHE_PATH"`
EnableMediaDownload bool `json:"enable_media_download" env:"PICOCLAW_CHANNELS_WECOM_WS_ENABLE_MEDIA_DOWNLOAD"`
PersistencePath string `json:"persistence_path" env:"PICOCLAW_CHANNELS_WECOM_WS_PERSISTENCE_PATH"`
}
type GroupPolicyConfig struct {
AllowFrom []string `json:"allow_from"`
MentionOnly bool `json:"mention_only"`
Prefixes []string `json:"prefixes"`
}
type PicoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_TOKEN"`