package alertnotification import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "strconv" "strings" "time" "data-terminal-cloud/internal/outbox" ) const maxErrorBody = 1024 type webhookEnvelope struct { EventID string `json:"event_id"` EventType string `json:"event_type"` Data json.RawMessage `json:"data"` } type Processor struct { endpoint string secret []byte client *http.Client now func() time.Time } func NewProcessor(endpoint, secret string, timeout time.Duration) (*Processor, error) { endpoint = strings.TrimSpace(endpoint) secret = strings.TrimSpace(secret) if timeout <= 0 { return nil, errors.New("alert webhook timeout must be positive") } if endpoint == "" { return &Processor{client: &http.Client{Timeout: timeout}, now: time.Now}, nil } parsed, err := url.Parse(endpoint) if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { return nil, errors.New("alert webhook URL must be an absolute HTTP(S) URL") } if len(secret) < 32 { return nil, errors.New("alert webhook secret must contain at least 32 characters") } return &Processor{endpoint: endpoint, secret: []byte(secret), client: &http.Client{Timeout: timeout}, now: time.Now}, nil } func (p *Processor) Handle(ctx context.Context, event outbox.Event) error { if p.endpoint == "" { if event.EventType == "alert.webhook_test_requested" { return errors.New("alert webhook delivery is not configured") } return nil } if event.ID == "" || !supportedEventType(event.EventType) || !json.Valid(event.Payload) { return errors.New("invalid alert webhook event") } body, err := json.Marshal(webhookEnvelope{EventID: event.ID, EventType: event.EventType, Data: event.Payload}) if err != nil { return fmt.Errorf("encode alert webhook: %w", err) } timestamp := strconv.FormatInt(p.now().UTC().Unix(), 10) req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.endpoint, bytes.NewReader(body)) if err != nil { return fmt.Errorf("build alert webhook request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "voice-platform-alert-webhook/1.0") req.Header.Set("X-Voice-Event-ID", event.ID) req.Header.Set("X-Voice-Timestamp", timestamp) req.Header.Set("X-Voice-Signature", sign(p.secret, timestamp, body)) resp, err := p.client.Do(req) if err != nil { return outbox.Retryable(fmt.Errorf("deliver alert webhook: %w", err)) } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBody)) return nil } responseBody, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody)) requestErr := fmt.Errorf("alert webhook returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(responseBody))) if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooEarly || resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500 { return outbox.Retryable(requestErr) } return requestErr } func supportedEventType(eventType string) bool { return eventType == "alert.webhook_requested" || eventType == "alert.webhook_test_requested" } func sign(secret []byte, timestamp string, body []byte) string { mac := hmac.New(sha256.New, secret) _, _ = mac.Write([]byte(timestamp)) _, _ = mac.Write([]byte(".")) _, _ = mac.Write(body) return "v1=" + hex.EncodeToString(mac.Sum(nil)) }