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.
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 }}"
}
+109 -108
View File
@@ -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
+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
}
// 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()
+16
View File
@@ -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 {
+11
View File
@@ -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 == "" {
+55 -9
View File
@@ -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.
+46 -10
View File
@@ -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
}
+22 -24
View File
@@ -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)