Changeset 0.27.3 (#170)
This commit is contained in:
111
server/webhook/webhook.go
Normal file
111
server/webhook/webhook.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Package webhook delivers HTTP POST notifications on task/workflow completion.
|
||||
//
|
||||
// v0.27.3: Retry (3 attempts, exponential backoff), HMAC-SHA256 signature,
|
||||
// 10s timeout per attempt.
|
||||
package webhook
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAttempts = 3
|
||||
timeout = 10 * time.Second
|
||||
signatureHeader = "X-Switchboard-Signature"
|
||||
)
|
||||
|
||||
// Payload is the JSON body sent to webhook endpoints.
|
||||
type Payload struct {
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
TaskName string `json:"task_name,omitempty"`
|
||||
WorkflowID string `json:"workflow_id,omitempty"`
|
||||
ChannelID string `json:"channel_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Output string `json:"output,omitempty"` // last assistant message or summary
|
||||
StageData any `json:"stage_data,omitempty"` // workflow stage data (if applicable)
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Deliver sends a webhook payload to the given URL with HMAC signing.
|
||||
// Retries up to 3 times with exponential backoff (1s, 5s, 25s).
|
||||
// Runs synchronously — call in a goroutine for non-blocking delivery.
|
||||
func Deliver(url, secret string, payload Payload) error {
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook marshal: %w", err)
|
||||
}
|
||||
|
||||
// Compute HMAC signature
|
||||
signature := ""
|
||||
if secret != "" {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(body)
|
||||
signature = hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: timeout}
|
||||
backoff := time.Second // 1s, 5s, 25s
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("webhook request build: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Switchboard-Webhook/1.0")
|
||||
if signature != "" {
|
||||
req.Header.Set(signatureHeader, signature)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
log.Printf("[webhook] Attempt %d/%d failed for %s: %v", attempt, maxAttempts, url, err)
|
||||
} else {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
if attempt > 1 {
|
||||
log.Printf("[webhook] Delivered to %s on attempt %d", url, attempt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lastErr = fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
log.Printf("[webhook] Attempt %d/%d returned %d for %s", attempt, maxAttempts, resp.StatusCode, url)
|
||||
}
|
||||
|
||||
if attempt < maxAttempts {
|
||||
time.Sleep(backoff)
|
||||
backoff *= 5
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("webhook delivery failed after %d attempts: %w", maxAttempts, lastErr)
|
||||
}
|
||||
|
||||
// GenerateSecret creates a random 32-byte hex-encoded webhook secret.
|
||||
func GenerateSecret() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// Fallback to timestamp-based (extremely unlikely)
|
||||
return fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
Reference in New Issue
Block a user