feat(channels/qq_obv11): OneBot v11 渠道插件(正/反 WS 双模式 + CQ 媒体 + 限频,stdlib 自研 WS)
This commit is contained in:
1 parent
f9f5a78a02
commit
7831b0ea1b
11 files changed
+2526
No files matched your search
@@ -0,0 +1,470 @@
|
||||
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 := formatID(ev.UserID)
|
||||
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":
|
||||
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
|
||||
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 maps the OneBot sender role field onto the actor Role enum.
|
||||
// It surfaces the platform role verbatim as structured actor metadata; the
|
||||
// channel-layer policy engine still decides real access from the bound owner
|
||||
// identity, not from this field alone.
|
||||
func roleFromOneBot(raw string) adapter.Role {
|
||||
switch raw {
|
||||
case "owner":
|
||||
return adapter.RoleOwner
|
||||
case "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 ""
|
||||
}
|
||||
}
|
||||
|
||||
func formatID(id int64) string {
|
||||
if id == 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
func parseInt64(s string) (int64, error) {
|
||||
if s == "" {
|
||||
return 0, fmt.Errorf("empty id")
|
||||
}
|
||||
return strconv.ParseInt(s, 10, 64)
|
||||
}
|
||||
|
||||
func firstNonEmpty(ss ...string) string {
|
||||
for _, s := range ss {
|
||||
if s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in new issue
Block a user