- 适配器不再整类丢弃:notice/request/meta_event/message_sent/未知 post_type/JSON 解析失败( decode_error ) 一律归一化投递并留痕
- notice 映射补 group_ban/sub_type 全枚举,未知类型走 NoticeUnknown 兜底(fail-safe 只登记、绝不猜、绝不放行)
- 不投影 flag/comment/file/duration;归一化 DEBUG 只记结构(脱敏,变异实测可打红)
- 传输层端到端已覆盖(真实 http+server 上报路径:加好友请求/心跳/未知类型)
- ⚠️ 主人 2026-10-05 决定「先不连 QQ」(风控)⇒ 本片为通用红线(绝不静默丢弃)+ 现成证据链,QQ 侧后续增量工作暂停
131 lines
5.2 KiB
Go
131 lines
5.2 KiB
Go
package qq_obv11
|
||
|
||
// 本文件承载**入站原始帧的脱敏结构日志**(DEBUG),用于回答一个此前无从取证的问题:
|
||
// 「上游 OneBot 到底有没有把这一帧上报过来?」
|
||
//
|
||
// 事故背景:QQ 群里群友 @ 了却没有回复,而 channelbridge 只对**已放行**的消息留痕
|
||
// (见 internal/services/channelbridge/inbound.go 的 "inbound → session"),上游没上报时
|
||
// 日志里一片空白,既不能证明「上报了但被过滤」也不能证明「压根没上报」。这条日志把
|
||
// 空白补上:每条成功解码的入站帧记一行**结构**信息——帧类型、会话/群/自身 id、
|
||
// 消息段**类型列表**与 at 段的**目标**。
|
||
//
|
||
// 三种形态的帧都在覆盖范围内,因为「没上报」和「上报了非消息事件」必须能区分:
|
||
// - `post_type=message`:真消息(本次排查要找的就是它缺不缺席);
|
||
// - `post_type=notice` / `request` / `meta_event` / 未知类型:非消息上报(design/26
|
||
// §3.1)——它们现在**一律投递**,这条日志随之覆盖各自的原生类型字段
|
||
// (notice_type / request_type / meta_event_type);
|
||
// - 空 `post_type`:OneBot 对我们自己 action 的回执(echo 响应)。**有等待者**的回执
|
||
// 被 routeActionResponse 消费(如 get_status 探活),没有等待者的散落回执才落这里。
|
||
//
|
||
// 脱敏口径(红线):**只记结构,不记内容**——绝不记 `raw_message` / 段里的 `text`、
|
||
// 绝不记 `access_token`、绝不记媒体段的完整 URL(连 `file` 引用也不记)。因此本文件
|
||
// 只从原始帧里**投影**出类型与 id,解析出的正文字节用完即弃。
|
||
//
|
||
// 级别固定 DEBUG:默认 INFO 下行为与改造前完全一致,需要取证时把日志级别调到 debug。
|
||
// 日志经 `a.log`(`source=plugins/channels/qq_obv11`)打出,满足「日志必须标来源」。
|
||
|
||
import (
|
||
"encoding/json"
|
||
"strings"
|
||
)
|
||
|
||
// logInboundFrame 为了排障打出本条入站帧的结构。字段刻意只用「有/无、哪一类、哪几个
|
||
// id」这类元数据:一次调用足以判断上游是否上报、上报的是哪类事件(含 request /
|
||
// meta_event / 未知 post_type 的类型字段)、群里那句 @ 是否带了 at 段(以及 @ 的是谁)。
|
||
//
|
||
// 类型字段按 post_type 各有各的家(notice_type / request_type / meta_event_type),
|
||
// 三个都照实打出:只打 post_type 的话,「notice 里到底是 poke 还是 group_ban」在日志里
|
||
// 依然看不出来——那正是这次事故里要回答的问题。
|
||
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,
|
||
"notice_type", ev.NoticeType,
|
||
"request_type", ev.RequestType,
|
||
"meta_event_type", ev.MetaEventType,
|
||
"sub_type", ev.SubType,
|
||
"self_id", formatID(ev.SelfID),
|
||
"user_id", formatID(ev.UserID),
|
||
"target_id", formatID(ev.TargetID),
|
||
"operator_id", formatID(ev.OperatorID),
|
||
"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
|
||
}
|