Skip to content

Commit a3f90b6

Browse files
committed
feat(alerting): Add basic send mode implementation
1 parent 40b1576 commit a3f90b6

File tree

8 files changed

+368
-0
lines changed

8 files changed

+368
-0
lines changed

alerting/alert/type.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ const (
110110
// TypeTelegram is the Type for the telegram alerting provider
111111
TypeTelegram Type = "telegram"
112112

113+
// TypeThreemaGateway is the Type for the threema-gateway alerting provider
114+
TypeThreemaGateway Type = "threema-gateway"
115+
113116
// TypeTwilio is the Type for the twilio alerting provider
114117
TypeTwilio Type = "twilio"
115118

alerting/config.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ import (
4141
"github.com/TwiN/gatus/v5/alerting/provider/teams"
4242
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
4343
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
44+
"github.com/TwiN/gatus/v5/alerting/provider/threemagateway"
4445
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
4546
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
4647
"github.com/TwiN/gatus/v5/alerting/provider/webex"
@@ -156,6 +157,9 @@ type Config struct {
156157
// Telegram is the configuration for the telegram alerting provider
157158
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
158159

160+
// ThreemaGateway is the configuration for the threema-gatway alerting provider
161+
ThreemaGateway *threemagateway.AlertProvider `yaml:"threema-gateway,omitempty"`
162+
159163
// Twilio is the configuration for the twilio alerting provider
160164
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
161165

alerting/provider/provider.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import (
3737
"github.com/TwiN/gatus/v5/alerting/provider/teams"
3838
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
3939
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
40+
"github.com/TwiN/gatus/v5/alerting/provider/threemagateway"
4041
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
4142
"github.com/TwiN/gatus/v5/alerting/provider/webex"
4243
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
@@ -126,6 +127,7 @@ var (
126127
_ AlertProvider = (*teams.AlertProvider)(nil)
127128
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
128129
_ AlertProvider = (*telegram.AlertProvider)(nil)
130+
_ AlertProvider = (*threemagateway.AlertProvider)(nil)
129131
_ AlertProvider = (*twilio.AlertProvider)(nil)
130132
_ AlertProvider = (*webex.AlertProvider)(nil)
131133
_ AlertProvider = (*zapier.AlertProvider)(nil)
@@ -167,6 +169,7 @@ var (
167169
_ Config[teams.Config] = (*teams.Config)(nil)
168170
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
169171
_ Config[telegram.Config] = (*telegram.Config)(nil)
172+
_ Config[threemagateway.Config] = (*threemagateway.Config)(nil)
170173
_ Config[twilio.Config] = (*twilio.Config)(nil)
171174
_ Config[webex.Config] = (*webex.Config)(nil)
172175
_ Config[zapier.Config] = (*zapier.Config)(nil)
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
package threemagateway
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"maps"
7+
"net/http"
8+
"net/url"
9+
"slices"
10+
"strings"
11+
12+
"github.com/TwiN/gatus/v5/alerting/alert"
13+
"github.com/TwiN/gatus/v5/client"
14+
"github.com/TwiN/gatus/v5/config/endpoint"
15+
"gopkg.in/yaml.v3"
16+
)
17+
18+
// TODO#1464: Add tests
19+
20+
const (
21+
defaultApiUrl = "https://msgapi.threema.ch"
22+
defaultMode = "basic"
23+
)
24+
25+
var (
26+
ErrModeInvalid = fmt.Errorf("invalid mode, must be one of: %s", joinKeys(modes, ", "))
27+
ErrNotImplementedMode = errors.New("the specified mode is not implemented yet")
28+
modes = map[string]Mode{
29+
"basic": ModeBasic,
30+
"e2ee": ModeE2EE,
31+
"e2ee-bulk": ModeE2EEBulk,
32+
}
33+
34+
ErrInvalidRecipientType = fmt.Errorf("invalid recipient type, must be one of: %v", joinKeys(recipientTypes, ", "))
35+
recipientTypes = map[string]RecipientType{
36+
"id": RecipientTypeID,
37+
"phone": RecipientTypePhone,
38+
"email": RecipientTypeEmail,
39+
}
40+
41+
ErrApiKeyMissing = errors.New("auth-secret is required")
42+
)
43+
44+
func joinKeys[V any](m map[string]V, separator string) string {
45+
return strings.Join(slices.Collect(maps.Keys(m)), separator)
46+
}
47+
48+
type Mode int // TODO#1464: Maybe move to separate file in package to keep things organized
49+
50+
const (
51+
ModeInvalid Mode = iota
52+
ModeBasic
53+
ModeE2EE
54+
ModeE2EEBulk
55+
)
56+
57+
func parseMode(s string) Mode {
58+
if val, ok := modes[s]; ok {
59+
return val
60+
}
61+
return ModeInvalid
62+
}
63+
64+
type RecipientType int // TODO#1464: Maybe move to separate file in package to keep things organized
65+
66+
const (
67+
RecipientTypeInvalid RecipientType = iota
68+
RecipientTypeID
69+
RecipientTypePhone
70+
RecipientTypeEmail
71+
)
72+
73+
type Config struct {
74+
ApiUrl string `yaml:"api-url"`
75+
Mode string `yaml:"mode"`
76+
ApiIdentity string `yaml:"api-identity"`
77+
Recipient string `yaml:"recipient"`
78+
ApiAuthSecret string `yaml:"auth-secret"`
79+
80+
sendMode Mode `yaml:"-"`
81+
recipientType RecipientType `yaml:"-"`
82+
parsedRecipient string `yaml:"-"`
83+
}
84+
85+
func parseRecipientType(s string) RecipientType {
86+
if val, ok := recipientTypes[s]; ok {
87+
return val
88+
}
89+
return RecipientTypeInvalid
90+
}
91+
92+
func parseRecipient(s string) (RecipientType, string, error) {
93+
parts := strings.SplitN(s, ":", 2)
94+
if len(parts) != 2 {
95+
return RecipientTypeInvalid, "", errors.New("recipient must be in the format '<type>:<value>'")
96+
}
97+
recipientType := parseRecipientType(parts[0])
98+
if recipientType == RecipientTypeInvalid {
99+
return RecipientTypeInvalid, "", ErrInvalidRecipientType
100+
}
101+
return recipientType, parts[1], nil
102+
}
103+
104+
func validateRecipient(recipientType RecipientType, recipient string) error {
105+
if len(recipient) == 0 {
106+
return errors.New("recipient value cannot be empty")
107+
}
108+
switch recipientType {
109+
case RecipientTypeID:
110+
if len(recipient) != 8 {
111+
return errors.New("recipient ID must be 8 characters long")
112+
}
113+
case RecipientTypePhone:
114+
// Basic validation for phone number # TODO#1464: improve phone number validation
115+
if !strings.HasPrefix(recipient, "+") || len(recipient) < 8 {
116+
return errors.New("invalid phone number format")
117+
}
118+
case RecipientTypeEmail:
119+
// Basic validation for email address // TODO#1464: improve email validation
120+
if !strings.Contains(recipient, "@") {
121+
return errors.New("invalid email address format")
122+
}
123+
default:
124+
return ErrInvalidRecipientType
125+
}
126+
return nil
127+
}
128+
129+
func (cfg *Config) Validate() error {
130+
// Validate API URL
131+
if len(cfg.ApiUrl) == 0 {
132+
cfg.ApiUrl = defaultApiUrl
133+
}
134+
135+
// Validate Mode
136+
if len(cfg.Mode) == 0 {
137+
cfg.Mode = defaultMode
138+
}
139+
cfg.sendMode = parseMode(cfg.Mode)
140+
switch cfg.sendMode {
141+
case ModeInvalid:
142+
return ErrModeInvalid
143+
case ModeE2EE, ModeE2EEBulk:
144+
return ErrNotImplementedMode // TODO#1464: implement E2EE and E2EE-Bulk modes
145+
}
146+
147+
// Validate Recipient
148+
var err error
149+
cfg.recipientType, cfg.parsedRecipient, err = parseRecipient(cfg.Recipient)
150+
if err != nil {
151+
return err
152+
}
153+
if err := validateRecipient(cfg.recipientType, cfg.parsedRecipient); err != nil {
154+
return err
155+
}
156+
157+
// Validate API Key
158+
if len(cfg.ApiAuthSecret) == 0 {
159+
return ErrApiKeyMissing
160+
}
161+
return nil
162+
}
163+
164+
func (cfg *Config) Merge(override *Config) {
165+
if len(override.ApiUrl) > 0 {
166+
cfg.ApiUrl = override.ApiUrl
167+
}
168+
if len(override.Mode) > 0 {
169+
cfg.Mode = override.Mode
170+
cfg.sendMode = ModeInvalid
171+
}
172+
if len(override.ApiIdentity) > 0 {
173+
cfg.ApiIdentity = override.ApiIdentity
174+
}
175+
if len(override.Recipient) > 0 {
176+
cfg.Recipient = override.Recipient
177+
cfg.recipientType = RecipientTypeInvalid
178+
}
179+
if len(override.ApiAuthSecret) > 0 {
180+
cfg.ApiAuthSecret = override.ApiAuthSecret
181+
}
182+
}
183+
184+
type AlertProvider struct {
185+
DefaultConfig Config `yaml:",inline"`
186+
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
187+
Overrides []Override `yaml:"overrides,omitempty"`
188+
}
189+
190+
type Override struct {
191+
Group string `yaml:"group"`
192+
Config `yaml:",inline"`
193+
}
194+
195+
func (provider *AlertProvider) Validate() error {
196+
return provider.DefaultConfig.Validate()
197+
}
198+
199+
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
200+
cfg, err := provider.GetConfig(ep.Group, alert)
201+
if err != nil {
202+
return err
203+
}
204+
body := provider.buildMessageBody(ep, alert, result, resolved)
205+
request, err := provider.prepareRequest(cfg, body)
206+
if err != nil {
207+
return err
208+
}
209+
response, err := client.GetHTTPClient(nil).Do(request)
210+
if err != nil {
211+
return err
212+
}
213+
return handleResponse(cfg, response)
214+
}
215+
216+
func (provider *AlertProvider) buildMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
217+
var body string
218+
if resolved {
219+
body = fmt.Sprintf("✅ Alert resolved for endpoint '%s' after passing %d checks.", ep.Name, alert.SuccessThreshold)
220+
} else {
221+
body = fmt.Sprintf("🚨 Alert triggered for endpoint '%s' after failing %d checks.\n\nConditions:\n", ep.Name, alert.FailureThreshold)
222+
for _, conditionResult := range result.ConditionResults {
223+
var icon rune
224+
if conditionResult.Success {
225+
icon = '✓'
226+
} else {
227+
icon = '✗'
228+
}
229+
body += fmt.Sprintf("- %c %s\n", icon, conditionResult.Condition)
230+
}
231+
if len(result.Errors) > 0 {
232+
body += "\nErrors:\n"
233+
for _, err := range result.Errors {
234+
body += fmt.Sprintf("- ✗ %s\n", err)
235+
}
236+
}
237+
}
238+
return body
239+
}
240+
241+
func (provider *AlertProvider) prepareRequest(cfg *Config, body string) (*http.Request, error) {
242+
requestUrl := cfg.ApiUrl
243+
switch cfg.sendMode {
244+
case ModeBasic:
245+
requestUrl += "/send_simple"
246+
case ModeE2EE, ModeE2EEBulk:
247+
return nil, ErrNotImplementedMode // TODO#1464: implement E2EE and E2EE-Bulk modes
248+
default:
249+
return nil, ErrNotImplementedMode
250+
}
251+
252+
data := url.Values{}
253+
data.Add("from", cfg.ApiIdentity)
254+
var toKey string
255+
switch cfg.recipientType {
256+
case RecipientTypeID:
257+
toKey = "to"
258+
case RecipientTypePhone:
259+
toKey = "phone"
260+
case RecipientTypeEmail:
261+
toKey = "email"
262+
default:
263+
return nil, ErrInvalidRecipientType
264+
}
265+
data.Add(toKey, cfg.parsedRecipient)
266+
data.Add("text", body)
267+
data.Add("secret", cfg.ApiAuthSecret)
268+
269+
request, err := http.NewRequest(http.MethodPost, requestUrl, strings.NewReader(data.Encode()))
270+
if err != nil {
271+
return nil, err
272+
}
273+
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
274+
return request, nil
275+
}
276+
277+
func handleResponse(cfg *Config, response *http.Response) error {
278+
switch response.StatusCode {
279+
case http.StatusOK:
280+
switch cfg.sendMode {
281+
case ModeBasic, ModeE2EE:
282+
return nil
283+
case ModeE2EEBulk:
284+
return nil // TODO#1464: Add correct handling once mode is implemented (check success fields in response body)
285+
}
286+
case http.StatusBadRequest:
287+
switch cfg.sendMode {
288+
case ModeBasic, ModeE2EE:
289+
return fmt.Errorf("%s: Invalid recipient or Threema Account not set up for %s mode", response.Status, cfg.Mode)
290+
case ModeE2EEBulk:
291+
// TODO#1464: Add correct error info once mode is implemented
292+
}
293+
case http.StatusUnauthorized:
294+
return fmt.Errorf("%s: Invalid auth-secret or api-identity", response.Status)
295+
case http.StatusPaymentRequired:
296+
return fmt.Errorf("%s: Insufficient credits to send message", response.Status)
297+
case http.StatusNotFound:
298+
if cfg.sendMode == ModeBasic {
299+
return fmt.Errorf("%s: Recipient could not be found", response.Status)
300+
}
301+
}
302+
return fmt.Errorf("Response: %s", response.Status)
303+
}
304+
305+
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
306+
return provider.DefaultAlert
307+
}
308+
309+
// GetConfig returns the configuration for the provider with the overrides applied
310+
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
311+
cfg := provider.DefaultConfig
312+
// Handle group overrides
313+
if len(provider.Overrides) > 0 {
314+
for _, override := range provider.Overrides {
315+
if group == override.Group {
316+
cfg.Merge(&override.Config)
317+
break
318+
}
319+
}
320+
}
321+
// Handle alert overrides
322+
if len(alert.ProviderOverride) > 0 {
323+
overrideConfig := Config{}
324+
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
325+
return nil, err
326+
}
327+
cfg.Merge(&overrideConfig)
328+
}
329+
return &cfg, cfg.Validate()
330+
}
331+
332+
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
333+
_, err := provider.GetConfig(group, alert)
334+
return err
335+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
package threemagateway
2+
3+
// TODO#1468: Implement tests

config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,7 @@ func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
628628
alert.TypeTeams,
629629
alert.TypeTeamsWorkflows,
630630
alert.TypeTelegram,
631+
alert.TypeThreemaGateway,
631632
alert.TypeTwilio,
632633
alert.TypeVonage,
633634
alert.TypeWebex,

0 commit comments

Comments
 (0)