diff --git a/pkg/channels/wecom/aibot.go b/pkg/channels/wecom/aibot.go index 93fe8c36d..81e15b355 100644 --- a/pkg/channels/wecom/aibot.go +++ b/pkg/channels/wecom/aibot.go @@ -3,22 +3,30 @@ package wecom import ( "bytes" "context" + "crypto/aes" + "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/json" "fmt" + "image" "io" "math/big" "net/http" + "net/url" + "os" + "path/filepath" "strings" "sync" "time" + "github.com/h2non/filetype" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -79,7 +87,9 @@ type WeComAIBotMessage struct { } `json:"stream,omitempty"` // image message Image *struct { - URL string `json:"url"` + URL string `json:"url"` + MediaID string `json:"media_id"` + AESKey string `json:"aeskey,omitempty"` } `json:"image,omitempty"` // mixed message (text + image) Mixed *struct { @@ -89,7 +99,9 @@ type WeComAIBotMessage struct { Content string `json:"content"` } `json:"text,omitempty"` Image *struct { - URL string `json:"url"` + URL string `json:"url"` + MediaID string `json:"media_id"` + AESKey string `json:"aeskey,omitempty"` } `json:"image,omitempty"` } `json:"msg_item"` } `json:"mixed,omitempty"` @@ -451,7 +463,25 @@ func (c *WeComAIBotChannel) processMessage( timestamp, nonce string, ) string { logger.DebugCF("wecom_aibot", "Processing message", map[string]any{ - "msgtype": msg.MsgType, + "msgtype": msg.MsgType, + "msgid": msg.MsgID, + "from": msg.From.UserID, + "chatid": msg.ChatID, + "chattype": msg.ChatType, + "has_image": msg.Image != nil, + "has_mixed": msg.Mixed != nil, + "image_url": func() string { + if msg.Image != nil { + return msg.Image.URL + } + return "" + }(), + "text": func() string { + if msg.Text != nil { + return utils.Truncate(msg.Text.Content, 100) + } + return "" + }(), }) switch msg.MsgType { @@ -607,26 +637,160 @@ func (c *WeComAIBotChannel) handleImageMessage( msg WeComAIBotMessage, timestamp, nonce string, ) string { - logger.WarnC("wecom_aibot", "Image message type not yet fully implemented") + logger.InfoCF("wecom_aibot", "Handling image message", map[string]any{ + "msgid": msg.MsgID, + "user_id": msg.From.UserID, + "chat_id": msg.ChatID, + "imageURL": msg.Image.URL, + "media_id": msg.Image.MediaID, + }) + if msg.Image == nil { logger.ErrorC("wecom_aibot", "Image message missing image field") return c.encryptEmptyResponse(timestamp, nonce) } imageURL := msg.Image.URL + mediaID := msg.Image.MediaID + aesKey := msg.Image.AESKey // Use the per-message AESKey for decryption - // For now, just acknowledge receipt without echoing the image - return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: WeComAIBotStreamInfo{ - ID: c.generateStreamID(), - Finish: true, - Content: fmt.Sprintf( - "Image received (URL: %s), but image messages are not yet supported", - imageURL, - ), - }, + // If no per-message AESKey, fall back to config EncodingAESKey + if aesKey == "" && c.config.EncodingAESKey != "" { + aesKey = c.config.EncodingAESKey + logger.InfoCF("wecom_aibot", "Using config EncodingAESKey as fallback", map[string]any{ + "msg_id": msg.MsgID, + }) + } + + userID := msg.From.UserID + if userID == "" { + userID = "unknown" + } + + // chatID: group chat uses chatid, single chat uses userid + chatID := msg.ChatID + if chatID == "" { + chatID = userID + } + + logger.InfoCF("wecom_aibot", "Image message with AESKey", map[string]any{ + "chat_id": chatID, + "imageURL": imageURL, + "aes_key": aesKey, + "msg_id": msg.MsgID, + "user_id": userID, }) + + // Download image and store in media store + var mediaRefs []string + store := c.GetMediaStore() + if store == nil { + logger.WarnCF("wecom_aibot", "MediaStore not available, image will not be processed", map[string]any{ + "user_id": userID, + "chat_id": chatID, + "msg_id": msg.MsgID, + }) + } else { + var ref string + scope := channels.BuildMediaScope("wecom_aibot", chatID, msg.MsgID) + + // Try to download via media_id first (WeCom AI Bot callback directly provides media_id) + if mediaID != "" { + logger.InfoCF("wecom_aibot", "Trying to download image via media_id", map[string]any{ + "media_id": mediaID, + "msg_id": msg.MsgID, + }) + ref = c.downloadMediaFromMediaIDNoAuth(ctx, mediaID, "image", store, scope, msg.MsgID) + } + + // Fallback to URL if media_id download failed or not available + if ref == "" && imageURL != "" { + logger.InfoCF("wecom_aibot", "Falling back to URL download", map[string]any{ + "url": imageURL, + "msg_id": msg.MsgID, + "aes_key": aesKey, + }) + ref = c.downloadMediaFromURL(ctx, imageURL, "image.jpg", aesKey, store, scope, msg.MsgID) + } + + if ref != "" { + mediaRefs = append(mediaRefs, ref) + logger.DebugCF("wecom_aibot", "Image downloaded successfully", map[string]any{ + "user_id": userID, + "chat_id": chatID, + "msg_id": msg.MsgID, + "media_count": len(mediaRefs), + }) + } else { + logger.WarnCF("wecom_aibot", "Image download failed", map[string]any{ + "user_id": userID, + "chat_id": chatID, + "msg_id": msg.MsgID, + "url": imageURL, + "media_id": mediaID, + }) + } + } + + // Build content with image tag + content := "[image]" + + streamID := c.generateStreamID() + deadline := time.Now().Add(30 * time.Second) + + taskCtx, taskCancel := context.WithCancel(c.ctx) + + task := &streamTask{ + StreamID: streamID, + ChatID: chatID, + ResponseURL: msg.ResponseURL, + Question: content, + CreatedTime: time.Now(), + Deadline: deadline, + Finished: false, + answerCh: make(chan string, 1), + ctx: taskCtx, + cancel: taskCancel, + } + + c.taskMu.Lock() + c.streamTasks[streamID] = task + c.chatTasks[chatID] = append(c.chatTasks[chatID], task) + c.taskMu.Unlock() + + // Publish to agent asynchronously with media refs + go func() { + sender := bus.SenderInfo{ + Platform: "wecom_aibot", + PlatformID: userID, + CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID), + DisplayName: userID, + } + peerKind := "direct" + if msg.ChatType == "group" { + peerKind = "group" + } + peer := bus.Peer{Kind: peerKind, ID: chatID} + metadata := map[string]string{ + "channel": "wecom_aibot", + "chat_type": msg.ChatType, + "msg_type": "image", + "msgid": msg.MsgID, + "aibotid": msg.AIBotID, + "stream_id": streamID, + "response_url": msg.ResponseURL, + } + logger.InfoCF("wecom_aibot", "Calling HandleMessage with media", map[string]any{ + "msgid": msg.MsgID, + "content": content, + "mediaRefs": mediaRefs, + }) + c.HandleMessage(task.ctx, peer, msg.MsgID, userID, chatID, + content, mediaRefs, metadata, sender) + }() + + // Return first streaming response immediately + return c.getStreamResponse(task, timestamp, nonce) } // handleMixedMessage handles mixed (text + image) messages @@ -635,15 +799,145 @@ func (c *WeComAIBotChannel) handleMixedMessage( msg WeComAIBotMessage, timestamp, nonce string, ) string { - logger.WarnC("wecom_aibot", "Mixed message type not yet fully implemented") - return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{ - MsgType: "stream", - Stream: WeComAIBotStreamInfo{ - ID: c.generateStreamID(), - Finish: true, - Content: "Mixed message type is not yet supported", - }, - }) + if msg.Mixed == nil || len(msg.Mixed.MsgItem) == 0 { + logger.ErrorC("wecom_aibot", "Mixed message missing msg_item field") + return c.encryptEmptyResponse(timestamp, nonce) + } + + userID := msg.From.UserID + if userID == "" { + userID = "unknown" + } + + // chatID: group chat uses chatid, single chat uses userid + chatID := msg.ChatID + if chatID == "" { + chatID = userID + } + + // Process text and image items + var content string + var mediaRefs []string + scope := channels.BuildMediaScope("wecom_aibot", chatID, msg.MsgID) + + store := c.GetMediaStore() + if store == nil { + logger.WarnCF("wecom_aibot", "MediaStore not available, mixed message images will not be processed", map[string]any{ + "user_id": userID, + "chat_id": chatID, + "msg_id": msg.MsgID, + }) + // Still process text content even without media store + for _, item := range msg.Mixed.MsgItem { + if item.MsgType == "text" && item.Text != nil && item.Text.Content != "" { + content += item.Text.Content + "\n" + } + } + } else { + for _, item := range msg.Mixed.MsgItem { + switch item.MsgType { + case "text": + if item.Text != nil && item.Text.Content != "" { + content += item.Text.Content + "\n" + } + case "image": + if item.Image != nil && item.Image.URL != "" { + aesKey := item.Image.AESKey + // If no per-message AESKey, fall back to config EncodingAESKey + if aesKey == "" && c.config.EncodingAESKey != "" { + aesKey = c.config.EncodingAESKey + } + ref := c.downloadMediaFromURL(ctx, item.Image.URL, "image.jpg", aesKey, store, scope, msg.MsgID) + if ref != "" { + mediaRefs = append(mediaRefs, ref) + logger.DebugCF("wecom_aibot", "Mixed message image downloaded", map[string]any{ + "user_id": userID, + "chat_id": chatID, + "msg_id": msg.MsgID, + "media_ref": ref, + }) + } else { + logger.WarnCF("wecom_aibot", "Mixed message image download failed", map[string]any{ + "user_id": userID, + "chat_id": chatID, + "msg_id": msg.MsgID, + "url": item.Image.URL, + }) + } + } + } + } + } + + // If no content from text, use image placeholder + if content == "" { + if len(mediaRefs) > 0 { + content = "[image]" + } else { + content = "[mixed message]" + } + } + + if len(mediaRefs) > 0 { + logger.DebugCF("wecom_aibot", "Mixed message processed", map[string]any{ + "user_id": userID, + "chat_id": chatID, + "msg_id": msg.MsgID, + "media_count": len(mediaRefs), + }) + } + + streamID := c.generateStreamID() + deadline := time.Now().Add(30 * time.Second) + + taskCtx, taskCancel := context.WithCancel(c.ctx) + + task := &streamTask{ + StreamID: streamID, + ChatID: chatID, + ResponseURL: msg.ResponseURL, + Question: content, + CreatedTime: time.Now(), + Deadline: deadline, + Finished: false, + answerCh: make(chan string, 1), + ctx: taskCtx, + cancel: taskCancel, + } + + c.taskMu.Lock() + c.streamTasks[streamID] = task + c.chatTasks[chatID] = append(c.chatTasks[chatID], task) + c.taskMu.Unlock() + + // Publish to agent asynchronously with media refs + go func() { + sender := bus.SenderInfo{ + Platform: "wecom_aibot", + PlatformID: userID, + CanonicalID: identity.BuildCanonicalID("wecom_aibot", userID), + DisplayName: userID, + } + peerKind := "direct" + if msg.ChatType == "group" { + peerKind = "group" + } + peer := bus.Peer{Kind: peerKind, ID: chatID} + metadata := map[string]string{ + "channel": "wecom_aibot", + "chat_type": msg.ChatType, + "msg_type": "mixed", + "msgid": msg.MsgID, + "aibotid": msg.AIBotID, + "stream_id": streamID, + "response_url": msg.ResponseURL, + } + c.HandleMessage(task.ctx, peer, msg.MsgID, userID, chatID, + content, mediaRefs, metadata, sender) + }() + + // Return first streaming response immediately + return c.getStreamResponse(task, timestamp, nonce) } // handleEventMessage handles event messages @@ -1015,3 +1309,598 @@ func (c *WeComAIBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) "status": status, }) } + +// downloadMediaFromURL downloads media from a URL and stores it in MediaStore. +func (c *WeComAIBotChannel) downloadMediaFromURL( + ctx context.Context, + mediaURL, filename, aesKey string, + store media.MediaStore, + scope string, + msgID string, +) string { + // Download the media file from the URL + req, err := http.NewRequestWithContext(ctx, http.MethodGet, mediaURL, nil) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to create media download request", map[string]any{ + "error": err.Error(), + "url": mediaURL, + }) + return "" + } + + // Minimal headers - WeCom COS signature may be sensitive + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + // Use default client - no custom redirects + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to download media", map[string]any{ + "error": err.Error(), + "url": mediaURL, + }) + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("wecom_aibot", "Media download failed with status", map[string]any{ + "status": resp.StatusCode, + "url": mediaURL, + }) + return "" + } + + // Read the media body first + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to read media body", map[string]any{ + "error": err.Error(), + }) + return "" + } + + // Try to decrypt the encrypted file content using the per-message AESKey + // WeCom AI Bot image URLs return encrypted data + var decryptedBody []byte + if aesKey != "" { + decryptedBody, err = decryptMediaContentWithKey(body, aesKey) + if err != nil { + logger.WarnCF("wecom_aibot", "Decryption failed, using raw data", map[string]any{ + "error": err.Error(), + "data_len": len(body), + "first_bytes": fmt.Sprintf("%x", body[:min(32, len(body))]), + }) + // Fallback to raw data if decryption fails + decryptedBody = body + } + } else { + logger.InfoCF("wecom_aibot", "No AESKey provided, using raw data", map[string]any{}) + decryptedBody = body + } + + // Debug: Log first few bytes to diagnose issues + firstBytesLen := 20 + if len(decryptedBody) < firstBytesLen { + firstBytesLen = len(decryptedBody) + } + + logger.InfoCF("wecom_aibot", "Media download response", map[string]any{ + "status": resp.StatusCode, + "content_type": resp.Header.Get("Content-Type"), + "content_len": len(decryptedBody), + "first_bytes": fmt.Sprintf("%x", decryptedBody[:firstBytesLen]), + }) + + // Determine actual file type from content (magic bytes) + contentType := resp.Header.Get("Content-Type") + if kind, err := filetype.Match(decryptedBody); err == nil && kind != filetype.Unknown { + // Use the detected type from content + contentType = kind.MIME.Value + filename = "image." + kind.Extension + logger.DebugCF("wecom_aibot", "Filetype detected from content", map[string]any{ + "detected_type": kind.MIME.Value, + "extension": kind.Extension, + "size": len(decryptedBody), + }) + } else { + // Filetype detection failed, try to determine from header or extension + logger.WarnCF("wecom_aibot", "Filetype detection failed, using extension inference", map[string]any{ + "header_content_type": contentType, + "error": err, + }) + // Try to determine from file extension + ext := filepath.Ext(filename) + if ext == "" { + // No extension, try header + switch { + case strings.Contains(contentType, "image/jpeg"): + filename += ".jpg" + contentType = "image/jpeg" + case strings.Contains(contentType, "image/png"): + filename += ".png" + contentType = "image/png" + case strings.Contains(contentType, "image/gif"): + filename += ".gif" + contentType = "image/gif" + case strings.Contains(contentType, "image/webp"): + filename += ".webp" + contentType = "image/webp" + case strings.Contains(contentType, "audio/amr"): + filename += ".amr" + contentType = "audio/amr" + case strings.Contains(contentType, "audio/mp3") || strings.Contains(contentType, "audio/mpeg"): + filename += ".mp3" + contentType = "audio/mpeg" + case strings.Contains(contentType, "video/mp4"): + filename += ".mp4" + contentType = "video/mp4" + case strings.Contains(contentType, "application/pdf"): + filename += ".pdf" + contentType = "application/pdf" + default: + filename += ".bin" + } + } else { + // Has extension, set correct MIME type based on extension + switch ext { + case ".jpg", ".jpeg": + contentType = "image/jpeg" + case ".png": + contentType = "image/png" + case ".gif": + contentType = "image/gif" + case ".webp": + contentType = "image/webp" + case ".amr": + contentType = "audio/amr" + case ".mp3": + contentType = "audio/mpeg" + case ".mp4": + contentType = "video/mp4" + case ".pdf": + contentType = "application/pdf" + } + } + } + + // Generate temp file path + tempDir := os.TempDir() + mediaDir := filepath.Join(tempDir, "picoclaw_media", "wecom_aibot") + if mkdirErr := os.MkdirAll(mediaDir, 0o755); mkdirErr != nil { + logger.ErrorCF("wecom_aibot", "Failed to create media directory", map[string]any{ + "error": mkdirErr.Error(), + }) + return "" + } + + // Create a unique filename to avoid collisions + uniqueFilename := fmt.Sprintf("%s-%d-%s", msgID, time.Now().Unix(), filename) + localPath := filepath.Join(mediaDir, uniqueFilename) + + // Write the file + err = os.WriteFile(localPath, decryptedBody, 0o644) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to write media file", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + return "" + } + + // Verify the image is valid by decoding it + f, err := os.Open(localPath) + if err == nil { + _, _, decodeErr := image.Decode(f) + f.Close() + if decodeErr != nil { + logger.ErrorCF("wecom_aibot", "Downloaded file is not a valid image", map[string]any{ + "path": localPath, + "error": decodeErr.Error(), + "size": len(decryptedBody), + "header": fmt.Sprintf("%x", decryptedBody[:min(20, len(decryptedBody))]), + }) + // Remove invalid file + os.Remove(localPath) + return "" + } + } else { + logger.WarnCF("wecom_aibot", "Failed to open file for verification", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + } + + // Store in media store + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + ContentType: contentType, + Source: "wecom_aibot", + }, scope) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to store media", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + os.Remove(localPath) + return "" + } + + logger.DebugCF("wecom_aibot", "Media downloaded successfully", map[string]any{ + "url": mediaURL, + "path": localPath, + "size": len(decryptedBody), + "content_type": contentType, + "fileref": ref, + }) + + return ref +} + +// decryptMediaContent decrypts the encrypted media content using EncodingAESKey +// WeCom encryption format: 16-byte random pad + 4-byte length + plaintext + appid +func (c *WeComAIBotChannel) decryptMediaContent(encryptedData []byte) ([]byte, error) { + if c.config.EncodingAESKey == "" { + return nil, fmt.Errorf("encoding_aes_key not configured") + } + + logger.DebugCF("wecom_aibot", "Starting decryption", map[string]any{ + "encrypted_len": len(encryptedData), + "first_bytes": fmt.Sprintf("%x", encryptedData[:min(32, len(encryptedData))]), + }) + + // EncodingAESKey is base64 encoded with padding + // Add padding if needed (43 chars + "=" = 44 chars) + keyStr := c.config.EncodingAESKey + if len(keyStr)%4 != 0 { + keyStr += strings.Repeat("=", 4-len(keyStr)%4) + } + + // Decode AES key + keyBytes, err := base64.StdEncoding.DecodeString(keyStr) + if err != nil { + return nil, fmt.Errorf("failed to decode encoding_aes_key: %w", err) + } + if len(keyBytes) != 32 { + return nil, fmt.Errorf("invalid encoding_aes_key length after decoding: %d, expected 32", len(keyBytes)) + } + + logger.DebugCF("wecom_aibot", "AES key decoded", map[string]any{ + "key_len": len(keyBytes), + "iv": fmt.Sprintf("%x", keyBytes[:16]), + }) + + // AES-256-CBC decryption + block, err := aes.NewCipher(keyBytes) + if err != nil { + return nil, fmt.Errorf("failed to create cipher: %w", err) + } + + iv := keyBytes[:16] // IV is first 16 bytes of key + + if len(encryptedData) < aes.BlockSize { + return nil, fmt.Errorf("encrypted data too short") + } + + // CBC decrypt + decrypted := make([]byte, len(encryptedData)) + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(decrypted, encryptedData) + + logger.DebugCF("wecom_aibot", "After CBC decryption", map[string]any{ + "decrypted_len": len(decrypted), + "decrypted_first": fmt.Sprintf("%x", decrypted[:min(32, len(decrypted))]), + }) + + // Remove PKCS7 padding using common function + decrypted, err = pkcs7Unpad(decrypted) + if err != nil { + logger.WarnCF("wecom_aibot", "PKCS7 unpad failed, returning decrypted data anyway", map[string]any{ + "error": err.Error(), + "last_byte": fmt.Sprintf("%x", decrypted[len(decrypted)-1]), + "decrypted_len": len(decrypted), + }) + // Return decrypted data even without unpadding - might be valid image data + return decrypted, nil + } + + logger.DebugCF("wecom_aibot", "After PKCS7 unpad", map[string]any{ + "unpadded_len": len(decrypted), + "unpadded_first": fmt.Sprintf("%x", decrypted[:min(32, len(decrypted))]), + }) + + // WeCom decrypted format: 16-byte random pad + 4-byte length + plaintext + appid + // Extract the actual plaintext + if len(decrypted) < 20 { + return nil, fmt.Errorf("decrypted data invalid, too short: %d", len(decrypted)) + } + + // Extract plaintext length (big-endian 4 bytes) + plainLen := int(decrypted[16])<<24 | int(decrypted[17])<<16 | int(decrypted[18])<<8 | int(decrypted[19]) + logger.DebugCF("wecom_aibot", "Extracted plaintext info", map[string]any{ + "plain_len": plainLen, + "total_len": len(decrypted), + "remaining_after": len(decrypted) - 20, + }) + + if plainLen <= 0 || plainLen > len(decrypted)-20 { + // If plaintext length is invalid, return the whole data as-is + logger.WarnCF("wecom_aibot", "Plaintext length invalid, returning raw decrypted data", map[string]any{ + "plain_len": plainLen, + "total_len": len(decrypted), + }) + return decrypted, nil + } + + // Extract plaintext (skip 16-byte pad + 4-byte length, remove appid at the end) + plainText := decrypted[20 : 20+plainLen] + + logger.DebugCF("wecom_aibot", "Decryption successful", map[string]any{ + "plaintext_len": len(plainText), + "plaintext_head": fmt.Sprintf("%x", plainText[:min(32, len(plainText))]), + }) + + return plainText, nil +} + +// decryptMediaContentWithKey decrypts media content using the provided AESKey +// This is used for WeCom AI Bot per-message AESKey (not the config EncodingAESKey) +// Reference: wecom-aibot-go decryptFile function +func decryptMediaContentWithKey(encryptedData []byte, aesKey string) ([]byte, error) { + if len(encryptedData) == 0 { + return nil, fmt.Errorf("encrypted buffer is empty") + } + if aesKey == "" { + return nil, fmt.Errorf("aesKey cannot be empty") + } + + // Decode AES key from base64 (support both standard and URL-safe base64) + // Try URL-safe first, then standard + key, err := base64.URLEncoding.DecodeString(aesKey) + if err != nil { + // Try standard base64 with padding + keyStr := aesKey + if len(keyStr)%4 != 0 { + keyStr += strings.Repeat("=", 4-len(keyStr)%4) + } + key, err = base64.StdEncoding.DecodeString(keyStr) + if err != nil { + return nil, fmt.Errorf("aesKey base64 decode failed: %w", err) + } + } + if len(key) != 32 { + return nil, fmt.Errorf("aesKey length error, expected 32 bytes, got %d", len(key)) + } + if len(encryptedData)%aes.BlockSize != 0 { + return nil, fmt.Errorf("encrypted buffer length not block aligned") + } + + // Create AES cipher + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("cipher init failed: %w", err) + } + + // IV is first 16 bytes of key + iv := key[:aes.BlockSize] + decrypted := make([]byte, len(encryptedData)) + mode := cipher.NewCBCDecrypter(block, iv) + mode.CryptBlocks(decrypted, encryptedData) + + // Remove PKCS#7 padding (32 byte block size) + trimmed, err := trimPKCS7Padding32(decrypted) + if err != nil { + return nil, fmt.Errorf("trim padding failed: %w", err) + } + return trimmed, nil +} + +// trimPKCS7Padding32 removes PKCS#7 padding with 32-byte block size +func trimPKCS7Padding32(data []byte) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty data") + } + padLen := int(data[len(data)-1]) + if padLen < 1 || padLen > 32 || padLen > len(data) { + return nil, fmt.Errorf("invalid padding value: %d", padLen) + } + for i := len(data) - padLen; i < len(data); i++ { + if int(data[i]) != padLen { + return nil, fmt.Errorf("padding bytes not consistent") + } + } + return data[:len(data)-padLen], nil +} + +// downloadMediaFromMediaIDNoAuth downloads media using media_id without access_token +// This is used for WeCom AI Bot which provides media_id directly in the callback +func (c *WeComAIBotChannel) downloadMediaFromMediaIDNoAuth( + ctx context.Context, + mediaID, filename string, + store media.MediaStore, + scope string, + msgID string, +) string { + // Try different possible download URLs + // WeCom AI Bot media can be accessed via different endpoints + urls := []string{ + // Option 1: Direct media download API + fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/media/get?media_id=%s", url.QueryEscape(mediaID)), + // Option 2: Alternative endpoint + fmt.Sprintf("https://qyapi.weixin.qq.com/cgi-bin/media/get?media_id=%s", url.QueryEscape(mediaID)), + } + + var lastErr error + for _, apiURL := range urls { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to create media download request", map[string]any{ + "error": err.Error(), + "media_id": mediaID, + "url": apiURL, + }) + lastErr = err + continue + } + + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to download media", map[string]any{ + "error": err.Error(), + "media_id": mediaID, + "url": apiURL, + }) + lastErr = err + continue + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.WarnCF("wecom_aibot", "Media download failed with status", map[string]any{ + "status": resp.StatusCode, + "media_id": mediaID, + "url": apiURL, + }) + lastErr = fmt.Errorf("HTTP %d", resp.StatusCode) + continue + } + + // Read the media body + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to read media body", map[string]any{ + "error": err.Error(), + "media_id": mediaID, + }) + lastErr = err + continue + } + + // Check if response is an error JSON (not the actual media) + if strings.HasPrefix(string(body), "{\"errcode\"") { + var errResp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if json.Unmarshal(body, &errResp) == nil { + logger.WarnCF("wecom_aibot", "Media download API error", map[string]any{ + "errcode": errResp.ErrCode, + "errmsg": errResp.ErrMsg, + "media_id": mediaID, + "url": apiURL, + }) + lastErr = fmt.Errorf("API error: %s", errResp.ErrMsg) + continue + } + } + + // Determine content type from response header + contentType := resp.Header.Get("Content-Type") + if contentType == "" || contentType == "application/octet-stream" { + // Try to detect from file content + if kind, err := filetype.Match(body); err == nil && kind != filetype.Unknown { + contentType = kind.MIME.Value + } + } + + // Generate temp file path + tempDir := os.TempDir() + mediaDir := filepath.Join(tempDir, "picoclaw_media", "wecom_aibot") + if mkdirErr := os.MkdirAll(mediaDir, 0o755); mkdirErr != nil { + logger.ErrorCF("wecom_aibot", "Failed to create media directory", map[string]any{ + "error": mkdirErr.Error(), + }) + lastErr = fmt.Errorf("mkdir failed: %w", mkdirErr) + continue + } + + // Determine file extension from content type + ext := ".jpg" + if strings.Contains(contentType, "png") { + ext = ".png" + } else if strings.Contains(contentType, "gif") { + ext = ".gif" + } else if strings.Contains(contentType, "webp") { + ext = ".webp" + } else if strings.Contains(contentType, "bmp") { + ext = ".bmp" + } + + // Create a unique filename + uniqueFilename := fmt.Sprintf("%s-%d-%s%s", msgID, time.Now().Unix(), filename, ext) + localPath := filepath.Join(mediaDir, uniqueFilename) + + // Write the file + err = os.WriteFile(localPath, body, 0o644) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to write media file", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + lastErr = err + continue + } + + // Verify the image is valid by decoding it + f, err := os.Open(localPath) + if err == nil { + _, _, decodeErr := image.Decode(f) + f.Close() + if decodeErr != nil { + logger.ErrorCF("wecom_aibot", "Downloaded file is not a valid image", map[string]any{ + "path": localPath, + "error": decodeErr.Error(), + "size": len(body), + "header": fmt.Sprintf("%x", body[:min(20, len(body))]), + }) + os.Remove(localPath) + lastErr = fmt.Errorf("invalid image: %w", decodeErr) + continue + } + } else { + logger.WarnCF("wecom_aibot", "Failed to open file for verification", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + } + + // Determine final content type for storage + if contentType == "" || contentType == "application/octet-stream" { + contentType = "image/jpeg" + } + + // Store in media store + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename + ext, + ContentType: contentType, + Source: "wecom_aibot", + }, scope) + if err != nil { + logger.ErrorCF("wecom_aibot", "Failed to store media", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + os.Remove(localPath) + return "" + } + + logger.DebugCF("wecom_aibot", "Media downloaded via media_id successfully", map[string]any{ + "media_id": mediaID, + "path": localPath, + "size": len(body), + "content_type": contentType, + "fileref": ref, + "url_tried": apiURL, + }) + + return ref + } + + logger.ErrorCF("wecom_aibot", "All media download attempts failed", map[string]any{ + "media_id": mediaID, + "last_err": lastErr.Error(), + }) + + return "" +} diff --git a/pkg/channels/wecom/app.go b/pkg/channels/wecom/app.go index 2098fcd4e..cbd8d642e 100644 --- a/pkg/channels/wecom/app.go +++ b/pkg/channels/wecom/app.go @@ -21,6 +21,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -650,10 +651,28 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag content := msg.Content + // Handle media messages (download and store) + var mediaRefs []string + store := c.GetMediaStore() + if store != nil && msg.MediaId != "" { + mediaRefs = c.downloadInboundMedia(ctx, msg.MsgType, msg.MediaId, messageID, store) + } + + // Append media tags to content + if len(mediaRefs) > 0 { + mediaTag := c.getMediaTag(msg.MsgType) + if content != "" { + content += "\n" + mediaTag + } else { + content = mediaTag + } + } + logger.DebugCF("wecom_app", "Received message", map[string]any{ "sender_id": senderID, "msg_type": msg.MsgType, "preview": utils.Truncate(content, 50), + "mediaRefs": len(mediaRefs), }) // Build sender info @@ -664,7 +683,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag } // Handle the message through the base channel - c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, nil, metadata, appSender) + c.HandleMessage(ctx, peer, messageID, senderID, chatID, content, mediaRefs, metadata, appSender) } // tokenRefreshLoop periodically refreshes the access token @@ -754,3 +773,187 @@ func (c *WeComAppChannel) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) } + +// downloadInboundMedia downloads media from inbound messages and stores in MediaStore. +func (c *WeComAppChannel) downloadInboundMedia( + ctx context.Context, + msgType, mediaID, messageID string, + store media.MediaStore, +) []string { + var refs []string + scope := channels.BuildMediaScope("wecom_app", messageID, mediaID) + + // Determine file extension based on message type + var ext string + switch msgType { + case "image": + ext = ".jpg" + case "voice": + ext = ".amr" + case "video": + ext = ".mp4" + case "file": + ext = "" + default: + ext = "" + } + + // Download the media file from WeCom server + localPath := c.downloadMedia(ctx, mediaID, ext) + if localPath == "" { + logger.ErrorCF("wecom_app", "Failed to download media", map[string]any{ + "media_id": mediaID, + "msg_type": msgType, + }) + return refs + } + + // Determine filename + filename := mediaID + ext + if msgType == "file" { + filename = "file" + } + + // Store in media store + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + Source: "wecom_app", + }, scope) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to store media", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + return refs + } + + refs = append(refs, ref) + return refs +} + +// downloadMedia downloads media from WeCom API and saves to local file. +func (c *WeComAppChannel) downloadMedia(ctx context.Context, mediaID, ext string) string { + accessToken := c.getAccessToken() + if accessToken == "" { + logger.ErrorCF("wecom_app", "No access token available for media download", nil) + return "" + } + + // WeCom API: GET /cgi-bin/media/get?access_token=ACCESS_TOKEN&media_id=MEDIA_ID + apiURL := fmt.Sprintf("%s/cgi-bin/media/get?access_token=%s&media_id=%s", + wecomAPIBase, url.QueryEscape(accessToken), url.QueryEscape(mediaID)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to create media download request", map[string]any{ + "error": err.Error(), + }) + return "" + } + + resp, err := c.client.Do(req) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to download media", map[string]any{ + "error": err.Error(), + "media_id": mediaID, + }) + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("wecom_app", "Media download failed with status", map[string]any{ + "status": resp.StatusCode, + "media_id": mediaID, + }) + return "" + } + + // Check content-type to determine file type + contentType := resp.Header.Get("Content-Type") + + // Determine file extension from content-type if not provided + if ext == "" { + switch { + case strings.Contains(contentType, "image/jpeg"): + ext = ".jpg" + case strings.Contains(contentType, "image/png"): + ext = ".png" + case strings.Contains(contentType, "image/gif"): + ext = ".gif" + case strings.Contains(contentType, "audio/amr"): + ext = ".amr" + case strings.Contains(contentType, "audio/mp3") || strings.Contains(contentType, "audio/mpeg"): + ext = ".mp3" + case strings.Contains(contentType, "video/mp4"): + ext = ".mp4" + default: + ext = ".bin" + } + } + + // Generate temp file path + tempDir := os.TempDir() + mediaDir := filepath.Join(tempDir, "picoclaw_media", "wecom") + os.MkdirAll(mediaDir, 0o755) + + localPath := filepath.Join(mediaDir, mediaID+ext) + + // Write to file + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to read media body", map[string]any{ + "error": err.Error(), + }) + return "" + } + + // Check if response is error JSON + if len(body) > 0 && body[0] == '{' { + var errResp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if json.Unmarshal(body, &errResp) == nil && errResp.ErrCode != 0 { + logger.ErrorCF("wecom_app", "Media download API error", map[string]any{ + "errcode": errResp.ErrCode, + "errmsg": errResp.ErrMsg, + "media_id": mediaID, + }) + return "" + } + } + + err = os.WriteFile(localPath, body, 0o644) + if err != nil { + logger.ErrorCF("wecom_app", "Failed to write media file", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + return "" + } + + logger.DebugCF("wecom_app", "Media downloaded successfully", map[string]any{ + "media_id": mediaID, + "path": localPath, + "size": len(body), + }) + + return localPath +} + +// getMediaTag returns a media tag string for the message type. +func (c *WeComAppChannel) getMediaTag(msgType string) string { + switch msgType { + case "image": + return "[image]" + case "voice": + return "[voice message]" + case "video": + return "[video]" + case "file": + return "[file]" + default: + return "[media]" + } +} diff --git a/pkg/channels/wecom/bot.go b/pkg/channels/wecom/bot.go index 96d5a961f..3ee865648 100644 --- a/pkg/channels/wecom/bot.go +++ b/pkg/channels/wecom/bot.go @@ -6,16 +6,21 @@ import ( "encoding/json" "encoding/xml" "fmt" + "image" "io" "net/http" + "os" + "path/filepath" "strings" "time" + "github.com/h2non/filetype" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/identity" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -356,21 +361,65 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag // Extract content based on message type var content string + var mediaRefs []string + + scope := channels.BuildMediaScope("wecom", chatID, msg.MsgID) + 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 mixed messages, process text and image 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 = "" + // Download images from mixed messages + if store := c.GetMediaStore(); store != nil { + for _, item := range msg.Mixed.MsgItem { + if item.MsgType == "image" && item.Image.URL != "" { + ref := c.downloadMediaFromURL(ctx, item.Image.URL, "image.jpg", store, scope, msg.MsgID) + if ref != "" { + mediaRefs = append(mediaRefs, ref) + } + } + } + } + case "image": + // Download image from URL + if msg.Image.URL != "" { + if store := c.GetMediaStore(); store != nil { + ref := c.downloadMediaFromURL(ctx, msg.Image.URL, "image.jpg", store, scope, msg.MsgID) + if ref != "" { + mediaRefs = append(mediaRefs, ref) + } + } + } + content = "[image]" + case "file": + // Download file from URL + if msg.File.URL != "" { + if store := c.GetMediaStore(); store != nil { + ref := c.downloadMediaFromURL(ctx, msg.File.URL, "file", store, scope, msg.MsgID) + if ref != "" { + mediaRefs = append(mediaRefs, ref) + } + } + } + content = "[file]" + } + + // Append media tags to content if needed + if len(mediaRefs) > 0 && content == "" { + switch msg.MsgType { + case "image": + content = "[image]" + case "file": + content = "[file]" + } } // Build metadata @@ -416,7 +465,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag } // Handle the message through the base channel - c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, nil, metadata, sender) + c.HandleMessage(ctx, peer, msg.MsgID, senderID, chatID, content, mediaRefs, metadata, sender) } // sendWebhookReply sends a reply using the webhook URL @@ -497,3 +546,195 @@ func (c *WeComBotChannel) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(status) } + +// downloadMediaFromURL downloads media from a URL and stores it in MediaStore. +func (c *WeComBotChannel) downloadMediaFromURL( + ctx context.Context, + mediaURL, filename string, + store media.MediaStore, + scope string, + msgID string, +) string { + // Download the media file from the URL + req, err := http.NewRequestWithContext(ctx, http.MethodGet, mediaURL, nil) + if err != nil { + logger.ErrorCF("wecom", "Failed to create media download request", map[string]any{ + "error": err.Error(), + "url": mediaURL, + }) + return "" + } + + // Add User-Agent header for WeCom/COS to properly serve the image + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8") + + resp, err := c.client.Do(req) + if err != nil { + logger.ErrorCF("wecom", "Failed to download media", map[string]any{ + "error": err.Error(), + "url": mediaURL, + }) + return "" + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.ErrorCF("wecom", "Media download failed with status", map[string]any{ + "status": resp.StatusCode, + "url": mediaURL, + }) + return "" + } + + // Read the media body first + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.ErrorCF("wecom", "Failed to read media body", map[string]any{ + "error": err.Error(), + }) + return "" + } + + // Determine actual file type from content (magic bytes) + contentType := resp.Header.Get("Content-Type") + if kind, err := filetype.Match(body); err == nil && kind != filetype.Unknown { + // Use the detected type from content + contentType = kind.MIME.Value + filename = "image." + kind.Extension + logger.DebugCF("wecom", "Filetype detected from content", map[string]any{ + "detected_type": kind.MIME.Value, + "extension": kind.Extension, + "size": len(body), + }) + } else { + // Filetype detection failed, try to determine from header or extension + logger.WarnCF("wecom", "Filetype detection failed, using extension inference", map[string]any{ + "header_content_type": contentType, + "error": err, + }) + // Try to determine from file extension + ext := filepath.Ext(filename) + if ext == "" { + // No extension, try header + switch { + case strings.Contains(contentType, "image/jpeg"): + filename += ".jpg" + contentType = "image/jpeg" + case strings.Contains(contentType, "image/png"): + filename += ".png" + contentType = "image/png" + case strings.Contains(contentType, "image/gif"): + filename += ".gif" + contentType = "image/gif" + case strings.Contains(contentType, "image/webp"): + filename += ".webp" + contentType = "image/webp" + case strings.Contains(contentType, "audio/amr"): + filename += ".amr" + contentType = "audio/amr" + case strings.Contains(contentType, "audio/mp3") || strings.Contains(contentType, "audio/mpeg"): + filename += ".mp3" + contentType = "audio/mpeg" + case strings.Contains(contentType, "video/mp4"): + filename += ".mp4" + contentType = "video/mp4" + case strings.Contains(contentType, "application/pdf"): + filename += ".pdf" + contentType = "application/pdf" + default: + filename += ".bin" + } + } else { + // Has extension, set correct MIME type based on extension + switch ext { + case ".jpg", ".jpeg": + contentType = "image/jpeg" + case ".png": + contentType = "image/png" + case ".gif": + contentType = "image/gif" + case ".webp": + contentType = "image/webp" + case ".amr": + contentType = "audio/amr" + case ".mp3": + contentType = "audio/mpeg" + case ".mp4": + contentType = "video/mp4" + case ".pdf": + contentType = "application/pdf" + } + } + } + + // Generate temp file path + tempDir := os.TempDir() + mediaDir := filepath.Join(tempDir, "picoclaw_media", "wecom") + if mkdirErr := os.MkdirAll(mediaDir, 0o755); mkdirErr != nil { + logger.ErrorCF("wecom", "Failed to create media directory", map[string]any{ + "error": mkdirErr.Error(), + }) + return "" + } + + // Create a unique filename to avoid collisions + uniqueFilename := fmt.Sprintf("%s-%d-%s", msgID, time.Now().Unix(), filename) + localPath := filepath.Join(mediaDir, uniqueFilename) + + // Write the file + err = os.WriteFile(localPath, body, 0o644) + if err != nil { + logger.ErrorCF("wecom", "Failed to write media file", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + return "" + } + + // Verify the image is valid by decoding it + f, err := os.Open(localPath) + if err == nil { + _, _, decodeErr := image.Decode(f) + f.Close() + if decodeErr != nil { + logger.ErrorCF("wecom", "Downloaded file is not a valid image", map[string]any{ + "path": localPath, + "error": decodeErr.Error(), + "size": len(body), + "header": fmt.Sprintf("%x", body[:min(20, len(body))]), + }) + // Remove invalid file + os.Remove(localPath) + return "" + } + } else { + logger.WarnCF("wecom", "Failed to open file for verification", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + } + + // Store in media store + ref, err := store.Store(localPath, media.MediaMeta{ + Filename: filename, + ContentType: contentType, + Source: "wecom", + }, scope) + if err != nil { + logger.ErrorCF("wecom", "Failed to store media", map[string]any{ + "error": err.Error(), + "path": localPath, + }) + os.Remove(localPath) + return "" + } + + logger.DebugCF("wecom", "Media downloaded successfully", map[string]any{ + "url": mediaURL, + "path": localPath, + "size": len(body), + }) + + return ref +}