feat(channels): QQ 非消息事件全枚举投递 + systemnotice 补 request/interaction/unknown 类
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 适配器不再整类丢弃: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:
AskaEth committed 2026-10-05 13:47:27 +08:00
1 parent ec3f6e810a
commit ba17820040
9 files changed
+1315 -193

No files matched your search

+118 -13
View File
@@ -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)
}
+296
View File
@@ -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,
)
}
+373
View File
@@ -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)
}
})
}
}
+14 -3
View File
@@ -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,
+39 -13
View File
@@ -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
View File
@@ -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 != ""}
}
+188 -27
View File
@@ -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("两个标志不该同时为真")
+32 -8
View File
@@ -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)
}
})
}
+28 -19
View File
@@ -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