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. if a.hasToken() { 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 } // A literal double-click within the reuse window: serve the same code. if time.Since(sess.startedAt) < qrReuseWindow { return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil } // Not scanned yet and past the reuse window: mint a fresh code, cancelling the // old (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. 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() 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() }