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 } }