965cce7192
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
395 lines
8.8 KiB
Go
395 lines
8.8 KiB
Go
package qq
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
"github.com/yourname/cyrene-ai/platform-bridge/internal/bridge"
|
|
)
|
|
|
|
var upgrader = websocket.Upgrader{
|
|
CheckOrigin: func(r *http.Request) bool { return true },
|
|
}
|
|
|
|
// Adapter implements PlatformAdapter for QQ via OBv11 WebSocket.
|
|
type Adapter struct {
|
|
port string
|
|
conn *websocket.Conn
|
|
connMu sync.Mutex
|
|
connected bool
|
|
|
|
// Pending API call responses.
|
|
pendingResponses map[string]chan *OBv11APIResponse
|
|
respMu sync.Mutex
|
|
}
|
|
|
|
func NewAdapter(port string) *Adapter {
|
|
return &Adapter{
|
|
port: port,
|
|
pendingResponses: make(map[string]chan *OBv11APIResponse),
|
|
}
|
|
}
|
|
|
|
func (a *Adapter) PlatformName() string { return "qq" }
|
|
|
|
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
|
|
return bridge.PlatformCapabilities{
|
|
MaxMessageLength: 200,
|
|
SupportsMarkdown: true, // QQ supports basic markdown
|
|
SupportsImage: true,
|
|
SupportsVoice: false,
|
|
SupportsEmoji: true,
|
|
SupportsReaction: false,
|
|
SupportsTypingHint: false,
|
|
RecommendBurstMax: 4,
|
|
}
|
|
}
|
|
|
|
func (a *Adapter) Connect(ctx context.Context) error {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/ws/qq", func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
fmt.Printf("[qq] upgrade error: %v\n", err)
|
|
return
|
|
}
|
|
a.connMu.Lock()
|
|
a.conn = conn
|
|
a.connected = true
|
|
a.connMu.Unlock()
|
|
fmt.Println("[qq] bot connected")
|
|
})
|
|
mux.HandleFunc("/ws/qq/event", func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := upgrader.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
fmt.Printf("[qq] event upgrade error: %v\n", err)
|
|
return
|
|
}
|
|
a.connMu.Lock()
|
|
a.conn = conn
|
|
a.connected = true
|
|
a.connMu.Unlock()
|
|
fmt.Println("[qq] event WebSocket connected")
|
|
})
|
|
|
|
addr := ":" + a.port
|
|
srv := &http.Server{Addr: addr, Handler: mux}
|
|
go func() {
|
|
fmt.Printf("[qq] listening on %s (waiting for bot WebSocket connection)\n", addr)
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
fmt.Printf("[qq] server error: %v\n", err)
|
|
}
|
|
}()
|
|
return nil
|
|
}
|
|
|
|
func (a *Adapter) Disconnect(ctx context.Context) error {
|
|
a.connMu.Lock()
|
|
defer a.connMu.Unlock()
|
|
if a.conn != nil {
|
|
a.conn.Close()
|
|
a.conn = nil
|
|
}
|
|
a.connected = false
|
|
return nil
|
|
}
|
|
|
|
func (a *Adapter) IsConnected() bool {
|
|
a.connMu.Lock()
|
|
defer a.connMu.Unlock()
|
|
return a.connected
|
|
}
|
|
|
|
func (a *Adapter) HealthCheck() error {
|
|
if !a.IsConnected() {
|
|
return fmt.Errorf("QQ bot not connected")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ToUnified converts an OBv11 message to UnifiedMessage.
|
|
func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, error) {
|
|
msg, ok := rawMessage.(*OBv11Message)
|
|
if !ok {
|
|
return nil, fmt.Errorf("expected *OBv11Message, got %T", rawMessage)
|
|
}
|
|
|
|
// Extract text content.
|
|
content := extractText(msg)
|
|
|
|
// Determine sender.
|
|
senderID := ""
|
|
senderName := "unknown"
|
|
channelType := "direct"
|
|
channelID := ""
|
|
|
|
switch msg.MessageType {
|
|
case "private":
|
|
senderID = fmt.Sprintf("%d", msg.UserID)
|
|
channelType = "direct"
|
|
channelID = fmt.Sprintf("private_%d", msg.UserID)
|
|
if msg.Sender != nil {
|
|
senderName = msg.Sender.Nickname
|
|
}
|
|
case "group":
|
|
senderID = fmt.Sprintf("%d", msg.UserID)
|
|
channelType = "group"
|
|
channelID = fmt.Sprintf("%d", msg.GroupID)
|
|
if msg.Sender != nil {
|
|
if msg.Sender.Card != "" {
|
|
senderName = msg.Sender.Card
|
|
} else {
|
|
senderName = msg.Sender.Nickname
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract mentions.
|
|
var mentions []string
|
|
if segments, ok := msg.Message.([]interface{}); ok {
|
|
for _, s := range segments {
|
|
if seg, ok := s.(map[string]interface{}); ok {
|
|
if seg["type"] == "at" {
|
|
if data, ok := seg["data"].(map[string]interface{}); ok {
|
|
if qq, ok := data["qq"].(string); ok {
|
|
mentions = append(mentions, qq)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return &bridge.UnifiedMessage{
|
|
SenderID: senderID,
|
|
SenderName: senderName,
|
|
Platform: "qq",
|
|
ChannelID: channelID,
|
|
ChannelType: channelType,
|
|
Content: content,
|
|
ContentType: "text",
|
|
MessageID: fmt.Sprintf("%d", msg.MessageID),
|
|
Mentions: mentions,
|
|
RawData: rawMessage,
|
|
Timestamp: time.Unix(msg.Time, 0),
|
|
}, nil
|
|
}
|
|
|
|
// FromUnified converts a unified response to QQ platform messages.
|
|
func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.PlatformMessage, error) {
|
|
var msgs []bridge.PlatformMessage
|
|
for _, rm := range response.Messages {
|
|
content := rm.Content
|
|
if rm.FormatMode == "markdown" {
|
|
content = convertMarkdownToQQ(rm.Content)
|
|
}
|
|
// QQ prefers shorter messages — split if needed.
|
|
runes := []rune(content)
|
|
if len(runes) > 200 {
|
|
content = string(runes[:200])
|
|
}
|
|
msgs = append(msgs, bridge.PlatformMessage{
|
|
Content: content,
|
|
ReplyTo: response.ReplyTo,
|
|
FormatMode: "text",
|
|
})
|
|
}
|
|
return msgs, nil
|
|
}
|
|
|
|
// SendMessage sends a message through the QQ WebSocket connection.
|
|
func (a *Adapter) SendMessage(msgType string, userID, groupID int64, content string) error {
|
|
a.connMu.Lock()
|
|
conn := a.conn
|
|
a.connMu.Unlock()
|
|
if conn == nil {
|
|
return fmt.Errorf("QQ bot not connected")
|
|
}
|
|
|
|
req := OBv11SendMsg{
|
|
Action: "send_msg",
|
|
Params: OBv11Params{
|
|
MessageType: msgType,
|
|
UserID: userID,
|
|
GroupID: groupID,
|
|
Message: content,
|
|
},
|
|
Echo: fmt.Sprintf("echo_%d", time.Now().UnixNano()),
|
|
}
|
|
|
|
data, _ := json.Marshal(req)
|
|
return conn.WriteMessage(websocket.TextMessage, data)
|
|
}
|
|
|
|
// ReadMessages reads OBv11 messages from the WebSocket connection.
|
|
func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
a.connMu.Lock()
|
|
conn := a.conn
|
|
a.connMu.Unlock()
|
|
if conn == nil {
|
|
time.Sleep(time.Second)
|
|
continue
|
|
}
|
|
|
|
_, raw, err := conn.ReadMessage()
|
|
if err != nil {
|
|
fmt.Printf("[qq] read error: %v\n", err)
|
|
a.connMu.Lock()
|
|
a.conn = nil
|
|
a.connected = false
|
|
a.connMu.Unlock()
|
|
time.Sleep(time.Second)
|
|
continue
|
|
}
|
|
|
|
// Try to parse as OBv11 message (event from QQ).
|
|
var msg OBv11Message
|
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
|
// Might be an API response.
|
|
var resp OBv11APIResponse
|
|
if err := json.Unmarshal(raw, &resp); err != nil {
|
|
fmt.Printf("[qq] unknown message: %s\n", string(raw))
|
|
continue
|
|
}
|
|
if resp.Echo != "" {
|
|
a.respMu.Lock()
|
|
if ch, ok := a.pendingResponses[resp.Echo]; ok {
|
|
ch <- &resp
|
|
delete(a.pendingResponses, resp.Echo)
|
|
}
|
|
a.respMu.Unlock()
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Only handle message events.
|
|
if msg.PostType == "message" {
|
|
select {
|
|
case msgCh <- &msg:
|
|
case <-ctx.Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// extractText retrieves plain text from an OBv11 message.
|
|
func extractText(msg *OBv11Message) string {
|
|
if msg.RawMessage != "" {
|
|
return msg.RawMessage
|
|
}
|
|
switch m := msg.Message.(type) {
|
|
case string:
|
|
return m
|
|
case []interface{}:
|
|
var text string
|
|
for _, seg := range m {
|
|
if s, ok := seg.(map[string]interface{}); ok {
|
|
if s["type"] == "text" {
|
|
if data, ok := s["data"].(map[string]interface{}); ok {
|
|
if t, ok := data["text"].(string); ok {
|
|
text += t
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return text
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// convertMarkdownToQQ converts common markdown to QQ-supported format.
|
|
func convertMarkdownToQQ(md string) string {
|
|
// QQ supports basic markdown: **bold**, *italic*, ~~strikethrough~~
|
|
// Remove unsupported elements (headings, code blocks, links).
|
|
md = removeHeadings(md)
|
|
md = removeCodeBlocks(md)
|
|
// Preserve bold, italic, strikethrough which QQ supports.
|
|
return md
|
|
}
|
|
|
|
func removeHeadings(s string) string {
|
|
for _, line := range splitLines(s) {
|
|
if len(line) > 0 && line[0] == '#' {
|
|
s = replaceLine(s, line, stripPrefix(line, "# "))
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func removeCodeBlocks(s string) string {
|
|
// Simple: remove ``` markers.
|
|
result := ""
|
|
inCode := false
|
|
for _, line := range splitLines(s) {
|
|
if hasPrefix(line, "```") {
|
|
inCode = !inCode
|
|
continue
|
|
}
|
|
if inCode {
|
|
result += line + "\n"
|
|
} else {
|
|
result += line + "\n"
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
var lines []string
|
|
start := 0
|
|
for i, c := range s {
|
|
if c == '\n' {
|
|
lines = append(lines, s[start:i])
|
|
start = i + 1
|
|
}
|
|
}
|
|
if start < len(s) {
|
|
lines = append(lines, s[start:])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func hasPrefix(s, prefix string) bool {
|
|
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
|
|
}
|
|
|
|
func stripPrefix(s, prefix string) string {
|
|
if len(s) >= len(prefix) && s[:len(prefix)] == prefix {
|
|
return s[len(prefix):]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func replaceLine(s, old, new string) string {
|
|
// Simple: find old line and replace with new.
|
|
idx := indexOf(s, old)
|
|
if idx < 0 {
|
|
return s
|
|
}
|
|
return s[:idx] + new + s[idx+len(old):]
|
|
}
|
|
|
|
func indexOf(s, sub string) int {
|
|
for i := 0; i <= len(s)-len(sub); i++ {
|
|
if s[i:i+len(sub)] == sub {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|