mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user