Email, ntfy and webhook kept their settings in a plain struct field copied at construction, so editing controllerMethod in config.json wrote the file and replaced the in-memory configuration while the running channels stayed on their boot values. Add Reload to the Controller interface and implement it for the three notification pipes; Manager.ReloadAll fans an update out to every registered controller and is wired into the reload hook from main. Reload runs on the goroutine serving the settings update while the send methods run on the status-change and incoming-webhook goroutines, so storing the new settings in a plain field would be a data race. Each pipe publishes its settings — and the HTTP client derived from them — through atomic pointers, and every send takes one snapshot so a reload landing mid-send cannot split it across two configurations. Only a change to networkUseProxy rebuilds the client; everything else is read from the settings at send time, so a reload that changes nothing relevant leaves the client alone. Email needed one more fix: gomail exposes its dial hook as a package-level variable with no unset, and the constructor only installed the proxy dialer when the flag was set, so turning networkUseProxy off left SMTP tunnelled through a proxy. applyEmailProxy now restores net.DialTimeout, which is gomail's own default. QQ (Napcat) and Telegram implement Reload because the interface requires it, but neither can apply a change in place: the NapCat client is stopped through a sync.Once and the Telegram polling context is created with the controller, so both need to be rebuilt and swapped into the manager. Rather than no-op silently and let an edit look applied, they log a warning while their settings diverge. That rebuild is the next step.
326 lines
11 KiB
Go
326 lines
11 KiB
Go
package controller
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"nukumizu-backend/config"
|
|
"nukumizu-backend/internal/node"
|
|
"nukumizu-backend/internal/template"
|
|
"nukumizu-backend/postLog"
|
|
)
|
|
|
|
// Command represents a parsed bot command.
|
|
type Command struct {
|
|
Source string // Name of the pipe the command arrived on (see Controller.Name)
|
|
RawText string // The raw text of the command message
|
|
Command string // The command word (e.g., "list", "status")
|
|
Args []string // Command arguments
|
|
ChatID int64 // Chat/group ID where the command was issued
|
|
ChatType string // "group" or "private"
|
|
SenderID int64 // User ID of the sender
|
|
}
|
|
|
|
// Message represents a message to be sent by a controller.
|
|
type Message struct {
|
|
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
|
|
Content string // The message content
|
|
ChatID int64 // Chat/group ID where the message should be sent
|
|
Type string // Message type (see MessageType*), used for per-member opt-outs
|
|
}
|
|
|
|
// Message type labels. They let bot pipes apply per-member opt-out options from
|
|
// bot_user_config.json (see MemberReceives) to automatic messages.
|
|
const (
|
|
// MessageTypeBotStarted marks the automatic welcome/server-list messages the
|
|
// bot pushes on startup. Gated by BotUserOptions.EventBotStarted.
|
|
MessageTypeBotStarted = "event_bot_started"
|
|
// MessageTypeReply marks a direct reply to a user command. Reserved for the
|
|
// BotUserOptions.EventReply opt-out.
|
|
MessageTypeReply = "event_reply"
|
|
// MessageTypeAlert marks an alert submitted by an external application
|
|
// through the incoming webhook API. Not member-controllable: an alert is
|
|
// always delivered to the channel's recipients.
|
|
MessageTypeAlert = "alert"
|
|
)
|
|
|
|
// Alert is a free-form notification submitted by an external application
|
|
// through the incoming webhook API. Its target channels are chosen per webhook
|
|
// endpoint in config.json, not per alert.
|
|
type Alert struct {
|
|
Subject string // Short one-line title of the alert
|
|
Source string // Name of the webhook endpoint the alert was submitted to
|
|
Content string // Free-form alert body
|
|
Time string // Submission time
|
|
}
|
|
|
|
// Render renders the alert body for a channel, wrapping the source and content
|
|
// in Markdown when that channel has markdown enabled (see template.RenderAlert).
|
|
func (a Alert) Render(markdown bool) string {
|
|
return template.RenderAlert(template.AlertParams{
|
|
Subject: a.Subject,
|
|
Source: a.Source,
|
|
Content: a.Content,
|
|
Time: a.Time,
|
|
}, markdown)
|
|
}
|
|
|
|
// MemberReceives reports whether a member whose bot_user_config.json options are
|
|
// opts receives an automatic message of the given type. Only member-controllable
|
|
// types are gated; anything else is always delivered.
|
|
func MemberReceives(opts config.BotUserOptions, messageType string) bool {
|
|
switch messageType {
|
|
case MessageTypeBotStarted:
|
|
return opts.EventBotStarted
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Controller defines the interface for all notification/bot controllers.
|
|
type Controller interface {
|
|
Name() string
|
|
Start() error
|
|
Stop()
|
|
IsEnabled() bool
|
|
// IsMarkdown reports whether the channel renders Markdown, per its own
|
|
// "markdown" setting in config.json.
|
|
IsMarkdown() bool
|
|
// Reload applies the current configuration to a running controller, so a
|
|
// settings update takes effect without a restart. It reads the live
|
|
// configuration itself and rebuilds whatever it derived from the old one.
|
|
//
|
|
// Reload runs on the goroutine serving the settings update while the send
|
|
// methods may be running on others, so an implementation must not replace
|
|
// state those methods read without synchronizing (see the atomic pointers in
|
|
// the notification pipes).
|
|
Reload()
|
|
SendStatusChange(change node.StatusChange) error
|
|
SendServerList(onlineServers, offlineServers string) error
|
|
SendExecuteResult(serverName, serverUUID, command, result string) error
|
|
// SendAlert delivers a free-form alert submitted through the incoming
|
|
// webhook API to the channel's own recipients.
|
|
SendAlert(alert Alert) 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 Message) error
|
|
}
|
|
|
|
// Manager manages all controller instances and routes events.
|
|
type Manager struct {
|
|
mu sync.RWMutex
|
|
controllers map[string]Controller
|
|
}
|
|
|
|
var globalManager *Manager
|
|
|
|
// InitManager initializes the global controller manager.
|
|
func InitManager() {
|
|
globalManager = &Manager{
|
|
controllers: make(map[string]Controller),
|
|
}
|
|
postLog.Info("Controller manager initialized")
|
|
}
|
|
|
|
// GetManager returns the global controller manager.
|
|
func GetManager() *Manager {
|
|
return globalManager
|
|
}
|
|
|
|
// Register adds a controller to the manager.
|
|
func (m *Manager) Register(c Controller) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.controllers[c.Name()] = c
|
|
postLog.Info("Controller registered: " + c.Name())
|
|
}
|
|
|
|
// ReloadAll applies the current configuration to every registered controller,
|
|
// so a settings update reaches the channels without a restart.
|
|
//
|
|
// The controllers are collected under the registry lock and reloaded outside
|
|
// it: a reload can rebuild a client and block on the network, and holding m.mu
|
|
// across that would stall every notification for its duration.
|
|
func (m *Manager) ReloadAll() {
|
|
m.mu.RLock()
|
|
controllers := make([]Controller, 0, len(m.controllers))
|
|
for _, ctrl := range m.controllers {
|
|
controllers = append(controllers, ctrl)
|
|
}
|
|
m.mu.RUnlock()
|
|
|
|
for _, ctrl := range controllers {
|
|
ctrl.Reload()
|
|
}
|
|
}
|
|
|
|
// 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. The message is typed
|
|
// MessageTypeBotStarted so each controller can honor its members' per-recipient
|
|
// EventBotStarted opt-out. It is rendered once per controller because the
|
|
// Markdown formatting depends on each channel's own markdown setting.
|
|
func (m *Manager) ShowBotInitMessage() {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
cfg := config.Current()
|
|
params := template.BuildBotInitializationMsgParams()
|
|
|
|
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.
|
|
}
|
|
message := Message{
|
|
Source: bot.Name(),
|
|
Content: template.Render(cfg.ControllerMessage.BotStarted, params, ctrl.IsMarkdown()),
|
|
Type: MessageTypeBotStarted,
|
|
}
|
|
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 startup server list to all enabled bot
|
|
// controllers. The message content is identical to the /list command (same
|
|
// template and parameters). Like the init message it is typed
|
|
// MessageTypeBotStarted so members who opted out of bot-started pushes do not
|
|
// receive it, and rendered once per controller so each channel's markdown
|
|
// setting is honored.
|
|
func (m *Manager) ShowBotServerList() {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
cfg := config.Current()
|
|
params := template.BuildParamsFromServerList()
|
|
|
|
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.
|
|
}
|
|
message := Message{
|
|
Source: bot.Name(),
|
|
Content: template.Render(cfg.ControllerMessage.ServerList, params, ctrl.IsMarkdown()),
|
|
Type: MessageTypeBotStarted,
|
|
}
|
|
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. It honors the per-node allow-list in bot_node_config.json: a
|
|
// node whose enableStatusNotify is not true is skipped entirely, so no
|
|
// controller (chat bots or notification pipes) broadcasts its change.
|
|
func (m *Manager) NotifyStatusChange(change node.StatusChange) {
|
|
if !config.NodeStatusNotifyEnabled(change.UUID) {
|
|
postLog.Debug(fmt.Sprintf("Status change for node %s skipped: enableStatusNotify is not enabled", change.UUID))
|
|
return
|
|
}
|
|
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
for _, ctrl := range m.controllers {
|
|
if !ctrl.IsEnabled() {
|
|
continue
|
|
}
|
|
if err := ctrl.SendStatusChange(change); err != nil {
|
|
postLog.Warning(fmt.Sprintf("Controller %s failed to send status change: %v", ctrl.Name(), err))
|
|
}
|
|
}
|
|
}
|
|
|
|
// NotifyAlert delivers an alert to the named pipes only, and returns the names
|
|
// of the pipes it was handed to. A pipe that is unknown, disabled or fails to
|
|
// send is reported through the returned error instead of stopping the delivery
|
|
// to the remaining pipes; if no pipe accepted the alert, the error describes
|
|
// every failure.
|
|
func (m *Manager) NotifyAlert(pipes []string, alert Alert) ([]string, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
var delivered, failures []string
|
|
for _, name := range pipes {
|
|
ctrl, ok := m.controllers[name]
|
|
if !ok {
|
|
failures = append(failures, fmt.Sprintf("%s: no such channel", name))
|
|
continue
|
|
}
|
|
if !ctrl.IsEnabled() {
|
|
failures = append(failures, fmt.Sprintf("%s: channel is disabled", name))
|
|
continue
|
|
}
|
|
if err := ctrl.SendAlert(alert); err != nil {
|
|
failures = append(failures, fmt.Sprintf("%s: %v", name, err))
|
|
continue
|
|
}
|
|
delivered = append(delivered, name)
|
|
}
|
|
|
|
if len(failures) > 0 {
|
|
postLog.Warning(fmt.Sprintf("Alert %q from %s not delivered by: %s", alert.Subject, alert.Source, strings.Join(failures, "; ")))
|
|
}
|
|
if len(delivered) == 0 {
|
|
if len(failures) == 0 {
|
|
return nil, errors.New("no notify channel configured")
|
|
}
|
|
return nil, errors.New(strings.Join(failures, "; "))
|
|
}
|
|
return delivered, nil
|
|
}
|
|
|
|
// IsMarkdown reports whether the pipe with the given name renders Markdown, per
|
|
// its channel's "markdown" setting in config.json. An unknown pipe renders
|
|
// plain text.
|
|
func (m *Manager) IsMarkdown(pipeName string) bool {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
ctrl, ok := m.controllers[pipeName]
|
|
if !ok {
|
|
return false
|
|
}
|
|
return ctrl.IsMarkdown()
|
|
}
|
|
|
|
// StopAll stops all registered controllers.
|
|
func (m *Manager) StopAll() {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
for _, ctrl := range m.controllers {
|
|
ctrl.Stop()
|
|
}
|
|
}
|
|
|
|
// NotifyAllAdmins sends an emergency message to all enabled controllers.
|
|
func (m *Manager) NotifyAllAdmins(message string) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
for _, ctrl := range m.controllers {
|
|
if !ctrl.IsEnabled() {
|
|
continue
|
|
}
|
|
postLog.Info(fmt.Sprintf("Notifying via %s: %s", ctrl.Name(), message))
|
|
}
|
|
}
|