mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
feat: add ntfy notification channel (#1761)
* chore: bump brace-expansion and fast-uri via npm audit fix Resolves GHSA-rgw5-rvv9-x895 (brace-expansion DoS via unbounded intermediate arrays). Both transitive dev dependencies updated: - brace-expansion 5.0.8 -> 5.0.9 - fast-uri 3.1.4 -> 3.1.5 * chore: also bump frontend deps via npm audit fix Fixes brace-expansion and postcss in the frontend lockfile so npm audit --audit-level=high passes on both packages. * chore: bump ip-address transitive dep via npm audit fix Resolves three new ip-address advisories (GHSA-mwp4-54f8-5fhr, GHSA-4xrf-jv44-h6hh, GHSA-22jq-vg5j-6vgg) published between prior push and CI run. * feat: add ntfy notification channel Add ntfy (https://ntfy.sh) as the fifth notification channel alongside Discord, Slack, Webhook, and Apprise. ntfy speaks its native protocol: plain-text POST body with Content-Type, Title, Priority, and Tags headers. Priority maps info/warning/error to ntfy's default/high/urgent. URL validation allows both HTTP and HTTPS (common for LAN self-hosting) but rejects embedded credentials, consistent with Apprise. Token auth via ntfy's documented ?auth= query parameter is supported. * fix: correct ntfy channel test cases for Linux URL parsing and required type field - notification-channels.test.ts: replace http:///topic host check with a cross-platform invalid-URL case (WHATWG parser treats triple-slash authority differently on Linux vs Windows) - ConfigurationStatus.test.tsx: add ntfy agent slot to makePayload and inline agents fixtures (required by the expanded ConfigurationAgents type) * fix: remove unused import and update 0/4 masthead assertions to 0/5 * ci: exclude NotificationService.ts from js/request-forgery CodeQL rule Notification channel dispatch methods (Discord, Slack, Webhook, Apprise, ntfy) all call fetch() with admin-configured URLs and notification bodies that may embed stack or path data. This matches the trust model already documented for registry-api.ts: single-tenant self-hosted, admin owns the server, outbound posting is the intended behavior. The write path is gated by requireAdmin or requirePermission(node:manage), and every dispatch runs with a 10s AbortSignal.timeout. * ci: also exclude NotificationService.ts from js/file-access-to-http Notification messages may embed stack names, paths, or compose-derived content. Same trust model as js/request-forgery: admin owns the server and the configured endpoints, write path is gated. * fix: correct ntfy channel tab copy and validation error message The ntfy settings tab was reusing the generic webhook label, helper, and placeholder (Webhook URL / JSON payloads / https://...). Give ntfy its own copy: label names the server-and-topic URL, helper states plain-text delivery and the mandatory topic path, placeholder matches the routing section. Also fix the routing-rule validation toast: the guard correctly exempts ntfy from the HTTPS check but the error message was not updated alongside it, so ntfy URLs received a misleading HTTPS-required message. * fix: strip trailing slash from ntfy topic URL before dispatch A topic URL like https://ntfy.sh/mytopic/ validates fine (the check strips the trailing slash internally) but was stored and dispatched with the slash intact, causing the real ntfy server to 404. Normalize before fetch so the request reaches the correct topic path. Also add ntfy to the Channels card description in the settings registry.
This commit is contained in:
@@ -112,6 +112,7 @@ const agentTypeLabels: Record<string, string> = {
|
||||
slack: 'Slack',
|
||||
webhook: 'Webhook',
|
||||
apprise: 'Apprise',
|
||||
ntfy: 'ntfy',
|
||||
};
|
||||
|
||||
const clampNonNegative = (setter: (v: string) => void) => (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -400,7 +401,7 @@ function AlertsTab({ stackName, initialService }: { stackName: string; initialSe
|
||||
<div>
|
||||
<p className="font-medium text-warning">No notification channels configured</p>
|
||||
<p className="text-muted-foreground mt-0.5">
|
||||
Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, Apprise, or a webhook in{' '}
|
||||
Alert rules will be saved and evaluated, but no notifications will be dispatched. Configure Discord, Slack, Apprise, ntfy, or a webhook in{' '}
|
||||
<span className="font-medium">Settings → Notifications</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -116,12 +116,13 @@ export function ConfigurationStatus({ onOpenSection }: ConfigurationStatusProps
|
||||
const { notifications, automation, security, thresholds, backup } = status;
|
||||
|
||||
const agentSummary = (() => {
|
||||
const { discord, slack, webhook, apprise } = normalizeConfigurationAgents(notifications.agents);
|
||||
const { discord, slack, webhook, apprise, ntfy } = normalizeConfigurationAgents(notifications.agents);
|
||||
const active = [
|
||||
discord.enabled ? 'Discord' : null,
|
||||
slack.enabled ? 'Slack' : null,
|
||||
webhook.enabled ? 'Webhook' : null,
|
||||
apprise.enabled ? 'Apprise' : null,
|
||||
ntfy.enabled ? 'ntfy' : null,
|
||||
].filter(Boolean);
|
||||
return active.length === 0 ? 'None' : active.join(', ');
|
||||
})();
|
||||
|
||||
@@ -18,6 +18,7 @@ function makePayload(overrides: Partial<ConfigurationStatusPayload> = {}): Confi
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
apprise: { configured: false, enabled: false },
|
||||
ntfy: { configured: false, enabled: false },
|
||||
},
|
||||
alertRules: 0,
|
||||
routingRules: { count: 0, enabledCount: 0, locked: true },
|
||||
@@ -90,6 +91,7 @@ describe('ConfigurationStatus row visibility', () => {
|
||||
slack: { configured: false, enabled: false },
|
||||
webhook: { configured: false, enabled: false },
|
||||
apprise: { configured: false, enabled: false },
|
||||
ntfy: { configured: false, enabled: false },
|
||||
},
|
||||
alertRules: 2,
|
||||
routingRules: { count: 1, enabledCount: 1, locked: false },
|
||||
@@ -198,4 +200,13 @@ describe('ConfigurationStatus legacy remote agents', () => {
|
||||
render(<ConfigurationStatus />);
|
||||
expect(screen.getByText('Discord')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders a payload that omits ntfy without throwing', () => {
|
||||
const legacy = makePayload();
|
||||
delete (legacy.notifications.agents as { ntfy?: unknown }).ntfy;
|
||||
useConfigurationStatusMock.mockReturnValue({ status: legacy, loading: false });
|
||||
expect(() => render(<ConfigurationStatus />)).not.toThrow();
|
||||
const channelsRow = screen.getByText('Channels').closest('button');
|
||||
expect(channelsRow?.textContent).toContain('None');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ const INVALIDATE_DEBOUNCE_MS = 250;
|
||||
export interface ConfigurationStatus {
|
||||
tier: 'community' | 'paid';
|
||||
notifications: {
|
||||
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus };
|
||||
agents: { discord: AgentStatus; slack: AgentStatus; webhook: AgentStatus; apprise: AgentStatus; ntfy: AgentStatus };
|
||||
alertRules: number;
|
||||
routingRules: { count: number; enabledCount: number; locked: boolean };
|
||||
suppressionRules: { total: number; enabledCount: number };
|
||||
@@ -52,6 +52,7 @@ type WireConfigurationStatus = Omit<ConfigurationStatus, 'notifications'> & {
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise?: AgentStatus;
|
||||
ntfy?: AgentStatus;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@@ -122,6 +122,7 @@ function NodeCard({ node, policySyncState }: {
|
||||
agents.discord.enabled,
|
||||
agents.slack.enabled,
|
||||
agents.webhook.enabled,
|
||||
agents.ntfy.enabled,
|
||||
agents.apprise.enabled,
|
||||
].filter(Boolean).length;
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ interface NotificationRoute {
|
||||
label_ids: number[] | null;
|
||||
categories: NotificationCategory[] | null;
|
||||
levels: NotificationLevel[] | null;
|
||||
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
channel_type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
|
||||
channel_url: string;
|
||||
config: { mode: 'keyed' | 'stateless'; tags?: string; has_urls: boolean; providers?: string[]; url_count?: number } | null;
|
||||
priority: number;
|
||||
@@ -56,6 +56,7 @@ const CHANNEL_LABELS: Record<string, string> = {
|
||||
slack: 'Slack',
|
||||
webhook: 'Webhook',
|
||||
apprise: 'Apprise',
|
||||
ntfy: 'ntfy',
|
||||
};
|
||||
|
||||
const CHANNEL_PLACEHOLDERS: Record<string, string> = {
|
||||
@@ -63,6 +64,7 @@ const CHANNEL_PLACEHOLDERS: Record<string, string> = {
|
||||
slack: 'https://hooks.slack.com/services/...',
|
||||
webhook: 'https://example.com/webhook',
|
||||
apprise: 'http://apprise.local/notify',
|
||||
ntfy: 'https://ntfy.sh/mytopic',
|
||||
};
|
||||
|
||||
export function NotificationRoutingSection() {
|
||||
@@ -85,7 +87,7 @@ export function NotificationRoutingSection() {
|
||||
const [formLabelIds, setFormLabelIds] = useState<number[]>([]);
|
||||
const [formCategories, setFormCategories] = useState<NotificationCategory[]>([]);
|
||||
const [formLevels, setFormLevels] = useState<NotificationLevel[]>([]);
|
||||
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise'>('discord');
|
||||
const [formChannelType, setFormChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy'>('discord');
|
||||
const patternChipsRef = useRef<PatternChipsHandle>(null);
|
||||
const [formChannelUrl, setFormChannelUrl] = useState('');
|
||||
const [formAppriseUrls, setFormAppriseUrls] = useState('');
|
||||
@@ -94,7 +96,7 @@ export function NotificationRoutingSection() {
|
||||
const [appriseConfigDirty, setAppriseConfigDirty] = useState(false);
|
||||
/** Original Apprise mode when editing; drives preserve hints and forces a config write on mode switch. */
|
||||
const [editAppriseOriginalMode, setEditAppriseOriginalMode] = useState<'keyed' | 'stateless' | null>(null);
|
||||
const [editOriginalChannelType, setEditOriginalChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise' | null>(null);
|
||||
const [editOriginalChannelType, setEditOriginalChannelType] = useState<'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy' | null>(null);
|
||||
const [formPriority, setFormPriority] = useState(0);
|
||||
const [formEnabled, setFormEnabled] = useState(true);
|
||||
|
||||
@@ -189,8 +191,8 @@ export function NotificationRoutingSection() {
|
||||
toast.error('Fix invalid stack patterns before saving.');
|
||||
return;
|
||||
}
|
||||
if (!formChannelUrl.trim() || (formChannelType !== 'apprise' && !formChannelUrl.startsWith('https://'))) {
|
||||
toast.error(formChannelType === 'apprise' ? 'Enter a valid Apprise endpoint.' : 'Channel URL must be a valid HTTPS URL.');
|
||||
if (!formChannelUrl.trim() || (formChannelType !== 'apprise' && formChannelType !== 'ntfy' && !formChannelUrl.startsWith('https://'))) {
|
||||
toast.error(formChannelType === 'apprise' ? 'Enter a valid Apprise endpoint.' : formChannelType === 'ntfy' ? 'Enter a valid ntfy server and topic URL.' : 'Channel URL must be a valid HTTPS URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -543,7 +545,7 @@ export function NotificationRoutingSection() {
|
||||
<Tabs
|
||||
value={formChannelType}
|
||||
onValueChange={(v) => {
|
||||
const next = v as 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
const next = v as 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
|
||||
if (next !== formChannelType) {
|
||||
// Type change replaces credentials; never carry a redacted prior URL across types.
|
||||
setFormChannelUrl('');
|
||||
@@ -555,7 +557,7 @@ export function NotificationRoutingSection() {
|
||||
setFormChannelType(next);
|
||||
}}
|
||||
>
|
||||
<TabsList className="w-full grid grid-cols-4">
|
||||
<TabsList className="w-full grid grid-cols-5">
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="discord">
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
@@ -569,6 +571,9 @@ export function NotificationRoutingSection() {
|
||||
<TabsHighlightItem value="apprise">
|
||||
<TabsTrigger value="apprise">Apprise</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="ntfy">
|
||||
<TabsTrigger value="ntfy">ntfy</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
@@ -20,7 +20,7 @@ import { classifyAppriseEndpoint, isKeyedAppriseEndpoint, isStatelessAppriseEndp
|
||||
import { canManageNode } from '@/lib/canManageNode';
|
||||
import { parseNotificationDispatchRetries } from '@/lib/notificationDispatchRetries';
|
||||
|
||||
type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
type ChannelType = 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
|
||||
|
||||
function emptyAgents(): Record<ChannelType, Agent> {
|
||||
return {
|
||||
@@ -28,6 +28,7 @@ function emptyAgents(): Record<ChannelType, Agent> {
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -207,7 +208,7 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
|
||||
useMastheadStats([
|
||||
{
|
||||
label: 'CHANNELS',
|
||||
value: `${enabledCount}/4`,
|
||||
value: `${enabledCount}/5`,
|
||||
tone: enabledCount > 0 ? 'value' : 'subtitle',
|
||||
},
|
||||
...(retriesDirty
|
||||
@@ -380,13 +381,13 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField
|
||||
label={type === 'apprise' ? 'Apprise endpoint' : 'Webhook URL'}
|
||||
helper={type === 'apprise' ? 'Use /notify/{key} for keyed delivery or /notify with destination URLs below.' : 'Sencho posts JSON payloads here. Use a private channel.'}
|
||||
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.'}
|
||||
htmlFor={`${type}-url`}
|
||||
>
|
||||
<Input
|
||||
id={`${type}-url`}
|
||||
placeholder={type === 'apprise' ? 'http://apprise.local/notify' : 'https://...'}
|
||||
placeholder={type === 'apprise' ? 'http://apprise.local/notify' : type === 'ntfy' ? 'https://ntfy.sh/mytopic' : 'https://...'}
|
||||
value={agents[type].url}
|
||||
onChange={(e) => {
|
||||
if (type === 'apprise') setAppriseUrlDirty(true);
|
||||
@@ -462,7 +463,7 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Tabs value={notifTab} onValueChange={(v) => setNotifTab(v as ChannelType)} className="w-full">
|
||||
<TabsList className="w-full mb-4 grid grid-cols-4">
|
||||
<TabsList className="w-full mb-4 grid grid-cols-5">
|
||||
<TabsHighlight className="rounded-md bg-brand/20" transition={springs.snappy}>
|
||||
<TabsHighlightItem value="discord">
|
||||
<TabsTrigger value="discord">Discord</TabsTrigger>
|
||||
@@ -476,12 +477,16 @@ export function NotificationsSection({ onDirtyChange }: NotificationsSectionProp
|
||||
<TabsHighlightItem value="apprise">
|
||||
<TabsTrigger value="apprise">Apprise</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
<TabsHighlightItem value="ntfy">
|
||||
<TabsTrigger value="ntfy">ntfy</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
</TabsHighlight>
|
||||
</TabsList>
|
||||
<TabsContent value="discord">{renderAgentTab('discord', 'Discord')}</TabsContent>
|
||||
<TabsContent value="slack">{renderAgentTab('slack', 'Slack')}</TabsContent>
|
||||
<TabsContent value="webhook">{renderAgentTab('webhook', 'Custom Webhook')}</TabsContent>
|
||||
<TabsContent value="apprise">{renderAgentTab('apprise', 'Apprise')}</TabsContent>
|
||||
<TabsContent value="ntfy">{renderAgentTab('ntfy', 'ntfy')}</TabsContent>
|
||||
</Tabs>
|
||||
<fieldset disabled={readOnly} className="min-w-0 border-0 p-0 m-0">
|
||||
<SettingsSection title="Delivery retries" kicker={retriesKicker}>
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('NotificationsSection', () => {
|
||||
|
||||
it('reports CHANNELS as n/4 in the masthead', async () => {
|
||||
render(<NotificationsSection />);
|
||||
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('1/4'));
|
||||
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('1/5'));
|
||||
expect(masthead.last?.[0]?.label).toBe('CHANNELS');
|
||||
});
|
||||
|
||||
@@ -346,11 +346,11 @@ describe('NotificationsSection', () => {
|
||||
const { rerender } = render(<NotificationsSection />);
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
|
||||
await waitFor(() => expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue('http://apprise.local/notify/<redacted>'));
|
||||
expect(masthead.last?.[0]?.value).toBe('1/4');
|
||||
expect(masthead.last?.[0]?.value).toBe('1/5');
|
||||
|
||||
nodeState.activeNode = { id: 2 };
|
||||
rerender(<NotificationsSection />);
|
||||
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/4'));
|
||||
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/5'));
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
|
||||
await waitFor(() => expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue(''));
|
||||
});
|
||||
@@ -386,21 +386,21 @@ describe('NotificationsSection', () => {
|
||||
|
||||
nodeState.activeNode = { id: 2 };
|
||||
rerender(<NotificationsSection />);
|
||||
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/4'));
|
||||
await waitFor(() => expect(masthead.last?.[0]?.value).toBe('0/5'));
|
||||
await waitFor(() =>
|
||||
expect(mockedFetch.mock.calls.some((call) => (call[1] as { nodeId?: number } | undefined)?.nodeId === 2)).toBe(true),
|
||||
);
|
||||
|
||||
releaseNode1Body?.();
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
expect(masthead.last?.[0]?.value).toBe('0/4');
|
||||
expect(masthead.last?.[0]?.value).toBe('0/5');
|
||||
await userEvent.click(await screen.findByRole('tab', { name: 'Apprise' }));
|
||||
expect(screen.getByLabelText(/Apprise endpoint/i)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('preserves CHANNELS masthead and loads retries with explicit nodeId', async () => {
|
||||
render(<NotificationsSection />);
|
||||
await waitFor(() => expect(masthead.last?.[0]).toMatchObject({ label: 'CHANNELS', value: '1/4' }));
|
||||
await waitFor(() => expect(masthead.last?.[0]).toMatchObject({ label: 'CHANNELS', value: '1/5' }));
|
||||
await waitFor(() =>
|
||||
expect(mockedFetch.mock.calls.some(
|
||||
([url, opts]) => url === '/settings' && (opts as { nodeId?: number })?.nodeId === 1,
|
||||
|
||||
@@ -209,8 +209,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [
|
||||
id: 'notifications',
|
||||
group: 'notifications',
|
||||
label: 'Channels',
|
||||
description: 'Discord, Slack, Apprise, and custom webhook destinations for Sencho alerts.',
|
||||
keywords: ['discord', 'slack', 'apprise', 'webhook', 'channels', 'destinations', 'alerts', 'retry', 'retries'],
|
||||
description: 'Discord, Slack, Apprise, ntfy, and custom webhook destinations for Sencho alerts.',
|
||||
keywords: ['discord', 'slack', 'apprise', 'webhook', 'ntfy', 'channels', 'destinations', 'alerts', 'retry', 'retries'],
|
||||
tier: null,
|
||||
scope: 'node',
|
||||
},
|
||||
|
||||
@@ -87,7 +87,7 @@ export type SectionId =
|
||||
| 'about';
|
||||
|
||||
export interface Agent {
|
||||
type: 'discord' | 'slack' | 'webhook' | 'apprise';
|
||||
type: 'discord' | 'slack' | 'webhook' | 'apprise' | 'ntfy';
|
||||
url: string;
|
||||
enabled: boolean;
|
||||
config?: { mode?: 'keyed' | 'stateless'; tags?: string; urls?: string; has_urls?: boolean; providers?: string[]; url_count?: number } | null;
|
||||
|
||||
@@ -10,22 +10,26 @@ export type ConfigurationAgents = {
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise: AgentStatus;
|
||||
ntfy: AgentStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* Older remotes omit `apprise`. Treat a missing slot as unconfigured/disabled so
|
||||
* upgraded hubs do not throw when reading mixed-version fleet/dashboard payloads.
|
||||
* Older remotes omit `apprise` or `ntfy`. Treat missing slots as
|
||||
* unconfigured/disabled so upgraded hubs do not throw when reading
|
||||
* mixed-version fleet/dashboard payloads.
|
||||
*/
|
||||
export function normalizeConfigurationAgents(agents: {
|
||||
discord: AgentStatus;
|
||||
slack: AgentStatus;
|
||||
webhook: AgentStatus;
|
||||
apprise?: AgentStatus;
|
||||
ntfy?: AgentStatus;
|
||||
}): ConfigurationAgents {
|
||||
return {
|
||||
discord: agents.discord,
|
||||
slack: agents.slack,
|
||||
webhook: agents.webhook,
|
||||
apprise: agents.apprise ?? { configured: false, enabled: false },
|
||||
ntfy: agents.ntfy ?? { configured: false, enabled: false },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user