feat: implement ntfy-discord bridge in Go
Some checks failed
Build and Push / build (push) Failing after 4m36s
Build and Push / test (push) Has been cancelled

- 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
This commit is contained in:
2026-02-02 18:13:55 -05:00
parent b325d9bfec
commit f97ad0e7cb
22 changed files with 2678 additions and 0 deletions

137
internal/ntfy/client.go Normal file
View File

@@ -0,0 +1,137 @@
package ntfy
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
)
// Message represents an ntfy message
type Message struct {
ID string `json:"id"`
Time int64 `json:"time"`
Expires int64 `json:"expires,omitempty"`
Event string `json:"event"`
Topic string `json:"topic"`
Title string `json:"title,omitempty"`
Message string `json:"message,omitempty"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Icon string `json:"icon,omitempty"`
}
// Client subscribes to ntfy topics via SSE
type Client struct {
baseURL string
topics []string
client *http.Client
}
// NewClient creates a new ntfy SSE client
func NewClient(baseURL string, topics []string) *Client {
return &Client{
baseURL: strings.TrimSuffix(baseURL, "/"),
topics: topics,
client: &http.Client{
Timeout: 0, // No timeout for SSE
},
}
}
// Subscribe connects to ntfy and streams messages to the channel
// It automatically reconnects with exponential backoff on failure
func (c *Client) Subscribe(ctx context.Context, msgCh chan<- Message) {
backoff := time.Second
maxBackoff := time.Minute
for {
select {
case <-ctx.Done():
return
default:
}
err := c.connect(ctx, msgCh)
if err != nil {
if ctx.Err() != nil {
return // Context cancelled
}
slog.Error("ntfy connection failed", "error", err, "backoff", backoff)
time.Sleep(backoff)
backoff = min(backoff*2, maxBackoff)
continue
}
// Reset backoff on successful connection
backoff = time.Second
}
}
func (c *Client) connect(ctx context.Context, msgCh chan<- Message) error {
// Build URL with all topics
topicPath := strings.Join(c.topics, ",")
url := fmt.Sprintf("%s/%s/json", c.baseURL, topicPath)
slog.Info("connecting to ntfy", "url", url)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("connect: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
slog.Info("connected to ntfy", "topics", c.topics)
// Read line by line (ntfy sends newline-delimited JSON)
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var msg Message
if err := json.Unmarshal([]byte(line), &msg); err != nil {
slog.Warn("failed to parse ntfy message", "error", err, "line", line)
continue
}
// Skip keepalive/open events
if msg.Event == "keepalive" || msg.Event == "open" {
slog.Debug("ntfy event", "event", msg.Event)
continue
}
if msg.Event == "message" {
slog.Debug("received ntfy message",
"id", msg.ID,
"topic", msg.Topic,
"title", msg.Title,
)
msgCh <- msg
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read stream: %w", err)
}
return fmt.Errorf("stream closed")
}

View File

@@ -0,0 +1,227 @@
package ntfy
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNewClient(t *testing.T) {
client := NewClient("http://ntfy.example.com", []string{"alerts", "updates"})
if client.baseURL != "http://ntfy.example.com" {
t.Errorf("baseURL = %s, want http://ntfy.example.com", client.baseURL)
}
if len(client.topics) != 2 {
t.Errorf("topics length = %d, want 2", len(client.topics))
}
}
func TestNewClient_TrimsTrailingSlash(t *testing.T) {
client := NewClient("http://ntfy.example.com/", []string{"test"})
if client.baseURL != "http://ntfy.example.com" {
t.Errorf("baseURL = %s, want http://ntfy.example.com (trailing slash removed)", client.baseURL)
}
}
func TestMessage_JSON(t *testing.T) {
msg := Message{
ID: "test-123",
Time: 1706803200,
Event: "message",
Topic: "alerts",
Title: "Test Alert",
Message: "This is a test",
Priority: 4,
Tags: []string{"warning", "fire"},
Click: "https://example.com",
}
data, err := json.Marshal(msg)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded Message
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
if decoded.ID != msg.ID {
t.Errorf("ID = %s, want %s", decoded.ID, msg.ID)
}
if decoded.Priority != msg.Priority {
t.Errorf("Priority = %d, want %d", decoded.Priority, msg.Priority)
}
if len(decoded.Tags) != 2 {
t.Errorf("Tags length = %d, want 2", len(decoded.Tags))
}
}
func TestClient_Subscribe_ReceivesMessages(t *testing.T) {
messages := []Message{
{Event: "open"},
{Event: "message", ID: "msg1", Topic: "test", Title: "First", Message: "Hello"},
{Event: "keepalive"},
{Event: "message", ID: "msg2", Topic: "test", Title: "Second", Message: "World"},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
for _, msg := range messages {
data, _ := json.Marshal(msg)
fmt.Fprintf(w, "%s\n", data)
}
}))
defer server.Close()
client := NewClient(server.URL, []string{"test"})
msgCh := make(chan Message, 10)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go client.Subscribe(ctx, msgCh)
// Should receive only "message" events
received := make([]Message, 0)
timeout := time.After(1 * time.Second)
loop:
for {
select {
case msg := <-msgCh:
received = append(received, msg)
if len(received) >= 2 {
break loop
}
case <-timeout:
break loop
}
}
if len(received) != 2 {
t.Errorf("received %d messages, want 2", len(received))
}
if len(received) > 0 && received[0].ID != "msg1" {
t.Errorf("first message ID = %s, want msg1", received[0].ID)
}
if len(received) > 1 && received[1].ID != "msg2" {
t.Errorf("second message ID = %s, want msg2", received[1].ID)
}
}
func TestClient_Subscribe_FilterEvents(t *testing.T) {
messages := []Message{
{Event: "open"},
{Event: "keepalive"},
{Event: "message", ID: "actual", Topic: "test"},
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
for _, msg := range messages {
data, _ := json.Marshal(msg)
fmt.Fprintf(w, "%s\n", data)
}
}))
defer server.Close()
client := NewClient(server.URL, []string{"test"})
msgCh := make(chan Message, 10)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
go client.Subscribe(ctx, msgCh)
select {
case msg := <-msgCh:
if msg.Event != "message" {
t.Errorf("received event = %s, want message", msg.Event)
}
if msg.ID != "actual" {
t.Errorf("message ID = %s, want actual", msg.ID)
}
case <-time.After(500 * time.Millisecond):
t.Error("timeout waiting for message")
}
}
func TestClient_Subscribe_ContextCancellation(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Slow server - keeps connection open
time.Sleep(10 * time.Second)
}))
defer server.Close()
client := NewClient(server.URL, []string{"test"})
msgCh := make(chan Message, 10)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
client.Subscribe(ctx, msgCh)
close(done)
}()
// Cancel quickly
time.Sleep(50 * time.Millisecond)
cancel()
// Should exit promptly
select {
case <-done:
// Success
case <-time.After(2 * time.Second):
t.Error("Subscribe did not exit after context cancellation")
}
}
func TestClient_connect_ServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal error"))
}))
defer server.Close()
client := NewClient(server.URL, []string{"test"})
msgCh := make(chan Message, 10)
err := client.connect(context.Background(), msgCh)
if err == nil {
t.Error("expected error for server error response")
}
}
func TestClient_connect_URLConstruction(t *testing.T) {
var requestedURL string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedURL = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := NewClient(server.URL, []string{"alerts", "updates"})
client.connect(context.Background(), make(chan Message))
expected := "/alerts,updates/json"
if requestedURL != expected {
t.Errorf("URL path = %s, want %s", requestedURL, expected)
}
}

View File

@@ -0,0 +1,58 @@
package ntfy
import (
"encoding/json"
"testing"
)
// FuzzParseMessage tests that JSON unmarshaling doesn't panic on arbitrary input
func FuzzParseMessage(f *testing.F) {
// Seed corpus with valid messages
f.Add(`{"event":"message","topic":"test","title":"hello","message":"world"}`)
f.Add(`{"event":"message","topic":"alerts","priority":5,"tags":["warning"]}`)
f.Add(`{"event":"keepalive"}`)
f.Add(`{"event":"open"}`)
f.Add(`{}`)
f.Add(`{"id":"abc123","time":1706803200,"expires":1706889600}`)
f.Add(`{"click":"https://example.com","icon":"https://example.com/icon.png"}`)
// Edge cases
f.Add(``)
f.Add(`null`)
f.Add(`[]`)
f.Add(`"string"`)
f.Add(`123`)
f.Add(`{"priority":-1}`)
f.Add(`{"priority":999999999}`)
f.Add(`{"tags":[]}`)
f.Add(`{"tags":["","",""]}`)
f.Fuzz(func(t *testing.T, data string) {
var msg Message
// Should never panic regardless of input
_ = json.Unmarshal([]byte(data), &msg)
// If it parsed, accessing fields should not panic
_ = msg.ID
_ = msg.Event
_ = msg.Topic
_ = msg.Title
_ = msg.Message
_ = msg.Priority
_ = msg.Click
_ = len(msg.Tags)
})
}
// FuzzParseMessageBytes tests binary input doesn't cause panics
func FuzzParseMessageBytes(f *testing.F) {
f.Add([]byte(`{"event":"message"}`))
f.Add([]byte{0x00})
f.Add([]byte{0xff, 0xfe})
f.Add([]byte("\xef\xbb\xbf{}")) // BOM + JSON
f.Fuzz(func(t *testing.T, data []byte) {
var msg Message
_ = json.Unmarshal(data, &msg)
})
}