- SSE subscription to ntfy with auto-reconnect - Discord webhook integration with embed formatting - Priority to color mapping, tag to emoji conversion - Native HashiCorp Vault support (Kubernetes + token auth) - Hot reload secrets via fsnotify or Vault polling - Prometheus metrics (/metrics endpoint) - Health/ready endpoints for Kubernetes probes - Comprehensive unit tests and fuzz tests - Multi-stage Docker build (~10MB scratch image) - CI/CD pipeline for Gitea Actions
204 lines
4.6 KiB
Go
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 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 ""
|
|
}
|