- 适配器不再整类丢弃: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 侧后续增量工作暂停
297 lines
14 KiB
Go
297 lines
14 KiB
Go
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,
|
||
)
|
||
}
|