309 lines
8.5 KiB
Go
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)
|
|
}
|