Skip to content

Commit

Permalink
Merge pull request #98 from davidnewhall/dn2_discord_webhook
Browse files Browse the repository at this point in the history
Add discord and slack direct webhook support.
  • Loading branch information
davidnewhall authored Feb 21, 2021
2 parents 9a44af5 + 84b3124 commit 18887fa
Show file tree
Hide file tree
Showing 12 changed files with 359 additions and 83 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,16 @@ Works great with [notifiarr.com](https://notifiarr.com). You can use
|---|---|---|
webhook.url|`UN_WEBHOOK_0_URL`|No Default; URL to send POST webhook to|
webhook.name|`UN_WEBHOOK_0_NAME`|Defaults to URL; provide an optional name to hide the URL in logs|
webhook.nickname|`UN_WEBHOOK_0_NICKNAME`|`Unpackerr` / Passed into templates for discord.com and slack.com webhooks|
webhook.channel|`UN_WEBHOOK_0_CHANNEL`|`""` / Passed into templates for slack.com webhooks|
webhook.timeout|`UN_WEBHOOK_0_TIMEOUT`|Defaults to global timeout, usually `10s`|
webhook.silent|`UN_WEBHOOK_0_SILENT`|`false` / Hide successful POSTs from logs|
webhook.ignore_ssl|`UN_WEBHOOK_0_IGNORE_SSL`|`false` / Ignore invalid SSL certificates|
webhook.exclude|`UN_WEBHOOK_0_EXCLUDE`|`[]` / List of apps to exclude: radarr, sonarr, folders, etc|
webhook.events|`UN_WEBHOOK_0_EVENTS`|`[0]` / List of event IDs to send (shown below)|
webhook.template_path|`UN_WEBHOOK_0_TEMPLATE_PATH`|`""` / Instead of internal template, provide your own|
webhook.content_type|`UN_WEBHOOK_0_CONTENT_TYPE`|`application/json` / Content-Type header sent to webhook|


Event IDs (not all of these are used in webhooks): `0` = all,
`1` = queued, `2` = extracting, `3` = extract failed, `4` = extracted,
Expand Down
5 changes: 4 additions & 1 deletion examples/MANUAL.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
unpackerr(1) -- Utility Unpack compressed files for importing by Sonarr and Radarr.
unpackerr(1) -- Unpack compressed files for importing by Starr applications.
===

SYNOPSIS
Expand Down Expand Up @@ -30,6 +30,9 @@ OPTIONS
This sends a webhook of the type specified then exits. This is only
for testing and development. This requires a valid webhook configured
in a config file or from environment variables.
Event IDs (not all of these are used in webhooks): 0 = all
1 = queued, 2 = extracting, 3 = extract failed, 4 = extracted
5 = imported, 6 = deleting, 7 = delete failed, 8 = deleted

-v, --version
Display version and exit.
Expand Down
23 changes: 16 additions & 7 deletions examples/unpackerr.conf.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## Unpackerr Example Configuration File ##
## The following values are application defaults. ##
## Environment Variables may override all values. ##
####################################################

# [true/false] Turn on debug messages in the output. Do not wrap this in quotes.
Expand Down Expand Up @@ -53,14 +54,22 @@ dir_mode = "0755"
### Webhooks ###
################
# Sends a webhook when an extraction starts and again when it finishes.
# Created to integrate with discordnotifier.com.
# Can possibly be used with other services. Don't forget to uncomment [[webhook]].
# Created to integrate with notifiarr.com. Also works natively with Discord.com and Slack.com webhooks.
# Can possibly be used with other services by providing a custom template_path.
###### Don't forget to uncomment [[webhook]] and url at a minimum !!!!
#[[webhook]]
# url = "https://discordnotifier.com/notifier.php?api=abcdef-ghijklmn-op"
# name = "" # Set this to hide the URL in logs.
# silent = false # do not log success (less log spam)
# events = [0] # list of event ids to include, 0 == all.
# exclude = [] # list of apps to exclude, ie. ["radarr", "lidarr"]
# url = "https://discordnotifier.com/notifier.php?api=abcdef-ghijklmn-op"
# name = "" # Set this to hide the URL in logs.
# silent = false # do not log success (less log spam)
# events = [0] # list of event ids to include, 0 == all.
## Advanced Optional Webhook Configuration
# nickname = "" # Passed into templates. Used in Discord and Slack templates as bot name.
# channel = "" # Passed into templates. Used in Slack templates for destination channel.
# exclude = [] # list of apps to exclude, ie. ["radarr", "lidarr"]
# template_path = "" # Override internal webhook template for discord.com or other hooks.
# ignore_ssl = false # Set this to true to ignore the SSL certificate on the server.
# timeout = "10s" # You can adjust how long to wait for a server response.
# content_type = "application/json" # If your custom template uses another MIME type, set this.

################
### Episodes ###
Expand Down
3 changes: 2 additions & 1 deletion pkg/unpackerr/folder.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,10 @@ func (u *Unpackerr) updateQueueStatus(data *newStatus) *Extract {
// Arr apps do not land here. They create their own queued items in u.Map.
u.Map[data.Name] = &Extract{
Path: data.Name,
App: "Unknown",
App: "Folder",
Status: QUEUED,
Updated: time.Now(),
IDs: map[string]interface{}{"title": data.Name}, // required or webhook may break.
}
u.sendWebhooks(u.Map[data.Name])

Expand Down
7 changes: 6 additions & 1 deletion pkg/unpackerr/lidarr.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,12 @@ func (u *Unpackerr) checkLidarrQueue() {
DeleteOrig: server.DeleteOrig,
DeleteDelay: server.DeleteDelay.Duration,
Path: u.getDownloadPath(q.StatusMessages, Lidarr, q.Title, server.Paths),
IDs: map[string]interface{}{"artistId": q.ArtistID, "albumId": q.AlbumID, "downloadId": q.DownloadID},
IDs: map[string]interface{}{
"title": q.Title,
"artistId": q.ArtistID,
"albumId": q.AlbumID,
"downloadId": q.DownloadID,
},
})

fallthrough
Expand Down
18 changes: 11 additions & 7 deletions pkg/unpackerr/radarr.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,17 +96,21 @@ func (u *Unpackerr) checkRadarrQueue() {
case ok && x.Status == EXTRACTED && u.isComplete(q.Status, q.Protocol, server.Protocols):
u.Debugf("%s (%s): Item Waiting for Import (%s): %v", Radarr, server.URL, q.Protocol, q.Title)
case (!ok || x.Status < QUEUED) && u.isComplete(q.Status, q.Protocol, server.Protocols):
u.handleCompletedDownload(q.Title, &Extract{
x := &Extract{
App: Radarr,
DeleteOrig: server.DeleteOrig,
DeleteDelay: server.DeleteDelay.Duration,
Path: u.getDownloadPath(q.StatusMessages, Radarr, q.Title, server.Paths),
IDs: map[string]interface{}{
"tmdbId": q.Movie.TmdbID,
"imdbId": q.Movie.ImdbID,
"downloadId": q.DownloadID,
},
})
IDs: map[string]interface{}{"downloadId": q.DownloadID, "title": q.Title},
}

if q.Movie != nil {
x.IDs["title"] = q.Movie.Title
x.IDs["tmdbId"] = q.Movie.TmdbID
x.IDs["imdbId"] = q.Movie.ImdbID
}

u.handleCompletedDownload(q.Title, x)

fallthrough
default:
Expand Down
7 changes: 6 additions & 1 deletion pkg/unpackerr/readarr.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,12 @@ func (u *Unpackerr) checkReadarrQueue() {
DeleteOrig: server.DeleteOrig,
DeleteDelay: server.DeleteDelay.Duration,
Path: u.getDownloadPath(q.StatusMessages, Readarr, q.Title, server.Paths),
IDs: map[string]interface{}{"authorId": q.AuthorID, "bookId": q.BookID, "downloadId": q.DownloadID},
IDs: map[string]interface{}{
"title": q.Title,
"authorId": q.AuthorID,
"bookId": q.BookID,
"downloadId": q.DownloadID,
},
})

fallthrough
Expand Down
9 changes: 7 additions & 2 deletions pkg/unpackerr/sonarr.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,13 @@ func (u *Unpackerr) checkSonarrQueue() {
DeleteDelay: server.DeleteDelay.Duration,
Path: u.getDownloadPath(q.StatusMessages, Sonarr, q.Title, server.Paths),
IDs: map[string]interface{}{
"tvdbId": q.Series.TvdbID, "imdbId": q.Series.ImdbID, "downloadId": q.DownloadID,
"seriesId": q.Episode.SeriesID, "tvRageId": q.Series.TvRageID, "tvMazeId": q.Series.TvMazeID,
"title": q.Title,
"tvdbId": q.Series.TvdbID,
"imdbId": q.Series.ImdbID,
"downloadId": q.DownloadID,
"seriesId": q.Episode.SeriesID,
"tvRageId": q.Series.TvRageID,
"tvMazeId": q.Series.TvMazeID,
},
})

