Basic impl added

This commit is contained in:
2025-10-16 21:22:51 -07:00
parent 097ca99788
commit 9087a710e5
43 changed files with 7616 additions and 107 deletions
+107
View File
@@ -0,0 +1,107 @@
package notifier
import (
"context"
"fmt"
"sync"
"github.com/igodwin/notifier/internal/domain"
)
// Factory creates and manages notifier instances
type Factory struct {
notifiers map[domain.NotificationType]domain.Notifier
mu sync.RWMutex
}
// NewFactory creates a new notifier factory
func NewFactory() *Factory {
return &Factory{
notifiers: make(map[domain.NotificationType]domain.Notifier),
}
}
// Create creates a notifier for the given type
func (f *Factory) Create(notificationType domain.NotificationType) (domain.Notifier, error) {
f.mu.RLock()
defer f.mu.RUnlock()
notifier, exists := f.notifiers[notificationType]
if !exists {
return nil, fmt.Errorf("unsupported notification type: %s", notificationType)
}
return notifier, nil
}
// RegisterNotifier registers a custom notifier implementation
func (f *Factory) RegisterNotifier(notificationType domain.NotificationType, notifier domain.Notifier) error {
f.mu.Lock()
defer f.mu.Unlock()
if _, exists := f.notifiers[notificationType]; exists {
return fmt.Errorf("notifier already registered for type: %s", notificationType)
}
f.notifiers[notificationType] = notifier
return nil
}
// SupportedTypes returns all supported notification types
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 {
types = append(types, t)
}
return types
}
// BaseNotifier provides common functionality for all notifiers
type BaseNotifier struct {
notificationType domain.NotificationType
}
// Type returns the notification type
func (b *BaseNotifier) Type() domain.NotificationType {
return b.notificationType
}
// Validate performs basic validation common to all notifiers
func (b *BaseNotifier) Validate(notification *domain.Notification) error {
if notification == nil {
return fmt.Errorf("notification is nil")
}
if len(notification.Recipients) == 0 {
return fmt.Errorf("notification has no recipients")
}
if notification.Type != b.notificationType {
return fmt.Errorf("notification type mismatch: expected %s, got %s", b.notificationType, notification.Type)
}
return nil
}
// Close performs cleanup (default implementation does nothing)
func (b *BaseNotifier) Close() error {
return nil
}
// ValidateContext checks if the context is valid
func ValidateContext(ctx context.Context) error {
if ctx == nil {
return fmt.Errorf("context is nil")
}
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
+261
View File
@@ -0,0 +1,261 @@
package notifier
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// NtfyConfig contains ntfy.sh configuration
type NtfyConfig struct {
// ServerURL is the ntfy server URL (default: https://ntfy.sh)
ServerURL string `mapstructure:"server_url"`
// Token is the access token for authentication (preferred method)
// Supports both regular tokens (tk_...) and publish tokens
Token string `mapstructure:"token"`
// Username for basic authentication (alternative to token)
Username string `mapstructure:"username"`
// Password for basic authentication (alternative to token)
Password string `mapstructure:"password"`
// DefaultTopic is the default topic if not specified in notification
DefaultTopic string `mapstructure:"default_topic"`
// InsecureSkipVerify skips TLS verification (for self-hosted servers with self-signed certs)
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"`
}
// NtfyNotifier sends notifications via ntfy.sh
type NtfyNotifier struct {
BaseNotifier
config *NtfyConfig
httpClient *http.Client
}
// ntfyRequest represents the ntfy API request format
type ntfyRequest struct {
Topic string `json:"topic"`
Message string `json:"message"`
Title string `json:"title,omitempty"`
Priority int `json:"priority,omitempty"`
Tags []string `json:"tags,omitempty"`
Click string `json:"click,omitempty"`
Attach string `json:"attach,omitempty"`
Actions []ntfyAction `json:"actions,omitempty"`
Icon string `json:"icon,omitempty"`
Delay string `json:"delay,omitempty"`
Email string `json:"email,omitempty"`
}
// ntfyAction represents an action button in ntfy
type ntfyAction struct {
Action string `json:"action"`
Label string `json:"label"`
URL string `json:"url,omitempty"`
Body string `json:"body,omitempty"`
Clear bool `json:"clear,omitempty"`
}
// NewNtfyNotifier creates a new ntfy notifier
func NewNtfyNotifier(config *NtfyConfig) (*NtfyNotifier, error) {
if config == nil {
return nil, fmt.Errorf("ntfy config is required")
}
if config.ServerURL == "" {
config.ServerURL = "https://ntfy.sh" // Default public ntfy server
}
// Create HTTP client with optional TLS skip verify
httpClient := &http.Client{
Timeout: 30 * time.Second,
}
if config.InsecureSkipVerify {
// For self-hosted servers with self-signed certificates
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
httpClient.Transport = transport
}
return &NtfyNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeNtfy,
},
config: config,
httpClient: httpClient,
}, nil
}
// Send sends a notification via ntfy
func (n *NtfyNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := n.Validate(notification); err != nil {
return nil, err
}
// For ntfy, recipients are topics
recipients := notification.Recipients
if len(recipients) == 0 && n.config.DefaultTopic != "" {
recipients = []string{n.config.DefaultTopic}
}
for _, topic := range recipients {
req := ntfyRequest{
Topic: topic,
Message: notification.Body,
Title: notification.Subject,
Priority: n.mapPriority(notification.Priority),
}
// Add custom tags from metadata
if tags, ok := notification.Metadata["tags"].([]interface{}); ok {
for _, tag := range tags {
if tagStr, ok := tag.(string); ok {
req.Tags = append(req.Tags, tagStr)
}
}
}
// Add click action from metadata
if click, ok := notification.Metadata["click"].(string); ok {
req.Click = click
}
// Add attachment from metadata
if attach, ok := notification.Metadata["attach"].(string); ok {
req.Attach = attach
}
// Add icon from metadata
if icon, ok := notification.Metadata["icon"].(string); ok {
req.Icon = icon
}
// Add delay from metadata (e.g., "30s", "1m", "1h")
if delay, ok := notification.Metadata["delay"].(string); ok {
req.Delay = delay
}
// Add email from metadata (for email notifications)
if email, ok := notification.Metadata["email"].(string); ok {
req.Email = email
}
// Add actions from metadata
if actions, ok := notification.Metadata["actions"].([]interface{}); ok {
for _, action := range actions {
if actionMap, ok := action.(map[string]interface{}); ok {
ntfyAct := ntfyAction{}
if actionType, ok := actionMap["action"].(string); ok {
ntfyAct.Action = actionType
}
if label, ok := actionMap["label"].(string); ok {
ntfyAct.Label = label
}
if url, ok := actionMap["url"].(string); ok {
ntfyAct.URL = url
}
if body, ok := actionMap["body"].(string); ok {
ntfyAct.Body = body
}
if clear, ok := actionMap["clear"].(bool); ok {
ntfyAct.Clear = clear
}
req.Actions = append(req.Actions, ntfyAct)
}
}
}
if err := n.sendToTopic(ctx, &req); err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, err
}
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: fmt.Sprintf("Notification sent to %d topics", len(notification.Recipients)),
SentAt: time.Now(),
ProviderResponse: map[string]interface{}{
"server": n.config.ServerURL,
"topics": notification.Recipients,
},
}, nil
}
// sendToTopic sends a notification to a specific ntfy topic
func (n *NtfyNotifier) sendToTopic(ctx context.Context, req *ntfyRequest) error {
url := fmt.Sprintf("%s", n.config.ServerURL)
jsonData, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("failed to marshal ntfy request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// Add authentication if configured
if n.config.Token != "" {
httpReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", n.config.Token))
} else if n.config.Username != "" && n.config.Password != "" {
httpReq.SetBasicAuth(n.config.Username, n.config.Password)
}
resp, err := n.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("failed to send ntfy notification: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ntfy server returned status: %d", resp.StatusCode)
}
return nil
}
// mapPriority maps domain priority to ntfy priority (1-5)
func (n *NtfyNotifier) mapPriority(priority domain.Priority) int {
switch priority {
case domain.PriorityLow:
return 2
case domain.PriorityNormal:
return 3
case domain.PriorityHigh:
return 4
case domain.PriorityCritical:
return 5
default:
return 3
}
}
// Close closes the HTTP client
func (n *NtfyNotifier) Close() error {
n.httpClient.CloseIdleConnections()
return nil
}
+215
View File
@@ -0,0 +1,215 @@
package notifier
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// SlackConfig contains Slack webhook configuration
type SlackConfig struct {
WebhookURL string `mapstructure:"webhook_url"`
Token string `mapstructure:"token"`
Channel string `mapstructure:"channel"`
Username string `mapstructure:"username"`
IconEmoji string `mapstructure:"icon_emoji"`
Webhooks map[string]string `mapstructure:"webhooks"` // Channel-specific webhooks
}
// SlackNotifier sends notifications to Slack
type SlackNotifier struct {
BaseNotifier
config *SlackConfig
httpClient *http.Client
}
// slackMessage represents the Slack API request format
type slackMessage struct {
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
Text string `json:"text,omitempty"`
Blocks []slackBlock `json:"blocks,omitempty"`
Markdown bool `json:"mrkdwn,omitempty"`
}
// slackBlock represents a Slack block element
type slackBlock struct {
Type string `json:"type"`
Text *slackTextBlock `json:"text,omitempty"`
}
// slackTextBlock represents a text element in a Slack block
type slackTextBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
// NewSlackNotifier creates a new Slack notifier
func NewSlackNotifier(config *SlackConfig) (*SlackNotifier, error) {
if config == nil {
return nil, fmt.Errorf("Slack config is required")
}
// Either webhook URL or token is required
if config.WebhookURL == "" && config.Token == "" && len(config.Webhooks) == 0 {
return nil, fmt.Errorf("Slack webhook URL, token, or channel webhooks are required")
}
return &SlackNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeSlack,
},
config: config,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
}
// Send sends a notification to Slack
func (s *SlackNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := s.Validate(notification); err != nil {
return nil, err
}
// For Slack, recipients are channel names or webhook URLs
for _, recipient := range notification.Recipients {
msg := s.buildMessage(notification, recipient)
webhookURL := s.getWebhookURL(recipient)
if err := s.sendToSlack(ctx, webhookURL, msg); err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, err
}
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: fmt.Sprintf("Slack notification sent to %d channels", len(notification.Recipients)),
SentAt: time.Now(),
ProviderResponse: map[string]interface{}{
"channels": notification.Recipients,
},
}, nil
}
// buildMessage constructs a Slack message with rich formatting
func (s *SlackNotifier) buildMessage(notification *domain.Notification, channel string) *slackMessage {
msg := &slackMessage{
Channel: channel,
Username: s.config.Username,
IconEmoji: s.config.IconEmoji,
Markdown: true,
}
// Use blocks for rich formatting if both subject and body exist
if notification.Subject != "" && notification.Body != "" {
msg.Blocks = []slackBlock{
{
Type: "header",
Text: &slackTextBlock{
Type: "plain_text",
Text: notification.Subject,
},
},
{
Type: "section",
Text: &slackTextBlock{
Type: "mrkdwn",
Text: notification.Body,
},
},
}
} else {
// Fallback to simple text
if notification.Subject != "" {
msg.Text = fmt.Sprintf("*%s*\n%s", notification.Subject, notification.Body)
} else {
msg.Text = notification.Body
}
}
// Add priority indicator for high priority notifications
if notification.Priority >= domain.PriorityHigh {
priorityEmoji := ":warning:"
if notification.Priority == domain.PriorityCritical {
priorityEmoji = ":rotating_light:"
}
msg.Blocks = append([]slackBlock{
{
Type: "context",
Text: &slackTextBlock{
Type: "mrkdwn",
Text: fmt.Sprintf("%s *Priority: %d*", priorityEmoji, notification.Priority),
},
},
}, msg.Blocks...)
}
return msg
}
// getWebhookURL returns the webhook URL for a specific channel
func (s *SlackNotifier) getWebhookURL(channel string) string {
// Check for channel-specific webhook
if webhook, ok := s.config.Webhooks[channel]; ok {
return webhook
}
// Fall back to default webhook URL
return s.config.WebhookURL
}
// sendToSlack sends the message to Slack via webhook
func (s *SlackNotifier) sendToSlack(ctx context.Context, webhookURL string, msg *slackMessage) error {
jsonData, err := json.Marshal(msg)
if err != nil {
return fmt.Errorf("failed to marshal Slack message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", webhookURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
// Add token authentication if configured
if s.config.Token != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", s.config.Token))
}
resp, err := s.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send Slack notification: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Slack API returned status: %d", resp.StatusCode)
}
return nil
}
// Close closes the HTTP client
func (s *SlackNotifier) Close() error {
s.httpClient.CloseIdleConnections()
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package notifier
import (
"context"
"fmt"
"net/smtp"
"strings"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// SMTPConfig contains SMTP server configuration
type SMTPConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
From string `mapstructure:"from"`
UseTLS bool `mapstructure:"use_tls"`
}
// SMTPNotifier sends notifications via email using SMTP
type SMTPNotifier struct {
BaseNotifier
config *SMTPConfig
}
// NewSMTPNotifier creates a new SMTP notifier
func NewSMTPNotifier(config *SMTPConfig) (*SMTPNotifier, error) {
if config == nil {
return nil, fmt.Errorf("SMTP config is required")
}
if config.Host == "" {
return nil, fmt.Errorf("SMTP host is required")
}
if config.Port == 0 {
config.Port = 587 // Default SMTP submission port
}
if config.From == "" {
return nil, fmt.Errorf("SMTP from address is required")
}
return &SMTPNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeEmail,
},
config: config,
}, nil
}
// Send sends a notification via email
func (s *SMTPNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := s.Validate(notification); err != nil {
return nil, err
}
// Validate email recipients
for _, recipient := range notification.Recipients {
if !strings.Contains(recipient, "@") {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: fmt.Sprintf("invalid email address: %s", recipient),
SentAt: time.Now(),
}, fmt.Errorf("invalid email address: %s", recipient)
}
}
// Build email message
message := s.buildMessage(notification)
// Send email
addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port)
auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host)
err := smtp.SendMail(addr, auth, s.config.From, notification.Recipients, []byte(message))
if err != nil {
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: false,
Error: err.Error(),
SentAt: time.Now(),
}, fmt.Errorf("failed to send email: %w", err)
}
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: fmt.Sprintf("Email sent to %d recipients", len(notification.Recipients)),
SentAt: time.Now(),
ProviderResponse: map[string]interface{}{
"smtp_server": addr,
"from": s.config.From,
"to": notification.Recipients,
},
}, nil
}
// buildMessage constructs the email message with headers
func (s *SMTPNotifier) buildMessage(notification *domain.Notification) string {
var builder strings.Builder
builder.WriteString(fmt.Sprintf("From: %s\r\n", s.config.From))
builder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(notification.Recipients, ", ")))
builder.WriteString(fmt.Sprintf("Subject: %s\r\n", notification.Subject))
builder.WriteString("MIME-Version: 1.0\r\n")
builder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
builder.WriteString("\r\n")
builder.WriteString(notification.Body)
return builder.String()
}
// Validate checks if the notification is valid for SMTP
func (s *SMTPNotifier) Validate(notification *domain.Notification) error {
if err := s.BaseNotifier.Validate(notification); err != nil {
return err
}
if notification.Subject == "" {
return fmt.Errorf("email subject is required")
}
if notification.Body == "" {
return fmt.Errorf("email body is required")
}
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package notifier
import (
"context"
"fmt"
"time"
"github.com/igodwin/notifier/internal/domain"
)
// StdoutNotifier sends notifications to stdout (useful for debugging)
type StdoutNotifier struct {
BaseNotifier
}
// NewStdoutNotifier creates a new stdout notifier
func NewStdoutNotifier() *StdoutNotifier {
return &StdoutNotifier{
BaseNotifier: BaseNotifier{
notificationType: domain.TypeStdout,
},
}
}
// Send sends a notification to stdout
func (s *StdoutNotifier) Send(ctx context.Context, notification *domain.Notification) (*domain.NotificationResult, error) {
if err := ValidateContext(ctx); err != nil {
return nil, err
}
if err := s.Validate(notification); err != nil {
return nil, err
}
fmt.Println("========================================")
fmt.Printf("Notification ID: %s\n", notification.ID)
fmt.Printf("Type: %s\n", notification.Type)
fmt.Printf("Priority: %d\n", notification.Priority)
fmt.Printf("Recipients: %v\n", notification.Recipients)
fmt.Printf("Subject: %s\n", notification.Subject)
fmt.Printf("Body:\n%s\n", notification.Body)
fmt.Println("========================================")
return &domain.NotificationResult{
NotificationID: notification.ID,
Success: true,
Message: "Notification printed to stdout",
SentAt: time.Now(),
}, nil
}