Pushover notifications can target specific devices

This commit is contained in:
Jarek Krochmalski committed 2026-08-23 10:20:59 +02:00
1 parent 7692758467
commit 70491423dc
5 files changed
+104 -12

No files matched your search

+1
View File
@@ -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':
+9 -6
View File
@@ -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<NotificationResult> {
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 } : {})
})
});
+32
View File
@@ -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 {
@@ -323,7 +323,7 @@
</Tabs.Trigger>
</Tabs.List>
<div class="overflow-y-auto pb-4 h-[520px]">
<div class="overflow-y-auto pb-4 pr-2 h-[530px]">
<Tabs.Content value="channel" class="space-y-4 mt-0">
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
@@ -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&amp;host=HOST&amp;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"
></textarea>
<p class="text-xs text-muted-foreground">
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 <code>apprise://</code> to forward to a self-hosted <a href="https://github.com/caronc/apprise-api" target="_blank" rel="noopener">caronc/apprise-api</a> 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.
</p>
<p class="text-xs text-muted-foreground">
Zabbix 7.x:
<code>zabbixs://hostname/api_jsonrpc.php?token=TOKEN&amp;host=HOST&amp;key=ITEM_KEY</code>
<p class="flex gap-1.5 text-xs text-muted-foreground">
<HelpCircle class="w-3.5 h-3.5 shrink-0 mt-0.5 text-amber-500" />
<span>Need a provider that is not in the list (Matrix, Nextcloud, Pushbullet, Home Assistant, ...)? Run a <a href="https://github.com/caronc/apprise-api" target="_blank" rel="noopener">caronc/apprise-api</a> server, configure the provider there, and point Dockhand at it with <code>apprise://host/key</code> (or <code>apprises://</code> for TLS). Every provider Apprise supports is then reachable.</span>
</p>
</div>
</div>
+55 -1
View File
@@ -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<string, string> = {
@@ -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();
});
});