mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
enhanced webhooks to support custom headers and payload templates
This commit is contained in:
parent
03c8d55ab9
commit
4d6351507f
4 changed files with 239 additions and 73 deletions
|
|
@ -74,11 +74,13 @@ class IntegrationViewSet(viewsets.ModelViewSet):
|
|||
{"detail": f"Invalid event: {event}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Only accept payload_template when the integration is a webhook
|
||||
payload_template = item.get("payload_template") if integration.type == "webhook" else None
|
||||
incoming.append(
|
||||
{
|
||||
"event": event,
|
||||
"enabled": bool(item.get("enabled", True)),
|
||||
"payload_template": item.get("payload_template"),
|
||||
"payload_template": payload_template,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,21 @@
|
|||
# connect/handlers/webhook.py
|
||||
import requests
|
||||
import requests, json, logging
|
||||
from .base import IntegrationHandler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class WebhookHandler(IntegrationHandler):
|
||||
def execute(self):
|
||||
url = self.integration.config.get("url")
|
||||
headers = self.integration.config.get("headers", {})
|
||||
response = requests.post(url, json=self.payload, headers=headers, timeout=10)
|
||||
logger.info(self.payload)
|
||||
|
||||
try:
|
||||
parsed = json.loads(self.payload)
|
||||
headers["Content-Type"] = "application/json"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response = requests.post(url, data=self.payload, headers=headers, timeout=10)
|
||||
|
||||
return {"status_code": response.status_code, "body": response.text, "success": response.ok}
|
||||
|
|
|
|||
|
|
@ -38,13 +38,14 @@ def trigger_event(event_name, payload):
|
|||
)
|
||||
continue
|
||||
|
||||
# apply optional payload template
|
||||
# apply optional payload template (only for webhook integrations)
|
||||
# If the rendered template is valid JSON, use that object as the payload.
|
||||
# Otherwise, pass the rendered string as-is.
|
||||
final_payload = payload
|
||||
if sub.payload_template:
|
||||
if integration.type == 'webhook' and sub.payload_template:
|
||||
try:
|
||||
template = Template(sub.payload_template)
|
||||
rendered = template.render(Context(payload))
|
||||
final_payload = {"message": rendered}
|
||||
final_payload = template.render(Context(payload)).strip()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Payload template render failed for subscription id={sub.id}: {e}"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ import {
|
|||
Checkbox,
|
||||
Text,
|
||||
SimpleGrid,
|
||||
Textarea,
|
||||
Group,
|
||||
Tabs,
|
||||
Accordion,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { isNotEmpty, useForm } from '@mantine/form';
|
||||
import { SUBSCRIPTION_EVENTS } from '../../constants';
|
||||
|
|
@ -25,6 +30,8 @@ const EVENT_OPTIONS = Object.entries(SUBSCRIPTION_EVENTS).map(
|
|||
const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedEvents, setSelectedEvents] = useState([]);
|
||||
const [headers, setHeaders] = useState([]);
|
||||
const [payloadTemplates, setPayloadTemplates] = useState({});
|
||||
const [apiError, setApiError] = useState('');
|
||||
|
||||
// One-time form
|
||||
|
|
@ -71,9 +78,24 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
|
|||
return acc;
|
||||
}, [])
|
||||
);
|
||||
// Initialize headers array from config.headers object
|
||||
const cfgHeaders = connection.config?.headers || {};
|
||||
const hdrs = Object.keys(cfgHeaders).length
|
||||
? Object.entries(cfgHeaders).map(([k, v]) => ({ key: k, value: v }))
|
||||
: [{ key: '', value: '' }];
|
||||
setHeaders(hdrs);
|
||||
|
||||
// Initialize payload templates per subscription
|
||||
const templates = {};
|
||||
connection.subscriptions.forEach((sub) => {
|
||||
if (sub.payload_template) templates[sub.event] = sub.payload_template;
|
||||
});
|
||||
setPayloadTemplates(templates);
|
||||
} else {
|
||||
form.reset();
|
||||
setSelectedEvents([]);
|
||||
setHeaders([{ key: '', value: '' }]);
|
||||
setPayloadTemplates({});
|
||||
}
|
||||
}, [connection]);
|
||||
|
||||
|
|
@ -87,10 +109,18 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
|
|||
try {
|
||||
setSubmitting(true);
|
||||
setApiError('');
|
||||
const config =
|
||||
values.type === 'webhook'
|
||||
? { url: values.url }
|
||||
: { path: values.script_path };
|
||||
// Build config including optional headers
|
||||
let config;
|
||||
if (values.type === 'webhook') {
|
||||
const hdrs = {};
|
||||
headers.forEach((h) => {
|
||||
if (h.key && h.key.trim()) hdrs[h.key] = h.value;
|
||||
});
|
||||
config = { url: values.url };
|
||||
if (Object.keys(hdrs).length) config.headers = hdrs;
|
||||
} else {
|
||||
config = { path: values.script_path };
|
||||
}
|
||||
|
||||
if (connection) {
|
||||
await API.updateConnectIntegration(connection.id, {
|
||||
|
|
@ -108,13 +138,14 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
|
|||
});
|
||||
}
|
||||
|
||||
await API.setConnectSubscriptions(
|
||||
connection.id,
|
||||
Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({
|
||||
event,
|
||||
enabled: selectedEvents.includes(event),
|
||||
}))
|
||||
);
|
||||
// Build subscription list including optional payload templates
|
||||
const subs = Object.keys(SUBSCRIPTION_EVENTS).map((event) => ({
|
||||
event,
|
||||
enabled: selectedEvents.includes(event),
|
||||
payload_template: payloadTemplates[event] || null,
|
||||
}));
|
||||
|
||||
await API.setConnectSubscriptions(connection.id, subs);
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to create/update connection', error);
|
||||
|
|
@ -164,64 +195,185 @@ const ConnectionForm = ({ connection = null, isOpen, onClose }) => {
|
|||
return (
|
||||
<Modal opened={isOpen} size="lg" onClose={handleClose} title="Connection">
|
||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
||||
<Stack gap="md">
|
||||
{apiError ? (
|
||||
<Text c="red" size="sm">
|
||||
{apiError}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
<Select
|
||||
{...form.getInputProps('type')}
|
||||
key={form.key('type')}
|
||||
label="Connection Type"
|
||||
data={[
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'script', label: 'Custom Script' },
|
||||
]}
|
||||
/>
|
||||
{form.getValues().type === 'webhook' ? (
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Script Path"
|
||||
{...form.getInputProps('script_path')}
|
||||
key={form.key('script_path')}
|
||||
/>
|
||||
)}
|
||||
<Tabs defaultValue="settings">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="settings">Settings</Tabs.Tab>
|
||||
<Tabs.Tab value="triggers">Event Triggers</Tabs.Tab>
|
||||
{form.getValues().type === 'webhook' && (
|
||||
<Tabs.Tab value="templates">Payload Templates</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" weight={500} mb={5}>
|
||||
Event Triggers
|
||||
</Text>
|
||||
<Stack gap="xs">
|
||||
<SimpleGrid cols={3}>
|
||||
{EVENT_OPTIONS.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
checked={selectedEvents.includes(opt.value)}
|
||||
onChange={() => toggleEvent(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
<Tabs.Panel value="settings" style={{ paddingTop: 10 }}>
|
||||
<Stack gap="md">
|
||||
{apiError ? (
|
||||
<Text c="red" size="sm">
|
||||
{apiError}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextInput
|
||||
label="Name"
|
||||
{...form.getInputProps('name')}
|
||||
key={form.key('name')}
|
||||
/>
|
||||
<Select
|
||||
{...form.getInputProps('type')}
|
||||
key={form.key('type')}
|
||||
label="Connection Type"
|
||||
data={[
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'script', label: 'Custom Script' },
|
||||
]}
|
||||
/>
|
||||
{form.getValues().type === 'webhook' ? (
|
||||
<TextInput
|
||||
label="Webhook URL"
|
||||
{...form.getInputProps('url')}
|
||||
key={form.key('url')}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Script Path"
|
||||
{...form.getInputProps('script_path')}
|
||||
key={form.key('script_path')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{form.getValues().type === 'webhook' ? (
|
||||
<Box>
|
||||
<Text size="sm" weight={500} mb={5}>
|
||||
Custom Headers (optional)
|
||||
</Text>
|
||||
<Stack spacing="xs">
|
||||
{headers.map((h, idx) => (
|
||||
<Group key={idx} align="flex-start">
|
||||
<TextInput
|
||||
placeholder="Header name"
|
||||
value={h.key}
|
||||
onChange={(e) => {
|
||||
const next = [...headers];
|
||||
next[idx] = { ...next[idx], key: e.target.value };
|
||||
setHeaders(next);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Header value"
|
||||
value={h.value}
|
||||
onChange={(e) => {
|
||||
const next = [...headers];
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
value: e.target.value,
|
||||
};
|
||||
setHeaders(next);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button
|
||||
size="xs"
|
||||
color="red"
|
||||
onClick={() => {
|
||||
const next = headers.filter((_, i) => i !== idx);
|
||||
setHeaders(
|
||||
next.length ? next : [{ key: '', value: '' }]
|
||||
);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Group>
|
||||
))}
|
||||
<Button
|
||||
size="xs"
|
||||
onClick={() =>
|
||||
setHeaders([...headers, { key: '', value: '' }])
|
||||
}
|
||||
>
|
||||
Add Header
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</Stack>
|
||||
<Tabs.Panel value="triggers" style={{ paddingTop: 10 }}>
|
||||
<SimpleGrid cols={3}>
|
||||
{EVENT_OPTIONS.map((opt) => (
|
||||
<Checkbox
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
checked={selectedEvents.includes(opt.value)}
|
||||
onChange={() => toggleEvent(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Tabs.Panel>
|
||||
|
||||
{form.getValues().type === 'webhook' && (
|
||||
<Tabs.Panel value="templates" style={{ paddingTop: 10 }}>
|
||||
<Stack gap="xs">
|
||||
<Alert variant="default">
|
||||
<Text size="sm">
|
||||
Enable event triggers to set individual templates.
|
||||
</Text>
|
||||
</Alert>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: '60vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ overflow: 'auto', flex: 1, minHeight: 0 }}>
|
||||
<Accordion
|
||||
multiple={true}
|
||||
styles={{
|
||||
label: { padding: 2 },
|
||||
}}
|
||||
>
|
||||
{EVENT_OPTIONS.map(
|
||||
(opt) =>
|
||||
selectedEvents.includes(opt.value) && (
|
||||
<Accordion.Item key={opt.value} value={opt.value}>
|
||||
<Accordion.Control
|
||||
disabled={!selectedEvents.includes(opt.value)}
|
||||
style={{ paddingTop: 4, paddingBottom: 4 }}
|
||||
>
|
||||
{opt.label}
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Textarea
|
||||
placeholder={
|
||||
'Optional Jinja2 payload template (use variables from event payload)'
|
||||
}
|
||||
minRows={3}
|
||||
value={payloadTemplates[opt.value] || ''}
|
||||
autosize
|
||||
onChange={(e) =>
|
||||
setPayloadTemplates({
|
||||
...payloadTemplates,
|
||||
[opt.value]: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)
|
||||
)}
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue