Add webhook POST then-action for automation engine

- New 'webhook' then-action: sends HTTP POST with JSON payload to any
  user-configured URL (Gotify, Home Assistant, Slack, n8n, etc.)
- Config: URL, optional custom headers (Key: Value per line with
  variable substitution), optional custom message
- Payload includes all event variables as JSON fields
- 15s timeout, errors on 400+ status codes
- Follows exact same pattern as Discord/Pushbullet/Telegram handlers
- Frontend: config fields, config reader, icon, help docs
- Updated changelogs with webhook, M3U fix, orchestrator hardening
This commit is contained in:
Broque Thomas 2026-04-02 12:22:07 -07:00
parent 7c85c31e8b
commit bec81cfd8d
4 changed files with 98 additions and 2 deletions

View file

@ -836,6 +836,8 @@ class AutomationEngine:
self._send_pushbullet_notification(c, variables) self._send_pushbullet_notification(c, variables)
elif t == 'telegram': elif t == 'telegram':
self._send_telegram_notification(c, variables) self._send_telegram_notification(c, variables)
elif t == 'webhook':
self._send_webhook(c, variables)
elif t == 'fire_signal': elif t == 'fire_signal':
sig = self._sanitize_signal_name(c.get('signal_name', '')) sig = self._sanitize_signal_name(c.get('signal_name', ''))
if sig: if sig:
@ -966,3 +968,36 @@ class AutomationEngine:
data = resp.json() if resp.status_code == 200 else {} data = resp.json() if resp.status_code == 200 else {}
if not data.get('ok'): if not data.get('ok'):
raise RuntimeError(f"Telegram returned {resp.status_code}: {resp.text[:200]}") raise RuntimeError(f"Telegram returned {resp.status_code}: {resp.text[:200]}")
def _send_webhook(self, config, variables):
"""Send a POST request to a user-configured webhook URL with JSON payload."""
url = config.get('url', '').strip()
if not url:
raise ValueError("No webhook URL configured")
# Build headers — always include Content-Type, plus optional custom headers
headers = {'Content-Type': 'application/json'}
custom_headers = config.get('headers', '').strip()
if custom_headers:
for line in custom_headers.split('\n'):
line = line.strip()
if ':' in line:
key, value = line.split(':', 1)
# Substitute variables in header values
for vk, vv in variables.items():
value = value.replace('{' + vk + '}', vv)
headers[key.strip()] = value.strip()
# Build JSON payload with all variables
payload = dict(variables)
# Add custom message if configured
message = config.get('message', '').strip()
if message:
for key, value in variables.items():
message = message.replace('{' + key + '}', value)
payload['message'] = message
resp = requests.post(url, json=payload, headers=headers, timeout=15)
if resp.status_code >= 400:
raise RuntimeError(f"Webhook returned {resp.status_code}: {resp.text[:200]}")

View file

