Files
Cyrene_v2/plugins/channels/qq_obv11/probe.go
T
AskaEth a79fe9684e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): QQ 账号 get_status 主动探活(复用既有 action 通道 / 状态变化才记日志 / AccountStatus 与 WS 连通分开)
2026-10-05 12:10:34 +08:00

220 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package qq_obv11
// 本文件实现 OneBot **账号在线状态**的主动探活(`get_status`)。它是 2026-10-05
// 事故(QQ 账号 11:41:18 掉线,而面板仍显示「已连接」、日志里一条痕迹都没有)的正面修复。
//
// 关键区分(本文件存在的理由):`IsConnected`(会话连通)与「QQ 账号在线」是**两件事**。
// OneBot 实现(NapCat 等)的进程与正向 WS 可能一直连着,而它背后的 QQ 账号却已掉线——
// 于是 `IsConnected` 为真、消息却收不到、发不出去。账号的在线与否只有 OneBot 侧知道:
// - 上游可配 `meta_event` 心跳主动推送(取决于对端配置,本项目此前直接丢弃);
// - 兜底手段就是**主动探活**:周期调 OneBot 标准 action `get_status`,读回
// `{online, good}`。探活不依赖对端是否配置了心跳推送,本文件即这条兜底通道。
//
// 走的是**既有的 action 调用通道**(与 `send_msg` 同类,不另造通道):
// - HTTP 端复用 `obHTTPClient`(请求-响应天然带 echo,见 http_client.go 的 call);
// - WS 两端复用同一条 `wsConn` 写帧,并按 `echo` 关联读循环投回来的回执。
//
// 探活口径:正常间隔约 60s(defaultProbeInterval);失败按 reconnect.go 的退避口径
// (复用 a.policy)退避重试,成功即回到正常间隔;单次探活超时收紧到 defaultProbeTimeout。
// 状态**只在变化时**记一条带 source 的日志(在线→离线 / 离线→在线,含 `good`),
// 绝不记 token / 完整 URL / 正文。ctx 取消(Disconnect)后退避循环干净退出。
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
const (
// actionGetStatus 是 OneBot v11 的标准 action,返回 `{online, good}`。
actionGetStatus = "get_status"
// defaultProbeInterval 是两次探活之间的正常间隔。取 60s:足够快地在掉线后察觉,
// 又不至于把 OneBot 实现压成每秒一次的轮询。
defaultProbeInterval = 60 * time.Second
// defaultProbeTimeout 是单次探活的等待上限。探活只要一个布尔,绝不该拖住退避循环,
// 因此比 defaultHTTPTimeout 更紧(可由 Adapter.probeTimeout 覆盖,便于测试)。
defaultProbeTimeout = 5 * time.Second
// probeEchoPrefix 让探活的 echo 与 send_msg 的 `echo_*` 区分开,避免误配。
probeEchoPrefix = "status_"
)
// obStatusData 是 OneBot `get_status` 返回的 data 载荷(只取本能力需要的两个字段)。
type obStatusData struct {
Online bool `json:"online"`
Good bool `json:"good"`
}
// accountStatus 是账号级在线状态的快照。known 区分「确知离线」与「尚未探到」。
type accountStatus struct {
online bool
good bool
known bool
}
// AccountStatus 报告 OneBot 侧的**账号**在线状态,与 IsConnected(会话连通)分属两件事。
// known=false 表示尚未探到结果(探活未接线、刚启动、或一直失败)——调用方必须把它与
// 「确知离线(online=false, known=true)」分开呈现,绝不用「未探到」冒充「在线」或「离线」。
func (a *Adapter) AccountStatus() (online bool, known bool) {
a.mu.RLock()
defer a.mu.RUnlock()
return a.account.online, a.account.known
}
// probeEnabled 报告该端是否存在可用的 action 通道。WS 两端总是可能(连接稍后建立);
// HTTP 端只有在装配了出站客户端(api_base_url)时才有得可探。
func (a *Adapter) probeEnabled() bool {
if a.cfg.Transport == transportHTTP {
return a.currentAPI() != nil
}
return true
}
// runProbeLoop 是周期探活循环:正常每 probeEvery 探一次;连续失败按 reconnect 口径退避
// (复用 a.policy.delayFor),成功即清零回到正常间隔。ctx 取消(Disconnect)后干净退出,
// 不泄漏 goroutine。先睡眠再探活,避免与既有的启动期请求(如 HTTP 的 health probe)
// 在同一瞬间叠成一串。
func (a *Adapter) runProbeLoop(ctx context.Context) {
failures := 0
for {
wait := a.probeEvery
if failures > 0 {
wait = a.policy.delayFor(failures)
}
if !sleepCtx(ctx, wait) {
return
}
if err := a.probeStatus(ctx); err != nil {
failures++
continue
}
failures = 0
}
}
// probeStatus 调一次 get_status,把 online/good 落进账号状态。任何失败(未连接、对端
// 报错、超时、data 解析失败)都以 wrapped error 返回,由退避循环决定下一次等待。
func (a *Adapter) probeStatus(ctx context.Context) error {
pctx, cancel := context.WithTimeout(ctx, a.probeTimeout)
defer cancel()
resp, err := a.callAction(pctx, actionGetStatus, map[string]any{})
if err != nil {
return fmt.Errorf("qq-obv11: get_status probe: %w", err)
}
var data obStatusData
if err := json.Unmarshal(resp.Data, &data); err != nil {
return fmt.Errorf("qq-obv11: decode get_status data: %w", err)
}
a.recordAccountStatus(data.Online, data.Good)
return nil
}
// recordAccountStatus 落一次账号状态观测,**只在状态发生变化时**记一条带 source 的日志
// (在线→离线 / 离线→在线),含 OneBot 返回的 good;不记 token / 完整 URL / 正文。
func (a *Adapter) recordAccountStatus(online, good bool) {
a.mu.Lock()
prev := a.account
changed := !prev.known || prev.online != online
a.account = accountStatus{online: online, good: good, known: true}
a.mu.Unlock()
if !changed {
return
}
if online {
a.log.Info("onebot account online", "good", good)
return
}
a.log.Warn("onebot account offline", "good", good)
}
// callAction 通过适配器**既有的** OneBot action 调用通道发一次请求并取回响应:HTTP 端
// 复用 sendMessage 用的同一个 obHTTPClient;WS 两端复用 sendMessage 用的同一条 wsConn。
// 绝不新建第二套 HTTP 客户端或第二条请求通道。
func (a *Adapter) callAction(ctx context.Context, action string, params any) (*obAPIResponse, error) {
if api := a.currentAPI(); api != nil {
return api.call(ctx, action, params)
}
return a.wsCall(ctx, action, params)
}
// wsCall 在 WS 长连接上写一条 action 请求,并按 echo 等待读循环投回的回执。回执经
// handlePayload → routeActionResponse 落到等待表(WS 上的请求-响应就该走这条既有读循环,
// 不另开读连接)。
func (a *Adapter) wsCall(ctx context.Context, action string, params any) (*obAPIResponse, error) {
conn := a.currentConn()
if conn == nil {
return nil, fmt.Errorf("qq-obv11: call %s: %w", action, adapter.ErrNotConnected)
}
// 等待回执只认一个时钟:优先用调用方给的 deadline(probeStatus 已收紧到
// a.probeTimeout);调用方没设 deadline 时才补一个,绝不让等待无界。
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, a.probeTimeout)
defer cancel()
}
echo := probeEchoPrefix + strconv.FormatInt(time.Now().UnixNano(), 10)
payload, err := buildActionReq(action, params, echo)
if err != nil {
return nil, fmt.Errorf("qq-obv11: encode %s call: %w", action, err)
}
ch := a.registerProbeWait(echo)
defer a.clearProbeWait(echo, ch)
if err := conn.WriteMessage(opText, payload); err != nil {
return nil, fmt.Errorf("qq-obv11: write %s: %w", action, err)
}
select {
case resp := <-ch:
return resp, nil
case <-ctx.Done():
return nil, fmt.Errorf("qq-obv11: call %s: %w", action, ctx.Err())
}
}
// registerProbeWait 登记一个待回执的 echo,返回容量 1 的投递通道(写方绝不阻塞)。
func (a *Adapter) registerProbeWait(echo string) chan *obAPIResponse {
ch := make(chan *obAPIResponse, 1)
a.probeMu.Lock()
a.probeWait[echo] = ch
a.probeMu.Unlock()
return ch
}
// clearProbeWait 注销 echo(等待超时 / 已收到回执)。
func (a *Adapter) clearProbeWait(echo string, ch chan *obAPIResponse) {
a.probeMu.Lock()
if a.probeWait[echo] == ch {
delete(a.probeWait, echo)
}
a.probeMu.Unlock()
}
// routeActionResponse 尝试把一帧当作 action 回执投递给等待中的调用方。命中返回 true;
// 不是回执(有 post_type)或没有等待者返回 false,交回 handlePayload 走既有入站路径。
func (a *Adapter) routeActionResponse(payload []byte) bool {
var head struct {
PostType string `json:"post_type"`
Echo string `json:"echo"`
}
if err := json.Unmarshal(payload, &head); err != nil || head.Echo == "" || head.PostType != "" {
return false
}
a.probeMu.Lock()
ch := a.probeWait[head.Echo]
a.probeMu.Unlock()
if ch == nil {
return false
}
var resp obAPIResponse
if err := json.Unmarshal(payload, &resp); err != nil {
return false
}
select {
case ch <- &resp:
default:
}
return true
}