430 lines
14 KiB
Go
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, "/")
|
|
}
|