- 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 侧改追加(不再重启即截断)
381 lines
13 KiB
Go
381 lines
13 KiB
Go
package channelbridge
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||
)
|
||
|
||
// 本文件覆盖 design/12 §6 策略一补充的「系统通知传入前审查」:归一化 / 分类 /
|
||
// fail-safe 三条路径,以及它与审查模型(策略四同一注入点)的复用点。
|
||
// fail-safe 的保守方向写死在 noticeFailSafeProceeds(false = 静默),变异验证即把它
|
||
// 改成 true —— 下面 TestNoticeReviewFailureFailsSafeSilent、
|
||
// TestNoticeReviewerMissingFailsSafeSilent 与 TestPreEntryReviewFailSafeIsSilent
|
||
// 会变红。
|
||
|
||
// noticeAdapter 是最小的 PlatformAdapter + SystemNoticeNormalizer:
|
||
// 它按夹具给定的结论回答归一化,并记录收到的原始消息与调用次数。
|
||
type noticeAdapter struct {
|
||
fakeAdapter
|
||
notice adapter.SystemNotice
|
||
ok bool
|
||
panics bool
|
||
calls int
|
||
rawSeen []byte
|
||
}
|
||
|
||
func (n *noticeAdapter) NormalizeSystemNotice(msg adapter.UnifiedMessage) (adapter.SystemNotice, bool) {
|
||
n.calls++
|
||
n.rawSeen = msg.RawData
|
||
if n.panics {
|
||
panic("malformed platform notice")
|
||
}
|
||
return n.notice, n.ok
|
||
}
|
||
|
||
// plainAdapter 是**未实现**归一化 seam 的适配器(可选接口缺席时的 fail-safe 路径)。
|
||
type plainAdapter struct{ fakeAdapter }
|
||
|
||
// groupNoticeMessage 是一条带结构化系统事件标记的群通知:ContentType=ContentAction
|
||
// 是适配器给出的「非聊天动作信号」标记(判「是否系统」只看这个元数据,绝不看正文)。
|
||
func groupNoticeMessage() adapter.UnifiedMessage {
|
||
return adapter.UnifiedMessage{
|
||
Platform: "qq-obv11",
|
||
ChannelID: "g1",
|
||
ChannelType: adapter.ChannelGroup,
|
||
GroupID: "g1",
|
||
GroupName: "群一",
|
||
SenderID: "system",
|
||
SenderName: "system",
|
||
Sender: adapter.Actor{Role: adapter.RoleSystem, UserID: "system"},
|
||
ContentType: adapter.ContentAction,
|
||
RawData: []byte(`{"post_type":"notice","notice_type":"group_upload"}`),
|
||
}
|
||
}
|
||
|
||
// noticeFixture 组装一条入站通知所需的桥接器:受限群会话 + 捕获历史 +
|
||
// 记录调用的循环。review 为 nil 表示未装配审查模型。
|
||
func noticeFixture(t *testing.T, ad adapter.PlatformAdapter, review adapter.Reviewer) (*Bridge, *stubChatter, *stubSessions, *captureHistory) {
|
||
t.Helper()
|
||
chat := &stubChatter{res: nil}
|
||
sess := &stubSessions{sess: session.Session{SessionID: "sess-g1", IsGroup: true}}
|
||
hist := &captureHistory{}
|
||
b, err := New(Options{
|
||
Loop: chat, Sessions: sess, History: hist,
|
||
Owner: testOwnerProfile("qq-obv11"),
|
||
Review: review,
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
b.Add(ad)
|
||
return b, chat, sess, hist
|
||
}
|
||
|
||
// systemLines 返回循环收到的系统角色消息文本。
|
||
func systemLines(msgs []model.Message) []string {
|
||
var out []string
|
||
for _, m := range msgs {
|
||
if m.Role == model.RoleSystem {
|
||
for _, c := range m.Content {
|
||
out = append(out, c.Text)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// TestNoticeIgnorableRegisteredOnly 覆盖「可忽略 → 仅登记,不喂 LLM,不驱动 loop」。
|
||
func TestNoticeIgnorableRegisteredOnly(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ad := ¬iceAdapter{notice: adapter.SystemNotice{
|
||
Kind: adapter.NoticeJoin, SafeText: "有成员加入了群聊", Ignorable: true,
|
||
}, ok: true}
|
||
b, chat, _, hist := noticeFixture(t, ad, nil)
|
||
|
||
b.processFrom(context.Background(), groupNoticeMessage(), ad)
|
||
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("可忽略的系统通知驱动了 loop:%q", chat.sessionID)
|
||
}
|
||
recs := hist.records()
|
||
if len(recs) != 1 {
|
||
t.Fatalf("登记的记录数 = %d,want 1(仅登记)", len(recs))
|
||
}
|
||
if recs[0].Content != "有成员加入了群聊" || recs[0].SessionID != "sess-g1" {
|
||
t.Errorf("登记内容 = %+v", recs[0])
|
||
}
|
||
}
|
||
|
||
// TestNoticeHighValueEscalatesWithoutReview 覆盖「高价值 → 升级真会话」:
|
||
// 它越过审查模型(结构判定优先),进入真会话,并以系统通知行**限定注入**。
|
||
func TestNoticeHighValueEscalatesWithoutReview(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
reviewed := 0
|
||
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) {
|
||
reviewed++
|
||
return nil, nil
|
||
}
|
||
ad := ¬iceAdapter{notice: adapter.SystemNotice{
|
||
Kind: adapter.NoticeLeave, SafeText: "系统通知:她(机器人账号)被移出了群聊", HighValue: true,
|
||
}, ok: true}
|
||
b, chat, _, _ := noticeFixture(t, ad, review)
|
||
|
||
b.processFrom(context.Background(), groupNoticeMessage(), ad)
|
||
|
||
if reviewed != 0 {
|
||
t.Errorf("高价值通知不该走剔噪审查,调用次数 = %d", reviewed)
|
||
}
|
||
if chat.sessionID != "sess-g1" {
|
||
t.Fatalf("高价值通知没进真会话:session=%q", chat.sessionID)
|
||
}
|
||
lines := systemLines(chat.messages)
|
||
if len(lines) != 1 || !strings.HasPrefix(lines[0], noticeSystemPrefix) {
|
||
t.Fatalf("限定注入的系统行 = %v", lines)
|
||
}
|
||
for _, m := range chat.messages {
|
||
if m.Role == model.RoleUser {
|
||
t.Errorf("系统通知被当成用户消息注入:%v", m.Content)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNoticeLimitedInjectReusesReviewModel 覆盖「其余 → 提取安全文本、剥离异常字段后
|
||
// 限定注入」,并证明它复用的是 design/12 §6 策略四的**同一个**审查模型注入点:
|
||
// 审查器只看得到剥离后的安全文本,且原始载荷与附件已被剥离。
|
||
func TestNoticeLimitedInjectReusesReviewModel(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var probe adapter.UnifiedMessage
|
||
review := func(_ context.Context, msgs []adapter.UnifiedMessage) ([]int, error) {
|
||
if len(msgs) != 1 {
|
||
t.Fatalf("审查器应收到单条消息,收到 %d 条", len(msgs))
|
||
}
|
||
probe = msgs[0]
|
||
return []int{0}, nil
|
||
}
|
||
dirty := "群文件\u200b上传\x1b[31m\x1b[0m\n\x07超长" + strings.Repeat("啊", 300)
|
||
ad := ¬iceAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: dirty}, ok: true}
|
||
b, chat, _, _ := noticeFixture(t, ad, review)
|
||
|
||
b.processFrom(context.Background(), groupNoticeMessage(), ad)
|
||
|
||
if chat.sessionID != "sess-g1" {
|
||
t.Fatalf("审查保留的通知没进上下文:session=%q", chat.sessionID)
|
||
}
|
||
if probe.RawData != nil || len(probe.Media) != 0 {
|
||
t.Errorf("审查器看到了未剥离字段:raw=%q media=%d", probe.RawData, len(probe.Media))
|
||
}
|
||
if strings.ContainsAny(probe.Content, "\u200b\x1b\x07\n") {
|
||
t.Errorf("审查器看到的安全文本未剥离异常字符:%q", probe.Content)
|
||
}
|
||
if len([]rune(probe.Content)) > adapter.NoticeSafeTextMaxRunes+1 {
|
||
t.Errorf("安全文本未截断:%d runes", len([]rune(probe.Content)))
|
||
}
|
||
lines := systemLines(chat.messages)
|
||
if len(lines) != 1 || !strings.Contains(lines[0], "群文件上传") {
|
||
t.Fatalf("限定注入的系统行 = %v", lines)
|
||
}
|
||
if got := len([]rune(strings.TrimPrefix(lines[0], noticeSystemPrefix))); got > adapter.NoticeSafeTextMaxRunes+1 {
|
||
t.Errorf("注入文本未截断:%d runes", got)
|
||
}
|
||
}
|
||
|
||
// TestNoticeReviewDroppedStaysSilent 覆盖审查模型判「没必要」那一侧:只登记、不进 loop。
|
||
func TestNoticeReviewDroppedStaysSilent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) { return nil, nil }
|
||
ad := ¬iceAdapter{notice: adapter.SystemNotice{
|
||
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
|
||
}, ok: true}
|
||
b, chat, _, hist := noticeFixture(t, ad, review)
|
||
|
||
b.processFrom(context.Background(), groupNoticeMessage(), ad)
|
||
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("被剔噪的通知仍驱动了 loop:%q", chat.sessionID)
|
||
}
|
||
if recs := hist.records(); len(recs) != 1 || recs[0].Content != "群/客户端系统事件" {
|
||
t.Errorf("被剔噪后未登记:%+v", recs)
|
||
}
|
||
}
|
||
|
||
// TestNoticeReviewFailureFailsSafeSilent 是 fail-safe 的核心用例:
|
||
// 审查模型报错 / 超时(这里用 DeadlineExceeded 模拟超时)时**保守侧 = 静默**。
|
||
// 变异验证(把 noticeFailSafeProceeds 改成 true)会让本用例变红。
|
||
func TestNoticeReviewFailureFailsSafeSilent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
var hadDeadline bool
|
||
review := func(ctx context.Context, _ []adapter.UnifiedMessage) ([]int, error) {
|
||
_, hadDeadline = ctx.Deadline()
|
||
return nil, context.DeadlineExceeded
|
||
}
|
||
ad := ¬iceAdapter{notice: adapter.SystemNotice{
|
||
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
|
||
}, ok: true}
|
||
b, chat, _, _ := noticeFixture(t, ad, review)
|
||
|
||
b.processFrom(context.Background(), groupNoticeMessage(), ad)
|
||
|
||
if !hadDeadline {
|
||
t.Error("审查调用没有超时上限(design/12 §6 实现要点的超时条款)")
|
||
}
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("审查失败时未 fail-safe 静默:%q", chat.sessionID)
|
||
}
|
||
}
|
||
|
||
// TestNoticeReviewerMissingFailsSafeSilent 覆盖「未装配审查模型」:
|
||
// 与审查失败同侧(静默),绝不放行未审查内容。
|
||
func TestNoticeReviewerMissingFailsSafeSilent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ad := ¬iceAdapter{notice: adapter.SystemNotice{
|
||
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
|
||
}, ok: true}
|
||
b, chat, _, _ := noticeFixture(t, ad, nil)
|
||
|
||
b.processFrom(context.Background(), groupNoticeMessage(), ad)
|
||
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("未装配审查模型时未静默:%q", chat.sessionID)
|
||
}
|
||
}
|
||
|
||
// TestNoticeUnparsableFailsSafeSilent 覆盖「格式异常 / 未知 / 解析失败」:
|
||
// 归一化 seam 说不认识 ⇒ 只静默,且绝不把原始载荷喂进上下文。
|
||
func TestNoticeUnparsableFailsSafeSilent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ad := ¬iceAdapter{ok: false}
|
||
b, chat, _, hist := noticeFixture(t, ad, nil)
|
||
|
||
b.processFrom(context.Background(), groupNoticeMessage(), ad)
|
||
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("未识别的系统事件驱动了 loop:%q", chat.sessionID)
|
||
}
|
||
for _, m := range chat.messages {
|
||
if strings.Contains(m.Content[0].Text, "notice_type") {
|
||
t.Errorf("原始载荷进了上下文:%v", m.Content)
|
||
}
|
||
}
|
||
if len(hist.records()) != 0 {
|
||
t.Errorf("无安全文本时不该落一条空登记:%+v", hist.records())
|
||
}
|
||
}
|
||
|
||
// TestNoticeNormalizerPanicFailsSafeSilent 覆盖畸形载荷把适配器解析器打崩:
|
||
// panic 必须折成 fail-safe 静默,绝不牵连常驻后端。
|
||
func TestNoticeNormalizerPanicFailsSafeSilent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ad := ¬iceAdapter{ok: true, panics: true}
|
||
b, chat, _, _ := noticeFixture(t, ad, nil)
|
||
|
||
b.processFrom(context.Background(), groupNoticeMessage(), ad)
|
||
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("归一化 panic 后仍驱动了 loop:%q", chat.sessionID)
|
||
}
|
||
}
|
||
|
||
// TestNoticeWithoutNormalizerFailsSafeSilent 覆盖适配器未实现归一化 seam 的情况:
|
||
// 看不懂平台事件 ⇒ 静默(宁可登记,也不放行)。
|
||
func TestNoticeWithoutNormalizerFailsSafeSilent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ad := &plainAdapter{fakeAdapter{name: "qq-obv11"}}
|
||
b, chat, _, _ := noticeFixture(t, ad, nil)
|
||
|
||
b.processFrom(context.Background(), groupNoticeMessage(), ad)
|
||
|
||
if chat.sessionID != "" {
|
||
t.Fatalf("无归一化器时未静默:%q", chat.sessionID)
|
||
}
|
||
}
|
||
|
||
// TestPreEntryReviewFailSafeIsSilent 直接钉住审查函数本身的 fail-safe 方向
|
||
// (未识别 / 空安全文本都不放行)。变异验证会让它变红。
|
||
func TestPreEntryReviewFailSafeIsSilent(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
cases := []struct {
|
||
name string
|
||
ad adapter.PlatformAdapter
|
||
msg adapter.UnifiedMessage
|
||
}{
|
||
{"未识别", ¬iceAdapter{ok: false}, groupNoticeMessage()},
|
||
{"无归一化器", &plainAdapter{fakeAdapter{name: "qq-obv11"}}, groupNoticeMessage()},
|
||
{"安全文本为空", ¬iceAdapter{ok: true, notice: adapter.SystemNotice{Kind: adapter.NoticeOther}}, groupNoticeMessage()},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
b, _, _, _ := noticeFixture(t, tc.ad, nil)
|
||
out := b.preEntryReview(context.Background(), &tc.msg, tc.ad)
|
||
if !out.Applicable {
|
||
t.Fatal("系统事件未被受理")
|
||
}
|
||
if out.Proceed {
|
||
t.Fatalf("fail-safe 方向被反转(应为静默):reason=%q", out.Reason)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestChatMessageIsNotAReviewSubject 是反向钉:普通聊天消息绝不进传入前审查
|
||
// (成本受控:只有系统事件走这条轻审查)。
|
||
func TestChatMessageIsNotAReviewSubject(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
ad := ¬iceAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeJoin, SafeText: "x", Ignorable: true}, ok: true}
|
||
b, chat, _, _ := noticeFixture(t, ad, nil)
|
||
|
||
msg := groupNoticeMessage()
|
||
msg.ContentType = adapter.ContentText
|
||
msg.Content = "大家好"
|
||
msg.Sender.Role = adapter.RoleMember
|
||
msg.SenderID = "42"
|
||
|
||
b.processFrom(context.Background(), msg, ad)
|
||
|
||
if ad.calls != 0 {
|
||
t.Errorf("普通聊天消息被送进了归一化 seam:calls=%d", ad.calls)
|
||
}
|
||
for _, line := range systemLines(chat.messages) {
|
||
if strings.HasPrefix(line, noticeSystemPrefix) {
|
||
t.Errorf("普通聊天消息被当成系统通知注入:%q", line)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestNoticeReviewTimeoutIsBounded 钉住超时常量本身,避免被改成无上限。
|
||
func TestNoticeReviewTimeoutIsBounded(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
if noticeReviewTimeout <= 0 || noticeReviewTimeout > 30*time.Second {
|
||
t.Fatalf("noticeReviewTimeout = %v,应在 (0, 30s] 内", noticeReviewTimeout)
|
||
}
|
||
}
|
||
|
||
// TestNoticeFailSafeDirectionConstant 把保守方向钉在测试里(design/12 §6 策略一补充:
|
||
// 审查钩子失败一侧 = 静默)。变异验证改的就是这个常量。
|
||
func TestNoticeFailSafeDirectionConstant(t *testing.T) {
|
||
t.Parallel()
|
||
|
||
if noticeFailSafeProceeds {
|
||
t.Fatal("fail-safe 保守方向被反转:审查失败/未知/未装配时必须静默(仅登记)")
|
||
}
|
||
}
|