Add support for multiple instances of the same notifier type

This commit is contained in:
2025-10-17 16:39:33 -07:00
parent ba3fad9431
commit eb9e107f65
13 changed files with 322 additions and 153 deletions
+47 -10
View File
@@ -29,10 +29,10 @@ type ServerConfig struct {
// NotifiersConfig contains configuration for all notifier types
type NotifiersConfig struct {
SMTP *notifier.SMTPConfig `mapstructure:"smtp"`
Slack *notifier.SlackConfig `mapstructure:"slack"`
Ntfy *notifier.NtfyConfig `mapstructure:"ntfy"`
Stdout bool `mapstructure:"stdout"` // Enable stdout notifier
SMTP map[string]*notifier.SMTPConfig `mapstructure:"smtp"`
Slack map[string]*notifier.SlackConfig `mapstructure:"slack"`
Ntfy map[string]*notifier.NtfyConfig `mapstructure:"ntfy"`
Stdout bool `mapstructure:"stdout"` // Enable stdout notifier
}
// LoggingConfig contains logging configuration
@@ -185,9 +185,9 @@ func (c *Config) Validate() error {
// HasAnyNotifier checks if at least one notifier is configured
func (c *Config) HasAnyNotifier() bool {
return c.Notifiers.Stdout ||
c.Notifiers.SMTP != nil ||
c.Notifiers.Slack != nil ||
c.Notifiers.Ntfy != nil
len(c.Notifiers.SMTP) > 0 ||
len(c.Notifiers.Slack) > 0 ||
len(c.Notifiers.Ntfy) > 0
}
// GetEnabledNotifiers returns a list of enabled notifier types
@@ -197,15 +197,52 @@ func (c *Config) GetEnabledNotifiers() []domain.NotificationType {
if c.Notifiers.Stdout {
enabled = append(enabled, domain.TypeStdout)
}
if c.Notifiers.SMTP != nil {
if len(c.Notifiers.SMTP) > 0 {
enabled = append(enabled, domain.TypeEmail)
}
if c.Notifiers.Slack != nil {
if len(c.Notifiers.Slack) > 0 {
enabled = append(enabled, domain.TypeSlack)
}
if c.Notifiers.Ntfy != nil {
if len(c.Notifiers.Ntfy) > 0 {
enabled = append(enabled, domain.TypeNtfy)
}
return enabled
}
// GetDefaultAccount returns the default account name for a notifier type, or the first account if no default is set
func (c *Config) GetDefaultAccount(notifierType domain.NotificationType) string {
switch notifierType {
case domain.TypeEmail:
for name, cfg := range c.Notifiers.SMTP {
if cfg.Default {
return name
}
}
// Return first account if no default is set
for name := range c.Notifiers.SMTP {
return name
}
case domain.TypeSlack:
for name, cfg := range c.Notifiers.Slack {
if cfg.Default {
return name
}
}
// Return first account if no default is set
for name := range c.Notifiers.Slack {
return name
}
case domain.TypeNtfy:
for name, cfg := range c.Notifiers.Ntfy {
if cfg.Default {
return name
}
}
// Return first account if no default is set
for name := range c.Notifiers.Ntfy {
return name
}
}
return ""
}
+4
View File
@@ -44,6 +44,10 @@ type Notification struct {
// Type specifies which notifier should handle this notification
Type NotificationType `json:"type"`
// Account specifies which named account/instance to use for this notifier type (optional)
// If not specified, the default account for the notifier type will be used
Account string `json:"account,omitempty"`
// Priority determines urgency and retry behavior
Priority Priority `json:"priority"`
+7 -3
View File
@@ -21,14 +21,18 @@ type Notifier interface {
// NotifierFactory creates notifier instances based on configuration
type NotifierFactory interface {
// Create creates a notifier for the given type
Create(notificationType NotificationType) (Notifier, error)
// Create creates a notifier for the given type and account
// If account is empty, the default account for the type will be used
Create(notificationType NotificationType, account string) (Notifier, error)
// RegisterNotifier registers a custom notifier implementation
RegisterNotifier(notificationType NotificationType, notifier Notifier) error
RegisterNotifier(notificationType NotificationType, account string, notifier Notifier) error
// SupportedTypes returns all supported notification types
SupportedTypes() []NotificationType
// GetAccounts returns all registered accounts for a given notification type
GetAccounts(notificationType NotificationType) []string
}
// NotificationService is the high-level service interface for managing notifications
+59 -11
View File
@@ -10,24 +10,38 @@ import (
// Factory creates and manages notifier instances
type Factory struct {
notifiers map[domain.NotificationType]domain.Notifier
// Map of "type:account" -> notifier instance
notifiers map[string]domain.Notifier
mu sync.RWMutex
}
// NewFactory creates a new notifier factory
func NewFactory() *Factory {
return &Factory{
notifiers: make(map[domain.NotificationType]domain.Notifier),
notifiers: make(map[string]domain.Notifier),
}
}
// Create creates a notifier for the given type
func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notifier, error) {
// makeKey creates a compound key from notification type and account
func makeKey(notificationType domain.NotificationType, account string) string {
if account == "" {
// For backward compatibility, if account is empty, just use the type
return string(notificationType)
}
return fmt.Sprintf("%s:%s", notificationType, account)
}
// Create creates a notifier for the given type and account
func (f *Factory) Create(notificationType domain.NotificationType, account string) (domain.Notifier, error) {
f.mu.RLock()
defer f.mu.RUnlock()
notifier, exists := f.notifiers[notificationType]
key := makeKey(notificationType, account)
notifier, exists := f.notifiers[key]
if !exists {
if account != "" {
return nil, fmt.Errorf("unsupported notification type: %s with account: %s", notificationType, account)
}
return nil, fmt.Errorf("unsupported notification type: %s", notificationType)
}
@@ -35,31 +49,65 @@ func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notif
}
// RegisterNotifier registers a custom notifier implementation
func (f *Factory) RegisterNotifier(notificationType domain.NotificationType, notifier domain.Notifier) error {
func (f *Factory) RegisterNotifier(notificationType domain.NotificationType, account string, notifier domain.Notifier) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, exists := f.notifiers[notificationType]; exists {
key := makeKey(notificationType, account)
if _, exists := f.notifiers[key]; exists {
if account != "" {
return fmt.Errorf("notifier already registered for type: %s with account: %s", notificationType, account)
}
return fmt.Errorf("notifier already registered for type: %s", notificationType)
}
f.notifiers[notificationType] = notifier
f.notifiers[key] = notifier
return nil
}
// SupportedTypes returns all supported notification types
// SupportedTypes returns all supported notification types (unique types only)
func (f *Factory) SupportedTypes() []domain.NotificationType {
f.mu.RLock()
defer f.mu.RUnlock()
types := make([]domain.NotificationType, 0, len(f.notifiers))
for t := range f.notifiers {
typeMap := make(map[domain.NotificationType]bool)
for key := range f.notifiers {
// Extract the type from the key (type:account)
var notifType domain.NotificationType
if n, err := fmt.Sscanf(key, "%s:", &notifType); err == nil && n > 0 {
typeMap[notifType] = true
} else {
// Backward compatibility: key might just be the type
typeMap[domain.NotificationType(key)] = true
}
}
types := make([]domain.NotificationType, 0, len(typeMap))
for t := range typeMap {
types = append(types, t)
}
return types
}
// GetAccounts returns all registered accounts for a given notification type
func (f *Factory) GetAccounts(notificationType domain.NotificationType) []string {
f.mu.RLock()
defer f.mu.RUnlock()
accounts := []string{}
prefix := string(notificationType) + ":"
for key := range f.notifiers {
if len(key) > len(prefix) && key[:len(prefix)] == prefix {
account := key[len(prefix):]
accounts = append(accounts, account)
}
}
return accounts
}
// BaseNotifier provides common functionality for all notifiers
type BaseNotifier struct {
notificationType domain.NotificationType
+3
View File
@@ -32,6 +32,9 @@ type NtfyConfig struct {
// InsecureSkipVerify skips TLS verification (for self-hosted servers with self-signed certs)
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
// Default marks this instance as default
Default bool `mapstructure:"default"`
}
// NtfyNotifier sends notifications via ntfy.sh
+1
View File
@@ -19,6 +19,7 @@ type SlackConfig struct {
Username string `mapstructure:"username"`
IconEmoji string `mapstructure:"icon_emoji"`
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
Default bool `mapstructure:"default"` // Mark this instance as default
}
// SlackNotifier sends notifications to Slack
+1
View File
@@ -18,6 +18,7 @@ type SMTPConfig struct {
Password string `mapstructure:"password"`
From string `mapstructure:"from"`
UseTLS bool `mapstructure:"use_tls"`
Default bool `mapstructure:"default"` // Mark this instance as default
}
// SMTPNotifier sends notifications via email using SMTP
+27 -14
View File
@@ -9,29 +9,36 @@ import (
"github.com/igodwin/notifier/internal/domain"
)
// AccountResolver is an interface for resolving default accounts
type AccountResolver interface {
GetDefaultAccount(notifierType domain.NotificationType) string
}
// NotificationService implements the domain.NotificationService interface
type NotificationService struct {
factory domain.NotifierFactory
queue domain.Queue
notifications map[string]*domain.Notification
mu sync.RWMutex
workerCount int
stopChan chan struct{}
wg sync.WaitGroup
factory domain.NotifierFactory
queue domain.Queue
accountResolver AccountResolver
notifications map[string]*domain.Notification
mu sync.RWMutex
workerCount int
stopChan chan struct{}
wg sync.WaitGroup
}
// NewNotificationService creates a new notification service
func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int) *NotificationService {
func NewNotificationService(factory domain.NotifierFactory, queue domain.Queue, workerCount int, accountResolver AccountResolver) *NotificationService {
if workerCount <= 0 {
workerCount = 10
}
return &NotificationService{
factory: factory,
queue: queue,
notifications: make(map[string]*domain.Notification),
workerCount: workerCount,
stopChan: make(chan struct{}),
factory: factory,
queue: queue,
accountResolver: accountResolver,
notifications: make(map[string]*domain.Notification),
workerCount: workerCount,
stopChan: make(chan struct{}),
}
}
@@ -90,8 +97,14 @@ func (s *NotificationService) worker(ctx context.Context, id int) {
func (s *NotificationService) processNotification(ctx context.Context, msg *domain.QueueMessage) {
notification := msg.Notification
// Resolve account if not specified
account := notification.Account
if account == "" && s.accountResolver != nil {
account = s.accountResolver.GetDefaultAccount(notification.Type)
}
// Get the appropriate notifier
notifier, err := s.factory.Create(notification.Type)
notifier, err := s.factory.Create(notification.Type, account)
if err != nil {
notification.Status = domain.StatusFailed
notification.LastError = fmt.Sprintf("failed to create notifier: %v", err)