package jobs import ( "context" "encoding/json" "time" "github.com/yan1h/agent-coding-workflow/internal/infra/clock" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" ) // WebhookNotifierConfig drives the WebhookNotifier. Backoff defines the retry // schedule passed to the worker via job MaxAttempts (len(Backoff)+1, since // the first attempt is "attempt 1" not a retry). Topics is the whitelist of // topics that get enqueued; everything else is dropped. type WebhookNotifierConfig struct { Enabled bool Topics []string Backoff []time.Duration } // WebhookNotifier implements notify.Notifier. Its Send method does NOT actually // deliver — it enqueues a webhook.deliver job. The Worker + WebhookHandler // performs the real HTTP call. This indirection gives us retry / dead-letter / // crash safety without coupling notify.Dispatcher to the jobs DB. type WebhookNotifier struct { repo Repository cfg WebhookNotifierConfig clk clock.Clock } // NewWebhookNotifier constructs the notifier. func NewWebhookNotifier(repo Repository, cfg WebhookNotifierConfig, clk clock.Clock) *WebhookNotifier { if clk == nil { clk = clock.Real() } return &WebhookNotifier{repo: repo, cfg: cfg, clk: clk} } // Name reports the notify.Channel for this notifier. func (n *WebhookNotifier) Name() notify.Channel { return notify.ChannelWebhook } // Send enqueues a webhook.deliver job. Disabled config / unmatched topics are // silently skipped (returning nil, since the dispatcher's contract treats // per-channel failures as non-fatal anyway). func (n *WebhookNotifier) Send(ctx context.Context, msg notify.Message) error { if !n.cfg.Enabled { return nil } if !topicAllowed(n.cfg.Topics, msg.Topic) { return nil } payload, err := json.Marshal(map[string]any{ "topic": msg.Topic, "severity": string(msg.Severity), "title": msg.Title, "body": msg.Body, "link": msg.Link, "metadata": msg.Metadata, "occurred_at": msg.CreatedAt.UTC().Format(time.RFC3339), }) if err != nil { return err } maxAttempts := int32(len(n.cfg.Backoff) + 1) if maxAttempts < 1 { maxAttempts = 1 } _, err = n.repo.Enqueue(ctx, TypeWebhookDeliver, payload, n.clk.Now(), maxAttempts) return err } func topicAllowed(allow []string, t string) bool { for _, x := range allow { if x == t { return true } } return false }