Files
picoclaw/pkg/channels/weixin/media.go
T
DimonB 6c0798ca3f feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs

Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.

Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.

preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.

* style: apply golangci-lint fixes (golines)

* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 11:07:32 +08:00

1158 lines
30 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
weixinDownloadRetryMax = 2
weixinDownloadRetryDelay = 300 * time.Millisecond
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 shouldRetryCDNDownload(statusCode int) bool {
// statusCode=0 represents transport/build errors from the HTTP client.
return statusCode == 0 || statusCode >= 500 || statusCode == http.StatusTooManyRequests
}
func buildCDNUploadURL(base, uploadParam, filekey string) string {
return strings.TrimRight(base, "/") +
"/upload?encrypted_query_param=" + url.QueryEscape(uploadParam) +
"&filekey=" + url.QueryEscape(filekey)
}
func uniqCDNURLs(urls []string) []string {
seen := make(map[string]struct{}, len(urls))
out := make([]string, 0, len(urls))
for _, raw := range urls {
u := strings.TrimSpace(raw)
if u == "" {
continue
}
if _, ok := seen[u]; ok {
continue
}
seen[u] = struct{}{}
out = append(out, u)
}
return out
}
func (c *WeixinChannel) downloadCDNBufferOnce(ctx context.Context, downloadURL string) ([]byte, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, 0, err
}
resp, err := c.api.HttpClient.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, resp.StatusCode, 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, resp.StatusCode, err
}
if len(data) > weixinMediaMaxBytes {
return nil, resp.StatusCode, fmt.Errorf("cdn media too large: %d bytes", len(data))
}
return data, resp.StatusCode, nil
}
func (c *WeixinChannel) downloadCDNBuffer(
ctx context.Context,
encryptedQueryParam,
fullURL string,
) ([]byte, error) {
candidates := uniqCDNURLs([]string{
strings.TrimSpace(fullURL),
func() string {
if strings.TrimSpace(encryptedQueryParam) == "" {
return ""
}
return buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam)
}(),
})
if len(candidates) == 0 {
return nil, fmt.Errorf("missing CDN download URL")
}
var lastErr error
for _, downloadURL := range candidates {
for attempt := 1; attempt <= weixinDownloadRetryMax; attempt++ {
data, statusCode, err := c.downloadCDNBufferOnce(ctx, downloadURL)
if err == nil {
return data, nil
}
lastErr = fmt.Errorf("%w (attempt=%d url=%s)", err, attempt, downloadURL)
if !shouldRetryCDNDownload(statusCode) {
break
}
if attempt < weixinDownloadRetryMax {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(weixinDownloadRetryDelay):
}
}
}
}
return nil, lastErr
}
func (c *WeixinChannel) downloadAndDecryptCDNBuffer(
ctx context.Context,
encryptedQueryParam string,
fullURL string,
key []byte,
) ([]byte, error) {
data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam, fullURL)
if err != nil {
return nil, err
}
if len(key) == 0 {
return data, nil
}
return decryptAESECB(data, key)
}
func (c *WeixinChannel) downloadImageBuffer(
ctx context.Context,
img *ImageItem,
key []byte,
) ([]byte, error) {
if img == nil {
return nil, fmt.Errorf("image item is nil")
}
if img.Media != nil {
data, err := c.downloadAndDecryptCDNBuffer(ctx, img.Media.EncryptQueryParam, img.Media.FullURL, key)
if err == nil {
return data, nil
}
if img.ThumbMedia == nil {
return nil, fmt.Errorf("image download failed: %w", err)
}
}
if img.ThumbMedia != nil {
data, err := c.downloadAndDecryptCDNBuffer(ctx, img.ThumbMedia.EncryptQueryParam, img.ThumbMedia.FullURL, key)
if err == nil {
return data, nil
}
return nil, fmt.Errorf("image download failed: %w", err)
}
return nil, fmt.Errorf("image media is nil")
}
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 != "" || item.ImageItem.Media.FullURL != "")
case MessageItemTypeVideo:
return item.VideoItem != nil && item.VideoItem.Media != nil &&
(item.VideoItem.Media.EncryptQueryParam != "" || item.VideoItem.Media.FullURL != "")
case MessageItemTypeFile:
return item.FileItem != nil && item.FileItem.Media != nil &&
(item.FileItem.Media.EncryptQueryParam != "" || item.FileItem.Media.FullURL != "")
case MessageItemTypeVoice:
return item.VoiceItem != nil &&
item.VoiceItem.Media != nil &&
(item.VoiceItem.Media.EncryptQueryParam != "" || item.VoiceItem.Media.FullURL != "") &&
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:
if item.ImageItem == nil {
return "", fmt.Errorf("image media is nil")
}
key, ok, err := imageAESKey(item.ImageItem)
if err != nil {
return "", err
}
decryptKey := func() []byte {
if ok {
return key
}
return nil
}()
data, err := c.downloadImageBuffer(ctx, item.ImageItem, decryptKey)
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,
item.VoiceItem.Media.FullURL,
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,
item.FileItem.Media.FullURL,
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,
item.VideoItem.Media.FullURL,
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)
}
uploadParam := strings.TrimSpace(resp.UploadParam)
uploadFullURL := strings.TrimSpace(resp.UploadFullURL)
if uploadParam == "" && uploadFullURL == "" {
return nil, fmt.Errorf("getuploadurl returned no upload URL")
}
downloadParam, err := c.uploadBufferToCDN(ctx, data, uploadParam, uploadFullURL, 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,
uploadFullURL,
filekey string,
aesKey []byte,
) (string, error) {
ciphertext, err := encryptAESECB(plaintext, aesKey)
if err != nil {
return "", err
}
uploadURL := strings.TrimSpace(uploadFullURL)
if uploadURL == "" {
if strings.TrimSpace(uploadParam) == "" {
return "", fmt.Errorf("missing CDN upload URL")
}
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) ([]string, error) {
if !c.IsRunning() {
return nil, basechannels.ErrNotRunning
}
if err := c.ensureSessionActive(); err != nil {
return nil, err
}
contextToken := ""
if v, ok := c.contextTokens.Load(msg.ChatID); ok {
contextToken, _ = v.(string)
}
if contextToken == "" {
return nil, 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 nil, 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 nil, fmt.Errorf("weixin send media: %w", basechannels.ErrSendFailed)
}
return nil, fmt.Errorf("weixin send media: %w", basechannels.ErrTemporary)
}
}
return nil, nil
}