This commit is contained in:
OpenClaw-User 2026-03-14 17:06:38 +08:00
commit 6e6da921c8
3 changed files with 1363 additions and 30 deletions

View file

@ -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 ""
}

View file

@ -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]"
}
}

View file

@ -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
}