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
+238 -33
View File
@@ -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 = &copy
}
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
@@ -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 处理结果