Files
Cyrene/backend/platform-bridge/internal/bridge/router.go
T
AskaEth 3ad728406e fix: 消息日志增强 + 历史消息抑制 + SSE实时追踪 + 群聊上下文优化
- 日志:收/发消息均显示群名称,管理员显示真实QQ昵称而非"开拓者"
- 历史消息:服务重启后NapCat回放的历史消息不再触发回复,静默注入上下文
- 消息时间戳:转发给AI时附带【消息时间: HH:MM:SS (XmXs前)】标记
- ♪ 分割符:QQ消息支持♪作为句子断点
- AI-Core SSE端点:全链路追踪实时推送,ethend不再5秒轮询
- 群聊上下文:AI-Core明确被告知消息来自群聊,以实际发送者为主语

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 11:49:36 +08:00

271 lines
7.7 KiB
Go

package bridge
import (
"context"
"fmt"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/permissions"
)
const participantTTL = 5 * time.Minute
// adapterKey returns the unique key for an adapter in the router map.
// Uses ConfigName() if the adapter implements it, otherwise PlatformName().
func adapterKey(a PlatformAdapter) string {
if named, ok := a.(interface{ ConfigName() string }); ok {
return named.ConfigName()
}
return a.PlatformName()
}
// PlatformRouter manages all platform adapters and routes messages.
type PlatformRouter struct {
mu sync.RWMutex
adapters map[string]PlatformAdapter
mapper *IdentityMapper
checker *permissions.Checker
handler MessageHandler
// Conversational context per channel.
contexts map[string]*ChannelContext // channelKey -> context
}
// ChannelContext stores the active conversation state for a channel.
type ChannelContext struct {
Platform string
ChannelID string
ChannelType string
LastUserMsg string
LastSenderUID string
RecentSenders []string // last 5 sender UIDs (original platform UIDs)
ActiveParticipants map[string]time.Time // UID -> last bot reply time (for multi-user conversation continuity)
MessageCount int
}
func NewPlatformRouter(mapper *IdentityMapper, checker *permissions.Checker) *PlatformRouter {
return &PlatformRouter{
adapters: make(map[string]PlatformAdapter),
mapper: mapper,
checker: checker,
contexts: make(map[string]*ChannelContext),
}
}
// RegisterAdapter adds a platform adapter, keyed by its config name.
func (r *PlatformRouter) RegisterAdapter(a PlatformAdapter) {
r.mu.Lock()
defer r.mu.Unlock()
r.adapters[adapterKey(a)] = a
}
// RemoveAdapter disconnects and removes a platform adapter.
func (r *PlatformRouter) RemoveAdapter(platform string) {
r.mu.Lock()
a, ok := r.adapters[platform]
if ok {
delete(r.adapters, platform)
}
r.mu.Unlock()
if ok {
a.Disconnect(context.Background())
}
}
// ReplaceAdapter disconnects the old adapter (if present), registers the new one,
// and connects it. Returns an error if the new adapter fails to connect.
func (r *PlatformRouter) ReplaceAdapter(a PlatformAdapter) error {
key := adapterKey(a)
r.mu.Lock()
if old, ok := r.adapters[key]; ok {
old.Disconnect(context.Background())
}
r.adapters[key] = a
r.mu.Unlock()
return a.Connect(context.Background())
}
// GetAdapter returns the adapter for a platform.
func (r *PlatformRouter) GetAdapter(platform string) (PlatformAdapter, error) {
r.mu.RLock()
defer r.mu.RUnlock()
a, ok := r.adapters[platform]
if !ok {
return nil, fmt.Errorf("no adapter for platform: %s", platform)
}
return a, nil
}
// ListAdapters returns all registered adapter names (config names).
func (r *PlatformRouter) ListAdapters() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.adapters))
for name := range r.adapters {
names = append(names, name)
}
return names
}
// GetAdaptersByPlatform returns all registered adapters for a given platform type.
func (r *PlatformRouter) GetAdaptersByPlatform(platform string) []PlatformAdapter {
r.mu.RLock()
defer r.mu.RUnlock()
var result []PlatformAdapter
for _, a := range r.adapters {
if a.PlatformName() == platform {
result = append(result, a)
}
}
return result
}
// SetMessageHandler sets the callback for processing unified messages.
func (r *PlatformRouter) SetMessageHandler(h MessageHandler) {
r.handler = h
}
// RouteMessage converts a platform message to unified, checks permissions, and dispatches.
// adapterKey is the config name (e.g., "qq", "qq-home") used to look up the adapter instance.
func (r *PlatformRouter) RouteMessage(adapterKey string, rawMsg interface{}) (*UnifiedResponse, error) {
a, err := r.GetAdapter(adapterKey)
if err != nil {
return nil, err
}
unified, err := a.ToUnified(rawMsg)
if err != nil {
return nil, fmt.Errorf("convert to unified: %w", err)
}
// Preserve original platform UID before identity mapping.
unified.OriginalSenderUID = unified.SenderID
unified.OriginalSenderName = unified.SenderName
unified.OriginalRawMessage = rawMsg
// Capture bot's own UID for @mention detection.
if selfAware, ok := a.(interface{ SelfID() string }); ok {
unified.BotUID = selfAware.SelfID()
}
// Resolve identity (nil for unknown users; caller decides routing).
// Use platform type (e.g. "qq") for identity resolution, not adapter key.
identity := r.mapper.ResolveOrNil(a.PlatformName(), unified.SenderID)
if identity != nil {
unified.SenderID = identity.CyreneUser
unified.SenderName = identity.Nickname
}
// Update channel context.
r.updateContext(unified)
if r.handler == nil {
return nil, fmt.Errorf("no message handler configured")
}
response, err := r.handler(unified)
if err != nil {
return nil, err
}
// Use adapter key for response routing so SendResponse finds the correct instance.
response.Platform = adapterKey
response.PlatformHints = r.platformHints(adapterKey)
return response, nil
}
// SendResponse converts and sends a unified response through the platform adapter.
func (r *PlatformRouter) SendResponse(response *UnifiedResponse) ([]PlatformMessage, error) {
a, err := r.GetAdapter(response.Platform)
if err != nil {
return nil, err
}
return a.FromUnified(response)
}
func (r *PlatformRouter) platformHints(platform string) PlatformHints {
cap := PlatformCapabilities{}
if a, err := r.GetAdapter(platform); err == nil {
cap = a.Capabilities()
}
return PlatformHints{
TypingIndicator: cap.SupportsTypingHint,
BurstMode: cap.RecommendBurstMax > 1,
}
}
func (r *PlatformRouter) channelKey(platform, channelID string) string {
return platform + ":" + channelID
}
func (r *PlatformRouter) updateContext(msg *UnifiedMessage) {
key := r.channelKey(msg.Platform, msg.ChannelID)
r.mu.Lock()
defer r.mu.Unlock()
ctx, ok := r.contexts[key]
if !ok {
ctx = &ChannelContext{
Platform: msg.Platform,
ChannelID: msg.ChannelID,
ChannelType: msg.ChannelType,
}
r.contexts[key] = ctx
}
ctx.LastUserMsg = msg.Content
ctx.LastSenderUID = msg.OriginalSenderUID
ctx.RecentSenders = append(ctx.RecentSenders, msg.OriginalSenderUID)
if len(ctx.RecentSenders) > 5 {
ctx.RecentSenders = ctx.RecentSenders[len(ctx.RecentSenders)-5:]
}
ctx.MessageCount++
}
// ListAllIdentities returns all registered identity mappings.
func (r *PlatformRouter) ListAllIdentities() map[string][]permissions.PlatformIdentity {
return r.mapper.ListAll()
}
// GetContext returns the channel context.
func (r *PlatformRouter) GetContext(platform, channelID string) *ChannelContext {
r.mu.RLock()
defer r.mu.RUnlock()
return r.contexts[platform+":"+channelID]
}
// NoteBotReply records that the bot just replied to a specific user in a channel.
// Used for conversation continuity: subsequent messages from this user continue the
// conversation even without an explicit @mention, within the participant TTL window.
func (r *PlatformRouter) NoteBotReply(platform, channelID, recipientUID string) {
r.mu.Lock()
defer r.mu.Unlock()
key := r.channelKey(platform, channelID)
ctx, ok := r.contexts[key]
if !ok {
return
}
if ctx.ActiveParticipants == nil {
ctx.ActiveParticipants = make(map[string]time.Time)
}
ctx.ActiveParticipants[recipientUID] = time.Now()
}
// IsActiveParticipant checks if a user was recently engaged by the bot.
// TTL controls how long the continuity window stays open after the last bot reply.
func (r *PlatformRouter) IsActiveParticipant(platform, channelID, uid string) bool {
r.mu.RLock()
defer r.mu.RUnlock()
key := r.channelKey(platform, channelID)
ctx, ok := r.contexts[key]
if !ok || ctx.ActiveParticipants == nil {
return false
}
t, ok := ctx.ActiveParticipants[uid]
if !ok {
return false
}
return time.Since(t) < participantTTL
}