feat: add wecom and wecomApp channel support

This commit is contained in:
swordkee
2026-02-20 15:33:24 +08:00
parent e599573ed4
commit 59772cdbf2
8 changed files with 3116 additions and 0 deletions
+24
View File
@@ -106,6 +106,30 @@
"reconnect_interval": 5,
"group_trigger_prefix": [],
"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": {
+26
View File
@@ -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{}{
"enabled_channels": len(m.channels),
})
+529
View File
@@ -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)
}
+707
View File
@@ -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
+689
View File
@@ -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+"&timestamp="+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&timestamp=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&timestamp="+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+"&timestamp="+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+"&timestamp="+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&timestamp="+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+"&timestamp="+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+"&timestamp="+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)
}
}
+28
View File
@@ -190,6 +190,8 @@ type ChannelsConfig struct {
Slack SlackConfig `json:"slack"`
LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"`
WeComApp WeComAppConfig `json:"wecom_app"`
}
type WhatsAppConfig struct {
@@ -267,6 +269,32 @@ type OneBotConfig struct {
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 {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
+24
View File
@@ -88,6 +88,30 @@ func DefaultConfig() *Config {
GroupTriggerPrefix: []string{},
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{
OpenAI: OpenAIProviderConfig{WebSearch: true},