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