Merge PR #1478
This commit is contained in:
commit
6e6da921c8
3 changed files with 1363 additions and 30 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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