3ad728406e
- 日志:收/发消息均显示群名称,管理员显示真实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>
271 lines
7.7 KiB
Go
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
|
|
}
|