From cadd5f223379c7e9bd933ccccb3452b3b87f9560 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 22 Jun 2026 20:26:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20QQ=E4=B8=BB=E5=8A=A8=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=AE=A1=E9=81=93=20+=20=E6=88=B3=E4=B8=80=E6=88=B3=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=20+=20thinker=E6=8F=90=E7=A4=BA=E8=AF=8D=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/ai-core/cmd/main.go | 47 +++ .../ai-core/internal/background/thinker.go | 271 +++++++++++++++--- .../internal/orchestrator/orchestrator.go | 2 + backend/platform-bridge/cmd/main.go | 2 + .../internal/adapter/qq/adapter.go | 62 +++- .../internal/adapter/qq/protocol.go | 12 +- .../internal/bridge/adapter.go | 6 + .../platform-bridge/internal/bridge/router.go | 14 + .../internal/handler/bridge_handler.go | 90 +++++- 9 files changed, 469 insertions(+), 37 deletions(-) diff --git a/backend/ai-core/cmd/main.go b/backend/ai-core/cmd/main.go index 77b27f2..3570215 100644 --- a/backend/ai-core/cmd/main.go +++ b/backend/ai-core/cmd/main.go @@ -373,6 +373,37 @@ func main() { } }) log.Printf("[主动消息] 推送已启用 (Gateway=%s)", gatewayURL) + + // 设置平台主动消息推送回调(调用 Platform Bridge 内部 API) + platformBridgeURL := getEnv("PLATFORM_BRIDGE_URL", "http://localhost:8082") + thinker.SetPlatformMessagePusher(func(target background.ProactiveTarget, message string) { + reqBody, _ := json.Marshal(map[string]string{ + "platform": target.Platform, + "chat_type": target.ChatType, + "user_id": target.UserID, + "group_id": target.GroupID, + "at_user_id": target.AtUserID, + "content": message, + }) + req, _ := http.NewRequest("POST", + platformBridgeURL+"/api/v1/internal/send-proactive", + strings.NewReader(string(reqBody))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Internal-Token", internalToken) + resp, err := proactiveClient.Do(req) + if err != nil { + log.Printf("[主动消息] 平台推送失败: %v", err) + return + } + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + log.Printf("[主动消息] 已推送到 Platform Bridge: target=%s/%s user=%s group=%s len=%d", + target.Platform, target.ChatType, target.UserID, target.GroupID, len(message)) + } else { + log.Printf("[主动消息] Platform Bridge 返回 %d", resp.StatusCode) + } + }) + log.Printf("[主动消息] 平台推送已启用 (PlatformBridge=%s)", platformBridgeURL) } else { log.Println("[主动消息] 未配置 INTERNAL_SERVICE_TOKEN,主动消息推送已禁用") } @@ -825,6 +856,8 @@ func handleChat( ChannelType string `json:"channel_type"` SenderName string `json:"sender_name"` OriginalUID string `json:"original_uid"` + BotUID string `json:"bot_uid"` + GroupName string `json:"group_name,omitempty"` } `json:"source,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -840,6 +873,12 @@ func handleChat( if req.Mode == "platform_silent" { if thinker != nil { thinker.RecordUserMessage(req.SessionID) + if req.Source.Platform != "" && req.Source.BotUID != "" { + thinker.SetBotUID(req.Source.Platform, req.Source.BotUID) + } + if req.Source.Platform != "" && req.Source.ChannelID != "" { + thinker.AddOrUpdatePlatformChannel(req.Source.Platform, req.Source.ChannelType, req.Source.ChannelID, req.Source.GroupName) + } } // 图片预处理:静默观察时也分析图片内容,供后台思考使用 message := req.Message @@ -870,6 +909,12 @@ func handleChat( // 0. 记录用户活动(重置闲置计时器) if thinker != nil { thinker.RecordUserMessage(req.SessionID) + if req.Source.Platform != "" && req.Source.BotUID != "" { + thinker.SetBotUID(req.Source.Platform, req.Source.BotUID) + } + if req.Source.Platform != "" && req.Source.ChannelID != "" { + thinker.AddOrUpdatePlatformChannel(req.Source.Platform, req.Source.ChannelType, req.Source.ChannelID, req.Source.GroupName) + } } // Admin private messages: redirect to the main admin session so conversation @@ -911,6 +956,8 @@ func handleChat( Mode: req.Mode, Nickname: userNickname, ChannelType: req.Source.ChannelType, + ChannelID: req.Source.ChannelID, + BotUID: req.Source.BotUID, }) if err != nil { errData, _ := json.Marshal(map[string]string{"delta": "", "error": fmt.Sprintf("处理失败: %v", err)}) diff --git a/backend/ai-core/internal/background/thinker.go b/backend/ai-core/internal/background/thinker.go index a6a71da..ad72cd7 100644 --- a/backend/ai-core/internal/background/thinker.go +++ b/backend/ai-core/internal/background/thinker.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "os" + "regexp" "strconv" "strings" "sync" @@ -29,15 +30,27 @@ type PendingThought struct { Consumed bool `json:"consumed"` } +// ProactiveTarget describes the target for proactive platform messages (QQ, etc.). +// When nil, the message goes through the existing Web push path. +type ProactiveTarget struct { + Platform string // "qq" + ChatType string // "private" or "group" + UserID string // QQ user number + GroupID string // QQ group number (group chat) + AtUserID string // QQ number to @mention (optional) +} + // PlatformChannel represents a platform channel to observe for background thinking. type PlatformChannel struct { Platform string // qq, telegram, etc. ChannelType string // group, private ChannelID string // group ID or user QQ number + ChannelName string // group name or user display name (resolved at runtime) } // ParsePlatformChannels parses PLATFORM_CHANNELS env var. // Format: "qq:group:123456,telegram:group:789012" +// Optional 4th field for display name: "qq:group:123456:群名称" func ParsePlatformChannels(raw string) []PlatformChannel { if raw == "" { return nil @@ -48,15 +61,19 @@ func ParsePlatformChannels(raw string) []PlatformChannel { if part == "" { continue } - fields := strings.SplitN(part, ":", 3) - if len(fields) != 3 { + fields := strings.SplitN(part, ":", 4) + if len(fields) < 3 { continue } - channels = append(channels, PlatformChannel{ + ch := PlatformChannel{ Platform: strings.TrimSpace(fields[0]), ChannelType: strings.TrimSpace(fields[1]), ChannelID: strings.TrimSpace(fields[2]), - }) + } + if len(fields) >= 4 { + ch.ChannelName = strings.TrimSpace(fields[3]) + } + channels = append(channels, ch) } return channels } @@ -101,6 +118,10 @@ type Thinker struct { // func(userID, sessionID, message string) messagePusher func(string, string, string) + // 平台主动消息推送回调 (nil = 不推送) + // func(target ProactiveTarget, message string) + platformMessagePusher func(ProactiveTarget, string) + // —— 事件驱动相关 —— // 周期性思考间隔:每隔固定时间自动触发一次思考 @@ -165,6 +186,9 @@ type Thinker struct { // 平台静默观察 platformChannels []PlatformChannel platformThinkInterval time.Duration + + // 平台 Bot UID (platform -> bot's own UID, e.g. "qq" -> "123456789") + botUIDs map[string]string } // AutonomousToolPolicy 自主思考工具调用安全策略 @@ -208,6 +232,47 @@ func (t *Thinker) SetMessagePusher(pusher func(string, string, string)) { t.messagePusher = pusher } +// SetPlatformMessagePusher sets the callback for pushing proactive messages to platform adapters (QQ, etc.). +func (t *Thinker) SetPlatformMessagePusher(pusher func(ProactiveTarget, string)) { + t.mu.Lock() + defer t.mu.Unlock() + t.platformMessagePusher = pusher +} + +// SetBotUID sets the bot's own platform UID (e.g., QQ number). +func (t *Thinker) SetBotUID(platform, uid string) { + t.mu.Lock() + defer t.mu.Unlock() + if t.botUIDs == nil { + t.botUIDs = make(map[string]string) + } + if uid != "" { + t.botUIDs[platform] = uid + } +} + +// AddOrUpdatePlatformChannel adds or updates a platform channel with resolved display name. +func (t *Thinker) AddOrUpdatePlatformChannel(platform, channelType, channelID, channelName string) { + t.mu.Lock() + defer t.mu.Unlock() + + for i, ch := range t.platformChannels { + if ch.Platform == platform && ch.ChannelType == channelType && ch.ChannelID == channelID { + if channelName != "" { + t.platformChannels[i].ChannelName = channelName + } + return + } + } + // Not found — append. + t.platformChannels = append(t.platformChannels, PlatformChannel{ + Platform: platform, + ChannelType: channelType, + ChannelID: channelID, + ChannelName: channelName, + }) +} + // SetEmotionTracker sets the emotion tracker. func (t *Thinker) SetEmotionTracker(et *persona.EmotionTracker) { t.mu.Lock() @@ -1258,13 +1323,103 @@ func (t *Thinker) buildThinkingUserPrompt( } // 平台观察摘要 (中间会话产生的报告) - if platformObservation != "" { - sb.WriteString("\n\n【平台频道观察报告(中间会话生成,可能包含多位群聊成员的信息)】\n") - sb.WriteString(platformObservation) - sb.WriteString("\n") - } + if platformObservation != "" { + sb.WriteString("\n\n【平台频道观察报告(中间会话生成,可能包含多位群聊成员的信息)】\n") + sb.WriteString(platformObservation) + sb.WriteString("\n") + } - // 结尾引导 + // QQ platform identity and available channels for proactive messaging. + t.mu.Lock() + qqChannels := t.platformChannels + botUIDs := t.botUIDs + activeSID := t.activeSessionID + lastMsgTime := t.lastUserMessage + t.mu.Unlock() + if len(qqChannels) > 0 { + sb.WriteString("\n\n【你的QQ平台身份与可用频道】\n") + + // Bot's own identity. + if qqBotUID, ok := botUIDs["qq"]; ok && qqBotUID != "" { + sb.WriteString(fmt.Sprintf("你在QQ上的账号是: %s(昔涟),这就是你。\n", qqBotUID)) + } + + // Active session context with time, group name, and trigger-aware guidance. + if strings.HasPrefix(activeSID, "platform_qq_") { + activeChID := strings.TrimPrefix(activeSID, "platform_qq_") + for _, ch := range qqChannels { + if ch.Platform == "qq" && ch.ChannelID == activeChID { + chLabel := activeChID + if ch.ChannelName != "" { + chLabel = fmt.Sprintf("%s(%s)", ch.ChannelName, activeChID) + } + // Last message time hint. + timeHint := "" + if !lastMsgTime.IsZero() { + elapsed := time.Since(lastMsgTime) + if elapsed < time.Minute { + timeHint = "刚刚" + } else if elapsed < 30*time.Minute { + timeHint = fmt.Sprintf("%d分钟前", int(elapsed.Minutes())) + } + } + if ch.ChannelType == "group" { + if triggerReason == "post_chat" { + if timeHint != "" && timeHint != "刚刚" { + sb.WriteString(fmt.Sprintf("【%s】你当前正在QQ群聊 %s 中。刚刚群里有人说了话——如果你想回应,用【主动消息】【QQ群聊:%s】格式输出。\n", timeHint, chLabel, activeChID)) + } else { + sb.WriteString(fmt.Sprintf("【刚刚】你当前正在QQ群聊 %s 中。群里有人说了话——如果你想回应,用【主动消息】【QQ群聊:%s】格式输出。\n", chLabel, activeChID)) + } + } else { + // Autonomous thinking (periodic, silence, startup). + if timeHint != "" { + sb.WriteString(fmt.Sprintf("【上次活跃: %s】你当前在QQ群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【QQ群聊:%s】格式。\n", timeHint, chLabel, activeChID)) + } else { + sb.WriteString(fmt.Sprintf("你当前在QQ群聊 %s 中。现在是自主思考时间——如果你想主动对群里说些什么,用【主动消息】【QQ群聊:%s】格式。\n", chLabel, activeChID)) + } + } + } else { + if triggerReason == "post_chat" { + sb.WriteString(fmt.Sprintf("【刚刚】你当前正在与QQ用户 %s 私聊。想回应ta的话用【主动消息】【QQ私聊:%s】格式。\n", chLabel, activeChID)) + } else { + sb.WriteString(fmt.Sprintf("你当前在与QQ用户 %s 私聊。现在是自主思考时间——想主动发消息用【主动消息】【QQ私聊:%s】格式。\n", chLabel, activeChID)) + } + } + break + } + } + } + + sb.WriteString("\n发送主动消息的格式:\n") + sb.WriteString("- QQ私聊: 【主动消息】【QQ私聊:QQ号】消息内容\n") + sb.WriteString("- QQ群聊: 【主动消息】【QQ群聊:群号】消息内容\n") + sb.WriteString("- QQ群聊@某人: 【主动消息】【QQ群聊:群号@QQ号】消息内容\n") + sb.WriteString("\n可用的QQ频道列表:\n") + for _, ch := range qqChannels { + if ch.Platform != "qq" { + continue + } + label := ch.ChannelID + if ch.ChannelName != "" { + label = fmt.Sprintf("%s (%s)", ch.ChannelName, ch.ChannelID) + } + if ch.ChannelType == "private" { + sb.WriteString(fmt.Sprintf("- 私聊 %s\n", label)) + } else { + sb.WriteString(fmt.Sprintf("- 群聊 %s\n", label)) + } + } + sb.WriteString("\n注意事项:\n") + sb.WriteString("- 只给真正有互动过的用户/群聊发消息,不要群发骚扰\n") + sb.WriteString("- 私聊消息要简短自然,像朋友间的问候\n") + sb.WriteString("- 群聊消息要有公共价值,不要当成私聊\n") + sb.WriteString("- @某人时:确认这个人在群里有发言过,你的@内容是对他之前说的话的回应\n") + if triggerReason == "post_chat" { + sb.WriteString("- 重要:如果你在反思中想往QQ群发消息,必须用【主动消息】【QQ群聊:群号】格式,不要省略群号!\n") + } + } + + // 结尾引导 // 结尾引导 sb.WriteString("\n---\n现在请写下你的私人反思。") sb.WriteString("\n记住:这是日记,用第三人称或自言自语的方式。") sb.WriteString("\n⚠️ 如果有人正在休息/睡觉/忙碌——不要输出【主动消息】指令行。你可以在心里想,但不要去打扰。") @@ -1327,22 +1482,24 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou Consumed: false, }) - // 只保留最近 10 条 + // Keep at most 10 recent thoughts. if len(t.pendingThoughts) > 10 { t.pendingThoughts = t.pendingThoughts[len(t.pendingThoughts)-10:] } - // 提取主动消息并推送(带频率限制) - proactiveMsg := extractProactiveMessage(content) - // 优先推送至活跃会话,回退到管理员主会话 - pushSessionID := t.activeSessionID - if pushSessionID == "" { - pushSessionID = t.adminSessionID - } + // Extract proactive message and optional QQ target. + proactiveMsg, proactiveTarget := extractProactiveMessage(content) + // Prefer active session, fall back to admin main session. + pushSessionID := t.activeSessionID + if pushSessionID == "" { + pushSessionID = t.adminSessionID + } pusher := t.messagePusher - canPush := proactiveMsg != "" && pusher != nil + platformPusher := t.platformMessagePusher + isPlatformMsg := proactiveTarget != nil + canPush := proactiveMsg != "" && ((!isPlatformMsg && pusher != nil) || (isPlatformMsg && platformPusher != nil)) if canPush { - // Phase 2: 使用 ProactiveGuard 多维度评估 + // ProactiveGuard rate limiting (applies to both web and platform pushes). urgency := ExtractUrgencyFromContent(proactiveMsg) if valid, reason := ValidateProactiveMessage(proactiveMsg); !valid { log.Printf("[后台思考] 主动消息内容校验失败: %s,跳过推送", reason) @@ -1367,11 +1524,17 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou } } } + // Copy target for use after unlock (avoid race). + var targetCopy *ProactiveTarget + if proactiveTarget != nil { + copy := *proactiveTarget + targetCopy = © + } t.mu.Unlock() log.Printf("[后台思考] 思考已存储 (当前累积 %d 条待推送思考)", len(t.pendingThoughts)) - // 异步持久化到 memory-service + // Async persist to memory-service. if t.memClient != nil { go func() { defer func() { @@ -1389,7 +1552,7 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou }() } - // 推送主动消息 + // Push proactive message. if canPush { go func() { defer func() { @@ -1397,8 +1560,14 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou log.Printf("[后台思考] 推送主动消息 panic 恢复: %v", r) } }() - log.Printf("[后台思考] 推送主动消息: %s", proactiveMsg) - pusher(t.adminUserID, pushSessionID, proactiveMsg) + if targetCopy != nil { + log.Printf("[后台思考] 推送平台主动消息: target=%s/%s user=%s group=%s msg=%s", + targetCopy.Platform, targetCopy.ChatType, targetCopy.UserID, targetCopy.GroupID, proactiveMsg) + platformPusher(*targetCopy, proactiveMsg) + } else { + log.Printf("[后台思考] 推送主动消息: %s", proactiveMsg) + pusher(t.adminUserID, pushSessionID, proactiveMsg) + } }() } } @@ -1408,35 +1577,71 @@ func (t *Thinker) storeThought(content string, toolCallsJSON string, toolCallCou // // 要求标记独立成行(前面只有空白或行首),避免把自然语言中的提及 // 当作指令(如 "不需要写【主动消息】" 这类否定表述)。 -func extractProactiveMessage(content string) string { +// qqTargetRe matches QQ target markers like 【QQ私聊:123456】, 【QQ群聊:789012】, 【QQ群聊:789012@123456】 +var qqTargetRe = regexp.MustCompile(`【QQ(私聊|群聊):(\d+)(?:@(\d+))?】`) + +// extractProactiveMessage extracts the 【主动消息】 marker and optional QQ target from thinking content. +// Returns the message content and an optional ProactiveTarget for platform delivery. +// When target is nil, the message goes through the existing Web push path. +func extractProactiveMessage(content string) (string, *ProactiveTarget) { marker := "【主动消息】" - // 扫描每一行,只接受 marker 在行首(忽略前导空白)的行作为指令 + // Scan each line; only accept lines where the marker starts the line (ignoring leading whitespace). for _, line := range strings.Split(content, "\n") { trimmed := strings.TrimSpace(line) if !strings.HasPrefix(trimmed, marker) { continue } - // 检查否定语境:标记前面的文字包含否定词 + // Check negation context: reject if prefix contains negation words. markerIdx := strings.Index(line, marker) prefix := strings.TrimSpace(line[:markerIdx]) if containsNegation(prefix) { continue } - // 提取标记后的内容 - msg := strings.TrimSpace(trimmed[len(marker):]) - if msg == "" { + // Extract everything after the marker. + raw := strings.TrimSpace(trimmed[len(marker):]) + if raw == "" { continue } - // 限制主动消息长度(最多 200 字符,保持简短) + + // Parse optional QQ target marker right after 【主动消息】. + msg := raw + var target *ProactiveTarget + if loc := qqTargetRe.FindStringSubmatchIndex(raw); loc != nil && loc[0] == 0 { + m := qqTargetRe.FindStringSubmatch(raw) + if len(m) == 4 { + chatType := "private" + if m[1] == "群聊" { + chatType = "group" + } + target = &ProactiveTarget{ + Platform: "qq", + ChatType: chatType, + UserID: m[2], + GroupID: m[2], + AtUserID: m[3], + } + // For private chat, UserID is the QQ number; GroupID stays empty. + if chatType == "private" { + target.GroupID = "" + } + } + // Remove the QQ target marker from the message content. + msg = strings.TrimSpace(raw[loc[1]:]) + if msg == "" { + continue + } + } + + // Limit message length (200 chars max, keep it short). runes := []rune(msg) if len(runes) > 200 { msg = string(runes[:200]) } - return msg + return msg, target } - return "" + return "", nil } // containsNegation checks if a short prefix string contains negation words diff --git a/backend/ai-core/internal/orchestrator/orchestrator.go b/backend/ai-core/internal/orchestrator/orchestrator.go index f1aa85e..9bb5676 100644 --- a/backend/ai-core/internal/orchestrator/orchestrator.go +++ b/backend/ai-core/internal/orchestrator/orchestrator.go @@ -138,6 +138,8 @@ type ProcessParams struct { Mode string // text / voice_msg / voice_assistant Nickname string ChannelType string // direct / group + ChannelID string // platform channel ID (group ID or private QQ number) + BotUID string // bot's own platform UID (e.g., QQ number) } // ProcessResult 处理结果 diff --git a/backend/platform-bridge/cmd/main.go b/backend/platform-bridge/cmd/main.go index cc2c653..c3febeb 100644 --- a/backend/platform-bridge/cmd/main.go +++ b/backend/platform-bridge/cmd/main.go @@ -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 { diff --git a/backend/platform-bridge/internal/adapter/qq/adapter.go b/backend/platform-bridge/internal/adapter/qq/adapter.go index af1151b..ee53857 100644 --- a/backend/platform-bridge/internal/adapter/qq/adapter.go +++ b/backend/platform-bridge/internal/adapter/qq/adapter.go @@ -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(): diff --git a/backend/platform-bridge/internal/adapter/qq/protocol.go b/backend/platform-bridge/internal/adapter/qq/protocol.go index 3a8f010..231818f 100644 --- a/backend/platform-bridge/internal/adapter/qq/protocol.go +++ b/backend/platform-bridge/internal/adapter/qq/protocol.go @@ -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. diff --git a/backend/platform-bridge/internal/bridge/adapter.go b/backend/platform-bridge/internal/bridge/adapter.go index 61bbe22..4fbc803 100644 --- a/backend/platform-bridge/internal/bridge/adapter.go +++ b/backend/platform-bridge/internal/bridge/adapter.go @@ -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) diff --git a/backend/platform-bridge/internal/bridge/router.go b/backend/platform-bridge/internal/bridge/router.go index be0cdc1..c1ca6cf 100644 --- a/backend/platform-bridge/internal/bridge/router.go +++ b/backend/platform-bridge/internal/bridge/router.go @@ -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 { diff --git a/backend/platform-bridge/internal/handler/bridge_handler.go b/backend/platform-bridge/internal/handler/bridge_handler.go index 9f1a0e5..85c7212 100644 --- a/backend/platform-bridge/internal/handler/bridge_handler.go +++ b/backend/platform-bridge/internal/handler/bridge_handler.go @@ -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} }