From f72e1bd412fe64225d9ad4be021bd26d9fb9f0c6 Mon Sep 17 00:00:00 2001 From: Logan Davis Date: Fri, 5 Jun 2026 16:47:57 -0400 Subject: [PATCH] feat(reminders): add generic webhook as a fourth reminder channel (#2952) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces any Discord-specific reminder channel with a generic outbound webhook channel. Users pick any saved Integration as the target and supply a JSON payload template with {{title}} and {{message}} placeholders — values are JSON-escaped before substitution. Works with Discord, Slack, Teams, ntfy (JSON mode), or any service that accepts a POST with a JSON body. - `src/settings.py` — reminder_webhook_integration_id + reminder_webhook_payload_template defaults - `routes/note_routes.py` — webhook delivery block; Integration lookup, template rendering, auth wiring; built-in preset defaults so discord_webhook works out of the box without a configured template; settings_override kwarg avoids test-button race condition - `routes/auth_routes.py` — discord_webhook preset test handler - `src/integrations.py` — discord_webhook preset with description + example templates; hides auth/key fields in the Integration form - `src/builtin_actions.py` — webhook_sent delivery check - `src/tool_implementations.py` — webhook aliases + enum updated - `static/index.html` — Webhook channel option; Integration picker + payload template textarea - `static/js/settings.js` — Integration list, populateWebhookIntegrations, syncChannelRows, hints, load/save, auto-fill preset templates, test-button override payload, hide auth/key for URL-auth presets Co-authored-by: Claude Sonnet 4.6 --- routes/auth_routes.py | 21 +++++++ routes/note_routes.py | 98 ++++++++++++++++++++++++++++--- src/builtin_actions.py | 2 + src/integrations.py | 13 ++++ src/settings.py | 9 ++- src/tool_implementations.py | 4 +- static/index.html | 11 +++- static/js/settings.js | 114 +++++++++++++++++++++++++++++++++++- 8 files changed, 260 insertions(+), 12 deletions(-) diff --git a/routes/auth_routes.py b/routes/auth_routes.py index 644b12d04..96284e4d0 100644 --- a/routes/auth_routes.py +++ b/routes/auth_routes.py @@ -585,6 +585,27 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter: hint = " If this is Docker Compose ntfy, set NTFY_BIND to that host/Tailscale IP and NTFY_BASE_URL to the same server URL in .env, then recreate ntfy." return {"ok": False, "message": f"ntfy publish to {full_url} failed: {e}.{hint}"[:500]} + if preset == "discord_webhook": + import httpx + webhook_url = (integ.get("base_url") or "").strip() + if not webhook_url: + return {"ok": False, "message": "No webhook URL set — paste the full Discord webhook URL into the Base URL field."} + payload = { + "embeds": [{ + "title": "Odysseus connectivity test", + "description": "If you see this, your Discord Webhook integration is wired up correctly.", + "color": 5793266, + }] + } + try: + async with httpx.AsyncClient(timeout=8.0) as client: + r = await client.post(webhook_url, json=payload) + if r.is_success: + return {"ok": True, "message": "Test embed sent — check your Discord channel to confirm it arrived."} + return {"ok": False, "message": f"Discord returned HTTP {r.status_code}: {r.text[:200]}"} + except Exception as e: + return {"ok": False, "message": f"Request failed: {e}"[:400]} + # All other presets: GET against a known health endpoint. # Fall back to detecting from name if preset is missing. health_paths = { diff --git a/routes/note_routes.py b/routes/note_routes.py index bcf7637f5..3ad002fb4 100644 --- a/routes/note_routes.py +++ b/routes/note_routes.py @@ -114,8 +114,9 @@ async def dispatch_reminder( note_id: str, owner: str = "", queue_browser: bool = True, + settings_override: dict | None = None, ) -> dict: - """Fire a reminder via the configured channel (browser/email/ntfy). + """Fire a reminder via the configured channel (browser/email/ntfy/webhook). Args: title: short headline shown to the user @@ -129,7 +130,7 @@ async def dispatch_reminder( nothing is "sent" synchronously for it — the channel just routes there. """ from src.settings import load_settings - settings = load_settings() + settings = {**load_settings(), **(settings_override or {})} channel = settings.get("reminder_channel", "browser") llm_on = bool(settings.get("reminder_llm_synthesis", False)) title = (title or "").strip() @@ -160,13 +161,14 @@ async def dispatch_reminder( # Treat those as browser-only dedupe so email reminders can be # retried by the backend scanner after a failed frontend path. should_skip = last_dt >= _dt.now(_tz.utc) - _td(minutes=25) - if should_skip and channel in ("email", "ntfy"): + if should_skip and channel in ("email", "ntfy", "webhook"): should_skip = last_channel == channel if should_skip: return { "synthesis": None, "email_sent": False, "ntfy_sent": False, + "webhook_sent": False, "browser_sent": True, "skipped": True, } @@ -360,6 +362,76 @@ async def dispatch_reminder( email_error = str(e) or e.__class__.__name__ logger.warning(f"Reminder email send failed: {e}") + webhook_sent = False + webhook_error = "" + if channel == "webhook": + try: + import httpx + import json as _wjson + from src.integrations import load_integrations + # Built-in payload defaults for known presets so users don't have + # to configure a template just to use a standard service. + _PRESET_TEMPLATE_DEFAULTS = { + "discord_webhook": '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}', + } + intg_id = settings.get("reminder_webhook_integration_id", "").strip() + template = settings.get("reminder_webhook_payload_template", "").strip() + if not intg_id: + webhook_error = "No webhook integration selected" + else: + intg = next( + (i for i in load_integrations() + if i.get("id") == intg_id and i.get("base_url")), + None, + ) + if not intg: + webhook_error = f"Integration {intg_id!r} not found or missing base URL" + else: + # Fall back to a built-in default for known presets so + # users don't have to configure a template for standard + # services like Discord. + if not template: + template = _PRESET_TEMPLATE_DEFAULTS.get(intg.get("preset", ""), "") + if not template: + webhook_error = "No payload template configured" + else: + # Render template: JSON-escape the values so the result + # is always valid JSON regardless of special characters. + # dumps() returns `"value"` — strip outer quotes. + msg = (synthesis or note_body or title or "Reminder")[:4000] + _t = _wjson.dumps(title or "Reminder")[1:-1] + _m = _wjson.dumps(msg)[1:-1] + rendered = template.replace("{{title}}", _t).replace("{{message}}", _m) + hdrs = {"Content-Type": "application/json"} + api_key = intg.get("api_key", "") + auth_type = (intg.get("auth_type") or "none").lower() + if api_key: + if auth_type == "bearer": + hdrs["Authorization"] = f"Bearer {api_key}" + elif auth_type == "header": + hdrs[intg.get("auth_header") or "Authorization"] = api_key + url = intg["base_url"].rstrip("/") + # SSRF guard — matches the pattern used by webhook_routes, + # CalDAV, search, and embeddings. Blocks link-local / metadata + # addresses (169.254.x.x) by default; set + # REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS=true to also block + # RFC-1918 ranges for locked-down deployments. + import os as _os + from src.url_safety import check_outbound_url as _chk + _block = _os.getenv("REMINDER_WEBHOOK_BLOCK_PRIVATE_IPS", "false").lower() == "true" + _ok, _reason = _chk(url, block_private=_block) + if not _ok: + webhook_error = f"Webhook URL rejected: {_reason}" + else: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(url, content=rendered.encode(), headers=hdrs) + webhook_sent = resp.is_success + if not webhook_sent: + webhook_error = f"Webhook returned HTTP {resp.status_code}" + except Exception as e: + webhook_error = str(e) or e.__class__.__name__ + logger.warning(f"Reminder webhook send failed: {e}") + ntfy_sent = False ntfy_error = "" if channel == "ntfy": @@ -415,7 +487,7 @@ async def dispatch_reminder( # second send for the same note within 25 min. Without this, a note # whose due_date fires while the user has the app open got TWO emails # (frontend-fired here + background-fired by ping_notes 0–5 min later). - if (email_sent or ntfy_sent or browser_sent or local_browser_sent) and note_id: + if (email_sent or ntfy_sent or webhook_sent or browser_sent or local_browser_sent) and note_id: try: import json as _json from datetime import datetime as _dt, timezone as _tz @@ -431,7 +503,7 @@ async def dispatch_reminder( _cache = cache or (_json.loads(_STATE.read_text(encoding="utf-8")) if _STATE.exists() else {}) except Exception: _cache = {} - sent_channel = "email" if email_sent else "ntfy" if ntfy_sent else "browser" + sent_channel = "email" if email_sent else "ntfy" if ntfy_sent else "webhook" if webhook_sent else "browser" _cache[cache_key or str(note_id)] = { "at": _dt.now(_tz.utc).isoformat(), "channel": sent_channel, @@ -441,11 +513,14 @@ async def dispatch_reminder( logger.debug(f"dispatch_reminder: cache write failed: {_e}") return { + "channel": channel, "synthesis": synthesis, "email_sent": email_sent, "email_error": email_error, "ntfy_sent": ntfy_sent, "ntfy_error": ntfy_error, + "webhook_sent": webhook_sent, + "webhook_error": webhook_error, "browser_sent": browser_sent or local_browser_sent, } @@ -692,12 +767,21 @@ def setup_note_routes(task_scheduler=None): if not note_id: raise HTTPException(400, "note_id required") - # Delegate to the module-level helper so background tasks can reuse - # the same dispatch without an HTTP roundtrip + auth cookie. + # Optional overrides let the test button pass the current UI values + # directly so the test never races against a pending settings save. + _override: dict = {} + if body.get("channel"): + _override["reminder_channel"] = body["channel"] + if body.get("webhook_integration_id"): + _override["reminder_webhook_integration_id"] = body["webhook_integration_id"] + if body.get("webhook_payload_template"): + _override["reminder_webhook_payload_template"] = body["webhook_payload_template"] + return await dispatch_reminder( title=title, note_body=note_body, note_id=note_id, owner=_owner(request) or "", queue_browser=False, + settings_override=_override or None, ) # --- REORDER NOTES --- diff --git a/src/builtin_actions.py b/src/builtin_actions.py index b1687000f..21975f910 100644 --- a/src/builtin_actions.py +++ b/src/builtin_actions.py @@ -1902,6 +1902,8 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]: delivered = bool(dispatch_result.get("email_sent")) elif channel == "ntfy": delivered = bool(dispatch_result.get("ntfy_sent")) + elif channel == "webhook": + delivered = bool(dispatch_result.get("webhook_sent")) if delivered: newly_notified.update(new_urgent) else: diff --git a/src/integrations.py b/src/integrations.py index 55fc293d5..8ff0aa065 100644 --- a/src/integrations.py +++ b/src/integrations.py @@ -100,6 +100,19 @@ INTEGRATION_PRESETS: Dict[str, Dict[str, Any]] = { " GET /{topic}/json?poll=1 — poll for messages" ), }, + "discord_webhook": { + "name": "Discord Webhook", + "auth_type": "none", + "description": ( + "Discord Incoming Webhook. Paste the full webhook URL (including the token) as the Base URL.\n" + "To get a URL: Discord server -> Server Settings -> Integrations -> Webhooks -> New Webhook -> Copy Webhook URL.\n" + "The secret is embedded in the URL — leave auth type as None.\n\n" + "Use this integration as the target in Settings -> Reminders -> Webhook channel.\n" + "Payload template examples:\n" + " Simple: {\"content\": \"{{title}}: {{message}}\"}\n" + " Embed: {\"embeds\": [{\"title\": \"{{title}}\", \"description\": \"{{message}}\", \"color\": 5793266}]}" + ), + }, "vaultwarden": { "name": "Vaultwarden", "auth_type": "header", diff --git a/src/settings.py b/src/settings.py index 5bce0fc70..f6540db53 100644 --- a/src/settings.py +++ b/src/settings.py @@ -141,10 +141,17 @@ DEFAULT_SETTINGS = { # library can grow beyond this; cleanup/retirement is an explicit review flow. "skill_max_injected": 3, # Reminders - "reminder_channel": "browser", # "browser" | "email" | "ntfy" + "reminder_channel": "browser", # "browser" | "email" | "ntfy" | "webhook" "reminder_llm_synthesis": False, "reminder_ntfy_topic": "Reminders", "reminder_email_to": "", + # Generic outbound webhook channel: pick any saved Integration as the + # target and supply a JSON payload template. Use {{title}} and {{message}} + # as placeholders — they are JSON-escaped before substitution, so the + # rendered string is always valid JSON. Works with Discord, Slack, Teams, + # ntfy (JSON mode), or any service that accepts a POST with a JSON body. + "reminder_webhook_integration_id": "", + "reminder_webhook_payload_template": "", # Email triage scanner rules. Running/paused state and schedule live in # Tasks via the built-in `check_email_urgency` task. "urgent_email_prompt": ( diff --git a/src/tool_implementations.py b/src/tool_implementations.py index dbaf50c2d..6a0999068 100644 --- a/src/tool_implementations.py +++ b/src/tool_implementations.py @@ -1566,6 +1566,8 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict: "image gen": "image_gen_enabled", "image generation": "image_gen_enabled", "reminder channel": "reminder_channel", "reminders": "reminder_channel", "ntfy topic": "reminder_ntfy_topic", + "webhook integration": "reminder_webhook_integration_id", + "webhook template": "reminder_webhook_payload_template", "webhook payload": "reminder_webhook_payload_template", "agent tool calls": "agent_max_tool_calls", "max tool calls": "agent_max_tool_calls", "agent timeout": "agent_stream_timeout_seconds", "stream timeout": "agent_stream_timeout_seconds", "token budget": "agent_input_token_budget", "input budget": "agent_input_token_budget", @@ -1581,7 +1583,7 @@ async def do_manage_settings(content: str, owner: Optional[str] = None) -> Dict: _ENUMS = { "image_quality": ["low", "medium", "high"], - "reminder_channel": ["browser", "email", "ntfy"], + "reminder_channel": ["browser", "email", "ntfy", "webhook"], } def _coerce(value, default): if isinstance(default, bool): diff --git a/static/index.html b/static/index.html index 3d5bad58c..98a5784e1 100644 --- a/static/index.html +++ b/static/index.html @@ -1957,6 +1957,7 @@ + + +
Configure email account, ntfy server, etc. in Integrations.

AI Synthesis

-
When on, the utility model writes a short, warm one-line reminder for browser, email, AND ntfy reminders instead of just the raw note content.
+
When on, the utility model writes a short, warm one-line reminder for browser, email, ntfy, AND webhook reminders instead of just the raw note content.

Public App URL

diff --git a/static/js/settings.js b/static/js/settings.js index dcc3498ba..403602fc3 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -2244,6 +2244,7 @@ async function initReminderSettings() { const channelSel = el('set-reminder-channel'); const emailOpt = el('set-reminder-channel-email-opt'); const ntfyOpt = el('set-reminder-channel-ntfy-opt'); + const webhookOpt = el('set-reminder-channel-webhook-opt'); const hint = el('set-reminder-channel-hint'); const llmToggle = el('set-reminder-llm-toggle'); // "Integrations" link in the channel-hint copy. Jumps to the @@ -2306,12 +2307,33 @@ async function initReminderSettings() { ntfyOpt.textContent = 'ntfy (add in Integrations first)'; } + // Webhook: available whenever at least one integration with a base_url exists. + // The user picks which integration to target and supplies a payload template. + let allIntegrations = []; + let webhookConfigured = false; + try { + const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); + if (res.ok) { + const data = await res.json(); + allIntegrations = (data.integrations || []).filter(i => i.base_url && i.enabled !== false); + webhookConfigured = allIntegrations.length > 0; + } + } catch (_) {} + if (!webhookConfigured && webhookOpt) { + webhookOpt.disabled = true; + webhookOpt.textContent = 'Webhook (add an Integration first)'; + } + const emailFromRow = el('set-reminder-email-from-row'); const emailAcctSel = el('set-reminder-email-account'); const emailToRow = el('set-reminder-email-to-row'); const emailToIn = el('set-reminder-email-to'); const ntfyTopicRow = el('set-reminder-ntfy-topic-row'); const ntfyTopicIn = el('set-reminder-ntfy-topic'); + const webhookIntgRow = el('set-reminder-webhook-intg-row'); + const webhookIntgSel = el('set-reminder-webhook-intg'); + const webhookTemplateRow = el('set-reminder-webhook-template-row'); + const webhookTemplateIn = el('set-reminder-webhook-template'); function populateReminderEmailAccounts(selectedId = '') { if (!emailAcctSel) return; @@ -2322,6 +2344,14 @@ async function initReminderSettings() { emailAcctSel.value = (selectedId && emailAccounts.some(a => a.id === selectedId)) ? selectedId : fallback; } + function populateWebhookIntegrations(selectedId = '') { + if (!webhookIntgSel) return; + webhookIntgSel.innerHTML = allIntegrations.length + ? allIntegrations.map(i => ``).join('') + : ''; + if (selectedId && allIntegrations.some(i => i.id === selectedId)) webhookIntgSel.value = selectedId; + } + function applyReminderChannelAvailability() { if (emailOpt) { emailOpt.disabled = !smtpConfigured; @@ -2331,11 +2361,16 @@ async function initReminderSettings() { ntfyOpt.disabled = !ntfyConfigured; ntfyOpt.textContent = ntfyConfigured ? 'ntfy' : 'ntfy (add in Integrations first)'; } + if (webhookOpt) { + webhookOpt.disabled = !webhookConfigured; + webhookOpt.textContent = webhookConfigured ? 'Webhook' : 'Webhook (add an Integration first)'; + } } async function refreshReminderChannelAvailability() { const currentChannel = channelSel.value || 'browser'; const currentEmailAccount = emailAcctSel?.value || ''; + const currentWebhookIntg = webhookIntgSel?.value || ''; try { const res = await fetch('/api/email/accounts', { credentials: 'same-origin' }); if (res.ok) { @@ -2353,6 +2388,8 @@ async function initReminderSettings() { ntfyConfigured = (data.integrations || []).some( i => (i.preset === 'ntfy' || (i.name || '').toLowerCase() === 'ntfy') && i.enabled !== false && i.base_url ); + allIntegrations = (data.integrations || []).filter(i => i.base_url && i.enabled !== false); + webhookConfigured = allIntegrations.length > 0; } } catch (_) {} if (!ntfyConfigured) { @@ -2365,8 +2402,10 @@ async function initReminderSettings() { applyReminderChannelAvailability(); populateReminderEmailAccounts(currentEmailAccount); + populateWebhookIntegrations(currentWebhookIntg); if (currentChannel === 'email' && !smtpConfigured) channelSel.value = 'browser'; else if (currentChannel === 'ntfy' && !ntfyConfigured) channelSel.value = 'browser'; + else if (currentChannel === 'webhook' && !webhookConfigured) channelSel.value = 'browser'; else channelSel.value = currentChannel; if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || ''; syncChannelRows(); @@ -2377,9 +2416,12 @@ async function initReminderSettings() { function syncChannelRows() { const isEmail = channelSel.value === 'email'; + const isWebhook = channelSel.value === 'webhook'; if (emailFromRow) emailFromRow.style.display = (isEmail && emailAccounts.length > 1) ? 'flex' : 'none'; if (emailToRow) emailToRow.style.display = isEmail ? 'flex' : 'none'; if (ntfyTopicRow) ntfyTopicRow.style.display = channelSel.value === 'ntfy' ? 'flex' : 'none'; + if (webhookIntgRow) webhookIntgRow.style.display = isWebhook ? 'flex' : 'none'; + if (webhookTemplateRow) webhookTemplateRow.style.display = isWebhook ? 'flex' : 'none'; } // Browser notifications fire on EVERY reminder (see @@ -2390,6 +2432,7 @@ async function initReminderSettings() { browser: 'Reminders appear as browser notifications inside Odysseus.', email: 'Reminders are emailed AND shown as a browser notification.', ntfy: 'Reminders are pushed via ntfy AND shown as a browser notification.', + webhook: 'Reminders are POSTed to the selected integration AND shown as a browser notification. Use {{title}} and {{message}} in the payload template.', }; applyReminderChannelAvailability(); @@ -2400,16 +2443,36 @@ async function initReminderSettings() { }); } + // Default payload templates for known presets — auto-filled when the user + // picks a matching integration so they don't have to write JSON from scratch. + // Defined here (before the load block) so both the load path and the change + // handler can reference it. + const WEBHOOK_PRESET_TEMPLATES = { + discord_webhook: '{"embeds": [{"title": "{{title}}", "description": "{{message}}", "color": 5793266}]}', + }; + try { const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const s = await res.json(); let savedChannel = s.reminder_channel || 'browser'; if (savedChannel === 'email' && !smtpConfigured) savedChannel = 'browser'; if (savedChannel === 'ntfy' && !ntfyConfigured) savedChannel = 'browser'; + if (savedChannel === 'webhook' && !webhookConfigured) savedChannel = 'browser'; channelSel.value = savedChannel; llmToggle.checked = !!s.reminder_llm_synthesis; if (emailToIn) emailToIn.value = s.reminder_email_to || ''; if (ntfyTopicIn) ntfyTopicIn.value = s.reminder_ntfy_topic || 'Reminders'; + populateWebhookIntegrations(s.reminder_webhook_integration_id || ''); + if (webhookTemplateIn) { + webhookTemplateIn.value = s.reminder_webhook_payload_template || ''; + // If an integration is already selected but no template was ever saved, + // auto-fill with the preset default so the first test works out of the box. + if (!webhookTemplateIn.value && webhookIntgSel?.value) { + const intg = allIntegrations.find(i => i.id === webhookIntgSel.value); + const tpl = WEBHOOK_PRESET_TEMPLATES[intg?.preset] || ''; + if (tpl) { webhookTemplateIn.value = tpl; save({ reminder_webhook_payload_template: tpl }); } + } + } // Restore the previously-picked email account (if any), otherwise // default to the account flagged is_default in the integrations // list. Falls through to the first option if neither exists. @@ -2459,6 +2522,28 @@ async function initReminderSettings() { topicDebounce = setTimeout(() => save({ reminder_ntfy_topic: ntfyTopicIn.value.trim() || 'reminders' }), 600); }); } + if (webhookIntgSel) { + webhookIntgSel.addEventListener('change', () => { + save({ reminder_webhook_integration_id: webhookIntgSel.value || '' }); + // If the template is empty and we recognise the integration's preset, + // pre-fill with a sensible default so users can test immediately. + if (webhookTemplateIn && !webhookTemplateIn.value.trim()) { + const intg = allIntegrations.find(i => i.id === webhookIntgSel.value); + const tpl = WEBHOOK_PRESET_TEMPLATES[intg?.preset] || ''; + if (tpl) { + webhookTemplateIn.value = tpl; + save({ reminder_webhook_payload_template: tpl }); + } + } + }); + } + if (webhookTemplateIn) { + let templateDebounce; + webhookTemplateIn.addEventListener('input', () => { + clearTimeout(templateDebounce); + templateDebounce = setTimeout(() => save({ reminder_webhook_payload_template: webhookTemplateIn.value.trim() }), 600); + }); + } // Dim the whole AI Synthesis card when off (matches Vision/Utility/etc.). function syncSynthesisDim() { const card = llmToggle.closest('.admin-card'); @@ -2495,6 +2580,11 @@ async function initReminderSettings() { note_id: 'test-' + Date.now(), title: 'Test Reminder', body: 'This is a test reminder to verify your settings are working.', + channel: channelSel.value, + ...(channelSel.value === 'webhook' ? { + webhook_integration_id: webhookIntgSel?.value || '', + webhook_payload_template: webhookTemplateIn?.value.trim() || '', + } : {}), }), }); const data = await res.json(); @@ -2505,10 +2595,15 @@ async function initReminderSettings() { if (channelSel.value === 'ntfy' && !data.ntfy_sent) { throw new Error(data.ntfy_error || 'ntfy reminder was not sent'); } + if (channelSel.value === 'webhook' && !data.webhook_sent) { + const activeChannel = data.channel ? ` (server used channel: "${data.channel}")` : ''; + throw new Error((data.webhook_error || 'Webhook reminder was not sent') + activeChannel); + } let status = 'Delivered via ' + channelSel.value; if (data.synthesis) status += ' (AI: "' + data.synthesis.slice(0, 60) + '...")'; if (data.email_sent) status += ' — email sent'; if (data.ntfy_sent) status += ' — ntfy sent'; + if (data.webhook_sent) status += ' — webhook sent'; if (testMsg) { testMsg.textContent = status; testMsg.style.color = 'var(--green, #50fa7b)'; } // Also fire a browser notification so user can see it if ('Notification' in window && Notification.permission === 'granted') { @@ -2932,12 +3027,18 @@ async function initIntegrations() { let editingId = null; let presets = {}; - // Toggle auth header row visibility + // Presets where the secret is embedded in the URL — no separate key or + // auth header is used, so hiding those fields avoids confusion. + const URL_AUTH_PRESETS = ['discord_webhook']; + + // Toggle auth header + key row visibility based on auth type and preset. function syncAuthRow() { const v = authTypeSel.value; authHeaderRow.style.display = (v === 'header' || v === 'query') ? 'flex' : 'none'; if (v === 'query') authHeaderIn.placeholder = 'api_key'; else authHeaderIn.placeholder = 'X-Auth-Token'; + const keyRow = keyIn?.closest('.settings-row'); + if (keyRow) keyRow.style.display = URL_AUTH_PRESETS.includes(presetSel?.value) ? 'none' : ''; } authTypeSel.addEventListener('change', syncAuthRow); @@ -3487,6 +3588,7 @@ async function initUnifiedIntegrations() { const _applyPreset = () => { const p = presets[preset.value]; const isNtfy = preset.value === 'ntfy' || (p && (p.name || '').toLowerCase() === 'ntfy'); + const isUrlAuth = preset.value === 'discord_webhook'; // secret embedded in URL — no key/auth fields needed if (ntfyHint) { ntfyHint.style.display = isNtfy ? 'block' : 'none'; if (isNtfy) { @@ -3494,8 +3596,16 @@ async function initUnifiedIntegrations() { } } if (url) { - url.placeholder = isNtfy ? 'http://127.0.0.1:8091' : 'http://localhost:8080'; + url.placeholder = isNtfy ? 'http://127.0.0.1:8091' : isUrlAuth ? 'https://discord.com/api/webhooks/...' : 'http://localhost:8080'; } + // For presets that embed the secret in the URL, hide auth/key/header rows + // so users aren't confused into thinking they need to fill them in. + const keyRow = key?.closest('.settings-row'); + const authRow = auth?.closest('.settings-row'); + const headerRow = el('uf-api-header-row'); + if (keyRow) keyRow.style.display = isUrlAuth ? 'none' : ''; + if (authRow) authRow.style.display = isUrlAuth ? 'none' : ''; + if (headerRow) headerRow.style.display = isUrlAuth ? 'none' : ''; if (!p) return; name.value = p.name || ''; auth.value = p.auth_type || 'none';