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:
parent
9cd2d21800
commit
9fb9b423ec
9 changed files with 2843 additions and 0 deletions
|
|
@ -273,6 +273,10 @@ func (m *Manager) initChannels() error {
|
||||||
m.initChannel("wecom_app", "WeCom App")
|
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 != "" {
|
if m.config.Channels.Pico.Enabled && m.config.Channels.Pico.Token != "" {
|
||||||
m.initChannel("pico", "Pico")
|
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) {
|
channels.RegisterFactory("wecom_aibot", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
return NewWeComAIBotChannel(cfg.Channels.WeComAIBot, b)
|
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()
|
||||||
|
}
|
||||||
1860
pkg/channels/wecom/websocket.go
Normal file
1860
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
|
||||||
|
}
|
||||||
|
|
@ -242,6 +242,7 @@ type ChannelsConfig struct {
|
||||||
WeCom WeComConfig `json:"wecom"`
|
WeCom WeComConfig `json:"wecom"`
|
||||||
WeComApp WeComAppConfig `json:"wecom_app"`
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
|
||||||
|
WeComWS WeComWSConfig `json:"wecom_ws"`
|
||||||
Pico PicoConfig `json:"pico"`
|
Pico PicoConfig `json:"pico"`
|
||||||
IRC IRCConfig `json:"irc"`
|
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"`
|
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 {
|
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"`
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue