From 5139945bfec771e215dd661c5d80a36508a66c6f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 5 Oct 2026 11:55:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E9=9D=9E=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=8E=A5=E5=85=A5=EF=BC=88notice=20=E5=BD=92?= =?UTF-8?q?=E4=B8=80=E5=8C=96=20+=20=E4=BC=A0=E5=85=A5=E5=89=8D=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=EF=BC=8Cfail-safe=20=E9=9D=99=E9=BB=98=EF=BC=89?= =?UTF-8?q?=E4=B8=8E=E5=85=A5=E7=AB=99=E8=84=B1=E6=95=8F=E5=B8=A7=E6=97=A5?= =?UTF-8?q?=E5=BF=97/=E6=97=A5=E5=BF=97=E8=BD=AE=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 侧改追加(不再重启即截断) --- internal/channel/adapter/systemnotice.go | 208 ++++++++++ internal/channel/adapter/systemnotice_test.go | 102 +++++ .../services/channelbridge/channelbridge.go | 4 + internal/services/channelbridge/inbound.go | 22 +- internal/services/channelbridge/notice.go | 201 +++++++++ .../services/channelbridge/notice_test.go | 380 ++++++++++++++++++ plugins/channels/qq_obv11/inbound_debug.go | 118 ++++++ .../channels/qq_obv11/inbound_debug_test.go | 159 ++++++++ plugins/channels/qq_obv11/message.go | 21 +- plugins/channels/qq_obv11/notice.go | 155 +++++++ plugins/channels/qq_obv11/notice_test.go | 180 +++++++++ plugins/channels/qq_obv11/session.go | 31 +- scripts/ctrl.bat | 34 ++ scripts/ctrl.sh | 8 +- scripts/dev-up.sh | 2 +- 15 files changed, 1604 insertions(+), 21 deletions(-) create mode 100644 internal/channel/adapter/systemnotice.go create mode 100644 internal/channel/adapter/systemnotice_test.go create mode 100644 internal/services/channelbridge/notice.go create mode 100644 internal/services/channelbridge/notice_test.go create mode 100644 plugins/channels/qq_obv11/inbound_debug.go create mode 100644 plugins/channels/qq_obv11/inbound_debug_test.go create mode 100644 plugins/channels/qq_obv11/notice.go create mode 100644 plugins/channels/qq_obv11/notice_test.go diff --git a/internal/channel/adapter/systemnotice.go b/internal/channel/adapter/systemnotice.go new file mode 100644 index 0000000..0addcfd --- /dev/null +++ b/internal/channel/adapter/systemnotice.go @@ -0,0 +1,208 @@ +package adapter + +import ( + "strings" + "unicode" +) + +// 本文件承载「系统通知传入前审查」的**归一化契约**(design/12 §6 策略一补充): +// 各适配器把平台原生的系统通知 / 系统账号通知 / 群内系统提醒解析成**统一结构** +// SystemNotice{Kind, SafeText, Ignorable, HighValue},交给 channelbridge 的传入前 +// 审查决定「仅登记 / 限定注入 / 升级真会话」。 +// +// 红线(design/12 §6 策略一补充):判「是否系统 / 是否高价值」只看**结构化元数据** +// (通知种类、事件类型、操作者 id),绝不看提示词、绝不看正文措辞;本文件不含任何 +// LLM 逻辑,也不做身份判定(身份永远来自 actor 元数据 + 白名单)。 + +// NoticeKind 是归一化后的系统通知种类(design/12 §6 策略一补充给定的 kind 集合, +// 末尾的 other 是「已识别为系统通知、但结构上不在已知集合内」的兜底值)。 +type NoticeKind string + +const ( + // NoticeJoin 是加入类通知(进群 / 加好友 / 入频道)。 + NoticeJoin NoticeKind = "join" + // NoticeLeave 是离开类通知(退群 / 被移出 / 删好友)。 + NoticeLeave NoticeKind = "leave" + // NoticeRecall 是撤回类通知(消息被撤回)。 + NoticeRecall NoticeKind = "recall" + // NoticeTyping 是轻提示类通知(正在输入 / 戳一戳 / 群内提醒)——最典型的小事件。 + NoticeTyping NoticeKind = "typing" + // NoticeBotAdd 是机器人自身被加入/被移出某个会话的通知。 + NoticeBotAdd NoticeKind = "bot_add" + // NoticeOther 是已识别但不在已知集合内的系统通知。 + NoticeOther NoticeKind = "other" +) + +// Valid 报告 k 是否是已知的通知种类。 +func (k NoticeKind) Valid() bool { + switch k { + case NoticeJoin, NoticeLeave, NoticeRecall, NoticeTyping, NoticeBotAdd, NoticeOther: + return true + default: + return false + } +} + +// NoticeClass 是传入前审查对归一化结构的**结构分类**结果(design/12 §6 策略一补充的 +// 三段式分级的类型化表达)。 +type NoticeClass int + +const ( + // NoticeRegisterOnly:可忽略 → 仅登记,不喂 LLM,绝不驱动 loop。 + NoticeRegisterOnly NoticeClass = iota + // NoticeLimitedInject:其余 → 提取安全文本、剥离异常字段后**限定注入**上下文。 + NoticeLimitedInject + // NoticeEscalate:高价值(如「XX 提到你」/ 敏感操作预警)→ **升级真会话**。 + NoticeEscalate +) + +// SystemNotice 是一条系统通知归一化后的统一结构(design/12 §6 策略一补充)。 +// +// Ignorable / HighValue 由**适配器**依结构化事件类型(notice_type / sub_type / +// 操作者 id 等)填充。两者皆为 false 表示「已识别为系统通知,但结构上无法归类」—— +// 它交给传入前审查用既有审查模型剔噪,而不是由模型判身份。 +type SystemNotice struct { + // Kind 是归一化后的通知种类。 + Kind NoticeKind + // SafeText 是剥离异常字段后的安全文本;它可以为空(此时绝不注入)。 + SafeText string + // Ignorable 报告该通知在结构上可忽略(进群/退群/撤回/戳一戳等小事件)。 + Ignorable bool + // HighValue 报告该通知在结构上是高价值(敏感操作预警 / 她本人被移出等)。 + HighValue bool +} + +// Classify 是结构分类的**单一事实来源**(design/12 §6 策略一补充): +// 可忽略 → 仅登记;高价值 → 升级真会话;其余 → 限定注入。 +// 两个标志同时为真时按**保守侧**取「仅登记」(不打扰为第一原则)。 +func (n SystemNotice) Classify() NoticeClass { + switch { + case n.Ignorable: + return NoticeRegisterOnly + case n.HighValue: + return NoticeEscalate + default: + return NoticeLimitedInject + } +} + +// SystemNoticeNormalizer 是**每个适配器**可选实现的归一化 seam(design/12 §6 策略一 +// 补充)。channelbridge 收到一条被结构化标记为系统事件的消息(ContentType 为 +// ContentAction)时调用它,把平台原生载荷解析成统一结构。 +// +// ok=false 表示**无法归一化**——格式异常 / 未知通知类型 / 解析失败 / 载荷缺失。 +// 调用方据此走 fail-safe 静默(仅登记、绝不驱动 loop,绝不让畸形数据进入 LLM +// 上下文)。实现必须无副作用:不调用 LLM、不判身份、不做 I/O。 +type SystemNoticeNormalizer interface { + NormalizeSystemNotice(msg UnifiedMessage) (SystemNotice, bool) +} + +const ( + // NoticeSafeTextMaxRunes 限定安全文本长度:系统通知只承载一句提示,超出部分 + // 一律截断——它既防畸形字段灌进上下文,也防超长文本撑爆 token 预算。 + NoticeSafeTextMaxRunes = 200 + // noticeSafeTextEllipsis 是截断标记。 + noticeSafeTextEllipsis = "…" +) + +// SanitizeNoticeText 把平台原生通知文本剥成**安全文本**(design/12 §6 策略一补充的 +// 「提取安全文本、剥离异常字段」):吞掉 ANSI 转义序列,去掉控制字符、零宽字符与 +// 双向覆盖字符,把换行/制表折成空格并折叠连续空白,最后按 NoticeSafeTextMaxRunes +// 截断。它对任何输入都不报错、不 panic,也绝不返回未净化的原文。 +func SanitizeNoticeText(s string) string { + var b strings.Builder + b.Grow(len(s)) + state := escNone + for _, r := range s { + if next, consumed := escStep(state, r); consumed { + state = next + continue + } + switch { + case r == 0x1b: + state = escStart + case r == '\n' || r == '\r' || r == '\t': + b.WriteRune(' ') + case unicode.IsControl(r): + continue + case isInvisibleRune(r): + continue + default: + b.WriteRune(r) + } + } + out := strings.Join(strings.Fields(b.String()), " ") + runes := []rune(out) + if len(runes) > NoticeSafeTextMaxRunes { + return string(runes[:NoticeSafeTextMaxRunes]) + noticeSafeTextEllipsis + } + return out +} + +// 转义序列解析的状态(ANSI/CSI/OSC)。系统通知里不该出现转义序列,出现的 +// 一律整段吞掉——留着只会让渲染文本与实际字节不一致(视觉欺骗 / 注入面)。 +const ( + escNone = iota + // escStart 刚见到 ESC,下一个字符决定这是哪一类序列。 + escStart + // escCSI 是 ESC [ / ESC ( / ESC ) / ESC # 参数序列:跳过参数与中间字节 + // (0x20–0x3F)直到终结字节(0x40–0x7E)。 + escCSI + // escOSC 是 ESC ] 字符串序列:跳过直到 BEL。 + escOSC +) + +// escStep 推进转义序列状态。consumed 为 true 表示 r 属于转义序列、不应输出; +// next 是推进后的状态。state 为 escNone 时它只把 ESC 本身折成 escStart。 +func escStep(state int, r rune) (next int, consumed bool) { + switch state { + case escStart: + switch r { + case '[', '(', ')', '#': + return escCSI, true + case ']': + return escOSC, true + default: + // 两字符转义(ESC X):X 是终结字节,一并吞掉。 + return escNone, true + } + case escCSI: + if r >= 0x20 && r <= 0x3f { + return escCSI, true + } + return escNone, true + case escOSC: + switch r { + case 0x07: + return escNone, true + case 0x1b: + return escStart, true + default: + return escOSC, true + } + default: + if r == 0x1b { + return escStart, true + } + return escNone, false + } +} + +// isInvisibleRune 报告 r 是否是零宽字符或双向文本覆盖字符——这两类字符在系统通知里 +// 没有正当用途,留着只会让渲染后的文本与真实字节不一致(提示词注入/视觉欺骗面)。 +func isInvisibleRune(r rune) bool { + switch { + case r >= 0x200b && r <= 0x200f: + return true + case r >= 0x202a && r <= 0x202e: + return true + case r >= 0x2060 && r <= 0x2064: + return true + case r >= 0x2066 && r <= 0x2069: + return true + case r == 0xfeff: + return true + default: + return false + } +} diff --git a/internal/channel/adapter/systemnotice_test.go b/internal/channel/adapter/systemnotice_test.go new file mode 100644 index 0000000..dbb8b89 --- /dev/null +++ b/internal/channel/adapter/systemnotice_test.go @@ -0,0 +1,102 @@ +package adapter + +import ( + "strings" + "testing" +) + +// 本文件覆盖 design/12 §6 策略一补充的归一化契约:结构分类(单一事实来源)、 +// 种类合法性,以及把平台原生通知文本剥成安全文本的净化器。 + +func TestSystemNoticeClassify(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in SystemNotice + want NoticeClass + }{ + {"可忽略 → 仅登记", SystemNotice{Kind: NoticeJoin, Ignorable: true}, NoticeRegisterOnly}, + {"高价值 → 升级真会话", SystemNotice{Kind: NoticeOther, HighValue: true}, NoticeEscalate}, + {"其余 → 限定注入", SystemNotice{Kind: NoticeOther}, NoticeLimitedInject}, + // 两个标志同时为真时取保守侧(不打扰为第一原则)。 + {"既标可忽略又标高价值 → 仅登记", SystemNotice{Ignorable: true, HighValue: true}, NoticeRegisterOnly}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := tc.in.Classify(); got != tc.want { + t.Fatalf("Classify() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestNoticeKindValid(t *testing.T) { + t.Parallel() + + for _, k := range []NoticeKind{NoticeJoin, NoticeLeave, NoticeRecall, NoticeTyping, NoticeBotAdd, NoticeOther} { + if !k.Valid() { + t.Errorf("%q 应合法", k) + } + } + if NoticeKind("weird").Valid() { + t.Error("未知 kind 不该合法") + } +} + +func TestSanitizeNoticeText(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want string + }{ + {"空串", "", ""}, + {"折叠换行与制表", "有人\n加入\t了群聊", "有人 加入 了群聊"}, + {"吞掉 ANSI 序列", "\x1b[31m红\x1b[0m字", "红字"}, + {"丢掉零宽与双向覆盖字符", "群\u200b聊\u202e事", "群聊事"}, + {"丢掉其余控制字符", "系\x07统\x00通知", "系统通知"}, + {"折叠多余空白", " a b ", "a b"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := SanitizeNoticeText(tc.in); got != tc.want { + t.Fatalf("SanitizeNoticeText(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestSanitizeNoticeTextTruncates(t *testing.T) { + t.Parallel() + + got := SanitizeNoticeText(strings.Repeat("啊", NoticeSafeTextMaxRunes+50)) + if runes := []rune(got); len(runes) != NoticeSafeTextMaxRunes+1 { + t.Fatalf("截断后 %d runes, want %d+1(含省略号)", len(runes), NoticeSafeTextMaxRunes) + } + if !strings.HasSuffix(got, "…") { + t.Errorf("截断未加省略号:%q", got) + } +} + +// TestSanitizeNoticeTextNeverPanicsOnGarbage 钉住「畸形数据不炸流水线」: +// 净化器对任意字节串都必须返回、不 panic,也绝不返回未净化的控制字符。 +func TestSanitizeNoticeTextNeverPanicsOnGarbage(t *testing.T) { + t.Parallel() + + for _, in := range []string{ + "\x1b", "\x1b[", "\x1b[31", "正常\x1b[31m文本", "\u200b", "\ufeff", "\x7f", "a\x1b[1;2Hb", + } { + got := SanitizeNoticeText(in) + for _, r := range got { + if r == 0x1b || isInvisibleRune(r) { + t.Fatalf("SanitizeNoticeText(%q) = %q 仍含异常字符", in, got) + } + } + } +} diff --git a/internal/services/channelbridge/channelbridge.go b/internal/services/channelbridge/channelbridge.go index 1a8c098..be85841 100644 --- a/internal/services/channelbridge/channelbridge.go +++ b/internal/services/channelbridge/channelbridge.go @@ -180,6 +180,9 @@ type Bridge struct { quietStartHour int quietEndHour int now func() time.Time + // noticeLog 是系统通知传入前审查(design/12 §6 策略一补充)的子功能日志器, + // 与 voiceLog 同款:`source=services/channelbridge/notice` 便于跨模块定位。 + noticeLog *slog.Logger // 参与度引擎状态(design/18):按会话的内存状态机, // 加上约束它的角色提醒词 / 参与度参数,以及概率触发 @@ -277,6 +280,7 @@ func New(opts Options) (*Bridge, error) { quietStartHour: qStart, quietEndHour: qEnd, now: now, + noticeLog: logger.WithSource(log, "services/channelbridge/notice"), triggerWords: opts.TriggerWords, personaPrompt: opts.PersonaPrompt, engage: opts.Engage, diff --git a/internal/services/channelbridge/inbound.go b/internal/services/channelbridge/inbound.go index e3d380d..92c5856 100644 --- a/internal/services/channelbridge/inbound.go +++ b/internal/services/channelbridge/inbound.go @@ -139,6 +139,16 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a // 模型可见消息看到的都是**同一份**文本(转写结果,或一行诚实的失败提示)。 b.normalizeInboundVoice(ctx, &msg) + // design/12 §6 策略一补充:系统通知在进入任何下游之前先过「传入前审查」—— + // 归一化 → 剥离异常字段 → 分类 → fail-safe。被判为可忽略(或畸形/未识别)的 + // 事件只登记、绝不驱动 loop;被放行的事件已就地换成**剥离后的安全文本**, + // 继续走常规流水线(限定注入 / 升级真会话)。 + notice := b.preEntryReview(ctx, &msg, a) + if notice.Applicable && !notice.Proceed { + b.registerNoticeOnly(ctx, msg, a, notice) + return + } + text := strings.TrimSpace(msg.Content) // 无文本且无**任何可用媒体** ⇒ 没有可处理的内容。带附件的「纯文件消息」、带语音的 // 「纯语音消息」与带图片的「纯图片消息」(微信/QQ 发图常常没有配文)必须继续往下走: @@ -176,7 +186,11 @@ func (b *Bridge) processFrom(ctx context.Context, msg adapter.UnifiedMessage, a // 消息,只登记到其隔离会话日志并静默——绝不 // 驱动完整 agent 循环。主人消息绝不在此被拦,因此 // 主人私聊与主人在群里的 @/高价值回复不受影响。 - if !actor.IsOwner() { + // + // 由「传入前审查」放行的系统通知(notice.Applicable)不在此列:它们已由策略一 + // 的归一化/分类**终局判定**(高价值升级真会话、其余限定注入),若再走这道面向 + // 聊天消息的闸门,actor=system 会在 isLowValueEvent 处被第二次静默掉。 + if !actor.IsOwner() && !notice.Applicable { if ok, reason := b.shouldRespond(ctx, msg, actor, sess); !ok { b.registerSilenced(ctx, msg, actor, sess, a) b.log.Debug("non-owner message silenced (minimal disturbance)", @@ -376,6 +390,12 @@ func deviceIDFor(msg adapter.UnifiedMessage) string { // 前置到列表最前面,与网关编排器的播种同序(转录在前、本次消息在后)。因此本函数只负责 // **本回合自己的**消息(来源行 + 当次用户消息),受限车道的形状在有无转录时保持一致。 func (b *Bridge) buildLoopMessages(_ context.Context, text string, env loop.SessionEnv, actor adapter.Actor, senderName string, src adapter.UnifiedMessage) []model.Message { + // design/12 §6 策略一补充:经传入前审查放行的系统通知走「限定注入」——只把剥离后的 + // 安全文本作为**一条系统行**注入她的上下文,绝不作为用户消息(否则等于把平台事件 + // 伪装成有人对她说话),也绝不携带任何原始字段。 + if isSystemNoticeMessage(src) { + return []model.Message{model.TextMessage(model.RoleSystem, noticeSystemPrefix+text)} + } msg := userTurnMessage(text) thirdParty := env.Restricted() && !actor.IsOwner() && senderName != "" if thirdParty { diff --git a/internal/services/channelbridge/notice.go b/internal/services/channelbridge/notice.go new file mode 100644 index 0000000..9c80c9e --- /dev/null +++ b/internal/services/channelbridge/notice.go @@ -0,0 +1,201 @@ +package channelbridge + +import ( + "context" + "errors" + "fmt" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" +) + +// 本文件承载 design/12 §6 策略一补充的「系统通知传入前审查」(pre-entry review): +// 系统类事件**先审查、归一化,再决定**是否 / 如何进入她的上下文——裸喂禁止。 +// +// 分级(结构判定优先,红线:绝不看正文措辞、绝不由模型判身份): +// - 可忽略 → 仅登记(不喂 LLM,不驱动 loop); +// - 高价值 → 升级真会话(越过「非主人聊天消息」的最小打扰闸门,直接放行); +// - 其余 → 提取安全文本、剥离异常字段后**限定注入**(先过既有审查模型剔噪)。 +// +// 审查失败 / 超时 / 未知 / 解析失败 / 未装配审查模型 → **fail-safe 静默**(见 +// noticeFailSafeProceeds)。畸形数据绝不进入 LLM 上下文,也绝不驱动 loop。 + +const ( + // noticeReviewTimeout 是一次系统通知剔噪审查的上限:审查模型卡住绝不能拖住 + // 入站流水线(design/12 §6 实现要点的并发条款同款口径)。 + noticeReviewTimeout = 5 * time.Second + // noticeSystemPrefix 是限定注入时系统行的前缀——它标在 **system 角色**消息上, + // 不是用户正文(design/11 §10:用户正文不得塞系统标记)。 + noticeSystemPrefix = "[系统通知] " +) + +// noticeFailSafeProceeds 是 design/12 §6 策略一补充**写死的保守方向**: +// false = 审查失败 / 超时 / 未识别 / 解析失败 / 未装配审查模型时**一律静默** +// (仅登记,绝不驱动 loop,绝不让畸形数据进入 LLM 上下文)。 +// 变异验证就是把它改成 true,看「fail-safe 静默」这组测试变红。 +const noticeFailSafeProceeds = false + +// ErrNoNoticeReviewer 表示未装配审查模型:系统通知的「其余」档没有可用的剔噪器。 +// 调用方据此走 fail-safe 静默,绝不因缺模型而放行未审查内容。 +var ErrNoNoticeReviewer = errors.New("channelbridge: no system notice reviewer") + +// noticeOutcome 是传入前审查对一条入站消息的裁决。 +type noticeOutcome struct { + // Applicable 报告该消息经**结构化元数据**判定为系统事件(已被本审查受理)。 + Applicable bool + // Proceed 报告归一化后的内容是否允许进入她的上下文。 + Proceed bool + // Class 是归一化后的结构分类结果。 + Class adapter.NoticeClass + // Notice 是归一化(且已剥离)后的统一结构。 + Notice adapter.SystemNotice + // Reason 是简短原因标记,用于日志与排查。 + Reason string +} + +// preEntryReview 是「传入前审查」主体:结构化识别 → 归一化 → 剥离 → 分类 → +// (其余档)既有审查模型剔噪 → fail-safe。 +// +// msg 为指针:凡是被判定放行(Proceed)的通知**就地**改写——Content 换成剥离后的 +// 安全文本、Media 与 RawData 清空(剥离异常字段:原始载荷、附件一律不进上下文)。 +// 这个改写挂在 defer 上,使「放行」只有一条出口:任何分支一旦放行,就绝不留下 +// 未剥离的原始字段(反之任何静默分支都不改写任何东西)。 +func (b *Bridge) preEntryReview(ctx context.Context, msg *adapter.UnifiedMessage, a adapter.PlatformAdapter) (out noticeOutcome) { + defer func() { + if !out.Proceed || out.Notice.SafeText == "" { + return + } + msg.Content = out.Notice.SafeText + msg.Media = nil + msg.RawData = nil + }() + if !isSystemNoticeMessage(*msg) { + return noticeOutcome{} + } + norm, ok := systemNoticeNormalizer(a) + if !ok { + return noticeOutcome{Applicable: true, Proceed: noticeFailSafeProceeds, + Reason: "system event without a normalizer for this adapter"} + } + notice, ok := normalizeNoticeSafely(norm, *msg) + if !ok { + return noticeOutcome{Applicable: true, Proceed: noticeFailSafeProceeds, + Reason: "system notice unparsable"} + } + notice.SafeText = adapter.SanitizeNoticeText(notice.SafeText) + class := notice.Classify() + out = noticeOutcome{Applicable: true, Class: class, Notice: notice} + if notice.SafeText == "" { + out.Reason = "system notice carries no safe text" + out.Proceed = noticeFailSafeProceeds + return out + } + switch class { + case adapter.NoticeRegisterOnly: + out.Reason = "ignorable system event" + return out + case adapter.NoticeEscalate: + out.Proceed = true + out.Reason = "high-value system event escalated to a real session" + default: + keep, err := b.reviewNoticeNoise(ctx, *msg, notice) + if err != nil { + out.Reason = "notice review failed: " + err.Error() + out.Proceed = noticeFailSafeProceeds + return out + } + if !keep { + // 这一侧不是 fail-safe:是审查模型明确判「没必要」。静默是它的裁决本身, + // 与审查失败那条路径(noticeFailSafeProceeds)无关。 + out.Reason = "notice review dropped the event as noise" + return out + } + out.Proceed = true + out.Reason = "notice review kept the event" + } + return out +} + +// reviewNoticeNoise 复用 design/12 §6 策略四的**同一个**审查模型注入点 +// (Options.Review / adapter.Reviewer,绝不另造第二套)对一条无法结构归类的系统 +// 通知做一次成本受控的剔噪:返回 true 表示值得进上下文。 +// 它把单条消息当作长度为 1 的队列交给审查器,因此复用其「保留编号」契约与 +// 越界/重复索引的防幻觉处理。审查模型只看剥离后的安全文本,绝不见原始载荷; +// 出错(含超时)一律往上抛,由调用方走 fail-safe 静默。 +func (b *Bridge) reviewNoticeNoise(ctx context.Context, msg adapter.UnifiedMessage, notice adapter.SystemNotice) (bool, error) { + if b.review == nil { + return false, ErrNoNoticeReviewer + } + probe := msg + probe.Content = notice.SafeText + probe.Media = nil + probe.RawData = nil + reviewCtx, cancel := context.WithTimeout(ctx, noticeReviewTimeout) + defer cancel() + keep, err := b.review(reviewCtx, []adapter.UnifiedMessage{probe}) + if err != nil { + return false, fmt.Errorf("channelbridge: notice review: %w", err) + } + for _, i := range keep { + if i == 0 { + return true, nil + } + } + return false, nil +} + +// registerNoticeOnly 是审查判「仅登记 / fail-safe 静默」那一侧的收口:把安全文本写进 +// 该消息所属会话的平台日志(登记),**绝不驱动 loop、绝不调用 LLM**。它与最小打扰 +// 路径共用 registerSilenced,因此「登记」只有一条实现。 +func (b *Bridge) registerNoticeOnly(ctx context.Context, msg adapter.UnifiedMessage, a adapter.PlatformAdapter, out noticeOutcome) { + if out.Notice.SafeText == "" { + b.noticeLog.Debug("system notice registered without text", + "platform", msg.Platform, "channel", msg.ChannelID, "reason", out.Reason) + return + } + msg.Content = out.Notice.SafeText + actor := session.ResolveActor(msg, b.owner) + sess, err := b.sessions.GetOrCreate(ctx, msg, b.owner) + if err != nil { + b.noticeLog.Warn("system notice session resolve failed, not registered", + "platform", msg.Platform, "channel", msg.ChannelID, "error", err) + return + } + b.registerSilenced(ctx, msg, actor, sess, a) + b.noticeLog.Debug("system notice registered only (no loop)", + "platform", msg.Platform, "channel", msg.ChannelID, "kind", out.Notice.Kind, "reason", out.Reason) +} + +// isSystemNoticeMessage 只按**结构化元数据**判定一条消息是否是系统类事件: +// 适配器在归一化时把它标成 ContentAction(非聊天动作信号:进群/退群/撤回/戳一戳/ +// 系统提醒)。它**绝不看正文**,也绝不用提示词判断(项目红线)。 +func isSystemNoticeMessage(msg adapter.UnifiedMessage) bool { + return msg.ContentType == adapter.ContentAction +} + +// systemNoticeNormalizer 取适配器的归一化 seam。未实现该可选接口的适配器返回 +// ok=false——调用方据此走 fail-safe 静默(宁可登记,也不放行看不懂的平台事件)。 +func systemNoticeNormalizer(a adapter.PlatformAdapter) (adapter.SystemNoticeNormalizer, bool) { + if a == nil { + return nil, false + } + norm, ok := a.(adapter.SystemNoticeNormalizer) + if !ok || norm == nil { + return nil, false + } + return norm, true +} + +// normalizeNoticeSafely 调用适配器的归一化 seam,并把任何 panic 折成 ok=false。 +// 各平台的系统通知格式多变且不确定,畸形载荷把某个解析器打崩**不能**牵连常驻后端 +// ——按 design/12 §6 策略一补充的审查钩子,这一侧同样是 fail-safe 静默。 +func normalizeNoticeSafely(norm adapter.SystemNoticeNormalizer, msg adapter.UnifiedMessage) (notice adapter.SystemNotice, ok bool) { + defer func() { + if r := recover(); r != nil { + notice, ok = adapter.SystemNotice{}, false + } + }() + notice, ok = norm.NormalizeSystemNotice(msg) + return notice, ok +} diff --git a/internal/services/channelbridge/notice_test.go b/internal/services/channelbridge/notice_test.go new file mode 100644 index 0000000..0bc4438 --- /dev/null +++ b/internal/services/channelbridge/notice_test.go @@ -0,0 +1,380 @@ +package channelbridge + +import ( + "context" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" + "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" +) + +// 本文件覆盖 design/12 §6 策略一补充的「系统通知传入前审查」:归一化 / 分类 / +// fail-safe 三条路径,以及它与审查模型(策略四同一注入点)的复用点。 +// fail-safe 的保守方向写死在 noticeFailSafeProceeds(false = 静默),变异验证即把它 +// 改成 true —— 下面 TestNoticeReviewFailureFailsSafeSilent、 +// TestNoticeReviewerMissingFailsSafeSilent 与 TestPreEntryReviewFailSafeIsSilent +// 会变红。 + +// noticeAdapter 是最小的 PlatformAdapter + SystemNoticeNormalizer: +// 它按夹具给定的结论回答归一化,并记录收到的原始消息与调用次数。 +type noticeAdapter struct { + fakeAdapter + notice adapter.SystemNotice + ok bool + panics bool + calls int + rawSeen []byte +} + +func (n *noticeAdapter) NormalizeSystemNotice(msg adapter.UnifiedMessage) (adapter.SystemNotice, bool) { + n.calls++ + n.rawSeen = msg.RawData + if n.panics { + panic("malformed platform notice") + } + return n.notice, n.ok +} + +// plainAdapter 是**未实现**归一化 seam 的适配器(可选接口缺席时的 fail-safe 路径)。 +type plainAdapter struct{ fakeAdapter } + +// groupNoticeMessage 是一条带结构化系统事件标记的群通知:ContentType=ContentAction +// 是适配器给出的「非聊天动作信号」标记(判「是否系统」只看这个元数据,绝不看正文)。 +func groupNoticeMessage() adapter.UnifiedMessage { + return adapter.UnifiedMessage{ + Platform: "qq-obv11", + ChannelID: "g1", + ChannelType: adapter.ChannelGroup, + GroupID: "g1", + GroupName: "群一", + SenderID: "system", + SenderName: "system", + Sender: adapter.Actor{Role: adapter.RoleSystem, UserID: "system"}, + ContentType: adapter.ContentAction, + RawData: []byte(`{"post_type":"notice","notice_type":"group_upload"}`), + } +} + +// noticeFixture 组装一条入站通知所需的桥接器:受限群会话 + 捕获历史 + +// 记录调用的循环。review 为 nil 表示未装配审查模型。 +func noticeFixture(t *testing.T, ad adapter.PlatformAdapter, review adapter.Reviewer) (*Bridge, *stubChatter, *stubSessions, *captureHistory) { + t.Helper() + chat := &stubChatter{res: nil} + sess := &stubSessions{sess: session.Session{SessionID: "sess-g1", IsGroup: true}} + hist := &captureHistory{} + b, err := New(Options{ + Loop: chat, Sessions: sess, History: hist, + Owner: testOwnerProfile("qq-obv11"), + Review: review, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + b.Add(ad) + return b, chat, sess, hist +} + +// systemLines 返回循环收到的系统角色消息文本。 +func systemLines(msgs []model.Message) []string { + var out []string + for _, m := range msgs { + if m.Role == model.RoleSystem { + for _, c := range m.Content { + out = append(out, c.Text) + } + } + } + return out +} + +// TestNoticeIgnorableRegisteredOnly 覆盖「可忽略 → 仅登记,不喂 LLM,不驱动 loop」。 +func TestNoticeIgnorableRegisteredOnly(t *testing.T) { + t.Parallel() + + ad := ¬iceAdapter{notice: adapter.SystemNotice{ + Kind: adapter.NoticeJoin, SafeText: "有成员加入了群聊", Ignorable: true, + }, ok: true} + b, chat, _, hist := noticeFixture(t, ad, nil) + + b.processFrom(context.Background(), groupNoticeMessage(), ad) + + if chat.sessionID != "" { + t.Fatalf("可忽略的系统通知驱动了 loop:%q", chat.sessionID) + } + recs := hist.records() + if len(recs) != 1 { + t.Fatalf("登记的记录数 = %d,want 1(仅登记)", len(recs)) + } + if recs[0].Content != "有成员加入了群聊" || recs[0].SessionID != "sess-g1" { + t.Errorf("登记内容 = %+v", recs[0]) + } +} + +// TestNoticeHighValueEscalatesWithoutReview 覆盖「高价值 → 升级真会话」: +// 它越过审查模型(结构判定优先),进入真会话,并以系统通知行**限定注入**。 +func TestNoticeHighValueEscalatesWithoutReview(t *testing.T) { + t.Parallel() + + reviewed := 0 + review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) { + reviewed++ + return nil, nil + } + ad := ¬iceAdapter{notice: adapter.SystemNotice{ + Kind: adapter.NoticeLeave, SafeText: "系统通知:她(机器人账号)被移出了群聊", HighValue: true, + }, ok: true} + b, chat, _, _ := noticeFixture(t, ad, review) + + b.processFrom(context.Background(), groupNoticeMessage(), ad) + + if reviewed != 0 { + t.Errorf("高价值通知不该走剔噪审查,调用次数 = %d", reviewed) + } + if chat.sessionID != "sess-g1" { + t.Fatalf("高价值通知没进真会话:session=%q", chat.sessionID) + } + lines := systemLines(chat.messages) + if len(lines) != 1 || !strings.HasPrefix(lines[0], noticeSystemPrefix) { + t.Fatalf("限定注入的系统行 = %v", lines) + } + for _, m := range chat.messages { + if m.Role == model.RoleUser { + t.Errorf("系统通知被当成用户消息注入:%v", m.Content) + } + } +} + +// TestNoticeLimitedInjectReusesReviewModel 覆盖「其余 → 提取安全文本、剥离异常字段后 +// 限定注入」,并证明它复用的是 design/12 §6 策略四的**同一个**审查模型注入点: +// 审查器只看得到剥离后的安全文本,且原始载荷与附件已被剥离。 +func TestNoticeLimitedInjectReusesReviewModel(t *testing.T) { + t.Parallel() + + var probe adapter.UnifiedMessage + review := func(_ context.Context, msgs []adapter.UnifiedMessage) ([]int, error) { + if len(msgs) != 1 { + t.Fatalf("审查器应收到单条消息,收到 %d 条", len(msgs)) + } + probe = msgs[0] + return []int{0}, nil + } + dirty := "群文件\u200b上传\x1b[31m\x1b[0m\n\x07超长" + strings.Repeat("啊", 300) + ad := ¬iceAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: dirty}, ok: true} + b, chat, _, _ := noticeFixture(t, ad, review) + + b.processFrom(context.Background(), groupNoticeMessage(), ad) + + if chat.sessionID != "sess-g1" { + t.Fatalf("审查保留的通知没进上下文:session=%q", chat.sessionID) + } + if probe.RawData != nil || len(probe.Media) != 0 { + t.Errorf("审查器看到了未剥离字段:raw=%q media=%d", probe.RawData, len(probe.Media)) + } + if strings.ContainsAny(probe.Content, "\u200b\x1b\x07\n") { + t.Errorf("审查器看到的安全文本未剥离异常字符:%q", probe.Content) + } + if len([]rune(probe.Content)) > adapter.NoticeSafeTextMaxRunes+1 { + t.Errorf("安全文本未截断:%d runes", len([]rune(probe.Content))) + } + lines := systemLines(chat.messages) + if len(lines) != 1 || !strings.Contains(lines[0], "群文件上传") { + t.Fatalf("限定注入的系统行 = %v", lines) + } + if got := len([]rune(strings.TrimPrefix(lines[0], noticeSystemPrefix))); got > adapter.NoticeSafeTextMaxRunes+1 { + t.Errorf("注入文本未截断:%d runes", got) + } +} + +// TestNoticeReviewDroppedStaysSilent 覆盖审查模型判「没必要」那一侧:只登记、不进 loop。 +func TestNoticeReviewDroppedStaysSilent(t *testing.T) { + t.Parallel() + + review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) { return nil, nil } + ad := ¬iceAdapter{notice: adapter.SystemNotice{ + Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件", + }, ok: true} + b, chat, _, hist := noticeFixture(t, ad, review) + + b.processFrom(context.Background(), groupNoticeMessage(), ad) + + if chat.sessionID != "" { + t.Fatalf("被剔噪的通知仍驱动了 loop:%q", chat.sessionID) + } + if recs := hist.records(); len(recs) != 1 || recs[0].Content != "群/客户端系统事件" { + t.Errorf("被剔噪后未登记:%+v", recs) + } +} + +// TestNoticeReviewFailureFailsSafeSilent 是 fail-safe 的核心用例: +// 审查模型报错 / 超时(这里用 DeadlineExceeded 模拟超时)时**保守侧 = 静默**。 +// 变异验证(把 noticeFailSafeProceeds 改成 true)会让本用例变红。 +func TestNoticeReviewFailureFailsSafeSilent(t *testing.T) { + t.Parallel() + + var hadDeadline bool + review := func(ctx context.Context, _ []adapter.UnifiedMessage) ([]int, error) { + _, hadDeadline = ctx.Deadline() + return nil, context.DeadlineExceeded + } + ad := ¬iceAdapter{notice: adapter.SystemNotice{ + Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件", + }, ok: true} + b, chat, _, _ := noticeFixture(t, ad, review) + + b.processFrom(context.Background(), groupNoticeMessage(), ad) + + if !hadDeadline { + t.Error("审查调用没有超时上限(design/12 §6 实现要点的超时条款)") + } + if chat.sessionID != "" { + t.Fatalf("审查失败时未 fail-safe 静默:%q", chat.sessionID) + } +} + +// TestNoticeReviewerMissingFailsSafeSilent 覆盖「未装配审查模型」: +// 与审查失败同侧(静默),绝不放行未审查内容。 +func TestNoticeReviewerMissingFailsSafeSilent(t *testing.T) { + t.Parallel() + + ad := ¬iceAdapter{notice: adapter.SystemNotice{ + Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件", + }, ok: true} + b, chat, _, _ := noticeFixture(t, ad, nil) + + b.processFrom(context.Background(), groupNoticeMessage(), ad) + + if chat.sessionID != "" { + t.Fatalf("未装配审查模型时未静默:%q", chat.sessionID) + } +} + +// TestNoticeUnparsableFailsSafeSilent 覆盖「格式异常 / 未知 / 解析失败」: +// 归一化 seam 说不认识 ⇒ 只静默,且绝不把原始载荷喂进上下文。 +func TestNoticeUnparsableFailsSafeSilent(t *testing.T) { + t.Parallel() + + ad := ¬iceAdapter{ok: false} + b, chat, _, hist := noticeFixture(t, ad, nil) + + b.processFrom(context.Background(), groupNoticeMessage(), ad) + + if chat.sessionID != "" { + t.Fatalf("未识别的系统事件驱动了 loop:%q", chat.sessionID) + } + for _, m := range chat.messages { + if strings.Contains(m.Content[0].Text, "notice_type") { + t.Errorf("原始载荷进了上下文:%v", m.Content) + } + } + if len(hist.records()) != 0 { + t.Errorf("无安全文本时不该落一条空登记:%+v", hist.records()) + } +} + +// TestNoticeNormalizerPanicFailsSafeSilent 覆盖畸形载荷把适配器解析器打崩: +// panic 必须折成 fail-safe 静默,绝不牵连常驻后端。 +func TestNoticeNormalizerPanicFailsSafeSilent(t *testing.T) { + t.Parallel() + + ad := ¬iceAdapter{ok: true, panics: true} + b, chat, _, _ := noticeFixture(t, ad, nil) + + b.processFrom(context.Background(), groupNoticeMessage(), ad) + + if chat.sessionID != "" { + t.Fatalf("归一化 panic 后仍驱动了 loop:%q", chat.sessionID) + } +} + +// TestNoticeWithoutNormalizerFailsSafeSilent 覆盖适配器未实现归一化 seam 的情况: +// 看不懂平台事件 ⇒ 静默(宁可登记,也不放行)。 +func TestNoticeWithoutNormalizerFailsSafeSilent(t *testing.T) { + t.Parallel() + + ad := &plainAdapter{fakeAdapter{name: "qq-obv11"}} + b, chat, _, _ := noticeFixture(t, ad, nil) + + b.processFrom(context.Background(), groupNoticeMessage(), ad) + + if chat.sessionID != "" { + t.Fatalf("无归一化器时未静默:%q", chat.sessionID) + } +} + +// TestPreEntryReviewFailSafeIsSilent 直接钉住审查函数本身的 fail-safe 方向 +// (未识别 / 空安全文本都不放行)。变异验证会让它变红。 +func TestPreEntryReviewFailSafeIsSilent(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + ad adapter.PlatformAdapter + msg adapter.UnifiedMessage + }{ + {"未识别", ¬iceAdapter{ok: false}, groupNoticeMessage()}, + {"无归一化器", &plainAdapter{fakeAdapter{name: "qq-obv11"}}, groupNoticeMessage()}, + {"安全文本为空", ¬iceAdapter{ok: true, notice: adapter.SystemNotice{Kind: adapter.NoticeOther}}, groupNoticeMessage()}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + b, _, _, _ := noticeFixture(t, tc.ad, nil) + out := b.preEntryReview(context.Background(), &tc.msg, tc.ad) + if !out.Applicable { + t.Fatal("系统事件未被受理") + } + if out.Proceed { + t.Fatalf("fail-safe 方向被反转(应为静默):reason=%q", out.Reason) + } + }) + } +} + +// TestChatMessageIsNotAReviewSubject 是反向钉:普通聊天消息绝不进传入前审查 +// (成本受控:只有系统事件走这条轻审查)。 +func TestChatMessageIsNotAReviewSubject(t *testing.T) { + t.Parallel() + + ad := ¬iceAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeJoin, SafeText: "x", Ignorable: true}, ok: true} + b, chat, _, _ := noticeFixture(t, ad, nil) + + msg := groupNoticeMessage() + msg.ContentType = adapter.ContentText + msg.Content = "大家好" + msg.Sender.Role = adapter.RoleMember + msg.SenderID = "42" + + b.processFrom(context.Background(), msg, ad) + + if ad.calls != 0 { + t.Errorf("普通聊天消息被送进了归一化 seam:calls=%d", ad.calls) + } + for _, line := range systemLines(chat.messages) { + if strings.HasPrefix(line, noticeSystemPrefix) { + t.Errorf("普通聊天消息被当成系统通知注入:%q", line) + } + } +} + +// TestNoticeReviewTimeoutIsBounded 钉住超时常量本身,避免被改成无上限。 +func TestNoticeReviewTimeoutIsBounded(t *testing.T) { + t.Parallel() + + if noticeReviewTimeout <= 0 || noticeReviewTimeout > 30*time.Second { + t.Fatalf("noticeReviewTimeout = %v,应在 (0, 30s] 内", noticeReviewTimeout) + } +} + +// TestNoticeFailSafeDirectionConstant 把保守方向钉在测试里(design/12 §6 策略一补充: +// 审查钩子失败一侧 = 静默)。变异验证改的就是这个常量。 +func TestNoticeFailSafeDirectionConstant(t *testing.T) { + t.Parallel() + + if noticeFailSafeProceeds { + t.Fatal("fail-safe 保守方向被反转:审查失败/未知/未装配时必须静默(仅登记)") + } +} diff --git a/plugins/channels/qq_obv11/inbound_debug.go b/plugins/channels/qq_obv11/inbound_debug.go new file mode 100644 index 0000000..2531d08 --- /dev/null +++ b/plugins/channels/qq_obv11/inbound_debug.go @@ -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 +} diff --git a/plugins/channels/qq_obv11/inbound_debug_test.go b/plugins/channels/qq_obv11/inbound_debug_test.go new file mode 100644 index 0000000..cce9594 --- /dev/null +++ b/plugins/channels/qq_obv11/inbound_debug_test.go @@ -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]) + } + } +} diff --git a/plugins/channels/qq_obv11/message.go b/plugins/channels/qq_obv11/message.go index 717ddbb..59ba4ce 100644 --- a/plugins/channels/qq_obv11/message.go +++ b/plugins/channels/qq_obv11/message.go @@ -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) diff --git a/plugins/channels/qq_obv11/notice.go b/plugins/channels/qq_obv11/notice.go new file mode 100644 index 0000000..126d169 --- /dev/null +++ b/plugins/channels/qq_obv11/notice.go @@ -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} +} diff --git a/plugins/channels/qq_obv11/notice_test.go b/plugins/channels/qq_obv11/notice_test.go new file mode 100644 index 0000000..47818db --- /dev/null +++ b/plugins/channels/qq_obv11/notice_test.go @@ -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) + } +} diff --git a/plugins/channels/qq_obv11/session.go b/plugins/channels/qq_obv11/session.go index 30eae6d..3f6de1b 100644 --- a/plugins/channels/qq_obv11/session.go +++ b/plugins/channels/qq_obv11/session.go @@ -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 diff --git a/scripts/ctrl.bat b/scripts/ctrl.bat index 2fcd53e..46747ef 100644 --- a/scripts/ctrl.bat +++ b/scripts/ctrl.bat @@ -16,6 +16,10 @@ rem - single binary, run from the repo root (reads .env PORT / DATA_DIR ...) rem - port comes from .env PORT, falls back to 10080 rem - log: %USERPROFILE%\cy.log (app logs to stderr); rem stdout fallback: %USERPROFILE%\cy.stdout.log +rem - the previous run's logs are NEVER discarded: before each start they are +rem archived as ..bak, newest %LOG_KEEP% kept. Needed +rem because Start-Process -RedirectStandard* OVERWRITES its target (PowerShell +rem has no append form), so every restart used to wipe the evidence in cy.log. rem - pid file: %USERPROFILE%\.cyrene.pid, else detected from the port rem - Start-Process -WindowStyle Hidden = detached start rem (the ctrl.sh equivalent of "setsid nohup ... & disown") @@ -41,6 +45,8 @@ if not exist "%APP_BIN%" set "APP_BIN=%USERPROFILE%\apps\cyrene\cyrene.exe" set "PID_FILE=%USERPROFILE%\.cyrene.pid" set "LOG_FILE=%USERPROFILE%\cy.log" set "STDOUT_FILE=%USERPROFILE%\cy.stdout.log" +rem LOG_KEEP: how many archived logs to retain (one per start, oldest pruned first) +set "LOG_KEEP=20" rem ---- port: .env PORT wins, fallback 10080 ---- set "GW_PORT=" @@ -99,6 +105,7 @@ if not exist "%APP_BIN%" ( ) echo Starting cyrene -^> %APP_BIN% ^(port %GW_PORT%^) cd /d "%REPO%" || exit /b 1 +call :rotate_logs powershell -NoProfile -Command "Start-Process -FilePath '%APP_BIN%' -WorkingDirectory '%REPO%' -RedirectStandardOutput '%STDOUT_FILE%' -RedirectStandardError '%LOG_FILE%' -WindowStyle Hidden" set /a TRIES=0 :start_wait @@ -222,3 +229,30 @@ if defined PORT_PID ( >"%PID_FILE%" echo !PORT_PID! ) exit /b 0 + +rem rotate_logs: keep the previous run's log instead of letting it be overwritten. +rem Start-Process -RedirectStandardOutput/-RedirectStandardError OVERWRITE their +rem target (PowerShell has no append form), so without this every restart wiped +rem cy.log -- i.e. each restart destroyed the evidence of the run before it. +rem Archiving (not appending) also keeps the runs separable. Failures are ignored +rem on purpose: a log we cannot archive must never block a start. +:rotate_logs +set "RUN_TS=" +for /f "usebackq delims=" %%T in (`powershell -NoProfile -Command "Get-Date -Format yyyyMMdd-HHmmss"`) do if not defined RUN_TS set "RUN_TS=%%T" +if not defined RUN_TS set "RUN_TS=unknown%RANDOM%" +if exist "%LOG_FILE%" move /y "%LOG_FILE%" "%LOG_FILE%.%RUN_TS%.bak" >nul 2>&1 +if exist "%STDOUT_FILE%" move /y "%STDOUT_FILE%" "%STDOUT_FILE%.%RUN_TS%.bak" >nul 2>&1 +call :prune_logs "%LOG_FILE%" +call :prune_logs "%STDOUT_FILE%" +exit /b 0 + +rem prune_logs : keep only the newest %LOG_KEEP% archives of . +rem Runs with %USERPROFILE% as the working directory so the bare names dir prints +rem resolve (the repo may sit on another drive than %USERPROFILE%; pushd/popd then +rem echo the directory, hence the >nul). +:prune_logs +set "PRUNE_BASE=%~1" +pushd "%USERPROFILE%" >nul 2>&1 || exit /b 0 +for /f "skip=%LOG_KEEP% delims=" %%F in ('dir /b /o-d /a-d "%PRUNE_BASE%.*.bak" 2^>nul') do del /q "%%~F" >nul 2>&1 +popd >nul 2>&1 +exit /b 0 diff --git a/scripts/ctrl.sh b/scripts/ctrl.sh index cb78553..fc5ba75 100644 --- a/scripts/ctrl.sh +++ b/scripts/ctrl.sh @@ -11,7 +11,9 @@ # 说明: # - 管理单个 cyrene 后端进程(单二进制)。llama-server / PG 若需一并管理, # 可另用 docker / scripts/dev-up.sh。 -# - cyrene 在仓库根下运行(读 .env 的 PORT / DATA_DIR 等),日志写 ~/cy.log。 +# - cyrene 在仓库根下运行(读 .env 的 PORT / DATA_DIR 等),日志**追加**写 ~/cy.log +# (重启不清空历史;Windows 侧 scripts\ctrl.bat 因 Start-Process 无法追加,改为 +# 按时间戳归档,见该脚本的注释)。 # - 用 setsid + nohup 脱离进程组,避免终端/后台 shell 超时误杀。 set -euo pipefail @@ -64,8 +66,10 @@ start() { echo "启动 cyrene → $APP_BIN (端口 $GW_PORT)" # setsid 只脱离会话,但 cyrene 仍是本 bash 的后台 job;脚本退出时 bash 会等待 # 它结束,导致 restart 命令"挂起超时"。disown 把它从作业表剔除,脚本立即返回。 + # 日志**追加**(>>)而不是覆盖(>):重启不再截断上一轮日志——排障证据必须能跨重启 + # 留存(「日志重启即清空」曾让一个线上问题无从取证)。 cd "$REPO" || return 1 - setsid nohup "$APP_BIN" > "$LOG_FILE" 2>&1 < /dev/null & + setsid nohup "$APP_BIN" >> "$LOG_FILE" 2>&1 < /dev/null & disown # 等待健康 local i diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh index 624ea22..c9c2674 100755 --- a/scripts/dev-up.sh +++ b/scripts/dev-up.sh @@ -65,7 +65,7 @@ if [ ! -f "$APP_BIN" ]; then go build -ldflags="-s -w" -trimpath -o "$APP_BIN" ./cmd/cyrene fi if ! curl -s --max-time 2 "http://127.0.0.1:$GW_PORT/api/v1/health" >/dev/null 2>&1; then - (cd "$REPO" && nohup "$APP_BIN" > "$HOME/cy.log" 2>&1 &) + (cd "$REPO" && nohup "$APP_BIN" >> "$HOME/cy.log" 2>&1 &) echo " cyrene 已启动: http://localhost:$GW_PORT (日志 $HOME/cy.log)" else echo " cyrene 已在运行 (: $GW_PORT),跳过"