Files
Cyrene_v2/plugins/channels/qq_obv11/notice_test.go
T
AskaEth ba17820040
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): QQ 非消息事件全枚举投递 + systemnotice 补 request/interaction/unknown 类
- 适配器不再整类丢弃: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 侧后续增量工作暂停
2026-10-05 13:47:27 +08:00

342 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}