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

273 lines
12 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 **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 (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// 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"
noticeGroupCard = "group_card"
noticeNotify = "notify"
noticeGroupUpload = "group_upload"
noticeEssence = "essence"
noticeOfflineFile = "offline_file"
noticeClientStatus = "client_status"
)
// notice 的 sub_type 取值(既有的三个 + design/26 §3.1 补齐的禁言 / 群成员变动 /
// notify 子类型)。title / honor / lucky_king 出自 go-cqhttp 扩展,本项目对接的正是
// go-cqhttp 家族,必须一并枚举,否则又是一次静默丢弃。
const (
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。它**必须**是一个不可能撞上主人
// 白名单的字面量:系统通知不是人说的,绝不允许任何平台字段冒充主人
// (项目红线;ResolveActor 只认 owner.yaml + 每渠道 adminIds[])。
const noticeSenderID = "system"
// 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:
n := newNotice(ev, noticeGroupIncrease, adapter.NoticeJoin, adapter.NoticeCategoryJoin)
n.SafeText, n.Ignorable = "有成员加入了群聊", true
return n
case noticeGroupDecrease:
// 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
}
n := newNotice(ev, noticeGroupDecrease, adapter.NoticeLeave, adapter.NoticeCategoryLeave)
n.SafeText, n.Ignorable = groupLeaveText(ev.SubType), true
return n
case noticeGroupAdmin:
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:
return normalizeNotifySubType(ev)
case noticeGroupUpload:
// 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 unknownNotice(ev.NoticeType)
}
}
// 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 != ""}
}