Files
Cyrene_v2/plugins/channels/weixin_ilink/adapter.go
T

430 lines
14 KiB
Go

// 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, "/")
}