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
@@ -24,14 +24,25 @@ type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
function emptyAgents(): Record<ChannelType, Agent> {
return {
discord: { type: 'discord', url: '', enabled: false },
slack: { type: 'slack', url: '', enabled: false },
webhook: { type: 'webhook', url: '', enabled: false },
apprise: { type: 'apprise', url: '', enabled: false, config: null },
ntfy: { type: 'ntfy', url: '', enabled: false },
discord: { type: 'discord', url: '', enabled: false, payload_template: null },
slack: { type: 'slack', url: '', enabled: false, payload_template: null },
webhook: { type: 'webhook', url: '', enabled: false, payload_template: null },
apprise: { type: 'apprise', url: '', enabled: false, config: null, payload_template: null },
ntfy: { type: 'ntfy', url: '', enabled: false, payload_template: null },
};
}
const EMPTY_TEMPLATE_STATE: Record<ChannelType, boolean> = {
discord: false,
slack: false,
webhook: false,
apprise: false,
ntfy: false,
};
// Mirrors PAYLOAD_TEMPLATE_VARS in backend/src/helpers/notificationPayloadTemplate.ts.
const PAYLOAD_TEMPLATE_VARS = '{{level}} {{message}} {{category}} {{timestamp}} {{stack_name}} {{actor}}';
function appriseWriteConfig(agent: Agent): { urls: string } | { tags: string } {
if (isStatelessAppriseEndpoint(agent.url)) {
return { urls: agent.config?.urls ?? '' };
@@ -70,6 +81,8 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
const [isTestingAgent, setIsTestingAgent] = useState<Record<string, boolean>>({});
const [appriseUrlDirty, setAppriseUrlDirty] = useState(false);
const [appriseConfigDirty, setAppriseConfigDirty] = useState(false);
const [payloadOpen, setPayloadOpen] = useState<Record<string, boolean>>(EMPTY_TEMPLATE_STATE);
const [templateDirty, setTemplateDirty] = useState<Record<string, boolean>>(EMPTY_TEMPLATE_STATE);
const [retries, setRetries] = useState(DEFAULT_SETTINGS.notification_dispatch_retries!);
const [savedRetries, setSavedRetries] = useState(DEFAULT_SETTINGS.notification_dispatch_retries!);
@@ -105,6 +118,7 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
setAgents(next);
setAppriseUrlDirty(false);
setAppriseConfigDirty(false);
setTemplateDirty(EMPTY_TEMPLATE_STATE);
} catch (e) {
console.error('Failed to fetch agents', e);
}
@@ -194,6 +208,8 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
setAgents(emptyAgents());
setAppriseUrlDirty(false);
setAppriseConfigDirty(false);
setPayloadOpen(EMPTY_TEMPLATE_STATE);
setTemplateDirty(EMPTY_TEMPLATE_STATE);
setRetries(DEFAULT_SETTINGS.notification_dispatch_retries!);
setSavedRetries(DEFAULT_SETTINGS.notification_dispatch_retries!);
setRetriesLoadState('idle');
@@ -292,6 +308,12 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
enabled: agent.enabled,
};
// Include the template only when the editor was touched so a clean
// save (including enable toggles) preserves the stored value.
if (templateDirty[type]) {
body.payload_template = agents[type].payload_template ?? '';
}
if (type !== 'apprise') {
body.url = agent.url;
} else {
@@ -351,6 +373,8 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
if (type === 'apprise') {
body.config = appriseWriteConfig(agents.apprise);
}
// The test uses the current editor template; blank falls back to the built-in body.
body.payload_template = agents[type].payload_template ?? '';
const res = await apiFetch('/notifications/test', {
method: 'POST',
body: JSON.stringify(body),
@@ -380,6 +404,40 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
onChange={(c) => handleAgentChange(type, 'enabled', c)}
/>
</SettingsField>
<SettingsField label="Edit Payload" helper="Optionally replace the built-in body with your own JSON.">
<Button
variant="outline"
onClick={() => setPayloadOpen(prev => ({ ...prev, [type]: !prev[type] }))}
>
{payloadOpen[type] ? 'Hide payload template' : 'Edit Payload'}
</Button>
</SettingsField>
{payloadOpen[type] && (
<SettingsField
label="Payload template"
align="start"
htmlFor={`${type}-payload-template`}
helper={
`Optional JSON posted instead of Sencho's built-in body. Variables: ${PAYLOAD_TEMPLATE_VARS}. `
+ 'Values are JSON-escaped; missing context becomes an empty string.'
+ (type === 'apprise'
? ' Apprise destinations (urls/tag) stay managed by the channel fields and are merged automatically.'
: '')
+ ' Leave blank to restore the built-in payload.'
}
>
<textarea
id={`${type}-payload-template`}
className="flex min-h-[120px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
placeholder={'{"title": "{{level}}", "body": "{{message}}"}'}
value={agents[type].payload_template ?? ''}
onChange={(e) => {
setTemplateDirty(prev => ({ ...prev, [type]: true }));
handleAgentChange(type, 'payload_template', e.target.value);
}}
/>
</SettingsField>
)}
<SettingsField
label={type === 'apprise' ? 'Apprise endpoint' : type === 'ntfy' ? 'ntfy server and topic URL' : 'Webhook URL'}
helper={type === 'apprise' ? 'Use /notify/{key} for keyed delivery or /notify with destination URLs below.' : type === 'ntfy' ? 'Sencho posts a plain-text message. The URL must include the topic path.' : 'Sencho posts JSON payloads here. Use a private channel.'}
@@ -3,7 +3,7 @@
* save (omit redacted url/config when not dirty), and Test gating.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { MastheadMetadataItem } from '@/components/ui/PageMasthead';
@@ -41,6 +41,7 @@ vi.mock('../MastheadStatsContext', () => ({
}));
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { NotificationsSection } from '../NotificationsSection';
const mockedFetch = apiFetch as unknown as ReturnType<typeof vi.fn>;
@@ -708,5 +709,139 @@ describe('NotificationsSection', () => {
expect(screen.getByText(/Stored delivery retries value is invalid/i)).toBeInTheDocument();
});
describe('payload templates', () => {
const DISCORD_AGENT = {
type: 'discord',
url: 'https://discord.com/api/webhooks/1/token',
enabled: true,
payload_template: null,
};
function mockDiscordAgents(discord: unknown = DISCORD_AGENT) {
mockedFetch.mockImplementation(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && !opts?.method) return agentsResponse([discord]);
if (url === '/agents' && opts?.method === 'POST') {
return { ok: true, json: async () => ({}) };
}
return { ok: true, json: async () => ([]) };
});
}
// Interact only after the agents GET settles: a successful load resets
// the template dirty flag by design, so typing before the load lands
// would be overwritten.
async function waitForLoadedUrl() {
await waitFor(() => expect(screen.getByLabelText(/Webhook URL/i))
.toHaveValue('https://discord.com/api/webhooks/1/token'));
}
it('opens the editor and sends the template on save', async () => {
mockDiscordAgents();
render(<NotificationsSection />);
await waitForLoadedUrl();
await userEvent.click(screen.getByRole('button', { name: 'Edit Payload' }));
const editor = screen.getByLabelText(/Payload template/i);
fireEvent.change(editor, { target: { value: '{"title": "{{level}}", "body": "{{message}}"}' } });
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(findAgentsPost()).toBeTruthy());
const body = JSON.parse((findAgentsPost()![1] as { body: string }).body);
expect(body).toMatchObject({ type: 'discord', enabled: true });
expect(body.payload_template).toBe('{"title": "{{level}}", "body": "{{message}}"}');
});
it('omits payload_template from a clean save', async () => {
mockDiscordAgents();
render(<NotificationsSection />);
await waitForLoadedUrl();
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(findAgentsPost()).toBeTruthy());
const body = JSON.parse((findAgentsPost()![1] as { body: string }).body);
expect(body).toEqual({
type: 'discord',
url: 'https://discord.com/api/webhooks/1/token',
enabled: true,
});
expect(body).not.toHaveProperty('payload_template');
});
it('clears a stored template with an empty editor', async () => {
mockDiscordAgents({
type: 'discord',
url: 'https://discord.com/api/webhooks/1/token',
enabled: true,
payload_template: '{"title": "{{level}}"}',
});
render(<NotificationsSection />);
await waitForLoadedUrl();
await userEvent.click(screen.getByRole('button', { name: 'Edit Payload' }));
const editor = screen.getByLabelText(/Payload template/i);
expect(editor).toHaveValue('{"title": "{{level}}"}');
await userEvent.clear(editor);
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(findAgentsPost()).toBeTruthy());
const body = JSON.parse((findAgentsPost()![1] as { body: string }).body);
expect(body.payload_template).toBe('');
});
it('includes the current editor template in the test dispatch', async () => {
mockDiscordAgents();
render(<NotificationsSection />);
await waitForLoadedUrl();
await userEvent.click(screen.getByRole('button', { name: 'Edit Payload' }));
fireEvent.change(screen.getByLabelText(/Payload template/i), {
target: { value: '{"m": "{{message}}"}' },
});
await userEvent.click(screen.getByRole('button', { name: 'Test' }));
await waitFor(() => {
const testCall = mockedFetch.mock.calls.find(
([url, opts]) => url === '/notifications/test'
&& (opts as { method?: string } | undefined)?.method === 'POST',
);
expect(testCall).toBeTruthy();
const body = JSON.parse((testCall![1] as { body: string }).body);
expect(body).toMatchObject({
type: 'discord',
url: 'https://discord.com/api/webhooks/1/token',
payload_template: '{"m": "{{message}}"}',
});
});
});
it('keeps the editor content when the server rejects the template', async () => {
mockDiscordAgents();
render(<NotificationsSection />);
await waitForLoadedUrl();
await userEvent.click(screen.getByRole('button', { name: 'Edit Payload' }));
const editor = screen.getByLabelText(/Payload template/i);
fireEvent.change(editor, { target: { value: '{"a": "{{nope}}"}' } });
mockedFetch.mockImplementationOnce(async (url: string, opts?: { method?: string }) => {
if (url === '/agents' && opts?.method === 'POST') {
return {
ok: false,
status: 400,
json: async () => ({ error: 'payload_template Unknown template variable: {{nope}}.' }),
};
}
return { ok: true, json: async () => ({}) };
});
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(toast.error)
.toHaveBeenCalledWith(expect.stringContaining('Unknown template variable')));
expect(editor).toHaveValue('{"a": "{{nope}}"}');
});
});
});
@@ -91,4 +91,6 @@ export interface Agent {
url: string;
enabled: boolean;
config?: { mode?: 'keyed' | 'stateless'; tags?: string; urls?: string; has_urls?: boolean; providers?: string[]; url_count?: number } | null;
/** Optional user-authored JSON payload template; null/blank = built-in payload. */
payload_template?: string | null;
}