feat(channel): 出站消息 id 采集(可选 seam + qq_obv11 两分支取回)

「接她的话」缺的 A 层:平台给她自己那条出站消息分配的 id,此前在解析的那一刻
就被丢掉(HTTP 只校 retcode、WS 写完不等回声、契约层没有 id 出口)。

- internal/channel/adapter/outbound.go(新):SendReceipt/SendOutcome/SendIDStatus
  + 可选接口 OutboundReceiptAdapter + 调用方入口 SendWithReceipt。**不加进
  PlatformAdapter** —— 四个未接线适配器与全部桩实现一行不改(向后兼容硬指标);
  也不用回调:id 是某一次投递的属性,必须随那次投递返回。
- qq_obv11:HTTP 从响应信封 data.message_id 取;WS 登记 send_msg 的 echo 等待者
  (复用 probe.go 的 action 等待表与 routeActionResponse,不另开通道)。取不到
  一律空 id + 三态可分辨(reported/absent/unreported),绝不编造。
- send_msg echo 键改用单调计数器(旧 UnixNano 在 Windows 时钟粒度下会撞键,
  撞键即拿不到自己的回执)。
- 既有 SendMessage/SendProactive 路径行为不变(wantReceipt=false 不等待)。

验收:go build ./... + go vet ./... + go test ./... 全绿(cmd/cyrene、internal/webui
的 setup 失败是 worktree 缺 dist 的既有环境限制,base 同样失败)。
This commit is contained in:
AskaEth committed 2026-10-13 20:38:12 +08:00
1 parent a6f3817061
commit b504df01e0
8 files changed
+1053 -63

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
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"
)
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 / msg,不记正文。
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", resp.Status, "msg", resp.Msg)
}