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 同样失败)。
This commit is contained in:
AskaEth committed 2026-10-13 20:38:12 +08:00
1 parent a6f3817061
commit b504df01e0
8 files changed
+1053 -63

No files matched your search

+150
View File
@@ -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)
}
}