diff --git a/package.json b/package.json index a5ff346..dd21597 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,9 @@ "typescript": "^5.5.3", "typescript-eslint": "^8.26.0" }, + "files": [ + "dist/**/*.{js,ts,map}" + ], "scripts": { "build": "tsc", "build:watch": "tsc --watch", diff --git a/src/Pushover.ts b/src/Pushover.ts new file mode 100644 index 0000000..ace51c7 --- /dev/null +++ b/src/Pushover.ts @@ -0,0 +1,251 @@ +import https from "node:https"; +import { URLSearchParams } from "node:url"; +import { z } from "zod"; + +const MessageSchema = z + .object({ + /** + * The message sent to the user. + */ + message: z.string().min(3), + /** + * An optional title. + */ + title: z.string().optional(), + /** + * A link attached to the message. + * Can be either the link or an object containing the link and an optional title. + */ + link: z + .string() + .url() + .or( + z.object({ + /** + * The url of the link + */ + url: z.string().url(), + /** + * The title displayed as the link + */ + title: z.string().optional(), + }), + ) + .optional(), + /** + * Sets notification setting for the message. + * + * -2: Message only, no notification. May increment notification bubble. + * -1: Silent notification + * 0: default notification + * 1: ignores user's quiet hours. + * 2: requires acknowledgement + */ + priority: z + .union([ + z.literal(-2), + z.literal(-1), + z.literal(0), + z.literal(1), + z.literal(2), + ]) + .default(0), + emergencyOpts: z + .object({ + repeat: z.number().min(30), + expire: z.number().max(10800), + callback: z.string().url().optional(), + tags: z.string().array().optional(), + }) + .optional(), + sound: z.string().optional(), + timestamp: z.number().optional(), + html: z.boolean().default(false), + monospace: z.boolean().default(false), + ttl: z.number().optional(), + }) + .refine( + (data) => { + if (data.priority == 2 && !data.emergencyOpts) return false; + return true; + }, + { + path: ["priority", "emergencyOpts"], + message: "If priority is set to 2, emergencyOpts must be included.", + }, + ) + .refine( + (data) => { + if (data.html && data.monospace) return false; + return true; + }, + { + path: ["html", "monospace"], + message: "html and monospace are mutually exclusive.", + }, + ); + +export type PushoverMessage = z.infer; + +export interface PushoverResponse { + status: number; + request: string; + errors?: string[]; +} + +export interface PushoverConfig { + token: string; + defaultUser?: string; +} + +export interface SendOptions { + recipients?: string | string[]; + device?: string | string[]; + verbose?: boolean; +} + +export class Pushover { + private token: string; + private defaultUser?: string; + private apiUrl = "https://api.pushover.net/1/messages.json"; + + constructor(config: PushoverConfig) { + this.token = config.token; + this.defaultUser = config.defaultUser; + } + + /** + * Send a notification to one or multiple recipients + */ + public async send( + message: PushoverMessage, + options: SendOptions = {}, + ): Promise { + const recipients = this.getRecipients(options); + + console.log(MessageSchema.parse(message)); + + if (recipients.length === 0) { + throw new Error( + "No recipients specified. Provide recipients in options or set a defaultUser.", + ); + } + + if (options.verbose) { + console.log("Verbose mode enabled. Logging message and options:"); + console.log(message); + console.log(options); + console.log("----------------------"); + console.log("Sending message..."); + } + + const promises = recipients.map((recipient) => + this.sendToSingleRecipient(message, recipient, options.device), + ); + + const results = await Promise.all(promises); + + // Combine results + const combinedResponse: PushoverResponse[] = results.map( + (result) => result, + ); + + return combinedResponse; + } + + private getRecipients(options: SendOptions): string[] { + const { recipients } = options; + + if (recipients) { + return Array.isArray(recipients) ? recipients : [recipients]; + } + + return this.defaultUser ? [this.defaultUser] : []; + } + + private async sendToSingleRecipient( + message: PushoverMessage, + user: string, + device?: string | string[], + ): Promise { + const params = new URLSearchParams(); + + // Add token and user + params.append("token", this.token); + params.append("user", user); + + // Add message properties + params.append("message", message.message); + if (message.title) params.append("title", message.title); + params.append("priority", "" + message.priority); + if (message.priority === 2 && message.emergencyOpts) { + params.append("repeat", String(message.emergencyOpts.repeat)); + params.append("expire", String(message.emergencyOpts.expire)); + if (message.emergencyOpts.callback) + params.append("callback", message.emergencyOpts.callback); + if (message.emergencyOpts.tags) + params.append("tags", message.emergencyOpts.tags.join()); + } + if (message.link) { + if (typeof message.link === "string") { + params.append("url", message.link); + } else { + params.append("url", message.link.url); + if (message.link.title) params.append("title", message.link.title); + } + } + if (message.html) params.append("html", "1"); + if (message.monospace) params.append("monospace", "1"); + if (message.sound) params.append("sound", message.sound); + if (message.timestamp) + params.append("timestamp", String(message.timestamp)); + if (message.ttl) params.append("ttl", String(message.ttl)); + + // Add device if specified + if (device) { + if (Array.isArray(device)) { + params.append("device", device.join(",")); + } else { + params.append("device", device); + } + } + + return this.makeRequest(params); + } + + private makeRequest(params: URLSearchParams): Promise { + return new Promise((resolve, reject) => { + const options = { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }; + + const req = https.request(this.apiUrl, options, (res) => { + let data = ""; + + res.on("data", (chunk) => { + data += chunk; + }); + + res.on("end", () => { + try { + const response = JSON.parse(data) as PushoverResponse; + resolve(response); + } catch (error) { + console.error(error); + reject(new Error(`Failed to parse response: ${data}`)); + } + }); + }); + + req.on("error", (error) => { + reject(error); + }); + + req.write(params.toString()); + req.end(); + }); + } +} diff --git a/src/index.ts b/src/index.ts index bad3809..54d4896 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,266 +1,5 @@ -import https from "node:https"; -import { URLSearchParams } from "node:url"; -import { z } from "zod"; +import { Pushover } from "./Pushover"; -const MessageSchema = z - .object({ - /** - * The message sent to the user. - */ - message: z.string().min(3), - /** - * An optional title. - */ - title: z.string().optional(), - /** - * A link attached to the message. - * Can be either the link or an object containing the link and an optional title. - */ - link: z - .string() - .url() - .or( - z.object({ - /** - * The url of the link - */ - url: z.string().url(), - /** - * The title displayed as the link - */ - title: z.string().optional(), - }), - ) - .optional(), - /** - * Sets notification setting for the message. - * - * -2: Message only, no notification. May increment notification bubble. - * -1: Silent notification - * 0: default notification - * 1: ignores user's quiet hours. - * 2: requires acknowledgement - */ - priority: z - .union([ - z.literal(-2), - z.literal(-1), - z.literal(0), - z.literal(1), - z.literal(2), - ]) - .default(0), - emergencyOpts: z - .object({ - repeat: z.number().min(30), - expire: z.number().max(10800), - callback: z.string().url().optional(), - tags: z.string().array().optional(), - }) - .optional(), - sound: z.string().optional(), - timestamp: z.number().optional(), - html: z.boolean().default(false), - monospace: z.boolean().default(false), - ttl: z.number().optional(), - }) - .refine( - (data) => { - if (data.priority == 2 && !data.emergencyOpts) return false; - return true; - }, - { - path: ["priority", "emergencyOpts"], - message: "If priority is set to 2, emergencyOpts must be included.", - }, - ) - .refine( - (data) => { - if (data.html && data.monospace) return false; - return true; - }, - { - path: ["html", "monospace"], - message: "html and monospace are mutually exclusive.", - }, - ); +export default Pushover; -export type PushoverMessage = z.infer; - -// export interface PushoverMessage { -// message: string; -// title?: string; -// link?: { -// url: string; -// title?: string; -// } | string; -// priority: -2 | -1 | 0 | 1 | 2; -// sound?: string; -// timestamp?: number; -// html: boolean; -// monospace: boolean; -// ttl?: number; -// } - -export interface PushoverResponse { - status: number; - request: string; - errors?: string[]; -} - -export interface PushoverConfig { - token: string; - defaultUser?: string; -} - -export interface SendOptions { - recipients?: string | string[]; - device?: string | string[]; - verbose?: boolean; -} - -export class Pushover { - private token: string; - private defaultUser?: string; - private apiUrl = "https://api.pushover.net/1/messages.json"; - - constructor(config: PushoverConfig) { - this.token = config.token; - this.defaultUser = config.defaultUser; - } - - /** - * Send a notification to one or multiple recipients - */ - public async send( - message: PushoverMessage, - options: SendOptions = {}, - ): Promise { - const recipients = this.getRecipients(options); - - console.log(MessageSchema.parse(message)); - - if (recipients.length === 0) { - throw new Error( - "No recipients specified. Provide recipients in options or set a defaultUser.", - ); - } - - if (options.verbose) { - console.log("Verbose mode enabled. Logging message and options:"); - console.log(message); - console.log(options); - console.log("----------------------"); - console.log("Sending message..."); - } - - const promises = recipients.map((recipient) => - this.sendToSingleRecipient(message, recipient, options.device), - ); - - const results = await Promise.all(promises); - - // Combine results - const combinedResponse: PushoverResponse[] = results.map( - (result) => result, - ); - - return combinedResponse; - } - - private getRecipients(options: SendOptions): string[] { - const { recipients } = options; - - if (recipients) { - return Array.isArray(recipients) ? recipients : [recipients]; - } - - return this.defaultUser ? [this.defaultUser] : []; - } - - private async sendToSingleRecipient( - message: PushoverMessage, - user: string, - device?: string | string[], - ): Promise { - const params = new URLSearchParams(); - - // Add token and user - params.append("token", this.token); - params.append("user", user); - - // Add message properties - params.append("message", message.message); - if (message.title) params.append("title", message.title); - params.append("priority", "" + message.priority); - if (message.priority === 2 && message.emergencyOpts) { - params.append("repeat", String(message.emergencyOpts.repeat)); - params.append("expire", String(message.emergencyOpts.expire)); - if (message.emergencyOpts.callback) - params.append("callback", message.emergencyOpts.callback); - if (message.emergencyOpts.tags) - params.append("tags", message.emergencyOpts.tags.join()); - } - if (message.link) { - if (typeof message.link === "string") { - params.append("url", message.link); - } else { - params.append("url", message.link.url); - if (message.link.title) params.append("title", message.link.title); - } - } - if (message.html) params.append("html", "1"); - if (message.monospace) params.append("monospace", "1"); - if (message.sound) params.append("sound", message.sound); - if (message.timestamp) - params.append("timestamp", String(message.timestamp)); - if (message.ttl) params.append("ttl", String(message.ttl)); - - // Add device if specified - if (device) { - if (Array.isArray(device)) { - params.append("device", device.join(",")); - } else { - params.append("device", device); - } - } - - return this.makeRequest(params); - } - - private makeRequest(params: URLSearchParams): Promise { - return new Promise((resolve, reject) => { - const options = { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - }; - - const req = https.request(this.apiUrl, options, (res) => { - let data = ""; - - res.on("data", (chunk) => { - data += chunk; - }); - - res.on("end", () => { - try { - const response = JSON.parse(data) as PushoverResponse; - resolve(response); - } catch (error) { - console.error(error); - reject(new Error(`Failed to parse response: ${data}`)); - } - }); - }); - - req.on("error", (error) => { - reject(error); - }); - - req.write(params.toString()); - req.end(); - }); - } -} +Object.assign(module.exports, Pushover);