apptise-core
Version:
Core library for Apptise unified notification system
445 lines • 15.1 kB
JavaScript
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