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

116
internal/bridge/bridge.go Normal file
View File

@@ -0,0 +1,116 @@
package bridge
import (
"context"
"log/slog"
"sync/atomic"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/config"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/discord"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
var (
messagesReceived = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_discord_messages_received_total",
Help: "Total number of messages received from ntfy",
}, []string{"topic"})
messagesSent = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_discord_messages_sent_total",
Help: "Total number of messages sent to Discord",
}, []string{"topic"})
messagesErrors = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "ntfy_discord_messages_errors_total",
Help: "Total number of errors sending to Discord",
}, []string{"topic"})
)
// Bridge connects ntfy to Discord
type Bridge struct {
cfg *config.Config
ntfyClient *ntfy.Client
discordClient *discord.Client
ready atomic.Bool
}
// New creates a new Bridge
func New(cfg *config.Config) *Bridge {
return &Bridge{
cfg: cfg,
ntfyClient: ntfy.NewClient(cfg.NtfyURL, cfg.NtfyTopics),
discordClient: discord.NewClient(),
}
}
// Run starts the bridge
func (b *Bridge) Run(ctx context.Context) {
if len(b.cfg.NtfyTopics) == 0 {
slog.Error("no topics configured")
return
}
msgCh := make(chan ntfy.Message, 100)
// Start SSE subscription
go b.ntfyClient.Subscribe(ctx, msgCh)
// Mark as ready once we start processing
b.ready.Store(true)
// Process messages
for {
select {
case msg := <-msgCh:
b.handleMessage(ctx, msg)
case <-ctx.Done():
b.ready.Store(false)
return
}
}
}
// IsReady returns true if the bridge is ready to process messages
func (b *Bridge) IsReady() bool {
return b.ready.Load()
}
// IsHealthy returns true if the bridge is healthy
func (b *Bridge) IsHealthy() bool {
// For now, healthy == ready
// Could add more checks like webhook URL configured
return b.cfg.WebhookURL() != ""
}
func (b *Bridge) handleMessage(ctx context.Context, msg ntfy.Message) {
messagesReceived.WithLabelValues(msg.Topic).Inc()
webhookURL := b.cfg.WebhookURL()
if webhookURL == "" {
slog.Warn("no webhook URL configured, dropping message", "topic", msg.Topic)
messagesErrors.WithLabelValues(msg.Topic).Inc()
return
}
slog.Info("forwarding message to Discord",
"id", msg.ID,
"topic", msg.Topic,
"title", msg.Title,
)
if err := b.discordClient.Send(ctx, webhookURL, msg); err != nil {
slog.Error("failed to send to Discord",
"error", err,
"topic", msg.Topic,
"id", msg.ID,
)
messagesErrors.WithLabelValues(msg.Topic).Inc()
return
}
messagesSent.WithLabelValues(msg.Topic).Inc()
slog.Debug("message sent to Discord", "id", msg.ID)
}

View File

@@ -0,0 +1,115 @@
package bridge
import (
"context"
"sync"
"testing"
"time"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/ntfy"
)
func TestBridge_IsReady(t *testing.T) {
// Create a minimal bridge for testing ready state
b := &Bridge{}
// Initially not ready
if b.IsReady() {
t.Error("bridge should not be ready before Run()")
}
// Set ready
b.ready.Store(true)
if !b.IsReady() {
t.Error("bridge should be ready after ready.Store(true)")
}
// Unset ready
b.ready.Store(false)
if b.IsReady() {
t.Error("bridge should not be ready after ready.Store(false)")
}
}
func TestMetricsRegistered(t *testing.T) {
// Verify metrics are registered by checking they're not nil
if messagesReceived == nil {
t.Error("messagesReceived metric not registered")
}
if messagesSent == nil {
t.Error("messagesSent metric not registered")
}
if messagesErrors == nil {
t.Error("messagesErrors metric not registered")
}
}
// Test that Bridge correctly uses the ready atomic
func TestBridge_ReadyState_Concurrent(t *testing.T) {
b := &Bridge{}
// Test concurrent access
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(2)
go func() {
defer wg.Done()
b.ready.Store(true)
}()
go func() {
defer wg.Done()
_ = b.IsReady()
}()
}
wg.Wait()
// Should not have any race conditions
}
// Test message channel buffer
func TestMessageChannelBuffer(t *testing.T) {
msgCh := make(chan ntfy.Message, 100)
// Should be able to buffer 100 messages without blocking
for i := 0; i < 100; i++ {
select {
case msgCh <- ntfy.Message{ID: "test"}:
// OK
default:
t.Fatalf("channel blocked at message %d", i)
}
}
// 101st should block (use select to avoid blocking test)
select {
case msgCh <- ntfy.Message{ID: "overflow"}:
t.Error("channel should be full")
default:
// Expected - channel full
}
}
// Test context cancellation behavior
func TestContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
// Simulate Run() loop behavior
select {
case <-ctx.Done():
close(done)
case <-time.After(5 * time.Second):
// Should not reach here
}
}()
cancel()
select {
case <-done:
// Success
case <-time.After(time.Second):
t.Error("context cancellation not handled")
}
}