Merge PR #1478
This commit is contained in:
commit
6e6da921c8
3 changed files with 1363 additions and 30 deletions
|
|
@ -3,22 +3,30 @@ package wecom
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
"io"
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/h2non/filetype"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -80,6 +88,8 @@ type WeComAIBotMessage struct {
|
||||||
// image message
|
// image message
|
||||||
Image *struct {
|
Image *struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
AESKey string `json:"aeskey,omitempty"`
|
||||||
} `json:"image,omitempty"`
|
} `json:"image,omitempty"`
|
||||||
// mixed message (text + image)
|
// mixed message (text + image)
|
||||||
Mixed *struct {
|
Mixed *struct {
|
||||||
|
|
@ -90,6 +100,8 @@ type WeComAIBotMessage struct {
|
||||||
} `json:"text,omitempty"`
|
} `json:"text,omitempty"`
|
||||||
Image *struct {
|
Image *struct {
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
|
MediaID string `json:"media_id"`
|
||||||
|
AESKey string `json:"aeskey,omitempty"`
|
||||||
} `json:"image,omitempty"`
|
} `json:"image,omitempty"`
|
||||||
} `json:"msg_item"`
|
} `json:"msg_item"`
|
||||||
} `json:"mixed,omitempty"`
|
} `json:"mixed,omitempty"`
|
||||||
|
|
@ -452,6 +464,24 @@ func (c *WeComAIBotChannel) processMessage(
|
||||||
) string {
|
) string {
|
||||||
logger.DebugCF("wecom_aibot", "Processing message", map[string]any{
|
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 {
|
switch msg.MsgType {
|
||||||
|
|
@ -607,26 +637,160 @@ func (c *WeComAIBotChannel) handleImageMessage(
|
||||||
msg WeComAIBotMessage,
|
msg WeComAIBotMessage,
|
||||||
timestamp, nonce string,
|
timestamp, nonce string,
|
||||||
) 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 {
|
if msg.Image == nil {
|
||||||
logger.ErrorC("wecom_aibot", "Image message missing image field")
|
logger.ErrorC("wecom_aibot", "Image message missing image field")
|
||||||
return c.encryptEmptyResponse(timestamp, nonce)
|
return c.encryptEmptyResponse(timestamp, nonce)
|
||||||
}
|
}
|
||||||
|
|
||||||
imageURL := msg.Image.URL
|
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
|
// If no per-message AESKey, fall back to config EncodingAESKey
|
||||||
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
|
if aesKey == "" && c.config.EncodingAESKey != "" {
|
||||||
MsgType: "stream",
|
aesKey = c.config.EncodingAESKey
|
||||||
Stream: WeComAIBotStreamInfo{
|
logger.InfoCF("wecom_aibot", "Using config EncodingAESKey as fallback", map[string]any{
|
||||||
ID: c.generateStreamID(),
|
"msg_id": msg.MsgID,
|
||||||
Finish: true,
|
|
||||||
Content: fmt.Sprintf(
|
|
||||||
"Image received (URL: %s), but image messages are not yet supported",
|
|
||||||
imageURL,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// handleMixedMessage handles mixed (text + image) messages
|
||||||
|
|
@ -635,15 +799,145 @@ func (c *WeComAIBotChannel) handleMixedMessage(
|
||||||
msg WeComAIBotMessage,
|
msg WeComAIBotMessage,
|
||||||
timestamp, nonce string,
|
timestamp, nonce string,
|
||||||
) string {
|
) string {
|
||||||
logger.WarnC("wecom_aibot", "Mixed message type not yet fully implemented")
|
if msg.Mixed == nil || len(msg.Mixed.MsgItem) == 0 {
|
||||||
return c.encryptResponse("", timestamp, nonce, WeComAIBotStreamResponse{
|
logger.ErrorC("wecom_aibot", "Mixed message missing msg_item field")
|
||||||
MsgType: "stream",
|
return c.encryptEmptyResponse(timestamp, nonce)
|
||||||
Stream: WeComAIBotStreamInfo{
|
}
|
||||||
ID: c.generateStreamID(),
|
|
||||||
Finish: true,
|
userID := msg.From.UserID
|
||||||
Content: "Mixed message type is not yet supported",
|
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
|
// handleEventMessage handles event messages
|
||||||
|
|
@ -1015,3 +1309,598 @@ func (c *WeComAIBotChannel) handleHealth(w http.ResponseWriter, r *http.Request)
|
||||||
"status": status,
|
"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/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -650,10 +651,28 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
||||||
|
|
||||||
content := msg.Content
|
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{
|
logger.DebugCF("wecom_app", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"msg_type": msg.MsgType,
|
"msg_type": msg.MsgType,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
|
"mediaRefs": len(mediaRefs),
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build sender info
|
// Build sender info
|
||||||
|
|
@ -664,7 +683,7 @@ func (c *WeComAppChannel) processMessage(ctx context.Context, msg WeComXMLMessag
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the message through the base channel
|
// 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
|
// 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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(status)
|
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/json"
|
||||||
"encoding/xml"
|
"encoding/xml"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/h2non/filetype"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/identity"
|
"github.com/sipeed/picoclaw/pkg/identity"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"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
|
// Extract content based on message type
|
||||||
var content string
|
var content string
|
||||||
|
var mediaRefs []string
|
||||||
|
|
||||||
|
scope := channels.BuildMediaScope("wecom", chatID, msg.MsgID)
|
||||||
|
|
||||||
switch msg.MsgType {
|
switch msg.MsgType {
|
||||||
case "text":
|
case "text":
|
||||||
content = msg.Text.Content
|
content = msg.Text.Content
|
||||||
case "voice":
|
case "voice":
|
||||||
content = msg.Voice.Content // Voice to text content
|
content = msg.Voice.Content // Voice to text content
|
||||||
case "mixed":
|
case "mixed":
|
||||||
// For mixed messages, concatenate text items
|
// For mixed messages, process text and image items
|
||||||
for _, item := range msg.Mixed.MsgItem {
|
for _, item := range msg.Mixed.MsgItem {
|
||||||
if item.MsgType == "text" {
|
if item.MsgType == "text" {
|
||||||
content += item.Text.Content
|
content += item.Text.Content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "image", "file":
|
// Download images from mixed messages
|
||||||
// For image and file, we don't have text content
|
if store := c.GetMediaStore(); store != nil {
|
||||||
content = ""
|
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
|
// Build metadata
|
||||||
|
|
@ -416,7 +465,7 @@ func (c *WeComBotChannel) processMessage(ctx context.Context, msg WeComBotMessag
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle the message through the base channel
|
// 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
|
// 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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(status)
|
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