feat(controller): add init message for bot pipes

chore: move softwareInfo from main package to a individual global package
This commit is contained in:
2026-08-07 22:13:15 +08:00
parent d8a192e4e5
commit 56ed32ac0c
9 changed files with 345 additions and 151 deletions
+3
View File
@@ -88,6 +88,9 @@ func LoadConfig(configPath string) (*Config, error) {
} }
// Apply default message templates if not specified. // Apply default message templates if not specified.
if cfg.ControllerMessage.BotStarted == "" {
cfg.ControllerMessage.BotStarted = "Nukumizu Alert Bot Started\nVersion: {{ softwareVersion }}.{{ softwareBuildVer }}.{{ softwareCommitHash }}.{{ softwareBuildType }}\nDeveloper: {{ softwareDeveloper }}\nTime: {{ time }}"
}
if cfg.ControllerMessage.ServerStatusChanged == "" { if cfg.ControllerMessage.ServerStatusChanged == "" {
cfg.ControllerMessage.ServerStatusChanged = "Server Status Changed Alert\n{{ serverName }} - {{ upStatus }}\nEvent: {{ event }}\nServer Name: {{ serverName }}\nMessage: {{ message }}\nTime: {{ time }}" cfg.ControllerMessage.ServerStatusChanged = "Server Status Changed Alert\n{{ serverName }} - {{ upStatus }}\nEvent: {{ event }}\nServer Name: {{ serverName }}\nMessage: {{ message }}\nTime: {{ time }}"
} }
+109 -108
View File
@@ -1,109 +1,110 @@
package config package config
// SystemConfig holds system-level configuration. // SystemConfig holds system-level configuration.
type SystemConfig struct { type SystemConfig struct {
DebugMode bool `json:"debugMode"` DebugMode bool `json:"debugMode"`
ListenAddr string `json:"listenAddr"` ListenAddr string `json:"listenAddr"`
ListenPort string `json:"listenPort"` ListenPort string `json:"listenPort"`
} }
// DebugConfig holds debug-level configuration. // DebugConfig holds debug-level configuration.
type DebugConfig struct { type DebugConfig struct {
ShowNapcatMsg bool `json:"showNapcatMsg"` ShowNapcatMsg bool `json:"showNapcatMsg"`
ShowNapcatAction bool `json:"showNapcatAction"` ShowNapcatAction bool `json:"showNapcatAction"`
ShowTelegramMsg bool `json:"showTelegramMsg"` ShowTelegramMsg bool `json:"showTelegramMsg"`
ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"` ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"`
NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"` NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"`
} }
// KomariAccount holds Komari login credentials. // KomariAccount holds Komari login credentials.
type KomariAccount struct { type KomariAccount struct {
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
} }
// KomariConfig holds Komari Dashboard connection settings. // KomariConfig holds Komari Dashboard connection settings.
type KomariConfig struct { type KomariConfig struct {
DashboardURL string `json:"dashboardURL"` DashboardURL string `json:"dashboardURL"`
Account KomariAccount `json:"account"` Account KomariAccount `json:"account"`
} }
// QQConfig holds QQ (Napcat) Bot controller configuration. // QQConfig holds QQ (Napcat) Bot controller configuration.
type QQConfig struct { type QQConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
NapcatAddr string `json:"napcatAddr"` NapcatAddr string `json:"napcatAddr"`
NapcatPort string `json:"napcatPort"` NapcatPort string `json:"napcatPort"`
NapcatToken string `json:"napcatToken"` NapcatToken string `json:"napcatToken"`
BotQQID int64 `json:"botQQID"` BotQQID int64 `json:"botQQID"`
ListenMethod string `json:"listenMethod"` ListenMethod string `json:"listenMethod"`
Admins []string `json:"admins"` Admins []string `json:"admins"`
TrustedGroups []string `json:"trustedGroups"` TrustedGroups []string `json:"trustedGroups"`
} }
// TelegramConfig holds Telegram Bot controller configuration. // TelegramConfig holds Telegram Bot controller configuration.
type TelegramConfig struct { type TelegramConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
BotToken string `json:"botToken"` BotToken string `json:"botToken"`
ListenMethod string `json:"listenMethod"` ListenMethod string `json:"listenMethod"`
Admins []string `json:"admins"` Admins []string `json:"admins"`
TrustedGroups []string `json:"trustedGroups"` TrustedGroups []string `json:"trustedGroups"`
} }
// EmailConfig holds Email notification controller configuration. // EmailConfig holds Email notification controller configuration.
type EmailConfig struct { type EmailConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
SMTPHost string `json:"smtpHost"` SMTPHost string `json:"smtpHost"`
SMTPPort int `json:"smtpPort"` SMTPPort int `json:"smtpPort"`
Username string `json:"username"` Username string `json:"username"`
Password string `json:"password"` Password string `json:"password"`
From string `json:"from"` From string `json:"from"`
To []string `json:"to"` To []string `json:"to"`
UseTLS bool `json:"useTLS"` UseTLS bool `json:"useTLS"`
} }
// NtfyConfig holds Ntfy notification controller configuration. // NtfyConfig holds Ntfy notification controller configuration.
type NtfyConfig struct { type NtfyConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Server string `json:"server"` Server string `json:"server"`
Topic string `json:"topic"` Topic string `json:"topic"`
Token string `json:"token"` Token string `json:"token"`
Priority string `json:"priority"` Priority string `json:"priority"`
} }
// WebhookConfig holds Webhook notification controller configuration. // WebhookConfig holds Webhook notification controller configuration.
type WebhookConfig struct { type WebhookConfig struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
URL string `json:"url"` URL string `json:"url"`
Method string `json:"method"` Method string `json:"method"`
Headers map[string]string `json:"headers"` Headers map[string]string `json:"headers"`
Template string `json:"template"` Template string `json:"template"`
} }
// ControllerMethodConfig holds all controller method configurations. // ControllerMethodConfig holds all controller method configurations.
type ControllerMethodConfig struct { type ControllerMethodConfig struct {
QQ QQConfig `json:"qq(napcat)"` QQ QQConfig `json:"qq(napcat)"`
Telegram TelegramConfig `json:"telegram"` Telegram TelegramConfig `json:"telegram"`
Email EmailConfig `json:"email"` Email EmailConfig `json:"email"`
Ntfy NtfyConfig `json:"ntfy"` Ntfy NtfyConfig `json:"ntfy"`
Webhook WebhookConfig `json:"webhook"` Webhook WebhookConfig `json:"webhook"`
} }
// ControllerMessageConfig holds message templates for controller responses. // ControllerMessageConfig holds message templates for controller responses.
type ControllerMessageConfig struct { type ControllerMessageConfig struct {
ServerStatusChanged string `json:"SERVER_STATUS_CHANGED"` BotStarted string `json:"BOT_STARTED"`
ServerList string `json:"SERVER_LIST"` ServerStatusChanged string `json:"SERVER_STATUS_CHANGED"`
ServerExecuteResult string `json:"SERVER_EXECUTE_RESULT"` ServerList string `json:"SERVER_LIST"`
} ServerExecuteResult string `json:"SERVER_EXECUTE_RESULT"`
}
// Config is the top-level application configuration.
type Config struct { // Config is the top-level application configuration.
System SystemConfig `json:"system"` type Config struct {
Debug DebugConfig `json:"debug"` System SystemConfig `json:"system"`
Komari KomariConfig `json:"komari"` Debug DebugConfig `json:"debug"`
ControllerMethod ControllerMethodConfig `json:"controllerMethod"` Komari KomariConfig `json:"komari"`
ControllerMessage ControllerMessageConfig `json:"controllerMessage"` ControllerMethod ControllerMethodConfig `json:"controllerMethod"`
DataPath string `json:"dataPath"` ControllerMessage ControllerMessageConfig `json:"controllerMessage"`
DBPath string `json:"dbPath"` DataPath string `json:"dataPath"`
} DBPath string `json:"dbPath"`
}
var globalConfig *Config var globalConfig *Config
+24
View File
@@ -0,0 +1,24 @@
package global
// SoftwareInfo holds build metadata.
type SoftwareInfoStr struct {
Name string
Version string
Developer string
BuildVer int16
CommitHash string
Description string
BuildType string
BuildTime string
}
var SoftwareInfo = SoftwareInfoStr{
Name: "Nukumizu",
Version: "0.1.0",
Developer: "Madobi Nanami",
BuildVer: 1,
CommitHash: "unknown",
Description: "Remote server monitoring and command execution subsystem for Komari",
BuildType: "Debug",
BuildTime: "unknown",
}
+59
View File
@@ -31,6 +31,15 @@ type Controller interface {
SendExecuteResult(serverName, serverUUID, command, result string) error SendExecuteResult(serverName, serverUUID, command, result string) error
} }
// BotController is implemented by controllers that act as chat bots and can
// deliver arbitrary messages, such as the bot initialization message on startup.
// Only bot-type pipes (QQ/NapCat, Telegram) implement it; notification-only
// pipes (email, ntfy, webhook) do not.
type BotController interface {
Controller
SendMessage(message string) error
}
// Manager manages all controller instances and routes events. // Manager manages all controller instances and routes events.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
@@ -60,6 +69,56 @@ func (m *Manager) Register(c Controller) {
postLog.Info("Controller registered: " + c.Name()) postLog.Info("Controller registered: " + c.Name())
} }
// ShowBotInitMessage sends the bot initialization message to all enabled
// bot controllers (QQ/NapCat and Telegram). Notification-only pipes that do
// not implement BotController are skipped.
func (m *Manager) ShowBotInitMessage() {
m.mu.RLock()
defer m.mu.RUnlock()
cfg := config.GetConfig()
params := template.BuildBotInitializationMsgParams()
message := template.Render(cfg.ControllerMessage.BotStarted, params)
for _, ctrl := range m.controllers {
if !ctrl.IsEnabled() {
continue
}
bot, ok := ctrl.(BotController)
if !ok {
continue // Notification-only pipe (email/ntfy/webhook), not a bot.
}
if err := bot.SendMessage(message); err != nil {
postLog.Warning(fmt.Sprintf("Controller %s failed to send init message: %v", bot.Name(), err))
}
}
}
// ShowBotServerList sends the server list to all enabled bot controllers. The
// message content is identical to the /list command (same template and
// parameters).
func (m *Manager) ShowBotServerList() {
m.mu.RLock()
defer m.mu.RUnlock()
cfg := config.GetConfig()
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params)
for _, ctrl := range m.controllers {
if !ctrl.IsEnabled() {
continue
}
bot, ok := ctrl.(BotController)
if !ok {
continue // Notification-only pipe (email/ntfy/webhook), not a bot.
}
if err := bot.SendMessage(message); err != nil {
postLog.Warning(fmt.Sprintf("Controller %s failed to send server list: %v", bot.Name(), err))
}
}
}
// NotifyStatusChange sends a status change notification to all enabled controllers. // NotifyStatusChange sends a status change notification to all enabled controllers.
func (m *Manager) NotifyStatusChange(change node.StatusChange) { func (m *Manager) NotifyStatusChange(change node.StatusChange) {
m.mu.RLock() m.mu.RLock()
+16
View File
@@ -201,6 +201,22 @@ func (q *QQController) isSelfMessage(ev oneBotEvent) bool {
return false return false
} }
// SendMessage sends an arbitrary message (e.g. the bot initialization message)
// to all QQ trusted groups and admins.
func (q *QQController) SendMessage(message string) error {
if !q.cfg.Enabled {
return nil
}
for _, groupID := range q.cfg.TrustedGroups {
q.sendGroupMessage(groupID, message)
}
for _, adminID := range q.cfg.Admins {
q.sendPrivateMessage(adminID, message)
}
return nil
}
// SendStatusChange sends a status change notification via QQ. // SendStatusChange sends a status change notification via QQ.
func (q *QQController) SendStatusChange(change node.StatusChange) error { func (q *QQController) SendStatusChange(change node.StatusChange) error {
if !q.cfg.Enabled { if !q.cfg.Enabled {
+11
View File
@@ -92,6 +92,17 @@ func (t *TelegramController) processCommand(cmd controller.Command) string {
return response return response
} }
// SendMessage sends an arbitrary message (e.g. the bot initialization message)
// via Telegram. The Telegram Bot API integration is currently a stub, so this
// only logs the message for now.
func (t *TelegramController) SendMessage(message string) error {
if !t.cfg.Enabled || t.cfg.BotToken == "" {
return nil
}
postLog.Debug("Telegram init message: " + message)
return nil
}
// SendStatusChange sends a status change notification via Telegram. // SendStatusChange sends a status change notification via Telegram.
func (t *TelegramController) SendStatusChange(change node.StatusChange) error { func (t *TelegramController) SendStatusChange(change node.StatusChange) error {
if !t.cfg.Enabled || t.cfg.BotToken == "" { if !t.cfg.Enabled || t.cfg.BotToken == "" {
+55 -9
View File
@@ -71,11 +71,12 @@ type StatusChangeCallback func(change StatusChange)
// Tracker maintains the in-memory state of all monitored nodes. // Tracker maintains the in-memory state of all monitored nodes.
// All methods are thread-safe. // All methods are thread-safe.
type Tracker struct { type Tracker struct {
mu sync.RWMutex mu sync.RWMutex
nodes map[string]*Node // uuid → Node nodes map[string]*Node // uuid → Node
uuidToName map[string]string // uuid → name (from node list) uuidToName map[string]string // uuid → name (from node list)
onlineSet map[string]bool // which uuids are currently online onlineSet map[string]bool // which uuids are currently online
callbacks []StatusChangeCallback callbacks []StatusChangeCallback
onBootstrapDone []func()
// Initial-refresh gating: status-change notifications are suppressed until // Initial-refresh gating: status-change notifications are suppressed until
// the initial status snapshot has been received, so the startup state is // the initial status snapshot has been received, so the startup state is
@@ -250,13 +251,21 @@ func (t *Tracker) UpdateStatus(onlineUUIDs []string, reports map[string]Report)
} }
suppressChanges := !t.bootstrapDone || firstSnapshot suppressChanges := !t.bootstrapDone || firstSnapshot
bootstrapDoneNow := false
if !t.bootstrapDone && t.refreshComplete(reports) { if !t.bootstrapDone && t.refreshComplete(reports) {
t.bootstrapDone = true t.bootstrapDone = true
bootstrapDoneNow = true
postLog.Info(fmt.Sprintf("Initial node status refresh complete (%d/%d nodes reported); status notifications enabled", postLog.Info(fmt.Sprintf("Initial node status refresh complete (%d/%d nodes reported); status notifications enabled",
len(t.reportedUUIDs), len(t.knownUUIDs))) len(t.reportedUUIDs), len(t.knownUUIDs)))
} }
t.mu.Unlock() t.mu.Unlock()
// Notify startup listeners (e.g. server list sender) once the initial
// refresh has finished.
if bootstrapDoneNow {
t.fireBootstrapDone()
}
// Fire callbacks for each change (outside the lock). // Fire callbacks for each change (outside the lock).
for _, change := range changes { for _, change := range changes {
if suppressChanges { if suppressChanges {
@@ -301,6 +310,40 @@ func (t *Tracker) refreshComplete(currentReports map[string]Report) bool {
return true return true
} }
// OnBootstrapDone registers a callback invoked once the initial status refresh
// completes (e.g. to send the startup server list). If the refresh has already
// completed, the callback is invoked immediately. Each callback fires exactly
// once.
func (t *Tracker) OnBootstrapDone(cb func()) {
t.mu.Lock()
t.onBootstrapDone = append(t.onBootstrapDone, cb)
if !t.bootstrapDone {
t.mu.Unlock()
return
}
// Already complete — collect everything pending and fire outside the lock.
cbs := t.onBootstrapDone
t.onBootstrapDone = nil
t.mu.Unlock()
for _, c := range cbs {
c()
}
}
// fireBootstrapDone notifies all registered callbacks that the initial status
// refresh is complete. Must be called with the lock NOT held.
func (t *Tracker) fireBootstrapDone() {
t.mu.Lock()
cbs := t.onBootstrapDone
t.onBootstrapDone = nil
t.mu.Unlock()
for _, cb := range cbs {
cb()
}
}
// CompleteBootstrap force-completes the initial status refresh gate. It is a // CompleteBootstrap force-completes the initial status refresh gate. It is a
// safety net so that notifications cannot be blocked forever when no status // safety net so that notifications cannot be blocked forever when no status
// snapshot is ever received. It does not mark a snapshot as received, so the // snapshot is ever received. It does not mark a snapshot as received, so the
@@ -308,11 +351,14 @@ func (t *Tracker) refreshComplete(currentReports map[string]Report) bool {
// again is a no-op. // again is a no-op.
func (t *Tracker) CompleteBootstrap() { func (t *Tracker) CompleteBootstrap() {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() if t.bootstrapDone {
if !t.bootstrapDone { t.mu.Unlock()
t.bootstrapDone = true return
postLog.Info("Initial node status refresh timeout reached; status notifications enabled")
} }
t.bootstrapDone = true
postLog.Info("Initial node status refresh timeout reached; status notifications enabled")
t.mu.Unlock()
t.fireBootstrapDone()
} }
// GetNode returns a copy of the node data for the given UUID. // GetNode returns a copy of the node data for the given UUID.
+46 -10
View File
@@ -5,21 +5,43 @@ import (
"strings" "strings"
"time" "time"
"nukumizu-backend/global"
"nukumizu-backend/internal/node" "nukumizu-backend/internal/node"
) )
// Params holds all possible template parameters. // Params holds all possible template parameters.
type Params struct { type Params struct {
Time string Time string
ServerName string ServerName string
ServerUUID string ServerUUID string
UpStatus string UpStatus string
Event string Event string
Message string Message string
Command string Command string
Result string Result string
OnlineServers string // Pre-formatted multi-line list OnlineServers string // Pre-formatted multi-line list
OfflineServers string // Pre-formatted multi-line list OfflineServers string // Pre-formatted multi-line list
SoftwareVersion string
SoftwareBuildVer int16
SoftwareCommitHash string
SoftwareBuildType string
SoftwareBuildTime string
SoftwareDeveloper string
SoftwareDescription string
}
// BuildBotInitializationMsgParams creates template parameters for the bot initialization message.
func BuildBotInitializationMsgParams() Params {
return Params{
Time: time.Now().Format("2006-01-02T15:04:05.000000000-07:00"),
SoftwareVersion: global.SoftwareInfo.Version,
SoftwareBuildVer: global.SoftwareInfo.BuildVer,
SoftwareCommitHash: global.SoftwareInfo.CommitHash,
SoftwareBuildType: global.SoftwareInfo.BuildType,
SoftwareBuildTime: global.SoftwareInfo.BuildTime,
SoftwareDeveloper: global.SoftwareInfo.Developer,
SoftwareDescription: global.SoftwareInfo.Description,
}
} }
// BuildParamsFromStatusChange creates template parameters from a status change event. // BuildParamsFromStatusChange creates template parameters from a status change event.
@@ -70,6 +92,13 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
// - {{ result }} — command execution result // - {{ result }} — command execution result
// - {{ list.onlineServers }} — multi-line online server list // - {{ list.onlineServers }} — multi-line online server list
// - {{ list.offlineServers }} — multi-line offline server list // - {{ list.offlineServers }} — multi-line offline server list
// - {{ softwareVersion }} — software version
// - {{ softwareBuildVer }} — software build version
// - {{ softwareCommitHash }} — software commit hash
// - {{ softwareBuildType }} — software build type (Debug/Release)
// - {{ softwareBuildTime }} — software build time
// - {{ softwareDeveloper }} — software developer
// - {{ softwareDescription }} — software description
func Render(tmpl string, params Params) string { func Render(tmpl string, params Params) string {
result := tmpl result := tmpl
@@ -83,6 +112,13 @@ func Render(tmpl string, params Params) string {
result = strings.ReplaceAll(result, "{{ result }}", params.Result) result = strings.ReplaceAll(result, "{{ result }}", params.Result)
result = strings.ReplaceAll(result, "{{ list.onlineServers }}", params.OnlineServers) result = strings.ReplaceAll(result, "{{ list.onlineServers }}", params.OnlineServers)
result = strings.ReplaceAll(result, "{{ list.offlineServers }}", params.OfflineServers) result = strings.ReplaceAll(result, "{{ list.offlineServers }}", params.OfflineServers)
result = strings.ReplaceAll(result, "{{ softwareVersion }}", params.SoftwareVersion)
result = strings.ReplaceAll(result, "{{ softwareBuildVer }}", fmt.Sprintf("%d", params.SoftwareBuildVer))
result = strings.ReplaceAll(result, "{{ softwareCommitHash }}", params.SoftwareCommitHash)
result = strings.ReplaceAll(result, "{{ softwareBuildType }}", params.SoftwareBuildType)
result = strings.ReplaceAll(result, "{{ softwareBuildTime }}", params.SoftwareBuildTime)
result = strings.ReplaceAll(result, "{{ softwareDeveloper }}", params.SoftwareDeveloper)
result = strings.ReplaceAll(result, "{{ softwareDescription }}", params.SoftwareDescription)
return result return result
} }
+22 -24
View File
@@ -12,6 +12,7 @@ import (
"nukumizu-backend/config" "nukumizu-backend/config"
"nukumizu-backend/database" "nukumizu-backend/database"
"nukumizu-backend/global"
"nukumizu-backend/internal/controller" "nukumizu-backend/internal/controller"
"nukumizu-backend/internal/controller/pipes" "nukumizu-backend/internal/controller/pipes"
"nukumizu-backend/internal/controller/pipes/qq_napcat" "nukumizu-backend/internal/controller/pipes/qq_napcat"
@@ -27,36 +28,17 @@ var (
BuildTime string BuildTime string
) )
// SoftwareInfo holds build metadata.
type SoftwareInfo struct {
Name string
Version string
Developer string
BuildVer int16
CommitHash string
Description string
BuildType string
BuildTime string
}
var softwareInfo = SoftwareInfo{
Name: "Nukumizu",
Version: "0.1.0",
Developer: "Madobi Nanami",
BuildVer: 1,
CommitHash: CommitHash,
Description: "Remote server monitoring and command execution subsystem for Komari",
BuildType: "Debug",
BuildTime: BuildTime,
}
func main() { func main() {
// Set global software info.
global.SoftwareInfo.CommitHash = CommitHash
global.SoftwareInfo.BuildTime = BuildTime
// Parse CLI flags. // Parse CLI flags.
configPath := flag.String("config", "config.json", "Path to configuration file") configPath := flag.String("config", "config.json", "Path to configuration file")
flag.Parse() flag.Parse()
// Log startup banner. // Log startup banner.
postLog.Info(fmt.Sprintf("%s Ver.%s.%d.%s.%s Developed by %s at %s", softwareInfo.Name, softwareInfo.Version, softwareInfo.BuildVer, softwareInfo.BuildType, softwareInfo.CommitHash, softwareInfo.Developer, softwareInfo.BuildTime)) 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. // Load configuration.
cfg, err := config.LoadConfig(*configPath) cfg, err := config.LoadConfig(*configPath)
@@ -119,6 +101,22 @@ func main() {
// --- Initialize controllers --- // --- Initialize controllers ---
initControllers() 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 --- // --- Start background tasks ---
startBackgroundTasks(cfg) startBackgroundTasks(cfg)