docs(comments): 注释中文化 · plugins/channels/weixin_ilink(20 文件,纯替换)
- 只翻注释/文档字符串:剥掉行尾注释后,每条「-」都有逐字节相同的「+」对应行(8 组,代码零改动) - 保留协议/线上标识符原文:errcode、bot_token、context_token、getupdates、binded_redirect、PKCS#7、ECB 等 - 术语对齐既有译法:owner→主人、peer→对端、long poll→长轮询、backoff→退避、sentinel error→哨兵错误 - 已知既有欠债(未顺手修):login_reuse_test.go 在 HEAD 上就未 gofmt 干净;login_test.go 的 TestStartLogin_ScanStatusTransition 91 行 >80(HEAD 同为 91 行)
This commit is contained in:
1 parent
09fba77f87
commit
95f97f5df3
20 files changed
+488
-590
No files matched your search
@@ -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 行的上限之内。
|
||||
@@ -1,9 +1,8 @@
|
||||
package weixin_ilink
|
||||
|
||||
// This file holds the HTTP plumbing for the iLink adapter (request building and
|
||||
// response decoding). It is split from adapter.go so that file stays under the
|
||||
// project's ≤500-line source-file limit. Nothing here exports a symbol the
|
||||
// rest of the package or its tests rely on being in a specific file.
|
||||
// 本文件承载 iLink 适配器的 HTTP 管道(请求构造与响应解码)。它从 adapter.go
|
||||
// 拆出,以便那个文件保持在项目 ≤500 行的单文件上限之内。这里不放任何「包内其余
|
||||
// 代码或其测试依赖其位于某个特定文件」的导出符号。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -19,9 +18,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// authorizationHeader decorates req with the iLink common headers: JSON
|
||||
// content type, the bearer token, the auth type, a fresh random
|
||||
// X-WECHAT-UIN replay guard, and the App identify headers.
|
||||
// authorizationHeader 为 req 装上 iLink 通用请求头:JSON 内容类型、bearer
|
||||
// token、认证类型、每次新生成的随机 X-WECHAT-UIN 防重放值,以及 App 标识头。
|
||||
func (a *Adapter) authorizationHeader(req *http.Request) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("AuthorizationType", "ilink_bot_token")
|
||||
@@ -36,8 +34,8 @@ func (a *Adapter) authorizationHeader(req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// randomUIN returns a base64-encoded decimal uint32, minted fresh per request
|
||||
// as iLink's anti-replay value.
|
||||
// randomUIN 返回 base64 编码的十进制 uint32,每次请求都新生成,作为 iLink 的
|
||||
// 防重放值。
|
||||
func randomUIN() string {
|
||||
var b [4]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
@@ -45,9 +43,8 @@ func randomUIN() string {
|
||||
return base64.StdEncoding.EncodeToString([]byte(fmt.Sprint(n)))
|
||||
}
|
||||
|
||||
// doJSON performs an HTTP request and decodes the JSON response into out. It
|
||||
// closes the response body and wraps network/decode errors. A nil out skips
|
||||
// decoding (used for PUTs whose body is opaque bytes).
|
||||
// doJSON 发起一次 HTTP 请求并把 JSON 响应解码进 out。它关闭响应体,并用 %w
|
||||
// 包裹网络/解码错误。out 为 nil 时跳过解码(用于响应体是不透明字节的 PUT)。
|
||||
func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Reader, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
|
||||
if err != nil {
|
||||
@@ -76,19 +73,18 @@ func (a *Adapter) doJSON(ctx context.Context, method, rawURL string, body io.Rea
|
||||
return nil
|
||||
}
|
||||
|
||||
// apiURL joins a path with the configured base URL.
|
||||
// apiURL 把路径与已配置的基础 URL 拼接起来。
|
||||
func (a *Adapter) apiURL(path string) string {
|
||||
return joinURL(a.cfg.BaseURL, path)
|
||||
}
|
||||
|
||||
// joinURL joins a base URL with a leading-slash path, tolerating a trailing slash
|
||||
// on the base.
|
||||
// joinURL 把基础 URL 与以斜杠开头的路径拼接起来,并容忍基础 URL 末尾带斜杠。
|
||||
func joinURL(base, path string) string {
|
||||
return strings.TrimRight(base, "/") + path
|
||||
}
|
||||
|
||||
// pollBase returns the base URL for get_qrcode_status: the IDC redirect host when
|
||||
// one was recorded (scaned_but_redirect), else the configured base URL.
|
||||
// pollBase 返回 get_qrcode_status 使用的基础 URL:已记录 IDC 重定向主机
|
||||
// (scaned_but_redirect)时用它,否则用已配置的基础 URL。
|
||||
func (a *Adapter) pollBase() string {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
@@ -98,9 +94,8 @@ func (a *Adapter) pollBase() string {
|
||||
return a.cfg.BaseURL
|
||||
}
|
||||
|
||||
// setPollBaseURL records an IDC redirect host (https://<redirect_host>) for
|
||||
// subsequent get_qrcode_status polls, per the official currentApiBaseUrl logic.
|
||||
// An empty host clears the override.
|
||||
// setPollBaseURL 按官方 currentApiBaseUrl 的逻辑,为后续的 get_qrcode_status
|
||||
// 轮询记录一个 IDC 重定向主机(https://<redirect_host>)。host 为空则清除该覆盖。
|
||||
func (a *Adapter) setPollBaseURL(host string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -111,9 +106,9 @@ func (a *Adapter) setPollBaseURL(host string) {
|
||||
a.pollBaseURL = "https://" + host
|
||||
}
|
||||
|
||||
// buildClientVersion encodes a semantic version into the iLink App-ClientVersion
|
||||
// wire value 0x00MMNNPP = (major&0xff)<<16 | (minor&0xff)<<8 | (patch&0xff), as
|
||||
// the official api.ts does. e.g. "2.4.8" -> "132104" (a decimal string, not "1").
|
||||
// buildClientVersion 按官方 api.ts 的做法,把语义化版本编码成 iLink
|
||||
// App-ClientVersion 的线上值 0x00MMNNPP = (major&0xff)<<16 | (minor&0xff)<<8 |
|
||||
// (patch&0xff)。例如 "2.4.8" -> "132104"(十进制字符串,而不是 "1")。
|
||||
func buildClientVersion(version string) string {
|
||||
parts := strings.Split(version, ".")
|
||||
at := func(i int) int {
|
||||
@@ -130,8 +125,8 @@ func buildClientVersion(version string) string {
|
||||
return strconv.Itoa(v)
|
||||
}
|
||||
|
||||
// cdnURL joins a path with the configured CDN base URL, escaping raw query
|
||||
// strings that some CDN references carry.
|
||||
// cdnURL 把路径与已配置的 CDN 基础 URL 拼接起来,并转义部分 CDN 引用携带的原始
|
||||
// 查询串。
|
||||
func (a *Adapter) cdnURL(raw string) string {
|
||||
if u, err := url.Parse(raw); err == nil && u.IsAbs() {
|
||||
return raw
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestAdapter_Config_ReturnsSnapshot(t *testing.T) {
|
||||
if cfg["base_url"] != "https://ilinkai.weixin.qq.com" {
|
||||
t.Fatalf("Config()[base_url] = %v", cfg["base_url"])
|
||||
}
|
||||
// Round-tripping through DecodeConfig must preserve the typed config.
|
||||
// 经 DecodeConfig 往返必须保留类型化配置。
|
||||
var back Config
|
||||
if err := adapter.DecodeConfig(cfg, &back); err != nil {
|
||||
t.Fatalf("DecodeConfig: %v", err)
|
||||
@@ -75,7 +75,7 @@ func TestAdapter_Connect_WithToken_IsIdempotent(t *testing.T) {
|
||||
if !ad.IsConnected() {
|
||||
t.Fatal("IsConnected() = false after Connect")
|
||||
}
|
||||
// Second call is a no-op success.
|
||||
// 第二次调用是无副作用的成功。
|
||||
if err := ad.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("second Connect: %v", err)
|
||||
}
|
||||
@@ -149,7 +149,7 @@ func TestAdapter_SetMessageHandler_And_RecordInbound(t *testing.T) {
|
||||
t.Fatal("handler called before any message")
|
||||
}
|
||||
|
||||
// recordInbound must refresh the per-peer token and last-message time.
|
||||
// recordInbound 必须刷新按对端的 token 与最后消息时间。
|
||||
ad.recordInbound("tok-abc", adapter.UnifiedMessage{
|
||||
SenderID: "user-1",
|
||||
Timestamp: ad.clock(),
|
||||
@@ -162,8 +162,8 @@ func TestAdapter_SetMessageHandler_And_RecordInbound(t *testing.T) {
|
||||
func TestBuildClientVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]string{
|
||||
"2.4.8": "132104", // official @tencent-weixin/openclaw-weixin 2.4.8
|
||||
"1.0.11": "65547", // official comment example 0x0001000B
|
||||
"2.4.8": "132104", // 官方 @tencent-weixin/openclaw-weixin 2.4.8
|
||||
"1.0.11": "65547", // 官方注释示例 0x0001000B
|
||||
"1.2.3": "66051",
|
||||
"0.0.0": "0",
|
||||
"bad": "0",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package weixin_ilink
|
||||
|
||||
// This file exposes the non-blocking scan-login surface the admin panel drives
|
||||
// (design/08 §4): StartLogin mints (or returns the in-progress) QR code without
|
||||
// blocking, and LoginStatus reports where the bind stands so the panel can poll
|
||||
// idle → waiting → confirmed. Connect (login.go) reuses StartLogin internally and
|
||||
// then waits, so a panel-initiated login and the bridge's Connect never start two
|
||||
// concurrent QR binds.
|
||||
// 本文件暴露管理面板驱动的非阻塞扫码登录接口(design/08 §4):StartLogin 不阻塞地
|
||||
// 生成(或返回进行中的)二维码,LoginStatus 报告绑定的进度,便于面板轮询
|
||||
// idle → waiting → confirmed。Connect(login.go)内部复用 StartLogin 再等待,因此
|
||||
// 面板发起的登录与桥接层的 Connect 永远不会并发启动两次二维码绑定。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -15,84 +13,77 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Login status values reported by Adapter.LoginStatus.
|
||||
// Adapter.LoginStatus 上报的登录状态取值。
|
||||
const (
|
||||
// LoginStatusIdle means no bot token and no scan-login in progress.
|
||||
// LoginStatusIdle 表示既无 bot token,也无进行中的扫码登录。
|
||||
LoginStatusIdle = "idle"
|
||||
// LoginStatusWaiting means a QR code is displayed and the user has not yet
|
||||
// confirmed the bind.
|
||||
// LoginStatusWaiting 表示二维码已展示,用户尚未确认绑定。
|
||||
LoginStatusWaiting = "waiting"
|
||||
// LoginStatusConfirmed means a bot token is present (either a fresh scan or a
|
||||
// pre-configured one).
|
||||
// LoginStatusConfirmed 表示已有 bot token(无论是刚扫码得到,还是预先配置的)。
|
||||
LoginStatusConfirmed = "confirmed"
|
||||
// LoginStatusFailed means the last scan/login attempt errored or timed out.
|
||||
// LoginStatusFailed 表示最近一次扫码/登录尝试出错或超时。
|
||||
LoginStatusFailed = "failed"
|
||||
// LoginStatusNeedVerifyCode means the WeChat server asks for a pairing code
|
||||
// shown on the phone WeChat; the panel should prompt the user to enter it.
|
||||
// LoginStatusNeedVerifyCode 表示微信服务端要求输入手机微信上显示的配对码;
|
||||
// 面板应提示用户输入。
|
||||
LoginStatusNeedVerifyCode = "need_verifycode"
|
||||
)
|
||||
|
||||
// loginNeedVerifyCodeHint is the detail LoginStatus reports for need_verifycode so
|
||||
// the panel can prompt the user; loginBindedRedirectDetail is what a binded_redirect
|
||||
// outcome reports.
|
||||
// loginNeedVerifyCodeHint 是 LoginStatus 在 need_verifycode 时上报的详情,便于面板
|
||||
// 提示用户;loginBindedRedirectDetail 是 binded_redirect 结果上报的内容。
|
||||
const (
|
||||
loginNeedVerifyCodeHint = "请输入微信显示的数字"
|
||||
loginBindedRedirectDetail = "已连接过此 OpenClaw,无需重复连接"
|
||||
// loginScannedDetail is the detail reported while the QR has been scanned and is
|
||||
// awaiting confirmation on the phone. It reuses LoginStatusWaiting so the panel
|
||||
// stays backward-compatible; the raw wire "scaned" state is tracked separately on
|
||||
// the session (loginSession.phase) so StartLogin can protect the bind from being
|
||||
// re-minted mid-confirmation.
|
||||
// loginScannedDetail 是二维码已被扫描、正在手机端等待确认期间上报的详情。它复用
|
||||
// LoginStatusWaiting,使面板保持向后兼容;线上的原始 "scaned" 状态另行记录在会话上
|
||||
// (loginSession.phase),使 StartLogin 能保护该绑定不在确认途中被重新取码。
|
||||
loginScannedDetail = "已被扫描,请在微信确认"
|
||||
)
|
||||
|
||||
// ErrAlreadyLoggedIn is returned by StartLogin when a bot token is already
|
||||
// configured, so the scan flow is moot.
|
||||
// ErrAlreadyLoggedIn 由 StartLogin 在已配置 bot token 时返回,此时扫码流程已无必要。
|
||||
var ErrAlreadyLoggedIn = errors.New("weixin_ilink: already logged in")
|
||||
|
||||
// LoginQR is the artifact StartLogin returns for the panel to render: Qrcode is
|
||||
// the raw scan identifier the adapter echoes to get_qrcode_status, and Image is
|
||||
// the QR image content (currently the image URL returned by get_bot_qrcode).
|
||||
// LoginQR 是 StartLogin 返回给面板渲染的产物:Qrcode 是适配器回传给
|
||||
// get_qrcode_status 的原始扫码标识符,Image 是二维码图片内容(当前为
|
||||
// get_bot_qrcode 返回的图片 URL)。
|
||||
type LoginQR struct {
|
||||
Qrcode string
|
||||
Image string
|
||||
}
|
||||
|
||||
// loginSession tracks one scan-login run. It is guarded by Adapter.loginMu.
|
||||
// loginSession 跟踪一次扫码登录。它由 Adapter.loginMu 保护。
|
||||
type loginSession struct {
|
||||
qrcode string
|
||||
image string
|
||||
startedAt time.Time
|
||||
// cancel stops the poll goroutine early (e.g. Connect's deadline elapsed).
|
||||
// cancel 提前停止轮询 goroutine(例如 Connect 的期限已到)。
|
||||
cancel context.CancelFunc
|
||||
// done carries the login outcome; the poll goroutine writes exactly once.
|
||||
// done 承载登录结果;轮询 goroutine 恰好写入一次。
|
||||
done chan error
|
||||
|
||||
// phase is the latest get_qrcode_status value the poll goroutine observed. It is
|
||||
// guarded by phaseMu and is distinct from loginStatus, which is the panel-observable
|
||||
// state and stays backward-compatible ("waiting") during a "scaned" confirmation.
|
||||
// phase 是轮询 goroutine 观测到的最新 get_qrcode_status 取值。它由 phaseMu 保护,
|
||||
// 且不同于 loginStatus——后者是面板可见状态,在 "scaned" 确认期间保持向后兼容的
|
||||
// "waiting"。
|
||||
phaseMu sync.Mutex
|
||||
phase string
|
||||
}
|
||||
|
||||
// setPhase records the latest observed wire status.
|
||||
// setPhase 记录最近观测到的线上状态。
|
||||
func (s *loginSession) setPhase(p string) {
|
||||
s.phaseMu.Lock()
|
||||
s.phase = p
|
||||
s.phaseMu.Unlock()
|
||||
}
|
||||
|
||||
// currentPhase returns the latest observed wire status.
|
||||
// currentPhase 返回最近观测到的线上状态。
|
||||
func (s *loginSession) currentPhase() string {
|
||||
s.phaseMu.Lock()
|
||||
defer s.phaseMu.Unlock()
|
||||
return s.phase
|
||||
}
|
||||
|
||||
// confirmingPhase reports whether the QR bind is mid-flow in a way that re-minting a
|
||||
// fresh code would discard: the code has been scanned and awaits confirmation, the
|
||||
// server redirected the polling host after a scan, or the server asked for a pairing
|
||||
// code. Such sessions are reused, never re-minted.
|
||||
// confirmingPhase 报告该二维码绑定是否正处于「重新取码会丢弃进度」的流程中:
|
||||
// 码已被扫描、等待确认;服务端在扫描后重定向了轮询主机;或服务端要求配对码。
|
||||
// 这类会话一律复用,绝不重新取码。
|
||||
func (s *loginSession) confirmingPhase() bool {
|
||||
switch s.currentPhase() {
|
||||
case "scaned", "scaned_but_redirect", "need_verifycode":
|
||||
@@ -102,52 +93,45 @@ func (s *loginSession) confirmingPhase() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// qrReuseWindow is how long a just-minted QR is reused on an immediate re-click
|
||||
// before StartLogin re-mints a fresh one. WeChat bot QRs are short-lived, so a
|
||||
// "扫码登录/刷新二维码" click must yield a brand-new code — reusing an old QR is
|
||||
// exactly what makes WeChat report "二维码过期". A QR that is already scanned
|
||||
// (confirmingPhase) is always reused regardless of this window so the bind is not
|
||||
// dropped mid-confirmation.
|
||||
// qrReuseWindow 是刚生成的二维码在立即重复点击时被复用的时长,超过它 StartLogin
|
||||
// 才会重新取码。微信机器人二维码寿命很短,所以「扫码登录/刷新二维码」的点击必须
|
||||
// 给出全新码——复用旧码正是让微信报「二维码过期」的原因。已扫描的二维码
|
||||
// (confirmingPhase)无论该窗口如何都一律复用,使绑定不会在确认途中被丢弃。
|
||||
const qrReuseWindow = 5 * time.Second
|
||||
|
||||
// StartLogin begins a scan-login and returns the QR code to display. It is
|
||||
// idempotent: when a login is already in progress it returns the current QR
|
||||
// without issuing a new get_bot_qrcode, and when a bot token is already present it
|
||||
// returns ErrAlreadyLoggedIn. It never blocks on the user's scan — the status poll
|
||||
// runs in a background goroutine that LoginStatus reports on.
|
||||
// StartLogin 发起一次扫码登录并返回待展示的二维码。它是幂等的:已有登录进行中时
|
||||
// 直接返回当前二维码、不再请求新的 get_bot_qrcode;已有 bot token 时返回
|
||||
// ErrAlreadyLoggedIn。它绝不阻塞等待用户扫码——状态轮询运行在后台 goroutine 中,
|
||||
// 由 LoginStatus 上报。
|
||||
func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
|
||||
a.loginMu.Lock()
|
||||
defer a.loginMu.Unlock()
|
||||
|
||||
// Already bound → scanning is moot — unless iLink REJECTED the stored token
|
||||
// (errcode=-14), so it is not a usable bind and the owner must be able to
|
||||
// re-scan (otherwise the only way out of the rejected state would be
|
||||
// unreachable). A live token still short-circuits the QR flow as before.
|
||||
// 已绑定 → 扫码已无必要——除非 iLink 已**拒绝**该存量 token(errcode=-14),
|
||||
// 此时它不是可用绑定,主人必须能够重新扫码(否则「离开被拒绝状态」的唯一出路
|
||||
// 将不可达)。仍可用的 token 照旧直接短路扫码流程。
|
||||
if rejected, _ := a.rejectedState(); a.hasToken() && !rejected {
|
||||
return LoginQR{}, ErrAlreadyLoggedIn
|
||||
}
|
||||
if sess := a.loginSession; sess != nil {
|
||||
// A bind is in progress. Re-minting would cancel the poll and, if the user has
|
||||
// already scanned or the server is mid-flow, discard the pending confirmation —
|
||||
// WeChat's confirm would then never land. Protect those sessions by reusing the
|
||||
// current QR unconditionally. Only a QR still waiting to be scanned and past the
|
||||
// tiny reuse window is re-minted, so an explicit "刷新二维码" yields a fresh code.
|
||||
// 绑定进行中。重新取码会取消轮询;若用户已扫码或服务端正处于流程中途,
|
||||
// 还会丢弃待处理的确认——微信那边的确认将永远无法落地。因此对这些会话
|
||||
// 无条件复用当前二维码加以保护。只有仍未被扫描、且已过那个极小复用窗口的
|
||||
// 二维码才会被重新生成,使显式的「刷新二维码」能拿到全新码。
|
||||
if sess.confirmingPhase() {
|
||||
a.log.Info("weixin_ilink: login in progress, reusing qrcode", "phase", sess.currentPhase())
|
||||
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
|
||||
}
|
||||
// Serve the same code while it is still fresh OR already scanned — always
|
||||
// reuse, never swap, so an in-progress bind (which WeChat may be confirming
|
||||
// right now) is never dropped. A frontend flooding login-qr calls therefore
|
||||
// cannot cancel the session under the user's scan. Only a genuinely stale
|
||||
// (>60s server TTL) and never-scanned code is re-minted.
|
||||
// 码仍然新鲜或已被扫描时都返回同一个码——一律复用、绝不替换,使进行中的
|
||||
// 绑定(微信此刻可能正在确认)永不被丢弃。于是前端的 login-qr 洪泛也无法在
|
||||
// 用户扫码期间取消会话。只有真正过期(超过服务端 60s TTL)且从未被扫描的码
|
||||
// 才会重新生成。
|
||||
if time.Since(sess.startedAt) < 60*time.Second {
|
||||
a.log.Info("weixin_ilink: login in progress, reusing qrcode", "phase", sess.currentPhase())
|
||||
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
|
||||
}
|
||||
// Code stale (>60s) and never scanned: mint a fresh one, cancelling the old
|
||||
// (expired, unscanned) poll. failLogin guards a cancelled goroutine from
|
||||
// clobbering the new session's state.
|
||||
// 码已过期(>60s)且从未被扫描:生成新码,并取消旧的那次(已过期、未扫描的)
|
||||
// 轮询。failLogin 会防止被取消的 goroutine 覆盖新会话的状态。
|
||||
a.log.Info("weixin_ilink: previous qrcode not scanned, minting a fresh one")
|
||||
if sess.cancel != nil {
|
||||
sess.cancel()
|
||||
@@ -171,29 +155,25 @@ func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
|
||||
}
|
||||
a.loginSession = sess
|
||||
a.setLoginState(LoginStatusWaiting, "")
|
||||
// Reset any IDC redirect host recorded by a previous (re-)scan so a new login
|
||||
// starts polling the configured base URL.
|
||||
// 清掉上一次(重新)扫码记录的 IDC 重定向主机,使新登录从已配置的基础 URL
|
||||
// 开始轮询。
|
||||
a.setPollBaseURL("")
|
||||
|
||||
go a.runLogin(sess, sessCtx)
|
||||
return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil
|
||||
}
|
||||
|
||||
// LoginStatus reports the current scan-login state. A non-empty bot token
|
||||
// overrides to "confirmed" even if a stale session is still recorded, so the panel
|
||||
// always sees the true binding — except when iLink REJECTED that token
|
||||
// (errcode=-14), so reporting "confirmed" would make the panel re-persist a dead
|
||||
// credential and call the bind a success. A rejected bind is reported as failed
|
||||
// with the re-scan reason instead, while a re-scan already in progress
|
||||
// keeps its own state. detail provides the bot id on confirmation and the failure
|
||||
// reason on failure.
|
||||
// LoginStatus 上报当前扫码登录状态。非空 bot token 会强制覆盖为 "confirmed",
|
||||
// 即使仍记录着陈旧会话,从而面板总能看见真实绑定——除非 iLink **拒绝**了该 token
|
||||
// (errcode=-14):此时上报 "confirmed" 会让面板重新持久化一份死凭据、并把绑定
|
||||
// 当作成功。被拒绝的绑定改为以 failed 加上重扫原因上报,而已经进行中的重扫则保留
|
||||
// 它自己的状态。detail 在确认时给出 bot id,在失败时给出失败原因。
|
||||
func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.rejectedLocked() {
|
||||
if a.loginStatus == LoginStatusWaiting || a.loginStatus == LoginStatusNeedVerifyCode {
|
||||
// A re-scan the owner already started: report its progress, not the
|
||||
// rejected state it is meant to replace.
|
||||
// 主人已经发起的重扫:上报它自己的进度,而不是它本要取代的那个被拒绝状态。
|
||||
return a.loginStatus, a.loginDetail
|
||||
}
|
||||
return LoginStatusFailed, a.rescanDetail
|
||||
@@ -204,16 +184,16 @@ func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
|
||||
return a.loginStatus, a.loginDetail
|
||||
}
|
||||
|
||||
// hasToken reports whether a bot token is configured. It reads cfg, so callers
|
||||
// must not rely on it while holding a.mu.
|
||||
// hasToken 报告是否已配置 bot token。它会读 cfg,因此调用方不得在持有 a.mu 时
|
||||
// 依赖它。
|
||||
func (a *Adapter) hasToken() bool {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.cfg.BotToken != ""
|
||||
}
|
||||
|
||||
// setLoginState writes the observable login status under a.mu. It may be called
|
||||
// with loginMu held (StartLogin) or from the poll goroutine; a.mu outlives both.
|
||||
// setLoginState 在 a.mu 下写入可观测的登录状态。它可能在持有 loginMu 时被调用
|
||||
// (StartLogin),也可能从轮询 goroutine 调用;a.mu 的生命周期长于两者。
|
||||
func (a *Adapter) setLoginState(status, detail string) {
|
||||
a.mu.Lock()
|
||||
a.loginStatus = status
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStartLogin_WaitThenConfirm drives the panel flow: StartLogin returns the QR,
|
||||
// the background poll confirms the bind, and LoginStatus transitions.
|
||||
// TestStartLogin_WaitThenConfirm 驱动面板流程:StartLogin 返回二维码,后台轮询确认
|
||||
// 绑定,LoginStatus 随之迁移。
|
||||
func TestStartLogin_WaitThenConfirm(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
@@ -49,8 +49,7 @@ func TestStartLogin_WaitThenConfirm(t *testing.T) {
|
||||
if got := ad.Config()["bot_token"]; got != "tok-x" {
|
||||
t.Fatalf("bot_token = %v, want tok-x", got)
|
||||
}
|
||||
// The QR mint must be a POST carrying the iLink identify headers per the
|
||||
// official protocol.
|
||||
// 按官方协议,取码必须是携带 iLink 标识头的 POST。
|
||||
for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" }) {
|
||||
if q.method != http.MethodPost {
|
||||
t.Fatalf("get_bot_qrcode method = %q, want POST", q.method)
|
||||
@@ -61,8 +60,8 @@ func TestStartLogin_WaitThenConfirm(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartLogin_IdempotentWhileWaiting verifies a second StartLogin while a login
|
||||
// is in progress returns the same QR and never issues a second get_bot_qrcode.
|
||||
// TestStartLogin_IdempotentWhileWaiting 验证登录进行中时的第二次 StartLogin 返回同一
|
||||
// 个二维码,且绝不发出第二次 get_bot_qrcode。
|
||||
func TestStartLogin_IdempotentWhileWaiting(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
@@ -93,8 +92,8 @@ func TestStartLogin_IdempotentWhileWaiting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartLogin_AlreadyLoggedIn verifies StartLogin refuses when a token exists and
|
||||
// LoginStatus reports confirmed.
|
||||
// TestStartLogin_AlreadyLoggedIn 验证已有 token 时 StartLogin 会拒绝,且 LoginStatus
|
||||
// 报告 confirmed。
|
||||
func TestStartLogin_AlreadyLoggedIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := newAdapter(t, Config{BaseURL: "https://example.invalid", BotToken: "tok-pre"}, nil)
|
||||
@@ -107,7 +106,7 @@ func TestStartLogin_AlreadyLoggedIn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoginStatus_Idle verifies a fresh adapter reports idle.
|
||||
// TestLoginStatus_Idle 验证全新适配器报告 idle。
|
||||
func TestLoginStatus_Idle(t *testing.T) {
|
||||
t.Parallel()
|
||||
ad := newAdapter(t, Config{BaseURL: "https://example.invalid"}, nil)
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStartLogin_ScanedExposesWaitingDetail verifies that an observed "scaned" wire
|
||||
// status is surfaced to the panel as the backward-compatible waiting status with the
|
||||
// "please confirm" detail, so the frontend keeps its waiting UI without a new status.
|
||||
// TestStartLogin_ScanedExposesWaitingDetail 验证观测到的线上状态 "scaned" 会以
|
||||
// 向后兼容的 waiting 状态加「请确认」详情呈现给面板,使前端不新增状态就能保留其
|
||||
// 等待界面。
|
||||
func TestStartLogin_ScanedExposesWaitingDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
|
||||
@@ -38,10 +38,9 @@ func TestStartLogin_ScanedExposesWaitingDetail(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestStartLogin_ScanedPastWindowReusesQR verifies the re-mint protection: once the
|
||||
// poll has observed "scaned" (awaiting confirmation), StartLogin reuses the current QR
|
||||
// even past the qrReuseWindow and never issues a second get_bot_qrcode — the pending
|
||||
// confirm is not dropped.
|
||||
// TestStartLogin_ScanedPastWindowReusesQR 验证重新取码保护:一旦轮询观测到 "scaned"
|
||||
// (等待确认),StartLogin 即使在超过 qrReuseWindow 后也复用当前二维码,绝不发出第二次
|
||||
// get_bot_qrcode——待处理的确认不会被丢弃。
|
||||
func TestStartLogin_ScanedPastWindowReusesQR(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
@@ -64,15 +63,15 @@ func TestStartLogin_ScanedPastWindowReusesQR(t *testing.T) {
|
||||
t.Fatalf("first StartLogin: %v", err)
|
||||
}
|
||||
|
||||
// Wait until the poll goroutine has observed "scaned" (the session is confirming).
|
||||
// 等到轮询 goroutine 观测到 "scaned"(该会话处于确认中)。
|
||||
waitFor(t, func() bool {
|
||||
ad.loginMu.Lock()
|
||||
defer ad.loginMu.Unlock()
|
||||
return ad.loginSession != nil && ad.loginSession.currentPhase() == "scaned"
|
||||
})
|
||||
|
||||
// Push startedAt well past the reuse window so only the protection, not the window,
|
||||
// could keep the QR. Then a refresh must still reuse it.
|
||||
// 把 startedAt 推到远超复用窗口之前,使保住该二维码的只能是保护逻辑而非窗口本身。
|
||||
// 此时一次刷新仍必须复用它。
|
||||
ad.loginMu.Lock()
|
||||
if ad.loginSession != nil {
|
||||
ad.loginSession.startedAt = time.Now().Add(-(60*time.Second + time.Second))
|
||||
@@ -91,10 +90,9 @@ func TestStartLogin_ScanedPastWindowReusesQR(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartLogin_WaitingPastWindowReMints verifies the explicit-refresh scenario is
|
||||
// preserved: a QR that is still waiting (not yet scanned) and is past the reuse window
|
||||
// is re-minted with a fresh get_bot_qrcode, and the cancelled old poll never clobbers
|
||||
// the new session's state with "failed".
|
||||
// TestStartLogin_WaitingPastWindowReMints 验证显式刷新场景仍然成立:仍在等待(尚未
|
||||
// 被扫描)且已过复用窗口的二维码会用新的 get_bot_qrcode 重新生成,且被取消的旧轮询
|
||||
// 绝不会用 "failed" 覆盖新会话的状态。
|
||||
func TestStartLogin_WaitingPastWindowReMints(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
@@ -142,15 +140,14 @@ func TestStartLogin_WaitingPastWindowReMints(t *testing.T) {
|
||||
if got := len(rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })); got != 2 {
|
||||
t.Fatalf("get_bot_qrcode called %d times, want 2 (past-window waiting refresh re-mints)", got)
|
||||
}
|
||||
// The cancelled old poll must not clobber the freshly-minted waiting state.
|
||||
// 被取消的旧轮询不得覆盖刚生成出来的 waiting 状态。
|
||||
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
|
||||
t.Fatalf("LoginStatus after re-mint = %q, want waiting (superceded session must not set failed)", status)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogin_LogRedaction verifies the scan-login path logs only redacted identifiers:
|
||||
// the renderable QR URL and the bot id are present, but the raw qrcode identifier, the
|
||||
// bot_token, and the owner user_id are never written.
|
||||
// TestLogin_LogRedaction 验证扫码登录路径只记录已脱敏的标识:可渲染的二维码 URL 与
|
||||
// bot id 会出现,但原始 qrcode 标识符、bot_token 以及主人的 user_id 绝不写入。
|
||||
func TestLogin_LogRedaction(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
@@ -188,9 +185,9 @@ func TestLogin_LogRedaction(t *testing.T) {
|
||||
logs := buf.String()
|
||||
for _, want := range []string{
|
||||
"get_bot_qrcode ok",
|
||||
"https://img/redact", // sanitized renderable URL
|
||||
"https://img/redact", // 已脱敏的可渲染 URL
|
||||
"login confirmed",
|
||||
"bot-redact", // bot id is non-sensitive
|
||||
"bot-redact", // bot id 非敏感
|
||||
} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Fatalf("logs missing %q:\n%s", want, logs)
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// scriptedTransport answers the weixin_ilink endpoints without network I/O. It
|
||||
// records the host each get_qrcode_status poll hits so tests can assert the IDC
|
||||
// redirect routing, and returns get_bot_qrcode with a fixed QR plus get_qrcode_status
|
||||
// bodies from statusFn (n is the 1-based poll count).
|
||||
// scriptedTransport 不做网络 I/O 即可应答 weixin_ilink 的各个端点。它记录每次
|
||||
// get_qrcode_status 轮询命中的主机,便于测试断言 IDC 重定向路由;并用固定二维码
|
||||
// 应答 get_bot_qrcode,用 statusFn 返回 get_qrcode_status 的响应体(n 是从 1 开始的
|
||||
// 轮询次数)。
|
||||
type scriptedTransport struct {
|
||||
qr []byte
|
||||
statusFn func(n int) []byte
|
||||
@@ -54,8 +54,8 @@ func (t *scriptedTransport) hosts() []string {
|
||||
return append([]string(nil), t.statusHosts...)
|
||||
}
|
||||
|
||||
// newScriptedAdapter builds an Adapter wired to a scripted transport (no real
|
||||
// network), with a base URL that does not resolve — the transport intercepts it.
|
||||
// newScriptedAdapter 构造一个接到脚本化传输层的 Adapter(无真实网络),其基础 URL
|
||||
// 无法解析——由该传输层拦截。
|
||||
func newScriptedAdapter(cfg Config, tr http.RoundTripper) (*Adapter, error) {
|
||||
return New(cfg, &http.Client{Transport: tr}, nil)
|
||||
}
|
||||
@@ -63,9 +63,9 @@ func newScriptedAdapter(cfg Config, tr http.RoundTripper) (*Adapter, error) {
|
||||
const scriptedQR = `{"qrcode":"qr-r","qrcode_img_content":"https://img/r"}`
|
||||
const scriptedBaseURL = "https://ilinkai.weixin.qq.com"
|
||||
|
||||
// TestStartLogin_ScanButRedirectRoutesPoll verifies that a scaned_but_redirect
|
||||
// response with a redirect_host switches subsequent get_qrcode_status polls to
|
||||
// the https://<redirect_host> IDC host.
|
||||
// TestStartLogin_ScanButRedirectRoutesPoll 验证带 redirect_host 的
|
||||
// scaned_but_redirect 响应会把后续 get_qrcode_status 轮询切到
|
||||
// https://<redirect_host> 这个 IDC 主机。
|
||||
func TestStartLogin_ScanButRedirectRoutesPoll(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := &scriptedTransport{
|
||||
@@ -106,8 +106,7 @@ func TestStartLogin_ScanButRedirectRoutesPoll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartLogin_VerifyCodeBlocked verifies verify_code_blocked transitions the
|
||||
// login to failed.
|
||||
// TestStartLogin_VerifyCodeBlocked 验证 verify_code_blocked 会把登录转为失败。
|
||||
func TestStartLogin_VerifyCodeBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := &scriptedTransport{
|
||||
@@ -132,8 +131,8 @@ func TestStartLogin_VerifyCodeBlocked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartLogin_BindedRedirect verifies binded_redirect is treated as already
|
||||
// bound (LoginStatus confirmed) rather than a failure.
|
||||
// TestStartLogin_BindedRedirect 验证 binded_redirect 被当作已绑定(LoginStatus 为
|
||||
// confirmed)而非失败。
|
||||
func TestStartLogin_BindedRedirect(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := &scriptedTransport{
|
||||
@@ -158,8 +157,8 @@ func TestStartLogin_BindedRedirect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartLogin_NeedVerifyCode verifies need_verifycode surfaces a dedicated
|
||||
// LoginStatus sub-state with a hint detail, then times out to failed.
|
||||
// TestStartLogin_NeedVerifyCode 验证 need_verifycode 会暴露一个专属的 LoginStatus
|
||||
// 子状态并附提示详情,随后超时转为失败。
|
||||
func TestStartLogin_NeedVerifyCode(t *testing.T) {
|
||||
t.Parallel()
|
||||
tr := &scriptedTransport{
|
||||
@@ -182,7 +181,7 @@ func TestStartLogin_NeedVerifyCode(t *testing.T) {
|
||||
if _, detail := ad.LoginStatus(context.Background()); detail != loginNeedVerifyCodeHint {
|
||||
t.Fatalf("need_verifycode detail = %q, want %q", detail, loginNeedVerifyCodeHint)
|
||||
}
|
||||
// Without a verify-code回传 path the flow eventually times out to failed.
|
||||
// 在没有验证码回传路径的情况下,流程最终超时转为失败。
|
||||
waitFor(t, func() bool {
|
||||
s, _ := ad.LoginStatus(context.Background())
|
||||
return s == LoginStatusFailed
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestStartLogin_ScanStatusTransition drives the QR bind flow via the out-of-band
|
||||
// API: StartLogin mints the QR, the background poll walks wait→scaned→confirmed and
|
||||
// stores the credentials, and LoginStatus surfaces confirmed.
|
||||
// TestStartLogin_ScanStatusTransition 经带外 API 驱动二维码绑定流程:StartLogin
|
||||
// 生成二维码,后台轮询走过 wait→scaned→confirmed 并保存凭据,LoginStatus 呈现
|
||||
// confirmed。
|
||||
func TestStartLogin_ScanStatusTransition(t *testing.T) {
|
||||
t.Parallel()
|
||||
rec := &reqRecorder{}
|
||||
@@ -52,7 +52,7 @@ func TestStartLogin_ScanStatusTransition(t *testing.T) {
|
||||
t.Fatal("StartLogin returned empty qrcode")
|
||||
}
|
||||
|
||||
// Poll LoginStatus until the background flow confirms.
|
||||
// 轮询 LoginStatus,直到后台流程确认。
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
status, _ := ad.LoginStatus(context.Background())
|
||||
@@ -70,7 +70,7 @@ func TestStartLogin_ScanStatusTransition(t *testing.T) {
|
||||
if got := ad.Config()["bot_id"]; got != "bot-x" {
|
||||
t.Fatalf("bot_id = %v, want bot-x", got)
|
||||
}
|
||||
// Confirm the qrcode id is echoed in the status poll query and the auth header.
|
||||
// 确认 qrcode id 在状态轮询的查询串与认证头中被回传。
|
||||
for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_qrcode_status" }) {
|
||||
if q.query != "qrcode=qr-1" {
|
||||
t.Fatalf("status poll query = %q, want qrcode=qr-1", q.query)
|
||||
@@ -79,8 +79,8 @@ func TestStartLogin_ScanStatusTransition(t *testing.T) {
|
||||
t.Fatalf("status poll AuthorizationType = %q, want ilink_bot_token", q.authType)
|
||||
}
|
||||
}
|
||||
// Confirm the QR mint follows the official protocol: POST, the iLink
|
||||
// App identify headers, and a local_token_list body (empty on a fresh bind).
|
||||
// 确认取码遵循官方协议:POST、iLink App 标识头,以及携带 local_token_list 的
|
||||
// 请求体(全新绑定时为空)。
|
||||
for _, q := range rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" }) {
|
||||
if q.method != http.MethodPost {
|
||||
t.Fatalf("get_bot_qrcode method = %q, want POST", q.method)
|
||||
@@ -104,8 +104,8 @@ func TestStartLogin_ScanStatusTransition(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartLogin_Timeout verifies the bind aborts with ErrLoginTimeout when the QR
|
||||
// is never confirmed, and Connect with an empty token does NOT block (ErrNeedsLogin).
|
||||
// TestStartLogin_Timeout 验证二维码始终未确认时绑定以 ErrLoginTimeout 终止,且空 token
|
||||
// 的 Connect **不**阻塞(ErrNeedsLogin)。
|
||||
func TestStartLogin_Timeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
|
||||
@@ -117,12 +117,12 @@ func TestStartLogin_Timeout(t *testing.T) {
|
||||
cfg := Config{BaseURL: srv.URL, LoginInterval: time.Millisecond, LoginTimeout: 25 * time.Millisecond}
|
||||
ad := newAdapter(t, cfg, srv)
|
||||
|
||||
// Connect with no token must NOT block on the QR — it surfaces ErrNeedsLogin so
|
||||
// the caller routes the owner to the out-of-band 扫码登录 panel.
|
||||
// 无 token 的 Connect 绝不能阻塞在二维码上——它上报 ErrNeedsLogin,让调用方把
|
||||
// 主人引向带外的扫码登录面板。
|
||||
if err := ad.Connect(context.Background()); !errors.Is(err, ErrNeedsLogin) {
|
||||
t.Fatalf("Connect(empty token) = %v, want ErrNeedsLogin", err)
|
||||
}
|
||||
// The out-of-band bind still times out if never confirmed.
|
||||
// 带外绑定若始终未确认,同样会超时。
|
||||
if _, err := ad.StartLogin(context.Background()); err != nil {
|
||||
t.Fatalf("StartLogin: %v", err)
|
||||
}
|
||||
@@ -143,4 +143,4 @@ func TestStartLogin_Timeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
var _ = json.RawMessage{} // keep encoding/json imported for future cases
|
||||
var _ = json.RawMessage{} // 保留 encoding/json 的导入,供后续用例使用
|
||||
@@ -11,15 +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 {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,49 +1,42 @@
|
||||
package weixin_ilink
|
||||
|
||||
// This file holds the adapter's session-rejection state machine (design/08 §4-§5).
|
||||
// 本文件承载适配器的「会话被拒绝」状态机(design/08 §4-§5)。
|
||||
//
|
||||
// iLink answers errcode=-14 when it rejects or invalidates the session credential.
|
||||
// The bot token is a LONG-LIVED credential — a token obtained by scanning a month
|
||||
// ago still works — so -14 is NOT a time-based expiry: it means iLink no longer
|
||||
// accepts this session (unbound, re-logged-in elsewhere, a protocol-level refusal).
|
||||
// It is worth recording that no log has ever actually shown -14; the earlier
|
||||
// reading of it as a time-based expiry was an inference, not an observation.
|
||||
// iLink 在拒绝或作废会话凭据时返回 errcode=-14。bot token 是长期凭据——一个月前
|
||||
// 扫码拿到的 token 依然可用——所以 -14 不是基于时间的过期:它表示 iLink 已不再接受
|
||||
// 这个会话(已解绑、在别处重新登录、或协议层拒绝)。值得记下的是,日志里从未真正
|
||||
// 出现过 -14;早先把 -14 读作「基于时间的过期」只是推断,并非观测。
|
||||
//
|
||||
// Either way it is not a transient failure the read loop can retry away: the same
|
||||
// credential keeps being refused until the owner scans a fresh QR code. The adapter
|
||||
// therefore keeps an EXPLICIT rejected flag instead of silently dropping the
|
||||
// connection, so:
|
||||
// 无论如何,它都不是读循环能靠重试消解的瞬时故障:同一份凭据会一直被拒,直到主人
|
||||
// 重新扫码。因此适配器保留一个**显式**的 rejected 标志,而不是静默丢弃连接,使得:
|
||||
//
|
||||
// - the read loop cannot exit and leave the adapter quietly mute while still
|
||||
// reporting "connected" (the "credential rejected → silently mute" defect);
|
||||
// - Connect/HealthCheck/send surface ErrSessionRejected — "re-scan required" —
|
||||
// rather than a fake OK or a generic "not connected";
|
||||
// - the admin panel can read RescanRequired() and show 需要重新扫码 (see
|
||||
// internal/services/gateway/admin.go).
|
||||
// - 读循环不会退出后让适配器悄悄变哑,却仍上报「connected」(即「凭据被拒 →
|
||||
// 静默变哑」缺陷);
|
||||
// - Connect/HealthCheck/发送路径上报 ErrSessionRejected——「需要重新扫码」——
|
||||
// 而不是假 OK 或笼统的「未连接」;
|
||||
// - 管理面板可以读 RescanRequired() 并显示 需要重新扫码(见
|
||||
// internal/services/gateway/admin.go)。
|
||||
//
|
||||
// Lifecycle (set once, cleared on a usable credential):
|
||||
// 生命周期(一次置位,拿到可用凭据后清除):
|
||||
//
|
||||
// SET markRejected iLink returned errcode=-14 on getupdates,
|
||||
// sendmessage or getuploadurl.
|
||||
// CLEAR clearRescanLocked a scan-code login delivered a new bot_token
|
||||
// (completeLogin), or
|
||||
// rejectedLocked saw a bot_token different from the one
|
||||
// iLink rejected — a config change / hot reload that
|
||||
// supplied a fresh token.
|
||||
// SET markRejected getupdates、sendmessage 或 getuploadurl
|
||||
// 返回 errcode=-14。
|
||||
// CLEAR clearRescanLocked 扫码登录交付了新的 bot_token(completeLogin),或
|
||||
// rejectedLocked 发现 bot_token 已不同于 iLink 拒绝的
|
||||
// 那一个——配置变更 / 热重载提供了新 token。
|
||||
|
||||
// Rescan reasons recorded on the rejected state. They are short, human-readable
|
||||
// and carry no credential: the bot token itself never leaves cfg.
|
||||
// 记录在「被拒绝」状态上的重扫原因。它们简短、人类可读,且不携带任何凭据:
|
||||
// bot token 本身从不离开 cfg。
|
||||
const (
|
||||
rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||||
rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||||
rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||||
)
|
||||
|
||||
// markRejected records that iLink rejected the current session credential
|
||||
// (errcode=-14). It drops the connected flag so IsConnected can never keep claiming
|
||||
// a live session for a rejected credential, and logs the transition at ERROR level
|
||||
// exactly once (a failing poll loop or a repeated send must not flood the log). Only
|
||||
// the non-secret bot_id and the reason are logged.
|
||||
// markRejected 记录 iLink 拒绝了当前会话凭据(errcode=-14)。它清掉 connected
|
||||
// 标志,使 IsConnected 永不为一已被拒绝的凭据继续宣称会话存活;并以 ERROR 级别
|
||||
// 恰好记录一次该状态转移(失败的轮询循环或反复的发送不得刷屏日志)。只记录非敏感的
|
||||
// bot_id 与原因。
|
||||
func (a *Adapter) markRejected(reason string) {
|
||||
a.mu.Lock()
|
||||
first := !a.rejected
|
||||
@@ -60,17 +53,16 @@ func (a *Adapter) markRejected(reason string) {
|
||||
}
|
||||
}
|
||||
|
||||
// clearRescanLocked drops the rejected state. Callers must hold a.mu.
|
||||
// clearRescanLocked 清除被拒绝状态。调用方须持有 a.mu。
|
||||
func (a *Adapter) clearRescanLocked() {
|
||||
a.rejected = false
|
||||
a.rejectedToken = ""
|
||||
a.rescanDetail = ""
|
||||
}
|
||||
|
||||
// rejectedLocked reports whether iLink rejected the session. It first clears the
|
||||
// state when the configured bot_token is no longer the one iLink rejected, so a
|
||||
// fresh token (scan login, operator config change, hot reload) restores the session
|
||||
// without extra bookkeeping. Callers must hold a.mu.
|
||||
// rejectedLocked 报告 iLink 是否拒绝了该会话。当已配置的 bot_token 不再是 iLink
|
||||
// 拒绝的那一个时,它会先清除该状态,于是新 token(扫码登录、运维改配置、热重载)
|
||||
// 无需额外簿记即可恢复会话。调用方须持有 a.mu。
|
||||
func (a *Adapter) rejectedLocked() bool {
|
||||
if a.rejected && a.rejectedToken != a.cfg.BotToken {
|
||||
a.clearRescanLocked()
|
||||
@@ -78,8 +70,8 @@ func (a *Adapter) rejectedLocked() bool {
|
||||
return a.rejected
|
||||
}
|
||||
|
||||
// rejectedState reports the rejected flag together with the rescan reason in one
|
||||
// lock acquisition. reason is "" when the session has not been rejected.
|
||||
// rejectedState 在一次加锁内同时返回「被拒绝」标志与重扫原因。会话未被拒绝时
|
||||
// reason 为 ""。
|
||||
func (a *Adapter) rejectedState() (bool, string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
@@ -89,11 +81,9 @@ func (a *Adapter) rejectedState() (bool, string) {
|
||||
return true, a.rescanDetail
|
||||
}
|
||||
|
||||
// RescanRequired implements the optional capability the admin panel reads by type
|
||||
// assertion (internal/services/gateway/admin.go): it reports whether this adapter
|
||||
// needs a new scan-code login instead of a plain reconnect, plus the
|
||||
// human-readable reason (never a secret). A healthy adapter reports false with an
|
||||
// empty reason.
|
||||
// RescanRequired 实现管理面板用类型断言读取的可选能力
|
||||
// (internal/services/gateway/admin.go):它报告该适配器是否需要重新扫码登录而非
|
||||
// 简单重连,并给出人类可读的原因(绝不含密钥)。健康的适配器返回 false 与空原因。
|
||||
func (a *Adapter) RescanRequired() (bool, string) {
|
||||
return a.rejectedState()
|
||||
}
|
||||
@@ -11,13 +11,12 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// These tests cover the explicit session-rejection state machine (session_state.go):
|
||||
// iLink answering errcode=-14 must be visible as "re-scan required" instead of a
|
||||
// silently mute adapter or a connect that reports success with a credential iLink
|
||||
// already refused.
|
||||
// 这些测试覆盖显式的「会话被拒绝」状态机(session_state.go):iLink 回复
|
||||
// errcode=-14 必须表现为「需要重新扫码」,而不是悄悄变哑的适配器、或一次拿着已被
|
||||
// iLink 拒绝的凭据却上报成功的连接。
|
||||
|
||||
// rejectingServer answers every getupdates long poll with the rejected-session
|
||||
// errcode, the way iLink refuses a session credential.
|
||||
// rejectingServer 对每次 getupdates 长轮询都回以「会话被拒绝」错误码,正如 iLink
|
||||
// 拒绝一份会话凭据那样。
|
||||
func rejectingServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return newJSONServer(t, nil, func(req recordedReq) handlerResult {
|
||||
@@ -28,9 +27,8 @@ func rejectingServer(t *testing.T) *httptest.Server {
|
||||
})
|
||||
}
|
||||
|
||||
// rejectByPoll drives the adapter into the rejected state through the long poll:
|
||||
// ReadMessages must return on errcode=-14 and the adapter must report the
|
||||
// rescan-required state.
|
||||
// rejectByPoll 通过长轮询把适配器驱入被拒绝状态:ReadMessages 必须在 errcode=-14 时
|
||||
// 返回,且适配器必须报告「需要重新扫码」状态。
|
||||
func rejectByPoll(t *testing.T, ad *Adapter) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
@@ -44,8 +42,7 @@ func rejectByPoll(t *testing.T, ad *Adapter) {
|
||||
}
|
||||
}
|
||||
|
||||
// newRejectedByPollAdapter builds a connected adapter and drives it into the
|
||||
// rejected state via the poll path.
|
||||
// newRejectedByPollAdapter 构造一个已连接的适配器,并经轮询路径把它驱入被拒绝状态。
|
||||
func newRejectedByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
|
||||
t.Helper()
|
||||
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-old"}, srv)
|
||||
@@ -53,10 +50,9 @@ func newRejectedByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
|
||||
return ad
|
||||
}
|
||||
|
||||
// assertRejectedSession asserts the observable contract of a session iLink rejected:
|
||||
// not connected, rescan required with a reason, HealthCheck distinguishing the
|
||||
// rejected state from not-connected, and Connect refusing with ErrSessionRejected
|
||||
// instead of a fake OK.
|
||||
// assertRejectedSession 断言「会话被 iLink 拒绝」的可观测契约:未连接、需要重新扫码
|
||||
// 且有原因、HealthCheck 能把被拒绝状态与未连接区分开、以及 Connect 以
|
||||
// ErrSessionRejected 拒绝而不是假 OK。
|
||||
func assertRejectedSession(t *testing.T, ad *Adapter) {
|
||||
t.Helper()
|
||||
if ad.IsConnected() {
|
||||
@@ -120,7 +116,7 @@ func TestSessionRejected_SendMarksRescan(t *testing.T) {
|
||||
if ad.IsConnected() {
|
||||
t.Fatal("IsConnected() = true after a send saw errcode=-14")
|
||||
}
|
||||
// A later send reports the rejected session itself, not a bare "not connected".
|
||||
// 之后的发送上报的是会话被拒绝本身,而不是光秃秃的「未连接」。
|
||||
err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
|
||||
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "again"}},
|
||||
})
|
||||
@@ -156,8 +152,8 @@ func TestSessionRejected_ClearedByScanLogin(t *testing.T) {
|
||||
ad := newRejectedByPollAdapter(t, srv)
|
||||
assertRejectedSession(t, ad)
|
||||
|
||||
// A rejected token must not short-circuit the scan flow: it is not a usable
|
||||
// bind, and ErrAlreadyLoggedIn would leave the owner no way back.
|
||||
// 被拒绝的 token 不得短路扫码流程:它不是可用绑定,而 ErrAlreadyLoggedIn 会让
|
||||
// 主人无路可退。
|
||||
qr, err := ad.StartLogin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("StartLogin() on a rejected session = %v, want a fresh QR", err)
|
||||
@@ -166,7 +162,7 @@ func TestSessionRejected_ClearedByScanLogin(t *testing.T) {
|
||||
t.Fatalf("StartLogin() qrcode = %q, want qr-re", qr.Qrcode)
|
||||
}
|
||||
|
||||
// The confirmed scan delivers a new token: the rejected state must clear.
|
||||
// 确认的扫码交付了新 token:被拒绝状态必须被清除。
|
||||
waitFor(t, func() bool { ok, _ := ad.RescanRequired(); return !ok })
|
||||
if err := ad.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect() after re-scan = %v, want nil", err)
|
||||
@@ -184,8 +180,8 @@ func TestSessionRejected_ClearedByScanLogin(t *testing.T) {
|
||||
t.Fatalf("LoginStatus() = %q, want confirmed after a successful re-scan", status)
|
||||
}
|
||||
|
||||
// The re-scan asked for a real rebind: the rejected token is not advertised as a
|
||||
// local token (that is what invites binded_redirect with no new credentials).
|
||||
// 本次重扫请求的是真正的重新绑定:被拒绝的 token 不作为本地 token 上报(正是
|
||||
// 上报它才会招来不带新凭据的 binded_redirect)。
|
||||
reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("get_bot_qrcode requests = %d, want 1", len(reqs))
|
||||
@@ -202,7 +198,7 @@ func TestSessionRejected_ClearedByNewToken(t *testing.T) {
|
||||
srv := rejectingServer(t)
|
||||
ad := newRejectedByPollAdapter(t, srv)
|
||||
|
||||
// The rejected token is still configured: the session stays rejected.
|
||||
// 被拒绝的 token 仍在配置中:会话保持被拒绝。
|
||||
if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionRejected) {
|
||||
t.Fatalf("Connect() = %v, want ErrSessionRejected while the rejected token is unchanged", err)
|
||||
}
|
||||
@@ -245,8 +241,7 @@ func TestSessionRejected_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
|
||||
}, srv)
|
||||
rejectByPoll(t, ad)
|
||||
|
||||
// A rejected credential must not read as "confirmed": the panel would re-persist
|
||||
// it and call the bind a success.
|
||||
// 被拒绝的凭据不得被读作 "confirmed":否则面板会重新持久化它并把绑定当作成功。
|
||||
status, detail := ad.LoginStatus(context.Background())
|
||||
if status != LoginStatusFailed {
|
||||
t.Fatalf("LoginStatus() = %q, want failed while iLink rejects the stored token", status)
|
||||
@@ -255,8 +250,7 @@ func TestSessionRejected_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
|
||||
t.Fatal("LoginStatus() detail is empty, want the re-scan reason")
|
||||
}
|
||||
|
||||
// The panel's 扫码登录 must still mint a QR (ErrAlreadyLoggedIn would block the
|
||||
// only recovery path).
|
||||
// 面板的扫码登录仍必须能生成二维码(ErrAlreadyLoggedIn 会堵死唯一的恢复路径)。
|
||||
qr, err := ad.StartLogin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("StartLogin() = %v, want a fresh QR", err)
|
||||
@@ -264,16 +258,15 @@ func TestSessionRejected_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
|
||||
if qr.Qrcode != "qr-2" {
|
||||
t.Fatalf("StartLogin() qrcode = %q, want qr-2", qr.Qrcode)
|
||||
}
|
||||
// While the re-scan is in progress its own state is reported, not the rejected one.
|
||||
// 重扫进行期间上报的是它自己的状态,而不是被拒绝状态。
|
||||
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusWaiting {
|
||||
t.Fatalf("LoginStatus() during the re-scan = %q, want waiting", status)
|
||||
}
|
||||
}
|
||||
|
||||
// setBotToken swaps the configured bot token, standing in for a panel save +
|
||||
// hot reload (which rebuilds the adapter) or any other in-place config refresh;
|
||||
// the adapter has no public setter because production always constructs a fresh
|
||||
// instance from the new config.
|
||||
// setBotToken 替换已配置的 bot token,用来模拟面板保存 + 热重载(会重建适配器)
|
||||
// 或任何其他原地配置刷新;适配器没有公开的 setter,因为生产环境总是依据新配置构造
|
||||
// 全新实例。
|
||||
func setBotToken(ad *Adapter, token string) {
|
||||
ad.mu.Lock()
|
||||
ad.cfg.BotToken = token
|
||||
|
||||
Reference in new issue
Block a user