Files
AskaEth b504df01e0 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 同样失败)。
2026-10-13 20:38:12 +08:00

138 lines
7.5 KiB
Go
Raw Permalink 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 adapter
// 本文件承载**出站消息 id 的出口**(切片 OUTBOUNDID / M12 的 A 层)。
//
// 背景(M11 查明的缺口):`PlatformAdapter.SendMessage(ctx, resp) error` 只回成败,
// **没有任何位置**放下「平台给这条消息分配的 id」。于是她自己刚发出的那条消息在系统里
// 没有身份 —— 平台侧明明把 id 回给了我们,却在解析的那一刻就被丢掉。任何「接她的话」
// (对方回复的是**她自己**那条消息 ⇒ `UnifiedMessage.ReplyToID` 落在她自己的出站 id 上)
// 的判定都无从谈起。
//
// ## 形态选择:**可选接口 + 调用方探测**(不采用返回 id 的直接改签名,也不用回调)
//
// 范式与 `SystemNoticeNormalizer` / `DeliveryFailure` 逐字同族 —— 都在本包,都是
// 「适配器按需实现、调用方探测」,理由也一样:契约层是唯一不必让任何一方反向依赖
// 具体插件的落点。
//
// - **不把它加进 `PlatformAdapter`**:加方法等于契约破坏 —— 四个未接线适配器
// (kook / telegram / weixin-ilink / webhook)与全部桩实现会**立刻编译失败**,
// 而它们拿不到 id 的原因是**平台不给**,不是「没接线」。向后兼容是硬指标。
// - **也不采用回调 / sink 注册**:那会把 id 变成异步、无关联的推送 —— 调用方拿到一条
// id 时已经无法确定它属于哪一次发送、哪一个会话;重连与重试下还会把上一轮的旧 id
// 混进环形表。出站 id 是**某一次投递的属性**,就该随那次投递一起返回。
//
// ## 三条铁律(任何实现都必须遵守)
//
// 1. **取不到就空**:`SendReceipt.MessageID` 为空一律表示「不知道」,绝不用占位值 /
// 序号 / 时间戳兜底。一个编出来的 id 会让「接她的话」凭空命中(把别人的话认成
// 回给她的)。宁可判不出来,不许判错。
// 2. **三态可分辨**:`SendIDStatus` 把「回执带回了 id」「回执到了但没带 id」
// 「压根没等到回执」分开 —— 排障时才分得清「平台没上报」与「我们没等到」。
// 3. **不改变既有投递语义**:这是**增量** seam。`SendMessage` 的成败判据、限流、
// 目标解析一律照旧;没实现本接口的适配器行为一个字节都不变。
import "context"
// SendOutcome 是一次投递的完整结果:**逐条**的投递凭据,顺序与
// `UnifiedResponse.Messages` 一一对应。
//
// 用结构体而不是裸切片:日后要给投递结果补别的属性(逐条耗时可观测、失败分类)时,
// 签名不必再动一次。
type SendOutcome struct {
// Receipts 是每条已投递消息的回执。调用方必须按**下标**与 Messages 对齐 ——
// 长度不一致说明适配器没遵守契约(实现应保证等长)。
Receipts []SendReceipt
}
// SendReceipt 是一条出站消息的投递凭据。`MessageID` 是出站 id 的**唯一**出口。
type SendReceipt struct {
// MessageID 是平台回执给出的消息 id(OneBot 的 `data.message_id`)。
//
// **空串一律表示「不知道」**:没等到回执、回执里没有这个字段、或平台根本不给。
// 绝不编造 —— 下游判「对方是不是在接她的话」时,空 id 必须落成「判不出来」,
// 而不是拿一个假值去撞环形表。
MessageID string
// IDStatus 说明上面那个空 / 非空是怎么来的(见 SendIDStatus 的三态)。
IDStatus SendIDStatus
}
// SendIDStatus 是出站 id 的**到达状态**(三态,绝不合并)。
//
// 零值即 `SendIDUnreported`(不知道):把「不知道」设为零值是刻意的 fail-closed
// 设计 —— 一个只写了 `SendReceipt{}` 的实现天然表示「我取不到 id」,绝不会因为
// 忘了填某个字段而被读成「取到了」。
type SendIDStatus string
const (
// SendIDUnreported 表示**没等到回执**:等待超时 / 连接在投递中途断了 / 适配器
// 压根没有取 id 的能力(此时它不该实现 OutboundReceiptAdapter,但调用方探测失败
// 时也走这一态)。帧可能已经写出去了 ——「发没发出去」由 error 回答,这一态只回
// 答「id 拿没拿到」。
SendIDUnreported SendIDStatus = ""
// SendIDReported 表示**回执把 id 带回来了**(此时 MessageID 必非空)。
SendIDReported SendIDStatus = "reported"
// SendIDAbsent 表示**回执到了,但它没带 id**(字段缺失 / 为 null / 形态不可用)。
// 与 SendIDUnreported 分开:前者是「平台明确给了个空」,后者是「我们没等到」。
SendIDAbsent SendIDStatus = "absent"
)
// NewSendReceipt 由平台回执里的 id 构造凭据:非空 ⇒ Reported,空 ⇒ Absent。
//
// 它是**唯一的构造出处**,因此 `MessageID` 与 `IDStatus` 不可能自相矛盾
// (不会出现「状态说取到了、id 却是空的」)。只用于「回执已经到了」的场景 ——
// 没等到回执时用零值 `SendReceipt{}`(SendIDUnreported)。
func NewSendReceipt(platformID string) SendReceipt {
if platformID == "" {
return SendReceipt{IDStatus: SendIDAbsent}
}
return SendReceipt{MessageID: platformID, IDStatus: SendIDReported}
}
// Reported 报告这条凭据里是否真的有一个可用的平台 id。
func (r SendReceipt) Reported() bool {
return r.IDStatus == SendIDReported && r.MessageID != ""
}
// OutboundReceiptAdapter 是**可选**的出站凭据 seam:能在投递时给出平台消息 id 的适配器
// 实现它。调用方用 `SendWithReceipt` 探测,绝不直接类型断言。
type OutboundReceiptAdapter interface {
// SendMessageWithReceipt 的投递语义与 `SendMessage` **完全一致**(同一套目标解析、
// 限流、错误与重试口径),额外逐条回一条投递凭据。
//
// 返回的 error 只描述**投递**:拿到了回执但回执报错、或回执里没有 id,都**不是**
// 这里的 error(那是 `SendIDStatus` 的答案)。error 非 nil 时 `SendOutcome` 可能
// 只带了前几条的凭据 —— 调用方按 `len(Receipts)` 取**已投递**的那几条。
SendMessageWithReceipt(ctx context.Context, resp UnifiedResponse) (SendOutcome, error)
}
// SendWithReceipt 投递一次回复并**尽力**取回平台消息 id —— 调用方(B 层的环形表)
// 的唯一入口。
//
// - 适配器实现了 `OutboundReceiptAdapter`:走它的凭据路径(HTTP / WS 各按自己的
// 平台协议取)。
// - 没实现(kook / telegram / weixin-ilink / webhook 等):退回既有的 `SendMessage`,
// 并**如实**为每条消息报一条 `SendIDUnreported` 的空凭据 —— 不是失败,是「这个
// 渠道给不出 id」。调用方据此就知道该渠道的环形表永远是空的,而不是以为取到了。
//
// 两种情况的 Receipts 长度都等于 len(resp.Messages),调用方不必分叉处理。
func SendWithReceipt(ctx context.Context, a PlatformAdapter, resp UnifiedResponse) (SendOutcome, error) {
if a == nil {
return SendOutcome{}, ErrNotConnected
}
if ra, ok := a.(OutboundReceiptAdapter); ok {
return ra.SendMessageWithReceipt(ctx, resp)
}
if err := a.SendMessage(ctx, resp); err != nil {
return SendOutcome{}, err
}
return SendOutcome{Receipts: makeUnknownReceipts(len(resp.Messages))}, nil
}
// makeUnknownReceipts 造 n 条「不知道」的空凭据(零值即不知道,见 SendIDStatus)。
func makeUnknownReceipts(n int) []SendReceipt {
if n <= 0 {
return nil
}
return make([]SendReceipt, n)
}