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) } }