Files
ntfy-discord/internal/discord/webhook.go
Billy D. 1c1a9cc35f
Some checks failed
CI / Lint (push) Successful in 58s
CI / Test (push) Successful in 1m15s
CI / Release (push) Successful in 6s
CI / Docker Build & Push (push) Failing after 23s
CI / Notify (push) Successful in 1s
fix: add golangci-lint config and fix all lint errors
- Add .golangci.yml with v2 config (errcheck, govet, staticcheck, misspell, etc.)
- Fix 32 errcheck issues across config, discord, ntfy, server packages
- Fix misspelling: cancelled → canceled
- Fix staticcheck: use append(slice...) instead of loop
- Fix staticcheck: remove empty error branch
- Use t.Setenv instead of os.Setenv/Unsetenv in tests
- Update CI workflow: add lint job, release tagging, ntfy notifications
2026-02-14 09:15:01 -05:00

204 lines
4.6 KiB
Go

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 func() { _ = 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 ""
}