Files
Cyrene_v2/plugins/channels/qq_obv11/message.go
T
AskaEth d532eb3fcd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(session,qq-obv11): 修「判主好了、会话车道没跟上」(QQ 私聊没镜像进主会话)
真因(agent-221 用三条独立证据推翻了「SenderID 为空」的假设):ev.UserID 有值;
owner.yaml 的 qq 条目是 10:37:57 才加的(此前那几条判陌生人属**正确的 fail-closed**);
而热重载成功后仍不对 —— 因为 internal/channel/session/manager.go 的 GetOrCreate
对**已绑定的渠道直接返回缓存会话**:判主门槛用新 actor 会过(所以她会回话),
但**车道用的是缓存的旧会话**(Owner=false ⇒ 不镜像进 Web 主会话、MemWritable=false、
落盘 actor_role 永不更新)。

修:缓存命中但 ActorRole 与本次解析不一致 ⇒ rebindLocked 刷新车道 + 同步落盘
(updateActorRoleSQL);persist 的 INSERT OR IGNORE 后读回落盘车道、不一致就地纠正
(覆盖上一轮进程留下的旧行);落盘失败只 WARN(不因记账失败丢主人消息);
**身份变化不搬家、不换 session id**;车道只来自 ResolveActor 的结构化白名单判定。

QQ 适配器(防御加固,非本次真机 bug 的修复):senderIDOf 兜底链 顶层 user_id →
sender.user_id;并用完整论证钉住「**群聊绝不能用 ChannelID 兜底**」(否则群里任何人
可顶群号冒充主人);顺手体检:自己发的消息(post_type=message_sent)与 meta_event
已被 handlePayload 过滤。为守 ≤500 行把 id/字符串小工具拆到 ids.go。

测试:session/ownerlane_test + qq/ownerlane_test 新增用例(含「群号等于主人号时
陌生人不得借群号冒充」「群聊发送者 id 缺失时 SenderID 必须为空而非群号」「白名单
热重载后车道立刻刷新且 session id 不变」「白名单移除后立即退回,不是单向提权」
「跨进程继承的旧行被就地纠正」)。
变异 5 条(去 sender.user_id 兜底 / 群聊用群号当 SenderID / 命中却判 unknown /
还原缓存直返 / 去掉旧行纠正),每条先确认可编译、贴红行、还原复绿(md5 逐字节校验)。
2026-10-05 10:53:05 +08:00

454 lines
14 KiB
Go

