Files
Cyrene_v2/plugins/channels/qq_obv11/probe.go
T
AskaEth 996201c4ed fix(httpclient): 信封 message 默认不进错误,渠道上游自由文本一并收口
信封路径(非 2xx + 信封体、2xx + code!=0)此前把上游 message 经 RedactText
拼进 error,而上游会在 message 里回显被拒的请求正文 —— 她的原话因此进了错误
与调用方日志(M49 探针实测:code 1: invalid content: 她刚才说的那句话 ...)。

- envelope message 改为**默认不带**(新 opt-in WithEnvelopeMessage(),与
  trace.UpstreamError 的 message 同一口径);code 与上游 status 保留,
  errors.Is 的 ErrAPI / trace.ErrUpstream 链不变。
- opt-in 下统一走单一清洗 upstreamText:压平空白 + trace.RedactTextLimited,
  上限 40 rune(比 trace 的 128 短:错误里那句话是提示,不是摘要)。
- plugins/channels 同族出口一并收口(扫过全部,命中 4 处):
  telegram description、qq_obv11 msg(错误 + logEchoFailure 日志)、
  weixin_ilink CDN x-error-message;qq 的 status 字段改走 trace.ErrorToken
  令牌守卫。被剥掉的一律由 status / error_code / retcode 承担分类。

每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
2026-10-26 19:28:15 +08:00

