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 "" } }