Files
AskaEth ba17820040
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): QQ 非消息事件全枚举投递 + systemnotice 补 request/interaction/unknown 类
- 适配器不再整类丢弃:notice/request/meta_event/message_sent/未知 post_type/JSON 解析失败( decode_error ) 一律归一化投递并留痕
- notice 映射补 group_ban/sub_type 全枚举,未知类型走 NoticeUnknown 兜底(fail-safe 只登记、绝不猜、绝不放行)
- 不投影 flag/comment/file/duration;归一化 DEBUG 只记结构(脱敏,变异实测可打红)
- 传输层端到端已覆盖(真实 http+server 上报路径:加好友请求/心跳/未知类型)
- ⚠️ 主人 2026-10-05 决定「先不连 QQ」(风控)⇒ 本片为通用红线(绝不静默丢弃)+ 现成证据链,QQ 侧后续增量工作暂停
2026-10-05 13:47:27 +08:00

297 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)
}