All checks were successful
CI / changes (push) Successful in 19s
CI / test (push) Successful in 31s
CI / build-ai-gateway (push) Successful in 1m8s
CI / build-ha-gateway (push) Successful in 1m19s
CI / build-discord-bot (push) Successful in 59s
CI / build-alexa-bridge (push) Successful in 1m8s
CI / build-alert-bridge (push) Successful in 1m17s
CI / build-tts-gateway (push) Successful in 1m10s
CI / build-tts-sidecar (push) Has been skipped
CI / build-tts-model (push) Has been skipped
New standalone service that receives authenticated HTTP POSTs (e.g. from a cronjob elsewhere on the home network) and posts them to a Discord channel via an Incoming Webhook. Fire-and-forget, no dependency on discord-bot's bot session at all - mirrors alexa-bridge's precedent of a small bridge service with its own HTTP listener and auth rather than adding an inbound edge to an existing internal service. Wires alert-bridge into go.work, CI (changes filter, test job, new build-alert-bridge job), and the other five Dockerfiles' COPY lines. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
109 lines
2.9 KiB
Go
109 lines
2.9 KiB
Go
package discordwebhook
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"gitea.nik4nao.com/nik/home-services/alert-bridge/internal/core/domain"
|
|
)
|
|
|
|
// Discord embed colors (decimal RGB), matching Alert severity.
|
|
const (
|
|
colorInfo = 3447003 // blue
|
|
colorWarn = 15844367 // yellow
|
|
colorError = 15158332 // red
|
|
)
|
|
|
|
// Client posts Alerts to a single Discord Incoming Webhook.
|
|
type Client struct {
|
|
webhookURL string
|
|
mentionUserID string
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// New constructs a Discord webhook Notifier. mentionUserID may be empty, in
|
|
// which case level=error alerts are posted without a mention.
|
|
func New(webhookURL, mentionUserID string) *Client {
|
|
return &Client{
|
|
webhookURL: webhookURL,
|
|
mentionUserID: mentionUserID,
|
|
httpClient: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
type embed struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Color int `json:"color"`
|
|
}
|
|
|
|
// allowedMentions is spelled out explicitly (rather than relying on Discord's
|
|
// default) so a mention only ever pings the specific configured user, never
|
|
// @everyone/@here/roles even if alert.Message text happens to contain them.
|
|
type allowedMentions struct {
|
|
Parse []string `json:"parse"`
|
|
Users []string `json:"users"`
|
|
}
|
|
|
|
type webhookPayload struct {
|
|
Content string `json:"content,omitempty"`
|
|
Embeds []embed `json:"embeds"`
|
|
AllowedMentions allowedMentions `json:"allowed_mentions"`
|
|
}
|
|
|
|
// Notify posts alert to the configured Discord webhook. level=error alerts
|
|
// mention mentionUserID (if configured) in the message content — Discord
|
|
// embeds alone never trigger a ping, only plain message content can.
|
|
func (c *Client) Notify(ctx context.Context, alert domain.Alert) error {
|
|
payload := webhookPayload{
|
|
Embeds: []embed{{
|
|
Title: fmt.Sprintf("[%s]", alert.Source),
|
|
Description: alert.Message,
|
|
Color: colorFor(alert.Level),
|
|
}},
|
|
AllowedMentions: allowedMentions{Parse: []string{}, Users: []string{}},
|
|
}
|
|
|
|
if alert.Level == domain.LevelError && c.mentionUserID != "" {
|
|
payload.Content = fmt.Sprintf("<@%s>", c.mentionUserID)
|
|
payload.AllowedMentions.Users = []string{c.mentionUserID}
|
|
}
|
|
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal webhook payload: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, bytes.NewReader(body))
|
|
if err != nil {
|
|
return fmt.Errorf("build webhook request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := c.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("send webhook request: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 300 {
|
|
return fmt.Errorf("discord webhook returned status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func colorFor(level domain.Level) int {
|
|
switch level {
|
|
case domain.LevelWarn:
|
|
return colorWarn
|
|
case domain.LevelError:
|
|
return colorError
|
|
default:
|
|
return colorInfo
|
|
}
|
|
}
|