Files
Cyrene_v2/internal/channel/adapter/systemnotice.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

209 lines
7.6 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 adapter
import (
"strings"
"unicode"
)
// 本文件承载「系统通知传入前审查」的**归一化契约**(design/12 §6 策略一补充):
// 各适配器把平台原生的系统通知 / 系统账号通知 / 群内系统提醒解析成**统一结构**
// SystemNotice{Kind, SafeText, Ignorable, HighValue},交给 channelbridge 的传入前
// 审查决定「仅登记 / 限定注入 / 升级真会话」。
//
// 红线(design/12 §6 策略一补充):判「是否系统 / 是否高价值」只看**结构化元数据**
// (通知种类、事件类型、操作者 id),绝不看提示词、绝不看正文措辞;本文件不含任何
// LLM 逻辑,也不做身份判定(身份永远来自 actor 元数据 + 白名单)。
// NoticeKind 是归一化后的系统通知种类(design/12 §6 策略一补充给定的 kind 集合,
// 末尾的 other 是「已识别为系统通知、但结构上不在已知集合内」的兜底值)。
type NoticeKind string
const (
// NoticeJoin 是加入类通知(进群 / 加好友 / 入频道)。
NoticeJoin NoticeKind = "join"
// NoticeLeave 是离开类通知(退群 / 被移出 / 删好友)。
NoticeLeave NoticeKind = "leave"
// NoticeRecall 是撤回类通知(消息被撤回)。
NoticeRecall NoticeKind = "recall"
// NoticeTyping 是轻提示类通知(正在输入 / 戳一戳 / 群内提醒)——最典型的小事件。
NoticeTyping NoticeKind = "typing"
// NoticeBotAdd 是机器人自身被加入/被移出某个会话的通知。
NoticeBotAdd NoticeKind = "bot_add"
// NoticeOther 是已识别但不在已知集合内的系统通知。
NoticeOther NoticeKind = "other"
)
// Valid 报告 k 是否是已知的通知种类。
func (k NoticeKind) Valid() bool {
switch k {
case NoticeJoin, NoticeLeave, NoticeRecall, NoticeTyping, NoticeBotAdd, NoticeOther:
return true
default:
return false
}
}
// NoticeClass 是传入前审查对归一化结构的**结构分类**结果(design/12 §6 策略一补充的
// 三段式分级的类型化表达)。
type NoticeClass int
const (
// NoticeRegisterOnly:可忽略 → 仅登记,不喂 LLM,绝不驱动 loop。
NoticeRegisterOnly NoticeClass = iota
// NoticeLimitedInject:其余 → 提取安全文本、剥离异常字段后**限定注入**上下文。
NoticeLimitedInject
// NoticeEscalate:高价值(如「XX 提到你」/ 敏感操作预警)→ **升级真会话**。
NoticeEscalate
)
// SystemNotice 是一条系统通知归一化后的统一结构(design/12 §6 策略一补充)。
//
// Ignorable / HighValue 由**适配器**依结构化事件类型(notice_type / sub_type /
// 操作者 id 等)填充。两者皆为 false 表示「已识别为系统通知,但结构上无法归类」——
// 它交给传入前审查用既有审查模型剔噪,而不是由模型判身份。
type SystemNotice struct {
// Kind 是归一化后的通知种类。
Kind NoticeKind
// SafeText 是剥离异常字段后的安全文本;它可以为空(此时绝不注入)。
SafeText string
// Ignorable 报告该通知在结构上可忽略(进群/退群/撤回/戳一戳等小事件)。
Ignorable bool
// HighValue 报告该通知在结构上是高价值(敏感操作预警 / 她本人被移出等)。
HighValue bool
}
// Classify 是结构分类的**单一事实来源**(design/12 §6 策略一补充):
// 可忽略 → 仅登记;高价值 → 升级真会话;其余 → 限定注入。
// 两个标志同时为真时按**保守侧**取「仅登记」(不打扰为第一原则)。
func (n SystemNotice) Classify() NoticeClass {
switch {
case n.Ignorable:
return NoticeRegisterOnly
case n.HighValue:
return NoticeEscalate
default:
return NoticeLimitedInject
}
}
// SystemNoticeNormalizer 是**每个适配器**可选实现的归一化 seam(design/12 §6 策略一
// 补充)。channelbridge 收到一条被结构化标记为系统事件的消息(ContentType 为
// ContentAction)时调用它,把平台原生载荷解析成统一结构。
//
// ok=false 表示**无法归一化**——格式异常 / 未知通知类型 / 解析失败 / 载荷缺失。
// 调用方据此走 fail-safe 静默(仅登记、绝不驱动 loop,绝不让畸形数据进入 LLM
// 上下文)。实现必须无副作用:不调用 LLM、不判身份、不做 I/O。
type SystemNoticeNormalizer interface {
NormalizeSystemNotice(msg UnifiedMessage) (SystemNotice, bool)
}
const (
// NoticeSafeTextMaxRunes 限定安全文本长度:系统通知只承载一句提示,超出部分
// 一律截断——它既防畸形字段灌进上下文,也防超长文本撑爆 token 预算。
NoticeSafeTextMaxRunes = 200
// noticeSafeTextEllipsis 是截断标记。
noticeSafeTextEllipsis = "…"
)
// SanitizeNoticeText 把平台原生通知文本剥成**安全文本**(design/12 §6 策略一补充的
// 「提取安全文本、剥离异常字段」):吞掉 ANSI 转义序列,去掉控制字符、零宽字符与
// 双向覆盖字符,把换行/制表折成空格并折叠连续空白,最后按 NoticeSafeTextMaxRunes
// 截断。它对任何输入都不报错、不 panic,也绝不返回未净化的原文。
func SanitizeNoticeText(s string) string {
var b strings.Builder
b.Grow(len(s))
state := escNone
for _, r := range s {
if next, consumed := escStep(state, r); consumed {
state = next
continue
}
switch {
case r == 0x1b:
state = escStart
case r == '\n' || r == '\r' || r == '\t':
b.WriteRune(' ')
case unicode.IsControl(r):
continue
case isInvisibleRune(r):
continue
default:
b.WriteRune(r)
}
}
out := strings.Join(strings.Fields(b.String()), " ")
runes := []rune(out)
if len(runes) > NoticeSafeTextMaxRunes {
return string(runes[:NoticeSafeTextMaxRunes]) + noticeSafeTextEllipsis
}
return out
}
// 转义序列解析的状态(ANSI/CSI/OSC)。系统通知里不该出现转义序列,出现的
// 一律整段吞掉——留着只会让渲染文本与实际字节不一致(视觉欺骗 / 注入面)。
const (
escNone = iota
// escStart 刚见到 ESC,下一个字符决定这是哪一类序列。
escStart
// escCSI 是 ESC [ / ESC ( / ESC ) / ESC # 参数序列:跳过参数与中间字节
// (0x20–0x3F)直到终结字节(0x40–0x7E)。
escCSI
// escOSC 是 ESC ] 字符串序列:跳过直到 BEL。
escOSC
)
// escStep 推进转义序列状态。consumed 为 true 表示 r 属于转义序列、不应输出;
// next 是推进后的状态。state 为 escNone 时它只把 ESC 本身折成 escStart。
func escStep(state int, r rune) (next int, consumed bool) {
switch state {
case escStart:
switch r {
case '[', '(', ')', '#':
return escCSI, true
case ']':
return escOSC, true
default:
// 两字符转义(ESC X):X 是终结字节,一并吞掉。
return escNone, true
}
case escCSI:
if r >= 0x20 && r <= 0x3f {
return escCSI, true
}
return escNone, true
case escOSC:
switch r {
case 0x07:
return escNone, true
case 0x1b:
return escStart, true
default:
return escOSC, true
}
default:
if r == 0x1b {
return escStart, true
}
return escNone, false
}
}
// isInvisibleRune 报告 r 是否是零宽字符或双向文本覆盖字符——这两类字符在系统通知里
// 没有正当用途,留着只会让渲染后的文本与真实字节不一致(提示词注入/视觉欺骗面)。
func isInvisibleRune(r rune) bool {
switch {
case r >= 0x200b && r <= 0x200f:
return true
case r >= 0x202a && r <= 0x202e:
return true
case r >= 0x2060 && r <= 0x2064:
return true
case r >= 0x2066 && r <= 0x2069:
return true
case r == 0xfeff:
return true
default:
return false
}
}