Files
Cyrene_v2/internal/services/channelbridge/notice.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

202 lines
8.7 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 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
}