package main import ( "bytes" "context" "encoding/json" "fmt" "hash/fnv" "io" "net/http" "os" "os/signal" "strconv" "strings" "sync" "syscall" "time" discordstub "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/adapter/discord" feishustub "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/adapter/feishu" qqadapter "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/adapter/qq" telegramadapter "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/adapter/telegram" wechatstub "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/adapter/wechat" webhookadapter "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/adapter/webhook" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/bridge" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/config" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/handler" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/logging" "git.yeij.top/AskaEth/Cyrene/platform-bridge/internal/permissions" ) func main() { cfg := config.Load() // Config store for platform adapter configs. configStore, err := config.NewStore("platform_configs.json") if err != nil { fmt.Printf("FATAL: config store: %v\n", err) os.Exit(1) } // Blocklist settings. blocklistStore, err := config.NewBlocklistStore("platform_blocklist.json") if err != nil { fmt.Printf("FATAL: blocklist store: %v\n", err) os.Exit(1) } // Message logger. msgLogger, err := logging.NewLogger("logs") if err != nil { fmt.Printf("FATAL: logger: %v\n", err) os.Exit(1) } defer msgLogger.Close() // Core components. mapper := bridge.NewIdentityMapper() checker := permissions.NewChecker() router := bridge.NewPlatformRouter(mapper, checker) lastDisplayNames := make(map[string]string) // platformUID -> last known display name // Seed default identities from environment. seedIdentities(mapper, configStore, cfg.AdminNickname) // Register platform adapters based on stored configs or defaults. adapters := createAdapters(cfg, configStore) for _, a := range adapters { router.RegisterAdapter(a) } // Set message handler with logging. router.SetMessageHandler(func(msg *bridge.UnifiedMessage) (*bridge.UnifiedResponse, error) { // Routing decisions. isAdmin := mapper.IsAdmin(msg.Platform, msg.OriginalSenderUID) // Blocklist check — MUST run first, before logging/enrichment, // to avoid wasting resources on blocked users/groups. if blocked := blocklistStore.IsBlocked(msg.ChannelType, msg.ChannelID, msg.OriginalSenderUID, isAdmin); blocked { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "incoming", Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, SenderName: msg.OriginalSenderName, GroupName: msg.GroupName, Content: "[blocked]", Success: true, }) return &bridge.UnifiedResponse{ Messages: []bridge.ResponseMessage{ {DisplayType: "silent", Content: "", FormatMode: "plain"}, }, Platform: msg.Platform, }, nil } // Log incoming (only non-blocked messages). msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "incoming", Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, SenderName: msg.OriginalSenderName, GroupName: msg.GroupName, Content: msg.Content, ContentType: msg.ContentType, MessageID: msg.MessageID, Success: true, }) adminNick := cfg.AdminNickname if isAdmin { if id := mapper.ResolveOrNil(msg.Platform, msg.OriginalSenderUID); id != nil && id.Nickname != "" { adminNick = id.Nickname } // Track per-group display names (群名片 can differ across groups). nameKey := msg.OriginalSenderUID if msg.ChannelType == "group" { nameKey = msg.OriginalSenderUID + ":" + msg.ChannelID } if prevName, ok := lastDisplayNames[nameKey]; ok && prevName != msg.OriginalSenderName && msg.OriginalSenderName != "" { ctx := msg.ChannelID if msg.GroupName != "" { ctx = truncateString(msg.GroupName, 8) } msg.Content = fmt.Sprintf("【昵称更新:该用户在%s(%s)上的昵称已从\"%s\"变更为\"%s\"】\n%s", ctx, msg.Platform, prevName, msg.OriginalSenderName, msg.Content) } if msg.OriginalSenderName != "" { lastDisplayNames[nameKey] = msg.OriginalSenderName } } isMentioned, _ := detectAdminMention(msg, mapper, cfg) isBotMentioned := msg.BotUID != "" && containsString(msg.Mentions, msg.BotUID) isSilent := cfg.PlatformSilentEnabled && !isAdmin && !isBotMentioned // Add message timestamp for AI context. if !msg.Timestamp.IsZero() { timeAgo := time.Since(msg.Timestamp) timeLabel := fmt.Sprintf("【消息时间: %s (%s前)】\n", msg.Timestamp.Format("15:04:05"), formatDuration(timeAgo)) msg.Content = timeLabel + msg.Content } // Enrich group messages with group name and sender info. if msg.ChannelType == "group" { groupLabel := msg.ChannelID if msg.GroupName != "" { groupLabel = truncateString(msg.GroupName, 8) + " " + msg.ChannelID } senderLabel := msg.OriginalSenderName if senderLabel == "" { senderLabel = msg.SenderName } if isAdmin { senderLabel = adminNick + "/" + msg.OriginalSenderName } msg.Content = fmt.Sprintf("[群聊 %s] %s (%s):\n%s", groupLabel, senderLabel, msg.OriginalSenderUID, msg.Content) } else if msg.ChannelType == "private" { msg.Content = fmt.Sprintf("【私聊 %s】%s/%s (%s):\n%s", msg.Platform, adminNick, msg.OriginalSenderName, msg.OriginalSenderUID, msg.Content) } var response *bridge.UnifiedResponse var routeErr error // Extract image URLs for vision/OCR processing (admin + bot-mentioned + admin-mentioned only). imageURLs := getImageURLs(msg) videoURLs := getShortVideoURLs(msg) voiceURLs := getVoiceURLs(msg) // For group chats, use a channel-based user ID to share context between admin and regular users. chatUserID := msg.SenderID if msg.ChannelType == "group" { chatUserID = fmt.Sprintf("platform_%s_group_%s", msg.Platform, msg.ChannelID) } groupSessionID := fmt.Sprintf("platform_%s_%s", msg.Platform, msg.ChannelID) // Helper: fire-and-forget a silent observation. Silent paths don't // produce visible responses, so we can background them to avoid // blocking the worker for the next message from the same user. fireSilent := func(namespace string, imgs, vids, voices []string) { go func() { _, err := forwardToAICore(cfg, msg, "platform_silent", namespace, namespace, imgs, vids, voices, isAdmin) if err != nil { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "error", Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, Success: false, Error: err.Error(), }) } }() } // 戳一戳/动作消息:总是回复 isPoke := strings.Contains(msg.Content, "【动作】") switch { case isPoke: msg.RouteType = "normal" response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin) case isMessageHistorical(msg, router): msg.RouteType = "silent" namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID) fireSilent(namespace, nil, videoURLs, voiceURLs) response = &bridge.UnifiedResponse{Messages: []bridge.ResponseMessage{{DisplayType: "silent"}}, Platform: msg.Platform} case isAdmin: // 管理员在群里@别人(非bot)时,用 group_ambient 避免昔涟频繁插嘴 if msg.ChannelType == "group" && !isBotMentioned { msg.RouteType = "group_ambient" namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID) fireSilent(namespace, imageURLs, videoURLs, voiceURLs) response, routeErr = forwardToAICore(cfg, msg, "group_ambient", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin) } else { msg.RouteType = "normal" response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin) } case isBotMentioned: msg.RouteType = "normal" response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin) case isMentioned: msg.RouteType = "silent" namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID) fireSilent(namespace, imageURLs, videoURLs, voiceURLs) response = &bridge.UnifiedResponse{Messages: []bridge.ResponseMessage{{DisplayType: "silent"}}, Platform: msg.Platform} case isSilent: // 群聊环境消息:让 LLM 自己判断是否值得插话 msg.RouteType = "group_ambient" // 同时后台记录(记忆提取) namespace := buildMemoryNamespace(msg.Platform, msg.ChannelType, msg.ChannelID) fireSilent(namespace, imageURLs, videoURLs, voiceURLs) response, routeErr = forwardToAICore(cfg, msg, "group_ambient", chatUserID, groupSessionID, imageURLs, videoURLs, voiceURLs, isAdmin) default: msg.RouteType = "normal" response, routeErr = forwardToAICore(cfg, msg, "text", chatUserID, groupSessionID, nil, videoURLs, voiceURLs, isAdmin) } if routeErr != nil { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "error", Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.OriginalSenderUID, Success: false, Error: routeErr.Error(), }) return nil, routeErr } // Log outgoing messages (skip for silent route). if msg.RouteType != "silent" { for _, rm := range response.Messages { msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: "outgoing", Platform: logKey(msg), ChannelID: msg.ChannelID, SenderID: msg.BotUID, SenderName: "Cyrene", GroupName: msg.GroupName, Content: rm.Content, ContentType: "text", Success: true, }) } } return response, nil }) // Connect all adapters. ctx := context.Background() for _, a := range adapters { if err := a.Connect(ctx); err != nil { fmt.Printf("WARN: connect %s failed: %v\n", a.PlatformName(), err) } else { fmt.Printf("Platform adapter connected: %s\n", a.PlatformName()) } } // Setup HTTP server. mux := http.NewServeMux() bh := handler.NewBridgeHandler(router) bh.SetLogFunc(func(platform, channelID, senderID, content string, success bool) { dir := "outgoing" if !success { dir = "error" } msgLogger.Log(logging.LogEntry{ Timestamp: time.Now(), Direction: dir, Platform: platform, ChannelID: channelID, SenderID: senderID, SenderName: "Cyrene", Content: content, ContentType: "text", Success: success, }) }) bh.RegisterRoutes(mux) bh.StartChannelRefresh(10 * time.Minute) // Config and log handlers. ch := handler.NewConfigHandler(configStore, router) // Hot-reload: on config save/delete, dynamically replace adapters. ch.SetOnConfigChanged(func(name, platform string, enabled bool, fields map[string]string) { if enabled { a := createSingleAdapter(cfg, platform, name, "", fields) // configID via Store.Set if a == nil { fmt.Printf("WARN: cannot create adapter for %s (platform=%s)\n", name, platform) return } if err := router.ReplaceAdapter(a); err != nil { fmt.Printf("WARN: hot-reload connect %s failed: %v\n", name, err) } else { fmt.Printf("Platform adapter hot-reloaded: %s\n", name) } // Sync admin identities from config fields. syncAdminUIDs(mapper, platform, fields, cfg.AdminNickname) // Restart OBv11 reader when OBv11 config changes. if platform == "obv11" { startOBv11Readers(router) } } else { router.RemoveAdapter(name) fmt.Printf("Platform adapter removed: %s\n", name) // Cancel reader goroutines for removed adapter. if platform == "obv11" { startOBv11Readers(router) } } }) ch.RegisterRoutes(mux) lh := handler.NewLogHandler(msgLogger, configStore) lh.RegisterRoutes(mux) // Log WebSocket for real-time log streaming to ethend. logWS := handler.NewLogWSHub(msgLogger) mux.HandleFunc("/ws/logs", logWS.ServeWS) // Blocklist settings. blh := handler.NewBlocklistHandler(blocklistStore) blh.RegisterRoutes(mux) // Start OBv11 message reader loop. startOBv11Readers(router) addr := ":" + cfg.Port srv := &http.Server{Addr: addr, Handler: mux} go func() { fmt.Printf("Platform Bridge listening on port %s\n", cfg.Port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { fmt.Printf("FATAL: %v\n", err) os.Exit(1) } }() quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit fmt.Println("Shutting down Platform Bridge...") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() for _, a := range adapters { a.Disconnect(shutdownCtx) } srv.Shutdown(shutdownCtx) fmt.Println("Platform Bridge stopped") } // qqReaderCancels maps adapter config name to its cancel function. var qqReaderCancels = make(map[string]context.CancelFunc) var qqReaderCancelsMu sync.Mutex // startOBv11Readers cancels any existing OBv11 readers and starts one per registered OBv11 adapter. func startOBv11Readers(router *bridge.PlatformRouter) { // Cancel all existing readers. qqReaderCancelsMu.Lock() for _, cancel := range qqReaderCancels { cancel() } qqReaderCancels = make(map[string]context.CancelFunc) qqReaderCancelsMu.Unlock() for _, qqa := range router.GetAdaptersByPlatform("obv11") { qqAdapter, ok := qqa.(*qqadapter.Adapter) if !ok { continue } adapterKey := qqAdapter.ConfigName() ctx, cancel := context.WithCancel(context.Background()) qqReaderCancelsMu.Lock() qqReaderCancels[adapterKey] = cancel qqReaderCancelsMu.Unlock() adapter := qqAdapter // capture for goroutine const numWorkers = 8 // Single reader pumps raw messages from the adapter. rawCh := make(chan *qqadapter.OBv11Message, 100) go adapter.ReadMessages(ctx, rawCh) // Per-worker channels. var workerChs [numWorkers]chan *qqadapter.OBv11Message for i := 0; i < numWorkers; i++ { workerChs[i] = make(chan *qqadapter.OBv11Message, 50) } // Dispatcher: route to worker by session hash so same conversation stays ordered. go func() { for msg := range rawCh { idx := hashOBv11Session(msg) % numWorkers workerChs[idx] <- msg } for i := 0; i < numWorkers; i++ { close(workerChs[i]) } }() // Worker pool: each worker processes messages sequentially; workers run in parallel. for _, wch := range workerChs { wch := wch // capture go func() { for msg := range wch { response, err := router.RouteMessage(adapterKey, msg) if err != nil { fmt.Printf("[qq:%s] route error: %v\n", adapterKey, err) continue } // nil response = internally handled (e.g., mute tracking), skip sending. if response == nil { continue } if response != nil && len(response.Messages) > 0 && !hasOnlySilentMessages(response.Messages) { messageType := msg.MessageType userID := msg.UserID groupID := msg.GroupID // 检查群禁言状态,避免向被禁言的群发送消息导致风控 if messageType == "group" { if cur, err := router.GetAdapter(adapterKey); err == nil { if qa, ok := cur.(*qqadapter.Adapter); ok && qa.IsGroupMuted(groupID) { fmt.Printf("[qq:%s] 群 %d 处于禁言状态,跳过发送\n", adapterKey, groupID) continue } } } // Filter non-empty messages and strip 【不发送】 self-censored ones. var toSend []bridge.ResponseMessage for _, rm := range response.Messages { if rm.Content != "" { // 昔涟可以加 【不发送】 标签表示这条消息不发送——她醒了但选择不说话。 if strings.Contains(rm.Content, "【不发送】") { fmt.Printf("[qq:%s] 昔涟自我审查,跳过发送: %s\n", adapterKey, truncateString(rm.Content, 60)) continue } rm.Content = qqadapter.ConvertMarkdownToQQ(rm.Content) toSend = append(toSend, rm) } } // NapCat 输入状态:私聊时在发送前显示"正在输入" if messageType == "private" { if cur, err := router.GetAdapter(adapterKey); err == nil { if qa, ok := cur.(*qqadapter.Adapter); ok { qa.SetTypingStatus(userID, 1) } } } interval := time.Duration(adapter.SendIntervalMs()) * time.Millisecond if interval <= 0 { interval = 2 * time.Second } // 群聊用更长间隔避免刷屏,限制最多2条消息,单条不超过500字防封控 if messageType == "group" { minGroupInterval := 3 * time.Second if interval < minGroupInterval { interval = minGroupInterval } if len(toSend) > 2 { toSend = toSend[:2] } const maxRunes = 500 for i := range toSend { runes := []rune(toSend[i].Content) if len(runes) > maxRunes { toSend[i].Content = string(runes[:maxRunes]) + "\n…(内容过长已截断)" } } } for i, rm := range toSend { if i > 0 && interval > 0 { select { case <-ctx.Done(): return case <-time.After(interval): } } // 每条消息发送前重新检查禁言——间隔期间可能被禁 if messageType == "group" { if cur, err := router.GetAdapter(adapterKey); err == nil { if qa, ok := cur.(*qqadapter.Adapter); ok && qa.IsGroupMuted(groupID) { fmt.Printf("[qq:%s] 群 %d 发送间隔期间被禁言,跳过剩余消息\n", adapterKey, groupID) break } } } cur, err := router.GetAdapter(adapterKey) if err != nil { continue } curAdapter, ok := cur.(*qqadapter.Adapter) if !ok { continue } var sendErr error switch messageType { case "private": sendErr = curAdapter.SendMessage("private", userID, 0, rm.Content) case "group": sendErr = curAdapter.SendMessage("group", 0, groupID, rm.Content) } if sendErr != nil { fmt.Printf("[qq:%s] send msg error: %v\n", adapterKey, sendErr) } } // NapCat: clear typing indicator if messageType == "private" { if cur, err := router.GetAdapter(adapterKey); err == nil { if qa, ok := cur.(*qqadapter.Adapter); ok { qa.SetTypingStatus(userID, 0) } } } } } }() } } } // createAdapters builds platform adapters from stored configs. func createAdapters(cfg *config.Config, store *config.Store) []bridge.PlatformAdapter { var adapters []bridge.PlatformAdapter // Build adapters from stored configs. Each config is a separate adapter instance. seen := make(map[string]bool) for _, stored := range store.List() { if !stored.Enabled { fmt.Printf("Platform %s is disabled in config, skipping\n", stored.Name) continue } platform := stored.Platform if platform == "" { platform = stored.Name } fields := mergeFields(cfg, platform, &stored) a := createSingleAdapter(cfg, platform, stored.Name, stored.ID, fields) if a != nil { adapters = append(adapters, a) seen[stored.Name] = true } } // Seed default adapters for platforms that have no stored config. // Track platform types (not config names) to avoid duplicate adapters. seededTypes := map[string]bool{} for _, a := range adapters { seededTypes[a.PlatformName()] = true } for _, stored := range store.List() { seededTypes[stored.Platform] = true // stored.Platform is the type (e.g. "obv11") } defaultPlatforms := []string{"obv11", "telegram", "webhook", "wechat", "feishu", "discord"} for _, name := range defaultPlatforms { if seededTypes[name] { continue } fields := mergeFields(cfg, name, nil) a := createSingleAdapter(cfg, name, name, "", fields) // seed adapter, no config ID if a != nil { adapters = append(adapters, a) } } return adapters } // createSingleAdapter creates one platform adapter from config fields. // platform is the base platform type ("obv11", "telegram", etc.), configName is the instance key. func createSingleAdapter(cfg *config.Config, platform, configName, configID string, fields map[string]string) bridge.PlatformAdapter { switch platform { case "obv11": port := cfg.OBv11BotPort if p, ok := fields["bot_port"]; ok && p != "" { port = p } token := "" if t, ok := fields["access_token"]; ok { token = t } remoteURL := "" if r, ok := fields["remote_url"]; ok { remoteURL = r } mode := "server" if m, ok := fields["mode"]; ok && m != "" { mode = m } else if fields["remote_url"] != "" { mode = "client" // backward compat: old configs with remote_url } sendIntervalMs := cfg.MessageSendIntervalMs if s, ok := fields["send_interval_ms"]; ok && s != "" { if n := parseIntOr(s, cfg.MessageSendIntervalMs); n > 0 { sendIntervalMs = n } } adapter := qqadapter.NewAdapter(configID, configName, mode, port, token, remoteURL, sendIntervalMs) // Optional HTTP API configuration (for typing status, etc.) httpURL := fields["http_url"] httpToken := fields["http_token"] if httpURL != "" || httpToken != "" { adapter.SetHTTPConfig(httpURL, httpToken) } return adapter case "telegram": token := cfg.TelegramToken if t, ok := fields["bot_token"]; ok && t != "" { token = t } webhookURL := cfg.TelegramWebhookURL if w, ok := fields["webhook_url"]; ok && w != "" { webhookURL = w } return telegramadapter.NewAdapter(token, webhookURL) case "webhook": return webhookadapter.NewAdapter(configName) case "wechat": return wechatstub.NewAdapter() case "feishu": return feishustub.NewAdapter() case "discord": return discordstub.NewAdapter() } return nil } // mergeFields returns fields from stored config, falling back to env defaults. func mergeFields(cfg *config.Config, platform string, stored *config.PlatformConfig) map[string]string { fields := make(map[string]string) if stored != nil { for k, v := range stored.Fields { fields[k] = v } } if fields["bot_token"] == "" && cfg.TelegramToken != "" && platform == "telegram" { fields["bot_token"] = cfg.TelegramToken } if fields["webhook_url"] == "" && cfg.TelegramWebhookURL != "" && platform == "telegram" { fields["webhook_url"] = cfg.TelegramWebhookURL } if fields["bot_port"] == "" && cfg.OBv11BotPort != "" && platform == "obv11" { fields["bot_port"] = cfg.OBv11BotPort } return fields } // containsString checks whether a string slice contains a specific value. func containsString(list []string, val string) bool { for _, v := range list { if v == val { return true } } return false } // shouldAdminBeSilent checks if admin is talking to other users in a group. // Returns true if 昔涟 should not interrupt (route as silent observation instead). func shouldAdminBeSilent(msg *bridge.UnifiedMessage, router *bridge.PlatformRouter) bool { if msg.ChannelType != "group" { return false } // Rule 1: Admin @mentions someone other than the bot → talking to them, don't interrupt. for _, m := range msg.Mentions { if m != msg.BotUID { return true } } // Rule 2: Recent context shows a conversation with non-admin users. // Note: updateContext runs before this handler, so RecentSenders already // includes the current message. Check the second-to-last sender instead. ctx := router.GetContext(msg.Platform, msg.ChannelID) if ctx != nil && len(ctx.RecentSenders) >= 2 { prevSender := ctx.RecentSenders[len(ctx.RecentSenders)-2] if prevSender != msg.OriginalSenderUID && prevSender != msg.BotUID { return true } } return false } // getImageURLs extracts image attachment URLs from a UnifiedMessage. func getImageURLs(msg *bridge.UnifiedMessage) []string { if len(msg.Attachments) == 0 { return nil } var urls []string for _, a := range msg.Attachments { if a.Type == "image" && a.URL != "" { urls = append(urls, a.URL) } } return urls } // getVoiceURLs extracts voice/record attachment URLs from a UnifiedMessage. func getVoiceURLs(msg *bridge.UnifiedMessage) []string { if len(msg.Attachments) == 0 { return nil } var urls []string for _, a := range msg.Attachments { if a.Type == "voice" && a.URL != "" { urls = append(urls, a.URL) } } return urls } // getShortVideoURLs returns video URLs with duration ≤ 20 seconds. func getShortVideoURLs(msg *bridge.UnifiedMessage) []string { if len(msg.Attachments) == 0 { return nil } var urls []string for _, a := range msg.Attachments { if a.Type == "video" && a.URL != "" && a.Duration > 0 && a.Duration <= 20 { urls = append(urls, a.URL) } } return urls } // forwardToAICore sends a unified message to AI-Core's chat endpoint and returns the response. func forwardToAICore(cfg *config.Config, msg *bridge.UnifiedMessage, mode, userID, sessionID string, images []string, videoURLs []string, voiceURLs []string, isAdmin bool) (*bridge.UnifiedResponse, error) { bodyMap := map[string]interface{}{ "user_id": userID, "session_id": sessionID, "message": msg.Content, "mode": mode, "routing": msg.RouteType, "nickname": fmt.Sprintf("%s (%s)", msg.SenderName, msg.OriginalSenderUID), "is_admin": isAdmin, "source": map[string]string{ "platform": msg.Platform, "channel_id": msg.ChannelID, "channel_type": msg.ChannelType, "sender_name": msg.SenderName, "original_uid": msg.OriginalSenderUID, "bot_uid": msg.BotUID, "group_name": msg.GroupName, "adapter_name": msg.AdapterName, }, } if len(images) > 0 { bodyMap["images"] = images } if len(videoURLs) > 0 { bodyMap["video_urls"] = videoURLs } if len(voiceURLs) > 0 { bodyMap["voice_urls"] = voiceURLs } reqBody, _ := json.Marshal(bodyMap) url := cfg.AICoreURL + "/api/v1/chat" req, _ := http.NewRequest("POST", url, bytes.NewReader(reqBody)) req.Header.Set("Content-Type", "application/json") if mode == "platform_silent" { req.Header.Set("Accept", "application/json") } else { req.Header.Set("Accept", "text/event-stream") } if cfg.InternalToken != "" { req.Header.Set("X-Internal-Token", cfg.InternalToken) } client := &http.Client{Timeout: 180 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("forward to ai-core failed: %w", err) } defer resp.Body.Close() // Silent mode: only check status, no reply expected. if mode == "platform_silent" { if resp.StatusCode >= 400 { return nil, fmt.Errorf("silent forward returned status %d", resp.StatusCode) } return &bridge.UnifiedResponse{ Messages: []bridge.ResponseMessage{ {DisplayType: "silent", Content: "", FormatMode: "plain"}, }, Platform: msg.Platform, }, nil } // Try JSON first (non-streaming or already-complete response). var result struct { Content string `json:"content"` Error string `json:"error"` } bodyBytes, readErr := ioReadAll(resp.Body) if readErr != nil { return nil, fmt.Errorf("read ai-core response: %w", readErr) } if json.Unmarshal(bodyBytes, &result) == nil { if result.Error != "" { return &bridge.UnifiedResponse{ Messages: []bridge.ResponseMessage{ {DisplayType: "system_info", Content: result.Error, FormatMode: "plain"}, }, Platform: msg.Platform, }, nil } if result.Content != "" { return &bridge.UnifiedResponse{ Messages: splitContent(filterActions(result.Content)), Platform: msg.Platform, }, nil } } // Not JSON — parse as SSE (text/event-stream). content := parseSSEAndAccumulate(string(bodyBytes)) if content == "" { return nil, fmt.Errorf("ai-core returned empty response") } return &bridge.UnifiedResponse{ Messages: splitContent(filterActions(content)), Platform: msg.Platform, }, nil } // ioReadAll reads all bytes from a reader (replaces io.ReadAll for older Go compat). func ioReadAll(r io.Reader) ([]byte, error) { buf := new(bytes.Buffer) _, err := buf.ReadFrom(r) return buf.Bytes(), err } // parseSSEAndAccumulate parses a Server-Sent Events stream and accumulates the full text. // It handles both "delta" chunks and the final "segments" structure. func parseSSEAndAccumulate(body string) string { lines := strings.Split(body, "\n") var deltas []string var finalText string for _, line := range lines { line = strings.TrimSpace(line) if line == "" || line == "[DONE]" { continue } if !strings.HasPrefix(line, "data: ") { continue } payload := strings.TrimPrefix(line, "data: ") var chunk map[string]interface{} if err := json.Unmarshal([]byte(payload), &chunk); err != nil { continue } // Final segment: use the full text if available. if done, _ := chunk["done"].(bool); done { if segs, ok := chunk["segments"].([]interface{}); ok && len(segs) > 0 { if seg, ok := segs[0].(map[string]interface{}); ok { if t, ok := seg["text"].(string); ok && t != "" { finalText = t } } } continue } // Accumulate deltas. if delta, ok := chunk["delta"].(string); ok && delta != "" { deltas = append(deltas, delta) } // Also accumulate content if present (some APIs send full content). if content, ok := chunk["content"].(string); ok && content != "" { deltas = append(deltas, content) } } if finalText != "" { return finalText } return strings.Join(deltas, "") } // splitContent splits text into separate chat messages. // It first splits by \n\n (message separator), then within each message // optionally splits further by ♪ (sentence-break marker). // Very short segments (< 10 chars) are merged with their neighbors to avoid // one-word messages followed by a wall of text. func splitContent(text string) []bridge.ResponseMessage { // Step 1: split by \n\n (message-level separator) always. rawParts := strings.Split(text, "\n\n") var parts []string for _, p := range rawParts { p = strings.TrimSpace(p) if p == "" { continue } // Step 2: within each \n\n segment, split by ♪ if present. if strings.Contains(p, "♪") { for _, sub := range strings.Split(p, "♪") { sub = strings.TrimSpace(sub) if sub != "" { parts = append(parts, sub) } } } else { parts = append(parts, p) } } // Step 3: merge very short segments with neighbors. const minRunes = 8 var merged []string for _, part := range parts { if len(merged) > 0 && len([]rune(merged[len(merged)-1])) < minRunes { // Previous segment is too short: merge current into it. merged[len(merged)-1] = merged[len(merged)-1] + "\n" + part } else if len(merged) > 0 && len([]rune(part)) < minRunes { // Current segment is too short: merge it into previous. merged[len(merged)-1] = merged[len(merged)-1] + "\n" + part } else { merged = append(merged, part) } } parts = merged var msgs []bridge.ResponseMessage for _, part := range parts { msgs = append(msgs, bridge.ResponseMessage{ DisplayType: "chat", Content: part, FormatMode: "plain", }) } if len(msgs) == 0 { return []bridge.ResponseMessage{ {DisplayType: "chat", Content: text, FormatMode: "plain"}, } } return msgs } // filterActions removes action/emote tags and their content from text. // Handles both and tag variants that DeepSeek may produce. func filterActions(text string) string { // Tags to filter (paired: open → close). actionTags := [][2]string{ {"", ""}, {"", ""}, } for _, tags := range actionTags { openTag, closeTag := tags[0], tags[1] for { start := strings.Index(text, openTag) if start == -1 { break } end := strings.Index(text[start:], closeTag) if end == -1 { text = text[:start] + text[start+len(openTag):] continue } text = text[:start] + text[start+end+len(closeTag):] } } // 过滤 LLM 直接输出工具名的情况(DeepSeek 有时不调用函数而是直接输出函数名) text = strings.TrimSpace(text) toolNames := []string{"vision_analyze", "video_analyze", "web_search", "web_fetch", "iot_control", "iot_query", "host_exec", "os_exec", "knowledge_search"} for _, name := range toolNames { if text == name || strings.HasPrefix(text, name+"\n") || strings.HasPrefix(text, name+":") || strings.HasPrefix(text, name+":") { text = strings.TrimPrefix(text, name) text = strings.TrimSpace(text) if text == "" { return "" // 纯工具名,完全去掉 } } } // 也处理 "vision_analyze\n..." 这种内嵌在第一行的情况 for _, name := range toolNames { if strings.HasPrefix(text, name+" ") { text = strings.TrimSpace(text[len(name):]) } } return text } // buildMemoryNamespace creates a memory-isolated user_id for a platform channel. func buildMemoryNamespace(platform, channelType, channelID string) string { return fmt.Sprintf("platform_%s_%s_%s", platform, channelType, channelID) } // detectAdminMention checks whether a message mentions the admin. func detectAdminMention(msg *bridge.UnifiedMessage, mapper *bridge.IdentityMapper, cfg *config.Config) (bool, string) { for _, mentionUID := range msg.Mentions { if mapper.IsAdmin(msg.Platform, mentionUID) { return true, fmt.Sprintf("@mention of admin UID %s", mentionUID) } } lowerContent := strings.ToLower(msg.Content) for _, nickname := range cfg.AdminNicknames { if strings.Contains(msg.Content, nickname) { return true, fmt.Sprintf("admin nickname mention: %s", nickname) } } for _, kw := range cfg.AdminMentionKeywords { if strings.Contains(lowerContent, strings.ToLower(kw)) { return true, fmt.Sprintf("admin keyword mention: %s", kw) } } return false, "" } // hasOnlySilentMessages checks if all response messages are silent (no reply needed). func hasOnlySilentMessages(messages []bridge.ResponseMessage) bool { for _, m := range messages { if m.DisplayType != "silent" && m.Content != "" { return false } } return true } // hashOBv11Session returns a hash for dispatching an OBv11 message to a worker. // Messages from the same conversation (private or group) get the same hash, // preserving ordering within a session while allowing cross-session parallelism. func hashOBv11Session(msg *qqadapter.OBv11Message) uint32 { h := fnv.New32a() h.Write([]byte(msg.MessageType)) h.Write([]byte(":")) if msg.MessageType == "group" { // Hash by group+user: same user's messages in a group stay ordered, // but different users in the same group can be processed in parallel. h.Write([]byte(strconv.FormatInt(msg.GroupID, 10))) h.Write([]byte(":")) h.Write([]byte(strconv.FormatInt(msg.UserID, 10))) } else { h.Write([]byte(strconv.FormatInt(msg.UserID, 10))) } return h.Sum32() } func parseIntOr(s string, defaultVal int) int { n := 0 for _, c := range s { if c >= '0' && c <= '9' { n = n*10 + int(c-'0') } else { return defaultVal } } if n == 0 && s != "0" { return defaultVal } return n } // seedIdentities loads default identity mappings from env vars and stored platform configs. func seedIdentities(m *bridge.IdentityMapper, store *config.Store, adminNickname string) { // From environment variables. for _, entry := range []struct{ envKey, platform string }{ {"OBV11_ADMIN_UID", "obv11"}, {"TELEGRAM_ADMIN_UID", "telegram"}, } { if raw := os.Getenv(entry.envKey); raw != "" { for _, uid := range strings.Split(raw, ",") { uid = strings.TrimSpace(uid) if uid == "" { continue } m.Register(permissions.PlatformIdentity{ Platform: entry.platform, PlatformUID: uid, CyreneUser: "admin", Nickname: adminNickname, PermissionLevel: "admin", }) } } } // From stored platform configs (admin_uids field) — iterate all configs. for _, cfg := range store.List() { if !cfg.Enabled { continue } syncAdminUIDs(m, cfg.Platform, cfg.Fields, adminNickname) } } // syncAdminUIDs registers admin identities from a platform config's admin_uids field. // Comma-separated list of platform UIDs. func syncAdminUIDs(m *bridge.IdentityMapper, platform string, fields map[string]string, adminNickname string) { raw, ok := fields["admin_uids"] if !ok || raw == "" { return } for _, uid := range strings.Split(raw, ",") { uid = strings.TrimSpace(uid) if uid == "" { continue } m.Register(permissions.PlatformIdentity{ Platform: platform, PlatformUID: uid, CyreneUser: "admin", Nickname: adminNickname, PermissionLevel: "admin", }) } fmt.Printf("Synced admin identities for %s from config: %s\n", platform, raw) } // formatDuration returns a human-readable duration string like "1h2m3s". func formatDuration(d time.Duration) string { if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) } if d < time.Hour { m := int(d.Minutes()) s := int(d.Seconds()) % 60 if s > 0 { return fmt.Sprintf("%dm%ds", m, s) } return fmt.Sprintf("%dm", m) } h := int(d.Hours()) m := int(d.Minutes()) % 60 if m > 0 { return fmt.Sprintf("%dh%dm", h, m) } return fmt.Sprintf("%dh", h) } // logKey returns the log file key for a message. // Uses the adapter config name (e.g., "obv11-main") if set, falling back to platform type (e.g., "obv11"). // This ensures each config writes to its own uniquely-named log file. func logKey(msg *bridge.UnifiedMessage) string { if msg.AdapterName != "" { return msg.AdapterName } return msg.Platform } // truncateString truncates a string to maxRunes runes, appending "…" if truncated. func truncateString(s string, maxRunes int) string { runes := []rune(s) if len(runes) <= maxRunes { return s } return string(runes[:maxRunes]) + "…" } // isMessageHistorical returns true if the message timestamp is before the adapter's connection time, // indicating it is a replayed historical message that should be silently observed. func isMessageHistorical(msg *bridge.UnifiedMessage, router *bridge.PlatformRouter) bool { if msg.Timestamp.IsZero() { return false } for _, a := range router.GetAdaptersByPlatform(msg.Platform) { if connectedAt, ok := a.(interface{ ConnectedAt() time.Time }); ok { if msg.Timestamp.Before(connectedAt.ConnectedAt()) { return true } } } return false }