Skip to content

Commit

Permalink
Merge pull request #236 from louis77/discord-webhook
Browse files Browse the repository at this point in the history
Add support for Discord webhook
  • Loading branch information
bart6114 authored Dec 23, 2024
2 parents b721a80 + 771c092 commit 0ce1d4e
Show file tree
Hide file tree
Showing 5 changed files with 166 additions and 68 deletions.
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ jobs:
- https://webhook.site/4b732eb4-ba10-4a84-8f6b-30167b2f2762
notify_slack_webhook: # notify slack via a slack compatible webhook
- https://webhook.site/048ff47f-9ef5-43fb-9375-a795a8c5cbf5
notify_discord_webhook: # notify discord via a discord compatible webhook
- https://discord.com/api/webhooks/user/token
```
If your `command` requires arguments, please make sure to pass them as an array like in `foo_job`.
Expand Down Expand Up @@ -103,7 +105,7 @@ Configuration can be passed as flags to the `cheek` CLI directly. All configurat

## Events & Notifications

There are two types of event you can hook into: `on_success` and `on_error`. Both events materialize after an (attempted) job run. Three types of actions can be taken as a response: `notify_webhook`, `notify_slack_webhook` and `trigger_job`. See the example below. Definition of these event actions can be done on job level or at schedule level, in the latter case it will apply to all jobs.
There are two types of event you can hook into: `on_success` and `on_error`. Both events materialize after an (attempted) job run. Three types of actions can be taken as a response: `notify_webhook`, `notify_slack_webhook`, `notify_slack_webhook` and `trigger_job`. See the example below. Definition of these event actions can be done on job level or at schedule level, in the latter case it will apply to all jobs.

```yaml
on_success:
Expand All @@ -121,7 +123,7 @@ jobs:
cron: "* * * * *"
```

Webhooks are a generic way to push notifications to a plethora of tools. There is a generic way to do this via the `notify_webhook` option or a Slack-compatible one via `notify_slack_webhook`.
Webhooks are a generic way to push notifications to a plethora of tools. There is a generic way to do this via the `notify_webhook` option or a Slack-compatible one via `notify_slack_webhook` or a Discord-compatible one via `notify_discord_webhook`.

The `notify_webhook` sends a JSON payload to your webhook url with the following structure:

Expand All @@ -144,6 +146,14 @@ The `notify_slack_webhook` sends a JSON payload to your Slack webhook url with t
}
```

The `notify_discord_webhook` sends a JSON payload to your Discord webhook url with the following structure (which is Discord app compatible):

```json
{
"content": "TeapotTask (exitcode 0):\nI'm a teapot, not a coffee machine!"
}
```


## Docker

