feat(channels/weixin_ilink): 微信 iLink 渠道插件(扫码登录+长轮询+游标+context_token+AES-128-ECB 媒体+24h 主动推送窗口)
This commit is contained in:
1 parent
7831b0ea1b
commit
88dc12466d
13 files changed
+2627
No files matched your search
@@ -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, "/")
|
||||
}
|
||||
Reference in new issue
Block a user