Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion internal/botapi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -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
Expand All @@ -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{
Expand All @@ -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{
Expand Down
36 changes: 36 additions & 0 deletions internal/botapi/send_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 25 additions & 0 deletions internal/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 16 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 9 additions & 6 deletions internal/queue/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"time"

"github.com/lavr/express-botx/internal/botapi"
"github.com/lavr/express-botx/internal/config"
)

Expand All @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions internal/server/handler_alertmanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"text/template"
"time"

"github.com/lavr/express-botx/internal/botapi"
vlog "github.com/lavr/express-botx/internal/log"
)

Expand All @@ -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"`
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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 }}
Expand All @@ -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
}
25 changes: 14 additions & 11 deletions internal/server/handler_send.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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.
Expand Down
Loading