feat(channels): 非消息事件接入(notice 归一化 + 传入前审查,fail-safe 静默)与入站脱敏帧日志/日志轮转
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- notice:adapter 归一化 seam + 结构化分类 + 净化(ANSI/控制/零宽/双向字符、200 runes 截断)
  + fail-safe 写死为「静默」(未装配审查/超时/失败 ⇒ 仅登记、不驱动 loop)
- 身份面零改动:notice 发送者恒 RoleSystem + SenderID=system
- 复用策略四的同一个审查模型(Options.Review),未另造第二套
- 入站帧脱敏 DEBUG(post_type/message_type/sub_type/self_id/user_id/sender_id/group_id/段类型/at 目标)
- ctrl.bat 启动前归档轮转(保留 20 份)+ bash 侧改追加(不再重启即截断)
This commit is contained in:
AskaEth committed 2026-10-05 11:55:05 +08:00
1 parent 85abb90067
commit 5139945bfe
15 files changed
+1604 -21

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
package qq_obv11
// 本文件承载**入站原始帧的脱敏结构日志**(DEBUG),用于回答一个此前无从取证的问题:
// 「上游 OneBot 到底有没有把这一帧上报过来?」
//
// 事故背景:QQ 群里群友 @ 了却没有回复,而 channelbridge 只对**已放行**的消息留痕
// (见 internal/services/channelbridge/inbound.go 的 "inbound → session"),上游没上报时
// 日志里一片空白,既不能证明「上报了但被过滤」也不能证明「压根没上报」。这条日志把
// 空白补上:每条成功解码的入站帧记一行**结构**信息——帧类型、会话/群/自身 id、
// 消息段**类型列表**与 at 段的**目标**。
//
// 三种形态的帧都在覆盖范围内,因为「没上报」和「上报了非消息事件」必须能区分:
// - `post_type=message`:真消息(本次排查要找的就是它缺不缺席);
// - `post_type=meta_event`:OneBot 心跳/生命周期,是「连接还活着」的正面证据;
// - 空 `post_type`:OneBot 对我们自己 action 的回执(echo 响应)。
//
// 脱敏口径(红线):**只记结构,不记内容**——绝不记 `raw_message` / 段里的 `text`、
// 绝不记 `access_token`、绝不记媒体段的完整 URL(连 `file` 引用也不记)。因此本文件
// 只从原始帧里**投影**出类型与 id,解析出的正文字节用完即弃。
//
// 级别固定 DEBUG:默认 INFO 下行为与改造前完全一致,需要取证时把日志级别调到 debug。
// 日志经 `a.log`(`source=plugins/channels/qq_obv11`)打出,满足「日志必须标来源」。
import (
"encoding/json"
"strings"
)
// logInboundFrame 为了排障打出本条入站帧的结构。字段刻意只用「有/无、哪一类、哪几个
// id」这类元数据:一次调用足以判断上游是否上报、上报的是哪类事件、群里那句 @ 是否带了
// at 段(以及 @ 的是谁)。
func (a *Adapter) logInboundFrame(ev *obEvent) {
if ev == nil {
return
}
segments, atTargets := frameSegmentShape(eventRawMessage(ev))
a.log.Debug("inbound frame",
"post_type", ev.PostType,
"message_type", ev.MessageType,
"sub_type", ev.SubType,
"self_id", formatID(ev.SelfID),
"user_id", formatID(ev.UserID),
"sender_id", senderIDOf(ev),
"group_id", formatID(ev.GroupID),
"segments", segments,
"at_targets", atTargets,
)
}
// eventRawMessage 取事件的原始消息体(数组段直接用 `message`,缺省回落 CQ 码字符串
// `raw_message`),与 parseMessage 同口径。
func eventRawMessage(ev *obEvent) []byte {
if ev == nil {
return nil
}
if len(ev.Message) > 0 {
return ev.Message
}
return []byte(ev.RawMessage)
}
// frameSegmentShape 从原始消息体里**只**解出段的类型列表与 at 段的目标 qq,正文与
// url/file 一律丢弃。两种报文形态都认(数组段与 CQ 码),形态认不出时返回空列表——
// 那是「结构没解出来」的诚实说法,绝不退回记原文。
func frameSegmentShape(raw []byte) (segments, atTargets []string) {
switch {
case len(raw) == 0:
return nil, nil
case raw[0] == '[':
var segs []obSegment
if err := json.Unmarshal(raw, &segs); err != nil {
return nil, nil
}
return arraySegmentShape(segs)
case raw[0] == '"':
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return nil, nil
}
return cqSegmentShape(s)
default:
return cqSegmentShape(string(raw))
}
}
// arraySegmentShape 投影数组段:类型进 segments,at 段的目标进 atTargets。
func arraySegmentShape(segs []obSegment) (segments, atTargets []string) {
for _, s := range segs {
segments = append(segments, s.Type)
if s.Type == atSegmentType {
if qq := segStr(s.Data, "qq"); qq != "" {
atTargets = append(atTargets, qq)
}
}
}
return segments, atTargets
}
// cqSegmentShape 扫 CQ 码,同样只取类型与 at 目标;码与码之间的裸文本(正文)直接跳过。
func cqSegmentShape(s string) (segments, atTargets []string) {
for i := 0; i < len(s); {
if !strings.HasPrefix(s[i:], cqPrefix) {
i++
continue
}
end := strings.IndexByte(s[i:], ']')
if end < 0 {
break
}
typ, params := decodeCQCode(s[i : i+end+1])
segments = append(segments, typ)
if typ == atSegmentType && params["qq"] != "" {
atTargets = append(atTargets, params["qq"])
}
i += end + 1
}
return segments, atTargets
}
@@ -0,0 +1,159 @@
package qq_obv11
// 入站帧脱敏日志(inbound_debug.go)的自证测试。断言分两类:
// - **记全了结构**:排障要看的那几个字段一个不少(否则日志等于白留);
// - **一个正文/凭据都没记**:正文、access_token、完整媒体 URL(含 raw_message 的
// CQ 码形态)都不得出现。
//
// 断言的落点是**渲染后的日志串**(TextHandler 真输出),不是属性表:「没往某个属性里
// 写」离「日志里没有」还差一步——正文只要混进任何一个属性,只有看渲染结果才抓得到。
import (
"bytes"
"context"
"log/slog"
"strings"
"testing"
)
// logCapture 返回渲染目标与一条把 DEBUG 也打出来的日志器(被测日志级别固定 DEBUG)。
func logCapture() (*bytes.Buffer, *slog.Logger) {
buf := &bytes.Buffer{}
return buf, slog.New(slog.NewTextHandler(buf, &slog.HandlerOptions{Level: slog.LevelDebug}))
}
// frameLines 摘出「入站帧」日志行(渲染后的一行行文本)。
func frameLines(buf *bytes.Buffer) []string {
var lines []string
for _, line := range strings.Split(buf.String(), "\n") {
if strings.Contains(line, `msg="inbound frame"`) {
lines = append(lines, line)
}
}
return lines
}
// TestInboundFrameLogIsStructuralOnly 用一条「群里 @ 了机器人」的真实形状事件走完整
// 入站路径:日志必须证明「上游上报了这一帧、帧里 @ 的是 10001」,而正文、access_token、
// 媒体 URL(含 raw_message 里的 CQ 码)一律不得落在日志里。
func TestInboundFrameLogIsStructuralOnly(t *testing.T) {
t.Parallel()
const (
body = "BODY-SENTINEL-9137"
token = "TOKEN-SENTINEL-9137"
path = "URL-PATH-SENTINEL-9137"
)
raw := []byte(`{
"post_type": "message",
"message_type": "group",
"sub_type": "normal",
"time": 1759600000,
"self_id": 10001,
"message_id": 7001,
"user_id": 20002,
"group_id": 1070898847,
"group_name": "GROUP-NAME-SENTINEL-9137",
"raw_message": "` + body + ` [CQ:at,qq=10001]",
"sender": {"user_id": 20002, "nickname": "NICK-SENTINEL-9137", "card": "", "role": "member"},
"message": [
{"type": "at", "data": {"qq": "10001"}},
{"type": "text", "data": {"text": "` + body + `"}},
{"type": "image", "data": {"url": "http://asset.invalid/` + path + `.png?token=` + token + `", "file": "` + path + `.png"}}
]
}`)
buf, log := logCapture()
a := NewAdapter(Config{AccessToken: token, Logger: log})
a.handlePayload(context.Background(), raw)
lines := frameLines(buf)
if len(lines) != 1 {
t.Fatalf("入站帧日志行数 = %d, want 1\n全量日志:\n%s", len(lines), buf.String())
}
line := lines[0]
for _, want := range []string{
"level=DEBUG",
"source=plugins/channels/qq_obv11",
"post_type=message",
"message_type=group",
"sub_type=normal",
"self_id=10001",
"user_id=20002",
"sender_id=20002",
"group_id=1070898847",
"segments=\"[at text image]\"",
"at_targets=[10001]",
} {
if !strings.Contains(line, want) {
t.Errorf("入站帧日志缺少结构字段 %q\n实际: %s", want, line)
}
}
for _, forbidden := range []string{body, token, path, "http", cqPrefix} {
if strings.Contains(line, forbidden) {
t.Errorf("入站帧日志泄露了 %q\n实际: %s", forbidden, line)
}
}
// 正文与凭据在任何一条日志里都不许出现(含附件内联等旁路日志)。
if all := buf.String(); strings.Contains(all, body) || strings.Contains(all, token) {
t.Errorf("正文或 access_token 出现在日志里:\n%s", all)
}
}
// TestInboundFrameLogStaysAtDebugLevel 这条日志必须留在 DEBUG:默认 INFO 下不得出现,
// 免得给正常运行加上每帧一条的噪声(要取证时把日志级别调到 debug)。
func TestInboundFrameLogStaysAtDebugLevel(t *testing.T) {
t.Parallel()
raw := []byte(`{"post_type":"message","message_type":"group","user_id":20002,"group_id":1070898847,
"raw_message":"hello","message":[{"type":"text","data":{"text":"hello"}}]}`)
buf := &bytes.Buffer{}
a := NewAdapter(Config{Logger: slog.New(slog.NewTextHandler(buf, nil))})
a.handlePayload(context.Background(), raw)
if strings.Contains(buf.String(), "inbound frame") {
t.Errorf("默认级别下不应出现入站帧日志:\n%s", buf.String())
}
}
// TestInboundFrameLogCoversCQAndNonMessageFrames 覆盖另外两种帧形状:CQ 码形态(只有
// raw_message,没有数组段)取到类型与 at 目标;meta_event 心跳同样留一行——「心跳一直在
// 打、却没有任何 post_type=message 的群帧」正是事故现场要区分的那种证据。
func TestInboundFrameLogCoversCQAndNonMessageFrames(t *testing.T) {
t.Parallel()
const body = "CQ-BODY-SENTINEL-9137"
cqFrame := []byte(`{"post_type":"message","message_type":"group","sub_type":"normal",
"self_id":10001,"user_id":30003,"group_id":1070898847,
"raw_message":"` + body + `[CQ:at,qq=10001] [CQ:image,file=abc.jpg]"}`)
heartbeat := []byte(`{"post_type":"meta_event","meta_event_type":"heartbeat","self_id":10001}`)
buf, log := logCapture()
a := NewAdapter(Config{Logger: log})
a.handlePayload(context.Background(), cqFrame)
a.handlePayload(context.Background(), heartbeat)
lines := frameLines(buf)
if len(lines) != 2 {
t.Fatalf("入站帧日志行数 = %d, want 2\n全量日志:\n%s", len(lines), buf.String())
}
for _, want := range []string{"segments=\"[at image]\"", "at_targets=[10001]", "user_id=30003"} {
if !strings.Contains(lines[0], want) {
t.Errorf("CQ 形态帧日志缺少 %q\n实际: %s", want, lines[0])
}
}
if strings.Contains(lines[0], body) {
t.Errorf("CQ 形态帧日志泄露正文\n实际: %s", lines[0])
}
for _, want := range []string{"post_type=meta_event", "self_id=10001", "segments=[]", "at_targets=[]"} {
if !strings.Contains(lines[1], want) {
t.Errorf("心跳帧日志缺少 %q\n实际: %s", want, lines[1])
}
}
}
+13 -8
View File
@@ -17,12 +17,20 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
const platformName = "qq-obv11"
const (
platformName = "qq-obv11"
// atSegmentType 是「@某人」在**数组段**里的段类型(`{"type":"at"}`),cqPrefix 则是
// **CQ 码**形态里每个码的起始(`[CQ:at,qq=...]`)。归一解析与入站帧脱敏日志
// (inbound_debug.go)共用这两个字面量,避免同一处协议知识各写一份。
atSegmentType = "at"
cqPrefix = "[CQ:"
)
// OneBot v11 event as delivered over WebSocket.
type obEvent struct {
PostType string `json:"post_type"`
MessageType string `json:"message_type"`
NoticeType string `json:"notice_type"`
Time int64 `json:"time"`
SelfID int64 `json:"self_id"`
MessageID int64 `json:"message_id"`
@@ -143,10 +151,7 @@ func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage {
// reports whether the message carries any real text (as opposed to only media /
// placeholder markers), which drives ContentType selection.
func (a *Adapter) parseMessage(ev *obEvent) (text string, media []adapter.Media, mentions []adapter.Mention, replyID, replyText string, hasText bool) {
raw := ev.Message
if len(raw) == 0 {
raw = []byte(ev.RawMessage)
}
raw := eventRawMessage(ev)
if len(raw) > 0 {
switch raw[0] {
case '[':
@@ -180,7 +185,7 @@ func parseSegmentArray(raw []byte) (string, []adapter.Media, []adapter.Mention,
if t != "" {
hasText = true
}
case "at":
case atSegmentType:
qq := segStr(s.Data, "qq")
if qq != "" {
mentions = append(mentions, adapter.Mention{UserID: qq})
@@ -230,7 +235,7 @@ func parseCQMessage(s string) (string, []adapter.Media, []adapter.Mention, strin
last := 0
for i := 0; i < len(s); {
if !strings.HasPrefix(s[i:], "[CQ:") {
if !strings.HasPrefix(s[i:], cqPrefix) {
i++
continue
}
@@ -246,7 +251,7 @@ func parseCQMessage(s string) (string, []adapter.Media, []adapter.Mention, strin
code := s[i : i+end+1]
typ, params := decodeCQCode(code)
switch typ {
case "at":
case atSegmentType:
if qq := params["qq"]; qq != "" {
mentions = append(mentions, adapter.Mention{UserID: qq})
b.WriteString("@" + qq)
+155
View File
@@ -0,0 +1,155 @@
package qq_obv11
// 本文件承载 OneBot v11 **notice**(系统通知)上报的归一化(design/12 §6 策略一补充):
// 适配器把平台原生的 notice 事件包成带结构化标记的入站消息(ContentType=ContentAction,
// 原文不进 Content),并实现 adapter.SystemNoticeNormalizer,把事件解析成统一结构
// `{kind, safeText, ignorable, highValue}`,交给 channelbridge 的传入前审查决定
// 「仅登记 / 限定注入 / 升级真会话」。
//
// 红线:判「是否系统 / 是否高价值」只看 **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 上报的类型字段)。
const (
noticeGroupIncrease = "group_increase"
noticeGroupDecrease = "group_decrease"
noticeGroupAdmin = "group_admin"
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 取值(本文件只关心这三个)。
const (
subTypeKickMe = "kick_me"
subTypeUnset = "unset"
subTypePoke = "poke"
)
// noticeSenderID 是 notice 上报的合成发送者 id。它**必须**是一个不可能撞上主人
// 白名单的字面量:系统通知不是人说的,绝不允许任何平台字段冒充主人
// (项目红线;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) {
switch ev.NoticeType {
case noticeGroupIncrease:
return ignoreNotice(adapter.NoticeJoin, "有成员加入了群聊"), true
case noticeGroupDecrease:
if ev.SubType == subTypeKickMe {
return adapter.SystemNotice{
Kind: adapter.NoticeLeave,
SafeText: "系统通知:她(机器人账号)被移出了群聊",
HighValue: true,
}, true
}
return ignoreNotice(adapter.NoticeLeave, "有成员退出了群聊"), true
case noticeFriendAdd:
return ignoreNotice(adapter.NoticeJoin, "有人加她为好友"), true
case noticeGroupRecall, noticeFriendRecall:
return ignoreNotice(adapter.NoticeRecall, "有人撤回了一条消息"), true
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
case noticeNotify:
if ev.SubType == subTypePoke {
return ignoreNotice(adapter.NoticeTyping, "群里有人戳了戳她"), true
}
return ignoreNotice(adapter.NoticeOther, "群内提醒(荣誉/红包等)"), true
case noticeGroupUpload:
return adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: "群文件上传"}, true
case noticeGroupCard, noticeEssence, noticeOfflineFile, noticeClientStatus:
return adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件"}, true
default:
return adapter.SystemNotice{}, false
}
}
// ignoreNotice 构造一条结构化可忽略的通知。
func ignoreNotice(kind adapter.NoticeKind, text string) adapter.SystemNotice {
return adapter.SystemNotice{Kind: kind, SafeText: text, Ignorable: true}
}
+180
View File
@@ -0,0 +1,180 @@
package qq_obv11
import (
"context"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// 本文件覆盖 OneBot v11 notice(系统通知)上报的归一化(design/12 §6 策略一补充):
// notice → 带结构化标记的入站消息(ContentType=ContentAction、发送者恒为 RoleSystem、
// Content 恒空),以及 notice_type/sub_type → 统一结构 {kind, safeText, ignorable,
// highValue} 的映射。判「是否系统 / 是否高价值」只看结构化字段,绝不看正文。
const (
groupIncreaseEvent = `{"post_type":"notice","notice_type":"group_increase","sub_type":"approve","time":1700000000,"self_id":100,"user_id":42,"operator_id":0,"group_id":123,"group_name":"Team"}`
groupKickMeEvent = `{"post_type":"notice","notice_type":"group_decrease","sub_type":"kick_me","time":1700000001,"self_id":100,"user_id":100,"operator_id":777,"group_id":123,"group_name":"Team"}`
groupLeaveEvent = `{"post_type":"notice","notice_type":"group_decrease","sub_type":"leave","time":1700000002,"self_id":100,"user_id":42,"group_id":123,"group_name":"Team"}`
selfDemotedEvent = `{"post_type":"notice","notice_type":"group_admin","sub_type":"unset","time":1700000003,"self_id":100,"user_id":100,"group_id":123,"group_name":"Team"}`
otherAdminEvent = `{"post_type":"notice","notice_type":"group_admin","sub_type":"set","time":1700000004,"self_id":100,"user_id":42,"group_id":123,"group_name":"Team"}`
groupRecallEvent = `{"post_type":"notice","notice_type":"group_recall","time":1700000005,"self_id":100,"user_id":42,"group_id":123,"operator_id":42,"message_id":99}`
pokeEvent = `{"post_type":"notice","notice_type":"notify","sub_type":"poke","time":1700000006,"self_id":100,"user_id":42,"target_id":100,"group_id":123}`
groupUploadEvent = `{"post_type":"notice","notice_type":"group_upload","time":1700000007,"self_id":100,"user_id":42,"group_id":123,"file":{"name":"a.txt"}}`
unknownNoticeEvent = `{"post_type":"notice","notice_type":"group_lucky_king_unknown","time":1700000008,"self_id":100,"user_id":42,"group_id":123}`
friendAddEvent = `{"post_type":"notice","notice_type":"friend_add","time":1700000009,"self_id":100,"user_id":200}`
)
// dispatchNotices 通过 handlePayload 走一遍适配器的入站路径,返回投递出来的消息。
func dispatchNotices(t *testing.T, raws ...string) []adapter.UnifiedMessage {
t.Helper()
a := NewAdapter(Config{})
var delivered []adapter.UnifiedMessage
a.SetMessageHandler(func(_ context.Context, m adapter.UnifiedMessage) {
delivered = append(delivered, m)
})
for _, raw := range raws {
a.handlePayload(context.Background(), []byte(raw))
}
return delivered
}
// TestNoticeDispatchedAsStructuredAction 钉住 notice 上报的入站形态:
// ContentType=ContentAction(结构化「系统事件」标记)、发送者恒 RoleSystem、
// Content 恒空(平台原文绝不进 Content —— 裸喂禁止)。
func TestNoticeDispatchedAsStructuredAction(t *testing.T) {
t.Parallel()
got := dispatchNotices(t, groupIncreaseEvent)
if len(got) != 1 {
t.Fatalf("投递条数 = %d, want 1", len(got))
}
msg := got[0]
if msg.ContentType != adapter.ContentAction {
t.Errorf("ContentType = %q, want %q", msg.ContentType, adapter.ContentAction)
}
if msg.Sender.Role != adapter.RoleSystem || msg.SenderID != noticeSenderID {
t.Errorf("发送者 = %q/%q, want system", msg.Sender.Role, msg.SenderID)
}
if msg.ChannelType != adapter.ChannelGroup || msg.ChannelID != "123" || msg.GroupID != "123" {
t.Errorf("会话键 = %q/%q/%q", msg.ChannelType, msg.ChannelID, msg.GroupID)
}
// 判主必须是不可能的:系统发送者绝不允许撞上主人白名单。
if msg.SenderID == "" || msg.SenderID == "42" {
t.Errorf("notice 的发送者 id 会冒充群成员:%q", msg.SenderID)
}
}
// TestNoticeFriendAddUsesDirectSession 覆盖非群通知按对端用户建私聊会话键。
func TestNoticeFriendAddUsesDirectSession(t *testing.T) {
t.Parallel()
got := dispatchNotices(t, friendAddEvent)
if len(got) != 1 {
t.Fatalf("投递条数 = %d, want 1", len(got))
}
if got[0].ChannelType != adapter.ChannelDirect || got[0].ChannelID != "200" {
t.Errorf("非群通知会话键 = %q/%q", got[0].ChannelType, got[0].ChannelID)
}
if got[0].GroupID != "" {
t.Errorf("非群通知不该带 group id:%q", got[0].GroupID)
}
}
// TestNonMessageFramesStillDropped 钉住既有边界:message_sent(回声)与
// meta_event(心跳)仍不得进流水线 —— 只有 notice 是新增的入站面。
func TestNonMessageFramesStillDropped(t *testing.T) {
t.Parallel()
got := dispatchNotices(t,
`{"post_type":"message_sent","message_type":"private","time":1700000000,"self_id":100,"message_id":21,"user_id":100,"target_id":100,"message":[{"type":"text","data":{"text":"x"}}]}`,
`{"post_type":"meta_event","meta_event_type":"heartbeat","time":1700000000,"self_id":100}`,
)
if len(got) != 0 {
t.Fatalf("非 message/notice 上报被投递:%+v", got)
}
}
// TestNormalizeOneBotNoticeMapping 是 notice_type/sub_type → 统一结构的映射钉。
func TestNormalizeOneBotNoticeMapping(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
wantOK bool
wantKind adapter.NoticeKind
ignorable bool
highValue bool
}{
{"进群 → 可忽略", groupIncreaseEvent, true, adapter.NoticeJoin, true, false},
{"退群 → 可忽略", groupLeaveEvent, true, adapter.NoticeLeave, true, false},
{"她本人被移出 → 高价值", groupKickMeEvent, true, adapter.NoticeLeave, false, true},
{"她本人被取消管理员 → 高价值", selfDemotedEvent, true, adapter.NoticeOther, false, true},
{"他人管理员变更 → 可忽略", otherAdminEvent, true, adapter.NoticeOther, true, false},
{"撤回 → 可忽略", groupRecallEvent, true, adapter.NoticeRecall, true, false},
{"戳一戳 → 可忽略", pokeEvent, true, adapter.NoticeTyping, true, false},
{"群文件上传 → 两者皆非(交审查模型)", groupUploadEvent, true, adapter.NoticeOther, false, false},
{"未知 notice_type → 不归一化(fail-safe)", unknownNoticeEvent, false, "", false, false},
{"非 notice 上报 → 不归一化", `{"post_type":"message","message_type":"private"}`, false, "", false, false},
{"畸形载荷 → 不归一化", `{`, false, "", false, false},
}
a := NewAdapter(Config{})
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
msg := adapter.UnifiedMessage{Platform: platformName, RawData: []byte(tc.raw)}
notice, ok := a.NormalizeSystemNotice(msg)
if ok != tc.wantOK {
t.Fatalf("ok = %v, want %v(notice=%+v)", ok, tc.wantOK, notice)
}
if !ok {
return
}
if notice.Kind != tc.wantKind {
t.Errorf("Kind = %q, want %q", notice.Kind, tc.wantKind)
}
if notice.Ignorable != tc.ignorable || notice.HighValue != tc.highValue {
t.Errorf("标志 = {ignorable:%v highValue:%v}, want {%v %v}",
notice.Ignorable, notice.HighValue, tc.ignorable, tc.highValue)
}
if notice.SafeText == "" {
t.Error("安全文本为空")
}
if notice.Ignorable && notice.HighValue {
t.Error("两个标志不该同时为真")
}
})
}
}
// TestNormalizeSystemNoticeRequiresRawPayload 钉住载荷缺失时绝不猜。
func TestNormalizeSystemNoticeRequiresRawPayload(t *testing.T) {
t.Parallel()
a := NewAdapter(Config{})
if _, ok := a.NormalizeSystemNotice(adapter.UnifiedMessage{Platform: platformName}); ok {
t.Fatal("无原始载荷时不该归一化成功")
}
}
// TestNoticeRoundTripThroughNormalizer 是生产端到端的一半:handlePayload 投递的
// 消息必须能被同一个适配器的归一化 seam 解析成统一结构(另一端 channelbridge 的
// 传入前审查会消费它)。
func TestNoticeRoundTripThroughNormalizer(t *testing.T) {
t.Parallel()
got := dispatchNotices(t, groupKickMeEvent)
if len(got) != 1 {
t.Fatalf("投递条数 = %d, want 1", len(got))
}
a := NewAdapter(Config{})
notice, ok := a.NormalizeSystemNotice(got[0])
if !ok {
t.Fatal("投递出来的 notice 无法归一化")
}
if !notice.HighValue || notice.Kind != adapter.NoticeLeave {
t.Fatalf("归一化结果 = %+v, want 高价值 leave", notice)
}
}
+22 -9
View File
@@ -179,22 +179,35 @@ func (a *Adapter) readConn(ctx context.Context, conn *wsConn) {
}
// handlePayload parses a text frame, converting message events into normalized
// UnifiedMessage and dispatching them.
// UnifiedMessage and dispatching them. notice(系统通知)上报同样被归一化后投递
// ——它们带 ContentType=ContentAction 的结构化标记,由 channelbridge 的「传入前
// 审查」决定是否 / 如何进入她的上下文(design/12 §6 策略一补充);其余非 message
// 上报(message_sent 回声、meta_event 心跳等)一律丢弃。丢弃前一律留一条**脱敏结构
// DEBUG**(logInboundFrame):上游到底有没有把这一帧推过来,是排障时最先要回答的问题,
// 而「被丢弃」与「从未到达」在日志里必须能分开。
func (a *Adapter) handlePayload(ctx context.Context, payload []byte) {
ev, err := parseEvent(payload)
if err != nil {
a.log.Debug("non-event payload ignored", "err", err)
return
}
if ev.PostType != "message" {
return
a.logInboundFrame(ev)
switch ev.PostType {
case postTypeMessage:
msg := a.toUnified(ev, payload)
if err := msg.Validate(); err != nil {
a.log.Warn("invalid unified message dropped", "err", err)
return
}
a.dispatch(ctx, msg)
case postTypeNotice:
msg := a.toNoticeMessage(ev, payload)
if err := msg.Validate(); err != nil {
a.log.Warn("invalid notice event dropped", "err", err)
return
}
a.dispatch(ctx, msg)
}
msg := a.toUnified(ev, payload)
if err := msg.Validate(); err != nil {
a.log.Warn("invalid unified message dropped", "err", err)
return
}
a.dispatch(ctx, msg)
}
// dispatch routes an inbound message to the registered push handler, falling