Expand Down
62 changes: 27 additions & 35 deletions pkg/job.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ const (

// OnEvent contains specs on what needs to happen after a job event.
type OnEvent struct {
TriggerJob []string `yaml:"trigger_job,omitempty" json:"trigger_job,omitempty"`
NotifyWebhook []string `yaml:"notify_webhook,omitempty" json:"notify_webhook,omitempty"`
NotifySlackWebhook []string `yaml:"notify_slack_webhook,omitempty" json:"notify_slack_webhook,omitempty"`
TriggerJob []string `yaml:"trigger_job,omitempty" json:"trigger_job,omitempty"`
NotifyWebhook []string `yaml:"notify_webhook,omitempty" json:"notify_webhook,omitempty"`
NotifySlackWebhook []string `yaml:"notify_slack_webhook,omitempty" json:"notify_slack_webhook,omitempty"`
NotifyDiscordWebhook []string `yaml:"notify_discord_webhook,omitempty" json:"notify_discord_webhook,omitempty"`
}

// JobSpec holds specifications and metadata of a job.
Expand Down Expand Up @@ -339,27 +340,32 @@ func (j *JobSpec) ValidateCron() error {

func (j *JobSpec) OnEvent(jr *JobRun) {
var jobsToTrigger []string
var webhooksToCall []string
var slackWebhooksToCall []string
var webhooksToCall []webhook
var events []OnEvent

switch *jr.Status == StatusOK {
case true: // after success
jobsToTrigger = j.OnSuccess.TriggerJob
webhooksToCall = j.OnSuccess.NotifyWebhook
slackWebhooksToCall = j.OnSuccess.NotifySlackWebhook
events = append(events, j.OnSuccess)
if j.globalSchedule != nil {
jobsToTrigger = append(jobsToTrigger, j.globalSchedule.OnSuccess.TriggerJob...)
webhooksToCall = append(webhooksToCall, j.globalSchedule.OnSuccess.NotifyWebhook...)
slackWebhooksToCall = append(slackWebhooksToCall, j.globalSchedule.OnSuccess.NotifySlackWebhook...)
events = append(events, j.globalSchedule.OnSuccess)
}
case false: // after error
jobsToTrigger = j.OnError.TriggerJob
webhooksToCall = j.OnError.NotifyWebhook
slackWebhooksToCall = j.OnError.NotifySlackWebhook
events = append(events, j.OnError)
if j.globalSchedule != nil {
jobsToTrigger = append(jobsToTrigger, j.globalSchedule.OnError.TriggerJob...)
webhooksToCall = append(webhooksToCall, j.globalSchedule.OnError.NotifyWebhook...)
slackWebhooksToCall = append(slackWebhooksToCall, j.globalSchedule.OnError.NotifySlackWebhook...)
events = append(events, j.globalSchedule.OnError)
}
}

for _, e := range events {
jobsToTrigger = append(jobsToTrigger, e.TriggerJob...)
for _, whURL := range e.NotifyWebhook {
webhooksToCall = append(webhooksToCall, NewDefaultWebhook(whURL))
}
for _, whURL := range e.NotifySlackWebhook {
webhooksToCall = append(webhooksToCall, NewSlackWebhook(whURL))
}
for _, whURL := range e.NotifyDiscordWebhook {
webhooksToCall = append(webhooksToCall, NewDiscordWebhook(whURL))
}
}

Expand All @@ -377,29 +383,15 @@ func (j *JobSpec) OnEvent(jr *JobRun) {

// trigger webhooks
for _, wu := range webhooksToCall {
j.log.Debug().Str("job", j.Name).Str("on_event", "webhook_call").Msg("triggered by parent job")
wg.Add(1)
go func(wg *sync.WaitGroup, webhookURL string) {
defer wg.Done()
resp_body, err := JobRunWebhookCall(jr, webhookURL, "generic")
if err != nil {
j.log.Warn().Str("job", j.Name).Str("on_event", "webhook").Err(err).Msg("webhook notify failed")
}
j.log.Debug().Str("job", jr.Name).Str("webhook_call", "response").Str("webhook_url", webhookURL).Msg(string(resp_body))
}(&wg, wu)
}

// trigger slack webhooks - this feels like a lot of duplication
for _, wu := range slackWebhooksToCall {
j.log.Debug().Str("job", j.Name).Str("on_event", "slack_webhook_call").Msg("triggered by parent job")
j.log.Debug().Str("job", j.Name).Str("on_event", wu.Name()+"_webhook_call").Msg("triggered by parent job")
wg.Add(1)
go func(wg *sync.WaitGroup, webhookURL string) {
go func(wg *sync.WaitGroup, wu webhook) {
defer wg.Done()
resp_body, err := JobRunWebhookCall(jr, webhookURL, "slack")
resp_body, err := wu.Call(jr)
if err != nil {
j.log.Warn().Str("job", j.Name).Str("on_event", "webhook").Err(err).Msg("webhook notify failed")
}
j.log.Debug().Str("job", jr.Name).Str("webhook_call", "response").Str("webhook_url", webhookURL).Msg(string(resp_body))
j.log.Debug().Str("job", jr.Name).Str("webhook_call", "response").Str("webhook_url", wu.URL()).Msg(string(resp_body))
}(&wg, wu)
}

Expand Down
128 changes: 113 additions & 15 deletions pkg/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,81 @@ import (
"net/http"
)

type slackPayload struct {
Text string `json:"text"`
type webhook interface {
Call(jr *JobRun) ([]byte, error)
URL() string
Name() string
}

func JobRunWebhookCall(jr *JobRun, webhookURL string, webhookType string) ([]byte, error) {
// Discord Webhook

type discordWebhook struct {
endpoint string
}

func NewDiscordWebhook(endpoint string) discordWebhook {
return discordWebhook{endpoint}
}

func (dw discordWebhook) Call(jr *JobRun) ([]byte, error) {
type discordPayload struct {
Content string `json:"content"`
}
payload := bytes.Buffer{}
msg := fmt.Sprintf("%s (exitcode %v):\n%s", jr.Name, *jr.Status, jr.Log)
d := discordPayload{
Content: msg[:min(len(msg), 2000)], // discord accepts a max. of 2000 chars
}
if err := json.NewEncoder(&payload).Encode(d); err != nil {
return nil, err
}

if webhookType == "slack" {
d := slackPayload{
Text: fmt.Sprintf("%s (exitcode %v):\n%s", jr.Name, *jr.Status, jr.Log),
}
resp, err := http.Post(dw.endpoint, "application/json", bytes.NewBuffer(payload.Bytes()))
if err != nil {
return []byte{}, err
}
defer resp.Body.Close()

if err := json.NewEncoder(&payload).Encode(d); err != nil {
return []byte{}, err
}
resp_body, err := io.ReadAll(resp.Body)
if err != nil {
return []byte{}, err
}

} else {
if err := json.NewEncoder(&payload).Encode(jr); err != nil {
return []byte{}, err
}
return resp_body, nil
}

func (dw discordWebhook) URL() string {
return dw.endpoint
}

func (dw discordWebhook) Name() string {
return "discord"
}

// Slack Webhook

type slackWebhook struct {
endpoint string
}

func NewSlackWebhook(endpoint string) slackWebhook {
return slackWebhook{endpoint}
}

func (dw slackWebhook) Call(jr *JobRun) ([]byte, error) {
type slackPayload struct {
Text string `json:"text"`
}
payload := bytes.Buffer{}
msg := fmt.Sprintf("%s (exitcode %v):\n%s", jr.Name, *jr.Status, jr.Log)
d := slackPayload{
Text: msg[:min(len(msg), 40000)], // slack accepts a max. of 40000 chars
}
if err := json.NewEncoder(&payload).Encode(d); err != nil {
return nil, err
}

resp, err := http.Post(webhookURL, "application/json", bytes.NewBuffer(payload.Bytes()))
resp, err := http.Post(dw.endpoint, "application/json", bytes.NewBuffer(payload.Bytes()))
if err != nil {
return []byte{}, err
}
Expand All @@ -43,3 +95,49 @@ func JobRunWebhookCall(jr *JobRun, webhookURL string, webhookType string) ([]byt

return resp_body, nil
}

func (dw slackWebhook) URL() string {
return dw.endpoint
}

func (dw slackWebhook) Name() string {
return "slack"
}

// Default Webhook

type defaultWebhook struct {
endpoint string
}

func NewDefaultWebhook(endpoint string) defaultWebhook {
return defaultWebhook{endpoint}
}

func (dw defaultWebhook) Call(jr *JobRun) ([]byte, error) {
payload := bytes.Buffer{}
if err := json.NewEncoder(&payload).Encode(jr); err != nil {
return []byte{}, err
}

resp, err := http.Post(dw.endpoint, "application/json", bytes.NewBuffer(payload.Bytes()))
if err != nil {
return []byte{}, err
}
defer resp.Body.Close()

resp_body, err := io.ReadAll(resp.Body)
if err != nil {
return []byte{}, err
}

return resp_body, nil
}

func (dw defaultWebhook) URL() string {
return dw.endpoint
}

func (dw defaultWebhook) Name() string {
return "generic"
}
28 changes: 12 additions & 16 deletions pkg/webhook_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package cheek

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
Expand All @@ -24,7 +22,7 @@ func TestJobRunWebhookCall(t *testing.T) {
}
// mirror this
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, string(body))
fmt.Fprint(w, string(body))
t.Log(string(body))
}))

Expand All @@ -38,23 +36,21 @@ func TestJobRunWebhookCall(t *testing.T) {
Log: "this is a random log statement\nwith multiple lines\nand stuff",
}

resp_body, err = JobRunWebhookCall(&jr, testServer.URL, "generic")
var wh webhook
wh = NewDefaultWebhook(testServer.URL)
resp_body, err = wh.Call(&jr)
assert.NoError(t, err)

jr2 := JobRun{}
err = json.NewDecoder(bytes.NewBuffer(resp_body)).Decode(&jr2)
assert.NoError(t, err)

assert.Equal(t, jr, jr2)
assert.Contains(t, string(resp_body), `{"status":0,"log":"this is a random log statement\nwith multiple lines\nand stuff","name":"test","triggered_at":"0001-01-01T00:00:00Z","triggered_by":"cron"}`)

// test slack webhook
resp_body, err = JobRunWebhookCall(&jr, testServer.URL, "slack")
wh = NewSlackWebhook(testServer.URL)
resp_body, err = wh.Call(&jr)
assert.NoError(t, err)
assert.Contains(t, string(resp_body), "text\":\"test (exitcode 0)")
assert.Contains(t, string(resp_body), `{"text":"test (exitcode 0):\nthis is a random log statement\nwith multiple lines\nand stuff"}`)

sl := slackPayload{}
err = json.NewDecoder(bytes.NewBuffer(resp_body)).Decode(&sl)
// test discord webhook
wh = NewDiscordWebhook(testServer.URL)
resp_body, err = wh.Call(&jr)
assert.NoError(t, err)
assert.NotEmpty(t, sl.Text)

assert.Contains(t, string(resp_body), `{"content":"test (exitcode 0):\nthis is a random log statement\nwith multiple lines\nand stuff"}`)
}
2 changes: 2 additions & 0 deletions testdata/readme_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,5 @@ jobs:
on_error:
notify_webhook: # notify something on error
- https://webhook.site/4b732eb4-ba10-4a84-8f6b-30167b2f2762
notify_slack_webhook: # notify slack via a slack compatible webhook
- https://webhook.site/048ff47f-9ef5-43fb-9375-a795a8c5cbf5

0 comments on commit 0ce1d4e

Please sign in to comment.