Files
2026-06-02 23:14:41 +08:00

309 lines
8.5 KiB
Go

package weixin
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/chenhg5/cc-connect/core"
)
// formatAesKeyForAPI encodes a raw AES key as base64(hex_string),
// matching the format expected by the WeChat iLink sendMessage API.
func formatAesKeyForAPI(key []byte) string {
return base64.StdEncoding.EncodeToString([]byte(hex.EncodeToString(key)))
}
// isWeixinCDNHost 检查 URL 是否指向已知的微信国内 CDN 域名
func isWeixinCDNHost(rawURL string) bool {
u, err := url.Parse(rawURL)
if err != nil {
return false
}
host := strings.ToLower(u.Hostname())
return strings.HasSuffix(host, ".weixin.qq.com") || strings.HasSuffix(host, ".wechat.com")
}
type cdnUploadedRef struct {
downloadParam string
aesKey []byte
cipherSize int
rawSize int
}
func (p *Platform) resolveReplyContext(replyCtx any) (*replyContext, error) {
rc, ok := replyCtx.(*replyContext)
if !ok || rc == nil {
return nil, fmt.Errorf("weixin: invalid reply context")
}
if strings.TrimSpace(rc.contextToken) == "" {
rc.contextToken = p.getContextToken(rc.peerUserID)
}
if strings.TrimSpace(rc.contextToken) == "" {
return nil, fmt.Errorf("weixin: missing context_token for peer %q", rc.peerUserID)
}
return rc, nil
}
func (p *Platform) uploadToWeixinCDN(ctx context.Context, to string, plaintext []byte, mediaType int, label string) (*cdnUploadedRef, error) {
if len(plaintext) == 0 {
return nil, fmt.Errorf("weixin: %s: empty payload", label)
}
if strings.TrimSpace(p.cdnBaseURL) == "" {
return nil, fmt.Errorf("weixin: cdn_base_url is empty")
}
rawSize := len(plaintext)
aesKey := make([]byte, 16)
if _, err := rand.Read(aesKey); err != nil {
return nil, fmt.Errorf("weixin: %s: aes key: %w", label, err)
}
filekey := randomHex(16)
req := getUploadURLRequest{
Filekey: filekey,
MediaType: mediaType,
ToUserID: to,
Rawsize: rawSize,
Rawfilemd5: md5Hex(plaintext),
Filesize: aesECBPaddedSize(rawSize),
NoNeedThumb: true,
Aeskey: hex.EncodeToString(aesKey),
}
resp, err := p.api.getUploadURL(ctx, req)
if err != nil {
return nil, fmt.Errorf("weixin: %s: %w", label, err)
}
// 选择上传 URL 和 HTTP client
var cdnUploadURL string
var uploadClient *http.Client
if resp.UploadFullURL != "" {
// 新版 API:使用服务端返回的完整 URL
cdnUploadURL = resp.UploadFullURL
// 如果 URL 指向已知的微信国内 CDN,使用无代理 client 直连
if isWeixinCDNHost(cdnUploadURL) {
uploadClient = p.cdnHttpClient
} else {
uploadClient = p.httpClient
}
} else {
// 旧版 API:用 upload_param 构建 URL,使用配置的 httpClient
cdnUploadURL = buildCdnUploadURL(p.cdnBaseURL, resp.UploadParam, filekey)
uploadClient = p.httpClient
}
dl, err := uploadBufferToCDN(ctx, uploadClient, cdnUploadURL, plaintext, aesKey, label)
if err != nil {
return nil, err
}
return &cdnUploadedRef{
downloadParam: dl,
aesKey: aesKey,
cipherSize: aesECBPaddedSize(rawSize),
rawSize: rawSize,
}, nil
}
func (p *Platform) sendSingleItem(ctx context.Context, rc *replyContext, item messageItem) error {
return p.sendSingleItemWithRetry(ctx, rc, item)
}
func mediaFromUploadRef(ref *cdnUploadedRef) *cdnMedia {
return &cdnMedia{
EncryptQueryParam: ref.downloadParam,
AESKey: formatAesKeyForAPI(ref.aesKey),
EncryptType: 1,
}
}
func buildVideoMessageItem(ref *cdnUploadedRef) messageItem {
return messageItem{
Type: messageItemVideo,
VideoItem: &videoItem{
Media: mediaFromUploadRef(ref),
VideoSize: ref.cipherSize,
},
}
}
// sendSingleItemWithRetry sends a media item with retry mechanism for ret=-2 errors.
func (p *Platform) sendSingleItemWithRetry(ctx context.Context, rc *replyContext, item messageItem) error {
var lastErr error
for attempt := 0; attempt < weixinSendMaxRetries; attempt++ {
msg := sendMessageReq{
Msg: weixinOutboundMsg{
FromUserID: "",
ToUserID: rc.peerUserID,
ClientID: "cc-" + randomHex(8),
MessageType: messageTypeBot,
MessageState: messageStateFinish,
ItemList: []messageItem{item},
ContextToken: rc.contextToken,
},
}
err := p.api.sendMessage(ctx, &msg)
if err == nil {
return nil
}
lastErr = err
// Check if error is ret=-2 (API declined) - retry with fresh token
if strings.Contains(err.Error(), "ret=-2") {
slog.Warn("weixin: sendMessage ret=-2 for media, retrying",
"attempt", attempt+1, "peer", rc.peerUserID)
// Add delay before retry
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(weixinSendRetryDelay):
}
// Refresh context_token from stored tokens
freshToken := p.getContextToken(rc.peerUserID)
if freshToken != "" && freshToken != rc.contextToken {
rc.contextToken = freshToken
slog.Debug("weixin: using refreshed context_token for media retry", "peer", rc.peerUserID)
}
continue
}
// For other errors, don't retry
return err
}
return lastErr
}
// SendImage implements core.ImageSender.
func (p *Platform) SendImage(ctx context.Context, replyCtx any, img core.ImageAttachment) error {
rc, err := p.resolveReplyContext(replyCtx)
if err != nil {
return err
}
if len(img.Data) == 0 {
return fmt.Errorf("weixin: empty image")
}
ref, err := p.uploadToWeixinCDN(ctx, rc.peerUserID, img.Data, uploadMediaImage, "SendImage")
if err != nil {
return err
}
item := messageItem{
Type: messageItemImage,
ImageItem: &imageItem{
Media: &cdnMedia{
EncryptQueryParam: ref.downloadParam,
AESKey: formatAesKeyForAPI(ref.aesKey),
EncryptType: 1,
},
MidSize: ref.cipherSize,
},
}
return p.sendSingleItem(ctx, rc, item)
}
// SendFile implements core.FileSender.
func (p *Platform) SendFile(ctx context.Context, replyCtx any, file core.FileAttachment) error {
rc, err := p.resolveReplyContext(replyCtx)
if err != nil {
return err
}
if len(file.Data) == 0 {
return fmt.Errorf("weixin: empty file")
}
name := strings.TrimSpace(file.FileName)
if name == "" {
name = "file.bin"
}
if isVideoFile(file) {
ref, err := p.uploadToWeixinCDN(ctx, rc.peerUserID, file.Data, uploadMediaVideo, "SendFileVideo")
if err != nil {
return err
}
return p.sendSingleItem(ctx, rc, buildVideoMessageItem(ref))
}
ref, err := p.uploadToWeixinCDN(ctx, rc.peerUserID, file.Data, uploadMediaFile, "SendFile")
if err != nil {
return err
}
item := messageItem{
Type: messageItemFile,
FileItem: &fileItem{
Media: &cdnMedia{
EncryptQueryParam: ref.downloadParam,
AESKey: formatAesKeyForAPI(ref.aesKey),
EncryptType: 1,
},
FileName: name,
Len: fmt.Sprintf("%d", ref.rawSize),
},
}
return p.sendSingleItem(ctx, rc, item)
}
func isVideoFile(file core.FileAttachment) bool {
mime := strings.ToLower(strings.TrimSpace(file.MimeType))
if strings.HasPrefix(mime, "video/") {
return true
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.FileName)), ".")
switch ext {
case "avi", "m4v", "mkv", "mov", "mp4", "mpeg", "mpg", "webm":
return true
default:
return false
}
}
// SendAudio implements core.AudioSender.
// Weixin voice messages require AMR or SILK format. Since SILK encoding is not
// widely supported, we convert to AMR format using ffmpeg.
func (p *Platform) SendAudio(ctx context.Context, replyCtx any, audio []byte, format string) error {
rc, err := p.resolveReplyContext(replyCtx)
if err != nil {
return err
}
if len(audio) == 0 {
return fmt.Errorf("weixin: empty audio")
}
// Convert to AMR format if not already AMR
sendData := audio
sendFormat := strings.ToLower(strings.TrimSpace(format))
if sendFormat == "" {
sendFormat = "wav" // TTS typically outputs WAV
}
if sendFormat != "amr" {
converted, err := core.ConvertAudioToAMR(ctx, audio, sendFormat)
if err != nil {
return fmt.Errorf("weixin: convert %s to AMR: %w", sendFormat, err)
}
sendData = converted
sendFormat = "amr"
}
slog.Debug("weixin: audio converted", "format", sendFormat, "size", len(sendData))
// Upload to CDN as file type (voice uses same CDN upload mechanism)
ref, err := p.uploadToWeixinCDN(ctx, rc.peerUserID, sendData, uploadMediaFile, "SendAudio")
if err != nil {
return err
}
// Send as voice message
item := messageItem{
Type: messageItemVoice,
VoiceItem: &voiceItem{
Media: &cdnMedia{
EncryptQueryParam: ref.downloadParam,
AESKey: formatAesKeyForAPI(ref.aesKey),
EncryptType: 1,
},
EncodeType: 0, // 0 = AMR format, 1 = SILK format
},
}
return p.sendSingleItem(ctx, rc, item)
}