初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+247
View File
@@ -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"
}
+81
View File
@@ -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")
}
}
+279
View File
@@ -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)
}
+165
View File
@@ -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
}
+308
View File
@@ -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)
}
+194
View File
@@ -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)
}
}
+57
View File
@@ -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 ""
}
+168
View File
@@ -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"`
}
+749
View File
@@ -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)
)
+200
View File
@@ -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)
}