package weixin_ilink import ( "bytes" "context" "encoding/json" "errors" "fmt" "net/http" "net/url" "strconv" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // loginPollInterval 是 Connect 轮询二维码确认状态的默认间隔。它是文档记录的约 1s // 节奏;Config.LoginInterval 字段可覆盖它(测试用它把流程压缩得很短)。 const loginPollInterval = 1 * time.Second // qrcodeResp 是 get_bot_qrcode 的响应。 type qrcodeResp struct { Qrcode string `json:"qrcode"` QrcodeImgContent string `json:"qrcode_img_content"` } // qrcodeStatusResp 是 get_qrcode_status 的响应。只有 status 为 "confirmed" 时才会 // 填充 token 与各类 id。 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 携带 status 为 "scaned_but_redirect" 时下一个要轮询的 IDC 主机 // (官方的 currentApiBaseUrl 重定向)。 RedirectHost string `json:"redirect_host"` } // login 是 Connect 一侧的扫码绑定(design/08 §4)。它通过 StartLogin 加入(或发起) // 一次扫码登录,并阻塞直到用户确认或窗口结束。由于它与面板的 StartLogin 共用同一套 // 会话机制,面板发起的登录与 Connect 绝不会同时跑两个二维码绑定。 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 轮询 get_qrcode_status,直到二维码被确认或登录窗口结束,然后把结果记录在 // 会话上。它运行在 goroutine 中,所以 StartLogin 能立即返回,而面板轮询 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 { // 轮询期间出现期限到点/取消,意味着整个窗口已过:上报 // ErrLoginTimeout,而不是原始的传输层错误。 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 } // 跟踪线上的原始阶段,便于 StartLogin 保护「已扫描/确认中」的绑定不被重新取码。 // 仅在状态变化时打日志以避免噪声,且绝不记录 qrcode 标识符或 token。 // // 🔴 status 是**上游字段**:进日志前过令牌守卫(trace.ErrorToken)。真实取值 // (wait / scaned / confirmed / scaned_but_redirect / …)全是短令牌 ⇒ 原样通过; // 上游若在这一栏塞散文,这一行就是空串 —— 与 qq_obv11 的 status、 // trace.UpstreamError 的 code/type 同一道守卫(M50 复审的第五个出口)。 // **控制流仍用原值**:setPhase 与下面的 switch 都读 status.Status,渲染口径 // 不参与判定。 sess.setPhase(status.Status) if status.Status != prevStatus { prevStatus = status.Status a.log.Info("weixin_ilink: get_qrcode_status", "status", trace.ErrorToken(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": // 被扫描的机器人已绑定到本实例,不会签发新凭据。把它当作「已绑定」的 // 成功结果处理。 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": // 码已被扫描,正在手机端等待确认。保留该会话并继续轮询——此处重新取码会 // 丢弃待处理的确认。用向后兼容的 waiting 状态加一条详情暴露该状态, // 便于面板提示用户去确认。 a.setLoginState(LoginStatusWaiting, loginScannedDetail) case "scaned_but_redirect": if status.RedirectHost != "" { // 🔴 路由用**原值**(下一行),日志只留守卫后的形态:redirect_host 是上游 // 字段,与上面的 status 同一道令牌守卫(trace.ErrorToken)。真实主机名通过; // 带端口(`h:8443`)或散文的整体丢弃 —— 宁可日志少一个字段,也不让这一栏 // 成为第二个正文通道。 a.setPollBaseURL(status.RedirectHost) a.log.Info("weixin_ilink: idc redirect, switching polling host", "host", trace.ErrorToken(status.RedirectHost)) } else { a.log.Warn("weixin_ilink: scaned_but_redirect without redirect_host, continuing on current host") } case "need_verifycode": // 服务端要求输入手机微信上显示的配对码。暴露该状态与提示,便于面板提示 // 用户;该码本身尚未回传到轮询(见 TODO)。 a.setLoginState(LoginStatusNeedVerifyCode, loginNeedVerifyCodeHint) } // wait / scaned / expired / need_verifycode / scaned_but_redirect 会持续轮询, // 直到确认或窗口结束。 select { case <-timeoutCtx.Done(): a.failLogin(sess, fmt.Errorf("%w: %w", ErrLoginTimeout, timeoutCtx.Err())) return case <-ticker.C: } } } // completeLogin 持久化已确认的凭据并把会话标记为存活。它在 a.mu 下写 cfg,再在 // loginMu 下清空会话(绝不嵌套两把锁),最后唤醒等待中的 Connect 路径。确认的扫码 // 会交付新的 bot_token,这是走出「被拒绝」状态的正规途径(session_state.go): // 重扫标志就在这里被清除。 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 把 binded_redirect 当作「已绑定」的结果:被扫描的机器人 // 已关联到本实例,因此服务端不签发新凭据,已有 token 继续有效。由于**没有**新 token // 到达,被拒绝的会话在这里保持被拒绝——binded_redirect 并不证明一份已被 iLink 以 // errcode=-14 拒绝的 token 重新可用,所以适配器不凭它宣称会话存活。清除被拒绝状态的 // 是确认的扫码(completeLogin)。 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.rejectedLocked() 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 记录一次失败的扫码登录并唤醒等待中的 Connect 路径。被取代的会话 // (因 StartLogin 要为未扫描的码生成新二维码而被取消的那个)绝不会覆盖新会话的状态, // 但它等待中的 Connect 仍必须被释放。 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 返回本机持有的 bot token,用于在 get_bot_qrcode 上上报,以便服务端 // 识别出已绑定的机器人(binded_redirect)。全新绑定时返回空的(非 nil)切片,使线上 // 载荷携带 `[]` 而不是 `null`,与官方客户端一致。已被 iLink **拒绝**的 token 刻意 // 不上报:它不是可用绑定,把它交出去会让服务端回以 binded_redirect(不签发新凭据) // ——那会让主人卡在一份不可用的凭据上、没有回头路。上报 `[]` 才是请求一次真正的 // 重新绑定。 func (a *Adapter) localTokenList() []string { if rejected, _ := a.rejectedState(); rejected { return []string{} } a.mu.RLock() defer a.mu.RUnlock() if a.cfg.BotToken == "" { return []string{} } return []string{a.cfg.BotToken} } // fetchQRCode 申请一个新的机器人登录二维码。官方客户端以 POST 携带 local_token_list // 发出该请求,使服务端能探测到已存在的绑定并回以 binded_redirect,而不是新码。 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") } // 只记录可渲染的二维码 URL,绝不记录原始 qrcode 标识符(它是回传给 // get_qrcode_status 的密钥)。时间戳由 logger 附加。 // // 🔴 URL 一律走 trace.MaskQuery(M50 复审补扫):它是**上游给的**串,可能是带签名 // 的图片地址,也可能整段是 `data:` 内联图 —— 原样落日志等于把二维码(扫码即绑定) // 抄进一个会被 tail、采集、粘进工单的面。MaskQuery 打码凭据类 query 值并保留路径, // 解析不出 host(data: URI、畸形串)时回空串而**不**回原串。 a.log.Info("weixin_ilink: get_bot_qrcode ok", "url", trace.MaskQuery(out.QrcodeImgContent)) return out, nil } // pollQRStatus 检查某个二维码是否已确认。若已记录 IDC 重定向主机 // (scaned_but_redirect)则轮询它,否则轮询基础 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 }