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