fix/wechat-new-protocol

This commit is contained in:
Huaaudio 2026-03-28 05:50:43 +01:00
parent 60d7ec20a5
commit 607037472d
5 changed files with 136 additions and 80 deletions

View file

@ -12,6 +12,14 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"strconv"
)
const (
weixinChannelVersion = "2.1.1"
weixinIlinkAppID = "bot"
// 2.1.1 encoded as 0x00MMNNPP => 0x00020101 => 131329
weixinClientVersion = 131329
) )
type ApiClient struct { type ApiClient struct {
@ -80,13 +88,9 @@ func (c *ApiClient) post(ctx context.Context, endpoint string, body any, respons
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
if endpoint == "ilink/bot/get_bot_qrcode" || endpoint == "ilink/bot/get_qrcode_status" { req.Header["iLink-App-Id"] = []string{weixinIlinkAppID}
// QR routes have different headers sometimes, but let's stick to base ones req.Header["iLink-App-ClientVersion"] = []string{strconv.Itoa(weixinClientVersion)}
if endpoint == "ilink/bot/get_qrcode_status" { if endpoint != "ilink/bot/get_bot_qrcode" && endpoint != "ilink/bot/get_qrcode_status" {
// Use direct map assignment to send exact header name the Tencent API expects
req.Header["iLink-App-ClientVersion"] = []string{"1"}
}
} else {
req.Header["AuthorizationType"] = []string{"ilink_bot_token"} req.Header["AuthorizationType"] = []string{"ilink_bot_token"}
req.Header["X-WECHAT-UIN"] = []string{randomWechatUIN()} req.Header["X-WECHAT-UIN"] = []string{randomWechatUIN()}
if c.Token != "" { if c.Token != "" {
@ -119,7 +123,7 @@ func (c *ApiClient) post(ctx context.Context, endpoint string, body any, respons
} }
func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpdatesResp, error) { func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpdatesResp, error) {
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp GetUpdatesResp var resp GetUpdatesResp
err := c.post(ctx, "ilink/bot/getupdates", req, &resp) err := c.post(ctx, "ilink/bot/getupdates", req, &resp)
if err != nil { if err != nil {
@ -129,7 +133,7 @@ func (c *ApiClient) GetUpdates(ctx context.Context, req GetUpdatesReq) (*GetUpda
} }
func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendMessageResp, error) { func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendMessageResp, error) {
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp SendMessageResp var resp SendMessageResp
if err := c.post(ctx, "ilink/bot/sendmessage", req, &resp); err != nil { if err := c.post(ctx, "ilink/bot/sendmessage", req, &resp); err != nil {
return nil, err return nil, err
@ -138,7 +142,7 @@ func (c *ApiClient) SendMessage(ctx context.Context, req SendMessageReq) (*SendM
} }
func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) { func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*GetUploadUrlResp, error) {
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp GetUploadUrlResp var resp GetUploadUrlResp
err := c.post(ctx, "ilink/bot/getuploadurl", req, &resp) err := c.post(ctx, "ilink/bot/getuploadurl", req, &resp)
if err != nil { if err != nil {
@ -148,7 +152,7 @@ func (c *ApiClient) GetUploadUrl(ctx context.Context, req GetUploadUrlReq) (*Get
} }
func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfigResp, error) { func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfigResp, error) {
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp GetConfigResp var resp GetConfigResp
if err := c.post(ctx, "ilink/bot/getconfig", req, &resp); err != nil { if err := c.post(ctx, "ilink/bot/getconfig", req, &resp); err != nil {
return nil, err return nil, err
@ -157,7 +161,7 @@ func (c *ApiClient) GetConfig(ctx context.Context, req GetConfigReq) (*GetConfig
} }
func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTypingResp, error) { func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTypingResp, error) {
req.BaseInfo = BaseInfo{ChannelVersion: "1.0.2"} req.BaseInfo = BaseInfo{ChannelVersion: weixinChannelVersion}
var resp SendTypingResp var resp SendTypingResp
if err := c.post(ctx, "ilink/bot/sendtyping", req, &resp); err != nil { if err := c.post(ctx, "ilink/bot/sendtyping", req, &resp); err != nil {
return nil, err return nil, err
@ -165,38 +169,51 @@ func (c *ApiClient) SendTyping(ctx context.Context, req SendTypingReq) (*SendTyp
return &resp, nil return &resp, nil
} }
func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) { func (c *ApiClient) getQR(ctx context.Context, endpoint string, query map[string]string, respObj any) error {
// get_bot_qrcode is GET, not POST
u, err := url.Parse(c.BaseURL) u, err := url.Parse(c.BaseURL)
if err != nil { if err != nil {
return nil, err return err
} }
u.Path = path.Join(u.Path, "ilink/bot/get_bot_qrcode") u.Path = path.Join(u.Path, endpoint)
q := u.Query() q := u.Query()
q.Set("bot_type", botType) for key, value := range query {
q.Set(key, value)
}
u.RawQuery = q.Encode() u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil { if err != nil {
return nil, err return err
} }
req.Header["iLink-App-Id"] = []string{weixinIlinkAppID}
req.Header["iLink-App-ClientVersion"] = []string{strconv.Itoa(weixinClientVersion)}
resp, err := c.HttpClient.Do(req) resp, err := c.HttpClient.Do(req)
if err != nil { if err != nil {
return nil, err return err
} }
defer resp.Body.Close() defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body) respBody, err := io.ReadAll(resp.Body)
if err != nil { if err != nil {
return nil, err return err
} }
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get_bot_qrcode failed: %d %s", resp.StatusCode, string(respBody)) return fmt.Errorf("%s failed: %d %s", endpoint, resp.StatusCode, string(respBody))
}
if err := json.Unmarshal(respBody, respObj); err != nil {
return err
} }
return nil
}
func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeResponse, error) {
// get_bot_qrcode is GET, not POST
var qrcodeResp QRCodeResponse var qrcodeResp QRCodeResponse
if err := json.Unmarshal(respBody, &qrcodeResp); err != nil { if err := c.getQR(ctx, "ilink/bot/get_bot_qrcode", map[string]string{
"bot_type": botType,
}, &qrcodeResp); err != nil {
return nil, err return nil, err
} }
return &qrcodeResp, nil return &qrcodeResp, nil
@ -204,37 +221,10 @@ func (c *ApiClient) GetQRCode(ctx context.Context, botType string) (*QRCodeRespo
func (c *ApiClient) GetQRCodeStatus(ctx context.Context, qrcode string) (*StatusResponse, error) { func (c *ApiClient) GetQRCodeStatus(ctx context.Context, qrcode string) (*StatusResponse, error) {
// get_qrcode_status is GET // get_qrcode_status is GET
u, err := url.Parse(c.BaseURL)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "ilink/bot/get_qrcode_status")
q := u.Query()
q.Set("qrcode", qrcode)
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return nil, err
}
req.Header["iLink-App-ClientVersion"] = []string{"1"}
resp, err := c.HttpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("get_qrcode_status failed: %d %s", resp.StatusCode, string(respBody))
}
var statusResp StatusResponse var statusResp StatusResponse
if err := json.Unmarshal(respBody, &statusResp); err != nil { if err := c.getQR(ctx, "ilink/bot/get_qrcode_status", map[string]string{
"qrcode": qrcode,
}, &statusResp); err != nil {
return nil, err return nil, err
} }
return &statusResp, nil return &statusResp, nil

View file

@ -40,6 +40,7 @@ func PerformLoginInteractive(
if err != nil { if err != nil {
return "", "", "", "", fmt.Errorf("failed to create api client: %w", err) return "", "", "", "", fmt.Errorf("failed to create api client: %w", err)
} }
pollAPI := api
logger.InfoC("weixin", "Requesting Weixin QR code...") logger.InfoC("weixin", "Requesting Weixin QR code...")
qrResp, err := api.GetQRCode(ctx, opts.BotType) qrResp, err := api.GetQRCode(ctx, opts.BotType)
@ -76,7 +77,7 @@ func PerformLoginInteractive(
case <-timeoutCtx.Done(): case <-timeoutCtx.Done():
return "", "", "", "", fmt.Errorf("login timeout") return "", "", "", "", fmt.Errorf("login timeout")
case <-pollTicker.C: case <-pollTicker.C:
statusResp, err := api.GetQRCodeStatus(timeoutCtx, qrResp.Qrcode) statusResp, err := pollAPI.GetQRCodeStatus(timeoutCtx, qrResp.Qrcode)
if err != nil { if err != nil {
// Long poll timeout or temporary error // Long poll timeout or temporary error
continue continue
@ -99,6 +100,27 @@ func PerformLoginInteractive(
}) })
return statusResp.BotToken, statusResp.IlinkUserID, statusResp.IlinkBotID, statusResp.Baseurl, nil return statusResp.BotToken, statusResp.IlinkUserID, statusResp.IlinkBotID, statusResp.Baseurl, nil
case "scaned_but_redirect":
if statusResp.RedirectHost == "" {
logger.WarnC(
"weixin",
"scaned_but_redirect received without redirect_host; continuing on current host",
)
continue
}
nextBaseURL := "https://" + statusResp.RedirectHost + "/"
nextAPI, nextErr := NewApiClient(nextBaseURL, "", opts.Proxy)
if nextErr != nil {
logger.WarnCF("weixin", "Failed to switch QR polling host", map[string]any{
"redirect_host": statusResp.RedirectHost,
"error": nextErr.Error(),
})
continue
}
pollAPI = nextAPI
logger.InfoCF("weixin", "Switched QR polling host", map[string]any{
"redirect_host": statusResp.RedirectHost,
})
case "expired": case "expired":
return "", "", "", "", fmt.Errorf("qrcode expired, please try again") return "", "", "", "", fmt.Errorf("qrcode expired, please try again")
default: default:

View file

@ -169,11 +169,19 @@ func buildCDNUploadURL(base, uploadParam, filekey string) string {
"&filekey=" + url.QueryEscape(filekey) "&filekey=" + url.QueryEscape(filekey)
} }
func (c *WeixinChannel) downloadCDNBuffer(ctx context.Context, encryptedQueryParam string) ([]byte, error) { func (c *WeixinChannel) downloadCDNBuffer(
ctx context.Context,
encryptedQueryParam,
fullURL string,
) ([]byte, error) {
downloadURL := strings.TrimSpace(fullURL)
if downloadURL == "" {
downloadURL = buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam)
}
req, err := http.NewRequestWithContext( req, err := http.NewRequestWithContext(
ctx, ctx,
http.MethodGet, http.MethodGet,
buildCDNDownloadURL(c.cdnBaseURL(), encryptedQueryParam), downloadURL,
nil, nil,
) )
if err != nil { if err != nil {
@ -203,9 +211,10 @@ func (c *WeixinChannel) downloadCDNBuffer(ctx context.Context, encryptedQueryPar
func (c *WeixinChannel) downloadAndDecryptCDNBuffer( func (c *WeixinChannel) downloadAndDecryptCDNBuffer(
ctx context.Context, ctx context.Context,
encryptedQueryParam string, encryptedQueryParam string,
fullURL string,
key []byte, key []byte,
) ([]byte, error) { ) ([]byte, error) {
data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam) data, err := c.downloadCDNBuffer(ctx, encryptedQueryParam, fullURL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -310,15 +319,18 @@ func isDownloadableMediaItem(item *MessageItem) bool {
switch item.Type { switch item.Type {
case MessageItemTypeImage: case MessageItemTypeImage:
return item.ImageItem != nil && item.ImageItem.Media != nil && item.ImageItem.Media.EncryptQueryParam != "" return item.ImageItem != nil && item.ImageItem.Media != nil &&
(item.ImageItem.Media.EncryptQueryParam != "" || item.ImageItem.Media.FullURL != "")
case MessageItemTypeVideo: case MessageItemTypeVideo:
return item.VideoItem != nil && item.VideoItem.Media != nil && item.VideoItem.Media.EncryptQueryParam != "" return item.VideoItem != nil && item.VideoItem.Media != nil &&
(item.VideoItem.Media.EncryptQueryParam != "" || item.VideoItem.Media.FullURL != "")
case MessageItemTypeFile: case MessageItemTypeFile:
return item.FileItem != nil && item.FileItem.Media != nil && item.FileItem.Media.EncryptQueryParam != "" return item.FileItem != nil && item.FileItem.Media != nil &&
(item.FileItem.Media.EncryptQueryParam != "" || item.FileItem.Media.FullURL != "")
case MessageItemTypeVoice: case MessageItemTypeVoice:
return item.VoiceItem != nil && return item.VoiceItem != nil &&
item.VoiceItem.Media != nil && item.VoiceItem.Media != nil &&
item.VoiceItem.Media.EncryptQueryParam != "" && (item.VoiceItem.Media.EncryptQueryParam != "" || item.VoiceItem.Media.FullURL != "") &&
strings.TrimSpace(item.VoiceItem.Text) == "" strings.TrimSpace(item.VoiceItem.Text) == ""
default: default:
return false return false
@ -438,12 +450,17 @@ func (c *WeixinChannel) downloadMediaFromItem(
if err != nil { if err != nil {
return "", err return "", err
} }
data, err := c.downloadAndDecryptCDNBuffer(ctx, item.ImageItem.Media.EncryptQueryParam, func() []byte { data, err := c.downloadAndDecryptCDNBuffer(
ctx,
item.ImageItem.Media.EncryptQueryParam,
item.ImageItem.Media.FullURL,
func() []byte {
if ok { if ok {
return key return key
} }
return nil return nil
}()) }(),
)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -454,7 +471,12 @@ func (c *WeixinChannel) downloadMediaFromItem(
if err != nil { if err != nil {
return "", err return "", err
} }
silk, err := c.downloadAndDecryptCDNBuffer(ctx, item.VoiceItem.Media.EncryptQueryParam, key) silk, err := c.downloadAndDecryptCDNBuffer(
ctx,
item.VoiceItem.Media.EncryptQueryParam,
item.VoiceItem.Media.FullURL,
key,
)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -468,7 +490,12 @@ func (c *WeixinChannel) downloadMediaFromItem(
if err != nil { if err != nil {
return "", err return "", err
} }
data, err := c.downloadAndDecryptCDNBuffer(ctx, item.FileItem.Media.EncryptQueryParam, key) data, err := c.downloadAndDecryptCDNBuffer(
ctx,
item.FileItem.Media.EncryptQueryParam,
item.FileItem.Media.FullURL,
key,
)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -484,7 +511,12 @@ func (c *WeixinChannel) downloadMediaFromItem(
if err != nil { if err != nil {
return "", err return "", err
} }
data, err := c.downloadAndDecryptCDNBuffer(ctx, item.VideoItem.Media.EncryptQueryParam, key) data, err := c.downloadAndDecryptCDNBuffer(
ctx,
item.VideoItem.Media.EncryptQueryParam,
item.VideoItem.Media.FullURL,
key,
)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -701,11 +733,13 @@ func (c *WeixinChannel) uploadLocalFile(
} }
return nil, fmt.Errorf("getuploadurl failed: ret=%d errcode=%d errmsg=%s", 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) == "" { uploadParam := strings.TrimSpace(resp.UploadParam)
return nil, fmt.Errorf("getuploadurl returned empty upload_param") uploadFullURL := strings.TrimSpace(resp.UploadFullURL)
if uploadParam == "" && uploadFullURL == "" {
return nil, fmt.Errorf("getuploadurl returned no upload URL")
} }
downloadParam, err := c.uploadBufferToCDN(ctx, data, resp.UploadParam, filekey, aesKey) downloadParam, err := c.uploadBufferToCDN(ctx, data, uploadParam, uploadFullURL, filekey, aesKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -723,6 +757,7 @@ func (c *WeixinChannel) uploadBufferToCDN(
ctx context.Context, ctx context.Context,
plaintext []byte, plaintext []byte,
uploadParam, uploadParam,
uploadFullURL,
filekey string, filekey string,
aesKey []byte, aesKey []byte,
) (string, error) { ) (string, error) {
@ -731,7 +766,13 @@ func (c *WeixinChannel) uploadBufferToCDN(
return "", err return "", err
} }
uploadURL := buildCDNUploadURL(c.cdnBaseURL(), uploadParam, filekey) 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 var lastErr error
for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ { for attempt := 1; attempt <= weixinUploadRetryMax; attempt++ {

View file

@ -38,6 +38,7 @@ type GetUploadUrlResp struct {
APIStatus APIStatus
UploadParam string `json:"upload_param,omitempty"` UploadParam string `json:"upload_param,omitempty"`
ThumbUploadParam string `json:"thumb_upload_param,omitempty"` ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
UploadFullURL string `json:"upload_full_url,omitempty"`
} }
const ( const (
@ -69,6 +70,7 @@ type CDNMedia struct {
EncryptQueryParam string `json:"encrypt_query_param,omitempty"` EncryptQueryParam string `json:"encrypt_query_param,omitempty"`
AesKey string `json:"aes_key,omitempty"` // base64 encoded AesKey string `json:"aes_key,omitempty"` // base64 encoded
EncryptType int `json:"encrypt_type,omitempty"` EncryptType int `json:"encrypt_type,omitempty"`
FullURL string `json:"full_url,omitempty"`
} }
type ImageItem struct { type ImageItem struct {
@ -202,9 +204,10 @@ type QRCodeResponse struct {
} }
type StatusResponse struct { type StatusResponse struct {
Status string `json:"status"` // "wait", "scaned", "confirmed", "expired" Status string `json:"status"` // "wait", "scaned", "confirmed", "expired", "scaned_but_redirect"
BotToken string `json:"bot_token,omitempty"` BotToken string `json:"bot_token,omitempty"`
IlinkBotID string `json:"ilink_bot_id,omitempty"` IlinkBotID string `json:"ilink_bot_id,omitempty"`
Baseurl string `json:"baseurl,omitempty"` Baseurl string `json:"baseurl,omitempty"`
IlinkUserID string `json:"ilink_user_id,omitempty"` IlinkUserID string `json:"ilink_user_id,omitempty"`
RedirectHost string `json:"redirect_host,omitempty"`
} }

View file

@ -72,7 +72,7 @@ func TestDownloadAndDecryptCDNBuffer(t *testing.T) {
typingCache: make(map[string]typingTicketCacheEntry), typingCache: make(map[string]typingTicketCacheEntry),
} }
got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", key) got, err := ch.downloadAndDecryptCDNBuffer(context.Background(), "token", "", key)
if err != nil { if err != nil {
t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err) t.Fatalf("downloadAndDecryptCDNBuffer() error = %v", err)
} }
@ -120,7 +120,7 @@ func TestUploadBufferToCDN(t *testing.T) {
typingCache: make(map[string]typingTicketCacheEntry), typingCache: make(map[string]typingTicketCacheEntry),
} }
got, err := ch.uploadBufferToCDN(context.Background(), plaintext, "upload-param", "file-key", key) got, err := ch.uploadBufferToCDN(context.Background(), plaintext, "upload-param", "", "file-key", key)
if err != nil { if err != nil {
t.Fatalf("uploadBufferToCDN() error = %v", err) t.Fatalf("uploadBufferToCDN() error = %v", err)
} }