Files
Cyrene_v2/plugins/channels/qq_obv11/message.go
T
AskaEth eca1f33d8c feat(qq_obv11): 出站语音发真 record 段(base64 内联优先,读不到的 file 形态提前拒绝)
M59 QQRECORD:adapter.Media 里 Kind=voice 的条目落成 {"type":"record"} 段。正文仍是独立的 text 段排在 record 前面(QQ 语音是独立气泡,正文既不塞进 record 也不被吞掉)。

record 段 file 参数三档裁决(outboundRecordFile,本片核心决策):

1. Data 非空 => base64://+标准 base64。出站语音字节本就在本进程内产生(TTS),内联不依赖跨进程共享文件系统(Napcat 可能在容器/另一台机器),也不需要插件知道框架的 $DATA_DIR(插件没有那个配置面)。

2. 原样透传:已是 base64://、http(s)://、或 filepath.IsAbs 的本地绝对路径 —— 这三种 OneBot 实现自己能解析,一个字节都不改写。

3. 明确拒绝:服务端相对路径(/api/v1/media/<name>)、裸文件名、空值,提前失败并说明要什么。前缀判定必须排在 IsAbs 之前 —— Linux 上 /api/v1/media/x.wav 会被 IsAbs 判成绝对路径,透传只会得到一条看不出根因的平台错误。注:框架语音副本(voice.SaveAudio)与文档同目录、同内容寻址、同一张 documents 表,不存在「服务端相对路径 -> 本地绝对路径」的公开映射,所以拒绝该形态是正确裁决而非偷懒。

重构:outboundSegments 从 message.go 迁到 outbound_segments.go(message.go 489 行,再加代码会破 500 行上限),并收掉原实现里不可达的内层分支(len(segs)==0 时 Content 必为 "",补 text 段那条永远走不到)—— 行为不变。

分层:生产代码不 import internal/runtime/voice(渠道插件反向依赖运行时存储层会把分层倒过来),serverMediaPathPrefix 重复一份字面量,由测试交叉断言钉住同值。

测试:7 个用例(形状/顺序/透传/拒绝/零变化/前缀同值)。零变化用硬编码期望值而非快照文件。变异实测:删 MediaVoice 分支 => 5 个 voice 用例红;base64 编码换成空切片 => record file 断言红;删前缀判定 => server-relative 用例因文案不含 "server-relative" 红。均已恢复。

⚠️ 未在本机验证(无 Napcat 环境):WAV/MP3 直发是否被 Napcat 接受、silk 转码是否成功 —— 本片只钉拼装形状,需真机验证。

验收:go build ./... + go vet ./... + go test ./... 全绿(worktree wt-59)。
2026-10-27 20:44:18 +08:00

463 lines
15 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 from the
// native event/segment model into the normalized adapter.UnifiedMessage 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. 反方向的「统一消息 → 出站消息段」在 `outbound_segments.go`
// (入站与出站各有各的知识面,不混在一个文件里)。
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}
}
// 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 ""
}
}