feat: add Slack target type for incoming webhook notifications #47

Merged
sneak merged 2 commits from feature/slack-target-type into main 2026-03-17 12:30:50 +01:00
4 changed files with 18 additions and 15 deletions
Showing only changes of commit a735252ffa - Show all commits

View File

@@ -611,8 +611,8 @@ fine — startup recovery rescans the database anyway).
**Scope:** Circuit breakers only apply to **HTTP targets with
`max_retries` > 0**. Fire-and-forget HTTP targets (`max_retries` == 0),
Slack targets, database targets (local operations), and log targets (stdout) do not use
circuit breakers.
Slack targets, database targets (local operations), and log
targets (stdout) do not use circuit breakers.
When a circuit is open and a new delivery arrives, the engine marks the
delivery as `retrying` and schedules a retry timer for after the

View File

@@ -1080,11 +1080,11 @@ func (e *Engine) parseSlackConfig(configJSON string) (*SlackTargetConfig, error)
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)))
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")
@@ -1096,7 +1096,7 @@ func FormatSlackMessage(event *database.Event) string {
if json.Unmarshal([]byte(event.Body), &parsed) == nil {
var pretty bytes.Buffer
if json.Indent(&pretty, parsed, "", " ") == nil {
b.WriteString("\n```json\n")
b.WriteString("\n```\n")
prettyStr := pretty.String()
// Truncate very large payloads to keep Slack messages reasonable
const maxPayloadDisplay = 3500

View File

@@ -973,10 +973,11 @@ func TestFormatSlackMessage_JSONBody(t *testing.T) {
msg := FormatSlackMessage(event)
assert.Contains(t, msg, "**Webhook Event Received**")
assert.Contains(t, msg, "*Webhook Event Received*")
assert.Contains(t, msg, "`POST`")
assert.Contains(t, msg, "`application/json`")
assert.Contains(t, msg, "```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"`)
@@ -994,7 +995,7 @@ func TestFormatSlackMessage_NonJSONBody(t *testing.T) {
msg := FormatSlackMessage(event)
assert.Contains(t, msg, "**Webhook Event Received**")
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")
@@ -1094,8 +1095,10 @@ func TestDeliverSlack_Success(t *testing.T) {
// 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.Contains(t, slackPayload["text"], "```json")
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) {

View File

@@ -98,14 +98,14 @@
</select>
</div>
<div x-show="targetType === 'http'">
<input type="url" name="url" placeholder="https://example.com/webhook" class="input text-sm">
<input type="url" name="url" placeholder="https://example.com/webhook" :disabled="targetType !== 'http'" class="input text-sm">
</div>
<div x-show="targetType === 'http'" class="flex gap-2 items-center">
<label class="text-sm text-gray-700">Max retries (0 = fire-and-forget):</label>
<input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
</div>
<div x-show="targetType === 'slack'">
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." class="input text-sm">
<input type="url" name="url" placeholder="https://hooks.slack.com/services/..." :disabled="targetType !== 'slack'" class="input text-sm">
<p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
</div>
<button type="submit" class="btn-primary text-sm">Add Target</button>