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,6 +180,9 @@ type Bridge struct {
quietStartHour int
quietEndHour int
now func() time.Time
// noticeLog 是系统通知传入前审查(design/12 §6 策略一补充)的子功能日志器,
// 与 voiceLog 同款:`source=services/channelbridge/notice` 便于跨模块定位。
noticeLog *slog.Logger
// 参与度引擎状态(design/18):按会话的内存状态机,
// 加上约束它的角色提醒词 / 参与度参数,以及概率触发
@@ -277,6 +280,7 @@ func New(opts Options) (*Bridge, error) {
quietStartHour: qStart,
quietEndHour: qEnd,
now: now,
noticeLog: logger.WithSource(log, "services/channelbridge/notice"),
triggerWords: opts.TriggerWords,
personaPrompt: opts.PersonaPrompt,
engage: opts.Engage,
+21 -1
View File
@@ -139,6 +139,16 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
// 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。
b.normalizeInboundVoice(ctx, &msg)
// design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」——
// 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的
// 事件只登记、绝不驱动 loop;被放行的事件已就地换成**剥离后的安全文本**,
// 继续走常规流水线(限定注入 / 升级真会话)。
notice := b.preEntryReview(ctx, &msg, a)
if notice.Applicable && !notice.Proceed {
b.registerNoticeOnly(ctx, msg, a, notice)
return
}
text := strings.TrimSpace(msg.Content)
// 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」、带语音的
// 「纯语音消息」与带图片的「纯图片消息」(微信/QQ 发图常常没有配文)必须继续往下走:
@@ -176,7 +186,11 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a
// 消息,只登记到其隔离会话日志并静默——绝不
// 驱动完整 agent 循环。主人消息绝不在此被拦,因此
// 主人私聊与主人在群里的 @/高价值回复不受影响。
if !actor.IsOwner() {
//
// 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一
// 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向
// 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。
if !actor.IsOwner() && !notice.Applicable {
if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok {
b.registerSilenced(ctx, msg, actor, sess, a)
b.log.Debug("non-owner message silenced (minimal disturbance)",
@@ -376,6 +390,12 @@ func deviceIDFor(msg adapter.UnifiedMessage) string {
// 前置到列表最前面,与网关编排器的播种同序(转录在前、本次消息在后)。因此本函数只负责
// **本回合自己的**消息(来源行 + 当次用户消息),受限车道的形状在有无转录时保持一致。
func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message {
// design/12 §6 策略一补充:经传入前审查放行的系统通知走「限定注入」——只把剥离后的
// 安全文本作为**一条系统行**注入她的上下文,绝不作为用户消息(否则等于把平台事件
// 伪装成有人对她说话),也绝不携带任何原始字段。
if isSystemNoticeMessage(src) {
return []model.Message{model.TextMessage(model.RoleSystem, noticeSystemPrefix+text)}
}
msg := userTurnMessage(text)
thirdParty := env.Restricted() && !actor.IsOwner() && senderName != ""
if thirdParty {
+201
View File
@@ -0,0 +1,201 @@
package channelbridge
import (
"context"
"errors"
"fmt"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
// 本文件承载 design/12 §6 策略一补充的「系统通知传入前审查」(pre-entry review):
// 系统类事件**先审查、归一化,再决定**是否 / 如何进入她的上下文——裸喂禁止。
//
// 分级(结构判定优先,红线:绝不看正文措辞、绝不由模型判身份):
// - 可忽略 → 仅登记(不喂 LLM,不驱动 loop);
// - 高价值 → 升级真会话(越过「非主人聊天消息」的最小打扰闸门,直接放行);
// - 其余 → 提取安全文本、剥离异常字段后**限定注入**(先过既有审查模型剔噪)。
//
// 审查失败 / 超时 / 未知 / 解析失败 / 未装配审查模型 → **fail-safe 静默**(见
// noticeFailSafeProceeds)。畸形数据绝不进入 LLM 上下文,也绝不驱动 loop。
const (
// noticeReviewTimeout 是一次系统通知剔噪审查的上限:审查模型卡住绝不能拖住
// 入站流水线(design/12 §6 实现要点的并发条款同款口径)。
noticeReviewTimeout = 5 * time.Second
// noticeSystemPrefix 是限定注入时系统行的前缀——它标在 **system 角色**消息上,
// 不是用户正文(design/11 §10:用户正文不得塞系统标记)。
noticeSystemPrefix = "[系统通知] "
)
// noticeFailSafeProceeds 是 design/12 §6 策略一补充**写死的保守方向**:
// false = 审查失败 / 超时 / 未识别 / 解析失败 / 未装配审查模型时**一律静默**
// (仅登记,绝不驱动 loop,绝不让畸形数据进入 LLM 上下文)。
// 变异验证就是把它改成 true,看「fail-safe 静默」这组测试变红。
const noticeFailSafeProceeds = false
// ErrNoNoticeReviewer 表示未装配审查模型:系统通知的「其余」档没有可用的剔噪器。
// 调用方据此走 fail-safe 静默,绝不因缺模型而放行未审查内容。
var ErrNoNoticeReviewer = errors.New("channelbridge: no system notice reviewer")
// noticeOutcome 是传入前审查对一条入站消息的裁决。
type noticeOutcome struct {
// Applicable 报告该消息经**结构化元数据**判定为系统事件(已被本审查受理)。
Applicable bool
// Proceed 报告归一化后的内容是否允许进入她的上下文。
Proceed bool
// Class 是归一化后的结构分类结果。
Class adapter.NoticeClass
// Notice 是归一化(且已剥离)后的统一结构。
Notice adapter.SystemNotice
// Reason 是简短原因标记,用于日志与排查。
Reason string
}
// preEntryReview 是「传入前审查」主体:结构化识别 → 归一化 → 剥离 → 分类 →
// (其余档)既有审查模型剔噪 → fail-safe。
//
// msg 为指针:凡是被判定放行(Proceed)的通知**就地**改写——Content 换成剥离后的
// 安全文本、Media 与 RawData 清空(剥离异常字段:原始载荷、附件一律不进上下文)。
// 这个改写挂在 defer 上,使「放行」只有一条出口:任何分支一旦放行,就绝不留下
// 未剥离的原始字段(反之任何静默分支都不改写任何东西)。
func (b *Bridge) preEntryReview(ctx context.Context, msg *adapter.UnifiedMessage, a adapter.PlatformAdapter) (out noticeOutcome) {
defer func() {
if !out.Proceed || out.Notice.SafeText == "" {
return
}
msg.Content = out.Notice.SafeText
msg.Media = nil
msg.RawData = nil
}()
if !isSystemNoticeMessage(*msg) {
return noticeOutcome{}
}
norm, ok := systemNoticeNormalizer(a)
if !ok {
return noticeOutcome{Applicable: true, Proceed: noticeFailSafeProceeds,
Reason: "system event without a normalizer for this adapter"}
}
notice, ok := normalizeNoticeSafely(norm, *msg)
if !ok {
return noticeOutcome{Applicable: true, Proceed: noticeFailSafeProceeds,
Reason: "system notice unparsable"}
}
notice.SafeText = adapter.SanitizeNoticeText(notice.SafeText)
class := notice.Classify()
out = noticeOutcome{Applicable: true, Class: class, Notice: notice}
if notice.SafeText == "" {
out.Reason = "system notice carries no safe text"
out.Proceed = noticeFailSafeProceeds
return out
}
switch class {
case adapter.NoticeRegisterOnly:
out.Reason = "ignorable system event"
return out
case adapter.NoticeEscalate:
out.Proceed = true
out.Reason = "high-value system event escalated to a real session"
default:
keep, err := b.reviewNoticeNoise(ctx, *msg, notice)
if err != nil {
out.Reason = "notice review failed: " + err.Error()
out.Proceed = noticeFailSafeProceeds
return out
}
if !keep {
// 这一侧不是 fail-safe:是审查模型明确判「没必要」。静默是它的裁决本身,
// 与审查失败那条路径(noticeFailSafeProceeds)无关。
out.Reason = "notice review dropped the event as noise"
return out
}
out.Proceed = true
out.Reason = "notice review kept the event"
}
return out
}
// reviewNoticeNoise 复用 design/12 §6 策略四的**同一个**审查模型注入点
// (Options.Review / adapter.Reviewer,绝不另造第二套)对一条无法结构归类的系统
// 通知做一次成本受控的剔噪:返回 true 表示值得进上下文。
// 它把单条消息当作长度为 1 的队列交给审查器,因此复用其「保留编号」契约与
// 越界/重复索引的防幻觉处理。审查模型只看剥离后的安全文本,绝不见原始载荷;
// 出错(含超时)一律往上抛,由调用方走 fail-safe 静默。
func (b *Bridge) reviewNoticeNoise(ctx context.Context, msg adapter.UnifiedMessage, notice adapter.SystemNotice) (bool, error) {
if b.review == nil {
return false, ErrNoNoticeReviewer
}
probe := msg
probe.Content = notice.SafeText
probe.Media = nil
probe.RawData = nil
reviewCtx, cancel := context.WithTimeout(ctx, noticeReviewTimeout)
defer cancel()
keep, err := b.review(reviewCtx, []adapter.UnifiedMessage{probe})
if err != nil {
return false, fmt.Errorf("channelbridge: notice review: %w", err)
}
for _, i := range keep {
if i == 0 {
return true, nil
}
}
return false, nil
}
// registerNoticeOnly 是审查判「仅登记 / fail-safe 静默」那一侧的收口:把安全文本写进
// 该消息所属会话的平台日志(登记),**绝不驱动 loop、绝不调用 LLM**。它与最小打扰
// 路径共用 registerSilenced,因此「登记」只有一条实现。
func (b *Bridge) registerNoticeOnly(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, out noticeOutcome) {
if out.Notice.SafeText == "" {
b.noticeLog.Debug("system notice registered without text",
"platform", msg.Platform, "channel", msg.ChannelID, "reason", out.Reason)
return
}
msg.Content = out.Notice.SafeText
actor := session.ResolveActor(msg, b.owner)
sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner)
if err != nil {
b.noticeLog.Warn("system notice session resolve failed, not registered",
"platform", msg.Platform, "channel", msg.ChannelID, "error", err)
return
}
b.registerSilenced(ctx, msg, actor, sess, a)
b.noticeLog.Debug("system notice registered only (no loop)",
"platform", msg.Platform, "channel", msg.ChannelID, "kind", out.Notice.Kind, "reason", out.Reason)
}
// isSystemNoticeMessage 只按**结构化元数据**判定一条消息是否是系统类事件:
// 适配器在归一化时把它标成 ContentAction(非聊天动作信号:进群/退群/撤回/戳一戳/
// 系统提醒)。它**绝不看正文**,也绝不用提示词判断(项目红线)。
func isSystemNoticeMessage(msg adapter.UnifiedMessage) bool {
return msg.ContentType == adapter.ContentAction
}
// systemNoticeNormalizer 取适配器的归一化 seam。未实现该可选接口的适配器返回
// ok=false——调用方据此走 fail-safe 静默(宁可登记,也不放行看不懂的平台事件)。
func systemNoticeNormalizer(a adapter.PlatformAdapter) (adapter.SystemNoticeNormalizer, bool) {
if a == nil {
return nil, false
}
norm, ok := a.(adapter.SystemNoticeNormalizer)
if !ok || norm == nil {
return nil, false
}
return norm, true
}
// normalizeNoticeSafely 调用适配器的归一化 seam,并把任何 panic 折成 ok=false。
// 各平台的系统通知格式多变且不确定,畸形载荷把某个解析器打崩**不能**牵连常驻后端
// ——按 design/12 §6 策略一补充的审查钩子,这一侧同样是 fail-safe 静默。
func normalizeNoticeSafely(norm adapter.SystemNoticeNormalizer, msg adapter.UnifiedMessage) (notice adapter.SystemNotice, ok bool) {
defer func() {
if r := recover(); r != nil {
notice, ok = adapter.SystemNotice{}, false
}
}()
notice, ok = norm.NormalizeSystemNotice(msg)
return notice, ok
}
@@ -0,0 +1,380 @@
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 := &noticeAdapter{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 := &noticeAdapter{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 := &noticeAdapter{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 := &noticeAdapter{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 := &noticeAdapter{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 := &noticeAdapter{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 := &noticeAdapter{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 := &noticeAdapter{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
}{
{"未识别", &noticeAdapter{ok: false}, groupNoticeMessage()},
{"无归一化器", &plainAdapter{fakeAdapter{name: "qq-obv11"}}, groupNoticeMessage()},
{"安全文本为空", &noticeAdapter{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 := &noticeAdapter{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 保守方向被反转:审查失败/未知/未装配时必须静默(仅登记)")
}
}