@ -5631,6 +5631,8 @@ def get_automation_blocks():
"variables": ["time", "name", "run_count", "status"]}, "variables": ["time", "name", "run_count", "status"]},
{"type": "telegram", "label": "Telegram", "icon": "message", "description": "Send a Telegram message", "available": True, {"type": "telegram", "label": "Telegram", "icon": "message", "description": "Send a Telegram message", "available": True,
"variables": ["time", "name", "run_count", "status"]}, "variables": ["time", "name", "run_count", "status"]},
{"type": "webhook", "label": "Webhook (POST)", "icon": "globe", "description": "Send a POST request to any URL", "available": True,
"variables": ["time", "name", "run_count", "status"]},
# Signal fire action # Signal fire action
{"type": "fire_signal", "label": "Fire Signal", "icon": "zap", {"type": "fire_signal", "label": "Fire Signal", "icon": "zap",
"description": "Fire a signal that other automations can listen for", "available": True, "description": "Fire a signal that other automations can listen for", "available": True,
@ -20300,7 +20302,10 @@ def get_version_info():
"• Track source-info and redownload work with Jellyfin string IDs (#237)", "• Track source-info and redownload work with Jellyfin string IDs (#237)",
"• Clear Match button to undo wrong manual matches (#236)", "• Clear Match button to undo wrong manual matches (#236)",
"• Tidal auth no longer crashes when download orchestrator not initialized", "• Tidal auth no longer crashes when download orchestrator not initialized",
"• Copy Debug Info includes API call rates and Spotify rate limit state" "• Download orchestrator hardened — one failing client no longer kills all download sources",
"• Webhook THEN action — send HTTP POST to any URL (Gotify, Home Assistant, Slack, n8n) from automations",
"• M3U auto-export now skips albums — only generates for playlists (#241)",
"• Copy Debug Info includes API call rates, Spotify rate limit state, and download client failures"
] ]
}, },
{ {

View file

@ -3403,6 +3403,7 @@ function closeHelperSearch() {
const WHATS_NEW = { const WHATS_NEW = {
'2.2': [ '2.2': [
// Newest features first // Newest features first
{ title: 'Webhook THEN Action', desc: 'Send HTTP POST to any URL when automations complete — integrate with Gotify, Home Assistant, Slack, n8n. Configurable headers and message template', page: 'automations' },
{ title: 'API Rate Monitor', desc: 'Real-time speedometer gauges for all 9 enrichment services on the Dashboard. Click any gauge for 24h history chart. Spotify shows per-endpoint breakdown', page: 'dashboard' }, { title: 'API Rate Monitor', desc: 'Real-time speedometer gauges for all 9 enrichment services on the Dashboard. Click any gauge for 24h history chart. Spotify shows per-endpoint breakdown', page: 'dashboard' },
{ title: 'Configurable Concurrent Downloads', desc: 'Set max simultaneous downloads per batch (1-10) in Settings. Soulseek albums stay at 1 for source reuse. Higher values speed up playlists and wishlists' }, { title: 'Configurable Concurrent Downloads', desc: 'Set max simultaneous downloads per batch (1-10) in Settings. Soulseek albums stay at 1 for source reuse. Higher values speed up playlists and wishlists' },
{ title: 'Streaming Search Sources', desc: 'Apple Music and other slow sources now stream results progressively — see artists, albums, tracks as each loads instead of waiting for all 3' }, { title: 'Streaming Search Sources', desc: 'Apple Music and other slow sources now stream results progressively — see artists, albums, tracks as each loads instead of waiting for all 3' },

View file

@ -23157,6 +23157,34 @@ const TOOL_HELP_CONTENT = {
` `
}, },
'auto-webhook': {
title: 'Webhook (POST)',
content: `
<h4>What does this then-action do?</h4>
<p>Sends an HTTP POST request with a JSON payload to any URL when the automation's action completes. Use it to integrate with Gotify, Home Assistant, Slack, n8n, or any service that accepts webhooks.</p>
<h4>Configuration</h4>
<ul>
<li><strong>URL:</strong> The endpoint to POST to (e.g. <code>https://gotify.example.com/message?token=xxx</code>)</li>
<li><strong>Headers:</strong> Optional custom headers, one per line in <code>Key: Value</code> format. Useful for auth tokens.</li>
<li><strong>Custom Message:</strong> Optional message with variable placeholders. Added as a "message" field in the JSON payload.</li>
</ul>
<h4>JSON payload</h4>
<p>The POST body always includes all event variables as JSON fields:</p>
<pre style="background:rgba(255,255,255,0.05);padding:8px;border-radius:6px;font-size:11px;">{"time": "2026-04-02 ...", "name": "My Automation", "status": "success", ...}</pre>
<h4>Available variables</h4>
<p>Use these in your message or header values:</p>
<ul>
<li><code>{time}</code> When the automation ran</li>
<li><code>{name}</code> Automation name</li>
<li><code>{run_count}</code> How many times this automation has run</li>
<li><code>{status}</code> Result status of the action</li>
</ul>
`
},
// ==================== Signal System Help ==================== // ==================== Signal System Help ====================
'auto-signal_received': { 'auto-signal_received': {
@ -62695,7 +62723,7 @@ const _autoIcons = {
process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F', process_wishlist: '\uD83D\uDCCB', scan_watchlist: '\uD83D\uDC41\uFE0F',
scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01', scan_library: '\uD83D\uDD04', refresh_mirrored: '\uD83D\uDCC2', sync_playlist: '\uD83D\uDD01',
discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D', discover_playlist: '\uD83D\uDD0D', discovery_completed: '\uD83D\uDD0D',
notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', notify_only: '\uD83D\uDD14', discord_webhook: '\uD83D\uDCAC', pushbullet: '\uD83D\uDD14', telegram: '\u2709\uFE0F', webhook: '\uD83C\uDF10',
signal_received: '\u26A1', fire_signal: '\u26A1', signal_received: '\u26A1', fire_signal: '\u26A1',
// Phase 3 // Phase 3
wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705', wishlist_processing_completed: '\u2705', watchlist_scan_completed: '\u2705',
@ -64758,6 +64786,26 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
</div> </div>
${_notifyVarHtml(slotKey)}`; ${_notifyVarHtml(slotKey)}`;
} }
if (blockType === 'webhook') {
const url = _escAttr(config.url || '');
const hdrs = (config.headers || '').replace(/"/g, '&quot;');
return `<div class="config-row">
<label>URL</label>
<input type="text" id="cfg-${slotKey}-url" value="${url}" placeholder="https://your-server.com/hook">
</div>
<div class="config-row">
<label>Headers <span style="opacity:0.4;font-weight:400">(one per line, Key: Value)</span></label>
<textarea id="cfg-${slotKey}-headers" placeholder="Authorization: Bearer token123\nX-Custom: value" style="font-family:monospace;font-size:11px;">${hdrs}</textarea>
</div>
<div class="config-row">
<label>Custom Message <span style="opacity:0.4;font-weight:400">(optional)</span></label>
<textarea id="cfg-${slotKey}-message" placeholder="Message with {variables}...">${config.message || ''}</textarea>
</div>
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">
Sends a JSON POST with all event variables. Custom message added as "message" field if set.
</div>
${_notifyVarHtml(slotKey)}`;
}
return ''; return '';
} }
@ -64999,6 +65047,13 @@ function _readPlacedConfig(slotKey) {
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '', message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
}; };
} }
if (type === 'webhook') {
return {
url: document.getElementById('cfg-' + slotKey + '-url')?.value?.trim() || '',
headers: document.getElementById('cfg-' + slotKey + '-headers')?.value || '',
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
};
}
return {}; return {};
} }