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 }}"
} }
+5 -4
View File
@@ -9,10 +9,10 @@ type SystemConfig struct {
// 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"`
} }
@@ -90,6 +90,7 @@ type ControllerMethodConfig struct {
// ControllerMessageConfig holds message templates for controller responses. // ControllerMessageConfig holds message templates for controller responses.
type ControllerMessageConfig struct { type ControllerMessageConfig struct {
BotStarted string `json:"BOT_STARTED"`
ServerStatusChanged string `json:"SERVER_STATUS_CHANGED"` ServerStatusChanged string `json:"SERVER_STATUS_CHANGED"`
ServerList string `json:"SERVER_LIST"` ServerList string `json:"SERVER_LIST"`
ServerExecuteResult string `json:"SERVER_EXECUTE_RESULT"` ServerExecuteResult string `json:"SERVER_EXECUTE_RESULT"`
+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)