package notify import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // DiscordNotifier posts to a Discord incoming webhook. Body is rendered // as a single embed so Discord shows a colored sidebar matching event // severity. Discord rejects empty content+embeds; we always include the // embed so that never happens. type DiscordNotifier struct { NameStr string WebhookURL string HTTP *http.Client } func NewDiscord(name, webhookURL string) *DiscordNotifier { return &DiscordNotifier{ NameStr: name, WebhookURL: webhookURL, HTTP: &http.Client{Timeout: 10 * time.Second}, } } func (d *DiscordNotifier) Name() string { return d.NameStr } type discordPayload struct { Embeds []discordEmbed `json:"embeds"` } type discordEmbed struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` URL string `json:"url,omitempty"` Color int `json:"color,omitempty"` } func (d *DiscordNotifier) Send(ctx context.Context, ev Event) error { if d.WebhookURL == "" { return fmt.Errorf("discord: no webhook_url configured") } payload := discordPayload{Embeds: []discordEmbed{{ Title: ev.Title, Description: ev.Body, URL: ev.URL, Color: discordColor(ev.Severity), }}} buf, err := json.Marshal(payload) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.WebhookURL, bytes.NewReader(buf)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := d.HTTP.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode >= 300 { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("discord: %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } return nil } // discordColor returns the embed sidebar color for each severity. // Values are standard Discord decimal color codes. func discordColor(s Severity) int { switch s { case SeverityCritical: return 0xE74C3C // red case SeverityWarning: return 0xF1C40F // yellow default: return 0x2ECC71 // green } }