Files
Cyrene_v2/plugins/channels/qq_obv11/message.go
T
AskaEth b26bc55a15 feat(channel): 消息契约加「她自己账号 id / 是否在叫她」结构化判据并接线 qq_obv11
契约(internal/channel/adapter):UnifiedMessage 新增三个字段 —— SelfID(她自己在该
平台的账号 id)、MentionAll(是否 @全体,design/26 §2.4 的 mention_all)、AddressedToMe
(本条是否指向她本人);判据的唯一出处是新增的 MentionedSelf(结构化 id 精确比对,
空 selfID 一律 false)与 AddressedToSelf(私聊恒 true;群聊 = 点名她或 @全体;
平台未给 SelfID ⇒ fail-closed)。全程不匹配正文文本。

接线(plugins/channels/qq_obv11):新增 addressed.go,把事件里早已解出、此前只用于
debug 日志与 notice 判定的 self_id 接进 toUnified,并从 at 段解出 @全体(qq=all),
在 ChannelType 判定之后统一回写三个字段。

design/18 §2 触发① 的旧口径「Mentions 非空」= @谁不论(实测群 672432767 里 @别人两次
被回);本片让消费侧(light.go:142,归 M12)能改用 AddressedToMe || MentionAll。
既有字段 Mentions/ReplyToID/ReplyToText 语义与形状不变。

测试:qq_obv11 十种帧形态(私聊/@她/@别人/@全体/无@/CQ 形态/fail-closed/纯回复)
逐条断言;adapter 侧覆盖 MentionedSelf 与 AddressedToSelf。变异自证:判据改回
「Mentions 非空」⇒ group_mention_other 红;摘掉 selfId 接线 ⇒ group_mention_me 红。
2026-10-13 20:17:56 +08:00

490 lines
16 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
// 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"
// atSegmentType 是「@某人」在**数组段**里的段类型(`{"type":"at"}`),cqPrefix 则是
// **CQ 码**形态里每个码的起始(`[CQ:at,qq=...]`)。归一解析与入站帧脱敏日志
// (inbound_debug.go)共用这两个字面量,避免同一处协议知识各写一份。
atSegmentType = "at"
cqPrefix = "[CQ:"
)
// OneBot v11 event as delivered over WebSocket.
//
// 字段集覆盖**全部四类** post_type(design/26 §3.1):message(message_type)、
// notice(notice_type / sub_type)、request(request_type)、meta_event
// (meta_event_type),外加 go-cqhttp 扩展的 message_sent。
//
// 刻意**不声明** `flag`(加好友 / 加群请求的可执行凭据)与 `comment`(验证信息):
// 它们分别等同 token 与用户生成文本,design/26 §4.2 明令不得入库、不得进日志。
// 不建字段,就没有任何一条路径能把它们带进结构化字段(要「自动同意」时再从内存载荷
// 单次取用,绝不落库)。同理不声明 `file` / `sender.*` 里的头像等隐私字段,也不声明
// `group_ban.duration`——它只说明时长,判定「全员禁言」用 `user_id == 0` 即足
// (多投影一个用不到的数字,只会多一处可被误用的字段)。
type obEvent struct {
PostType string `json:"post_type"`
MessageType string `json:"message_type"`
NoticeType string `json:"notice_type"`
MetaEventType string `json:"meta_event_type"`
RequestType string `json:"request_type"`
Time int64 `json:"time"`
SelfID int64 `json:"self_id"`
MessageID int64 `json:"message_id"`
UserID int64 `json:"user_id"`
TargetID int64 `json:"target_id"`
GroupID int64 `json:"group_id"`
GroupName string `json:"group_name"`
SubType string `json:"sub_type"`
OperatorID int64 `json:"operator_id"`
Echo string `json:"echo"`
RawMessage string `json:"raw_message"`
Message json.RawMessage `json:"message"`
Sender *obSender `json:"sender"`
}
// hitsSelf 报告该事件是否命中**她自己**(design/26 §2.2 的「self 命中升档」判据):
// 发起者 / 被作用者 / 操作者任一等于 self_id 即算命中。判据全是**结构化 id**,
// 与正文措辞无关,也绝不参与判主。
func (ev *obEvent) hitsSelf() bool {
if ev == nil || ev.SelfID == 0 {
return false
}
return ev.UserID == ev.SelfID || ev.TargetID == ev.SelfID || ev.OperatorID == ev.SelfID
}
// 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)
// selfID 是**她自己**在该平台的账号 id(OneBot 的 self_id),只用于
// 「本条是否指向她」的结构化判定(见 applyAddressing),绝不参与判主。
selfID := formatID(ev.SelfID)
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
}
// 结构化「是否在叫她」接线必须在 ChannelType 判定之后(私聊恒 true)。
applyAddressing(&msg, selfID, mentions)
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 := eventRawMessage(ev)
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 atSegmentType:
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:], cqPrefix) {
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 atSegmentType:
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 ""
}
}