- 适配器新增过期状态机(plugins/channels/weixin_ilink/session_state.go):getupdates/sendmessage/getuploadurl 收到 errcode=-14 即 markExpired(ERROR 仅一次,只带 bot_id,token 永不入日志),扫码登录拿到新 token 或配置换新 token 时清除(惰性比对) - Connect/HealthCheck 在过期时返回 ErrSessionExpired(可与 ErrNotConnected 区分):不再对死 token 设 connected=true 报假成功;发送路径同样返回该错误而不是笼统 ErrNotConnected - 网关:POST /admin/channels/open 遇过期 → 409 session_expired + 明确文案(不再吞错回 200);GET /admin/channels 摘要新增 needsRescan/lastError(经可选接口类型断言读取,取不到不伪造) - channelbridge:读循环非取消退出时把自身记账的 connected 置 false(热重载被替换者退出不抹掉新实例) - login/login_admin:过期时 StartLogin 允许重新出码、LoginStatus 如实报 failed(原先可能把死 token 当 confirmed 重新落盘) - Docs/api-client v0.39;新增 5 组测试 + 变异验证(去掉标记/恢复假成功/网关不填字段 均 FAIL)
269 lines
9.6 KiB
Go
269 lines
9.6 KiB
Go
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. A confirmed scan
|
|
// delivers a fresh bot_token, which is the canonical way OUT of the expired state
|
|
// (session_state.go): the rescan flag is cleared here.
|
|
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.clearRescanLocked()
|
|
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. Because NO new token arrives,
|
|
// an expired session stays expired here — binded_redirect carries no proof that a
|
|
// token iLink already rejected with errcode=-14 works again, so the adapter does
|
|
// not claim a live session on the strength of it. A confirmed scan (completeLogin)
|
|
// is what clears the expired state.
|
|
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.expiredLocked()
|
|
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. An EXPIRED token is deliberately not
|
|
// advertised: it is not a usable bind, and offering it lets the server answer
|
|
// binded_redirect (no new credentials) — which would leave the owner stuck with a
|
|
// dead token and no way back. Reporting `[]` asks for a real rebind.
|
|
func (a *Adapter) localTokenList() []string {
|
|
if expired, _ := a.expiredState(); expired {
|
|
return []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
|
|
}
|