Expand Down
116 changes: 61 additions & 55 deletions pkg/unpackerr/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"runtime"
Expand All @@ -21,48 +21,25 @@ import (
type WebhookConfig struct {
Name string `json:"name" toml:"name" xml:"name" yaml:"name"`
URL string `json:"url" toml:"url" xml:"url" yaml:"url"`
CType string `json:"content_type" toml:"content_type" xml:"content_type" yaml:"content_type"`
TmplPath string `json:"template_path" toml:"template_path" xml:"template_path" yaml:"template_path"`
Timeout cnfg.Duration `json:"timeout" toml:"timeout" xml:"timeout" yaml:"timeout"`
IgnoreSSL bool `json:"ignore_ssl" toml:"ignore_ssl" xml:"ignore_ssl" yaml:"ignore_ssl"`
Silent bool `json:"silent" toml:"silent" xml:"silent" yaml:"silent"`
Events []ExtractStatus `json:"events" toml:"events" xml:"events" yaml:"events"`
Exclude []string `json:"exclude" toml:"exclude" xml:"exclude" yaml:"exclude"`
Nickname string `json:"nickname" toml:"nickname" xml:"nickname" yaml:"nickname"`
Channel string `json:"channel" toml:"channel" xml:"channel" yaml:"channel"`
client *http.Client
fails uint
posts uint
sync.Mutex `json:"-"`
sync.Mutex `json:"-" toml:"-" xml:"-" yaml:"-"`
}

// WebhookPayload defines the data sent to outbound webhooks.
type WebhookPayload struct {
Path string `json:"path"` // Path for the extracted item.
App string `json:"app"` // Application Triggering Event
IDs map[string]interface{} `json:"ids,omitempty"` // Arbitrary IDs from each app.
Event ExtractStatus `json:"unpackerr_eventtype"` // The type of the event.
Time time.Time `json:"time"` // Time of this event.
Data *XtractPayload `json:"data,omitempty"` // Payload from extraction process.
// Application Metadata.
Go string `json:"go_version"` // Version of go compiled with
OS string `json:"os"` // Operating system: linux, windows, darwin
Arch string `json:"arch"` // Architecture: amd64, armhf
Version string `json:"version"` // Application Version
Revision string `json:"revision"` // Application Revision
Branch string `json:"branch"` // Branch built from.
Started time.Time `json:"started"` // App start time.
}

// XtractPayload is a rewrite of xtractr.Response.
type XtractPayload struct {
Error string `json:"error,omitempty"` // error only during extractfailed
Archives []string `json:"archives,omitempty"` // list of all archive files extracted
Files []string `json:"files,omitempty"` // list of all files extracted
Start time.Time `json:"start,omitempty"` // start time of extraction
Output string `json:"tmp_folder,omitempty"` // temporary items folder
Bytes int64 `json:"bytes,omitempty"` // Bytes written
Elapsed float64 `json:"elapsed,omitempty"` // Duration in seconds
}

// ErrInvalidStatus is an error message.
var ErrInvalidStatus = fmt.Errorf("invalid HTTP status reply")
// Errors produced by this file.
var (
ErrInvalidStatus = fmt.Errorf("invalid HTTP status reply")
)

func (u *Unpackerr) sendWebhooks(i *Extract) {
if i.Status == IMPORTED && i.App == FolderString {
Expand Down Expand Up @@ -93,7 +70,8 @@ func (u *Unpackerr) sendWebhooks(i *Extract) {
Start: i.Resp.Started,
Output: i.Resp.Output,
Bytes: i.Resp.Size,
Elapsed: i.Resp.Elapsed.Seconds(),
Queue: i.Resp.Queued,
Elapsed: cnfg.Duration{Duration: i.Resp.Elapsed},
}

if i.Resp.Error != nil {
Expand All @@ -111,23 +89,35 @@ func (u *Unpackerr) sendWebhooks(i *Extract) {
}

func (u *Unpackerr) sendWebhookWithLog(hook *WebhookConfig, payload *WebhookPayload) {
if body, err := hook.Send(payload); err != nil {
tmpl, err := hook.Template()
if err != nil {
u.Printf("[ERROR] Webhook Template (%s = %s): %v", payload.Path, payload.Event, err)
}

var body bytes.Buffer
if err = tmpl.Execute(&body, payload); err != nil {
u.Printf("[ERROR] Webhook Payload (%s = %s): %v", payload.Path, payload.Event, err)
} else /*
// This is for testing payload output.
log.Print(string(body.Bytes()))
return /**/
if reply, err := hook.Send(&body); err != nil {
u.Printf("[ERROR] Webhook (%s = %s): %v", payload.Path, payload.Event, err)
} else if !hook.Silent {
u.Printf("[Webhook] Posted Payload (%s = %s): %s: 200 OK", payload.Path, payload.Event, hook.Name)
u.Debugf("[DEBUG] Webhook Response: %s", string(bytes.ReplaceAll(body, []byte{'\n'}, []byte{' '})))
u.Printf("[Webhook] Posted Payload (%s = %s): %s: OK", payload.Path, payload.Event, hook.Name)
u.Debugf("[DEBUG] Webhook Response: %s", string(bytes.ReplaceAll(reply, []byte{'\n'}, []byte{' '})))
}
}

// Send marshals an interface{} into json and POSTs it to a URL.
func (w *WebhookConfig) Send(i interface{}) ([]byte, error) {
func (w *WebhookConfig) Send(body io.Reader) ([]byte, error) {
w.Lock()
defer w.Unlock()

ctx, cancel := context.WithTimeout(context.Background(), w.Timeout.Duration+time.Second)
defer cancel()

b, err := w.send(ctx, i)
b, err := w.send(ctx, body)
if err != nil {
w.fails++
}
Expand All @@ -137,18 +127,13 @@ func (w *WebhookConfig) Send(i interface{}) ([]byte, error) {
return b, err
}

func (w *WebhookConfig) send(ctx context.Context, i interface{}) ([]byte, error) {
b, err := json.Marshal(i)
if err != nil {
return nil, fmt.Errorf("marshaling payload '%s': %w", w.Name, err)
}

req, err := http.NewRequestWithContext(ctx, "POST", w.URL, bytes.NewBuffer(b))
func (w *WebhookConfig) send(ctx context.Context, body io.Reader) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "POST", w.URL, body)
if err != nil {
return nil, fmt.Errorf("creating request '%s': %w", w.Name, err)
}

req.Header.Set("content-type", "application/json")
req.Header.Set("content-type", w.CType)

res, err := w.client.Do(req)
if err != nil {
Expand All @@ -158,13 +143,13 @@ func (w *WebhookConfig) send(ctx context.Context, i interface{}) ([]byte, error)

// The error is mostly ignored because we don't care about the body.
// Read it in to avoid a memopry leak. Used in the if-stanza below.
body, _ := ioutil.ReadAll(res.Body)
reply, _ := ioutil.ReadAll(res.Body)

if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%w (%s) '%s': %s", ErrInvalidStatus, res.Status, w.Name, body)
if res.StatusCode < http.StatusOK || res.StatusCode > http.StatusNoContent {
return nil, fmt.Errorf("%w (%s) '%s': %s", ErrInvalidStatus, res.Status, w.Name, reply)
}

return body, nil
return reply, nil
}

func (u *Unpackerr) validateWebhook() {
Expand All @@ -173,6 +158,16 @@ func (u *Unpackerr) validateWebhook() {
u.Webhook[i].Name = u.Webhook[i].URL
}

if u.Webhook[i].Nickname == "" {
u.Webhook[i].Nickname = "Unpackerr"
} else if len(u.Webhook[i].Nickname) > 20 { //nolint:gomnd // be reasonable
u.Webhook[i].Nickname = u.Webhook[i].Nickname[:20]
}

if u.Webhook[i].CType == "" {
u.Webhook[i].CType = "application/json"
}

if u.Webhook[i].Timeout.Duration == 0 {
u.Webhook[i].Timeout.Duration = u.Timeout.Duration
}
Expand All @@ -193,15 +188,26 @@ func (u *Unpackerr) validateWebhook() {
}

func (u *Unpackerr) logWebhook() {
var ex string

if c := len(u.Webhook); c == 1 {
u.Printf(" => Webhook Config: 1 URL: %s (timeout: %v, ignore ssl: %v, silent: %v, events: %v)",
u.Webhook[0].Name, u.Webhook[0].Timeout, u.Webhook[0].IgnoreSSL, u.Webhook[0].Silent, logEvents(u.Webhook[0].Events))
if u.Webhook[0].TmplPath != "" {
ex = fmt.Sprintf(", template: %s, content_type: %s", u.Webhook[0].TmplPath, u.Webhook[0].CType)
}

u.Printf(" => Webhook Config: 1 URL: %s, timeout: %v, ignore ssl: %v, silent: %v%s, events: %v",
u.Webhook[0].Name, u.Webhook[0].Timeout, u.Webhook[0].IgnoreSSL, u.Webhook[0].Silent, ex,
logEvents(u.Webhook[0].Events))
} else {
u.Print(" => Webhook Configs:", c, "URLs")

for _, f := range u.Webhook {
u.Printf(" => URL: %s (timeout: %v, ignore ssl: %v, silent: %v, events: %v)",
f.Name, f.Timeout, f.IgnoreSSL, f.Silent, logEvents(f.Events))
if ex = ""; f.TmplPath != "" {
ex = fmt.Sprintf(", template: %s, content_type: %s", f.TmplPath, f.CType)
}

u.Printf(" => URL: %s, timeout: %v, ignore ssl: %v, silent: %v%s, events: %v",
f.Name, f.Timeout, f.IgnoreSSL, f.Silent, ex, logEvents(f.Events))
}
}
}
Expand Down
Loading

0 comments on commit 18887fa

Please sign in to comment.