// Package handlers contains job.Handler implementations for the jobs module. // Each file defines exactly one Handler so each can be tested in isolation. package handlers import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "strconv" "time" "github.com/yan1h/agent-coding-workflow/internal/infra/clock" "github.com/yan1h/agent-coding-workflow/internal/jobs" ) // WebhookConfig is the per-handler webhook config (URL/secret/timeout). // Topic-whitelist filtering is performed upstream by the WebhookNotifier // before enqueue, so this handler always delivers what it gets. type WebhookConfig struct { URL string Secret string Timeout time.Duration } // WebhookHandler implements jobs.Handler for the TypeWebhookDeliver type. type WebhookHandler struct { cfg WebhookConfig clk clock.Clock client *http.Client } // NewWebhookHandler constructs a handler with the given config. The handler // builds a fresh http.Client per dispatch (no keep-alive) since webhook // frequency is low. func NewWebhookHandler(cfg WebhookConfig, clk clock.Clock) *WebhookHandler { if cfg.Timeout <= 0 { cfg.Timeout = 10 * time.Second } if clk == nil { clk = clock.Real() } return &WebhookHandler{ cfg: cfg, clk: clk, client: &http.Client{Timeout: cfg.Timeout}, } } func (h *WebhookHandler) Type() jobs.JobType { return jobs.TypeWebhookDeliver } // payloadEnvelope mirrors the JSON shape the webhook receiver gets. Topic is // surfaced as a header AND in the body for convenience. type payloadEnvelope struct { Topic string `json:"topic"` } func (h *WebhookHandler) Handle(ctx context.Context, job jobs.Job) error { if h.cfg.URL == "" { return jobs.Permanent(fmt.Errorf("webhook url not configured")) } var env payloadEnvelope if err := json.Unmarshal(job.Payload, &env); err != nil { return jobs.Permanent(fmt.Errorf("payload not valid json: %w", err)) } body := []byte(job.Payload) ts := strconv.FormatInt(h.clk.Now().Unix(), 10) sig := sign(h.cfg.Secret, ts, body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, h.cfg.URL, bytes.NewReader(body)) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "agent-coding-workflow/dev") req.Header.Set("X-ACW-Topic", env.Topic) req.Header.Set("X-ACW-Event-Id", job.ID.String()) req.Header.Set("X-ACW-Timestamp", ts) req.Header.Set("X-ACW-Signature", sig) resp, err := h.client.Do(req) if err != nil { return fmt.Errorf("http: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { _, _ = io.Copy(io.Discard, resp.Body) return nil } respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 500)) msg := fmt.Sprintf("%d %s", resp.StatusCode, string(respBody)) if resp.StatusCode >= 400 && resp.StatusCode < 500 { return jobs.Permanent(fmt.Errorf("%s", msg)) } return fmt.Errorf("%s", msg) } func sign(secret, ts string, body []byte) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + ".")) mac.Write(body) return "sha256=" + hex.EncodeToString(mac.Sum(nil)) }