120 lines
4.5 KiB
Go
120 lines
4.5 KiB
Go
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 响应)。**有等待者**的回执
|
||
// 被 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」这类元数据:一次调用足以判断上游是否上报、上报的是哪类事件、群里那句 @ 是否带了
|
||
// 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
|
||
}
|