Files
picoclaw/pkg/channels/weixin/media.go
T
美電球 75270c4777 Fix 1886 media cleanup policy (#1887)
* fix(media): track cleanup ownership per path

Add explicit cleanup policy handling to MediaStore and count refs by path before deleting the underlying file. This prevents cleanup from removing shared files until the final ref is gone.

Refs #1886

* fix(tools): keep send_file refs forget-only

Mark send_file media registrations as forget-only so cleanup drops the ref without deleting the original workspace file.

Refs #1886

* fix(channels): declare managed media cleanup policy

Explicitly mark downloaded and managed channel media as delete-on-cleanup so media ownership is visible at each registration site.

Refs #1886
2026-03-23 12:13:59 +08:00

1039 lines
26 KiB
Go

package weixin
import (
"bytes"
"context"
"crypto/aes"
"crypto/md5"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/h2non/filetype"
"github.com/sipeed/picoclaw/pkg/bus"
basechannels "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
)
const (
weixinMediaMaxBytes = 100 << 20
weixinTypingKeepAlive = 5 * time.Second
weixinUploadRetryMax = 3
weixinVoiceTranscodeTimeout = 15 * time.Second
)
type uploadedFileInfo struct {
downloadParam string
aesKeyHex string
fileSize int64
cipherSize int64
filename string
}
func pkcs7Pad(src []byte, blockSize int) []byte {
padding := blockSize - len(src)%blockSize
if padding == 0 {
padding = blockSize
}
out := make([]byte, len(src)+padding)
copy(out, src)
for i := len(src); i < len(out); i++ {
out[i] = byte(padding)
}
return out
}
func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) {
if len(src) == 0 || len(src)%blockSize != 0 {
return nil, fmt.Errorf("invalid padded data size %d", len(src))
}
padding := int(src[len(src)-1])
if padding <= 0 || padding > blockSize || padding > len(src) {
return nil, fmt.Errorf("invalid padding size %d", padding)
}
for i := len(src) - padding; i < len(src); i++ {
if src[i] != byte(padding) {
return nil, fmt.Errorf("invalid padding content")
}
}
return src[:len(src)-padding], nil
}
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
padded := pkcs7Pad(plaintext, block.BlockSize())
out := make([]byte, len(padded))
for i := 0; i < len(padded); i += block.BlockSize() {
block.Encrypt(out[i:i+block.BlockSize()], padded[i:i+block.BlockSize()])
}
return out, nil
}
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
if len(ciphertext)%block.BlockSize() != 0 {
return nil, fmt.Errorf("invalid ciphertext size %d", len(ciphertext))
}
out := make([]byte, len(ciphertext))
for i := 0; i < len(ciphertext); i += block.BlockSize() {
block.Decrypt(out[i:i+block.BlockSize()], ciphertext[i:i+block.BlockSize()])
}
return pkcs7Unpad(out, block.BlockSize())
}
func parseWeixinMediaAESKey(aesKeyBase64 string) ([]byte, error) {
decoded, err := base64.StdEncoding.DecodeString(aesKeyBase64)
if err != nil {
return nil, err
}
if len(decoded) == 16 {
return decoded, nil
}
if len(decoded) == 32 {
if raw, err := hex.DecodeString(string(decoded)); err == nil && len(raw) == 16 {
return raw, nil
}
}
return nil, fmt.Errorf("unsupported aes_key length %d", len(decoded))
}
func imageAESKey(img *ImageItem) ([]byte, bool, error) {
if img == nil {
return nil, false, nil
}
if img.Aeskey != "" {
raw, err := hex.DecodeString(img.Aeskey)
if err != nil {
return nil, false, err
}
return raw, true, nil
}
if img.Media != nil && img.Media.AesKey != "" {
raw, err := parseWeixinMediaAESKey(img.Media.AesKey)
if err != nil {
return nil, false, err
}
return raw, true, nil
}
return nil, false, nil
}
func genericMediaAESKey(mediaRef *CDNMedia) ([]byte, error) {
if mediaRef == nil || mediaRef.AesKey == "" {
return nil, fmt.Errorf("missing aes_key")
}
return parseWeixinMediaAESKey(mediaRef.AesKey)
}
func aesEcbPaddedSize(size int64) int64 {
return (size/16 + 1) * 16
}
func randomHex(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func buildCDNDownloadURL(base, encryptedQueryParam string) string {
return strings.TrimRight(base, "/") +
"/download?encrypted_query_param=" + url.QueryEscape(encryptedQueryParam)
}
func buildCDNUploadURL(base, uploadParam, filekey string) string {
return strings.TrimRight(base, "/") +
"/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) +
"&filekey=" + url.QueryEscape(filekey)
}
func (c *WeixinChannel) downloadCDNBuffer(ctx context.Context, encryptedQueryParam string) ([]byte, error) {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam),
nil,
)
if err != nil {
return nil, err
}
resp, err := c.api.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("cdn download HTTP %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1))
if err != nil {
return nil, err
}
if len(data) > weixinMediaMaxBytes {
return nil, fmt.Errorf("cdn media too large: %d bytes", len(data))
}
return data, nil
}
func (c *WeixinChannel) downloadAndDecryptCDNBuffer(
ctx context.Context,
encryptedQueryParam string,
key []byte,
) ([]byte, error) {
data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam)
if err != nil {
return nil, err
}
if len(key) == 0 {
return data, nil
}
return decryptAESECB(data, key)
}
func detectMediaMetadata(data []byte, fallbackName, fallbackContentType string) (string, string) {
contentType := strings.TrimSpace(fallbackContentType)
ext := filepath.Ext(fallbackName)
if kind, err := filetype.Match(data); err == nil && kind != filetype.Unknown {
contentType = kind.MIME.Value
if kind.Extension != "" {
ext = "." + kind.Extension
}
}
if contentType == "" && ext != "" {
contentType = mime.TypeByExtension(strings.ToLower(ext))
}
if contentType == "" {
contentType = http.DetectContentType(data)
}
if ext == "" && contentType != "" {
if exts, err := mime.ExtensionsByType(contentType); err == nil && len(exts) > 0 {
ext = exts[0]
}
}
filename := sanitizeFilename(fallbackName)
if filename == "" {
filename = "media"
}
if filepath.Ext(filename) == "" && ext != "" {
filename += ext
}
return filename, contentType
}
func sanitizeFilename(name string) string {
name = filepath.Base(strings.TrimSpace(name))
if name == "." || name == "/" || name == "" {
return ""
}
return name
}
func writeManagedTempFile(prefix, filename string, data []byte) (string, error) {
if err := os.MkdirAll(media.TempDir(), 0o700); err != nil {
return "", err
}
pattern := prefix + "-*"
if ext := filepath.Ext(filename); ext != "" {
pattern += ext
}
f, err := os.CreateTemp(media.TempDir(), pattern)
if err != nil {
return "", err
}
defer f.Close()
if _, err := f.Write(data); err != nil {
os.Remove(f.Name())
return "", err
}
return f.Name(), nil
}
func (c *WeixinChannel) storeInboundBytes(
chatID,
messageID,
filename,
contentType string,
data []byte,
) (string, error) {
store := c.GetMediaStore()
if store == nil {
return "", fmt.Errorf("no media store available")
}
filename, contentType = detectMediaMetadata(data, filename, contentType)
tmpPath, err := writeManagedTempFile("weixin-inbound", filename, data)
if err != nil {
return "", err
}
ref, err := store.Store(tmpPath, media.MediaMeta{
Filename: filename,
ContentType: contentType,
Source: "weixin",
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
}, basechannels.BuildMediaScope("weixin", chatID, messageID))
if err != nil {
os.Remove(tmpPath)
return "", err
}
return ref, nil
}
func isDownloadableMediaItem(item *MessageItem) bool {
if item == nil {
return false
}
switch item.Type {
case MessageItemTypeImage:
return item.ImageItem != nil && item.ImageItem.Media != nil && item.ImageItem.Media.EncryptQueryParam != ""
case MessageItemTypeVideo:
return item.VideoItem != nil && item.VideoItem.Media != nil && item.VideoItem.Media.EncryptQueryParam != ""
case MessageItemTypeFile:
return item.FileItem != nil && item.FileItem.Media != nil && item.FileItem.Media.EncryptQueryParam != ""
case MessageItemTypeVoice:
return item.VoiceItem != nil &&
item.VoiceItem.Media != nil &&
item.VoiceItem.Media.EncryptQueryParam != "" &&
strings.TrimSpace(item.VoiceItem.Text) == ""
default:
return false
}
}
func selectInboundMediaItem(msg WeixinMessage) *MessageItem {
priorities := []int{
MessageItemTypeImage,
MessageItemTypeVideo,
MessageItemTypeFile,
MessageItemTypeVoice,
}
for _, want := range priorities {
for i := range msg.ItemList {
item := &msg.ItemList[i]
if item.Type == want && isDownloadableMediaItem(item) {
return item
}
}
}
for i := range msg.ItemList {
item := &msg.ItemList[i]
if item.Type != MessageItemTypeText || item.RefMsg == nil || item.RefMsg.MessageItem == nil {
continue
}
if isDownloadableMediaItem(item.RefMsg.MessageItem) {
return item.RefMsg.MessageItem
}
}
return nil
}
func tryTranscodeSilkToWAV(ctx context.Context, silk []byte) ([]byte, error) {
decoders := []struct {
name string
args func(inputPath, outputPath string) []string
}{
{
name: "silk_v3_decoder",
args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} },
},
{
name: "silk_decoder",
args: func(inputPath, outputPath string) []string { return []string{inputPath, outputPath, "24000"} },
},
{
name: "ffmpeg",
args: func(inputPath, outputPath string) []string {
return []string{"-y", "-i", inputPath, outputPath}
},
},
}
for _, decoder := range decoders {
bin, err := exec.LookPath(decoder.name)
if err != nil {
continue
}
tmpIn, err := writeManagedTempFile("weixin-voice", "voice.silk", silk)
if err != nil {
return nil, err
}
tmpOut := filepath.Join(media.TempDir(), "weixin-voice-"+uuid.New().String()+".wav")
wav, ok := func() ([]byte, bool) {
defer os.Remove(tmpIn)
defer os.Remove(tmpOut)
runCtx, cancel := context.WithTimeout(ctx, weixinVoiceTranscodeTimeout)
cmd := exec.CommandContext(runCtx, bin, decoder.args(tmpIn, tmpOut)...)
out, runErr := cmd.CombinedOutput()
cancel()
if runErr != nil {
logger.DebugCF("weixin", "SILK transcode command failed", map[string]any{
"decoder": decoder.name,
"error": runErr.Error(),
"output": strings.TrimSpace(string(out)),
})
return nil, false
}
wav, readErr := os.ReadFile(tmpOut)
if readErr != nil {
logger.DebugCF("weixin", "Failed to read transcoded WAV", map[string]any{
"decoder": decoder.name,
"error": readErr.Error(),
})
return nil, false
}
return wav, len(wav) > 0
}()
if ok {
return wav, nil
}
}
return nil, fmt.Errorf("no SILK decoder available")
}
func (c *WeixinChannel) downloadMediaFromItem(
ctx context.Context,
chatID,
messageID string,
item *MessageItem,
) (string, error) {
if item == nil {
return "", nil
}
switch item.Type {
case MessageItemTypeImage:
key, ok, err := imageAESKey(item.ImageItem)
if err != nil {
return "", err
}
data, err := c.downloadAndDecryptCDNBuffer(ctx, item.ImageItem.Media.EncryptQueryParam, func() []byte {
if ok {
return key
}
return nil
}())
if err != nil {
return "", err
}
return c.storeInboundBytes(chatID, messageID, "image", "", data)
case MessageItemTypeVoice:
key, err := genericMediaAESKey(item.VoiceItem.Media)
if err != nil {
return "", err
}
silk, err := c.downloadAndDecryptCDNBuffer(ctx, item.VoiceItem.Media.EncryptQueryParam, key)
if err != nil {
return "", err
}
if wav, err := tryTranscodeSilkToWAV(ctx, silk); err == nil && len(wav) > 0 {
return c.storeInboundBytes(chatID, messageID, "voice.wav", "audio/wav", wav)
}
return c.storeInboundBytes(chatID, messageID, "voice.silk", "audio/silk", silk)
case MessageItemTypeFile:
key, err := genericMediaAESKey(item.FileItem.Media)
if err != nil {
return "", err
}
data, err := c.downloadAndDecryptCDNBuffer(ctx, item.FileItem.Media.EncryptQueryParam, key)
if err != nil {
return "", err
}
filename := item.FileItem.FileName
if filename == "" {
filename = "file.bin"
}
contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(filename)))
return c.storeInboundBytes(chatID, messageID, filename, contentType, data)
case MessageItemTypeVideo:
key, err := genericMediaAESKey(item.VideoItem.Media)
if err != nil {
return "", err
}
data, err := c.downloadAndDecryptCDNBuffer(ctx, item.VideoItem.Media.EncryptQueryParam, key)
if err != nil {
return "", err
}
return c.storeInboundBytes(chatID, messageID, "video.mp4", "video/mp4", data)
}
return "", nil
}
func outboundMediaKind(partType, filename, contentType string) int {
switch strings.ToLower(strings.TrimSpace(partType)) {
case "image":
return UploadMediaTypeImage
case "video":
return UploadMediaTypeVideo
}
ct := strings.ToLower(contentType)
switch {
case strings.HasPrefix(ct, "image/"):
return UploadMediaTypeImage
case strings.HasPrefix(ct, "video/"):
return UploadMediaTypeVideo
default:
return UploadMediaTypeFile
}
}
func detectLocalContentType(localPath, hintContentType string) string {
if strings.TrimSpace(hintContentType) != "" {
return hintContentType
}
if kind, err := filetype.MatchFile(localPath); err == nil && kind != filetype.Unknown {
return kind.MIME.Value
}
if ext := filepath.Ext(localPath); ext != "" {
if ct := mime.TypeByExtension(strings.ToLower(ext)); ct != "" {
return ct
}
}
return "application/octet-stream"
}
func downloadFilenameFromURL(rawURL, fallback string) string {
if fallback = sanitizeFilename(fallback); fallback != "" {
return fallback
}
parsed, err := url.Parse(rawURL)
if err == nil {
if base := sanitizeFilename(path.Base(parsed.Path)); base != "" {
return base
}
}
return "remote-media"
}
func (c *WeixinChannel) downloadRemoteMediaToTemp(
ctx context.Context,
rawURL,
fallbackName string,
) (string, string, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return "", "", "", err
}
resp, err := c.api.HttpClient.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return "", "", "", fmt.Errorf("remote media HTTP %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, weixinMediaMaxBytes+1))
if err != nil {
return "", "", "", err
}
if len(data) > weixinMediaMaxBytes {
return "", "", "", fmt.Errorf("remote media too large: %d bytes", len(data))
}
filename, contentType := detectMediaMetadata(
data,
downloadFilenameFromURL(rawURL, fallbackName),
resp.Header.Get("Content-Type"),
)
tmpPath, err := writeManagedTempFile("weixin-remote", filename, data)
if err != nil {
return "", "", "", err
}
return tmpPath, filename, contentType, nil
}
func (c *WeixinChannel) resolveOutboundPart(
ctx context.Context,
part bus.MediaPart,
) (string, string, string, func(), error) {
cleanup := func() {}
filename := sanitizeFilename(part.Filename)
contentType := strings.TrimSpace(part.ContentType)
switch {
case strings.HasPrefix(part.Ref, "http://") || strings.HasPrefix(part.Ref, "https://"):
localPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, part.Ref, filename)
if err != nil {
return "", "", "", cleanup, err
}
return localPath, name, ct, func() { os.Remove(localPath) }, nil
case strings.HasPrefix(part.Ref, "media://"):
store := c.GetMediaStore()
if store == nil {
return "", "", "", cleanup, fmt.Errorf("no media store available")
}
localPath, meta, err := store.ResolveWithMeta(part.Ref)
if err != nil {
return "", "", "", cleanup, err
}
if filename == "" {
filename = sanitizeFilename(meta.Filename)
}
if contentType == "" {
contentType = meta.ContentType
}
if strings.HasPrefix(localPath, "http://") || strings.HasPrefix(localPath, "https://") {
tmpPath, name, ct, err := c.downloadRemoteMediaToTemp(ctx, localPath, filename)
if err != nil {
return "", "", "", cleanup, err
}
return tmpPath, name, ct, func() { os.Remove(tmpPath) }, nil
}
if filename == "" {
filename = sanitizeFilename(filepath.Base(localPath))
}
if contentType == "" {
contentType = detectLocalContentType(localPath, "")
}
return localPath, filename, contentType, cleanup, nil
case strings.HasPrefix(part.Ref, "file://"):
u, err := url.Parse(part.Ref)
if err != nil {
return "", "", "", cleanup, err
}
localPath := u.Path
if filename == "" {
filename = sanitizeFilename(filepath.Base(localPath))
}
if contentType == "" {
contentType = detectLocalContentType(localPath, "")
}
return localPath, filename, contentType, cleanup, nil
default:
localPath := part.Ref
if filename == "" {
filename = sanitizeFilename(filepath.Base(localPath))
}
if contentType == "" {
contentType = detectLocalContentType(localPath, "")
}
return localPath, filename, contentType, cleanup, nil
}
}
func (c *WeixinChannel) uploadLocalFile(
ctx context.Context,
localPath,
filename,
toUserID string,
mediaType int,
) (*uploadedFileInfo, error) {
data, err := os.ReadFile(localPath)
if err != nil {
return nil, err
}
if len(data) > weixinMediaMaxBytes {
return nil, fmt.Errorf("media too large: %d bytes", len(data))
}
filekey, err := randomHex(16)
if err != nil {
return nil, err
}
aesKey := make([]byte, 16)
if _, readErr := rand.Read(aesKey); readErr != nil {
return nil, readErr
}
aesKeyHex := hex.EncodeToString(aesKey)
rawMD5 := md5.Sum(data)
resp, err := c.api.GetUploadUrl(ctx, GetUploadUrlReq{
Filekey: filekey,
MediaType: mediaType,
ToUserID: toUserID,
Rawsize: int64(len(data)),
RawfileMD5: hex.EncodeToString(rawMD5[:]),
Filesize: aesEcbPaddedSize(int64(len(data))),
NoNeedThumb: true,
Aeskey: aesKeyHex,
})
if err != nil {
return nil, err
}
if resp == nil {
return nil, fmt.Errorf("getuploadurl returned nil response")
}
if resp.Ret != 0 || resp.Errcode != 0 {
if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
c.pauseSession("getuploadurl", resp.Ret, resp.Errcode, resp.Errmsg)
}
return nil, fmt.Errorf("getuploadurl failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
}
if strings.TrimSpace(resp.UploadParam) == "" {
return nil, fmt.Errorf("getuploadurl returned empty upload_param")
}
downloadParam, err := c.uploadBufferToCDN(ctx, data, resp.UploadParam, filekey, aesKey)
if err != nil {
return nil, err
}
return &uploadedFileInfo{
downloadParam: downloadParam,
aesKeyHex: aesKeyHex,
fileSize: int64(len(data)),
cipherSize: aesEcbPaddedSize(int64(len(data))),
filename: filename,
}, nil
}
func (c *WeixinChannel) uploadBufferToCDN(
ctx context.Context,
plaintext []byte,
uploadParam,
filekey string,
aesKey []byte,
) (string, error) {
ciphertext, err := encryptAESECB(plaintext, aesKey)
if err != nil {
return "", err
}
uploadURL := buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey)
var lastErr error
for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ {
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, uploadURL, bytes.NewReader(ciphertext))
if reqErr != nil {
return "", reqErr
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, doErr := c.api.HttpClient.Do(req)
if doErr != nil {
lastErr = doErr
} else {
func() {
defer resp.Body.Close()
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
lastErr = fmt.Errorf(
"cdn upload client error %d: %s",
resp.StatusCode,
strings.TrimSpace(string(body)),
)
return
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
lastErr = fmt.Errorf(
"cdn upload server error %d: %s",
resp.StatusCode,
strings.TrimSpace(string(body)),
)
return
}
if encrypted := strings.TrimSpace(resp.Header.Get("X-Encrypted-Param")); encrypted != "" {
lastErr = nil
uploadParam = encrypted
return
}
lastErr = fmt.Errorf("cdn upload missing x-encrypted-param header")
}()
}
if lastErr == nil {
return uploadParam, nil
}
if strings.Contains(lastErr.Error(), "client error") || attempt == weixinUploadRetryMax {
break
}
}
return "", lastErr
}
func (c *WeixinChannel) sendMessageItem(
ctx context.Context,
toUserID,
contextToken string,
item MessageItem,
) error {
resp, err := c.api.SendMessage(ctx, SendMessageReq{
Msg: WeixinMessage{
ToUserID: toUserID,
ClientID: "picoclaw-" + uuid.New().String(),
MessageType: MessageTypeBot,
MessageState: MessageStateFinish,
ItemList: []MessageItem{item},
ContextToken: contextToken,
},
})
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("sendmessage returned nil response")
}
if resp.Ret != 0 || resp.Errcode != 0 {
if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
c.pauseSession("sendmessage", resp.Ret, resp.Errcode, resp.Errmsg)
}
return fmt.Errorf("sendmessage failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
}
return nil
}
func (c *WeixinChannel) sendTextMessage(
ctx context.Context,
toUserID,
contextToken,
text string,
) error {
if strings.TrimSpace(text) == "" {
return nil
}
return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
Type: MessageItemTypeText,
TextItem: &TextItem{
Text: text,
},
})
}
func encodeWeixinOutboundAESKey(aesKeyHex string) string {
return base64.StdEncoding.EncodeToString([]byte(aesKeyHex))
}
func (c *WeixinChannel) sendUploadedMedia(
ctx context.Context,
toUserID,
contextToken,
caption string,
mediaType int,
uploaded *uploadedFileInfo,
) error {
if err := c.sendTextMessage(ctx, toUserID, contextToken, caption); err != nil {
return err
}
mediaRef := &CDNMedia{
EncryptQueryParam: uploaded.downloadParam,
AesKey: encodeWeixinOutboundAESKey(uploaded.aesKeyHex),
EncryptType: 1,
}
switch mediaType {
case UploadMediaTypeImage:
return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
Type: MessageItemTypeImage,
ImageItem: &ImageItem{
Media: mediaRef,
MidSize: uploaded.cipherSize,
},
})
case UploadMediaTypeVideo:
return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
Type: MessageItemTypeVideo,
VideoItem: &VideoItem{
Media: mediaRef,
VideoSize: uploaded.cipherSize,
},
})
default:
return c.sendMessageItem(ctx, toUserID, contextToken, MessageItem{
Type: MessageItemTypeFile,
FileItem: &FileItem{
Media: mediaRef,
FileName: uploaded.filename,
Len: fmt.Sprintf("%d", uploaded.fileSize),
},
})
}
}
func (c *WeixinChannel) sendTypingStatus(
ctx context.Context,
chatID,
typingTicket string,
status int,
) error {
resp, err := c.api.SendTyping(ctx, SendTypingReq{
IlinkUserID: chatID,
TypingTicket: typingTicket,
Status: status,
})
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("sendtyping returned nil response")
}
if resp.Ret != 0 || resp.Errcode != 0 {
if isSessionExpiredStatus(resp.Ret, resp.Errcode) {
c.pauseSession("sendtyping", resp.Ret, resp.Errcode, resp.Errmsg)
}
return fmt.Errorf("sendtyping failed: ret=%d errcode=%d errmsg=%s", resp.Ret, resp.Errcode, resp.Errmsg)
}
return nil
}
// StartTyping implements channels.TypingCapable.
func (c *WeixinChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
if strings.TrimSpace(chatID) == "" {
return func() {}, nil
}
if c.remainingPause() > 0 {
return func() {}, nil
}
ticket, err := c.getTypingTicket(ctx, chatID)
if err != nil {
if ticket == "" {
return func() {}, err
}
logger.DebugCF("weixin", "GetConfig refresh failed; using cached typing ticket", map[string]any{
"chat_id": chatID,
"error": err.Error(),
})
}
if ticket == "" {
return func() {}, nil
}
typingCtx, cancel := context.WithCancel(ctx)
var once sync.Once
stop := func() {
once.Do(func() {
cancel()
stopCtx, stopCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer stopCancel()
if err := c.sendTypingStatus(stopCtx, chatID, ticket, TypingStatusCancel); err != nil {
logger.DebugCF("weixin", "Failed to cancel typing indicator", map[string]any{
"chat_id": chatID,
"error": err.Error(),
})
}
})
}
if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil {
stop()
return func() {}, err
}
ticker := time.NewTicker(weixinTypingKeepAlive)
go func() {
defer ticker.Stop()
for {
select {
case <-typingCtx.Done():
return
case <-ticker.C:
if err := c.sendTypingStatus(typingCtx, chatID, ticket, TypingStatusTyping); err != nil {
logger.DebugCF("weixin", "Failed to refresh typing indicator", map[string]any{
"chat_id": chatID,
"error": err.Error(),
})
}
}
}
}()
return stop, nil
}
// SendMedia implements channels.MediaSender.
func (c *WeixinChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return basechannels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
return err
}
contextToken := ""
if v, ok := c.contextTokens.Load(msg.ChatID); ok {
contextToken, _ = v.(string)
}
if contextToken == "" {
return fmt.Errorf(
"weixin send media: missing context token for chat %s: %w",
msg.ChatID,
basechannels.ErrSendFailed,
)
}
for _, part := range msg.Parts {
localPath, filename, contentType, cleanup, err := c.resolveOutboundPart(ctx, part)
if err != nil {
logger.ErrorCF("weixin", "Failed to resolve outbound media", map[string]any{
"chat_id": msg.ChatID,
"ref": part.Ref,
"error": err.Error(),
})
return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
func() {
if cleanup != nil {
defer cleanup()
}
kind := outboundMediaKind(part.Type, filename, contentType)
uploaded, uploadErr := c.uploadLocalFile(ctx, localPath, filename, msg.ChatID, kind)
if uploadErr != nil {
err = uploadErr
return
}
err = c.sendUploadedMedia(ctx, msg.ChatID, contextToken, part.Caption, kind, uploaded)
}()
if err != nil {
logger.ErrorCF("weixin", "Failed to send outbound media", map[string]any{
"chat_id": msg.ChatID,
"ref": part.Ref,
"error": err.Error(),
})
if c.remainingPause() > 0 {
return fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
return fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
}
}
return nil
}