package qq_obv11
// This file carries OneBot v11 protocol types and the conversions between the
// native event/segment model and the normalized adapter.UnifiedMessage /
// UnifiedResponse model. It understands both array-format messages
// ([]{"type","data"}) and the legacy CQ-code string format
// ([CQ:image,file=...]text), and maps QQ media (image/record/video/file) into
// adapter.Media and QQ mentions into adapter.Mention.
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
const platformName = "qq-obv11"
// OneBot v11 event as delivered over WebSocket.
type obEvent struct {
PostType string `json:"post_type"`
MessageType string `json:"message_type"`
Time int64 `json:"time"`
SelfID int64 `json:"self_id"`
MessageID int64 `json:"message_id"`
UserID int64 `json:"user_id"`
GroupID int64 `json:"group_id"`
GroupName string `json:"group_name"`
SubType string `json:"sub_type"`
RawMessage string `json:"raw_message"`
Message json.RawMessage `json:"message"`
Sender *obSender `json:"sender"`
}
// obSender is the sender identity carried by a message event.
type obSender struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Card string `json:"card"`
Role string `json:"role"`
Sex string `json:"sex"`
}
// obSegment is one element of an array-format message.
type obSegment struct {
Type string `json:"type"`
Data map[string]any `json:"data"`
}
// obSendMsg is the outbound OneBot v11 API request (action + params).
type obSendMsg struct {
Action string `json:"action"`
Params obSendParams `json:"params"`
Echo string `json:"echo,omitempty"`
}
// obSendParams carries the send_msg parameters.
type obSendParams struct {
MessageType string `json:"message_type"`
UserID int64 `json:"user_id,omitempty"`
GroupID int64 `json:"group_id,omitempty"`
Message []obSegment `json:"message"`
AutoEscape bool `json:"auto_escape"`
}
// parseEvent decodes a raw WebSocket text frame into a OneBot event. If the
// payload is not a message-style event the returned error is non-nil and the
// caller should attempt to fall back to another interpretation.
func parseEvent(payload []byte) (*obEvent, error) {
var ev obEvent
if err := json.Unmarshal(payload, &ev); err != nil {
return nil, fmt.Errorf("qq-obv11: decode event: %w", err)
}
return &ev, nil
}
// toUnified converts a parsed message event into a normalized UnifiedMessage.
// raw preserves the original event payload for downstream tracing.
func (a *Adapter) toUnified(ev *obEvent, raw []byte) adapter.UnifiedMessage {
text, media, mentions, replyID, replyText, hasText := a.parseMessage(ev)
senderID := senderIDOf(ev)
senderName := "unknown"
role := adapter.RoleUnknown
if ev.Sender != nil {
senderName = firstNonEmpty(ev.Sender.Card, ev.Sender.Nickname, senderID)
role = roleFromOneBot(ev.Sender.Role)
}
msg := adapter.UnifiedMessage{
Platform: platformName,
SenderID: senderID,
SenderName: senderName,
Content: text,
ContentType: contentOf(media, hasText),
Media: media,
Mentions: mentions,
ReplyToID: replyID,
ReplyToText: replyText,
MessageID: formatID(ev.MessageID),
RawData: raw,
Timestamp: time.Unix(ev.Time, 0),
Sender: adapter.Actor{
UserID: senderID,
DisplayName: senderName,
Role: role,
},
}
switch ev.MessageType {
case "group":
// 群聊的渠道键是**群号**,发送者仍是 senderID(上一步解析):两者绝不可互换。
// 用群号当 SenderID 会让群里任何人都顶着群号去撞白名单(冒充主人)。事件里
// 没有第二个群号来源,group_id 缺失时 ChannelID 就如实留空——下游
// session.channelID() 会以 ErrInvalidChannel 拒掉这条消息(fail-closed),
// 绝不退回用发送者号去编一个群会话键。
gid := formatID(ev.GroupID)
msg.ChannelType = adapter.ChannelGroup
msg.ChannelID = gid
msg.GroupID = gid
name := firstNonEmpty(ev.GroupName, a.groupName(gid))
msg.GroupName = name
msg.Sender.GroupID = gid
msg.Sender.IsGroup = true
if name != "" {
a.rememberGroup(gid, name)
}
case "private":
msg.ChannelType = adapter.ChannelDirect
// 私聊的对端**就是**发送者,所以这里的 ChannelID 可以安全地回落到 SenderID
// (对端号 == 发送者号)。群聊绝不允许这样回落:群号不是发送者,用 ChannelID
// 充当 SenderID 会让**任何群成员**都顶着群号去撞白名单——见 group 分支。
msg.ChannelID = senderID
}
return msg
}
// parseMessage extracts the normalized parts of an event's message payload,
// handling both array-format segments and the CQ-code string format. hasText
// reports whether the message carries any real text (as opposed to only media /
// placeholder markers), which drives ContentType selection.
func (a *Adapter) parseMessage(ev *obEvent) (text string, media []adapter.Media, mentions []adapter.Mention, replyID, replyText string, hasText bool) {
raw := ev.Message
if len(raw) == 0 {
raw = []byte(ev.RawMessage)
}
if len(raw) > 0 {
switch raw[0] {
case '[':
return parseSegmentArray(raw)
case '"':
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return parseCQMessage(s)
}
}
}
return parseCQMessage(ev.RawMessage)
}
// parseSegmentArray parses an array-format message.
func parseSegmentArray(raw []byte) (string, []adapter.Media, []adapter.Mention, string, string, bool) {
var segs []obSegment
_ = json.Unmarshal(raw, &segs)
var b strings.Builder
var media []adapter.Media
var mentions []adapter.Mention
var replyID, replyText string
hasText := false
for _, s := range segs {
switch s.Type {
case "text":
t := segStr(s.Data, "text")
b.WriteString(t)
if t != "" {
hasText = true
}
case "at":
qq := segStr(s.Data, "qq")
if qq != "" {
mentions = append(mentions, adapter.Mention{UserID: qq})
b.WriteString("@" + qq)
}
case "image":
media = append(media, mediaFromSeg(s.Data, adapter.MediaImage))
b.WriteString("[图片]")
case "record":
media = append(media, mediaFromSeg(s.Data, adapter.MediaVoice))
b.WriteString("[语音]")
case "video":
media = append(media, mediaFromSeg(s.Data, adapter.MediaVideo))
b.WriteString("[视频]")
case "file":
media = append(media, mediaFromSeg(s.Data, adapter.MediaFile))
b.WriteString("[文件]")
case "face":
b.WriteString("[表情]")
case "reply":
if id := segStr(s.Data, "id"); id != "" {
replyID = id
}
if t := segStr(s.Data, "text"); t != "" {
replyText = t
}
b.WriteString("[回复]")
case "json", "card":
b.WriteString("[卡片消息]")
default:
b.WriteString("[未知消息]")
}
}
return b.String(), media, mentions, replyID, replyText, hasText
}
// parseCQMessage parses a CQ-code string, yielding text, media, mentions, and a
// hasText flag indicating real (non-placeholder) content. It scans for
// [CQ:type,...] codes manually (no package-level regexp, per the no-global-var
// constraint).
func parseCQMessage(s string) (string, []adapter.Media, []adapter.Mention, string, string, bool) {
var b strings.Builder
var media []adapter.Media
var mentions []adapter.Mention
var replyID, replyText string
hasText := false
last := 0
for i := 0; i < len(s); {
if !strings.HasPrefix(s[i:], "[CQ:") {
i++
continue
}
end := strings.IndexByte(s[i:], ']')
if end < 0 {
break
}
fragment := s[last:i]
b.WriteString(fragment)
if fragment != "" {
hasText = true
}
code := s[i : i+end+1]
typ, params := decodeCQCode(code)
switch typ {
case "at":
if qq := params["qq"]; qq != "" {
mentions = append(mentions, adapter.Mention{UserID: qq})
b.WriteString("@" + qq)
}
case "image":
media = append(media, mediaFromParams(params, adapter.MediaImage))
b.WriteString("[图片]")
case "record":
media = append(media, mediaFromParams(params, adapter.MediaVoice))
b.WriteString("[语音]")
case "video":
media = append(media, mediaFromParams(params, adapter.MediaVideo))
b.WriteString("[视频]")
case "file":
media = append(media, mediaFromParams(params, adapter.MediaFile))
b.WriteString("[文件]")
case "face":
b.WriteString("[表情]")
case "reply":
if id := params["id"]; id != "" {
replyID = id
}
replyText = params["text"]
b.WriteString("[回复]")
case "json", "card":
b.WriteString("[卡片消息]")
default:
b.WriteString(code)
}
last = i + end + 1
i = last
}
tail := s[last:]
b.WriteString(tail)
if tail != "" {
hasText = true
}
return b.String(), media, mentions, replyID, replyText, hasText
}
func decodeCQCode(code string) (string, map[string]string) {
inner := strings.TrimPrefix(code, "[CQ:")
inner = strings.TrimSuffix(inner, "]")
parts := strings.SplitN(inner, ",", 2)
typ := parts[0]
params := map[string]string{}
if len(parts) == 2 && parts[1] != "" {
for _, kv := range strings.Split(parts[1], ",") {
kvParts := strings.SplitN(kv, "=", 2)
if len(kvParts) != 2 {
continue
}
params[kvParts[0]] = unescapeCQ(kvParts[1])
}
}
return typ, params
}
func unescapeCQ(s string) string {
s = strings.ReplaceAll(s, "&#44;", ",")
s = strings.ReplaceAll(s, "&#91;", "[")
s = strings.ReplaceAll(s, "&#93;", "]")
s = strings.ReplaceAll(s, "&amp;", "&")
s = strings.ReplaceAll(s, "&#40;", "(")
s = strings.ReplaceAll(s, "&#41;", ")")
return s
}
// mediaFromSeg builds a Media from an array-format segment's data map.
func mediaFromSeg(d map[string]any, kind adapter.MediaKind) adapter.Media {
url := segStr(d, "url")
file := segStr(d, "file")
if url == "" {
url = file
}
return adapter.Media{Kind: kind, URL: url, Name: file}
}
// mediaFromParams builds a Media from parsed CQ params.
func mediaFromParams(p map[string]string, kind adapter.MediaKind) adapter.Media {
url := p["url"]
file := p["file"]
if url == "" {
url = file
}
return adapter.Media{Kind: kind, URL: url, Name: file}
}
// outboundSegments converts a unified message into OneBot message segments.
func outboundSegments(msg adapter.UnifiedMessage) ([]obSegment, error) {
var segs []obSegment
if msg.Content != "" {
segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}})
}
for _, m := range msg.Media {
file := firstNonEmpty(m.URL, m.Name)
switch m.Kind {
case adapter.MediaImage:
segs = append(segs, obSegment{Type: "image", Data: map[string]any{"file": file}})
case adapter.MediaVoice:
segs = append(segs, obSegment{Type: "record", Data: map[string]any{"file": file}})
case adapter.MediaVideo:
segs = append(segs, obSegment{Type: "video", Data: map[string]any{"file": file}})
case adapter.MediaFile:
segs = append(segs, obSegment{Type: "file", Data: map[string]any{"file": file}})
}
}
if len(segs) == 0 {
if msg.Content == "" {
return nil, fmt.Errorf("qq-obv11: message has no content or media: %w", adapter.ErrUnsupported)
}
segs = append(segs, obSegment{Type: "text", Data: map[string]any{"text": msg.Content}})
}
return segs, nil
}
// resolveTarget determines where a reply should be delivered.
func resolveTarget(msg adapter.UnifiedMessage) (messageType string, userID, groupID int64, err error) {
if msg.ChannelType == adapter.ChannelGroup {
gid := firstNonEmpty(msg.GroupID, msg.ChannelID, msg.Sender.GroupID)
if gid == "" {
return "", 0, 0, fmt.Errorf("qq-obv11: group reply missing target: %w", adapter.ErrUnsupported)
}
g, convErr := parseInt64(gid)
if convErr != nil {
return "", 0, 0, fmt.Errorf("qq-obv11: group target %q: %w", gid, convErr)
}
return "group", 0, g, nil
}
uid := firstNonEmpty(msg.ChannelID, msg.SenderID, msg.Sender.UserID)
if uid == "" {
return "", 0, 0, fmt.Errorf("qq-obv11: private reply missing target: %w", adapter.ErrUnsupported)
}
u, convErr := parseInt64(uid)
if convErr != nil {
return "", 0, 0, fmt.Errorf("qq-obv11: user target %q: %w", uid, convErr)
}
return "private", u, 0, nil
}
func buildSendReq(messageType string, userID, groupID int64, segs []obSegment, echo string) ([]byte, error) {
req := obSendMsg{
Action: "send_msg",
Params: obSendParams{
MessageType: messageType,
UserID: userID,
GroupID: groupID,
Message: segs,
AutoEscape: false,
},
Echo: echo,
}
b, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("qq-obv11: encode send_msg: %w", err)
}
return b, nil
}
// contentOf picks the primary content type. When the message has no real text
// (only media / placeholder markers), it reflects the first media kind;
// otherwise text is the default.
func contentOf(media []adapter.Media, hasText bool) adapter.ContentType {
if !hasText && len(media) > 0 {
switch media[0].Kind {
case adapter.MediaImage:
return adapter.ContentImage
case adapter.MediaVoice:
return adapter.ContentVoice
default:
return adapter.ContentFile
}
}
return adapter.ContentText
}
// roleFromOneBot 把 OneBot 上报的 sender.role 映射到 actor 的 Role 枚举。
// **群主(owner)与群管理员(admin)一律映射成 RoleMember**(裁决 06.4.2):平台字段
// 只描述群内身份,不是主人车道凭据——主人只认白名单(owner.yaml + 每渠道 adminIds[]),
// 所以群主/管理员既绕不过限流与黑白名单,也进不了主会话;未知角色值保守归入 RoleUnknown。
func roleFromOneBot(raw string) adapter.Role {
switch raw {
case "owner", "admin", "member":
return adapter.RoleMember
default:
return adapter.RoleUnknown
}
}
func segStr(d map[string]any, key string) string {
if d == nil {
return ""
}
switch v := d[key].(type) {
case string:
return v
case float64:
return strconv.FormatInt(int64(v), 10)
case json.Number:
return v.String()
default:
return ""
}
}