Files
Cyrene_v2/plugins/channels/weixin_ilink/login.go
T

110 lines
3.3 KiB
Go

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
}