220 lines
8.7 KiB
Go
220 lines
8.7 KiB
Go
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
|
||
}
|