Merge PR #1408
This commit is contained in:
commit
b7ec6b76a2
9 changed files with 2901 additions and 0 deletions
|
|
@ -299,6 +299,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")
|
||||
}
|
||||
|
|
|
|||
116
pkg/channels/wecom/events.go
Normal file
116
pkg/channels/wecom/events.go
Normal 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
|
||||
}
|
||||
123
pkg/channels/wecom/filetype.go
Normal file
123
pkg/channels/wecom/filetype.go
Normal 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
110
pkg/channels/wecom/group.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
187
pkg/channels/wecom/persistence.go
Normal file
187
pkg/channels/wecom/persistence.go
Normal 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()
|
||||
}
|
||||
1918
pkg/channels/wecom/websocket.go
Normal file
1918
pkg/channels/wecom/websocket.go
Normal file
File diff suppressed because it is too large
Load diff
413
pkg/channels/wecom/websocket_test.go
Normal file
413
pkg/channels/wecom/websocket_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -269,6 +269,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"`
|
||||
}
|
||||
|
|
@ -459,6 +460,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"`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue