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 } }