Files
sanity/internal/discord/discord.go
todor ac836d0b67
All checks were successful
Build sanity / build (push) Successful in 11s
init
2026-05-31 18:00:20 +02:00

171 lines
4.9 KiB
Go

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("<t:%d:f>", 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
}