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