package weixin_ilink import ( "context" "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"` } // login runs the one-time scan-code binding (design/08 ยง4): request a QR code, // then poll get_qrcode_status until the user confirms. On confirmation it // persists bot_token, bot_id, owner_id and any returned baseurl, so later // requests carry the Bearer token. func (a *Adapter) login(ctx context.Context) error { loginCtx, cancel := context.WithTimeout(ctx, a.cfg.LoginTimeout) defer cancel() qr, err := a.fetchQRCode(loginCtx) if err != nil { if loginCtx.Err() != nil { return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err()) } return fmt.Errorf("weixin_ilink: fetch qrcode: %w", err) } a.mu.Lock() a.qrcode = qr.Qrcode a.mu.Unlock() // 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", qr.QrcodeImgContent) ticker := time.NewTicker(a.cfg.LoginInterval) defer ticker.Stop() for { status, err := a.pollQRStatus(loginCtx, qr.Qrcode) if err != nil { // A deadline/cancel during the poll means the overall window // elapsed; surface ErrLoginTimeout rather than the raw transport // error. if loginCtx.Err() != nil { return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err()) } return fmt.Errorf("weixin_ilink: poll qr status: %w", err) } if status.Status == "confirmed" && status.BotToken != "" { 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.mu.Unlock() return nil } select { case <-loginCtx.Done(): return fmt.Errorf("%w: %w", ErrLoginTimeout, loginCtx.Err()) case <-ticker.C: } } } // fetchQRCode requests a new bot login QR code. func (a *Adapter) fetchQRCode(ctx context.Context) (qrcodeResp, error) { path := a.apiURL("/ilink/bot/get_bot_qrcode?bot_type=" + strconv.Itoa(botType)) var out qrcodeResp if err := a.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil { return qrcodeResp{}, err } if out.Qrcode == "" { return qrcodeResp{}, fmt.Errorf("weixin_ilink: get_bot_qrcode returned empty qrcode") } return out, nil } // pollQRStatus checks whether a QR code has been confirmed. func (a *Adapter) pollQRStatus(ctx context.Context, qrcode string) (qrcodeStatusResp, error) { path := a.apiURL("/ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)) var out qrcodeStatusResp if err := a.doJSON(ctx, http.MethodGet, path, nil, &out); err != nil { return qrcodeStatusResp{}, err } return out, nil }