feat: add incoming webhook API support with configurable endpoints

- Implemented the incoming webhook API to handle alerts from external applications.
- Added configuration options for webhook listening address, port, and endpoints in config.go.
- Created WebhookReceiverConfig and WebhookEndpointConfig structures to manage webhook settings.
- Developed WebhookHandler to process incoming requests, validate tokens, and deliver alerts to specified channels.
- Enhanced existing controller interfaces to support alert delivery.
- Updated message rendering to respect Markdown settings for different channels.
- Added tests for webhook functionality and ensured proper error handling.
This commit is contained in:
2026-09-24 11:21:38 +08:00
parent 90ca08066b
commit 057d7d584d
17 changed files with 595 additions and 65 deletions
+93 -7
View File
@@ -1,7 +1,9 @@
package controller
import (
"errors"
"fmt"
"strings"
"sync"
"nukumizu-backend/config"
@@ -12,7 +14,7 @@ import (
// Command represents a parsed bot command.
type Command struct {
Source string // The source pipe (e.g., "telegram", "qq", "napcat")
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
@@ -38,8 +40,33 @@ const (
// 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.
@@ -58,9 +85,15 @@ type Controller interface {
Start() error
Stop()
IsEnabled() bool
// IsMarkdown reports whether the channel renders Markdown, per its own
// "markdown" setting in config.json.
IsMarkdown() bool
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
@@ -105,14 +138,14 @@ func (m *Manager) Register(c Controller) {
// 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.
// 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.C_globalConfig
params := template.BuildBotInitializationMsgParams()
content := template.Render(cfg.ControllerMessage.BotStarted, params)
for _, ctrl := range m.controllers {
if !ctrl.IsEnabled() {
@@ -124,7 +157,7 @@ func (m *Manager) ShowBotInitMessage() {
}
message := Message{
Source: bot.Name(),
Content: content,
Content: template.Render(cfg.ControllerMessage.BotStarted, params, ctrl.IsMarkdown()),
Type: MessageTypeBotStarted,
}
if err := bot.SendMessage(message); err != nil {
@@ -137,14 +170,14 @@ func (m *Manager) ShowBotInitMessage() {
// 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.
// 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.C_globalConfig
params := template.BuildParamsFromServerList()
content := template.Render(cfg.ControllerMessage.ServerList, params)
for _, ctrl := range m.controllers {
if !ctrl.IsEnabled() {
@@ -156,7 +189,7 @@ func (m *Manager) ShowBotServerList() {
}
message := Message{
Source: bot.Name(),
Content: content,
Content: template.Render(cfg.ControllerMessage.ServerList, params, ctrl.IsMarkdown()),
Type: MessageTypeBotStarted,
}
if err := bot.SendMessage(message); err != nil {
@@ -188,6 +221,59 @@ func (m *Manager) NotifyStatusChange(change node.StatusChange) {
}
}
// 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()
+24 -3
View File
@@ -6,6 +6,7 @@ import (
gomail "gopkg.in/mail.v2"
"nukumizu-backend/config"
"nukumizu-backend/internal/controller"
"nukumizu-backend/internal/netproxy"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
@@ -54,6 +55,12 @@ func (e *EmailController) IsEnabled() bool {
return e.cfg.Enabled
}
// IsMarkdown returns whether the channel renders Markdown, per its markdown
// setting in config.json.
func (e *EmailController) IsMarkdown() bool {
return e.cfg.Markdown
}
// SendStatusChange sends a status change notification via Email.
func (e *EmailController) SendStatusChange(change node.StatusChange) error {
if !e.cfg.Enabled {
@@ -66,7 +73,7 @@ func (e *EmailController) SendStatusChange(change node.StatusChange) error {
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
body := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
body := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, e.cfg.Markdown)
subject := fmt.Sprintf("Server Status Change: %s - %s", change.Name, change.Event)
return e.sendEmail(subject, body)
@@ -80,7 +87,7 @@ func (e *EmailController) SendServerList(onlineServers, offlineServers string) e
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
body := template.Render(cfg.ControllerMessage.ServerList, params)
body := template.Render(cfg.ControllerMessage.ServerList, params, e.cfg.Markdown)
return e.sendEmail("Server List", body)
}
@@ -93,12 +100,26 @@ func (e *EmailController) SendExecuteResult(serverName, serverUUID, command, res
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
body := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
body := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, e.cfg.Markdown)
subject := fmt.Sprintf("Command Result: %s on %s", command, serverName)
return e.sendEmail(subject, body)
}
// SendAlert sends an alert submitted through the incoming webhook API to the
// configured recipients.
func (e *EmailController) SendAlert(alert controller.Alert) error {
if !e.cfg.Enabled {
return nil
}
if len(e.cfg.To) == 0 {
postLog.Debug("Email controller has no recipients configured")
return nil
}
return e.sendEmail(alert.Subject, alert.Render(e.cfg.Markdown))
}
func (e *EmailController) sendEmail(subject, body string) error {
m := gomail.NewMessage()
m.SetHeader("From", e.cfg.From)
+20 -3
View File
@@ -7,6 +7,7 @@ import (
"time"
"nukumizu-backend/config"
"nukumizu-backend/internal/controller"
"nukumizu-backend/internal/netproxy"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
@@ -52,6 +53,12 @@ func (n *NtfyController) IsEnabled() bool {
return n.cfg.Enabled
}
// IsMarkdown returns whether the channel renders Markdown, per its markdown
// setting in config.json.
func (n *NtfyController) IsMarkdown() bool {
return n.cfg.Markdown
}
// SendStatusChange sends a status change notification via Ntfy.
func (n *NtfyController) SendStatusChange(change node.StatusChange) error {
if !n.cfg.Enabled {
@@ -60,7 +67,7 @@ func (n *NtfyController) SendStatusChange(change node.StatusChange) error {
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, n.cfg.Markdown)
title := fmt.Sprintf("Server %s: %s", change.Name, change.Event)
return n.publish(title, message)
@@ -74,7 +81,7 @@ func (n *NtfyController) SendServerList(onlineServers, offlineServers string) er
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params)
message := template.Render(cfg.ControllerMessage.ServerList, params, n.cfg.Markdown)
return n.publish("Server List", message)
}
@@ -87,12 +94,22 @@ func (n *NtfyController) SendExecuteResult(serverName, serverUUID, command, resu
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, n.cfg.Markdown)
title := fmt.Sprintf("Command Result: %s on %s", command, serverName)
return n.publish(title, message)
}
// SendAlert sends an alert submitted through the incoming webhook API to the
// configured topic.
func (n *NtfyController) SendAlert(alert controller.Alert) error {
if !n.cfg.Enabled {
return nil
}
return n.publish(alert.Subject, alert.Render(n.cfg.Markdown))
}
func (n *NtfyController) publish(title, message string) error {
serverURL := n.cfg.Server
if serverURL == "" {
+24 -4
View File
@@ -86,6 +86,12 @@ func (q *QQController) IsEnabled() bool {
return q.cfg.Enabled
}
// IsMarkdown returns whether the channel renders Markdown, per its markdown
// setting in config.json.
func (q *QQController) IsMarkdown() bool {
return q.cfg.Markdown
}
// handleNapcatEvent processes a raw OneBot event received from the NapCat WebSocket.
func (q *QQController) handleNapcatEvent(raw []byte) {
var ev oneBotEvent
@@ -179,7 +185,7 @@ func (q *QQController) processCommand(cmd controller.Command) string {
parsed.ChatID = cmd.ChatID
parsed.ChatType = cmd.ChatType
parsed.SenderID = cmd.SenderID
parsed.Source = "qq_napcat"
parsed.Source = q.Name()
// Hand the complete command to the unified processor, which checks group
// vs private, trusted groups, admin permissions, and executes it.
@@ -256,7 +262,7 @@ func (q *QQController) SendStatusChange(change node.StatusChange) error {
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
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 {
@@ -285,7 +291,7 @@ func (q *QQController) SendServerList(onlineServers, offlineServers string) erro
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params)
message := template.Render(cfg.ControllerMessage.ServerList, params, q.cfg.Markdown)
for _, groupID := range q.trustedGroupIDs() {
q.sendGroupMessage(groupID, message)
@@ -301,7 +307,7 @@ func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, q.cfg.Markdown)
for _, groupID := range q.trustedGroupIDs() {
q.sendGroupMessage(groupID, message)
@@ -309,6 +315,20 @@ func (q *QQController) SendExecuteResult(serverName, serverUUID, command, result
return nil
}
// SendAlert sends an alert submitted through the incoming webhook API to all QQ
// trusted groups and admins.
func (q *QQController) SendAlert(alert controller.Alert) error {
if !q.cfg.Enabled {
return nil
}
return q.SendMessage(controller.Message{
Source: q.Name(),
Content: alert.Render(q.cfg.Markdown),
Type: controller.MessageTypeAlert,
})
}
func (q *QQController) sendGroupMessage(groupID string, message string) {
if q.napcatClient == nil {
postLog.Warning("Cannot send QQ group message: NapCat client not initialized")
+16 -10
View File
@@ -14,11 +14,13 @@ import (
const maxMessageLen = 4000
// sendMessage sends a text message to a chat, splitting it into chunks that fit
// Telegram's 4096-character limit. All messages are sent with
// parse_mode=Markdown so fenced code blocks and inline formatting render as
// rich text. Templates must stay valid under Telegram's legacy Markdown:
// unpaired '*' or '_' characters (e.g. a lone '*Event: ...' label) make the
// API reject the whole message.
// Telegram's 4096-character limit. When the channel has markdown enabled the
// message is sent with parse_mode=Markdown so fenced code blocks and inline
// formatting render as rich text; templates must then stay valid under
// Telegram's legacy Markdown, because unpaired '*' or '_' characters (e.g. a
// lone '*Event: ...' label) make the API reject the whole message. With
// markdown disabled the message is sent without a parse mode, so it is
// delivered verbatim whatever it contains.
func (t *TelegramController) sendMessage(message controller.Message) error {
if t.client == nil {
return nil
@@ -40,11 +42,15 @@ func (t *TelegramController) sendMessageChunk(chatID int64, text string) error {
ctx, cancel := context.WithTimeout(context.Background(), apiTimeout)
defer cancel()
_, err := t.client.SendMessage(ctx, &bot.SendMessageParams{
ChatID: chatID,
Text: text,
ParseMode: models.ParseModeMarkdownV1, // Telegram legacy Markdown
})
params := &bot.SendMessageParams{
ChatID: chatID,
Text: text,
}
if t.cfg.Markdown {
params.ParseMode = models.ParseModeMarkdownV1 // Telegram legacy Markdown
}
_, err := t.client.SendMessage(ctx, params)
return err
}
+23 -3
View File
@@ -124,6 +124,12 @@ func (t *TelegramController) IsEnabled() bool {
return t.cfg.Enabled
}
// IsMarkdown returns whether the channel renders Markdown, per its markdown
// setting in config.json.
func (t *TelegramController) IsMarkdown() bool {
return t.cfg.Markdown
}
// handleUpdate processes a single Telegram update received via long polling. It
// is installed as the framework's default handler (every update with a Message
// reaches it). Updates are processed sequentially because the bot is created
@@ -271,7 +277,7 @@ func (t *TelegramController) SendStatusChange(change node.StatusChange) error {
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
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 {
@@ -299,7 +305,7 @@ func (t *TelegramController) SendServerList(onlineServers, offlineServers string
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params)
message := template.Render(cfg.ControllerMessage.ServerList, params, t.cfg.Markdown)
t.sendToGroups(message)
return nil
@@ -313,12 +319,26 @@ func (t *TelegramController) SendExecuteResult(serverName, serverUUID, command,
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, t.cfg.Markdown)
t.sendToGroups(message)
return nil
}
// SendAlert sends an alert submitted through the incoming webhook API to all
// Telegram trusted groups and admins.
func (t *TelegramController) SendAlert(alert controller.Alert) error {
if !t.cfg.Enabled || t.client == nil {
return nil
}
return t.SendMessage(controller.Message{
Source: t.Name(),
Content: alert.Render(t.cfg.Markdown),
Type: controller.MessageTypeAlert,
})
}
// telegramChatType maps a Telegram chat type to the unified ChatType value used
// by the controller package. Empty means the chat type is unsupported.
func telegramChatType(chatType string) string {
+34 -8
View File
@@ -8,6 +8,7 @@ import (
"time"
"nukumizu-backend/config"
"nukumizu-backend/internal/controller"
"nukumizu-backend/internal/netproxy"
"nukumizu-backend/internal/node"
"nukumizu-backend/internal/template"
@@ -53,6 +54,12 @@ func (w *WebhookController) IsEnabled() bool {
return w.cfg.Enabled
}
// IsMarkdown returns whether the channel renders Markdown, per its markdown
// setting in config.json.
func (w *WebhookController) IsMarkdown() bool {
return w.cfg.Markdown
}
// SendStatusChange sends a status change notification via Webhook.
func (w *WebhookController) SendStatusChange(change node.StatusChange) error {
if !w.cfg.Enabled {
@@ -61,7 +68,7 @@ func (w *WebhookController) SendStatusChange(change node.StatusChange) error {
cfg := config.C_globalConfig
params := template.BuildParamsFromStatusChange(change)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params)
message := template.Render(cfg.ControllerMessage.ServerStatusChanged, params, w.cfg.Markdown)
payload := map[string]interface{}{
"event": change.Event,
@@ -82,14 +89,14 @@ func (w *WebhookController) SendServerList(onlineServers, offlineServers string)
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
message := template.Render(cfg.ControllerMessage.ServerList, params)
message := template.Render(cfg.ControllerMessage.ServerList, params, w.cfg.Markdown)
payload := map[string]interface{}{
"type": "serverList",
"onlineServers": params.OnlineServers,
"offlineServers": params.OfflineServers,
"message": message,
"time": params.Time,
"type": "serverList",
"onlineServers": params.OnlineServers,
"offlineServers": params.OfflineServers,
"message": message,
"time": params.Time,
}
return w.send(payload)
@@ -103,7 +110,7 @@ func (w *WebhookController) SendExecuteResult(serverName, serverUUID, command, r
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(serverName, serverUUID, command, result)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params)
message := template.Render(cfg.ControllerMessage.ServerExecuteResult, params, w.cfg.Markdown)
payload := map[string]interface{}{
"type": "executeResult",
@@ -118,6 +125,25 @@ func (w *WebhookController) SendExecuteResult(serverName, serverUUID, command, r
return w.send(payload)
}
// SendAlert sends an alert submitted through the incoming webhook API to the
// configured URL.
func (w *WebhookController) SendAlert(alert controller.Alert) error {
if !w.cfg.Enabled {
return nil
}
payload := map[string]interface{}{
"type": "alert",
"subject": alert.Subject,
"source": alert.Source,
"content": alert.Content,
"message": alert.Render(w.cfg.Markdown),
"time": alert.Time,
}
return w.send(payload)
}
func (w *WebhookController) send(payload map[string]interface{}) error {
method := w.cfg.Method
if method == "" {
+16 -5
View File
@@ -10,16 +10,27 @@ import (
"nukumizu-backend/internal/template"
)
// commandMarkdown reports whether responses to the given command are rendered
// with Markdown, per the markdown setting of the pipe the command came from
// (see Command.Source).
func commandMarkdown(cmd Command) bool {
mgr := GetManager()
if mgr == nil {
return false
}
return mgr.IsMarkdown(cmd.Source)
}
func handleHelp(cmd Command) (string, error) {
cfg := config.C_globalConfig
params := template.BuildBotInitializationMsgParams()
return template.Render(cfg.ControllerMessage.BotHelp, params, cmd.Source), nil
return template.Render(cfg.ControllerMessage.BotHelp, params, commandMarkdown(cmd)), nil
}
func handleList(cmd Command) (string, error) {
cfg := config.C_globalConfig
params := template.BuildParamsFromServerList()
return template.Render(cfg.ControllerMessage.ServerList, params, cmd.Source), nil
return template.Render(cfg.ControllerMessage.ServerList, params, commandMarkdown(cmd)), nil
}
func handleStatus(cmd Command) (string, error) {
@@ -130,7 +141,7 @@ func handleRun(cmd Command) (string, error) {
cfg := config.C_globalConfig
params := template.BuildParamsFromExecResult(uuidArg, uuidArg, command, formatTaskResults(results))
return template.Render(cfg.ControllerMessage.ServerExecuteResult, params, cmd.Source), nil
return template.Render(cfg.ControllerMessage.ServerExecuteResult, params, commandMarkdown(cmd)), nil
}
func handleInfo(cmd Command) (string, error) {
@@ -177,10 +188,10 @@ func handleInfo(cmd Command) (string, error) {
return sb.String(), nil
}
func telegram_handleStart() (string, error) {
func telegram_handleStart(cmd Command) (string, error) {
cfg := config.C_globalConfig
params := template.BuildBotInitializationMsgParams()
return template.Render(cfg.ControllerMessage.Tg_BotStart, params, "telegram"), nil
return template.Render(cfg.ControllerMessage.Tg_BotStart, params, commandMarkdown(cmd)), nil
}
func handleGetIP(cmd Command) (string, error) {
+2 -2
View File
@@ -43,10 +43,10 @@ func (m *Manager) Trigger(cmd Command, trustedGroups, admins []string, listenMet
// RouteCommand processes a parsed bot command and returns the response text.
// The actual command execution for every pipe is unified here.
func (m *Manager) RouteCommand(cmd Command) (string, error) {
if cmd.Source == "telegram"{
if cmd.Source == "telegram" {
switch cmd.Command {
case "start":
return telegram_handleStart()
return telegram_handleStart(cmd)
}
}
switch cmd.Command {
+63 -12
View File
@@ -19,6 +19,9 @@ type Params struct {
Message string
Command string
Result string
Subject string // Alert subject (see AlertParams)
Source string // Alert source (see AlertParams)
Content string // Alert content (see AlertParams)
OnlineServers string // Pre-formatted multi-line list
OfflineServers string // Pre-formatted multi-line list
SoftwareVersion string
@@ -30,6 +33,37 @@ type Params struct {
SoftwareDescription string
}
// AlertTemplate is the body format of an alert submitted by an external
// application through the incoming webhook API.
const AlertTemplate = "{{ subject }}\n- Source: {{ source }}\n- Content:\n{{ content }}\n\n- Time: {{ time }}\nSent by Nukumizu Alert System"
// AlertParams holds the parameters of an alert submitted through the incoming
// webhook API.
type AlertParams 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
}
// RenderAlert renders the body of an alert for a channel. The alert source and
// content may be wrapped in Markdown — the source in inline code, the content
// in a fenced code block — when the target channel has markdown enabled
// (markdown); everything else, the timestamp included, stays plain text.
func RenderAlert(alert AlertParams, markdown bool) string {
params := Params{
Time: alert.Time,
Subject: alert.Subject,
Source: alert.Source,
Content: alert.Content,
}
if markdown {
params.Source = "`" + params.Source + "`"
params.Content = "```\n" + params.Content + "\n```"
}
return Render(AlertTemplate, params, false)
}
// BuildBotInitializationMsgParams creates template parameters for the bot initialization message.
func BuildBotInitializationMsgParams() Params {
return Params{
@@ -80,7 +114,13 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
}
}
// Render substitutes {{ paramName }} placeholders in a template string.
// Render substitutes {{ paramName }} placeholders in a template string. The
// markdown argument is the target channel's markdown setting: when true the
// values that are meant to be read verbatim (UUIDs, messages, commands, command
// results) are wrapped in Markdown code spans and blocks, otherwise every value
// is inserted as plain text. Whether a channel renders Markdown comes from the
// configuration alone — the renderer never infers it from the channel name.
//
// Supported placeholders:
// - {{ time }} — current server time
// - {{ serverName }} — server name
@@ -90,6 +130,9 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
// - {{ message }} — event descriptive message
// - {{ command }} — executed command
// - {{ result }} — command execution result
// - {{ subject }} — alert subject
// - {{ source }} — alert source
// - {{ content }} — alert content
// - {{ list.onlineServers }} — multi-line online server list
// - {{ list.offlineServers }} — multi-line offline server list
// - {{ softwareVersion }} — software version
@@ -99,19 +142,20 @@ func BuildParamsFromExecResult(serverName, serverUUID, command, result string) P
// - {{ softwareBuildTime }} — software build time
// - {{ softwareDeveloper }} — software developer
// - {{ softwareDescription }} — software description
func Render(tmpl string, params Params, source ...string) string {
func Render(tmpl string, params Params, markdown bool) string {
result := tmpl
if len(source) > 0 && source[0] == "telegram" {
// Telegram requires special formatting for code blocks and inline code.
result = strings.ReplaceAll(result, "{{ time }}", "**" + params.Time + "**")
result = strings.ReplaceAll(result, "{{ serverName }}", "**" + params.ServerName + "**")
result = strings.ReplaceAll(result, "{{ serverUUID }}", "`" + params.ServerUUID + "`")
result = strings.ReplaceAll(result, "{{ upStatus }}", "**" + params.UpStatus + "**")
result = strings.ReplaceAll(result, "{{ event }}", "**" + params.Event + "**")
result = strings.ReplaceAll(result, "{{ message }}", "`" + params.Message + "`")
result = strings.ReplaceAll(result, "{{ command }}", "`" + params.Command + "`")
result = strings.ReplaceAll(result, "{{ result }}", "```bash\n" + params.Result + "\n```")
if markdown {
// Channels that render Markdown get code blocks and inline code for the
// values that are read verbatim.
result = strings.ReplaceAll(result, "{{ time }}", "**"+params.Time+"**")
result = strings.ReplaceAll(result, "{{ serverName }}", "**"+params.ServerName+"**")
result = strings.ReplaceAll(result, "{{ serverUUID }}", "`"+params.ServerUUID+"`")
result = strings.ReplaceAll(result, "{{ upStatus }}", "**"+params.UpStatus+"**")
result = strings.ReplaceAll(result, "{{ event }}", "**"+params.Event+"**")
result = strings.ReplaceAll(result, "{{ message }}", "`"+params.Message+"`")
result = strings.ReplaceAll(result, "{{ command }}", "`"+params.Command+"`")
result = strings.ReplaceAll(result, "{{ result }}", "```bash\n"+params.Result+"\n```")
result = strings.ReplaceAll(result, "{{ list.onlineServers }}", params.OnlineServers)
result = strings.ReplaceAll(result, "{{ list.offlineServers }}", params.OfflineServers)
result = strings.ReplaceAll(result, "{{ softwareVersion }}", params.SoftwareVersion)
@@ -140,6 +184,13 @@ func Render(tmpl string, params Params, source ...string) string {
result = strings.ReplaceAll(result, "{{ softwareDeveloper }}", params.SoftwareDeveloper)
result = strings.ReplaceAll(result, "{{ softwareDescription }}", params.SoftwareDescription)
}
// Alert values carry their own formatting (see RenderAlert), so they are
// substituted identically in both branches.
result = strings.ReplaceAll(result, "{{ subject }}", params.Subject)
result = strings.ReplaceAll(result, "{{ source }}", params.Source)
result = strings.ReplaceAll(result, "{{ content }}", params.Content)
return result
}