feat: add wecom and wecomApp test

This commit is contained in:
swordkee 2026-02-20 18:28:10 +08:00
parent 59772cdbf2
commit 14ccfb39d9
5 changed files with 371 additions and 356 deletions

View file

@ -7,17 +7,11 @@ package channels
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -40,40 +34,54 @@ type WeComBotChannel struct {
msgMu sync.RWMutex msgMu sync.RWMutex
} }
// WeComBotXMLMessage represents the XML message structure from WeCom Bot // WeComBotMessage represents the JSON message structure from WeCom Bot (AIBOT)
type WeComBotXMLMessage struct { type WeComBotMessage struct {
XMLName xml.Name `xml:"xml"` MsgID string `json:"msgid"`
ToUserName string `xml:"ToUserName"` AIBotID string `json:"aibotid"`
FromUserName string `xml:"FromUserName"` ChatID string `json:"chatid"` // Session ID, only present for group chats
CreateTime int64 `xml:"CreateTime"` ChatType string `json:"chattype"` // "single" for DM, "group" for group chat
MsgType string `xml:"MsgType"` From struct {
Content string `xml:"Content"` UserID string `json:"userid"`
MsgId int64 `xml:"MsgId"` } `json:"from"`
PicUrl string `xml:"PicUrl"` ResponseURL string `json:"response_url"`
MediaId string `xml:"MediaId"` MsgType string `json:"msgtype"` // text, image, voice, file, mixed
Format string `xml:"Format"` Text struct {
Recognition string `xml:"Recognition"` // Voice recognition result Content string `json:"content"`
} `json:"text"`
Image struct {
URL string `json:"url"`
} `json:"image"`
Voice struct {
Content string `json:"content"` // Voice to text content
} `json:"voice"`
File struct {
URL string `json:"url"`
} `json:"file"`
Mixed struct {
MsgItem []struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
} `json:"text"`
Image struct {
URL string `json:"url"`
} `json:"image"`
} `json:"msg_item"`
} `json:"mixed"`
Quote struct {
MsgType string `json:"msgtype"`
Text struct {
Content string `json:"content"`
} `json:"text"`
} `json:"quote"`
} }
// WeComBotReplyMessage represents the reply message structure // WeComBotReplyMessage represents the reply message structure
type WeComBotReplyMessage struct { 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"` MsgType string `json:"msgtype"`
Text struct { Text struct {
Content string `json:"content"` Content string `json:"content"`
} `json:"text,omitempty"` } `json:"text,omitempty"`
Markdown struct {
Content string `json:"content"`
} `json:"markdown,omitempty"`
} }
// NewWeComBotChannel creates a new WeCom Bot channel instance // NewWeComBotChannel creates a new WeCom Bot channel instance
@ -205,14 +213,14 @@ func (c *WeComBotChannel) handleVerification(ctx context.Context, w http.Respons
} }
// Verify signature // Verify signature
if !c.verifySignature(msgSignature, timestamp, nonce, echostr) { if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnC("wecom", "Signature verification failed") logger.WarnC("wecom", "Signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden) http.Error(w, "Invalid signature", http.StatusForbidden)
return return
} }
// Decrypt echostr // Decrypt echostr
decryptedEchoStr, err := c.decryptMessage(echostr) decryptedEchoStr, err := WeComDecryptMessage(echostr, c.config.EncodingAESKey)
if err != nil { if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{ logger.ErrorCF("wecom", "Failed to decrypt echostr", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@ -265,14 +273,14 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
} }
// Verify signature // Verify signature
if !c.verifySignature(msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom", "Message signature verification failed") logger.WarnC("wecom", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden) http.Error(w, "Invalid signature", http.StatusForbidden)
return return
} }
// Decrypt message // Decrypt message
decryptedMsg, err := c.decryptMessage(encryptedMsg.Encrypt) decryptedMsg, err := WeComDecryptMessage(encryptedMsg.Encrypt, c.config.EncodingAESKey)
if err != nil { if err != nil {
logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{ logger.ErrorCF("wecom", "Failed to decrypt message", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@ -281,9 +289,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
return return
} }
// Parse decrypted XML message // Parse decrypted JSON message (AIBOT uses JSON format)
var msg WeComBotXMLMessage var msg WeComBotMessage
if err := xml.Unmarshal([]byte(decryptedMsg), &msg); err != nil { if err := json.Unmarshal([]byte(decryptedMsg), &msg); err != nil {
logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{ logger.ErrorCF("wecom", "Failed to parse decrypted message", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
}) })
@ -300,9 +308,9 @@ func (c *WeComBotChannel) handleMessageCallback(ctx context.Context, w http.Resp
} }
// processMessage processes the received message // processMessage processes the received message
func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotXMLMessage) { func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessage) {
// Skip non-text messages for now (can be extended) // Skip unsupported message types
if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" { if msg.MsgType != "text" && msg.MsgType != "image" && msg.MsgType != "voice" && msg.MsgType != "file" && msg.MsgType != "mixed" {
logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{ logger.DebugCF("wecom", "Skipping non-supported message type", map[string]interface{}{
"msg_type": msg.MsgType, "msg_type": msg.MsgType,
}) })
@ -310,8 +318,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotXMLMes
} }
// Message deduplication: Use msg_id to prevent duplicate processing // Message deduplication: Use msg_id to prevent duplicate processing
// As per WeCom documentation, use msg_id for deduplication msgID := msg.MsgID
msgID := fmt.Sprintf("%d", msg.MsgId)
c.msgMu.Lock() c.msgMu.Lock()
if c.processedMsgs[msgID] { if c.processedMsgs[msgID] {
c.msgMu.Unlock() c.msgMu.Unlock()
@ -330,141 +337,73 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotXMLMes
c.msgMu.Unlock() c.msgMu.Unlock()
} }
senderID := msg.FromUserName senderID := msg.From.UserID
chatID := senderID // WeCom Bot uses user ID as chat ID
// Use voice recognition result if available // Determine if this is a group chat or direct message
content := msg.Content // ChatType: "single" for DM, "group" for group chat
if msg.MsgType == "voice" && msg.Recognition != "" { isGroupChat := msg.ChatType == "group"
content = msg.Recognition
var chatID, peerKind, peerID string
if isGroupChat {
// Group chat: use ChatID as chatID and peer_id
chatID = msg.ChatID
peerKind = "group"
peerID = msg.ChatID
} else {
// Direct message: use senderID as chatID and peer_id
chatID = senderID
peerKind = "direct"
peerID = senderID
}
// Extract content based on message type
var content string
switch msg.MsgType {
case "text":
content = msg.Text.Content
case "voice":
content = msg.Voice.Content // Voice to text content
case "mixed":
// For mixed messages, concatenate text items
for _, item := range msg.Mixed.MsgItem {
if item.MsgType == "text" {
content += item.Text.Content
}
}
case "image", "file":
// For image and file, we don't have text content
content = ""
} }
// Build metadata // Build metadata
// WeCom Bot only supports direct messages (private chat)
metadata := map[string]string{ metadata := map[string]string{
"msg_type": msg.MsgType, "msg_type": msg.MsgType,
"msg_id": fmt.Sprintf("%d", msg.MsgId), "msg_id": msg.MsgID,
"platform": "wecom", "platform": "wecom",
"media_id": msg.MediaId, "peer_kind": peerKind,
"create_time": fmt.Sprintf("%d", msg.CreateTime), "peer_id": peerID,
"peer_kind": "direct", "response_url": msg.ResponseURL,
"peer_id": senderID, }
if isGroupChat {
metadata["chat_id"] = msg.ChatID
metadata["sender_id"] = senderID
} }
logger.DebugCF("wecom", "Received message", map[string]interface{}{ logger.DebugCF("wecom", "Received message", map[string]interface{}{
"sender_id": senderID, "sender_id": senderID,
"msg_type": msg.MsgType, "msg_type": msg.MsgType,
"preview": utils.Truncate(content, 50), "peer_kind": peerKind,
"is_group_chat": isGroupChat,
"preview": utils.Truncate(content, 50),
}) })
// Handle the message through the base channel // Handle the message through the base channel
c.HandleMessage(senderID, chatID, content, nil, metadata) 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 // sendWebhookReply sends a reply using the webhook URL
func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error { func (c *WeComBotChannel) sendWebhookReply(ctx context.Context, userID, content string) error {
reply := WeComBotWebhookReply{ reply := WeComBotReplyMessage{
MsgType: "text", MsgType: "text",
} }
reply.Text.Content = content reply.Text.Content = content

View file

@ -7,18 +7,12 @@ package channels
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -265,14 +259,14 @@ func (c *WeComAppChannel) handleVerification(ctx context.Context, w http.Respons
} }
// Verify signature // Verify signature
if !c.verifySignature(msgSignature, timestamp, nonce, echostr) { if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, echostr) {
logger.WarnC("wecom_app", "Signature verification failed") logger.WarnC("wecom_app", "Signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden) http.Error(w, "Invalid signature", http.StatusForbidden)
return return
} }
// Decrypt echostr // Decrypt echostr
decryptedEchoStr, err := c.decryptMessage(echostr) decryptedEchoStr, err := WeComDecryptMessage(echostr, c.config.EncodingAESKey)
if err != nil { if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{ logger.ErrorCF("wecom_app", "Failed to decrypt echostr", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@ -325,14 +319,14 @@ func (c *WeComAppChannel) handleMessageCallback(ctx context.Context, w http.Resp
} }
// Verify signature // Verify signature
if !c.verifySignature(msgSignature, timestamp, nonce, encryptedMsg.Encrypt) { if !WeComVerifySignature(c.config.Token, msgSignature, timestamp, nonce, encryptedMsg.Encrypt) {
logger.WarnC("wecom_app", "Message signature verification failed") logger.WarnC("wecom_app", "Message signature verification failed")
http.Error(w, "Invalid signature", http.StatusForbidden) http.Error(w, "Invalid signature", http.StatusForbidden)
return return
} }
// Decrypt message // Decrypt message
decryptedMsg, err := c.decryptMessage(encryptedMsg.Encrypt) decryptedMsg, err := WeComDecryptMessage(encryptedMsg.Encrypt, c.config.EncodingAESKey)
if err != nil { if err != nil {
logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{ logger.ErrorCF("wecom_app", "Failed to decrypt message", map[string]interface{}{
"error": err.Error(), "error": err.Error(),
@ -418,107 +412,6 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
c.HandleMessage(senderID, chatID, content, nil, metadata) 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 // tokenRefreshLoop periodically refreshes the access token
func (c *WeComAppChannel) tokenRefreshLoop() { func (c *WeComAppChannel) tokenRefreshLoop() {
ticker := time.NewTicker(5 * time.Minute) ticker := time.NewTicker(5 * time.Minute)

View file

@ -197,7 +197,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
msgEncrypt := "test_message" msgEncrypt := "test_message"
expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt) expectedSig := generateSignatureApp("test_token", timestamp, nonce, msgEncrypt)
if !ch.verifySignature(expectedSig, timestamp, nonce, msgEncrypt) { if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification") t.Error("valid signature should pass verification")
} }
}) })
@ -207,7 +207,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
nonce := "test_nonce" nonce := "test_nonce"
msgEncrypt := "test_message" msgEncrypt := "test_message"
if ch.verifySignature("invalid_sig", timestamp, nonce, msgEncrypt) { if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification") t.Error("invalid signature should fail verification")
} }
}) })
@ -221,7 +221,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
} }
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
if !chEmpty.verifySignature("any_sig", "any_ts", "any_nonce", "any_msg") { if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true") t.Error("empty token should skip verification and return true")
} }
}) })
@ -243,7 +243,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
plainText := "hello world" plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
result, err := ch.decryptMessage(encoded) result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -268,12 +268,12 @@ func TestWeComAppDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err) t.Fatalf("failed to encrypt test message: %v", err)
} }
result, err := ch.decryptMessage(encrypted) result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if result != originalMsg { if result != originalMsg {
t.Errorf("decryptMessage() = %q, want %q", result, originalMsg) t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg)
} }
}) })
@ -286,7 +286,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
} }
ch, _ := NewWeComAppChannel(cfg, msgBus) ch, _ := NewWeComAppChannel(cfg, msgBus)
_, err := ch.decryptMessage("invalid_base64!!!") _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
if err == nil { if err == nil {
t.Error("expected error for invalid base64, got nil") t.Error("expected error for invalid base64, got nil")
} }
@ -301,7 +301,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
} }
ch, _ := NewWeComAppChannel(cfg, msgBus) ch, _ := NewWeComAppChannel(cfg, msgBus)
_, err := ch.decryptMessage(base64.StdEncoding.EncodeToString([]byte("test"))) _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
if err == nil { if err == nil {
t.Error("expected error for invalid AES key, got nil") t.Error("expected error for invalid AES key, got nil")
} }
@ -319,7 +319,7 @@ func TestWeComAppDecryptMessage(t *testing.T) {
// Encrypt a very short message that results in ciphertext less than block size // Encrypt a very short message that results in ciphertext less than block size
shortData := make([]byte, 8) shortData := make([]byte, 8)
_, err := ch.decryptMessage(base64.StdEncoding.EncodeToString(shortData)) _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString(shortData), ch.config.EncodingAESKey)
if err == nil { if err == nil {
t.Error("expected error for short ciphertext, got nil") t.Error("expected error for short ciphertext, got nil")
} }
@ -361,7 +361,7 @@ func TestWeComAppPKCS7Unpad(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result, err := pkcs7Unpad(tt.input) result, err := pkcs7UnpadWeCom(tt.input)
if tt.expected == nil { if tt.expected == nil {
// This case should return an error // This case should return an error
if err == nil { if err == nil {

View file

@ -0,0 +1,117 @@
// PicoClaw - Ultra-lightweight personal AI agent
// WeCom common utilities for both WeCom Bot and WeCom App
package channels
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"fmt"
"sort"
"strings"
)
// WeComVerifySignature verifies the message signature for WeCom
// This is a common function used by both WeCom Bot and WeCom App
func WeComVerifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" {
return true // Skip verification if token is not set
}
// Sort parameters
params := []string{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
}
// WeComDecryptMessage decrypts the encrypted message using AES
// This is a common function used by both WeCom Bot and WeCom App
func WeComDecryptMessage(encryptedMsg, encodingAESKey string) (string, error) {
if 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(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]
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
}

View file

@ -11,6 +11,7 @@ import (
"crypto/sha1" "crypto/sha1"
"encoding/base64" "encoding/base64"
"encoding/binary" "encoding/binary"
"encoding/json"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"net/http" "net/http"
@ -34,7 +35,7 @@ func generateTestAESKey() string {
return base64.StdEncoding.EncodeToString(key)[:43] return base64.StdEncoding.EncodeToString(key)[:43]
} }
// encryptTestMessage encrypts a message for testing // encryptTestMessage encrypts a message for testing (AIBOT JSON format)
func encryptTestMessage(message, aesKey string) (string, error) { func encryptTestMessage(message, aesKey string) (string, error) {
// Decode AES key // Decode AES key
key, err := base64.StdEncoding.DecodeString(aesKey + "=") key, err := base64.StdEncoding.DecodeString(aesKey + "=")
@ -42,14 +43,14 @@ func encryptTestMessage(message, aesKey string) (string, error) {
return "", err return "", err
} }
// Prepare message: random(16) + msg_len(4) + msg + corp_id // Prepare message: random(16) + msg_len(4) + msg + receiveid
random := make([]byte, 0, 16) random := make([]byte, 0, 16)
for i := 0; i < 16; i++ { for i := 0; i < 16; i++ {
random = append(random, byte(i)) random = append(random, byte(i))
} }
msgBytes := []byte(message) msgBytes := []byte(message)
corpID := []byte("test_corp_id") receiveID := []byte("test_aibot_id")
msgLen := uint32(len(msgBytes)) msgLen := uint32(len(msgBytes))
lenBytes := make([]byte, 4) lenBytes := make([]byte, 4)
@ -57,7 +58,7 @@ func encryptTestMessage(message, aesKey string) (string, error) {
plainText := append(random, lenBytes...) plainText := append(random, lenBytes...)
plainText = append(plainText, msgBytes...) plainText = append(plainText, msgBytes...)
plainText = append(plainText, corpID...) plainText = append(plainText, receiveID...)
// PKCS7 padding // PKCS7 padding
blockSize := aes.BlockSize blockSize := aes.BlockSize
@ -176,7 +177,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
msgEncrypt := "test_message" msgEncrypt := "test_message"
expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt) expectedSig := generateSignature("test_token", timestamp, nonce, msgEncrypt)
if !ch.verifySignature(expectedSig, timestamp, nonce, msgEncrypt) { if !WeComVerifySignature(ch.config.Token, expectedSig, timestamp, nonce, msgEncrypt) {
t.Error("valid signature should pass verification") t.Error("valid signature should pass verification")
} }
}) })
@ -186,7 +187,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
nonce := "test_nonce" nonce := "test_nonce"
msgEncrypt := "test_message" msgEncrypt := "test_message"
if ch.verifySignature("invalid_sig", timestamp, nonce, msgEncrypt) { if WeComVerifySignature(ch.config.Token, "invalid_sig", timestamp, nonce, msgEncrypt) {
t.Error("invalid signature should fail verification") t.Error("invalid signature should fail verification")
} }
}) })
@ -203,7 +204,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty, config: cfgEmpty,
} }
if !chEmpty.verifySignature("any_sig", "any_ts", "any_nonce", "any_msg") { if !WeComVerifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true") t.Error("empty token should skip verification and return true")
} }
}) })
@ -224,7 +225,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
plainText := "hello world" plainText := "hello world"
encoded := base64.StdEncoding.EncodeToString([]byte(plainText)) encoded := base64.StdEncoding.EncodeToString([]byte(plainText))
result, err := ch.decryptMessage(encoded) result, err := WeComDecryptMessage(encoded, ch.config.EncodingAESKey)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -248,12 +249,12 @@ func TestWeComBotDecryptMessage(t *testing.T) {
t.Fatalf("failed to encrypt test message: %v", err) t.Fatalf("failed to encrypt test message: %v", err)
} }
result, err := ch.decryptMessage(encrypted) result, err := WeComDecryptMessage(encrypted, ch.config.EncodingAESKey)
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
if result != originalMsg { if result != originalMsg {
t.Errorf("decryptMessage() = %q, want %q", result, originalMsg) t.Errorf("WeComDecryptMessage() = %q, want %q", result, originalMsg)
} }
}) })
@ -265,7 +266,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
} }
ch, _ := NewWeComBotChannel(cfg, msgBus) ch, _ := NewWeComBotChannel(cfg, msgBus)
_, err := ch.decryptMessage("invalid_base64!!!") _, err := WeComDecryptMessage("invalid_base64!!!", ch.config.EncodingAESKey)
if err == nil { if err == nil {
t.Error("expected error for invalid base64, got nil") t.Error("expected error for invalid base64, got nil")
} }
@ -279,7 +280,7 @@ func TestWeComBotDecryptMessage(t *testing.T) {
} }
ch, _ := NewWeComBotChannel(cfg, msgBus) ch, _ := NewWeComBotChannel(cfg, msgBus)
_, err := ch.decryptMessage(base64.StdEncoding.EncodeToString([]byte("test"))) _, err := WeComDecryptMessage(base64.StdEncoding.EncodeToString([]byte("test")), ch.config.EncodingAESKey)
if err == nil { if err == nil {
t.Error("expected error for invalid AES key, got nil") t.Error("expected error for invalid AES key, got nil")
} }
@ -408,20 +409,62 @@ func TestWeComBotHandleMessageCallback(t *testing.T) {
} }
ch, _ := NewWeComBotChannel(cfg, msgBus) ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("valid message callback", func(t *testing.T) { t.Run("valid direct message callback", func(t *testing.T) {
// Create XML message // Create JSON message for direct chat (single)
xmlMsg := WeComBotXMLMessage{ jsonMsg := `{
ToUserName: "corp_id", "msgid": "test_msg_id_123",
FromUserName: "user123", "aibotid": "test_aibot_id",
CreateTime: 1234567890, "chattype": "single",
MsgType: "text", "from": {"userid": "user123"},
Content: "Hello World", "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
MsgId: 123456, "msgtype": "text",
} "text": {"content": "Hello World"}
xmlData, _ := xml.Marshal(xmlMsg) }`
// Encrypt message // Encrypt message
encrypted, _ := encryptTestMessage(string(xmlData), aesKey) encrypted, _ := encryptTestMessage(jsonMsg, 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("valid group message callback", func(t *testing.T) {
// Create JSON message for group chat
jsonMsg := `{
"msgid": "test_msg_id_456",
"aibotid": "test_aibot_id",
"chatid": "group_chat_id_123",
"chattype": "group",
"from": {"userid": "user456"},
"response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
"msgtype": "text",
"text": {"content": "Hello Group"}
}`
// Encrypt message
encrypted, _ := encryptTestMessage(jsonMsg, aesKey)
// Create encrypted XML wrapper // Create encrypted XML wrapper
encryptedWrapper := struct { encryptedWrapper := struct {
@ -506,42 +549,61 @@ func TestWeComBotProcessMessage(t *testing.T) {
} }
ch, _ := NewWeComBotChannel(cfg, msgBus) ch, _ := NewWeComBotChannel(cfg, msgBus)
t.Run("process text message", func(t *testing.T) { t.Run("process direct text message", func(t *testing.T) {
msg := WeComBotXMLMessage{ msg := WeComBotMessage{
ToUserName: "corp_id", MsgID: "test_msg_id_123",
FromUserName: "user123", AIBotID: "test_aibot_id",
CreateTime: 1234567890, ChatType: "single",
MsgType: "text", ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
Content: "Hello World", MsgType: "text",
MsgId: 123456,
} }
msg.From.UserID = "user123"
msg.Text.Content = "Hello World"
// Should not panic // Should not panic
ch.processMessage(context.Background(), msg) ch.processMessage(context.Background(), msg)
}) })
t.Run("process voice message with recognition", func(t *testing.T) { t.Run("process group text message", func(t *testing.T) {
msg := WeComBotXMLMessage{ msg := WeComBotMessage{
ToUserName: "corp_id", MsgID: "test_msg_id_456",
FromUserName: "user123", AIBotID: "test_aibot_id",
CreateTime: 1234567890, ChatID: "group_chat_id_123",
MsgType: "voice", ChatType: "group",
Recognition: "Voice message text", ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
MsgId: 123456, MsgType: "text",
} }
msg.From.UserID = "user456"
msg.Text.Content = "Hello Group"
// Should not panic
ch.processMessage(context.Background(), msg)
})
t.Run("process voice message", func(t *testing.T) {
msg := WeComBotMessage{
MsgID: "test_msg_id_789",
AIBotID: "test_aibot_id",
ChatType: "single",
ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
MsgType: "voice",
}
msg.From.UserID = "user123"
msg.Voice.Content = "Voice message text"
// Should not panic // Should not panic
ch.processMessage(context.Background(), msg) ch.processMessage(context.Background(), msg)
}) })
t.Run("skip unsupported message type", func(t *testing.T) { t.Run("skip unsupported message type", func(t *testing.T) {
msg := WeComBotXMLMessage{ msg := WeComBotMessage{
ToUserName: "corp_id", MsgID: "test_msg_id_000",
FromUserName: "user123", AIBotID: "test_aibot_id",
CreateTime: 1234567890, ChatType: "single",
MsgType: "video", ResponseURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
MsgId: 123456, MsgType: "video",
} }
msg.From.UserID = "user123"
// Should not panic // Should not panic
ch.processMessage(context.Background(), msg) ch.processMessage(context.Background(), msg)
@ -637,8 +699,8 @@ func TestWeComBotHandleHealth(t *testing.T) {
} }
} }
func TestWeComBotWebhookReplyMessage(t *testing.T) { func TestWeComBotReplyMessage(t *testing.T) {
msg := WeComBotWebhookReply{ msg := WeComBotReplyMessage{
MsgType: "text", MsgType: "text",
} }
msg.Text.Content = "Hello World" msg.Text.Content = "Hello World"
@ -651,39 +713,43 @@ func TestWeComBotWebhookReplyMessage(t *testing.T) {
} }
} }
func TestWeComBotXMLMessageStructure(t *testing.T) { func TestWeComBotMessageStructure(t *testing.T) {
xmlData := `<?xml version="1.0"?> jsonData := `{
<xml> "msgid": "test_msg_id_123",
<ToUserName><![CDATA[corp_id]]></ToUserName> "aibotid": "test_aibot_id",
<FromUserName><![CDATA[user123]]></FromUserName> "chatid": "group_chat_id_123",
<CreateTime>1234567890</CreateTime> "chattype": "group",
<MsgType><![CDATA[text]]></MsgType> "from": {"userid": "user123"},
<Content><![CDATA[Hello World]]></Content> "response_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
<MsgId>1234567890123456</MsgId> "msgtype": "text",
</xml>` "text": {"content": "Hello World"}
}`
var msg WeComBotXMLMessage var msg WeComBotMessage
err := xml.Unmarshal([]byte(xmlData), &msg) err := json.Unmarshal([]byte(jsonData), &msg)
if err != nil { if err != nil {
t.Fatalf("failed to unmarshal XML: %v", err) t.Fatalf("failed to unmarshal JSON: %v", err)
} }
if msg.ToUserName != "corp_id" { if msg.MsgID != "test_msg_id_123" {
t.Errorf("ToUserName = %q, want %q", msg.ToUserName, "corp_id") t.Errorf("MsgID = %q, want %q", msg.MsgID, "test_msg_id_123")
} }
if msg.FromUserName != "user123" { if msg.AIBotID != "test_aibot_id" {
t.Errorf("FromUserName = %q, want %q", msg.FromUserName, "user123") t.Errorf("AIBotID = %q, want %q", msg.AIBotID, "test_aibot_id")
} }
if msg.CreateTime != 1234567890 { if msg.ChatID != "group_chat_id_123" {
t.Errorf("CreateTime = %d, want %d", msg.CreateTime, 1234567890) t.Errorf("ChatID = %q, want %q", msg.ChatID, "group_chat_id_123")
}
if msg.ChatType != "group" {
t.Errorf("ChatType = %q, want %q", msg.ChatType, "group")
}
if msg.From.UserID != "user123" {
t.Errorf("From.UserID = %q, want %q", msg.From.UserID, "user123")
} }
if msg.MsgType != "text" { if msg.MsgType != "text" {
t.Errorf("MsgType = %q, want %q", msg.MsgType, "text") t.Errorf("MsgType = %q, want %q", msg.MsgType, "text")
} }
if msg.Content != "Hello World" { if msg.Text.Content != "Hello World" {
t.Errorf("Content = %q, want %q", msg.Content, "Hello World") t.Errorf("Text.Content = %q, want %q", msg.Text.Content, "Hello World")
}
if msg.MsgId != 1234567890123456 {
t.Errorf("MsgId = %d, want %d", msg.MsgId, 1234567890123456)
} }
} }