Files
Cyrene_v2/plugins/channels/qq_obv11/inbound_debug.go
T
AskaEth a79fe9684e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(channels): QQ 账号 get_status 主动探活(复用既有 action 通道 / 状态变化才记日志 / AccountStatus 与 WS 连通分开)
2026-10-05 12:10:34 +08:00

120 lines
4.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}