diff --git a/config/config.example.json b/config/config.example.json
index e14d4fa63..f0c82c2bc 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -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": {
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 7f6abc4cb..b80d1c8fb 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -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),
})
diff --git a/pkg/channels/wecom.go b/pkg/channels/wecom.go
new file mode 100644
index 000000000..5d4e14697
--- /dev/null
+++ b/pkg/channels/wecom.go
@@ -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)
+}
diff --git a/pkg/channels/wecom_app.go b/pkg/channels/wecom_app.go
new file mode 100644
index 000000000..c1d0ebaad
--- /dev/null
+++ b/pkg/channels/wecom_app.go
@@ -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)
+}
diff --git a/pkg/channels/wecom_app_test.go b/pkg/channels/wecom_app_test.go
new file mode 100644
index 000000000..4283c07e6
--- /dev/null
+++ b/pkg/channels/wecom_app_test.go
@@ -0,0 +1,1089 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// WeCom App (企业微信自建应用) channel tests
+
+package channels
+
+import (
+ "bytes"
+ "context"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/sha1"
+ "encoding/base64"
+ "encoding/binary"
+ "encoding/json"
+ "encoding/xml"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "sort"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// generateTestAESKeyApp generates a valid test AES key for WeCom App
+func generateTestAESKeyApp() 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 + 1)
+ }
+ // Return base64 encoded key without padding
+ return base64.StdEncoding.EncodeToString(key)[:43]
+}
+
+// encryptTestMessageApp encrypts a message for testing WeCom App
+func encryptTestMessageApp(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+1))
+ }
+
+ 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
+}
+
+// generateSignatureApp generates a signature for testing WeCom App
+func generateSignatureApp(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 TestNewWeComAppChannel(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("missing corp_id", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ }
+ _, err := NewWeComAppChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing corp_id, got nil")
+ }
+ })
+
+ t.Run("missing corp_secret", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "",
+ AgentID: 1000002,
+ }
+ _, err := NewWeComAppChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing corp_secret, got nil")
+ }
+ })
+
+ t.Run("missing agent_id", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 0,
+ }
+ _, err := NewWeComAppChannel(cfg, msgBus)
+ if err == nil {
+ t.Error("expected error for missing agent_id, got nil")
+ }
+ })
+
+ t.Run("valid config", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ AllowFrom: []string{"user1", "user2"},
+ }
+ ch, err := NewWeComAppChannel(cfg, msgBus)
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if ch.Name() != "wecom_app" {
+ t.Errorf("Name() = %q, want %q", ch.Name(), "wecom_app")
+ }
+ if ch.IsRunning() {
+ t.Error("new channel should not be running")
+ }
+ })
+}
+
+func TestWeComAppChannelIsAllowed(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("empty allowlist allows all", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ AllowFrom: []string{},
+ }
+ ch, _ := NewWeComAppChannel(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.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ AllowFrom: []string{"allowed_user"},
+ }
+ ch, _ := NewWeComAppChannel(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 TestWeComAppVerifySignature(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "test_token",
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("valid signature", func(t *testing.T) {
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ msgEncrypt := "test_message"
+ expectedSig := generateSignatureApp("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) {
+ cfgEmpty := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "",
+ }
+ chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
+
+ if !chEmpty.verifySignature("any_sig", "any_ts", "any_nonce", "any_msg") {
+ t.Error("empty token should skip verification and return true")
+ }
+ })
+}
+
+func TestWeComAppDecryptMessage(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+
+ t.Run("decrypt without AES key", func(t *testing.T) {
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: "",
+ }
+ ch, _ := NewWeComAppChannel(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 := generateTestAESKeyApp()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: aesKey,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ originalMsg := "Hello"
+ encrypted, err := encryptTestMessageApp(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.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: "",
+ }
+ ch, _ := NewWeComAppChannel(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.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: "invalid_key",
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ _, err := ch.decryptMessage(base64.StdEncoding.EncodeToString([]byte("test")))
+ if err == nil {
+ t.Error("expected error for invalid AES key, got nil")
+ }
+ })
+
+ t.Run("ciphertext too short", func(t *testing.T) {
+ aesKey := generateTestAESKeyApp()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ EncodingAESKey: aesKey,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ // Encrypt a very short message that results in ciphertext less than block size
+ shortData := make([]byte, 8)
+ _, err := ch.decryptMessage(base64.StdEncoding.EncodeToString(shortData))
+ if err == nil {
+ t.Error("expected error for short ciphertext, got nil")
+ }
+ })
+}
+
+func TestWeComAppPKCS7Unpad(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 := pkcs7Unpad(tt.input)
+ if tt.expected == nil {
+ // This case should return an error
+ if err == nil {
+ t.Errorf("pkcs7Unpad() expected error for invalid padding, got result: %v", result)
+ }
+ return
+ }
+ if err != nil {
+ t.Errorf("pkcs7Unpad() unexpected error: %v", err)
+ return
+ }
+ if !bytes.Equal(result, tt.expected) {
+ t.Errorf("pkcs7Unpad() = %v, want %v", result, tt.expected)
+ }
+ })
+ }
+}
+
+func TestWeComAppHandleVerification(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ aesKey := generateTestAESKeyApp()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "test_token",
+ EncodingAESKey: aesKey,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("valid verification request", func(t *testing.T) {
+ echostr := "test_echostr_123"
+ encryptedEchostr, _ := encryptTestMessageApp(echostr, aesKey)
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+ signature := generateSignatureApp("test_token", timestamp, nonce, encryptedEchostr)
+
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?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-app?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, _ := encryptTestMessageApp(echostr, aesKey)
+ timestamp := "1234567890"
+ nonce := "test_nonce"
+
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?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 TestWeComAppHandleMessageCallback(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ aesKey := generateTestAESKeyApp()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "test_token",
+ EncodingAESKey: aesKey,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("valid message callback", func(t *testing.T) {
+ // Create XML message
+ xmlMsg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "text",
+ Content: "Hello World",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+ xmlData, _ := xml.Marshal(xmlMsg)
+
+ // Encrypt message
+ encrypted, _ := encryptTestMessageApp(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 := generateSignatureApp("test_token", timestamp, nonce, encrypted)
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?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-app?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 := generateSignatureApp("test_token", timestamp, nonce, "")
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?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-app?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 TestWeComAppProcessMessage(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("process text message", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "text",
+ Content: "Hello World",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("process image message", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "image",
+ PicUrl: "https://example.com/image.jpg",
+ MediaId: "media_123",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("process voice message", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "voice",
+ MediaId: "media_123",
+ Format: "amr",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("skip unsupported message type", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "video",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+
+ t.Run("process event message", func(t *testing.T) {
+ msg := WeComXMLMessage{
+ ToUserName: "corp_id",
+ FromUserName: "user123",
+ CreateTime: 1234567890,
+ MsgType: "event",
+ Event: "subscribe",
+ MsgId: 123456,
+ AgentID: 1000002,
+ }
+
+ // Should not panic
+ ch.processMessage(context.Background(), msg)
+ })
+}
+
+func TestWeComAppHandleWebhook(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ Token: "test_token",
+ }
+ ch, _ := NewWeComAppChannel(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 := generateSignatureApp("test_token", timestamp, nonce, encoded)
+
+ req := httptest.NewRequest(http.MethodGet, "/webhook/wecom-app?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 := generateSignatureApp("test_token", timestamp, nonce, encryptedWrapper.Encrypt)
+
+ req := httptest.NewRequest(http.MethodPost, "/webhook/wecom-app?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-app", 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 TestWeComAppHandleHealth(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ req := httptest.NewRequest(http.MethodGet, "/health/wecom-app", 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") || !strings.Contains(body, "has_token") {
+ t.Errorf("response body should contain status, running, and has_token fields, got: %s", body)
+ }
+}
+
+func TestWeComAppAccessToken(t *testing.T) {
+ msgBus := bus.NewMessageBus()
+ cfg := config.WeComAppConfig{
+ CorpID: "test_corp_id",
+ CorpSecret: "test_secret",
+ AgentID: 1000002,
+ }
+ ch, _ := NewWeComAppChannel(cfg, msgBus)
+
+ t.Run("get empty access token initially", func(t *testing.T) {
+ token := ch.getAccessToken()
+ if token != "" {
+ t.Errorf("getAccessToken() = %q, want empty string", token)
+ }
+ })
+
+ t.Run("set and get access token", func(t *testing.T) {
+ ch.tokenMu.Lock()
+ ch.accessToken = "test_token_123"
+ ch.tokenExpiry = time.Now().Add(1 * time.Hour)
+ ch.tokenMu.Unlock()
+
+ token := ch.getAccessToken()
+ if token != "test_token_123" {
+ t.Errorf("getAccessToken() = %q, want %q", token, "test_token_123")
+ }
+ })
+
+ t.Run("expired token returns empty", func(t *testing.T) {
+ ch.tokenMu.Lock()
+ ch.accessToken = "expired_token"
+ ch.tokenExpiry = time.Now().Add(-1 * time.Hour)
+ ch.tokenMu.Unlock()
+
+ token := ch.getAccessToken()
+ if token != "" {
+ t.Errorf("getAccessToken() = %q, want empty string for expired token", token)
+ }
+ })
+}
+
+func TestWeComAppMessageStructures(t *testing.T) {
+ t.Run("WeComTextMessage structure", func(t *testing.T) {
+ msg := WeComTextMessage{
+ ToUser: "user123",
+ MsgType: "text",
+ AgentID: 1000002,
+ }
+ msg.Text.Content = "Hello World"
+
+ if msg.ToUser != "user123" {
+ t.Errorf("ToUser = %q, want %q", msg.ToUser, "user123")
+ }
+ if msg.MsgType != "text" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
+ }
+ if msg.AgentID != 1000002 {
+ t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
+ }
+ if msg.Text.Content != "Hello World" {
+ t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
+ }
+
+ // Test JSON marshaling
+ jsonData, err := json.Marshal(msg)
+ if err != nil {
+ t.Fatalf("failed to marshal JSON: %v", err)
+ }
+
+ var unmarshaled WeComTextMessage
+ err = json.Unmarshal(jsonData, &unmarshaled)
+ if err != nil {
+ t.Fatalf("failed to unmarshal JSON: %v", err)
+ }
+
+ if unmarshaled.ToUser != msg.ToUser {
+ t.Errorf("JSON round-trip failed for ToUser")
+ }
+ })
+
+ t.Run("WeComMarkdownMessage structure", func(t *testing.T) {
+ msg := WeComMarkdownMessage{
+ ToUser: "user123",
+ MsgType: "markdown",
+ AgentID: 1000002,
+ }
+ msg.Markdown.Content = "# Hello\nWorld"
+
+ if msg.Markdown.Content != "# Hello\nWorld" {
+ t.Errorf("Markdown.Content = %q, want %q", msg.Markdown.Content, "# Hello\nWorld")
+ }
+
+ // Test JSON marshaling
+ jsonData, err := json.Marshal(msg)
+ if err != nil {
+ t.Fatalf("failed to marshal JSON: %v", err)
+ }
+
+ if !bytes.Contains(jsonData, []byte("markdown")) {
+ t.Error("JSON should contain 'markdown' field")
+ }
+ })
+
+ t.Run("WeComImageMessage structure", func(t *testing.T) {
+ msg := WeComImageMessage{
+ ToUser: "user123",
+ MsgType: "image",
+ AgentID: 1000002,
+ }
+ msg.Image.MediaID = "media_123456"
+
+ if msg.Image.MediaID != "media_123456" {
+ t.Errorf("Image.MediaID = %q, want %q", msg.Image.MediaID, "media_123456")
+ }
+ })
+
+ t.Run("WeComAccessTokenResponse structure", func(t *testing.T) {
+ jsonData := `{
+ "errcode": 0,
+ "errmsg": "ok",
+ "access_token": "test_access_token",
+ "expires_in": 7200
+ }`
+
+ var resp WeComAccessTokenResponse
+ err := json.Unmarshal([]byte(jsonData), &resp)
+ if err != nil {
+ t.Fatalf("failed to unmarshal JSON: %v", err)
+ }
+
+ if resp.ErrCode != 0 {
+ t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0)
+ }
+ if resp.ErrMsg != "ok" {
+ t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok")
+ }
+ if resp.AccessToken != "test_access_token" {
+ t.Errorf("AccessToken = %q, want %q", resp.AccessToken, "test_access_token")
+ }
+ if resp.ExpiresIn != 7200 {
+ t.Errorf("ExpiresIn = %d, want %d", resp.ExpiresIn, 7200)
+ }
+ })
+
+ t.Run("WeComSendMessageResponse structure", func(t *testing.T) {
+ jsonData := `{
+ "errcode": 0,
+ "errmsg": "ok",
+ "invaliduser": "",
+ "invalidparty": "",
+ "invalidtag": ""
+ }`
+
+ var resp WeComSendMessageResponse
+ err := json.Unmarshal([]byte(jsonData), &resp)
+ if err != nil {
+ t.Fatalf("failed to unmarshal JSON: %v", err)
+ }
+
+ if resp.ErrCode != 0 {
+ t.Errorf("ErrCode = %d, want %d", resp.ErrCode, 0)
+ }
+ if resp.ErrMsg != "ok" {
+ t.Errorf("ErrMsg = %q, want %q", resp.ErrMsg, "ok")
+ }
+ })
+}
+
+func TestWeComAppXMLMessageStructure(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ 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)
+ }
+ if msg.AgentID != 1000002 {
+ t.Errorf("AgentID = %d, want %d", msg.AgentID, 1000002)
+ }
+}
+
+func TestWeComAppXMLMessageImage(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "image" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "image")
+ }
+ if msg.PicUrl != "https://example.com/image.jpg" {
+ t.Errorf("PicUrl = %q, want %q", msg.PicUrl, "https://example.com/image.jpg")
+ }
+ if msg.MediaId != "media_123" {
+ t.Errorf("MediaId = %q, want %q", msg.MediaId, "media_123")
+ }
+}
+
+func TestWeComAppXMLMessageVoice(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "voice" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "voice")
+ }
+ if msg.Format != "amr" {
+ t.Errorf("Format = %q, want %q", msg.Format, "amr")
+ }
+}
+
+func TestWeComAppXMLMessageLocation(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+ 39.9042
+ 116.4074
+ 16
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "location" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "location")
+ }
+ if msg.LocationX != 39.9042 {
+ t.Errorf("LocationX = %f, want %f", msg.LocationX, 39.9042)
+ }
+ if msg.LocationY != 116.4074 {
+ t.Errorf("LocationY = %f, want %f", msg.LocationY, 116.4074)
+ }
+ if msg.Scale != 16 {
+ t.Errorf("Scale = %d, want %d", msg.Scale, 16)
+ }
+ if msg.Label != "Beijing" {
+ t.Errorf("Label = %q, want %q", msg.Label, "Beijing")
+ }
+}
+
+func TestWeComAppXMLMessageLink(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+
+
+ 1234567890123456
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "link" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "link")
+ }
+ if msg.Title != "Link Title" {
+ t.Errorf("Title = %q, want %q", msg.Title, "Link Title")
+ }
+ if msg.Description != "Link Description" {
+ t.Errorf("Description = %q, want %q", msg.Description, "Link Description")
+ }
+ if msg.Url != "https://example.com" {
+ t.Errorf("Url = %q, want %q", msg.Url, "https://example.com")
+ }
+}
+
+func TestWeComAppXMLMessageEvent(t *testing.T) {
+ xmlData := `
+
+
+
+ 1234567890
+
+
+
+ 1000002
+`
+
+ var msg WeComXMLMessage
+ err := xml.Unmarshal([]byte(xmlData), &msg)
+ if err != nil {
+ t.Fatalf("failed to unmarshal XML: %v", err)
+ }
+
+ if msg.MsgType != "event" {
+ t.Errorf("MsgType = %q, want %q", msg.MsgType, "event")
+ }
+ if msg.Event != "subscribe" {
+ t.Errorf("Event = %q, want %q", msg.Event, "subscribe")
+ }
+ if msg.EventKey != "event_key_123" {
+ t.Errorf("EventKey = %q, want %q", msg.EventKey, "event_key_123")
+ }
+}
diff --git a/pkg/channels/wecom_test.go b/pkg/channels/wecom_test.go
new file mode 100644
index 000000000..a2015a8d3
--- /dev/null
+++ b/pkg/channels/wecom_test.go
@@ -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 := "Hello"
+ 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 := `
+
+
+
+ 1234567890
+
+
+ 1234567890123456
+`
+
+ 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)
+ }
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 0d41796a4..95753bf15 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -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
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 70ba67adf..ee46034a5 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -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},