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) }