Skip to content

Commit 6a251c3

Browse files
committed
feat(notifications): add auto-detect webhook formatters for slack and discord
1 parent 557f318 commit 6a251c3

File tree

7 files changed

+515
-20
lines changed

7 files changed

+515
-20
lines changed

README.md

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -300,27 +300,17 @@ updo aws destroy --regions all
300300

301301
## Webhook Notifications
302302

303-
Updo can send webhook notifications when targets go up or down. This enables integration with various services like Slack, Discord, PagerDuty, or custom alerting systems.
303+
Updo can send webhook notifications when targets go up or down. Updo **automatically detects** Slack and Discord webhooks by URL pattern and formats messages accordingly with rich formatting. Custom webhooks receive a generic JSON payload.
304304

305-
### Webhook Payload
305+
### Supported Platforms
306306

307-
When a target status changes, Updo sends a JSON payload:
308-
309-
```json
310-
{
311-
"event": "target_down", // or "target_up"
312-
"target": "Critical API",
313-
"url": "https://api.example.com",
314-
"timestamp": "2024-01-01T12:00:00Z",
315-
"response_time_ms": 1500,
316-
"status_code": 500,
317-
"error": "Internal Server Error" // only for down events
318-
}
319-
```
307+
- **Slack** - Auto-detected via `hooks.slack.com` URL, sends rich messages with attachments and color coding
308+
- **Discord** - Auto-detected via `discord.com/api/webhooks` URL, sends embeds with color and structured fields
309+
- **Custom** - Any other webhook URL receives generic JSON format
320310

321311
### Integration Examples
322312

323-
**Slack Webhook:**
313+
**Slack Webhook (Auto-Detected):**
324314

325315
```toml
326316
[[targets]]
@@ -329,7 +319,41 @@ name = "Production API"
329319
webhook_url = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
330320
```
331321

332-
**Custom Webhook with Headers:**
322+
Updo automatically formats Slack messages with:
323+
- Color-coded attachments (red for down, green for up)
324+
- Emoji indicators (🔴 for down, ✅ for up)
325+
- Structured fields for URL, error, status code, response time, and timestamp
326+
327+
**Discord Webhook (Auto-Detected):**
328+
329+
```toml
330+
[[targets]]
331+
url = "https://api.example.com"
332+
name = "Production API"
333+
webhook_url = "https://discord.com/api/webhooks/123456789/YOUR_WEBHOOK_TOKEN"
334+
```
335+
336+
Updo automatically formats Discord messages with:
337+
- Color-coded embeds (red for down, green for up)
338+
- Emoji indicators (🔴 for down, ✅ for up)
339+
- Structured fields with inline formatting
340+
- Clickable URL links
341+
342+
**Custom Webhook:**
343+
344+
For custom webhooks, Updo sends a generic JSON payload:
345+
346+
```json
347+
{
348+
"event": "target_down",
349+
"target": "Production API",
350+
"url": "https://api.example.com",
351+
"timestamp": "2024-01-01T12:00:00Z",
352+
"response_time_ms": 1500,
353+
"status_code": 500,
354+
"error": "Internal Server Error"
355+
}
356+
```
333357

