feat(weixin-ilink,gateway): 会话过期显式化 — 不再静默变哑/假 OK 重连,面板可见「需重扫」
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 适配器新增过期状态机(plugins/channels/weixin_ilink/session_state.go):getupdates/sendmessage/getuploadurl 收到 errcode=-14 即 markExpired(ERROR 仅一次,只带 bot_id,token 永不入日志),扫码登录拿到新 token 或配置换新 token 时清除(惰性比对)
- Connect/HealthCheck 在过期时返回 ErrSessionExpired(可与 ErrNotConnected 区分):不再对死 token 设 connected=true 报假成功;发送路径同样返回该错误而不是笼统 ErrNotConnected
- 网关:POST /admin/channels/open 遇过期 → 409 session_expired + 明确文案(不再吞错回 200);GET /admin/channels 摘要新增 needsRescan/lastError(经可选接口类型断言读取,取不到不伪造)
- channelbridge:读循环非取消退出时把自身记账的 connected 置 false(热重载被替换者退出不抹掉新实例)
- login/login_admin:过期时 StartLogin 允许重新出码、LoginStatus 如实报 failed(原先可能把死 token 当 confirmed 重新落盘)
- Docs/api-client v0.39;新增 5 组测试 + 变异验证(去掉标记/恢复假成功/网关不填字段 均 FAIL)
This commit is contained in:
AskaEth committed 2026-10-03 18:00:01 +08:00
1 parent 2092094d0a
commit cdc370dc82
14 files changed
+975 -30

No files matched your search

