Pushover notifications can target specific devices
This commit is contained in:
5 files changed
+104
-12
No files matched your search
@@ -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':
|
||||
|
||||
@@ -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 } : {})
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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&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"
|
||||
></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&host=HOST&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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user