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}` ) // 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) } } // TestNonMessageFramesStillDropped 钉住既有边界:message_sent(回声)与 // meta_event(心跳)仍不得进流水线 —— 只有 notice 是新增的入站面。 func TestNonMessageFramesStillDropped(t *testing.T) { t.Parallel() got := dispatchNotices(t, `{"post_type":"message_sent","message_type":"private","time":1700000000,"self_id":100,"message_id":21,"user_id":100,"target_id":100,"message":[{"type":"text","data":{"text":"x"}}]}`, `{"post_type":"meta_event","meta_event_type":"heartbeat","time":1700000000,"self_id":100}`, ) if len(got) != 0 { t.Fatalf("非 message/notice 上报被投递:%+v", got) } } // TestNormalizeOneBotNoticeMapping 是 notice_type/sub_type → 统一结构的映射钉。 func TestNormalizeOneBotNoticeMapping(t *testing.T) { t.Parallel() cases := []struct { name string raw string wantOK bool wantKind adapter.NoticeKind ignorable bool highValue bool }{ {"进群 → 可忽略", groupIncreaseEvent, true, adapter.NoticeJoin, true, false}, {"退群 → 可忽略", groupLeaveEvent, true, adapter.NoticeLeave, true, false}, {"她本人被移出 → 高价值", groupKickMeEvent, true, adapter.NoticeLeave, false, true}, {"她本人被取消管理员 → 高价值", selfDemotedEvent, true, adapter.NoticeOther, false, true}, {"他人管理员变更 → 可忽略", otherAdminEvent, true, adapter.NoticeOther, true, false}, {"撤回 → 可忽略", groupRecallEvent, true, adapter.NoticeRecall, true, false}, {"戳一戳 → 可忽略", pokeEvent, true, adapter.NoticeTyping, true, false}, {"群文件上传 → 两者皆非(交审查模型)", groupUploadEvent, true, adapter.NoticeOther, false, false}, {"未知 notice_type → 不归一化(fail-safe)", unknownNoticeEvent, false, "", false, false}, {"非 notice 上报 → 不归一化", `{"post_type":"message","message_type":"private"}`, false, "", false, 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.Ignorable != tc.ignorable || notice.HighValue != tc.highValue { t.Errorf("标志 = {ignorable:%v highValue:%v}, want {%v %v}", notice.Ignorable, notice.HighValue, tc.ignorable, tc.highValue) } 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) } }