diff --git a/internal/channel/adapter/systemnotice.go b/internal/channel/adapter/systemnotice.go index 0addcfd..bb13d0c 100644 --- a/internal/channel/adapter/systemnotice.go +++ b/internal/channel/adapter/systemnotice.go @@ -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) } diff --git a/plugins/channels/qq_obv11/event.go b/plugins/channels/qq_obv11/event.go new file mode 100644 index 0000000..4fde799 --- /dev/null +++ b/plugins/channels/qq_obv11/event.go @@ -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, + ) +} diff --git a/plugins/channels/qq_obv11/event_test.go b/plugins/channels/qq_obv11/event_test.go new file mode 100644 index 0000000..d7931ff --- /dev/null +++ b/plugins/channels/qq_obv11/event_test.go @@ -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) + } + }) + } +} diff --git a/plugins/channels/qq_obv11/inbound_debug.go b/plugins/channels/qq_obv11/inbound_debug.go index a8fe5d4..a0e909e 100644 --- a/plugins/channels/qq_obv11/inbound_debug.go +++ b/plugins/channels/qq_obv11/inbound_debug.go @@ -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, diff --git a/plugins/channels/qq_obv11/message.go b/plugins/channels/qq_obv11/message.go index 59ba4ce..10bbdff 100644 --- a/plugins/channels/qq_obv11/message.go +++ b/plugins/channels/qq_obv11/message.go @@ -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. diff --git a/plugins/channels/qq_obv11/notice.go b/plugins/channels/qq_obv11/notice.go index 126d169..1cd22b6 100644 --- a/plugins/channels/qq_obv11/notice.go +++ b/plugins/channels/qq_obv11/notice.go @@ -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 != ""} } diff --git a/plugins/channels/qq_obv11/notice_test.go b/plugins/channels/qq_obv11/notice_test.go index 47818db..8da7222 100644 --- a/plugins/channels/qq_obv11/notice_test.go +++ b/plugins/channels/qq_obv11/notice_test.go @@ -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("两个标志不该同时为真") diff --git a/plugins/channels/qq_obv11/ownerlane_test.go b/plugins/channels/qq_obv11/ownerlane_test.go index f16b382..03013ad 100644 --- a/plugins/channels/qq_obv11/ownerlane_test.go +++ b/plugins/channels/qq_obv11/ownerlane_test.go @@ -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) } }) } diff --git a/plugins/channels/qq_obv11/session.go b/plugins/channels/qq_obv11/session.go index deeda3c..3f9b447 100644 --- a/plugins/channels/qq_obv11/session.go +++ b/plugins/channels/qq_obv11/session.go @@ -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