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 侧后续增量工作暂停
This commit is contained in:
1 parent
ec3f6e810a
commit
ba17820040
9 files changed
+1315
-193
No files matched your search
@@ -23,6 +23,16 @@ const (
|
||||
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 走一遍适配器的入站路径,返回投递出来的消息。
|
||||
@@ -81,43 +91,173 @@ func TestNoticeFriendAddUsesDirectSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonMessageFramesStillDropped 钉住既有边界:message_sent(回声)与
|
||||
// meta_event(心跳)仍不得进流水线 —— 只有 notice 是新增的入站面。
|
||||
func TestNonMessageFramesStillDropped(t *testing.T) {
|
||||
// TestNonMessageFramesAreDelivered 钉住主人 2026-10-05 的裁决:**消息之外的一切上报
|
||||
// 都必须投递,不许整类丢弃**(design/26 §1.1 / §3.1)。它们一律带
|
||||
// ContentType=ContentAction 的结构化标记(⇒ 桥接层按系统事件处置,绝不进聊天路径)、
|
||||
// 发送者恒为 RoleSystem(⇒ 判主不可能被冒充)、Content 恒空(⇒ 平台原文不进正文)。
|
||||
func TestNonMessageFramesAreDelivered(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}`,
|
||||
)
|
||||
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("非 message/notice 上报被投递:%+v", got)
|
||||
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
|
||||
ignorable bool
|
||||
highValue bool
|
||||
name string
|
||||
raw string
|
||||
wantOK bool
|
||||
wantKind adapter.NoticeKind
|
||||
wantCategory adapter.NoticeCategory
|
||||
wantPlatformType string
|
||||
ignorable bool
|
||||
highValue bool
|
||||
selfRelated 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},
|
||||
{"进群 → 可忽略", 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 {
|
||||
@@ -135,12 +275,33 @@ func TestNormalizeOneBotNoticeMapping(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if notice.SafeText == "" {
|
||||
t.Error("安全文本为空")
|
||||
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("两个标志不该同时为真")
|
||||
|
||||
Reference in new issue
Block a user