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