mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
6c0798ca3f
* 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
1158 lines
30 KiB
Go
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
|
|
}
|