初始化仓库
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"crypto/md5"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxWeixinMediaBytes = 100 << 20
|
||||
|
||||
var hex32RE = regexp.MustCompile(`^[0-9a-fA-F]{32}$`)
|
||||
|
||||
// aesECBPaddedSize returns ciphertext length for AES-128-ECB with PKCS#7 padding.
|
||||
func aesECBPaddedSize(plaintextLen int) int {
|
||||
if plaintextLen < 0 {
|
||||
return 0
|
||||
}
|
||||
return ((plaintextLen + aes.BlockSize) / aes.BlockSize) * aes.BlockSize
|
||||
}
|
||||
|
||||
func pkcs7Pad(b []byte, blockSize int) []byte {
|
||||
if blockSize <= 0 || blockSize > 255 {
|
||||
panic("invalid block size")
|
||||
}
|
||||
n := blockSize - (len(b) % blockSize)
|
||||
pad := bytes.Repeat([]byte{byte(n)}, n)
|
||||
return append(b, pad...)
|
||||
}
|
||||
|
||||
func pkcs7Unpad(b []byte, blockSize int) ([]byte, error) {
|
||||
if len(b) == 0 || len(b)%blockSize != 0 {
|
||||
return nil, fmt.Errorf("invalid padded length %d", len(b))
|
||||
}
|
||||
n := int(b[len(b)-1])
|
||||
if n == 0 || n > blockSize || n > len(b) {
|
||||
return nil, fmt.Errorf("invalid pkcs7 padding")
|
||||
}
|
||||
for i := len(b) - n; i < len(b); i++ {
|
||||
if b[i] != byte(n) {
|
||||
return nil, fmt.Errorf("invalid pkcs7 padding")
|
||||
}
|
||||
}
|
||||
return b[:len(b)-n], nil
|
||||
}
|
||||
|
||||
func encryptAESECB(plaintext, key []byte) ([]byte, error) {
|
||||
if len(key) != 16 {
|
||||
return nil, fmt.Errorf("aes key must be 16 bytes, got %d", len(key))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
padded := pkcs7Pad(plaintext, aes.BlockSize)
|
||||
out := make([]byte, len(padded))
|
||||
for i := 0; i < len(padded); i += aes.BlockSize {
|
||||
block.Encrypt(out[i:i+aes.BlockSize], padded[i:i+aes.BlockSize])
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decryptAESECB(ciphertext, key []byte) ([]byte, error) {
|
||||
if len(key) != 16 {
|
||||
return nil, fmt.Errorf("aes key must be 16 bytes, got %d", len(key))
|
||||
}
|
||||
if len(ciphertext)%aes.BlockSize != 0 {
|
||||
return nil, fmt.Errorf("ciphertext length %d not aligned to block", len(ciphertext))
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]byte, len(ciphertext))
|
||||
for i := 0; i < len(ciphertext); i += aes.BlockSize {
|
||||
block.Decrypt(out[i:i+aes.BlockSize], ciphertext[i:i+aes.BlockSize])
|
||||
}
|
||||
return pkcs7Unpad(out, aes.BlockSize)
|
||||
}
|
||||
|
||||
// parseAesKey decodes CDNMedia.aes_key: base64(raw 16 bytes) or base64(32-char hex ASCII) → 16 bytes.
|
||||
func parseAesKey(aesKeyBase64, label string) ([]byte, error) {
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(aesKeyBase64))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: aes_key base64: %w", label, err)
|
||||
}
|
||||
if len(decoded) == 16 {
|
||||
return decoded, nil
|
||||
}
|
||||
if len(decoded) == 32 {
|
||||
s := string(decoded)
|
||||
if hex32RE.MatchString(s) {
|
||||
k, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: aes_key hex inside base64: %w", label, err)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("%s: aes_key must be 16 raw bytes or 32-char hex (base64-wrapped), got %d bytes after base64", label, len(decoded))
|
||||
}
|
||||
|
||||
func buildCdnDownloadURL(encryptedQueryParam, cdnBase string) string {
|
||||
return fmt.Sprintf("%s/download?encrypted_query_param=%s",
|
||||
strings.TrimRight(cdnBase, "/"),
|
||||
url.QueryEscape(encryptedQueryParam))
|
||||
}
|
||||
|
||||
func buildCdnUploadURL(cdnBase, uploadParam, filekey string) string {
|
||||
return fmt.Sprintf("%s/upload?encrypted_query_param=%s&filekey=%s",
|
||||
strings.TrimRight(cdnBase, "/"),
|
||||
url.QueryEscape(uploadParam),
|
||||
url.QueryEscape(filekey))
|
||||
}
|
||||
|
||||
func fetchCdnBytes(ctx context.Context, client *http.Client, fullURL, label string) ([]byte, error) {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: new request: %w", label, err)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: get: %w", label, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxWeixinMediaBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: read: %w", label, err)
|
||||
}
|
||||
if len(body) > maxWeixinMediaBytes {
|
||||
return nil, fmt.Errorf("%s: CDN body exceeds %d bytes", label, maxWeixinMediaBytes)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: CDN http %d: %s", label, resp.StatusCode, truncateForLog(body, 256))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func downloadAndDecryptCDN(ctx context.Context, client *http.Client, cdnBase, encParam, aesKeyBase64, label string) ([]byte, error) {
|
||||
key, err := parseAesKey(aesKeyBase64, label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := buildCdnDownloadURL(encParam, cdnBase)
|
||||
enc, err := fetchCdnBytes(ctx, client, u, label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plain, err := decryptAESECB(enc, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: decrypt: %w", label, err)
|
||||
}
|
||||
return plain, nil
|
||||
}
|
||||
|
||||
func downloadPlainCDN(ctx context.Context, client *http.Client, cdnBase, encParam, label string) ([]byte, error) {
|
||||
u := buildCdnDownloadURL(encParam, cdnBase)
|
||||
return fetchCdnBytes(ctx, client, u, label)
|
||||
}
|
||||
|
||||
const cdnUploadMaxRetries = 3
|
||||
|
||||
// uploadBufferToCDN encrypts plaintext with AES-128-ECB and uploads to the given CDN URL.
|
||||
// Caller is responsible for building the full URL (via buildCdnUploadURL or from upload_full_url).
|
||||
func uploadBufferToCDN(ctx context.Context, client *http.Client, cdnURL string, plaintext, aesKey []byte, label string) (downloadParam string, err error) {
|
||||
ciphertext, err := encryptAESECB(plaintext, aesKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: encrypt: %w", label, err)
|
||||
}
|
||||
u := cdnURL
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= cdnUploadMaxRetries; attempt++ {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(ciphertext))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: new request: %w", label, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
slog.Warn("weixin: CDN upload request failed", "label", label, "attempt", attempt, "error", err)
|
||||
continue
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
msg := resp.Header.Get("x-error-message")
|
||||
if msg == "" {
|
||||
msg = resp.Status
|
||||
}
|
||||
return "", fmt.Errorf("%s: CDN upload client error %d: %s", label, resp.StatusCode, msg)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg := resp.Header.Get("x-error-message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("status %d", resp.StatusCode)
|
||||
}
|
||||
lastErr = fmt.Errorf("%s: CDN upload server error: %s", label, msg)
|
||||
slog.Warn("weixin: CDN upload server error", "label", label, "attempt", attempt, "error", lastErr)
|
||||
continue
|
||||
}
|
||||
dl := resp.Header.Get("x-encrypted-param")
|
||||
if dl == "" {
|
||||
lastErr = fmt.Errorf("%s: CDN response missing x-encrypted-param", label)
|
||||
slog.Warn("weixin: CDN upload bad response", "label", label, "attempt", attempt)
|
||||
continue
|
||||
}
|
||||
return dl, nil
|
||||
}
|
||||
if lastErr != nil {
|
||||
return "", fmt.Errorf("%s: CDN upload failed after %d attempts: %w", label, cdnUploadMaxRetries, lastErr)
|
||||
}
|
||||
return "", fmt.Errorf("%s: CDN upload failed after %d attempts", label, cdnUploadMaxRetries)
|
||||
}
|
||||
|
||||
func md5Hex(b []byte) string {
|
||||
h := md5.Sum(b)
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func detectImageMime(b []byte) string {
|
||||
if len(b) >= 3 && b[0] == 0xFF && b[1] == 0xD8 && b[2] == 0xFF {
|
||||
return "image/jpeg"
|
||||
}
|
||||
if len(b) >= 8 && string(b[0:8]) == "\x89PNG\r\n\x1a\n" {
|
||||
return "image/png"
|
||||
}
|
||||
if len(b) >= 6 && (string(b[0:6]) == "GIF87a" || string(b[0:6]) == "GIF89a") {
|
||||
return "image/gif"
|
||||
}
|
||||
if len(b) >= 12 && string(b[0:4]) == "RIFF" && string(b[8:12]) == "WEBP" {
|
||||
return "image/webp"
|
||||
}
|
||||
return "image/jpeg"
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAesECBPaddedSize(t *testing.T) {
|
||||
if aesECBPaddedSize(0) != 16 {
|
||||
t.Fatalf("0 -> %d", aesECBPaddedSize(0))
|
||||
}
|
||||
if aesECBPaddedSize(1) != 16 {
|
||||
t.Fatalf("1 -> %d", aesECBPaddedSize(1))
|
||||
}
|
||||
if aesECBPaddedSize(16) != 32 {
|
||||
t.Fatalf("16 -> %d (pkcs7 adds a full block when aligned)", aesECBPaddedSize(16))
|
||||
}
|
||||
if aesECBPaddedSize(17) != 32 {
|
||||
t.Fatalf("17 -> %d", aesECBPaddedSize(17))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncryptDecryptAESECB_RoundTrip(t *testing.T) {
|
||||
key := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}
|
||||
plain := []byte("hello weixin cdn")
|
||||
ct, err := encryptAESECB(plain, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := decryptAESECB(ct, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, plain) {
|
||||
t.Fatalf("got %q want %q", got, plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAesKey_Raw16(t *testing.T) {
|
||||
key := []byte{9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5, 6}
|
||||
b64 := base64.StdEncoding.EncodeToString(key)
|
||||
got, err := parseAesKey(b64, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, key) {
|
||||
t.Fatal("mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAesKey_HexWrapped(t *testing.T) {
|
||||
raw, _ := hex.DecodeString("00112233445566778899aabbccddeeff")
|
||||
// Simulate API: base64(ASCII hex string)
|
||||
wrapped := base64.StdEncoding.EncodeToString([]byte("00112233445566778899aabbccddeeff"))
|
||||
got, err := parseAesKey(wrapped, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, raw) {
|
||||
t.Fatalf("got %x want %x", got, raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCdnDownloadURL(t *testing.T) {
|
||||
u := buildCdnDownloadURL("abc+def", "https://example/c2c")
|
||||
if u != "https://example/c2c/download?encrypted_query_param=abc%2Bdef" {
|
||||
t.Fatalf("got %q", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectImageMime(t *testing.T) {
|
||||
png := []byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a}
|
||||
if detectImageMime(png) != "image/png" {
|
||||
t.Fatalf("png: %s", detectImageMime(png))
|
||||
}
|
||||
if detectImageMime([]byte{0xff, 0xd8, 0xff}) != "image/jpeg" {
|
||||
t.Fatal("jpeg magic")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
defaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
|
||||
|
||||
defaultLongPollTimeout = 35 * time.Second
|
||||
defaultAPITimeout = 15 * time.Second
|
||||
|
||||
// maxIlinkHTTPResponseBody caps JSON response size (getUpdates may batch many msgs).
|
||||
maxIlinkHTTPResponseBody = 64 << 20
|
||||
|
||||
channelVersion = "cc-connect-weixin/1.0"
|
||||
)
|
||||
|
||||
type apiClient struct {
|
||||
baseURL string
|
||||
token string
|
||||
routeTag string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func newAPIClient(baseURL, token, routeTag string, httpClient *http.Client) *apiClient {
|
||||
if strings.TrimSpace(baseURL) == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/") + "/"
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: defaultAPITimeout}
|
||||
}
|
||||
return &apiClient{
|
||||
baseURL: baseURL,
|
||||
token: strings.TrimSpace(token),
|
||||
routeTag: strings.TrimSpace(routeTag),
|
||||
httpClient: httpClient,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *apiClient) longPollClient(timeout time.Duration) *http.Client {
|
||||
if timeout <= 0 {
|
||||
timeout = defaultLongPollTimeout
|
||||
}
|
||||
tr := http.DefaultTransport
|
||||
if t, ok := http.DefaultTransport.(*http.Transport); ok {
|
||||
cloned := t.Clone()
|
||||
tr = cloned
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout + 5*time.Second,
|
||||
Transport: tr,
|
||||
}
|
||||
}
|
||||
|
||||
func randomWechatUIN() string {
|
||||
var b [4]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return base64.StdEncoding.EncodeToString([]byte("0000"))
|
||||
}
|
||||
u := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||||
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%d", u)))
|
||||
}
|
||||
|
||||
func (c *apiClient) post(ctx context.Context, endpoint string, body []byte, timeout time.Duration, label string) ([]byte, error) {
|
||||
url := c.baseURL + strings.TrimPrefix(endpoint, "/")
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin: %s: new request: %w", label, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
req.Header.Set("Content-Length", fmt.Sprintf("%d", len(body)))
|
||||
req.Header.Set("X-WECHAT-UIN", randomWechatUIN())
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
if c.routeTag != "" {
|
||||
req.Header.Set("SKRouteTag", c.routeTag)
|
||||
}
|
||||
|
||||
client := c.httpClient
|
||||
if timeout > 0 {
|
||||
// Dedicated client so long-poll does not inherit short Timeout from default client.
|
||||
client = c.longPollClient(timeout)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin: %s: %w", label, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, maxIlinkHTTPResponseBody+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin: %s: read body: %w", label, err)
|
||||
}
|
||||
if len(raw) > maxIlinkHTTPResponseBody {
|
||||
return nil, fmt.Errorf("weixin: %s: response body exceeds %d bytes", label, maxIlinkHTTPResponseBody)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("weixin: %s: http %d: %s", label, resp.StatusCode, truncateForLog(raw, 512))
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
func truncateForLog(b []byte, max int) string {
|
||||
s := string(b)
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "…"
|
||||
}
|
||||
|
||||
func (c *apiClient) getUpdates(ctx context.Context, buf string, timeoutMs int) (*getUpdatesResp, error) {
|
||||
timeout := defaultLongPollTimeout
|
||||
if timeoutMs > 0 {
|
||||
timeout = time.Duration(timeoutMs) * time.Millisecond
|
||||
}
|
||||
req := getUpdatesReq{
|
||||
GetUpdatesBuf: buf,
|
||||
BaseInfo: baseInfo{ChannelVersion: channelVersion},
|
||||
}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.post(ctx, "ilink/bot/getupdates", payload, timeout, "getUpdates")
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return &getUpdatesResp{Ret: 0, Msgs: nil, GetUpdatesBuf: buf}, nil
|
||||
}
|
||||
var ne net.Error
|
||||
if errors.As(err, &ne) && ne.Timeout() {
|
||||
return &getUpdatesResp{Ret: 0, Msgs: nil, GetUpdatesBuf: buf}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var out getUpdatesResp
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("weixin: getUpdates json: %w", err)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *apiClient) sendMessage(ctx context.Context, msg *sendMessageReq) error {
|
||||
if msg == nil {
|
||||
return fmt.Errorf("weixin: sendMessage: nil request")
|
||||
}
|
||||
msg.BaseInfo = baseInfo{ChannelVersion: channelVersion}
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := c.post(ctx, "ilink/bot/sendmessage", payload, 0, "sendMessage")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bytes.TrimSpace(raw)) == 0 {
|
||||
return nil
|
||||
}
|
||||
var resp sendMessageResp
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return fmt.Errorf("weixin: sendMessage: response json: %w: %s", err, truncateForLog(raw, 256))
|
||||
}
|
||||
if resp.Ret != 0 {
|
||||
slog.Warn("weixin: sendMessage declined by API",
|
||||
"ret", resp.Ret, "errcode", resp.Errcode, "errmsg", resp.Errmsg,
|
||||
"content_len", len(payload))
|
||||
return fmt.Errorf("weixin: sendMessage: ret=%d errcode=%d errmsg=%s",
|
||||
resp.Ret, resp.Errcode, resp.Errmsg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *apiClient) getUploadURL(ctx context.Context, req getUploadURLRequest) (*getUploadURLResponse, error) {
|
||||
req.BaseInfo = baseInfo{ChannelVersion: channelVersion}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.post(ctx, "ilink/bot/getuploadurl", payload, 0, "getUploadUrl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out getUploadURLResponse
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("weixin: getUploadUrl json: %w", err)
|
||||
}
|
||||
// 兼容微信 iLink API 变更:新版返回 upload_full_url 而非 upload_param
|
||||
// upload_full_url 是完整的 CDN 上传地址,可独立作为成功路径
|
||||
if strings.TrimSpace(out.UploadParam) == "" && strings.TrimSpace(out.UploadFullURL) == "" {
|
||||
return nil, fmt.Errorf("weixin: getUploadUrl: empty upload_param and upload_full_url in %s", truncateForLog(raw, 512))
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *apiClient) getConfig(ctx context.Context, userID, contextToken string) (*getConfigResp, error) {
|
||||
req := getConfigReq{
|
||||
UserID: userID,
|
||||
ContextToken: contextToken,
|
||||
BaseInfo: baseInfo{ChannelVersion: channelVersion},
|
||||
}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := c.post(ctx, "ilink/bot/getconfig", payload, 0, "getConfig")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out getConfigResp
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, fmt.Errorf("weixin: getConfig json: %w", err)
|
||||
}
|
||||
if out.Ret != 0 || out.Errcode != 0 {
|
||||
return nil, fmt.Errorf("weixin: getConfig ret=%d errcode=%d errmsg=%s", out.Ret, out.Errcode, out.Errmsg)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *apiClient) sendTyping(ctx context.Context, userID, typingTicket string, status int) error {
|
||||
req := sendTypingReq{
|
||||
IlinkUserID: userID,
|
||||
TypingTicket: typingTicket,
|
||||
Status: status,
|
||||
BaseInfo: baseInfo{ChannelVersion: channelVersion},
|
||||
}
|
||||
payload, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = c.post(ctx, "ilink/bot/sendtyping", payload, 0, "sendTyping")
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *apiClient) sendText(ctx context.Context, to, text, contextToken, clientID string) error {
|
||||
if strings.TrimSpace(contextToken) == "" {
|
||||
return fmt.Errorf("weixin: context_token is required for send")
|
||||
}
|
||||
items := []messageItem{}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
items = append(items, messageItem{
|
||||
Type: messageItemText,
|
||||
TextItem: &textItem{Text: text},
|
||||
})
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return fmt.Errorf("weixin: sendText: empty item_list")
|
||||
}
|
||||
msg := sendMessageReq{
|
||||
Msg: weixinOutboundMsg{
|
||||
FromUserID: "",
|
||||
ToUserID: to,
|
||||
ClientID: clientID,
|
||||
MessageType: messageTypeBot,
|
||||
MessageState: messageStateFinish,
|
||||
ItemList: items,
|
||||
ContextToken: contextToken,
|
||||
},
|
||||
}
|
||||
return c.sendMessage(ctx, &msg)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func imageDecryptMaterial(img *imageItem) (encParam, aesKeyBase64 string, ok bool) {
|
||||
if img == nil || img.Media == nil {
|
||||
return "", "", false
|
||||
}
|
||||
encParam = strings.TrimSpace(img.Media.EncryptQueryParam)
|
||||
if encParam == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if hx := strings.TrimSpace(img.AESKeyHex); hx != "" {
|
||||
raw, err := hex.DecodeString(hx)
|
||||
if err == nil && len(raw) == 16 {
|
||||
return encParam, base64.StdEncoding.EncodeToString(raw), true
|
||||
}
|
||||
}
|
||||
if k := strings.TrimSpace(img.Media.AESKey); k != "" {
|
||||
return encParam, k, true
|
||||
}
|
||||
return encParam, "", false
|
||||
}
|
||||
|
||||
func (p *Platform) collectInboundMedia(ctx context.Context, items []messageItem) (images []core.ImageAttachment, files []core.FileAttachment, audio *core.AudioAttachment) {
|
||||
if p == nil || len(items) == 0 || strings.TrimSpace(p.cdnBaseURL) == "" {
|
||||
return nil, nil, nil
|
||||
}
|
||||
dlCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
client := p.cdnHttpClient
|
||||
if client == nil {
|
||||
client = p.httpClient
|
||||
}
|
||||
base := p.cdnBaseURL
|
||||
|
||||
// Deduplicate identical CDN references within one message (duplicate items / retries).
|
||||
seenEnc := make(map[string]struct{})
|
||||
tryEnc := func(enc string) bool {
|
||||
if enc == "" {
|
||||
return false
|
||||
}
|
||||
if _, ok := seenEnc[enc]; ok {
|
||||
return false
|
||||
}
|
||||
seenEnc[enc] = struct{}{}
|
||||
return true
|
||||
}
|
||||
var extraVoiceN int
|
||||
|
||||
for _, it := range items {
|
||||
switch it.Type {
|
||||
case messageItemImage:
|
||||
img := it.ImageItem
|
||||
enc, keyB64, hasKey := imageDecryptMaterial(img)
|
||||
if enc == "" || !tryEnc(enc) {
|
||||
continue
|
||||
}
|
||||
var buf []byte
|
||||
var err error
|
||||
if hasKey && keyB64 != "" {
|
||||
buf, err = downloadAndDecryptCDN(dlCtx, client, base, enc, keyB64, "weixin inbound image")
|
||||
} else {
|
||||
buf, err = downloadPlainCDN(dlCtx, client, base, enc, "weixin inbound image-plain")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Warn("weixin: inbound image CDN failed", "error", err)
|
||||
continue
|
||||
}
|
||||
mt := detectImageMime(buf)
|
||||
images = append(images, core.ImageAttachment{MimeType: mt, Data: buf})
|
||||
|
||||
case messageItemFile:
|
||||
f := it.FileItem
|
||||
if f == nil || f.Media == nil {
|
||||
continue
|
||||
}
|
||||
enc := strings.TrimSpace(f.Media.EncryptQueryParam)
|
||||
keyB64 := strings.TrimSpace(f.Media.AESKey)
|
||||
if enc == "" || keyB64 == "" || !tryEnc(enc) {
|
||||
continue
|
||||
}
|
||||
buf, err := downloadAndDecryptCDN(dlCtx, client, base, enc, keyB64, "weixin inbound file")
|
||||
if err != nil {
|
||||
slog.Warn("weixin: inbound file CDN failed", "error", err)
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(f.FileName)
|
||||
if name == "" {
|
||||
name = "attachment.bin"
|
||||
}
|
||||
mt := mime.TypeByExtension(filepath.Ext(name))
|
||||
if mt == "" {
|
||||
mt = "application/octet-stream"
|
||||
}
|
||||
files = append(files, core.FileAttachment{MimeType: mt, Data: buf, FileName: name})
|
||||
|
||||
case messageItemVideo:
|
||||
v := it.VideoItem
|
||||
if v == nil || v.Media == nil {
|
||||
continue
|
||||
}
|
||||
enc := strings.TrimSpace(v.Media.EncryptQueryParam)
|
||||
keyB64 := strings.TrimSpace(v.Media.AESKey)
|
||||
if enc == "" || keyB64 == "" || !tryEnc(enc) {
|
||||
continue
|
||||
}
|
||||
buf, err := downloadAndDecryptCDN(dlCtx, client, base, enc, keyB64, "weixin inbound video")
|
||||
if err != nil {
|
||||
slog.Warn("weixin: inbound video CDN failed", "error", err)
|
||||
continue
|
||||
}
|
||||
files = append(files, core.FileAttachment{MimeType: "video/mp4", Data: buf, FileName: "video.mp4"})
|
||||
|
||||
case messageItemVoice:
|
||||
v := it.VoiceItem
|
||||
if v == nil || v.Media == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(v.Text) != "" {
|
||||
// WeChat ASR text is enough when present; avoid STT path / duplicate handling.
|
||||
continue
|
||||
}
|
||||
enc := strings.TrimSpace(v.Media.EncryptQueryParam)
|
||||
keyB64 := strings.TrimSpace(v.Media.AESKey)
|
||||
if enc == "" || keyB64 == "" || !tryEnc(enc) {
|
||||
continue
|
||||
}
|
||||
buf, err := downloadAndDecryptCDN(dlCtx, client, base, enc, keyB64, "weixin inbound voice")
|
||||
if err != nil {
|
||||
slog.Warn("weixin: inbound voice CDN failed", "error", err)
|
||||
continue
|
||||
}
|
||||
a := &core.AudioAttachment{
|
||||
MimeType: "audio/silk",
|
||||
Data: buf,
|
||||
Format: "silk",
|
||||
}
|
||||
if audio == nil {
|
||||
audio = a
|
||||
} else {
|
||||
// core.Message carries one Audio; extra raw voice segments go as file attachments for the agent.
|
||||
extraVoiceN++
|
||||
files = append(files, core.FileAttachment{
|
||||
MimeType: "audio/silk",
|
||||
Data: buf,
|
||||
FileName: fmt.Sprintf("voice_%d.silk", extraVoiceN),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return images, files, audio
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func TestFormatAesKeyForAPI(t *testing.T) {
|
||||
// Verify our encode matches the Python SDK's format:
|
||||
// base64(hex_string_bytes), not base64(raw_bytes).
|
||||
key := []byte{0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}
|
||||
got := formatAesKeyForAPI(key)
|
||||
|
||||
// Expected: base64("00112233445566778899aabbccddeeff")
|
||||
hexStr := hex.EncodeToString(key)
|
||||
want := base64.StdEncoding.EncodeToString([]byte(hexStr))
|
||||
if got != want {
|
||||
t.Fatalf("formatAesKeyForAPI: got %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// Verify round-trip with parseAesKey (decode direction)
|
||||
decoded, err := parseAesKey(got, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("parseAesKey failed on formatAesKeyForAPI output: %v", err)
|
||||
}
|
||||
for i := range key {
|
||||
if decoded[i] != key[i] {
|
||||
t.Fatalf("round-trip mismatch at byte %d: got %02x, want %02x", i, decoded[i], key[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAesKeyForAPI_NotRawBase64(t *testing.T) {
|
||||
// Ensure the output is NOT just base64(raw_bytes) — that was the old bug.
|
||||
key := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}
|
||||
got := formatAesKeyForAPI(key)
|
||||
wrongFormat := base64.StdEncoding.EncodeToString(key) // base64(raw) — the old bug
|
||||
if got == wrongFormat {
|
||||
t.Fatalf("formatAesKeyForAPI should NOT produce base64(raw_bytes), but got %q which equals the wrong format", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWeixinCDNHost(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"https://novac2c.cdn.weixin.qq.com/c2c/upload?param=abc", true},
|
||||
{"https://anything.weixin.qq.com/path", true},
|
||||
{"https://cdn.wechat.com/upload", true},
|
||||
{"https://sub.domain.wechat.com/path", true},
|
||||
{"https://example.com/upload", false},
|
||||
{"https://weixin.qq.com.evil.com/fake", false},
|
||||
{"https://notwechat.com/path", false},
|
||||
{"", false},
|
||||
{"not-a-url", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := isWeixinCDNHost(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("isWeixinCDNHost(%q) = %v, want %v", tt.url, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUploadURLResponse_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resp getUploadURLResponse
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "upload_param only (legacy)",
|
||||
resp: getUploadURLResponse{UploadParam: "some_param"},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "upload_full_url only (new API)",
|
||||
resp: getUploadURLResponse{UploadFullURL: "https://novac2c.cdn.weixin.qq.com/c2c/upload?encrypted_query_param=abc"},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "both present",
|
||||
resp: getUploadURLResponse{UploadParam: "param", UploadFullURL: "https://cdn.example.com/upload"},
|
||||
wantError: false,
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
resp: getUploadURLResponse{},
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "whitespace only",
|
||||
resp: getUploadURLResponse{UploadParam: " ", UploadFullURL: " "},
|
||||
wantError: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Replicate the validation logic from client.go:
|
||||
// both fields empty/whitespace-only → error
|
||||
trim := func(s string) string {
|
||||
for len(s) > 0 && s[0] == ' ' {
|
||||
s = s[1:]
|
||||
}
|
||||
for len(s) > 0 && s[len(s)-1] == ' ' {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
hasError := trim(tt.resp.UploadParam) == "" && trim(tt.resp.UploadFullURL) == ""
|
||||
if hasError != tt.wantError {
|
||||
t.Errorf("validation error = %v, wantError %v", hasError, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsVideoFile(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
file core.FileAttachment
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "video mime",
|
||||
file: core.FileAttachment{MimeType: "video/mp4", FileName: "reply.bin"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "mp4 extension",
|
||||
file: core.FileAttachment{MimeType: "application/octet-stream", FileName: "reply.mp4"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "uppercase mov extension",
|
||||
file: core.FileAttachment{FileName: "reply.MOV"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "plain file",
|
||||
file: core.FileAttachment{MimeType: "application/pdf", FileName: "reply.pdf"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "audio is not video",
|
||||
file: core.FileAttachment{MimeType: "audio/mpeg", FileName: "reply.mp3"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isVideoFile(tt.file); got != tt.want {
|
||||
t.Fatalf("isVideoFile() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildVideoMessageItemUsesVideoShape(t *testing.T) {
|
||||
ref := &cdnUploadedRef{
|
||||
downloadParam: "encrypted-query",
|
||||
aesKey: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
|
||||
cipherSize: 4096,
|
||||
rawSize: 4000,
|
||||
}
|
||||
|
||||
item := buildVideoMessageItem(ref)
|
||||
if item.Type != messageItemVideo {
|
||||
t.Fatalf("item.Type = %d, want %d", item.Type, messageItemVideo)
|
||||
}
|
||||
if item.VideoItem == nil {
|
||||
t.Fatal("VideoItem is nil")
|
||||
}
|
||||
if item.VideoItem.VideoSize != ref.cipherSize {
|
||||
t.Fatalf("VideoSize = %d, want %d", item.VideoItem.VideoSize, ref.cipherSize)
|
||||
}
|
||||
if item.VideoItem.Media == nil {
|
||||
t.Fatal("VideoItem.Media is nil")
|
||||
}
|
||||
if item.VideoItem.Media.EncryptQueryParam != ref.downloadParam {
|
||||
t.Fatalf("EncryptQueryParam = %q, want %q", item.VideoItem.Media.EncryptQueryParam, ref.downloadParam)
|
||||
}
|
||||
if item.VideoItem.Media.AESKey != formatAesKeyForAPI(ref.aesKey) {
|
||||
t.Fatal("VideoItem.Media.AESKey does not match API format")
|
||||
}
|
||||
if item.VideoItem.Media.EncryptType != 1 {
|
||||
t.Fatalf("EncryptType = %d, want 1", item.VideoItem.Media.EncryptType)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func isMediaItemType(t int) bool {
|
||||
switch t {
|
||||
case messageItemImage, messageItemVoice, messageItemFile, messageItemVideo:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// bodyFromItemList extracts user-visible text from Weixin item_list (text, quotes, voice ASR).
|
||||
func bodyFromItemList(items []messageItem) string {
|
||||
if len(items) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, item := range items {
|
||||
switch item.Type {
|
||||
case messageItemText:
|
||||
if item.TextItem == nil {
|
||||
continue
|
||||
}
|
||||
text := strings.TrimSpace(item.TextItem.Text)
|
||||
ref := item.RefMsg
|
||||
if ref == nil {
|
||||
return text
|
||||
}
|
||||
if ref.MessageItem != nil && isMediaItemType(ref.MessageItem.Type) {
|
||||
return text
|
||||
}
|
||||
var parts []string
|
||||
if ref.Title != "" {
|
||||
parts = append(parts, ref.Title)
|
||||
}
|
||||
if ref.MessageItem != nil {
|
||||
refBody := bodyFromItemList([]messageItem{*ref.MessageItem})
|
||||
if refBody != "" {
|
||||
parts = append(parts, refBody)
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return text
|
||||
}
|
||||
return fmt.Sprintf("[引用: %s]\n%s", strings.Join(parts, " | "), text)
|
||||
case messageItemVoice:
|
||||
if item.VoiceItem != nil && strings.TrimSpace(item.VoiceItem.Text) != "" {
|
||||
return strings.TrimSpace(item.VoiceItem.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package weixin
|
||||
|
||||
// JSON shapes mirror the ilink bot HTTP API (Weixin / personal bridge).
|
||||
|
||||
const (
|
||||
messageTypeUser = 1
|
||||
messageTypeBot = 2
|
||||
|
||||
messageItemText = 1
|
||||
messageItemImage = 2
|
||||
messageItemVoice = 3
|
||||
messageItemFile = 4
|
||||
messageItemVideo = 5
|
||||
|
||||
messageStateFinish = 2
|
||||
|
||||
sessionExpiredErrcode = -14
|
||||
|
||||
uploadMediaImage = 1
|
||||
uploadMediaVideo = 2
|
||||
uploadMediaFile = 3
|
||||
|
||||
typingStatusStart = 1
|
||||
typingStatusStop = 2
|
||||
)
|
||||
|
||||
type baseInfo struct {
|
||||
ChannelVersion string `json:"channel_version,omitempty"`
|
||||
}
|
||||
|
||||
type getUpdatesReq struct {
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
BaseInfo baseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
type getUpdatesResp struct {
|
||||
Ret int `json:"ret"`
|
||||
Errcode int `json:"errcode"`
|
||||
Errmsg string `json:"errmsg"`
|
||||
Msgs []weixinMessage `json:"msgs"`
|
||||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||||
LongpollingTimeoutMs int `json:"longpolling_timeout_ms"`
|
||||
}
|
||||
|
||||
type textItem struct {
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
|
||||
// cdnMedia mirrors CDNMedia in the ilink JSON API.
|
||||
type cdnMedia struct {
|
||||
EncryptQueryParam string `json:"encrypt_query_param,omitempty"`
|
||||
AESKey string `json:"aes_key,omitempty"`
|
||||
EncryptType int `json:"encrypt_type,omitempty"`
|
||||
}
|
||||
|
||||
type imageItem struct {
|
||||
Media *cdnMedia `json:"media,omitempty"`
|
||||
ThumbMedia *cdnMedia `json:"thumb_media,omitempty"`
|
||||
AESKeyHex string `json:"aeskey,omitempty"` // inbound: raw key as hex (16 bytes)
|
||||
MidSize int `json:"mid_size,omitempty"`
|
||||
}
|
||||
|
||||
type fileItem struct {
|
||||
Media *cdnMedia `json:"media,omitempty"`
|
||||
FileName string `json:"file_name,omitempty"`
|
||||
Len string `json:"len,omitempty"`
|
||||
}
|
||||
|
||||
type videoItem struct {
|
||||
Media *cdnMedia `json:"media,omitempty"`
|
||||
ThumbMedia *cdnMedia `json:"thumb_media,omitempty"`
|
||||
VideoSize int `json:"video_size,omitempty"`
|
||||
}
|
||||
|
||||
type refMessage struct {
|
||||
MessageItem *messageItem `json:"message_item,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
type messageItem struct {
|
||||
Type int `json:"type,omitempty"`
|
||||
TextItem *textItem `json:"text_item,omitempty"`
|
||||
VoiceItem *voiceItem `json:"voice_item,omitempty"`
|
||||
ImageItem *imageItem `json:"image_item,omitempty"`
|
||||
FileItem *fileItem `json:"file_item,omitempty"`
|
||||
VideoItem *videoItem `json:"video_item,omitempty"`
|
||||
RefMsg *refMessage `json:"ref_msg,omitempty"`
|
||||
}
|
||||
|
||||
type voiceItem struct {
|
||||
Media *cdnMedia `json:"media,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
EncodeType int `json:"encode_type,omitempty"`
|
||||
}
|
||||
|
||||
type getUploadURLRequest struct {
|
||||
Filekey string `json:"filekey,omitempty"`
|
||||
MediaType int `json:"media_type,omitempty"`
|
||||
ToUserID string `json:"to_user_id,omitempty"`
|
||||
Rawsize int `json:"rawsize,omitempty"`
|
||||
Rawfilemd5 string `json:"rawfilemd5,omitempty"`
|
||||
Filesize int `json:"filesize,omitempty"`
|
||||
NoNeedThumb bool `json:"no_need_thumb,omitempty"`
|
||||
Aeskey string `json:"aeskey,omitempty"`
|
||||
BaseInfo baseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
type getUploadURLResponse struct {
|
||||
UploadParam string `json:"upload_param,omitempty"`
|
||||
ThumbUploadParam string `json:"thumb_upload_param,omitempty"`
|
||||
UploadFullURL string `json:"upload_full_url,omitempty"`
|
||||
}
|
||||
|
||||
type weixinMessage struct {
|
||||
Seq int64 `json:"seq,omitempty"`
|
||||
MessageID int64 `json:"message_id,omitempty"`
|
||||
FromUserID string `json:"from_user_id,omitempty"`
|
||||
ToUserID string `json:"to_user_id,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
CreateTimeMs int64 `json:"create_time_ms,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
MessageType int `json:"message_type,omitempty"`
|
||||
MessageState int `json:"message_state,omitempty"`
|
||||
ItemList []messageItem `json:"item_list,omitempty"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
}
|
||||
|
||||
type sendMessageReq struct {
|
||||
Msg weixinOutboundMsg `json:"msg"`
|
||||
BaseInfo baseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
// sendMessageResp is the JSON body returned by ilink/bot/sendmessage on HTTP 200.
|
||||
type sendMessageResp struct {
|
||||
Ret int `json:"ret"`
|
||||
Errcode int `json:"errcode"`
|
||||
Errmsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
type sendTypingReq struct {
|
||||
IlinkUserID string `json:"ilink_user_id"`
|
||||
TypingTicket string `json:"typing_ticket"`
|
||||
Status int `json:"status"`
|
||||
BaseInfo baseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
type getConfigReq struct {
|
||||
UserID string `json:"user_id"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
BaseInfo baseInfo `json:"base_info"`
|
||||
}
|
||||
|
||||
type getConfigResp struct {
|
||||
Ret int `json:"ret"`
|
||||
Errcode int `json:"errcode"`
|
||||
Errmsg string `json:"errmsg"`
|
||||
TypingTicket string `json:"typing_ticket"`
|
||||
}
|
||||
|
||||
type weixinOutboundMsg struct {
|
||||
FromUserID string `json:"from_user_id"`
|
||||
ToUserID string `json:"to_user_id"`
|
||||
ClientID string `json:"client_id"`
|
||||
MessageType int `json:"message_type"`
|
||||
MessageState int `json:"message_state"`
|
||||
ItemList []messageItem `json:"item_list,omitempty"`
|
||||
ContextToken string `json:"context_token,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,749 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterPlatform("weixin", New)
|
||||
}
|
||||
|
||||
const (
|
||||
sessionKeyPrefix = "weixin:dm:"
|
||||
maxWeixinChunk = 3800 // stay under typical IM limits
|
||||
|
||||
// weixinSendMaxRetries is the maximum number of retries for sendMessage when API returns ret=-2.
|
||||
weixinSendMaxRetries = 3
|
||||
// weixinSendRetryDelay is the delay between retries when sendMessage fails.
|
||||
weixinSendRetryDelay = 500 * time.Millisecond
|
||||
// weixinChunkSendDelay is the delay between sending message chunks to avoid rate limiting.
|
||||
weixinChunkSendDelay = 100 * time.Millisecond
|
||||
// typingTicketTTL is how long a cached typing ticket remains valid.
|
||||
typingTicketTTL = 10 * time.Minute
|
||||
// typingRepeatInterval is how often to resend the typing status to keep it alive.
|
||||
typingRepeatInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
type replyContext struct {
|
||||
peerUserID string
|
||||
contextToken string
|
||||
}
|
||||
|
||||
// Platform implements core.Platform for Weixin personal chat via the ilink bot HTTP API
|
||||
// (same backend as the OpenClaw openclaw-weixin plugin: long-poll getUpdates + sendMessage).
|
||||
type Platform struct {
|
||||
token string
|
||||
baseURL string
|
||||
cdnBaseURL string
|
||||
allowFrom string
|
||||
routeTag string
|
||||
stateDir string
|
||||
longPollMS int
|
||||
accountLabel string
|
||||
|
||||
httpClient *http.Client
|
||||
cdnHttpClient *http.Client // 专用于 CDN 上传/下载,不走代理
|
||||
api *apiClient
|
||||
|
||||
mu sync.RWMutex
|
||||
handler core.MessageHandler
|
||||
cancel context.CancelFunc
|
||||
stopping bool
|
||||
|
||||
syncBufMu sync.Mutex
|
||||
syncBuf string
|
||||
syncBufPath string
|
||||
|
||||
dedupMu sync.Mutex
|
||||
dedup map[string]time.Time
|
||||
|
||||
pauseMu sync.Mutex
|
||||
pauseUntil time.Time
|
||||
|
||||
tokensMu sync.RWMutex
|
||||
tokens map[string]string
|
||||
tokensPath string
|
||||
|
||||
typingMu sync.RWMutex
|
||||
typingTickets map[string]typingTicketEntry // peerUserID → cached ticket
|
||||
}
|
||||
|
||||
type typingTicketEntry struct {
|
||||
ticket string
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
func sanitizePathSegment(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return "default"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
switch r {
|
||||
case '/', '\\', ':', '\x00':
|
||||
b.WriteByte('_')
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// New constructs a Weixin platform. Required options: token.
|
||||
// Optional: base_url, cdn_base_url (default https://novac2c.cdn.weixin.qq.com/c2c), allow_from, route_tag, account_id, long_poll_timeout_ms,
|
||||
// state_dir (override persistence dir), proxy, cc_data_dir + cc_project (injected by main).
|
||||
func New(opts map[string]any) (core.Platform, error) {
|
||||
token, _ := opts["token"].(string)
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return nil, fmt.Errorf("weixin: token is required (ilink bot Bearer token)")
|
||||
}
|
||||
allowFrom, _ := opts["allow_from"].(string)
|
||||
core.CheckAllowFrom("weixin", allowFrom)
|
||||
|
||||
baseURL, _ := opts["base_url"].(string)
|
||||
cdnBaseURL, _ := opts["cdn_base_url"].(string)
|
||||
if strings.TrimSpace(cdnBaseURL) == "" {
|
||||
cdnBaseURL = defaultCDNBaseURL
|
||||
}
|
||||
cdnBaseURL = strings.TrimRight(strings.TrimSpace(cdnBaseURL), "/")
|
||||
routeTag, _ := opts["route_tag"].(string)
|
||||
accountLabel, _ := opts["account_id"].(string)
|
||||
if accountLabel == "" {
|
||||
accountLabel = "default"
|
||||
}
|
||||
lp := pickInt(opts["long_poll_timeout_ms"])
|
||||
|
||||
dataDir, _ := opts["cc_data_dir"].(string)
|
||||
project, _ := opts["cc_project"].(string)
|
||||
stateDir := ""
|
||||
if dataDir != "" && project != "" {
|
||||
safeProj := sanitizePathSegment(project)
|
||||
stateDir = filepath.Join(dataDir, "weixin", safeProj, sanitizePathSegment(accountLabel))
|
||||
}
|
||||
if override, _ := opts["state_dir"].(string); strings.TrimSpace(override) != "" {
|
||||
stateDir = strings.TrimSpace(override)
|
||||
}
|
||||
|
||||
httpClient := &http.Client{Timeout: defaultAPITimeout}
|
||||
if proxyURL, _ := opts["proxy"].(string); proxyURL != "" {
|
||||
u, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin: invalid proxy URL %q: %w", proxyURL, err)
|
||||
}
|
||||
proxyUser, _ := opts["proxy_username"].(string)
|
||||
proxyPass, _ := opts["proxy_password"].(string)
|
||||
if proxyUser != "" {
|
||||
u.User = url.UserPassword(proxyUser, proxyPass)
|
||||
}
|
||||
httpClient.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
|
||||
slog.Info("weixin: using proxy", "proxy", u.Redacted())
|
||||
}
|
||||
|
||||
// CDN 客户端:微信国内 CDN 必须直连,绕过环境变量中的代理(如 HTTPS_PROXY)
|
||||
cdnHttpClient := &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
Transport: &http.Transport{Proxy: nil},
|
||||
}
|
||||
|
||||
p := &Platform{
|
||||
token: token,
|
||||
baseURL: baseURL,
|
||||
cdnBaseURL: cdnBaseURL,
|
||||
allowFrom: allowFrom,
|
||||
routeTag: routeTag,
|
||||
stateDir: stateDir,
|
||||
longPollMS: lp,
|
||||
accountLabel: accountLabel,
|
||||
httpClient: httpClient,
|
||||
cdnHttpClient: cdnHttpClient,
|
||||
tokens: make(map[string]string),
|
||||
dedup: make(map[string]time.Time),
|
||||
typingTickets: make(map[string]typingTicketEntry),
|
||||
}
|
||||
p.api = newAPIClient(baseURL, token, routeTag, httpClient)
|
||||
|
||||
if stateDir != "" {
|
||||
if err := os.MkdirAll(stateDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("weixin: create state dir: %w", err)
|
||||
}
|
||||
p.syncBufPath = filepath.Join(stateDir, "get_updates.buf")
|
||||
p.tokensPath = filepath.Join(stateDir, "context_tokens.json")
|
||||
p.loadSyncBuf()
|
||||
p.loadTokens()
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func pickInt(v any) int {
|
||||
switch x := v.(type) {
|
||||
case int:
|
||||
return x
|
||||
case int64:
|
||||
return int(x)
|
||||
case float64:
|
||||
return int(x)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Platform) Name() string { return "weixin" }
|
||||
|
||||
func (p *Platform) loadSyncBuf() {
|
||||
if p.syncBufPath == "" {
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(p.syncBufPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
p.syncBuf = string(b)
|
||||
}
|
||||
|
||||
// persistSyncBuf writes buf as the next get_updates cursor (caller must hold syncBufMu).
|
||||
func (p *Platform) persistSyncBuf(buf string) {
|
||||
p.syncBuf = buf
|
||||
if p.syncBufPath == "" {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(p.syncBufPath, []byte(buf), 0o600); err != nil {
|
||||
slog.Warn("weixin: save sync buf failed", "path", p.syncBufPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Platform) loadTokens() {
|
||||
if p.tokensPath == "" {
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(p.tokensPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var m map[string]string
|
||||
if json.Unmarshal(b, &m) != nil {
|
||||
return
|
||||
}
|
||||
p.tokensMu.Lock()
|
||||
p.tokens = m
|
||||
p.tokensMu.Unlock()
|
||||
}
|
||||
|
||||
func (p *Platform) persistTokens() {
|
||||
if p.tokensPath == "" {
|
||||
return
|
||||
}
|
||||
p.tokensMu.RLock()
|
||||
out, err := json.MarshalIndent(p.tokens, "", " ")
|
||||
p.tokensMu.RUnlock()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(p.tokensPath, out, 0o600); err != nil {
|
||||
slog.Warn("weixin: save context tokens failed", "path", p.tokensPath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Platform) setContextToken(peer, tok string) {
|
||||
if peer == "" || tok == "" {
|
||||
return
|
||||
}
|
||||
p.tokensMu.Lock()
|
||||
if p.tokens == nil {
|
||||
p.tokens = make(map[string]string)
|
||||
}
|
||||
p.tokens[peer] = tok
|
||||
p.tokensMu.Unlock()
|
||||
p.persistTokens()
|
||||
}
|
||||
|
||||
func (p *Platform) getContextToken(peer string) string {
|
||||
p.tokensMu.RLock()
|
||||
defer p.tokensMu.RUnlock()
|
||||
return p.tokens[peer]
|
||||
}
|
||||
|
||||
func (p *Platform) isPaused() bool {
|
||||
p.pauseMu.Lock()
|
||||
defer p.pauseMu.Unlock()
|
||||
if p.pauseUntil.IsZero() || time.Now().After(p.pauseUntil) {
|
||||
p.pauseUntil = time.Time{}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Platform) pauseSession(d time.Duration) {
|
||||
if d <= 0 {
|
||||
d = time.Hour
|
||||
}
|
||||
p.pauseMu.Lock()
|
||||
p.pauseUntil = time.Now().Add(d)
|
||||
p.pauseMu.Unlock()
|
||||
slog.Warn("weixin: session paused after gateway error", "duration", d, "account", p.accountLabel)
|
||||
}
|
||||
|
||||
func (p *Platform) Start(handler core.MessageHandler) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.stopping {
|
||||
return fmt.Errorf("weixin: platform stopped")
|
||||
}
|
||||
p.handler = handler
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
p.cancel = cancel
|
||||
go p.pollLoop(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) Stop() error {
|
||||
p.mu.Lock()
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
p.cancel = nil
|
||||
}
|
||||
p.stopping = true
|
||||
p.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Platform) pollLoop(ctx context.Context) {
|
||||
backoff := time.Second
|
||||
const maxBackoff = 30 * time.Second
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if p.isPaused() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
p.syncBufMu.Lock()
|
||||
buf := p.syncBuf
|
||||
p.syncBufMu.Unlock()
|
||||
|
||||
timeoutMs := p.longPollMS
|
||||
resp, err := p.api.getUpdates(ctx, buf, timeoutMs)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
slog.Warn("weixin: getUpdates failed", "error", err, "backoff", backoff)
|
||||
time.Sleep(backoff)
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
backoff = time.Second
|
||||
|
||||
if resp.Errcode == sessionExpiredErrcode {
|
||||
p.pauseSession(time.Hour)
|
||||
continue
|
||||
}
|
||||
if resp.Ret != 0 && resp.Errmsg != "" {
|
||||
slog.Warn("weixin: getUpdates ret", "ret", resp.Ret, "errcode", resp.Errcode, "errmsg", resp.Errmsg)
|
||||
}
|
||||
|
||||
p.mu.RLock()
|
||||
h := p.handler
|
||||
p.mu.RUnlock()
|
||||
if h == nil {
|
||||
continue
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := range resp.Msgs {
|
||||
i := i
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p.dispatchInbound(ctx, &resp.Msgs[i], h)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if ctx.Err() == nil && resp.GetUpdatesBuf != "" {
|
||||
p.syncBufMu.Lock()
|
||||
p.persistSyncBuf(resp.GetUpdatesBuf)
|
||||
p.syncBufMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Platform) dispatchInbound(ctx context.Context, m *weixinMessage, h core.MessageHandler) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if m.MessageType == messageTypeBot {
|
||||
return
|
||||
}
|
||||
if m.MessageType != 0 && m.MessageType != messageTypeUser {
|
||||
return
|
||||
}
|
||||
from := strings.TrimSpace(m.FromUserID)
|
||||
if from == "" {
|
||||
return
|
||||
}
|
||||
if !core.AllowList(p.allowFrom, from) {
|
||||
slog.Debug("weixin: sender not in allow_from", "from", from)
|
||||
return
|
||||
}
|
||||
if m.CreateTimeMs > 0 {
|
||||
t := time.UnixMilli(m.CreateTimeMs)
|
||||
if core.IsOldMessage(t) {
|
||||
slog.Debug("weixin: skip old message", "time", t)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Include create_time_ms and client_id so (seq,message_id)=(0,0) or duplicates are less likely to collide.
|
||||
dedupKey := fmt.Sprintf("%s|%d|%d|%d|%s", from, m.MessageID, m.Seq, m.CreateTimeMs, strings.TrimSpace(m.ClientID))
|
||||
p.dedupMu.Lock()
|
||||
if p.dedup == nil {
|
||||
p.dedup = make(map[string]time.Time)
|
||||
}
|
||||
now := time.Now()
|
||||
for k, ts := range p.dedup {
|
||||
if now.Sub(ts) > 5*time.Minute {
|
||||
delete(p.dedup, k)
|
||||
}
|
||||
}
|
||||
if _, ok := p.dedup[dedupKey]; ok {
|
||||
p.dedupMu.Unlock()
|
||||
return
|
||||
}
|
||||
p.dedup[dedupKey] = now
|
||||
p.dedupMu.Unlock()
|
||||
|
||||
if tok := strings.TrimSpace(m.ContextToken); tok != "" {
|
||||
p.setContextToken(from, tok)
|
||||
p.refreshTypingTicket(ctx, from, tok)
|
||||
}
|
||||
|
||||
body := bodyFromItemList(m.ItemList)
|
||||
images, files, audio := p.collectInboundMedia(ctx, m.ItemList)
|
||||
if strings.TrimSpace(body) == "" && len(images) == 0 && len(files) == 0 && audio == nil && mediaOnlyItems(m.ItemList) {
|
||||
body = "[收到媒体消息:CDN 下载或解密失败,或未配置 cdn_base_url;请改用文字说明。]"
|
||||
}
|
||||
if strings.TrimSpace(body) == "" && len(images) == 0 && len(files) == 0 && audio == nil {
|
||||
return
|
||||
}
|
||||
|
||||
rc := &replyContext{peerUserID: from, contextToken: strings.TrimSpace(m.ContextToken)}
|
||||
msgID := fmt.Sprintf("%d", m.MessageID)
|
||||
if m.MessageID == 0 {
|
||||
msgID = randomHex(8)
|
||||
}
|
||||
|
||||
h(p, &core.Message{
|
||||
SessionKey: sessionKeyPrefix + from,
|
||||
Platform: p.Name(),
|
||||
MessageID: msgID,
|
||||
UserID: from,
|
||||
UserName: shortWeixinUser(from),
|
||||
Content: body,
|
||||
Images: images,
|
||||
Files: files,
|
||||
Audio: audio,
|
||||
ReplyCtx: rc,
|
||||
})
|
||||
}
|
||||
|
||||
func mediaOnlyItems(items []messageItem) bool {
|
||||
for _, it := range items {
|
||||
switch it.Type {
|
||||
case messageItemImage, messageItemVideo, messageItemFile:
|
||||
return true
|
||||
case messageItemVoice:
|
||||
if it.VoiceItem == nil || strings.TrimSpace(it.VoiceItem.Text) == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func shortWeixinUser(id string) string {
|
||||
if len(id) > 32 {
|
||||
return id[:32] + "…"
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func (p *Platform) Reply(ctx context.Context, replyCtx any, content string) error {
|
||||
return p.sendChunks(ctx, replyCtx, content)
|
||||
}
|
||||
|
||||
func (p *Platform) Send(ctx context.Context, replyCtx any, content string) error {
|
||||
return p.sendChunks(ctx, replyCtx, content)
|
||||
}
|
||||
|
||||
// StartTyping sends a typing indicator to the peer and repeats every few seconds
|
||||
// until the returned stop function is called. Implements core.TypingIndicator.
|
||||
func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) {
|
||||
rc, ok := rctx.(*replyContext)
|
||||
if !ok || rc == nil {
|
||||
return func() {}
|
||||
}
|
||||
peerID := rc.peerUserID
|
||||
contextToken := rc.contextToken
|
||||
if strings.TrimSpace(contextToken) == "" {
|
||||
contextToken = p.getContextToken(peerID)
|
||||
}
|
||||
|
||||
ticket := p.getTypingTicket(ctx, peerID, contextToken)
|
||||
if ticket == "" {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
if err := p.api.sendTyping(ctx, peerID, ticket, typingStatusStart); err != nil {
|
||||
slog.Debug("weixin: initial typing start failed", "peer", peerID, "error", err)
|
||||
return func() {}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
ticker := time.NewTicker(typingRepeatInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
// Best-effort stop; use background context since ctx may already be cancelled.
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
if err := p.api.sendTyping(stopCtx, peerID, ticket, typingStatusStop); err != nil {
|
||||
slog.Debug("weixin: typing stop failed", "peer", peerID, "error", err)
|
||||
}
|
||||
cancel()
|
||||
return
|
||||
case <-ctx.Done():
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
if err := p.api.sendTyping(stopCtx, peerID, ticket, typingStatusStop); err != nil {
|
||||
slog.Debug("weixin: typing stop failed (ctx cancelled)", "peer", peerID, "error", err)
|
||||
}
|
||||
cancel()
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := p.api.sendTyping(ctx, peerID, ticket, typingStatusStart); err != nil {
|
||||
slog.Debug("weixin: typing repeat failed", "peer", peerID, "error", err)
|
||||
bestEffortCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_ = p.api.sendTyping(bestEffortCtx, peerID, ticket, typingStatusStop)
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() { close(done) }
|
||||
}
|
||||
|
||||
// getTypingTicket returns a cached typing ticket for the peer, fetching one
|
||||
// from the getconfig API if the cache is empty or expired.
|
||||
func (p *Platform) getTypingTicket(ctx context.Context, peerID, contextToken string) string {
|
||||
p.typingMu.RLock()
|
||||
entry, ok := p.typingTickets[peerID]
|
||||
p.typingMu.RUnlock()
|
||||
if ok && time.Since(entry.fetchedAt) < typingTicketTTL {
|
||||
return entry.ticket
|
||||
}
|
||||
|
||||
resp, err := p.api.getConfig(ctx, peerID, contextToken)
|
||||
if err != nil {
|
||||
slog.Debug("weixin: getConfig for typing ticket failed", "peer", peerID, "error", err)
|
||||
return ""
|
||||
}
|
||||
ticket := strings.TrimSpace(resp.TypingTicket)
|
||||
if ticket == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
p.typingMu.Lock()
|
||||
p.typingTickets[peerID] = typingTicketEntry{ticket: ticket, fetchedAt: time.Now()}
|
||||
p.typingMu.Unlock()
|
||||
return ticket
|
||||
}
|
||||
|
||||
// refreshTypingTicket proactively fetches and caches a typing ticket when a
|
||||
// message is received, so that StartTyping can use it without an extra round-trip.
|
||||
func (p *Platform) refreshTypingTicket(ctx context.Context, peerID, contextToken string) {
|
||||
go func() {
|
||||
fetchCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
p.getTypingTicket(fetchCtx, peerID, contextToken)
|
||||
}()
|
||||
}
|
||||
|
||||
func (p *Platform) sendChunks(ctx context.Context, replyCtx any, content string) error {
|
||||
rc, ok := replyCtx.(*replyContext)
|
||||
if !ok || rc == nil {
|
||||
return fmt.Errorf("weixin: invalid reply context")
|
||||
}
|
||||
if strings.TrimSpace(rc.contextToken) == "" {
|
||||
rc.contextToken = p.getContextToken(rc.peerUserID)
|
||||
}
|
||||
if strings.TrimSpace(rc.contextToken) == "" {
|
||||
slog.Error("weixin: cannot send message - missing context_token",
|
||||
"peer", rc.peerUserID,
|
||||
"content_preview", truncatePreview(content, 100),
|
||||
"hint", "user needs to send a new message to refresh context_token")
|
||||
return fmt.Errorf("weixin: missing context_token for peer %q - user must send a new message first", rc.peerUserID)
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return nil
|
||||
}
|
||||
chunks := splitUTF8(content, maxWeixinChunk)
|
||||
total := len(chunks)
|
||||
for i, chunk := range chunks {
|
||||
// Add delay between chunks to avoid rate limiting (except for first chunk)
|
||||
if i > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(weixinChunkSendDelay):
|
||||
}
|
||||
}
|
||||
// Retry sendText with context_token refresh on failure
|
||||
err := p.sendChunkWithRetry(ctx, rc, chunk, i+1, total)
|
||||
if err != nil {
|
||||
slog.Error("weixin: chunk send failed, message incomplete",
|
||||
"peer", rc.peerUserID,
|
||||
"failed_chunk", fmt.Sprintf("%d/%d", i+1, total),
|
||||
"error", err)
|
||||
// Notify user that message delivery was incomplete.
|
||||
// Use a short message that is unlikely to fail itself.
|
||||
notice := "⚠️ 消息发送不完整,请在终端查看完整结果。"
|
||||
noticeID := "cc-" + randomHex(6)
|
||||
if nerr := p.api.sendText(ctx, rc.peerUserID, notice, rc.contextToken, noticeID); nerr != nil {
|
||||
slog.Warn("weixin: failed to send incomplete-delivery notice", "peer", rc.peerUserID, "error", nerr)
|
||||
}
|
||||
return fmt.Errorf("weixin: send chunk %d/%d: %w", i+1, total, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendChunkWithRetry sends a single chunk with retry mechanism.
|
||||
// When sendMessage returns ret=-2, it retries with a fresh context_token.
|
||||
// chunkIdx and totalChunks are 1-based indices used for logging context.
|
||||
func (p *Platform) sendChunkWithRetry(ctx context.Context, rc *replyContext, chunk string, chunkIdx, totalChunks int) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < weixinSendMaxRetries; attempt++ {
|
||||
clientID := "cc-" + randomHex(6)
|
||||
err := p.api.sendText(ctx, rc.peerUserID, chunk, rc.contextToken, clientID)
|
||||
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") {
|
||||
preview := []rune(chunk)
|
||||
if len(preview) > 50 {
|
||||
preview = preview[:50]
|
||||
}
|
||||
slog.Warn("weixin: sendMessage ret=-2, retrying with fresh context_token",
|
||||
"attempt", attempt+1, "peer", rc.peerUserID,
|
||||
"chunk", fmt.Sprintf("%d/%d", chunkIdx, totalChunks),
|
||||
"chunk_runes", utf8.RuneCountInString(chunk),
|
||||
"preview", string(preview))
|
||||
// Add delay before retry
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(weixinSendRetryDelay):
|
||||
}
|
||||
// Refresh context_token from stored tokens (may have been updated by new incoming message)
|
||||
freshToken := p.getContextToken(rc.peerUserID)
|
||||
if freshToken != "" && freshToken != rc.contextToken {
|
||||
rc.contextToken = freshToken
|
||||
slog.Debug("weixin: using refreshed context_token for retry", "peer", rc.peerUserID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// For other errors, don't retry
|
||||
return err
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
func truncatePreview(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max] + "..."
|
||||
}
|
||||
|
||||
func splitUTF8(s string, maxRunes int) []string {
|
||||
if maxRunes <= 0 || utf8.RuneCountInString(s) <= maxRunes {
|
||||
return []string{s}
|
||||
}
|
||||
var out []string
|
||||
runes := []rune(s)
|
||||
for len(runes) > 0 {
|
||||
n := maxRunes
|
||||
if len(runes) < n {
|
||||
n = len(runes)
|
||||
}
|
||||
out = append(out, string(runes[:n]))
|
||||
runes = runes[n:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ReconstructReplyCtx implements core.ReplyContextReconstructor for cron / proactive sends.
|
||||
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
|
||||
if !strings.HasPrefix(sessionKey, sessionKeyPrefix) {
|
||||
return nil, fmt.Errorf("weixin: not a weixin session key")
|
||||
}
|
||||
peer := strings.TrimPrefix(sessionKey, sessionKeyPrefix)
|
||||
tok := p.getContextToken(peer)
|
||||
if tok == "" {
|
||||
return nil, fmt.Errorf("weixin: no stored context_token for %q (user must message the bot first)", peer)
|
||||
}
|
||||
return &replyContext{peerUserID: peer, contextToken: tok}, nil
|
||||
}
|
||||
|
||||
// FormattingInstructions implements core.FormattingInstructionProvider.
|
||||
func (p *Platform) FormattingInstructions() string {
|
||||
return "Replies are delivered as plain text to Weixin. Avoid markdown tables; use short paragraphs."
|
||||
}
|
||||
|
||||
var (
|
||||
_ core.Platform = (*Platform)(nil)
|
||||
_ core.ReplyContextReconstructor = (*Platform)(nil)
|
||||
_ core.FormattingInstructionProvider = (*Platform)(nil)
|
||||
_ core.ImageSender = (*Platform)(nil)
|
||||
_ core.FileSender = (*Platform)(nil)
|
||||
_ core.TypingIndicator = (*Platform)(nil)
|
||||
)
|
||||
@@ -0,0 +1,200 @@
|
||||
package weixin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBodyFromItemList_Text(t *testing.T) {
|
||||
got := bodyFromItemList([]messageItem{
|
||||
{Type: messageItemText, TextItem: &textItem{Text: " hello "}},
|
||||
})
|
||||
if got != "hello" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyFromItemList_VoiceText(t *testing.T) {
|
||||
got := bodyFromItemList([]messageItem{
|
||||
{Type: messageItemVoice, VoiceItem: &voiceItem{Text: "transcribed"}},
|
||||
})
|
||||
if got != "transcribed" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyFromItemList_Quote(t *testing.T) {
|
||||
ref := &refMessage{
|
||||
Title: "t",
|
||||
MessageItem: &messageItem{
|
||||
Type: messageItemText,
|
||||
TextItem: &textItem{Text: "inner"},
|
||||
},
|
||||
}
|
||||
got := bodyFromItemList([]messageItem{
|
||||
{Type: messageItemText, TextItem: &textItem{Text: "reply"}, RefMsg: ref},
|
||||
})
|
||||
want := "[引用: t | inner]\nreply"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitUTF8(t *testing.T) {
|
||||
s := string([]rune{'a', '啊', 'b', '吧', 'c'})
|
||||
parts := splitUTF8(s, 2)
|
||||
if len(parts) != 3 || parts[0] != "a啊" || parts[1] != "b吧" || parts[2] != "c" {
|
||||
t.Fatalf("parts=%#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitUTF8Empty(t *testing.T) {
|
||||
parts := splitUTF8("", maxWeixinChunk)
|
||||
if len(parts) != 1 || parts[0] != "" {
|
||||
t.Fatalf("parts=%#v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaOnlyItems(t *testing.T) {
|
||||
if !mediaOnlyItems([]messageItem{{Type: messageItemImage}}) {
|
||||
t.Fatal("image should be media-only")
|
||||
}
|
||||
if mediaOnlyItems([]messageItem{{Type: messageItemVoice, VoiceItem: &voiceItem{Text: "x"}}}) {
|
||||
t.Fatal("voice with text is not media-only")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectInboundMediaUsesCDNHTTPClient(t *testing.T) {
|
||||
png := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/download" {
|
||||
t.Fatalf("path = %q, want /download", r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("encrypted_query_param") != "image-ref" {
|
||||
t.Fatalf("encrypted_query_param = %q, want image-ref", r.URL.Query().Get("encrypted_query_param"))
|
||||
}
|
||||
_, _ = w.Write(png)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
p := &Platform{
|
||||
cdnBaseURL: server.URL,
|
||||
httpClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
return nil, errors.New("api client should not download media")
|
||||
})},
|
||||
cdnHttpClient: server.Client(),
|
||||
}
|
||||
|
||||
images, files, audio := p.collectInboundMedia(context.Background(), []messageItem{{
|
||||
Type: messageItemImage,
|
||||
ImageItem: &imageItem{
|
||||
Media: &cdnMedia{EncryptQueryParam: "image-ref"},
|
||||
},
|
||||
}})
|
||||
|
||||
if len(images) != 1 {
|
||||
t.Fatalf("images len = %d, want 1", len(images))
|
||||
}
|
||||
if images[0].MimeType != "image/png" {
|
||||
t.Fatalf("mime = %q, want image/png", images[0].MimeType)
|
||||
}
|
||||
if string(images[0].Data) != string(png) {
|
||||
t.Fatalf("image data = %v, want %v", images[0].Data, png)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Fatalf("files len = %d, want 0", len(files))
|
||||
}
|
||||
if audio != nil {
|
||||
t.Fatalf("audio = %#v, want nil", audio)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendMessageResp_JSON(t *testing.T) {
|
||||
var r sendMessageResp
|
||||
if err := json.Unmarshal([]byte(`{"ret":-1,"errcode":100,"errmsg":"rate limited"}`), &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Ret != -1 || r.Errcode != 100 || r.Errmsg != "rate limited" {
|
||||
t.Fatalf("got %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAudioRejectsEmptyAudio(t *testing.T) {
|
||||
p := &Platform{}
|
||||
// resolveReplyContext checks context_token first, so provide one
|
||||
rc := &replyContext{peerUserID: "test", contextToken: "valid-token"}
|
||||
err := p.SendAudio(context.Background(), rc, []byte{}, "wav")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty audio")
|
||||
}
|
||||
if !containsStr(err.Error(), "empty audio") {
|
||||
t.Fatalf("expected 'empty audio' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAudioRejectsInvalidReplyContext(t *testing.T) {
|
||||
p := &Platform{}
|
||||
err := p.SendAudio(context.Background(), "invalid-context", []byte("audio-data"), "wav")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid reply context")
|
||||
}
|
||||
if !containsStr(err.Error(), "invalid reply context") {
|
||||
t.Fatalf("expected 'invalid reply context' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendAudioRejectsNilReplyContext(t *testing.T) {
|
||||
p := &Platform{}
|
||||
err := p.SendAudio(context.Background(), nil, []byte("audio-data"), "wav")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil reply context")
|
||||
}
|
||||
if !containsStr(err.Error(), "invalid reply context") {
|
||||
t.Fatalf("expected 'invalid reply context' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig_RejectsNonZeroErrcode(t *testing.T) {
|
||||
raw := `{"ret":0,"errcode":40001,"errmsg":"invalid token","typing_ticket":""}`
|
||||
var out getConfigResp
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Errcode != 40001 {
|
||||
t.Fatalf("expected errcode 40001, got %d", out.Errcode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig_RejectsNonZeroRet(t *testing.T) {
|
||||
raw := `{"ret":-1,"errcode":0,"errmsg":"internal error","typing_ticket":"tk"}`
|
||||
var out getConfigResp
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Ret != -1 {
|
||||
t.Fatalf("expected ret -1, got %d", out.Ret)
|
||||
}
|
||||
}
|
||||
|
||||
func containsStr(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStrHelper(s, substr))
|
||||
}
|
||||
|
||||
func containsStrHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
|
||||
return f(r)
|
||||
}
|
||||
Reference in New Issue
Block a user