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 ( "time" "context" "errors" "fmt" ) // 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" ) // 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 } // qrRefreshTTL bounds how long a WeChat bot QR is usable before StartLogin re-mints // a fresh one. iLink bot QRs are short-lived; an idempotent StartLogin must not keep // serving a long-expired QR (the user would scan it and get "二维码过期"). const qrRefreshTTL = 90 * 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 { // Idempotent for a fresh QR, but re-mint once the current QR has aged past // qrRefreshTTL so the panel's 刷新二维码 always yields a scannable code. if time.Since(sess.startedAt) < qrRefreshTTL { return LoginQR{Qrcode: sess.qrcode, Image: sess.image}, nil } 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, "") // Log only the renderable URL, never the raw qrcode (it is a secret). a.log.Info("scan the qr code to bind the bot", "url", raw.QrcodeImgContent) 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() }