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, ",", ",") 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} } // 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 "" } }