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 <twin@linux.com>
This commit is contained in:
ChrisJr404
2026-09-08 17:47:19 -04:00
committed by GitHub
co-authored by Chris TwiN
parent 4d15cb7eed
commit 7f3873d0b4
3 changed files with 73 additions and 5 deletions
+2
View File
@@ -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.<br />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
+20 -5
View File
@@ -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()
}
+51
View File
@@ -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)