diff --git a/config/concurrency_test.go b/config/concurrency_test.go new file mode 100644 index 0000000..476aa35 --- /dev/null +++ b/config/concurrency_test.go @@ -0,0 +1,164 @@ +package config + +import ( + "sync" + "testing" + + "nukumizu-backend/global" +) + +// TestConcurrentReloadAndRead drives every configuration accessor from reader +// goroutines while UpdateSettings and SaveBotNodeConfig replace the in-memory +// configurations underneath them. Run with -race to check that the swap is +// race-free: before the singletons were published through atomic pointers this +// pattern was an unsynchronized read of a variable written by LoadGlobalConfig +// and friends, which the race detector reports. +// +// The readers call the accessors the way production code does — take the value +// and use it immediately, never store it — because that is what keeps a reader +// pinned to one complete version of the configuration. +func TestConcurrentReloadAndRead(t *testing.T) { + writeTempConfig(t, &global.ConfigPath.Global, `{ + "system": { + "debugMode": true, + "listenPort": "8080" + }, + "webhook": { + "endpoints": { + "example": { "enabled": true, "token": "t", "notifyPipes": ["ntfy"] } + } + }, + "controllerMessage": { + "BOT_STARTED": "hello" + } +}`) + writeTempConfig(t, &global.ConfigPath.BotUserConfig, `{ + "qq(napcat)": { + "admins": { "1": { "event_reply": true } }, + "trustedGroups": { "2": { "event_status_notify": true } } + } +}`) + writeTempConfig(t, &global.ConfigPath.BotNodeConfig, `{ + "node-1": { "enableStatusNotify": true } +}`) + + // Seed every singleton so the readers start from a loaded configuration + // rather than racing the first store. + if _, err := LoadGlobalConfig(global.ConfigPath.Global); err != nil { + t.Fatalf("LoadGlobalConfig: %v", err) + } + if _, err := LoadBotUserConfig(global.ConfigPath.BotUserConfig); err != nil { + t.Fatalf("LoadBotUserConfig: %v", err) + } + if err := LoadBotNodeConfig(global.ConfigPath.BotNodeConfig); err != nil { + t.Fatalf("LoadBotNodeConfig: %v", err) + } + + const readers = 4 + const rounds = 40 + + var readersWg, writersWg sync.WaitGroup + stop := make(chan struct{}) + + for i := 0; i < readers; i++ { + readersWg.Add(1) + go func() { + defer readersWg.Done() + for { + select { + case <-stop: + return + default: + } + if cfg := Current(); cfg != nil { + _ = cfg.System.DebugMode + _ = cfg.System.ListenPort + _ = cfg.ControllerMessage.BotStarted + _ = cfg.Webhook.Endpoints + } + if users := BotUsers(); users != nil { + _ = users.QQ.Admins.IDs() + _ = users.QQ.TrustedGroups.IDs() + } + _ = BotNodes() + _ = IsDebugMode() + _ = NodeStatusNotifyEnabled("node-1") + _ = WebhookEndpoints() + _, _ = GetWebhookEndpoint("example") + } + }() + } + + // Writer: reloads the global and bot user configurations, and rewrites the + // node registry through UpdateSettings so its reload runs too. + writersWg.Add(1) + go func() { + defer writersWg.Done() + for i := 0; i < rounds; i++ { + enabled := i%2 == 0 + patch := map[string]interface{}{ + "system": map[string]interface{}{"debugMode": enabled}, + "controllerMessage": map[string]interface{}{ + "BOT_STARTED": "hello", + }, + } + if err := UpdateSettings(SettingGlobal, patch); err != nil { + t.Errorf("UpdateSettings(global): %v", err) + return + } + if err := UpdateSettings(SettingBotUserConfig, map[string]interface{}{ + "qq(napcat)": map[string]interface{}{ + "admins": map[string]interface{}{ + "1": map[string]interface{}{"event_reply": enabled}, + }, + }, + }); err != nil { + t.Errorf("UpdateSettings(bot_user_config): %v", err) + return + } + if err := UpdateSettings(SettingBotNodeConfig, map[string]interface{}{ + "node-2": map[string]interface{}{"enableStatusNotify": enabled}, + }); err != nil { + t.Errorf("UpdateSettings(bot_node_config): %v", err) + return + } + } + }() + + // Second writer: the node tracker's background save, which shares the same + // read-modify-write lock as the admin edits above. + writersWg.Add(1) + go func() { + defer writersWg.Done() + for i := 0; i < rounds; i++ { + if err := SaveBotNodeConfig(global.ConfigPath.BotNodeConfig, []string{"node-1", "node-2"}); err != nil { + t.Errorf("SaveBotNodeConfig: %v", err) + return + } + } + }() + + // Let the writers finish, then release the readers. Waiting on the readers + // first would deadlock: they only return once stop is closed. + writersWg.Wait() + close(stop) + readersWg.Wait() + + // The last write must be visible: the accessors are not allowed to serve a + // stale configuration once UpdateSettings has returned. + if err := UpdateSettings(SettingGlobal, map[string]interface{}{ + "system": map[string]interface{}{"debugMode": true}, + }); err != nil { + t.Fatalf("final UpdateSettings(global): %v", err) + } + cfg := Current() + if cfg == nil { + t.Fatal("Current() is nil after a successful reload") + } + if !cfg.System.DebugMode { + t.Error("Current() did not observe the reloaded debugMode") + } + if cfg.System.ListenPort != "8080" { + t.Errorf("reload dropped an untouched sibling: listenPort = %q", cfg.System.ListenPort) + } +} diff --git a/config/config.go b/config/config.go index 921cf8b..84fbe91 100644 --- a/config/config.go +++ b/config/config.go @@ -17,7 +17,7 @@ func LoadBotNodeConfig(configPath string) error { data, err := os.ReadFile(configPath) if err != nil { if os.IsNotExist(err) { - C_botNodeConfig = cfg + botNodeConfig.Store(&cfg) return nil } return fmt.Errorf("failed to read bot node config file: %w", err) @@ -27,19 +27,17 @@ func LoadBotNodeConfig(configPath string) error { return fmt.Errorf("failed to parse bot node config file: %w", err) } } - C_botNodeConfig = cfg + botNodeConfig.Store(&cfg) return nil } // NodeStatusNotifyEnabled reports whether the node identified by uuid should // broadcast status-change notifications, per bot_node_config.json. // enableStatusNotify defaults to true: a node notifies unless its entry -// explicitly sets the flag to false. +// explicitly sets the flag to false. A missing configuration (nil map) yields +// the same default. func NodeStatusNotifyEnabled(uuid string) bool { - if C_botNodeConfig == nil { - return true - } - opts, ok := C_botNodeConfig[uuid] + opts, ok := BotNodes()[uuid] if !ok || opts.EnableStatusNotify == nil { return true } @@ -146,7 +144,7 @@ func LoadGlobalConfig(configPath string) (*Config, error) { cfg.ControllerMessage.ServerExecuteResult = "Command execute result:\nServer Name: {{ serverName }}\nCommand: {{ command }}\n***Result***\n\n{{ result }}\n\n************\nTime: {{ time }}" } - C_globalConfig = &cfg + globalConfig.Store(&cfg) return &cfg, nil } @@ -162,7 +160,7 @@ func LoadBotUserConfig(configPath string) (*BotUserConfig, error) { if err := json.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("failed to parse bot user config file: %w", err) } - C_botUserConfig = &cfg + botUserConfig.Store(&cfg) return &cfg, nil } @@ -213,8 +211,9 @@ func SaveBotNodeConfig(configPath string, uuids []string) error { // IsDebugMode returns whether debug mode is enabled. func IsDebugMode() bool { - if C_globalConfig == nil { + cfg := Current() + if cfg == nil { return false } - return C_globalConfig.System.DebugMode + return cfg.System.DebugMode } diff --git a/config/variables.go b/config/variables.go index 44e1c92..5105788 100644 --- a/config/variables.go +++ b/config/variables.go @@ -1,6 +1,9 @@ package config -import "sort" +import ( + "sort" + "sync/atomic" +) // SystemConfig holds system-level configuration. type SystemConfig struct { @@ -136,10 +139,11 @@ type WebhookReceiverConfig struct { // GetWebhookEndpoint returns the incoming webhook endpoint registered under the // given name, and whether such an endpoint exists. func GetWebhookEndpoint(name string) (WebhookEndpointConfig, bool) { - if C_globalConfig == nil { + cfg := Current() + if cfg == nil { return WebhookEndpointConfig{}, false } - endpoint, ok := C_globalConfig.Webhook.Endpoints[name] + endpoint, ok := cfg.Webhook.Endpoints[name] return endpoint, ok } @@ -165,7 +169,19 @@ type Config struct { DBPath string `json:"dbPath"` } -var C_globalConfig *Config +// globalConfig holds the configuration currently in effect. It is replaced as a +// whole by LoadGlobalConfig — and therefore by every settings update — and never +// mutated in place, so a reader that loads the pointer always observes a fully +// initialized Config. Read it through Current rather than caching the result: a +// cached pointer stops tracking reloads. +var globalConfig atomic.Pointer[Config] + +// Current returns the configuration currently in effect, or nil before the +// first successful LoadGlobalConfig. It is safe to call from any goroutine, and +// must be called on every use rather than stored, so the caller sees reloads. +func Current() *Config { + return globalConfig.Load() +} // BotUserOptions holds per-member options stored in bot_user_config.json. type BotUserOptions struct { @@ -220,7 +236,16 @@ type BotUserConfig struct { Telegram BotUser_TelegramConfig `json:"telegram"` } -var C_botUserConfig *BotUserConfig +// botUserConfig mirrors bot_user_config.json the same way globalConfig mirrors +// config.json: replaced wholesale on reload and read through BotUsers. +var botUserConfig atomic.Pointer[BotUserConfig] + +// BotUsers returns the bot user configuration currently in effect, or nil +// before the first successful LoadBotUserConfig. Like Current it must be called +// on every use rather than stored. +func BotUsers() *BotUserConfig { + return botUserConfig.Load() +} // BotNodeOptions holds per-node options stored in bot_node_config.json. The // file is auto-populated by the node tracker for every node Komari reports; @@ -238,6 +263,18 @@ type BotNodeOptions struct { // options. type BotNodeMembers map[string]BotNodeOptions -// C_botNodeConfig is the global singleton mirroring bot_node_config.json, -// populated by LoadBotNodeConfig. -var C_botNodeConfig BotNodeMembers \ No newline at end of file +// botNodeConfig mirrors bot_node_config.json, populated by LoadBotNodeConfig. +// The map is rebuilt rather than mutated on every load, so the pointer can be +// swapped atomically; read it through BotNodes. +var botNodeConfig atomic.Pointer[BotNodeMembers] + +// BotNodes returns the per-node options currently in effect, or nil before the +// first LoadBotNodeConfig. Like Current it must be called on every use rather +// than stored. +func BotNodes() BotNodeMembers { + nodes := botNodeConfig.Load() + if nodes == nil { + return nil + } + return *nodes +} diff --git a/config/webhook.go b/config/webhook.go index 37feedc..11efbe8 100644 --- a/config/webhook.go +++ b/config/webhook.go @@ -37,10 +37,11 @@ var webhookEndpointFields = map[string]func(interface{}) bool{ // configuration. func WebhookEndpoints() map[string]WebhookEndpointConfig { endpoints := map[string]WebhookEndpointConfig{} - if C_globalConfig == nil { + cfg := Current() + if cfg == nil { return endpoints } - for name, endpoint := range C_globalConfig.Webhook.Endpoints { + for name, endpoint := range cfg.Webhook.Endpoints { endpoints[name] = endpoint } return endpoints @@ -107,14 +108,15 @@ func DeleteWebhookEndpoint(name string) error { } // webhookEndpoint returns the named endpoint held by the loaded configuration. -// No lock is needed to read it: a reload replaces the whole configuration -// rather than mutating it in place, and the value is read from whichever -// version is current. +// No extra lock is needed to read it: a reload replaces the whole configuration +// rather than mutating it in place, and Current publishes the replacement +// atomically, so the value read is always from one complete version. func webhookEndpoint(name string) (WebhookEndpointConfig, bool) { - if C_globalConfig == nil { + cfg := Current() + if cfg == nil { return WebhookEndpointConfig{}, false } - endpoint, exists := C_globalConfig.Webhook.Endpoints[name] + endpoint, exists := cfg.Webhook.Endpoints[name] return endpoint, exists } diff --git a/internal/controller/controller.go b/internal/controller/controller.go index a4faaa8..3297449 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -144,7 +144,7 @@ func (m *Manager) ShowBotInitMessage() { m.mu.RLock() defer m.mu.RUnlock() - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildBotInitializationMsgParams() for _, ctrl := range m.controllers { @@ -176,7 +176,7 @@ func (m *Manager) ShowBotServerList() { m.mu.RLock() defer m.mu.RUnlock() - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromServerList() for _, ctrl := range m.controllers { diff --git a/internal/controller/pipes/email.go b/internal/controller/pipes/email.go index 4f65783..c95e593 100644 --- a/internal/controller/pipes/email.go +++ b/internal/controller/pipes/email.go @@ -71,7 +71,7 @@ func (e *EmailController) SendStatusChange(change node.StatusChange) error { return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromStatusChange(change) body := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, e.cfg.Markdown) @@ -85,7 +85,7 @@ func (e *EmailController) SendServerList(onlineServers, offlineServers string) e return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromServerList() body := template.Render(cfg.ControllerMessage.ServerList, params, e.cfg.Markdown) @@ -98,7 +98,7 @@ func (e *EmailController) SendExecuteResult(serverName, serverUUID, command, res return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result) body := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, e.cfg.Markdown) diff --git a/internal/controller/pipes/ntfy.go b/internal/controller/pipes/ntfy.go index b5a47ab..1da7dbc 100644 --- a/internal/controller/pipes/ntfy.go +++ b/internal/controller/pipes/ntfy.go @@ -65,7 +65,7 @@ func (n *NtfyController) SendStatusChange(change node.StatusChange) error { return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromStatusChange(change) message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, n.cfg.Markdown) @@ -79,7 +79,7 @@ func (n *NtfyController) SendServerList(onlineServers, offlineServers string) er return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromServerList() message := template.Render(cfg.ControllerMessage.ServerList, params, n.cfg.Markdown) @@ -92,7 +92,7 @@ func (n *NtfyController) SendExecuteResult(serverName, serverUUID, command, resu return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result) message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, n.cfg.Markdown) diff --git a/internal/controller/pipes/qq_napcat/napcat.go b/internal/controller/pipes/qq_napcat/napcat.go index a872ba2..ddf86f1 100644 --- a/internal/controller/pipes/qq_napcat/napcat.go +++ b/internal/controller/pipes/qq_napcat/napcat.go @@ -17,6 +17,15 @@ import ( "nukumizu-backend/postLog" ) +// actionLogEnabled reports whether the NapCat HTTP API methods echo each action +// they send, per the showNapcatAction toggle. Unlike the WebSocket message path +// in qq.go it deliberately does not also require debugMode: these methods have +// always logged on this toggle alone, and preserving that is intentional. +func actionLogEnabled() bool { + cfg := config.Current() + return cfg != nil && cfg.Debug.ShowNapcatAction +} + // APIResponse mirrors NapCat's HTTP API response envelope. type APIResponse struct { Status string `json:"status"` @@ -155,7 +164,7 @@ func (c *Client) SendMsg(targetType string, targetID int64, msg string, hasAt bo return nil, fmt.Errorf("failed to marshal request: %w", err) } - if config.C_globalConfig.Debug.ShowNapcatAction { + if actionLogEnabled() { postLog.Debug(fmt.Sprintf("[Napcat] SendMsg -> %s (%s): %s", endpoint, targetType, message)) } @@ -176,7 +185,7 @@ func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) { return nil, fmt.Errorf("failed to marshal request: %w", err) } - if config.C_globalConfig.Debug.ShowNapcatAction { + if actionLogEnabled() { postLog.Debug(fmt.Sprintf("[Napcat] RecallMsg -> /delete_msg: %d", msgID)) } @@ -190,7 +199,7 @@ func (c *Client) RecallMsg(msgID int64) (*APIResponse, error) { // GetGroupList retrieves the list of joined groups from NapCat. func (c *Client) GetGroupList() (*APIResponse, error) { - if config.C_globalConfig.Debug.ShowNapcatAction { + if actionLogEnabled() { postLog.Debug("[Napcat] GetGroupList -> /get_group_list") } @@ -211,7 +220,7 @@ func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) { return nil, fmt.Errorf("failed to marshal request: %w", err) } - if config.C_globalConfig.Debug.ShowNapcatAction { + if actionLogEnabled() { postLog.Debug(fmt.Sprintf("[Napcat] GetGroupInfo -> /get_group_info: %d", groupID)) } @@ -225,7 +234,7 @@ func (c *Client) GetGroupInfo(groupID int64) (*APIResponse, error) { // GetFriendsList retrieves the friends list from NapCat. func (c *Client) GetFriendsList() (*APIResponse, error) { - if config.C_globalConfig.Debug.ShowNapcatAction { + if actionLogEnabled() { postLog.Debug("[Napcat] GetFriendsList -> /get_friend_list") } diff --git a/internal/controller/pipes/qq_napcat/qq.go b/internal/controller/pipes/qq_napcat/qq.go index 66df321..21da416 100644 --- a/internal/controller/pipes/qq_napcat/qq.go +++ b/internal/controller/pipes/qq_napcat/qq.go @@ -100,24 +100,34 @@ func (q *QQController) handleNapcatEvent(raw []byte) { return } - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatMsg { + // The configuration is read once per event: the debug guards below are + // evaluated several times, and taking them all from one version keeps a + // concurrent reload from splitting them mid-event. + cfg := config.Current() + if cfg == nil { + return + } + debugMode := cfg.System.DebugMode + showAction := debugMode && cfg.Debug.ShowNapcatAction + + if debugMode && cfg.Debug.ShowNapcatMsg { postLog.Debug("Napcat WS event received: " + string(raw)) } // Only handle message events; ignore notice/request/meta_event. if ev.PostType != "message" { - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction { + if showAction { postLog.Debug("Ignoring Napcat WS event: " + string(raw)) } return } // Ignore messages the bot itself sent (echo prevention). - if q.isSelfMessage(ev) && !config.C_globalConfig.System.DebugMode { + if q.isSelfMessage(ev) && !debugMode { return } - if q.isSelfMessage(ev) && config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.NapcatIgnoreSelfMsg { - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction { + if q.isSelfMessage(ev) && debugMode && cfg.Debug.NapcatIgnoreSelfMsg { + if showAction { postLog.Debug("Ignoring Napcat WS self message: " + string(raw)) } return @@ -138,7 +148,7 @@ func (q *QQController) handleNapcatEvent(raw []byte) { response := q.processCommand(cmd) if response == "" { - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction { + if showAction { postLog.Debug("Napcat WS command discarded: " + string(raw)) } return @@ -160,6 +170,7 @@ func (q *QQController) handleNapcatEvent(raw []byte) { // complete command to the unified processor. It returns the response text to // reply with; an empty response means the message was discarded. func (q *QQController) processCommand(cmd controller.Command) string { + cfg := config.Current() text := cmd.RawText // In "at" listen mode, require an @mention of the bot and strip it before @@ -168,7 +179,7 @@ func (q *QQController) processCommand(cmd controller.Command) string { if q.cfg.ListenMethod == "at" { atMention := fmt.Sprintf("[CQ:at,qq=%d]", q.cfg.BotQQID) if !strings.Contains(text, atMention) { - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowNapcatAction { + if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowNapcatAction { postLog.Debug("Napcat WS message ignored (no @mention): " + text) } return "" // Not mentioned, ignore. @@ -190,7 +201,7 @@ func (q *QQController) processCommand(cmd controller.Command) string { // Hand the complete command to the unified processor, which checks group // vs private, trusted groups, admin permissions, and executes it. response, err := controller.GetManager().Trigger(parsed, q.trustedGroupIDs(), q.adminIDs(), q.cfg.ListenMethod) - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTriggerCmdEcho { + if cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho { postLog.Debug(fmt.Sprintf("[qq_napcat] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID)) } if err != nil { @@ -213,7 +224,7 @@ func (q *QQController) isSelfMessage(ev oneBotEvent) bool { // adminIDs returns the QQ admin IDs from bot_user_config.json. func (q *QQController) adminIDs() []string { - if c := config.C_botUserConfig; c != nil { + if c := config.BotUsers(); c != nil { return c.QQ.Admins.IDs() } return nil @@ -221,7 +232,7 @@ func (q *QQController) adminIDs() []string { // trustedGroupIDs returns the QQ trusted group IDs from bot_user_config.json. func (q *QQController) trustedGroupIDs() []string { - if c := config.C_botUserConfig; c != nil { + if c := config.BotUsers(); c != nil { return c.QQ.TrustedGroups.IDs() } return nil @@ -237,7 +248,7 @@ func (q *QQController) SendMessage(message controller.Message) error { } // Only notify trusted groups and admins whose options allow this message type. - if uc := config.C_botUserConfig; uc != nil { + if uc := config.BotUsers(); uc != nil { for groupID, opts := range uc.QQ.TrustedGroups { if !controller.MemberReceives(opts, message.Type) { continue @@ -260,12 +271,12 @@ func (q *QQController) SendStatusChange(change node.StatusChange) error { return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromStatusChange(change) message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, q.cfg.Markdown) // Only notify trusted groups and admins whose event_status_notify is true. - if uc := config.C_botUserConfig; uc != nil { + if uc := config.BotUsers(); uc != nil { for groupID, opts := range uc.QQ.TrustedGroups { if !opts.EventStatusNotify { continue @@ -289,7 +300,7 @@ func (q *QQController) SendServerList(onlineServers, offlineServers string) erro return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromServerList() message := template.Render(cfg.ControllerMessage.ServerList, params, q.cfg.Markdown) @@ -305,7 +316,7 @@ func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result) message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, q.cfg.Markdown) diff --git a/internal/controller/pipes/telegram/telegram.go b/internal/controller/pipes/telegram/telegram.go index 04dfbd2..a082db9 100644 --- a/internal/controller/pipes/telegram/telegram.go +++ b/internal/controller/pipes/telegram/telegram.go @@ -148,7 +148,7 @@ func (t *TelegramController) handleUpdate(_ context.Context, _ *bot.Bot, update } msg := update.Message - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTelegramMsg { + if cfg := config.Current(); cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTelegramMsg { raw, _ := json.Marshal(update) postLog.Debug("Telegram update received: " + string(raw)) } @@ -232,7 +232,7 @@ func (t *TelegramController) processCommand(cmd controller.Command) string { // Hand the complete command to the unified processor, which checks group // vs private, trusted groups, admin permissions, and executes it. response, err := controller.GetManager().Trigger(parsed, t.trustedGroupIDs(), t.resolvedAdminList(), t.cfg.ListenMethod) - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowTriggerCmdEcho { + if cfg := config.Current(); cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowTriggerCmdEcho { postLog.Debug(fmt.Sprintf("[telegram] triggered command: \"/%s\" with args: \"%s\" from chatID: %d and senderID: %d", parsed.Command, strings.Join(parsed.Args, ", "), cmd.ChatID, cmd.SenderID)) } if err != nil { @@ -252,7 +252,7 @@ func (t *TelegramController) SendMessage(message controller.Message) error { } // Only notify trusted groups and admins whose options allow this message type. - if uc := config.C_botUserConfig; uc != nil { + if uc := config.BotUsers(); uc != nil { for groupID, opts := range uc.Telegram.TrustedGroups { if !controller.MemberReceives(opts, message.Type) { continue @@ -275,12 +275,12 @@ func (t *TelegramController) SendStatusChange(change node.StatusChange) error { return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromStatusChange(change) message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, t.cfg.Markdown) // Only notify trusted groups and admins whose event_status_notify is true. - if uc := config.C_botUserConfig; uc != nil { + if uc := config.BotUsers(); uc != nil { for groupID, opts := range uc.Telegram.TrustedGroups { if !opts.EventStatusNotify { continue @@ -303,7 +303,7 @@ func (t *TelegramController) SendServerList(onlineServers, offlineServers string return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromServerList() message := template.Render(cfg.ControllerMessage.ServerList, params, t.cfg.Markdown) @@ -317,7 +317,7 @@ func (t *TelegramController) SendExecuteResult(serverName, serverUUID, command, return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result) message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, t.cfg.Markdown) @@ -395,7 +395,7 @@ func (t *TelegramController) resolveUsername(username string) (int64, bool) { // adminIDs returns the Telegram admin entries (numeric user ID or @username) // from bot_user_config.json. func (t *TelegramController) adminIDs() []string { - if c := config.C_botUserConfig; c != nil { + if c := config.BotUsers(); c != nil { return c.Telegram.Admins.IDs() } return nil @@ -403,7 +403,7 @@ func (t *TelegramController) adminIDs() []string { // trustedGroupIDs returns the Telegram trusted group IDs from bot_user_config.json. func (t *TelegramController) trustedGroupIDs() []string { - if c := config.C_botUserConfig; c != nil { + if c := config.BotUsers(); c != nil { return c.Telegram.TrustedGroups.IDs() } return nil diff --git a/internal/controller/pipes/webhook.go b/internal/controller/pipes/webhook.go index 5057855..8392ec0 100644 --- a/internal/controller/pipes/webhook.go +++ b/internal/controller/pipes/webhook.go @@ -66,7 +66,7 @@ func (w *WebhookController) SendStatusChange(change node.StatusChange) error { return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromStatusChange(change) message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, w.cfg.Markdown) @@ -87,7 +87,7 @@ func (w *WebhookController) SendServerList(onlineServers, offlineServers string) return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromServerList() message := template.Render(cfg.ControllerMessage.ServerList, params, w.cfg.Markdown) @@ -108,7 +108,7 @@ func (w *WebhookController) SendExecuteResult(serverName, serverUUID, command, r return nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result) message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, w.cfg.Markdown) diff --git a/internal/controller/processor.go b/internal/controller/processor.go index e6b24d5..31b7a1a 100644 --- a/internal/controller/processor.go +++ b/internal/controller/processor.go @@ -22,13 +22,13 @@ func commandMarkdown(cmd Command) bool { } func handleHelp(cmd Command) (string, error) { - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildBotInitializationMsgParams() return template.Render(cfg.ControllerMessage.BotHelp, params, commandMarkdown(cmd)), nil } func handleList(cmd Command) (string, error) { - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromServerList() return template.Render(cfg.ControllerMessage.ServerList, params, commandMarkdown(cmd)), nil } @@ -139,7 +139,7 @@ func handleRun(cmd Command) (string, error) { return fmt.Sprintf("Error getting results: %v", err), nil } - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildParamsFromExecResult(uuidArg, uuidArg, command, formatTaskResults(results)) return template.Render(cfg.ControllerMessage.ServerExecuteResult, params, commandMarkdown(cmd)), nil } @@ -189,7 +189,7 @@ func handleInfo(cmd Command) (string, error) { } func telegram_handleStart(cmd Command) (string, error) { - cfg := config.C_globalConfig + cfg := config.Current() params := template.BuildBotInitializationMsgParams() return template.Render(cfg.ControllerMessage.Tg_BotStart, params, commandMarkdown(cmd)), nil } diff --git a/internal/komari/client.go b/internal/komari/client.go index 542079a..f727296 100644 --- a/internal/komari/client.go +++ b/internal/komari/client.go @@ -18,6 +18,14 @@ import ( "nukumizu-backend/postLog" ) +// taskEchoEnabled reports whether Komari task progress should be echoed to the +// log: debug mode plus the showKomariTaskEcho toggle. The configuration is read +// once per call so both flags come from the same reload. +func taskEchoEnabled() bool { + cfg := config.Current() + return cfg != nil && cfg.System.DebugMode && cfg.Debug.ShowKomariTaskEcho +} + // NodeInfo represents a single node as returned by Komari's // common:getNodes RPC2 method. type NodeInfo struct { @@ -146,7 +154,7 @@ func (c *Client) Login(username, password string) error { var kr KomariResponse if err := json.NewDecoder(resp.Body).Decode(&kr); err != nil { - if config.C_globalConfig.System.DebugMode { + if config.IsDebugMode() { respBody, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to parse komari login response: %w.\nResponse: %s", err, respBody) } @@ -332,7 +340,7 @@ func (c *Client) ExecTask(uuids []string, command string) (string, error) { return "", fmt.Errorf("failed to parse komari task exec data: %w", err) } - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho { + if taskEchoEnabled() { postLog.Debug(fmt.Sprintf("Created Komari task %s for %d clients", result.TaskID, len(uuids))) } return result.TaskID, nil @@ -375,7 +383,7 @@ func (c *Client) GetTaskResult(taskID string) ([]TaskResult, bool, error) { // PollTaskResult polls for task results every 1 second until all results are // available or 60 seconds have elapsed. func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) { - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho { + if taskEchoEnabled() { postLog.Debug(fmt.Sprintf("Polling for Komari task %s results...", taskID)) } @@ -393,7 +401,7 @@ func (c *Client) PollTaskResult(taskID string) ([]TaskResult, error) { return nil, err } if done { - if config.C_globalConfig.System.DebugMode && config.C_globalConfig.Debug.ShowKomariTaskEcho { + if taskEchoEnabled() { postLog.Info(fmt.Sprintf("Task %s completed with %d results", taskID, len(results))) } return results, nil diff --git a/internal/komari/ws.go b/internal/komari/ws.go index d1ea122..2796461 100644 --- a/internal/komari/ws.go +++ b/internal/komari/ws.go @@ -409,7 +409,10 @@ func GetWSClient() *WSClient { // LoginAndStart performs the Komari login and returns an error if it fails. func LoginAndStart() error { - cfg := config.C_globalConfig + cfg := config.Current() + if cfg == nil { + return fmt.Errorf("configuration not loaded") + } client := GetClient() if client == nil { return fmt.Errorf("komari client not initialized") diff --git a/internal/netproxy/netproxy.go b/internal/netproxy/netproxy.go index 46d7b31..8fe0bdf 100644 --- a/internal/netproxy/netproxy.go +++ b/internal/netproxy/netproxy.go @@ -20,7 +20,11 @@ import ( // proxyURL returns the system-wide network proxy URL, or nil when none is // configured. A missing scheme is normalized to http:// for convenience. func proxyURL() *url.URL { - raw := config.C_globalConfig.System.NetworkProxy + cfg := config.Current() + if cfg == nil { + return nil + } + raw := cfg.System.NetworkProxy if raw == "" { return nil } diff --git a/main.go b/main.go index 5954492..a1bc0ed 100644 --- a/main.go +++ b/main.go @@ -201,7 +201,7 @@ func startWebhookServer(cfg *config.Config) { // initControllers initializes and starts all configured controllers. func initControllers() { - cfg := config.C_globalConfig + cfg := config.Current() mgr := controller.GetManager() if mgr == nil { return