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

@@ -0,0 +1,458 @@
package qq_obv11
// 本文件验收 M12 的 A 层(出站消息 id 采集)三态:**带回 / 没带 / 没等到**。
// 逐条钉死两件事:① 取到就报出来(HTTP 从响应信封的 data.message_id,WS 从 echo 回执);
// ② **取不到就是空**,绝不编造。另加一条「既有发送路径行为不变」的回归。
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// fakeSendAPI 是只服务出站 action 的假 OneBot HTTP API:`data` 由用例给定(原样回),
// 用来驱动「回带 id / 不回带 id」两态。
type fakeSendAPI struct {
data string
}
func (f *fakeSendAPI) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
data := f.data
if data == "" {
data = "{}"
}
_, _ = w.Write([]byte(`{"status":"ok","retcode":0,"msg":"fake","data":` + data + `}`))
})
}
// fakeOneBotWS 是本地假的 OneBot WS 服务端:记录收到的每一帧,并按 reply 决定是否回执。
// reply 返回 false 表示**不回**(「没等到」那一态就是这么造出来的)。
type fakeOneBotWS struct {
mu sync.Mutex
received [][]byte
reply func(action, echo string, params json.RawMessage) ([]byte, bool)
}
func (f *fakeOneBotWS) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.received)
}
func (f *fakeOneBotWS) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgradeWS(w, r)
if err != nil {
return
}
defer func() { _ = conn.Close() }()
for {
_, payload, err := conn.ReadMessage()
if err != nil {
return
}
f.mu.Lock()
f.received = append(f.received, payload)
replyFn := f.reply
f.mu.Unlock()
if replyFn == nil {
continue
}
var req struct {
Action string `json:"action"`
Echo string `json:"echo"`
Params json.RawMessage `json:"params"`
}
if err := json.Unmarshal(payload, &req); err != nil {
continue
}
frame, ok := replyFn(req.Action, req.Echo, req.Params)
if !ok {
continue
}
if err := conn.WriteMessage(opText, frame); err != nil {
return
}
}
})
}
// wsEchoFrame 构造一条 OneBot 动作回执帧:**空 post_type + echo**(就是 routeActionResponse
// 认的那种形态),data 原样拼进去。
func wsEchoFrame(echo, data string) []byte {
return []byte(`{"status":"ok","retcode":0,"data":` + data + `,"echo":"` + echo + `"}`)
}
// echoOf 从请求体里取 echo,用来把回执发回给**发起它的那一次**发送。
func echoOf(t *testing.T, payload []byte) string {
t.Helper()
var req struct {
Echo string `json:"echo"`
}
if err := json.Unmarshal(payload, &req); err != nil {
t.Fatalf("decode outbound frame: %v", err)
}
if req.Echo == "" {
t.Fatal("outbound send_msg must carry an echo for receipt correlation")
}
return req.Echo
}
// newWSReceiptAdapter 起一个 client 模式适配器,指向本地假 OneBot,并把等待回执的上限
// 收紧到 150ms(真实默认 5s 只覆盖异常,测试里不必真等)。
func newWSReceiptAdapter(t *testing.T, srv *httptest.Server) *Adapter {
t.Helper()
url := "ws://" + strings.TrimPrefix(srv.URL, "http://")
a := NewAdapter(Config{Mode: modeClient, RemoteURL: url})
a.sendReceiptTimeout = 150 * time.Millisecond
t.Cleanup(func() { _ = a.Disconnect(context.Background()) })
return a
}
func groupReply(text string) adapter.UnifiedResponse {
return adapter.UnifiedResponse{Messages: []adapter.UnifiedMessage{
{ChannelType: adapter.ChannelGroup, GroupID: "123", ContentType: adapter.ContentText, Content: text},
}}
}
// TestHTTPOutboundIDReported:HTTP 分支从响应信封的 data.message_id 取到 id。
func TestHTTPOutboundIDReported(t *testing.T) {
t.Parallel()
srv := httptest.NewServer((&fakeSendAPI{data: `{"message_id":12345}`}).handler())
defer srv.Close()
a := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient, APIBaseURL: srv.URL})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
out, err := a.SendMessageWithReceipt(context.Background(), groupReply("pong"))
if err != nil {
t.Fatalf("SendMessageWithReceipt error: %v", err)
}
if len(out.Receipts) != 1 {
t.Fatalf("Receipts len = %d, want 1", len(out.Receipts))
}
got := out.Receipts[0]
if got.MessageID != "12345" || got.IDStatus != adapter.SendIDReported || !got.Reported() {
t.Errorf("receipt = %+v, want reported 12345", got)
}
}
// TestHTTPOutboundIDAbsent:回执到了但**没带** id ⇒ 空 id + Absent(可分辨,不编造)。
func TestHTTPOutboundIDAbsent(t *testing.T) {
t.Parallel()
srv := httptest.NewServer((&fakeSendAPI{data: `{}`}).handler())
defer srv.Close()
a := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient, APIBaseURL: srv.URL})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
out, err := a.SendMessageWithReceipt(context.Background(), groupReply("pong"))
if err != nil {
t.Fatalf("SendMessageWithReceipt error: %v", err)
}
got := out.Receipts[0]
if got.MessageID != "" {
t.Errorf("MessageID = %q, want empty (must never invent one)", got.MessageID)
}
if got.IDStatus != adapter.SendIDAbsent {
t.Errorf("IDStatus = %q, want %q", got.IDStatus, adapter.SendIDAbsent)
}
}
// TestHTTPOutboundIDAsString:实现对 message_id 回**字符串**时同样要取到(实现之间有出入)。
func TestHTTPOutboundIDAsString(t *testing.T) {
t.Parallel()
srv := httptest.NewServer((&fakeSendAPI{data: `{"message_id":"777888"}`}).handler())
defer srv.Close()
a := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient, APIBaseURL: srv.URL})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
out, err := a.SendMessageWithReceipt(context.Background(), groupReply("pong"))
if err != nil {
t.Fatalf("SendMessageWithReceipt error: %v", err)
}
if got := out.Receipts[0]; got.MessageID != "777888" || !got.Reported() {
t.Errorf("receipt = %+v, want reported 777888", got)
}
}
// TestWSOutboundIDReported:WS 分支登记 send_msg 的 echo 等待者,从回执里取出 id。
func TestWSOutboundIDReported(t *testing.T) {
t.Parallel()
fake := &fakeOneBotWS{reply: func(action, echo string, _ json.RawMessage) ([]byte, bool) {
if action != "send_msg" {
return nil, false
}
return wsEchoFrame(echo, `{"message_id":9001}`), true
}}
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a := newWSReceiptAdapter(t, srv)
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
out, err := a.SendMessageWithReceipt(context.Background(), groupReply("pong"))
if err != nil {
t.Fatalf("SendMessageWithReceipt error: %v", err)
}
if got := out.Receipts[0]; got.MessageID != "9001" || !got.Reported() {
t.Errorf("receipt = %+v, want reported 9001", got)
}
}
// TestWSOutboundIDAbsent:回执到了但没带 id ⇒ 空 id + Absent。
func TestWSOutboundIDAbsent(t *testing.T) {
t.Parallel()
fake := &fakeOneBotWS{reply: func(action, echo string, _ json.RawMessage) ([]byte, bool) {
if action != "send_msg" {
return nil, false
}
return wsEchoFrame(echo, `{}`), true
}}
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a := newWSReceiptAdapter(t, srv)
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
out, err := a.SendMessageWithReceipt(context.Background(), groupReply("pong"))
if err != nil {
t.Fatalf("SendMessageWithReceipt error: %v", err)
}
got := out.Receipts[0]
if got.MessageID != "" {
t.Errorf("MessageID = %q, want empty (must never invent one)", got.MessageID)
}
if got.IDStatus != adapter.SendIDAbsent {
t.Errorf("IDStatus = %q, want %q", got.IDStatus, adapter.SendIDAbsent)
}
}
// TestWSOutboundIDTimeoutUnknown:对端**根本不回**回执 ⇒ 超时收敛成「不知道」,
// 而且帧确实写出去了(发送成功与「拿不到 id」是两件事)。
func TestWSOutboundIDTimeoutUnknown(t *testing.T) {
t.Parallel()
fake := &fakeOneBotWS{} // reply 为 nil:永远不回
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a := newWSReceiptAdapter(t, srv)
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
start := time.Now()
out, err := a.SendMessageWithReceipt(context.Background(), groupReply("pong"))
elapsed := time.Since(start)
if err != nil {
t.Fatalf("SendMessageWithReceipt error: %v (写完帧不等于投递失败)", err)
}
if got := out.Receipts[0]; got.MessageID != "" || got.IDStatus != adapter.SendIDUnreported {
t.Errorf("receipt = %+v, want empty id with unreported status", got)
}
if elapsed < a.sendReceiptTimeout {
t.Errorf("returned after %v, want at least the %v receipt timeout", elapsed, a.sendReceiptTimeout)
}
if fake.count() != 1 {
t.Errorf("fake OneBot received %d frames, want 1 (frame must be written regardless)", fake.count())
}
}
// TestSendMessageDoesNotAwaitEcho 是**既有发送路径行为不变**的回归:SendMessage 不等
// 回执(对端不回也立刻返回),帧照旧写出去。等待只发生在显式的凭据路径上。
func TestSendMessageDoesNotAwaitEcho(t *testing.T) {
t.Parallel()
fake := &fakeOneBotWS{}
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a := newWSReceiptAdapter(t, srv)
// 故意把等待上限拉到 3s:若 SendMessage 误走等待路径,这条用例会明显变慢。
a.sendReceiptTimeout = 3 * time.Second
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
start := time.Now()
if err := a.SendMessage(context.Background(), groupReply("pong")); err != nil {
t.Fatalf("SendMessage error: %v", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("SendMessage took %v, want no receipt wait on the legacy path", elapsed)
}
if !waitUntil(2*time.Second, func() bool { return fake.count() == 1 }) {
t.Errorf("fake OneBot received %d frames, want the send_msg frame", fake.count())
}
}
// TestWSSendReceiptsCorrelateByEcho:并发的多次发送必须各拿各的 id(echo 关联键唯一,
// **不靠时钟**)。假 OneBot 故意**乱序**回执:后发的先回。
func TestWSSendReceiptsCorrelateByEcho(t *testing.T) {
t.Parallel()
contents := []string{"m1", "m2", "m3"}
delay := map[string]time.Duration{"m1": 120 * time.Millisecond, "m2": 60 * time.Millisecond, "m3": 0}
fake := &fakeOneBotWS{reply: func(action, echo string, params json.RawMessage) ([]byte, bool) {
if action != "send_msg" {
return nil, false
}
var p struct {
Message []struct {
Data struct {
Text string `json:"text"`
} `json:"data"`
} `json:"message"`
}
if err := json.Unmarshal(params, &p); err != nil || len(p.Message) == 0 {
return nil, false
}
text := p.Message[0].Data.Text
time.Sleep(delay[text])
return wsEchoFrame(echo, `{"message_id":`+strings.TrimPrefix(text, "m")+`}`), true
}}
srv := httptest.NewServer(fake.handler())
defer srv.Close()
a := newWSReceiptAdapter(t, srv)
a.sendReceiptTimeout = 2 * time.Second
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
var wg sync.WaitGroup
got := make(map[string]string, len(contents))
var mu sync.Mutex
for _, text := range contents {
wg.Add(1)
go func(text string) {
defer wg.Done()
out, err := a.SendMessageWithReceipt(context.Background(), groupReply(text))
if err != nil || len(out.Receipts) != 1 {
t.Errorf("send %q: out=%+v err=%v", text, out, err)
return
}
mu.Lock()
got[text] = out.Receipts[0].MessageID
mu.Unlock()
}(text)
}
wg.Wait()
for _, text := range contents {
want := strings.TrimPrefix(text, "m")
if got[text] != want {
t.Errorf("send %q got id %q, want %q (echo correlation broken)", text, got[text], want)
}
}
}
// TestPlatformMessageIDForms 钉住 id 的**形态判据**:只有纯十进制数字串能当 id,其余
// 一律空(= 平台没给可用 id),绝不把原始 JSON 片段当 id 用。
func TestPlatformMessageIDForms(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
want string
}{
{"整数", `12345`, "12345"},
{"数字字符串", `"12345"`, "12345"},
{"带前导零的数字串", `"0012"`, "0012"},
{"null", `null`, ""},
{"缺失字段", ``, ""},
{"空字符串", `""`, ""},
{"浮点", `12.5`, ""},
{"负数", `-1`, ""},
{"对象", `{"a":1}`, ""},
{"数组", `[1]`, ""},
{"夹带字母", `"12ab"`, ""},
{"夹带空格", `" 12"`, ""},
{"超长", `"` + strings.Repeat("9", maxPlatformIDLen+1) + `"`, ""},
}
for _, tc := range cases {
if got := platformMessageID(json.RawMessage(tc.raw)); got != tc.want {
t.Errorf("platformMessageID(%s) = %q, want %q", tc.name, got, tc.want)
}
}
}
// TestSendReceiptForensicsLog:出站 id 的采集必须在 **DEBUG 里可取证** —— 「生产里平台
// 到底带不带 id」是下一片(M13 的环形表)能否成立的前提。日志只许记状态与 id,绝不记正文。
func TestSendReceiptForensicsLog(t *testing.T) {
t.Parallel()
buf, log := logCapture()
srv := httptest.NewServer((&fakeSendAPI{data: `{"message_id":4242}`}).handler())
defer srv.Close()
a := NewAdapter(Config{Transport: transportHTTP, Mode: modeClient, APIBaseURL: srv.URL, Logger: log})
if err := a.Connect(context.Background()); err != nil {
t.Fatalf("Connect error: %v", err)
}
defer func() { _ = a.Disconnect(context.Background()) }()
if _, err := a.SendMessageWithReceipt(context.Background(), groupReply("pong")); err != nil {
t.Fatalf("SendMessageWithReceipt error: %v", err)
}
line := buf.String()
if !strings.Contains(line, `msg="send_msg receipt captured"`) {
t.Errorf("missing send receipt forensics line; log = %s", line)
}
if !strings.Contains(line, "message_id=4242") {
t.Errorf("forensics line must carry the captured id; log = %s", line)
}
if strings.Contains(line, "pong") {
t.Errorf("outbound text must never reach the log; log = %s", line)
}
}
// TestMessageSentEchoCarriesPlatformID 证明**适配器侧**已经把 message_sent 回声的平台
// message_id 投影进了结构(SystemNotice.RefMessage)—— 也就是「她自己那条消息的 id」
// 在归一化之后并未丢失,丢它的是下游的档位判定(见交付说明)。
func TestMessageSentEchoCarriesPlatformID(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
notice, ok := a.NormalizeSystemNotice(adapter.UnifiedMessage{RawData: []byte(messageSentPrivate)})
if !ok {
t.Fatal("message_sent must normalize into a system notice (nil ok=false)")
}
if notice.Category != adapter.NoticeCategoryMessageSent {
t.Fatalf("category = %q, want %q", notice.Category, adapter.NoticeCategoryMessageSent)
}
if notice.RefMessage != "77" {
t.Errorf("RefMessage = %q, want 77 (platform message_id of her own echo)", notice.RefMessage)
}
}