Merge branch 'feat/outboundid-capture-platform-message-id-o' into dev
This commit is contained in:
commit
041a6ff504
8 files changed
+1053
-63
No files matched your search
@@ -0,0 +1,137 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package adapter
|
||||
|
||||
// 本文件证明出站 id 出口(outbound.go)的三条硬性质:**向后兼容**(老适配器一行不改、
|
||||
// 探测失败即如实报「不知道」)、**三态可分辨**、**取不到就是空 / 绝不编造**。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// receiptAdapter 是同时实现了 PlatformAdapter 与 OutboundReceiptAdapter 的桩:
|
||||
// 它记录两条路径各被调用了几次,用来证明「实现了 seam 的适配器**只**走凭据路径」
|
||||
// (绝不出现一条消息发两遍)。
|
||||
type receiptAdapter struct {
|
||||
mockAdapter
|
||||
receiptCalls int
|
||||
receipts []SendReceipt
|
||||
}
|
||||
|
||||
func (m *receiptAdapter) SendMessageWithReceipt(_ context.Context, resp UnifiedResponse) (SendOutcome, error) {
|
||||
m.receiptCalls++
|
||||
m.sent = resp.Messages
|
||||
return SendOutcome{Receipts: m.receipts}, nil
|
||||
}
|
||||
|
||||
var (
|
||||
_ PlatformAdapter = (*receiptAdapter)(nil)
|
||||
_ OutboundReceiptAdapter = (*receiptAdapter)(nil)
|
||||
)
|
||||
|
||||
// failAdapter 的投递恒失败,用来验证错误原样穿过 SendWithReceipt。
|
||||
type failAdapter struct {
|
||||
mockAdapter
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *failAdapter) SendMessage(context.Context, UnifiedResponse) error { return f.err }
|
||||
|
||||
func TestNewSendReceiptNeverReportsEmptyID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
absent := NewSendReceipt("")
|
||||
if absent.MessageID != "" || absent.IDStatus != SendIDAbsent || absent.Reported() {
|
||||
t.Errorf("NewSendReceipt(\"\") = %+v, want absent with empty id", absent)
|
||||
}
|
||||
|
||||
reported := NewSendReceipt("1700000012345")
|
||||
if reported.MessageID != "1700000012345" || !reported.Reported() {
|
||||
t.Errorf("NewSendReceipt(id) = %+v, want reported id", reported)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendReceiptZeroValueIsUnknown 钉住 fail-closed 的零值口径:一个只写了
|
||||
// `SendReceipt{}` 的实现表示「不知道」,绝不能被读成「平台给了 id」。
|
||||
func TestSendReceiptZeroValueIsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var zero SendReceipt
|
||||
if zero.IDStatus != SendIDUnreported {
|
||||
t.Errorf("zero IDStatus = %q, want %q", zero.IDStatus, SendIDUnreported)
|
||||
}
|
||||
if zero.MessageID != "" || zero.Reported() {
|
||||
t.Errorf("zero receipt = %+v, want empty and not reported", zero)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlainAdapterIsNotAReceiptAdapter 是**向后兼容**的运行时证据:一个只实现
|
||||
// PlatformAdapter 的适配器(等价于 kook / telegram / weixin-ilink / webhook)**不是**
|
||||
// OutboundReceiptAdapter —— 契约没被破坏,它们一行都不用改。
|
||||
func TestPlainAdapterIsNotAReceiptAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var a PlatformAdapter = &mockAdapter{name: "telegram"}
|
||||
if _, ok := a.(OutboundReceiptAdapter); ok {
|
||||
t.Fatal("plain adapter must not satisfy OutboundReceiptAdapter")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendWithReceiptFallsBackToSendMessage 覆盖未接线渠道:投递照旧走 SendMessage,
|
||||
// 凭据如实报「未取到」——**逐条**与 Messages 等长,且 id 全空(绝不编造)。
|
||||
func TestSendWithReceiptFallsBackToSendMessage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ad := &mockAdapter{name: "telegram"}
|
||||
resp := UnifiedResponse{Messages: []UnifiedMessage{
|
||||
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "一"},
|
||||
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "二"},
|
||||
}}
|
||||
|
||||
out, err := SendWithReceipt(context.Background(), ad, resp)
|
||||
if err != nil {
|
||||
t.Fatalf("SendWithReceipt() error = %v", err)
|
||||
}
|
||||
if len(ad.sent) != 2 {
|
||||
t.Fatalf("fallback did not deliver through SendMessage: sent = %+v", ad.sent)
|
||||
}
|
||||
if len(out.Receipts) != 2 {
|
||||
t.Fatalf("Receipts len = %d, want 2 (one per message)", len(out.Receipts))
|
||||
}
|
||||
for i, r := range out.Receipts {
|
||||
if r.MessageID != "" || r.IDStatus != SendIDUnreported || r.Reported() {
|
||||
t.Errorf("receipt[%d] = %+v, want empty id with unreported status", i, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSendWithReceiptUsesSeamWhenImplemented 证明实现了 seam 的适配器只走凭据路径
|
||||
// (不会两条路径各发一遍)。
|
||||
func TestSendWithReceiptUsesSeamWhenImplemented(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ad := &receiptAdapter{receipts: []SendReceipt{NewSendReceipt("9001")}}
|
||||
ad.name = "qq-obv11"
|
||||
|
||||
out, err := SendWithReceipt(context.Background(), ad, UnifiedResponse{Messages: []UnifiedMessage{
|
||||
{ChannelType: ChannelGroup, GroupID: "123", ContentType: ContentText, Content: "pong"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("SendWithReceipt() error = %v", err)
|
||||
}
|
||||
if ad.receiptCalls != 1 {
|
||||
t.Errorf("receipt path calls = %d, want 1", ad.receiptCalls)
|
||||
}
|
||||
if len(out.Receipts) != 1 || out.Receipts[0].MessageID != "9001" {
|
||||
t.Errorf("Receipts = %+v, want one reported 9001", out.Receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithReceiptPropagatesError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
want := errors.New("boom")
|
||||
ad := &failAdapter{err: want}
|
||||
|
||||
_, err := SendWithReceipt(context.Background(), ad, UnifiedResponse{Messages: []UnifiedMessage{
|
||||
{ChannelType: ChannelDirect, ContentType: ContentText, Content: "x"},
|
||||
}})
|
||||
if !errors.Is(err, want) {
|
||||
t.Fatalf("SendWithReceipt() error = %v, want %v", err, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendWithReceiptNilAdapter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := SendWithReceipt(context.Background(), nil, UnifiedResponse{}); !errors.Is(err, ErrNotConnected) {
|
||||
t.Fatalf("SendWithReceipt(nil) error = %v, want ErrNotConnected", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user