Files
story/internal/alertnotification/webhook.go
T

109 lines
3.5 KiB
Go

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))
}