feat: QQ主动消息管道 + 戳一戳事件 + thinker提示词优化

- ProactiveTarget 结构体 + QQ目标正则解析(extractProactiveMessage 返回双值)
- storeThought 双路推送:platform消息 → platformMessagePusher,web消息 → messagePusher
- platform-bridge: SendProactive接口 + ProactiveSender + /api/v1/internal/send-proactive端点
- QQ戳一戳/notice事件 → noticeToUnified → ContentType "action"
- thinker提示词注入QQ频道上下文:群名称(群号)、活跃时间、trigger-aware引导
- SetBotUID/SetPlatformMessagePusher/AddOrUpdatePlatformChannel 方法
- ai-core source 增加 BotUID/GroupName,platform-bridge source 增加 bot_uid/group_name
- 支持 [CQ:at,qq=xxx] @提及标签自动拼接

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-22 20:26:36 +08:00
parent cd053194a9
commit cadd5f2233
9 changed files with 469 additions and 37 deletions
+2
View File
@@ -663,6 +663,8 @@ func forwardToAICore(cfg *config.Config, msg *bridge.UnifiedMessage, mode, userI
"channel_type": msg.ChannelType,
"sender_name": msg.SenderName,
"original_uid": msg.OriginalSenderUID,
"bot_uid": msg.BotUID,
"group_name": msg.GroupName,
},
}
if len(images) > 0 {
@@ -294,6 +294,11 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
return nil, fmt.Errorf("expected *OBv11Message, got %T", rawMessage)
}
// Handle notice events (e.g., poke/"戳一戳").
if msg.PostType == "notice" {
return a.noticeToUnified(msg)
}
content := extractText(msg)
senderID := ""
@@ -393,6 +398,55 @@ func (a *Adapter) ToUnified(rawMessage interface{}) (*bridge.UnifiedMessage, err
}, nil
}
// noticeToUnified converts an OBv11 notice event (poke/戳一戳 etc.) to a UnifiedMessage.
func (a *Adapter) noticeToUnified(msg *OBv11Message) (*bridge.UnifiedMessage, error) {
senderID := fmt.Sprintf("%d", msg.UserID)
senderName := senderID
channelType := "direct"
channelID := fmt.Sprintf("private_%d", msg.UserID)
var groupName string
if msg.GroupID != 0 {
channelType = "group"
channelID = fmt.Sprintf("%d", msg.GroupID)
groupName = a.GroupName(msg.GroupID)
}
// Build a human-readable action description.
// Group/private context is prepended by the message handler, so keep content to the action itself.
var content string
switch {
case msg.SubType == "poke":
detail := "戳了戳"
if msg.PokeDetail != nil {
if msg.PokeDetail.Action != "" {
detail = msg.PokeDetail.Action
}
if msg.PokeDetail.Suffix != "" {
detail += " " + msg.PokeDetail.Suffix
}
}
content = fmt.Sprintf("【动作】%s 昔涟", detail)
default:
content = fmt.Sprintf("【动作】%s/%s", msg.NoticeType, msg.SubType)
}
return &bridge.UnifiedMessage{
SenderID: senderID,
SenderName: senderName,
Platform: "qq",
ChannelID: channelID,
ChannelType: channelType,
Content: content,
ContentType: "action",
MessageID: fmt.Sprintf("notice_%d", time.Now().UnixNano()),
GroupName: groupName,
RawData: msg,
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
@@ -411,6 +465,11 @@ func (a *Adapter) FromUnified(response *bridge.UnifiedResponse) ([]bridge.Platfo
return msgs, nil
}
// SendProactive implements bridge.ProactiveSender for proactive message delivery.
func (a *Adapter) SendProactive(chatType string, userID, groupID int64, content string) error {
return a.SendMessage(chatType, userID, groupID, content)
}
// SendMessage sends a message through the QQ WebSocket connection.
func (a *Adapter) SendMessage(msgType string, userID, groupID int64, content string) error {
a.connMu.Lock()
@@ -499,7 +558,8 @@ func (a *Adapter) ReadMessages(ctx context.Context, msgCh chan<- *OBv11Message)
fmt.Printf("[qq:%s] self ID captured: %s\n", a.configName, a.selfID)
}
if msg.PostType == "message" {
// Dispatch message and notice (poke, etc.) events.
if msg.PostType == "message" || (msg.PostType == "notice" && msg.NoticeType == "notify" && msg.SubType == "poke") {
select {
case msgCh <- &msg:
case <-ctx.Done():
@@ -26,7 +26,17 @@ type OBv11Message struct {
MessageSeq int64 `json:"message_seq"`
// Notice fields.
NoticeType string `json:"notice_type"`
NoticeType string `json:"notice_type"`
// Poke detail (sub_type === "poke").
PokeDetail *OBv11PokeDetail `json:"poke_detail,omitempty"`
}
// OBv11PokeDetail contains extra info for poke ("戳一戳") notice events.
type OBv11PokeDetail struct {
Action string `json:"action"` // e.g. "戳了戳"
Suffix string `json:"suffix"` // e.g. "你的脸蛋"
PokePic string `json:"poke_pic"` // e.g. "https://..."
}
// OBv11Sender represents a message sender.
@@ -20,5 +20,11 @@ type PlatformAdapter interface {
HealthCheck() error
}
// ProactiveSender is an optional interface for adapters that can
// proactively send messages (e.g., QQ bot sending without prior request).
type ProactiveSender interface {
SendProactive(chatType string, userID, groupID int64, content string) error
}
// MessageHandler receives unified messages from adapters for processing.
type MessageHandler func(msg *UnifiedMessage) (*UnifiedResponse, error)
@@ -186,6 +186,20 @@ func (r *PlatformRouter) SendResponse(response *UnifiedResponse) ([]PlatformMess
return a.FromUnified(response)
}
// SendProactive sends a proactive message through a platform adapter that supports it.
// Returns an error if the adapter doesn't support proactive sending or the send fails.
func (r *PlatformRouter) SendProactive(adapterName, chatType string, userID, groupID int64, content string) error {
a, err := r.GetAdapter(adapterName)
if err != nil {
return err
}
sender, ok := a.(ProactiveSender)
if !ok {
return fmt.Errorf("adapter %s does not support proactive sending", adapterName)
}
return sender.SendProactive(chatType, userID, groupID, content)
}
func (r *PlatformRouter) platformHints(platform string) PlatformHints {
cap := PlatformCapabilities{}
if a, err := r.GetAdapter(platform); err == nil {
@@ -2,18 +2,26 @@ package handler
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strconv"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
)
// BridgeHandler exposes the Platform Bridge REST API.
type BridgeHandler struct {
router *bridge.PlatformRouter
router *bridge.PlatformRouter
internalToken string
}
func NewBridgeHandler(router *bridge.PlatformRouter) *BridgeHandler {
return &BridgeHandler{router: router}
return &BridgeHandler{
router: router,
internalToken: os.Getenv("INTERNAL_SERVICE_TOKEN"),
}
}
func (h *BridgeHandler) RegisterRoutes(mux *http.ServeMux) {
@@ -21,6 +29,7 @@ func (h *BridgeHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/platforms", h.listPlatforms)
mux.HandleFunc("/api/v1/platforms/", h.platformInfo)
mux.HandleFunc("/api/v1/identities", h.listIdentities)
mux.HandleFunc("/api/v1/internal/send-proactive", h.sendProactive)
mux.HandleFunc("/api/v1/webhook/telegram", h.telegramWebhook)
mux.HandleFunc("/api/v1/webhook/", h.genericWebhook)
}
@@ -138,6 +147,83 @@ func (h *BridgeHandler) genericWebhook(w http.ResponseWriter, r *http.Request) {
})
}
// sendProactive handles internal proactive message delivery to platform adapters.
// POST /api/v1/internal/send-proactive
func (h *BridgeHandler) sendProactive(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeJSON(w, http.StatusMethodNotAllowed, errResp("method not allowed"))
return
}
// Validate internal token
token := r.Header.Get("X-Internal-Token")
if h.internalToken == "" || token != h.internalToken {
writeJSON(w, http.StatusUnauthorized, errResp("unauthorized"))
return
}
var req struct {
Platform string `json:"platform"`
ChatType string `json:"chat_type"`
UserID string `json:"user_id"`
GroupID string `json:"group_id"`
AtUserID string `json:"at_user_id"`
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if req.Platform == "" || req.ChatType == "" || req.Content == "" {
writeJSON(w, http.StatusBadRequest, errResp("platform, chat_type, and content are required"))
return
}
// Map chat type to QQ message_type
msgType := req.ChatType
if msgType != "private" && msgType != "group" {
writeJSON(w, http.StatusBadRequest, errResp("chat_type must be private or group"))
return
}
userID := parseIntSafe(req.UserID)
groupID := parseIntSafe(req.GroupID)
// Prepend CQ @mention tag if at_user_id is specified
content := req.Content
if req.AtUserID != "" {
content = fmt.Sprintf("[CQ:at,qq=%s] %s", req.AtUserID, content)
}
// Find the adapter. For QQ, use "qq" as the default adapter name.
adapterName := req.Platform
err := h.router.SendProactive(adapterName, msgType, userID, groupID, content)
if err != nil {
log.Printf("[send-proactive] 发送失败: adapter=%s err=%v", adapterName, err)
writeJSON(w, http.StatusInternalServerError, errResp("send failed: "+err.Error()))
return
}
log.Printf("[send-proactive] 已发送: adapter=%s chat=%s user=%d group=%d at=%s len=%d",
adapterName, msgType, userID, groupID, req.AtUserID, len(content))
writeJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"message": "消息已发送",
})
}
func parseIntSafe(s string) int64 {
if s == "" {
return 0
}
n, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0
}
return n
}
func errResp(msg string) map[string]string {
return map[string]string{"error": msg}
}