mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
0e13f6bdec
* fix/wechat-new-protocol * fix cdn download logic
1158 lines
29 KiB
Go
1158 lines
29 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) 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
|
|
}
|