初始化仓库
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user