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 }