- 适配器不再整类丢弃: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 侧后续增量工作暂停
342 lines
17 KiB
Go
342 lines
17 KiB
Go
package qq_obv11
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
)
|
||
|
||
// 本文件覆盖 OneBot v11 notice(系统通知)上报的归一化(design/12 §6 策略一补充):
|
||
// notice → 带结构化标记的入站消息(ContentType=ContentAction、发送者恒为 RoleSystem、
|
||
// Content 恒空),以及 notice_type/sub_type → 统一结构 {kind, safeText, ignorable,
|
||
// highValue} 的映射。判「是否系统 / 是否高价值」只看结构化字段,绝不看正文。
|
||
|
||
const (
|
||
groupIncreaseEvent = `{"post_type":"notice","notice_type":"group_increase","sub_type":"approve","time":1700000000,"self_id":100,"user_id":42,"operator_id":0,"group_id":123,"group_name":"Team"}`
|
||
groupKickMeEvent = `{"post_type":"notice","notice_type":"group_decrease","sub_type":"kick_me","time":1700000001,"self_id":100,"user_id":100,"operator_id":777,"group_id":123,"group_name":"Team"}`
|
||
groupLeaveEvent = `{"post_type":"notice","notice_type":"group_decrease","sub_type":"leave","time":1700000002,"self_id":100,"user_id":42,"group_id":123,"group_name":"Team"}`
|
||
selfDemotedEvent = `{"post_type":"notice","notice_type":"group_admin","sub_type":"unset","time":1700000003,"self_id":100,"user_id":100,"group_id":123,"group_name":"Team"}`
|
||
otherAdminEvent = `{"post_type":"notice","notice_type":"group_admin","sub_type":"set","time":1700000004,"self_id":100,"user_id":42,"group_id":123,"group_name":"Team"}`
|
||
groupRecallEvent = `{"post_type":"notice","notice_type":"group_recall","time":1700000005,"self_id":100,"user_id":42,"group_id":123,"operator_id":42,"message_id":99}`
|
||
pokeEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"poke","time":1700000006,"self_id":100,"user_id":42,"target_id":100,"group_id":123}`
|
||
groupUploadEvent = `{"post_type":"notice","notice_type":"group_upload","time":1700000007,"self_id":100,"user_id":42,"group_id":123,"file":{"name":"a.txt"}}`
|
||
unknownNoticeEvent = `{"post_type":"notice","notice_type":"group_lucky_king_unknown","time":1700000008,"self_id":100,"user_id":42,"group_id":123}`
|
||
friendAddEvent = `{"post_type":"notice","notice_type":"friend_add","time":1700000009,"self_id":100,"user_id":200}`
|
||
// 禁言三态(design/26 §3.1 的 group_ban 行):她本人被禁言 / 她禁言别人 / 全员禁言。
|
||
selfBannedEvent = `{"post_type":"notice","notice_type":"group_ban","sub_type":"ban","time":1700000010,"self_id":100,"user_id":100,"operator_id":42,"group_id":123,"duration":600}`
|
||
selfBansEvent = `{"post_type":"notice","notice_type":"group_ban","sub_type":"ban","time":1700000011,"self_id":100,"user_id":42,"operator_id":100,"group_id":123,"duration":60}`
|
||
allMutedEvent = `{"post_type":"notice","notice_type":"group_ban","sub_type":"ban","time":1700000012,"self_id":100,"user_id":0,"operator_id":42,"group_id":123,"duration":-1}`
|
||
// go-cqhttp 扩展的 notify 子类型(本项目对接的正是这一族)。
|
||
luckyKingEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"lucky_king","time":1700000013,"self_id":100,"user_id":42,"target_id":43,"group_id":123}`
|
||
honorEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"honor","honor_type":"talkative","time":1700000014,"self_id":100,"user_id":42,"group_id":123}`
|
||
titleEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"title","time":1700000015,"self_id":100,"user_id":42,"group_id":123}`
|
||
// 未知 sub_type:notice_type 认得出来,但这一支没被枚举 ⇒ 兜底留痕(不是丢弃)。
|
||
unknownNotifySubEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"quantum_entanglement","time":1700000016,"self_id":100,"user_id":42,"group_id":123}`
|
||
)
|
||
|
||
// dispatchNotices 通过 handlePayload 走一遍适配器的入站路径,返回投递出来的消息。
|
||
func dispatchNotices(t *testing.T, raws ...string) []adapter.UnifiedMessage {
|
||
t.Helper()
|
||
a := NewAdapter(Config{})
|
||
var delivered []adapter.UnifiedMessage
|
||
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) {
|
||
delivered = append(delivered, m)
|
||
})
|
||
for _, raw := range raws {
|
||
a.handlePayload(context.Background(), []byte(raw))
|
||
}
|
||
return delivered
|
||
}
|
||
|
||
// TestNoticeDispatchedAsStructuredAction 钉住 notice 上报的入站形态:
|
||
// ContentType=ContentAction(结构化「系统事件」标记)、发送者恒 RoleSystem、
|
||
// Content 恒空(平台原文绝不进 Content —— 裸喂禁止)。
|
||
func TestNoticeDispatchedAsStructuredAction(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := dispatchNotices(t, groupIncreaseEvent)
|
||
if len(got) != 1 {
|
||
t.Fatalf("投递条数 = %d, want 1", len(got))
|
||
}
|
||
msg := got[0]
|
||
if msg.ContentType != adapter.ContentAction {
|
||
t.Errorf("ContentType = %q, want %q", msg.ContentType, adapter.ContentAction)
|
||
}
|
||
if msg.Sender.Role != adapter.RoleSystem || msg.SenderID != noticeSenderID {
|
||
t.Errorf("发送者 = %q/%q, want system", msg.Sender.Role, msg.SenderID)
|
||
}
|
||
if msg.ChannelType != adapter.ChannelGroup || msg.ChannelID != "123" || msg.GroupID != "123" {
|
||
t.Errorf("会话键 = %q/%q/%q", msg.ChannelType, msg.ChannelID, msg.GroupID)
|
||
}
|
||
// 判主必须是不可能的:系统发送者绝不允许撞上主人白名单。
|
||
if msg.SenderID == "" || msg.SenderID == "42" {
|
||
t.Errorf("notice 的发送者 id 会冒充群成员:%q", msg.SenderID)
|
||
}
|
||
}
|
||
|
||
// TestNoticeFriendAddUsesDirectSession 覆盖非群通知按对端用户建私聊会话键。
|
||
func TestNoticeFriendAddUsesDirectSession(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := dispatchNotices(t, friendAddEvent)
|
||
if len(got) != 1 {
|
||
t.Fatalf("投递条数 = %d, want 1", len(got))
|
||
}
|
||
if got[0].ChannelType != adapter.ChannelDirect || got[0].ChannelID != "200" {
|
||
t.Errorf("非群通知会话键 = %q/%q", got[0].ChannelType, got[0].ChannelID)
|
||
}
|
||
if got[0].GroupID != "" {
|
||
t.Errorf("非群通知不该带 group id:%q", got[0].GroupID)
|
||
}
|
||
}
|
||
|
||
// TestNonMessageFramesAreDelivered 钉住主人 2026-10-05 的裁决:**消息之外的一切上报
|
||
// 都必须投递,不许整类丢弃**(design/26 §1.1 / §3.1)。它们一律带
|
||
// ContentType=ContentAction 的结构化标记(⇒ 桥接层按系统事件处置,绝不进聊天路径)、
|
||
// 发送者恒为 RoleSystem(⇒ 判主不可能被冒充)、Content 恒空(⇒ 平台原文不进正文)。
|
||
func TestNonMessageFramesAreDelivered(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
raw string
|
||
}{
|
||
{"message_sent 回声(私聊)", `{"post_type":"message_sent","message_type":"private","time":1700000000,"self_id":100,"message_id":21,"user_id":100,"target_id":200,"message":[{"type":"text","data":{"text":"x"}}]}`},
|
||
{"meta_event 心跳", `{"post_type":"meta_event","meta_event_type":"heartbeat","time":1700000000,"self_id":100,"status":{"online":true}}`},
|
||
{"meta_event 生命周期", `{"post_type":"meta_event","meta_event_type":"lifecycle","sub_type":"enable","time":1700000000,"self_id":100}`},
|
||
{"request 加好友", `{"post_type":"request","request_type":"friend","time":1700000000,"self_id":100,"user_id":200,"comment":"我是同事","flag":"FLAG-SENTINEL"}`},
|
||
{"request 加群", `{"post_type":"request","request_type":"group","sub_type":"add","time":1700000000,"self_id":100,"user_id":200,"group_id":123,"comment":"拉我进去","flag":"FLAG-SENTINEL"}`},
|
||
{"未知 post_type", `{"post_type":"quantum_entanglement","time":1700000000,"self_id":100,"user_id":200}`},
|
||
{"解析失败(JSON 坏)", `{"post_type":`},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
got := dispatchNotices(t, tc.raw)
|
||
if len(got) != 1 {
|
||
t.Fatalf("投递条数 = %d, want 1(非消息上报被丢弃了)", len(got))
|
||
}
|
||
msg := got[0]
|
||
if msg.ContentType != adapter.ContentAction {
|
||
t.Errorf("ContentType = %q, want %q", msg.ContentType, adapter.ContentAction)
|
||
}
|
||
if msg.Sender.Role != adapter.RoleSystem || msg.SenderID != noticeSenderID {
|
||
t.Errorf("发送者 = %q/%q, want system", msg.Sender.Role, msg.SenderID)
|
||
}
|
||
if msg.Content != "" {
|
||
t.Errorf("Content = %q, want 空(平台原文绝不进正文)", msg.Content)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestActionReceiptIsNotAnEvent 钉住唯一的例外:**我们自己主动 action 的回执**
|
||
// (空 post_type + echo)不是上游推来的事件,因此不进事件流水线;但它也不静默——
|
||
// 它落在脱敏结构日志里(见 inbound_debug.go)。没有等待者的散落回执同理。
|
||
func TestActionReceiptIsNotAnEvent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := dispatchNotices(t, `{"status":"ok","retcode":0,"data":{"online":true},"echo":"echo_1"}`)
|
||
if len(got) != 0 {
|
||
t.Fatalf("action 回执被当成事件投递:%+v", got)
|
||
}
|
||
}
|
||
|
||
// TestPokeKeepsGroupAndDirectDistinguishable 覆盖主人点名的「戳一戳」:
|
||
// 归一化后既能认出它(kind=interaction / category=poke),也判得出**群聊还是私聊**
|
||
// (会话键 + actor 的群上下文),以及**是不是戳的她本人**(SelfRelated + Target)。
|
||
func TestPokeKeepsGroupAndDirectDistinguishable(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
raw string
|
||
wantGroup bool
|
||
wantToSelf bool
|
||
wantTarget string
|
||
wantChannel string
|
||
}{
|
||
{
|
||
name: "群聊戳她本人",
|
||
raw: pokeEvent,
|
||
wantGroup: true,
|
||
wantToSelf: true,
|
||
wantTarget: "100",
|
||
wantChannel: "123",
|
||
},
|
||
{
|
||
name: "私聊戳她本人",
|
||
raw: `{"post_type":"notice","notice_type":"notify","sub_type":"poke","time":1700000020,"self_id":100,"user_id":42,"target_id":100}`,
|
||
wantGroup: false,
|
||
wantToSelf: true,
|
||
wantTarget: "100",
|
||
wantChannel: "42",
|
||
},
|
||
{
|
||
name: "群里戳别人",
|
||
raw: `{"post_type":"notice","notice_type":"notify","sub_type":"poke","time":1700000021,"self_id":100,"user_id":42,"target_id":43,"group_id":123}`,
|
||
wantGroup: true,
|
||
wantToSelf: false,
|
||
wantTarget: "43",
|
||
wantChannel: "123",
|
||
},
|
||
}
|
||
|
||
a := NewAdapter(Config{})
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
delivered := dispatchNotices(t, tc.raw)
|
||
if len(delivered) != 1 {
|
||
t.Fatalf("投递条数 = %d, want 1", len(delivered))
|
||
}
|
||
msg := delivered[0]
|
||
if gotGroup := msg.ChannelType == adapter.ChannelGroup; gotGroup != tc.wantGroup {
|
||
t.Errorf("群聊判定 = %v, want %v(channel=%q)", gotGroup, tc.wantGroup, msg.ChannelID)
|
||
}
|
||
if msg.ChannelID != tc.wantChannel {
|
||
t.Errorf("会话键 = %q, want %q", msg.ChannelID, tc.wantChannel)
|
||
}
|
||
notice, ok := a.NormalizeSystemNotice(msg)
|
||
if !ok {
|
||
t.Fatal("戳一戳无法归一化")
|
||
}
|
||
if notice.Kind != adapter.NoticeInteraction || notice.Category != adapter.NoticeCategoryPoke {
|
||
t.Errorf("kind/category = %q/%q, want interaction/poke", notice.Kind, notice.Category)
|
||
}
|
||
if notice.SelfRelated != tc.wantToSelf {
|
||
t.Errorf("SelfRelated = %v, want %v", notice.SelfRelated, tc.wantToSelf)
|
||
}
|
||
if notice.Target.UserID != tc.wantTarget {
|
||
t.Errorf("Target = %q, want %q", notice.Target.UserID, tc.wantTarget)
|
||
}
|
||
// 戳一戳默认**只登记、不自动回应**(design/26 §5.5)。
|
||
if !notice.Ignorable || notice.Classify() != adapter.NoticeRegisterOnly {
|
||
t.Errorf("戳一戳的处置 = %v(ignorable=%v),want 仅登记", notice.Classify(), notice.Ignorable)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestNormalizeOneBotNoticeMapping 是 notice_type/sub_type → 统一结构的映射钉。
|
||
// wantCategory / wantPlatformType / wantSelfRelated 一并钉住 design/26 §2.4 新增的
|
||
// 结构化字段——留存层(E1/E3)判 Tier/Category 时靠的正是它们,少一个都会让
|
||
// 「这条事件该不该升档」变成猜。
|
||
func TestNormalizeOneBotNoticeMapping(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
raw string
|
||
wantOK bool
|
||
wantKind adapter.NoticeKind
|
||
wantCategory adapter.NoticeCategory
|
||
wantPlatformType string
|
||
ignorable bool
|
||
highValue bool
|
||
selfRelated bool
|
||
}{
|
||
{"进群 → 可忽略", groupIncreaseEvent, true, adapter.NoticeJoin, adapter.NoticeCategoryJoin, noticeGroupIncrease, true, false, false},
|
||
{"退群 → 可忽略", groupLeaveEvent, true, adapter.NoticeLeave, adapter.NoticeCategoryLeave, noticeGroupDecrease, true, false, false},
|
||
{"她本人被移出 → 高价值", groupKickMeEvent, true, adapter.NoticeLeave, adapter.NoticeCategoryLeave, noticeGroupDecrease, false, true, true},
|
||
{"她本人管理员变更 → 高价值", selfDemotedEvent, true, adapter.NoticeOther, adapter.NoticeCategoryAdmin, noticeGroupAdmin, false, true, true},
|
||
{"他人管理员变更 → 可忽略", otherAdminEvent, true, adapter.NoticeOther, adapter.NoticeCategoryAdmin, noticeGroupAdmin, true, false, false},
|
||
{"撤回 → 可忽略", groupRecallEvent, true, adapter.NoticeRecall, adapter.NoticeCategoryRecall, noticeGroupRecall, true, false, false},
|
||
{"戳一戳 → 定向交互(仅登记)", pokeEvent, true, adapter.NoticeInteraction, adapter.NoticeCategoryPoke, noticeNotify, true, false, true},
|
||
{"运气王 → 定向交互(仅登记)", luckyKingEvent, true, adapter.NoticeInteraction, adapter.NoticeCategoryLuckyKing, noticeNotify, true, false, false},
|
||
{"荣誉变更 → 可忽略", honorEvent, true, adapter.NoticeOther, adapter.NoticeCategoryHonor, noticeNotify, true, false, false},
|
||
{"头衔变更 → 可忽略", titleEvent, true, adapter.NoticeOther, adapter.NoticeCategoryTitle, noticeNotify, true, false, false},
|
||
{"她本人被禁言 → 高价值", selfBannedEvent, true, adapter.NoticeOther, adapter.NoticeCategoryBan, noticeGroupBan, false, true, true},
|
||
{"她禁言别人 → 高价值", selfBansEvent, true, adapter.NoticeOther, adapter.NoticeCategoryBan, noticeGroupBan, false, true, true},
|
||
{"全员禁言 → 独立类别(不是「用户 0」)", allMutedEvent, true, adapter.NoticeOther, adapter.NoticeCategoryBanAll, noticeGroupBan, true, false, false},
|
||
{"群文件上传 → 两者皆非(交审查模型)", groupUploadEvent, true, adapter.NoticeOther, adapter.NoticeCategoryUpload, noticeGroupUpload, false, false, false},
|
||
{"未知 notice_type → 兜底留痕(可忽略、不进上下文)", unknownNoticeEvent, true, adapter.NoticeUnknown, adapter.NoticeCategoryUnknown, "group_lucky_king_unknown", true, false, false},
|
||
{"未知 notify sub_type → 兜底留痕", unknownNotifySubEvent, true, adapter.NoticeUnknown, adapter.NoticeCategoryUnknown, noticeNotify, true, false, false},
|
||
{"解析失败的载荷 → 兜底留痕(decode_error)", `{"post_type":`, true, adapter.NoticeUnknown, adapter.NoticeCategoryDecodeError, "", true, false, false},
|
||
{"非 notice 上报 → 不归一化(消息不是系统事件)", `{"post_type":"message","message_type":"private"}`, false, "", "", "", false, false, false},
|
||
}
|
||
a := NewAdapter(Config{})
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
msg := adapter.UnifiedMessage{Platform: platformName, RawData: []byte(tc.raw)}
|
||
notice, ok := a.NormalizeSystemNotice(msg)
|
||
if ok != tc.wantOK {
|
||
t.Fatalf("ok = %v, want %v(notice=%+v)", ok, tc.wantOK, notice)
|
||
}
|
||
if !ok {
|
||
return
|
||
}
|
||
if notice.Kind != tc.wantKind {
|
||
t.Errorf("Kind = %q, want %q", notice.Kind, tc.wantKind)
|
||
}
|
||
if notice.Category != tc.wantCategory {
|
||
t.Errorf("Category = %q, want %q", notice.Category, tc.wantCategory)
|
||
}
|
||
if notice.PlatformType != tc.wantPlatformType {
|
||
t.Errorf("PlatformType = %q, want %q(原生类型码必须原样保留)", notice.PlatformType, tc.wantPlatformType)
|
||
}
|
||
if notice.SelfRelated != tc.selfRelated {
|
||
t.Errorf("SelfRelated = %v, want %v", notice.SelfRelated, tc.selfRelated)
|
||
}
|
||
if notice.Ignorable != tc.ignorable || notice.HighValue != tc.highValue {
|
||
t.Errorf("标志 = {ignorable:%v highValue:%v}, want {%v %v}",
|
||
notice.Ignorable, notice.HighValue, tc.ignorable, tc.highValue)
|
||
}
|
||
switch notice.Kind {
|
||
case adapter.NoticeUnknown:
|
||
// 兜底通知**刻意不编造文案**:类型名都读不准,编一句话只会误导,
|
||
// 留痕靠 PlatformType + 桥接层的计数与留存(design/26 §3.7)。
|
||
if notice.SafeText != "" {
|
||
t.Errorf("兜底通知不该有安全文本:%q", notice.SafeText)
|
||
}
|
||
if !notice.Ignorable {
|
||
t.Error("兜底通知必须可忽略(fail-safe:绝不进她的上下文)")
|
||
}
|
||
default:
|
||
if notice.SafeText == "" {
|
||
t.Error("安全文本为空")
|
||
}
|
||
}
|
||
if notice.Ignorable && notice.HighValue {
|
||
t.Error("两个标志不该同时为真")
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestNormalizeSystemNoticeRequiresRawPayload 钉住载荷缺失时绝不猜。
|
||
func TestNormalizeSystemNoticeRequiresRawPayload(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
a := NewAdapter(Config{})
|
||
if _, ok := a.NormalizeSystemNotice(adapter.UnifiedMessage{Platform: platformName}); ok {
|
||
t.Fatal("无原始载荷时不该归一化成功")
|
||
}
|
||
}
|
||
|
||
// TestNoticeRoundTripThroughNormalizer 是生产端到端的一半:handlePayload 投递的
|
||
// 消息必须能被同一个适配器的归一化 seam 解析成统一结构(另一端 channelbridge 的
|
||
// 传入前审查会消费它)。
|
||
func TestNoticeRoundTripThroughNormalizer(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
got := dispatchNotices(t, groupKickMeEvent)
|
||
if len(got) != 1 {
|
||
t.Fatalf("投递条数 = %d, want 1", len(got))
|
||
}
|
||
a := NewAdapter(Config{})
|
||
notice, ok := a.NormalizeSystemNotice(got[0])
|
||
if !ok {
|
||
t.Fatal("投递出来的 notice 无法归一化")
|
||
}
|
||
if !notice.HighValue || notice.Kind != adapter.NoticeLeave {
|
||
t.Fatalf("归一化结果 = %+v, want 高价值 leave", notice)
|
||
}
|
||
}
|