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 }