feat(channels): 非消息事件接入(notice 归一化 + 传入前审查,fail-safe 静默)与入站脱敏帧日志/日志轮转
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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 侧改追加(不再重启即截断)
This commit is contained in:
AskaEth committed 2026-10-05 11:55:05 +08:00
1 parent 85abb90067
commit 5139945bfe
15 files changed
+1604 -21

No files matched your search

+180
View File
@@ -0,0 +1,180 @@
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)
}
}