Merge pull request #523 from opticon454/feat/webhook-notifications

feat(notifications): ntfy/Slack/Discord/generic webhook for the push events

# Conflicts:
#	config/test-suites.ts
#	docs/api-reference.md
#	src/web/public/settings-ui.js
#	src/web/routes/index.ts
#	src/web/server.ts
This commit is contained in:
Codeman maintainer
2026-10-04 23:24:39 +02:00
13 changed files with 1280 additions and 22 deletions
+47
View File
@@ -2641,6 +2641,53 @@
</div>
</div>
</div>
<div class="set-group" id="webhookGroup" style="display:none">
<div class="set-group-head"><h4>Webhook (ntfy, Slack, Discord)</h4><span class="set-scope">server</span></div>
<div class="set-group-body">
<div class="set-row" data-search="webhook ntfy slack discord notification phone headless">
<div class="set-row-text">
<span class="set-row-label">Send alerts to a webhook</span>
<span class="set-row-desc">Posts the same events as push notifications (permission prompts, questions, errors, idle) to ntfy, Slack, Discord or any URL, so a server with no browser open can still reach your phone. The URL is a secret: it is stored on the server only and is never shown again once saved.</span>
</div>
<label class="switch switch-sm"><input type="checkbox" id="webhookEnabled"><span class="slider"></span></label>
</div>
<div class="set-row has-field">
<div class="set-row-text"><span class="set-row-label">Service</span></div>
<select id="webhookKind" class="set-select">
<option value="ntfy">ntfy</option>
<option value="slack">Slack</option>
<option value="discord">Discord</option>
<option value="generic">Generic JSON</option>
</select>
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Webhook URL</span>
<span class="set-row-desc" id="webhookUrlHint">Nothing saved yet.</span>
</div>
<input type="password" id="webhookUrl" class="set-select" autocomplete="off" spellcheck="false" placeholder="https://ntfy.sh/your-topic">
</div>
<div class="set-row has-field">
<div class="set-row-text">
<span class="set-row-label">Which events</span>
<span class="set-row-desc">"Needs attention" skips the routine "response complete" message.</span>
</div>
<select id="webhookScope" class="set-select">
<option value="attention">Needs attention</option>
<option value="all">Everything</option>
</select>
</div>
<div class="set-row">
<div class="set-row-text"><span class="set-row-label">Save and test</span></div>
<span>
<button class="btn-toolbar btn-sm btn-primary" id="webhookSaveBtn" onclick="app.saveWebhook()">Save</button>
<button class="btn-toolbar btn-sm" id="webhookTestBtn" onclick="app.testWebhook()">Send test</button>
</span>
</div>
<div id="webhookResult" class="set-note" style="display:none" data-i18n-skip></div>
</div>
</div>
</section>
<!-- ══ Voice ════════════════════════════════════════════════════ -->
+83
View File
@@ -419,6 +419,7 @@ Object.assign(CodemanApp.prototype, {
// MCP server sync: synced, default OFF; same explicit-sync reasoning as above.
document.getElementById('appSettingsMcpSync').checked = settings.mcpSyncEnabled === true;
this.applyMcpSyncVisibility();
this.loadWebhook();
// Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens).
document.getElementById('appSettingsReadMyMind').checked = settings.readMyMindEnabled === true;
document.getElementById('appSettingsUltracodeFloatingWindows').checked =
@@ -1188,6 +1189,88 @@ Object.assign(CodemanApp.prototype, {
show(`<ul>${rows.join('')}</ul>${conflicts}${disabled}${unsupported}`);
},
/**
* Webhook notifications (Settings → Notifications). Server-side config behind /api/webhook, not a
* settings-payload field: the URL is a secret, so it never round-trips through settings.json or
* this page. The URL box is write-only; the status line shows scheme + host only.
*/
_webhookSay(text, bad = false) {
const out = document.getElementById('webhookResult');
if (!out) return;
out.textContent = text;
out.style.display = text ? 'block' : 'none';
out.style.color = bad ? 'var(--danger, #e5534b)' : '';
},
async loadWebhook() {
const group = document.getElementById('webhookGroup');
if (!group) return;
const res = await this._api('/api/webhook');
if (!res || !res.ok) {
group.style.display = 'none'; // not an admin in multi-user mode, or the server predates the route
return;
}
let body = null;
try { body = await res.json(); } catch { /* leave hidden */ }
if (!body || body.success === false) { group.style.display = 'none'; return; }
const d = body.data;
group.style.display = '';
document.getElementById('webhookEnabled').checked = d.enabled === true;
document.getElementById('webhookKind').value = d.kind;
document.getElementById('webhookScope').value = d.scope;
const url = document.getElementById('webhookUrl');
url.value = '';
url.placeholder = d.hasUrl ? 'Saved. Paste a new URL to replace it' : 'https://ntfy.sh/your-topic';
document.getElementById('webhookUrlHint').textContent = d.hasUrl ? `Saved: ${d.urlMasked}` : 'Nothing saved yet.';
if (d.lastResult) {
const when = new Date(d.lastResult.at).toLocaleString();
this._webhookSay(
d.lastResult.ok ? `Last delivery succeeded (${when}).` : `Last delivery failed (${when}): ${d.lastResult.error}`,
!d.lastResult.ok
);
} else {
this._webhookSay('');
}
},
async saveWebhook() {
const payload = {
enabled: document.getElementById('webhookEnabled').checked,
kind: document.getElementById('webhookKind').value,
scope: document.getElementById('webhookScope').value,
};
const url = document.getElementById('webhookUrl').value.trim();
if (url) payload.url = url; // blank = keep the saved one
const res = await this._api('/api/webhook', { method: 'PUT', body: payload });
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not save the webhook.', true);
return;
}
await this.loadWebhook();
this._webhookSay('Saved.');
},
async testWebhook() {
const btn = document.getElementById('webhookTestBtn');
if (btn) btn.disabled = true;
this._webhookSay('Sending…');
try {
const res = await this._apiPost('/api/webhook/test', {});
let body = null;
try { body = res ? await res.json() : null; } catch { /* fall through */ }
if (!res || !res.ok || !body || body.success === false) {
this._webhookSay(body?.error || 'Could not send the test.', true);
return;
}
const r = body.data;
this._webhookSay(r.ok ? 'Test sent. Check your phone or channel.' : `Delivery failed: ${r.error}`, !r.ok);
} finally {
if (btn) btn.disabled = false;
}
},
_setUpdateResult(html) {
const el = this.$('updateResult');
if (el) { el.style.display = 'block'; el.innerHTML = html; }