fix: 修复企业微信(WeCom,Aibot)完整实现多模态消息处理功能
This commit is contained in:
parent
19835b2f60
commit
f3d4582a43
3 changed files with 1363 additions and 30 deletions
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
|
@ -80,6 +88,8 @@ type WeComAIBotMessage struct {
|
|||
// image message
|
||||
Image *struct {
|
||||
URL string `json:"url"`
|
||||
MediaID string `json:"media_id"`
|
||||
AESKey string `json:"aeskey,omitempty"`
|
||||
} `json:"image,omitempty"`
|
||||
// mixed message (text + image)
|
||||
Mixed *struct {
|
||||
|
|
@ -90,6 +100,8 @@ type WeComAIBotMessage struct {
|
|||
} `json:"text,omitempty"`
|
||||
Image *struct {
|
||||
URL string `json:"url"`
|
||||
MediaID string `json:"media_id"`
|
||||
AESKey string `json:"aeskey,omitempty"`
|
||||
} `json:"image,omitempty"`
|
||||
} `json:"msg_item"`
|
||||
} `json:"mixed,omitempty"`
|
||||
|
|
@ -452,6 +464,24 @@ func (c *WeComAIBotChannel) processMessage(
|
|||
) string {
|
||||
logger.DebugCF("wecom_aibot", "Processing message", map[string]any{
|
||||
"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,43 +637,307 @@ 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
|
||||
func (c *WeComAIBotChannel) handleMixedMessage(
|
||||
ctx context.Context,
|
||||
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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue