package weixin_ilink import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strconv" "time" ) // loginPollInterval is the default how often Connect polls the QR confirmation // status. It is the documented ~1s cadence; the Config.LoginInterval field // overrides it (used by tests to keep the flow short). const loginPollInterval = 1 * time.Second // qrcodeResp is the get_bot_qrcode response. type qrcodeResp struct { Qrcode string `json:"qrcode"` QrcodeImgContent string `json:"qrcode_img_content"` } // qrcodeStatusResp is the get_qrcode_status response. The token and ids are // only populated once status is "confirmed". type qrcodeStatusResp struct { Status string `json:"status"` BotToken string `json:"bot_token"` BotID string `json:"ilink_bot_id"` UserID string `json:"ilink_user_id"` BaseURL string `json:"baseurl"` // RedirectHost carries the IDC host to poll next when status is // "scaned_but_redirect" (official currentApiBaseUrl redirect). RedirectHost string `json:"redirect_host"` } // login is the Connect-side scan-code binding (design/08 §4). It joins (or // starts) a scan-login via StartLogin and blocks until the user confirms or the // window elapses. Because it shares the session machinery with the panel's // StartLogin, a panel-initiated login and Connect never run two QR binds at once. func (a *Adapter) login(ctx context.Context) error { _, err := a.StartLogin(ctx) if errors.Is(err, ErrAlreadyLoggedIn) { return nil } if err != nil { return err } a.loginMu.Lock() sess := a.loginSession a.loginMu.Unlock() if sess == nil { return fmt.Errorf("weixin_ilink: login session missing") } select { case lerr := <-sess.done: return lerr case <-ctx.Done(): sess.cancel() return fmt.Errorf("%w: %w", ErrLoginTimeout, ctx.Err()) } } // runLogin polls get_qrcode_status until the QR is confirmed or the login window // elapses, then records the outcome under the session. It runs in a goroutine so // StartLogin returns immediately while the panel polls LoginStatus. func (a *Adapter) runLogin(sess *loginSession, sessCtx context.Context) { timeoutCtx, cancel := context.WithTimeout(sessCtx, a.cfg.LoginTimeout) defer cancel() ticker := time.NewTicker(a.cfg.LoginInterval) defer ticker.Stop() prevStatus := "" for { status, err := a.pollQRStatus(timeoutCtx, sess.qrcode) if err != nil { // A deadline/cancel during the poll means the whole window elapsed; // surface ErrLoginTimeout rather than the raw transport error. if timeoutCtx.Err() != nil { a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err())) } else { a.failLogin(sess, fmt.Errorf("weixin_ilink: poll qr status: %w", err)) } return } // Track the raw wire phase so StartLogin can protect a scanned/confirming bind // from being re-minted. Log only on a status change to avoid noise, and never // the qrcode identifier or tokens. sess.setPhase(status.Status) if status.Status != prevStatus { prevStatus = status.Status a.log.Info("weixin_ilink: get_qrcode_status", "status", status.Status) } switch status.Status { case "confirmed": if status.BotToken == "" { a.failLogin(sess, fmt.Errorf("weixin_ilink: login confirmed but no bot_token returned")) return } a.completeLogin(sess, status) return case "binded_redirect": // The scanned bot is already bound to this instance; no new credentials // are issued. Treat it as an already-bound (successful) outcome. a.completeBindedRedirect(sess) return case "verify_code_blocked": a.failLogin(sess, fmt.Errorf("weixin_ilink: verify code blocked, too many failed attempts")) return case "scaned": // The code has been scanned and awaits confirmation on the phone. Keep the // session and keep polling — re-minting here would discard the pending // confirmation. Expose the state through the backward-compatible waiting // status plus a detail so the panel can prompt the user to confirm. a.setLoginState(LoginStatusWaiting, loginScannedDetail) case "scaned_but_redirect": if status.RedirectHost != "" { a.setPollBaseURL(status.RedirectHost) a.log.Info("weixin_ilink: idc redirect, switching polling host", "host", status.RedirectHost) } else { a.log.Warn("weixin_ilink: scaned_but_redirect without redirect_host, continuing on current host") } case "need_verifycode": // The server asks for a pairing code shown in the phone WeChat. Expose // the state and a hint so the panel can prompt the user; the code itself // is not yet plumbed back to the poll (see TODO). a.setLoginState(LoginStatusNeedVerifyCode, loginNeedVerifyCodeHint) } // wait / scaned / expired / need_verifycode / scaned_but_redirect keep // polling until confirmed or the window elapses. select { case <-timeoutCtx.Done(): a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err())) return case <-ticker.C: } } } // 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. func (a *Adapter) completeLogin(sess *loginSession, status qrcodeStatusResp) { a.mu.Lock() a.cfg.BotToken = status.BotToken a.cfg.BotID = status.BotID a.cfg.OwnerID = status.UserID if status.BaseURL != "" { a.cfg.BaseURL = status.BaseURL } a.connected = true a.loginStatus = LoginStatusConfirmed a.loginDetail = "" a.mu.Unlock() a.log.Info("weixin_ilink: login confirmed", "bot_id", status.BotID) a.loginMu.Lock() if a.loginSession == sess { a.loginSession = nil } a.loginMu.Unlock() sess.done <- nil } // 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. 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.loginStatus = LoginStatusConfirmed a.loginDetail = loginBindedRedirectDetail a.mu.Unlock() a.loginMu.Lock() if a.loginSession == sess { a.loginSession = nil } a.loginMu.Unlock() sess.done <- nil } // failLogin records a failed scan-login and signals the waiting Connect path. A // superseded session (one cancelled so StartLogin could mint a fresh QR for an // unscanned code) never clobbers the newer session's state, but its Connect wait // must still be released. func (a *Adapter) failLogin(sess *loginSession, err error) { a.loginMu.Lock() isCurrent := a.loginSession == sess if isCurrent { a.loginSession = nil } a.loginMu.Unlock() if isCurrent { a.log.Warn("weixin_ilink: login failed", "err", err) a.setLoginState(LoginStatusFailed, err.Error()) } sess.done <- err } // 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. func (a *Adapter) localTokenList() []string { a.mu.RLock() defer a.mu.RUnlock() if a.cfg.BotToken == "" { return []string{} } return []string{a.cfg.BotToken} } // fetchQRCode requests a new bot login QR code. The official client issues this as // a POST carrying local_token_list so the server can detect an existing bind and // answer binded_redirect instead of a fresh code. func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) { path := a.apiURL("/ilink/bot/get_bot_qrcode?bot_type=" + strconv.Itoa(botType)) body, err := json.Marshal(map[string]any{"local_token_list": a.localTokenList()}) if err != nil { return qrcodeResp{}, fmt.Errorf("weixin_ilink: marshal get_bot_qrcode body: %w", err) } var out qrcodeResp if err := a.doJSON(ctx, http.MethodPost, path, bytes.NewReader(body), &out); err != nil { return qrcodeResp{}, err } if out.Qrcode == "" { return qrcodeResp{}, fmt.Errorf("weixin_ilink: get_bot_qrcode returned empty qrcode") } // Log only the renderable QR URL, never the raw qrcode identifier (it is a secret // echoed to get_qrcode_status). The timestamp is attached by the logger. a.log.Info("weixin_ilink: get_bot_qrcode ok", "url", out.QrcodeImgContent) return out, nil } // pollQRStatus checks whether a QR code has been confirmed. It polls the IDC // redirect host if one was recorded (scaned_but_redirect), else the base URL. func (a *Adapter) pollQRStatus(ctx context.Context, qrcode string) (qrcodeStatusResp, error) { path := "/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode) var out qrcodeStatusResp if err := a.doJSON(ctx, http.MethodGet, joinURL(a.pollBase(), path), nil, &out); err != nil { return qrcodeStatusResp{}, err } return out, nil }