diff --git a/config/config.go b/config/config.go index 0f3641b..5dc80c2 100644 --- a/config/config.go +++ b/config/config.go @@ -88,6 +88,9 @@ func LoadConfig(configPath string) (*Config, error) { } // 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 == "" { cfg.ControllerMessage.ServerStatusChanged = "Server Status Changed Alert\n{{ serverName }} - {{ upStatus }}\nEvent: {{ event }}\nServer Name: {{ serverName }}\nMessage: {{ message }}\nTime: {{ time }}" } diff --git a/config/variables.go b/config/variables.go index 9abae52..588f6bd 100644 --- a/config/variables.go +++ b/config/variables.go @@ -1,109 +1,110 @@ -package config - -// SystemConfig holds system-level configuration. -type SystemConfig struct { - DebugMode bool `json:"debugMode"` - ListenAddr string `json:"listenAddr"` - ListenPort string `json:"listenPort"` -} - -// DebugConfig holds debug-level configuration. -type DebugConfig struct { - ShowNapcatMsg bool `json:"showNapcatMsg"` - ShowNapcatAction bool `json:"showNapcatAction"` - ShowTelegramMsg bool `json:"showTelegramMsg"` - ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"` - NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"` -} - -// KomariAccount holds Komari login credentials. -type KomariAccount struct { - Username string `json:"username"` - Password string `json:"password"` -} - -// KomariConfig holds Komari Dashboard connection settings. -type KomariConfig struct { - DashboardURL string `json:"dashboardURL"` - Account KomariAccount `json:"account"` -} - -// QQConfig holds QQ (Napcat) Bot controller configuration. -type QQConfig struct { - Enabled bool `json:"enabled"` - NapcatAddr string `json:"napcatAddr"` - NapcatPort string `json:"napcatPort"` - NapcatToken string `json:"napcatToken"` - BotQQID int64 `json:"botQQID"` - ListenMethod string `json:"listenMethod"` - Admins []string `json:"admins"` - TrustedGroups []string `json:"trustedGroups"` -} - -// TelegramConfig holds Telegram Bot controller configuration. -type TelegramConfig struct { - Enabled bool `json:"enabled"` - BotToken string `json:"botToken"` - ListenMethod string `json:"listenMethod"` - Admins []string `json:"admins"` - TrustedGroups []string `json:"trustedGroups"` -} - -// EmailConfig holds Email notification controller configuration. -type EmailConfig struct { - Enabled bool `json:"enabled"` - SMTPHost string `json:"smtpHost"` - SMTPPort int `json:"smtpPort"` - Username string `json:"username"` - Password string `json:"password"` - From string `json:"from"` - To []string `json:"to"` - UseTLS bool `json:"useTLS"` -} - -// NtfyConfig holds Ntfy notification controller configuration. -type NtfyConfig struct { - Enabled bool `json:"enabled"` - Server string `json:"server"` - Topic string `json:"topic"` - Token string `json:"token"` - Priority string `json:"priority"` -} - -// WebhookConfig holds Webhook notification controller configuration. -type WebhookConfig struct { - Enabled bool `json:"enabled"` - URL string `json:"url"` - Method string `json:"method"` - Headers map[string]string `json:"headers"` - Template string `json:"template"` -} - -// ControllerMethodConfig holds all controller method configurations. -type ControllerMethodConfig struct { - QQ QQConfig `json:"qq(napcat)"` - Telegram TelegramConfig `json:"telegram"` - Email EmailConfig `json:"email"` - Ntfy NtfyConfig `json:"ntfy"` - Webhook WebhookConfig `json:"webhook"` -} - -// ControllerMessageConfig holds message templates for controller responses. -type ControllerMessageConfig struct { - ServerStatusChanged string `json:"SERVER_STATUS_CHANGED"` - ServerList string `json:"SERVER_LIST"` - ServerExecuteResult string `json:"SERVER_EXECUTE_RESULT"` -} - -// Config is the top-level application configuration. -type Config struct { - System SystemConfig `json:"system"` - Debug DebugConfig `json:"debug"` - Komari KomariConfig `json:"komari"` - ControllerMethod ControllerMethodConfig `json:"controllerMethod"` - ControllerMessage ControllerMessageConfig `json:"controllerMessage"` - DataPath string `json:"dataPath"` - DBPath string `json:"dbPath"` -} - +package config + +// SystemConfig holds system-level configuration. +type SystemConfig struct { + DebugMode bool `json:"debugMode"` + ListenAddr string `json:"listenAddr"` + ListenPort string `json:"listenPort"` +} + +// DebugConfig holds debug-level configuration. +type DebugConfig struct { + ShowNapcatMsg bool `json:"showNapcatMsg"` + ShowNapcatAction bool `json:"showNapcatAction"` + ShowTelegramMsg bool `json:"showTelegramMsg"` + ShowTriggerCmdEcho bool `json:"showTriggerCmdEcho"` + NapcatIgnoreSelfMsg bool `json:"napcatIgnoreSelfMsg"` +} + +// KomariAccount holds Komari login credentials. +type KomariAccount struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// KomariConfig holds Komari Dashboard connection settings. +type KomariConfig struct { + DashboardURL string `json:"dashboardURL"` + Account KomariAccount `json:"account"` +} + +// QQConfig holds QQ (Napcat) Bot controller configuration. +type QQConfig struct { + Enabled bool `json:"enabled"` + NapcatAddr string `json:"napcatAddr"` + NapcatPort string `json:"napcatPort"` + NapcatToken string `json:"napcatToken"` + BotQQID int64 `json:"botQQID"` + ListenMethod string `json:"listenMethod"` + Admins []string `json:"admins"` + TrustedGroups []string `json:"trustedGroups"` +} + +// TelegramConfig holds Telegram Bot controller configuration. +type TelegramConfig struct { + Enabled bool `json:"enabled"` + BotToken string `json:"botToken"` + ListenMethod string `json:"listenMethod"` + Admins []string `json:"admins"` + TrustedGroups []string `json:"trustedGroups"` +} + +// EmailConfig holds Email notification controller configuration. +type EmailConfig struct { + Enabled bool `json:"enabled"` + SMTPHost string `json:"smtpHost"` + SMTPPort int `json:"smtpPort"` + Username string `json:"username"` + Password string `json:"password"` + From string `json:"from"` + To []string `json:"to"` + UseTLS bool `json:"useTLS"` +} + +// NtfyConfig holds Ntfy notification controller configuration. +type NtfyConfig struct { + Enabled bool `json:"enabled"` + Server string `json:"server"` + Topic string `json:"topic"` + Token string `json:"token"` + Priority string `json:"priority"` +} + +// WebhookConfig holds Webhook notification controller configuration. +type WebhookConfig struct { + Enabled bool `json:"enabled"` + URL string `json:"url"` + Method string `json:"method"` + Headers map[string]string `json:"headers"` + Template string `json:"template"` +} + +// ControllerMethodConfig holds all controller method configurations. +type ControllerMethodConfig struct { + QQ QQConfig `json:"qq(napcat)"` + Telegram TelegramConfig `json:"telegram"` + Email EmailConfig `json:"email"` + Ntfy NtfyConfig `json:"ntfy"` + Webhook WebhookConfig `json:"webhook"` +} + +// ControllerMessageConfig holds message templates for controller responses. +type ControllerMessageConfig struct { + BotStarted string `json:"BOT_STARTED"` + ServerStatusChanged string `json:"SERVER_STATUS_CHANGED"` + ServerList string `json:"SERVER_LIST"` + ServerExecuteResult string `json:"SERVER_EXECUTE_RESULT"` +} + +// Config is the top-level application configuration. +type Config struct { + System SystemConfig `json:"system"` + Debug DebugConfig `json:"debug"` + Komari KomariConfig `json:"komari"` + ControllerMethod ControllerMethodConfig `json:"controllerMethod"` + ControllerMessage ControllerMessageConfig `json:"controllerMessage"` + DataPath string `json:"dataPath"` + DBPath string `json:"dbPath"` +} + var globalConfig *Config \ No newline at end of file diff --git a/global/variables.go b/global/variables.go new file mode 100644 index 0000000..ba3e210 --- /dev/null +++ b/global/variables.go @@ -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", +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 94d3d84..68d9a24 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -31,6 +31,15 @@ type Controller interface { 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. type Manager struct { mu sync.RWMutex @@ -60,6 +69,56 @@ func (m *Manager) Register(c Controller) { 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. func (m *Manager) NotifyStatusChange(change node.StatusChange) { m.mu.RLock() diff --git a/internal/controller/pipes/qq_napcat/qq.go b/internal/controller/pipes/qq_napcat/qq.go index 6df2a5a..96c1a23 100644 --- a/internal/controller/pipes/qq_napcat/qq.go +++ b/internal/controller/pipes/qq_napcat/qq.go @@ -201,6 +201,22 @@ func (q *QQController) isSelfMessage(ev oneBotEvent) bool { 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. func (q *QQController) SendStatusChange(change node.StatusChange) error { if !q.cfg.Enabled { diff --git a/internal/controller/pipes/telegram.go b/internal/controller/pipes/telegram.go index 0e6d240..fbf8c57 100644 --- a/internal/controller/pipes/telegram.go +++ b/internal/controller/pipes/telegram.go @@ -92,6 +92,17 @@ func (t *TelegramController) processCommand(cmd controller.Command) string { 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. func (t *TelegramController) SendStatusChange(change node.StatusChange) error { if !t.cfg.Enabled || t.cfg.BotToken == "" { diff --git a/internal/node/tracker.go b/internal/node/tracker.go index 0a2f871..743efc1 100644 --- a/internal/node/tracker.go +++ b/internal/node/tracker.go @@ -71,11 +71,12 @@ type StatusChangeCallback func(change StatusChange) // Tracker maintains the in-memory state of all monitored nodes. // All methods are thread-safe. type Tracker struct { - mu sync.RWMutex - nodes map[string]*Node // uuid → Node - uuidToName map[string]string // uuid → name (from node list) - onlineSet map[string]bool // which uuids are currently online - callbacks []StatusChangeCallback + mu sync.RWMutex + nodes map[string]*Node // uuid → Node + uuidToName map[string]string // uuid → name (from node list) + onlineSet map[string]bool // which uuids are currently online + callbacks []StatusChangeCallback + onBootstrapDone []func() // Initial-refresh gating: status-change notifications are suppressed until // 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 + bootstrapDoneNow := false if !t.bootstrapDone && t.refreshComplete(reports) { t.bootstrapDone = true + bootstrapDoneNow = true postLog.Info(fmt.Sprintf("Initial node status refresh complete (%d/%d nodes reported); status notifications enabled", len(t.reportedUUIDs), len(t.knownUUIDs))) } 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). for _, change := range changes { if suppressChanges { @@ -301,6 +310,40 @@ func (t *Tracker) refreshComplete(currentReports map[string]Report) bool { 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 // 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 @@ -308,11 +351,14 @@ func (t *Tracker) refreshComplete(currentReports map[string]Report) bool { // again is a no-op. func (t *Tracker) CompleteBootstrap() { t.mu.Lock() - defer t.mu.Unlock() - if !t.bootstrapDone { - t.bootstrapDone = true - postLog.Info("Initial node status refresh timeout reached; status notifications enabled") + if t.bootstrapDone { + t.mu.Unlock() + return } + 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. diff --git a/internal/template/template.go b/internal/template/template.go index 0d01d81..d4aeb41 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -5,21 +5,43 @@ import ( "strings" "time" + "nukumizu-backend/global" "nukumizu-backend/internal/node" ) // Params holds all possible template parameters. type Params struct { - Time string - ServerName string - ServerUUID string - UpStatus string - Event string - Message string - Command string - Result string - OnlineServers string // Pre-formatted multi-line list - OfflineServers string // Pre-formatted multi-line list + Time string + ServerName string + ServerUUID string + UpStatus string + Event string + Message string + Command string + Result string + OnlineServers 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. @@ -70,6 +92,13 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P // - {{ result }} — command execution result // - {{ list.onlineServers }} — multi-line online 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 { result := tmpl @@ -83,6 +112,13 @@ func Render(tmpl string, params Params) string { result = strings.ReplaceAll(result, "{{ result }}", params.Result) result = strings.ReplaceAll(result, "{{ list.onlineServers }}", params.OnlineServers) 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 } diff --git a/main.go b/main.go index 1c110dc..e8724c4 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "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" @@ -27,36 +28,17 @@ var ( 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() { + // Set global software info. + global.SoftwareInfo.CommitHash = CommitHash + global.SoftwareInfo.BuildTime = BuildTime + // Parse CLI flags. configPath := flag.String("config", "config.json", "Path to configuration file") flag.Parse() // 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. cfg, err := config.LoadConfig(*configPath) @@ -119,6 +101,22 @@ func main() { // --- 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)