「接她的话」缺的 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 同样失败)。
138 lines
7.5 KiB
Go
138 lines
7.5 KiB
Go
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)
|
||
}
|