334358
```toml
335359
[[targets]]

notifications/formatter.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package notifications
2+
3+
import (
4+
"strings"
5+
)
6+
7+
type WebhookFormatter interface {
8+
Format(payload WebhookPayload) ([]byte, error)
9+
}
10+
11+
func SelectFormatter(url string) WebhookFormatter {
12+
lowerURL := strings.ToLower(url)
13+
14+
if strings.Contains(lowerURL, "hooks.slack.com") {
15+
return &SlackFormatter{}
16+
}
17+
18+
if strings.Contains(lowerURL, "discord.com/api/webhooks") {
19+
return &DiscordFormatter{}
20+
}
21+
22+
return &GenericFormatter{}
23+
}

notifications/formatter_discord.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package notifications
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"time"
7+
)
8+
9+
const (
10+
_discordColorRed = 15158332
11+
_discordColorGreen = 3066993
12+
)
13+
14+
type discordMessage struct {
15+
Content string `json:"content"`
16+
Embeds []discordEmbed `json:"embeds,omitempty"`
17+
}
18+
19+
type discordEmbed struct {
20+
Title string `json:"title"`
21+
URL string `json:"url,omitempty"`
22+
Color int `json:"color"`
23+
Fields []discordField `json:"fields,omitempty"`
24+
Timestamp time.Time `json:"timestamp"`
25+
}
26+
27+
type discordField struct {
28+
Name string `json:"name"`
29+
Value string `json:"value"`
30+
Inline bool `json:"inline,omitempty"`
31+
}
32+
33+
type DiscordFormatter struct{}
34+
35+
func (f *DiscordFormatter) Format(payload WebhookPayload) ([]byte, error) {
36+
symbol := _symbolDown
37+
color := _discordColorRed
38+
if payload.Event == _eventTargetUp {
39+
symbol = _symbolUp
40+
color = _discordColorGreen
41+
}
42+
43+
content := fmt.Sprintf("%s %s", symbol, payload.Event)
44+
45+
var fields []discordField
46+
47+
if payload.Error != "" {
48+
fields = append(fields, discordField{
49+
Name: "Error",
50+
Value: payload.Error,
51+
})
52+
}
53+
54+
if payload.StatusCode > 0 {
55+
fields = append(fields, discordField{
56+
Name: "Status Code",
57+
Value: fmt.Sprintf("%d", payload.StatusCode),
58+
Inline: true,
59+
})
60+
}
61+
62+
fields = append(fields, discordField{
63+
Name: "Response Time",
64+
Value: fmt.Sprintf("%dms", payload.ResponseTimeMs),
65+
Inline: true,
66+
})
67+
68+
msg := discordMessage{
69+
Content: content,
70+
Embeds: []discordEmbed{
71+
{
72+
Title: payload.Target,
73+
URL: payload.URL,
74+
Color: color,
75+
Fields: fields,
76+
Timestamp: payload.Timestamp,
77+
},
78+
},
79+
}
80+
81+
data, err := json.Marshal(msg)
82+
if err != nil {
83+
return nil, fmt.Errorf("failed to marshal Discord webhook payload: %w", err)
84+
}
85+
86+
return data, nil
87+
}

notifications/formatter_generic.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package notifications
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
type GenericFormatter struct{}
9+
10+
func (f *GenericFormatter) Format(payload WebhookPayload) ([]byte, error) {
11+
data, err := json.Marshal(payload)
12+
if err != nil {
13+
return nil, fmt.Errorf("failed to marshal generic webhook payload: %w", err)
14+
}
15+
return data, nil
16+
}

notifications/formatter_slack.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package notifications
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
)
7+
8+
const (
9+
_eventTargetDown = "target_down"
10+
_eventTargetUp = "target_up"
11+
_colorDanger = "danger"
12+
_colorGood = "good"
13+
_symbolDown = "✘"
14+
_symbolUp = "✔"
15+
)
16+
17+
type slackMessage struct {
18+
Text string `json:"text"`
19+
Attachments []slackAttachment `json:"attachments,omitempty"`
20+
}
21+
22+
type slackAttachment struct {
23+
Color string `json:"color"`
24+
Fields []slackField `json:"fields,omitempty"`
25+
}
26+
27+
type slackField struct {
28+
Title string `json:"title"`
29+
Value string `json:"value"`
30+
Short bool `json:"short"`
31+
}
32+
33+
type SlackFormatter struct{}
34+
35+
func (f *SlackFormatter) Format(payload WebhookPayload) ([]byte, error) {
36+
symbol := _symbolDown
37+
color := _colorDanger
38+
if payload.Event == _eventTargetUp {
39+
symbol = _symbolUp
40+
color = _colorGood
41+
}
42+
43+
text := fmt.Sprintf("%s %s: %s", symbol, payload.Event, payload.Target)
44+
45+
var fields []slackField
46+
47+
fields = append(fields, slackField{
48+
Title: "URL",
49+
Value: payload.URL,
50+
})
51+
52+
if payload.Error != "" {
53+
fields = append(fields, slackField{
54+
Title: "Error",
55+
Value: payload.Error,
56+
})
57+
}
58+
59+
if payload.StatusCode > 0 {
60+
fields = append(fields, slackField{
61+
Title: "Status Code",
62+
Value: fmt.Sprintf("%d", payload.StatusCode),
63+
Short: true,
64+
})
65+
}
66+
67+
fields = append(fields, slackField{
68+
Title: "Response Time",
69+
Value: fmt.Sprintf("%dms", payload.ResponseTimeMs),
70+
Short: true,
71+
})
72+
73+
fields = append(fields, slackField{
74+
Title: "Timestamp",
75+
Value: payload.Timestamp.Format("2006-01-02 15:04:05 UTC"),
76+
})
77+
78+
msg := slackMessage{
79+
Text: text,
80+
Attachments: []slackAttachment{
81+
{
82+
Color: color,
83+
Fields: fields,
84+
},
85+
},
86+
}
87+
88+
data, err := json.Marshal(msg)
89+
if err != nil {
90+
return nil, fmt.Errorf("failed to marshal Slack webhook payload: %w", err)
91+
}
92+
93+
return data, nil
94+
}

0 commit comments

Comments
 (0)