check / check (push) Waiting to run
Target.MaxRetries (max_retries) becomes Target.MaxAttempts (max_attempts), and the form field, parser and ceiling are renamed to match. The column is NOT NULL and a check constraint refuses a value below 1, so every target, whatever its type, is stored with at least 1. 1 is a single attempt with no circuit breaker; above 1 retries behind the breaker up to that many attempts. Both target forms refuse 0 and less; a blank field on the add target form gives 1, and on the edit form keeps the stored value. The target page shows the plain number, and the README's two paragraphs on 0 against 1 become one sentence. Model: opus-5-5
310 lines
5.7 KiB
Go
310 lines
5.7 KiB
Go
package delivery
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// errMissingWebhookURL is returned when a Slack target config
|
|
// omits its webhook URL.
|
|
var errMissingWebhookURL = errors.New(
|
|
"webhook_url is required",
|
|
)
|
|
|
|
// SlackTargetConfig holds configuration for slack target
|
|
// types.
|
|
type SlackTargetConfig struct {
|
|
WebhookURL string `json:"webhookUrl"`
|
|
}
|
|
|
|
// slackTarget delivers events to Slack incoming webhooks. It
|
|
// formats the event into a Slack message and posts it as
|
|
// JSON. It shares the retry core with the HTTP target: a
|
|
// MaxAttempts of 1 is a single attempt, while a higher one
|
|
// adds backoff and circuit breaking.
|
|
type slackTarget struct {
|
|
*httpCore
|
|
|
|
client *http.Client
|
|
}
|
|
|
|
// Deliver implements Target.
|
|
func (t *slackTarget) Deliver(
|
|
ctx context.Context,
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
task *Task,
|
|
sched Scheduler,
|
|
) {
|
|
cfg, err := parseSlackConfig(d.Target.Config)
|
|
if err != nil {
|
|
t.eng.log.Error(
|
|
"invalid Slack target config",
|
|
"target_id", d.TargetID,
|
|
"error", err,
|
|
)
|
|
|
|
t.failConfig(webhookDB, d, err)
|
|
|
|
return
|
|
}
|
|
|
|
msg := FormatSlackMessage(&d.Event)
|
|
|
|
payload, err := json.Marshal(
|
|
map[string]string{"text": msg},
|
|
)
|
|
if err != nil {
|
|
t.eng.log.Error(
|
|
"failed to marshal Slack payload",
|
|
"target_id", d.TargetID,
|
|
"error", err,
|
|
)
|
|
|
|
t.failConfig(webhookDB, d, err)
|
|
|
|
return
|
|
}
|
|
|
|
attempt := func() attemptResult {
|
|
return t.attempt(ctx, cfg, payload)
|
|
}
|
|
|
|
t.deliver(
|
|
ctx, webhookDB, d, task, sched,
|
|
d.Target.MaxAttempts, attempt,
|
|
)
|
|
}
|
|
|
|
// failConfig records a first-attempt failure for a delivery
|
|
// that could not be prepared (bad config or unmarshalable
|
|
// payload) and marks it failed.
|
|
func (t *slackTarget) failConfig(
|
|
webhookDB *gorm.DB,
|
|
d *database.Delivery,
|
|
err error,
|
|
) {
|
|
recErr := t.eng.recordResult(
|
|
webhookDB, d, 1,
|
|
false, 0, "", err.Error(), 0,
|
|
)
|
|
if recErr != nil {
|
|
t.eng.bookkeepingFailed(d, recErr)
|
|
|
|
return
|
|
}
|
|
|
|
t.eng.settleStatus(
|
|
webhookDB, d, d.Target.Type,
|
|
database.DeliveryStatusFailed,
|
|
)
|
|
}
|
|
|
|
// attempt performs a single Slack POST and derives its
|
|
// outcome, preserving the engine's original semantics: a
|
|
// non-2xx response records an "HTTP <code>" error string and
|
|
// a transport error records a "sending request" error.
|
|
func (t *slackTarget) attempt(
|
|
ctx context.Context,
|
|
cfg *SlackTargetConfig,
|
|
payload []byte,
|
|
) attemptResult {
|
|
start := time.Now()
|
|
|
|
req, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
cfg.WebhookURL,
|
|
bytes.NewReader(payload),
|
|
)
|
|
if err != nil {
|
|
return attemptResult{
|
|
success: false,
|
|
errMsg: maskURLError(err).Error(),
|
|
}
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", t.eng.userAgent())
|
|
|
|
resp, doErr := executeHTTPRequest(t.client, req)
|
|
durationMs := time.Since(start).Milliseconds()
|
|
|
|
if doErr != nil {
|
|
return attemptResult{
|
|
success: false,
|
|
duration: durationMs,
|
|
errMsg: fmt.Errorf(
|
|
"sending request: %w", doErr,
|
|
).Error(),
|
|
}
|
|
}
|
|
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
return t.readSlackResponse(resp, durationMs)
|
|
}
|
|
|
|
func (t *slackTarget) readSlackResponse(
|
|
resp *http.Response,
|
|
durationMs int64,
|
|
) attemptResult {
|
|
body, readErr := io.ReadAll(
|
|
io.LimitReader(resp.Body, maxBodyLog),
|
|
)
|
|
if readErr != nil {
|
|
t.eng.log.Error(
|
|
"failed to read Slack response body",
|
|
"error", readErr,
|
|
)
|
|
}
|
|
|
|
success := resp.StatusCode >= httpSuccessMin &&
|
|
resp.StatusCode < httpSuccessMax
|
|
|
|
errMsg := ""
|
|
if !success {
|
|
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
|
|
}
|
|
|
|
return attemptResult{
|
|
statusCode: resp.StatusCode,
|
|
respBody: string(body),
|
|
duration: durationMs,
|
|
success: success,
|
|
errMsg: errMsg,
|
|
}
|
|
}
|
|
|
|
func parseSlackConfig(
|
|
configJSON string,
|
|
) (*SlackTargetConfig, error) {
|
|
if configJSON == "" {
|
|
return nil, errEmptyTargetConfig
|
|
}
|
|
|
|
var cfg SlackTargetConfig
|
|
|
|
err := json.Unmarshal(
|
|
[]byte(configJSON), &cfg,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"parsing config JSON: %w", err,
|
|
)
|
|
}
|
|
|
|
if cfg.WebhookURL == "" {
|
|
return nil, errMissingWebhookURL
|
|
}
|
|
|
|
return &cfg, nil
|
|
}
|
|
|
|
// FormatSlackMessage builds a Slack-compatible message
|
|
// string from a webhook event.
|
|
func FormatSlackMessage(
|
|
event *database.Event,
|
|
) string {
|
|
var b strings.Builder
|
|
|
|
b.WriteString("*Webhook Event Received*\n")
|
|
|
|
fmt.Fprintf(
|
|
&b, "*Method:* `%s`\n", event.Method,
|
|
)
|
|
|
|
fmt.Fprintf(
|
|
&b,
|
|
"*Content-Type:* `%s`\n",
|
|
event.ContentType,
|
|
)
|
|
|
|
timestamp := "unknown"
|
|
if !event.CreatedAt.IsZero() {
|
|
timestamp = event.CreatedAt.UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
fmt.Fprintf(
|
|
&b,
|
|
"*Timestamp:* `%s`\n",
|
|
timestamp,
|
|
)
|
|
|
|
fmt.Fprintf(
|
|
&b,
|
|
"*Body Size:* %d bytes\n",
|
|
len(event.Body),
|
|
)
|
|
|
|
if event.Body == "" {
|
|
b.WriteString("\n_(empty body)_\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
if formatted := formatJSONBody(event.Body); formatted != "" {
|
|
b.WriteString(formatted)
|
|
|
|
return b.String()
|
|
}
|
|
|
|
formatRawBody(&b, event.Body)
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func formatJSONBody(body string) string {
|
|
var parsed json.RawMessage
|
|
if json.Unmarshal([]byte(body), &parsed) != nil {
|
|
return ""
|
|
}
|
|
|
|
var pretty bytes.Buffer
|
|
if json.Indent(&pretty, parsed, "", " ") != nil {
|
|
return ""
|
|
}
|
|
|
|
var b strings.Builder
|
|
|
|
b.WriteString("\n```\n")
|
|
|
|
prettyStr := pretty.String()
|
|
|
|
const maxPayloadDisplay = 3500
|
|
if len(prettyStr) > maxPayloadDisplay {
|
|
b.WriteString(prettyStr[:maxPayloadDisplay])
|
|
b.WriteString("\n... (truncated)")
|
|
} else {
|
|
b.WriteString(prettyStr)
|
|
}
|
|
|
|
b.WriteString("\n```\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
func formatRawBody(b *strings.Builder, body string) {
|
|
b.WriteString("\n```\n")
|
|
|
|
const maxRawDisplay = 3500
|
|
if len(body) > maxRawDisplay {
|
|
b.WriteString(body[:maxRawDisplay])
|
|
b.WriteString("\n... (truncated)")
|
|
} else {
|
|
b.WriteString(body)
|
|
}
|
|
|
|
b.WriteString("\n```\n")
|
|
}
|