UNPKG

apptise-core

Version:

Core library for Apptise unified notification system

445 lines 15.1 kB
import { NotificationPlugin } from '../base/plugin.js'; import { HttpClient } from '../utils/http-client.js'; // Ntfy Priority Levels const NTFY_PRIORITIES = [ 'max', 'high', 'default', 'low', 'min', ]; // Ntfy Priority Mapping (matches Python NTFY_PRIORITY_MAP) const NTFY_PRIORITY_MAP = { // Maps against string 'low' but maps to Moderate to avoid // conflicting with actual ntfy mappings 'l': 'low', // Maps against string 'moderate' 'mo': 'low', // Maps against string 'normal' 'n': 'default', // Maps against string 'high' 'h': 'high', // Maps against string 'emergency' 'e': 'max', // Entries to additionally support (so more like Ntfy's API) // Maps against string 'min' 'mi': 'min', // Maps against string 'max' 'ma': 'max', // Maps against string 'default' 'd': 'default', // support 1-5 values as well '1': 'min', // Maps against string 'moderate' '2': 'low', // Maps against string 'normal' '3': 'default', // Maps against string 'high' '4': 'high', // Maps against string 'emergency' '5': 'max', }; // Ntfy Modes const NTFY_MODES = ['private', 'cloud']; // Ntfy Authentication Types const NTFY_AUTH = ['basic', 'token']; // Token detection regex (matches Python NTFY_AUTH_DETECT_RE) const NTFY_AUTH_DETECT_RE = /^tk_[a-z0-9]+$/i; // Auto cloud host detection (matches Python __auto_cloud_host) const AUTO_CLOUD_HOST_RE = /ntfy\.sh/i; export class NtfyPlugin extends NotificationPlugin { registration = { serviceId: 'ntfy', protocols: ['ntfy', 'ntfys'], name: 'ntfy', description: 'ntfy push notification service', version: '1.0.0', }; serviceConfig = { name: 'ntfy', url: 'https://ntfy.sh/', setupUrl: 'https://github.com/caronc/apprise/wiki/Notify_ntfy', cloudNotifyUrl: 'https://ntfy.sh', timeout: 10000, userAgent: 'Apprise', titleMaxlen: 200, bodyMaxlen: 7800, ntfyJsonUpstreamSizeLimit: 8000, timeToLive: 2419200, }; templates = [ '{schema}://{topic}', '{schema}://{host}/{targets}', '{schema}://{host}:{port}/{targets}', '{schema}://{user}@{host}/{targets}', '{schema}://{user}@{host}:{port}/{targets}', '{schema}://{user}:{password}@{host}/{targets}', '{schema}://{user}:{password}@{host}:{port}/{targets}', '{schema}://{token}@{host}/{targets}', '{schema}://{token}@{host}:{port}/{targets}', ]; templateTokens = { host: { name: 'Hostname', type: 'string', }, port: { name: 'Port', type: 'int', min: 1, max: 65535, }, user: { name: 'Username', type: 'string', }, password: { name: 'Password', type: 'string', private: true, }, token: { name: 'Token', type: 'string', private: true, }, topic: { name: 'Topic', type: 'string', mapTo: 'targets', regex: /^[a-z0-9_-]{1,64}$/i, }, targets: { name: 'Targets', type: 'list:string', }, }; templateArgs = { attach: { name: 'Attach', type: 'string', }, image: { name: 'Include Image', type: 'bool', default: true, mapTo: 'includeImage', }, avatar_url: { name: 'Avatar URL', type: 'string', }, filename: { name: 'Attach Filename', type: 'string', }, click: { name: 'Click', type: 'string', }, delay: { name: 'Delay', type: 'string', }, email: { name: 'Email', type: 'string', }, priority: { name: 'Priority', type: 'choice:string', values: NTFY_PRIORITIES, default: 'default', }, tags: { name: 'Tags', type: 'string', }, mode: { name: 'Mode', type: 'choice:string', values: NTFY_MODES, default: 'private', }, token: { aliasOf: 'token', }, auth: { name: 'Authentication Type', type: 'choice:string', values: NTFY_AUTH, default: 'basic', }, to: { aliasOf: 'targets', }, }; isValidTopic(topic) { return /^[a-z0-9_-]{1,64}$/i.test(topic); } parsePriority(priority) { if (!priority) { return 'default'; } const lowerPriority = priority.toLowerCase(); for (const [key, value] of Object.entries(NTFY_PRIORITY_MAP)) { if (lowerPriority.startsWith(key)) { return value; } } return 'default'; } isHostname(host) { // Basic hostname validation return /^[a-zA-Z0-9.-]+$/.test(host) && !host.startsWith('.') && !host.endsWith('.'); } isIpAddr(host) { // Basic IP address validation (IPv4) const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; return ipv4Regex.test(host); } parseUrl(url) { try { const parsedUrl = new URL(url); // Determine if secure protocol const secure = parsedUrl.protocol.endsWith('s'); const host = parsedUrl.hostname || ''; const port = parsedUrl.port ? parseInt(parsedUrl.port, 10) : undefined; const user = parsedUrl.username || undefined; const password = parsedUrl.password || undefined; // Extract targets from path const pathSegments = parsedUrl.pathname.split('/').filter(segment => segment.length > 0); let targets = pathSegments.length > 0 ? pathSegments : []; // Parse query parameters const params = new URLSearchParams(parsedUrl.search); // Extract optional parameters const attach = params.get('attach') ?? undefined; const filename = params.get('filename') ?? undefined; const click = params.get('click') ?? undefined; const delay = params.get('delay') ?? undefined; const email = params.get('email') ?? undefined; const avatarUrl = params.get('avatar_url') ?? undefined; const tags = params.get('tags')?.split(',').map(t => t.trim()).filter(t => t) || []; // Priority parsing const priorityParam = params.get('priority'); const priority = this.parsePriority(priorityParam ?? undefined); // Include image flag const includeImage = params.get('image') !== 'false' && params.get('image') !== '0'; // Token handling let token = params.get('token') ?? undefined; // Auth type determination let auth = 'basic'; const authParam = params.get('auth'); if (authParam && NTFY_AUTH.includes(authParam)) { auth = authParam; } // Auto-detect auth type based on username format if (!authParam && user && !password) { if (NTFY_AUTH_DETECT_RE.test(user)) { auth = 'token'; } } // Set token from user/password if auth is token if (auth === 'token' && !token) { if (user && !password) { token = user; } else if (password) { token = password; } } // Mode determination let mode = 'private'; const modeParam = params.get('mode'); if (modeParam && NTFY_MODES.includes(modeParam)) { mode = modeParam; } else { // Auto-detect mode based on hostname validity if ((this.isHostname(host) || this.isIpAddr(host)) && targets.length > 0) { mode = 'private'; } else { mode = 'cloud'; } } // Handle cloud mode special cases if (mode === 'cloud') { // Add host as topic if it's not ntfy.sh itself if (!AUTO_CLOUD_HOST_RE.test(host)) { targets.unshift(host); } } else if (mode === 'private' && !(this.isHostname(host) || this.isIpAddr(host))) { throw new Error('Invalid host for private mode'); } // Handle 'to' parameter as additional targets const toParam = params.get('to'); if (toParam) { targets.push(...toParam.split(',').map(t => t.trim()).filter(t => t)); } // Validate topics const validTopics = targets.filter(topic => this.isValidTopic(topic)); if (validTopics.length === 0) { console.warn('No valid ntfy topics found'); } return { serviceId: this.registration.serviceId, url, config: { targets: validTopics, host, port, secure, user, password, token, auth, mode, priority, includeImage, attach, filename, click, delay, email, avatarUrl, tags, }, }; } catch (error) { throw new Error(`Failed to parse ntfy URL: ${error instanceof Error ? error.message : String(error)}`); } } async send(config, message) { const ntfyConfig = config.config; const targets = ntfyConfig.targets; if (!targets || targets.length === 0) { return { success: false, serviceId: this.registration.serviceId, error: 'There are no ntfy topics to notify', }; } const results = []; // Process each topic for (const topic of targets) { try { const result = await this._send(ntfyConfig, topic, message); results.push(result); } catch (error) { console.error(`Error sending ntfy notification to ${topic}:`, error); results.push(false); } } const successCount = results.filter(r => r).length; const success = successCount > 0; return { success, serviceId: this.registration.serviceId, error: success ? undefined : 'Failed to send notifications to all targets', }; } async _send(ntfyConfig, topic, message) { // Prepare headers const headers = { 'User-Agent': this.serviceConfig.userAgent, }; let notifyUrl; let data = {}; let params = {}; let auth; // Determine URL based on mode if (ntfyConfig.mode === 'cloud') { notifyUrl = this.serviceConfig.cloudNotifyUrl; } else { // Private mode if (ntfyConfig.auth === 'basic' && ntfyConfig.user) { auth = [ntfyConfig.user, ntfyConfig.password || '']; } else if (ntfyConfig.auth === 'token') { if (!ntfyConfig.token) { console.warn('No Ntfy Token was specified'); return false; } headers['Authorization'] = `Bearer ${ntfyConfig.token}`; } const schema = ntfyConfig.secure ? 'https' : 'http'; notifyUrl = `${schema}://${ntfyConfig.host}`; if (ntfyConfig.port) { notifyUrl += `:${ntfyConfig.port}`; } } // Set content type for JSON headers['Content-Type'] = 'application/json'; // Prepare JSON payload data.topic = topic; if (ntfyConfig.attach) { data.attach = ntfyConfig.attach; if (ntfyConfig.filename) { data.filename = ntfyConfig.filename; } } // Add image support if (ntfyConfig.avatarUrl) { headers['X-Icon'] = ntfyConfig.avatarUrl; } else if (ntfyConfig.includeImage !== false) { // Use default Apprise icon URL headers['X-Icon'] = 'https://github.com/caronc/apprise/raw/master/apprise/assets/themes/default/apprise-info-256x256.png'; } if (message.title) { data.title = message.title; } if (message.body) { data.message = message.body; } // Add optional headers if (ntfyConfig.priority !== 'default') { headers['X-Priority'] = ntfyConfig.priority; } if (ntfyConfig.delay) { headers['X-Delay'] = ntfyConfig.delay; } if (ntfyConfig.click) { headers['X-Click'] = encodeURIComponent(ntfyConfig.click); } if (ntfyConfig.email) { headers['X-Email'] = ntfyConfig.email; } if (ntfyConfig.tags && ntfyConfig.tags.length > 0) { headers['X-Tags'] = ntfyConfig.tags.join(','); } // Prepare request options const options = { headers, timeout: this.serviceConfig.timeout, }; // Add auth if needed if (auth) { const authString = Buffer.from(`${auth[0]}:${auth[1]}`).toString('base64'); options.headers['Authorization'] = `Basic ${authString}`; } try { const response = await HttpClient.post(notifyUrl, JSON.stringify(data), options); if (response.status === 200) { console.log(`Sent ntfy notification to '${notifyUrl}'.`); return true; } else { console.error(`Failed to send ntfy notification to topic '${topic}': HTTP ${response.status}`); return false; } } catch (error) { console.error(`Connection error occurred sending ntfy:${notifyUrl} notification:`, error); return false; } } } export const ntfyPlugin = new NtfyPlugin(); export default ntfyPlugin; //# sourceMappingURL=ntfy.js.map