docs(comments): 注释中文化 · plugins/channels/weixin_ilink(20 文件,纯替换)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(8 组,代码零改动)
- 保留协议/线上标识符原文:errcode、bot_token、context_token、getupdates、binded_redirect、PKCS#7、ECB 等
- 术语对齐既有译法:owner→主人、peer→对端、long poll→长轮询、backoff→退避、sentinel error→哨兵错误
- 已知既有欠债(未顺手修):login_reuse_test.go 在 HEAD 上就未 gofmt 干净;login_test.go 的 TestStartLogin_ScanStatusTransition 91 行 >80(HEAD 同为 91 行)
This commit is contained in:
AskaEth committed 2026-10-04 12:29:05 +08:00
1 parent 09fba77f87
commit 95f97f5df3
20 files changed
+488 -590

No files matched your search

+102 -120
View File
@@ -1,17 +1,14 @@
// 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.
// Package weixin_ilink 实现 Cyrene v2 的微信 ClawBot iLink 渠道适配器
// (DevDocs/design/08-渠道接入.md §4)。它把腾讯 iLink Bot API 适配到归一化的
// internal/channel/adapter 契约上。
//
// 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).
// 该适配器覆盖 weixin-ilink 的能力面:扫码登录、带持久化 get_updates_buf 游标的
// HTTP 长轮询、按对端跟踪 context_token、配合 CDN 下载的 AES-128-ECB 媒体加解密,
// 以及 24 小时主动推送窗口。媒体 CDN 引用是惰性捕获的——轮询循环绝不下载附件,
// 因此大文件不会拖住消息投递(design 03 §8 第 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.
// 所有网络 I/O 都经过注入的 *http.Client 并尊重传入的 context.Context,测试因此
// 可以换成 httptest.Server,并在取消读循环时不泄漏 goroutine 或定时器。
package weixin_ilink
import (
@@ -29,101 +26,97 @@ import (
)
const (
// source labels every log record emitted by this package so failures can be
// traced back to the owning module (constraint 02 §5).
// source 为本包发出的每条日志记录标注来源,使故障可回溯到所属模块
// (constraint 02 §5)。
source = "plugins/channels/weixin_ilink"
// platformName is the Name() value of the adapter.
// platformName 是适配器的 Name() 取值。
platformName = "weixin-ilink"
// defaultBaseURL and defaultCDNBaseURL come from the protocol summary.
// defaultBaseURL 与 defaultCDNBaseURL 来自协议摘要。
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 超过服务端约 35s 的保持时长,使 HTTP 客户端不会
// 中断一次正常进行中的长轮询。
defaultLongPollTimeout = 45 * time.Second
// defaultLoginTimeout bounds the scan-code confirmation wait.
// defaultLoginTimeout 限定扫码确认的等待时长。
defaultLoginTimeout = 8 * time.Minute
// defaultProactiveWindow is what design/08 §4 calls the iLink 24h window.
// defaultProactiveWindow 即 design/08 §4 所称的 iLink 24 小时窗口。
defaultProactiveWindow = 24 * time.Hour
// defaultMaxMediaSize caps a downloaded attachment at 50 MiB.
// defaultMaxMediaSize 把单个下载的附件限制在 50 MiB。
defaultMaxMediaSize int64 = 50 << 20
// botType is hard-coded in the official iLink source and is passed
// verbatim to get_bot_qrcode.
// botType 在官方 iLink 源码中硬编码,原样传给 get_bot_qrcode。
botType = 3
// channelVersion is the base_info.channel_version sent on getupdates.
// channelVersion 是 getupdates 上发送的 base_info.channel_version。
channelVersion = "1.0.2"
// ilinkAppID mirrors the official package.json ilink_appid field and is sent
// as the iLink-App-Id header on every request.
// ilinkAppID 对应官方 package.json 的 ilink_appid 字段,并在每次请求上作为
// iLink-App-Id 头发送。
ilinkAppID = "bot"
// ilinkVersion mirrors the upstream @tencent-weixin/openclaw-weixin version
// whose client version we advertise on the wire as iLink-App-ClientVersion.
// ilinkVersion 对应上游 @tencent-weixin/openclaw-weixin 的版本,我们以它的
// 客户端版本作为 iLink-App-ClientVersion 在线路上宣称。
ilinkVersion = "2.4.8"
// errcodeSessionRejected is the iLink errcode meaning iLink rejected or
// invalidated this session credential. It is NOT a time-based expiry: the bot
// token is a long-lived credential (a token obtained by scanning a month ago
// still works), so -14 means iLink no longer accepts this session — unbound,
// re-logged-in elsewhere, or refused at the protocol level.
// errcodeSessionRejected 是 iLink 的错误码,含义是 iLink 拒绝或作废了这份会话
// 凭据。它**不是**基于时间的过期:bot token 是长期凭据(一个月前扫码拿到的 token
// 依然可用),所以 -14 表示 iLink 不再接受这个会话——已解绑、在别处重新登录、
// 或协议层拒绝。
errcodeSessionRejected = -14
)
// Sentinel errors returned by this adapter, usable with errors.Is.
// 本适配器返回的哨兵错误,可用 errors.Is 判定。
var (
// ErrInvalidConfig is returned when a required config field is empty.
// ErrInvalidConfig 在必填配置字段为空时返回。
ErrInvalidConfig = errors.New("weixin_ilink: invalid config")
// ErrLoginTimeout is returned when the QR code is not confirmed in time.
// ErrLoginTimeout 在二维码未及时确认时返回。
ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out")
// ErrSessionRejected is returned when iLink reports errcode=-14.
// ErrSessionRejected 在 iLink 上报 errcode=-14 时返回。
ErrSessionRejected = errors.New("weixin_ilink: iLink rejected the session (errcode=-14); re-scan required")
// ErrNeedsLogin is returned by Connect when no bot_token is configured yet; the
// scan-code bind must be driven via the panel login-qr/login-status endpoints.
// ErrNeedsLogin 由 Connect 在尚未配置 bot_token 时返回;扫码绑定必须经面板的
// login-qr/login-status 端点驱动。
ErrNeedsLogin = errors.New("weixin_ilink: bot not logged in yet, scan the QR")
// ErrNoContextToken is returned when a send lacks the peer's context_token.
// ErrNoContextToken 在一次发送缺少该对端的 context_token 时返回。
ErrNoContextToken = errors.New("weixin_ilink: no context_token for peer")
// ErrProactiveWindow is returned when a proactive push is outside 24h.
// ErrProactiveWindow 在主动推送落在 24 小时窗口之外时返回。
ErrProactiveWindow = errors.New("weixin_ilink: proactive push outside 24h window")
// ErrMediaTooLarge is returned when a download exceeds MaxMediaSize.
// ErrMediaTooLarge 在下载超过 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.
// Config 保存 weixin-ilink 适配器的设置。它由渠道层从环境/配置读取,并可通过
// Config() 与 adapter.DecodeConfig 往返转换。
type Config struct {
// BaseURL is the iLink API root.
// BaseURL 是 iLink API 的根地址。
BaseURL string `json:"base_url"`
// CDNBaseURL is the media CDN root used for downloads and uploads.
// CDNBaseURL 是用于下载与上传的媒体 CDN 根地址。
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 是扫码登录得到的 bearer token。token 非空意味着 Connect 会跳过
// 二维码流程。
BotToken string `json:"bot_token"`
// BotID is the ilink_bot_id from the login confirmation.
// BotID 是登录确认返回的 ilink_bot_id。
BotID string `json:"bot_id"`
// OwnerID is the ilink_user_id of the bound owner account.
// OwnerID 是已绑定主人账号的 ilink_user_id。
OwnerID string `json:"owner_id"`
// Cursor is the persisted get_updates_buf.
// Cursor 是持久化的 get_updates_buf。
Cursor string `json:"cursor"`
// LongPollTimeout is the per-request timeout for getupdates.
// LongPollTimeout 是 getupdates 的单请求超时。
LongPollTimeout time.Duration `json:"long_poll_timeout"`
// LoginTimeout bounds how long the scan-code flow waits for confirmation.
// LoginTimeout 限定扫码流程等待确认的时长。
LoginTimeout time.Duration `json:"login_timeout"`
// LoginInterval is how often the QR status is polled during login.
// LoginInterval 是登录期间轮询二维码状态的间隔。
LoginInterval time.Duration `json:"login_interval"`
// PollInterval is the gap between long polls; zero means poll immediately.
// PollInterval 是两次长轮询之间的间隔;为零表示立即轮询。
PollInterval time.Duration `json:"poll_interval"`
// ProactiveWindow is how long after the user's last message a proactive
// push is allowed.
// ProactiveWindow 是用户最后一条消息之后仍允许主动推送的时长。
ProactiveWindow time.Duration `json:"proactive_window"`
// MaxMediaSize caps a downloaded attachment in bytes.
// MaxMediaSize 以字节为单位限制单个下载的附件。
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.
// withDefaults 用协议默认值填充零值字段。BotID/OwnerID 合法地可以为空,因此原样
// 保留。
func (c Config) withDefaults() Config {
if c.BaseURL == "" {
c.BaseURL = defaultBaseURL
@@ -152,9 +145,8 @@ func (c Config) withDefaults() Config {
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.
// Adapter 是微信 ClawBot iLink 的 PlatformAdapter 实现。它可安全并发使用:会话
// 标志、游标以及按对端的 token/时间戳映射的读写都由同一把 RWMutex 保护。
type Adapter struct {
cfg Config
httpClient *http.Client
@@ -163,36 +155,34 @@ type Adapter struct {
mu sync.RWMutex
connected bool
// rejected marks a session iLink rejected with errcode=-14: the credential is
// refused until a fresh scan-code login re-binds it (session_state.go).
// rejectedToken records WHICH token was rejected, so a subsequently configured
// token clears the state; rescanDetail is the panel-visible reason. All three
// are guarded by mu.
// rejected 标记 iLink 以 errcode=-14 拒绝过的会话:在重新扫码登录把它重新绑定
// 之前,这份凭据一直被拒(session_state.go)。rejectedToken 记录**哪一个** token
// 被拒,于是之后配置的新 token 会清除该状态;rescanDetail 是面板可见的原因。
// 这三者都由 mu 保护。
rejected bool
rejectedToken string
rescanDetail string
qrcode string
// pollBaseURL optionally overrides cfg.BaseURL for get_qrcode_status polling.
// It is set when the server returns scaned_but_redirect with a redirect_host,
// so subsequent status polls follow the IDC host. Guarded by mu.
// pollBaseURL 可选地覆盖 cfg.BaseURL,用于 get_qrcode_status 轮询。服务端返回
// scaned_but_redirect 且带 redirect_host 时会设置它,使后续状态轮询跟随该 IDC
// 主机。由 mu 保护。
pollBaseURL string
contexts map[string]string
lastMsgAt map[string]time.Time
handler adapter.MessageHandler
// loginMu serializes the scan-login session lifecycle (StartLogin/Connect),
// so only one QR bind runs at a time. loginSession / loginStatus / loginDetail
// carry the panel-observable login state; loginStatus/loginDetail are guarded
// by mu, loginSession by loginMu.
// loginMu 串行化扫码登录会话的生命周期(StartLogin/Connect),使同一时刻只有
// 一次二维码绑定在跑。loginSession / loginStatus / loginDetail 承载面板可观测的
// 登录状态;loginStatus/loginDetail 由 mu 保护,loginSession 由 loginMu 保护。
loginMu sync.Mutex
loginSession *loginSession
loginStatus string
loginDetail string
}
// 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.
// New 构造一个 Adapter。client 可为 nil,此时使用 http.DefaultClient;log 可为 nil,
// 此时回退到项目 logger 的 info 级别。默认值填补后基础 URL 仍为空时返回
// ErrInvalidConfig。
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://") {
@@ -217,11 +207,10 @@ func New(cfg Config, client *http.Client, log *slog.Logger) (*Adapter, error) {
}, nil
}
// Name returns the platform identifier "weixin-ilink".
// Name 返回平台标识符 "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.
// Config 以普通 map 返回当前适配器配置的快照,其中包含持久化的游标与登录凭据。
func (a *Adapter) Config() map[string]any {
a.mu.RLock()
defer a.mu.RUnlock()
@@ -241,14 +230,12 @@ func (a *Adapter) Config() map[string]any {
}
}
// Connect establishes the iLink session. It is idempotent. When a bot_token is
// already configured and usable it is treated as a live session; otherwise it
// runs the scan-code login flow (see login.go).
// Connect 建立 iLink 会话。它是幂等的。已配置且可用的 bot_token 会被当作存活会话;
// 否则它会跑扫码登录流程(见 login.go)。
//
// A token iLink already rejected with errcode=-14 is NOT usable: Connect refuses
// it with ErrSessionRejected (re-scan required) instead of reporting a session
// that the very next poll would tear down, and the scan-code login flow is the
// way back (see login_admin.go StartLogin).
// 已被 iLink 以 errcode=-14 拒绝的 token **不可用**:Connect 会以
// ErrSessionRejected(需要重新扫码)拒绝它,而不是上报一个「下一次轮询就会拆掉」的
// 会话;回到正轨的途径是扫码登录流程(见 login_admin.go 的 StartLogin)。
func (a *Adapter) Connect(ctx context.Context) error {
if rejected, reason := a.rejectedState(); rejected {
a.log.Error("ilink connect refused: session rejected, 需要重新扫码绑定(请用「扫码登录」)",
@@ -263,10 +250,9 @@ func (a *Adapter) Connect(ctx context.Context) error {
return nil
}
if token == "" {
// No token yet: the scan-code bind must be driven out-of-band (the panel's
// login-qr / login-status endpoints, design/08 §4). Never block a "connect"
// HTTP call on the waiting QR — surface ErrNeedsLogin so the caller tells the
// owner to scan, rather than holding the request for up to the login timeout.
// 尚无 token:扫码绑定必须带外驱动(面板的 login-qr / login-status 端点,
// design/08 §4)。绝不让一次「连接」HTTP 调用阻塞在等待二维码上——上报
// ErrNeedsLogin,让调用方告知主人去扫码,而不是把请求挂到登录超时。
return ErrNeedsLogin
}
a.mu.Lock()
@@ -276,15 +262,15 @@ func (a *Adapter) Connect(ctx context.Context) error {
return nil
}
// botID returns the configured bot identifier; it is the only account identifier
// safe to log (the bot token is a credential and never reaches a log line).
// botID 返回已配置的机器人标识符;它是唯一可安全记录到日志的账号标识(bot token
// 是凭据,绝不进入日志行)。
func (a *Adapter) botID() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg.BotID
}
// Disconnect tears down the session. It is idempotent.
// Disconnect 拆除会话。它是幂等的。
func (a *Adapter) Disconnect(ctx context.Context) error {
a.mu.Lock()
defer a.mu.Unlock()
@@ -296,19 +282,17 @@ func (a *Adapter) Disconnect(ctx context.Context) error {
return nil
}
// IsConnected reports whether the session is currently live.
// IsConnected 报告会话当前是否存活。
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. A session iLink rejected (errcode=-14) is reported as
// ErrSessionRejected — "re-scan required" — so a caller can tell a refused
// credential apart from a plain disconnected adapter. It does not probe the
// network so callers can use it cheaply after a reconnect without incurring a
// request.
// HealthCheck 校验会话可用:它必须处于已连接状态并携带 bot_token。被 iLink 拒绝的
// 会话(errcode=-14)以 ErrSessionRejected 上报——「需要重新扫码」——使调用方能
// 把被拒凭据与单纯的适配器未连接区分开。它不探测网络,调用方因此可以在重连后廉价
// 调用,不产生任何请求。
func (a *Adapter) HealthCheck(ctx context.Context) error {
if rejected, reason := a.rejectedState(); rejected {
return fmt.Errorf("%w: %s", ErrSessionRejected, reason)
@@ -321,15 +305,15 @@ func (a *Adapter) HealthCheck(ctx context.Context) error {
return nil
}
// Capabilities declares what the adapter supports.
// Capabilities 声明该适配器支持的能力。
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.
// ListChannels 把机器人账号作为单个私聊渠道返回。iLink 的群成员关系无法可靠枚举,
// 因此只列出已绑定的账号。
func (a *Adapter) ListChannels(ctx context.Context) ([]adapter.ChannelInfo, error) {
a.mu.RLock()
botID := a.cfg.BotID
@@ -344,9 +328,8 @@ func (a *Adapter) ListChannels(ctx context.Context) ([]adapter.ChannelInfo, erro
}}, 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.
// SetMessageHandler 安装接收推送式入站消息的回调。fn 为 nil 表示清除。处理器一旦
// 设置,ReadMessages 就把转换后的消息交给它,而不再交给 emit 回调。
func (a *Adapter) SetMessageHandler(fn adapter.MessageHandler) {
a.mu.Lock()
defer a.mu.Unlock()
@@ -359,7 +342,7 @@ func (a *Adapter) handlerLocked() adapter.MessageHandler {
return a.handler
}
// --- concurrency helpers ---
// --- 并发辅助 ---
func (a *Adapter) cursor() string {
a.mu.RLock()
@@ -376,9 +359,8 @@ func (a *Adapter) setCursor(c string) {
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).
// recordInbound 刷新按对端的 context_token 与最后消息时间(design/08 §8:每条入站
// 消息都刷新 token,使主动推送在窗口内有可用 token)。
func (a *Adapter) recordInbound(token string, m adapter.UnifiedMessage) {
peer := m.SenderID
if m.IsGroup() {
@@ -393,15 +375,15 @@ func (a *Adapter) recordInbound(token string, m adapter.UnifiedMessage) {
a.mu.Unlock()
}
// contextFor returns the stored context_token for a peer.
// contextFor 返回某个对端已存的 context_token。
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.
// recipient 为一条出站消息解析目标对端与群标志:群聊取群 id,否则取发送者 id,
// 否则取渠道 id。
func recipient(m adapter.UnifiedMessage) (peer string, group bool) {
if m.IsGroup() {
return m.GroupID, true
@@ -412,8 +394,8 @@ func recipient(m adapter.UnifiedMessage) (peer string, group bool) {
return m.ChannelID, false
}
// --- HTTP plumbing ---
// --- HTTP 管道 ---
//
// The request/response HTTP helpers (authorizationHeader, doJSON, apiURL,
// joinURL, pollBase, setPollBaseURL, buildClientVersion, cdnURL) live in
// adapter_http.go so this file stays under the project's ≤500-line limit.
// 请求/响应的 HTTP 辅助函数(authorizationHeader、doJSON、apiURL、joinURL、
// pollBase、setPollBaseURL、buildClientVersion、cdnURL)位于 adapter_http.go,
// 以便本文件保持在项目 ≤500 行的上限之内。