信封路径(非 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 承担分类。 每条新增/改写用例都做过变异验证(把字段拼回去即变红)。
172 lines
8.4 KiB
Go
172 lines
8.4 KiB
Go
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))
|
||
}
|