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 侧改追加(不再重启即截断)
This commit is contained in:
1 parent
85abb90067
commit
5139945bfe
15 files changed
+1604
-21
No files matched your search
@@ -0,0 +1,208 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件覆盖 design/12 §6 策略一补充的归一化契约:结构分类(单一事实来源)、
|
||||
// 种类合法性,以及把平台原生通知文本剥成安全文本的净化器。
|
||||
|
||||
func TestSystemNoticeClassify(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in SystemNotice
|
||||
want NoticeClass
|
||||
}{
|
||||
{"可忽略 → 仅登记", SystemNotice{Kind: NoticeJoin, Ignorable: true}, NoticeRegisterOnly},
|
||||
{"高价值 → 升级真会话", SystemNotice{Kind: NoticeOther, HighValue: true}, NoticeEscalate},
|
||||
{"其余 → 限定注入", SystemNotice{Kind: NoticeOther}, NoticeLimitedInject},
|
||||
// 两个标志同时为真时取保守侧(不打扰为第一原则)。
|
||||
{"既标可忽略又标高价值 → 仅登记", SystemNotice{Ignorable: true, HighValue: true}, NoticeRegisterOnly},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := tc.in.Classify(); got != tc.want {
|
||||
t.Fatalf("Classify() = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoticeKindValid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, k := range []NoticeKind{NoticeJoin, NoticeLeave, NoticeRecall, NoticeTyping, NoticeBotAdd, NoticeOther} {
|
||||
if !k.Valid() {
|
||||
t.Errorf("%q 应合法", k)
|
||||
}
|
||||
}
|
||||
if NoticeKind("weird").Valid() {
|
||||
t.Error("未知 kind 不该合法")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeNoticeText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"空串", "", ""},
|
||||
{"折叠换行与制表", "有人\n加入\t了群聊", "有人 加入 了群聊"},
|
||||
{"吞掉 ANSI 序列", "\x1b[31m红\x1b[0m字", "红字"},
|
||||
{"丢掉零宽与双向覆盖字符", "群\u200b聊\u202e事", "群聊事"},
|
||||
{"丢掉其余控制字符", "系\x07统\x00通知", "系统通知"},
|
||||
{"折叠多余空白", " a b ", "a b"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := SanitizeNoticeText(tc.in); got != tc.want {
|
||||
t.Fatalf("SanitizeNoticeText(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeNoticeTextTruncates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := SanitizeNoticeText(strings.Repeat("啊", NoticeSafeTextMaxRunes+50))
|
||||
if runes := []rune(got); len(runes) != NoticeSafeTextMaxRunes+1 {
|
||||
t.Fatalf("截断后 %d runes, want %d+1(含省略号)", len(runes), NoticeSafeTextMaxRunes)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Errorf("截断未加省略号:%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeNoticeTextNeverPanicsOnGarbage 钉住「畸形数据不炸流水线」:
|
||||
// 净化器对任意字节串都必须返回、不 panic,也绝不返回未净化的控制字符。
|
||||
func TestSanitizeNoticeTextNeverPanicsOnGarbage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, in := range []string{
|
||||
"\x1b", "\x1b[", "\x1b[31", "正常\x1b[31m文本", "\u200b", "\ufeff", "\x7f", "a\x1b[1;2Hb",
|
||||
} {
|
||||
got := SanitizeNoticeText(in)
|
||||
for _, r := range got {
|
||||
if r == 0x1b || isInvisibleRune(r) {
|
||||
t.Fatalf("SanitizeNoticeText(%q) = %q 仍含异常字符", in, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user