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