Files
Cyrene_v2/plugins/channels/qq_obv11/outbound_id.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

172 lines
8.4 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 v11 **出站消息 id 的采集**(切片 OUTBOUNDID / M12 的 A 层):
// 把「她刚发出的那条消息」在平台侧的 id 取出来,经 adapter.OutboundReceiptAdapter 交出去。
//
// 两条出站通道各有各的取法(同一个 OneBot 协议的两半,不是两套实现):
//
// - **HTTP**(Cyrene→OneBot 的 HTTP API):响应信封本来就等回来了,id 就在
// `data.message_id` 里。此前 http_client.post 只校 retcode/status 就把它扔掉 ——
// 本文件把它读出来。
// - **WS**(两端共用的长连接):`send_msg` 是请求-响应,回执按 `echo` 关联。此前只
// `WriteMessage` 写帧、不等回声,id 根本无从取得;现在**复用 probe.go 的 action
// 等待表**登记等待者(同一张表、同一条 routeActionResponse,不另开请求通道,
// 也不新增读连接)。
//
// 诚实口径(本文件存在的理由):**取不到就是「不知道」**,绝不留一个像 id 的假值。
// 三态(带回 / 没带 / 没等到)在 `adapter.SendReceipt.IDStatus` 里可分辨,逐条与出站
// 消息对齐;一个编出来的 id 会让下一片(M13)的「接她的话」判定凭空命中。
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
const (
// sendEchoPrefix 让 send_msg 的回执关联键与探活(status_)区分开:两者共用同一张
// 等待表,前缀不同就永远撞不了键。
sendEchoPrefix = "send_"
// defaultSendReceiptTimeout 是等待 send_msg 回执的上限(可由 Adapter.sendReceiptTimeout
// 收紧,测试用)。本地 OneBot 的回执是**毫秒级**的 —— 探活 get_status 早就在同一条
// 路径上按 echo 关联,生产一直通。5s 只覆盖真实异常:对端假死、连接刚断、实现不回声。
// 等不到就如实报「不知道」,绝不挂死发送路径。
defaultSendReceiptTimeout = 5 * time.Second
// maxPlatformIDLen 是平台 id 的长度上限。id 是**对端给的**字符串:既不能照收一段
// 任意长度的文本当 id,也不能截断它(截出来的半截 id 是编造)。超长一律判「没给」。
maxPlatformIDLen = 64
)
// obSendData 是 send_msg 响应 `data` 载荷里本能力**唯一**需要的字段。
//
// 用 json.RawMessage 收而不是 int64:实现之间有出入(go-cqhttp 家族回数字,有的回
// 字符串,有的干脆不回),形态校验统一交给 platformMessageID —— 收成 int64 会把
// 「回字符串」误判成 0(= 没给),那是拿实现差异当事实。
type obSendData struct {
MessageID json.RawMessage `json:"message_id"`
}
// receiptFromResponse 从一封装载 send_msg 回执的信封里取凭据。
//
// 它只回答「这一封装载里有没有一个可用的 id」。信封自带的 retcode / status **不在这里
// 判**:HTTP 侧由 http_client.post 按既有判据校验(非 0 直接是 error,根本走不到这里),
// WS 侧由 logEchoFailure 如实记一条 WARN(判据与 post 相同)。两份判据同源,不重复。
func receiptFromResponse(resp *obAPIResponse) adapter.SendReceipt {
if resp == nil {
// 没等到回执 —— 不知道。帧可能已经写出去了,但 id 与我们无关了。
return adapter.SendReceipt{}
}
var data obSendData
if err := json.Unmarshal(resp.Data, &data); err != nil {
// 回执到了,但 data 读不出来(坏形态)。这仍是「回执没带 id」而**不是**
// 「没等到回执」:两态分开,排障时才分得清是谁的问题。
return adapter.NewSendReceipt("")
}
return adapter.NewSendReceipt(platformMessageID(data.MessageID))
}
// platformMessageID 把回执里 message_id 的**原始 JSON 片段**归一成字符串 id。
//
// 只接受两种可辨认的形态:裸数字片段(`12345`)与纯数字字符串(`"12345"`)—— 后者是
// 实现差异,前者是规范形态。其余一切(null、字段缺失、对象 / 数组、浮点、负数、夹带
// 别的字符、超长)一律返回空串(= 平台没给可用 id),绝不把原始片段当 id 用。
func platformMessageID(raw json.RawMessage) string {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || trimmed == "null" {
return ""
}
if unquoted, err := strconv.Unquote(trimmed); err == nil {
return digitsOnly(unquoted)
}
return digitsOnly(trimmed)
}
// digitsOnly 校验候选 id 是**纯十进制数字串**(OneBot 的 message_id 就是这样一个非负
// 整数)。任何其它字符、空串或超长都说明这段文本不是 id —— 返回空串,绝不修正它。
func digitsOnly(s string) string {
if s == "" || len(s) > maxPlatformIDLen {
return ""
}
for i := 0; i < len(s); i++ {
if s[i] < '0' || s[i] > '9' {
return ""
}
}
return s
}
// nextSendEcho 生成 send_msg 的回执关联键,**必须进程内唯一**:等待表按 echo 索引,
// 撞键会让先登记的那次发送拿不到自己的回执(旧实现用 time.Now().UnixNano(),Windows
// 的时钟粒度下一毫秒内的多次发送会落在同一个 tick 上,两次等待互相覆盖)。单调计数器
// 不依赖时钟精度,与 probe.go 的 nextProbeEcho 同款。
func (a *Adapter) nextSendEcho() string {
return sendEchoPrefix + strconv.FormatUint(a.sendSeq.Add(1), 10)
}
// sendOverWSAndAwait 写一条 send_msg 帧并等它的回执。
//
// 返回 nil 信封**不是错误**:帧已经写出去(投递成败沿用既有判据),只是没等到回执
// ⇒ 调用方如实报「不知道」。error 只留给**写帧失败**(既有语义:标记断开 + 返回 error)。
func (a *Adapter) sendOverWSAndAwait(ctx context.Context, conn *wsConn, payload []byte, echo string) (*obAPIResponse, error) {
// 先登记等待者再写帧:回执可能比登记跑得更快,反序会让这一帧变成无人认领的散落
// 回执(handlePayload 对「空 post_type + echo」的帧只交给等待表,命中不了就丢掉)。
ch := a.registerActionWait(echo)
defer a.clearActionWait(echo, ch)
if err := conn.WriteMessage(opText, payload); err != nil {
a.setDisconnected()
return nil, fmt.Errorf("qq-obv11: write send_msg: %w", err)
}
timer := time.NewTimer(a.sendReceiptTimeout)
defer timer.Stop()
select {
case resp := <-ch:
return resp, nil
case <-timer.C:
return nil, nil
case <-ctx.Done():
// 帧已经写出去了:ctx 取消不该把一次**已投递**的消息报成失败(调用方可能重发,
// 群里就收到两条)。只如实标「没等到回执」。
return nil, nil
}
}
// logSendReceipt 记一条**结构级 DEBUG**:这一条出站消息到底有没有取到平台 id。
//
// 「生产里平台到底带不带 id」是下一片(M13 的环形表)能否成立的前提,必须能取证 ——
// 口径与 inbound_debug.go 的入站帧取证相同:只记**状态与 id**,不记正文、不记 token、
// 不记完整 URL。取不到 id 时照样记(id_status=absent / 空),因为「平台没给」与
// 「我们没等到」在排障时必须分得开。
func (a *Adapter) logSendReceipt(ctx context.Context, receipt adapter.SendReceipt) {
if !a.log.Enabled(ctx, slog.LevelDebug) {
return
}
a.log.Debug("send_msg receipt captured",
"id_status", string(receipt.IDStatus), "message_id", receipt.MessageID)
}
// logEchoFailure 报告一次 send_msg 回执自带的成败(判据与 http_client.post 的
// retcode/status 判据相同)。
//
// 回执报错**不**改成返回 error:帧已经写出去了,把「已投递」报成失败会让调用方重发。
// 但**绝不静默** —— 观察到了一个「发送失败」却一声不吭,正是「数据看到了又丢掉」的
// 老毛病(本切片要修的就是它)。只记 retcode 与 status(status 过 trace.ErrorToken 的
// 令牌守卫):上游的 msg / wording 是自由文本面,会把失败的请求内容回显出来,所以
// **不记**(M50 口径,与 http_client.post 的错误文本同一道边界)。
func (a *Adapter) logEchoFailure(resp *obAPIResponse) {
if resp == nil {
return
}
if resp.RetCode == 0 && (resp.Status == "" || strings.EqualFold(resp.Status, "ok")) {
return
}
a.log.Warn("send_msg echo reports failure",
"retcode", resp.RetCode, "status", trace.ErrorToken(resp.Status))
}