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:
@@ -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)})
|
||||
|
||||
@@ -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()
|
||||
@@ -1264,7 +1329,97 @@ func (t *Thinker) buildThinkingUserPrompt(
|
||||
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)
|
||||
// 优先推送至活跃会话,回退到管理员主会话
|
||||
// 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)
|
||||
}
|
||||
}()
|
||||
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):])
|
||||
// Extract everything after the marker.
|
||||
raw := strings.TrimSpace(trimmed[len(marker):])
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// 限制主动消息长度(最多 200 字符,保持简短)
|
||||
}
|
||||
|
||||
// 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 处理结果
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -27,6 +27,16 @@ type OBv11Message struct {
|
||||
|
||||
// Notice fields.
|
||||
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,7 +2,11 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge"
|
||||
)
|
||||
@@ -10,10 +14,14 @@ import (
|
||||
// BridgeHandler exposes the Platform Bridge REST API.
|
||||
type BridgeHandler struct {
|
||||
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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user