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)。
463 lines
15 KiB
Go
463 lines
15 KiB
Go
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, ",", ",")
|
||
s = strings.ReplaceAll(s, "[", "[")
|
||
s = strings.ReplaceAll(s, "]", "]")
|
||
s = strings.ReplaceAll(s, "&", "&")
|
||
s = strings.ReplaceAll(s, "(", "(")
|
||
s = strings.ReplaceAll(s, ")", ")")
|
||
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 ""
|
||
}
|
||
}
|