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

+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,
)
}