feat: implement ntfy-discord bridge in Go
- 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:
116
internal/bridge/bridge.go
Normal file
116
internal/bridge/bridge.go
Normal 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)
|
||||
}
|
||||
115
internal/bridge/bridge_test.go
Normal file
115
internal/bridge/bridge_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user