mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
feat(notifications): ntfy/Slack/Discord/generic webhook for the push events
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
9240493c43
commit
0ff57ce304
@@ -2601,6 +2601,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 ════════════════════════════════════════════════════ -->
|
||||
|
||||
@@ -416,6 +416,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// .checked fires no onchange, so the list's visibility (and lazy load)
|
||||
// needs an explicit sync on every open, not just a save.
|
||||
this.applyCliManagementVisibility();
|
||||
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 =
|
||||
@@ -1114,6 +1115,88 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._updateCheck = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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; }
|
||||
|
||||
@@ -28,6 +28,7 @@ export { registerWsRoutes } from './ws-routes.js';
|
||||
export { registerVoiceRoutes } from './voice-routes.js';
|
||||
export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js';
|
||||
export { registerTabLayoutRoutes } from './tab-layout-routes.js';
|
||||
export { registerWebhookRoutes } from './webhook-routes.js';
|
||||
export {
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* @fileoverview Webhook notification settings (src/webhook-notify.ts).
|
||||
*
|
||||
* GET /api/webhook — the config WITHOUT its URL (scheme + host only), and the last delivery result
|
||||
* PUT /api/webhook — change enabled / kind / url / scope; an empty `url` clears it
|
||||
* POST /api/webhook/test — send one test message with the saved config
|
||||
*
|
||||
* The URL is a bearer secret (anyone holding a Slack/Discord webhook URL can post as it), so it is
|
||||
* stored in its own 0600 file and never returned. In multi-user mode all three routes are admin only:
|
||||
* the channel receives every session's events, the same reach an admin's own Web Push has.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
import { isAdmin, parseBody } from '../route-helpers.js';
|
||||
import { isMultiUserMode } from '../../config/multiuser.js';
|
||||
import { WebhookUpdateSchema } from '../schemas.js';
|
||||
import {
|
||||
maskWebhookUrl,
|
||||
readWebhookConfig,
|
||||
webhookUrlProblem,
|
||||
writeWebhookConfig,
|
||||
type WebhookKind,
|
||||
type WebhookNotifier,
|
||||
type WebhookResult,
|
||||
type WebhookScope,
|
||||
} from '../../webhook-notify.js';
|
||||
|
||||
export interface WebhookStatus {
|
||||
enabled: boolean;
|
||||
kind: WebhookKind;
|
||||
scope: WebhookScope;
|
||||
hasUrl: boolean;
|
||||
/** Scheme + host only; the path and query are the secret. */
|
||||
urlMasked: string;
|
||||
lastResult: WebhookResult | null;
|
||||
}
|
||||
|
||||
export interface WebhookRouteDeps {
|
||||
notifier: WebhookNotifier;
|
||||
configDir: string;
|
||||
/** The instance's window title, so a test message says which machine sent it. */
|
||||
hostTitle: () => string;
|
||||
}
|
||||
|
||||
export function registerWebhookRoutes(app: FastifyInstance, deps: WebhookRouteDeps): void {
|
||||
const denied = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => {
|
||||
if (isMultiUserMode() && !isAdmin(req)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const status = async (): Promise<WebhookStatus> => {
|
||||
const cfg = await readWebhookConfig(deps.configDir);
|
||||
return {
|
||||
enabled: cfg.enabled,
|
||||
kind: cfg.kind,
|
||||
scope: cfg.scope,
|
||||
hasUrl: cfg.url !== '',
|
||||
urlMasked: maskWebhookUrl(cfg.url),
|
||||
lastResult: deps.notifier.lastResult,
|
||||
};
|
||||
};
|
||||
|
||||
app.get('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.put('/api/webhook', async (req, reply): Promise<ApiResponse<WebhookStatus>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const patch = parseBody(WebhookUpdateSchema, req.body, 'Invalid webhook settings');
|
||||
const current = await readWebhookConfig(deps.configDir);
|
||||
const next = {
|
||||
enabled: patch.enabled ?? current.enabled,
|
||||
kind: patch.kind ?? current.kind,
|
||||
scope: patch.scope ?? current.scope,
|
||||
url: patch.url !== undefined ? patch.url.trim() : current.url,
|
||||
};
|
||||
if (next.url) {
|
||||
const problem = webhookUrlProblem(next.url);
|
||||
if (problem) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, problem);
|
||||
}
|
||||
}
|
||||
if (next.enabled && !next.url) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Add a webhook URL before enabling notifications');
|
||||
}
|
||||
try {
|
||||
await writeWebhookConfig(deps.configDir, next);
|
||||
} catch (err) {
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
|
||||
}
|
||||
return { success: true, data: await status() };
|
||||
});
|
||||
|
||||
app.post('/api/webhook/test', async (req, reply): Promise<ApiResponse<WebhookResult>> => {
|
||||
const no = denied(req, reply);
|
||||
if (no) return no;
|
||||
const cfg = await readWebhookConfig(deps.configDir);
|
||||
if (!cfg.url) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Save a webhook URL first');
|
||||
}
|
||||
// 200 even when delivery failed: the request to Codeman worked, `data.ok` says whether the webhook did.
|
||||
return { success: true, data: await deps.notifier.sendTest(cfg, deps.hostTitle()) };
|
||||
});
|
||||
}
|
||||
@@ -1827,6 +1827,19 @@ export const RespawnEnableSchema = z.object({
|
||||
// ========== Web Push ==========
|
||||
|
||||
/** POST /api/push/subscribe */
|
||||
/**
|
||||
* PUT /api/webhook. `.strict()` like every settings-shaped schema; `url` is optional so a change of
|
||||
* kind or scope never needs the secret re-sent, and an empty string clears it.
|
||||
*/
|
||||
export const WebhookUpdateSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
kind: z.enum(['ntfy', 'slack', 'discord', 'generic']).optional(),
|
||||
scope: z.enum(['attention', 'all']).optional(),
|
||||
url: z.string().max(2048).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const PushSubscribeSchema = z.object({
|
||||
endpoint: z
|
||||
.string()
|
||||
|
||||
+51
-22
@@ -44,6 +44,8 @@ import { hostname as getHostname, uptime as osUptime } from 'node:os';
|
||||
import { looksLikeHostReboot, newestPersistedActivity, planRebootRestore } from '../reboot-restore.js';
|
||||
import { rebootRestoreRegistry } from './reboot-restore-registry.js';
|
||||
import { dataPath, getDataDir, CODEMAN_INSTANCE } from '../config/instance.js';
|
||||
import { WebhookNotifier, readWebhookConfig, type WebhookUrgency } from '../webhook-notify.js';
|
||||
import { webviewFetch } from './webview-egress.js';
|
||||
import { readRemoteHosts, rehydrateRemoteHostFields } from '../remote-hosts.js';
|
||||
import type { RemoteWakeRegistry } from '../remote-wake.js';
|
||||
import { normalizeBasePath, stripBasePath, joinBasePath } from '../config/base-path.js';
|
||||
@@ -197,6 +199,7 @@ import {
|
||||
registerVoiceRoutes,
|
||||
registerWebviewRoutes,
|
||||
registerTabLayoutRoutes,
|
||||
registerWebhookRoutes,
|
||||
registerCustomModelRoutes,
|
||||
refreshAllCustomModelHosts,
|
||||
readCustomModelEndpointsEnabled,
|
||||
@@ -368,6 +371,8 @@ export class WebServer extends EventEmitter {
|
||||
private hookSecretFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private userFailures: StaleExpirationMap<string, number> | null = null;
|
||||
private pushStore: PushSubscriptionStore = new PushSubscriptionStore();
|
||||
/** ntfy / Slack / Discord / generic webhook for the push events; config in webhook.json (0600). */
|
||||
private webhookNotifier = new WebhookNotifier(() => readWebhookConfig(getDataDir()), webviewFetch);
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
private _orchestratorLoop: import('../orchestrator-loop.js').OrchestratorLoop | null = null;
|
||||
private readonly titleHostname: string;
|
||||
@@ -1130,6 +1135,11 @@ export class WebServer extends EventEmitter {
|
||||
registerOrchestratorRoutes(this.app, ctx);
|
||||
registerWebviewRoutes(this.app, ctx, this.basePath);
|
||||
registerTabLayoutRoutes(this.app, ctx);
|
||||
registerWebhookRoutes(this.app, {
|
||||
notifier: this.webhookNotifier,
|
||||
configDir: getDataDir(),
|
||||
hostTitle: () => this.windowTitle,
|
||||
});
|
||||
registerCustomModelRoutes(this.app);
|
||||
registerCliRegistryRoutes(this.app);
|
||||
|
||||
@@ -2652,6 +2662,25 @@ export class WebServer extends EventEmitter {
|
||||
const template = WebServer.PUSH_EVENT_MAP[event];
|
||||
if (!template) return;
|
||||
|
||||
const sessionName = (data.sessionName as string) || '';
|
||||
const sessionId = (data.sessionId as string) || '';
|
||||
const body = WebServer.pushBodyText(event, data, sessionName);
|
||||
|
||||
// Webhook channel (ntfy / Slack / Discord / generic): independent of Web Push, so it runs BEFORE
|
||||
// the "no subscriptions" return below, which is exactly the headless-server case it exists for.
|
||||
// Fire-and-forget; WebhookNotifier dedupes, caps what is in flight and never throws.
|
||||
void this.webhookNotifier
|
||||
.notify({
|
||||
event,
|
||||
title: template.title,
|
||||
body,
|
||||
urgency: template.urgency as WebhookUrgency,
|
||||
sessionId: sessionId || undefined,
|
||||
sessionName: sessionName || undefined,
|
||||
host: this.windowTitle,
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
const subscriptions = this.pushStore.getAll();
|
||||
if (subscriptions.length === 0) return;
|
||||
|
||||
@@ -2670,9 +2699,6 @@ export class WebServer extends EventEmitter {
|
||||
const vapidKeys = this.pushStore.getVapidKeys();
|
||||
webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey);
|
||||
|
||||
const sessionName = (data.sessionName as string) || '';
|
||||
const sessionId = (data.sessionId as string) || '';
|
||||
|
||||
// Multi-user: a session-scoped push (all PUSH_EVENT_MAP events carry a sessionId)
|
||||
// must reach only the owner's devices (+ admins) — the body embeds the session
|
||||
// name + activity, so cross-user delivery would leak it. Resolved once here; the
|
||||
@@ -2680,25 +2706,6 @@ export class WebServer extends EventEmitter {
|
||||
const multiUserPush = isMultiUserMode();
|
||||
const pushSessionOwner = sessionId ? this.sessions.get(sessionId)?.owner : undefined;
|
||||
|
||||
// Build body text from event data
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.error).slice(0, 200);
|
||||
} else if (event === SseEvent.RespawnBlocked && data.reason) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.reason);
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({
|
||||
title: template.title,
|
||||
// Hostname-aware prefix so OS-level notifications from multiple Codeman
|
||||
@@ -2752,6 +2759,28 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
/** The notification body for an event (shared by Web Push and the webhook channel). */
|
||||
private static pushBodyText(event: string, data: Record<string, unknown>, sessionName: string): string {
|
||||
let body = sessionName ? `[${sessionName}]` : '';
|
||||
if (event === SseEvent.SessionError && data.error) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.error).slice(0, 200);
|
||||
} else if (event === SseEvent.RespawnBlocked && data.reason) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.reason);
|
||||
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
|
||||
body += body ? ' ' : '';
|
||||
body += String(data.phrase);
|
||||
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
|
||||
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
|
||||
body += body ? ' ' : '';
|
||||
body += `Tool: ${String(data.tool_name)}`;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private cleanupDeadSSEClients(): void {
|
||||
this.sse.cleanupDeadClients();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user