From 7f3873d0b455a0d2bf7880eeee1dd0ea7391b2ab Mon Sep 17 00:00:00 2001 From: ChrisJr404 Date: Tue, 8 Sep 2026 17:47:19 -0400 Subject: [PATCH] feat(alerting): allow configuring the topic for Zulip alerts (#1764) Zulip alerts previously always used a hardcoded "Gatus" topic, sending every alert and resolution into a single thread. Add a "topic" config field (defaulting to "Gatus" for backwards compatibility) that supports the [ENDPOINT_NAME], [ENDPOINT_GROUP] and [ALERT_DESCRIPTION] placeholders, matching the templating convention used by other providers such as clickup. Closes #1387 Co-authored-by: Chris (ChrisJr404) <11917633+ChrisJr404@users.noreply.github.com> Co-authored-by: TwiN --- README.md | 2 ++ alerting/provider/zulip/zulip.go | 25 ++++++++++--- alerting/provider/zulip/zulip_test.go | 51 +++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b1fa27e5..72da5ca0 100644 --- a/README.md +++ b/README.md @@ -2530,6 +2530,7 @@ endpoints: | `alerting.zulip.bot-api-key` | Bot API key | Required `""` | | `alerting.zulip.domain` | Full organization domain (e.g.: yourZulipDomain.zulipchat.com) | Required `""` | | `alerting.zulip.channel-id` | The channel ID where Gatus will send the alerts | Required `""` | +| `alerting.zulip.topic` | The topic under which the alerts will be sent.
Supports the `[ENDPOINT_NAME]`, `[ENDPOINT_GROUP]` and `[ALERT_DESCRIPTION]` placeholders. | `Gatus` | | `alerting.zulip.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | | `alerting.zulip.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | | `alerting.zulip.overrides[].*` | See `alerting.zulip.*` parameters | `{}` | @@ -2541,6 +2542,7 @@ alerting: bot-api-key: "********************************" domain: some.zulip.org channel-id: 123456 + topic: "[ENDPOINT_NAME]" # Optional; groups each endpoint's alerts under its own topic. Defaults to "Gatus" endpoints: - name: website diff --git a/alerting/provider/zulip/zulip.go b/alerting/provider/zulip/zulip.go index 9160cf9d..b9fe80da 100644 --- a/alerting/provider/zulip/zulip.go +++ b/alerting/provider/zulip/zulip.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "strings" "github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/client" @@ -23,10 +24,14 @@ var ( ) type Config struct { - BotEmail string `yaml:"bot-email"` // Email of the bot user - BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user - Domain string `yaml:"domain"` // Domain of the Zulip server - ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to + BotEmail string `yaml:"bot-email"` // Email of the bot user + BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user + Domain string `yaml:"domain"` // Domain of the Zulip server + ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to + Topic string `yaml:"topic,omitempty"` // Topic to send the message to; defaults to "Gatus" when not set + + // The following placeholders are supported in Topic: + // [ENDPOINT_NAME], [ENDPOINT_GROUP], [ALERT_DESCRIPTION] } func (cfg *Config) Validate() error { @@ -58,6 +63,9 @@ func (cfg *Config) Merge(override *Config) { if len(override.ChannelID) > 0 { cfg.ChannelID = override.ChannelID } + if len(override.Topic) > 0 { + cfg.Topic = override.Topic + } } // AlertProvider is the configuration necessary for sending an alert using Zulip @@ -138,10 +146,17 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi } message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition) } + topic := cfg.Topic + if len(topic) == 0 { + topic = "Gatus" + } + topic = strings.ReplaceAll(topic, "[ENDPOINT_NAME]", ep.Name) + topic = strings.ReplaceAll(topic, "[ENDPOINT_GROUP]", ep.Group) + topic = strings.ReplaceAll(topic, "[ALERT_DESCRIPTION]", alert.GetDescription()) return url.Values{ "type": {"channel"}, "to": {cfg.ChannelID}, - "topic": {"Gatus"}, + "topic": {topic}, "content": {message}, }.Encode() } diff --git a/alerting/provider/zulip/zulip_test.go b/alerting/provider/zulip/zulip_test.go index 8d9c34f3..7e14daff 100644 --- a/alerting/provider/zulip/zulip_test.go +++ b/alerting/provider/zulip/zulip_test.go @@ -128,6 +128,52 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { "type": {"channel"}, }, }, + { + name: "Failed alert with custom topic", + provider: AlertProvider{ + DefaultConfig: Config{ + BotEmail: "bot-email", + BotAPIKey: "bot-api-key", + Domain: "domain", + ChannelID: "channel-id", + Topic: "alerts", + }, + }, + alert: basicAlert, + resolved: false, + hasConditions: false, + expectedBody: url.Values{ + "content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row +> Description +`}, + "to": {"channel-id"}, + "topic": {"alerts"}, + "type": {"channel"}, + }, + }, + { + name: "Failed alert with templated topic", + provider: AlertProvider{ + DefaultConfig: Config{ + BotEmail: "bot-email", + BotAPIKey: "bot-api-key", + Domain: "domain", + ChannelID: "channel-id", + Topic: "[ENDPOINT_NAME]", + }, + }, + alert: basicAlert, + resolved: false, + hasConditions: false, + expectedBody: url.Values{ + "content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row +> Description +`}, + "to": {"channel-id"}, + "topic": {"endpoint-Name"}, + "type": {"channel"}, + }, + }, { name: "Resolved alert with conditions", provider: AlertProvider{ @@ -446,12 +492,14 @@ func TestAlertProvider_GetConfig(t *testing.T) { "bot-api-key": "alert-bot-api-key", "domain": "alert-domain", "channel-id": "alert-channel-id", + "topic": "alert-topic", }}, ExpectedOutput: Config{ BotEmail: "alert-bot-email", BotAPIKey: "alert-bot-api-key", Domain: "alert-domain", ChannelID: "alert-channel-id", + Topic: "alert-topic", }, }, } @@ -473,6 +521,9 @@ func TestAlertProvider_GetConfig(t *testing.T) { if got.ChannelID != scenario.ExpectedOutput.ChannelID { t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID) } + if got.Topic != scenario.ExpectedOutput.Topic { + t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Topic, got.Topic) + } // Test ValidateOverrides as well, since it really just calls GetConfig if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil { t.Errorf("unexpected error: %s", err)