refactor(weixin-ilink): 措辞纠正 —「会话过期」→「会话被 iLink 拒绝」(零逻辑改动)
事实(主人 2026-10-03 确认):iLink 的 bot token 是长期凭据(一个月前扫码的仍可用);errcode=-14 是 iLink 侧拒绝/作废该会话,不是时间性过期;且至今未观测到真实 -14(当日全部日志 0 次)。此前代码把这些写成「过期」,属沿用的旧命名,现纠正。 - 纯改名/文案:ErrSessionExpired→ErrSessionRejected、errSessionExpired→errcodeSessionRejected、expired/expiredToken→rejected/rejectedToken、markExpired/expiredLocked/expiredState→markRejected/rejectedLocked/rejectedState;409 错误码 session_expired→session_rejected - 行为一字未改:显式标记 / Connect 不假 OK / 面板 needsRescan+lastError / 扫码或换新 token 即清除 全部保留 - 微信协议自身的「二维码过期」与 auth 的 JWT/refresh 过期一律未动(那些是真·时间性过期) - Docs/api-client v0.39 补事实说明;顺手收尾 3 处残留注释(admin.go / channelbridge/inbound.go / readloop_test.go)
This commit is contained in:
1 parent
cdc370dc82
commit
a51aadeef4
14 files changed
+205
-192
No files matched your search
@@ -63,8 +63,12 @@ const (
|
||||
// ilinkVersion mirrors the upstream @tencent-weixin/openclaw-weixin version
|
||||
// whose client version we advertise on the wire as iLink-App-ClientVersion.
|
||||
ilinkVersion = "2.4.8"
|
||||
// errSessionExpired is the iLink errcode for an expired bot token.
|
||||
errSessionExpired = -14
|
||||
// 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 = -14
|
||||
)
|
||||
|
||||
// Sentinel errors returned by this adapter, usable with errors.Is.
|
||||
@@ -73,8 +77,8 @@ var (
|
||||
ErrInvalidConfig = errors.New("weixin_ilink: invalid config")
|
||||
// ErrLoginTimeout is returned when the QR code is not confirmed in time.
|
||||
ErrLoginTimeout = errors.New("weixin_ilink: qr login timed out")
|
||||
// ErrSessionExpired is returned when iLink reports errcode=-14.
|
||||
ErrSessionExpired = errors.New("weixin_ilink: session expired, re-scan required")
|
||||
// ErrSessionRejected is returned when iLink reports 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 = errors.New("weixin_ilink: bot not logged in yet, scan the QR")
|
||||
@@ -159,15 +163,15 @@ type Adapter struct {
|
||||
|
||||
mu sync.RWMutex
|
||||
connected bool
|
||||
// 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
|
||||
// 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.
|
||||
expired bool
|
||||
expiredToken string
|
||||
rescanDetail string
|
||||
qrcode string
|
||||
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.
|
||||
@@ -242,14 +246,14 @@ func (a *Adapter) Config() map[string]any {
|
||||
// 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
|
||||
// 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).
|
||||
func (a *Adapter) Connect(ctx context.Context) error {
|
||||
if expired, reason := a.expiredState(); expired {
|
||||
a.log.Error("ilink connect refused: session expired, 需要重新扫码登录",
|
||||
if rejected, reason := a.rejectedState(); rejected {
|
||||
a.log.Error("ilink connect refused: session rejected, 需要重新扫码绑定(请用「扫码登录」)",
|
||||
"bot_id", a.botID(), "reason", reason)
|
||||
return fmt.Errorf("%w: %s", ErrSessionExpired, reason)
|
||||
return fmt.Errorf("%w: %s", ErrSessionRejected, reason)
|
||||
}
|
||||
a.mu.RLock()
|
||||
connected := a.connected
|
||||
@@ -300,14 +304,14 @@ func (a *Adapter) IsConnected() bool {
|
||||
}
|
||||
|
||||
// HealthCheck verifies the session is usable: it must be connected and carry a
|
||||
// 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
|
||||
// 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.
|
||||
func (a *Adapter) HealthCheck(ctx context.Context) error {
|
||||
if expired, reason := a.expiredState(); expired {
|
||||
return fmt.Errorf("%w: %s", ErrSessionExpired, reason)
|
||||
if rejected, reason := a.rejectedState(); rejected {
|
||||
return fmt.Errorf("%w: %s", ErrSessionRejected, reason)
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
|
||||
@@ -145,7 +145,7 @@ 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 expired state
|
||||
// delivers a fresh bot_token, which is the canonical way OUT of the rejected state
|
||||
// (session_state.go): the rescan flag is cleared here.
|
||||
func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) {
|
||||
a.mu.Lock()
|
||||
@@ -175,14 +175,14 @@ 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. Because NO new token arrives,
|
||||
// an expired session stays expired here — binded_redirect carries no proof that a
|
||||
// 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 expired state.
|
||||
// is what clears the rejected 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.expiredLocked()
|
||||
a.connected = a.cfg.BotToken != "" && !a.rejectedLocked()
|
||||
a.loginStatus = LoginStatusConfirmed
|
||||
a.loginDetail = loginBindedRedirectDetail
|
||||
a.mu.Unlock()
|
||||
@@ -218,12 +218,12 @@ 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. 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.
|
||||
// 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.
|
||||
func (a *Adapter) localTokenList() []string {
|
||||
if expired, _ := a.expiredState(); expired {
|
||||
if rejected, _ := a.rejectedState(); rejected {
|
||||
return []string{}
|
||||
}
|
||||
a.mu.RLock()
|
||||
|
||||
@@ -119,11 +119,11 @@ func (a *Adapter) StartLogin(ctx context.Context) (LoginQR, error) {
|
||||
a.loginMu.Lock()
|
||||
defer a.loginMu.Unlock()
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
if expired, _ := a.expiredState(); a.hasToken() && !expired {
|
||||
if rejected, _ := a.rejectedState(); a.hasToken() && !rejected {
|
||||
return LoginQR{}, ErrAlreadyLoggedIn
|
||||
}
|
||||
if sess := a.loginSession; sess != nil {
|
||||
@@ -181,19 +181,19 @@ 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 — 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
|
||||
// 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.
|
||||
func (a *Adapter) LoginStatus(ctx context.Context) (status, detail string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.expiredLocked() {
|
||||
if a.rejectedLocked() {
|
||||
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.
|
||||
// rejected state it is meant to replace.
|
||||
return a.loginStatus, a.loginDetail
|
||||
}
|
||||
return LoginStatusFailed, a.rescanDetail
|
||||
|
||||
@@ -35,7 +35,7 @@ const (
|
||||
type getupdatesResp struct {
|
||||
// Ret is the top-level business result; non-zero is a transient error.
|
||||
Ret int `json:"ret"`
|
||||
// Errcode is the platform error code; -14 means the session expired.
|
||||
// Errcode is the platform error code; -14 means iLink rejected the session.
|
||||
Errcode int `json:"errcode"`
|
||||
// Msgs holds the raw inbound messages delivered in this poll.
|
||||
Msgs []json.RawMessage `json:"msgs"`
|
||||
@@ -94,13 +94,13 @@ func (a *Adapter) ReadMessages(ctx context.Context, emit func(adapter.UnifiedMes
|
||||
}
|
||||
|
||||
failures = 0
|
||||
if resp.Errcode == errSessionExpired {
|
||||
// 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)
|
||||
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.
|
||||
a.markRejected(rescanReasonGetUpdates)
|
||||
return
|
||||
}
|
||||
if resp.Ret != 0 {
|
||||
|
||||
@@ -216,7 +216,7 @@ func TestReadMessages_GroupMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMessages_SessionExpired(t *testing.T) {
|
||||
func TestReadMessages_SessionRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := newJSONServer(t, nil, func(req recordedReq) handlerResult {
|
||||
if req.path != "/ilink/bot/getupdates" {
|
||||
@@ -237,11 +237,11 @@ func TestReadMessages_SessionExpired(t *testing.T) {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
cancel()
|
||||
t.Fatal("ReadMessages did not stop on session expired")
|
||||
t.Fatal("ReadMessages did not stop on a session iLink rejected")
|
||||
}
|
||||
cancel()
|
||||
if ad.IsConnected() {
|
||||
t.Fatal("IsConnected() = true after session expired")
|
||||
t.Fatal("IsConnected() = true after iLink rejected the session")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -73,13 +73,12 @@ 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.
|
||||
// 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.
|
||||
func (a *Adapter) sendGuard() error {
|
||||
if expired, reason := a.expiredState(); expired {
|
||||
return fmt.Errorf("weixin_ilink: send: %w: %s", ErrSessionExpired, reason)
|
||||
if rejected, reason := a.rejectedState(); rejected {
|
||||
return fmt.Errorf("weixin_ilink: send: %w: %s", ErrSessionRejected, reason)
|
||||
}
|
||||
if !a.IsConnected() {
|
||||
return adapter.ErrNotConnected
|
||||
@@ -165,12 +164,12 @@ func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items [
|
||||
bytes.NewReader(body), &out); err != nil {
|
||||
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.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
|
||||
}
|
||||
if out.Ret != 0 {
|
||||
return fmt.Errorf("weixin_ilink: sendmessage ret=%d", out.Ret)
|
||||
@@ -190,11 +189,11 @@ 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
|
||||
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".
|
||||
a.markRejected(rescanReasonUploadURL)
|
||||
return "", ErrSessionRejected
|
||||
}
|
||||
u := firstString(raw, "upload_url", "url", "uploadUrl", "cdn_url")
|
||||
if u == "" {
|
||||
|
||||
@@ -1,82 +1,89 @@
|
||||
package weixin_ilink
|
||||
|
||||
// This file holds the adapter's session-expiry state machine (design/08 §4-§5).
|
||||
// This file holds the adapter's session-rejection 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:
|
||||
// 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.
|
||||
//
|
||||
// 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:
|
||||
//
|
||||
// - 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" —
|
||||
// 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).
|
||||
//
|
||||
// Lifecycle (set once, cleared on a usable credential):
|
||||
//
|
||||
// SET markExpired iLink returned errcode=-14 on getupdates,
|
||||
// SET markRejected 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
|
||||
// rejectedLocked 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
|
||||
// Rescan reasons recorded on the rejected 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 已过期)"
|
||||
rescanReasonGetUpdates = "iLink getupdates 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||||
rescanReasonSendMessage = "iLink sendmessage 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||||
rescanReasonUploadURL = "iLink getuploadurl 返回 errcode=-14(本次会话已被 iLink 拒绝)"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// 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.
|
||||
func (a *Adapter) markRejected(reason string) {
|
||||
a.mu.Lock()
|
||||
first := !a.expired
|
||||
a.expired = true
|
||||
a.expiredToken = a.cfg.BotToken
|
||||
first := !a.rejected
|
||||
a.rejected = true
|
||||
a.rejectedToken = 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)
|
||||
a.log.Error("ilink session rejected(errcode=-14):本次会话已被 iLink 拒绝,需要重新扫码绑定(请用「扫码登录」)",
|
||||
"bot_id", botID, "errcode", errcodeSessionRejected, "reason", reason)
|
||||
}
|
||||
}
|
||||
|
||||
// clearRescanLocked drops the expired state. Callers must hold a.mu.
|
||||
// clearRescanLocked drops the rejected state. Callers must hold a.mu.
|
||||
func (a *Adapter) clearRescanLocked() {
|
||||
a.expired = false
|
||||
a.expiredToken = ""
|
||||
a.rejected = false
|
||||
a.rejectedToken = ""
|
||||
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
|
||||
// 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.
|
||||
func (a *Adapter) expiredLocked() bool {
|
||||
if a.expired && a.expiredToken != a.cfg.BotToken {
|
||||
func (a *Adapter) rejectedLocked() bool {
|
||||
if a.rejected && a.rejectedToken != a.cfg.BotToken {
|
||||
a.clearRescanLocked()
|
||||
}
|
||||
return a.expired
|
||||
return a.rejected
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// rejectedState reports the rejected flag together with the rescan reason in one
|
||||
// lock acquisition. reason is "" when the session has not been rejected.
|
||||
func (a *Adapter) rejectedState() (bool, string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if !a.expiredLocked() {
|
||||
if !a.rejectedLocked() {
|
||||
return false, ""
|
||||
}
|
||||
return true, a.rescanDetail
|
||||
@@ -88,5 +95,5 @@ func (a *Adapter) expiredState() (bool, string) {
|
||||
// human-readable reason (never a secret). A healthy adapter reports false with an
|
||||
// empty reason.
|
||||
func (a *Adapter) RescanRequired() (bool, string) {
|
||||
return a.expiredState()
|
||||
return a.rejectedState()
|
||||
}
|
||||
@@ -11,26 +11,27 @@ import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// These tests cover the explicit session-expiry state machine (session_state.go):
|
||||
// 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 dead token.
|
||||
// silently mute adapter or a connect that reports success with a credential iLink
|
||||
// already refused.
|
||||
|
||||
// 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 {
|
||||
// rejectingServer answers every getupdates long poll with the rejected-session
|
||||
// errcode, the way iLink refuses a session credential.
|
||||
func rejectingServer(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})}
|
||||
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
|
||||
})
|
||||
}
|
||||
|
||||
// expireByPoll drives the adapter into the expired state through the long poll:
|
||||
// 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.
|
||||
func expireByPoll(t *testing.T, ad *Adapter) {
|
||||
func rejectByPoll(t *testing.T, ad *Adapter) {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
@@ -43,22 +44,23 @@ func expireByPoll(t *testing.T, ad *Adapter) {
|
||||
}
|
||||
}
|
||||
|
||||
// newExpiredByPollAdapter builds a connected adapter and expires it via the poll
|
||||
// path.
|
||||
func newExpiredByPollAdapter(t *testing.T, srv *httptest.Server) *Adapter {
|
||||
// newRejectedByPollAdapter builds a connected adapter and drives it into the
|
||||
// rejected state via the poll path.
|
||||
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)
|
||||
expireByPoll(t, ad)
|
||||
rejectByPoll(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) {
|
||||
// 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.
|
||||
func assertRejectedSession(t *testing.T, ad *Adapter) {
|
||||
t.Helper()
|
||||
if ad.IsConnected() {
|
||||
t.Fatal("IsConnected() = true while the session is expired")
|
||||
t.Fatal("IsConnected() = true while iLink rejects the session")
|
||||
}
|
||||
required, reason := ad.RescanRequired()
|
||||
if !required {
|
||||
@@ -69,40 +71,40 @@ func assertExpiredSession(t *testing.T, ad *Adapter) {
|
||||
}
|
||||
|
||||
herr := ad.HealthCheck(context.Background())
|
||||
if !errors.Is(herr, ErrSessionExpired) {
|
||||
t.Fatalf("HealthCheck() = %v, want ErrSessionExpired", herr)
|
||||
if !errors.Is(herr, ErrSessionRejected) {
|
||||
t.Fatalf("HealthCheck() = %v, want ErrSessionRejected", herr)
|
||||
}
|
||||
if errors.Is(herr, adapter.ErrNotConnected) {
|
||||
t.Fatal("HealthCheck() also matches ErrNotConnected, want expired to be distinguishable")
|
||||
t.Fatal("HealthCheck() also matches ErrNotConnected, want the rejected state to be distinguishable")
|
||||
}
|
||||
|
||||
cerr := ad.Connect(context.Background())
|
||||
if cerr == nil {
|
||||
t.Fatal("Connect() = nil on an expired session, want ErrSessionExpired (no fake OK)")
|
||||
t.Fatal("Connect() = nil on a rejected session, want ErrSessionRejected (no fake OK)")
|
||||
}
|
||||
if !errors.Is(cerr, ErrSessionExpired) {
|
||||
t.Fatalf("Connect() = %v, want ErrSessionExpired", cerr)
|
||||
if !errors.Is(cerr, ErrSessionRejected) {
|
||||
t.Fatalf("Connect() = %v, want ErrSessionRejected", cerr)
|
||||
}
|
||||
if errors.Is(cerr, ErrNeedsLogin) {
|
||||
t.Fatal("Connect() = ErrNeedsLogin, want ErrSessionExpired (expired is not 'unconfigured')")
|
||||
t.Fatal("Connect() = ErrNeedsLogin, want ErrSessionRejected (a rejected session is not 'unconfigured')")
|
||||
}
|
||||
if ad.IsConnected() {
|
||||
t.Fatal("IsConnected() = true after a refused Connect on an expired session")
|
||||
t.Fatal("IsConnected() = true after a refused Connect on a rejected session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpired_PollMarksRescanAndRefusesConnect(t *testing.T) {
|
||||
func TestSessionRejected_PollMarksRescanAndRefusesConnect(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := expiringServer(t)
|
||||
ad := newExpiredByPollAdapter(t, srv)
|
||||
assertExpiredSession(t, ad)
|
||||
srv := rejectingServer(t)
|
||||
ad := newRejectedByPollAdapter(t, srv)
|
||||
assertRejectedSession(t, ad)
|
||||
}
|
||||
|
||||
func TestSessionExpired_SendMarksRescan(t *testing.T) {
|
||||
func TestSessionRejected_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{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
|
||||
}
|
||||
return handlerResult{status: 404}
|
||||
})
|
||||
@@ -112,33 +114,33 @@ func TestSessionExpired_SendMarksRescan(t *testing.T) {
|
||||
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 !errors.Is(err, ErrSessionRejected) {
|
||||
t.Fatalf("SendMessage() = %v, want ErrSessionRejected", 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".
|
||||
// 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"}},
|
||||
})
|
||||
if !errors.Is(err, ErrSessionExpired) {
|
||||
t.Fatalf("second SendMessage() = %v, want ErrSessionExpired", err)
|
||||
if !errors.Is(err, ErrSessionRejected) {
|
||||
t.Fatalf("second SendMessage() = %v, want ErrSessionRejected", 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)
|
||||
if !errors.Is(perr, ErrSessionRejected) {
|
||||
t.Fatalf("SendProactive() = %v, want ErrSessionRejected", perr)
|
||||
}
|
||||
assertExpiredSession(t, ad)
|
||||
assertRejectedSession(t, ad)
|
||||
}
|
||||
|
||||
func TestSessionExpired_ClearedByScanLogin(t *testing.T) {
|
||||
func TestSessionRejected_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})}
|
||||
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
|
||||
case "/ilink/bot/get_bot_qrcode":
|
||||
return handlerResult{body: jsonBody(map[string]any{
|
||||
"qrcode": "qr-re", "qrcode_img_content": "https://img/qr",
|
||||
@@ -151,20 +153,20 @@ func TestSessionExpired_ClearedByScanLogin(t *testing.T) {
|
||||
}
|
||||
return handlerResult{status: 404}
|
||||
})
|
||||
ad := newExpiredByPollAdapter(t, srv)
|
||||
assertExpiredSession(t, ad)
|
||||
ad := newRejectedByPollAdapter(t, srv)
|
||||
assertRejectedSession(t, ad)
|
||||
|
||||
// An expired token must not short-circuit the scan flow: it is not a usable
|
||||
// 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.
|
||||
qr, err := ad.StartLogin(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("StartLogin() on an expired session = %v, want a fresh QR", err)
|
||||
t.Fatalf("StartLogin() on a rejected 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.
|
||||
// The confirmed scan delivers a new token: the rejected 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)
|
||||
@@ -182,7 +184,7 @@ func TestSessionExpired_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 dead token is not advertised as a
|
||||
// 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).
|
||||
reqs := rec.find(func(q recordedReq) bool { return q.path == "/ilink/bot/get_bot_qrcode" })
|
||||
if len(reqs) != 1 {
|
||||
@@ -195,17 +197,17 @@ func TestSessionExpired_ClearedByScanLogin(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpired_ClearedByNewToken(t *testing.T) {
|
||||
func TestSessionRejected_ClearedByNewToken(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := expiringServer(t)
|
||||
ad := newExpiredByPollAdapter(t, srv)
|
||||
srv := rejectingServer(t)
|
||||
ad := newRejectedByPollAdapter(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)
|
||||
// The rejected token is still configured: the session stays rejected.
|
||||
if err := ad.Connect(context.Background()); !errors.Is(err, ErrSessionRejected) {
|
||||
t.Fatalf("Connect() = %v, want ErrSessionRejected 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)
|
||||
if err := ad.HealthCheck(context.Background()); !errors.Is(err, ErrSessionRejected) {
|
||||
t.Fatalf("HealthCheck() = %v, want ErrSessionRejected while the rejected token is unchanged", err)
|
||||
}
|
||||
|
||||
setBotToken(ad, "tok-fresh")
|
||||
@@ -223,12 +225,12 @@ func TestSessionExpired_ClearedByNewToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionExpired_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
|
||||
func TestSessionRejected_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})}
|
||||
return handlerResult{body: jsonBody(map[string]any{"errcode": errcodeSessionRejected})}
|
||||
case "/ilink/bot/get_bot_qrcode":
|
||||
return handlerResult{body: jsonBody(map[string]any{
|
||||
"qrcode": "qr-2", "qrcode_img_content": "https://img/qr2",
|
||||
@@ -241,13 +243,13 @@ func TestSessionExpired_LoginStatusReportsRescanAndAllowsRescan(t *testing.T) {
|
||||
LoginInterval: time.Millisecond, LoginTimeout: 30 * time.Millisecond,
|
||||
BotToken: "tok-old",
|
||||
}, srv)
|
||||
expireByPoll(t, ad)
|
||||
rejectByPoll(t, ad)
|
||||
|
||||
// A dead token must not read as "confirmed": the panel would re-persist it and
|
||||
// call the bind a success.
|
||||
// A rejected credential 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)
|
||||
t.Fatalf("LoginStatus() = %q, want failed while iLink rejects the stored token", status)
|
||||
}
|
||||
if detail == "" {
|
||||
t.Fatal("LoginStatus() detail is empty, want the re-scan reason")
|
||||
@@ -262,7 +264,7 @@ func TestSessionExpired_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 expired one.
|
||||
// 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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user