Files
Cyrene_v2/internal/channel/adapter/systemnotice.go
T
AskaEth ba17820040
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): QQ 非消息事件全枚举投递 + systemnotice 补 request/interaction/unknown 类
- 适配器不再整类丢弃:notice/request/meta_event/message_sent/未知 post_type/JSON 解析失败( decode_error ) 一律归一化投递并留痕
- notice 映射补 group_ban/sub_type 全枚举,未知类型走 NoticeUnknown 兜底(fail-safe 只登记、绝不猜、绝不放行)
- 不投影 flag/comment/file/duration;归一化 DEBUG 只记结构(脱敏,变异实测可打红)
- 传输层端到端已覆盖(真实 http+server 上报路径:加好友请求/心跳/未知类型)
- ⚠️ 主人 2026-10-05 决定「先不连 QQ」(风控)⇒ 本片为通用红线(绝不静默丢弃)+ 现成证据链,QQ 侧后续增量工作暂停
2026-10-05 13:47:27 +08:00

314 lines
14 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 策略一补充;
// 字段集按 design/26 §2.4 扩展):各适配器把平台原生的系统通知 / 系统账号通知 /
// 群内系统提醒解析成**统一结构** SystemNotice{Kind, SafeText, Ignorable, HighValue,
// Category, PlatformType, Actor, Target, SelfRelated, RefMessage},交给 channelbridge
// 的传入前审查决定「仅登记 / 限定注入 / 升级真会话」;Category / PlatformType /
// SelfRelated 等结构化字段则由桥接层用于**留存**(design/26 §4)。
//
// 红线(design/12 §6 策略一补充 + design/26 §1.2):判「是否系统 / 是否高价值」只看
// **结构化元数据**(通知种类、事件类型、操作者 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"
// NoticeRequest 是**请求类**通知(加好友请求 / 加群请求 / 邀请入群):它不是
// 「已经发生的事」,而是**等着被裁决的请求**(design/26 §3.1 的 request 行)。
NoticeRequest NoticeKind = "request"
// NoticeInteraction 是**定向交互**事件(戳一戳 / 表情回应 / 道具 / 按钮 / 投票):
// 统一形态是「谁(Actor)对谁(Target)做了什么(Category)」(design/26 §2.5)。
// 它默认**只登记、不自动回应**——回不回由参与度引擎 / 主人显式规则决定,绝不写死
// 在归一化层(design/26 §5.5)。
NoticeInteraction NoticeKind = "interaction"
// NoticeUnknown 是**平台类型未能枚举**的兜底种类(design/26 §3.7):它不是
// 「看不懂就当没有」,而是「如实记为未知」;它恒为可忽略(仅登记),
// **绝不进她的上下文**(fail-safe),但必须留痕、可计数。
NoticeUnknown NoticeKind = "unknown"
)
// Valid 报告 k 是否是已知的通知种类。
func (k NoticeKind) Valid() bool {
switch k {
case NoticeJoin, NoticeLeave, NoticeRecall, NoticeTyping, NoticeBotAdd, NoticeOther,
NoticeRequest, NoticeInteraction, NoticeUnknown:
return true
default:
return false
}
}
// NoticeCategory 是比 NoticeKind 细一档的**归一化类别**(design/26 §2.4/§4.1):
// Kind 回答「这是哪一类系统事件」,Category 回答「具体是哪一件事」——同一种 Kind 下
// 可以有多个类别(如 Kind=join 下的 join / friend_add,Kind=interaction 下的
// poke / lucky_king)。
//
// 它与留存档(design/26 的 T1/T2/T3)是**两个正交轴**:留存档由桥接层按
// Category + SelfRelated 映射(design/26 §2.3),不在适配器层落地。
type NoticeCategory string
const (
// NoticeCategoryJoin 是成员加入(进群 / 入频道)。
NoticeCategoryJoin NoticeCategory = "join"
// NoticeCategoryFriendAdd 是「已加为好友」的结果通知(与请求态 friend_request 区分)。
NoticeCategoryFriendAdd NoticeCategory = "friend_add"
// NoticeCategoryLeave 是成员离开(退群 / 被移出 / 删好友)。
NoticeCategoryLeave NoticeCategory = "leave"
// NoticeCategoryRecall 是消息被撤回。
NoticeCategoryRecall NoticeCategory = "recall"
// NoticeCategoryAdmin 是管理员 / 权限身份变更。
NoticeCategoryAdmin NoticeCategory = "admin"
// NoticeCategoryBan 是禁言状态变更(不含全员禁言)。
NoticeCategoryBan NoticeCategory = "ban"
// NoticeCategoryBanAll 是**全员禁言**开关(`user_id==0 && duration==-1`)。
// 它必须与 ban 分开:归一成「用户 0」会把「整个群被禁言」读成「某个成员被禁言」
// (design/26 §3.1 的 group_ban 行)。
NoticeCategoryBanAll NoticeCategory = "ban_all"
// NoticeCategoryUpload 是群文件上传。
NoticeCategoryUpload NoticeCategory = "upload"
// NoticeCategoryEssence 是精华消息变更。
NoticeCategoryEssence NoticeCategory = "essence"
// NoticeCategoryCard 是群名片变更。
NoticeCategoryCard NoticeCategory = "card"
// NoticeCategoryOfflineFile 是收到离线文件。
NoticeCategoryOfflineFile NoticeCategory = "offline_file"
// NoticeCategoryClientStatus 是**他人**客户端在线状态变更(存在性遥测)。
NoticeCategoryClientStatus NoticeCategory = "client_status"
// NoticeCategoryHonor 是群成员荣誉变更(龙王 / 群聊之火 / 快乐源泉)。
NoticeCategoryHonor NoticeCategory = "honor"
// NoticeCategoryTitle 是群成员头衔变更。
NoticeCategoryTitle NoticeCategory = "title"
// NoticeCategoryPoke 是戳一戳。
NoticeCategoryPoke NoticeCategory = "poke"
// NoticeCategoryLuckyKing 是红包运气王。
NoticeCategoryLuckyKing NoticeCategory = "lucky_king"
// NoticeCategoryFriendRequest 是加好友请求。
NoticeCategoryFriendRequest NoticeCategory = "friend_request"
// NoticeCategoryGroupRequest 是加群请求 / 邀请入群请求。
NoticeCategoryGroupRequest NoticeCategory = "group_request"
// NoticeCategoryHeartbeat 是 OneBot 心跳(链路遥测)。
NoticeCategoryHeartbeat NoticeCategory = "heartbeat"
// NoticeCategoryLifecycle 是 OneBot 生命周期(connect / enable / disable)。
NoticeCategoryLifecycle NoticeCategory = "lifecycle"
// NoticeCategoryMessageSent 是她自己发出消息的回声(防自我回声,只登记不回应)。
NoticeCategoryMessageSent NoticeCategory = "message_sent"
// NoticeCategoryDecodeError 是**载荷无法解析**(JSON 坏 / 类型字段全缺)的兜底类别:
// «解析失败» 也是一条事件,绝不是「什么都没有」(design/26 §3.7)。
NoticeCategoryDecodeError NoticeCategory = "decode_error"
// NoticeCategoryUnknown 是**类型未枚举**的兜底类别(design/26 §3.7)。
NoticeCategoryUnknown NoticeCategory = "unknown"
)
// NoticeClass 是传入前审查对归一化结构的**结构分类**结果(design/12 §6 策略一补充的
// 三段式分级的类型化表达)。
type NoticeClass int
const (
// NoticeRegisterOnly:可忽略 → 仅登记,不喂 LLM,绝不驱动 loop。
NoticeRegisterOnly NoticeClass = iota
// NoticeLimitedInject:其余 → 提取安全文本、剥离异常字段后**限定注入**上下文。
NoticeLimitedInject
// NoticeEscalate:高价值(如「XX 提到你」/ 敏感操作预警)→ **升级真会话**。
NoticeEscalate
)
// SystemNotice 是一条系统通知归一化后的统一结构(design/12 §6 策略一补充,
// 字段集按 design/26 §2.4 扩展)。
//
// Ignorable / HighValue 由**适配器**依结构化事件类型(notice_type / sub_type /
// 操作者 id 等)填充。两者皆为 false 表示「已识别为系统通知,但结构上无法归类」——
// 它交给传入前审查用既有审查模型剔噪,而不是由模型判身份。
//
// 刻意**不设** RefFlag:OneBot 的 `request.flag` 是**可执行凭据**(拿它能直接同意
// 好友 / 加群请求),design/26 §4.2 明令它不得入表、不得进日志。把它挂在会流向留存
// 层的结构上,等于给它开一条渗漏路径;将来若要做「自动同意」,那一刻再从内存载荷里
// 临时取用(design/26 §8-1),绝不经过这里。
type SystemNotice struct {
// Kind 是归一化后的通知种类。
Kind NoticeKind
// SafeText 是剥离异常字段后的安全文本;它可以为空(此时绝不注入)。
SafeText string
// Ignorable 报告该通知在结构上可忽略(进群/退群/撤回/戳一戳等小事件)。
Ignorable bool
// HighValue 报告该通知在结构上是高价值(敏感操作预警 / 她本人被移出等)。
HighValue bool
// Category 是比 Kind 细一档的归一化类别(design/26 §2.4/§4.1);零值表示适配器
// 未给类别,留存侧按 Kind 兜底。
Category NoticeCategory
// PlatformType 是平台**原生**类型码(OneBot 的 notice_type / request_type /
// meta_event_type / post_type、KOOK 的 extra.type、Telegram 的 update 键名),
// **原样保留、绝不改写**——它是排障时回答「上游到底报了什么类型」的唯一凭据
// (design/26 §2.4)。
PlatformType string
// Actor 是事件的发起者(结构化字段)。它**绝不参与判主**——身份永远由会话层的
// 白名单 + 策略引擎裁决(项目红线)。零值表示平台没给。
Actor Actor
// Target 是被作用者(戳一戳的目标 / 被禁言者 / 运气王…)。无则零值。
Target Actor
// SelfRelated 报告该事件是否命中**她自己**(OneBot 的 self_id / 自己的成员身份),
// 是 design/26 §2.2「self 命中升档」的判据(结构化 id 比对,与正文措辞无关)。
SelfRelated bool
// RefMessage 是关联的消息 id(撤回 / 回应 / 按钮 / 置顶)。无则空串。
RefMessage string
}
// 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。
//
// ⚠️ 口径(design/26 §1.1 / §3.7):**「类型未知」不等于「读不出事件」**。适配器应当
// 把能解析、但类型未被枚举的帧归一化成 `NoticeUnknown` 兜底并返回 ok=true(带
// PlatformType 原样留痕),而不是用 ok=false 让它消失在「未识别」里——「被丢弃」与
// 「从未到达」必须在数据里能分开。ok=false 只保留给**根本解不出**的载荷。
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
}
}