feat(channels/weixin_ilink): 微信 iLink 渠道插件(扫码登录+长轮询+游标+context_token+AES-128-ECB 媒体+24h 主动推送窗口)

This commit is contained in:
Aska committed 2026-09-06 06:49:35 +08:00
1 parent 7831b0ea1b
commit 88dc12466d
13 files changed
+2627

No files matched your search

+429
View File
@@ -0,0 +1,429 @@
// Package weixin_ilink implements the WeChat ClawBot iLink channel adapter
// for Cyrene v2 (DevDocs/design/08-渠道接入.md §4). It adapts Tencent's iLink
// Bot API into the normalized internal/channel/adapter contract.
//
// The adapter covers the weixin-ilink surface: scan-code login, HTTP long
// polling with a persistent get_updates_buf cursor, per-peer context_token
// tracking, AES-128-ECB media encrypt/decrypt with CDN download, and the 24h
// proactive-push window. Media CDN references are captured lazily — the poll
// loop never downloads attachments, so a large file cannot stall message
// delivery (design 03 §8, item 6).
//
// All network I/O goes through an injected *http.Client and honors the passed
// context.Context, so tests can substitute an httptest.Server and cancel the
// read loop without leaking goroutines or timers.
package weixin_ilink
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
const (
// source labels every log record emitted by this package so failures can be
// traced back to the owning module (constraint 02 §5).
source = "plugins/channels/weixin_ilink"
// platformName is the Name() value of the adapter.
platformName = "weixin-ilink"
// defaultBaseURL and defaultCDNBaseURL come from the protocol summary.
defaultBaseURL = "https://ilinkai.weixin.qq.com"
defaultCDNBaseURL = "https://novac2c.cdn.weixin.qq.com/c2c"
// defaultLongPollTimeout exceeds the ~35s server hold so the HTTP client
// does not abort a normal long-poll in flight.
defaultLongPollTimeout = 45 * time.Second
// defaultLoginTimeout bounds the scan-code confirmation wait.
defaultLoginTimeout = 8 * time.Minute
// defaultProactiveWindow is what design/08 §4 calls the iLink 24h window.
defaultProactiveWindow = 24 * time.Hour
// defaultMaxMediaSize caps a downloaded attachment at 50 MiB.
defaultMaxMediaSize int64 = 50 << 20
// botType is hard-coded in the official iLink source and is passed
// verbatim to get_bot_qrcode.
botType = 3
// channelVersion is the base_info.channel_version sent on getupdates.
channelVersion = "1.0.2"
// errSessionExpired is the iLink errcode for an expired bot token.
errSessionExpired = -14
)
// Sentinel errors returned by this adapter, usable with errors.Is.
var (
// ErrInvalidConfig is returned when a required config field is empty.
ErrInvalidConfig = errors.New("weixin_ilink: invalid config")
// ErrLoginTimeout is returned when the QR code is not confirmed in time.
ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out")
// ErrSessionExpired is returned when iLink reports errcode=-14.
ErrSessionExpired = errors.New("weixin_ilink: session expired, re-scan required")
// ErrNoContextToken is returned when a send lacks the peer's context_token.
ErrNoContextToken = errors.New("weixin_ilink: no context_token for peer")
// ErrProactiveWindow is returned when a proactive push is outside 24h.
ErrProactiveWindow = errors.New("weixin_ilink: proactive push outside 24h window")
// ErrMediaTooLarge is returned when a download exceeds MaxMediaSize.
ErrMediaTooLarge = errors.New("weixin_ilink: media exceeds size limit")
)
// Config holds the weixin-ilink adapter settings. It is sourced from
// environment/config by the channel layer and can be round-tripped through
// Config() and adapter.DecodeConfig.
type Config struct {
// BaseURL is the iLink API root.
BaseURL string `json:"base_url"`
// CDNBaseURL is the media CDN root used for downloads and uploads.
CDNBaseURL string `json:"cdn_base_url"`
// BotToken is the bearer token obtained from scan login. A non-empty token
// means Connect skips the QR flow.
BotToken string `json:"bot_token"`
// BotID is the ilink_bot_id from the login confirmation.
BotID string `json:"bot_id"`
// OwnerID is the ilink_user_id of the bound owner account.
OwnerID string `json:"owner_id"`
// Cursor is the persisted get_updates_buf.
Cursor string `json:"cursor"`
// LongPollTimeout is the per-request timeout for getupdates.
LongPollTimeout time.Duration `json:"long_poll_timeout"`
// LoginTimeout bounds how long the scan-code flow waits for confirmation.
LoginTimeout time.Duration `json:"login_timeout"`
// LoginInterval is how often the QR status is polled during login.
LoginInterval time.Duration `json:"login_interval"`
// PollInterval is the gap between long polls; zero means poll immediately.
PollInterval time.Duration `json:"poll_interval"`
// ProactiveWindow is how long after the user's last message a proactive
// push is allowed.
ProactiveWindow time.Duration `json:"proactive_window"`
// MaxMediaSize caps a downloaded attachment in bytes.
MaxMediaSize int64 `json:"max_media_size"`
}
// withDefaults fills zero-valued fields with protocol defaults. BotID/OwnerID
// may legitimately be empty, so they are left untouched.
func (c Config) withDefaults() Config {
if c.BaseURL == "" {
c.BaseURL = defaultBaseURL
}
if c.CDNBaseURL == "" {
c.CDNBaseURL = defaultCDNBaseURL
}
if c.LongPollTimeout <= 0 {
c.LongPollTimeout = defaultLongPollTimeout
}
if c.LoginTimeout <= 0 {
c.LoginTimeout = defaultLoginTimeout
}
if c.LoginInterval <= 0 {
c.LoginInterval = loginPollInterval
}
if c.PollInterval < 0 {
c.PollInterval = 0
}
if c.ProactiveWindow <= 0 {
c.ProactiveWindow = defaultProactiveWindow
}
if c.MaxMediaSize <= 0 {
c.MaxMediaSize = defaultMaxMediaSize
}
return c
}
// Adapter is the PlatformAdapter implementation for WeChat ClawBot iLink. It is
// safe for concurrent use: reads and writes of the session flag, cursor, and
// per-peer token/timestamp maps are guarded by a single RWMutex.
type Adapter struct {
cfg Config
httpClient *http.Client
log *slog.Logger
clock func() time.Time
mu sync.RWMutex
connected bool
qrcode string
contexts map[string]string
lastMsgAt map[string]time.Time
handler adapter.MessageHandler
}
// New builds an Adapter. client may be nil to use http.DefaultClient; log may be
// nil to fall back to the project logger at info level. It returns
// ErrInvalidConfig when the base URL is blank after defaults.
func New(cfg Config, client *http.Client, log *slog.Logger) (*Adapter, error) {
cfg = cfg.withDefaults()
if !strings.HasPrefix(cfg.BaseURL, "http://") && !strings.HasPrefix(cfg.BaseURL, "https://") {
return nil, fmt.Errorf("%w: base url must be http(s): %q", ErrInvalidConfig, cfg.BaseURL)
}
if client == nil {
client = http.DefaultClient
}
if log == nil {
log = logger.New(logger.Options{})
}
log = logger.WithSource(log, source)
return &Adapter{
cfg: cfg,
httpClient: client,
log: log,
clock: time.Now,
contexts: map[string]string{},
lastMsgAt: map[string]time.Time{},
}, nil
}
// Name returns the platform identifier "weixin-ilink".
func (a *Adapter) Name() string { return platformName }
// Config returns a snapshot of the current adapter configuration as a plain
// map, including the persisted cursor and login credentials.
func (a *Adapter) Config() map[string]any {
a.mu.RLock()
defer a.mu.RUnlock()
return map[string]any{
"base_url": a.cfg.BaseURL,
"cdn_base_url": a.cfg.CDNBaseURL,
"bot_token": a.cfg.BotToken,
"bot_id": a.cfg.BotID,
"owner_id": a.cfg.OwnerID,
"cursor": a.cfg.Cursor,
"long_poll_timeout": a.cfg.LongPollTimeout,
"login_timeout": a.cfg.LoginTimeout,
"login_interval": a.cfg.LoginInterval,
"poll_interval": a.cfg.PollInterval,
"proactive_window": a.cfg.ProactiveWindow,
"max_media_size": a.cfg.MaxMediaSize,
}
}
// Connect establishes the iLink session. It is idempotent. When a bot_token is
// already configured it is treated as a live session; otherwise it runs the
// scan-code login flow (see login.go).
func (a *Adapter) Connect(ctx context.Context) error {
a.mu.RLock()
connected := a.connected
token := a.cfg.BotToken
a.mu.RUnlock()
if connected && token != "" {
return nil
}
if token == "" {
if err := a.login(ctx); err != nil {
return fmt.Errorf("weixin_ilink: connect: %w", err)
}
}
a.mu.Lock()
a.connected = true
a.mu.Unlock()
a.log.Info("ilink session established", "bot_id", a.cfg.BotID)
return nil
}
// Disconnect tears down the session. It is idempotent.
func (a *Adapter) Disconnect(ctx context.Context) error {
a.mu.Lock()
defer a.mu.Unlock()
if !a.connected {
return nil
}
a.connected = false
a.log.Info("ilink session closed")
return nil
}
// IsConnected reports whether the session is currently live.
func (a *Adapter) IsConnected() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.connected
}
// HealthCheck verifies the session is usable: it must be connected and carry a
// bot_token. It does not probe the network so callers can use it cheaply after
// a reconnect without incurring a request.
func (a *Adapter) HealthCheck(ctx context.Context) error {
a.mu.RLock()
defer a.mu.RUnlock()
if !a.connected || a.cfg.BotToken == "" {
return adapter.ErrNotConnected
}
return nil
}
// Capabilities declares what the adapter supports.
func (a *Adapter) Capabilities() adapter.Capabilities {
caps := adapter.DefaultCapabilities()
caps.Proactive = true
return caps
}
// ListChannels returns the bot account as a single direct channel. iLink group
// membership is not reliably enumerable, so only the bound account is listed.
func (a *Adapter) ListChannels(ctx context.Context) ([]adapter.ChannelInfo, error) {
a.mu.RLock()
botID := a.cfg.BotID
a.mu.RUnlock()
if botID == "" {
botID = "weixin-ilink"
}
return []adapter.ChannelInfo{{
ChannelID: botID,
ChannelType: adapter.ChannelDirect,
ChannelName: "wechat",
}}, nil
}
// SetMessageHandler installs the callback receiving push-style inbound
// messages. A nil fn clears it. When the handler is set, ReadMessages routes
// converted messages to it instead of the emit callback.
func (a *Adapter) SetMessageHandler(fn adapter.MessageHandler) {
a.mu.Lock()
defer a.mu.Unlock()
a.handler = fn
}
func (a *Adapter) handlerLocked() adapter.MessageHandler {
a.mu.RLock()
defer a.mu.RUnlock()
return a.handler
}
// --- concurrency helpers ---
func (a *Adapter) cursor() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg.Cursor
}
func (a *Adapter) setCursor(c string) {
if c == "" {
return
}
a.mu.Lock()
a.cfg.Cursor = c
a.mu.Unlock()
}
// recordInbound refreshes the per-peer context_token and last-message time
// (design/08 §8: every inbound message refreshes the token so proactive sends
// have a usable token within the window).
func (a *Adapter) recordInbound(token string, m adapter.UnifiedMessage) {
peer := m.SenderID
if m.IsGroup() {
peer = m.GroupID
}
if peer == "" {
return
}
a.mu.Lock()
a.contexts[peer] = token
a.lastMsgAt[peer] = m.Timestamp
a.mu.Unlock()
}
// contextFor returns the stored context_token for a peer.
func (a *Adapter) contextFor(peer string) string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.contexts[peer]
}
// recipient resolves the destination peer and group flag for an outbound
// message: group id for group chats, else the sender id, else the channel id.
func recipient(m adapter.UnifiedMessage) (peer string, group bool) {
if m.IsGroup() {
return m.GroupID, true
}
if m.SenderID != "" {
return m.SenderID, false
}
return m.ChannelID, false
}
// --- HTTP plumbing ---
// authorizationHeader decorates req with the iLink common headers: JSON
// content type, the bearer token, the auth type, and a fresh random
// X-WECHAT-UIN replay guard.
func (a *Adapter) authorizationHeader(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
req.Header.Set("X-WECHAT-UIN", randomUIN())
a.mu.RLock()
token := a.cfg.BotToken
a.mu.RUnlock()
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
}
// randomUIN returns a base64-encoded decimal uint32, minted fresh per request
// as iLink's anti-replay value.
func randomUIN() string {
var b [4]byte
_, _ = rand.Read(b[:])
n := binary.BigEndian.Uint32(b[:])
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprint(n)))
}
// doJSON performs an HTTP request and decodes the JSON response into out. It
// closes the response body and wraps network/decode errors. A nil out skips
// decoding (used for PUTs whose body is opaque bytes).
func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Reader, out any) error {
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return fmt.Errorf("weixin_ilink: build %s %s: %w", method, rawURL, err)
}
a.authorizationHeader(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("weixin_ilink: %s %s: %w", method, rawURL, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("weixin_ilink: %s %s: status %d: %s",
method, rawURL, resp.StatusCode, strings.TrimSpace(string(msg)))
}
if out == nil {
if _, err := io.Copy(io.Discard, resp.Body); err != nil {
return fmt.Errorf("weixin_ilink: drain %s %s: %w", method, rawURL, err)
}
return nil
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("weixin_ilink: decode %s %s: %w", method, rawURL, err)
}
return nil
}
// apiURL joins a path with the configured base URL.
func (a *Adapter) apiURL(path string) string {
return strings.TrimRight(a.cfg.BaseURL, "/") + path
}
// cdnURL joins a path with the configured CDN base URL, escaping raw query
// strings that some CDN references carry.
func (a *Adapter) cdnURL(raw string) string {
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
return raw
}
return strings.TrimRight(a.cfg.CDNBaseURL, "/") + "/" + strings.TrimLeft(raw, "/")
}
@@ -0,0 +1,184 @@
package weixin_ilink
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestAdapter_Name_And_DefaultCapabilities(t *testing.T) {
t.Parallel()
ad, err := New(Config{BaseURL: "https://ilinkai.weixin.qq.com"}, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if got := ad.Name(); got != "weixin-ilink" {
t.Fatalf("Name() = %q, want weixin-ilink", got)
}
caps := ad.Capabilities()
if !caps.Proactive {
t.Error("Capabilities().Proactive = false, want true (iLink has a 24h window)")
}
if !caps.Image {
t.Error("Capabilities().Image = false, want true")
}
if got := caps.Normalize().MaxMessageLength; got <= 0 {
t.Fatalf("MaxMessageLength = %d, want > 0", got)
}
}
func TestAdapter_New_RejectsBadBaseURL(t *testing.T) {
t.Parallel()
_, err := New(Config{BaseURL: "not-a-url"}, nil, nil)
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
}
func TestAdapter_Config_ReturnsSnapshot(t *testing.T) {
t.Parallel()
ad, err := New(Config{BaseURL: "https://ilinkai.weixin.qq.com", BotID: "bot-1"}, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
cfg := ad.Config()
if cfg["bot_id"] != "bot-1" {
t.Fatalf("Config()[bot_id] = %v, want bot-1", cfg["bot_id"])
}
if cfg["base_url"] != "https://ilinkai.weixin.qq.com" {
t.Fatalf("Config()[base_url] = %v", cfg["base_url"])
}
// Round-tripping through DecodeConfig must preserve the typed config.
var back Config
if err := adapter.DecodeConfig(cfg, &back); err != nil {
t.Fatalf("DecodeConfig: %v", err)
}
if back.BaseURL != "https://ilinkai.weixin.qq.com" || back.BotID != "bot-1" {
t.Fatalf("decoded config = %+v, want base_url/bot_id preserved", back)
}
}
func TestAdapter_Connect_WithToken_IsIdempotent(t *testing.T) {
t.Parallel()
ad, err := New(Config{BaseURL: "https://ilinkai.weixin.qq.com", BotToken: "tok-1"}, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
if ad.IsConnected() {
t.Fatal("IsConnected() = true before Connect")
}
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if !ad.IsConnected() {
t.Fatal("IsConnected() = false after Connect")
}
// Second call is a no-op success.
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("second Connect: %v", err)
}
}
func TestAdapter_HealthCheck(t *testing.T) {
t.Parallel()
t.Run("disconnected", func(t *testing.T) {
t.Parallel()
ad, _ := New(Config{BaseURL: "https://ilinkai.weixin.qq.com"}, nil, nil)
if err := ad.HealthCheck(context.Background()); !errors.Is(err, adapter.ErrNotConnected) {
t.Fatalf("HealthCheck = %v, want ErrNotConnected", err)
}
})
t.Run("connected_with_token", func(t *testing.T) {
t.Parallel()
ad, _ := New(Config{BaseURL: "https://ilinkai.weixin.qq.com", BotToken: "tok-1"}, nil, nil)
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if err := ad.HealthCheck(context.Background()); err != nil {
t.Fatalf("HealthCheck = %v, want nil", err)
}
})
}
func TestAdapter_Disconnect_IsIdempotent(t *testing.T) {
t.Parallel()
ad, _ := New(Config{BaseURL: "https://ilinkai.weixin.qq.com", BotToken: "tok-1"}, nil, nil)
_ = ad.Connect(context.Background())
if err := ad.Disconnect(context.Background()); err != nil {
t.Fatalf("Disconnect: %v", err)
}
if ad.IsConnected() {
t.Fatal("IsConnected() = true after Disconnect")
}
if err := ad.Disconnect(context.Background()); err != nil {
t.Fatalf("second Disconnect: %v", err)
}
}
func TestAdapter_ListChannels(t *testing.T) {
t.Parallel()
ad, _ := New(Config{BaseURL: "https://ilinkai.weixin.qq.com", BotID: "bot-9"}, nil, nil)
chans, err := ad.ListChannels(context.Background())
if err != nil {
t.Fatalf("ListChannels: %v", err)
}
if len(chans) != 1 {
t.Fatalf("ListChannels() = %d channels, want 1", len(chans))
}
if chans[0].ChannelID != "bot-9" || chans[0].ChannelType != adapter.ChannelDirect {
t.Fatalf("channel = %+v, want {bot-9 direct}", chans[0])
}
}
func TestAdapter_SetMessageHandler_And_RecordInbound(t *testing.T) {
t.Parallel()
ad, _ := New(Config{BaseURL: "https://ilinkai.weixin.qq.com"}, nil, nil)
called := false
ad.SetMessageHandler(func(ctx context.Context, m adapter.UnifiedMessage) { called = true })
if ad.handlerLocked() == nil {
t.Fatal("handlerLocked() = nil after SetMessageHandler")
}
ad.SetMessageHandler(nil)
if ad.handlerLocked() != nil {
t.Fatal("handlerLocked() != nil after clearing")
}
if called {
t.Fatal("handler called before any message")
}
// recordInbound must refresh the per-peer token and last-message time.
ad.recordInbound("tok-abc", adapter.UnifiedMessage{
SenderID: "user-1",
Timestamp: ad.clock(),
})
if got := ad.contextFor("user-1"); got != "tok-abc" {
t.Fatalf("contextFor(user-1) = %q, want tok-abc", got)
}
}
func TestAdapter_Recipient_ResolvesGroupAndSender(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg adapter.UnifiedMessage
wantPeer string
wantGroup bool
}{
{name: "direct sender", msg: adapter.UnifiedMessage{SenderID: "u1"}, wantPeer: "u1"},
{name: "group id", msg: adapter.UnifiedMessage{ChannelType: adapter.ChannelGroup, GroupID: "g1"}, wantPeer: "g1", wantGroup: true},
{name: "default channel id", msg: adapter.UnifiedMessage{ChannelID: "c1"}, wantPeer: "c1"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
peer, group := recipient(tc.msg)
if peer != tc.wantPeer || group != tc.wantGroup {
t.Fatalf("recipient() = (%q,%v), want (%q,%v)", peer, group, tc.wantPeer, tc.wantGroup)
}
})
}
}
+79
View File
@@ -0,0 +1,79 @@
package weixin_ilink
import (
"crypto/aes"
"errors"
"fmt"
)
// ErrBadCiphertext is returned when ciphertext is not a whole number of AES
// blocks or its padding is malformed.
var ErrBadCiphertext = errors.New("weixin_ilink: bad ciphertext or padding")
// pkcs7Pad appends PKCS#7 padding to src so its length becomes a multiple of
// blockSize. Every appended byte holds the pad length, so it self-describes
// and Unpad can strip it unambiguously.
func pkcs7Pad(src []byte, blockSize int) []byte {
n := blockSize - len(src)%blockSize
out := make([]byte, len(src)+n)
copy(out, src)
for i := len(src); i < len(out); i++ {
out[i] = byte(n)
}
return out
}
// pkcs7Unpad strips PKCS#7 padding from src. It returns ErrBadCiphertext when
// the length is not a block multiple or the final pad byte is inconsistent.
func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) {
if len(src) == 0 || len(src)%blockSize != 0 {
return nil, ErrBadCiphertext
}
n := int(src[len(src)-1])
if n < 1 || n > blockSize {
return nil, ErrBadCiphertext
}
for _, b := range src[len(src)-n:] {
if int(b) != n {
return nil, ErrBadCiphertext
}
}
return src[:len(src)-n], nil
}
// aesECBEncrypt encrypts src under AES with the supplied key using ECB mode.
// Go's crypto/cipher package does not ship ECB, so the mode is implemented
// here: src is padded to a block multiple and every block is encrypted
// independently with the same key. ECB is deterministic and not semantically
// secure; it is required here only because the iLink CDN protocol mandates it
// for media payloads.
func aesECBEncrypt(key, src []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: new aes cipher: %w", err)
}
padded := pkcs7Pad(src, block.BlockSize())
out := make([]byte, len(padded))
for i := 0; i < len(padded); i += block.BlockSize() {
block.Encrypt(out[i:i+block.BlockSize()], padded[i:i+block.BlockSize()])
}
return out, nil
}
// aesECBDecrypt decrypts src under AES with the supplied key and strips the
// PKCS#7 padding. ECB has no IV, so the first block decrypts directly with the
// key. It returns ErrBadCiphertext for malformed input.
func aesECBDecrypt(key, src []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: new aes cipher: %w", err)
}
if len(src) == 0 || len(src)%block.BlockSize() != 0 {
return nil, ErrBadCiphertext
}
out := make([]byte, len(src))
for i := 0; i < len(src); i += block.BlockSize() {
block.Decrypt(out[i:i+block.BlockSize()], src[i:i+block.BlockSize()])
}
return pkcs7Unpad(out, block.BlockSize())
}
+109
View File
@@ -0,0 +1,109 @@
package weixin_ilink
import (
"bytes"
"crypto/aes"
"errors"
"strconv"
"testing"
)
func TestAESECB_RoundTrip_AllBlockBoundaries(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{0x42}, aes.BlockSize)
// Exercise len 0 through just past two blocks (padding edge cases).
for n := 0; n <= 2*aes.BlockSize+5; n++ {
n := n
t.Run("len="+strconv.Itoa(n), func(t *testing.T) {
t.Parallel()
pt := bytes.Repeat([]byte{0xAB}, n)
ct, err := aesECBEncrypt(key, pt)
if err != nil {
t.Fatalf("aesECBEncrypt: %v", err)
}
got, err := aesECBDecrypt(key, ct)
if err != nil {
t.Fatalf("aesECBDecrypt: %v", err)
}
if !bytes.Equal(got, pt) {
t.Fatalf("round trip mismatch: got %x want %x", got, pt)
}
})
}
}
func TestAESECB_EncryptPadsToBlockSize(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{1}, aes.BlockSize)
ct, err := aesECBEncrypt(key, []byte("abc")) // 3 bytes -> 16 after pad
if err != nil {
t.Fatalf("aesECBEncrypt: %v", err)
}
if len(ct) != aes.BlockSize {
t.Fatalf("ciphertext = %d bytes, want %d", len(ct), aes.BlockSize)
}
// Full block input -> one pad block appended.
ct2, err := aesECBEncrypt(key, bytes.Repeat([]byte{0}, aes.BlockSize))
if err != nil {
t.Fatalf("aesECBEncrypt: %v", err)
}
if len(ct2) != 2*aes.BlockSize {
t.Fatalf("full-block ciphertext = %d bytes, want %d", len(ct2), 2*aes.BlockSize)
}
}
func TestAESECB_DecryptRejectsBadLength(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{2}, aes.BlockSize)
cases := []int{0, 5, aes.BlockSize - 1, aes.BlockSize + 1}
for _, n := range cases {
n := n
t.Run("len="+strconv.Itoa(n), func(t *testing.T) {
t.Parallel()
_, err := aesECBDecrypt(key, make([]byte, n))
if !errors.Is(err, ErrBadCiphertext) {
t.Fatalf("err = %v, want ErrBadCiphertext", err)
}
})
}
}
func TestAESECB_DecryptRejectsBadPadding(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{3}, aes.BlockSize)
// Valid ciphertext for one block with a correct pad (0x10) ...
good, err := aesECBEncrypt(key, bytes.Repeat([]byte{4}, aes.BlockSize-1))
if err != nil {
t.Fatalf("aesECBEncrypt: %v", err)
}
// ... then tamper the final pad byte so the padding check must fail.
bad := append([]byte(nil), good...)
bad[len(bad)-1] ^= 0xFF
_, err = aesECBDecrypt(key, bad)
if !errors.Is(err, ErrBadCiphertext) {
t.Fatalf("err = %v, want ErrBadCiphertext", err)
}
}
func TestAESECB_KeySizesAccepted(t *testing.T) {
t.Parallel()
// AES-128 requires a 16-byte key. Verify out-of-range keys are rejected.
if _, err := aesECBEncrypt(bytes.Repeat([]byte{1}, aes.BlockSize-1), []byte("x")); err == nil {
t.Fatal("short key: expected error, got nil")
}
if _, err := aesECBEncrypt(bytes.Repeat([]byte{1}, aes.BlockSize+1), []byte("x")); err == nil {
t.Fatal("long key: expected error, got nil")
}
}
func TestPKCS7_UnpadRejectsInvalid(t *testing.T) {
t.Parallel()
// More pad bytes than a block.
if _, err := pkcs7Unpad([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17}, 16); !errors.Is(err, ErrBadCiphertext) {
t.Fatalf("oversize pad: err = %v, want ErrBadCiphertext", err)
}
// Zero pad byte is never valid.
if _, err := pkcs7Unpad([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0}, 16); !errors.Is(err, ErrBadCiphertext) {
t.Fatalf("zero pad: err = %v, want ErrBadCiphertext", err)
}
}
+197
View File
@@ -0,0 +1,197 @@
package weixin_ilink
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// handlerResult is the decision a test's fake handler returns for a request.
type handlerResult struct {
status int
body []byte
ctype string
}
// reqRecorder captures the requests a fake server receives so tests can assert
// that the adapter sent the expected cursor, bearer token, payload, etc.
type reqRecorder struct {
mu sync.Mutex
reqs []recordedReq
}
// recordedReq is a normalized snapshot of an inbound HTTP request.
type recordedReq struct {
method string
path string
query string
body []byte
auth string
authType string
uin string
}
func (r *reqRecorder) add(req recordedReq) {
r.mu.Lock()
defer r.mu.Unlock()
r.reqs = append(r.reqs, req)
}
func (r *reqRecorder) all() []recordedReq {
r.mu.Lock()
defer r.mu.Unlock()
return append([]recordedReq(nil), r.reqs...)
}
func (r *reqRecorder) reset() {
r.mu.Lock()
defer r.mu.Unlock()
r.reqs = nil
}
// find returns requests matching pred.
func (r *reqRecorder) find(pred func(recordedReq) bool) []recordedReq {
r.mu.Lock()
defer r.mu.Unlock()
var out []recordedReq
for _, q := range r.reqs {
if pred(q) {
out = append(out, q)
}
}
return out
}
// bodyJSON decodes a request body into a plain map, returning nil on failure.
func (q recordedReq) bodyJSON() map[string]any {
var m map[string]any
if err := json.Unmarshal(q.body, &m); err != nil {
return nil
}
return m
}
// jsonBody marshals v into a JSON byte slice.
func jsonBody(v any) []byte {
b, _ := json.Marshal(v)
return b
}
// inlineJSON builds a json.RawMessage that inlines when placed in an []any, so
// the fake server can embed pre-serialized message objects into a msgs array.
func inlineJSON(v any) json.RawMessage { return json.RawMessage(jsonBody(v)) }
// encodeToken builds a base64 bot_token whose trailing bytes are a known key, so
// deriveInboundKey yields a deterministic AES key (design/08 §3.3).
func encodeToken(key []byte) string {
return base64.StdEncoding.EncodeToString(append([]byte("prefix!"), key...))
}
// newJSONServer starts an httptest server whose handler records each request
// and answers via fn. The server is closed on test cleanup.
func newJSONServer(t *testing.T, rec *reqRecorder, fn func(r recordedReq) handlerResult) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
req := recordedReq{
method: r.Method,
path: r.URL.Path,
query: r.URL.RawQuery,
body: body,
auth: r.Header.Get("Authorization"),
authType: r.Header.Get("AuthorizationType"),
uin: r.Header.Get("X-WECHAT-UIN"),
}
if rec != nil {
rec.add(req)
}
res := fn(req)
if res.ctype == "" {
res.ctype = "application/json"
}
if res.status == 0 {
res.status = http.StatusOK
}
w.Header().Set("Content-Type", res.ctype)
w.WriteHeader(res.status)
_, _ = w.Write(res.body)
}))
t.Cleanup(srv.Close)
return srv
}
// newAdapter builds an Adapter wired to a test server. The caller controls the
// Config (BaseURL overridden from the server when left blank).
func newAdapter(t *testing.T, cfg Config, srv *httptest.Server) *Adapter {
t.Helper()
if srv != nil && cfg.BaseURL == "" {
cfg.BaseURL = srv.URL
}
var client *http.Client
if srv != nil {
client = srv.Client()
}
ad, err := New(cfg, client, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
return ad
}
// newConnectedAdapter builds a connected Adapter carrying a bot token.
func newConnectedAdapter(t *testing.T, cfg Config, srv *httptest.Server) *Adapter {
t.Helper()
ad := newAdapter(t, cfg, srv)
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
return ad
}
// conformance guards that the production adapter satisfies the contract at
// compile time.
var _ adapter.PlatformAdapter = (*Adapter)(nil)
// waitFor polls fn until it returns true or the timeout elapses, failing the
// test on timeout.
func waitFor(t *testing.T, fn func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if fn() {
return
}
time.Sleep(2 * time.Millisecond)
}
t.Fatal("condition not met within timeout")
}
// getupdatesReqs returns the recorded getupdates requests.
func getupdatesReqs(rec *reqRecorder) []recordedReq {
return rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getupdates" })
}
// onceUpdates serves first (a raw getupdates body) exactly once, then empty
// polls. Read-loop tests use it so a repeated message is not double-emitted.
func onceUpdates(t *testing.T, rec *reqRecorder, first []byte) *httptest.Server {
t.Helper()
calls := 0
return newJSONServer(t, rec, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
calls++
if calls == 1 {
return handlerResult{body: first}
}
return handlerResult{body: jsonBody(map[string]any{"msgs": []any{}, "get_updates_buf": "c2"})}
})
}
+109
View File
@@ -0,0 +1,109 @@
package weixin_ilink
import (
"context"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
)
// loginPollInterval is the default how often Connect polls the QR confirmation
// status. It is the documented ~1s cadence; the Config.LoginInterval field
// overrides it (used by tests to keep the flow short).
const loginPollInterval = 1 * time.Second
// qrcodeResp is the get_bot_qrcode response.
type qrcodeResp struct {
Qrcode string `json:"qrcode"`
QrcodeImgContent string `json:"qrcode_img_content"`
}
// qrcodeStatusResp is the get_qrcode_status response. The token and ids are
// only populated once status is "confirmed".
type qrcodeStatusResp struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
BotID string `json:"ilink_bot_id"`
UserID string `json:"ilink_user_id"`
BaseURL string `json:"baseurl"`
}
// login runs the one-time scan-code binding (design/08 §4): request a QR code,
// then poll get_qrcode_status until the user confirms. On confirmation it
// persists bot_token, bot_id, owner_id and any returned baseurl, so later
// requests carry the Bearer token.
func (a *Adapter) login(ctx context.Context) error {
loginCtx, cancel := context.WithTimeout(ctx, a.cfg.LoginTimeout)
defer cancel()
qr, err := a.fetchQRCode(loginCtx)
if err != nil {
if loginCtx.Err() != nil {
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
}
return fmt.Errorf("weixin_ilink: fetch qrcode: %w", err)
}
a.mu.Lock()
a.qrcode = qr.Qrcode
a.mu.Unlock()
// Log only the renderable URL, never the raw qrcode (it is a secret).
a.log.Info("scan the qr code to bind the bot", "url", qr.QrcodeImgContent)
ticker := time.NewTicker(a.cfg.LoginInterval)
defer ticker.Stop()
for {
status, err := a.pollQRStatus(loginCtx, qr.Qrcode)
if err != nil {
// A deadline/cancel during the poll means the overall window
// elapsed; surface ErrLoginTimeout rather than the raw transport
// error.
if loginCtx.Err() != nil {
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
}
return fmt.Errorf("weixin_ilink: poll qr status: %w", err)
}
if status.Status == "confirmed" && status.BotToken != "" {
a.mu.Lock()
a.cfg.BotToken = status.BotToken
a.cfg.BotID = status.BotID
a.cfg.OwnerID = status.UserID
if status.BaseURL != "" {
a.cfg.BaseURL = status.BaseURL
}
a.mu.Unlock()
return nil
}
select {
case <-loginCtx.Done():
return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err())
case <-ticker.C:
}
}
}
// fetchQRCode requests a new bot login QR code.
func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) {
path := a.apiURL("/ilink/bot/get_bot_qrcode?bot_type=" + strconv.Itoa(botType))
var out qrcodeResp
if err := a.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil {
return qrcodeResp{}, err
}
if out.Qrcode == "" {
return qrcodeResp{}, fmt.Errorf("weixin_ilink: get_bot_qrcode returned empty qrcode")
}
return out, nil
}
// pollQRStatus checks whether a QR code has been confirmed.
func (a *Adapter) pollQRStatus(ctx context.Context, qrcode string) (qrcodeStatusResp, error) {
path := a.apiURL("/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode))
var out qrcodeStatusResp
if err := a.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil {
return qrcodeStatusResp{}, err
}
return out, nil
}
+146
View File
@@ -0,0 +1,146 @@
package weixin_ilink
import (
"context"
"errors"
"testing"
"time"
)
// TestConnect_ScanLogin_StatusTransition drives the QR status flow
// wait → scaned → confirmed and asserts the credentials are stored.
func TestConnect_ScanLogin_StatusTransition(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
statusCalls := 0
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch req.path {
case "/ilink/bot/get_bot_qrcode":
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-1", "qrcode_img_content": "https://img/qr",
})}
case "/ilink/bot/get_qrcode_status":
statusCalls++
switch statusCalls {
case 1:
return handlerResult{body: jsonBody(map[string]any{"status": "wait"})}
case 2:
return handlerResult{body: jsonBody(map[string]any{"status": "scaned"})}
default:
return handlerResult{body: jsonBody(map[string]any{
"status": "confirmed",
"bot_token": "tok-x",
"ilink_bot_id": "bot-x",
"ilink_user_id": "o-1",
"baseurl": "https://ilinkai.weixin.qq.com",
})}
}
}
return handlerResult{status: 404}
})
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 5 * time.Second}
ad := newAdapter(t, cfg, srv)
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if !ad.IsConnected() {
t.Fatal("IsConnected() = false after scan login")
}
if got := ad.Config()["bot_token"]; got != "tok-x" {
t.Fatalf("bot_token = %v, want tok-x", got)
}
if got := ad.Config()["bot_id"]; got != "bot-x" {
t.Fatalf("bot_id = %v, want bot-x", got)
}
reqs := rec.all()
if len(reqs) < 3 {
t.Fatalf("expected >=3 requests, got %d", len(reqs))
}
// Confirm the qrcode id is echoed in the status poll query.
statusReqs := rec.find(func(q recordedReq) bool {
return q.path == "/ilink/bot/get_qrcode_status"
})
if len(statusReqs) == 0 {
t.Fatal("no get_qrcode_status requests recorded")
}
if len(statusReqs) > 0 {
for i, q := range statusReqs {
if q.query != "qrcode=qr-1" {
t.Fatalf("status poll #%d query = %q, want qrcode=qr-1", i, q.query)
}
if q.authType != "ilink_bot_token" {
t.Fatalf("status poll #%d AuthorizationType = %q, want ilink_bot_token", i, q.authType)
}
}
}
}
// TestConnect_ScanLogin_Timeout verifies the login aborts with ErrLoginTimeout
// when the QR is never confirmed.
func TestConnect_ScanLogin_Timeout(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path == "/ilink/bot/get_bot_qrcode" {
return handlerResult{body: jsonBody(map[string]any{"qrcode": "qr-t"})}
}
return handlerResult{body: jsonBody(map[string]any{"status": "wait"})}
})
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 25 * time.Millisecond}
ad := newAdapter(t, cfg, srv)
err := ad.Connect(context.Background())
if !errors.Is(err, ErrLoginTimeout) {
t.Fatalf("err = %v, want ErrLoginTimeout", err)
}
if ad.IsConnected() {
t.Fatal("IsConnected() = true after failed login")
}
}
// TestConnect_ScanLogin_EmptyQRCode verifies a qrcode-less response fails.
func TestConnect_ScanLogin_EmptyQRCode(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path == "/ilink/bot/get_bot_qrcode" {
return handlerResult{body: jsonBody(map[string]any{"qrcode": ""})}
}
return handlerResult{status: 404}
})
ad := newAdapter(t, Config{BaseURL: srv.URL}, srv)
if err := ad.Connect(context.Background()); err == nil {
t.Fatal("Connect() = nil, want error for empty qrcode")
}
}
// TestConnect_ScanLogin_ServerError verifies a non-2xx qrcode response fails.
func TestConnect_ScanLogin_ServerError(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
return handlerResult{status: 500, body: []byte("boom")}
})
ad := newAdapter(t, Config{BaseURL: srv.URL}, srv)
if err := ad.Connect(context.Background()); err == nil {
t.Fatal("Connect() = nil, want error on 500")
}
}
// TestConnect_WithToken_SkipsQR verifies Connect does not touch the network
// when a bot token is already configured.
func TestConnect_WithToken_SkipsQR(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
return handlerResult{status: 500, body: []byte("should not be called")}
})
ad := newAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok-pre"}, srv)
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect: %v", err)
}
if got := rec.all(); len(got) != 0 {
t.Fatalf("expected no network calls, got %d", len(got))
}
if !ad.IsConnected() {
t.Fatal("IsConnected() = false with a pre-set token")
}
}
+96
View File
@@ -0,0 +1,96 @@
package weixin_ilink
import (
"context"
"crypto/aes"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// ErrKeyTooShort is returned when the inbound derivation cannot yield an AES
// key of the required block size.
var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block")
// deriveInboundKey derives the AES-128 key for an inbound media payload. Per
// design/08 §3.3 the key is extracted from the bot_token: base64-decode the
// token and take the trailing 16 bytes. This is marked ⚠️ in the reference, so
// DownloadMediaWithKey lets callers supply an explicit key when the platform
// carries one per item instead.
func deriveInboundKey(botToken string) ([]byte, error) {
token := strings.TrimSpace(botToken)
if token == "" {
return nil, ErrInvalidConfig
}
b, err := base64.StdEncoding.DecodeString(token)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: decode bot_token for key: %w", err)
}
if len(b) < aes.BlockSize {
return nil, ErrKeyTooShort
}
return b[len(b)-aes.BlockSize:], nil
}
// DownloadMedia downloads an inbound CDN reference and returns the decrypted
// plaintext using the key derived from the configured bot_token. It fails with
// ErrMediaTooLarge when the ciphertext exceeds MaxMediaSize.
func (a *Adapter) DownloadMedia(ctx context.Context, cdnURL string) ([]byte, error) {
key, err := deriveInboundKey(a.cfg.BotToken)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err)
}
return a.downloadWithKey(ctx, cdnURL, key)
}
// DownloadMediaWithKey is DownloadMedia with an explicit per-item AES key,
// used when the protocol delivers the key alongside the media reference.
func (a *Adapter) DownloadMediaWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
if len(key) != aes.BlockSize {
return nil, fmt.Errorf("weixin_ilink: key must be %d bytes: %w", aes.BlockSize, ErrKeyTooShort)
}
return a.downloadWithKey(ctx, cdnURL, key)
}
// downloadWithKey fetches the CDN bytes and removes the AES-ECB layer.
func (a *Adapter) downloadWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
ciphertext, err := a.fetchBody(ctx, a.cdnURL(cdnURL))
if err != nil {
return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err)
}
plaintext, err := aesECBDecrypt(key, ciphertext)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err)
}
return plaintext, nil
}
// fetchBody GETs rawURL and returns the body, refusing anything over
// MaxMediaSize so a single huge file cannot exhaust memory.
func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: build media request: %w", err)
}
a.authorizationHeader(req)
resp, err := a.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: download media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("weixin_ilink: download media: status %d", resp.StatusCode)
}
limit := a.cfg.MaxMediaSize + 1
data, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return nil, fmt.Errorf("weixin_ilink: read media: %w", err)
}
if int64(len(data)) > a.cfg.MaxMediaSize {
return nil, ErrMediaTooLarge
}
return data, nil
}
+145
View File
@@ -0,0 +1,145 @@
package weixin_ilink
import (
"bytes"
"context"
"crypto/aes"
"errors"
"testing"
)
func TestDeriveInboundKey(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{0x7F}, aes.BlockSize)
token := encodeToken(key)
got, err := deriveInboundKey(token)
if err != nil {
t.Fatalf("deriveInboundKey: %v", err)
}
if !bytes.Equal(got, key) {
t.Fatalf("key = %x, want %x", got, key)
}
}
func TestDeriveInboundKey_Errors(t *testing.T) {
t.Parallel()
t.Run("empty token", func(t *testing.T) {
t.Parallel()
if _, err := deriveInboundKey(""); !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("err = %v, want ErrInvalidConfig", err)
}
})
t.Run("not base64", func(t *testing.T) {
t.Parallel()
if _, err := deriveInboundKey("@not-base64@"); err == nil {
t.Fatal("err = nil, want decode error")
}
})
t.Run("token too short", func(t *testing.T) {
t.Parallel()
if _, err := deriveInboundKey(encodeToken([]byte("short"))); !errors.Is(err, ErrKeyTooShort) {
t.Fatalf("err = %v, want ErrKeyTooShort", err)
}
})
}
func TestDownloadMedia_DecryptsFromTokenDerivedKey(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{0x11}, aes.BlockSize)
plain := []byte("the quick brown fox jumps over the lazy dog")
enc, err := aesECBEncrypt(key, plain)
if err != nil {
t.Fatalf("aesECBEncrypt: %v", err)
}
token := encodeToken(key)
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path == "/media/1" {
return handlerResult{ctype: "application/octet-stream", body: enc}
}
return handlerResult{status: 404}
})
ad := newAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: token}, srv)
got, err := ad.DownloadMedia(context.Background(), srv.URL+"/media/1")
if err != nil {
t.Fatalf("DownloadMedia: %v", err)
}
if !bytes.Equal(got, plain) {
t.Fatalf("decrypted = %q, want %q", got, plain)
}
}
func TestDownloadMediaWithKey_ExplicitKey(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{0x22}, aes.BlockSize)
plain := []byte("explicit key path")
enc, err := aesECBEncrypt(key, plain)
if err != nil {
t.Fatalf("aesECBEncrypt: %v", err)
}
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path == "/media/2" {
return handlerResult{ctype: "application/octet-stream", body: enc}
}
return handlerResult{status: 404}
})
ad := newAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "unused"}, srv)
got, err := ad.DownloadMediaWithKey(context.Background(), srv.URL+"/media/2", key)
if err != nil {
t.Fatalf("DownloadMediaWithKey: %v", err)
}
if !bytes.Equal(got, plain) {
t.Fatalf("decrypted = %q, want %q", got, plain)
}
}
func TestDownloadMedia_RejectsOversize(t *testing.T) {
t.Parallel()
big := bytes.Repeat([]byte{0x01}, 256)
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path == "/media/big" {
return handlerResult{ctype: "application/octet-stream", body: big}
}
return handlerResult{status: 404}
})
ad := newAdapter(t, Config{
BaseURL: srv.URL, CDNBaseURL: srv.URL,
BotToken: encodeToken(bytes.Repeat([]byte{1}, aes.BlockSize)),
MaxMediaSize: 64,
}, srv)
_, err := ad.DownloadMedia(context.Background(), srv.URL+"/media/big")
if !errors.Is(err, ErrMediaTooLarge) {
t.Fatalf("err = %v, want ErrMediaTooLarge", err)
}
}
func TestDownloadMedia_RejectsDecryptFailure(t *testing.T) {
t.Parallel()
// Serve bytes that are not a valid AES block sequence.
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path == "/media/bad" {
return handlerResult{ctype: "application/octet-stream", body: []byte("not-a-block")}
}
return handlerResult{status: 404}
})
ad := newAdapter(t, Config{
BaseURL: srv.URL, CDNBaseURL: srv.URL,
BotToken: encodeToken(bytes.Repeat([]byte{1}, aes.BlockSize)),
}, srv)
if _, err := ad.DownloadMedia(context.Background(), srv.URL+"/media/bad"); err == nil {
t.Fatal("err = nil, want decrypt failure")
}
}
func TestCDNURL_JoinsRelativeAndPassesAbsolute(t *testing.T) {
t.Parallel()
ad, _ := New(Config{BaseURL: "https://api.example.com", CDNBaseURL: "https://cdn.example.com/c2c"}, nil, nil)
if got := ad.cdnURL("media/1.jpg"); got != "https://cdn.example.com/c2c/media/1.jpg" {
t.Fatalf("cdnURL(relative) = %q", got)
}
if got := ad.cdnURL("https://other.example.com/x"); got != "https://other.example.com/x" {
t.Fatalf("cdnURL(absolute) = %q", got)
}
}
+378
View File
@@ -0,0 +1,378 @@
package weixin_ilink
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
const (
// msgTypeUser marks an inbound user message (bot messages are type 2).
msgTypeUser = 1
// item type codes from the iLink reference (design 03 §1).
itemText = 1
itemImage = 2
itemVoice = 3
itemFile = 4
itemVideo = 5
// retryFastCount is how many consecutive failures use the fast retry.
retryFastCount = 3
// retryFastDelay is the delay before the first failures.
retryFastDelay = 2 * time.Second
// retrySlowDelay is the backoff after retryFastCount consecutive failures.
retrySlowDelay = 30 * time.Second
)
// getupdatesResp is the getupdates (long-poll) response envelope.
type getupdatesResp struct {
// Ret is the top-level business result; non-zero is a transient error.
Ret int `json:"ret"`
// Errcode is the platform error code; -14 means the session expired.
Errcode int `json:"errcode"`
// Msgs holds the raw inbound messages delivered in this poll.
Msgs []json.RawMessage `json:"msgs"`
// GetUpdatesBuf is the next Cursor to echo back.
GetUpdatesBuf string `json:"get_updates_buf"`
// LongPollTimeoutMs is the server's suggested hold time.
LongPollTimeoutMs int `json:"longpolling_timeout_ms"`
}
// rawMsg is the parsed common fields of an inbound WeixinMessage. iLink media
// field names are only partially public, so item_list entries are decoded
// defensively in parseItem.
type rawMsg struct {
ClientID string `json:"client_id"`
FromUserID string `json:"from_user_id"`
ToUserID string `json:"to_user_id"`
MessageType int `json:"message_type"`
MessageState int `json:"message_state"`
ContextToken string `json:"context_token"`
GroupID string `json:"group_id"`
RoomID string `json:"room_id"`
ItemList []json.RawMessage `json:"item_list"`
}
// ReadMessages runs the long-poll read loop until ctx is canceled. Each
// converted inbound message is delivered to the handler installed via
// SetMessageHandler when one is present, otherwise to emit. The loop is
// cooperative with ctx: a canceled context returns immediately and no timers
// outlive it.
func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMessage)) {
a.mu.RLock()
connected := a.connected
a.mu.RUnlock()
if !connected {
a.log.Warn("read loop started while disconnected")
return
}
failures := 0
for {
resp, err := a.fetchUpdates(ctx)
if err != nil {
if ctx.Err() != nil {
return
}
// A long-poll timeout is the normal "no messages yet" path.
if errors.Is(err, context.DeadlineExceeded) {
continue
}
failures++
a.log.Warn("getupdates failed", "err", err, "failures", failures)
if !a.wait(ctx, a.retryDelay(failures)) {
return
}
continue
}
failures = 0
if resp.Errcode == errSessionExpired {
a.log.Warn("session expired (errcode=-14); stop polling, re-scan required")
a.mu.Lock()
a.connected = false
a.mu.Unlock()
return
}
if resp.Ret != 0 {
failures++
a.log.Warn("getupdates business error", "ret", resp.Ret)
if !a.wait(ctx, a.retryDelay(failures)) {
return
}
continue
}
a.setCursor(resp.GetUpdatesBuf)
for _, raw := range resp.Msgs {
token, m := a.convertMessage(raw)
if m == nil {
continue
}
a.recordInbound(token, *m)
a.deliver(ctx, *m, emit)
}
if !a.wait(ctx, a.cfg.PollInterval) {
return
}
}
}
// deliver routes a normalized message to the handler when installed, else to
// the emit callback (design/08 §1: push-style vs pull-style delivery).
func (a *Adapter) deliver(ctx context.Context, m adapter.UnifiedMessage, emit func(adapter.UnifiedMessage)) {
if h := a.handlerLocked(); h != nil {
h(ctx, m)
return
}
if emit != nil {
emit(m)
}
}
// fetchUpdates performs one long poll. The request context is bounded by
// LongPollTimeout if the caller has not already set a tighter deadline.
func (a *Adapter) fetchUpdates(ctx context.Context) (*getupdatesResp, error) {
reqCtx, cancel := context.WithTimeout(ctx, a.cfg.LongPollTimeout)
defer cancel()
body, err := json.Marshal(map[string]any{
"get_updates_buf": a.cursor(),
"base_info": map[string]string{"channel_version": channelVersion},
})
if err != nil {
return nil, fmt.Errorf("weixin_ilink: encode getupdates: %w", err)
}
var out getupdatesResp
if err := a.doJSON(reqCtx, http.MethodPost, a.apiURL("/ilink/bot/getupdates"),
bytes.NewReader(body), &out); err != nil {
return nil, fmt.Errorf("weixin_ilink: getupdates: %w", err)
}
return &out, nil
}
// wait sleeps for d or returns false when ctx is canceled. A non-positive
// delay returns immediately.
func (a *Adapter) wait(ctx context.Context, d time.Duration) bool {
if d <= 0 {
return true
}
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// retryDelay grows from the fast retry to the slow backoff after enough
// consecutive failures (design/08 §5.4).
func (a *Adapter) retryDelay(failures int) time.Duration {
if failures <= retryFastCount {
return retryFastDelay
}
return retrySlowDelay
}
// convertMessage maps a raw iLink message to a UnifiedMessage and returns the
// raw context_token so the caller can track it per peer. It returns nil when
// the message is not an inbound user message or cannot be parsed.
func (a *Adapter) convertMessage(raw json.RawMessage) (string, *adapter.UnifiedMessage) {
var rm rawMsg
if err := json.Unmarshal(raw, &rm); err != nil {
a.log.Warn("decode inbound message", "err", err)
return "", nil
}
if rm.MessageType != msgTypeUser {
return "", nil
}
group := resolveGroup(&rm)
m := &adapter.UnifiedMessage{
Platform: platformName,
SenderID: rm.FromUserID,
SenderName: rm.FromUserID,
MessageID: rm.ClientID,
Timestamp: a.clock(),
RawData: raw,
}
if group != "" {
m.ChannelType = adapter.ChannelGroup
m.GroupID = group
m.ChannelID = group
} else {
m.ChannelType = adapter.ChannelDirect
m.ChannelID = rm.FromUserID
}
m.Sender = adapter.Actor{
Role: adapter.RoleUnknown,
UserID: rm.FromUserID,
DisplayName: rm.FromUserID,
IsGroup: group != "",
GroupID: group,
}
m.Content, m.ContentType, m.Media = a.convertItems(rm.ItemList)
return rm.ContextToken, m
}
// convertItems drains the item list into text, a primary content type and a
// media slice. Text from text items is concatenated; voice items contribute
// their ASR transcript; image/file/video items are attached lazily as CDN
// references (never downloaded during the poll).
func (a *Adapter) convertItems(rawItems []json.RawMessage) (string, adapter.ContentType, []adapter.Media) {
var text strings.Builder
var media []adapter.Media
firstMedia := adapter.ContentType("")
for _, raw := range rawItems {
item := parseItem(raw)
switch item.kind {
case itemText:
text.WriteString(item.text)
case itemVoice:
text.WriteString(item.text)
media = append(media, item.media)
firstMedia = adapter.ContentVoice
case itemImage:
media = append(media, item.media)
firstMedia = adapter.ContentImage
case itemFile, itemVideo:
media = append(media, item.media)
firstMedia = adapter.ContentFile
}
}
content := strings.TrimRight(text.String(), "\n")
if firstMedia != "" {
return content, firstMedia, media
}
return content, adapter.ContentText, nil
}
// resolveGroup returns the group identifier when the message carries one,
// otherwise "" (direct chat). WeChat group signalling is ⚠️ per design/08 §4,
// so several candidate fields are checked defensively.
func resolveGroup(rm *rawMsg) string {
if rm.GroupID != "" {
return rm.GroupID
}
return rm.RoomID
}
// parsedItem is a normalized view of a single native item_list entry.
type parsedItem struct {
kind int
text string
media adapter.Media
}
// parseItem decodes one item_list entry defensively. iLink media field names
// are not fully public, so a generic map is scanned for the CDN reference and
// metadata with several fallback keys.
func parseItem(raw json.RawMessage) parsedItem {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
return parsedItem{}
}
kind := intOf(m["type"])
switch kind {
case itemText:
return parsedItem{kind: itemText, text: textOf(m, "text_item")}
case itemVoice:
return parsedItem{kind: itemVoice, text: textOf(m, "voice_item"), media: mediaOf(m, adapter.MediaVoice)}
case itemImage:
return parsedItem{kind: itemImage, media: mediaOf(m, adapter.MediaImage)}
case itemFile:
return parsedItem{kind: itemFile, media: mediaOf(m, adapter.MediaFile)}
case itemVideo:
return parsedItem{kind: itemVideo, media: mediaOf(m, adapter.MediaVideo)}
default:
return parsedItem{kind: kind}
}
}
// textOf reads the text payload from the named nested sub-object, falling back
// to a flat "text" key.
func textOf(m map[string]any, sub string) string {
if inner, ok := m[sub].(map[string]any); ok {
if s, ok := inner["text"].(string); ok {
return s
}
}
if s, ok := m["text"].(string); ok {
return s
}
return ""
}
// mediaOf builds an adapter.Media from a media item, resolving the CDN URL and
// metadata from the flat item or a nested sub-object.
func mediaOf(m map[string]any, kind adapter.MediaKind) adapter.Media {
res := adapter.Media{Kind: kind}
res.URL = firstString(m, "url", "cdn_url", "download_url", "file_url", "cdn_ref")
res.Name = firstString(m, "file_name", "name")
res.Size = firstInt64(m, "size", "file_size")
res.MIMEType = firstString(m, "mime", "mime_type", "content_type")
if res.URL != "" {
return res
}
// Bounded scan of the common nested sub-objects for the CDN reference.
for _, sub := range []string{"image_item", "file_item", "video_item", "media_item", "cdn_item"} {
inner, ok := m[sub].(map[string]any)
if !ok {
continue
}
res.URL = firstString(inner, "url", "cdn_url", "download_url", "file_url", "cdn_ref")
if res.Name == "" {
res.Name = firstString(inner, "file_name", "name")
}
if res.Size == 0 {
res.Size = firstInt64(inner, "size", "file_size")
}
if res.MIMEType == "" {
res.MIMEType = firstString(inner, "mime", "mime_type", "content_type")
}
if res.URL != "" {
return res
}
}
return res
}
// intOf coerces a JSON number to int, returning 0 for missing or non-numeric.
func intOf(v any) int {
f, ok := v.(float64)
if !ok {
return 0
}
return int(f)
}
// firstString returns the first non-empty string among keys.
func firstString(m map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := m[k].(string); ok && s != "" {
return s
}
}
return ""
}
// firstInt64 returns the first non-zero integer among keys.
func firstInt64(m map[string]any, keys ...string) int64 {
for _, k := range keys {
if f, ok := m[k].(float64); ok && f != 0 {
return int64(f)
}
}
return 0
}
+279
View File
@@ -0,0 +1,279 @@
package weixin_ilink
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// msgCollector collects emitted messages under a mutex.
type msgCollector struct {
mu sync.Mutex
msgs []adapter.UnifiedMessage
}
func (c *msgCollector) add(m adapter.UnifiedMessage) {
c.mu.Lock()
defer c.mu.Unlock()
c.msgs = append(c.msgs, m)
}
func (c *msgCollector) snapshot() []adapter.UnifiedMessage {
c.mu.Lock()
defer c.mu.Unlock()
return append([]adapter.UnifiedMessage(nil), c.msgs...)
}
func (c *msgCollector) count() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.msgs)
}
func textItem(t string) map[string]any {
return map[string]any{"type": 1, "text_item": map[string]any{"text": t}}
}
func TestReadMessages_LongPoll_CursorAndContextToken(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
msg1, msg2 := longPollMessages()
updatesCalls := 0
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
updatesCalls++
if updatesCalls == 1 {
return handlerResult{body: jsonBody(map[string]any{
"msgs": []any{msg1, msg2}, "get_updates_buf": "cursor-1",
})}
}
return handlerResult{body: jsonBody(map[string]any{
"msgs": []any{}, "get_updates_buf": "cursor-2",
})}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
col := &msgCollector{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(ctx, col.add)
}()
waitFor(t, func() bool { return col.count() >= 2 })
// Ensure the second long poll has run so the cursor echo is observable.
waitFor(t, func() bool { return len(getupdatesReqs(rec)) >= 2 })
cancel()
waitDone(t, done)
assertDecodedMessages(t, col.snapshot())
assertCursorAndContext(t, ad, rec)
}
func longPollMessages() (json.RawMessage, json.RawMessage) {
msg1 := inlineJSON(map[string]any{
"client_id": "m-1", "from_user_id": "user-1", "to_user_id": "bot-1",
"message_type": 1, "message_state": 2, "context_token": "tok-a",
"item_list": []any{textItem("hi")},
})
msg2 := inlineJSON(map[string]any{
"client_id": "m-2", "from_user_id": "user-2", "to_user_id": "bot-1",
"message_type": 1, "message_state": 2, "context_token": "tok-b",
"item_list": []any{map[string]any{"type": 2, "image_item": map[string]any{"url": "https://cdn/x.jpg"}}},
})
return msg1, msg2
}
func assertDecodedMessages(t *testing.T, msgs []adapter.UnifiedMessage) {
t.Helper()
if len(msgs) != 2 {
t.Fatalf("emitted %d messages, want 2", len(msgs))
}
m := msgs[0]
if m.Platform != "weixin-ilink" || m.SenderID != "user-1" || m.ChannelID != "user-1" {
t.Fatalf("msg0 identity = platform %q sender %q channel %q", m.Platform, m.SenderID, m.ChannelID)
}
if m.ChannelType != adapter.ChannelDirect || m.ContentType != adapter.ContentText || m.Content != "hi" {
t.Fatalf("msg0 = channel %q content %q type %q", m.ChannelType, m.Content, m.ContentType)
}
m2 := msgs[1]
if m2.ContentType != adapter.ContentImage || len(m2.Media) != 1 {
t.Fatalf("msg1 = type %q media %d", m2.ContentType, len(m2.Media))
}
if m2.Media[0].URL != "https://cdn/x.jpg" || m2.Media[0].Kind != adapter.MediaImage {
t.Fatalf("msg1 media = %+v", m2.Media[0])
}
if m2.Timestamp.IsZero() {
t.Fatal("msg1 Timestamp is zero")
}
}
func assertCursorAndContext(t *testing.T, ad *Adapter, rec *reqRecorder) {
t.Helper()
if got := ad.contextFor("user-1"); got != "tok-a" {
t.Fatalf("contextFor(user-1) = %q, want tok-a", got)
}
if got := ad.contextFor("user-2"); got != "tok-b" {
t.Fatalf("contextFor(user-2) = %q, want tok-b", got)
}
ups := getupdatesReqs(rec)
if len(ups) < 2 {
t.Fatalf("getupdates requests = %d, want >=2", len(ups))
}
body2 := ups[1].bodyJSON()
if body2 == nil {
t.Fatal("second getupdates body is not JSON")
}
if got := body2["get_updates_buf"]; got != "cursor-1" {
t.Fatalf("second poll get_updates_buf = %v, want cursor-1", got)
}
if ups[0].auth != "Bearer tok" {
t.Fatalf("poll Authorization = %q, want Bearer tok", ups[0].auth)
}
}
// waitDone waits for the read loop goroutine to exit or fails after a timeout.
func waitDone(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("ReadMessages did not return after ctx cancel")
}
}
func TestReadMessages_SkipsNonUserMessages(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := onceUpdates(t, rec, jsonBody(map[string]any{
"msgs": []any{
inlineJSON(map[string]any{"message_type": 1, "from_user_id": "u-1", "item_list": []any{textItem("yo")}}),
inlineJSON(map[string]any{"message_type": 2, "from_user_id": "bot-1", "item_list": []any{textItem("echo")}}),
},
"get_updates_buf": "cursor-1",
}))
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
col := &msgCollector{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(ctx, col.add)
}()
waitFor(t, func() bool { return col.count() >= 1 })
cancel()
<-done
if got := col.count(); got != 1 {
t.Fatalf("emitted %d messages, want 1 (bot echo skipped)", got)
}
if msgs := col.snapshot(); msgs[0].Content != "yo" {
t.Fatalf("content = %q, want yo", msgs[0].Content)
}
}
func TestReadMessages_GroupMessage(t *testing.T) {
t.Parallel()
srv := onceUpdates(t, nil, jsonBody(map[string]any{
"msgs": []any{inlineJSON(map[string]any{
"message_type": 1, "from_user_id": "u-1", "group_id": "g-1",
"context_token": "tok-g", "item_list": []any{textItem("group msg")},
})},
"get_updates_buf": "c1",
}))
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
col := &msgCollector{}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(ctx, col.add)
}()
waitFor(t, func() bool { return col.count() >= 1 })
cancel()
<-done
m := col.snapshot()[0]
if m.ChannelType != adapter.ChannelGroup || m.GroupID != "g-1" || m.ChannelID != "g-1" {
t.Fatalf("group message = channel %q group %q channelid %q", m.ChannelType, m.GroupID, m.ChannelID)
}
if !m.Sender.IsGroup || m.Sender.GroupID != "g-1" {
t.Fatalf("sender group flags = %+v", m.Sender)
}
if m.IsGroup() != true {
t.Fatal("IsGroup() = false, want true")
}
}
func TestReadMessages_SessionExpired(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
return handlerResult{body: jsonBody(map[string]any{"errcode": -14})}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(ctx, func(m adapter.UnifiedMessage) {})
}()
select {
case <-done:
case <-time.After(2 * time.Second):
cancel()
t.Fatal("ReadMessages did not stop on session expired")
}
cancel()
if ad.IsConnected() {
t.Fatal("IsConnected() = true after session expired")
}
}
func TestReadMessages_HandlerDelivery(t *testing.T) {
t.Parallel()
srv := onceUpdates(t, nil, jsonBody(map[string]any{
"msgs": []any{inlineJSON(map[string]any{
"message_type": 1, "from_user_id": "u-1", "context_token": "tok-h",
"item_list": []any{textItem("hello")},
})},
"get_updates_buf": "c1",
}))
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok"}, srv)
handlerCalls := &msgCollector{}
ad.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { handlerCalls.add(m) })
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
// emit is intentionally nil: the handler must receive the message.
ad.ReadMessages(ctx, nil)
}()
waitFor(t, func() bool { return handlerCalls.count() >= 1 })
cancel()
<-done
if got := handlerCalls.count(); got != 1 {
t.Fatalf("handler called %d times, want 1", got)
}
if m := handlerCalls.snapshot()[0]; m.Content != "hello" {
t.Fatalf("handler content = %q, want hello", m.Content)
}
}
+247
View File
@@ -0,0 +1,247 @@
package weixin_ilink
import (
"bytes"
"context"
"crypto/aes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// item kind constants reuse the iLink item type codes from poll.go, so
// outbound media maps to the same numbers as inbound parsing.
// sendResult is the sendmessage response envelope.
type sendResult struct {
Ret int `json:"ret"`
Errcode int `json:"errcode"`
}
// SendMessage delivers a reply to the channel. Each message is sent to the
// peer resolved from the message (or resp.ReplyTo) using that peer's stored
// context_token, so the platform can associate the reply with the right
// conversation (design/08 §8).
func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse) error {
if !a.IsConnected() {
return adapter.ErrNotConnected
}
for _, m := range resp.Messages {
peer, _ := recipient(m)
if peer == "" {
peer = resp.ReplyTo
}
if err := a.sendToOne(ctx, peer, m); err != nil {
return fmt.Errorf("weixin_ilink: send message: %w", err)
}
}
return nil
}
// SendProactive pushes a message that was not triggered by a recent inbound.
// WeChat iLink only allows this inside the 24h window since the user's last
// message (design/08 §3-4): when the user has been silent longer, or has never
// messaged, the push is rejected with ErrProactiveWindow and Capabilities.
// Proactive is only true so callers know the capability exists.
func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
if !a.IsConnected() {
return adapter.ErrNotConnected
}
peer, _ := recipient(msg)
if peer == "" {
return ErrNoContextToken
}
a.mu.RLock()
last, seen := a.lastMsgAt[peer]
window := a.cfg.ProactiveWindow
a.mu.RUnlock()
if !seen || a.clock().Sub(last) > window {
return ErrProactiveWindow
}
if a.contextFor(peer) == "" {
return ErrNoContextToken
}
return a.sendToOne(ctx, peer, msg)
}
// sendToOne sends one normalized message to a peer as a native iLink message,
// composing its item_list from any text plus uploaded media.
func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedMessage) error {
if peer == "" {
return ErrNoContextToken
}
ctxToken := a.contextFor(peer)
if ctxToken == "" {
return ErrNoContextToken
}
items := make([]any, 0, 1+len(m.Media))
if m.Content != "" {
items = append(items, nativeTextItem(m.Content))
}
for _, md := range m.Media {
item, err := a.buildMediaItem(ctx, md)
if err != nil {
return fmt.Errorf("weixin_ilink: build media item for %q: %w", md.Kind, err)
}
items = append(items, item)
}
if len(items) == 0 {
return nil
}
return a.sendNative(ctx, peer, ctxToken, items)
}
// buildMediaItem encrypts media bytes, uploads them to the CDN and returns the
// native item referencing the uploaded object and its AES key.
func (a *Adapter) buildMediaItem(ctx context.Context, md adapter.Media) (any, error) {
plaintext := md.Data
if len(plaintext) == 0 && md.URL != "" {
data, err := a.fetchBody(ctx, md.URL)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: fetch media source: %w", err)
}
plaintext = data
}
key := make([]byte, aes.BlockSize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("weixin_ilink: generate aes key: %w", err)
}
encrypted, err := aesECBEncrypt(key, plaintext)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: encrypt media: %w", err)
}
uploadURL, err := a.getUploadURL(ctx)
if err != nil {
return nil, fmt.Errorf("weixin_ilink: get upload url: %w", err)
}
if err := a.putBytes(ctx, uploadURL, encrypted); err != nil {
return nil, fmt.Errorf("weixin_ilink: upload media: %w", err)
}
return nativeMediaItem(nativeMediaTypeFor(md.Kind), uploadURL, key, md), nil
}
// sendNative posts a composed sendmessage payload.
func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items []any) error {
body, err := json.Marshal(map[string]any{
"msg": map[string]any{
"to_user_id": peer,
"client_id": newClientID(),
"message_type": 2, // bot message
"message_state": 2, // FINISH
"context_token": ctxToken,
"item_list": items,
},
})
if err != nil {
return fmt.Errorf("weixin_ilink: encode sendmessage: %w", err)
}
var out sendResult
if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/sendmessage"),
bytes.NewReader(body), &out); err != nil {
return fmt.Errorf("weixin_ilink: sendmessage: %w", err)
}
if out.Errcode == errSessionExpired {
return ErrSessionExpired
}
if out.Ret != 0 {
return fmt.Errorf("weixin_ilink: sendmessage ret=%d", out.Ret)
}
return nil
}
// getUploadURL requests a pre-signed CDN upload URL. The response field names
// vary across server versions, so several candidates are tried.
func (a *Adapter) getUploadURL(ctx context.Context) (string, error) {
body, err := json.Marshal(map[string]any{})
if err != nil {
return "", fmt.Errorf("weixin_ilink: encode getuploadurl: %w", err)
}
var raw map[string]any
if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/getuploadurl"),
bytes.NewReader(body), &raw); err != nil {
return "", fmt.Errorf("weixin_ilink: getuploadurl: %w", err)
}
u := firstString(raw, "upload_url", "url", "uploadUrl", "cdn_url")
if u == "" {
return "", fmt.Errorf("weixin_ilink: getuploadurl returned no url")
}
return u, nil
}
// putBytes uploads encrypted media bytes to a pre-signed CDN URL.
func (a *Adapter) putBytes(ctx context.Context, uploadURL string, data []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(data))
if err != nil {
return fmt.Errorf("weixin_ilink: build upload: %w", err)
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := a.httpClient.Do(req)
if err != nil {
return fmt.Errorf("weixin_ilink: upload media: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("weixin_ilink: upload media: status %d", resp.StatusCode)
}
return nil
}
// nativeTextItem builds a text item_list entry.
func nativeTextItem(text string) map[string]any {
return map[string]any{
"type": itemText,
"text_item": map[string]any{"text": text},
}
}
// nativeMediaItem builds a media item_list entry that references the uploaded
// object. Both cdn_ref and url are set because the exact field is not public.
func nativeMediaItem(kind int, ref string, key []byte, md adapter.Media) map[string]any {
item := map[string]any{
"type": kind,
"cdn_ref": ref,
"url": ref,
"aes_key": base64.StdEncoding.EncodeToString(key),
"file_name": md.Name,
"size": md.Size,
}
if md.MIMEType != "" {
item["mime"] = md.MIMEType
}
return item
}
// nativeMediaTypeFor maps a normalized MediaKind to the iLink item type code.
func nativeMediaTypeFor(kind adapter.MediaKind) int {
switch kind {
case adapter.MediaImage:
return itemImage
case adapter.MediaVoice:
return itemVoice
case adapter.MediaVideo:
return itemVideo
default:
return itemFile
}
}
// newClientID returns a random hex client id used to de-duplicate sends. On an
// (unlikely) crypto/rand failure it falls back to a time-based value.
func newClientID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err == nil {
return hex.EncodeToString(b[:])
}
return "c-" + strconv.FormatInt(time.Now().UnixNano(), 36)
}
+229
View File
@@ -0,0 +1,229 @@
package weixin_ilink
import (
"bytes"
"context"
"crypto/aes"
"encoding/base64"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// sendOKServer replies ret:0 to sendmessage, recording every request.
func sendOKServer(t *testing.T, rec *reqRecorder) *httptest.Server {
t.Helper()
return newJSONServer(t, rec, func(req recordedReq) handlerResult {
if req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage" {
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
}
return handlerResult{status: 404}
})
}
func TestSendMessage_RequiresConnection(t *testing.T) {
t.Parallel()
ad, err := New(Config{BaseURL: "https://ilinkai.weixin.qq.com"}, nil, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}},
})
if !errors.Is(err, adapter.ErrNotConnected) {
t.Fatalf("err = %v, want ErrNotConnected", err)
}
}
func TestSendMessage_RequiresContextToken(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := sendOKServer(t, rec)
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}},
})
if !errors.Is(err, ErrNoContextToken) {
t.Fatalf("err = %v, want ErrNoContextToken", err)
}
}
func TestSendMessage_SendsTextReply(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := sendOKServer(t, rec)
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
resp := adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "pong"}},
ReplyTo: "anything",
}
if err := ad.SendMessage(context.Background(), resp); err != nil {
t.Fatalf("SendMessage: %v", err)
}
body := getSendBody(t, rec)
msg := body["msg"].(map[string]any)
if msg["to_user_id"] != "u-1" {
t.Fatalf("to_user_id = %v, want u-1", msg["to_user_id"])
}
if msg["context_token"] != "tok-1" {
t.Fatalf("context_token = %v, want tok-1", msg["context_token"])
}
item := msg["item_list"].([]any)[0].(map[string]any)
if typ, ok := item["type"].(float64); !ok || typ != 1 {
t.Fatalf("item type = %v, want 1", item["type"])
}
if txt := item["text_item"].(map[string]any)["text"]; txt != "pong" {
t.Fatalf("text = %v, want pong", txt)
}
if msg["client_id"] == "" || msg["client_id"] == nil {
t.Fatal("client_id missing")
}
}
func TestSendProactive_WithinWindow(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := sendOKServer(t, rec)
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
ad.clock = func() time.Time { return now }
ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now})
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
SenderID: "u-1", Content: "daily tip",
})
if err != nil {
t.Fatalf("SendProactive within window: %v", err)
}
body := getSendBody(t, rec)
msg := body["msg"].(map[string]any)
if msg["to_user_id"] != "u-1" || msg["context_token"] != "tok-p" {
t.Fatalf("proactive send = to_user_id %v context %v", msg["to_user_id"], msg["context_token"])
}
}
func TestSendProactive_ExpiredWindow(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := sendOKServer(t, rec)
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
ad.clock = func() time.Time { return now }
ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now})
// Advance beyond the 24h window.
now = now.Add(25 * time.Hour)
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
SenderID: "u-1", Content: "too late",
})
if !errors.Is(err, ErrProactiveWindow) {
t.Fatalf("err = %v, want ErrProactiveWindow", err)
}
if len(getupdatesReqs(rec)) != 0 {
t.Fatal("expired window must not send")
}
}
func TestSendProactive_NoPriorInbound(t *testing.T) {
t.Parallel()
srv := sendOKServer(t, &reqRecorder{})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
SenderID: "u-1", Content: "hello",
})
if !errors.Is(err, ErrProactiveWindow) {
t.Fatalf("err = %v, want ErrProactiveWindow (no prior inbound)", err)
}
}
func TestSendMessage_OutboundMedia(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
var srv *httptest.Server
srv = newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch {
case req.method == http.MethodPost && req.path == "/ilink/bot/getuploadurl":
return handlerResult{body: jsonBody(map[string]any{"upload_url": srv.URL + "/upload/1"})}
case req.method == http.MethodPut && req.path == "/upload/1":
return handlerResult{status: 200, body: []byte("ok")}
case req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage":
return handlerResult{body: jsonBody(map[string]any{"ret": 0})}
default:
return handlerResult{status: 404}
}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, CDNBaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-m", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
imageData := []byte("fake-jpeg-bytes")
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{
SenderID: "u-1",
Media: []adapter.Media{{Kind: adapter.MediaImage, Data: imageData, Name: "a.jpg"}},
}},
})
if err != nil {
t.Fatalf("SendMessage media: %v", err)
}
body := getSendBody(t, rec)
msg := body["msg"].(map[string]any)
item := msg["item_list"].([]any)[0].(map[string]any)
if typ, ok := item["type"].(float64); !ok || typ != 2 {
t.Fatalf("media item type = %v, want 2", item["type"])
}
if item["cdn_ref"] != srv.URL+"/upload/1" {
t.Fatalf("cdn_ref = %v, want upload url", item["cdn_ref"])
}
aesKeyEncoded, _ := item["aes_key"].(string)
if aesKeyEncoded == "" {
t.Fatal("aes_key missing on media item")
}
key, err := base64.StdEncoding.DecodeString(aesKeyEncoded)
if err != nil {
t.Fatalf("decode aes_key: %v", err)
}
if len(key) != aes.BlockSize {
t.Fatalf("aes_key = %d bytes, want %d", len(key), aes.BlockSize)
}
// The uploaded bytes (PUT body) must decrypt back to the original media.
puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut })
if len(puts) != 1 {
t.Fatalf("PUT requests = %d, want 1", len(puts))
}
decrypted, err := aesECBDecrypt(key, puts[0].body)
if err != nil {
t.Fatalf("decrypt uploaded media: %v", err)
}
if !bytes.Equal(decrypted, imageData) {
t.Fatalf("uploaded media mismatch: got %q want %q", decrypted, imageData)
}
}
// getSendBody returns the decoded body of the first sendmessage request.
func getSendBody(t *testing.T, rec *reqRecorder) map[string]any {
t.Helper()
reqs := rec.find(func(q recordedReq) bool {
return q.method == http.MethodPost && q.path == "/ilink/bot/sendmessage"
})
if len(reqs) == 0 {
t.Fatal("no sendmessage request recorded")
}
body := reqs[0].bodyJSON()
if body == nil {
t.Fatal("sendmessage body is not JSON")
}
return body
}