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