Nik Afiq 39b7d6d4b4
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
feat: add alert-bridge service for external cronjob -> Discord alerts
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>
2026-08-01 22:45:18 +09:00

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