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