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

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

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
package qq_obv11
// 本文件承载**入站原始帧的脱敏结构日志**(DEBUG),用于回答一个此前无从取证的问题:
// 「上游 OneBot 到底有没有把这一帧上报过来?」
//
// 事故背景:QQ 群里群友 @ 了却没有回复,而 channelbridge 只对**已放行**的消息留痕
// (见 internal/services/channelbridge/inbound.go 的 "inbound → session"),上游没上报时
// 日志里一片空白,既不能证明「上报了但被过滤」也不能证明「压根没上报」。这条日志把
// 空白补上:每条成功解码的入站帧记一行**结构**信息——帧类型、会话/群/自身 id、
// 消息段**类型列表**与 at 段的**目标**。
//
// 三种形态的帧都在覆盖范围内,因为「没上报」和「上报了非消息事件」必须能区分:
// - `post_type=message`:真消息(本次排查要找的就是它缺不缺席);
// - `post_type=meta_event`:OneBot 心跳/生命周期,是「连接还活着」的正面证据;
// - 空 `post_type`:OneBot 对我们自己 action 的回执(echo 响应)。
//
// 脱敏口径(红线):**只记结构,不记内容**——绝不记 `raw_message` / 段里的 `text`、
// 绝不记 `access_token`、绝不记媒体段的完整 URL(连 `file` 引用也不记)。因此本文件
// 只从原始帧里**投影**出类型与 id,解析出的正文字节用完即弃。
//
// 级别固定 DEBUG:默认 INFO 下行为与改造前完全一致,需要取证时把日志级别调到 debug。
// 日志经 `a.log`(`source=plugins/channels/qq_obv11`)打出,满足「日志必须标来源」。
import (
"encoding/json"
"strings"
)
// logInboundFrame 为了排障打出本条入站帧的结构。字段刻意只用「有/无、哪一类、哪几个
// id」这类元数据:一次调用足以判断上游是否上报、上报的是哪类事件、群里那句 @ 是否带了
// at 段(以及 @ 的是谁)。
func (a *Adapter) logInboundFrame(ev *obEvent) {
if ev == nil {
return
}
segments, atTargets := frameSegmentShape(eventRawMessage(ev))
a.log.Debug("inbound frame",
"post_type", ev.PostType,
"message_type", ev.MessageType,
"sub_type", ev.SubType,
"self_id", formatID(ev.SelfID),
"user_id", formatID(ev.UserID),
"sender_id", senderIDOf(ev),
"group_id", formatID(ev.GroupID),
"segments", segments,
"at_targets", atTargets,
)
}
// eventRawMessage 取事件的原始消息体(数组段直接用 `message`,缺省回落 CQ 码字符串
// `raw_message`),与 parseMessage 同口径。
func eventRawMessage(ev *obEvent) []byte {
if ev == nil {
return nil
}
if len(ev.Message) > 0 {
return ev.Message
}
return []byte(ev.RawMessage)
}
// frameSegmentShape 从原始消息体里**只**解出段的类型列表与 at 段的目标 qq,正文与
// url/file 一律丢弃。两种报文形态都认(数组段与 CQ 码),形态认不出时返回空列表——
// 那是「结构没解出来」的诚实说法,绝不退回记原文。
func frameSegmentShape(raw []byte) (segments, atTargets []string) {
switch {
case len(raw) == 0:
return nil, nil
case raw[0] == '[':
var segs []obSegment
if err := json.Unmarshal(raw, &segs); err != nil {
return nil, nil
}
return arraySegmentShape(segs)
case raw[0] == '"':
var s string
if err := json.Unmarshal(raw, &s); err != nil {
return nil, nil
}
return cqSegmentShape(s)
default:
return cqSegmentShape(string(raw))
}
}
// arraySegmentShape 投影数组段:类型进 segments,at 段的目标进 atTargets。
func arraySegmentShape(segs []obSegment) (segments, atTargets []string) {
for _, s := range segs {
segments = append(segments, s.Type)
if s.Type == atSegmentType {
if qq := segStr(s.Data, "qq"); qq != "" {
atTargets = append(atTargets, qq)
}
}
}
return segments, atTargets
}
// cqSegmentShape 扫 CQ 码,同样只取类型与 at 目标;码与码之间的裸文本(正文)直接跳过。
func cqSegmentShape(s string) (segments, atTargets []string) {
for i := 0; i < len(s); {
if !strings.HasPrefix(s[i:], cqPrefix) {
i++
continue
}
end := strings.IndexByte(s[i:], ']')
if end < 0 {
break
}
typ, params := decodeCQCode(s[i : i+end+1])
segments = append(segments, typ)
if typ == atSegmentType && params["qq"] != "" {
atTargets = append(atTargets, params["qq"])
}
i += end + 1
}
return segments, atTargets
}