From 70491423dc52619b8a6ce393b2fb27dd864c436c Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Sun, 23 Aug 2026 10:20:59 +0200 Subject: [PATCH] Pushover notifications can target specific devices --- src/lib/server/notifications/index.ts | 1 + src/lib/server/notifications/pushover.ts | 15 +++-- src/lib/utils/notification-parsers.ts | 32 +++++++++++ .../notifications/NotificationModal.svelte | 12 ++-- tests/notifications.test.ts | 56 ++++++++++++++++++- 5 files changed, 104 insertions(+), 12 deletions(-) diff --git a/src/lib/server/notifications/index.ts b/src/lib/server/notifications/index.ts index 65aeec0..8e8f399 100644 --- a/src/lib/server/notifications/index.ts +++ b/src/lib/server/notifications/index.ts @@ -108,6 +108,7 @@ async function sendToAppriseUrl(url: string, payload: NotificationPayload): Prom case 'apprises': return await sendApprise(url, payload); case 'pushover': + case 'pover': return await sendPushover(url, payload); case 'json': case 'jsons': diff --git a/src/lib/server/notifications/pushover.ts b/src/lib/server/notifications/pushover.ts index 3f4d19d..3978136 100644 --- a/src/lib/server/notifications/pushover.ts +++ b/src/lib/server/notifications/pushover.ts @@ -1,13 +1,14 @@ -/** Pushover. pushover://user_key/api_token. */ +/** Pushover. pushover://user_key/api_token[/device...] or pover://user_key@api_token[/device...]. */ import { notificationFetch, drainResponse, type NotificationPayload, type NotificationResult } from './shared'; +import { parsePushoverUrl } from '$lib/utils/notification-parsers'; export async function sendPushover(appriseUrl: string, payload: NotificationPayload): Promise { - const match = appriseUrl.match(/^pushover:\/\/([^/]+)\/(.+)/); - if (!match) { - return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token' }; + const parsed = parsePushoverUrl(appriseUrl); + if (!parsed) { + return { success: false, error: 'Invalid Pushover URL format. Expected: pushover://user_key/api_token[/device]' }; } - const [, userKey, apiToken] = match; + const { userKey, apiToken, device } = parsed; const url = 'https://api.pushover.net/1/messages.json'; const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title; @@ -20,7 +21,9 @@ export async function sendPushover(appriseUrl: string, payload: NotificationPayl user: userKey, title: titleWithEnv, message: payload.message, - priority: payload.type === 'error' ? 1 : 0 + priority: payload.type === 'error' ? 1 : 0, + // Optional: a comma-separated device list restricts delivery; omit to reach all devices. + ...(device ? { device } : {}) }) }); diff --git a/src/lib/utils/notification-parsers.ts b/src/lib/utils/notification-parsers.ts index 3417cf3..712de92 100644 --- a/src/lib/utils/notification-parsers.ts +++ b/src/lib/utils/notification-parsers.ts @@ -53,6 +53,38 @@ export function buildGotifyUrl(appriseUrl: string): { url: string; priority?: nu }; } +// --- Pushover --- + +/** + * Parse a Pushover apprise URL into its parts. Two accepted schemes, both with + * an OPTIONAL trailing device list (backward compatible - existing + * pushover://user/token URLs parse with device undefined): + * pushover://user_key/api_token[/device1[/device2...]] + * pover://user_key@api_token[/device1[/device2...]] (Apprise-native form) + * The Pushover API `device` field is a comma-separated list, so multiple path + * segments are joined with commas. Returns null on a malformed URL. + */ +export function parsePushoverUrl( + url: string +): { userKey: string; apiToken: string; device?: string } | null { + let userKey: string; + let apiToken: string; + let rest: string; + + const pover = url.match(/^pover:\/\/([^/@]+)@([^/]+)(?:\/(.*))?$/); + if (pover) { + [, userKey, apiToken, rest = ''] = pover; + } else { + const push = url.match(/^pushover:\/\/([^/]+)\/([^/]+)(?:\/(.*))?$/); + if (!push) return null; + [, userKey, apiToken, rest = ''] = push; + } + + if (!userKey || !apiToken) return null; + const devices = rest.split('/').map((d) => d.trim()).filter(Boolean); + return { userKey, apiToken, device: devices.length ? devices.join(',') : undefined }; +} + // --- Workflows (Microsoft Power Automate) --- export function parseWorkflowsUrl(appriseUrl: string): { hostname: string; workflow: string; signature: string } | null { diff --git a/src/routes/settings/notifications/NotificationModal.svelte b/src/routes/settings/notifications/NotificationModal.svelte index 0c71293..8fc6de4 100644 --- a/src/routes/settings/notifications/NotificationModal.svelte +++ b/src/routes/settings/notifications/NotificationModal.svelte @@ -323,7 +323,7 @@ -
+
@@ -449,6 +449,8 @@ ntfy://my-topic ntfy://host/topic?auth=base64token&priority=3 ntfys://host/topic?auth=base64token pushover://user_key/api_token +pushover://user_key/api_token/device1/device2 +pover://user_key@api_token/device1/device2 workflows://hostname/workflow/signature bark://bark_key bark://host/bark_key @@ -461,11 +463,11 @@ zabbixs://hostname/api_jsonrpc.php?token=TOKEN&host=HOST&key=ITEM_KEY" class="flex min-h-[220px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring" >

- Supports Discord, Slack, Mattermost, Telegram, ntfy, Gotify, Pushover, Bark, Signal (via signal-cli-rest-api), Microsoft Teams (via Workflows), Zabbix (via history.push), and generic JSON. Or use apprise:// to forward to a self-hosted caronc/apprise-api server for any provider Apprise upstream supports. + Built-in channels: Discord, Slack, Mattermost, Telegram, ntfy, Gotify, Pushover, Bark, Signal (via signal-cli-rest-api), Microsoft Teams (via Workflows), Zabbix (via history.push), and generic JSON.

-

- Zabbix 7.x: - zabbixs://hostname/api_jsonrpc.php?token=TOKEN&host=HOST&key=ITEM_KEY +

+ + Need a provider that is not in the list (Matrix, Nextcloud, Pushbullet, Home Assistant, ...)? Run a caronc/apprise-api server, configure the provider there, and point Dockhand at it with apprise://host/key (or apprises:// for TLS). Every provider Apprise supports is then reachable.

diff --git a/tests/notifications.test.ts b/tests/notifications.test.ts index 53983bc..83e25f3 100644 --- a/tests/notifications.test.ts +++ b/tests/notifications.test.ts @@ -6,7 +6,7 @@ */ import { describe, test, expect } from 'bun:test'; -import { escapeTelegramMarkdown, buildGotifyUrl } from '../src/lib/utils/notification-parsers'; +import { escapeTelegramMarkdown, buildGotifyUrl, parsePushoverUrl } from '../src/lib/utils/notification-parsers'; // Test the event type mapping logic (mirrors notifications.ts) const EVENT_TYPE_MAPPING: Record = { @@ -273,3 +273,57 @@ describe('Telegram Markdown Escaping', () => { expect(envTag).not.toContain('\\]'); }); }); + +describe('Pushover URL Parsing', () => { + test('backward compatible: pushover://user/token has no device', () => { + expect(parsePushoverUrl('pushover://uKey123/aToken456')).toEqual({ + userKey: 'uKey123', + apiToken: 'aToken456', + device: undefined + }); + }); + + test('pushover:// with a single device', () => { + expect(parsePushoverUrl('pushover://uKey123/aToken456/phone')).toEqual({ + userKey: 'uKey123', + apiToken: 'aToken456', + device: 'phone' + }); + }); + + test('pushover:// with multiple devices -> comma-separated list', () => { + expect(parsePushoverUrl('pushover://uKey123/aToken456/phone/tablet/desktop')).toEqual({ + userKey: 'uKey123', + apiToken: 'aToken456', + device: 'phone,tablet,desktop' + }); + }); + + test('apprise-native pover://user@token form, no device', () => { + expect(parsePushoverUrl('pover://uKey123@aToken456')).toEqual({ + userKey: 'uKey123', + apiToken: 'aToken456', + device: undefined + }); + }); + + test('apprise-native pover://user@token/device1/device2', () => { + expect(parsePushoverUrl('pover://uKey123@aToken456/phone/tablet')).toEqual({ + userKey: 'uKey123', + apiToken: 'aToken456', + device: 'phone,tablet' + }); + }); + + test('trailing slash / empty segments are ignored', () => { + expect(parsePushoverUrl('pushover://uKey123/aToken456/')?.device).toBeUndefined(); + expect(parsePushoverUrl('pover://uKey123@aToken456//phone')?.device).toBe('phone'); + }); + + test('malformed URLs return null', () => { + expect(parsePushoverUrl('pushover://onlyuser')).toBeNull(); + expect(parsePushoverUrl('pover://useronly')).toBeNull(); + expect(parsePushoverUrl('invalid://user/token')).toBeNull(); + expect(parsePushoverUrl('')).toBeNull(); + }); +});