From 0b5918d20358706d2226feecf61302dec30c7369 Mon Sep 17 00:00:00 2001 From: Rodion Habibov Date: Thu, 14 May 2026 14:10:34 +0500 Subject: [PATCH] Add Alertmanager bubble button support --- internal/botapi/client.go | 34 +++++++++++- internal/botapi/send_test.go | 36 +++++++++++++ internal/cmd/serve.go | 25 +++++++++ internal/cmd/worker.go | 2 + internal/config/config.go | 21 ++++++-- internal/queue/queue.go | 15 +++--- internal/server/handler_alertmanager.go | 52 +++++++++++++++++++ internal/server/handler_send.go | 25 +++++---- internal/server/server_test.go | 69 +++++++++++++++++++++++++ 9 files changed, 256 insertions(+), 23 deletions(-) diff --git a/internal/botapi/client.go b/internal/botapi/client.go index b5eea76..4e77aa0 100644 --- a/internal/botapi/client.go +++ b/internal/botapi/client.go @@ -191,9 +191,35 @@ type SendNotification struct { Body string `json:"body"` Metadata json.RawMessage `json:"metadata,omitempty"` Mentions json.RawMessage `json:"mentions,omitempty"` + Bubble ButtonMarkup `json:"bubble,omitempty"` + Keyboard ButtonMarkup `json:"keyboard,omitempty"` Opts *NotificationMsgOpts `json:"opts,omitempty"` } +// ButtonMarkup is a matrix of BotX UI buttons. +type ButtonMarkup [][]Button + +// Button is a BotX bubble/keyboard button. +type Button struct { + Command string `json:"command,omitempty"` + Label string `json:"label,omitempty"` + Data json.RawMessage `json:"data,omitempty"` + Opts *ButtonOpts `json:"opts,omitempty"` +} + +// ButtonOpts controls BotX button behavior. +type ButtonOpts struct { + Silent *bool `json:"silent,omitempty"` + FontColor string `json:"font_color,omitempty"` + BackgroundColor string `json:"background_color,omitempty"` + Align string `json:"align,omitempty"` + HSize int `json:"h_size,omitempty"` + ShowAlert bool `json:"show_alert,omitempty"` + AlertText string `json:"alert_text,omitempty"` + Handler string `json:"handler,omitempty"` + Link string `json:"link,omitempty"` +} + // NotificationMsgOpts controls per-message notification behavior. type NotificationMsgOpts struct { SilentResponse bool `json:"silent_response,omitempty"` @@ -227,6 +253,8 @@ type SendParams struct { File *SendFile Metadata json.RawMessage Mentions json.RawMessage + Bubble ButtonMarkup + Keyboard ButtonMarkup Silent bool Stealth bool ForceDND bool @@ -245,6 +273,8 @@ func BuildSendRequest(p *SendParams) *SendRequest { Body: p.Message, Metadata: p.Metadata, Mentions: p.Mentions, + Bubble: p.Bubble, + Keyboard: p.Keyboard, } if p.Silent { sr.Notification.Opts = &NotificationMsgOpts{ @@ -271,11 +301,13 @@ func BuildSendRequest(p *SendParams) *SendRequest { } // File-only with metadata/mentions: still need a notification to carry them - if sr.Notification == nil && (len(p.Metadata) > 0 || len(p.Mentions) > 0) { + if sr.Notification == nil && (len(p.Metadata) > 0 || len(p.Mentions) > 0 || len(p.Bubble) > 0 || len(p.Keyboard) > 0) { sr.Notification = &SendNotification{ Status: p.Status, Metadata: p.Metadata, Mentions: p.Mentions, + Bubble: p.Bubble, + Keyboard: p.Keyboard, } if p.Silent { sr.Notification.Opts = &NotificationMsgOpts{ diff --git a/internal/botapi/send_test.go b/internal/botapi/send_test.go index 67c5318..4c52fdc 100644 --- a/internal/botapi/send_test.go +++ b/internal/botapi/send_test.go @@ -89,6 +89,42 @@ func TestBuildSendRequest_FileOnlyWithMetadata(t *testing.T) { } } +func TestBuildSendRequest_Bubble(t *testing.T) { + silent := true + sr := BuildSendRequest(&SendParams{ + ChatID: "chat-1", + Status: "ok", + Message: "hello", + Bubble: ButtonMarkup{{ + { + Label: "Open alert", + Opts: &ButtonOpts{ + Silent: &silent, + Align: "center", + Handler: "client", + Link: "https://alertmanager.example.com", + }, + }, + }}, + }) + if sr.Notification == nil { + t.Fatal("expected Notification") + } + if len(sr.Notification.Bubble) != 1 || len(sr.Notification.Bubble[0]) != 1 { + t.Fatalf("unexpected bubble markup: %#v", sr.Notification.Bubble) + } + btn := sr.Notification.Bubble[0][0] + if btn.Label != "Open alert" { + t.Errorf("Label = %q", btn.Label) + } + if btn.Command != "" { + t.Errorf("Command = %q, want empty", btn.Command) + } + if btn.Opts == nil || btn.Opts.Silent == nil || !*btn.Opts.Silent || btn.Opts.Handler != "client" || btn.Opts.Link != "https://alertmanager.example.com" || btn.Opts.Align != "center" { + t.Errorf("Opts = %#v", btn.Opts) + } +} + func TestBuildSendRequest_AllOpts(t *testing.T) { sr := BuildSendRequest(&SendParams{ ChatID: "chat-1", diff --git a/internal/cmd/serve.go b/internal/cmd/serve.go index baaa67e..3df6ac0 100644 --- a/internal/cmd/serve.go +++ b/internal/cmd/serve.go @@ -374,6 +374,8 @@ func buildSendRequest(p *server.SendPayload) *botapi.SendRequest { Status: p.Status, Metadata: p.Metadata, Mentions: p.Mentions, + Bubble: p.Bubble, + Keyboard: p.Keyboard, } if p.File != nil { params.File = botapi.BuildFileAttachmentFromBase64(p.File.Name, p.File.Data) @@ -420,10 +422,31 @@ func buildAlertmanagerConfig(am *config.AlertmanagerYAMLConfig, configPath strin return nil, err } + var button *server.AlertmanagerButtonConfig + if am.Button != nil && am.Button.Enabled { + label := am.Button.Label + if label == "" { + label = "Open Alertmanager" + } + urlTemplate := am.Button.URLTemplate + if urlTemplate == "" { + urlTemplate = `{{- if .ExternalURL -}}{{ .ExternalURL }}/#/alerts?receiver={{ .Receiver | urlquery }}{{- with index .CommonLabels "alertname" }}&alertname={{ . | urlquery }}{{- end }}{{- end -}}` + } + buttonURLTemplate, err := server.ParseAlertmanagerButtonURLTemplate(urlTemplate) + if err != nil { + return nil, err + } + button = &server.AlertmanagerButtonConfig{ + Label: label, + URLTemplate: buttonURLTemplate, + } + } + return &server.AlertmanagerConfig{ DefaultChatID: am.DefaultChatID, ErrorSeverities: severities, Template: tmpl, + Button: button, }, nil } @@ -841,6 +864,8 @@ func runServeEnqueue(flags config.Flags, listenFlag, apiKeyFlag string, deps Dep Status: p.Status, Metadata: p.Metadata, Mentions: p.Mentions, + Bubble: p.Bubble, + Keyboard: p.Keyboard, }, ReplyTo: cfg.Queue.ReplyQueue, EnqueuedAt: time.Now().UTC(), diff --git a/internal/cmd/worker.go b/internal/cmd/worker.go index f74b0df..415faec 100644 --- a/internal/cmd/worker.go +++ b/internal/cmd/worker.go @@ -404,6 +404,8 @@ func buildSendRequestFromWork(msg *queue.WorkMessage) *botapi.SendRequest { Status: msg.Payload.Status, Metadata: msg.Payload.Metadata, Mentions: msg.Payload.Mentions, + Bubble: msg.Payload.Bubble, + Keyboard: msg.Payload.Keyboard, Silent: msg.Payload.Opts.Silent, Stealth: msg.Payload.Opts.Stealth, ForceDND: msg.Payload.Opts.ForceDND, diff --git a/internal/config/config.go b/internal/config/config.go index 746ccd8..d9006f7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -128,10 +128,18 @@ type CallbackHandlerConfig struct { // AlertmanagerYAMLConfig holds YAML settings for the alertmanager webhook endpoint. type AlertmanagerYAMLConfig struct { - DefaultChatID string `yaml:"default_chat_id,omitempty"` - ErrorSeverities []string `yaml:"error_severities,omitempty"` - Template string `yaml:"template,omitempty"` - TemplateFile string `yaml:"template_file,omitempty"` + DefaultChatID string `yaml:"default_chat_id,omitempty"` + ErrorSeverities []string `yaml:"error_severities,omitempty"` + Template string `yaml:"template,omitempty"` + TemplateFile string `yaml:"template_file,omitempty"` + Button *AlertmanagerButtonYAMLConfig `yaml:"button,omitempty"` +} + +// AlertmanagerButtonYAMLConfig holds BotX bubble button settings for alertmanager. +type AlertmanagerButtonYAMLConfig struct { + Enabled bool `yaml:"enabled,omitempty"` + Label string `yaml:"label,omitempty"` + URLTemplate string `yaml:"url_template,omitempty"` } // GrafanaYAMLConfig holds YAML settings for the Grafana webhook endpoint. @@ -995,7 +1003,10 @@ var knownKeys = map[string]map[string]bool{ "alertmanager": true, "grafana": true, "callbacks": true, "docs": true, "external_url": true, }, "server.alertmanager": { - "default_chat_id": true, "error_severities": true, "template": true, "template_file": true, + "default_chat_id": true, "error_severities": true, "template": true, "template_file": true, "button": true, + }, + "server.alertmanager.button": { + "enabled": true, "label": true, "url_template": true, }, "server.grafana": { "default_chat_id": true, "error_states": true, "template": true, "template_file": true, diff --git a/internal/queue/queue.go b/internal/queue/queue.go index aead15f..1cf7413 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -5,6 +5,7 @@ import ( "encoding/json" "time" + "github.com/lavr/express-botx/internal/botapi" "github.com/lavr/express-botx/internal/config" ) @@ -30,12 +31,14 @@ type Routing struct { // Payload carries the message content and delivery options. type Payload struct { - Message string `json:"message"` - Status string `json:"status,omitempty"` - File *FileAttachment `json:"file,omitempty"` - Opts DeliveryOpts `json:"opts"` - Metadata json.RawMessage `json:"metadata,omitempty"` - Mentions json.RawMessage `json:"mentions,omitempty"` + Message string `json:"message"` + Status string `json:"status,omitempty"` + File *FileAttachment `json:"file,omitempty"` + Opts DeliveryOpts `json:"opts"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Mentions json.RawMessage `json:"mentions,omitempty"` + Bubble botapi.ButtonMarkup `json:"bubble,omitempty"` + Keyboard botapi.ButtonMarkup `json:"keyboard,omitempty"` } // FileAttachment is a base64-encoded file for async delivery. diff --git a/internal/server/handler_alertmanager.go b/internal/server/handler_alertmanager.go index bf613fc..dd63bdc 100644 --- a/internal/server/handler_alertmanager.go +++ b/internal/server/handler_alertmanager.go @@ -8,6 +8,7 @@ import ( "text/template" "time" + "github.com/lavr/express-botx/internal/botapi" vlog "github.com/lavr/express-botx/internal/log" ) @@ -16,11 +17,18 @@ type AlertmanagerConfig struct { DefaultChatID string // default target chat UUID or alias (may be empty) ErrorSeverities []string // severities that map to status "error" Template *template.Template + Button *AlertmanagerButtonConfig // FallbackChatID is resolved at startup from the config's chats section // when there is exactly one chat alias configured. Empty otherwise. FallbackChatID string } +// AlertmanagerButtonConfig holds settings for a BotX bubble button under alerts. +type AlertmanagerButtonConfig struct { + Label string + URLTemplate *template.Template +} + // AlertmanagerWebhook is the JSON payload from Alertmanager. type AlertmanagerWebhook struct { Version string `json:"version"` @@ -111,6 +119,7 @@ func (s *Server) handleAlertmanager(w http.ResponseWriter, r *http.Request) { ChatID: chatResult.ChatID, Message: message, Status: status, + Bubble: s.renderAlertmanagerButton(webhook), }) elapsed := time.Since(start) @@ -142,6 +151,40 @@ func (s *Server) resolveAlertStatus(webhook AlertmanagerWebhook) string { return "ok" } +func (s *Server) renderAlertmanagerButton(webhook AlertmanagerWebhook) botapi.ButtonMarkup { + if s.amCfg == nil || s.amCfg.Button == nil || s.amCfg.Button.URLTemplate == nil { + return nil + } + + var buf bytes.Buffer + if err := s.amCfg.Button.URLTemplate.Execute(&buf, webhook); err != nil { + vlog.V1("alertmanager: button url template error: %v", err) + return nil + } + + url := buf.String() + if url == "" { + return nil + } + + label := s.amCfg.Button.Label + if label == "" { + label = "Open Alertmanager" + } + silent := true + return botapi.ButtonMarkup{{ + { + Label: label, + Opts: &botapi.ButtonOpts{ + Silent: &silent, + Align: "center", + Handler: "client", + Link: url, + }, + }, + }} +} + // DefaultAlertmanagerTemplate is the built-in template for formatting alerts. const DefaultAlertmanagerTemplate = `{{ if eq .Status "firing" }}` + "\U0001F525" + ` FIRING{{ else }}` + "\u2705" + ` RESOLVED{{ end }} [{{ index .GroupLabels "alertname" }}] {{ range .Alerts }} @@ -160,3 +203,12 @@ func ParseAlertmanagerTemplate(tmplStr string) (*template.Template, error) { } return t, nil } + +// ParseAlertmanagerButtonURLTemplate compiles a Go text/template for alertmanager button URLs. +func ParseAlertmanagerButtonURLTemplate(tmplStr string) (*template.Template, error) { + t, err := template.New("alertmanager_button_url").Parse(tmplStr) + if err != nil { + return nil, fmt.Errorf("parsing alertmanager button url template: %w", err) + } + return t, nil +} diff --git a/internal/server/handler_send.go b/internal/server/handler_send.go index 6995f27..1ef4d88 100644 --- a/internal/server/handler_send.go +++ b/internal/server/handler_send.go @@ -12,6 +12,7 @@ import ( "regexp" "time" + "github.com/lavr/express-botx/internal/botapi" vlog "github.com/lavr/express-botx/internal/log" "github.com/lavr/express-botx/internal/mentions" ) @@ -22,17 +23,19 @@ func isUUID(s string) bool { return uuidRe.MatchString(s) } // SendPayload is the parsed request for sending a message. type SendPayload struct { - Bot string `json:"bot,omitempty"` - ChatID string `json:"chat_id"` - Message string `json:"message"` - File *FilePayload `json:"file,omitempty"` - Status string `json:"status"` - Opts *OptsPayload `json:"opts,omitempty"` - Metadata json.RawMessage `json:"metadata,omitempty"` - Mentions json.RawMessage `json:"mentions,omitempty"` - RoutingMode string `json:"routing_mode,omitempty"` // async mode: direct, catalog, mixed - BotID string `json:"bot_id,omitempty"` // async mode: bot UUID for direct routing - NoParse bool `json:"-"` // internal: skip mentions parsing (set by handler for async mode) + Bot string `json:"bot,omitempty"` + ChatID string `json:"chat_id"` + Message string `json:"message"` + File *FilePayload `json:"file,omitempty"` + Status string `json:"status"` + Opts *OptsPayload `json:"opts,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Mentions json.RawMessage `json:"mentions,omitempty"` + Bubble botapi.ButtonMarkup `json:"bubble,omitempty"` + Keyboard botapi.ButtonMarkup `json:"keyboard,omitempty"` + RoutingMode string `json:"routing_mode,omitempty"` // async mode: direct, catalog, mixed + BotID string `json:"bot_id,omitempty"` // async mode: bot UUID for direct routing + NoParse bool `json:"-"` // internal: skip mentions parsing (set by handler for async mode) } // FilePayload represents a file attachment in the JSON request. diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c872b32..a586c92 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -905,6 +905,75 @@ func TestAlertmanager_ChatIDQueryParam(t *testing.T) { } } +func TestAlertmanager_Button(t *testing.T) { + tmpl, _ := ParseAlertmanagerTemplate(`test`) + buttonURLTemplate, err := ParseAlertmanagerButtonURLTemplate(`{{ .ExternalURL }}/#/alerts?receiver={{ .Receiver | urlquery }}&alertname={{ index .CommonLabels "alertname" | urlquery }}`) + if err != nil { + t.Fatalf("parse button template: %v", err) + } + amCfg := &AlertmanagerConfig{ + DefaultChatID: "default-chat", + ErrorSeverities: []string{"critical"}, + Template: tmpl, + Button: &AlertmanagerButtonConfig{ + Label: "Open alert", + URLTemplate: buttonURLTemplate, + }, + } + + var captured *SendPayload + cfg := Config{ + Listen: ":0", + BasePath: "/api/v1", + Keys: []ResolvedKey{{Name: "t", Key: "k"}}, + } + sendFn := func(ctx context.Context, p *SendPayload) (string, error) { + captured = p + return "id", nil + } + chatResolver := func(chatID string) (ChatResolveResult, error) { return ChatResolveResult{ChatID: chatID}, nil } + srv := New(cfg, sendFn, chatResolver, WithAlertmanager(amCfg)) + + webhook := AlertmanagerWebhook{ + Version: "4", + Status: "firing", + Receiver: "team-express", + GroupLabels: map[string]string{"alertname": "TestAlert"}, + CommonLabels: map[string]string{"alertname": "TestAlert"}, + ExternalURL: "https://alertmanager.example.com", + Alerts: []AlertItem{{ + Status: "firing", + Labels: map[string]string{"alertname": "TestAlert", "severity": "critical"}, + }}, + } + body, _ := json.Marshal(webhook) + + w := doRequest(srv, "POST", "/api/v1/alertmanager", bytes.NewReader(body), map[string]string{ + "X-API-Key": "k", + "Content-Type": "application/json", + }) + if w.Code != 200 { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if captured == nil { + t.Fatal("expected captured payload") + } + if len(captured.Bubble) != 1 || len(captured.Bubble[0]) != 1 { + t.Fatalf("unexpected bubble markup: %#v", captured.Bubble) + } + btn := captured.Bubble[0][0] + if btn.Label != "Open alert" { + t.Errorf("Label = %q", btn.Label) + } + wantURL := "https://alertmanager.example.com/#/alerts?receiver=team-express&alertname=TestAlert" + if btn.Command != "" { + t.Errorf("Command = %q, want empty", btn.Command) + } + if btn.Opts == nil || btn.Opts.Handler != "client" || btn.Opts.Silent == nil || !*btn.Opts.Silent || btn.Opts.Link != wantURL || btn.Opts.Align != "center" { + t.Errorf("Opts = %#v", btn.Opts) + } +} + func TestAlertmanager_NoChatID(t *testing.T) { tmpl, _ := ParseAlertmanagerTemplate(`test`) amCfg := &AlertmanagerConfig{