package discord import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" "time" "heartbeat/internal/alerts" "heartbeat/internal/metrics" ) type Client struct { webhookURL string httpClient *http.Client serverName string notifyRoleID string } type webhookPayload struct { Content string `json:"content,omitempty"` AllowedMentions allowedMentions `json:"allowed_mentions,omitempty"` Embeds []embed `json:"embeds"` } type allowedMentions struct { Roles []string `json:"roles,omitempty"` } type embed struct { Title string `json:"title"` Description string `json:"description,omitempty"` Color int `json:"color"` Timestamp string `json:"timestamp,omitempty"` Fields []embedField `json:"fields,omitempty"` Footer *embedFooter `json:"footer,omitempty"` } type embedField struct { Name string `json:"name"` Value string `json:"value"` Inline bool `json:"inline"` } type embedFooter struct { Text string `json:"text"` } func New(serverName string, webhookURL string, notifyRoleID string, timeout time.Duration) *Client { return &Client{ serverName: serverName, webhookURL: webhookURL, httpClient: &http.Client{Timeout: timeout}, notifyRoleID: notifyRoleID, } } func (c *Client) SendSummary(ctx context.Context, sample metrics.Sample, summaryInterval time.Duration) error { fields := []embedField{ {Name: "Server", Value: c.serverName, Inline: true}, {Name: "Generated", Value: formatSummaryTime(sample.Timestamp), Inline: true}, {Name: "Uptime", Value: formatUptime(sample.UptimeSeconds), Inline: true}, {Name: "CPU", Value: fmt.Sprintf("Now: %.1f%%\n15m: %.1f%%\n12h: %.1f%%", sample.CPUCurrentPercent, sample.CPUAvg15mPercent, sample.CPUAvg12hPercent), Inline: true}, {Name: "Load", Value: fmt.Sprintf("1m: %.2f\n5m: %.2f\n15m/core: %.2f", sample.Load1, sample.Load5, sample.LoadPerCore), Inline: true}, {Name: "Memory", Value: fmt.Sprintf("RAM: %.1f%%\nSwap: %.1f%%", sample.MemoryUsedPercent, sample.SwapUsedPercent), Inline: true}, {Name: "Disk /", Value: fmt.Sprintf("Used: %.1f%%\nFree: %.1f GB\nInodes: %.1f%%", sample.RootUsedPercent, sample.RootFreeGB, sample.InodeUsedPercent), Inline: true}, {Name: "Network", Value: fmt.Sprintf("RX: %s\nTX: %s", formatRate(sample.RXBytesPerSecond), formatRate(sample.TXBytesPerSecond)), Inline: true}, {Name: "Processes", Value: fmt.Sprintf("Count: %d", sample.ProcessCount), Inline: true}, {Name: "Sites", Value: formatSiteStatuses(sample.Sites), Inline: false}, } return c.send(ctx, webhookPayload{Embeds: []embed{{ Title: fmt.Sprintf("heartbeat (%s) - %s", formatSummaryIntervalHours(summaryInterval), c.serverName), Description: "Scheduled server health snapshot.", Color: 0x2D9CDB, Fields: fields, Footer: &embedFooter{Text: formatSummaryFooter(c.serverName)}, }}}) } func (c *Client) SendEvent(ctx context.Context, sample metrics.Sample, event alerts.Event) error { fields := []embedField{ {Name: "Server", Value: c.serverName, Inline: true}, {Name: "Severity", Value: string(event.Severity), Inline: true}, {Name: "Timestamp", Value: formatEventTime(sample.Timestamp), Inline: true}, } payload := webhookPayload{Embeds: []embed{{ Title: event.Title, Description: event.Body, Color: colorForSeverity(event.Severity), Timestamp: sample.Timestamp.Format(time.RFC3339), Fields: fields, }}} if c.notifyRoleID != "" && (event.Severity == alerts.SeverityWarning || event.Severity == alerts.SeverityCritical) { payload.Content = fmt.Sprintf("<@&%s>", c.notifyRoleID) payload.AllowedMentions = allowedMentions{Roles: []string{c.notifyRoleID}} } return c.send(ctx, payload) } func formatUptime(totalSeconds uint64) string { duration := time.Duration(totalSeconds) * time.Second days := duration / (24 * time.Hour) duration -= days * 24 * time.Hour hours := duration / time.Hour duration -= hours * time.Hour minutes := duration / time.Minute if days > 0 { return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) } return fmt.Sprintf("%dh %dm", hours, minutes) } func formatSiteStatuses(sites []metrics.SiteStatus) string { if len(sites) == 0 { return "No site checks configured" } parts := make([]string, 0, len(sites)) for _, site := range sites { status := "UP" detail := fmt.Sprintf("%d in %s", site.StatusCode, site.Latency.Round(time.Millisecond)) if !site.Healthy { status = "DOWN" detail = site.ErrorMessage } parts = append(parts, fmt.Sprintf("%s: %s (%s)", site.Name, status, detail)) } return strings.Join(parts, "\n") } func formatSummaryTime(timestamp time.Time) string { unixSeconds := timestamp.Unix() return fmt.Sprintf("", unixSeconds) } func formatEventTime(timestamp time.Time) string { unixSeconds := timestamp.Unix() return fmt.Sprintf(" - ", unixSeconds, unixSeconds) } func formatSummaryFooter(serverName string) string { return fmt.Sprintf("heartbeat - %s", serverName) } func formatSummaryIntervalHours(interval time.Duration) string { if interval%time.Hour == 0 { return fmt.Sprintf("%dh", interval/time.Hour) } return fmt.Sprintf("%.2fh", interval.Hours()) } func formatRate(bytesPerSecond float64) string { if bytesPerSecond < 0 { return "n/a" } if bytesPerSecond < 1024 { return fmt.Sprintf("%.0f B/s", bytesPerSecond) } kib := bytesPerSecond / 1024 if kib < 1024 { return fmt.Sprintf("%.2f KB/s", kib) } mib := kib / 1024 return fmt.Sprintf("%.2f MB/s", mib) } func (c *Client) send(ctx context.Context, payload webhookPayload) error { buffer := &bytes.Buffer{} if err := json.NewEncoder(buffer).Encode(payload); err != nil { return err } req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.webhookURL, buffer) if err != nil { return err } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("discord webhook returned %s", resp.Status) } return nil } func colorForSeverity(severity alerts.Severity) int { switch severity { case alerts.SeverityCritical: return 0xE74C3C case alerts.SeverityWarning: return 0xF39C12 default: return 0x27AE60 } }