- 适配器不再整类丢弃:notice/request/meta_event/message_sent/未知 post_type/JSON 解析失败( decode_error ) 一律归一化投递并留痕
- notice 映射补 group_ban/sub_type 全枚举,未知类型走 NoticeUnknown 兜底(fail-safe 只登记、绝不猜、绝不放行)
- 不投影 flag/comment/file/duration;归一化 DEBUG 只记结构(脱敏,变异实测可打红)
- 传输层端到端已覆盖(真实 http+server 上报路径:加好友请求/心跳/未知类型)
- ⚠️ 主人 2026-10-05 决定「先不连 QQ」(风控)⇒ 本片为通用红线(绝不静默丢弃)+ 现成证据链,QQ 侧后续增量工作暂停
374 lines
16 KiB
Go
374 lines
16 KiB
Go
package qq_obv11
|
||
|
||
// 本文件覆盖 design/26 §3.1 / §3.7 的**全枚举投递**:post_type 的每一个取值
|
||
// (notice / request / meta_event / message_sent / 从未枚举过的未知值)都必须走到
|
||
// 归一化并留痕,**不许静默丢弃**;以及归一化产出的结构化字段(Category /
|
||
// PlatformType / Actor / Target / SelfRelated)与脱敏口径。
|
||
//
|
||
// notice_type 一整张映射表在 notice_test.go,这里只管 post_type 这一层。
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"log/slog"
|
||
"net/http"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
)
|
||
|
||
// 四类上报各一条真实形状的报文(字段取自 OneBot v11 官方规范与 go-cqhttp 扩展)。
|
||
const (
|
||
friendRequestEvent = `{"post_type":"request","request_type":"friend","time":1700000100,"self_id":100,"user_id":200,"comment":"我是同事","flag":"FLAG-SENTINEL-1"}`
|
||
groupAddRequest = `{"post_type":"request","request_type":"group","sub_type":"add","time":1700000101,"self_id":100,"user_id":200,"group_id":123,"comment":"拉我进群","flag":"FLAG-SENTINEL-2"}`
|
||
groupInviteRequest = `{"post_type":"request","request_type":"group","sub_type":"invite","time":1700000102,"self_id":100,"user_id":555,"group_id":123}`
|
||
heartbeatEvent = `{"post_type":"meta_event","meta_event_type":"heartbeat","interval":15000,"time":1700000103,"self_id":100,"status":{"online":true,"good":true}}`
|
||
lifecycleEnable = `{"post_type":"meta_event","meta_event_type":"lifecycle","sub_type":"enable","time":1700000104,"self_id":100}`
|
||
lifecycleDisable = `{"post_type":"meta_event","meta_event_type":"lifecycle","sub_type":"disable","time":1700000105,"self_id":100}`
|
||
messageSentPrivate = `{"post_type":"message_sent","message_type":"private","sub_type":"friend","time":1700000106,"self_id":100,"message_id":77,"user_id":100,"target_id":200,"message":[{"type":"text","data":{"text":"她刚发出的回复"}}],"sender":{"user_id":100,"nickname":"bot"}}`
|
||
// 既未枚举、也不可能被归一化的假类型:主人裁定的核心反例(design/26 §3.7)。
|
||
unenumeratedPostType = "quantum_entanglement"
|
||
unenumeratedEvent = `{"post_type":"` + unenumeratedPostType + `","time":1700000107,"self_id":100,"user_id":200,"group_id":123,"some_new_field":{"deep":true}}`
|
||
)
|
||
|
||
// normalizeDelivered 走一遍完整入站路径并归一化投递出来的那一条。
|
||
func normalizeDelivered(t *testing.T, raw string) (adapter.UnifiedMessage, adapter.SystemNotice) {
|
||
t.Helper()
|
||
delivered := dispatchNotices(t, raw)
|
||
if len(delivered) != 1 {
|
||
t.Fatalf("投递条数 = %d, want 1(上报被丢弃了)", len(delivered))
|
||
}
|
||
notice, ok := NewAdapter(Config{}).NormalizeSystemNotice(delivered[0])
|
||
if !ok {
|
||
t.Fatalf("投递出来的上报无法归一化:%+v", delivered[0])
|
||
}
|
||
return delivered[0], notice
|
||
}
|
||
|
||
// TestUnenumeratedTypeIsNeverSwallowed 是**主人裁定的对照测试**(design/26 §1.1/§3.7):
|
||
// 构造一个既未枚举、也没有任何映射分支的假 post_type,断言它绝不会被静默吞掉 ——
|
||
// 它必须①被投递、②被归一化成显式的 Unknown 兜底、③原样带着自己的类型名留痕、
|
||
// ④可忽略(绝不进她的上下文,fail-safe)、⑤在 DEBUG 日志里看得见。
|
||
//
|
||
// 变异验证:把 normalizeOneBotEvent 的 default 分支改回 `return ..., false`(改造前的
|
||
// 静默丢弃),或让 handlePayload 对非 message 上报直接 return,本测试立刻打红。
|
||
func TestUnenumeratedTypeIsNeverSwallowed(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
buf, log := logCapture()
|
||
a := NewAdapter(Config{Logger: log})
|
||
var delivered []adapter.UnifiedMessage
|
||
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) {
|
||
delivered = append(delivered, m)
|
||
})
|
||
a.handlePayload(context.Background(), []byte(unenumeratedEvent))
|
||
|
||
if len(delivered) != 1 {
|
||
t.Fatalf("未枚举类型的上报被静默吞掉:投递条数 = %d, want 1", len(delivered))
|
||
}
|
||
msg := delivered[0]
|
||
if msg.ContentType != adapter.ContentAction || msg.Sender.Role != adapter.RoleSystem {
|
||
t.Errorf("未枚举上报未带系统事件标记:%q/%q", msg.ContentType, msg.Sender.Role)
|
||
}
|
||
if msg.ChannelType != adapter.ChannelGroup || msg.ChannelID != "123" {
|
||
t.Errorf("未枚举上报的会话键 = %q/%q, want group/123", msg.ChannelType, msg.ChannelID)
|
||
}
|
||
|
||
notice, ok := a.NormalizeSystemNotice(msg)
|
||
if !ok {
|
||
t.Fatal("未枚举类型必须走 Unknown 兜底归一化,绝不是「读不出事件」")
|
||
}
|
||
if notice.Kind != adapter.NoticeUnknown || notice.Category != adapter.NoticeCategoryUnknown {
|
||
t.Errorf("兜底结果 = %q/%q, want unknown/unknown", notice.Kind, notice.Category)
|
||
}
|
||
if notice.PlatformType != unenumeratedPostType {
|
||
t.Errorf("PlatformType = %q, want %q(原生类型名必须原样保留)", notice.PlatformType, unenumeratedPostType)
|
||
}
|
||
if notice.Classify() != adapter.NoticeRegisterOnly {
|
||
t.Errorf("兜底处置 = %v, want 仅登记(unknown 绝不进她的上下文)", notice.Classify())
|
||
}
|
||
if !strings.Contains(buf.String(), unenumeratedPostType) {
|
||
t.Errorf("未枚举类型在日志里留不下痕:\n%s", buf.String())
|
||
}
|
||
}
|
||
|
||
// TestRequestEventsAreRequestsNotChat 覆盖此前**整类缺失**的 post_type=request
|
||
// (design/26 §3.1 的 request 行):加好友请求 / 加群请求 / 邀请入群都要投递、都要
|
||
// 归一成 NoticeRequest 且高价值(她该知道 ⇐ 主人原话),且**绝不自动同意**。
|
||
func TestRequestEventsAreRequestsNotChat(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
raw string
|
||
wantCategory adapter.NoticeCategory
|
||
wantText string
|
||
wantActor string
|
||
wantChannel string
|
||
wantGroup bool
|
||
selfRelated bool
|
||
}{
|
||
{"加好友请求", friendRequestEvent, adapter.NoticeCategoryFriendRequest, "有人请求加她为好友", "200", "200", false, false},
|
||
{"加群请求", groupAddRequest, adapter.NoticeCategoryGroupRequest, "有人请求加入群聊", "200", "123", true, false},
|
||
{"邀请她入群", groupInviteRequest, adapter.NoticeCategoryGroupRequest, "有人邀请她加入群聊", "555", "123", true, true},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
msg, notice := normalizeDelivered(t, tc.raw)
|
||
if notice.Kind != adapter.NoticeRequest || notice.Category != tc.wantCategory {
|
||
t.Errorf("kind/category = %q/%q, want request/%q", notice.Kind, notice.Category, tc.wantCategory)
|
||
}
|
||
if notice.SafeText != tc.wantText {
|
||
t.Errorf("SafeText = %q, want %q", notice.SafeText, tc.wantText)
|
||
}
|
||
if !notice.HighValue || notice.Classify() != adapter.NoticeEscalate {
|
||
t.Errorf("请求类处置 = %v(highValue=%v),want 升级真会话", notice.Classify(), notice.HighValue)
|
||
}
|
||
if notice.SelfRelated != tc.selfRelated {
|
||
t.Errorf("SelfRelated = %v, want %v", notice.SelfRelated, tc.selfRelated)
|
||
}
|
||
if notice.Actor.UserID != tc.wantActor {
|
||
t.Errorf("Actor = %q, want %q", notice.Actor.UserID, tc.wantActor)
|
||
}
|
||
if msg.ChannelID != tc.wantChannel || (msg.ChannelType == adapter.ChannelGroup) != tc.wantGroup {
|
||
t.Errorf("会话键 = %q/%q, want group=%v/%q", msg.ChannelType, msg.ChannelID, tc.wantGroup, tc.wantChannel)
|
||
}
|
||
// 请求的**可执行凭据**与验证信息一个字都没留下:本适配器根本没投影这两个字段。
|
||
if strings.Contains(notice.SafeText, "FLAG-SENTINEL") || strings.Contains(msg.Content, "FLAG-SENTINEL") {
|
||
t.Error("request.flag 泄漏进了结构化字段")
|
||
}
|
||
if strings.Contains(notice.SafeText, "同事") || strings.Contains(msg.Content, "同事") {
|
||
t.Error("request.comment(验证信息)泄漏进了结构化字段")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestUnknownRequestTypeIsUnknownNotDropped 覆盖 request_type 未枚举的一支:
|
||
// 同样不丢,只是降级成 Unknown 兜底。
|
||
func TestUnknownRequestTypeIsUnknownNotDropped(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
_, notice := normalizeDelivered(t, `{"post_type":"request","request_type":"telepathy","time":1700000108,"self_id":100,"user_id":200}`)
|
||
if notice.Kind != adapter.NoticeUnknown || notice.PlatformType != "telepathy" {
|
||
t.Fatalf("归一化结果 = %+v, want unknown/telepathy", notice)
|
||
}
|
||
if notice.Classify() != adapter.NoticeRegisterOnly {
|
||
t.Error("未枚举 request_type 必须仅登记")
|
||
}
|
||
}
|
||
|
||
// TestMetaEventDeliveryAndSilence 覆盖 post_type=meta_event(design/26 §3.1 的
|
||
// meta_event 行):
|
||
// - 心跳 / 生命周期都要投递、要归一化(此前整类被丢),且都**只登记不进上下文**
|
||
// (拿每 15s 一帧的遥测去驱动模型回合是荒谬的);
|
||
// - 它们**没有会话**:ChannelID 如实留空,绝不编一个会话出来;
|
||
// - SafeText 刻意留空:遥测没有要对她说的一句话,留空才不会让登记路径把心跳写进
|
||
// 会话历史(design/26 §4.3 明确反对逐帧写库);
|
||
// - SelfRelated=true:链路事件天然是**她自己的**账号状态。
|
||
func TestMetaEventDeliveryAndSilence(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
raw string
|
||
wantCategory adapter.NoticeCategory
|
||
}{
|
||
{"心跳", heartbeatEvent, adapter.NoticeCategoryHeartbeat},
|
||
{"生命周期 enable", lifecycleEnable, adapter.NoticeCategoryLifecycle},
|
||
{"生命周期 disable", lifecycleDisable, adapter.NoticeCategoryLifecycle},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
msg, notice := normalizeDelivered(t, tc.raw)
|
||
if notice.Category != tc.wantCategory {
|
||
t.Errorf("Category = %q, want %q", notice.Category, tc.wantCategory)
|
||
}
|
||
if notice.Classify() != adapter.NoticeRegisterOnly {
|
||
t.Errorf("元事件处置 = %v, want 仅登记", notice.Classify())
|
||
}
|
||
if !notice.SelfRelated {
|
||
t.Error("元事件必须标 SelfRelated(她自己的链路)")
|
||
}
|
||
if notice.SafeText != "" {
|
||
t.Errorf("T1 遥测不该带安全文本(会把心跳写进会话历史):%q", notice.SafeText)
|
||
}
|
||
if msg.ChannelID != "" || msg.ChannelType != adapter.ChannelDirect {
|
||
t.Errorf("无会话的元事件不该编出会话键:%q/%q", msg.ChannelType, msg.ChannelID)
|
||
}
|
||
})
|
||
}
|
||
|
||
_, unknown := normalizeDelivered(t, `{"post_type":"meta_event","meta_event_type":"teleport","time":1700000109,"self_id":100}`)
|
||
if unknown.Kind != adapter.NoticeUnknown || unknown.PlatformType != "teleport" {
|
||
t.Fatalf("未枚举 meta_event_type = %+v, want unknown/teleport", unknown)
|
||
}
|
||
}
|
||
|
||
// TestMessageSentEchoUsesPeerChannel 覆盖 message_sent(她自己发出消息的回声,
|
||
// design/26 §3.1):会话键必须是**对端**(target_id),不是她自己(user_id)——
|
||
// 拿 user_id 当键会建出一个「她与自己」的私聊,把她刚发的话再答一遍。
|
||
func TestMessageSentEchoUsesPeerChannel(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
msg, notice := normalizeDelivered(t, messageSentPrivate)
|
||
if msg.ChannelType != adapter.ChannelDirect || msg.ChannelID != "200" {
|
||
t.Fatalf("回声会话键 = %q/%q, want direct/200(对端)", msg.ChannelType, msg.ChannelID)
|
||
}
|
||
if notice.Category != adapter.NoticeCategoryMessageSent {
|
||
t.Errorf("Category = %q, want message_sent", notice.Category)
|
||
}
|
||
if notice.Classify() != adapter.NoticeRegisterOnly {
|
||
t.Errorf("回声处置 = %v, want 仅登记(绝不进上下文)", notice.Classify())
|
||
}
|
||
if msg.Content != "" {
|
||
t.Errorf("回声的平台原文进了正文:%q", msg.Content)
|
||
}
|
||
}
|
||
|
||
// TestEventLogsAndStructuresLeakNoPlatformText 是脱敏口径(design/26 §4.2)的对照
|
||
// 测试:一条**故意塞满敏感物**的事件走完入站路径后,
|
||
// - 投放出来的消息 Content 恒空(平台原文绝不进正文);
|
||
// - 归一化产出的结构化字段里没有正文 / 验证信息 / 凭据;
|
||
// - 渲染后的**全部日志**(含脱敏结构日志与归一化结果日志)里一个都没有。
|
||
//
|
||
// 变异验证:把 logNormalizedEvent 改成多打一个原始载荷(如 "payload", string(raw)),
|
||
// 本测试立刻打红。
|
||
func TestEventLogsAndStructuresLeakNoPlatformText(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
const (
|
||
body = "BODY-SENTINEL-9137"
|
||
flag = "FLAG-SENTINEL-9137"
|
||
cdnURL = "http://cdn.invalid/x.png?sign=SIGN-SENTINEL-9137"
|
||
token = "TOKEN-SENTINEL-9137"
|
||
)
|
||
raw := `{
|
||
"post_type": "request",
|
||
"request_type": "friend",
|
||
"time": 1700000110,
|
||
"self_id": 100,
|
||
"user_id": 200,
|
||
"comment": "` + body + `",
|
||
"raw_message": "` + body + `",
|
||
"flag": "` + flag + `",
|
||
"access_token": "` + token + `",
|
||
"file": {"url": "` + cdnURL + `"}
|
||
}`
|
||
|
||
buf, log := logCapture()
|
||
a := NewAdapter(Config{AccessToken: token, Logger: log})
|
||
var delivered []adapter.UnifiedMessage
|
||
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) {
|
||
delivered = append(delivered, m)
|
||
})
|
||
a.handlePayload(context.Background(), []byte(raw))
|
||
|
||
if len(delivered) != 1 {
|
||
t.Fatalf("投递条数 = %d, want 1", len(delivered))
|
||
}
|
||
if delivered[0].Content != "" {
|
||
t.Errorf("平台原文进了 Content:%q", delivered[0].Content)
|
||
}
|
||
notice, ok := a.NormalizeSystemNotice(delivered[0])
|
||
if !ok {
|
||
t.Fatal("加好友请求无法归一化")
|
||
}
|
||
for name, value := range map[string]string{
|
||
"SafeText": notice.SafeText, "PlatformType": notice.PlatformType, "Category": string(notice.Category),
|
||
} {
|
||
for _, forbidden := range []string{body, flag, token, "SIGN-SENTINEL", "http"} {
|
||
if strings.Contains(value, forbidden) {
|
||
t.Errorf("%s 泄漏了 %q:%q", name, forbidden, value)
|
||
}
|
||
}
|
||
}
|
||
logged := buf.String()
|
||
if logged == "" {
|
||
t.Fatal("DEBUG 日志一条都没有:取证地基不存在")
|
||
}
|
||
for _, forbidden := range []string{body, flag, token, "SIGN-SENTINEL", "http"} {
|
||
if strings.Contains(logged, forbidden) {
|
||
t.Errorf("日志泄漏了 %q:\n%s", forbidden, logged)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNormalizedEventLogStaysAtDebugLevel 归一化结果日志与入站帧日志同级:默认 INFO
|
||
// 下一条都不打(免得正常运行每帧两条噪声),要取证时把级别调到 debug。
|
||
func TestNormalizedEventLogStaysAtDebugLevel(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
buf := &bytes.Buffer{}
|
||
a := NewAdapter(Config{Logger: slog.New(slog.NewTextHandler(buf, nil))})
|
||
a.handlePayload(context.Background(), []byte(heartbeatEvent))
|
||
|
||
if strings.Contains(buf.String(), "system event normalized") {
|
||
t.Errorf("默认级别下不该出现归一化结果日志:\n%s", buf.String())
|
||
}
|
||
}
|
||
|
||
// TestHTTPReportDeliversNonMessageEventsEndToEnd 是**传输层 → 归一化的端到端**:走
|
||
// http+server 这条真实上报路径(OneBot 把事件 POST 到 Cyrene 的监听端口,见
|
||
// http_server.go),断言加好友请求 / 心跳 / 未知类型三类上报**都**到达消息处理器且都能
|
||
// 归一化——而不是像改造前那样在 handlePayload 的 switch 里掉进虚空(既不投递、也不留痕)。
|
||
func TestHTTPReportDeliversNonMessageEventsEndToEnd(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
a := NewAdapter(Config{
|
||
Transport: transportHTTP,
|
||
Mode: modeServer,
|
||
ListenAddr: "127.0.0.1:0",
|
||
AccessToken: "secret-token",
|
||
ReportPath: "/onebot/report",
|
||
})
|
||
got := make(chan adapter.UnifiedMessage, 4)
|
||
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { got <- m })
|
||
|
||
if err := a.Connect(context.Background()); err != nil {
|
||
t.Fatalf("Connect: %v", err)
|
||
}
|
||
defer func() { _ = a.Disconnect(context.Background()) }()
|
||
addr := a.Addr()
|
||
if addr == "" {
|
||
t.Fatal("Addr() 为空:http+server 端没起来")
|
||
}
|
||
|
||
cases := []struct {
|
||
name string
|
||
raw string
|
||
wantCategory adapter.NoticeCategory
|
||
}{
|
||
{"加好友请求", friendRequestEvent, adapter.NoticeCategoryFriendRequest},
|
||
{"心跳", heartbeatEvent, adapter.NoticeCategoryHeartbeat},
|
||
{"未知类型", unenumeratedEvent, adapter.NoticeCategoryUnknown},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
if res := postReport(t, "http://"+addr+"/onebot/report", "secret-token", tc.raw); res.status != http.StatusNoContent {
|
||
t.Fatalf("上报状态 = %d (body %q), want 204", res.status, res.body)
|
||
}
|
||
select {
|
||
case m := <-got:
|
||
notice, ok := a.NormalizeSystemNotice(m)
|
||
if !ok {
|
||
t.Fatalf("%s 到达了处理器却无法归一化", tc.name)
|
||
}
|
||
if notice.Category != tc.wantCategory {
|
||
t.Errorf("Category = %q, want %q", notice.Category, tc.wantCategory)
|
||
}
|
||
case <-time.After(3 * time.Second):
|
||
t.Fatalf("%s 上报从未到达消息处理器(非消息事件被吞)", tc.name)
|
||
}
|
||
})
|
||
}
|
||
}
|