feat(notifications): customizable per-channel JSON payload templates (#1805)

Add an optional Edit Payload editor to every notification channel
(Discord, Slack, Webhook, Apprise, ntfy). A saved template replaces the
built-in payload for that channel, with {{level}}, {{message}},
{{category}}, {{timestamp}}, {{stack_name}}, and {{actor}} substituted as
JSON-escaped values (variables may stand alone or be mixed into strings).
Templates are validated on save: known variables only, no unterminated
tokens, valid JSON after substitution, 8000 characters max. Apprise keeps
urls/tag managed by the channel fields and merges them server-side at
dispatch. ntfy publishes JSON instead of plain text when templated. Test
dispatch uses the editor template.
This commit is contained in:
Anso
2026-08-11 10:25:29 -04:00
committed by GitHub
parent 578ce7684d
commit 866d784316
16 changed files with 1508 additions and 33 deletions
+7 -4
View File
@@ -30,6 +30,8 @@ export interface Agent {
url: string;
enabled: boolean;
config?: string | null;
/** Optional user-authored JSON payload template; null = built-in payload. */
payload_template?: string | null;
}
export interface GlobalSetting {
@@ -2436,6 +2438,7 @@ export class DatabaseService {
private migrateNotificationChannelConfig(): void {
this.tryAddColumn('agents', 'config', 'TEXT NULL');
this.tryAddColumn('agents', 'payload_template', 'TEXT NULL');
this.tryAddColumn('notification_routes', 'config', 'TEXT NULL');
}
@@ -2995,11 +2998,11 @@ export class DatabaseService {
const stored = this.storeAppriseFields(agent.type === 'apprise', agent.url, agent.config);
const existing = this.db.prepare('SELECT id FROM agents WHERE node_id = ? AND type = ?').get(nodeId, agent.type) as any;
if (existing) {
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ?, config = ? WHERE node_id = ? AND type = ?');
stmt.run(stored.url, agent.enabled ? 1 : 0, stored.config, nodeId, agent.type);
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ?, config = ?, payload_template = ? WHERE node_id = ? AND type = ?');
stmt.run(stored.url, agent.enabled ? 1 : 0, stored.config, agent.payload_template ?? null, nodeId, agent.type);
} else {
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled, config) VALUES (?, ?, ?, ?, ?)');
stmt.run(nodeId, agent.type, stored.url, agent.enabled ? 1 : 0, stored.config);
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled, config, payload_template) VALUES (?, ?, ?, ?, ?, ?)');
stmt.run(nodeId, agent.type, stored.url, agent.enabled ? 1 : 0, stored.config, agent.payload_template ?? null);
}
}
+152 -20
View File
@@ -21,6 +21,7 @@ import {
validateNotificationChannel,
} from '../helpers/notificationChannels';
import { parseNotificationDispatchRetries } from '../helpers/notificationDispatchRetries';
import { renderPayloadTemplate } from '../helpers/notificationPayloadTemplate';
export type NotificationCategory =
| 'deploy_success'
@@ -92,6 +93,22 @@ export class NotificationDeliveryError extends Error {
}
}
/**
* Per-dispatch extras for templated payloads. The template replaces the
* built-in body; all variable values are fixed at dispatch time (level and
* message come from the dispatch arguments, the rest from this object), so
* retries of the same dispatch send an identical body.
*/
export interface NotificationDispatchOptions {
category?: string;
stackName?: string;
actor?: string;
/** Dispatch timestamp (epoch ms); rendered as ISO-8601. Resolved once per dispatch by callers. */
timestampMs: number;
/** User-authored payload template; null/blank keeps the built-in body. */
template?: string | null;
}
export class NotificationService {
private static instance: NotificationService;
private dbService: DatabaseService;
@@ -317,7 +334,13 @@ export class NotificationService {
if (isDebugEnabled()) console.log(`[Notify:diag] Falling back to ${agents.length} global agent(s)`);
await Promise.allSettled(
agents.map(agent =>
this.sendWithRetries(agent.type, agent.url, level, sanitized, agent.config, retries)
this.sendWithRetries(agent.type, agent.url, level, sanitized, agent.config, retries, {
category,
stackName,
actor,
timestampMs: notification.timestamp,
template: agent.payload_template ?? null,
})
.then(() => {
if (isDebugEnabled()) console.log(`[Notify:diag] Dispatched ${level} via global agent (${agent.type})`);
})
@@ -377,12 +400,13 @@ export class NotificationService {
message: string,
config: string | null | undefined,
retries: number,
options?: NotificationDispatchOptions,
): Promise<void> {
const totalAttempts = 1 + retries;
let lastError: NotificationDeliveryError | undefined;
for (let attempt = 0; attempt < totalAttempts; attempt++) {
try {
await this.sendToChannel(type, url, level, message, config);
await this.sendToChannel(type, url, level, message, config, options);
return;
} catch (error) {
const deliveryError = error instanceof NotificationDeliveryError
@@ -403,7 +427,13 @@ export class NotificationService {
throw lastError ?? new NotificationDeliveryError('Notification delivery failed', null, false);
}
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null): Promise<void> {
private async sendToChannel(type: string, url: string, level: 'info' | 'warning' | 'error', message: string, config?: string | null, options?: NotificationDispatchOptions): Promise<void> {
// A stored template replaces the built-in body for every channel
// type; a whitespace-only stored template counts as no template.
if (options?.template && options.template.trim()) {
await this.sendTemplatedPayload(type, url, level, message, config, options);
return;
}
if (type === 'discord') {
await this.sendDiscordWebhook(url, level, message);
} else if (type === 'slack') {
@@ -423,13 +453,104 @@ export class NotificationService {
}
}
public async testDispatch(type: NotificationChannelType, url: string, config?: unknown) {
public async testDispatch(type: NotificationChannelType, url: string, config?: unknown, template?: string | null) {
if (!ALLOWED_CHANNEL_TYPES.has(type)) throw new Error(`Invalid notification type: ${type}`);
const validation = validateNotificationChannel(type, url, config);
if (validation) throw new Error(`URL ${validation}`);
const stored = type === 'apprise' ? normalizeAppriseStoredJson(url, config) : (config == null ? null : JSON.stringify(config));
const retries = this.resolveDispatchRetries();
await this.sendWithRetries(type, url, 'info', '🔌 Test Notification from Sencho!', stored, retries);
// Single timestamp for the whole test dispatch so every retry renders the same body.
await this.sendWithRetries(type, url, 'info', '🔌 Test Notification from Sencho!', stored, retries, {
category: 'system',
stackName: '',
actor: '',
timestampMs: Date.now(),
template: template ?? null,
});
}
/**
* Deliver a user-authored payload template for any channel type. The
* rendered document fully replaces the built-in body. Apprise keeps its
* destinations authoritative: the stored `urls` (stateless) or `tag`
* (keyed) are merged in after rendering, and the template may not carry
* those keys (rejected at write time). Render failures are non-retryable:
* a template that survived save-time validation cannot fail here, so a
* failure means hand-edited storage.
*/
private async sendTemplatedPayload(
type: string,
url: string,
level: 'info' | 'warning' | 'error',
message: string,
config: string | null | undefined,
options: NotificationDispatchOptions,
): Promise<void> {
let payload: unknown;
try {
payload = renderPayloadTemplate(options.template!, {
level,
message,
category: options.category ?? '',
timestamp: new Date(options.timestampMs).toISOString(),
stack_name: options.stackName ?? '',
actor: options.actor ?? '',
});
} catch (error) {
console.error('[Notify] Failed to render payload template:', error);
throw new NotificationDeliveryError('Templated payload could not be rendered', null, false);
}
let body = payload;
if (type === 'apprise') {
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
throw new NotificationDeliveryError('Apprise payload template must render a JSON object', null, false);
}
const parsed = parseStoredAppriseConfig(url, config);
if (!parsed.ok) {
throw new NotificationDeliveryError('Stored Apprise configuration is invalid for this endpoint', null, false);
}
const merged: Record<string, unknown> = { ...(body as Record<string, unknown>) };
if (parsed.mode === 'stateless') merged.urls = parsed.urlsJoined;
else if (parsed.tags) merged.tag = parsed.tags;
body = merged;
}
let targetUrl = url;
let authorization: string | undefined;
if (type === 'ntfy') {
const normalized = this.normalizeNtfyEndpoint(url);
targetUrl = normalized.effectiveUrl;
authorization = normalized.authorization;
}
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (authorization) headers['Authorization'] = authorization;
const response = await fetch(targetUrl, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS),
});
if (type === 'apprise' && response.status === 204) {
throw new NotificationDeliveryError('Apprise returned no delivery (HTTP 204)', 204, false);
}
if (response.status >= 400 && response.status < 500) {
throw new NotificationDeliveryError(`${type} rejected templated payload with HTTP ${response.status}`, response.status, false);
}
if (!response.ok) {
throw new NotificationDeliveryError(`${type} responded with HTTP ${response.status}`, response.status, true);
}
} catch (error) {
if (error instanceof NotificationDeliveryError) throw error;
const aborted = error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError');
throw new NotificationDeliveryError(
aborted ? `${type} request timed out` : `${type} request failed`,
null,
true,
);
}
}
private async sendAppriseNotify(
@@ -578,6 +699,30 @@ export class NotificationService {
}
}
/**
* Normalize an ntfy URL for delivery: strip URL userinfo into a Basic
* Authorization header (defensive; validateNtfyUrl rejects userinfo on
* the write path) and strip a trailing slash so URLs like
* https://ntfy.sh/mytopic/ reach the correct topic path. Returns the raw
* URL unchanged on parse failure.
*/
private normalizeNtfyEndpoint(url: string): { effectiveUrl: string; authorization?: string } {
try {
const parsed = new URL(url);
let authorization: string | undefined;
if (parsed.username || parsed.password) {
const encoded = btoa(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`);
authorization = `Basic ${encoded}`;
parsed.username = '';
parsed.password = '';
}
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
return { effectiveUrl: parsed.toString(), authorization };
} catch {
return { effectiveUrl: url };
}
}
private async sendNtfy(url: string, level: 'info' | 'warning' | 'error', message: string) {
const priorityMap = {
info: 'default',
@@ -594,21 +739,8 @@ export class NotificationService {
};
if (tags) headers['Tags'] = tags;
// Normalize the URL: strip userinfo (defensive; validateNtfyUrl rejects it
// on the write path) and strip a trailing slash so that URLs like
// https://ntfy.sh/mytopic/ reach the correct topic path.
let effectiveUrl = url;
try {
const parsed = new URL(url);
if (parsed.username || parsed.password) {
const encoded = btoa(`${decodeURIComponent(parsed.username)}:${decodeURIComponent(parsed.password)}`);
headers['Authorization'] = `Basic ${encoded}`;
parsed.username = '';
parsed.password = '';
}
parsed.pathname = parsed.pathname.replace(/\/+$/, '');
effectiveUrl = parsed.toString();
} catch { /* use the raw url on parse failure */ }
const { effectiveUrl, authorization } = this.normalizeNtfyEndpoint(url);
if (authorization) headers['Authorization'] = authorization;
try {
const response = await fetch(effectiveUrl, {