diff --git a/.github/assets/telegram-alerts.png b/.github/assets/telegram-alerts.png new file mode 100644 index 00000000..0981c64b Binary files /dev/null and b/.github/assets/telegram-alerts.png differ diff --git a/.gitignore b/.gitignore index b2618a1a..8376d1cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea .vscode gatus -db.db \ No newline at end of file +db.db +config/config.yml \ No newline at end of file diff --git a/README.md b/README.md index c6cc8d9d..0e5fc5bb 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ core applications: https://status.twinnation.org/ - [Configuring Twilio alerts](#configuring-twilio-alerts) - [Configuring Mattermost alerts](#configuring-mattermost-alerts) - [Configuring Messagebird alerts](#configuring-messagebird-alerts) + - [Configuring Telegram alerts](#configuring-telegram-alerts) - [Configuring custom alerts](#configuring-custom-alerts) - [Kubernetes (ALPHA)](#kubernetes-alpha) - [Auto Discovery](#auto-discovery) @@ -220,6 +221,9 @@ ignored. | `alerting.messagebird.access-key` | Messagebird access key | Required `""` | | `alerting.messagebird.originator` | The sender of the message | Required `""` | | `alerting.messagebird.recipients` | The recipients of the message | Required `""` | +| `alerting.telegram` | Configuration for alerts of type `telegram` | `{}` | +| `alerting.telegram.token` | Telegram Bot Token | Required `""` | +| `alerting.telegram.id` | Telegram User ID | Required `""` | | `alerting.custom` | Configuration for custom actions on failure or alerts | `{}` | | `alerting.custom.url` | Custom alerting request url | Required `""` | | `alerting.custom.method` | Request method | `GET` | @@ -395,6 +399,31 @@ services: ``` +#### Configuring Telegram alerts + +```yaml +alerting: + telegram: + token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" + id: "0123456789" + +services: + - name: twinnation + url: "https://twinnation.org/health" + interval: 30s + alerts: + - type: telegram + enabled: true + send-on-resolved: true + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" +``` + +Here's an example of what the notifications look like: + +![Telegram notifications](.github/assets/telegram-alerts.png) + #### Configuring custom alerts While they're called alerts, you can use this feature to call anything. diff --git a/alerting/config.go b/alerting/config.go index c99456ab..ca51c9a6 100644 --- a/alerting/config.go +++ b/alerting/config.go @@ -7,6 +7,7 @@ import ( "github.com/TwinProduction/gatus/alerting/provider/messagebird" "github.com/TwinProduction/gatus/alerting/provider/pagerduty" "github.com/TwinProduction/gatus/alerting/provider/slack" + "github.com/TwinProduction/gatus/alerting/provider/telegram" "github.com/TwinProduction/gatus/alerting/provider/twilio" ) @@ -30,6 +31,9 @@ type Config struct { // Slack is the configuration for the slack alerting provider Slack *slack.AlertProvider `yaml:"slack"` + // Telegram is the configuration for the telegram alerting provider + Telegram *telegram.AlertProvider `yaml:"telegram"` + // Twilio is the configuration for the twilio alerting provider Twilio *twilio.AlertProvider `yaml:"twilio"` } diff --git a/alerting/provider/provider.go b/alerting/provider/provider.go index eeba3523..81b97b85 100644 --- a/alerting/provider/provider.go +++ b/alerting/provider/provider.go @@ -7,6 +7,7 @@ import ( "github.com/TwinProduction/gatus/alerting/provider/messagebird" "github.com/TwinProduction/gatus/alerting/provider/pagerduty" "github.com/TwinProduction/gatus/alerting/provider/slack" + "github.com/TwinProduction/gatus/alerting/provider/telegram" "github.com/TwinProduction/gatus/alerting/provider/twilio" "github.com/TwinProduction/gatus/core" ) @@ -28,5 +29,6 @@ var ( _ AlertProvider = (*messagebird.AlertProvider)(nil) _ AlertProvider = (*pagerduty.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil) + _ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*twilio.AlertProvider)(nil) ) diff --git a/alerting/provider/telegram/telegram.go b/alerting/provider/telegram/telegram.go new file mode 100644 index 00000000..b363ba84 --- /dev/null +++ b/alerting/provider/telegram/telegram.go @@ -0,0 +1,52 @@ +package telegram + +import ( + "fmt" + "net/http" + + "github.com/TwinProduction/gatus/alerting/provider/custom" + "github.com/TwinProduction/gatus/core" +) + +// AlertProvider is the configuration necessary for sending an alert using Telegram +type AlertProvider struct { + Token string `yaml:"token"` + ID string `yaml:"id"` +} + +// IsValid returns whether the provider's configuration is valid +func (provider *AlertProvider) IsValid() bool { + return len(provider.Token) > 0 && len(provider.ID) > 0 +} + +// ToCustomAlertProvider converts the provider into a custom.AlertProvider +func (provider *AlertProvider) ToCustomAlertProvider(service *core.Service, alert *core.Alert, result *core.Result, resolved bool) *custom.AlertProvider { + var message, results string + if resolved { + message = fmt.Sprintf("An alert for *%s* has been resolved:\\n—\\n _healthcheck passing successfully %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold) + } else { + message = fmt.Sprintf("An alert for *%s* has been triggered:\\n—\\n _healthcheck failed %d time(s) in a row_\\n— ", service.Name, alert.FailureThreshold) + } + for _, conditionResult := range result.ConditionResults { + var prefix string + if conditionResult.Success { + prefix = "✅" + } else { + prefix = "❌" + } + results += fmt.Sprintf("%s - `%s`\\n", prefix, conditionResult.Condition) + } + var text string + if len(alert.Description) > 0 { + text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Description* \\n_%s_ \\n\\n*Condition results*\\n%s", message, alert.Description, results) + } else { + text = fmt.Sprintf("⛑ *Gatus* \\n%s \\n*Condition results*\\n%s", message, results) + } + + return &custom.AlertProvider{ + URL: fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), + Method: http.MethodPost, + Body: fmt.Sprintf(`{"chat_id": "%s", "text": "%s", "parse_mode": "MARKDOWN" }`, provider.ID, text), + Headers: map[string]string{"Content-Type": "application/json"}, + } +} diff --git a/alerting/provider/telegram/telegram_test.go b/alerting/provider/telegram/telegram_test.go new file mode 100644 index 00000000..c4e7631b --- /dev/null +++ b/alerting/provider/telegram/telegram_test.go @@ -0,0 +1,89 @@ +package telegram + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/TwinProduction/gatus/core" +) + +func TestAlertProvider_IsValid(t *testing.T) { + invalidProvider := AlertProvider{Token: "", ID: ""} + if invalidProvider.IsValid() { + t.Error("provider shouldn't have been valid") + } + validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"} + if !validProvider.IsValid() { + t.Error("provider should've been valid") + } +} + +func TestAlertProvider_ToCustomAlertProviderWithResolvedAlert(t *testing.T) { + provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"} + customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "SUCCESSFUL_CONDITION", Success: true}}}, true) + if customAlertProvider == nil { + t.Fatal("customAlertProvider shouldn't have been nil") + } + if !strings.Contains(customAlertProvider.Body, "resolved") { + t.Error("customAlertProvider.Body should've contained the substring resolved") + } + if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) { + t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL) + } + if customAlertProvider.Method != http.MethodPost { + t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) + } + body := make(map[string]interface{}) + err := json.Unmarshal([]byte(customAlertProvider.Body), &body) + //_, err := json.Marshal(customAlertProvider.Body) + if err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } +} + +func TestAlertProvider_ToCustomAlertProviderWithTriggeredAlert(t *testing.T) { + provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"} + customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{Description: "Healthcheck Successful"}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) + if customAlertProvider == nil { + t.Fatal("customAlertProvider shouldn't have been nil") + } + if !strings.Contains(customAlertProvider.Body, "triggered") { + t.Error("customAlertProvider.Body should've contained the substring triggered") + } + if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) { + t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL) + } + if customAlertProvider.Method != http.MethodPost { + t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) + } + body := make(map[string]interface{}) + err := json.Unmarshal([]byte(customAlertProvider.Body), &body) + if err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } +} + +func TestAlertProvider_ToCustomAlertProviderWithDescription(t *testing.T) { + provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "0123456789"} + customAlertProvider := provider.ToCustomAlertProvider(&core.Service{}, &core.Alert{}, &core.Result{ConditionResults: []*core.ConditionResult{{Condition: "UNSUCCESSFUL_CONDITION", Success: false}}}, false) + if customAlertProvider == nil { + t.Fatal("customAlertProvider shouldn't have been nil") + } + if !strings.Contains(customAlertProvider.Body, "triggered") { + t.Error("customAlertProvider.Body should've contained the substring triggered") + } + if customAlertProvider.URL != fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token) { + t.Errorf("expected URL to be %s, got %s", fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", provider.Token), customAlertProvider.URL) + } + if customAlertProvider.Method != http.MethodPost { + t.Errorf("expected method to be %s, got %s", http.MethodPost, customAlertProvider.Method) + } + body := make(map[string]interface{}) + err := json.Unmarshal([]byte(customAlertProvider.Body), &body) + if err != nil { + t.Error("expected body to be valid JSON, got error:", err.Error()) + } +} diff --git a/config/config.go b/config/config.go index 51036194..31e6c06d 100644 --- a/config/config.go +++ b/config/config.go @@ -234,6 +234,7 @@ func validateAlertingConfig(config *Config) { core.MessagebirdAlert, core.PagerDutyAlert, core.SlackAlert, + core.TelegramAlert, core.TwilioAlert, } var validProviders, invalidProviders []core.AlertType @@ -292,6 +293,12 @@ func GetAlertingProviderByAlertType(config *Config, alertType core.AlertType) pr return nil } return config.Alerting.Slack + case core.TelegramAlert: + if config.Alerting.Telegram == nil { + // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil + return nil + } + return config.Alerting.Telegram case core.TwilioAlert: if config.Alerting.Twilio == nil { // Since we're returning an interface, we need to explicitly return nil, even if the provider itself is nil diff --git a/config/config_test.go b/config/config_test.go index 1ee72654..6912a801 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -13,6 +13,7 @@ import ( "github.com/TwinProduction/gatus/alerting/provider/messagebird" "github.com/TwinProduction/gatus/alerting/provider/pagerduty" "github.com/TwinProduction/gatus/alerting/provider/slack" + "github.com/TwinProduction/gatus/alerting/provider/telegram" "github.com/TwinProduction/gatus/alerting/provider/twilio" "github.com/TwinProduction/gatus/core" "github.com/TwinProduction/gatus/k8stest" @@ -354,6 +355,9 @@ alerting: access-key: "1" originator: "31619191918" recipients: "31619191919" + telegram: + token: 123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11 + id: 0123456789 services: - name: twinnation url: https://twinnation.org/health @@ -369,6 +373,8 @@ services: - type: discord enabled: true failure-threshold: 10 + - type: telegram + enabled: true conditions: - "[STATUS] == 200" `)) @@ -394,12 +400,13 @@ services: if config.Alerting.PagerDuty == nil || !config.Alerting.PagerDuty.IsValid() { t.Fatal("PagerDuty alerting config should've been valid") } - if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() { - t.Fatal("Messagebird alerting config should've been valid") - } if config.Alerting.PagerDuty.IntegrationKey != "00000000000000000000000000000000" { t.Errorf("PagerDuty integration key should've been %s, but was %s", "00000000000000000000000000000000", config.Alerting.PagerDuty.IntegrationKey) } + + if config.Alerting.Messagebird == nil || !config.Alerting.Messagebird.IsValid() { + t.Fatal("Messagebird alerting config should've been valid") + } if config.Alerting.Messagebird.AccessKey != "1" { t.Errorf("Messagebird access key should've been %s, but was %s", "1", config.Alerting.Messagebird.AccessKey) } @@ -409,12 +416,20 @@ services: if config.Alerting.Messagebird.Recipients != "31619191919" { t.Errorf("Messagebird to recipients should've been %s, but was %s", "31619191919", config.Alerting.Messagebird.Recipients) } + if config.Alerting.Discord == nil || !config.Alerting.Discord.IsValid() { t.Fatal("Discord alerting config should've been valid") } if config.Alerting.Discord.WebhookURL != "http://example.org" { t.Errorf("Discord webhook should've been %s, but was %s", "http://example.org", config.Alerting.Discord.WebhookURL) } + + if config.Alerting.Telegram.Token != "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" { + t.Errorf("Telegram token should've been %s, but was %s", "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", config.Alerting.Telegram.Token) + } + if config.Alerting.Telegram.ID != "0123456789" { + t.Errorf("Telegram ID should've been %s, but was %s", "012345689", config.Alerting.Telegram.ID) + } if GetAlertingProviderByAlertType(config, core.DiscordAlert) != config.Alerting.Discord { t.Error("expected discord configuration") } @@ -428,8 +443,8 @@ services: if config.Services[0].Interval != 60*time.Second { t.Errorf("Interval should have been %s, because it is the default value", 60*time.Second) } - if len(config.Services[0].Alerts) != 4 { - t.Fatal("There should've been 4 alerts configured") + if len(config.Services[0].Alerts) != 5 { + t.Fatal("There should've been 5 alerts configured") } if config.Services[0].Alerts[0].Type != core.SlackAlert { @@ -451,9 +466,6 @@ services: if config.Services[0].Alerts[1].Description != "Healthcheck failed 7 times in a row" { t.Errorf("The description of the alert should've been %s, but it was %s", "Healthcheck failed 7 times in a row", config.Services[0].Alerts[1].Description) } - if config.Services[0].Alerts[1].FailureThreshold != 7 { - t.Errorf("The failure threshold of the alert should've been %d, but it was %d", 7, config.Services[0].Alerts[1].FailureThreshold) - } if config.Services[0].Alerts[1].SuccessThreshold != 5 { t.Errorf("The success threshold of the alert should've been %d, but it was %d", 5, config.Services[0].Alerts[1].SuccessThreshold) } @@ -855,6 +867,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { Messagebird: &messagebird.AlertProvider{}, PagerDuty: &pagerduty.AlertProvider{}, Slack: &slack.AlertProvider{}, + Telegram: &telegram.AlertProvider{}, Twilio: &twilio.AlertProvider{}, }, } @@ -876,6 +889,9 @@ func TestGetAlertingProviderByAlertType(t *testing.T) { if GetAlertingProviderByAlertType(cfg, core.SlackAlert) != cfg.Alerting.Slack { t.Error("expected Slack configuration") } + if GetAlertingProviderByAlertType(cfg, core.TelegramAlert) != cfg.Alerting.Telegram { + t.Error("expected Telegram configuration") + } if GetAlertingProviderByAlertType(cfg, core.TwilioAlert) != cfg.Alerting.Twilio { t.Error("expected Twilio configuration") } diff --git a/core/alert.go b/core/alert.go index 254c09ef..e81e02e4 100644 --- a/core/alert.go +++ b/core/alert.go @@ -58,6 +58,9 @@ const ( // SlackAlert is the AlertType for the slack alerting provider SlackAlert AlertType = "slack" + // TelegramAlert is the AlertType for the telegram alerting provider + TelegramAlert AlertType = "telegram" + // TwilioAlert is the AlertType for the twilio alerting provider TwilioAlert AlertType = "twilio" )