package events import ( "fmt" "log/slog" "regexp" "strings" "sync" "time" "github.com/alexei/tinyforge/internal/store" ) // TriggerSource is the read-side seam the dispatcher uses to fetch the // currently-enabled set of triggers. Kept as an interface so tests can // swap in a static list without spinning up SQLite. The dispatcher // re-reads triggers from this source on every event so config edits // take effect within one event without an explicit hot-reload hook. type TriggerSource interface { ListEnabledEventTriggers() ([]store.EventTrigger, error) } // TriggerNotifier is what the dispatcher uses to deliver. Real callers // pass *notify.Notifier; tests pass a recorder. The shape matches the // notifier method one-to-one so wiring is just a method-value pass. type TriggerNotifier interface { SendPayload(webhookURL, secret, eventType string, payload any) } // TriggerWebhookPayload is the JSON shape sent to action_target webhook // receivers. Includes both the event that fired the trigger and a brief // trigger descriptor so receivers can route by trigger name or filter // shape without re-looking-up the rule. type TriggerWebhookPayload struct { Type string `json:"type"` // "event_trigger" — stable TriggerID int64 `json:"trigger_id"` Trigger string `json:"trigger_name"` Event EventLogPayload `json:"event"` Timestamp string `json:"timestamp"` } // RegisterEventTriggerDispatcher subscribes to EventLog events on the // bus and dispatches matching triggers via the supplied notifier. // // Loop-prevention is structural: the dispatcher never writes to // event_log. All delivery outcomes are recorded inside the notifier // implementation (webhook_deliveries audit trail today). Adding a new // EventLog row here would cause the dispatcher to re-fire on its own // emission — a tight feedback loop the design explicitly forbids. // // Returns an unsubscribe function. Safe to call multiple times. func RegisterEventTriggerDispatcher(b *Bus, triggers TriggerSource, notifier TriggerNotifier) func() { sub := b.Subscribe(func(evt Event) bool { return evt.Type == EventLog }) d := &dispatcher{ triggers: triggers, notifier: notifier, regexCache: map[string]*regexp.Regexp{}, } go func() { for evt := range sub { payload, ok := evt.Payload.(EventLogPayload) if !ok { continue } d.handle(payload) } }() return func() { b.Unsubscribe(sub) } } type dispatcher struct { triggers TriggerSource notifier TriggerNotifier // regexCache memoizes compiled message-regex patterns so the hot // path doesn't re-compile on every event. Bounded by the number of // distinct patterns across all triggers, which is small in practice. mu sync.Mutex regexCache map[string]*regexp.Regexp } func (d *dispatcher) handle(p EventLogPayload) { triggers, err := d.triggers.ListEnabledEventTriggers() if err != nil { slog.Warn("event-trigger dispatcher: list failed", "error", err) return } for _, t := range triggers { ok, err := d.matches(t, p) if err != nil { slog.Warn("event-trigger: filter eval failed", "trigger", t.Name, "error", err) continue } if !ok { continue } switch t.ActionType { case store.EventTriggerActionWebhook: d.notifier.SendPayload(t.ActionTarget, t.ActionSecret, "event_trigger", TriggerWebhookPayload{ Type: "event_trigger", TriggerID: t.ID, Trigger: t.Name, Event: p, Timestamp: time.Now().UTC().Format(time.RFC3339), }) default: slog.Warn("event-trigger: unsupported action_type", "trigger", t.Name, "action_type", t.ActionType) } } } // matches evaluates the (severity, source, message-regex) filters // against an event log payload. AND semantics — every non-empty filter // must pass. An empty filter is "any" and silently passes. func (d *dispatcher) matches(t store.EventTrigger, p EventLogPayload) (bool, error) { if !filterMatchCSV(t.FilterSeverity, p.Severity) { return false, nil } if !filterMatchCSV(t.FilterSource, p.Source) { return false, nil } if t.FilterMessageRegex != "" { re, err := d.compile(t.FilterMessageRegex) if err != nil { return false, fmt.Errorf("invalid regex %q: %w", t.FilterMessageRegex, err) } if !re.MatchString(p.Message) { return false, nil } } return true, nil } // filterMatchCSV returns true when the candidate equals one of the // comma-separated values in filter, or when filter is empty (no filter // = match-all). Whitespace around list entries is tolerated so the // operator's CSV pasting is forgiving. func filterMatchCSV(filter, candidate string) bool { filter = strings.TrimSpace(filter) if filter == "" { return true } for _, p := range strings.Split(filter, ",") { if strings.TrimSpace(p) == candidate { return true } } return false } func (d *dispatcher) compile(pattern string) (*regexp.Regexp, error) { d.mu.Lock() if cached, ok := d.regexCache[pattern]; ok { d.mu.Unlock() return cached, nil } d.mu.Unlock() re, err := regexp.Compile(pattern) if err != nil { return nil, err } d.mu.Lock() d.regexCache[pattern] = re d.mu.Unlock() return re, nil }