309 lines
13 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)后退避循环干净退出。
//
// 日志契约(2026-10-05 修订):探活的四种结局都必须在日志里有一席之地,因为「没跑」与
// 「跑了但一直失败」此前同样沉默,排障时无法区分——
// - 启动一行:启用(间隔/超时/端)或禁用(原因),见 logProbeStart;
// - 首次失败一行 WARN,之后每 probeWarnEvery 次再 WARN 一次、其余 DEBUG,见 logProbeFailure;
// - 从失败恢复一行 INFO,见 runProbeLoop;
// - 状态变化一行(INFO 在线 / WARN 离线),见 recordAccountStatus。
// 所有失败错误的构造路径都只带 action 名与 OneBot 的 retcode/status,不带 token、完整
// URL 与消息正文。
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"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_"
// probeWarnEvery 是连续失败期间「仍然打 WARN」的节流步长:第 1 次失败必 WARN,之后
// 每 probeWarnEvery 次再 WARN 一次,其余降 DEBUG。按 reconnect.go 的退避口径
// (2s 起步、×2、60s 封顶),10 次连续失败约 5 分钟,于是「长期掉线」既能被看见、
// 又绝不刷屏。
probeWarnEvery = 10
)
// 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 {
return a.probeDisabledReason() == ""
}
// probeDisabledReason 返回探活不可用的原因(可用时返回空串)。它与 probeEnabled 共用
// 同一份判据,因此日志里说的「启用/禁用」与真正是否起了循环永远一致。
func (a *Adapter) probeDisabledReason() string {
if a.cfg.Transport == transportHTTP && a.currentAPI() == nil {
return "http transport without apiBaseUrl: no outbound action channel"
}
return ""
}
// logProbeStart 在 Connect 里报一行探活状态:启用时给出间隔、超时与端,禁用时给出原因。
// 没有这一行,「探活根本没跑」与「跑了但一直失败」在日志里长得一模一样(都是没有)。
func (a *Adapter) logProbeStart() {
if reason := a.probeDisabledReason(); reason != "" {
a.log.Info("onebot get_status probe disabled", "reason", reason)
return
}
a.log.Info("onebot get_status probe enabled",
"interval", a.probeEvery, "timeout", a.probeTimeout,
"transport", a.cfg.Transport, "mode", a.cfg.Mode)
}
// runProbeLoop 是周期探活循环:正常每 probeEvery 探一次;连续失败按 reconnect 口径退避
// (复用 a.policy.delayFor),成功即清零回到正常间隔。ctx 取消(Disconnect)后干净退出,
// 不泄漏 goroutine。先睡眠再探活,避免与既有的启动期请求(如 HTTP 的 health probe)
// 在同一瞬间叠成一串。
//
// 失败**绝不静默**:每次失败都经 logProbeFailure 落一条(首败 WARN、之后节流),恢复时
// 补一条 INFO。此前「失败即 continue」让探活长期失败与「根本没跑」无法区分。
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++
a.logProbeFailure(err, failures)
continue
}
if failures > 0 {
a.log.Info("onebot get_status probe recovered", "failedAttempts", failures)
failures = 0
}
}
}
// logProbeFailure 记一次探活失败:连续失败里的第 1 次必 WARN(含 wrapped error、连续次数
// 与下次重试间隔),其后每 probeWarnEvery 次再 WARN 一次,其余降 DEBUG——既守住「不许
// 静默」,又不会在长期掉线时每秒刷屏。err 只带 action 名与 OneBot 的 retcode/status。
func (a *Adapter) logProbeFailure(err error, failures int) {
args := []any{"err", err, "failures", failures, "retryIn", a.policy.delayFor(failures)}
switch {
case failures == 1:
a.log.Warn("onebot get_status probe failed", args...)
case failures%probeWarnEvery == 0:
a.log.Warn("onebot get_status probe still failing", args...)
default:
a.log.Debug("onebot get_status probe failed", args...)
}
}
// probeStatus 调一次 get_status,把 online/good 落进账号状态。任何失败(未连接、对端
// 报错、超时、信封/载荷不合法)都以 wrapped error 返回,由退避循环决定下一次等待,
// 由 logProbeFailure 落日志。
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)
}
data, err := parseStatusResponse(resp)
if err != nil {
return err
}
a.recordAccountStatus(data.Online, data.Good)
return nil
}
// parseStatusResponse 校验 OneBot 响应信封并解出 online/good。
//
// 为什么必须在探活侧校验(而不是只管 HTTP):HTTP 端经 http_client.post,天然有
// retcode/status 校验;WS 端是读循环把裸帧投回等待表,**没有**任何信封校验。于是
// 「retcode!=0 且 data 为 null」的失败响应会被解成 `online=false, good=false`——
// 把「探活失败」谎报成「账号离线」,这比不报更危险;`data` 字段缺失则退化成 json 解码
// 错误,虽不撒谎但同样要靠日志才看得见。
func parseStatusResponse(resp *obAPIResponse) (obStatusData, error) {
if resp == nil {
return obStatusData{}, errors.New("qq-obv11: get_status response: empty envelope")
}
if resp.RetCode != 0 || (resp.Status != "" && !strings.EqualFold(resp.Status, "ok")) {
return obStatusData{}, fmt.Errorf("qq-obv11: get_status response: retcode=%d%s",
resp.RetCode, upstreamStatusSuffix(resp.Status))
}
var data *obStatusData
if err := json.Unmarshal(resp.Data, &data); err != nil {
return obStatusData{}, fmt.Errorf("qq-obv11: decode get_status data: %w", err)
}
if data == nil {
return obStatusData{}, errors.New("qq-obv11: get_status response: data is null or missing")
}
return *data, 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 := a.nextProbeEcho()
payload, err := buildActionReq(action, params, echo)
if err != nil {
return nil, fmt.Errorf("qq-obv11: encode %s call: %w", action, err)
}
ch := a.registerActionWait(echo)
defer a.clearActionWait(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())
}
}
// nextProbeEcho 生成一个**进程内唯一**的回执关联键。此前用 time.Now().UnixNano():时钟
// 粒度粗时相邻两次调用可能落在同一个 tick 上,后一次登记会覆盖前一次的等待通道,先登记的
// 那个只能等到超时。改用单调计数器后,唯一性不再依赖时钟精度。
func (a *Adapter) nextProbeEcho() string {
return probeEchoPrefix + strconv.FormatUint(a.probeSeq.Add(1), 10)
}
// registerActionWait 登记一个待回执的 echo,返回容量 1 的投递通道(写方绝不阻塞)。
func (a *Adapter) registerActionWait(echo string) chan *obAPIResponse {
ch := make(chan *obAPIResponse, 1)
a.actionMu.Lock()
a.actionWait[echo] = ch
a.actionMu.Unlock()
return ch
}
// clearActionWait 注销 echo(等待超时 / 已收到回执)。
func (a *Adapter) clearActionWait(echo string, ch chan *obAPIResponse) {
a.actionMu.Lock()
if a.actionWait[echo] == ch {
delete(a.actionWait, echo)
}
a.actionMu.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.actionMu.Lock()
ch := a.actionWait[head.Echo]
a.actionMu.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
}