feat(actions): add webhook support (#184)

* feat(actions): add webhook support

* feat: add type and method
This commit is contained in:
Ludvig Lundgren 2022-03-20 12:16:47 +01:00 committed by GitHub
parent 3c323004c0
commit 159133ef35
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 178 additions and 35 deletions

View file

@ -1,7 +1,11 @@
package action package action
import ( import (
"bytes"
"crypto/tls"
"encoding/json"
"io" "io"
"net/http"
"os" "os"
"path" "path"
"time" "time"
@ -70,6 +74,16 @@ func (s *service) runAction(action domain.Action, release domain.Release) error
s.watchFolder(action, release) s.watchFolder(action, release)
case domain.ActionTypeWebhook:
if release.TorrentTmpFile == "" {
if err := release.DownloadTorrentFile(nil); err != nil {
log.Error().Stack().Err(err)
return err
}
}
s.webhook(action, release)
case domain.ActionTypeDelugeV1, domain.ActionTypeDelugeV2: case domain.ActionTypeDelugeV1, domain.ActionTypeDelugeV2:
canDownload, err := s.delugeCheckRulesCanDownload(action) canDownload, err := s.delugeCheckRulesCanDownload(action)
if err != nil { if err != nil {
@ -251,3 +265,52 @@ func (s *service) watchFolder(action domain.Action, release domain.Release) {
log.Info().Msgf("saved file to watch folder: %v", fullFileName) log.Info().Msgf("saved file to watch folder: %v", fullFileName)
} }
func (s *service) webhook(action domain.Action, release domain.Release) {
m := NewMacro(release)
// parse and replace values in argument string before continuing
dataArgs, err := m.Parse(action.WebhookData)
if err != nil {
log.Error().Stack().Err(err).Msgf("could not parse macro: %v", action.WebhookData)
return
}
log.Trace().Msgf("action WEBHOOK: '%v' file: %v", action.Name, release.TorrentName)
log.Trace().Msgf("webhook action '%v' - host: %v data: %v", action.Name, action.WebhookHost, action.WebhookData)
jsonData, err := json.Marshal(dataArgs)
if err != nil {
log.Error().Err(err).Msgf("webhook client could not marshal data: %v", action.WebhookHost)
return
}
t := &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
}
client := http.Client{Transport: t, Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodPost, action.WebhookHost, bytes.NewBuffer(jsonData))
if err != nil {
log.Error().Err(err).Msgf("webhook client request error: %v", action.WebhookHost)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "autobrr")
res, err := client.Do(req)
if err != nil {
log.Error().Err(err).Msgf("webhook client request error: %v", action.WebhookHost)
return
}
defer res.Body.Close()
log.Info().Msgf("successfully ran webhook action: '%v' to: %v payload: %v", action.Name, action.WebhookHost, dataArgs)
return
}

View file

@ -3,6 +3,7 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/domain"
"github.com/rs/zerolog/log" "github.com/rs/zerolog/log"
@ -20,7 +21,7 @@ func (r *ActionRepo) FindByFilterID(ctx context.Context, filterID int) ([]domain
//r.db.lock.RLock() //r.db.lock.RLock()
//defer r.db.lock.RUnlock() //defer r.db.lock.RUnlock()
rows, err := r.db.handler.Query("SELECT id, name, type, enabled, exec_cmd, exec_args, watch_folder, category, tags, label, save_path, paused, ignore_rules, limit_download_speed, limit_upload_speed, client_id FROM action WHERE action.filter_id = ?", filterID) rows, err := r.db.handler.QueryContext(ctx, "SELECT id, name, type, enabled, exec_cmd, exec_args, watch_folder, category, tags, label, save_path, paused, ignore_rules, limit_download_speed, limit_upload_speed, webhook_host, webhook_data, webhook_type, webhook_method, client_id FROM action WHERE action.filter_id = ?", filterID)
if err != nil { if err != nil {
log.Fatal().Err(err) log.Fatal().Err(err)
} }
@ -31,13 +32,13 @@ func (r *ActionRepo) FindByFilterID(ctx context.Context, filterID int) ([]domain
for rows.Next() { for rows.Next() {
var a domain.Action var a domain.Action
var execCmd, execArgs, watchFolder, category, tags, label, savePath sql.NullString var execCmd, execArgs, watchFolder, category, tags, label, savePath, host, data, webhookType, webhookMethod sql.NullString
var limitUl, limitDl sql.NullInt64 var limitUl, limitDl sql.NullInt64
var clientID sql.NullInt32 var clientID sql.NullInt32
// filterID // filterID
var paused, ignoreRules sql.NullBool var paused, ignoreRules sql.NullBool
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.Enabled, &execCmd, &execArgs, &watchFolder, &category, &tags, &label, &savePath, &paused, &ignoreRules, &limitDl, &limitUl, &clientID); err != nil { if err := rows.Scan(&a.ID, &a.Name, &a.Type, &a.Enabled, &execCmd, &execArgs, &watchFolder, &category, &tags, &label, &savePath, &paused, &ignoreRules, &limitDl, &limitUl, &host, &data, &webhookType, &webhookMethod, &clientID); err != nil {
log.Fatal().Err(err) log.Fatal().Err(err)
} }
if err != nil { if err != nil {
@ -55,6 +56,10 @@ func (r *ActionRepo) FindByFilterID(ctx context.Context, filterID int) ([]domain
a.IgnoreRules = ignoreRules.Bool a.IgnoreRules = ignoreRules.Bool
a.LimitUploadSpeed = limitUl.Int64 a.LimitUploadSpeed = limitUl.Int64
a.LimitDownloadSpeed = limitDl.Int64 a.LimitDownloadSpeed = limitDl.Int64
a.WebhookHost = host.String
a.WebhookData = data.String
a.WebhookType = webhookType.String
a.WebhookMethod = webhookMethod.String
a.ClientID = clientID.Int32 a.ClientID = clientID.Int32
actions = append(actions, a) actions = append(actions, a)
@ -154,6 +159,10 @@ func (r *ActionRepo) Store(ctx context.Context, action domain.Action) (*domain.A
tags := toNullString(action.Tags) tags := toNullString(action.Tags)
label := toNullString(action.Label) label := toNullString(action.Label)
savePath := toNullString(action.SavePath) savePath := toNullString(action.SavePath)
host := toNullString(action.WebhookHost)
data := toNullString(action.WebhookData)
webhookType := toNullString(action.WebhookType)
webhookMethod := toNullString(action.WebhookMethod)
limitDL := toNullInt64(action.LimitDownloadSpeed) limitDL := toNullInt64(action.LimitDownloadSpeed)
limitUL := toNullInt64(action.LimitUploadSpeed) limitUL := toNullInt64(action.LimitUploadSpeed)
@ -163,13 +172,13 @@ func (r *ActionRepo) Store(ctx context.Context, action domain.Action) (*domain.A
var err error var err error
if action.ID != 0 { if action.ID != 0 {
log.Debug().Msg("actions: update existing record") log.Debug().Msg("actions: update existing record")
_, err = r.db.handler.ExecContext(ctx, `UPDATE action SET name = ?, type = ?, enabled = ?, exec_cmd = ?, exec_args = ?, watch_folder = ? , category =? , tags = ?, label = ?, save_path = ?, paused = ?, ignore_rules = ?, limit_upload_speed = ?, limit_download_speed = ?, client_id = ? _, err = r.db.handler.ExecContext(ctx, `UPDATE action SET name = ?, type = ?, enabled = ?, exec_cmd = ?, exec_args = ?, watch_folder = ? , category =? , tags = ?, label = ?, save_path = ?, paused = ?, ignore_rules = ?, limit_upload_speed = ?, limit_download_speed = ?, webhook_host = ?, webhook_data = ?, webhook_type = ?, webhook_method = ?, client_id = ?
WHERE id = ?`, action.Name, action.Type, action.Enabled, execCmd, execArgs, watchFolder, category, tags, label, savePath, action.Paused, action.IgnoreRules, limitUL, limitDL, clientID, action.ID) WHERE id = ?`, action.Name, action.Type, action.Enabled, execCmd, execArgs, watchFolder, category, tags, label, savePath, action.Paused, action.IgnoreRules, limitUL, limitDL, host, data, webhookType, webhookMethod, clientID, action.ID)
} else { } else {
var res sql.Result var res sql.Result
res, err = r.db.handler.ExecContext(ctx, `INSERT INTO action(name, type, enabled, exec_cmd, exec_args, watch_folder, category, tags, label, save_path, paused, ignore_rules, limit_upload_speed, limit_download_speed, client_id, filter_id) res, err = r.db.handler.ExecContext(ctx, `INSERT INTO action(name, type, enabled, exec_cmd, exec_args, watch_folder, category, tags, label, save_path, paused, ignore_rules, limit_upload_speed, limit_download_speed, webhook_host, webhook_data, webhook_type, webhook_method, client_id, filter_id)
VALUES (?, ?, ?, ?, ?,? ,?, ?,?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, action.Name, action.Type, action.Enabled, execCmd, execArgs, watchFolder, category, tags, label, savePath, action.Paused, action.IgnoreRules, limitUL, limitDL, clientID, filterID) VALUES (?, ?, ?, ?, ?,? ,?, ?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, action.Name, action.Type, action.Enabled, execCmd, execArgs, watchFolder, category, tags, label, savePath, action.Paused, action.IgnoreRules, limitUL, limitDL, host, data, webhookType, webhookMethod, clientID, filterID)
if err != nil { if err != nil {
log.Error().Err(err) log.Error().Err(err)
return nil, err return nil, err
@ -208,6 +217,10 @@ func (r *ActionRepo) StoreFilterActions(ctx context.Context, actions []domain.Ac
tags := toNullString(action.Tags) tags := toNullString(action.Tags)
label := toNullString(action.Label) label := toNullString(action.Label)
savePath := toNullString(action.SavePath) savePath := toNullString(action.SavePath)
host := toNullString(action.WebhookHost)
data := toNullString(action.WebhookData)
webhookType := toNullString(action.WebhookType)
webhookMethod := toNullString(action.WebhookMethod)
limitDL := toNullInt64(action.LimitDownloadSpeed) limitDL := toNullInt64(action.LimitDownloadSpeed)
limitUL := toNullInt64(action.LimitUploadSpeed) limitUL := toNullInt64(action.LimitUploadSpeed)
@ -216,8 +229,8 @@ func (r *ActionRepo) StoreFilterActions(ctx context.Context, actions []domain.Ac
var err error var err error
var res sql.Result var res sql.Result
res, err = tx.ExecContext(ctx, `INSERT INTO action(name, type, enabled, exec_cmd, exec_args, watch_folder, category, tags, label, save_path, paused, ignore_rules, limit_upload_speed, limit_download_speed, client_id, filter_id) res, err = tx.ExecContext(ctx, `INSERT INTO action(name, type, enabled, exec_cmd, exec_args, watch_folder, category, tags, label, save_path, paused, ignore_rules, limit_upload_speed, limit_download_speed, webhook_host, webhook_data, webhook_type, webhook_method, client_id, filter_id)
VALUES (?, ?, ?, ?, ?,? ,?, ?,?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, action.Name, action.Type, action.Enabled, execCmd, execArgs, watchFolder, category, tags, label, savePath, action.Paused, action.IgnoreRules, limitUL, limitDL, clientID, filterID) VALUES (?, ?, ?, ?, ?,? ,?, ?,?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT DO NOTHING`, action.Name, action.Type, action.Enabled, execCmd, execArgs, watchFolder, category, tags, label, savePath, action.Paused, action.IgnoreRules, limitUL, limitDL, host, data, webhookType, webhookMethod, clientID, filterID)
if err != nil { if err != nil {
log.Error().Stack().Err(err).Msg("actions: error executing query") log.Error().Stack().Err(err).Msg("actions: error executing query")
return nil, err return nil, err
@ -257,23 +270,3 @@ func (r *ActionRepo) ToggleEnabled(actionID int) error {
return nil return nil
} }
func toNullString(s string) sql.NullString {
return sql.NullString{
String: s,
Valid: s != "",
}
}
func toNullInt32(s int32) sql.NullInt32 {
return sql.NullInt32{
Int32: s,
Valid: s != 0,
}
}
func toNullInt64(s int64) sql.NullInt64 {
return sql.NullInt64{
Int64: s,
Valid: s != 0,
}
}

View file

@ -354,6 +354,22 @@ var migrations = []string{
ALTER TABLE "client" ALTER TABLE "client"
RENAME COLUMN ssl TO tls; RENAME COLUMN ssl TO tls;
`, `,
`
ALTER TABLE "action"
ADD COLUMN webhook_host TEXT;
ALTER TABLE "action"
ADD COLUMN webhook_data TEXT;
ALTER TABLE "action"
ADD COLUMN webhook_method TEXT;
ALTER TABLE "action"
ADD COLUMN webhook_type TEXT;
ALTER TABLE "action"
ADD COLUMN webhook_headers TEXT [] DEFAULT '{}';
`,
} }
func (db *SqliteDB) migrate() error { func (db *SqliteDB) migrate() error {

View file

@ -1,6 +1,7 @@
package database package database
import ( import (
"database/sql"
"path" "path"
) )
@ -11,3 +12,23 @@ func dataSourceName(configPath string, name string) string {
return name return name
} }
func toNullString(s string) sql.NullString {
return sql.NullString{
String: s,
Valid: s != "",
}
}
func toNullInt32(s int32) sql.NullInt32 {
return sql.NullInt32{
Int32: s,
Valid: s != 0,
}
}
func toNullInt64(s int64) sql.NullInt64 {
return sql.NullInt64{
Int64: s,
Valid: s != 0,
}
}

View file

@ -28,6 +28,11 @@ type Action struct {
IgnoreRules bool `json:"ignore_rules,omitempty"` IgnoreRules bool `json:"ignore_rules,omitempty"`
LimitUploadSpeed int64 `json:"limit_upload_speed,omitempty"` LimitUploadSpeed int64 `json:"limit_upload_speed,omitempty"`
LimitDownloadSpeed int64 `json:"limit_download_speed,omitempty"` LimitDownloadSpeed int64 `json:"limit_download_speed,omitempty"`
WebhookHost string `json:"webhook_host,omitempty"`
WebhookType string `json:"webhook_type,omitempty"`
WebhookMethod string `json:"webhook_method,omitempty"`
WebhookData string `json:"webhook_data,omitempty"`
WebhookHeaders []string `json:"webhook_headers,omitempty"`
FilterID int `json:"filter_id,omitempty"` FilterID int `json:"filter_id,omitempty"`
ClientID int32 `json:"client_id,omitempty"` ClientID int32 `json:"client_id,omitempty"`
} }
@ -41,6 +46,7 @@ const (
ActionTypeDelugeV1 ActionType = "DELUGE_V1" ActionTypeDelugeV1 ActionType = "DELUGE_V1"
ActionTypeDelugeV2 ActionType = "DELUGE_V2" ActionTypeDelugeV2 ActionType = "DELUGE_V2"
ActionTypeWatchFolder ActionType = "WATCH_FOLDER" ActionTypeWatchFolder ActionType = "WATCH_FOLDER"
ActionTypeWebhook ActionType = "WEBHOOK"
ActionTypeRadarr ActionType = "RADARR" ActionTypeRadarr ActionType = "RADARR"
ActionTypeSonarr ActionType = "SONARR" ActionTypeSonarr ActionType = "SONARR"
ActionTypeLidarr ActionType = "LIDARR" ActionTypeLidarr ActionType = "LIDARR"

View file

@ -7,22 +7,27 @@ type COL_WIDTHS = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
interface TextFieldProps { interface TextFieldProps {
name: string; name: string;
defaultValue?: string;
label?: string; label?: string;
placeholder?: string; placeholder?: string;
columns?: COL_WIDTHS; columns?: COL_WIDTHS;
autoComplete?: string; autoComplete?: string;
hidden?: boolean;
} }
export const TextField = ({ export const TextField = ({
name, name,
defaultValue,
label, label,
placeholder, placeholder,
columns, columns,
autoComplete autoComplete,
hidden,
}: TextFieldProps) => ( }: TextFieldProps) => (
<div <div
className={classNames( className={classNames(
columns ? `col-span-${columns}` : "col-span-12" hidden ? "hidden" : "",
columns ? `col-span-${columns}` : "col-span-12",
)} )}
> >
{label && ( {label && (
@ -40,6 +45,7 @@ export const TextField = ({
{...field} {...field}
id={name} id={name}
type="text" type="text"
defaultValue={defaultValue}
autoComplete={autoComplete} autoComplete={autoComplete}
className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100" className="mt-2 block w-full dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md py-2 px-3 focus:outline-none focus:ring-blue-500 focus:border-blue-500 dark:text-gray-100"
placeholder={placeholder} placeholder={placeholder}

View file

@ -191,6 +191,7 @@ export const DownloadClientTypeNameMap: Record<DownloadClientType | string, stri
export const ActionTypeOptions: RadioFieldsetOption[] = [ export const ActionTypeOptions: RadioFieldsetOption[] = [
{label: "Test", description: "A simple action to test a filter.", value: "TEST"}, {label: "Test", description: "A simple action to test a filter.", value: "TEST"},
{label: "Watch dir", description: "Add filtered torrents to a watch directory", value: "WATCH_FOLDER"}, {label: "Watch dir", description: "Add filtered torrents to a watch directory", value: "WATCH_FOLDER"},
{label: "Webhook", description: "Run webhook", value: "WEBHOOK"},
{label: "Exec", description: "Run a custom command after a filter match", value: "EXEC"}, {label: "Exec", description: "Run a custom command after a filter match", value: "EXEC"},
{label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: "QBITTORRENT"}, {label: "qBittorrent", description: "Add torrents directly to qBittorrent", value: "QBITTORRENT"},
{label: "Deluge", description: "Add torrents directly to Deluge", value: "DELUGE_V1"}, {label: "Deluge", description: "Add torrents directly to Deluge", value: "DELUGE_V1"},
@ -203,6 +204,7 @@ export const ActionTypeOptions: RadioFieldsetOption[] = [
export const ActionTypeNameMap = { export const ActionTypeNameMap = {
"TEST": "Test", "TEST": "Test",
"WATCH_FOLDER": "Watch folder", "WATCH_FOLDER": "Watch folder",
"WEBHOOK": "Webhook",
"EXEC": "Exec", "EXEC": "Exec",
"DELUGE_V1": "Deluge v1", "DELUGE_V1": "Deluge v1",
"DELUGE_V2": "Deluge v2", "DELUGE_V2": "Deluge v2",

View file

@ -178,7 +178,16 @@ export default function FilterDetails() {
return null return null
} }
const handleSubmit = (data: any) => { const handleSubmit = (data: Filter) => {
// force set method and type on webhook actions
// TODO add options for these
data.actions.forEach((a: Action) => {
if (a.type === "WEBHOOK") {
a.webhook_method = "POST"
a.webhook_type = "JSON"
}
})
updateMutation.mutate(data) updateMutation.mutate(data)
} }
@ -268,8 +277,6 @@ export default function FilterDetails() {
except_uploaders: filter.except_uploaders, except_uploaders: filter.except_uploaders,
freeleech: filter.freeleech, freeleech: filter.freeleech,
freeleech_percent: filter.freeleech_percent, freeleech_percent: filter.freeleech_percent,
indexers: filter.indexers || [],
actions: filter.actions || [],
formats: filter.formats || [], formats: filter.formats || [],
quality: filter.quality || [], quality: filter.quality || [],
media: filter.media || [], media: filter.media || [],
@ -280,6 +287,8 @@ export default function FilterDetails() {
perfect_flac: filter.perfect_flac, perfect_flac: filter.perfect_flac,
artists: filter.artists, artists: filter.artists,
albums: filter.albums, albums: filter.albums,
indexers: filter.indexers || [],
actions: filter.actions || [],
} as Filter} } as Filter}
onSubmit={handleSubmit} onSubmit={handleSubmit}
> >
@ -624,6 +633,11 @@ function FilterActions({ filter, values }: FilterActionsProps) {
limit_upload_speed: 0, limit_upload_speed: 0,
limit_download_speed: 0, limit_download_speed: 0,
filter_id: filter.id, filter_id: filter.id,
webhook_host: "",
webhook_type: "",
webhook_method: "",
webhook_data: "",
webhook_headers: [],
// client_id: 0, // client_id: 0,
} }
@ -719,6 +733,23 @@ function FilterActionsItem({ action, clients, idx, remove }: FilterActionsItemPr
/> />
</div> </div>
); );
case "WEBHOOK":
return (
<div className="mt-6 grid grid-cols-12 gap-6">
<TextField
name={`actions.${idx}.webhook_host`}
label="Host"
columns={6}
placeholder="Host eg. http://localhost/webhook"
/>
<TextField
name={`actions.${idx}.webhook_data`}
label="Data (json)"
columns={6}
placeholder={`Request data: { "key": "value" }`}
/>
</div>
);
case "QBITTORRENT": case "QBITTORRENT":
return ( return (
<div className="w-full"> <div className="w-full">

View file

@ -66,8 +66,13 @@ interface Action {
ignore_rules?: boolean; ignore_rules?: boolean;
limit_upload_speed?: number; limit_upload_speed?: number;
limit_download_speed?: number; limit_download_speed?: number;
webhook_host: string,
webhook_type: string;
webhook_method: string;
webhook_data: string,
webhook_headers: string[];
filter_id?: number; filter_id?: number;
client_id?: number; client_id?: number;
} }
type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | DownloadClientType; type ActionType = 'TEST' | 'EXEC' | 'WATCH_FOLDER' | 'WEBHOOK' | DownloadClientType;