Files
Cyrene_v2/plugins/channels/qq_obv11/notice_test.go
T
AskaEth 5139945bfe
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): 非消息事件接入(notice 归一化 + 传入前审查,fail-safe 静默)与入站脱敏帧日志/日志轮转
- notice:adapter 归一化 seam + 结构化分类 + 净化(ANSI/控制/零宽/双向字符、200 runes 截断)
  + fail-safe 写死为「静默」(未装配审查/超时/失败 ⇒ 仅登记、不驱动 loop)
- 身份面零改动:notice 发送者恒 RoleSystem + SenderID=system
- 复用策略四的同一个审查模型(Options.Review),未另造第二套
- 入站帧脱敏 DEBUG(post_type/message_type/sub_type/self_id/user_id/sender_id/group_id/段类型/at 目标)
- ctrl.bat 启动前归档轮转(保留 20 份)+ bash 侧改追加(不再重启即截断)
2026-10-05 11:55:05 +08:00

181 lines
8.1 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}`
)
// 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)
}
}