package discord import ( "bytes" "context" "encoding/json" "fmt" "io" "log/slog" "net/http" "strconv" "strings" "time" "git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy" ) // Priority to Discord embed color mapping var priorityColors = map[int]int{ 5: 15158332, // Red - Max/Urgent 4: 15105570, // Orange - High 3: 3066993, // Blue - Default 2: 9807270, // Gray - Low 1: 12370112, // Light Gray - Min } // Tag to emoji mapping var tagEmojis = map[string]string{ "white_check_mark": "✅", "heavy_check_mark": "✅", "check": "✅", "x": "❌", "skull": "❌", "warning": "⚠️", "rotating_light": "🚨", "rocket": "🚀", "package": "📦", "tada": "🎉", "fire": "🔥", "bug": "🐛", "wrench": "🔧", "gear": "⚙️", "lock": "🔒", "key": "🔑", "bell": "🔔", "mega": "📢", "eyes": "👀", "sos": "🆘", "no_entry": "⛔", "construction": "🚧", } // Embed represents a Discord embed type Embed struct { Title string `json:"title,omitempty"` Description string `json:"description,omitempty"` Color int `json:"color,omitempty"` Fields []Field `json:"fields,omitempty"` Timestamp string `json:"timestamp,omitempty"` Footer *Footer `json:"footer,omitempty"` } // Field represents a Discord embed field type Field struct { Name string `json:"name"` Value string `json:"value"` Inline bool `json:"inline,omitempty"` } // Footer represents a Discord embed footer type Footer struct { Text string `json:"text"` } // WebhookPayload is the Discord webhook request body type WebhookPayload struct { Embeds []Embed `json:"embeds"` } // Client sends messages to Discord webhooks type Client struct { httpClient *http.Client } // NewClient creates a new Discord webhook client func NewClient() *Client { return &Client{ httpClient: &http.Client{ Timeout: 30 * time.Second, }, } } // Send converts an ntfy message to Discord format and sends it func (c *Client) Send(ctx context.Context, webhookURL string, msg ntfy.Message) error { if webhookURL == "" { return fmt.Errorf("no webhook URL configured") } embed := c.buildEmbed(msg) payload := WebhookPayload{ Embeds: []Embed{embed}, } body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal payload: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return fmt.Errorf("send request: %w", err) } defer resp.Body.Close() // Handle rate limiting if resp.StatusCode == http.StatusTooManyRequests { retryAfter := resp.Header.Get("Retry-After") if retryAfter != "" { if seconds, err := strconv.Atoi(retryAfter); err == nil { slog.Warn("Discord rate limited", "retry_after", seconds) time.Sleep(time.Duration(seconds) * time.Second) return c.Send(ctx, webhookURL, msg) // Retry } } return fmt.Errorf("rate limited") } if resp.StatusCode < 200 || resp.StatusCode >= 300 { respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody)) } return nil } func (c *Client) buildEmbed(msg ntfy.Message) Embed { // Get color from priority color := priorityColors[3] // Default if msg.Priority >= 1 && msg.Priority <= 5 { color = priorityColors[msg.Priority] } // Build title with emoji prefix title := msg.Title if title == "" { title = msg.Topic } emoji := c.extractEmoji(msg.Tags) if emoji != "" { title = emoji + " " + title } // Build timestamp timestamp := "" if msg.Time > 0 { timestamp = time.Unix(msg.Time, 0).UTC().Format(time.RFC3339) } embed := Embed{ Title: title, Description: msg.Message, Color: color, Timestamp: timestamp, Footer: &Footer{Text: "ntfy"}, } // Add topic as field if msg.Topic != "" { embed.Fields = append(embed.Fields, Field{ Name: "Topic", Value: msg.Topic, Inline: true, }) } // Add click URL if present if msg.Click != "" { embed.Fields = append(embed.Fields, Field{ Name: "Link", Value: msg.Click, Inline: false, }) } return embed } func (c *Client) extractEmoji(tags []string) string { for _, tag := range tags { tag = strings.ToLower(tag) if emoji, ok := tagEmojis[tag]; ok { return emoji } } return "" }