- 适配器新增过期状态机(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)
223 lines
9.1 KiB
Go
223 lines
9.1 KiB
Go
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.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Login status values reported by Adapter.LoginStatus.
|
|
const (
|
|
// LoginStatusIdle means no bot token and no scan-login in progress.
|
|
LoginStatusIdle = "idle"
|
|
// LoginStatusWaiting means a QR code is displayed and the user has not yet
|
|
// confirmed the bind.
|
|
LoginStatusWaiting = "waiting"
|
|
// LoginStatusConfirmed means a bot token is present (either a fresh scan or a
|
|
// pre-configured one).
|
|
LoginStatusConfirmed = "confirmed"
|
|
// LoginStatusFailed means the last scan/login attempt errored or timed out.
|
|
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 = "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.
|
|
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 = "已被扫描,请在微信确认"
|
|
)
|
|
|
|
// ErrAlreadyLoggedIn is returned by StartLogin when a bot token is already
|
|
// configured, so the scan flow is moot.
|
|
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).
|
|
type LoginQR struct {
|
|
Qrcode string
|
|
Image string
|
|
}
|
|
|
|
// loginSession tracks one scan-login run. It is guarded by 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 context.CancelFunc
|
|
// done carries the login outcome; the poll goroutine writes exactly once.
|
|
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.
|
|
phaseMu sync.Mutex
|
|
phase string
|
|
}
|
|
|
|
// setPhase records the latest observed wire status.
|
|
func (s *loginSession) setPhase(p string) {
|
|
s.phaseMu.Lock()
|
|
s.phase = p
|
|
s.phaseMu.Unlock()
|
|
}
|
|
|
|
// currentPhase returns the latest observed wire status.
|
|
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.
|
|
func (s *loginSession) confirmingPhase() bool {
|
|
switch s.currentPhase() {
|
|
case "scaned", "scaned_but_redirect", "need_verifycode":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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.
|
|
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
|
|
// 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 {
|
|
// 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.
|
|
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.
|
|
a.log.Info("weixin_ilink: previous qrcode not scanned, minting a fresh one")
|
|
if sess.cancel != nil {
|
|
sess.cancel()
|
|
}
|
|
a.loginSession = nil
|
|
}
|
|
|
|
raw, err := a.fetchQRCode(ctx)
|
|
if err != nil {
|
|
a.setLoginState(LoginStatusFailed, err.Error())
|
|
return LoginQR{}, fmt.Errorf("weixin_ilink: fetch qrcode: %w", err)
|
|
}
|
|
|
|
sessCtx, cancel := context.WithCancel(context.Background())
|
|
sess := &loginSession{
|
|
qrcode: raw.Qrcode,
|
|
image: raw.QrcodeImgContent,
|
|
startedAt: time.Now(),
|
|
cancel: cancel,
|
|
done: make(chan error, 1),
|
|
}
|
|
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.
|
|
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 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.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
|
|
}
|
|
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.
|
|
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.
|
|
func (a *Adapter) setLoginState(status, detail string) {
|
|
a.mu.Lock()
|
|
a.loginStatus = status
|
|
a.loginDetail = detail
|
|
a.mu.Unlock()
|
|
}
|