package discord import ( "bytes" "context" "encoding/json" "fmt" "net/http" "time" "sanity/internal/backup" ) 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) SendSuccess(ctx context.Context, result backup.Result, interval time.Duration) error { fields := []embedField{ {Name: "Server", Value: c.serverName, Inline: true}, {Name: "Source", Value: result.SourceFile, Inline: false}, {Name: "Artifact", Value: result.ArtifactPath, Inline: false}, {Name: "Artifact size", Value: formatBytes(result.SizeBytes), Inline: true}, {Name: "Backup dir total", Value: formatOptionalBytes(result.TotalBackupDirSizeBytes), Inline: true}, {Name: "Duration", Value: result.Duration.Round(time.Millisecond).String(), Inline: true}, {Name: "Next run", Value: formatSummaryInterval(interval), Inline: true}, {Name: "Timestamp", Value: formatDiscordTime(result.Timestamp), Inline: true}, } return c.send(ctx, webhookPayload{Embeds: []embed{{ Title: fmt.Sprintf("sanity backup successful - %s", c.serverName), Description: "A scheduled backup completed successfully.", Color: 0x27AE60, Fields: fields, Footer: &embedFooter{Text: fmt.Sprintf("sanity - %s", c.serverName)}, }}}) } func (c *Client) SendFailure(ctx context.Context, timestamp time.Time, reason string, sourceFile string, outputDir string, totalBackupDirSizeBytes int64) error { fields := []embedField{ {Name: "Server", Value: c.serverName, Inline: true}, {Name: "Source", Value: sourceFile, Inline: false}, {Name: "Output dir", Value: outputDir, Inline: false}, {Name: "Backup dir total", Value: formatOptionalBytes(totalBackupDirSizeBytes), Inline: true}, {Name: "Timestamp", Value: formatDiscordTime(timestamp), Inline: true}, {Name: "Reason", Value: reason, Inline: false}, } payload := webhookPayload{Embeds: []embed{{ Title: fmt.Sprintf("sanity backup failed - %s", c.serverName), Description: "A scheduled backup run failed.", Color: 0xE74C3C, Timestamp: timestamp.Format(time.RFC3339), Fields: fields, }}} if c.notifyRoleID != "" { payload.Content = fmt.Sprintf("<@&%s>", c.notifyRoleID) payload.AllowedMentions = allowedMentions{Roles: []string{c.notifyRoleID}} } return c.send(ctx, payload) } func formatDiscordTime(timestamp time.Time) string { unixSeconds := timestamp.Unix() return fmt.Sprintf("", unixSeconds) } func formatSummaryInterval(interval time.Duration) string { if interval%time.Hour == 0 { return fmt.Sprintf("%dh", interval/time.Hour) } if interval%time.Minute == 0 { return fmt.Sprintf("%dm", interval/time.Minute) } return interval.String() } func formatOptionalBytes(sizeBytes int64) string { if sizeBytes < 0 { return "unknown" } return formatBytes(sizeBytes) } func formatBytes(sizeBytes int64) string { const ( kib = 1024 mib = 1024 * 1024 gib = 1024 * 1024 * 1024 tib = 1024 * 1024 * 1024 * 1024 ) if sizeBytes <= 0 { return "0 B" } if sizeBytes < kib { return fmt.Sprintf("%d B", sizeBytes) } if sizeBytes < mib { return fmt.Sprintf("%.2f KB", float64(sizeBytes)/kib) } if sizeBytes < gib { return fmt.Sprintf("%.2f MB", float64(sizeBytes)/mib) } if sizeBytes < tib { return fmt.Sprintf("%.2f GB", float64(sizeBytes)/gib) } return fmt.Sprintf("%.2f TB", float64(sizeBytes)/tib) } 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 }