From 52a54f9c5cc33c8832b3090c23d3d085cd4623a6 Mon Sep 17 00:00:00 2001 From: Tyler Koenig Date: Fri, 15 May 2026 10:53:38 -0400 Subject: [PATCH] feat(alert): add Telegram, PagerDuty, Pushover, Gotify providers Expand alert provider count from 5 to 9. All new providers use the shared HTTPProvider with closure-based payload functions. Includes TUI form support and tests for each provider. --- internal/alert/alert.go | 76 ++++++++++++++++++++ internal/alert/alert_test.go | 104 +++++++++++++++++++++++++++ internal/tui/tab_alerts.go | 131 ++++++++++++++++++++++++++++++++++- 3 files changed, 308 insertions(+), 3 deletions(-) diff --git a/internal/alert/alert.go b/internal/alert/alert.go index d67a30d..c60013f 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -7,6 +7,7 @@ import ( "go-upkeep/internal/models" "net/http" "net/smtp" + "strconv" "strings" "time" ) @@ -52,6 +53,52 @@ func webhookPayload(title, message string) ([]byte, error) { return json.Marshal(map[string]string{"title": title, "message": message, "status": "alert"}) } +func telegramPayload(chatID string) PayloadFunc { + return func(title, message string) ([]byte, error) { + return json.Marshal(map[string]string{ + "chat_id": chatID, + "text": fmt.Sprintf("*%s*\n%s", title, message), + "parse_mode": "Markdown", + }) + } +} + +func pagerdutyPayload(routingKey, severity string) PayloadFunc { + return func(title, message string) ([]byte, error) { + return json.Marshal(map[string]any{ + "routing_key": routingKey, + "event_action": "trigger", + "payload": map[string]string{ + "summary": fmt.Sprintf("%s: %s", title, message), + "source": "go-upkeep", + "severity": severity, + }, + }) + } +} + +func pushoverPayload(token, user string) PayloadFunc { + return func(title, message string) ([]byte, error) { + return json.Marshal(map[string]string{ + "token": token, + "user": user, + "title": title, + "message": message, + }) + } +} + +func gotifyPayload(priority string) PayloadFunc { + return func(title, message string) ([]byte, error) { + pri, _ := strconv.Atoi(priority) + return json.Marshal(map[string]any{ + "title": title, + "message": message, + "priority": pri, + }) + } +} + func GetProvider(cfg models.AlertConfig) Provider { switch cfg.Type { case "discord": @@ -85,6 +132,35 @@ func GetProvider(cfg models.AlertConfig) Provider { Username: cfg.Settings["username"], Password: cfg.Settings["password"], } + case "telegram": + return &HTTPProvider{ + URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", cfg.Settings["token"]), + Payload: telegramPayload(cfg.Settings["chat_id"]), + } + case "pagerduty": + severity := "critical" + if s, ok := cfg.Settings["severity"]; ok && s != "" { + severity = s + } + return &HTTPProvider{ + URL: "https://events.pagerduty.com/v2/enqueue", + Payload: pagerdutyPayload(cfg.Settings["routing_key"], severity), + } + case "pushover": + return &HTTPProvider{ + URL: "https://api.pushover.net/1/messages.json", + Payload: pushoverPayload(cfg.Settings["token"], cfg.Settings["user"]), + } + case "gotify": + priority := "5" + if p, ok := cfg.Settings["priority"]; ok && p != "" { + priority = p + } + serverURL := strings.TrimRight(cfg.Settings["url"], "/") + return &HTTPProvider{ + URL: fmt.Sprintf("%s/message?token=%s", serverURL, cfg.Settings["token"]), + Payload: gotifyPayload(priority), + } default: return nil } diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go index 348f2c9..35e1c8d 100644 --- a/internal/alert/alert_test.go +++ b/internal/alert/alert_test.go @@ -101,6 +101,110 @@ func TestNtfyProvider(t *testing.T) { } } +func TestHTTPProviderTelegram(t *testing.T) { + var received map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := &HTTPProvider{URL: srv.URL, Payload: telegramPayload("12345")} + if err := p.Send("Alert", "Down"); err != nil { + t.Fatalf("Send: %v", err) + } + if received["chat_id"] != "12345" { + t.Errorf("expected chat_id '12345', got '%s'", received["chat_id"]) + } + if received["text"] != "*Alert*\nDown" { + t.Errorf("unexpected text: %s", received["text"]) + } + if received["parse_mode"] != "Markdown" { + t.Errorf("expected parse_mode 'Markdown', got '%s'", received["parse_mode"]) + } +} + +func TestHTTPProviderPagerDuty(t *testing.T) { + var received map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := &HTTPProvider{URL: srv.URL, Payload: pagerdutyPayload("test-key", "critical")} + if err := p.Send("Alert", "Down"); err != nil { + t.Fatalf("Send: %v", err) + } + if received["routing_key"] != "test-key" { + t.Errorf("expected routing_key 'test-key', got '%v'", received["routing_key"]) + } + if received["event_action"] != "trigger" { + t.Errorf("expected event_action 'trigger', got '%v'", received["event_action"]) + } + payload := received["payload"].(map[string]any) + if payload["summary"] != "Alert: Down" { + t.Errorf("unexpected summary: %v", payload["summary"]) + } + if payload["severity"] != "critical" { + t.Errorf("expected severity 'critical', got '%v'", payload["severity"]) + } +} + +func TestHTTPProviderPushover(t *testing.T) { + var received map[string]string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := &HTTPProvider{URL: srv.URL, Payload: pushoverPayload("app-tok", "user-key")} + if err := p.Send("Alert", "Down"); err != nil { + t.Fatalf("Send: %v", err) + } + if received["token"] != "app-tok" { + t.Errorf("expected token 'app-tok', got '%s'", received["token"]) + } + if received["user"] != "user-key" { + t.Errorf("expected user 'user-key', got '%s'", received["user"]) + } + if received["title"] != "Alert" || received["message"] != "Down" { + t.Errorf("unexpected payload: %v", received) + } +} + +func TestHTTPProviderGotify(t *testing.T) { + var received map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&received) + w.WriteHeader(200) + })) + defer srv.Close() + + p := &HTTPProvider{URL: srv.URL, Payload: gotifyPayload("8")} + if err := p.Send("Alert", "Down"); err != nil { + t.Fatalf("Send: %v", err) + } + if received["title"] != "Alert" || received["message"] != "Down" { + t.Errorf("unexpected payload: %v", received) + } + if pri, ok := received["priority"].(float64); !ok || pri != 8 { + t.Errorf("expected priority 8, got %v", received["priority"]) + } +} + +func TestGetProviderNewTypes(t *testing.T) { + for _, typ := range []string{"telegram", "pagerduty", "pushover", "gotify"} { + p := GetProvider(models.AlertConfig{Type: typ, Settings: map[string]string{ + "token": "x", "chat_id": "1", "routing_key": "k", "user": "u", "url": "http://localhost", + }}) + if p == nil { + t.Errorf("GetProvider(%q) returned nil", typ) + } + } +} + func TestGetProviderUnknown(t *testing.T) { p := GetProvider(models.AlertConfig{Type: "unknown"}) if p != nil { diff --git a/internal/tui/tab_alerts.go b/internal/tui/tab_alerts.go index 11c9bf6..342e1bd 100644 --- a/internal/tui/tab_alerts.go +++ b/internal/tui/tab_alerts.go @@ -23,6 +23,19 @@ type alertFormData struct { NtfyUser string NtfyPass string NtfyPri string + // Telegram + TelegramToken string + TelegramChatID string + // PagerDuty + PagerDutyKey string + PagerDutySeverity string + // Pushover + PushoverToken string + PushoverUser string + // Gotify + GotifyURL string + GotifyToken string + GotifyPriority string } func fmtAlertType(t string) string { @@ -37,6 +50,14 @@ func fmtAlertType(t string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("#73F59F")).Render(t) case "ntfy": return lipgloss.NewStyle().Foreground(lipgloss.Color("#FF6B6B")).Render(t) + case "telegram": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#26A5E4")).Render(t) + case "pagerduty": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#06AC38")).Render(t) + case "pushover": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#249DF1")).Render(t) + case "gotify": + return lipgloss.NewStyle().Foreground(lipgloss.Color("#3F8BBA")).Render(t) default: return t } @@ -64,6 +85,26 @@ func fmtAlertConfig(alert struct { return limitStr(fmt.Sprintf("%s/%s", url, topic), 34) } return subtleStyle.Render("—") + case "telegram": + if id := alert.Settings["chat_id"]; id != "" { + return limitStr(fmt.Sprintf("chat:%s", id), 34) + } + return subtleStyle.Render("—") + case "pagerduty": + if key := alert.Settings["routing_key"]; key != "" { + return limitStr(key, 34) + } + return subtleStyle.Render("—") + case "pushover": + if user := alert.Settings["user"]; user != "" { + return limitStr(fmt.Sprintf("user:%s", user), 34) + } + return subtleStyle.Render("—") + case "gotify": + if url := alert.Settings["url"]; url != "" { + return limitStr(url, 34) + } + return subtleStyle.Render("—") default: if val, ok := alert.Settings["url"]; ok { return limitStr(val, 34) @@ -102,8 +143,10 @@ func (m Model) viewAlertsTab() string { func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData = &alertFormData{ - AlertType: "discord", - NtfyPri: "3", + AlertType: "discord", + NtfyPri: "3", + PagerDutySeverity: "critical", + GotifyPriority: "5", } if m.editID > 0 { @@ -128,6 +171,19 @@ func (m *Model) initAlertHuhForm() tea.Cmd { m.alertFormData.NtfyUser = alert.Settings["username"] m.alertFormData.NtfyPass = alert.Settings["password"] m.alertFormData.NtfyPri = alert.Settings["priority"] + case "telegram": + m.alertFormData.TelegramToken = alert.Settings["token"] + m.alertFormData.TelegramChatID = alert.Settings["chat_id"] + case "pagerduty": + m.alertFormData.PagerDutyKey = alert.Settings["routing_key"] + m.alertFormData.PagerDutySeverity = alert.Settings["severity"] + case "pushover": + m.alertFormData.PushoverToken = alert.Settings["token"] + m.alertFormData.PushoverUser = alert.Settings["user"] + case "gotify": + m.alertFormData.GotifyURL = alert.Settings["url"] + m.alertFormData.GotifyToken = alert.Settings["token"] + m.alertFormData.GotifyPriority = alert.Settings["priority"] } break } @@ -152,6 +208,10 @@ func (m *Model) initAlertHuhForm() tea.Cmd { huh.NewOption("Webhook", "webhook"), huh.NewOption("Email (SMTP)", "email"), huh.NewOption("Ntfy", "ntfy"), + huh.NewOption("Telegram", "telegram"), + huh.NewOption("PagerDuty", "pagerduty"), + huh.NewOption("Pushover", "pushover"), + huh.NewOption("Gotify", "gotify"), ).Value(&m.alertFormData.AlertType), ).Title("Alert Config"), huh.NewGroup( @@ -159,7 +219,8 @@ func (m *Model) initAlertHuhForm() tea.Cmd { Placeholder("https://discord.com/api/webhooks/..."). Value(&m.alertFormData.WebhookURL), ).Title("Webhook").WithHideFunc(func() bool { - return m.alertFormData.AlertType == "email" || m.alertFormData.AlertType == "ntfy" + t := m.alertFormData.AlertType + return t != "discord" && t != "slack" && t != "webhook" }), huh.NewGroup( huh.NewInput().Title("Ntfy Server URL"). @@ -207,6 +268,57 @@ func (m *Model) initAlertHuhForm() tea.Cmd { ).Title("Email Settings").WithHideFunc(func() bool { return m.alertFormData.AlertType != "email" }), + huh.NewGroup( + huh.NewInput().Title("Bot Token"). + Placeholder("123456:ABC-DEF1234..."). + Value(&m.alertFormData.TelegramToken), + huh.NewInput().Title("Chat ID"). + Placeholder("-1001234567890"). + Value(&m.alertFormData.TelegramChatID), + ).Title("Telegram Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "telegram" + }), + huh.NewGroup( + huh.NewInput().Title("Routing Key"). + Placeholder("your-integration-routing-key"). + Value(&m.alertFormData.PagerDutyKey), + huh.NewSelect[string]().Title("Severity"). + Options( + huh.NewOption("Critical", "critical"), + huh.NewOption("Error", "error"), + huh.NewOption("Warning", "warning"), + huh.NewOption("Info", "info"), + ).Value(&m.alertFormData.PagerDutySeverity), + ).Title("PagerDuty Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "pagerduty" + }), + huh.NewGroup( + huh.NewInput().Title("App Token"). + Placeholder("your-pushover-app-token"). + Value(&m.alertFormData.PushoverToken), + huh.NewInput().Title("User Key"). + Placeholder("your-pushover-user-key"). + Value(&m.alertFormData.PushoverUser), + ).Title("Pushover Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "pushover" + }), + huh.NewGroup( + huh.NewInput().Title("Server URL"). + Placeholder("https://gotify.example.com"). + Value(&m.alertFormData.GotifyURL), + huh.NewInput().Title("App Token"). + Placeholder("your-gotify-app-token"). + Value(&m.alertFormData.GotifyToken), + huh.NewSelect[string]().Title("Priority"). + Options( + huh.NewOption("Min (0)", "0"), + huh.NewOption("Low (2)", "2"), + huh.NewOption("Normal (5)", "5"), + huh.NewOption("High (8)", "8"), + ).Value(&m.alertFormData.GotifyPriority), + ).Title("Gotify Settings").WithHideFunc(func() bool { + return m.alertFormData.AlertType != "gotify" + }), ).WithTheme(huh.ThemeDracula()) return m.huhForm.Init() @@ -230,6 +342,19 @@ func (m *Model) submitAlertForm() { settings["priority"] = d.NtfyPri settings["username"] = d.NtfyUser settings["password"] = d.NtfyPass + case "telegram": + settings["token"] = d.TelegramToken + settings["chat_id"] = d.TelegramChatID + case "pagerduty": + settings["routing_key"] = d.PagerDutyKey + settings["severity"] = d.PagerDutySeverity + case "pushover": + settings["token"] = d.PushoverToken + settings["user"] = d.PushoverUser + case "gotify": + settings["url"] = d.GotifyURL + settings["token"] = d.GotifyToken + settings["priority"] = d.GotifyPriority default: settings["url"] = d.WebhookURL }