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 行的上限之内。
+20 -25
View File
@@ -1,9 +1,8 @@
package weixin_ilink
// This file holds the HTTP plumbing for the iLink adapter (request building and
// response decoding). It is split from adapter.go so that file stays under the
// project's ≤500-line source-file limit. Nothing here exports a symbol the
// rest of the package or its tests rely on being in a specific file.
// 本文件承载 iLink 适配器的 HTTP 管道(请求构造与响应解码)。它从 adapter.go
// 拆出,以便那个文件保持在项目 ≤500 行的单文件上限之内。这里不放任何「包内其余
// 代码或其测试依赖其位于某个特定文件」的导出符号。
import (
"context"
@@ -19,9 +18,8 @@ import (
"strings"
)
// authorizationHeader decorates req with the iLink common headers: JSON
// content type, the bearer token, the auth type, a fresh random
// X-WECHAT-UIN replay guard, and the App identify headers.
// authorizationHeader 为 req 装上 iLink 通用请求头:JSON 内容类型、bearer
// token、认证类型、每次新生成的随机 X-WECHAT-UIN 防重放值,以及 App 标识头。
func (a *Adapter) authorizationHeader(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("AuthorizationType", "ilink_bot_token")
@@ -36,8 +34,8 @@ func (a *Adapter) authorizationHeader(req *http.Request) {
}
}
// randomUIN returns a base64-encoded decimal uint32, minted fresh per request
// as iLink's anti-replay value.
// randomUIN 返回 base64 编码的十进制 uint32,每次请求都新生成,作为 iLink 的
// 防重放值。
func randomUIN() string {
var b [4]byte
_, _ = rand.Read(b[:])
@@ -45,9 +43,8 @@ func randomUIN() string {
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).
// doJSON 发起一次 HTTP 请求并把 JSON 响应解码进 out。它关闭响应体,并用 %w
// 包裹网络/解码错误。out 为 nil 时跳过解码(用于响应体是不透明字节的 PUT)。
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 {
@@ -76,19 +73,18 @@ func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Rea
return nil
}
// apiURL joins a path with the configured base URL.
// apiURL 把路径与已配置的基础 URL 拼接起来。
func (a *Adapter) apiURL(path string) string {
return joinURL(a.cfg.BaseURL, path)
}
// joinURL joins a base URL with a leading-slash path, tolerating a trailing slash
// on the base.
// joinURL 把基础 URL 与以斜杠开头的路径拼接起来,并容忍基础 URL 末尾带斜杠。
func joinURL(base, path string) string {
return strings.TrimRight(base, "/") + path
}
// pollBase returns the base URL for get_qrcode_status: the IDC redirect host when
// one was recorded (scaned_but_redirect), else the configured base URL.
// pollBase 返回 get_qrcode_status 使用的基础 URL:已记录 IDC 重定向主机
// (scaned_but_redirect)时用它,否则用已配置的基础 URL。
func (a *Adapter) pollBase() string {
a.mu.RLock()
defer a.mu.RUnlock()
@@ -98,9 +94,8 @@ func (a *Adapter) pollBase() string {
return a.cfg.BaseURL
}
// setPollBaseURL records an IDC redirect host (https://<redirect_host>) for
// subsequent get_qrcode_status polls, per the official currentApiBaseUrl logic.
// An empty host clears the override.
// setPollBaseURL 按官方 currentApiBaseUrl 的逻辑,为后续的 get_qrcode_status
// 轮询记录一个 IDC 重定向主机(https://<redirect_host>)。host 为空则清除该覆盖。
func (a *Adapter) setPollBaseURL(host string) {
a.mu.Lock()
defer a.mu.Unlock()
@@ -111,9 +106,9 @@ func (a *Adapter) setPollBaseURL(host string) {
a.pollBaseURL = "https://" + host
}
// buildClientVersion encodes a semantic version into the iLink App-ClientVersion
// wire value 0x00MMNNPP = (major&0xff)<<16 | (minor&0xff)<<8 | (patch&0xff), as
// the official api.ts does. e.g. "2.4.8" -> "132104" (a decimal string, not "1").
// buildClientVersion 按官方 api.ts 的做法,把语义化版本编码成 iLink
// App-ClientVersion 的线上值 0x00MMNNPP = (major&0xff)<<16 | (minor&0xff)<<8 |
// (patch&0xff)。例如 "2.4.8" -> "132104"(十进制字符串,而不是 "1")。
func buildClientVersion(version string) string {
parts := strings.Split(version, ".")
at := func(i int) int {
@@ -130,8 +125,8 @@ func buildClientVersion(version string) string {
return strconv.Itoa(v)
}
// cdnURL joins a path with the configured CDN base URL, escaping raw query
// strings that some CDN references carry.
// cdnURL 把路径与已配置的 CDN 基础 URL 拼接起来,并转义部分 CDN 引用携带的原始
// 查询串。
func (a *Adapter) cdnURL(raw string) string {
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
return raw
@@ -50,7 +50,7 @@ func TestAdapter_Config_ReturnsSnapshot(t *testing.T) {
if cfg["base_url"] != "https://ilinkai.weixin.qq.com" {
t.Fatalf("Config()[base_url] = %v", cfg["base_url"])
}
// Round-tripping through DecodeConfig must preserve the typed config.
// 经 DecodeConfig 往返必须保留类型化配置。
var back Config
if err := adapter.DecodeConfig(cfg, &back); err != nil {
t.Fatalf("DecodeConfig: %v", err)
@@ -75,7 +75,7 @@ func TestAdapter_Connect_WithToken_IsIdempotent(t *testing.T) {
if !ad.IsConnected() {
t.Fatal("IsConnected() = false after Connect")
}
// Second call is a no-op success.
// 第二次调用是无副作用的成功。
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("second Connect: %v", err)
}
@@ -149,7 +149,7 @@ func TestAdapter_SetMessageHandler_And_RecordInbound(t *testing.T) {
t.Fatal("handler called before any message")
}
// recordInbound must refresh the per-peer token and last-message time.
// recordInbound 必须刷新按对端的 token 与最后消息时间。
ad.recordInbound("tok-abc", adapter.UnifiedMessage{
SenderID: "user-1",
Timestamp: ad.clock(),
@@ -162,8 +162,8 @@ func TestAdapter_SetMessageHandler_And_RecordInbound(t *testing.T) {
func TestBuildClientVersion(t *testing.T) {
t.Parallel()
cases := map[string]string{
"2.4.8": "132104", // official @tencent-weixin/openclaw-weixin 2.4.8
"1.0.11": "65547", // official comment example 0x0001000B
"2.4.8": "132104", // 官方 @tencent-weixin/openclaw-weixin 2.4.8
"1.0.11": "65547", // 官方注释示例 0x0001000B
"1.2.3": "66051",
"0.0.0": "0",
"bad": "0",
+11 -16
View File
@@ -6,13 +6,11 @@ import (
"fmt"
)
// ErrBadCiphertext is returned when ciphertext is not a whole number of AES
// blocks or its padding is malformed.
// ErrBadCiphertext 在密文长度不是 AES 分组整数倍、或其填充格式非法时返回。
var ErrBadCiphertext = errors.New("weixin_ilink: bad ciphertext or padding")
// pkcs7Pad appends PKCS#7 padding to src so its length becomes a multiple of
// blockSize. Every appended byte holds the pad length, so it self-describes
// and Unpad can strip it unambiguously.
// pkcs7Pad 按 PKCS#7 向 src 追加填充,使其长度成为 blockSize 的整数倍。
// 每个追加字节都写入填充长度,因此它是自描述的,Unpad 可以无歧义地剥离。
func pkcs7Pad(src []byte, blockSize int) []byte {
n := blockSize - len(src)%blockSize
out := make([]byte, len(src)+n)
@@ -23,8 +21,8 @@ func pkcs7Pad(src []byte, blockSize int) []byte {
return out
}
// pkcs7Unpad strips PKCS#7 padding from src. It returns ErrBadCiphertext when
// the length is not a block multiple or the final pad byte is inconsistent.
// pkcs7Unpad 从 src 剥离 PKCS#7 填充。长度不是分组整数倍、或末尾填充字节不自洽
// 时返回 ErrBadCiphertext。
func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) {
if len(src) == 0 || len(src)%blockSize != 0 {
return nil, ErrBadCiphertext
@@ -41,12 +39,10 @@ func pkcs7Unpad(src []byte, blockSize int) ([]byte, error) {
return src[:len(src)-n], nil
}
// aesECBEncrypt encrypts src under AES with the supplied key using ECB mode.
// Go's crypto/cipher package does not ship ECB, so the mode is implemented
// here: src is padded to a block multiple and every block is encrypted
// independently with the same key. ECB is deterministic and not semantically
// secure; it is required here only because the iLink CDN protocol mandates it
// for media payloads.
// aesECBEncrypt 用给定密钥以 ECB 模式对 src 做 AES 加密。Go 的 crypto/cipher
// 不提供 ECB,所以此模式在此自行实现:先把 src 填充到分组整数倍,再用同一密钥
// 逐块独立加密。ECB 是确定性的、不具备语义安全性;这里之所以需要它,仅仅是因为
// iLink CDN 协议对媒体负载强制要求该模式。
func aesECBEncrypt(key, src []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
@@ -60,9 +56,8 @@ func aesECBEncrypt(key, src []byte) ([]byte, error) {
return out, nil
}
// aesECBDecrypt decrypts src under AES with the supplied key and strips the
// PKCS#7 padding. ECB has no IV, so the first block decrypts directly with the
// key. It returns ErrBadCiphertext for malformed input.
// aesECBDecrypt 用给定密钥对 src 做 AES 解密并剥离 PKCS#7 填充。ECB 没有 IV,
// 所以首块直接用密钥解密。输入格式非法时返回 ErrBadCiphertext。
func aesECBDecrypt(key, src []byte) ([]byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
+8 -8
View File
@@ -11,7 +11,7 @@ import (
func TestAESECB_RoundTrip_AllBlockBoundaries(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{0x42}, aes.BlockSize)
// Exercise len 0 through just past two blocks (padding edge cases).
// 覆盖长度 0 到刚过两个分组的范围(填充边界情况)。
for n := 0; n <= 2*aes.BlockSize+5; n++ {
n := n
t.Run("len="+strconv.Itoa(n), func(t *testing.T) {
@@ -35,14 +35,14 @@ func TestAESECB_RoundTrip_AllBlockBoundaries(t *testing.T) {
func TestAESECB_EncryptPadsToBlockSize(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{1}, aes.BlockSize)
ct, err := aesECBEncrypt(key, []byte("abc")) // 3 bytes -> 16 after pad
ct, err := aesECBEncrypt(key, []byte("abc")) // 3 字节 -> 填充后 16 字节
if err != nil {
t.Fatalf("aesECBEncrypt: %v", err)
}
if len(ct) != aes.BlockSize {
t.Fatalf("ciphertext = %d bytes, want %d", len(ct), aes.BlockSize)
}
// Full block input -> one pad block appended.
// 整块输入 -> 追加一个填充块。
ct2, err := aesECBEncrypt(key, bytes.Repeat([]byte{0}, aes.BlockSize))
if err != nil {
t.Fatalf("aesECBEncrypt: %v", err)
@@ -71,12 +71,12 @@ func TestAESECB_DecryptRejectsBadLength(t *testing.T) {
func TestAESECB_DecryptRejectsBadPadding(t *testing.T) {
t.Parallel()
key := bytes.Repeat([]byte{3}, aes.BlockSize)
// Valid ciphertext for one block with a correct pad (0x10) ...
// 一个分组、填充正确(0x10)的合法密文……
good, err := aesECBEncrypt(key, bytes.Repeat([]byte{4}, aes.BlockSize-1))
if err != nil {
t.Fatalf("aesECBEncrypt: %v", err)
}
// ... then tamper the final pad byte so the padding check must fail.
// ……然后篡改末尾填充字节,使填充校验必然失败。
bad := append([]byte(nil), good...)
bad[len(bad)-1] ^= 0xFF
_, err = aesECBDecrypt(key, bad)
@@ -87,7 +87,7 @@ func TestAESECB_DecryptRejectsBadPadding(t *testing.T) {
func TestAESECB_KeySizesAccepted(t *testing.T) {
t.Parallel()
// AES-128 requires a 16-byte key. Verify out-of-range keys are rejected.
// AES-128 需要 16 字节密钥。验证超范围的密钥会被拒绝。
if _, err := aesECBEncrypt(bytes.Repeat([]byte{1}, aes.BlockSize-1), []byte("x")); err == nil {
t.Fatal("short key: expected error, got nil")
}
@@ -98,11 +98,11 @@ func TestAESECB_KeySizesAccepted(t *testing.T) {
func TestPKCS7_UnpadRejectsInvalid(t *testing.T) {
t.Parallel()
// More pad bytes than a block.
// 填充字节多于一个分组。
if _, err := pkcs7Unpad([]byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 17}, 16); !errors.Is(err, ErrBadCiphertext) {
t.Fatalf("oversize pad: err = %v, want ErrBadCiphertext", err)
}
// Zero pad byte is never valid.
// 填充字节为 0 永远非法。
if _, err := pkcs7Unpad([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 0}, 16); !errors.Is(err, ErrBadCiphertext) {
t.Fatalf("zero pad: err = %v, want ErrBadCiphertext", err)
}
+21 -23
View File
@@ -14,21 +14,21 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// handlerResult is the decision a test's fake handler returns for a request.
// handlerResult 是测试里的假处理器对一次请求给出的裁决。
type handlerResult struct {
status int
body []byte
ctype string
}
// reqRecorder captures the requests a fake server receives so tests can assert
// that the adapter sent the expected cursor, bearer token, payload, etc.
// reqRecorder 捕获假服务端收到的请求,使测试能断言适配器发送了预期的游标、
// bearer token、载荷等。
type reqRecorder struct {
mu sync.Mutex
reqs []recordedReq
}
// recordedReq is a normalized snapshot of an inbound HTTP request.
// recordedReq 是一条入站 HTTP 请求的归一化快照。
type recordedReq struct {
method string
path string
@@ -59,7 +59,7 @@ func (r *reqRecorder) reset() {
r.reqs = nil
}
// find returns requests matching pred.
// find 返回匹配 pred 的请求。
func (r *reqRecorder) find(pred func(recordedReq) bool) []recordedReq {
r.mu.Lock()
defer r.mu.Unlock()
@@ -72,7 +72,7 @@ func (r *reqRecorder) find(pred func(recordedReq) bool) []recordedReq {
return out
}
// bodyJSON decodes a request body into a plain map, returning nil on failure.
// bodyJSON 把请求体解码为普通 map,失败时返回 nil。
func (q recordedReq) bodyJSON() map[string]any {
var m map[string]any
if err := json.Unmarshal(q.body, &m); err != nil {
@@ -81,24 +81,24 @@ func (q recordedReq) bodyJSON() map[string]any {
return m
}
// jsonBody marshals v into a JSON byte slice.
// jsonBody 把 v 序列化成 JSON 字节切片。
func jsonBody(v any) []byte {
b, _ := json.Marshal(v)
return b
}
// inlineJSON builds a json.RawMessage that inlines when placed in an []any, so
// the fake server can embed pre-serialized message objects into a msgs array.
// inlineJSON 构造一个放进 []any 时会内联的 json.RawMessage,便于假服务端把预序列化
// 的消息对象嵌进 msgs 数组。
func inlineJSON(v any) json.RawMessage { return json.RawMessage(jsonBody(v)) }
// encodeToken builds a base64 bot_token whose trailing bytes are a known key, so
// deriveInboundKey yields a deterministic AES key (design/08 §3.3).
// encodeToken 构造一个 base64 bot_token,其末尾字节是已知密钥,使 deriveInboundKey
// 得到确定性的 AES 密钥(design/08 §3.3)。
func encodeToken(key []byte) string {
return base64.StdEncoding.EncodeToString(append([]byte("prefix!"), key...))
}
// newJSONServer starts an httptest server whose handler records each request
// and answers via fn. The server is closed on test cleanup.
// newJSONServer 启动一个 httptest 服务端,其处理器记录每个请求并通过 fn 作答。
// 测试清理时该服务端会被关闭。
func newJSONServer(t *testing.T, rec *reqRecorder, fn func(r recordedReq) handlerResult) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -132,8 +132,8 @@ func newJSONServer(t *testing.T, rec *reqRecorder, fn func(r recordedReq) handle
return srv
}
// newAdapter builds an Adapter wired to a test server. The caller controls the
// Config (BaseURL overridden from the server when left blank).
// newAdapter 构造一个接到测试服务端的 Adapter。Config 由调用方掌控(BaseURL 留空时
// 用服务端地址覆盖)。
func newAdapter(t *testing.T, cfg Config, srv *httptest.Server) *Adapter {
t.Helper()
if srv != nil && cfg.BaseURL == "" {
@@ -150,7 +150,7 @@ func newAdapter(t *testing.T, cfg Config, srv *httptest.Server) *Adapter {
return ad
}
// newConnectedAdapter builds a connected Adapter carrying a bot token.
// newConnectedAdapter 构造一个带 bot token 的已连接 Adapter。
func newConnectedAdapter(t *testing.T, cfg Config, srv *httptest.Server) *Adapter {
t.Helper()
ad := newAdapter(t, cfg, srv)
@@ -160,12 +160,10 @@ func newConnectedAdapter(t *testing.T, cfg Config, srv *httptest.Server) *Adapte
return ad
}
// conformance guards that the production adapter satisfies the contract at
// compile time.
// conformance 在编译期守护生产适配器满足该契约。
var _ adapter.PlatformAdapter = (*Adapter)(nil)
// waitFor polls fn until it returns true or the timeout elapses, failing the
// test on timeout.
// waitFor 轮询 fn 直到它返回 true 或超时,超时则让测试失败。
func waitFor(t *testing.T, fn func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
@@ -178,13 +176,13 @@ func waitFor(t *testing.T, fn func() bool) {
t.Fatal("condition not met within timeout")
}
// getupdatesReqs returns the recorded getupdates requests.
// getupdatesReqs 返回已记录的 getupdates 请求。
func getupdatesReqs(rec *reqRecorder) []recordedReq {
return rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/getupdates" })
}
// onceUpdates serves first (a raw getupdates body) exactly once, then empty
// polls. Read-loop tests use it so a repeated message is not double-emitted.
// onceUpdates 恰好一次返回 first(一份原始 getupdates 响应体),之后返回空轮询。
// 读循环测试用它,避免重复的消息被重复投递。
func onceUpdates(t *testing.T, rec *reqRecorder, first []byte) *httptest.Server {
t.Helper()
calls := 0
+49 -61
View File
@@ -12,34 +12,32 @@ import (
"time"
)
// loginPollInterval is the default how often Connect polls the QR confirmation
// status. It is the documented ~1s cadence; the Config.LoginInterval field
// overrides it (used by tests to keep the flow short).
// loginPollInterval 是 Connect 轮询二维码确认状态的默认间隔。它是文档记录的约 1s
// 节奏;Config.LoginInterval 字段可覆盖它(测试用它把流程压缩得很短)。
const loginPollInterval = 1 * time.Second
// qrcodeResp is the get_bot_qrcode response.
// qrcodeResp 是 get_bot_qrcode 的响应。
type qrcodeResp struct {
Qrcode string `json:"qrcode"`
QrcodeImgContent string `json:"qrcode_img_content"`
}
// qrcodeStatusResp is the get_qrcode_status response. The token and ids are
// only populated once status is "confirmed".
// qrcodeStatusResp 是 get_qrcode_status 的响应。只有 status 为 "confirmed" 时才会
// 填充 token 与各类 id。
type qrcodeStatusResp struct {
Status string `json:"status"`
BotToken string `json:"bot_token"`
BotID string `json:"ilink_bot_id"`
UserID string `json:"ilink_user_id"`
BaseURL string `json:"baseurl"`
// RedirectHost carries the IDC host to poll next when status is
// "scaned_but_redirect" (official currentApiBaseUrl redirect).
// RedirectHost 携带 status 为 "scaned_but_redirect" 时下一个要轮询的 IDC 主机
// (官方的 currentApiBaseUrl 重定向)。
RedirectHost string `json:"redirect_host"`
}
// login is the Connect-side scan-code binding (design/08 §4). It joins (or
// starts) a scan-login via StartLogin and blocks until the user confirms or the
// window elapses. Because it shares the session machinery with the panel's
// StartLogin, a panel-initiated login and Connect never run two QR binds at once.
// login 是 Connect 一侧的扫码绑定(design/08 §4)。它通过 StartLogin 加入(或发起)
// 一次扫码登录,并阻塞直到用户确认或窗口结束。由于它与面板的 StartLogin 共用同一套
// 会话机制,面板发起的登录与 Connect 绝不会同时跑两个二维码绑定。
func (a *Adapter) login(ctx context.Context) error {
_, err := a.StartLogin(ctx)
if errors.Is(err, ErrAlreadyLoggedIn) {
@@ -65,9 +63,8 @@ func (a *Adapter) login(ctx context.Context) error {
}
}
// runLogin polls get_qrcode_status until the QR is confirmed or the login window
// elapses, then records the outcome under the session. It runs in a goroutine so
// StartLogin returns immediately while the panel polls LoginStatus.
// runLogin 轮询 get_qrcode_status,直到二维码被确认或登录窗口结束,然后把结果记录在
// 会话上。它运行在 goroutine 中,所以 StartLogin 能立即返回,而面板轮询 LoginStatus。
func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
timeoutCtx, cancel := context.WithTimeout(sessCtx, a.cfg.LoginTimeout)
defer cancel()
@@ -78,8 +75,8 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
for {
status, err := a.pollQRStatus(timeoutCtx, sess.qrcode)
if err != nil {
// A deadline/cancel during the poll means the whole window elapsed;
// surface ErrLoginTimeout rather than the raw transport error.
// 轮询期间出现期限到点/取消,意味着整个窗口已过:上报
// ErrLoginTimeout,而不是原始的传输层错误。
if timeoutCtx.Err() != nil {
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
} else {
@@ -87,9 +84,8 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
}
return
}
// Track the raw wire phase so StartLogin can protect a scanned/confirming bind
// from being re-minted. Log only on a status change to avoid noise, and never
// the qrcode identifier or tokens.
// 跟踪线上的原始阶段,便于 StartLogin 保护「已扫描/确认中」的绑定不被重新取码。
// 仅在状态变化时打日志以避免噪声,且绝不记录 qrcode 标识符或 token。
sess.setPhase(status.Status)
if status.Status != prevStatus {
prevStatus = status.Status
@@ -105,18 +101,17 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
a.completeLogin(sess, status)
return
case "binded_redirect":
// The scanned bot is already bound to this instance; no new credentials
// are issued. Treat it as an already-bound (successful) outcome.
// 被扫描的机器人已绑定到本实例,不会签发新凭据。把它当作「已绑定」的
// 成功结果处理。
a.completeBindedRedirect(sess)
return
case "verify_code_blocked":
a.failLogin(sess, fmt.Errorf("weixin_ilink: verify code blocked, too many failed attempts"))
return
case "scaned":
// The code has been scanned and awaits confirmation on the phone. Keep the
// session and keep polling — re-minting here would discard the pending
// confirmation. Expose the state through the backward-compatible waiting
// status plus a detail so the panel can prompt the user to confirm.
// 码已被扫描,正在手机端等待确认。保留该会话并继续轮询——此处重新取码会
// 丢弃待处理的确认。用向后兼容的 waiting 状态加一条详情暴露该状态,
// 便于面板提示用户去确认。
a.setLoginState(LoginStatusWaiting, loginScannedDetail)
case "scaned_but_redirect":
if status.RedirectHost != "" {
@@ -126,13 +121,12 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
a.log.Warn("weixin_ilink: scaned_but_redirect without redirect_host, continuing on current host")
}
case "need_verifycode":
// The server asks for a pairing code shown in the phone WeChat. Expose
// the state and a hint so the panel can prompt the user; the code itself
// is not yet plumbed back to the poll (see TODO).
// 服务端要求输入手机微信上显示的配对码。暴露该状态与提示,便于面板提示
// 用户;该码本身尚未回传到轮询(见 TODO)。
a.setLoginState(LoginStatusNeedVerifyCode, loginNeedVerifyCodeHint)
}
// wait / scaned / expired / need_verifycode / scaned_but_redirect keep
// polling until confirmed or the window elapses.
// wait / scaned / expired / need_verifycode / scaned_but_redirect 会持续轮询,
// 直到确认或窗口结束。
select {
case <-timeoutCtx.Done():
a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err()))
@@ -142,11 +136,10 @@ func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) {
}
}
// completeLogin persists the confirmed credentials and marks the session live.
// It writes cfg under a.mu, then clears the session under loginMu (never nesting
// the two locks), and finally signals the waiting Connect path. A confirmed scan
// delivers a fresh bot_token, which is the canonical way OUT of the rejected state
// (session_state.go): the rescan flag is cleared here.
// completeLogin 持久化已确认的凭据并把会话标记为存活。它在 a.mu 下写 cfg,再在
// loginMu 下清空会话(绝不嵌套两把锁),最后唤醒等待中的 Connect 路径。确认的扫码
// 会交付新的 bot_token,这是走出「被拒绝」状态的正规途径(session_state.go):
// 重扫标志就在这里被清除。
func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) {
a.mu.Lock()
a.cfg.BotToken = status.BotToken
@@ -172,13 +165,11 @@ func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) {
sess.done <- nil
}
// completeBindedRedirect treats binded_redirect as an already-bound outcome: the
// scanned bot is already linked to this instance, so the server issues no new
// credentials and any existing token remains valid. Because NO new token arrives,
// a rejected session stays rejected here — binded_redirect carries no proof that a
// token iLink already rejected with errcode=-14 works again, so the adapter does
// not claim a live session on the strength of it. A confirmed scan (completeLogin)
// is what clears the rejected state.
// completeBindedRedirect 把 binded_redirect 当作「已绑定」的结果:被扫描的机器人
// 已关联到本实例,因此服务端不签发新凭据,已有 token 继续有效。由于**没有**新 token
// 到达,被拒绝的会话在这里保持被拒绝——binded_redirect 并不证明一份已被 iLink 以
// errcode=-14 拒绝的 token 重新可用,所以适配器不凭它宣称会话存活。清除被拒绝状态的
// 是确认的扫码(completeLogin)。
func (a *Adapter) completeBindedRedirect(sess *loginSession) {
a.log.Info("weixin_ilink: login already bound (binded_redirect)")
a.mu.Lock()
@@ -196,10 +187,9 @@ func (a *Adapter) completeBindedRedirect(sess *loginSession) {
sess.done <- nil
}
// failLogin records a failed scan-login and signals the waiting Connect path. A
// superseded session (one cancelled so StartLogin could mint a fresh QR for an
// unscanned code) never clobbers the newer session's state, but its Connect wait
// must still be released.
// failLogin 记录一次失败的扫码登录并唤醒等待中的 Connect 路径。被取代的会话
// (因 StartLogin 要为未扫描的码生成新二维码而被取消的那个)绝不会覆盖新会话的状态,
// 但它等待中的 Connect 仍必须被释放。
func (a *Adapter) failLogin(sess *loginSession, err error) {
a.loginMu.Lock()
isCurrent := a.loginSession == sess
@@ -215,13 +205,12 @@ func (a *Adapter) failLogin(sess *loginSession, err error) {
sess.done <- err
}
// localTokenList returns the locally-held bot tokens to advertise on
// get_bot_qrcode, so an already-bound bot can be detected (binded_redirect). On a
// fresh bind it returns an empty (non-nil) slice so the wire carries `[]` rather
// than `null`, matching the official client. A token iLink REJECTED is deliberately
// not advertised: it is not a usable bind, and offering it lets the server answer
// binded_redirect (no new credentials) — which would leave the owner stuck with an
// unusable credential and no way back. Reporting `[]` asks for a real rebind.
// localTokenList 返回本机持有的 bot token,用于在 get_bot_qrcode 上上报,以便服务端
// 识别出已绑定的机器人(binded_redirect)。全新绑定时返回空的(非 nil)切片,使线上
// 载荷携带 `[]` 而不是 `null`,与官方客户端一致。已被 iLink **拒绝**的 token 刻意
// 不上报:它不是可用绑定,把它交出去会让服务端回以 binded_redirect(不签发新凭据)
// ——那会让主人卡在一份不可用的凭据上、没有回头路。上报 `[]` 才是请求一次真正的
// 重新绑定。
func (a *Adapter) localTokenList() []string {
if rejected, _ := a.rejectedState(); rejected {
return []string{}
@@ -234,9 +223,8 @@ func (a *Adapter) localTokenList() []string {
return []string{a.cfg.BotToken}
}
// fetchQRCode requests a new bot login QR code. The official client issues this as
// a POST carrying local_token_list so the server can detect an existing bind and
// answer binded_redirect instead of a fresh code.
// fetchQRCode 申请一个新的机器人登录二维码。官方客户端以 POST 携带 local_token_list
// 发出该请求,使服务端能探测到已存在的绑定并回以 binded_redirect,而不是新码。
func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) {
path := a.apiURL("/ilink/bot/get_bot_qrcode?bot_type=" + strconv.Itoa(botType))
body, err := json.Marshal(map[string]any{"local_token_list": a.localTokenList()})
@@ -250,14 +238,14 @@ func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) {
if out.Qrcode == "" {
return qrcodeResp{}, fmt.Errorf("weixin_ilink: get_bot_qrcode returned empty qrcode")
}
// Log only the renderable QR URL, never the raw qrcode identifier (it is a secret
// echoed to get_qrcode_status). The timestamp is attached by the logger.
// 只记录可渲染的二维码 URL,绝不记录原始 qrcode 标识符(它是回传给
// get_qrcode_status 的密钥)。时间戳由 logger 附加。
a.log.Info("weixin_ilink: get_bot_qrcode ok", "url", out.QrcodeImgContent)
return out, nil
}
// pollQRStatus checks whether a QR code has been confirmed. It polls the IDC
// redirect host if one was recorded (scaned_but_redirect), else the base URL.
// pollQRStatus 检查某个二维码是否已确认。若已记录 IDC 重定向主机
// (scaned_but_redirect)则轮询它,否则轮询基础 URL。
func (a *Adapter) pollQRStatus(ctx context.Context, qrcode string) (qrcodeStatusResp, error) {
path := "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
var out qrcodeStatusResp
+64 -84
View File
@@ -1,11 +1,9 @@
package weixin_ilink
// This file exposes the non-blocking scan-login surface the admin panel drives
// (design/08 §4): StartLogin mints (or returns the in-progress) QR code without
// blocking, and LoginStatus reports where the bind stands so the panel can poll
// idle → waiting → confirmed. Connect (login.go) reuses StartLogin internally and
// then waits, so a panel-initiated login and the bridge's Connect never start two
// concurrent QR binds.
// 本文件暴露管理面板驱动的非阻塞扫码登录接口(design/08 §4):StartLogin 不阻塞地
// 生成(或返回进行中的)二维码,LoginStatus 报告绑定的进度,便于面板轮询
// idle → waiting → confirmed。Connect(login.go)内部复用 StartLogin 再等待,因此
// 面板发起的登录与桥接层的 Connect 永远不会并发启动两次二维码绑定。
import (
"context"
@@ -15,84 +13,77 @@ import (
"time"
)
// Login status values reported by Adapter.LoginStatus.
// Adapter.LoginStatus 上报的登录状态取值。
const (
// LoginStatusIdle means no bot token and no scan-login in progress.
// LoginStatusIdle 表示既无 bot token,也无进行中的扫码登录。
LoginStatusIdle = "idle"
// LoginStatusWaiting means a QR code is displayed and the user has not yet
// confirmed the bind.
// LoginStatusWaiting 表示二维码已展示,用户尚未确认绑定。
LoginStatusWaiting = "waiting"
// LoginStatusConfirmed means a bot token is present (either a fresh scan or a
// pre-configured one).
// LoginStatusConfirmed 表示已有 bot token(无论是刚扫码得到,还是预先配置的)。
LoginStatusConfirmed = "confirmed"
// LoginStatusFailed means the last scan/login attempt errored or timed out.
// LoginStatusFailed 表示最近一次扫码/登录尝试出错或超时。
LoginStatusFailed = "failed"
// LoginStatusNeedVerifyCode means the WeChat server asks for a pairing code
// shown on the phone WeChat; the panel should prompt the user to enter it.
// LoginStatusNeedVerifyCode 表示微信服务端要求输入手机微信上显示的配对码;
// 面板应提示用户输入。
LoginStatusNeedVerifyCode = "need_verifycode"
)
// loginNeedVerifyCodeHint is the detail LoginStatus reports for need_verifycode so
// the panel can prompt the user; loginBindedRedirectDetail is what a binded_redirect
// outcome reports.
// loginNeedVerifyCodeHint 是 LoginStatus 在 need_verifycode 时上报的详情,便于面板
// 提示用户;loginBindedRedirectDetail 是 binded_redirect 结果上报的内容。
const (
loginNeedVerifyCodeHint = "请输入微信显示的数字"
loginBindedRedirectDetail = "已连接过此 OpenClaw,无需重复连接"
// loginScannedDetail is the detail reported while the QR has been scanned and is
// awaiting confirmation on the phone. It reuses LoginStatusWaiting so the panel
// stays backward-compatible; the raw wire "scaned" state is tracked separately on
// the session (loginSession.phase) so StartLogin can protect the bind from being
// re-minted mid-confirmation.
// loginScannedDetail 是二维码已被扫描、正在手机端等待确认期间上报的详情。它复用
// LoginStatusWaiting,使面板保持向后兼容;线上的原始 "scaned" 状态另行记录在会话上
// (loginSession.phase),使 StartLogin 能保护该绑定不在确认途中被重新取码。
loginScannedDetail = "已被扫描,请在微信确认"
)
// ErrAlreadyLoggedIn is returned by StartLogin when a bot token is already
// configured, so the scan flow is moot.
// ErrAlreadyLoggedIn 由 StartLogin 在已配置 bot token 时返回,此时扫码流程已无必要。
var ErrAlreadyLoggedIn = errors.New("weixin_ilink: already logged in")
// LoginQR is the artifact StartLogin returns for the panel to render: Qrcode is
// the raw scan identifier the adapter echoes to get_qrcode_status, and Image is
// the QR image content (currently the image URL returned by get_bot_qrcode).
// LoginQR 是 StartLogin 返回给面板渲染的产物:Qrcode 是适配器回传给
// get_qrcode_status 的原始扫码标识符,Image 是二维码图片内容(当前为
// get_bot_qrcode 返回的图片 URL)。
type LoginQR struct {
Qrcode string
Image string
}
// loginSession tracks one scan-login run. It is guarded by Adapter.loginMu.
// loginSession 跟踪一次扫码登录。它由 Adapter.loginMu 保护。
type loginSession struct {
qrcode string
image string
startedAt time.Time
// cancel stops the poll goroutine early (e.g. Connect's deadline elapsed).
// cancel 提前停止轮询 goroutine(例如 Connect 的期限已到)。
cancel context.CancelFunc
// done carries the login outcome; the poll goroutine writes exactly once.
// done 承载登录结果;轮询 goroutine 恰好写入一次。
done chan error
// phase is the latest get_qrcode_status value the poll goroutine observed. It is
// guarded by phaseMu and is distinct from loginStatus, which is the panel-observable
// state and stays backward-compatible ("waiting") during a "scaned" confirmation.
// phase 是轮询 goroutine 观测到的最新 get_qrcode_status 取值。它由 phaseMu 保护,
// 且不同于 loginStatus——后者是面板可见状态,在 "scaned" 确认期间保持向后兼容的
// "waiting"。
phaseMu sync.Mutex
phase string
}
// setPhase records the latest observed wire status.
// setPhase 记录最近观测到的线上状态。
func (s *loginSession) setPhase(p string) {
s.phaseMu.Lock()
s.phase = p
s.phaseMu.Unlock()
}
// currentPhase returns the latest observed wire status.
// currentPhase 返回最近观测到的线上状态。
func (s *loginSession) currentPhase() string {
s.phaseMu.Lock()
defer s.phaseMu.Unlock()
return s.phase
}
// confirmingPhase reports whether the QR bind is mid-flow in a way that re-minting a
// fresh code would discard: the code has been scanned and awaits confirmation, the
// server redirected the polling host after a scan, or the server asked for a pairing
// code. Such sessions are reused, never re-minted.
// confirmingPhase 报告该二维码绑定是否正处于「重新取码会丢弃进度」的流程中:
// 码已被扫描、等待确认;服务端在扫描后重定向了轮询主机;或服务端要求配对码。
// 这类会话一律复用,绝不重新取码。
func (s *loginSession) confirmingPhase() bool {
switch s.currentPhase() {
case "scaned", "scaned_but_redirect", "need_verifycode":
@@ -102,52 +93,45 @@ func (s *loginSession) confirmingPhase() bool {
}
}
// qrReuseWindow is how long a just-minted QR is reused on an immediate re-click
// before StartLogin re-mints a fresh one. WeChat bot QRs are short-lived, so a
// "扫码登录/刷新二维码" click must yield a brand-new code — reusing an old QR is
// exactly what makes WeChat report "二维码过期". A QR that is already scanned
// (confirmingPhase) is always reused regardless of this window so the bind is not
// dropped mid-confirmation.
// qrReuseWindow 是刚生成的二维码在立即重复点击时被复用的时长,超过它 StartLogin
// 才会重新取码。微信机器人二维码寿命很短,所以「扫码登录/刷新二维码」的点击必须
// 给出全新码——复用旧码正是让微信报「二维码过期」的原因。已扫描的二维码
// (confirmingPhase)无论该窗口如何都一律复用,使绑定不会在确认途中被丢弃。
const qrReuseWindow = 5 * time.Second
// StartLogin begins a scan-login and returns the QR code to display. It is
// idempotent: when a login is already in progress it returns the current QR
// without issuing a new get_bot_qrcode, and when a bot token is already present it
// returns ErrAlreadyLoggedIn. It never blocks on the user's scan — the status poll
// runs in a background goroutine that LoginStatus reports on.
// StartLogin 发起一次扫码登录并返回待展示的二维码。它是幂等的:已有登录进行中时
// 直接返回当前二维码、不再请求新的 get_bot_qrcode;已有 bot token 时返回
// ErrAlreadyLoggedIn。它绝不阻塞等待用户扫码——状态轮询运行在后台 goroutine 中,
// 由 LoginStatus 上报。
func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
a.loginMu.Lock()
defer a.loginMu.Unlock()
// Already bound → scanning is moot — unless iLink REJECTED the stored token
// (errcode=-14), so it is not a usable bind and the owner must be able to
// re-scan (otherwise the only way out of the rejected state would be
// unreachable). A live token still short-circuits the QR flow as before.
// 已绑定 → 扫码已无必要——除非 iLink 已**拒绝**该存量 token(errcode=-14),
// 此时它不是可用绑定,主人必须能够重新扫码(否则「离开被拒绝状态」的唯一出路
// 将不可达)。仍可用的 token 照旧直接短路扫码流程。
if rejected, _ := a.rejectedState(); a.hasToken() && !rejected {
return LoginQR{}, ErrAlreadyLoggedIn
}
if sess := a.loginSession; sess != nil {
// A bind is in progress. Re-minting would cancel the poll and, if the user has
// already scanned or the server is mid-flow, discard the pending confirmation —
// WeChat's confirm would then never land. Protect those sessions by reusing the
// current QR unconditionally. Only a QR still waiting to be scanned and past the
// tiny reuse window is re-minted, so an explicit "刷新二维码" yields a fresh code.
// 绑定进行中。重新取码会取消轮询;若用户已扫码或服务端正处于流程中途,
// 还会丢弃待处理的确认——微信那边的确认将永远无法落地。因此对这些会话
// 无条件复用当前二维码加以保护。只有仍未被扫描、且已过那个极小复用窗口的
// 二维码才会被重新生成,使显式的「刷新二维码」能拿到全新码。
if sess.confirmingPhase() {
a.log.Info("weixin_ilink: login in progress, reusing qrcode", "phase", sess.currentPhase())
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// Serve the same code while it is still fresh OR already scanned — always
// reuse, never swap, so an in-progress bind (which WeChat may be confirming
// right now) is never dropped. A frontend flooding login-qr calls therefore
// cannot cancel the session under the user's scan. Only a genuinely stale
// (>60s server TTL) and never-scanned code is re-minted.
// 码仍然新鲜或已被扫描时都返回同一个码——一律复用、绝不替换,使进行中的
// 绑定(微信此刻可能正在确认)永不被丢弃。于是前端的 login-qr 洪泛也无法在
// 用户扫码期间取消会话。只有真正过期(超过服务端 60s TTL)且从未被扫描的码
// 才会重新生成。
if time.Since(sess.startedAt) < 60*time.Second {
a.log.Info("weixin_ilink: login in progress, reusing qrcode", "phase", sess.currentPhase())
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// Code stale (>60s) and never scanned: mint a fresh one, cancelling the old
// (expired, unscanned) poll. failLogin guards a cancelled goroutine from
// clobbering the new session's state.
// 码已过期(>60s)且从未被扫描:生成新码,并取消旧的那次(已过期、未扫描的)
// 轮询。failLogin 会防止被取消的 goroutine 覆盖新会话的状态。
a.log.Info("weixin_ilink: previous qrcode not scanned, minting a fresh one")
if sess.cancel != nil {
sess.cancel()
@@ -171,29 +155,25 @@ func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
}
a.loginSession = sess
a.setLoginState(LoginStatusWaiting, "")
// Reset any IDC redirect host recorded by a previous (re-)scan so a new login
// starts polling the configured base URL.
// 清掉上一次(重新)扫码记录的 IDC 重定向主机,使新登录从已配置的基础 URL
// 开始轮询。
a.setPollBaseURL("")
go a.runLogin(sess, sessCtx)
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
}
// LoginStatus reports the current scan-login state. A non-empty bot token
// overrides to "confirmed" even if a stale session is still recorded, so the panel
// always sees the true binding — except when iLink REJECTED that token
// (errcode=-14), so reporting "confirmed" would make the panel re-persist a dead
// credential and call the bind a success. A rejected bind is reported as failed
// with the re-scan reason instead, while a re-scan already in progress
// keeps its own state. detail provides the bot id on confirmation and the failure
// reason on failure.
// LoginStatus 上报当前扫码登录状态。非空 bot token 会强制覆盖为 "confirmed",
// 即使仍记录着陈旧会话,从而面板总能看见真实绑定——除非 iLink **拒绝**了该 token
// (errcode=-14):此时上报 "confirmed" 会让面板重新持久化一份死凭据、并把绑定
// 当作成功。被拒绝的绑定改为以 failed 加上重扫原因上报,而已经进行中的重扫则保留
// 它自己的状态。detail 在确认时给出 bot id,在失败时给出失败原因。
func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
a.mu.Lock()
defer a.mu.Unlock()
if a.rejectedLocked() {
if a.loginStatus == LoginStatusWaiting || a.loginStatus == LoginStatusNeedVerifyCode {
// A re-scan the owner already started: report its progress, not the
// rejected state it is meant to replace.
// 主人已经发起的重扫:上报它自己的进度,而不是它本要取代的那个被拒绝状态。
return a.loginStatus, a.loginDetail
}
return LoginStatusFailed, a.rescanDetail
@@ -204,16 +184,16 @@ func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
return a.loginStatus, a.loginDetail
}
// hasToken reports whether a bot token is configured. It reads cfg, so callers
// must not rely on it while holding a.mu.
// hasToken 报告是否已配置 bot token。它会读 cfg,因此调用方不得在持有 a.mu 时
// 依赖它。
func (a *Adapter) hasToken() bool {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg.BotToken != ""
}
// setLoginState writes the observable login status under a.mu. It may be called
// with loginMu held (StartLogin) or from the poll goroutine; a.mu outlives both.
// setLoginState 在 a.mu 下写入可观测的登录状态。它可能在持有 loginMu 时被调用
// (StartLogin),也可能从轮询 goroutine 调用;a.mu 的生命周期长于两者。
func (a *Adapter) setLoginState(status, detail string) {
a.mu.Lock()
a.loginStatus = status
@@ -8,8 +8,8 @@ import (
"time"
)
// TestStartLogin_WaitThenConfirm drives the panel flow: StartLogin returns the QR,
// the background poll confirms the bind, and LoginStatus transitions.
// TestStartLogin_WaitThenConfirm 驱动面板流程:StartLogin 返回二维码,后台轮询确认
// 绑定,LoginStatus 随之迁移。
func TestStartLogin_WaitThenConfirm(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
@@ -49,8 +49,7 @@ func TestStartLogin_WaitThenConfirm(t *testing.T) {
if got := ad.Config()["bot_token"]; got != "tok-x" {
t.Fatalf("bot_token = %v, want tok-x", got)
}
// The QR mint must be a POST carrying the iLink identify headers per the
// official protocol.
// 按官方协议,取码必须是携带 iLink 标识头的 POST。
for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" }) {
if q.method != http.MethodPost {
t.Fatalf("get_bot_qrcode method = %q, want POST", q.method)
@@ -61,8 +60,8 @@ func TestStartLogin_WaitThenConfirm(t *testing.T) {
}
}
// TestStartLogin_IdempotentWhileWaiting verifies a second StartLogin while a login
// is in progress returns the same QR and never issues a second get_bot_qrcode.
// TestStartLogin_IdempotentWhileWaiting 验证登录进行中时的第二次 StartLogin 返回同一
// 个二维码,且绝不发出第二次 get_bot_qrcode。
func TestStartLogin_IdempotentWhileWaiting(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
@@ -93,8 +92,8 @@ func TestStartLogin_IdempotentWhileWaiting(t *testing.T) {
}
}
// TestStartLogin_AlreadyLoggedIn verifies StartLogin refuses when a token exists and
// LoginStatus reports confirmed.
// TestStartLogin_AlreadyLoggedIn 验证已有 token 时 StartLogin 会拒绝,且 LoginStatus
// 报告 confirmed。
func TestStartLogin_AlreadyLoggedIn(t *testing.T) {
t.Parallel()
ad := newAdapter(t, Config{BaseURL: "https://example.invalid", BotToken: "tok-pre"}, nil)
@@ -107,7 +106,7 @@ func TestStartLogin_AlreadyLoggedIn(t *testing.T) {
}
}
// TestLoginStatus_Idle verifies a fresh adapter reports idle.
// TestLoginStatus_Idle 验证全新适配器报告 idle。
func TestLoginStatus_Idle(t *testing.T) {
t.Parallel()
ad := newAdapter(t, Config{BaseURL: "https://example.invalid"}, nil)
@@ -10,9 +10,9 @@ import (
"time"
)
// TestStartLogin_ScanedExposesWaitingDetail verifies that an observed "scaned" wire
// status is surfaced to the panel as the backward-compatible waiting status with the
// "please confirm" detail, so the frontend keeps its waiting UI without a new status.
// TestStartLogin_ScanedExposesWaitingDetail 验证观测到的线上状态 "scaned" 会以
// 向后兼容的 waiting 状态加「请确认」详情呈现给面板,使前端不新增状态就能保留其
// 等待界面。
func TestStartLogin_ScanedExposesWaitingDetail(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
@@ -38,10 +38,9 @@ func TestStartLogin_ScanedExposesWaitingDetail(t *testing.T) {
})
}
// TestStartLogin_ScanedPastWindowReusesQR verifies the re-mint protection: once the
// poll has observed "scaned" (awaiting confirmation), StartLogin reuses the current QR
// even past the qrReuseWindow and never issues a second get_bot_qrcode — the pending
// confirm is not dropped.
// TestStartLogin_ScanedPastWindowReusesQR 验证重新取码保护:一旦轮询观测到 "scaned"
// (等待确认),StartLogin 即使在超过 qrReuseWindow 后也复用当前二维码,绝不发出第二次
// get_bot_qrcode——待处理的确认不会被丢弃。
func TestStartLogin_ScanedPastWindowReusesQR(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
@@ -64,15 +63,15 @@ func TestStartLogin_ScanedPastWindowReusesQR(t *testing.T) {
t.Fatalf("first StartLogin: %v", err)
}
// Wait until the poll goroutine has observed "scaned" (the session is confirming).
// 等到轮询 goroutine 观测到 "scaned"(该会话处于确认中)。
waitFor(t, func() bool {
ad.loginMu.Lock()
defer ad.loginMu.Unlock()
return ad.loginSession != nil && ad.loginSession.currentPhase() == "scaned"
})
// Push startedAt well past the reuse window so only the protection, not the window,
// could keep the QR. Then a refresh must still reuse it.
// 把 startedAt 推到远超复用窗口之前,使保住该二维码的只能是保护逻辑而非窗口本身。
// 此时一次刷新仍必须复用它。
ad.loginMu.Lock()
if ad.loginSession != nil {
ad.loginSession.startedAt = time.Now().Add(-(60*time.Second + time.Second))
@@ -91,10 +90,9 @@ func TestStartLogin_ScanedPastWindowReusesQR(t *testing.T) {
}
}
// TestStartLogin_WaitingPastWindowReMints verifies the explicit-refresh scenario is
// preserved: a QR that is still waiting (not yet scanned) and is past the reuse window
// is re-minted with a fresh get_bot_qrcode, and the cancelled old poll never clobbers
// the new session's state with "failed".
// TestStartLogin_WaitingPastWindowReMints 验证显式刷新场景仍然成立:仍在等待(尚未
// 被扫描)且已过复用窗口的二维码会用新的 get_bot_qrcode 重新生成,且被取消的旧轮询
// 绝不会用 "failed" 覆盖新会话的状态。
func TestStartLogin_WaitingPastWindowReMints(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
@@ -142,15 +140,14 @@ func TestStartLogin_WaitingPastWindowReMints(t *testing.T) {
if got := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })); got != 2 {
t.Fatalf("get_bot_qrcode called %d times, want 2 (past-window waiting refresh re-mints)", got)
}
// The cancelled old poll must not clobber the freshly-minted waiting state.
// 被取消的旧轮询不得覆盖刚生成出来的 waiting 状态。
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
t.Fatalf("LoginStatus after re-mint = %q, want waiting (superceded session must not set failed)", status)
}
}
// TestLogin_LogRedaction verifies the scan-login path logs only redacted identifiers:
// the renderable QR URL and the bot id are present, but the raw qrcode identifier, the
// bot_token, and the owner user_id are never written.
// TestLogin_LogRedaction 验证扫码登录路径只记录已脱敏的标识:可渲染的二维码 URL 与
// bot id 会出现,但原始 qrcode 标识符、bot_token 以及主人的 user_id 绝不写入。
func TestLogin_LogRedaction(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
@@ -188,9 +185,9 @@ func TestLogin_LogRedaction(t *testing.T) {
logs := buf.String()
for _, want := range []string{
"get_bot_qrcode ok",
"https://img/redact", // sanitized renderable URL
"https://img/redact", // 已脱敏的可渲染 URL
"login confirmed",
"bot-redact", // bot id is non-sensitive
"bot-redact", // bot id 非敏感
} {
if !strings.Contains(logs, want) {
t.Fatalf("logs missing %q:\n%s", want, logs)
@@ -10,10 +10,10 @@ import (
"time"
)
// scriptedTransport answers the weixin_ilink endpoints without network I/O. It
// records the host each get_qrcode_status poll hits so tests can assert the IDC
// redirect routing, and returns get_bot_qrcode with a fixed QR plus get_qrcode_status
// bodies from statusFn (n is the 1-based poll count).
// scriptedTransport 不做网络 I/O 即可应答 weixin_ilink 的各个端点。它记录每次
// get_qrcode_status 轮询命中的主机,便于测试断言 IDC 重定向路由;并用固定二维码
// 应答 get_bot_qrcode,用 statusFn 返回 get_qrcode_status 的响应体(n 是从 1 开始的
// 轮询次数)。
type scriptedTransport struct {
qr []byte
statusFn func(n int) []byte
@@ -54,8 +54,8 @@ func (t *scriptedTransport) hosts() []string {
return append([]string(nil), t.statusHosts...)
}
// newScriptedAdapter builds an Adapter wired to a scripted transport (no real
// network), with a base URL that does not resolve — the transport intercepts it.
// newScriptedAdapter 构造一个接到脚本化传输层的 Adapter(无真实网络),其基础 URL
// 无法解析——由该传输层拦截。
func newScriptedAdapter(cfg Config, tr http.RoundTripper) (*Adapter, error) {
return New(cfg, &http.Client{Transport: tr}, nil)
}
@@ -63,9 +63,9 @@ func newScriptedAdapter(cfg Config, tr http.RoundTripper) (*Adapter, error) {
const scriptedQR = `{"qrcode":"qr-r","qrcode_img_content":"https://img/r"}`
const scriptedBaseURL = "https://ilinkai.weixin.qq.com"
// TestStartLogin_ScanButRedirectRoutesPoll verifies that a scaned_but_redirect
// response with a redirect_host switches subsequent get_qrcode_status polls to
// the https://<redirect_host> IDC host.
// TestStartLogin_ScanButRedirectRoutesPoll 验证带 redirect_host 的
// scaned_but_redirect 响应会把后续 get_qrcode_status 轮询切到
// https://<redirect_host> 这个 IDC 主机。
func TestStartLogin_ScanButRedirectRoutesPoll(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
@@ -106,8 +106,7 @@ func TestStartLogin_ScanButRedirectRoutesPoll(t *testing.T) {
}
}
// TestStartLogin_VerifyCodeBlocked verifies verify_code_blocked transitions the
// login to failed.
// TestStartLogin_VerifyCodeBlocked 验证 verify_code_blocked 会把登录转为失败。
func TestStartLogin_VerifyCodeBlocked(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
@@ -132,8 +131,8 @@ func TestStartLogin_VerifyCodeBlocked(t *testing.T) {
}
}
// TestStartLogin_BindedRedirect verifies binded_redirect is treated as already
// bound (LoginStatus confirmed) rather than a failure.
// TestStartLogin_BindedRedirect 验证 binded_redirect 被当作已绑定(LoginStatus 为
// confirmed)而非失败。
func TestStartLogin_BindedRedirect(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
@@ -158,8 +157,8 @@ func TestStartLogin_BindedRedirect(t *testing.T) {
}
}
// TestStartLogin_NeedVerifyCode verifies need_verifycode surfaces a dedicated
// LoginStatus sub-state with a hint detail, then times out to failed.
// TestStartLogin_NeedVerifyCode 验证 need_verifycode 会暴露一个专属的 LoginStatus
// 子状态并附提示详情,随后超时转为失败。
func TestStartLogin_NeedVerifyCode(t *testing.T) {
t.Parallel()
tr := &scriptedTransport{
@@ -182,7 +181,7 @@ func TestStartLogin_NeedVerifyCode(t *testing.T) {
if _, detail := ad.LoginStatus(context.Background()); detail != loginNeedVerifyCodeHint {
t.Fatalf("need_verifycode detail = %q, want %q", detail, loginNeedVerifyCodeHint)
}
// Without a verify-code回传 path the flow eventually times out to failed.
// 在没有验证码回传路径的情况下,流程最终超时转为失败。
waitFor(t, func() bool {
s, _ := ad.LoginStatus(context.Background())
return s == LoginStatusFailed
+13 -13
View File
@@ -9,9 +9,9 @@ import (
"time"
)
// TestStartLogin_ScanStatusTransition drives the QR bind flow via the out-of-band
// API: StartLogin mints the QR, the background poll walks wait→scaned→confirmed and
// stores the credentials, and LoginStatus surfaces confirmed.
// TestStartLogin_ScanStatusTransition 经带外 API 驱动二维码绑定流程:StartLogin
// 生成二维码,后台轮询走过 wait→scaned→confirmed 并保存凭据,LoginStatus 呈现
// confirmed。
func TestStartLogin_ScanStatusTransition(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
@@ -52,7 +52,7 @@ func TestStartLogin_ScanStatusTransition(t *testing.T) {
t.Fatal("StartLogin returned empty qrcode")
}
// Poll LoginStatus until the background flow confirms.
// 轮询 LoginStatus,直到后台流程确认。
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
status, _ := ad.LoginStatus(context.Background())
@@ -70,7 +70,7 @@ func TestStartLogin_ScanStatusTransition(t *testing.T) {
if got := ad.Config()["bot_id"]; got != "bot-x" {
t.Fatalf("bot_id = %v, want bot-x", got)
}
// Confirm the qrcode id is echoed in the status poll query and the auth header.
// 确认 qrcode id 在状态轮询的查询串与认证头中被回传。
for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_qrcode_status" }) {
if q.query != "qrcode=qr-1" {
t.Fatalf("status poll query = %q, want qrcode=qr-1", q.query)
@@ -79,8 +79,8 @@ func TestStartLogin_ScanStatusTransition(t *testing.T) {
t.Fatalf("status poll AuthorizationType = %q, want ilink_bot_token", q.authType)
}
}
// Confirm the QR mint follows the official protocol: POST, the iLink
// App identify headers, and a local_token_list body (empty on a fresh bind).
// 确认取码遵循官方协议:POST、iLink App 标识头,以及携带 local_token_list 的
// 请求体(全新绑定时为空)。
for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" }) {
if q.method != http.MethodPost {
t.Fatalf("get_bot_qrcode method = %q, want POST", q.method)
@@ -104,8 +104,8 @@ func TestStartLogin_ScanStatusTransition(t *testing.T) {
}
}
// TestStartLogin_Timeout verifies the bind aborts with ErrLoginTimeout when the QR
// is never confirmed, and Connect with an empty token does NOT block (ErrNeedsLogin).
// TestStartLogin_Timeout 验证二维码始终未确认时绑定以 ErrLoginTimeout 终止,且空 token
// 的 Connect **不**阻塞(ErrNeedsLogin)。
func TestStartLogin_Timeout(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
@@ -117,12 +117,12 @@ func TestStartLogin_Timeout(t *testing.T) {
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 25 * time.Millisecond}
ad := newAdapter(t, cfg, srv)
// Connect with no token must NOT block on the QR — it surfaces ErrNeedsLogin so
// the caller routes the owner to the out-of-band 扫码登录 panel.
// 无 token 的 Connect 绝不能阻塞在二维码上——它上报 ErrNeedsLogin,让调用方把
// 主人引向带外的扫码登录面板。
if err := ad.Connect(context.Background()); !errors.Is(err, ErrNeedsLogin) {
t.Fatalf("Connect(empty token) = %v, want ErrNeedsLogin", err)
}
// The out-of-band bind still times out if never confirmed.
// 带外绑定若始终未确认,同样会超时。
if _, err := ad.StartLogin(context.Background()); err != nil {
t.Fatalf("StartLogin: %v", err)
}
@@ -143,4 +143,4 @@ func TestStartLogin_Timeout(t *testing.T) {
}
}
var _ = json.RawMessage{} // keep encoding/json imported for future cases
var _ = json.RawMessage{} // 保留 encoding/json 的导入,供后续用例使用
+11 -15
View File
@@ -11,15 +11,12 @@ import (
"strings"
)
// ErrKeyTooShort is returned when the inbound derivation cannot yield an AES
// key of the required block size.
// ErrKeyTooShort 在入站密钥推导无法给出所需分组长度的 AES 密钥时返回。
var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block")
// deriveInboundKey derives the AES-128 key for an inbound media payload. Per
// design/08 §3.3 the key is extracted from the bot_token: base64-decode the
// token and take the trailing 16 bytes. This is marked ⚠️ in the reference, so
// DownloadMediaWithKey lets callers supply an explicit key when the platform
// carries one per item instead.
// deriveInboundKey 为入站媒体负载推导 AES-128 密钥。按 design/08 §3.3,密钥从
// bot_token 中提取:先 base64 解码 token,再取末尾 16 字节。参考实现把这一步标记为
// ⚠️,因此 DownloadMediaWithKey 允许调用方在平台按每条媒体下发密钥时显式传入密钥。
func deriveInboundKey(botToken string) ([]byte, error) {
token := strings.TrimSpace(botToken)
if token == "" {
@@ -35,9 +32,8 @@ func deriveInboundKey(botToken string) ([]byte, error) {
return b[len(b)-aes.BlockSize:], nil
}
// DownloadMedia downloads an inbound CDN reference and returns the decrypted
// plaintext using the key derived from the configured bot_token. It fails with
// ErrMediaTooLarge when the ciphertext exceeds MaxMediaSize.
// DownloadMedia 下载一条入站 CDN 引用,并用从已配置 bot_token 推导出的密钥返回
// 明文。密文超过 MaxMediaSize 时以 ErrMediaTooLarge 失败。
func (a *Adapter) DownloadMedia(ctx context.Context, cdnURL string) ([]byte, error) {
key, err := deriveInboundKey(a.cfg.BotToken)
if err != nil {
@@ -46,8 +42,8 @@ func (a *Adapter) DownloadMedia(ctx context.Context, cdnURL string) ([]byte, err
return a.downloadWithKey(ctx, cdnURL, key)
}
// DownloadMediaWithKey is DownloadMedia with an explicit per-item AES key,
// used when the protocol delivers the key alongside the media reference.
// DownloadMediaWithKey 是 DownloadMedia 的显式按条 AES 密钥版本,用于协议把密钥
// 与媒体引用一并下发的场景。
func (a *Adapter) DownloadMediaWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
if len(key) != aes.BlockSize {
return nil, fmt.Errorf("weixin_ilink: key must be %d bytes: %w", aes.BlockSize, ErrKeyTooShort)
@@ -55,7 +51,7 @@ func (a *Adapter) DownloadMediaWithKey(ctx context.Context, cdnURL string, key [
return a.downloadWithKey(ctx, cdnURL, key)
}
// downloadWithKey fetches the CDN bytes and removes the AES-ECB layer.
// downloadWithKey 拉取 CDN 字节并剥掉 AES-ECB 层。
func (a *Adapter) downloadWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
ciphertext, err := a.fetchBody(ctx, a.cdnURL(cdnURL))
if err != nil {
@@ -68,8 +64,8 @@ func (a *Adapter) downloadWithKey(ctx context.Context, cdnURL string, key []byte
return plaintext, nil
}
// fetchBody GETs rawURL and returns the body, refusing anything over
// MaxMediaSize so a single huge file cannot exhaust memory.
// fetchBody 对 rawURL 发 GET 并返回响应体,超过 MaxMediaSize 的一律拒绝,避免单个
// 超大文件耗尽内存。
func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
+1 -1
View File
@@ -117,7 +117,7 @@ func TestDownloadMedia_RejectsOversize(t *testing.T) {
func TestDownloadMedia_RejectsDecryptFailure(t *testing.T) {
t.Parallel()
// Serve bytes that are not a valid AES block sequence.
// 返回不构成合法 AES 分组序列的字节。
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path == "/media/bad" {
return handlerResult{ctype: "application/octet-stream", body: []byte("not-a-block")}
+44 -55
View File
@@ -14,40 +14,39 @@ import (
)
const (
// msgTypeUser marks an inbound user message (bot messages are type 2).
// msgTypeUser 标记一条入站用户消息(机器人消息的 type 为 2)。
msgTypeUser = 1
// item type codes from the iLink reference (design 03 §1).
// 来自 iLink 参考的 item 类型码(design 03 §1)。
itemText = 1
itemImage = 2
itemVoice = 3
itemFile = 4
itemVideo = 5
// retryFastCount is how many consecutive failures use the fast retry.
// retryFastCount 是使用快速重试的连续失败次数上限。
retryFastCount = 3
// retryFastDelay is the delay before the first failures.
// retryFastDelay 是头几次失败前的延迟。
retryFastDelay = 2 * time.Second
// retrySlowDelay is the backoff after retryFastCount consecutive failures.
// retrySlowDelay 是连续失败超过 retryFastCount 之后的退避延迟。
retrySlowDelay = 30 * time.Second
)
// getupdatesResp is the getupdates (long-poll) response envelope.
// getupdatesResp 是 getupdates(长轮询)的响应外壳。
type getupdatesResp struct {
// Ret is the top-level business result; non-zero is a transient error.
// Ret 是顶层业务结果;非零表示瞬时错误。
Ret int `json:"ret"`
// Errcode is the platform error code; -14 means iLink rejected the session.
// Errcode 是平台错误码;-14 表示 iLink 拒绝了该会话。
Errcode int `json:"errcode"`
// Msgs holds the raw inbound messages delivered in this poll.
// Msgs 保存本次轮询投递的原始入站消息。
Msgs []json.RawMessage `json:"msgs"`
// GetUpdatesBuf is the next Cursor to echo back.
// GetUpdatesBuf 是下一次要回传的游标(Cursor)。
GetUpdatesBuf string `json:"get_updates_buf"`
// LongPollTimeoutMs is the server's suggested hold time.
// LongPollTimeoutMs 是服务端建议的保持时长。
LongPollTimeoutMs int `json:"longpolling_timeout_ms"`
}
// rawMsg is the parsed common fields of an inbound WeixinMessage. iLink media
// field names are only partially public, so item_list entries are decoded
// defensively in parseItem.
// rawMsg 是一条入站 WeixinMessage 的公共字段解析结果。iLink 的媒体字段名只有一部分
// 是公开的,因此 item_list 条目在 parseItem 中做防御式解码。
type rawMsg struct {
ClientID string `json:"client_id"`
FromUserID string `json:"from_user_id"`
@@ -60,11 +59,9 @@ type rawMsg struct {
ItemList []json.RawMessage `json:"item_list"`
}
// ReadMessages runs the long-poll read loop until ctx is canceled. Each
// converted inbound message is delivered to the handler installed via
// SetMessageHandler when one is present, otherwise to emit. The loop is
// cooperative with ctx: a canceled context returns immediately and no timers
// outlive it.
// ReadMessages 运行长轮询读循环,直到 ctx 被取消。每条转换后的入站消息,在已通过
// SetMessageHandler 安装了处理器时交给它,否则交给 emit。该循环与 ctx 协作:上下文
// 被取消即立即返回,且没有任何定时器会活过它。
func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMessage)) {
a.mu.RLock()
connected := a.connected
@@ -81,7 +78,7 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
if ctx.Err() != nil {
return
}
// A long-poll timeout is the normal "no messages yet" path.
// 长轮询超时是正常的「暂无消息」路径。
if errors.Is(err, context.DeadlineExceeded) {
continue
}
@@ -95,11 +92,10 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
failures = 0
if resp.Errcode == errcodeSessionRejected {
// iLink rejected this session credential: retrying cannot help and the poll
// loop would go mute for good. Record the explicit rejected state (which also
// drops the connected flag and logs at ERROR) and stop the loop; the owner
// has to re-scan, and everything downstream sees ErrSessionRejected until a
// fresh token arrives.
// iLink 拒绝了这份会话凭据:重试无济于事,轮询循环会永久变哑。记录显式的
// 被拒绝状态(它同时清掉 connected 标志并以 ERROR 记录日志)并停止循环;
// 主人必须重新扫码,在新 token 到来之前,下游看到的一律是
// ErrSessionRejected。
a.markRejected(rescanReasonGetUpdates)
return
}
@@ -127,8 +123,8 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
}
}
// deliver routes a normalized message to the handler when installed, else to
// the emit callback (design/08 §1: push-style vs pull-style delivery).
// deliver 在已安装处理器时把归一化消息交给它,否则交给 emit 回调
// (design/08 §1:推送式与拉取式投递)。
func (a *Adapter) deliver(ctx context.Context, m adapter.UnifiedMessage, emit func(adapter.UnifiedMessage)) {
if h := a.handlerLocked(); h != nil {
h(ctx, m)
@@ -139,8 +135,8 @@ func (a *Adapter) deliver(ctx context.Context, m adapter.UnifiedMessage, emit fu
}
}
// fetchUpdates performs one long poll. The request context is bounded by
// LongPollTimeout if the caller has not already set a tighter deadline.
// fetchUpdates 执行一次长轮询。若调用方尚未设置更紧的期限,请求上下文以
// LongPollTimeout 为界。
func (a *Adapter) fetchUpdates(ctx context.Context) (*getupdatesResp, error) {
reqCtx, cancel := context.WithTimeout(ctx, a.cfg.LongPollTimeout)
defer cancel()
@@ -160,8 +156,7 @@ func (a *Adapter) fetchUpdates(ctx context.Context) (*getupdatesResp, error) {
return &out, nil
}
// wait sleeps for d or returns false when ctx is canceled. A non-positive
// delay returns immediately.
// wait 休眠 d,或在 ctx 被取消时返回 false。delay 非正数时立即返回。
func (a *Adapter) wait(ctx context.Context, d time.Duration) bool {
if d <= 0 {
return true
@@ -176,8 +171,7 @@ func (a *Adapter) wait(ctx context.Context, d time.Duration) bool {
}
}
// retryDelay grows from the fast retry to the slow backoff after enough
// consecutive failures (design/08 §5.4).
// retryDelay 在连续失败足够多之后,从快速重试切换到慢速退避(design/08 §5.4)。
func (a *Adapter) retryDelay(failures int) time.Duration {
if failures <= retryFastCount {
return retryFastDelay
@@ -185,9 +179,8 @@ func (a *Adapter) retryDelay(failures int) time.Duration {
return retrySlowDelay
}
// convertMessage maps a raw iLink message to a UnifiedMessage and returns the
// raw context_token so the caller can track it per peer. It returns nil when
// the message is not an inbound user message or cannot be parsed.
// convertMessage 把一条原始 iLink 消息映射为 UnifiedMessage,并返回原始
// context_token 以便调用方按对端跟踪它。消息不是入站用户消息、或无法解析时返回 nil。
func (a *Adapter) convertMessage(raw json.RawMessage) (string, *adapter.UnifiedMessage) {
var rm rawMsg
if err := json.Unmarshal(raw, &rm); err != nil {
@@ -226,10 +219,9 @@ func (a *Adapter) convertMessage(raw json.RawMessage) (string, *adapter.UnifiedM
return rm.ContextToken, m
}
// convertItems drains the item list into text, a primary content type and a
// media slice. Text from text items is concatenated; voice items contribute
// their ASR transcript; image/file/video items are attached lazily as CDN
// references (never downloaded during the poll).
// convertItems 把 item 列表抽取成文本、一个主内容类型和媒体切片。文本 item 的文本
// 会拼接起来;语音 item 贡献其 ASR 转写;图片/文件/视频 item 以 CDN 引用的形式惰性
// 挂上(轮询期间绝不下载)。
func (a *Adapter) convertItems(rawItems []json.RawMessage) (string, adapter.ContentType, []adapter.Media) {
var text strings.Builder
var media []adapter.Media
@@ -260,9 +252,8 @@ func (a *Adapter) convertItems(rawItems []json.RawMessage) (string, adapter.Cont
return content, adapter.ContentText, nil
}
// resolveGroup returns the group identifier when the message carries one,
// otherwise "" (direct chat). WeChat group signalling is ⚠️ per design/08 §4,
// so several candidate fields are checked defensively.
// resolveGroup 在消息携带群标识时返回它,否则返回 ""(私聊)。按 design/08 §4,
// 微信群信号是 ⚠️,因此对多个候选字段做防御式检查。
func resolveGroup(rm *rawMsg) string {
if rm.GroupID != "" {
return rm.GroupID
@@ -270,16 +261,15 @@ func resolveGroup(rm *rawMsg) string {
return rm.RoomID
}
// parsedItem is a normalized view of a single native item_list entry.
// parsedItem 是单个原生 item_list 条目的归一化视图。
type parsedItem struct {
kind int
text string
media adapter.Media
}
// parseItem decodes one item_list entry defensively. iLink media field names
// are not fully public, so a generic map is scanned for the CDN reference and
// metadata with several fallback keys.
// parseItem 防御式地解码一个 item_list 条目。iLink 的媒体字段名并未完全公开,因此
// 用通用 map 配合多个兜底键来扫描 CDN 引用与元数据。
func parseItem(raw json.RawMessage) parsedItem {
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
@@ -302,8 +292,7 @@ func parseItem(raw json.RawMessage) parsedItem {
}
}
// textOf reads the text payload from the named nested sub-object, falling back
// to a flat "text" key.
// textOf 从指定的嵌套子对象读取文本载荷,兜底回退到平铺的 "text" 键。
func textOf(m map[string]any, sub string) string {
if inner, ok := m[sub].(map[string]any); ok {
if s, ok := inner["text"].(string); ok {
@@ -316,8 +305,8 @@ func textOf(m map[string]any, sub string) string {
return ""
}
// mediaOf builds an adapter.Media from a media item, resolving the CDN URL and
// metadata from the flat item or a nested sub-object.
// mediaOf 从媒体 item 构造 adapter.Media,从平铺 item 或嵌套子对象解析 CDN URL
// 与元数据。
func mediaOf(m map[string]any, kind adapter.MediaKind) adapter.Media {
res := adapter.Media{Kind: kind}
res.URL = firstString(m, "url", "cdn_url", "download_url", "file_url", "cdn_ref")
@@ -327,7 +316,7 @@ func mediaOf(m map[string]any, kind adapter.MediaKind) adapter.Media {
if res.URL != "" {
return res
}
// Bounded scan of the common nested sub-objects for the CDN reference.
// 对常见的嵌套子对象做有界扫描,寻找 CDN 引用。
for _, sub := range []string{"image_item", "file_item", "video_item", "media_item", "cdn_item"} {
inner, ok := m[sub].(map[string]any)
if !ok {
@@ -350,7 +339,7 @@ func mediaOf(m map[string]any, kind adapter.MediaKind) adapter.Media {
return res
}
// intOf coerces a JSON number to int, returning 0 for missing or non-numeric.
// intOf 把 JSON 数字强制转换为 int,缺失或非数字时返回 0。
func intOf(v any) int {
f, ok := v.(float64)
if !ok {
@@ -359,7 +348,7 @@ func intOf(v any) int {
return int(f)
}
// firstString returns the first non-empty string among keys.
// firstString 返回 keys 中第一个非空字符串。
func firstString(m map[string]any, keys ...string) string {
for _, k := range keys {
if s, ok := m[k].(string); ok && s != "" {
@@ -369,7 +358,7 @@ func firstString(m map[string]any, keys ...string) string {
return ""
}
// firstInt64 returns the first non-zero integer among keys.
// firstInt64 返回 keys 中第一个非零整数。
func firstInt64(m map[string]any, keys ...string) int64 {
for _, k := range keys {
if f, ok := m[k].(float64); ok && f != 0 {
+4 -4
View File
@@ -10,7 +10,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// msgCollector collects emitted messages under a mutex.
// msgCollector 在互斥锁下收集已投递的消息。
type msgCollector struct {
mu sync.Mutex
msgs []adapter.UnifiedMessage
@@ -70,7 +70,7 @@ func TestReadMessages_LongPoll_CursorAndContextToken(t *testing.T) {
}()
waitFor(t, func() bool { return col.count() >= 2 })
// Ensure the second long poll has run so the cursor echo is observable.
// 确保第二次长轮询已执行,使游标回传可被观测。
waitFor(t, func() bool { return len(getupdatesReqs(rec)) >= 2 })
cancel()
waitDone(t, done)
@@ -141,7 +141,7 @@ func assertCursorAndContext(t *testing.T, ad *Adapter, rec *reqRecorder) {
}
}
// waitDone waits for the read loop goroutine to exit or fails after a timeout.
// waitDone 等待读循环 goroutine 退出,超时则让测试失败。
func waitDone(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
@@ -263,7 +263,7 @@ func TestReadMessages_HandlerDelivery(t *testing.T) {
done := make(chan struct{})
go func() {
defer close(done)
// emit is intentionally nil: the handler must receive the message.
// emit 故意传 nil:消息必须由处理器接收。
ad.ReadMessages(ctx, nil)
}()
waitFor(t, func() bool { return handlerCalls.count() >= 1 })
+33 -36
View File
@@ -16,19 +16,18 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// item kind constants reuse the iLink item type codes from poll.go, so
// outbound media maps to the same numbers as inbound parsing.
// item 类型常量复用 poll.go 里的 iLink item 类型码,使出站媒体与入站解析映射到
// 同一组数字。
// sendResult is the sendmessage response envelope.
// sendResult 是 sendmessage 的响应外壳。
type sendResult struct {
Ret int `json:"ret"`
Errcode int `json:"errcode"`
}
// SendMessage delivers a reply to the channel. Each message is sent to the
// peer resolved from the message (or resp.ReplyTo) using that peer's stored
// context_token, so the platform can associate the reply with the right
// conversation (design/08 §8).
// SendMessage 向渠道投递一条回复。每条消息都发给从该消息(或 resp.ReplyTo)
// 解析出的对端,并使用该对端已存的 context_token,以便平台把回复关联到正确的
// 会话(design/08 §8)。
func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse) error {
if err := a.sendGuard(); err != nil {
return err
@@ -45,11 +44,10 @@ func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse)
return nil
}
// SendProactive pushes a message that was not triggered by a recent inbound.
// WeChat iLink only allows this inside the 24h window since the user's last
// message (design/08 §3-4): when the user has been silent longer, or has never
// messaged, the push is rejected with ErrProactiveWindow and Capabilities.
// Proactive is only true so callers know the capability exists.
// SendProactive 推送一条并非由近期入站消息触发的消息。微信 iLink 只允许在用户
// 最后一条消息之后的 24 小时窗口内这么做(design/08 §3-4):用户沉默更久、或从未
// 发过消息时,推送以 ErrProactiveWindow 被拒,而 Capabilities.Proactive 仅为 true,
// 好让调用方知道该能力存在。
func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
if err := a.sendGuard(); err != nil {
return err
@@ -73,9 +71,9 @@ func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage)
return a.sendToOne(ctx, peer, msg)
}
// sendGuard reports the error a send must surface before touching the network: a
// session iLink already rejected (errcode=-14) reads as ErrSessionRejected — the
// owner must re-scan — while any other down session stays ErrNotConnected.
// sendGuard 报告发送在触网之前必须暴露的错误:iLink 已拒绝的会话(errcode=-14)
// 表现为 ErrSessionRejected——主人必须重新扫码——而其余任何断开状态仍是
// ErrNotConnected。
func (a *Adapter) sendGuard() error {
if rejected, reason := a.rejectedState(); rejected {
return fmt.Errorf("weixin_ilink: send: %w: %s", ErrSessionRejected, reason)
@@ -86,8 +84,8 @@ func (a *Adapter) sendGuard() error {
return nil
}
// sendToOne sends one normalized message to a peer as a native iLink message,
// composing its item_list from any text plus uploaded media.
// sendToOne 把一条归一化消息作为原生 iLink 消息发给某个对端,用文本加上已上传的
// 媒体组装其 item_list。
func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedMessage) error {
if peer == "" {
return ErrNoContextToken
@@ -114,8 +112,8 @@ func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedM
return a.sendNative(ctx, peer, ctxToken, items)
}
// buildMediaItem encrypts media bytes, uploads them to the CDN and returns the
// native item referencing the uploaded object and its AES key.
// buildMediaItem 加密媒体字节、上传到 CDN,并返回引用该已上传对象及其 AES 密钥的
// 原生 item。
func (a *Adapter) buildMediaItem(ctx context.Context, md adapter.Media) (any, error) {
plaintext := md.Data
if len(plaintext) == 0 && md.URL != "" {
@@ -144,14 +142,14 @@ func (a *Adapter) buildMediaItem(ctx context.Context, md adapter.Media) (any, er
return nativeMediaItem(nativeMediaTypeFor(md.Kind), uploadURL, key, md), nil
}
// sendNative posts a composed sendmessage payload.
// sendNative 提交一份组装好的 sendmessage 载荷。
func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items []any) error {
body, err := json.Marshal(map[string]any{
"msg": map[string]any{
"to_user_id": peer,
"client_id": newClientID(),
"message_type": 2, // bot message
"message_state": 2, // FINISH
"message_type": 2, // 机器人消息
"message_state": 2, // FINISH(结束态)
"context_token": ctxToken,
"item_list": items,
},
@@ -165,9 +163,8 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [
return fmt.Errorf("weixin_ilink: sendmessage: %w", err)
}
if out.Errcode == errcodeSessionRejected {
// The send path sees the same rejected credential as the poll path: record
// the rejected state so the adapter stops claiming a live session and the
// panel can ask for a re-scan.
// 发送路径与轮询路径看到的是同一份被拒凭据:记录该拒绝状态,让适配器不再
// 宣称会话存活,面板也就可以要求重新扫码。
a.markRejected(rescanReasonSendMessage)
return ErrSessionRejected
}
@@ -177,8 +174,8 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [
return nil
}
// getUploadURL requests a pre-signed CDN upload URL. The response field names
// vary across server versions, so several candidates are tried.
// getUploadURL 申请一条预签名的 CDN 上传 URL。响应字段名在不同服务端版本间有
// 差异,所以会尝试多个候选键。
func (a *Adapter) getUploadURL(ctx context.Context) (string, error) {
body, err := json.Marshal(map[string]any{})
if err != nil {
@@ -190,8 +187,8 @@ func (a *Adapter) getUploadURL(ctx context.Context) (string, error) {
return "", fmt.Errorf("weixin_ilink: getuploadurl: %w", err)
}
if intOf(raw["errcode"]) == errcodeSessionRejected {
// Media upload is part of the send path, so the same rejected credential is
// recorded here too rather than degrading into "no url returned".
// 媒体上传属于发送路径,所以这里同样记录这份被拒凭据,而不是退化成
//「未返回 url」。
a.markRejected(rescanReasonUploadURL)
return "", ErrSessionRejected
}
@@ -202,7 +199,7 @@ func (a *Adapter) getUploadURL(ctx context.Context) (string, error) {
return u, nil
}
// putBytes uploads encrypted media bytes to a pre-signed CDN URL.
// putBytes 把加密后的媒体字节上传到预签名 CDN URL。
func (a *Adapter) putBytes(ctx context.Context, uploadURL string, data []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(data))
if err != nil {
@@ -220,7 +217,7 @@ func (a *Adapter) putBytes(ctx context.Context, uploadURL string, data []byte) e
return nil
}
// nativeTextItem builds a text item_list entry.
// nativeTextItem 构造一个文本 item_list 条目。
func nativeTextItem(text string) map[string]any {
return map[string]any{
"type": itemText,
@@ -228,8 +225,8 @@ func nativeTextItem(text string) map[string]any {
}
}
// nativeMediaItem builds a media item_list entry that references the uploaded
// object. Both cdn_ref and url are set because the exact field is not public.
// nativeMediaItem 构造一个引用已上传对象的媒体 item_list 条目。同时设置 cdn_ref
// 与 url,因为确切的字段名并未公开。
func nativeMediaItem(kind int, ref string, key []byte, md adapter.Media) map[string]any {
item := map[string]any{
"type": kind,
@@ -245,7 +242,7 @@ func nativeMediaItem(kind int, ref string, key []byte, md adapter.Media) map[str
return item
}
// nativeMediaTypeFor maps a normalized MediaKind to the iLink item type code.
// nativeMediaTypeFor 把归一化的 MediaKind 映射到 iLink item 类型码。
func nativeMediaTypeFor(kind adapter.MediaKind) int {
switch kind {
case adapter.MediaImage:
@@ -259,8 +256,8 @@ func nativeMediaTypeFor(kind adapter.MediaKind) int {
}
}
// newClientID returns a random hex client id used to de-duplicate sends. On an
// (unlikely) crypto/rand failure it falls back to a time-based value.
// newClientID 返回随机的十六进制 client id,用于发送去重。若(极小概率的)
// crypto/rand 失败,则退回到基于时间的值。
func newClientID() string {
var b [12]byte
if _, err := rand.Read(b[:]); err == nil {
+4 -4
View File
@@ -14,7 +14,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// sendOKServer replies ret:0 to sendmessage, recording every request.
// sendOKServer 对 sendmessage 回以 ret:0,并记录每个请求。
func sendOKServer(t *testing.T, rec *reqRecorder) *httptest.Server {
t.Helper()
return newJSONServer(t, rec, func(req recordedReq) handlerResult {
@@ -120,7 +120,7 @@ func TestSendProactive_ExpiredWindow(t *testing.T) {
now := time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)
ad.clock = func() time.Time { return now }
ad.recordInbound("tok-p", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: now})
// Advance beyond the 24h window.
// 推进到 24 小时窗口之外。
now = now.Add(25 * time.Hour)
err := ad.SendProactive(context.Background(), adapter.UnifiedMessage{
@@ -198,7 +198,7 @@ func TestSendMessage_OutboundMedia(t *testing.T) {
t.Fatalf("aes_key = %d bytes, want %d", len(key), aes.BlockSize)
}
// The uploaded bytes (PUT body) must decrypt back to the original media.
// 已上传的字节(PUT 请求体)必须能解密回原始媒体。
puts := rec.find(func(q recordedReq) bool { return q.method == http.MethodPut })
if len(puts) != 1 {
t.Fatalf("PUT requests = %d, want 1", len(puts))
@@ -212,7 +212,7 @@ func TestSendMessage_OutboundMedia(t *testing.T) {
}
}
// getSendBody returns the decoded body of the first sendmessage request.
// getSendBody 返回第一个 sendmessage 请求的解码后请求体。
func getSendBody(t *testing.T, rec *reqRecorder) map[string]any {
t.Helper()
reqs := rec.find(func(q recordedReq) bool {
+34 -44
View File
@@ -1,49 +1,42 @@
package weixin_ilink
// This file holds the adapter's session-rejection state machine (design/08 §4-§5).
// 本文件承载适配器的「会话被拒绝」状态机(design/08 §4-§5)。
//
// iLink answers errcode=-14 when it rejects or invalidates the session credential.
// The bot token is a LONG-LIVED credential — a token obtained by scanning a month
// ago still works — so -14 is NOT a time-based expiry: it means iLink no longer
// accepts this session (unbound, re-logged-in elsewhere, a protocol-level refusal).
// It is worth recording that no log has ever actually shown -14; the earlier
// reading of it as a time-based expiry was an inference, not an observation.
// iLink 在拒绝或作废会话凭据时返回 errcode=-14。bot token 是长期凭据——一个月前
// 扫码拿到的 token 依然可用——所以 -14 不是基于时间的过期:它表示 iLink 已不再接受
// 这个会话(已解绑、在别处重新登录、或协议层拒绝)。值得记下的是,日志里从未真正
// 出现过 -14;早先把 -14 读作「基于时间的过期」只是推断,并非观测。
//
// Either way it is not a transient failure the read loop can retry away: the same
// credential keeps being refused until the owner scans a fresh QR code. The adapter
// therefore keeps an EXPLICIT rejected flag instead of silently dropping the
// connection, so:
// 无论如何,它都不是读循环能靠重试消解的瞬时故障:同一份凭据会一直被拒,直到主人
// 重新扫码。因此适配器保留一个**显式**的 rejected 标志,而不是静默丢弃连接,使得:
//
// - the read loop cannot exit and leave the adapter quietly mute while still
// reporting "connected" (the "credential rejected → silently mute" defect);
// - Connect/HealthCheck/send surface ErrSessionRejected — "re-scan required" —
// rather than a fake OK or a generic "not connected";
// - the admin panel can read RescanRequired() and show 需要重新扫码 (see
// internal/services/gateway/admin.go).
// - 读循环不会退出后让适配器悄悄变哑,却仍上报「connected」(即「凭据被拒 →
// 静默变哑」缺陷);
// - Connect/HealthCheck/发送路径上报 ErrSessionRejected——「需要重新扫码」——
// 而不是假 OK 或笼统的「未连接」;
// - 管理面板可以读 RescanRequired() 并显示 需要重新扫码(见
// internal/services/gateway/admin.go)。
//
// Lifecycle (set once, cleared on a usable credential):
// 生命周期(一次置位,拿到可用凭据后清除):
//
// SET markRejected iLink returned errcode=-14 on getupdates,
// sendmessage or getuploadurl.
// CLEAR clearRescanLocked a scan-code login delivered a new bot_token
// (completeLogin), or
// rejectedLocked saw a bot_token different from the one
// iLink rejected — a config change / hot reload that
// supplied a fresh token.
// SET markRejected getupdates、sendmessage 或 getuploadurl
// 返回 errcode=-14。
// CLEAR clearRescanLocked 扫码登录交付了新的 bot_token(completeLogin),或
// rejectedLocked 发现 bot_token 已不同于 iLink 拒绝的
// 那一个——配置变更 / 热重载提供了新 token。
// Rescan reasons recorded on the rejected state. They are short, human-readable
// and carry no credential: the bot token itself never leaves cfg.
// 记录在「被拒绝」状态上的重扫原因。它们简短、人类可读,且不携带任何凭据:
// bot token 本身从不离开 cfg。
const (
rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)"
rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(本次会话已被 iLink 拒绝)"
rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(本次会话已被 iLink 拒绝)"
)
// markRejected records that iLink rejected the current session credential
// (errcode=-14). It drops the connected flag so IsConnected can never keep claiming
// a live session for a rejected credential, and logs the transition at ERROR level
// exactly once (a failing poll loop or a repeated send must not flood the log). Only
// the non-secret bot_id and the reason are logged.
// markRejected 记录 iLink 拒绝了当前会话凭据(errcode=-14)。它清掉 connected
// 标志,使 IsConnected 永不为一已被拒绝的凭据继续宣称会话存活;并以 ERROR 级别
// 恰好记录一次该状态转移(失败的轮询循环或反复的发送不得刷屏日志)。只记录非敏感的
// bot_id 与原因。
func (a *Adapter) markRejected(reason string) {
a.mu.Lock()
first := !a.rejected
@@ -60,17 +53,16 @@ func (a *Adapter) markRejected(reason string) {
}
}
// clearRescanLocked drops the rejected state. Callers must hold a.mu.
// clearRescanLocked 清除被拒绝状态。调用方须持有 a.mu。
func (a *Adapter) clearRescanLocked() {
a.rejected = false
a.rejectedToken = ""
a.rescanDetail = ""
}
// rejectedLocked reports whether iLink rejected the session. It first clears the
// state when the configured bot_token is no longer the one iLink rejected, so a
// fresh token (scan login, operator config change, hot reload) restores the session
// without extra bookkeeping. Callers must hold a.mu.
// rejectedLocked 报告 iLink 是否拒绝了该会话。当已配置的 bot_token 不再是 iLink
// 拒绝的那一个时,它会先清除该状态,于是新 token(扫码登录、运维改配置、热重载)
// 无需额外簿记即可恢复会话。调用方须持有 a.mu。
func (a *Adapter) rejectedLocked() bool {
if a.rejected && a.rejectedToken != a.cfg.BotToken {
a.clearRescanLocked()
@@ -78,8 +70,8 @@ func (a *Adapter) rejectedLocked() bool {
return a.rejected
}
// rejectedState reports the rejected flag together with the rescan reason in one
// lock acquisition. reason is "" when the session has not been rejected.
// rejectedState 在一次加锁内同时返回「被拒绝」标志与重扫原因。会话未被拒绝时
// reason 为 ""。
func (a *Adapter) rejectedState() (bool, string) {
a.mu.Lock()
defer a.mu.Unlock()
@@ -89,11 +81,9 @@ func (a *Adapter) rejectedState() (bool, string) {
return true, a.rescanDetail
}
// RescanRequired implements the optional capability the admin panel reads by type
// assertion (internal/services/gateway/admin.go): it reports whether this adapter
// needs a new scan-code login instead of a plain reconnect, plus the
// human-readable reason (never a secret). A healthy adapter reports false with an
// empty reason.
// RescanRequired 实现管理面板用类型断言读取的可选能力
// (internal/services/gateway/admin.go):它报告该适配器是否需要重新扫码登录而非
// 简单重连,并给出人类可读的原因(绝不含密钥)。健康的适配器返回 false 与空原因。
func (a *Adapter) RescanRequired() (bool, string) {
return a.rejectedState()
}
@@ -11,13 +11,12 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// These tests cover the explicit session-rejection state machine (session_state.go):
// iLink answering errcode=-14 must be visible as "re-scan required" instead of a
// silently mute adapter or a connect that reports success with a credential iLink
// already refused.
// 这些测试覆盖显式的「会话被拒绝」状态机(session_state.go):iLink 回复
// errcode=-14 必须表现为「需要重新扫码」,而不是悄悄变哑的适配器、或一次拿着已被
// iLink 拒绝的凭据却上报成功的连接。
// rejectingServer answers every getupdates long poll with the rejected-session
// errcode, the way iLink refuses a session credential.
// rejectingServer 对每次 getupdates 长轮询都回以「会话被拒绝」错误码,正如 iLink
// 拒绝一份会话凭据那样。
func rejectingServer(t *testing.T) *httptest.Server {
t.Helper()
return newJSONServer(t, nil, func(req recordedReq) handlerResult {
@@ -28,9 +27,8 @@ func rejectingServer(t *testing.T) *httptest.Server {
})
}
// rejectByPoll drives the adapter into the rejected state through the long poll:
// ReadMessages must return on errcode=-14 and the adapter must report the
// rescan-required state.
// rejectByPoll 通过长轮询把适配器驱入被拒绝状态:ReadMessages 必须在 errcode=-14 时
// 返回,且适配器必须报告「需要重新扫码」状态。
func rejectByPoll(t *testing.T, ad *Adapter) {
t.Helper()
done := make(chan struct{})
@@ -44,8 +42,7 @@ func rejectByPoll(t *testing.T, ad *Adapter) {
}
}
// newRejectedByPollAdapter builds a connected adapter and drives it into the
// rejected state via the poll path.
// newRejectedByPollAdapter 构造一个已连接的适配器,并经轮询路径把它驱入被拒绝状态。
func newRejectedByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
t.Helper()
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-old"}, srv)
@@ -53,10 +50,9 @@ func newRejectedByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
return ad
}
// assertRejectedSession asserts the observable contract of a session iLink rejected:
// not connected, rescan required with a reason, HealthCheck distinguishing the
// rejected state from not-connected, and Connect refusing with ErrSessionRejected
// instead of a fake OK.
// assertRejectedSession 断言「会话被 iLink 拒绝」的可观测契约:未连接、需要重新扫码
// 且有原因、HealthCheck 能把被拒绝状态与未连接区分开、以及 Connect 以
// ErrSessionRejected 拒绝而不是假 OK。
func assertRejectedSession(t *testing.T, ad *Adapter) {
t.Helper()
if ad.IsConnected() {
@@ -120,7 +116,7 @@ func TestSessionRejected_SendMarksRescan(t *testing.T) {
if ad.IsConnected() {
t.Fatal("IsConnected() = true after a send saw errcode=-14")
}
// A later send reports the rejected session itself, not a bare "not connected".
// 之后的发送上报的是会话被拒绝本身,而不是光秃秃的「未连接」。
err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "again"}},
})
@@ -156,8 +152,8 @@ func TestSessionRejected_ClearedByScanLogin(t *testing.T) {
ad := newRejectedByPollAdapter(t, srv)
assertRejectedSession(t, ad)
// A rejected token must not short-circuit the scan flow: it is not a usable
// bind, and ErrAlreadyLoggedIn would leave the owner no way back.
// 被拒绝的 token 不得短路扫码流程:它不是可用绑定,而 ErrAlreadyLoggedIn 会让
// 主人无路可退。
qr, err := ad.StartLogin(context.Background())
if err != nil {
t.Fatalf("StartLogin() on a rejected session = %v, want a fresh QR", err)
@@ -166,7 +162,7 @@ func TestSessionRejected_ClearedByScanLogin(t *testing.T) {
t.Fatalf("StartLogin() qrcode = %q, want qr-re", qr.Qrcode)
}
// The confirmed scan delivers a new token: the rejected state must clear.
// 确认的扫码交付了新 token:被拒绝状态必须被清除。
waitFor(t, func() bool { ok, _ := ad.RescanRequired(); return !ok })
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect() after re-scan = %v, want nil", err)
@@ -184,8 +180,8 @@ func TestSessionRejected_ClearedByScanLogin(t *testing.T) {
t.Fatalf("LoginStatus() = %q, want confirmed after a successful re-scan", status)
}
// The re-scan asked for a real rebind: the rejected token is not advertised as a
// local token (that is what invites binded_redirect with no new credentials).
// 本次重扫请求的是真正的重新绑定:被拒绝的 token 不作为本地 token 上报(正是
// 上报它才会招来不带新凭据的 binded_redirect)。
reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })
if len(reqs) != 1 {
t.Fatalf("get_bot_qrcode requests = %d, want 1", len(reqs))
@@ -202,7 +198,7 @@ func TestSessionRejected_ClearedByNewToken(t *testing.T) {
srv := rejectingServer(t)
ad := newRejectedByPollAdapter(t, srv)
// The rejected token is still configured: the session stays rejected.
// 被拒绝的 token 仍在配置中:会话保持被拒绝。
if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionRejected) {
t.Fatalf("Connect() = %v, want ErrSessionRejected while the rejected token is unchanged", err)
}
@@ -245,8 +241,7 @@ func TestSessionRejected_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
}, srv)
rejectByPoll(t, ad)
// A rejected credential must not read as "confirmed": the panel would re-persist
// it and call the bind a success.
// 被拒绝的凭据不得被读作 "confirmed":否则面板会重新持久化它并把绑定当作成功。
status, detail := ad.LoginStatus(context.Background())
if status != LoginStatusFailed {
t.Fatalf("LoginStatus() = %q, want failed while iLink rejects the stored token", status)
@@ -255,8 +250,7 @@ func TestSessionRejected_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
t.Fatal("LoginStatus() detail is empty, want the re-scan reason")
}
// The panel's 扫码登录 must still mint a QR (ErrAlreadyLoggedIn would block the
// only recovery path).
// 面板的扫码登录仍必须能生成二维码(ErrAlreadyLoggedIn 会堵死唯一的恢复路径)。
qr, err := ad.StartLogin(context.Background())
if err != nil {
t.Fatalf("StartLogin() = %v, want a fresh QR", err)
@@ -264,16 +258,15 @@ func TestSessionRejected_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
if qr.Qrcode != "qr-2" {
t.Fatalf("StartLogin() qrcode = %q, want qr-2", qr.Qrcode)
}
// While the re-scan is in progress its own state is reported, not the rejected one.
// 重扫进行期间上报的是它自己的状态,而不是被拒绝状态。
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
t.Fatalf("LoginStatus() during the re-scan = %q, want waiting", status)
}
}
// setBotToken swaps the configured bot token, standing in for a panel save +
// hot reload (which rebuilds the adapter) or any other in-place config refresh;
// the adapter has no public setter because production always constructs a fresh
// instance from the new config.
// setBotToken 替换已配置的 bot token,用来模拟面板保存 + 热重载(会重建适配器)
// 或任何其他原地配置刷新;适配器没有公开的 setter,因为生产环境总是依据新配置构造
// 全新实例。
func setBotToken(ad *Adapter, token string) {
ad.mu.Lock()
ad.cfg.BotToken = token