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 侧后续增量工作暂停
This commit is contained in:
1 parent
ec3f6e810a
commit
ba17820040
9 files changed
+1315
-193
No files matched your search
+227
-110
@@ -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 != ""}
|
||||
}
|
||||
Reference in new issue
Block a user