package main import ( "fmt" "log" "net/http" "os" "os/signal" "syscall" "time" "nukumizu-backend/config" "nukumizu-backend/database" "nukumizu-backend/global" "nukumizu-backend/internal/controller" "nukumizu-backend/internal/controller/pipes" "nukumizu-backend/internal/controller/pipes/qq_napcat" "nukumizu-backend/internal/controller/pipes/telegram" "nukumizu-backend/internal/komari" "nukumizu-backend/internal/node" "nukumizu-backend/postLog" "nukumizu-backend/utils" ) // CommitHash and BuildTime are set at build time using -ldflags. var ( CommitHash string BuildTime string ) func main() { // Set global software info. global.SoftwareInfo.CommitHash = CommitHash global.SoftwareInfo.BuildTime = BuildTime // Log startup banner. postLog.Info(fmt.Sprintf("%s Ver.%s.%d.%s.%s Developed by %s at %s", global.SoftwareInfo.Name, global.SoftwareInfo.Version, global.SoftwareInfo.BuildVer, global.SoftwareInfo.BuildType, global.SoftwareInfo.CommitHash, global.SoftwareInfo.Developer, global.SoftwareInfo.BuildTime)) // Load configuration. cfg, err := config.LoadGlobalConfig(global.ConfigPath.Global) if err != nil { log.Fatalf("Failed to load global config: %v", err) } _, err = config.LoadBotUserConfig(global.ConfigPath.BotUserConfig) if err != nil { log.Fatalf("Failed to load bot user config: %v", err) } // Load the node registry config. Unlike the other files it is optional: // missing or empty bot_node_config.json simply means every node keeps its // default enableStatusNotify (true). if err := config.LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil { log.Fatalf("Failed to load bot node config: %v", err) } // Initialize logging. postLog.SetDebugMode(cfg.System.DebugMode) postLog.InitLogBroadcaster() // The logger's debug flag is a process-wide setting rather than something // read at every log call, so a settings update has to push the new value // into it. This is the first link in the reload hook chain; the controllers // join it as they gain reload support. config.OnReload(func(updated *config.Config) { postLog.SetDebugMode(updated.System.DebugMode) }) dbPath := cfg.DBPath if err := postLog.InitLogsDatabase(fmt.Sprintf("%s/log.db", dbPath)); err != nil { postLog.Fatal("Failed to initialize logs database: " + err.Error()) } // Initialize database. if err := database.InitUserDB(fmt.Sprintf("%s/user.db", dbPath)); err != nil { postLog.Fatal("Failed to initialize user database: " + err.Error()) } defer database.CloseUserDB() // Initialize node tracker. node.InitTracker() // Initialize controller manager. controller.InitManager() // Initialize rate limiter. utils.InitRateLimiter(100, time.Minute) // Start token cleaner. utils.StartTokenCleaner() postLog.Info("Starting Nukumizu server...") // --- Login to Komari --- komari.InitClient(cfg.Komari.DashboardURL) if err := komari.LoginAndStart(); err != nil { postLog.Fatal("Failed to login to Komari: " + err.Error()) return } // --- Start Komari WebSocket connection --- komari.InitWSClient(cfg.Komari.DashboardURL, 5) wsClient := komari.GetWSClient() if wsClient != nil { wsClient.SetOnReconnectFail(func() { postLog.Error("Komari WebSocket reconnection exhausted") controller.GetManager().NotifyAllAdmins("Komari WebSocket connection lost after 5 retry attempts") }) if err := wsClient.Connect(); err != nil { postLog.Error("Failed to connect Komari WebSocket: " + err.Error()) } } // --- Initialize controllers --- initControllers() // Send the bot initialization message to all enabled bot controllers // (QQ_NapCat, Telegram), then the server list right after it once the // initial status refresh has completed so the list reflects the real // online/offline state. if mgr := controller.GetManager(); mgr != nil { mgr.ShowBotInitMessage() if tracker := node.GetTracker(); tracker != nil { tracker.OnBootstrapDone(func() { if m := controller.GetManager(); m != nil { m.ShowBotServerList() } }) } } // --- Start background tasks --- startBackgroundTasks(cfg) // --- Setup HTTP router --- mux := SetupRouter() // Apply middleware chain. handler := utils.RateLimitMiddleware(mux) handler = utils.CORSMiddleware(handler) handler = utils.XSSProtectionMiddleware(handler) addr := fmt.Sprintf("%s:%s", cfg.System.ListenAddr, cfg.System.ListenPort) postLog.Info(fmt.Sprintf("Server listening on %s", addr)) // Start HTTP server in a goroutine. go func() { if err := http.ListenAndServe(addr, handler); err != nil { postLog.Fatal("Server error: " + err.Error()) } }() // --- Start the incoming webhook listener --- startWebhookServer(cfg) // --- Graceful shutdown --- quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit postLog.Info("Shutting down Nukumizu server...") // Stop WebSocket client. if wsClient != nil { wsClient.Stop() } // Stop all controllers. mgr := controller.GetManager() if mgr != nil { mgr.StopAll() } postLog.Info("Server stopped") } // startWebhookServer serves the incoming webhook API on its own listener. The // API is not exposed on the main listener: external applications post alerts to // this port only, so its rate limiter and CORS policy are configured // independently. A failure to bind it is logged rather than fatal — the rest of // the program (bots, status monitoring) keeps running without it. func startWebhookServer(cfg *config.Config) { if !cfg.Webhook.Enabled { postLog.Warning("Incoming webhook API is disabled") return } handler := utils.RateLimitMiddleware(SetupWebhookRouter()) handler = utils.CORSMiddleware(handler) addr := fmt.Sprintf("%s:%s", cfg.Webhook.ListenAddr, cfg.Webhook.ListenPort) postLog.Info(fmt.Sprintf("Webhook API listening on %s", addr)) go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("Webhook server panic: %v", r)) } }() if err := http.ListenAndServe(addr, handler); err != nil { postLog.Error("Webhook server error: " + err.Error()) } }() } // initControllers initializes and starts all configured controllers. func initControllers() { cfg := config.Current() mgr := controller.GetManager() if mgr == nil { return } // QQ (Napcat) controller. qqCtrl := qq_napcat.NewQQController(cfg.ControllerMethod.QQ) mgr.Register(qqCtrl) go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("QQ controller panic: %v", r)) } }() if err := qqCtrl.Start(); err != nil { postLog.Error("Failed to start QQ controller: " + err.Error()) } }() // Telegram controller. tgCtrl := telegram.NewTelegramController(cfg.ControllerMethod.Telegram) mgr.Register(tgCtrl) go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("Telegram controller panic: %v", r)) } }() if err := tgCtrl.Start(); err != nil { postLog.Error("Failed to start Telegram controller: " + err.Error()) } }() // Email controller (status-only). emailCtrl := pipes.NewEmailController(cfg.ControllerMethod.Email) mgr.Register(emailCtrl) go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("Email controller panic: %v", r)) } }() if err := emailCtrl.Start(); err != nil { postLog.Error("Failed to start Email controller: " + err.Error()) } }() // Ntfy controller (status-only). ntfyCtrl := pipes.NewNtfyController(cfg.ControllerMethod.Ntfy) mgr.Register(ntfyCtrl) go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("Ntfy controller panic: %v", r)) } }() if err := ntfyCtrl.Start(); err != nil { postLog.Error("Failed to start Ntfy controller: " + err.Error()) } }() // Webhook controller (status-only). webhookCtrl := pipes.NewWebhookController(cfg.ControllerMethod.Webhook) mgr.Register(webhookCtrl) go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("Webhook controller panic: %v", r)) } }() if err := webhookCtrl.Start(); err != nil { postLog.Error("Failed to start Webhook controller: " + err.Error()) } }() } // startBackgroundTasks starts periodic background goroutines. func startBackgroundTasks(_ *config.Config) { // Refresh node list every 5 minutes. go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("Node refresh panic: %v", r)) } }() ticker := time.NewTicker(5 * time.Minute) defer ticker.Stop() for range ticker.C { client := komari.GetClient() if client == nil { continue } nodes, err := client.FetchNodes() if err != nil { postLog.Warning("Failed to refresh node list: " + err.Error()) continue } tracker := node.GetTracker() if tracker != nil { tracker.UpdateNodeList(komari.BuildNodeListData(nodes)) } } }() // Re-login to Komari every 12 hours. go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("Komari re-login panic: %v", r)) } }() ticker := time.NewTicker(12 * time.Hour) defer ticker.Stop() for range ticker.C { postLog.Info("Performing scheduled Komari re-login...") if err := komari.LoginAndStart(); err != nil { postLog.Error("Komari re-login failed: " + err.Error()) } } }() // Wire node status change notifications to all controllers. tracker := node.GetTracker() if tracker != nil { tracker.OnStatusChange(func(change node.StatusChange) { mgr := controller.GetManager() if mgr != nil { mgr.NotifyStatusChange(change) } }) } // Safety net for the initial refresh gate: if some node never reports a // status, force-complete the initial refresh after a grace period so status // notifications cannot stay blocked forever. go func() { defer func() { if r := recover(); r != nil { postLog.Error(fmt.Sprintf("Bootstrap timeout panic: %v", r)) } }() time.Sleep(2 * time.Minute) // Add 2 minutes grace period to allow nodes to report status. if tracker := node.GetTracker(); tracker != nil { tracker.CompleteBootstrap() } }() }