WayfinderAK 6f7549d439
All checks were successful
build-image / docker (push) Successful in 53s
Implement phase 1 foundations
2026-07-03 20:14:06 -08:00

109 lines
3.2 KiB
Go

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
}