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 **Scope:** Circuit breakers only apply to **HTTP targets with
`max_retries` > 0**. Fire-and-forget HTTP targets (`max_retries` == 0), `max_retries` > 0**. Fire-and-forget HTTP targets (`max_retries` == 0),
Slack targets, database targets (local operations), and log targets (stdout) do not use Slack targets, database targets (local operations), and log
circuit breakers. targets (stdout) do not use circuit breakers.
When a circuit is open and a new delivery arrives, the engine marks the 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 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 { func FormatSlackMessage(event *database.Event) string {
var b strings.Builder var b strings.Builder
b.WriteString("**Webhook Event Received**\n") b.WriteString("*Webhook Event Received*\n")
b.WriteString(fmt.Sprintf("**Method:** `%s`\n", event.Method)) b.WriteString(fmt.Sprintf("*Method:* `%s`\n", event.Method))
b.WriteString(fmt.Sprintf("**Content-Type:** `%s`\n", event.ContentType)) 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("*Timestamp:* `%s`\n", event.CreatedAt.UTC().Format(time.RFC3339)))
b.WriteString(fmt.Sprintf("**Body Size:** %d bytes\n", len(event.Body))) b.WriteString(fmt.Sprintf("*Body Size:* %d bytes\n", len(event.Body)))
if event.Body == "" { if event.Body == "" {
b.WriteString("\n_(empty body)_\n") b.WriteString("\n_(empty body)_\n")
@@ -1096,7 +1096,7 @@ func FormatSlackMessage(event *database.Event) string {
if json.Unmarshal([]byte(event.Body), &parsed) == nil { if json.Unmarshal([]byte(event.Body), &parsed) == nil {
var pretty bytes.Buffer var pretty bytes.Buffer
if json.Indent(&pretty, parsed, "", " ") == nil { if json.Indent(&pretty, parsed, "", " ") == nil {
b.WriteString("\n```json\n") b.WriteString("\n```\n")
prettyStr := pretty.String() prettyStr := pretty.String()
// Truncate very large payloads to keep Slack messages reasonable // Truncate very large payloads to keep Slack messages reasonable
const maxPayloadDisplay = 3500 const maxPayloadDisplay = 3500

View File

@@ -973,10 +973,11 @@ func TestFormatSlackMessage_JSONBody(t *testing.T) {
msg := FormatSlackMessage(event) 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, "`POST`")
assert.Contains(t, msg, "`application/json`") 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 // Pretty-printed JSON should have indentation
assert.Contains(t, msg, ` "action": "push"`) assert.Contains(t, msg, ` "action": "push"`)
assert.Contains(t, msg, ` "repo": "test/repo"`) assert.Contains(t, msg, ` "repo": "test/repo"`)
@@ -994,7 +995,7 @@ func TestFormatSlackMessage_NonJSONBody(t *testing.T) {
msg := FormatSlackMessage(event) 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```") assert.Contains(t, msg, "```\nhello world plain text\n```")
// Should NOT have ```json marker for non-JSON // Should NOT have ```json marker for non-JSON
assert.NotContains(t, msg, "```json") assert.NotContains(t, msg, "```json")
@@ -1094,8 +1095,10 @@ func TestDeliverSlack_Success(t *testing.T) {
// Verify the Slack payload contains the expected message // Verify the Slack payload contains the expected message
var slackPayload map[string]string var slackPayload map[string]string
require.NoError(t, json.Unmarshal([]byte(receivedBody), &slackPayload)) require.NoError(t, json.Unmarshal([]byte(receivedBody), &slackPayload))
assert.Contains(t, slackPayload["text"], "**Webhook Event Received**") assert.Contains(t, slackPayload["text"], "*Webhook Event Received*")
assert.Contains(t, slackPayload["text"], "```json") 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) { func TestDeliverSlack_Failure(t *testing.T) {

View File

@@ -98,14 +98,14 @@
</select> </select>
</div> </div>
<div x-show="targetType === 'http'"> <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>
<div x-show="targetType === 'http'" class="flex gap-2 items-center"> <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> <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"> <input type="number" name="max_retries" value="0" min="0" max="20" class="input text-sm w-24">
</div> </div>
<div x-show="targetType === 'slack'"> <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> <p class="text-xs text-gray-500 mt-1">Slack or Mattermost incoming webhook URL. Payloads are pretty-printed in code blocks.</p>
</div> </div>
<button type="submit" class="btn-primary text-sm">Add Target</button> <button type="submit" class="btn-primary text-sm">Add Target</button>