+38 -6
View File
@@ -159,7 +159,15 @@ type Adapter struct {
mu sync.RWMutex
connected bool
qrcode string
// expired marks a session iLink rejected with errcode=-14: the bot token is
// dead and only a fresh scan-code login re-binds it (session_state.go).
// expiredToken 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.
expired bool
expiredToken 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.
@@ -230,9 +238,19 @@ func (a *Adapter) Config() map[string]any {
}
// Connect establishes the iLink session. It is idempotent. When a bot_token is
// already configured it is treated as a live session; otherwise it runs the
// scan-code login flow (see login.go).
// already configured and usable it is treated as a live session; otherwise it
// runs the scan-code login flow (see login.go).
//
// A token iLink already rejected with errcode=-14 is NOT usable: Connect refuses
// it with ErrSessionExpired (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).
func (a *Adapter) Connect(ctx context.Context) error {
if expired, reason := a.expiredState(); expired {
a.log.Error("ilink connect refused: session expired, 需要重新扫码登录",
"bot_id", a.botID(), "reason", reason)
return fmt.Errorf("%w: %s", ErrSessionExpired, reason)
}
a.mu.RLock()
connected := a.connected
token := a.cfg.BotToken
@@ -250,10 +268,18 @@ func (a *Adapter) Connect(ctx context.Context) error {
a.mu.Lock()
a.connected = true
a.mu.Unlock()
a.log.Info("ilink session established", "bot_id", a.cfg.BotID)
a.log.Info("ilink session established", "bot_id", a.botID())
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).
func (a *Adapter) botID() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.cfg.BotID
}
// Disconnect tears down the session. It is idempotent.
func (a *Adapter) Disconnect(ctx context.Context) error {
a.mu.Lock()
@@ -274,9 +300,15 @@ func (a *Adapter) IsConnected() bool {
}
// HealthCheck verifies the session is usable: it must be connected and carry a
// bot_token. It does not probe the network so callers can use it cheaply after
// a reconnect without incurring a request.
// bot_token. An expired session (iLink rejected the token with errcode=-14) is
// reported as ErrSessionExpired — "re-scan required" — so a caller can tell a
// dead credential apart from a plain disconnected adapter. It does not probe the
// network so callers can use it cheaply after a reconnect without incurring a
// request.
func (a *Adapter) HealthCheck(ctx context.Context) error {
if expired, reason := a.expiredState(); expired {
return fmt.Errorf("%w: %s", ErrSessionExpired, reason)
}
a.mu.RLock()
defer a.mu.RUnlock()
if !a.connected || a.cfg.BotToken == "" {
+17 -4
View File
@@ -144,7 +144,9 @@ 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.
// 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 expired state
// (session_state.go): the rescan flag is cleared here.
func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) {
a.mu.Lock()
a.cfg.BotToken = status.BotToken
@@ -153,6 +155,7 @@ func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) {
if status.BaseURL != "" {
a.cfg.BaseURL = status.BaseURL
}
a.clearRescanLocked()
a.connected = true
a.loginStatus = LoginStatusConfirmed
a.loginDetail = ""
@@ -171,11 +174,15 @@ func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) {
// 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.
// credentials and any existing token remains valid. Because NO new token arrives,
// an expired session stays expired 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 expired state.
func (a *Adapter) completeBindedRedirect(sess *loginSession) {
a.log.Info("weixin_ilink: login already bound (binded_redirect)")
a.mu.Lock()
a.connected = a.cfg.BotToken != ""
a.connected = a.cfg.BotToken != "" && !a.expiredLocked()
a.loginStatus = LoginStatusConfirmed
a.loginDetail = loginBindedRedirectDetail
a.mu.Unlock()
@@ -211,8 +218,14 @@ func (a *Adapter) failLogin(sess *loginSession, err error) {
// 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.
// than `null`, matching the official client. An EXPIRED token 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 a
// dead token and no way back. Reporting `[]` asks for a real rebind.
func (a *Adapter) localTokenList() []string {
if expired, _ := a.expiredState(); expired {
return []string{}
}
a.mu.RLock()
defer a.mu.RUnlock()
if a.cfg.BotToken == "" {
+21 -6
View File
@@ -119,8 +119,11 @@ func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
a.loginMu.Lock()
defer a.loginMu.Unlock()
// Already bound → scanning is moot.
if a.hasToken() {
// Already bound → scanning is moot — unless the stored token is EXPIRED: iLink
// rejected it with 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 expired state would be
// unreachable). A live token still short-circuits the QR flow as before.
if expired, _ := a.expiredState(); a.hasToken() && !expired {
return LoginQR{}, ErrAlreadyLoggedIn
}
if sess := a.loginSession; sess != nil {
@@ -178,11 +181,23 @@ func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
// 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. detail provides the bot id on confirmation and the
// failure reason on failure.
// always sees the true binding — except when that token is EXPIRED: iLink rejected
// it with errcode=-14, so reporting "confirmed" would make the panel re-persist a
// dead credential and call the bind a success. An expired 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.
func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
a.mu.RLock()
defer a.mu.RUnlock()
a.mu.Lock()
defer a.mu.Unlock()
if a.expiredLocked() {
if a.loginStatus == LoginStatusWaiting || a.loginStatus == LoginStatusNeedVerifyCode {
// A re-scan the owner already started: report its progress, not the
// expired state it is meant to replace.
return a.loginStatus, a.loginDetail
}
return LoginStatusFailed, a.rescanDetail
}
if a.cfg.BotToken != "" {
return LoginStatusConfirmed, a.cfg.BotID
}
+6 -4
View File
@@ -95,10 +95,12 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
failures = 0
if resp.Errcode == errSessionExpired {
a.log.Warn("session expired (errcode=-14); stop polling, re-scan required")
a.mu.Lock()
a.connected = false
a.mu.Unlock()
// The bot token is dead: retrying cannot help and the poll loop would go
// mute for good. Record the explicit expired 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 ErrSessionExpired until a fresh
// token arrives.
a.markExpired(rescanReasonGetUpdates)
return
}
if resp.Ret != 0 {
+28 -4
View File
@@ -30,8 +30,8 @@ type sendResult struct {
// context_token, so the platform can associate the reply with the right
// conversation (design/08 §8).
func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse) error {
if !a.IsConnected() {
return adapter.ErrNotConnected
if err := a.sendGuard(); err != nil {
return err
}
for _, m := range resp.Messages {
peer, _ := recipient(m)
@@ -51,8 +51,8 @@ func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse)
// messaged, the push is rejected with ErrProactiveWindow and Capabilities.
// Proactive is only true so callers know the capability exists.
func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
if !a.IsConnected() {
return adapter.ErrNotConnected
if err := a.sendGuard(); err != nil {
return err
}
peer, _ := recipient(msg)
if peer == "" {
@@ -73,6 +73,20 @@ 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: an
// expired session (iLink already refused this token with errcode=-14) reads as
// ErrSessionExpired — the owner must re-scan — while any other down session stays
// ErrNotConnected.
func (a *Adapter) sendGuard() error {
if expired, reason := a.expiredState(); expired {
return fmt.Errorf("weixin_ilink: send: %w: %s", ErrSessionExpired, reason)
}
if !a.IsConnected() {
return adapter.ErrNotConnected
}
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.
func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedMessage) error {
@@ -152,6 +166,10 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [
return fmt.Errorf("weixin_ilink: sendmessage: %w", err)
}
if out.Errcode == errSessionExpired {
// The send path sees the same dead token as the poll path: record the
// expired state so the adapter stops claiming a live session and the panel
// can ask for a re-scan.
a.markExpired(rescanReasonSendMessage)
return ErrSessionExpired
}
if out.Ret != 0 {
@@ -172,6 +190,12 @@ func (a *Adapter) getUploadURL(ctx context.Context) (string, error) {
bytes.NewReader(body), &raw); err != nil {
return "", fmt.Errorf("weixin_ilink: getuploadurl: %w", err)
}
if intOf(raw["errcode"]) == errSessionExpired {
// Media upload is part of the send path, so the same dead token is recorded
// here too rather than degrading into "no url returned".
a.markExpired(rescanReasonUploadURL)
return "", ErrSessionExpired
}
u := firstString(raw, "upload_url", "url", "uploadUrl", "cdn_url")
if u == "" {
return "", fmt.Errorf("weixin_ilink: getuploadurl returned no url")
@@ -0,0 +1,92 @@
package weixin_ilink
// This file holds the adapter's session-expiry state machine (design/08 §4-§5).
//
// iLink answers errcode=-14 when the bot token is no longer valid. That is not a
// transient failure the read loop can retry away: the token is dead until the
// owner scans a fresh QR code. The adapter therefore keeps an EXPLICIT expired
// flag instead of silently dropping the connection, so:
//
// - the read loop cannot exit and leave the adapter quietly mute while still
// reporting "connected" (the "过期后静默变哑" defect);
// - Connect/HealthCheck/send surface ErrSessionExpired — "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).
//
// Lifecycle (set once, cleared on a usable credential):
//
// SET markExpired iLink returned errcode=-14 on getupdates,
// sendmessage or getuploadurl.
// CLEAR clearRescanLocked a scan-code login delivered a new bot_token
// (completeLogin), or
// expiredLocked saw a bot_token different from the one
// iLink rejected — a config change / hot reload that
// supplied a fresh token.
// Rescan reasons recorded on the expired state. They are short, human-readable
// and carry no credential: the bot token itself never leaves cfg.
const (
rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(bot token 已过期)"
rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(bot token 已过期)"
rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(bot token 已过期)"
)
// markExpired records that iLink rejected the current bot token (errcode=-14).
// It drops the connected flag so IsConnected can never keep claiming a live
// session for a dead token, 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.
func (a *Adapter) markExpired(reason string) {
a.mu.Lock()
first := !a.expired
a.expired = true
a.expiredToken = a.cfg.BotToken
a.rescanDetail = reason
a.connected = false
botID := a.cfg.BotID
a.mu.Unlock()
if first {
a.log.Error("ilink session expired: 需要重新扫码登录(请用「扫码登录」重新绑定)",
"bot_id", botID, "errcode", errSessionExpired, "reason", reason)
}
}
// clearRescanLocked drops the expired state. Callers must hold a.mu.
func (a *Adapter) clearRescanLocked() {
a.expired = false
a.expiredToken = ""
a.rescanDetail = ""
}
// expiredLocked reports whether the session is expired. 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.
func (a *Adapter) expiredLocked() bool {
if a.expired && a.expiredToken != a.cfg.BotToken {
a.clearRescanLocked()
}
return a.expired
}
// expiredState reports the expired flag together with the rescan reason in one
// lock acquisition. reason is "" when the session is not expired.
func (a *Adapter) expiredState() (bool, string) {
a.mu.Lock()
defer a.mu.Unlock()
if !a.expiredLocked() {
return false, ""
}
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.
func (a *Adapter) RescanRequired() (bool, string) {
return a.expiredState()
}
@@ -0,0 +1,279 @@
package weixin_ilink
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// These tests cover the explicit session-expiry 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 dead token.
// expiringServer answers every getupdates long poll with the expired-session
// errcode, the way iLink reports a dead bot token.
func expiringServer(t *testing.T) *httptest.Server {
t.Helper()
return newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.path != "/ilink/bot/getupdates" {
return handlerResult{status: 404}
}
return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})}
})
}
// expireByPoll drives the adapter into the expired state through the long poll:
// ReadMessages must return on errcode=-14 and the adapter must report the
// rescan-required state.
func expireByPoll(t *testing.T, ad *Adapter) {
t.Helper()
done := make(chan struct{})
go func() {
defer close(done)
ad.ReadMessages(context.Background(), func(adapter.UnifiedMessage) {})
}()
waitDone(t, done)
if required, reason := ad.RescanRequired(); !required || reason == "" {
t.Fatalf("RescanRequired() = (%v,%q), want (true, non-empty) after errcode=-14", required, reason)
}
}
// newExpiredByPollAdapter builds a connected adapter and expires it via the poll
// path.
func newExpiredByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
t.Helper()
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, PollInterval: time.Millisecond, BotToken: "tok-old"}, srv)
expireByPoll(t, ad)
return ad
}
// assertExpiredSession asserts the observable contract of an expired session: not
// connected, rescan required with a reason, HealthCheck distinguishing expired from
// not-connected, and Connect refusing with ErrSessionExpired instead of a fake OK.
func assertExpiredSession(t *testing.T, ad *Adapter) {
t.Helper()
if ad.IsConnected() {
t.Fatal("IsConnected() = true while the session is expired")
}
required, reason := ad.RescanRequired()
if !required {
t.Fatal("RescanRequired() = false, want true")
}
if reason == "" {
t.Fatal("RescanRequired() reason is empty, want the re-scan prompt")
}
herr := ad.HealthCheck(context.Background())
if !errors.Is(herr, ErrSessionExpired) {
t.Fatalf("HealthCheck() = %v, want ErrSessionExpired", herr)
}
if errors.Is(herr, adapter.ErrNotConnected) {
t.Fatal("HealthCheck() also matches ErrNotConnected, want expired to be distinguishable")
}
cerr := ad.Connect(context.Background())
if cerr == nil {
t.Fatal("Connect() = nil on an expired session, want ErrSessionExpired (no fake OK)")
}
if !errors.Is(cerr, ErrSessionExpired) {
t.Fatalf("Connect() = %v, want ErrSessionExpired", cerr)
}
if errors.Is(cerr, ErrNeedsLogin) {
t.Fatal("Connect() = ErrNeedsLogin, want ErrSessionExpired (expired is not 'unconfigured')")
}
if ad.IsConnected() {
t.Fatal("IsConnected() = true after a refused Connect on an expired session")
}
}
func TestSessionExpired_PollMarksRescanAndRefusesConnect(t *testing.T) {
t.Parallel()
srv := expiringServer(t)
ad := newExpiredByPollAdapter(t, srv)
assertExpiredSession(t, ad)
}
func TestSessionExpired_SendMarksRescan(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
if req.method == http.MethodPost && req.path == "/ilink/bot/sendmessage" {
return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})}
}
return handlerResult{status: 404}
})
ad := newConnectedAdapter(t, Config{BaseURL: srv.URL, BotToken: "tok"}, srv)
ad.recordInbound("tok-1", adapter.UnifiedMessage{SenderID: "u-1", Timestamp: ad.clock()})
err := ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "hi"}},
})
if !errors.Is(err, ErrSessionExpired) {
t.Fatalf("SendMessage() = %v, want ErrSessionExpired", err)
}
if ad.IsConnected() {
t.Fatal("IsConnected() = true after a send saw errcode=-14")
}
// A later send reports the expired session itself, not a bare "not connected".
err = ad.SendMessage(context.Background(), adapter.UnifiedResponse{
Messages: []adapter.UnifiedMessage{{SenderID: "u-1", Content: "again"}},
})
if !errors.Is(err, ErrSessionExpired) {
t.Fatalf("second SendMessage() = %v, want ErrSessionExpired", err)
}
perr := ad.SendProactive(context.Background(), adapter.UnifiedMessage{SenderID: "u-1", Content: "push"})
if !errors.Is(perr, ErrSessionExpired) {
t.Fatalf("SendProactive() = %v, want ErrSessionExpired", perr)
}
assertExpiredSession(t, ad)
}
func TestSessionExpired_ClearedByScanLogin(t *testing.T) {
t.Parallel()
rec := &reqRecorder{}
srv := newJSONServer(t, rec, func(req recordedReq) handlerResult {
switch req.path {
case "/ilink/bot/getupdates":
return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})}
case "/ilink/bot/get_bot_qrcode":
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-re", "qrcode_img_content": "https://img/qr",
})}
case "/ilink/bot/get_qrcode_status":
return handlerResult{body: jsonBody(map[string]any{
"status": "confirmed", "bot_token": "tok-new",
"ilink_bot_id": "bot-new", "ilink_user_id": "o-1",
})}
}
return handlerResult{status: 404}
})
ad := newExpiredByPollAdapter(t, srv)
assertExpiredSession(t, ad)
// An expired token must not short-circuit the scan flow: it is not a usable
// bind, and ErrAlreadyLoggedIn would leave the owner no way back.
qr, err := ad.StartLogin(context.Background())
if err != nil {
t.Fatalf("StartLogin() on an expired session = %v, want a fresh QR", err)
}
if qr.Qrcode != "qr-re" {
t.Fatalf("StartLogin() qrcode = %q, want qr-re", qr.Qrcode)
}
// The confirmed scan delivers a new token: the expired state must clear.
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)
}
if !ad.IsConnected() {
t.Fatal("IsConnected() = false after a confirmed re-scan")
}
if err := ad.HealthCheck(context.Background()); err != nil {
t.Fatalf("HealthCheck() after re-scan = %v, want nil", err)
}
if got := ad.Config()["bot_token"]; got != "tok-new" {
t.Fatalf("bot_token = %v, want tok-new", got)
}
if status, _ := ad.LoginStatus(context.Background()); status != LoginStatusConfirmed {
t.Fatalf("LoginStatus() = %q, want confirmed after a successful re-scan", status)
}
// The re-scan asked for a real rebind: the dead token is not advertised as a
// local token (that is what invites binded_redirect with no new credentials).
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))
}
if ltl := reqs[0].bodyJSON()["local_token_list"]; ltl == nil {
t.Fatalf("get_bot_qrcode body has no local_token_list: %s", reqs[0].body)
} else if list, ok := ltl.([]any); !ok || len(list) != 0 {
t.Fatalf("get_bot_qrcode local_token_list = %v, want an empty array", ltl)
}
}
func TestSessionExpired_ClearedByNewToken(t *testing.T) {
t.Parallel()
srv := expiringServer(t)
ad := newExpiredByPollAdapter(t, srv)
// The rejected token is still configured: the session stays expired.
if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("Connect() = %v, want ErrSessionExpired while the rejected token is unchanged", err)
}
if err := ad.HealthCheck(context.Background()); !errors.Is(err, ErrSessionExpired) {
t.Fatalf("HealthCheck() = %v, want ErrSessionExpired while the rejected token is unchanged", err)
}
setBotToken(ad, "tok-fresh")
if required, _ := ad.RescanRequired(); required {
t.Fatal("RescanRequired() = true after a new token was configured")
}
if err := ad.Connect(context.Background()); err != nil {
t.Fatalf("Connect() with a fresh token = %v, want nil", err)
}
if !ad.IsConnected() {
t.Fatal("IsConnected() = false after Connect with a fresh token")
}
if err := ad.HealthCheck(context.Background()); err != nil {
t.Fatalf("HealthCheck() with a fresh token = %v, want nil", err)
}
}
func TestSessionExpired_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
t.Parallel()
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
switch req.path {
case "/ilink/bot/getupdates":
return handlerResult{body: jsonBody(map[string]any{"errcode": errSessionExpired})}
case "/ilink/bot/get_bot_qrcode":
return handlerResult{body: jsonBody(map[string]any{
"qrcode": "qr-2", "qrcode_img_content": "https://img/qr2",
})}
}
return handlerResult{body: jsonBody(map[string]any{"status": "wait"})}
})
ad := newConnectedAdapter(t, Config{
BaseURL: srv.URL, PollInterval: time.Millisecond,
LoginInterval: time.Millisecond, LoginTimeout: 30 * time.Millisecond,
BotToken: "tok-old",
}, srv)
expireByPoll(t, ad)
// A dead token must not read as "confirmed": the panel would re-persist it and
// call the bind a success.
status, detail := ad.LoginStatus(context.Background())
if status != LoginStatusFailed {
t.Fatalf("LoginStatus() = %q, want failed while the stored token is expired", status)
}
if detail == "" {
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).
qr, err := ad.StartLogin(context.Background())
if err != nil {
t.Fatalf("StartLogin() = %v, want a fresh QR", err)
}
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 expired 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.
func setBotToken(ad *Adapter, token string) {
ad.mu.Lock()
ad.cfg.BotToken = token
ad.mu.Unlock()
}