- 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 侧改追加(不再重启即截断)
181 lines
8.1 KiB
Go
181 lines
8.1 KiB
Go
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)
|
||
}
|
||
}
|