mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
feat: add wecom and wecomApp channel support
This commit is contained in:
@@ -106,6 +106,30 @@
|
|||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
|
},
|
||||||
|
"wecom": {
|
||||||
|
"enabled": false,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18793,
|
||||||
|
"webhook_path": "/webhook/wecom",
|
||||||
|
"allow_from": [],
|
||||||
|
"reply_timeout": 5
|
||||||
|
},
|
||||||
|
"wecom_app": {
|
||||||
|
"enabled": false,
|
||||||
|
"corp_id": "YOUR_CORP_ID",
|
||||||
|
"corp_secret": "YOUR_CORP_SECRET",
|
||||||
|
"agent_id": 1000002,
|
||||||
|
"token": "YOUR_TOKEN",
|
||||||
|
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||||
|
"webhook_host": "0.0.0.0",
|
||||||
|
"webhook_port": 18792,
|
||||||
|
"webhook_path": "/webhook/wecom-app",
|
||||||
|
"allow_from": [],
|
||||||
|
"reply_timeout": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
|
|||||||
@@ -176,6 +176,32 @@ func (m *Manager) initChannels() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize WeCom channel")
|
||||||
|
wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["wecom"] = wecom
|
||||||
|
logger.InfoC("channels", "WeCom channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
|
||||||
|
logger.DebugC("channels", "Attempting to initialize WeCom App channel")
|
||||||
|
wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.channels["wecom_app"] = wecomApp
|
||||||
|
logger.InfoC("channels", "WeCom App channel enabled successfully")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// WeCom Bot (企业微信智能机器人) channel implementation
|
||||||
|
// Uses webhook callback mode for receiving messages and webhook API for sending replies
|
||||||
|
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeComBotChannel implements the Channel interface for WeCom Bot (企业微信智能机器人)
|
||||||
|
// Uses webhook callback mode - simpler than WeCom App but only supports passive replies
|
||||||
|
type WeComBotChannel struct {
|
||||||
|
*BaseChannel
|
||||||
|
config config.WeComConfig
|
||||||
|
server *http.Server
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||||
|
msgMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComBotXMLMessage represents the XML message structure from WeCom Bot
|
||||||
|
type WeComBotXMLMessage struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
ToUserName string `xml:"ToUserName"`
|
||||||
|
FromUserName string `xml:"FromUserName"`
|
||||||
|
CreateTime int64 `xml:"CreateTime"`
|
||||||
|
MsgType string `xml:"MsgType"`
|
||||||
|
Content string `xml:"Content"`
|
||||||
|
MsgId int64 `xml:"MsgId"`
|
||||||
|
PicUrl string `xml:"PicUrl"`
|
||||||
|
MediaId string `xml:"MediaId"`
|
||||||
|
Format string `xml:"Format"`
|
||||||
|
Recognition string `xml:"Recognition"` // Voice recognition result
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComBotReplyMessage represents the reply message structure
|
||||||
|
type WeComBotReplyMessage struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
ToUserName string `xml:"ToUserName"`
|
||||||
|
FromUserName string `xml:"FromUserName"`
|
||||||
|
CreateTime int64 `xml:"CreateTime"`
|
||||||
|
MsgType string `xml:"MsgType"`
|
||||||
|
Content string `xml:"Content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComBotWebhookReply represents the webhook API reply
|
||||||
|
type WeComBotWebhookReply struct {
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
Text struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"text,omitempty"`
|
||||||
|
Markdown struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"markdown,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWeComBotChannel creates a new WeCom Bot channel instance
|
||||||
|
func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*WeComBotChannel, error) {
|
||||||
|
if cfg.Token == "" || cfg.WebhookURL == "" {
|
||||||
|
return nil, fmt.Errorf("wecom token and webhook_url are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
return &WeComBotChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
processedMsgs: make(map[string]bool),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the channel name
|
||||||
|
func (c *WeComBotChannel) Name() string {
|
||||||
|
return "wecom"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initializes the WeCom Bot channel with HTTP webhook server
|
||||||
|
func (c *WeComBotChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("wecom", "Starting WeCom Bot channel...")
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
// Setup HTTP server for webhook
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
webhookPath := c.config.WebhookPath
|
||||||
|
if webhookPath == "" {
|
||||||
|
webhookPath = "/webhook/wecom"
|
||||||
|
}
|
||||||
|
mux.HandleFunc(webhookPath, c.handleWebhook)
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
mux.HandleFunc("/health/wecom", c.handleHealth)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
||||||
|
c.server = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(true)
|
||||||
|
logger.InfoCF("wecom", "WeCom Bot channel started", map[string]interface{}{
|
||||||
|
"address": addr,
|
||||||
|
"path": webhookPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start server in goroutine
|
||||||
|
go func() {
|
||||||
|
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("wecom", "HTTP server error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the WeCom Bot channel
|
||||||
|
func (c *WeComBotChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("wecom", "Stopping WeCom Bot channel...")
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.server != nil {
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c.server.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(false)
|
||||||
|
logger.InfoC("wecom", "WeCom Bot channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to WeCom user via webhook API
|
||||||
|
// Note: WeCom Bot can only reply within the configured timeout (default 5 seconds) of receiving a message
|
||||||
|
// For delayed responses, we use the webhook URL
|
||||||
|
func (c *WeComBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return fmt.Errorf("wecom channel not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("wecom", "Sending message via webhook", map[string]interface{}{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"preview": utils.Truncate(msg.Content, 100),
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.sendWebhookReply(ctx, msg.ChatID, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebhook handles incoming webhook requests from WeCom
|
||||||
|
func (c *WeComBotChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
// Handle verification request
|
||||||
|
c.handleVerification(ctx, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
// Handle message callback
|
||||||
|
c.handleMessageCallback(ctx, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVerification handles the URL verification request from WeCom
|
||||||
|
func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
msgSignature := query.Get("msg_signature")
|
||||||
|
timestamp := query.Get("timestamp")
|
||||||
|
nonce := query.Get("nonce")
|
||||||
|
echostr := query.Get("echostr")
|
||||||
|
|
||||||
|
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
|
||||||
|
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
if !c.verifySignature(msgSignature, timestamp, nonce, echostr) {
|
||||||
|
logger.WarnC("wecom", "Signature verification failed")
|
||||||
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt echostr
|
||||||
|
decryptedEchoStr, err := c.decryptMessage(echostr)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove BOM and whitespace as per WeCom documentation
|
||||||
|
// The response must be plain text without quotes, BOM, or newlines
|
||||||
|
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
|
||||||
|
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
|
||||||
|
w.Write([]byte(decryptedEchoStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessageCallback handles incoming messages from WeCom
|
||||||
|
func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
msgSignature := query.Get("msg_signature")
|
||||||
|
timestamp := query.Get("timestamp")
|
||||||
|
nonce := query.Get("nonce")
|
||||||
|
|
||||||
|
if msgSignature == "" || timestamp == "" || nonce == "" {
|
||||||
|
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Parse XML to get encrypted message
|
||||||
|
var encryptedMsg struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
ToUserName string `xml:"ToUserName"`
|
||||||
|
Encrypt string `xml:"Encrypt"`
|
||||||
|
AgentID string `xml:"AgentID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||||
|
logger.ErrorCF("wecom", "Failed to parse XML", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
if !c.verifySignature(msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||||
|
logger.WarnC("wecom", "Message signature verification failed")
|
||||||
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt message
|
||||||
|
decryptedMsg, err := c.decryptMessage(encryptedMsg.Encrypt)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse decrypted XML message
|
||||||
|
var msg WeComBotXMLMessage
|
||||||
|
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||||
|
logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the message asynchronously with context
|
||||||
|
go c.processMessage(ctx, msg)
|
||||||
|
|
||||||
|
// Return success response immediately
|
||||||
|
// WeCom Bot requires response within configured timeout (default 5 seconds)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// processMessage processes the received message
|
||||||
|
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotXMLMessage) {
|
||||||
|
// Skip non-text messages for now (can be extended)
|
||||||
|
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
|
||||||
|
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||||
|
// As per WeCom documentation, use msg_id for deduplication
|
||||||
|
msgID := fmt.Sprintf("%d", msg.MsgId)
|
||||||
|
c.msgMu.Lock()
|
||||||
|
if c.processedMsgs[msgID] {
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
logger.DebugCF("wecom", "Skipping duplicate message", map[string]interface{}{
|
||||||
|
"msg_id": msgID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.processedMsgs[msgID] = true
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
|
||||||
|
// Clean up old messages periodically (keep last 1000)
|
||||||
|
if len(c.processedMsgs) > 1000 {
|
||||||
|
c.msgMu.Lock()
|
||||||
|
c.processedMsgs = make(map[string]bool)
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := msg.FromUserName
|
||||||
|
chatID := senderID // WeCom Bot uses user ID as chat ID
|
||||||
|
|
||||||
|
// Use voice recognition result if available
|
||||||
|
content := msg.Content
|
||||||
|
if msg.MsgType == "voice" && msg.Recognition != "" {
|
||||||
|
content = msg.Recognition
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build metadata
|
||||||
|
// WeCom Bot only supports direct messages (private chat)
|
||||||
|
metadata := map[string]string{
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
"msg_id": fmt.Sprintf("%d", msg.MsgId),
|
||||||
|
"platform": "wecom",
|
||||||
|
"media_id": msg.MediaId,
|
||||||
|
"create_time": fmt.Sprintf("%d", msg.CreateTime),
|
||||||
|
"peer_kind": "direct",
|
||||||
|
"peer_id": senderID,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("wecom", "Received message", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
"preview": utils.Truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle the message through the base channel
|
||||||
|
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySignature verifies the message signature
|
||||||
|
func (c *WeComBotChannel) verifySignature(msgSignature, timestamp, nonce, msgEncrypt string) bool {
|
||||||
|
if c.config.Token == "" {
|
||||||
|
return true // Skip verification if token is not set
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort parameters
|
||||||
|
params := []string{c.config.Token, timestamp, nonce, msgEncrypt}
|
||||||
|
sort.Strings(params)
|
||||||
|
|
||||||
|
// Concatenate
|
||||||
|
str := strings.Join(params, "")
|
||||||
|
|
||||||
|
// SHA1 hash
|
||||||
|
hash := sha1.Sum([]byte(str))
|
||||||
|
expectedSignature := fmt.Sprintf("%x", hash)
|
||||||
|
|
||||||
|
return expectedSignature == msgSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptMessage decrypts the encrypted message using AES
|
||||||
|
func (c *WeComBotChannel) decryptMessage(encryptedMsg string) (string, error) {
|
||||||
|
if c.config.EncodingAESKey == "" {
|
||||||
|
// No encryption, return as is (base64 decode)
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode AES key (base64)
|
||||||
|
aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode AES key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode encrypted message
|
||||||
|
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES decrypt
|
||||||
|
block, err := aes.NewCipher(aesKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cipherText) < aes.BlockSize {
|
||||||
|
return "", fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := cipher.NewCBCDecrypter(block, aesKey[:aes.BlockSize])
|
||||||
|
plainText := make([]byte, len(cipherText))
|
||||||
|
mode.CryptBlocks(plainText, cipherText)
|
||||||
|
|
||||||
|
// Remove PKCS7 padding
|
||||||
|
plainText, err = pkcs7UnpadWeCom(plainText)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to unpad: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse message structure
|
||||||
|
// Format: random(16) + msg_len(4) + msg + corp_id
|
||||||
|
if len(plainText) < 20 {
|
||||||
|
return "", fmt.Errorf("decrypted message too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
msgLen := binary.BigEndian.Uint32(plainText[16:20])
|
||||||
|
if int(msgLen) > len(plainText)-20 {
|
||||||
|
return "", fmt.Errorf("invalid message length")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := plainText[20 : 20+msgLen]
|
||||||
|
// corpID := plainText[20+msgLen:] // Could be used for verification
|
||||||
|
|
||||||
|
return string(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkcs7UnpadWeCom removes PKCS7 padding with validation
|
||||||
|
func pkcs7UnpadWeCom(data []byte) ([]byte, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
padding := int(data[len(data)-1])
|
||||||
|
if padding == 0 || padding > aes.BlockSize {
|
||||||
|
return nil, fmt.Errorf("invalid padding size: %d", padding)
|
||||||
|
}
|
||||||
|
if padding > len(data) {
|
||||||
|
return nil, fmt.Errorf("padding size larger than data")
|
||||||
|
}
|
||||||
|
// Verify all padding bytes
|
||||||
|
for i := 0; i < padding; i++ {
|
||||||
|
if data[len(data)-1-i] != byte(padding) {
|
||||||
|
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:len(data)-padding], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendWebhookReply sends a reply using the webhook URL
|
||||||
|
func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error {
|
||||||
|
reply := WeComBotWebhookReply{
|
||||||
|
MsgType: "text",
|
||||||
|
}
|
||||||
|
reply.Text.Content = content
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(reply)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal reply: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use configurable timeout (default 5 seconds)
|
||||||
|
timeout := c.config.ReplyTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.config.WebhookURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send webhook reply: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check response
|
||||||
|
var result struct {
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.ErrCode != 0 {
|
||||||
|
return fmt.Errorf("webhook API error: %s (code: %d)", result.ErrMsg, result.ErrCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHealth handles health check requests
|
||||||
|
func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"running": c.IsRunning(),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
@@ -0,0 +1,707 @@
|
|||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// WeCom App (企业微信自建应用) channel implementation
|
||||||
|
// Supports receiving messages via webhook callback and sending messages proactively
|
||||||
|
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
wecomAPIBase = "https://qyapi.weixin.qq.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WeComAppChannel implements the Channel interface for WeCom App (企业微信自建应用)
|
||||||
|
type WeComAppChannel struct {
|
||||||
|
*BaseChannel
|
||||||
|
config config.WeComAppConfig
|
||||||
|
server *http.Server
|
||||||
|
accessToken string
|
||||||
|
tokenExpiry time.Time
|
||||||
|
tokenMu sync.RWMutex
|
||||||
|
ctx context.Context
|
||||||
|
cancel context.CancelFunc
|
||||||
|
processedMsgs map[string]bool // Message deduplication: msg_id -> processed
|
||||||
|
msgMu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComXMLMessage represents the XML message structure from WeCom
|
||||||
|
type WeComXMLMessage struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
ToUserName string `xml:"ToUserName"`
|
||||||
|
FromUserName string `xml:"FromUserName"`
|
||||||
|
CreateTime int64 `xml:"CreateTime"`
|
||||||
|
MsgType string `xml:"MsgType"`
|
||||||
|
Content string `xml:"Content"`
|
||||||
|
MsgId int64 `xml:"MsgId"`
|
||||||
|
AgentID int64 `xml:"AgentID"`
|
||||||
|
PicUrl string `xml:"PicUrl"`
|
||||||
|
MediaId string `xml:"MediaId"`
|
||||||
|
Format string `xml:"Format"`
|
||||||
|
ThumbMediaId string `xml:"ThumbMediaId"`
|
||||||
|
LocationX float64 `xml:"Location_X"`
|
||||||
|
LocationY float64 `xml:"Location_Y"`
|
||||||
|
Scale int `xml:"Scale"`
|
||||||
|
Label string `xml:"Label"`
|
||||||
|
Title string `xml:"Title"`
|
||||||
|
Description string `xml:"Description"`
|
||||||
|
Url string `xml:"Url"`
|
||||||
|
Event string `xml:"Event"`
|
||||||
|
EventKey string `xml:"EventKey"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComTextMessage represents text message for sending
|
||||||
|
type WeComTextMessage struct {
|
||||||
|
ToUser string `json:"touser"`
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
AgentID int64 `json:"agentid"`
|
||||||
|
Text struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"text"`
|
||||||
|
Safe int `json:"safe,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComMarkdownMessage represents markdown message for sending
|
||||||
|
type WeComMarkdownMessage struct {
|
||||||
|
ToUser string `json:"touser"`
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
AgentID int64 `json:"agentid"`
|
||||||
|
Markdown struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"markdown"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComImageMessage represents image message for sending
|
||||||
|
type WeComImageMessage struct {
|
||||||
|
ToUser string `json:"touser"`
|
||||||
|
MsgType string `json:"msgtype"`
|
||||||
|
AgentID int64 `json:"agentid"`
|
||||||
|
Image struct {
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
} `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComAccessTokenResponse represents the access token API response
|
||||||
|
type WeComAccessTokenResponse struct {
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WeComSendMessageResponse represents the send message API response
|
||||||
|
type WeComSendMessageResponse struct {
|
||||||
|
ErrCode int `json:"errcode"`
|
||||||
|
ErrMsg string `json:"errmsg"`
|
||||||
|
InvalidUser string `json:"invaliduser"`
|
||||||
|
InvalidParty string `json:"invalidparty"`
|
||||||
|
InvalidTag string `json:"invalidtag"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PKCS7Padding adds PKCS7 padding
|
||||||
|
type PKCS7Padding struct{}
|
||||||
|
|
||||||
|
// NewWeComAppChannel creates a new WeCom App channel instance
|
||||||
|
func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (*WeComAppChannel, error) {
|
||||||
|
if cfg.CorpID == "" || cfg.CorpSecret == "" || cfg.AgentID == 0 {
|
||||||
|
return nil, fmt.Errorf("wecom_app corp_id, corp_secret and agent_id are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
base := NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom)
|
||||||
|
|
||||||
|
return &WeComAppChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfg,
|
||||||
|
processedMsgs: make(map[string]bool),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the channel name
|
||||||
|
func (c *WeComAppChannel) Name() string {
|
||||||
|
return "wecom_app"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initializes the WeCom App channel with HTTP webhook server
|
||||||
|
func (c *WeComAppChannel) Start(ctx context.Context) error {
|
||||||
|
logger.InfoC("wecom_app", "Starting WeCom App channel...")
|
||||||
|
|
||||||
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
// Get initial access token
|
||||||
|
if err := c.refreshAccessToken(); err != nil {
|
||||||
|
logger.WarnCF("wecom_app", "Failed to get initial access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start token refresh goroutine
|
||||||
|
go c.tokenRefreshLoop()
|
||||||
|
|
||||||
|
// Setup HTTP server for webhook
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
webhookPath := c.config.WebhookPath
|
||||||
|
if webhookPath == "" {
|
||||||
|
webhookPath = "/webhook/wecom-app"
|
||||||
|
}
|
||||||
|
mux.HandleFunc(webhookPath, c.handleWebhook)
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
mux.HandleFunc("/health/wecom-app", c.handleHealth)
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
|
||||||
|
c.server = &http.Server{
|
||||||
|
Addr: addr,
|
||||||
|
Handler: mux,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(true)
|
||||||
|
logger.InfoCF("wecom_app", "WeCom App channel started", map[string]interface{}{
|
||||||
|
"address": addr,
|
||||||
|
"path": webhookPath,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start server in goroutine
|
||||||
|
go func() {
|
||||||
|
if err := c.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
logger.ErrorCF("wecom_app", "HTTP server error", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop gracefully stops the WeCom App channel
|
||||||
|
func (c *WeComAppChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("wecom_app", "Stopping WeCom App channel...")
|
||||||
|
|
||||||
|
if c.cancel != nil {
|
||||||
|
c.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.server != nil {
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
c.server.Shutdown(shutdownCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.setRunning(false)
|
||||||
|
logger.InfoC("wecom_app", "WeCom App channel stopped")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send sends a message to WeCom user proactively using access token
|
||||||
|
func (c *WeComAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return fmt.Errorf("wecom_app channel not running")
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken := c.getAccessToken()
|
||||||
|
if accessToken == "" {
|
||||||
|
return fmt.Errorf("no valid access token available")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("wecom_app", "Sending message", map[string]interface{}{
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"preview": utils.Truncate(msg.Content, 100),
|
||||||
|
})
|
||||||
|
|
||||||
|
return c.sendTextMessage(ctx, accessToken, msg.ChatID, msg.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWebhook handles incoming webhook requests from WeCom
|
||||||
|
func (c *WeComAppChannel) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
// Handle verification request
|
||||||
|
c.handleVerification(ctx, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Method == http.MethodPost {
|
||||||
|
// Handle message callback
|
||||||
|
c.handleMessageCallback(ctx, w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleVerification handles the URL verification request from WeCom
|
||||||
|
func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
msgSignature := query.Get("msg_signature")
|
||||||
|
timestamp := query.Get("timestamp")
|
||||||
|
nonce := query.Get("nonce")
|
||||||
|
echostr := query.Get("echostr")
|
||||||
|
|
||||||
|
if msgSignature == "" || timestamp == "" || nonce == "" || echostr == "" {
|
||||||
|
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
if !c.verifySignature(msgSignature, timestamp, nonce, echostr) {
|
||||||
|
logger.WarnC("wecom_app", "Signature verification failed")
|
||||||
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt echostr
|
||||||
|
decryptedEchoStr, err := c.decryptMessage(echostr)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove BOM and whitespace as per WeCom documentation
|
||||||
|
// The response must be plain text without quotes, BOM, or newlines
|
||||||
|
decryptedEchoStr = strings.TrimSpace(decryptedEchoStr)
|
||||||
|
decryptedEchoStr = strings.TrimPrefix(decryptedEchoStr, "\xef\xbb\xbf") // Remove UTF-8 BOM
|
||||||
|
w.Write([]byte(decryptedEchoStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMessageCallback handles incoming messages from WeCom
|
||||||
|
func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
query := r.URL.Query()
|
||||||
|
msgSignature := query.Get("msg_signature")
|
||||||
|
timestamp := query.Get("timestamp")
|
||||||
|
nonce := query.Get("nonce")
|
||||||
|
|
||||||
|
if msgSignature == "" || timestamp == "" || nonce == "" {
|
||||||
|
http.Error(w, "Missing parameters", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to read body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Parse XML to get encrypted message
|
||||||
|
var encryptedMsg struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
ToUserName string `xml:"ToUserName"`
|
||||||
|
Encrypt string `xml:"Encrypt"`
|
||||||
|
AgentID string `xml:"AgentID"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := xml.Unmarshal(body, &encryptedMsg); err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to parse XML", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Invalid XML", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify signature
|
||||||
|
if !c.verifySignature(msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
|
||||||
|
logger.WarnC("wecom_app", "Message signature verification failed")
|
||||||
|
http.Error(w, "Invalid signature", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt message
|
||||||
|
decryptedMsg, err := c.decryptMessage(encryptedMsg.Encrypt)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Decryption failed", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse decrypted XML message
|
||||||
|
var msg WeComXMLMessage
|
||||||
|
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to parse decrypted message", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
http.Error(w, "Invalid message format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the message with context
|
||||||
|
go c.processMessage(ctx, msg)
|
||||||
|
|
||||||
|
// Return success response immediately
|
||||||
|
// WeCom App requires response within configured timeout (default 5 seconds)
|
||||||
|
w.Write([]byte("success"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// processMessage processes the received message
|
||||||
|
func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessage) {
|
||||||
|
// Skip non-text messages for now (can be extended)
|
||||||
|
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" {
|
||||||
|
logger.DebugCF("wecom_app", "Skipping non-supported message type", map[string]interface{}{
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message deduplication: Use msg_id to prevent duplicate processing
|
||||||
|
// As per WeCom documentation, use msg_id for deduplication
|
||||||
|
msgID := fmt.Sprintf("%d", msg.MsgId)
|
||||||
|
c.msgMu.Lock()
|
||||||
|
if c.processedMsgs[msgID] {
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
logger.DebugCF("wecom_app", "Skipping duplicate message", map[string]interface{}{
|
||||||
|
"msg_id": msgID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.processedMsgs[msgID] = true
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
|
||||||
|
// Clean up old messages periodically (keep last 1000)
|
||||||
|
if len(c.processedMsgs) > 1000 {
|
||||||
|
c.msgMu.Lock()
|
||||||
|
c.processedMsgs = make(map[string]bool)
|
||||||
|
c.msgMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
senderID := msg.FromUserName
|
||||||
|
chatID := senderID // WeCom App uses user ID as chat ID for direct messages
|
||||||
|
|
||||||
|
// Build metadata
|
||||||
|
// WeCom App only supports direct messages (private chat)
|
||||||
|
metadata := map[string]string{
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
"msg_id": fmt.Sprintf("%d", msg.MsgId),
|
||||||
|
"agent_id": fmt.Sprintf("%d", msg.AgentID),
|
||||||
|
"platform": "wecom_app",
|
||||||
|
"media_id": msg.MediaId,
|
||||||
|
"create_time": fmt.Sprintf("%d", msg.CreateTime),
|
||||||
|
"peer_kind": "direct",
|
||||||
|
"peer_id": senderID,
|
||||||
|
}
|
||||||
|
|
||||||
|
content := msg.Content
|
||||||
|
|
||||||
|
logger.DebugCF("wecom_app", "Received message", map[string]interface{}{
|
||||||
|
"sender_id": senderID,
|
||||||
|
"msg_type": msg.MsgType,
|
||||||
|
"preview": utils.Truncate(content, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle the message through the base channel
|
||||||
|
c.HandleMessage(senderID, chatID, content, nil, metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifySignature verifies the message signature
|
||||||
|
func (c *WeComAppChannel) verifySignature(msgSignature, timestamp, nonce, msgEncrypt string) bool {
|
||||||
|
if c.config.Token == "" {
|
||||||
|
return true // Skip verification if token is not set
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort parameters
|
||||||
|
params := []string{c.config.Token, timestamp, nonce, msgEncrypt}
|
||||||
|
sort.Strings(params)
|
||||||
|
|
||||||
|
// Concatenate
|
||||||
|
str := strings.Join(params, "")
|
||||||
|
|
||||||
|
// SHA1 hash
|
||||||
|
hash := sha1.Sum([]byte(str))
|
||||||
|
expectedSignature := fmt.Sprintf("%x", hash)
|
||||||
|
|
||||||
|
return expectedSignature == msgSignature
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptMessage decrypts the encrypted message using AES
|
||||||
|
func (c *WeComAppChannel) decryptMessage(encryptedMsg string) (string, error) {
|
||||||
|
if c.config.EncodingAESKey == "" {
|
||||||
|
// No encryption, return as is (base64 decode)
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(decoded), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode AES key (base64)
|
||||||
|
aesKey, err := base64.StdEncoding.DecodeString(c.config.EncodingAESKey + "=")
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode AES key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode encrypted message
|
||||||
|
cipherText, err := base64.StdEncoding.DecodeString(encryptedMsg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to decode message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES decrypt
|
||||||
|
block, err := aes.NewCipher(aesKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create cipher: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cipherText) < aes.BlockSize {
|
||||||
|
return "", fmt.Errorf("ciphertext too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := cipher.NewCBCDecrypter(block, aesKey[:aes.BlockSize])
|
||||||
|
plainText := make([]byte, len(cipherText))
|
||||||
|
mode.CryptBlocks(plainText, cipherText)
|
||||||
|
|
||||||
|
// Remove PKCS7 padding
|
||||||
|
plainText, err = pkcs7Unpad(plainText)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to unpad: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse message structure
|
||||||
|
// Format: random(16) + msg_len(4) + msg + corp_id
|
||||||
|
if len(plainText) < 20 {
|
||||||
|
return "", fmt.Errorf("decrypted message too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
msgLen := binary.BigEndian.Uint32(plainText[16:20])
|
||||||
|
if int(msgLen) > len(plainText)-20 {
|
||||||
|
return "", fmt.Errorf("invalid message length")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := plainText[20 : 20+msgLen]
|
||||||
|
// corpID := plainText[20+msgLen:] // Can be used for verification
|
||||||
|
|
||||||
|
return string(msg), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkcs7Unpad removes PKCS7 padding with validation
|
||||||
|
func pkcs7Unpad(data []byte) ([]byte, error) {
|
||||||
|
if len(data) == 0 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
padding := int(data[len(data)-1])
|
||||||
|
if padding == 0 || padding > aes.BlockSize {
|
||||||
|
return nil, fmt.Errorf("invalid padding size: %d", padding)
|
||||||
|
}
|
||||||
|
if padding > len(data) {
|
||||||
|
return nil, fmt.Errorf("padding size larger than data")
|
||||||
|
}
|
||||||
|
// Verify all padding bytes
|
||||||
|
for i := 0; i < padding; i++ {
|
||||||
|
if data[len(data)-1-i] != byte(padding) {
|
||||||
|
return nil, fmt.Errorf("invalid padding byte at position %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data[:len(data)-padding], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tokenRefreshLoop periodically refreshes the access token
|
||||||
|
func (c *WeComAppChannel) tokenRefreshLoop() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := c.refreshAccessToken(); err != nil {
|
||||||
|
logger.ErrorCF("wecom_app", "Failed to refresh access token", map[string]interface{}{
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshAccessToken gets a new access token from WeCom API
|
||||||
|
func (c *WeComAppChannel) refreshAccessToken() error {
|
||||||
|
apiURL := fmt.Sprintf("%s/cgi-bin/gettoken?corpid=%s&corpsecret=%s",
|
||||||
|
wecomAPIBase, url.QueryEscape(c.config.CorpID), url.QueryEscape(c.config.CorpSecret))
|
||||||
|
|
||||||
|
resp, err := http.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to request access token: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp WeComAccessTokenResponse
|
||||||
|
if err := json.Unmarshal(body, &tokenResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tokenResp.ErrCode != 0 {
|
||||||
|
return fmt.Errorf("API error: %s (code: %d)", tokenResp.ErrMsg, tokenResp.ErrCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.tokenMu.Lock()
|
||||||
|
c.accessToken = tokenResp.AccessToken
|
||||||
|
c.tokenExpiry = time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second) // Refresh 5 minutes early
|
||||||
|
c.tokenMu.Unlock()
|
||||||
|
|
||||||
|
logger.DebugC("wecom_app", "Access token refreshed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAccessToken returns the current valid access token
|
||||||
|
func (c *WeComAppChannel) getAccessToken() string {
|
||||||
|
c.tokenMu.RLock()
|
||||||
|
defer c.tokenMu.RUnlock()
|
||||||
|
|
||||||
|
if time.Now().After(c.tokenExpiry) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendTextMessage sends a text message to a user
|
||||||
|
func (c *WeComAppChannel) sendTextMessage(ctx context.Context, accessToken, userID, content string) error {
|
||||||
|
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
|
||||||
|
|
||||||
|
msg := WeComTextMessage{
|
||||||
|
ToUser: userID,
|
||||||
|
MsgType: "text",
|
||||||
|
AgentID: c.config.AgentID,
|
||||||
|
}
|
||||||
|
msg.Text.Content = content
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use configurable timeout (default 5 seconds)
|
||||||
|
timeout := c.config.ReplyTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send message: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendResp WeComSendMessageResponse
|
||||||
|
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sendResp.ErrCode != 0 {
|
||||||
|
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendMarkdownMessage sends a markdown message to a user
|
||||||
|
func (c *WeComAppChannel) sendMarkdownMessage(ctx context.Context, accessToken, userID, content string) error {
|
||||||
|
apiURL := fmt.Sprintf("%s/cgi-bin/message/send?access_token=%s", wecomAPIBase, accessToken)
|
||||||
|
|
||||||
|
msg := WeComMarkdownMessage{
|
||||||
|
ToUser: userID,
|
||||||
|
MsgType: "markdown",
|
||||||
|
AgentID: c.config.AgentID,
|
||||||
|
}
|
||||||
|
msg.Markdown.Content = content
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use configurable timeout (default 5 seconds)
|
||||||
|
timeout := c.config.ReplyTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to send message: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sendResp WeComSendMessageResponse
|
||||||
|
if err := json.Unmarshal(body, &sendResp); err != nil {
|
||||||
|
return fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sendResp.ErrCode != 0 {
|
||||||
|
return fmt.Errorf("API error: %s (code: %d)", sendResp.ErrMsg, sendResp.ErrCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleHealth handles health check requests
|
||||||
|
func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
status := map[string]interface{}{
|
||||||
|
"status": "ok",
|
||||||
|
"running": c.IsRunning(),
|
||||||
|
"has_token": c.getAccessToken() != "",
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(status)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,689 @@
|
|||||||
|
// PicoClaw - Ultra-lightweight personal AI agent
|
||||||
|
// WeCom Bot (企业微信智能机器人) channel tests
|
||||||
|
|
||||||
|
package channels
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// generateTestAESKey generates a valid test AES key
|
||||||
|
func generateTestAESKey() string {
|
||||||
|
// AES key needs to be 32 bytes (256 bits) for AES-256
|
||||||
|
key := make([]byte, 32)
|
||||||
|
for i := range key {
|
||||||
|
key[i] = byte(i)
|
||||||
|
}
|
||||||
|
// Return base64 encoded key without padding
|
||||||
|
return base64.StdEncoding.EncodeToString(key)[:43]
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptTestMessage encrypts a message for testing
|
||||||
|
func encryptTestMessage(message, aesKey string) (string, error) {
|
||||||
|
// Decode AES key
|
||||||
|
key, err := base64.StdEncoding.DecodeString(aesKey + "=")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare message: random(16) + msg_len(4) + msg + corp_id
|
||||||
|
random := make([]byte, 0, 16)
|
||||||
|
for i := 0; i < 16; i++ {
|
||||||
|
random = append(random, byte(i))
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBytes := []byte(message)
|
||||||
|
corpID := []byte("test_corp_id")
|
||||||
|
|
||||||
|
msgLen := uint32(len(msgBytes))
|
||||||
|
lenBytes := make([]byte, 4)
|
||||||
|
binary.BigEndian.PutUint32(lenBytes, msgLen)
|
||||||
|
|
||||||
|
plainText := append(random, lenBytes...)
|
||||||
|
plainText = append(plainText, msgBytes...)
|
||||||
|
plainText = append(plainText, corpID...)
|
||||||
|
|
||||||
|
// PKCS7 padding
|
||||||
|
blockSize := aes.BlockSize
|
||||||
|
padding := blockSize - len(plainText)%blockSize
|
||||||
|
padText := bytes.Repeat([]byte{byte(padding)}, padding)
|
||||||
|
plainText = append(plainText, padText...)
|
||||||
|
|
||||||
|
// Encrypt
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := cipher.NewCBCEncrypter(block, key[:aes.BlockSize])
|
||||||
|
cipherText := make([]byte, len(plainText))
|
||||||
|
mode.CryptBlocks(cipherText, plainText)
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(cipherText), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSignature generates a signature for testing
|
||||||
|
func generateSignature(token, timestamp, nonce, msgEncrypt string) string {
|
||||||
|
params := []string{token, timestamp, nonce, msgEncrypt}
|
||||||
|
sort.Strings(params)
|
||||||
|
str := strings.Join(params, "")
|
||||||
|
hash := sha1.Sum([]byte(str))
|
||||||
|
return fmt.Sprintf("%x", hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewWeComBotChannel(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
|
t.Run("missing token", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
_, err := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing token, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing webhook_url", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "",
|
||||||
|
}
|
||||||
|
_, err := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for missing webhook_url, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid config", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
AllowFrom: []string{"user1", "user2"},
|
||||||
|
}
|
||||||
|
ch, err := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if ch.Name() != "wecom" {
|
||||||
|
t.Errorf("Name() = %q, want %q", ch.Name(), "wecom")
|
||||||
|
}
|
||||||
|
if ch.IsRunning() {
|
||||||
|
t.Error("new channel should not be running")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotChannelIsAllowed(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
|
t.Run("empty allowlist allows all", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
AllowFrom: []string{},
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if !ch.IsAllowed("any_user") {
|
||||||
|
t.Error("empty allowlist should allow all users")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowlist restricts users", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
AllowFrom: []string{"allowed_user"},
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
if !ch.IsAllowed("allowed_user") {
|
||||||
|
t.Error("allowed user should pass allowlist check")
|
||||||
|
}
|
||||||
|
if ch.IsAllowed("blocked_user") {
|
||||||
|
t.Error("non-allowed user should be blocked")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotVerifySignature(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("valid signature", func(t *testing.T) {
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
msgEncrypt := "test_message"
|
||||||
|
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
|
||||||
|
|
||||||
|
if !ch.verifySignature(expectedSig, timestamp, nonce, msgEncrypt) {
|
||||||
|
t.Error("valid signature should pass verification")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid signature", func(t *testing.T) {
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
msgEncrypt := "test_message"
|
||||||
|
|
||||||
|
if ch.verifySignature("invalid_sig", timestamp, nonce, msgEncrypt) {
|
||||||
|
t.Error("invalid signature should fail verification")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty token skips verification", func(t *testing.T) {
|
||||||
|
// Create a channel manually with empty token to test the behavior
|
||||||
|
cfgEmpty := config.WeComConfig{
|
||||||
|
Token: "",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
base := NewBaseChannel("wecom", cfgEmpty, msgBus, cfgEmpty.AllowFrom)
|
||||||
|
chEmpty := &WeComBotChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
config: cfgEmpty,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !chEmpty.verifySignature("any_sig", "any_ts", "any_nonce", "any_msg") {
|
||||||
|
t.Error("empty token should skip verification and return true")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotDecryptMessage(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
|
||||||
|
t.Run("decrypt without AES key", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
// Without AES key, message should be base64 decoded only
|
||||||
|
plainText := "hello world"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
|
||||||
|
|
||||||
|
result, err := ch.decryptMessage(encoded)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result != plainText {
|
||||||
|
t.Errorf("decryptMessage() = %q, want %q", result, plainText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("decrypt with AES key", func(t *testing.T) {
|
||||||
|
aesKey := generateTestAESKey()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
EncodingAESKey: aesKey,
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
originalMsg := "<xml><Content>Hello</Content></xml>"
|
||||||
|
encrypted, err := encryptTestMessage(originalMsg, aesKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to encrypt test message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := ch.decryptMessage(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result != originalMsg {
|
||||||
|
t.Errorf("decryptMessage() = %q, want %q", result, originalMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid base64", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
_, err := ch.decryptMessage("invalid_base64!!!")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid base64, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid AES key", func(t *testing.T) {
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
EncodingAESKey: "invalid_key",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
_, err := ch.decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")))
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid AES key, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotPKCS7Unpad(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []byte
|
||||||
|
expected []byte
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty input",
|
||||||
|
input: []byte{},
|
||||||
|
expected: []byte{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid padding 3 bytes",
|
||||||
|
input: append([]byte("hello"), bytes.Repeat([]byte{3}, 3)...),
|
||||||
|
expected: []byte("hello"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid padding 16 bytes (full block)",
|
||||||
|
input: append([]byte("123456789012345"), bytes.Repeat([]byte{16}, 16)...),
|
||||||
|
expected: []byte("123456789012345"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid padding larger than data",
|
||||||
|
input: []byte{20},
|
||||||
|
expected: nil, // should return error
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid padding zero",
|
||||||
|
input: append([]byte("test"), byte(0)),
|
||||||
|
expected: nil, // should return error
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := pkcs7UnpadWeCom(tt.input)
|
||||||
|
if tt.expected == nil {
|
||||||
|
// This case should return an error
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("pkcs7UnpadWeCom() expected error for invalid padding, got result: %v", result)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("pkcs7UnpadWeCom() unexpected error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !bytes.Equal(result, tt.expected) {
|
||||||
|
t.Errorf("pkcs7UnpadWeCom() = %v, want %v", result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotHandleVerification(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
aesKey := generateTestAESKey()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: aesKey,
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("valid verification request", func(t *testing.T) {
|
||||||
|
echostr := "test_echostr_123"
|
||||||
|
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
signature := generateSignature("test_token", timestamp, nonce, encryptedEchostr)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if w.Body.String() != echostr {
|
||||||
|
t.Errorf("response body = %q, want %q", w.Body.String(), echostr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing parameters", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=sig×tamp=ts", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid signature", func(t *testing.T) {
|
||||||
|
echostr := "test_echostr"
|
||||||
|
encryptedEchostr, _ := encryptTestMessage(echostr, aesKey)
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encryptedEchostr, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleVerification(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotHandleMessageCallback(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
aesKey := generateTestAESKey()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
EncodingAESKey: aesKey,
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("valid message callback", func(t *testing.T) {
|
||||||
|
// Create XML message
|
||||||
|
xmlMsg := WeComBotXMLMessage{
|
||||||
|
ToUserName: "corp_id",
|
||||||
|
FromUserName: "user123",
|
||||||
|
CreateTime: 1234567890,
|
||||||
|
MsgType: "text",
|
||||||
|
Content: "Hello World",
|
||||||
|
MsgId: 123456,
|
||||||
|
}
|
||||||
|
xmlData, _ := xml.Marshal(xmlMsg)
|
||||||
|
|
||||||
|
// Encrypt message
|
||||||
|
encrypted, _ := encryptTestMessage(string(xmlData), aesKey)
|
||||||
|
|
||||||
|
// Create encrypted XML wrapper
|
||||||
|
encryptedWrapper := struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
Encrypt string `xml:"Encrypt"`
|
||||||
|
}{
|
||||||
|
Encrypt: encrypted,
|
||||||
|
}
|
||||||
|
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||||
|
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
signature := generateSignature("test_token", timestamp, nonce, encrypted)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
if w.Body.String() != "success" {
|
||||||
|
t.Errorf("response body = %q, want %q", w.Body.String(), "success")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing parameters", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=sig", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid XML", func(t *testing.T) {
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
signature := generateSignature("test_token", timestamp, nonce, "")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, strings.NewReader("invalid xml"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid signature", func(t *testing.T) {
|
||||||
|
encryptedWrapper := struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
Encrypt string `xml:"Encrypt"`
|
||||||
|
}{
|
||||||
|
Encrypt: "encrypted_data",
|
||||||
|
}
|
||||||
|
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||||
|
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature=invalid_sig×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleMessageCallback(context.Background(), w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusForbidden)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotProcessMessage(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("process text message", func(t *testing.T) {
|
||||||
|
msg := WeComBotXMLMessage{
|
||||||
|
ToUserName: "corp_id",
|
||||||
|
FromUserName: "user123",
|
||||||
|
CreateTime: 1234567890,
|
||||||
|
MsgType: "text",
|
||||||
|
Content: "Hello World",
|
||||||
|
MsgId: 123456,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
ch.processMessage(context.Background(), msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("process voice message with recognition", func(t *testing.T) {
|
||||||
|
msg := WeComBotXMLMessage{
|
||||||
|
ToUserName: "corp_id",
|
||||||
|
FromUserName: "user123",
|
||||||
|
CreateTime: 1234567890,
|
||||||
|
MsgType: "voice",
|
||||||
|
Recognition: "Voice message text",
|
||||||
|
MsgId: 123456,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
ch.processMessage(context.Background(), msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("skip unsupported message type", func(t *testing.T) {
|
||||||
|
msg := WeComBotXMLMessage{
|
||||||
|
ToUserName: "corp_id",
|
||||||
|
FromUserName: "user123",
|
||||||
|
CreateTime: 1234567890,
|
||||||
|
MsgType: "video",
|
||||||
|
MsgId: 123456,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not panic
|
||||||
|
ch.processMessage(context.Background(), msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotHandleWebhook(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
t.Run("GET request calls verification", func(t *testing.T) {
|
||||||
|
echostr := "test_echostr"
|
||||||
|
encoded := base64.StdEncoding.EncodeToString([]byte(echostr))
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
signature := generateSignature("test_token", timestamp, nonce, encoded)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce+"&echostr="+encoded, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleWebhook(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("POST request calls message callback", func(t *testing.T) {
|
||||||
|
encryptedWrapper := struct {
|
||||||
|
XMLName xml.Name `xml:"xml"`
|
||||||
|
Encrypt string `xml:"Encrypt"`
|
||||||
|
}{
|
||||||
|
Encrypt: base64.StdEncoding.EncodeToString([]byte("test")),
|
||||||
|
}
|
||||||
|
wrapperData, _ := xml.Marshal(encryptedWrapper)
|
||||||
|
|
||||||
|
timestamp := "1234567890"
|
||||||
|
nonce := "test_nonce"
|
||||||
|
signature := generateSignature("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/webhook/wecom?msg_signature="+signature+"×tamp="+timestamp+"&nonce="+nonce, bytes.NewReader(wrapperData))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleWebhook(w, req)
|
||||||
|
|
||||||
|
// Should not be method not allowed
|
||||||
|
if w.Code == http.StatusMethodNotAllowed {
|
||||||
|
t.Error("POST request should not return Method Not Allowed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unsupported method", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/webhook/wecom", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleWebhook(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotHandleHealth(t *testing.T) {
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
cfg := config.WeComConfig{
|
||||||
|
Token: "test_token",
|
||||||
|
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
|
||||||
|
}
|
||||||
|
ch, _ := NewWeComBotChannel(cfg, msgBus)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health/wecom", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ch.handleHealth(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status code = %d, want %d", w.Code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := w.Header().Get("Content-Type")
|
||||||
|
if contentType != "application/json" {
|
||||||
|
t.Errorf("Content-Type = %q, want %q", contentType, "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
if !strings.Contains(body, "status") || !strings.Contains(body, "running") {
|
||||||
|
t.Errorf("response body should contain status and running fields, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotWebhookReplyMessage(t *testing.T) {
|
||||||
|
msg := WeComBotWebhookReply{
|
||||||
|
MsgType: "text",
|
||||||
|
}
|
||||||
|
msg.Text.Content = "Hello World"
|
||||||
|
|
||||||
|
if msg.MsgType != "text" {
|
||||||
|
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
|
||||||
|
}
|
||||||
|
if msg.Text.Content != "Hello World" {
|
||||||
|
t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWeComBotXMLMessageStructure(t *testing.T) {
|
||||||
|
xmlData := `<?xml version="1.0"?>
|
||||||
|
<xml>
|
||||||
|
<ToUserName><![CDATA[corp_id]]></ToUserName>
|
||||||
|
<FromUserName><![CDATA[user123]]></FromUserName>
|
||||||
|
<CreateTime>1234567890</CreateTime>
|
||||||
|
<MsgType><![CDATA[text]]></MsgType>
|
||||||
|
<Content><![CDATA[Hello World]]></Content>
|
||||||
|
<MsgId>1234567890123456</MsgId>
|
||||||
|
</xml>`
|
||||||
|
|
||||||
|
var msg WeComBotXMLMessage
|
||||||
|
err := xml.Unmarshal([]byte(xmlData), &msg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to unmarshal XML: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.ToUserName != "corp_id" {
|
||||||
|
t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id")
|
||||||
|
}
|
||||||
|
if msg.FromUserName != "user123" {
|
||||||
|
t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123")
|
||||||
|
}
|
||||||
|
if msg.CreateTime != 1234567890 {
|
||||||
|
t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890)
|
||||||
|
}
|
||||||
|
if msg.MsgType != "text" {
|
||||||
|
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
|
||||||
|
}
|
||||||
|
if msg.Content != "Hello World" {
|
||||||
|
t.Errorf("Content = %q, want %q", msg.Content, "Hello World")
|
||||||
|
}
|
||||||
|
if msg.MsgId != 1234567890123456 {
|
||||||
|
t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -190,6 +190,8 @@ type ChannelsConfig struct {
|
|||||||
Slack SlackConfig `json:"slack"`
|
Slack SlackConfig `json:"slack"`
|
||||||
LINE LINEConfig `json:"line"`
|
LINE LINEConfig `json:"line"`
|
||||||
OneBot OneBotConfig `json:"onebot"`
|
OneBot OneBotConfig `json:"onebot"`
|
||||||
|
WeCom WeComConfig `json:"wecom"`
|
||||||
|
WeComApp WeComAppConfig `json:"wecom_app"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WhatsAppConfig struct {
|
type WhatsAppConfig struct {
|
||||||
@@ -267,6 +269,32 @@ type OneBotConfig struct {
|
|||||||
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WeComConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
|
||||||
|
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
|
||||||
|
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
|
||||||
|
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
|
||||||
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
|
||||||
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WeComAppConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
|
||||||
|
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
|
||||||
|
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
|
||||||
|
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
|
||||||
|
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
|
||||||
|
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
|
||||||
|
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
|
||||||
|
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
|
||||||
|
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
|
||||||
|
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
|
||||||
|
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
|
||||||
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|||||||
@@ -88,6 +88,30 @@ func DefaultConfig() *Config {
|
|||||||
GroupTriggerPrefix: []string{},
|
GroupTriggerPrefix: []string{},
|
||||||
AllowFrom: FlexibleStringSlice{},
|
AllowFrom: FlexibleStringSlice{},
|
||||||
},
|
},
|
||||||
|
WeCom: WeComConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Token: "",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
WebhookURL: "",
|
||||||
|
WebhookHost: "0.0.0.0",
|
||||||
|
WebhookPort: 18793,
|
||||||
|
WebhookPath: "/webhook/wecom",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
ReplyTimeout: 5,
|
||||||
|
},
|
||||||
|
WeComApp: WeComAppConfig{
|
||||||
|
Enabled: false,
|
||||||
|
CorpID: "",
|
||||||
|
CorpSecret: "",
|
||||||
|
AgentID: 0,
|
||||||
|
Token: "",
|
||||||
|
EncodingAESKey: "",
|
||||||
|
WebhookHost: "0.0.0.0",
|
||||||
|
WebhookPort: 18792,
|
||||||
|
WebhookPath: "/webhook/wecom-app",
|
||||||
|
AllowFrom: FlexibleStringSlice{},
|
||||||
|
ReplyTimeout: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Providers: ProvidersConfig{
|
Providers: ProvidersConfig{
|
||||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||||
|
|||||||
Reference in New Issue
Block a user