mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
feat(reminders): add generic webhook as a fourth reminder channel (#2952)
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 <noreply@anthropic.com>
This commit is contained in:
+112
-2
@@ -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 => `<option value="${esc(i.id)}">${esc(i.name || i.id)}</option>`).join('')
|
||||
: '<option value="">No integrations configured</option>';
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user