All checks were successful
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
113 lines
3.1 KiB
Go
113 lines
3.1 KiB
Go
// Package webhook delivers HTTP POST notifications on task/workflow completion.
|
|
//
|
|
// 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"`
|
|
RunID string `json:"run_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 relay payload
|
|
TokensUsed int `json:"tokens_used,omitempty"` // total tokens consumed
|
|
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)
|
|
}
|