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 侧后续增量工作暂停
This commit is contained in:
1 parent
ec3f6e810a
commit
ba17820040
9 files changed
+1315
-193
No files matched your search
@@ -5,14 +5,16 @@ import (
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// 本文件承载「系统通知传入前审查」的**归一化契约**(design/12 §6 策略一补充):
|
||||
// 各适配器把平台原生的系统通知 / 系统账号通知 / 群内系统提醒解析成**统一结构**
|
||||
// SystemNotice{Kind, SafeText, Ignorable, HighValue},交给 channelbridge 的传入前
|
||||
// 审查决定「仅登记 / 限定注入 / 升级真会话」。
|
||||
// 本文件承载「系统通知传入前审查」的**归一化契约**(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 策略一补充):判「是否系统 / 是否高价值」只看**结构化元数据**
|
||||
// (通知种类、事件类型、操作者 id),绝不看提示词、绝不看正文措辞;本文件不含任何
|
||||
// LLM 逻辑,也不做身份判定(身份永远来自 actor 元数据 + 白名单)。
|
||||
// 红线(design/12 §6 策略一补充 + design/26 §1.2):判「是否系统 / 是否高价值」只看
|
||||
// **结构化元数据**(通知种类、事件类型、操作者 id),绝不看提示词、绝不看正文措辞;
|
||||
// 本文件不含任何 LLM 逻辑,也不做身份判定(身份永远来自 actor 元数据 + 白名单)。
|
||||
|
||||
// NoticeKind 是归一化后的系统通知种类(design/12 §6 策略一补充给定的 kind 集合,
|
||||
// 末尾的 other 是「已识别为系统通知、但结构上不在已知集合内」的兜底值)。
|
||||
@@ -25,24 +27,98 @@ const (
|
||||
NoticeLeave NoticeKind = "leave"
|
||||
// NoticeRecall 是撤回类通知(消息被撤回)。
|
||||
NoticeRecall NoticeKind = "recall"
|
||||
// NoticeTyping 是轻提示类通知(正在输入 / 戳一戳 / 群内提醒)——最典型的小事件。
|
||||
// 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:
|
||||
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
|
||||
@@ -56,11 +132,17 @@ const (
|
||||
NoticeEscalate
|
||||
)
|
||||
|
||||
// SystemNotice 是一条系统通知归一化后的统一结构(design/12 §6 策略一补充)。
|
||||
// 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
|
||||
@@ -70,6 +152,24 @@ type SystemNotice struct {
|
||||
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 策略一补充):
|
||||
@@ -90,9 +190,14 @@ func (n SystemNotice) Classify() NoticeClass {
|
||||
// 补充)。channelbridge 收到一条被结构化标记为系统事件的消息(ContentType 为
|
||||
// ContentAction)时调用它,把平台原生载荷解析成统一结构。
|
||||
//
|
||||
// ok=false 表示**无法归一化**——格式异常 / 未知通知类型 / 解析失败 / 载荷缺失。
|
||||
// 调用方据此走 fail-safe 静默(仅登记、绝不驱动 loop,绝不让畸形数据进入 LLM
|
||||
// 上下文)。实现必须无副作用:不调用 LLM、不判身份、不做 I/O。
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件承载 OneBot v11 **非消息上报的全枚举投递**(design/26 §1.1 / §3.1 / §3.7):
|
||||
// 任何到达适配器的帧都不许静默丢弃 —— `notice` / `request` / `meta_event` /
|
||||
// `message_sent`(go-cqhttp 扩展)以及**未知 post_type** 一律先包成带结构化标记的
|
||||
// UnifiedMessage,再归一化成统一结构;「帧到达过适配器」这件事本身必须留下痕迹。
|
||||
//
|
||||
// 它只做两件事,**绝不**做第三件:
|
||||
// ① 投递:把帧包成 ContentType=ContentAction、发送者恒为 RoleSystem、Content 恒空的
|
||||
// 入站消息(平台原文只进 RawData,供归一化读,随后由桥接层剥离清空);
|
||||
// ② 归一化:post_type → 统一结构的分派(notice 的一整张映射表在 notice.go)。
|
||||
//
|
||||
// 存储、身份判定、进不进上下文都由 channelbridge 的传入前审查与留存层负责
|
||||
// (design/26 §5):适配器不碰存储、不判身份、不驱动 loop。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// OneBot v11 的 post_type 取值:官方规范恰为四值(message / notice / request /
|
||||
// meta_event),go-cqhttp 家族还会上报扩展值 message_sent(自己发出消息的回声,
|
||||
// 需对端开启 report-self-message)。四个官方值 + 扩展值都必须有归宿(design/26 §3.1)。
|
||||
const (
|
||||
postTypeMessage = "message"
|
||||
postTypeMessageSent = "message_sent"
|
||||
postTypeNotice = "notice"
|
||||
postTypeRequest = "request"
|
||||
postTypeMetaEvent = "meta_event"
|
||||
)
|
||||
|
||||
// request 的 request_type、meta_event 的 meta_event_type,以及它们用到的 sub_type
|
||||
// (design/26 §6.1 要求补全的那一组常量)。
|
||||
const (
|
||||
requestTypeFriend = "friend"
|
||||
requestTypeGroup = "group"
|
||||
|
||||
metaEventHeartbeat = "heartbeat"
|
||||
metaEventLifecycle = "lifecycle"
|
||||
|
||||
subTypeAdd = "add"
|
||||
subTypeInvite = "invite"
|
||||
)
|
||||
|
||||
// groupJoinRequestText 是加群请求(sub_type=add)的固定摘要;未枚举的 sub_type 也用它,
|
||||
// 因为它仍然是「有人想进群」——request_type 已经认得出来,不必降级成 unknown。
|
||||
const groupJoinRequestText = "有人请求加入群聊"
|
||||
|
||||
// toEventMessage 把一条**非消息**上报包成入站 UnifiedMessage。Content 恒为空——平台
|
||||
// 原文字段绝不写进 Content(裸喂禁止);RawData 携带原始帧,供 NormalizeSystemNotice
|
||||
// 解析。群事件按群号建会话键;非群事件按对端(人)建私聊键;**没有任何会话概念**的
|
||||
// 事件(meta_event 心跳 / 生命周期)ChannelID 如实留空——它没有会话,绝不编一个出来。
|
||||
func (a *Adapter) toEventMessage(ev *obEvent, raw []byte) adapter.UnifiedMessage {
|
||||
channelType, channelID, groupID := a.eventChannel(ev)
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: platformName,
|
||||
ContentType: adapter.ContentAction,
|
||||
SenderID: noticeSenderID,
|
||||
SenderName: noticeSenderID,
|
||||
MessageID: formatID(ev.MessageID),
|
||||
RawData: raw,
|
||||
Timestamp: time.Unix(ev.Time, 0),
|
||||
ChannelType: channelType,
|
||||
ChannelID: channelID,
|
||||
GroupID: groupID,
|
||||
Sender: adapter.Actor{
|
||||
Role: adapter.RoleSystem,
|
||||
UserID: noticeSenderID,
|
||||
DisplayName: noticeSenderID,
|
||||
},
|
||||
}
|
||||
if groupID != "" {
|
||||
msg.GroupName = firstNonEmpty(ev.GroupName, a.groupName(groupID))
|
||||
msg.Sender.GroupID = groupID
|
||||
msg.Sender.IsGroup = true
|
||||
if msg.GroupName != "" {
|
||||
a.rememberGroup(groupID, msg.GroupName)
|
||||
}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
// eventChannel 解析一条非消息事件的会话键。群事件用群号;非群事件的**对端**是
|
||||
// user_id(加好友请求的申请人、撤回私聊消息的当事人、戳一戳的发起者…);唯一的例外是
|
||||
// message_sent——它是**她自己发出的**回声,user_id 一侧是发送者本人,对端在 target_id,
|
||||
// 拿 user_id 当会话键会凭空建出一个「她与自己」的私聊。
|
||||
//
|
||||
// 三样都缺(meta_event / 未知 post_type)时如实返回空 ChannelID:它没有会话。
|
||||
func (a *Adapter) eventChannel(ev *obEvent) (adapter.ChannelType, string, string) {
|
||||
if gid := formatID(ev.GroupID); gid != "" {
|
||||
return adapter.ChannelGroup, gid, gid
|
||||
}
|
||||
if ev.PostType == postTypeMessageSent {
|
||||
return adapter.ChannelDirect, firstNonEmpty(formatID(ev.TargetID), formatID(ev.UserID)), ""
|
||||
}
|
||||
return adapter.ChannelDirect, formatID(ev.UserID), ""
|
||||
}
|
||||
|
||||
// NormalizeSystemNotice 实现 adapter.SystemNoticeNormalizer(design/12 §6 策略一补充,
|
||||
// 口径见 design/26 §1.1):把一条非消息上报解析成统一结构。
|
||||
//
|
||||
// 返回 ok=false 的情形**只有两种**,都表示「这压根不是一条能读的系统事件」:载荷缺失,
|
||||
// 或它其实是**真消息**(post_type=message,走聊天路径,本就不该被归一化成通知)。
|
||||
// JSON 解析失败**不在此列**——解析失败也是一条事件(decode_error),照样留痕;
|
||||
// **类型未知更不在此列**——一律返回 ok=true 走 NoticeUnknown 兜底并原样保留
|
||||
// platform_type:「类型不认」与「什么都没发生」必须在数据里能分开(design/26 §3.7)。
|
||||
func (a *Adapter) NormalizeSystemNotice(msg adapter.UnifiedMessage) (adapter.SystemNotice, bool) {
|
||||
if len(msg.RawData) == 0 {
|
||||
return adapter.SystemNotice{}, false
|
||||
}
|
||||
var ev obEvent
|
||||
if err := json.Unmarshal(msg.RawData, &ev); err != nil {
|
||||
// 解析失败**也是一条事件**(decode_error):它照样要留痕、要计数,
|
||||
// 只是绝不进她的上下文(fail-safe)。
|
||||
return decodeErrorNotice(), true
|
||||
}
|
||||
return normalizeOneBotEvent(&ev)
|
||||
}
|
||||
|
||||
// normalizeOneBotEvent 是 post_type → 统一结构的**唯一分派**。
|
||||
//
|
||||
// ok=false 只留给**真消息**(post_type=message):它不是系统事件,走的是聊天路径,
|
||||
// 本就不该被归一化成通知。其余一切取值 —— 包括四个官方值之外、从未见过的
|
||||
// post_type —— 都必须归一化并投递(design/26 §3.1 / §3.7)。
|
||||
func normalizeOneBotEvent(ev *obEvent) (adapter.SystemNotice, bool) {
|
||||
switch ev.PostType {
|
||||
case postTypeNotice:
|
||||
return normalizeOneBotNotice(ev), true
|
||||
case postTypeRequest:
|
||||
return normalizeOneBotRequest(ev), true
|
||||
case postTypeMetaEvent:
|
||||
return normalizeOneBotMetaEvent(ev), true
|
||||
case postTypeMessageSent:
|
||||
return normalizeMessageSent(ev), true
|
||||
case postTypeMessage:
|
||||
return adapter.SystemNotice{}, false
|
||||
default:
|
||||
return unknownNotice(ev.PostType), true
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeOneBotRequest 归一化**请求类**事件(design/26 §3.1 的 request 行)。它整类
|
||||
// 此前是缺失的:加好友请求 / 加群请求被静默吞掉,主人既看不到也查不到。两条都是
|
||||
// 「她该知道的事」⇒ HighValue(升级真会话 + 由桥接层通知主人),
|
||||
// **但绝不自动同意**(design/26 §8-1 的保守默认);`flag` 根本没有被投影成字段,
|
||||
// 因此它不可能随这条链路流出去。
|
||||
func normalizeOneBotRequest(ev *obEvent) adapter.SystemNotice {
|
||||
switch ev.RequestType {
|
||||
case requestTypeFriend:
|
||||
n := newNotice(ev, requestTypeFriend, adapter.NoticeRequest, adapter.NoticeCategoryFriendRequest)
|
||||
n.SafeText, n.HighValue = "有人请求加她为好友", true
|
||||
return n
|
||||
case requestTypeGroup:
|
||||
n := newNotice(ev, requestTypeGroup, adapter.NoticeRequest, adapter.NoticeCategoryGroupRequest)
|
||||
n.HighValue = true
|
||||
switch ev.SubType {
|
||||
case subTypeInvite:
|
||||
// 邀请入群:她自己的成员身份被请求扩到新会话 ⇒ 高价值,措辞不同。
|
||||
n.SafeText, n.SelfRelated = "有人邀请她加入群聊", true
|
||||
case subTypeAdd:
|
||||
n.SafeText = groupJoinRequestText
|
||||
default:
|
||||
// 其余未枚举的 sub_type:request_type 已经认得出来,保守措辞即可,
|
||||
// 不必降级成 unknown(它仍然是「有人想进群」)。
|
||||
n.SafeText = groupJoinRequestText
|
||||
}
|
||||
return n
|
||||
default:
|
||||
return unknownNotice(ev.RequestType)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeOneBotMetaEvent 归一化**元事件**(design/26 §3.1 的 meta_event 行)。
|
||||
//
|
||||
// 两类都标可忽略(仅登记):心跳每 ~15s 一帧,把它升级真会话会让模型被遥测淹掉;
|
||||
// 「她自己账号的在线 / 离线」这条派生事实由桥接层按 SelfRelated + PlatformType/Category
|
||||
// 映射(design/26 §2.6),并由 probe.go 的 get_status 主动探活兜底——两者都与
|
||||
// 「每一帧心跳都进上下文」无关。
|
||||
//
|
||||
// SafeText 刻意留空:T1 遥测没有要对她说的一句话,而「有文本」会让登记路径去解析会话
|
||||
// 键并把遥测写进会话历史(心跳每 15s 一帧 = 库被写胖,design/26 §4.3 明确禁止)。
|
||||
// 留痕靠平台原生类型 + 桥接层的计数与留存。
|
||||
func normalizeOneBotMetaEvent(ev *obEvent) adapter.SystemNotice {
|
||||
switch ev.MetaEventType {
|
||||
case metaEventHeartbeat:
|
||||
n := newNotice(ev, metaEventHeartbeat, adapter.NoticeOther, adapter.NoticeCategoryHeartbeat)
|
||||
n.Ignorable, n.SelfRelated = true, ev.SelfID != 0
|
||||
return n
|
||||
case metaEventLifecycle:
|
||||
n := newNotice(ev, metaEventLifecycle, adapter.NoticeOther, adapter.NoticeCategoryLifecycle)
|
||||
n.Ignorable, n.SelfRelated = true, ev.SelfID != 0
|
||||
return n
|
||||
default:
|
||||
return unknownNotice(ev.MetaEventType)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeMessageSent 归一化 go-cqhttp 的 message_sent(她自己发出消息的回声)。
|
||||
// 它恒为可忽略(仅登记、计数),**绝不进上下文**——否则她刚发出的话会被当成对方的
|
||||
// 来信再答一遍(回声环,design/26 §3.1 的 message_sent 行)。
|
||||
func normalizeMessageSent(ev *obEvent) adapter.SystemNotice {
|
||||
n := newNotice(ev, postTypeMessageSent, adapter.NoticeOther, adapter.NoticeCategoryMessageSent)
|
||||
n.Ignorable, n.SelfRelated = true, true
|
||||
return n
|
||||
}
|
||||
|
||||
// unknownNotice 构造「类型未能枚举」的兜底通知(design/26 §3.1 末行 / §3.7):
|
||||
// 可忽略 ⇒ 仅登记,**绝不进她的上下文**(fail-safe,绝不猜、绝不放行),
|
||||
// 但**必须留痕**——平台原生类型名原样带在 PlatformType 里,一个字符都不改写。
|
||||
//
|
||||
// SafeText 留空:未知类型的措辞只能靠猜,而这类事件的固定摘要由桥接层按 PlatformType
|
||||
// 生成(design/26 §4.2 允许「适配器按事件类型生成的固定摘要」,但这里连类型名都读不准,
|
||||
// 编一句话反而是误导)。
|
||||
func unknownNotice(platformType string) adapter.SystemNotice {
|
||||
return fallbackNotice(adapter.NoticeCategoryUnknown, platformType)
|
||||
}
|
||||
|
||||
// decodeErrorNotice 是**载荷解不出**的兜底(design/26 §3.7):JSON 坏(非 UTF-8 /
|
||||
// 截断 / 根本不是对象)。它同样只登记、绝不放行,但也同样要留痕与计数——「解析失败」
|
||||
// 与「上游没推」不是一回事。
|
||||
func decodeErrorNotice() adapter.SystemNotice {
|
||||
return fallbackNotice(adapter.NoticeCategoryDecodeError, "")
|
||||
}
|
||||
|
||||
// fallbackNotice 是两类兜底的共同形态:种类恒为 NoticeUnknown、可忽略、无安全文本,
|
||||
// 只保留原样的平台类型码。
|
||||
func fallbackNotice(category adapter.NoticeCategory, platformType string) adapter.SystemNotice {
|
||||
return adapter.SystemNotice{
|
||||
Kind: adapter.NoticeUnknown,
|
||||
Category: category,
|
||||
PlatformType: platformType,
|
||||
Ignorable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// decodeErrorMessage 把一条**解不出**的载荷包成入站消息(design/26 §3.7):它不是
|
||||
// 「没有事件」,而是「有一条事件读不出来」。Content 恒空(读都读不出,更不可能有正文)、
|
||||
// 会话键如实留空(走 direct + 空 ChannelID:来源都拿不准,绝不编一个会话出来)、
|
||||
// RawData 带原始字节供桥接层确认它确实是坏的(归一化侧恒回 decode_error 兜底)。
|
||||
func decodeErrorMessage(payload []byte) adapter.UnifiedMessage {
|
||||
return adapter.UnifiedMessage{
|
||||
Platform: platformName,
|
||||
ContentType: adapter.ContentAction,
|
||||
SenderID: noticeSenderID,
|
||||
SenderName: noticeSenderID,
|
||||
RawData: payload,
|
||||
Timestamp: time.Now(),
|
||||
ChannelType: adapter.ChannelDirect,
|
||||
Sender: adapter.Actor{
|
||||
Role: adapter.RoleSystem,
|
||||
UserID: noticeSenderID,
|
||||
DisplayName: noticeSenderID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// isActionReceipt 报告这一帧是否是我们自己**主动 action 的回执**(空 post_type + echo,
|
||||
// 与 probe.go 的 routeActionResponse 同一判据)。它回答的是「我们刚问的对端答了什么」,
|
||||
// 不是上游推来的事件,因此不进事件流水线;**但它也不静默**——路由未命中时它照样落
|
||||
// inbound_debug.go 的脱敏结构日志。
|
||||
func isActionReceipt(ev *obEvent) bool {
|
||||
return ev.PostType == "" && ev.Echo != ""
|
||||
}
|
||||
|
||||
// logNormalizedEvent 打出本条非消息上报的**归一化结果**(design/26 §4.6:留存回答
|
||||
// 「历史上推过什么」,DEBUG 回答「此刻这帧长什么样」,两者口径必须一致)。
|
||||
//
|
||||
// 只记**结构**:种类 / 类别 / 平台原生类型 / 当事人 id / 是否涉及她自己 / 关联消息 id。
|
||||
// 绝不记 SafeText 之外的平台原文,更绝不记 flag / comment / token / 完整 URL ——
|
||||
// 本适配器连这些字段都没有投影(见 message.go 的 obEvent 字段约束)。
|
||||
//
|
||||
// 只在 DEBUG 打开时才真的解析一遍(先问 slog 的 Enabled),因此默认 INFO 下这条日志
|
||||
// 零开销、行为与改造前一致;要取证时把日志级别调到 debug 即可。
|
||||
func (a *Adapter) logNormalizedEvent(ctx context.Context, ev *obEvent) {
|
||||
if !a.log.Enabled(ctx, slog.LevelDebug) {
|
||||
return
|
||||
}
|
||||
notice, ok := normalizeOneBotEvent(ev)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
a.log.Debug("system event normalized",
|
||||
"post_type", ev.PostType,
|
||||
"platform_type", notice.PlatformType,
|
||||
"kind", string(notice.Kind),
|
||||
"category", string(notice.Category),
|
||||
"actor_id", notice.Actor.UserID,
|
||||
"target_id", notice.Target.UserID,
|
||||
"self_related", notice.SelfRelated,
|
||||
"ref_message_id", notice.RefMessage,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件覆盖 design/26 §3.1 / §3.7 的**全枚举投递**:post_type 的每一个取值
|
||||
// (notice / request / meta_event / message_sent / 从未枚举过的未知值)都必须走到
|
||||
// 归一化并留痕,**不许静默丢弃**;以及归一化产出的结构化字段(Category /
|
||||
// PlatformType / Actor / Target / SelfRelated)与脱敏口径。
|
||||
//
|
||||
// notice_type 一整张映射表在 notice_test.go,这里只管 post_type 这一层。
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// 四类上报各一条真实形状的报文(字段取自 OneBot v11 官方规范与 go-cqhttp 扩展)。
|
||||
const (
|
||||
friendRequestEvent = `{"post_type":"request","request_type":"friend","time":1700000100,"self_id":100,"user_id":200,"comment":"我是同事","flag":"FLAG-SENTINEL-1"}`
|
||||
groupAddRequest = `{"post_type":"request","request_type":"group","sub_type":"add","time":1700000101,"self_id":100,"user_id":200,"group_id":123,"comment":"拉我进群","flag":"FLAG-SENTINEL-2"}`
|
||||
groupInviteRequest = `{"post_type":"request","request_type":"group","sub_type":"invite","time":1700000102,"self_id":100,"user_id":555,"group_id":123}`
|
||||
heartbeatEvent = `{"post_type":"meta_event","meta_event_type":"heartbeat","interval":15000,"time":1700000103,"self_id":100,"status":{"online":true,"good":true}}`
|
||||
lifecycleEnable = `{"post_type":"meta_event","meta_event_type":"lifecycle","sub_type":"enable","time":1700000104,"self_id":100}`
|
||||
lifecycleDisable = `{"post_type":"meta_event","meta_event_type":"lifecycle","sub_type":"disable","time":1700000105,"self_id":100}`
|
||||
messageSentPrivate = `{"post_type":"message_sent","message_type":"private","sub_type":"friend","time":1700000106,"self_id":100,"message_id":77,"user_id":100,"target_id":200,"message":[{"type":"text","data":{"text":"她刚发出的回复"}}],"sender":{"user_id":100,"nickname":"bot"}}`
|
||||
// 既未枚举、也不可能被归一化的假类型:主人裁定的核心反例(design/26 §3.7)。
|
||||
unenumeratedPostType = "quantum_entanglement"
|
||||
unenumeratedEvent = `{"post_type":"` + unenumeratedPostType + `","time":1700000107,"self_id":100,"user_id":200,"group_id":123,"some_new_field":{"deep":true}}`
|
||||
)
|
||||
|
||||
// normalizeDelivered 走一遍完整入站路径并归一化投递出来的那一条。
|
||||
func normalizeDelivered(t *testing.T, raw string) (adapter.UnifiedMessage, adapter.SystemNotice) {
|
||||
t.Helper()
|
||||
delivered := dispatchNotices(t, raw)
|
||||
if len(delivered) != 1 {
|
||||
t.Fatalf("投递条数 = %d, want 1(上报被丢弃了)", len(delivered))
|
||||
}
|
||||
notice, ok := NewAdapter(Config{}).NormalizeSystemNotice(delivered[0])
|
||||
if !ok {
|
||||
t.Fatalf("投递出来的上报无法归一化:%+v", delivered[0])
|
||||
}
|
||||
return delivered[0], notice
|
||||
}
|
||||
|
||||
// TestUnenumeratedTypeIsNeverSwallowed 是**主人裁定的对照测试**(design/26 §1.1/§3.7):
|
||||
// 构造一个既未枚举、也没有任何映射分支的假 post_type,断言它绝不会被静默吞掉 ——
|
||||
// 它必须①被投递、②被归一化成显式的 Unknown 兜底、③原样带着自己的类型名留痕、
|
||||
// ④可忽略(绝不进她的上下文,fail-safe)、⑤在 DEBUG 日志里看得见。
|
||||
//
|
||||
// 变异验证:把 normalizeOneBotEvent 的 default 分支改回 `return ..., false`(改造前的
|
||||
// 静默丢弃),或让 handlePayload 对非 message 上报直接 return,本测试立刻打红。
|
||||
func TestUnenumeratedTypeIsNeverSwallowed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
buf, log := logCapture()
|
||||
a := NewAdapter(Config{Logger: log})
|
||||
var delivered []adapter.UnifiedMessage
|
||||
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) {
|
||||
delivered = append(delivered, m)
|
||||
})
|
||||
a.handlePayload(context.Background(), []byte(unenumeratedEvent))
|
||||
|
||||
if len(delivered) != 1 {
|
||||
t.Fatalf("未枚举类型的上报被静默吞掉:投递条数 = %d, want 1", len(delivered))
|
||||
}
|
||||
msg := delivered[0]
|
||||
if msg.ContentType != adapter.ContentAction || msg.Sender.Role != adapter.RoleSystem {
|
||||
t.Errorf("未枚举上报未带系统事件标记:%q/%q", msg.ContentType, msg.Sender.Role)
|
||||
}
|
||||
if msg.ChannelType != adapter.ChannelGroup || msg.ChannelID != "123" {
|
||||
t.Errorf("未枚举上报的会话键 = %q/%q, want group/123", msg.ChannelType, msg.ChannelID)
|
||||
}
|
||||
|
||||
notice, ok := a.NormalizeSystemNotice(msg)
|
||||
if !ok {
|
||||
t.Fatal("未枚举类型必须走 Unknown 兜底归一化,绝不是「读不出事件」")
|
||||
}
|
||||
if notice.Kind != adapter.NoticeUnknown || notice.Category != adapter.NoticeCategoryUnknown {
|
||||
t.Errorf("兜底结果 = %q/%q, want unknown/unknown", notice.Kind, notice.Category)
|
||||
}
|
||||
if notice.PlatformType != unenumeratedPostType {
|
||||
t.Errorf("PlatformType = %q, want %q(原生类型名必须原样保留)", notice.PlatformType, unenumeratedPostType)
|
||||
}
|
||||
if notice.Classify() != adapter.NoticeRegisterOnly {
|
||||
t.Errorf("兜底处置 = %v, want 仅登记(unknown 绝不进她的上下文)", notice.Classify())
|
||||
}
|
||||
if !strings.Contains(buf.String(), unenumeratedPostType) {
|
||||
t.Errorf("未枚举类型在日志里留不下痕:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRequestEventsAreRequestsNotChat 覆盖此前**整类缺失**的 post_type=request
|
||||
// (design/26 §3.1 的 request 行):加好友请求 / 加群请求 / 邀请入群都要投递、都要
|
||||
// 归一成 NoticeRequest 且高价值(她该知道 ⇐ 主人原话),且**绝不自动同意**。
|
||||
func TestRequestEventsAreRequestsNotChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantCategory adapter.NoticeCategory
|
||||
wantText string
|
||||
wantActor string
|
||||
wantChannel string
|
||||
wantGroup bool
|
||||
selfRelated bool
|
||||
}{
|
||||
{"加好友请求", friendRequestEvent, adapter.NoticeCategoryFriendRequest, "有人请求加她为好友", "200", "200", false, false},
|
||||
{"加群请求", groupAddRequest, adapter.NoticeCategoryGroupRequest, "有人请求加入群聊", "200", "123", true, false},
|
||||
{"邀请她入群", groupInviteRequest, adapter.NoticeCategoryGroupRequest, "有人邀请她加入群聊", "555", "123", true, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg, notice := normalizeDelivered(t, tc.raw)
|
||||
if notice.Kind != adapter.NoticeRequest || notice.Category != tc.wantCategory {
|
||||
t.Errorf("kind/category = %q/%q, want request/%q", notice.Kind, notice.Category, tc.wantCategory)
|
||||
}
|
||||
if notice.SafeText != tc.wantText {
|
||||
t.Errorf("SafeText = %q, want %q", notice.SafeText, tc.wantText)
|
||||
}
|
||||
if !notice.HighValue || notice.Classify() != adapter.NoticeEscalate {
|
||||
t.Errorf("请求类处置 = %v(highValue=%v),want 升级真会话", notice.Classify(), notice.HighValue)
|
||||
}
|
||||
if notice.SelfRelated != tc.selfRelated {
|
||||
t.Errorf("SelfRelated = %v, want %v", notice.SelfRelated, tc.selfRelated)
|
||||
}
|
||||
if notice.Actor.UserID != tc.wantActor {
|
||||
t.Errorf("Actor = %q, want %q", notice.Actor.UserID, tc.wantActor)
|
||||
}
|
||||
if msg.ChannelID != tc.wantChannel || (msg.ChannelType == adapter.ChannelGroup) != tc.wantGroup {
|
||||
t.Errorf("会话键 = %q/%q, want group=%v/%q", msg.ChannelType, msg.ChannelID, tc.wantGroup, tc.wantChannel)
|
||||
}
|
||||
// 请求的**可执行凭据**与验证信息一个字都没留下:本适配器根本没投影这两个字段。
|
||||
if strings.Contains(notice.SafeText, "FLAG-SENTINEL") || strings.Contains(msg.Content, "FLAG-SENTINEL") {
|
||||
t.Error("request.flag 泄漏进了结构化字段")
|
||||
}
|
||||
if strings.Contains(notice.SafeText, "同事") || strings.Contains(msg.Content, "同事") {
|
||||
t.Error("request.comment(验证信息)泄漏进了结构化字段")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownRequestTypeIsUnknownNotDropped 覆盖 request_type 未枚举的一支:
|
||||
// 同样不丢,只是降级成 Unknown 兜底。
|
||||
func TestUnknownRequestTypeIsUnknownNotDropped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, notice := normalizeDelivered(t, `{"post_type":"request","request_type":"telepathy","time":1700000108,"self_id":100,"user_id":200}`)
|
||||
if notice.Kind != adapter.NoticeUnknown || notice.PlatformType != "telepathy" {
|
||||
t.Fatalf("归一化结果 = %+v, want unknown/telepathy", notice)
|
||||
}
|
||||
if notice.Classify() != adapter.NoticeRegisterOnly {
|
||||
t.Error("未枚举 request_type 必须仅登记")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMetaEventDeliveryAndSilence 覆盖 post_type=meta_event(design/26 §3.1 的
|
||||
// meta_event 行):
|
||||
// - 心跳 / 生命周期都要投递、要归一化(此前整类被丢),且都**只登记不进上下文**
|
||||
// (拿每 15s 一帧的遥测去驱动模型回合是荒谬的);
|
||||
// - 它们**没有会话**:ChannelID 如实留空,绝不编一个会话出来;
|
||||
// - SafeText 刻意留空:遥测没有要对她说的一句话,留空才不会让登记路径把心跳写进
|
||||
// 会话历史(design/26 §4.3 明确反对逐帧写库);
|
||||
// - SelfRelated=true:链路事件天然是**她自己的**账号状态。
|
||||
func TestMetaEventDeliveryAndSilence(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantCategory adapter.NoticeCategory
|
||||
}{
|
||||
{"心跳", heartbeatEvent, adapter.NoticeCategoryHeartbeat},
|
||||
{"生命周期 enable", lifecycleEnable, adapter.NoticeCategoryLifecycle},
|
||||
{"生命周期 disable", lifecycleDisable, adapter.NoticeCategoryLifecycle},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
msg, notice := normalizeDelivered(t, tc.raw)
|
||||
if notice.Category != tc.wantCategory {
|
||||
t.Errorf("Category = %q, want %q", notice.Category, tc.wantCategory)
|
||||
}
|
||||
if notice.Classify() != adapter.NoticeRegisterOnly {
|
||||
t.Errorf("元事件处置 = %v, want 仅登记", notice.Classify())
|
||||
}
|
||||
if !notice.SelfRelated {
|
||||
t.Error("元事件必须标 SelfRelated(她自己的链路)")
|
||||
}
|
||||
if notice.SafeText != "" {
|
||||
t.Errorf("T1 遥测不该带安全文本(会把心跳写进会话历史):%q", notice.SafeText)
|
||||
}
|
||||
if msg.ChannelID != "" || msg.ChannelType != adapter.ChannelDirect {
|
||||
t.Errorf("无会话的元事件不该编出会话键:%q/%q", msg.ChannelType, msg.ChannelID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
_, unknown := normalizeDelivered(t, `{"post_type":"meta_event","meta_event_type":"teleport","time":1700000109,"self_id":100}`)
|
||||
if unknown.Kind != adapter.NoticeUnknown || unknown.PlatformType != "teleport" {
|
||||
t.Fatalf("未枚举 meta_event_type = %+v, want unknown/teleport", unknown)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMessageSentEchoUsesPeerChannel 覆盖 message_sent(她自己发出消息的回声,
|
||||
// design/26 §3.1):会话键必须是**对端**(target_id),不是她自己(user_id)——
|
||||
// 拿 user_id 当键会建出一个「她与自己」的私聊,把她刚发的话再答一遍。
|
||||
func TestMessageSentEchoUsesPeerChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg, notice := normalizeDelivered(t, messageSentPrivate)
|
||||
if msg.ChannelType != adapter.ChannelDirect || msg.ChannelID != "200" {
|
||||
t.Fatalf("回声会话键 = %q/%q, want direct/200(对端)", msg.ChannelType, msg.ChannelID)
|
||||
}
|
||||
if notice.Category != adapter.NoticeCategoryMessageSent {
|
||||
t.Errorf("Category = %q, want message_sent", notice.Category)
|
||||
}
|
||||
if notice.Classify() != adapter.NoticeRegisterOnly {
|
||||
t.Errorf("回声处置 = %v, want 仅登记(绝不进上下文)", notice.Classify())
|
||||
}
|
||||
if msg.Content != "" {
|
||||
t.Errorf("回声的平台原文进了正文:%q", msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventLogsAndStructuresLeakNoPlatformText 是脱敏口径(design/26 §4.2)的对照
|
||||
// 测试:一条**故意塞满敏感物**的事件走完入站路径后,
|
||||
// - 投放出来的消息 Content 恒空(平台原文绝不进正文);
|
||||
// - 归一化产出的结构化字段里没有正文 / 验证信息 / 凭据;
|
||||
// - 渲染后的**全部日志**(含脱敏结构日志与归一化结果日志)里一个都没有。
|
||||
//
|
||||
// 变异验证:把 logNormalizedEvent 改成多打一个原始载荷(如 "payload", string(raw)),
|
||||
// 本测试立刻打红。
|
||||
func TestEventLogsAndStructuresLeakNoPlatformText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
body = "BODY-SENTINEL-9137"
|
||||
flag = "FLAG-SENTINEL-9137"
|
||||
cdnURL = "http://cdn.invalid/x.png?sign=SIGN-SENTINEL-9137"
|
||||
token = "TOKEN-SENTINEL-9137"
|
||||
)
|
||||
raw := `{
|
||||
"post_type": "request",
|
||||
"request_type": "friend",
|
||||
"time": 1700000110,
|
||||
"self_id": 100,
|
||||
"user_id": 200,
|
||||
"comment": "` + body + `",
|
||||
"raw_message": "` + body + `",
|
||||
"flag": "` + flag + `",
|
||||
"access_token": "` + token + `",
|
||||
"file": {"url": "` + cdnURL + `"}
|
||||
}`
|
||||
|
||||
buf, log := logCapture()
|
||||
a := NewAdapter(Config{AccessToken: token, Logger: log})
|
||||
var delivered []adapter.UnifiedMessage
|
||||
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) {
|
||||
delivered = append(delivered, m)
|
||||
})
|
||||
a.handlePayload(context.Background(), []byte(raw))
|
||||
|
||||
if len(delivered) != 1 {
|
||||
t.Fatalf("投递条数 = %d, want 1", len(delivered))
|
||||
}
|
||||
if delivered[0].Content != "" {
|
||||
t.Errorf("平台原文进了 Content:%q", delivered[0].Content)
|
||||
}
|
||||
notice, ok := a.NormalizeSystemNotice(delivered[0])
|
||||
if !ok {
|
||||
t.Fatal("加好友请求无法归一化")
|
||||
}
|
||||
for name, value := range map[string]string{
|
||||
"SafeText": notice.SafeText, "PlatformType": notice.PlatformType, "Category": string(notice.Category),
|
||||
} {
|
||||
for _, forbidden := range []string{body, flag, token, "SIGN-SENTINEL", "http"} {
|
||||
if strings.Contains(value, forbidden) {
|
||||
t.Errorf("%s 泄漏了 %q:%q", name, forbidden, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
logged := buf.String()
|
||||
if logged == "" {
|
||||
t.Fatal("DEBUG 日志一条都没有:取证地基不存在")
|
||||
}
|
||||
for _, forbidden := range []string{body, flag, token, "SIGN-SENTINEL", "http"} {
|
||||
if strings.Contains(logged, forbidden) {
|
||||
t.Errorf("日志泄漏了 %q:\n%s", forbidden, logged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizedEventLogStaysAtDebugLevel 归一化结果日志与入站帧日志同级:默认 INFO
|
||||
// 下一条都不打(免得正常运行每帧两条噪声),要取证时把级别调到 debug。
|
||||
func TestNormalizedEventLogStaysAtDebugLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
a := NewAdapter(Config{Logger: slog.New(slog.NewTextHandler(buf, nil))})
|
||||
a.handlePayload(context.Background(), []byte(heartbeatEvent))
|
||||
|
||||
if strings.Contains(buf.String(), "system event normalized") {
|
||||
t.Errorf("默认级别下不该出现归一化结果日志:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHTTPReportDeliversNonMessageEventsEndToEnd 是**传输层 → 归一化的端到端**:走
|
||||
// http+server 这条真实上报路径(OneBot 把事件 POST 到 Cyrene 的监听端口,见
|
||||
// http_server.go),断言加好友请求 / 心跳 / 未知类型三类上报**都**到达消息处理器且都能
|
||||
// 归一化——而不是像改造前那样在 handlePayload 的 switch 里掉进虚空(既不投递、也不留痕)。
|
||||
func TestHTTPReportDeliversNonMessageEventsEndToEnd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
a := NewAdapter(Config{
|
||||
Transport: transportHTTP,
|
||||
Mode: modeServer,
|
||||
ListenAddr: "127.0.0.1:0",
|
||||
AccessToken: "secret-token",
|
||||
ReportPath: "/onebot/report",
|
||||
})
|
||||
got := make(chan adapter.UnifiedMessage, 4)
|
||||
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) { got <- m })
|
||||
|
||||
if err := a.Connect(context.Background()); err != nil {
|
||||
t.Fatalf("Connect: %v", err)
|
||||
}
|
||||
defer func() { _ = a.Disconnect(context.Background()) }()
|
||||
addr := a.Addr()
|
||||
if addr == "" {
|
||||
t.Fatal("Addr() 为空:http+server 端没起来")
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantCategory adapter.NoticeCategory
|
||||
}{
|
||||
{"加好友请求", friendRequestEvent, adapter.NoticeCategoryFriendRequest},
|
||||
{"心跳", heartbeatEvent, adapter.NoticeCategoryHeartbeat},
|
||||
{"未知类型", unenumeratedEvent, adapter.NoticeCategoryUnknown},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if res := postReport(t, "http://"+addr+"/onebot/report", "secret-token", tc.raw); res.status != http.StatusNoContent {
|
||||
t.Fatalf("上报状态 = %d (body %q), want 204", res.status, res.body)
|
||||
}
|
||||
select {
|
||||
case m := <-got:
|
||||
notice, ok := a.NormalizeSystemNotice(m)
|
||||
if !ok {
|
||||
t.Fatalf("%s 到达了处理器却无法归一化", tc.name)
|
||||
}
|
||||
if notice.Category != tc.wantCategory {
|
||||
t.Errorf("Category = %q, want %q", notice.Category, tc.wantCategory)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatalf("%s 上报从未到达消息处理器(非消息事件被吞)", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ package qq_obv11
|
||||
//
|
||||
// 三种形态的帧都在覆盖范围内,因为「没上报」和「上报了非消息事件」必须能区分:
|
||||
// - `post_type=message`:真消息(本次排查要找的就是它缺不缺席);
|
||||
// - `post_type=meta_event`:OneBot 心跳/生命周期,是「连接还活着」的正面证据;
|
||||
// - `post_type=notice` / `request` / `meta_event` / 未知类型:非消息上报(design/26
|
||||
// §3.1)——它们现在**一律投递**,这条日志随之覆盖各自的原生类型字段
|
||||
// (notice_type / request_type / meta_event_type);
|
||||
// - 空 `post_type`:OneBot 对我们自己 action 的回执(echo 响应)。**有等待者**的回执
|
||||
// 被 routeActionResponse 消费(如 get_status 探活),没有等待者的散落回执才落这里。
|
||||
//
|
||||
@@ -28,8 +30,12 @@ import (
|
||||
)
|
||||
|
||||
// logInboundFrame 为了排障打出本条入站帧的结构。字段刻意只用「有/无、哪一类、哪几个
|
||||
// id」这类元数据:一次调用足以判断上游是否上报、上报的是哪类事件、群里那句 @ 是否带了
|
||||
// at 段(以及 @ 的是谁)。
|
||||
// id」这类元数据:一次调用足以判断上游是否上报、上报的是哪类事件(含 request /
|
||||
// meta_event / 未知 post_type 的类型字段)、群里那句 @ 是否带了 at 段(以及 @ 的是谁)。
|
||||
//
|
||||
// 类型字段按 post_type 各有各的家(notice_type / request_type / meta_event_type),
|
||||
// 三个都照实打出:只打 post_type 的话,「notice 里到底是 poke 还是 group_ban」在日志里
|
||||
// 依然看不出来——那正是这次事故里要回答的问题。
|
||||
func (a *Adapter) logInboundFrame(ev *obEvent) {
|
||||
if ev == nil {
|
||||
return
|
||||
@@ -38,9 +44,14 @@ func (a *Adapter) logInboundFrame(ev *obEvent) {
|
||||
a.log.Debug("inbound frame",
|
||||
"post_type", ev.PostType,
|
||||
"message_type", ev.MessageType,
|
||||
"notice_type", ev.NoticeType,
|
||||
"request_type", ev.RequestType,
|
||||
"meta_event_type", ev.MetaEventType,
|
||||
"sub_type", ev.SubType,
|
||||
"self_id", formatID(ev.SelfID),
|
||||
"user_id", formatID(ev.UserID),
|
||||
"target_id", formatID(ev.TargetID),
|
||||
"operator_id", formatID(ev.OperatorID),
|
||||
"sender_id", senderIDOf(ev),
|
||||
"group_id", formatID(ev.GroupID),
|
||||
"segments", segments,
|
||||
|
||||
@@ -27,20 +27,46 @@ const (
|
||||
)
|
||||
|
||||
// OneBot v11 event as delivered over WebSocket.
|
||||
//
|
||||
// 字段集覆盖**全部四类** post_type(design/26 §3.1):message(message_type)、
|
||||
// notice(notice_type / sub_type)、request(request_type)、meta_event
|
||||
// (meta_event_type),外加 go-cqhttp 扩展的 message_sent。
|
||||
//
|
||||
// 刻意**不声明** `flag`(加好友 / 加群请求的可执行凭据)与 `comment`(验证信息):
|
||||
// 它们分别等同 token 与用户生成文本,design/26 §4.2 明令不得入库、不得进日志。
|
||||
// 不建字段,就没有任何一条路径能把它们带进结构化字段(要「自动同意」时再从内存载荷
|
||||
// 单次取用,绝不落库)。同理不声明 `file` / `sender.*` 里的头像等隐私字段,也不声明
|
||||
// `group_ban.duration`——它只说明时长,判定「全员禁言」用 `user_id == 0` 即足
|
||||
// (多投影一个用不到的数字,只会多一处可被误用的字段)。
|
||||
type obEvent struct {
|
||||
PostType string `json:"post_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
NoticeType string `json:"notice_type"`
|
||||
Time int64 `json:"time"`
|
||||
SelfID int64 `json:"self_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
SubType string `json:"sub_type"`
|
||||
RawMessage string `json:"raw_message"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
Sender *obSender `json:"sender"`
|
||||
PostType string `json:"post_type"`
|
||||
MessageType string `json:"message_type"`
|
||||
NoticeType string `json:"notice_type"`
|
||||
MetaEventType string `json:"meta_event_type"`
|
||||
RequestType string `json:"request_type"`
|
||||
Time int64 `json:"time"`
|
||||
SelfID int64 `json:"self_id"`
|
||||
MessageID int64 `json:"message_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
TargetID int64 `json:"target_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
GroupName string `json:"group_name"`
|
||||
SubType string `json:"sub_type"`
|
||||
OperatorID int64 `json:"operator_id"`
|
||||
Echo string `json:"echo"`
|
||||
RawMessage string `json:"raw_message"`
|
||||
Message json.RawMessage `json:"message"`
|
||||
Sender *obSender `json:"sender"`
|
||||
}
|
||||
|
||||
// hitsSelf 报告该事件是否命中**她自己**(design/26 §2.2 的「self 命中升档」判据):
|
||||
// 发起者 / 被作用者 / 操作者任一等于 self_id 即算命中。判据全是**结构化 id**,
|
||||
// 与正文措辞无关,也绝不参与判主。
|
||||
func (ev *obEvent) hitsSelf() bool {
|
||||
if ev == nil || ev.SelfID == 0 {
|
||||
return false
|
||||
}
|
||||
return ev.UserID == ev.SelfID || ev.TargetID == ev.SelfID || ev.OperatorID == ev.SelfID
|
||||
}
|
||||
|
||||
// obSender is the sender identity carried by a message event.
|
||||
|
||||
+227
-110
@@ -1,32 +1,27 @@
|
||||
package qq_obv11
|
||||
|
||||
// 本文件承载 OneBot v11 **notice**(系统通知)上报的归一化(design/12 §6 策略一补充):
|
||||
// 适配器把平台原生的 notice 事件包成带结构化标记的入站消息(ContentType=ContentAction,
|
||||
// 原文不进 Content),并实现 adapter.SystemNoticeNormalizer,把事件解析成统一结构
|
||||
// `{kind, safeText, ignorable, highValue}`,交给 channelbridge 的传入前审查决定
|
||||
// 「仅登记 / 限定注入 / 升级真会话」。
|
||||
// 本文件承载 OneBot v11 **notice**(系统通知)上报的归一化(design/12 §6 策略一补充、
|
||||
// design/26 §3.1):把平台原生的 notice 事件归纳成统一结构(种类 / 类别 / 平台原生
|
||||
// 类型 / 当事人 / 是否涉及她自己),交给 channelbridge 的传入前审查决定「仅登记 /
|
||||
// 限定注入 / 升级真会话」,其结构化字段另供桥接层留存(design/26 §4/§5)。
|
||||
//
|
||||
// 帧的**投递**(包成带结构化标记的 UnifiedMessage)与其余 post_type 的分派见 event.go;
|
||||
// 本文件只管 notice_type → 统一结构的映射,它是这条映射的**唯一出处**。
|
||||
//
|
||||
// 红线:判「是否系统 / 是否高价值」只看 **notice_type / sub_type / 操作者 id** 等
|
||||
// 结构化字段,绝不看正文措辞;判主永远走白名单(本文件的发送者恒为 RoleSystem)。
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// OneBot v11 的 post_type 取值(协议常量,避免魔法字符串)。
|
||||
const (
|
||||
postTypeMessage = "message"
|
||||
postTypeNotice = "notice"
|
||||
)
|
||||
|
||||
// OneBot v11 notice_type 取值(notice 上报的类型字段)。
|
||||
// OneBot v11 notice_type 取值(notice 上报的类型字段)。前 12 个是既有枚举,
|
||||
// group_ban 按 design/26 §3.1 补齐(此前整类缺失 ⇒ 被她禁言 / 她禁言别人从不留痕)。
|
||||
const (
|
||||
noticeGroupIncrease = "group_increase"
|
||||
noticeGroupDecrease = "group_decrease"
|
||||
noticeGroupAdmin = "group_admin"
|
||||
noticeGroupBan = "group_ban"
|
||||
noticeGroupRecall = "group_recall"
|
||||
noticeFriendAdd = "friend_add"
|
||||
noticeFriendRecall = "friend_recall"
|
||||
@@ -38,11 +33,21 @@ const (
|
||||
noticeClientStatus = "client_status"
|
||||
)
|
||||
|
||||
// notice 的 sub_type 取值(本文件只关心这三个)。
|
||||
// notice 的 sub_type 取值(既有的三个 + design/26 §3.1 补齐的禁言 / 群成员变动 /
|
||||
// notify 子类型)。title / honor / lucky_king 出自 go-cqhttp 扩展,本项目对接的正是
|
||||
// go-cqhttp 家族,必须一并枚举,否则又是一次静默丢弃。
|
||||
const (
|
||||
subTypeKickMe = "kick_me"
|
||||
subTypeUnset = "unset"
|
||||
subTypePoke = "poke"
|
||||
subTypeKickMe = "kick_me"
|
||||
subTypeUnset = "unset"
|
||||
subTypePoke = "poke"
|
||||
subTypeSet = "set"
|
||||
subTypeBan = "ban"
|
||||
subTypeLiftBan = "lift_ban"
|
||||
subTypeLeave = "leave"
|
||||
subTypeKick = "kick"
|
||||
subTypeHonor = "honor"
|
||||
subTypeTitle = "title"
|
||||
subTypeLuckyKing = "lucky_king"
|
||||
)
|
||||
|
||||
// noticeSenderID 是 notice 上报的合成发送者 id。它**必须**是一个不可能撞上主人
|
||||
@@ -50,106 +55,218 @@ const (
|
||||
// (项目红线;ResolveActor 只认 owner.yaml + 每渠道 adminIds[])。
|
||||
const noticeSenderID = "system"
|
||||
|
||||
// toNoticeMessage 把一条 notice 事件包成入站 UnifiedMessage。Content 恒为空——平台
|
||||
// 原文字段绝不写进 Content(裸喂禁止);RawData 携带原始帧,供
|
||||
// NormalizeSystemNotice 解析。群通知按群号建会话键,非群通知(加好友 / 撤回私聊
|
||||
// 消息)按对端用户建私聊键。
|
||||
func (a *Adapter) toNoticeMessage(ev *obEvent, raw []byte) adapter.UnifiedMessage {
|
||||
msg := adapter.UnifiedMessage{
|
||||
Platform: platformName,
|
||||
ContentType: adapter.ContentAction,
|
||||
SenderID: noticeSenderID,
|
||||
SenderName: noticeSenderID,
|
||||
MessageID: formatID(ev.MessageID),
|
||||
RawData: raw,
|
||||
Timestamp: time.Unix(ev.Time, 0),
|
||||
Sender: adapter.Actor{
|
||||
Role: adapter.RoleSystem,
|
||||
UserID: noticeSenderID,
|
||||
DisplayName: noticeSenderID,
|
||||
},
|
||||
}
|
||||
if gid := formatID(ev.GroupID); gid != "" {
|
||||
msg.ChannelType = adapter.ChannelGroup
|
||||
msg.ChannelID = gid
|
||||
msg.GroupID = gid
|
||||
msg.GroupName = firstNonEmpty(ev.GroupName, a.groupName(gid))
|
||||
msg.Sender.GroupID = gid
|
||||
msg.Sender.IsGroup = true
|
||||
if msg.GroupName != "" {
|
||||
a.rememberGroup(gid, msg.GroupName)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
// 非群通知:对端是 user_id(friend_add / friend_recall 的当事人)。
|
||||
msg.ChannelType = adapter.ChannelDirect
|
||||
msg.ChannelID = formatID(ev.UserID)
|
||||
return msg
|
||||
}
|
||||
|
||||
// NormalizeSystemNotice 实现 adapter.SystemNoticeNormalizer(design/12 §6 策略一补充),
|
||||
// 把 OneBot notice 事件解析成统一结构。ok=false 表示**无法归一化**(非 notice 上报、
|
||||
// 载荷解析失败、或未知 notice_type)——调用方据此走 fail-safe 静默,绝不放行看不懂
|
||||
// 的平台事件。
|
||||
func (a *Adapter) NormalizeSystemNotice(msg adapter.UnifiedMessage) (adapter.SystemNotice, bool) {
|
||||
if len(msg.RawData) == 0 {
|
||||
return adapter.SystemNotice{}, false
|
||||
}
|
||||
var ev obEvent
|
||||
if err := json.Unmarshal(msg.RawData, &ev); err != nil {
|
||||
return adapter.SystemNotice{}, false
|
||||
}
|
||||
if ev.PostType != postTypeNotice {
|
||||
return adapter.SystemNotice{}, false
|
||||
}
|
||||
return normalizeOneBotNotice(&ev)
|
||||
}
|
||||
|
||||
// normalizeOneBotNotice 是 notice_type → 统一结构的**唯一映射**。低价值小事件标
|
||||
// Ignorable;涉及她本人(self_id)的权限变更 / 被移出群聊标 HighValue;已识别但
|
||||
// 不在已知集合内的系统事件两个标志都不给(交给审查模型剔噪)。未知 notice_type 返回
|
||||
// ok=false(fail-safe 静默),绝不猜。
|
||||
func normalizeOneBotNotice(ev *obEvent) (adapter.SystemNotice, bool) {
|
||||
// normalizeOneBotNotice 是 notice_type → 统一结构的**唯一映射**(design/26 §3.1)。
|
||||
//
|
||||
// 低价值小事件标 Ignorable(只登记);涉及她本人(self_id)的权限变更 / 被移出群聊 /
|
||||
// 被禁言,以及**请求类**事件标 HighValue(升级真会话);已识别但结构上归不了类的
|
||||
// (如群文件上传)两个标志都不给,交给既有审查模型剔噪。**未知 notice_type 同样要有
|
||||
// 留痕**:走 NoticeUnknown 兜底(可忽略 ⇒ 绝不进她的上下文),platform_type 原样
|
||||
// 保留,绝不静默丢弃、也绝不猜(design/26 §1.1 / §3.7)。
|
||||
func normalizeOneBotNotice(ev *obEvent) adapter.SystemNotice {
|
||||
switch ev.NoticeType {
|
||||
case noticeGroupIncrease:
|
||||
return ignoreNotice(adapter.NoticeJoin, "有成员加入了群聊"), true
|
||||
n := newNotice(ev, noticeGroupIncrease, adapter.NoticeJoin, adapter.NoticeCategoryJoin)
|
||||
n.SafeText, n.Ignorable = "有成员加入了群聊", true
|
||||
return n
|
||||
case noticeGroupDecrease:
|
||||
if ev.SubType == subTypeKickMe {
|
||||
return adapter.SystemNotice{
|
||||
Kind: adapter.NoticeLeave,
|
||||
SafeText: "系统通知:她(机器人账号)被移出了群聊",
|
||||
HighValue: true,
|
||||
}, true
|
||||
// kick_me 是「她被移出群聊」的正面证据(设计 08/12 的敏感事件);有的实现把
|
||||
// 她被踢上报成 sub_type=kick + user_id=self_id,因此**两个判据都要认**。
|
||||
if ev.SubType == subTypeKickMe || ev.UserID == ev.SelfID {
|
||||
n := newNotice(ev, noticeGroupDecrease, adapter.NoticeLeave, adapter.NoticeCategoryLeave)
|
||||
n.SafeText, n.HighValue = "系统通知:她(机器人账号)被移出了群聊", true
|
||||
return n
|
||||
}
|
||||
return ignoreNotice(adapter.NoticeLeave, "有成员退出了群聊"), true
|
||||
case noticeFriendAdd:
|
||||
return ignoreNotice(adapter.NoticeJoin, "有人加她为好友"), true
|
||||
case noticeGroupRecall, noticeFriendRecall:
|
||||
return ignoreNotice(adapter.NoticeRecall, "有人撤回了一条消息"), true
|
||||
n := newNotice(ev, noticeGroupDecrease, adapter.NoticeLeave, adapter.NoticeCategoryLeave)
|
||||
n.SafeText, n.Ignorable = groupLeaveText(ev.SubType), true
|
||||
return n
|
||||
case noticeGroupAdmin:
|
||||
if ev.SubType == subTypeUnset && ev.UserID == ev.SelfID {
|
||||
return adapter.SystemNotice{
|
||||
Kind: adapter.NoticeOther,
|
||||
SafeText: "系统通知:她的群管理员身份被取消",
|
||||
HighValue: true,
|
||||
}, true
|
||||
}
|
||||
return ignoreNotice(adapter.NoticeOther, "群管理员身份发生变更"), true
|
||||
n := newNotice(ev, noticeGroupAdmin, adapter.NoticeOther, adapter.NoticeCategoryAdmin)
|
||||
n.SafeText = adminText(ev)
|
||||
n.HighValue = ev.UserID == ev.SelfID
|
||||
n.Ignorable = !n.HighValue
|
||||
return n
|
||||
case noticeGroupBan:
|
||||
n := newNotice(ev, noticeGroupBan, adapter.NoticeOther, banCategory(ev))
|
||||
n.SafeText = banText(ev)
|
||||
n.HighValue = ev.UserID == ev.SelfID || ev.OperatorID == ev.SelfID
|
||||
n.Ignorable = !n.HighValue
|
||||
return n
|
||||
case noticeFriendAdd:
|
||||
n := newNotice(ev, noticeFriendAdd, adapter.NoticeJoin, adapter.NoticeCategoryFriendAdd)
|
||||
n.SafeText, n.Ignorable = "有人加她为好友", true
|
||||
return n
|
||||
case noticeGroupRecall, noticeFriendRecall:
|
||||
n := newNotice(ev, ev.NoticeType, adapter.NoticeRecall, adapter.NoticeCategoryRecall)
|
||||
n.SafeText, n.Ignorable = "有人撤回了一条消息", true
|
||||
return n
|
||||
case noticeNotify:
|
||||
if ev.SubType == subTypePoke {
|
||||
return ignoreNotice(adapter.NoticeTyping, "群里有人戳了戳她"), true
|
||||
}
|
||||
return ignoreNotice(adapter.NoticeOther, "群内提醒(荣誉/红包等)"), true
|
||||
return normalizeNotifySubType(ev)
|
||||
case noticeGroupUpload:
|
||||
return adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: "群文件上传"}, true
|
||||
case noticeGroupCard, noticeEssence, noticeOfflineFile, noticeClientStatus:
|
||||
return adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件"}, true
|
||||
// T2「他人上传文件」:留痕 + 以 system 行告知;低价值,交给审查模型剔噪
|
||||
// (file.url 绝不入库,本适配器根本不解析 file 字段)。
|
||||
n := newNotice(ev, noticeGroupUpload, adapter.NoticeOther, adapter.NoticeCategoryUpload)
|
||||
n.SafeText = "群文件上传"
|
||||
return n
|
||||
case noticeOfflineFile:
|
||||
n := newNotice(ev, noticeOfflineFile, adapter.NoticeOther, adapter.NoticeCategoryOfflineFile)
|
||||
n.SafeText = "收到离线文件"
|
||||
return n
|
||||
case noticeGroupCard:
|
||||
n := newNotice(ev, noticeGroupCard, adapter.NoticeOther, adapter.NoticeCategoryCard)
|
||||
n.SafeText, n.Ignorable = "群名片变更", true
|
||||
return n
|
||||
case noticeEssence:
|
||||
n := newNotice(ev, noticeEssence, adapter.NoticeOther, adapter.NoticeCategoryEssence)
|
||||
n.SafeText, n.Ignorable = "精华消息变更", true
|
||||
return n
|
||||
case noticeClientStatus:
|
||||
// 他人客户端在线状态 = 存在性遥测 ⇒ T1,恒「仅登记」(design/26 §2.1/§2.3)。
|
||||
n := newNotice(ev, noticeClientStatus, adapter.NoticeOther, adapter.NoticeCategoryClientStatus)
|
||||
n.SafeText, n.Ignorable = "他人客户端在线状态变更", true
|
||||
return n
|
||||
default:
|
||||
return adapter.SystemNotice{}, false
|
||||
return unknownNotice(ev.NoticeType)
|
||||
}
|
||||
}
|
||||
|
||||
// ignoreNotice 构造一条结构化可忽略的通知。
|
||||
func ignoreNotice(kind adapter.NoticeKind, text string) adapter.SystemNotice {
|
||||
return adapter.SystemNotice{Kind: kind, SafeText: text, Ignorable: true}
|
||||
// normalizeNotifySubType 覆盖 `notice.notify` 一族(poke / lucky_king / honor / title)。
|
||||
// 四个子类型都是**定向动作或成员状态**,因此都必须能判「谁对谁做了什么」:
|
||||
// Actor 取 user_id、Target 取 target_id、SelfRelated 取是否命中 self_id
|
||||
// (design/26 §2.5 的 T3 统一形态)。
|
||||
func normalizeNotifySubType(ev *obEvent) adapter.SystemNotice {
|
||||
switch ev.SubType {
|
||||
case subTypePoke:
|
||||
n := newNotice(ev, noticeNotify, adapter.NoticeInteraction, adapter.NoticeCategoryPoke)
|
||||
// 戳一戳:默认**只登记、不自动回应**(design/26 §5.5)——回不回交给参与度引擎,
|
||||
// 绝不在这里写死回应动作。
|
||||
n.SafeText, n.Ignorable = pokeText(ev), true
|
||||
return n
|
||||
case subTypeLuckyKing:
|
||||
n := newNotice(ev, noticeNotify, adapter.NoticeInteraction, adapter.NoticeCategoryLuckyKing)
|
||||
n.SafeText, n.Ignorable = "红包运气王产生了", true
|
||||
return n
|
||||
case subTypeHonor:
|
||||
n := newNotice(ev, noticeNotify, adapter.NoticeOther, adapter.NoticeCategoryHonor)
|
||||
n.SafeText, n.Ignorable = "群成员荣誉发生变更", true
|
||||
return n
|
||||
case subTypeTitle:
|
||||
n := newNotice(ev, noticeNotify, adapter.NoticeOther, adapter.NoticeCategoryTitle)
|
||||
n.SafeText, n.Ignorable = "群成员头衔发生变更", true
|
||||
return n
|
||||
default:
|
||||
return unknownNotice(ev.NoticeType)
|
||||
}
|
||||
}
|
||||
|
||||
// adminText 给出群管理员变更的固定摘要:set(授予)/ unset(取消)分述,且**她本人**
|
||||
// 与他人分行——「她的管理员身份被取消」是设计 08/12 点名的敏感事件,措辞含糊会让它看
|
||||
// 不出严重性。判据只有 sub_type 与 user_id/self_id 两个结构化字段,绝不看正文;
|
||||
// 未枚举的 sub_type 只描述「发生变更」,不替平台补一个方向。
|
||||
func adminText(ev *obEvent) string {
|
||||
switch {
|
||||
case ev.UserID == ev.SelfID && ev.SubType == subTypeSet:
|
||||
return "系统通知:她被设为群管理员"
|
||||
case ev.UserID == ev.SelfID && ev.SubType == subTypeUnset:
|
||||
return "系统通知:她的群管理员身份被取消"
|
||||
case ev.UserID == ev.SelfID:
|
||||
return "系统通知:她的群管理员身份发生变更"
|
||||
case ev.SubType == subTypeSet:
|
||||
return "有群成员被设为管理员"
|
||||
default:
|
||||
return "群管理员身份发生变更"
|
||||
}
|
||||
}
|
||||
|
||||
// groupLeaveText 给出群成员减少的固定摘要:kick(被移出)与 leave(主动退群)分述,
|
||||
// 未枚举的 sub_type 只说「有人离开」这件事本身,不替平台补一个方向。
|
||||
func groupLeaveText(subType string) string {
|
||||
switch subType {
|
||||
case subTypeKick:
|
||||
return "有成员被移出了群聊"
|
||||
case subTypeLeave:
|
||||
return "有成员退出了群聊"
|
||||
default:
|
||||
return "有成员离开了群聊"
|
||||
}
|
||||
}
|
||||
|
||||
// pokeText 给出戳一戳的固定摘要:群聊与私聊、是否指向她自己,两两组合都分述。
|
||||
// 判据只有 group_id 与 target_id 两个**结构化**字段,绝不看正文。
|
||||
func pokeText(ev *obEvent) string {
|
||||
inGroup := formatID(ev.GroupID) != ""
|
||||
toSelf := ev.TargetID == ev.SelfID
|
||||
switch {
|
||||
case inGroup && toSelf:
|
||||
return "群里有人戳了戳她"
|
||||
case inGroup:
|
||||
return "群里有人戳了戳某人"
|
||||
case toSelf:
|
||||
return "有人戳了戳她"
|
||||
default:
|
||||
return "有人戳了戳某人"
|
||||
}
|
||||
}
|
||||
|
||||
// banCategory 区分「全员禁言」与「某成员禁言」:design/26 §3.1 的判据是
|
||||
// `user_id == 0 && duration == -1`——**没有具体的被禁言人**(user_id 为 0)才是整群
|
||||
// 开关,绝不能归一成「用户 0」。
|
||||
func banCategory(ev *obEvent) adapter.NoticeCategory {
|
||||
if ev.UserID == 0 {
|
||||
return adapter.NoticeCategoryBanAll
|
||||
}
|
||||
return adapter.NoticeCategoryBan
|
||||
}
|
||||
|
||||
// banText 给出禁言类事件的固定摘要(全员 / 她自己 / 她操作 / 其他成员)。开启还是解除
|
||||
// 只认 sub_type(ban / lift_ban)——它是结构化枚举,比时长可靠;未枚举的 sub_type 一律
|
||||
// 按**开启**这一侧取值:把一次禁言读成「解除」会让她以为已经没事,保守方向取更重的一侧。
|
||||
func banText(ev *obEvent) string {
|
||||
lifted := ev.SubType == subTypeLiftBan
|
||||
switch {
|
||||
case ev.UserID == 0 && ev.SubType == subTypeLiftBan:
|
||||
return "群解除了全员禁言"
|
||||
case ev.UserID == 0 && ev.SubType == subTypeBan:
|
||||
return "群开启了全员禁言"
|
||||
case ev.UserID == 0:
|
||||
return "群全员禁言状态发生变更"
|
||||
case ev.UserID == ev.SelfID && lifted:
|
||||
return "系统通知:她的禁言被解除"
|
||||
case ev.UserID == ev.SelfID:
|
||||
return "系统通知:她被禁言"
|
||||
case ev.OperatorID == ev.SelfID && lifted:
|
||||
return "系统通知:她解除了某位群成员的禁言"
|
||||
case ev.OperatorID == ev.SelfID:
|
||||
return "系统通知:她禁言了某位群成员"
|
||||
case lifted:
|
||||
return "某位群成员的禁言被解除"
|
||||
default:
|
||||
return "某位群成员被禁言"
|
||||
}
|
||||
}
|
||||
|
||||
// newNotice 组装一条归一化结果:平台原生类型码、当事人(发起者 / 被作用者)、
|
||||
// 是否命中她自己、关联消息 id 一律在这一处填充,各分支只填**差异项**——否则十几条
|
||||
// notice_type 都要把同一组结构化字段抄一遍(AGENTS §10:三次复制必须抽取)。
|
||||
func newNotice(ev *obEvent, platformType string, kind adapter.NoticeKind, category adapter.NoticeCategory) adapter.SystemNotice {
|
||||
return adapter.SystemNotice{
|
||||
Kind: kind,
|
||||
Category: category,
|
||||
PlatformType: platformType,
|
||||
Actor: partyActor(ev.UserID, ev.GroupID),
|
||||
Target: partyActor(ev.TargetID, ev.GroupID),
|
||||
SelfRelated: ev.hitsSelf(),
|
||||
RefMessage: formatID(ev.MessageID),
|
||||
}
|
||||
}
|
||||
|
||||
// partyActor 把一个结构化 id 包成事件的当事人。**只填 id 与原样保留的群上下文**,
|
||||
// 绝不填 Role:事件里的任何字段都不得用于判身份(项目红线),角色一律留给会话层的
|
||||
// 白名单 + 策略引擎裁决;id 缺失(0)时返回零值,绝不猜。
|
||||
func partyActor(userID, groupID int64) adapter.Actor {
|
||||
id := formatID(userID)
|
||||
if id == "" {
|
||||
return adapter.Actor{}
|
||||
}
|
||||
gid := formatID(groupID)
|
||||
return adapter.Actor{UserID: id, GroupID: gid, IsGroup: gid != ""}
|
||||
}
|
||||
@@ -23,6 +23,16 @@ const (
|
||||
groupUploadEvent = `{"post_type":"notice","notice_type":"group_upload","time":1700000007,"self_id":100,"user_id":42,"group_id":123,"file":{"name":"a.txt"}}`
|
||||
unknownNoticeEvent = `{"post_type":"notice","notice_type":"group_lucky_king_unknown","time":1700000008,"self_id":100,"user_id":42,"group_id":123}`
|
||||
friendAddEvent = `{"post_type":"notice","notice_type":"friend_add","time":1700000009,"self_id":100,"user_id":200}`
|
||||
// 禁言三态(design/26 §3.1 的 group_ban 行):她本人被禁言 / 她禁言别人 / 全员禁言。
|
||||
selfBannedEvent = `{"post_type":"notice","notice_type":"group_ban","sub_type":"ban","time":1700000010,"self_id":100,"user_id":100,"operator_id":42,"group_id":123,"duration":600}`
|
||||
selfBansEvent = `{"post_type":"notice","notice_type":"group_ban","sub_type":"ban","time":1700000011,"self_id":100,"user_id":42,"operator_id":100,"group_id":123,"duration":60}`
|
||||
allMutedEvent = `{"post_type":"notice","notice_type":"group_ban","sub_type":"ban","time":1700000012,"self_id":100,"user_id":0,"operator_id":42,"group_id":123,"duration":-1}`
|
||||
// go-cqhttp 扩展的 notify 子类型(本项目对接的正是这一族)。
|
||||
luckyKingEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"lucky_king","time":1700000013,"self_id":100,"user_id":42,"target_id":43,"group_id":123}`
|
||||
honorEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"honor","honor_type":"talkative","time":1700000014,"self_id":100,"user_id":42,"group_id":123}`
|
||||
titleEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"title","time":1700000015,"self_id":100,"user_id":42,"group_id":123}`
|
||||
// 未知 sub_type:notice_type 认得出来,但这一支没被枚举 ⇒ 兜底留痕(不是丢弃)。
|
||||
unknownNotifySubEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"quantum_entanglement","time":1700000016,"self_id":100,"user_id":42,"group_id":123}`
|
||||
)
|
||||
|
||||
// dispatchNotices 通过 handlePayload 走一遍适配器的入站路径,返回投递出来的消息。
|
||||
@@ -81,43 +91,173 @@ func TestNoticeFriendAddUsesDirectSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNonMessageFramesStillDropped 钉住既有边界:message_sent(回声)与
|
||||
// meta_event(心跳)仍不得进流水线 —— 只有 notice 是新增的入站面。
|
||||
func TestNonMessageFramesStillDropped(t *testing.T) {
|
||||
// TestNonMessageFramesAreDelivered 钉住主人 2026-10-05 的裁决:**消息之外的一切上报
|
||||
// 都必须投递,不许整类丢弃**(design/26 §1.1 / §3.1)。它们一律带
|
||||
// ContentType=ContentAction 的结构化标记(⇒ 桥接层按系统事件处置,绝不进聊天路径)、
|
||||
// 发送者恒为 RoleSystem(⇒ 判主不可能被冒充)、Content 恒空(⇒ 平台原文不进正文)。
|
||||
func TestNonMessageFramesAreDelivered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := dispatchNotices(t,
|
||||
`{"post_type":"message_sent","message_type":"private","time":1700000000,"self_id":100,"message_id":21,"user_id":100,"target_id":100,"message":[{"type":"text","data":{"text":"x"}}]}`,
|
||||
`{"post_type":"meta_event","meta_event_type":"heartbeat","time":1700000000,"self_id":100}`,
|
||||
)
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{"message_sent 回声(私聊)", `{"post_type":"message_sent","message_type":"private","time":1700000000,"self_id":100,"message_id":21,"user_id":100,"target_id":200,"message":[{"type":"text","data":{"text":"x"}}]}`},
|
||||
{"meta_event 心跳", `{"post_type":"meta_event","meta_event_type":"heartbeat","time":1700000000,"self_id":100,"status":{"online":true}}`},
|
||||
{"meta_event 生命周期", `{"post_type":"meta_event","meta_event_type":"lifecycle","sub_type":"enable","time":1700000000,"self_id":100}`},
|
||||
{"request 加好友", `{"post_type":"request","request_type":"friend","time":1700000000,"self_id":100,"user_id":200,"comment":"我是同事","flag":"FLAG-SENTINEL"}`},
|
||||
{"request 加群", `{"post_type":"request","request_type":"group","sub_type":"add","time":1700000000,"self_id":100,"user_id":200,"group_id":123,"comment":"拉我进去","flag":"FLAG-SENTINEL"}`},
|
||||
{"未知 post_type", `{"post_type":"quantum_entanglement","time":1700000000,"self_id":100,"user_id":200}`},
|
||||
{"解析失败(JSON 坏)", `{"post_type":`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := dispatchNotices(t, tc.raw)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("投递条数 = %d, want 1(非消息上报被丢弃了)", len(got))
|
||||
}
|
||||
msg := got[0]
|
||||
if msg.ContentType != adapter.ContentAction {
|
||||
t.Errorf("ContentType = %q, want %q", msg.ContentType, adapter.ContentAction)
|
||||
}
|
||||
if msg.Sender.Role != adapter.RoleSystem || msg.SenderID != noticeSenderID {
|
||||
t.Errorf("发送者 = %q/%q, want system", msg.Sender.Role, msg.SenderID)
|
||||
}
|
||||
if msg.Content != "" {
|
||||
t.Errorf("Content = %q, want 空(平台原文绝不进正文)", msg.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestActionReceiptIsNotAnEvent 钉住唯一的例外:**我们自己主动 action 的回执**
|
||||
// (空 post_type + echo)不是上游推来的事件,因此不进事件流水线;但它也不静默——
|
||||
// 它落在脱敏结构日志里(见 inbound_debug.go)。没有等待者的散落回执同理。
|
||||
func TestActionReceiptIsNotAnEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := dispatchNotices(t, `{"status":"ok","retcode":0,"data":{"online":true},"echo":"echo_1"}`)
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("非 message/notice 上报被投递:%+v", got)
|
||||
t.Fatalf("action 回执被当成事件投递:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPokeKeepsGroupAndDirectDistinguishable 覆盖主人点名的「戳一戳」:
|
||||
// 归一化后既能认出它(kind=interaction / category=poke),也判得出**群聊还是私聊**
|
||||
// (会话键 + actor 的群上下文),以及**是不是戳的她本人**(SelfRelated + Target)。
|
||||
func TestPokeKeepsGroupAndDirectDistinguishable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantGroup bool
|
||||
wantToSelf bool
|
||||
wantTarget string
|
||||
wantChannel string
|
||||
}{
|
||||
{
|
||||
name: "群聊戳她本人",
|
||||
raw: pokeEvent,
|
||||
wantGroup: true,
|
||||
wantToSelf: true,
|
||||
wantTarget: "100",
|
||||
wantChannel: "123",
|
||||
},
|
||||
{
|
||||
name: "私聊戳她本人",
|
||||
raw: `{"post_type":"notice","notice_type":"notify","sub_type":"poke","time":1700000020,"self_id":100,"user_id":42,"target_id":100}`,
|
||||
wantGroup: false,
|
||||
wantToSelf: true,
|
||||
wantTarget: "100",
|
||||
wantChannel: "42",
|
||||
},
|
||||
{
|
||||
name: "群里戳别人",
|
||||
raw: `{"post_type":"notice","notice_type":"notify","sub_type":"poke","time":1700000021,"self_id":100,"user_id":42,"target_id":43,"group_id":123}`,
|
||||
wantGroup: true,
|
||||
wantToSelf: false,
|
||||
wantTarget: "43",
|
||||
wantChannel: "123",
|
||||
},
|
||||
}
|
||||
|
||||
a := NewAdapter(Config{})
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
delivered := dispatchNotices(t, tc.raw)
|
||||
if len(delivered) != 1 {
|
||||
t.Fatalf("投递条数 = %d, want 1", len(delivered))
|
||||
}
|
||||
msg := delivered[0]
|
||||
if gotGroup := msg.ChannelType == adapter.ChannelGroup; gotGroup != tc.wantGroup {
|
||||
t.Errorf("群聊判定 = %v, want %v(channel=%q)", gotGroup, tc.wantGroup, msg.ChannelID)
|
||||
}
|
||||
if msg.ChannelID != tc.wantChannel {
|
||||
t.Errorf("会话键 = %q, want %q", msg.ChannelID, tc.wantChannel)
|
||||
}
|
||||
notice, ok := a.NormalizeSystemNotice(msg)
|
||||
if !ok {
|
||||
t.Fatal("戳一戳无法归一化")
|
||||
}
|
||||
if notice.Kind != adapter.NoticeInteraction || notice.Category != adapter.NoticeCategoryPoke {
|
||||
t.Errorf("kind/category = %q/%q, want interaction/poke", notice.Kind, notice.Category)
|
||||
}
|
||||
if notice.SelfRelated != tc.wantToSelf {
|
||||
t.Errorf("SelfRelated = %v, want %v", notice.SelfRelated, tc.wantToSelf)
|
||||
}
|
||||
if notice.Target.UserID != tc.wantTarget {
|
||||
t.Errorf("Target = %q, want %q", notice.Target.UserID, tc.wantTarget)
|
||||
}
|
||||
// 戳一戳默认**只登记、不自动回应**(design/26 §5.5)。
|
||||
if !notice.Ignorable || notice.Classify() != adapter.NoticeRegisterOnly {
|
||||
t.Errorf("戳一戳的处置 = %v(ignorable=%v),want 仅登记", notice.Classify(), notice.Ignorable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeOneBotNoticeMapping 是 notice_type/sub_type → 统一结构的映射钉。
|
||||
// wantCategory / wantPlatformType / wantSelfRelated 一并钉住 design/26 §2.4 新增的
|
||||
// 结构化字段——留存层(E1/E3)判 Tier/Category 时靠的正是它们,少一个都会让
|
||||
// 「这条事件该不该升档」变成猜。
|
||||
func TestNormalizeOneBotNoticeMapping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantOK bool
|
||||
wantKind adapter.NoticeKind
|
||||
ignorable bool
|
||||
highValue bool
|
||||
name string
|
||||
raw string
|
||||
wantOK bool
|
||||
wantKind adapter.NoticeKind
|
||||
wantCategory adapter.NoticeCategory
|
||||
wantPlatformType string
|
||||
ignorable bool
|
||||
highValue bool
|
||||
selfRelated bool
|
||||
}{
|
||||
{"进群 → 可忽略", groupIncreaseEvent, true, adapter.NoticeJoin, true, false},
|
||||
{"退群 → 可忽略", groupLeaveEvent, true, adapter.NoticeLeave, true, false},
|
||||
{"她本人被移出 → 高价值", groupKickMeEvent, true, adapter.NoticeLeave, false, true},
|
||||
{"她本人被取消管理员 → 高价值", selfDemotedEvent, true, adapter.NoticeOther, false, true},
|
||||
{"他人管理员变更 → 可忽略", otherAdminEvent, true, adapter.NoticeOther, true, false},
|
||||
{"撤回 → 可忽略", groupRecallEvent, true, adapter.NoticeRecall, true, false},
|
||||
{"戳一戳 → 可忽略", pokeEvent, true, adapter.NoticeTyping, true, false},
|
||||
{"群文件上传 → 两者皆非(交审查模型)", groupUploadEvent, true, adapter.NoticeOther, false, false},
|
||||
{"未知 notice_type → 不归一化(fail-safe)", unknownNoticeEvent, false, "", false, false},
|
||||
{"非 notice 上报 → 不归一化", `{"post_type":"message","message_type":"private"}`, false, "", false, false},
|
||||
{"畸形载荷 → 不归一化", `{`, false, "", false, false},
|
||||
{"进群 → 可忽略", groupIncreaseEvent, true, adapter.NoticeJoin, adapter.NoticeCategoryJoin, noticeGroupIncrease, true, false, false},
|
||||
{"退群 → 可忽略", groupLeaveEvent, true, adapter.NoticeLeave, adapter.NoticeCategoryLeave, noticeGroupDecrease, true, false, false},
|
||||
{"她本人被移出 → 高价值", groupKickMeEvent, true, adapter.NoticeLeave, adapter.NoticeCategoryLeave, noticeGroupDecrease, false, true, true},
|
||||
{"她本人管理员变更 → 高价值", selfDemotedEvent, true, adapter.NoticeOther, adapter.NoticeCategoryAdmin, noticeGroupAdmin, false, true, true},
|
||||
{"他人管理员变更 → 可忽略", otherAdminEvent, true, adapter.NoticeOther, adapter.NoticeCategoryAdmin, noticeGroupAdmin, true, false, false},
|
||||
{"撤回 → 可忽略", groupRecallEvent, true, adapter.NoticeRecall, adapter.NoticeCategoryRecall, noticeGroupRecall, true, false, false},
|
||||
{"戳一戳 → 定向交互(仅登记)", pokeEvent, true, adapter.NoticeInteraction, adapter.NoticeCategoryPoke, noticeNotify, true, false, true},
|
||||
{"运气王 → 定向交互(仅登记)", luckyKingEvent, true, adapter.NoticeInteraction, adapter.NoticeCategoryLuckyKing, noticeNotify, true, false, false},
|
||||
{"荣誉变更 → 可忽略", honorEvent, true, adapter.NoticeOther, adapter.NoticeCategoryHonor, noticeNotify, true, false, false},
|
||||
{"头衔变更 → 可忽略", titleEvent, true, adapter.NoticeOther, adapter.NoticeCategoryTitle, noticeNotify, true, false, false},
|
||||
{"她本人被禁言 → 高价值", selfBannedEvent, true, adapter.NoticeOther, adapter.NoticeCategoryBan, noticeGroupBan, false, true, true},
|
||||
{"她禁言别人 → 高价值", selfBansEvent, true, adapter.NoticeOther, adapter.NoticeCategoryBan, noticeGroupBan, false, true, true},
|
||||
{"全员禁言 → 独立类别(不是「用户 0」)", allMutedEvent, true, adapter.NoticeOther, adapter.NoticeCategoryBanAll, noticeGroupBan, true, false, false},
|
||||
{"群文件上传 → 两者皆非(交审查模型)", groupUploadEvent, true, adapter.NoticeOther, adapter.NoticeCategoryUpload, noticeGroupUpload, false, false, false},
|
||||
{"未知 notice_type → 兜底留痕(可忽略、不进上下文)", unknownNoticeEvent, true, adapter.NoticeUnknown, adapter.NoticeCategoryUnknown, "group_lucky_king_unknown", true, false, false},
|
||||
{"未知 notify sub_type → 兜底留痕", unknownNotifySubEvent, true, adapter.NoticeUnknown, adapter.NoticeCategoryUnknown, noticeNotify, true, false, false},
|
||||
{"解析失败的载荷 → 兜底留痕(decode_error)", `{"post_type":`, true, adapter.NoticeUnknown, adapter.NoticeCategoryDecodeError, "", true, false, false},
|
||||
{"非 notice 上报 → 不归一化(消息不是系统事件)", `{"post_type":"message","message_type":"private"}`, false, "", "", "", false, false, false},
|
||||
}
|
||||
a := NewAdapter(Config{})
|
||||
for _, tc := range cases {
|
||||
@@ -135,12 +275,33 @@ func TestNormalizeOneBotNoticeMapping(t *testing.T) {
|
||||
if notice.Kind != tc.wantKind {
|
||||
t.Errorf("Kind = %q, want %q", notice.Kind, tc.wantKind)
|
||||
}
|
||||
if notice.Category != tc.wantCategory {
|
||||
t.Errorf("Category = %q, want %q", notice.Category, tc.wantCategory)
|
||||
}
|
||||
if notice.PlatformType != tc.wantPlatformType {
|
||||
t.Errorf("PlatformType = %q, want %q(原生类型码必须原样保留)", notice.PlatformType, tc.wantPlatformType)
|
||||
}
|
||||
if notice.SelfRelated != tc.selfRelated {
|
||||
t.Errorf("SelfRelated = %v, want %v", notice.SelfRelated, tc.selfRelated)
|
||||
}
|
||||
if notice.Ignorable != tc.ignorable || notice.HighValue != tc.highValue {
|
||||
t.Errorf("标志 = {ignorable:%v highValue:%v}, want {%v %v}",
|
||||
notice.Ignorable, notice.HighValue, tc.ignorable, tc.highValue)
|
||||
}
|
||||
if notice.SafeText == "" {
|
||||
t.Error("安全文本为空")
|
||||
switch notice.Kind {
|
||||
case adapter.NoticeUnknown:
|
||||
// 兜底通知**刻意不编造文案**:类型名都读不准,编一句话只会误导,
|
||||
// 留痕靠 PlatformType + 桥接层的计数与留存(design/26 §3.7)。
|
||||
if notice.SafeText != "" {
|
||||
t.Errorf("兜底通知不该有安全文本:%q", notice.SafeText)
|
||||
}
|
||||
if !notice.Ignorable {
|
||||
t.Error("兜底通知必须可忽略(fail-safe:绝不进她的上下文)")
|
||||
}
|
||||
default:
|
||||
if notice.SafeText == "" {
|
||||
t.Error("安全文本为空")
|
||||
}
|
||||
}
|
||||
if notice.Ignorable && notice.HighValue {
|
||||
t.Error("两个标志不该同时为真")
|
||||
|
||||
@@ -123,11 +123,18 @@ func TestOwnerLaneRejectsAnonymousWhitelistEntry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSelfSentAndNonMessageFramesNotDispatched 覆盖第三种事件:自己发出的消息
|
||||
// (OneBot v11 的 post_type=message_sent)与任何非 message 上报都不得进流水线。
|
||||
// message_sent 的 user_id 是**对端**:一旦被当成入站,机器人会把自己刚发出的消息
|
||||
// 当对方的来信再回一遍(回声环),判主/会话键也会跟着乱。
|
||||
func TestSelfSentAndNonMessageFramesNotDispatched(t *testing.T) {
|
||||
// TestSelfSentAndNonMessageFramesDeliveredButNeverChat 覆盖主人 2026-10-05 的裁决与
|
||||
// 它**必须同时保住的那条安全性质**(design/26 §3.1 / §5.5):
|
||||
//
|
||||
// - 自己发出的消息回声(post_type=message_sent)、元事件与非消息上报现在**要投递**
|
||||
// (「绝不整类丢弃」);
|
||||
// - 但它们必须带着 ContentType=ContentAction 投递,并在归一化里被判为**可忽略**——
|
||||
// 于是桥接层的传入前审查把它们**只登记**,绝不进聊天路径。否则 message_sent 的
|
||||
// 对端会被当成来信再答一遍(回声环),判主/会话键也会跟着乱。
|
||||
//
|
||||
// 换句话说:防回声环的手段从「适配器丢弃」换成了「结构化标注 + 审查层仅登记」,
|
||||
// 这既保住了不回自己,又让「上游推过什么」留得下痕。
|
||||
func TestSelfSentAndNonMessageFramesDeliveredButNeverChat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
frames := []struct {
|
||||
@@ -150,15 +157,32 @@ func TestSelfSentAndNonMessageFramesNotDispatched(t *testing.T) {
|
||||
for _, f := range frames {
|
||||
f := f
|
||||
t.Run(f.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := NewAdapter(Config{})
|
||||
var delivered []adapter.UnifiedMessage
|
||||
// handlePayload → dispatch 是同步的,因此无需 sleep 即可断言「没投递」。
|
||||
// handlePayload → dispatch 是同步的,因此无需 sleep 即可断言。
|
||||
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) {
|
||||
delivered = append(delivered, m)
|
||||
})
|
||||
a.handlePayload(context.Background(), []byte(f.raw))
|
||||
if len(delivered) != 0 {
|
||||
t.Fatalf("%s 被投递进流水线: %+v", f.name, delivered)
|
||||
if len(delivered) != 1 {
|
||||
t.Fatalf("%s 投递条数 = %d, want 1(非消息上报不许被整类丢弃)", f.name, len(delivered))
|
||||
}
|
||||
msg := delivered[0]
|
||||
if msg.ContentType != adapter.ContentAction {
|
||||
t.Fatalf("%s 未带结构化系统事件标记:%q", f.name, msg.ContentType)
|
||||
}
|
||||
// 回声的会话键必须是**对端**:拿 user_id(她自己)当键会建出一个
|
||||
// 「她与自己」的私聊,把她刚发的话再答一遍。
|
||||
notice, ok := a.NormalizeSystemNotice(msg)
|
||||
if !ok {
|
||||
t.Fatalf("%s 无法归一化", f.name)
|
||||
}
|
||||
if !notice.Ignorable {
|
||||
t.Fatalf("%s 未判为可忽略(会进聊天路径 ⇒ 回声环):%+v", f.name, notice)
|
||||
}
|
||||
if notice.Kind != adapter.NoticeUnknown && notice.SafeText != "" && msg.Content != "" {
|
||||
t.Fatalf("%s 的平台原文进了正文:%q", f.name, msg.Content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -179,42 +179,51 @@ func (a *Adapter) readConn(ctx context.Context, conn *wsConn) {
|
||||
}
|
||||
|
||||
// handlePayload parses a text frame, converting message events into normalized
|
||||
// UnifiedMessage and dispatching them. notice(系统通知)上报同样被归一化后投递
|
||||
// ——它们带 ContentType=ContentAction 的结构化标记,由 channelbridge 的「传入前
|
||||
// 审查」决定是否 / 如何进入她的上下文(design/12 §6 策略一补充);其余非 message
|
||||
// 上报(message_sent 回声、meta_event 心跳等)一律丢弃。丢弃前一律留一条**脱敏结构
|
||||
// DEBUG**(logInboundFrame):上游到底有没有把这一帧推过来,是排障时最先要回答的问题,
|
||||
// 而「被丢弃」与「从未到达」在日志里必须能分开。唯一的例外是 action 回执(空 post_type
|
||||
// + echo):它交给等待中的主动 action(见 probe.go),不再走入站日志。
|
||||
// UnifiedMessage and dispatching them. **消息之外的一切上报同样被归一化后投递**
|
||||
// (design/26 §1.1 / §3.1:notice / request / meta_event / message_sent 与未知
|
||||
// post_type 一律不许静默丢弃)——它们带 ContentType=ContentAction 的结构化标记,
|
||||
// 由 channelbridge 的「传入前审查」决定是否 / 如何进入她的上下文,并由留存层落库计数
|
||||
// (design/26 §5)。丢弃前一律留一条**脱敏结构 DEBUG**(logInboundFrame),成功投递的
|
||||
// 另加一条归一化结果 DEBUG(logNormalizedEvent):上游到底有没有把这一帧推过来,
|
||||
// 是排障时最先要回答的问题,而「被丢弃」与「从未到达」在日志里必须能分开。
|
||||
//
|
||||
// 唯一的例外是**我们自己主动 action 的回执**(空 post_type + echo):它交给等待中的
|
||||
// 主动 action(见 probe.go),不是上游推来的事件,因此不进事件流水线(但它照样落
|
||||
// 脱敏结构日志)。载荷解析失败的帧**同样是事件**(decode_error 兜底),照样投递。
|
||||
func (a *Adapter) handlePayload(ctx context.Context, payload []byte) {
|
||||
// 先给 action 回执(空 post_type + echo)一个归宿:WS 上的 get_status 等主动
|
||||
// action 靠 echo 关联等待表(见 probe.go)。命中即由调用方消费,不再走入站日志;
|
||||
// 没有等待者的散落回执仍落回下面的脱敏结构 DEBUG。
|
||||
// action 靠 echo 关联等待表(见 probe.go)。命中即由调用方消费;没有等待者的散落
|
||||
// 回执仍落回下面的脱敏结构 DEBUG。
|
||||
if a.routeActionResponse(payload) {
|
||||
return
|
||||
}
|
||||
ev, err := parseEvent(payload)
|
||||
if err != nil {
|
||||
a.log.Debug("non-event payload ignored", "err", err)
|
||||
a.log.Debug("undecodable inbound frame",
|
||||
"err", err, "bytes", len(payload))
|
||||
a.dispatch(ctx, decodeErrorMessage(payload))
|
||||
return
|
||||
}
|
||||
a.logInboundFrame(ev)
|
||||
switch ev.PostType {
|
||||
case postTypeMessage:
|
||||
if isActionReceipt(ev) {
|
||||
return
|
||||
}
|
||||
if ev.PostType == postTypeMessage {
|
||||
msg := a.toUnified(ev, payload)
|
||||
if err := msg.Validate(); err != nil {
|
||||
a.log.Warn("invalid unified message dropped", "err", err)
|
||||
return
|
||||
}
|
||||
a.dispatch(ctx, msg)
|
||||
case postTypeNotice:
|
||||
msg := a.toNoticeMessage(ev, payload)
|
||||
if err := msg.Validate(); err != nil {
|
||||
a.log.Warn("invalid notice event dropped", "err", err)
|
||||
return
|
||||
}
|
||||
a.dispatch(ctx, msg)
|
||||
return
|
||||
}
|
||||
a.logNormalizedEvent(ctx, ev)
|
||||
msg := a.toEventMessage(ev, payload)
|
||||
if err := msg.Validate(); err != nil {
|
||||
a.log.Warn("invalid non-message event dropped", "post_type", ev.PostType, "err", err)
|
||||
return
|
||||
}
|
||||
a.dispatch(ctx, msg)
|
||||
}
|
||||
|
||||
// dispatch routes an inbound message to the registered push handler, falling
|
||||
|
||||
Reference in new issue
Block a user