package alerts import ( "bytes" "context" "fmt" "log" "net/http" "net/url" "strings" "time" "gitea.wayfinderak.com/wayfinderak/log-guardian/internal/analysis" "gitea.wayfinderak.com/wayfinderak/log-guardian/internal/store" ) type Result struct { ChannelID string Detail string } type Dispatcher struct { http *http.Client } func NewDispatcher() *Dispatcher { return &Dispatcher{http: &http.Client{Timeout: 15 * time.Second}} } func (d *Dispatcher) Send(ctx context.Context, rule store.Rule, finding analysis.Finding, channels []store.AlertChannel) ([]Result, error) { if len(channels) == 0 { log.Printf("alert for rule %q: %s", rule.Name, finding.Summary) return []Result{{Detail: "no channels configured; logged alert intent"}}, nil } var results []Result var firstErr error for _, channel := range channels { if !channel.Enabled { results = append(results, Result{ChannelID: channel.ID, Detail: "disabled"}) continue } var err error switch channel.Type { case "ntfy": err = d.sendNtfy(ctx, rule, finding, channel) case "gotify", "generic_webhook", "smtp": err = fmt.Errorf("%s delivery is scaffolded but not implemented in phase 1", channel.Type) default: err = fmt.Errorf("unsupported channel type %q", channel.Type) } if err != nil { if firstErr == nil { firstErr = err } results = append(results, Result{ChannelID: channel.ID, Detail: err.Error()}) continue } results = append(results, Result{ChannelID: channel.ID, Detail: "sent"}) } return results, firstErr } func (d *Dispatcher) Test(ctx context.Context, channel store.AlertChannel) error { finding := analysis.Finding{Summary: "Log Guardian test notification", Confidence: "test"} rule := store.Rule{Name: "Test alert", Severity: "info"} switch channel.Type { case "ntfy": return d.sendNtfy(ctx, rule, finding, channel) case "gotify", "generic_webhook", "smtp": return fmt.Errorf("%s delivery is scaffolded but not implemented in phase 1", channel.Type) default: return fmt.Errorf("unsupported channel type %q", channel.Type) } } func (d *Dispatcher) sendNtfy(ctx context.Context, rule store.Rule, finding analysis.Finding, channel store.AlertChannel) error { server := strings.TrimRight(channel.Params["server_url"], "/") topic := strings.Trim(channel.Params["topic"], "/") if server == "" || topic == "" { return fmt.Errorf("ntfy requires server_url and topic") } target, err := url.JoinPath(server, topic) if err != nil { return err } body := rule.Name + ": " + finding.Summary req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewBufferString(body)) if err != nil { return err } req.Header.Set("Title", "Log Guardian: "+rule.Severity) if priority := channel.Params["priority"]; priority != "" { req.Header.Set("Priority", priority) } if tags := channel.Params["tags"]; tags != "" { req.Header.Set("Tags", tags) } if token := channel.Params["token"]; token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := d.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { return fmt.Errorf("ntfy delivery failed: status %d", resp.StatusCode) } return nil }