feat: add Slack target type for incoming webhook notifications (#47)
All checks were successful
check / check (push) Successful in 4s

## Summary

Adds a new `slack` target type that sends webhook events as formatted messages to any Slack-compatible incoming webhook URL (Slack, Mattermost, and other compatible services).

closes #44

## What it does

When a webhook event is received, the Slack target:

1. Formats a human-readable message with event metadata (HTTP method, content type, timestamp, body size)
2. Pretty-prints the payload in a code block — JSON payloads get indented formatting, non-JSON payloads are shown as raw text
3. Truncates large payloads at 3500 characters to keep Slack messages reasonable
4. POSTs the message as a `{"text": "..."}` JSON payload to the configured webhook URL

## Changes

- **`internal/database/model_target.go`** — Add `TargetTypeSlack` constant
- **`internal/delivery/engine.go`** — Add `SlackTargetConfig` struct, `deliverSlack` method, `FormatSlackMessage` function (exported), `parseSlackConfig` helper. Route slack targets in `processDelivery` switch.
- **`internal/handlers/source_management.go`** — Handle `slack` type in `HandleTargetCreate`, building `webhook_url` config from the URL form field
- **`templates/source_detail.html`** — Add "Slack" option to target type dropdown with URL field and helper text
- **`README.md`** — Document the new target type, update roadmap

## Tests

- `TestParseSlackConfig_Valid` / `_Empty` / `_MissingWebhookURL` — Config parsing
- `TestFormatSlackMessage_JSONBody` / `_NonJSONBody` / `_EmptyBody` / `_LargeJSONTruncated` — Message formatting
- `TestDeliverSlack_Success` / `_Failure` / `_InvalidConfig` — End-to-end delivery
- `TestProcessDelivery_RoutesToSlack` — Routing from processDelivery switch

All existing tests continue to pass. `docker build .` (which runs `make check`) passes clean.

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #47
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
This commit was merged in pull request #47.
This commit is contained in:
2026-03-17 12:30:50 +01:00
committed by Jeffrey Paul
parent 1fbcf96581
commit 8d702a16c6
6 changed files with 465 additions and 6 deletions

View File

@@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"net/http"
"strings"
"sync"
"time"
@@ -97,6 +98,12 @@ type HTTPTargetConfig struct {
Timeout int `json:"timeout,omitempty"` // seconds, 0 = default
}
// SlackTargetConfig holds configuration for slack target types.
// Compatible with any Slack-format incoming webhook (Slack, Mattermost, etc.).
type SlackTargetConfig struct {
WebhookURL string `json:"webhook_url"`
}
// EngineParams are the fx dependencies for the delivery engine.
//
//nolint:revive // EngineParams is a standard fx naming convention
@@ -835,6 +842,8 @@ func (e *Engine) processDelivery(ctx context.Context, webhookDB *gorm.DB, d *dat
e.deliverDatabase(webhookDB, d)
case database.TargetTypeLog:
e.deliverLog(webhookDB, d)
case database.TargetTypeSlack:
e.deliverSlack(webhookDB, d)
default:
e.log.Error("unknown target type",
"target_id", d.TargetID,
@@ -981,6 +990,142 @@ func (e *Engine) deliverLog(webhookDB *gorm.DB, d *database.Delivery) {
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusDelivered)
}
// deliverSlack formats the webhook event as a human-readable Slack message
// and POSTs it to a Slack-compatible incoming webhook URL (works with Slack,
// Mattermost, and other compatible services). The message includes metadata
// (method, content type, timestamp, body size) and the payload pretty-printed
// in a code block if it is valid JSON.
func (e *Engine) deliverSlack(webhookDB *gorm.DB, d *database.Delivery) {
cfg, err := e.parseSlackConfig(d.Target.Config)
if err != nil {
e.log.Error("invalid Slack target config",
"target_id", d.TargetID,
"error", err,
)
e.recordResult(webhookDB, d, 1, false, 0, "", err.Error(), 0)
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
return
}
msg := FormatSlackMessage(&d.Event)
payload, err := json.Marshal(map[string]string{"text": msg})
if err != nil {
e.log.Error("failed to marshal Slack payload",
"target_id", d.TargetID,
"error", err,
)
e.recordResult(webhookDB, d, 1, false, 0, "", err.Error(), 0)
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
return
}
start := time.Now()
req, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, bytes.NewReader(payload))
if err != nil {
e.recordResult(webhookDB, d, 1, false, 0, "", err.Error(), 0)
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "webhooker/1.0")
resp, err := e.client.Do(req)
durationMs := time.Since(start).Milliseconds()
if err != nil {
e.recordResult(webhookDB, d, 1, false, 0, "", fmt.Errorf("sending request: %w", err).Error(), durationMs)
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
return
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, maxBodyLog))
if readErr != nil {
e.log.Error("failed to read Slack response body", "error", readErr)
}
respBody := string(body)
success := resp.StatusCode >= 200 && resp.StatusCode < 300
errMsg := ""
if !success {
errMsg = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
e.recordResult(webhookDB, d, 1, success, resp.StatusCode, respBody, errMsg, durationMs)
if success {
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusDelivered)
} else {
e.updateDeliveryStatus(webhookDB, d, database.DeliveryStatusFailed)
}
}
func (e *Engine) parseSlackConfig(configJSON string) (*SlackTargetConfig, error) {
if configJSON == "" {
return nil, fmt.Errorf("empty target config")
}
var cfg SlackTargetConfig
if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil {
return nil, fmt.Errorf("parsing config JSON: %w", err)
}
if cfg.WebhookURL == "" {
return nil, fmt.Errorf("webhook_url is required")
}
return &cfg, nil
}
// FormatSlackMessage builds a Slack-compatible message string from a webhook
// event. It includes metadata (method, content type, timestamp, body size)
// and pretty-prints the payload in a code block if it is valid JSON.
func FormatSlackMessage(event *database.Event) string {
var b strings.Builder
b.WriteString("*Webhook Event Received*\n")
b.WriteString(fmt.Sprintf("*Method:* `%s`\n", event.Method))
b.WriteString(fmt.Sprintf("*Content-Type:* `%s`\n", event.ContentType))
b.WriteString(fmt.Sprintf("*Timestamp:* `%s`\n", event.CreatedAt.UTC().Format(time.RFC3339)))
b.WriteString(fmt.Sprintf("*Body Size:* %d bytes\n", len(event.Body)))
if event.Body == "" {
b.WriteString("\n_(empty body)_\n")
return b.String()
}
// Try to pretty-print as JSON
var parsed json.RawMessage
if json.Unmarshal([]byte(event.Body), &parsed) == nil {
var pretty bytes.Buffer
if json.Indent(&pretty, parsed, "", " ") == nil {
b.WriteString("\n```\n")
prettyStr := pretty.String()
// Truncate very large payloads to keep Slack messages reasonable
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()
}
}
// Not JSON — show raw body in a plain code block
b.WriteString("\n```\n")
bodyStr := event.Body
const maxRawDisplay = 3500
if len(bodyStr) > maxRawDisplay {
b.WriteString(bodyStr[:maxRawDisplay])
b.WriteString("\n... (truncated)")
} else {
b.WriteString(bodyStr)
}
b.WriteString("\n```\n")
return b.String()
}
// doHTTPRequest performs the outbound HTTP POST to a target URL.
func (e *Engine) doHTTPRequest(cfg *HTTPTargetConfig, event *database.Event) (statusCode int, respBody string, durationMs int64, err error) {
start := time.Now()

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
@@ -934,3 +935,284 @@ func TestMaxInlineBodySize_Constant(t *testing.T) {
assert.Equal(t, 16*1024, MaxInlineBodySize,
"MaxInlineBodySize should be 16KB (16384 bytes)")
}
func TestParseSlackConfig_Valid(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
cfg, err := e.parseSlackConfig(`{"webhook_url":"https://hooks.slack.com/services/T00/B00/xxx"}`)
require.NoError(t, err)
assert.Equal(t, "https://hooks.slack.com/services/T00/B00/xxx", cfg.WebhookURL)
}
func TestParseSlackConfig_Empty(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.parseSlackConfig("")
assert.Error(t, err, "empty config should return error")
}
func TestParseSlackConfig_MissingWebhookURL(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.parseSlackConfig(`{"other":"field"}`)
assert.Error(t, err, "config without webhook_url should return error")
}
func TestFormatSlackMessage_JSONBody(t *testing.T) {
t.Parallel()
event := &database.Event{
Method: "POST",
ContentType: "application/json",
Body: `{"action":"push","repo":"test/repo","ref":"refs/heads/main"}`,
}
event.CreatedAt = time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
msg := FormatSlackMessage(event)
assert.Contains(t, msg, "*Webhook Event Received*")
assert.Contains(t, msg, "`POST`")
assert.Contains(t, msg, "`application/json`")
assert.Contains(t, msg, "```")
assert.NotContains(t, msg, "```json")
// Pretty-printed JSON should have indentation
assert.Contains(t, msg, ` "action": "push"`)
assert.Contains(t, msg, ` "repo": "test/repo"`)
}
func TestFormatSlackMessage_NonJSONBody(t *testing.T) {
t.Parallel()
event := &database.Event{
Method: "POST",
ContentType: "text/plain",
Body: "hello world plain text",
}
event.CreatedAt = time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
msg := FormatSlackMessage(event)
assert.Contains(t, msg, "*Webhook Event Received*")
assert.Contains(t, msg, "```\nhello world plain text\n```")
// Should NOT have ```json marker for non-JSON
assert.NotContains(t, msg, "```json")
}
func TestFormatSlackMessage_EmptyBody(t *testing.T) {
t.Parallel()
event := &database.Event{
Method: "POST",
ContentType: "application/json",
Body: "",
}
event.CreatedAt = time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
msg := FormatSlackMessage(event)
assert.Contains(t, msg, "_(empty body)_")
assert.NotContains(t, msg, "```")
}
func TestFormatSlackMessage_LargeJSONTruncated(t *testing.T) {
t.Parallel()
// Build a large JSON body that will exceed 3500 chars when pretty-printed
largeObj := make(map[string]string)
for i := 0; i < 200; i++ {
largeObj[fmt.Sprintf("key_%03d", i)] = strings.Repeat("v", 20)
}
largeJSON, err := json.Marshal(largeObj)
require.NoError(t, err)
event := &database.Event{
Method: "POST",
ContentType: "application/json",
Body: string(largeJSON),
}
event.CreatedAt = time.Date(2025, 1, 15, 10, 30, 0, 0, time.UTC)
msg := FormatSlackMessage(event)
assert.Contains(t, msg, "... (truncated)")
}
func TestDeliverSlack_Success(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
var receivedBody string
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
bodyBytes, readErr := io.ReadAll(r.Body)
if readErr != nil {
http.Error(w, "read error", http.StatusInternalServerError)
return
}
receivedBody = string(bodyBytes)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
}))
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
slackCfg, err := json.Marshal(SlackTargetConfig{WebhookURL: ts.URL})
require.NoError(t, err)
event := seedEvent(t, db, `{"action":"test","data":"value"}`)
dlv := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending)
d := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-slack",
Type: database.TargetTypeSlack,
Config: string(slackCfg),
},
}
d.ID = dlv.ID
e.deliverSlack(db, d)
// The delivery should be marked as delivered
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", dlv.ID).Error)
assert.Equal(t, database.DeliveryStatusDelivered, updated.Status)
// Check that a result was recorded
var result database.DeliveryResult
require.NoError(t, db.Where("delivery_id = ?", dlv.ID).First(&result).Error)
assert.True(t, result.Success)
assert.Equal(t, http.StatusOK, result.StatusCode)
// Verify the Slack payload contains the expected message
var slackPayload map[string]string
require.NoError(t, json.Unmarshal([]byte(receivedBody), &slackPayload))
assert.Contains(t, slackPayload["text"], "*Webhook Event Received*")
assert.NotContains(t, slackPayload["text"], "**Webhook Event Received**")
assert.Contains(t, slackPayload["text"], "```")
assert.NotContains(t, slackPayload["text"], "```json")
}
func TestDeliverSlack_Failure(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
fmt.Fprint(w, "invalid_token")
}))
defer ts.Close()
e := testEngine(t, 1)
targetID := uuid.New().String()
slackCfg, err := json.Marshal(SlackTargetConfig{WebhookURL: ts.URL})
require.NoError(t, err)
event := seedEvent(t, db, `{"test":true}`)
dlv := seedDelivery(t, db, event.ID, targetID, database.DeliveryStatusPending)
d := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-slack-fail",
Type: database.TargetTypeSlack,
Config: string(slackCfg),
},
}
d.ID = dlv.ID
e.deliverSlack(db, d)
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", dlv.ID).Error)
assert.Equal(t, database.DeliveryStatusFailed, updated.Status)
var result database.DeliveryResult
require.NoError(t, db.Where("delivery_id = ?", dlv.ID).First(&result).Error)
assert.False(t, result.Success)
assert.Equal(t, http.StatusForbidden, result.StatusCode)
}
func TestDeliverSlack_InvalidConfig(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{"test":true}`)
dlv := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending)
d := &database.Delivery{
EventID: event.ID,
TargetID: dlv.TargetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-slack-bad",
Type: database.TargetTypeSlack,
Config: `{"not_webhook_url":"missing"}`,
},
}
d.ID = dlv.ID
e.deliverSlack(db, d)
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", dlv.ID).Error)
assert.Equal(t, database.DeliveryStatusFailed, updated.Status)
}
func TestProcessDelivery_RoutesToSlack(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
var received atomic.Bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
received.Store(true)
w.WriteHeader(http.StatusOK)
}))
defer ts.Close()
e := testEngine(t, 1)
slackCfg, err := json.Marshal(SlackTargetConfig{WebhookURL: ts.URL})
require.NoError(t, err)
event := seedEvent(t, db, `{"route":"slack"}`)
dlv := seedDelivery(t, db, event.ID, uuid.New().String(), database.DeliveryStatusPending)
d := &database.Delivery{
EventID: event.ID,
TargetID: dlv.TargetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "test-slack-route",
Type: database.TargetTypeSlack,
Config: string(slackCfg),
},
}
d.ID = dlv.ID
task := &DeliveryTask{
DeliveryID: dlv.ID,
TargetType: database.TargetTypeSlack,
}
e.processDelivery(context.TODO(), db, d, task)
assert.True(t, received.Load(), "Slack target should have received the request")
var updated database.Delivery
require.NoError(t, db.First(&updated, "id = ?", dlv.ID).Error)
assert.Equal(t, database.DeliveryStatusDelivered, updated.Status)
}