fix: platform_silent记忆提取 + 群聊上下文整合 + 多QQ实例支持
- platform_silent模式接入Orchestrator记忆提取:被动观察群聊时提取值得记住的信息到对应命名空间 - post_chat后台思考注入平台观察:对话后思考也能看到群聊摘要 - QQ适配器:OneBot v11 self_id动态捕获、CQ图片URL提取、视觉+OCR并行处理 - Router解耦:ConfigName/PlatformName分离,支持多QQ实例独立连接 - 黑白名单功能:后端API + Ethend代理 + UI面板 - \n\n双换行断句:AI回复按双换行分割为多条消息按间隔发送 - @提及修复:bot自感知UID进行@检测 - 群聊上下文共享:channel-based userID避免记忆碎片化 - 消息日志显示处理后内容而非原始SSE数据 - platform-bridge Dockerfile + docker-compose.yml更新 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -17,31 +19,51 @@ var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// Adapter implements PlatformAdapter for QQ via OBv11 WebSocket.
|
||||
// Adapter implements PlatformAdapter for QQ via OneBot v11 WebSocket.
|
||||
// Supports two modes:
|
||||
// - "server" (正向 WS): adapter starts a WS server, NapCat connects as client.
|
||||
// - "client" (反向 WS): adapter connects to NapCat's WS server as a client.
|
||||
type Adapter struct {
|
||||
port string
|
||||
conn *websocket.Conn
|
||||
connMu sync.Mutex
|
||||
connected bool
|
||||
configName string // instance name, e.g. "qq-home"
|
||||
mode string // "client" or "server"
|
||||
port string
|
||||
accessToken string
|
||||
remoteURL string // NapCat OneBot WS server URL, used in client mode
|
||||
sendIntervalMs int // minimum interval between consecutive messages
|
||||
selfID string // bot's own QQ number, populated from incoming messages
|
||||
conn *websocket.Conn
|
||||
connMu sync.Mutex
|
||||
connected bool
|
||||
srv *http.Server // HTTP server for WS upgrades (server mode only)
|
||||
|
||||
// Pending API call responses.
|
||||
pendingResponses map[string]chan *OBv11APIResponse
|
||||
respMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewAdapter(port string) *Adapter {
|
||||
func NewAdapter(configName, mode, port, accessToken, remoteURL string, sendIntervalMs int) *Adapter {
|
||||
if mode == "" {
|
||||
mode = "server"
|
||||
}
|
||||
return &Adapter{
|
||||
configName: configName,
|
||||
mode: mode,
|
||||
port: port,
|
||||
accessToken: accessToken,
|
||||
remoteURL: remoteURL,
|
||||
sendIntervalMs: sendIntervalMs,
|
||||
pendingResponses: make(map[string]chan *OBv11APIResponse),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Adapter) PlatformName() string { return "qq" }
|
||||
func (a *Adapter) PlatformName() string { return "qq" }
|
||||
func (a *Adapter) ConfigName() string { return a.configName }
|
||||
func (a *Adapter) SendIntervalMs() int { return a.sendIntervalMs }
|
||||
func (a *Adapter) SelfID() string { return a.selfID }
|
||||
|
||||
func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
|
||||
return bridge.PlatformCapabilities{
|
||||
MaxMessageLength: 200,
|
||||
SupportsMarkdown: true, // QQ supports basic markdown
|
||||
SupportsMarkdown: true,
|
||||
SupportsImage: true,
|
||||
SupportsVoice: false,
|
||||
SupportsEmoji: true,
|
||||
@@ -51,38 +73,101 @@ func (a *Adapter) Capabilities() bridge.PlatformCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
// checkAuth 验证 WebSocket 升级请求的 access_token。
|
||||
// NapCat 通过两种方式传递: query 参数 ?access_token=xxx 或 Authorization: Bearer xxx 头.
|
||||
func (a *Adapter) checkAuth(r *http.Request) bool {
|
||||
if a.accessToken == "" {
|
||||
return true // 未配置 token,允许所有连接
|
||||
}
|
||||
// 1) query 参数
|
||||
if r.URL.Query().Get("access_token") == a.accessToken {
|
||||
return true
|
||||
}
|
||||
// 2) Authorization: Bearer <token>
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == a.accessToken {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wsHandler 统一的 WebSocket 连接处理 — 单连接承载 API 调用 + 事件推送 (OneBot 正向 WS).
|
||||
func (a *Adapter) wsHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if !a.checkAuth(r) {
|
||||
http.Error(w, "Forbidden: invalid access_token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("[qq] upgrade error: %v\n", err)
|
||||
return
|
||||
}
|
||||
a.connMu.Lock()
|
||||
// 关闭旧连接 (NapCat 重连)
|
||||
if a.conn != nil {
|
||||
a.conn.Close()
|
||||
}
|
||||
a.conn = conn
|
||||
a.connected = true
|
||||
a.connMu.Unlock()
|
||||
fmt.Println("[qq] NapCat/OneBot connected (正向WS)")
|
||||
}
|
||||
|
||||
// legacyHandler 兼容旧的路径 /ws/qq 和 /ws/qq/event.
|
||||
func (a *Adapter) legacyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Printf("[qq] legacy WS path %s connected (consider changing NapCat URL to root /)\n", r.URL.Path)
|
||||
a.wsHandler(w, r)
|
||||
}
|
||||
|
||||
func (a *Adapter) Connect(ctx context.Context) error {
|
||||
if a.mode == "client" {
|
||||
return a.connectClient(ctx)
|
||||
}
|
||||
return a.connectServer()
|
||||
}
|
||||
|
||||
func (a *Adapter) connectClient(ctx context.Context) error {
|
||||
url := a.remoteURL
|
||||
if a.accessToken != "" {
|
||||
sep := "?"
|
||||
if strings.Contains(url, "?") {
|
||||
sep = "&"
|
||||
}
|
||||
url += sep + "access_token=" + a.accessToken
|
||||
}
|
||||
|
||||
dialer := websocket.DefaultDialer
|
||||
conn, _, err := dialer.DialContext(ctx, url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial NapCat WS %s: %w", url, err)
|
||||
}
|
||||
|
||||
a.connMu.Lock()
|
||||
if a.conn != nil {
|
||||
a.conn.Close()
|
||||
}
|
||||
a.conn = conn
|
||||
a.connected = true
|
||||
a.connMu.Unlock()
|
||||
|
||||
fmt.Printf("[qq] connected to NapCat OneBot WS (client mode): %s\n", a.remoteURL)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Adapter) connectServer() 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")
|
||||
})
|
||||
mux.HandleFunc("/", a.wsHandler) // NapCat 正向 WS 标准路径
|
||||
mux.HandleFunc("/ws/qq", a.legacyHandler) // 向下兼容旧配置
|
||||
mux.HandleFunc("/ws/qq/event", a.legacyHandler) // 向下兼容旧配置
|
||||
|
||||
addr := ":" + a.port
|
||||
srv := &http.Server{Addr: addr, Handler: mux}
|
||||
a.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] WebSocket server on %s (waiting for NapCat forward WS connection)\n", addr)
|
||||
if a.accessToken != "" {
|
||||
fmt.Println("[qq] access_token 已配置,将验证连接请求")
|
||||
}
|
||||
if err := a.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Printf("[qq] server error: %v\n", err)
|
||||
}
|
||||
}()
|
||||
@@ -91,12 +176,22 @@ func (a *Adapter) Connect(ctx context.Context) error {
|
||||
|
||||
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
|
||||
srv := a.srv
|
||||
a.srv = nil
|
||||
a.connMu.Unlock()
|
||||
|
||||
if srv != nil {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
return fmt.Errorf("qq server shutdown: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -120,10 +215,8 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
|
||||
return nil, fmt.Errorf("expected *OBv11Message, got %T", rawMessage)
|
||||
}
|
||||
|
||||
// Extract text content.
|
||||
content := extractText(msg)
|
||||
|
||||
// Determine sender.
|
||||
senderID := ""
|
||||
senderName := "unknown"
|
||||
channelType := "direct"
|
||||
@@ -150,7 +243,6 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
|
||||
}
|
||||
}
|
||||
|
||||
// Extract mentions.
|
||||
var mentions []string
|
||||
if segments, ok := msg.Message.([]interface{}); ok {
|
||||
for _, s := range segments {
|
||||
@@ -166,6 +258,8 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
|
||||
}
|
||||
}
|
||||
|
||||
attachments := extractAttachments(msg)
|
||||
|
||||
return &bridge.UnifiedMessage{
|
||||
SenderID: senderID,
|
||||
SenderName: senderName,
|
||||
@@ -176,6 +270,7 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
|
||||
ContentType: "text",
|
||||
MessageID: fmt.Sprintf("%d", msg.MessageID),
|
||||
Mentions: mentions,
|
||||
Attachments: attachments,
|
||||
RawData: rawMessage,
|
||||
Timestamp: time.Unix(msg.Time, 0),
|
||||
}, nil
|
||||
@@ -189,7 +284,6 @@ func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.Platfo
|
||||
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])
|
||||
@@ -240,6 +334,14 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
|
||||
conn := a.conn
|
||||
a.connMu.Unlock()
|
||||
if conn == nil {
|
||||
// Client mode: auto-reconnect when connection is lost.
|
||||
if a.mode == "client" {
|
||||
if err := a.connectClient(ctx); err != nil {
|
||||
fmt.Printf("[qq] reconnect failed: %v, retrying in 3s...\n", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
continue
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
@@ -248,6 +350,7 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
|
||||
if err != nil {
|
||||
fmt.Printf("[qq] read error: %v\n", err)
|
||||
a.connMu.Lock()
|
||||
a.conn.Close()
|
||||
a.conn = nil
|
||||
a.connected = false
|
||||
a.connMu.Unlock()
|
||||
@@ -258,7 +361,6 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
|
||||
// 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))
|
||||
@@ -275,7 +377,12 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
|
||||
continue
|
||||
}
|
||||
|
||||
// Only handle message events.
|
||||
// Capture bot's own QQ number from incoming messages.
|
||||
if msg.SelfID != 0 && a.selfID == "" {
|
||||
a.selfID = fmt.Sprintf("%d", msg.SelfID)
|
||||
fmt.Printf("[qq:%s] self ID captured: %s\n", a.configName, a.selfID)
|
||||
}
|
||||
|
||||
if msg.PostType == "message" {
|
||||
select {
|
||||
case msgCh <- &msg:
|
||||
@@ -312,13 +419,64 @@ func extractText(msg *OBv11Message) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
var cqImageRegex = regexp.MustCompile(`\[CQ:image,[^\]]*\]`)
|
||||
var cqURLRegex = regexp.MustCompile(`\burl=([^,\]]+)`)
|
||||
|
||||
// extractAttachments extracts image URLs from OBv11Message.
|
||||
// Handles both string format (CQ codes in raw_message) and array format (parsed segments).
|
||||
func extractAttachments(msg *OBv11Message) []bridge.Attachment {
|
||||
var attachments []bridge.Attachment
|
||||
|
||||
// Array format: iterate segments looking for type="image".
|
||||
if segments, ok := msg.Message.([]interface{}); ok {
|
||||
for _, s := range segments {
|
||||
if seg, ok := s.(map[string]interface{}); ok {
|
||||
if seg["type"] != "image" {
|
||||
continue
|
||||
}
|
||||
data, _ := seg["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
continue
|
||||
}
|
||||
url, _ := data["url"].(string)
|
||||
file, _ := data["file"].(string)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
attachments = append(attachments, bridge.Attachment{
|
||||
Type: "image",
|
||||
URL: url,
|
||||
FileName: file,
|
||||
})
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
// String format: parse CQ codes from RawMessage or string Message.
|
||||
raw := msg.RawMessage
|
||||
if raw == "" {
|
||||
if s, ok := msg.Message.(string); ok {
|
||||
raw = s
|
||||
}
|
||||
}
|
||||
matches := cqImageRegex.FindAllString(raw, -1)
|
||||
for _, m := range matches {
|
||||
urlMatch := cqURLRegex.FindStringSubmatch(m)
|
||||
if len(urlMatch) >= 2 {
|
||||
attachments = append(attachments, bridge.Attachment{
|
||||
Type: "image",
|
||||
URL: urlMatch[1],
|
||||
})
|
||||
}
|
||||
}
|
||||
return attachments
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -332,7 +490,6 @@ func removeHeadings(s string) string {
|
||||
}
|
||||
|
||||
func removeCodeBlocks(s string) string {
|
||||
// Simple: remove ``` markers.
|
||||
result := ""
|
||||
inCode := false
|
||||
for _, line := range splitLines(s) {
|
||||
@@ -376,7 +533,6 @@ func stripPrefix(s, prefix string) string {
|
||||
}
|
||||
|
||||
func replaceLine(s, old, new string) string {
|
||||
// Simple: find old line and replace with new.
|
||||
idx := indexOf(s, old)
|
||||
if idx < 0 {
|
||||
return s
|
||||
|
||||
Reference in New Issue
Block a user