feat(channels): 非消息事件接入(notice 归一化 + 传入前审查,fail-safe 静默)与入站脱敏帧日志/日志轮转
- 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:
1 parent
85abb90067
commit
5139945bfe
15 files changed
+1604
-21
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 保守方向被反转:审查失败/未知/未装配时必须静默(仅登记)")
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <name>.<yyyyMMdd-HHmmss>.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 <base>: keep only the newest %LOG_KEEP% archives of <base>.
|
||||
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
|
||||
+6
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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),跳过"
|
||||
|
||||
Reference in new issue
Block a user