Feature/message sending #2

Merged
B00tLoad merged 28 commits from feature/message-sending into develop 2025-08-31 13:59:38 +02:00
3 changed files with 257 additions and 264 deletions
Showing only changes of commit 2106734b88 - Show all commits

View File

@@ -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",

251
src/Pushover.ts Normal file
View File

@@ -0,0 +1,251 @@
import https from "node:https";
copilot-pull-request-reviewer[bot] commented 2025-08-30 21:39:16 +02:00 (Migrated from github.com)
Review

Missing closing parenthesis in the template literal. Should be ${data.length}): ${data}.

              `Received response body (length: ${data.length}): ${data}`,
Missing closing parenthesis in the template literal. Should be `${data.length}): ${data}`. ```suggestion `Received response body (length: ${data.length}): ${data}`, ```
copilot-pull-request-reviewer[bot] commented 2025-08-30 21:39:17 +02:00 (Migrated from github.com)
Review

The nullish coalescing operator is unnecessary here since options.user is required in the ValidateOptions interface and cannot be undefined.

    params.append("user", options.user);
The nullish coalescing operator is unnecessary here since `options.user` is required in the `ValidateOptions` interface and cannot be undefined. ```suggestion params.append("user", options.user); ```
copilot-pull-request-reviewer[bot] commented 2025-08-30 21:39:17 +02:00 (Migrated from github.com)
Review

This incorrectly resolves with a Promise instead of the resolved value. Should be Promise.all(promises).then(resolve).catch(reject) or use async/await pattern.

    if (options.recipients.length === 0) {
      throw new Error("No recipients specified.");
    }

    const {
      success,
      error,
      data: parsedMessage,
    } = MessageSchema.safeParse(message);

    if (!success) {
      throw new Error(`Message validation failed: ${error}`);
    }

    if (options.verbose) {
      console.log("Verbose mode enabled. Logging message and options:");
      console.log(parsedMessage);
      console.log(options);
      console.log("----------------------");
      console.log("Sending message...");
    }

    const promises = options.recipients.map((recipient) =>
      this.sendToSingleRecipient(
        parsedMessage,
        recipient,
        options.verbose ?? false,
      ),
    );

    return Promise.all(promises);
This incorrectly resolves with a Promise instead of the resolved value. Should be `Promise.all(promises).then(resolve).catch(reject)` or use `async/await` pattern. ```suggestion if (options.recipients.length === 0) { throw new Error("No recipients specified."); } const { success, error, data: parsedMessage, } = MessageSchema.safeParse(message); if (!success) { throw new Error(`Message validation failed: ${error}`); } if (options.verbose) { console.log("Verbose mode enabled. Logging message and options:"); console.log(parsedMessage); console.log(options); console.log("----------------------"); console.log("Sending message..."); } const promises = options.recipients.map((recipient) => this.sendToSingleRecipient( parsedMessage, recipient, options.verbose ?? false, ), ); return Promise.all(promises); ```
copilot-pull-request-reviewer[bot] commented 2025-08-30 22:14:53 +02:00 (Migrated from github.com)
Review

Calling resolve with a Promise will cause the method to return Promise<Promise<PushoverMessageResponse[]>> instead of the expected Promise<PushoverMessageResponse[]>. Use await Promise.all(promises) or restructure to avoid wrapping a Promise in another Promise.

    if (options.recipients.length === 0) {
      throw new Error("No recipients specified.");
    }

    const {
      success,
      error,
      data: parsedMessage,
    } = MessageSchema.safeParse(message);

    if (!success) {
      throw new Error(`Message validation failed: ${error}`);
    }

    if (options.verbose) {
      console.log("Verbose mode enabled. Logging message and options:");
      console.log(parsedMessage);
      console.log(options);
      console.log("----------------------");
      console.log("Sending message...");
    }

    const promises = options.recipients.map((recipient) =>
      this.sendToSingleRecipient(
        parsedMessage,
        recipient,
        options.verbose ?? false,
      ),
    );

    return Promise.all(promises);
Calling `resolve` with a Promise will cause the method to return `Promise<Promise<PushoverMessageResponse[]>>` instead of the expected `Promise<PushoverMessageResponse[]>`. Use `await Promise.all(promises)` or restructure to avoid wrapping a Promise in another Promise. ```suggestion if (options.recipients.length === 0) { throw new Error("No recipients specified."); } const { success, error, data: parsedMessage, } = MessageSchema.safeParse(message); if (!success) { throw new Error(`Message validation failed: ${error}`); } if (options.verbose) { console.log("Verbose mode enabled. Logging message and options:"); console.log(parsedMessage); console.log(options); console.log("----------------------"); console.log("Sending message..."); } const promises = options.recipients.map((recipient) => this.sendToSingleRecipient( parsedMessage, recipient, options.verbose ?? false, ), ); return Promise.all(promises); ```
copilot-pull-request-reviewer[bot] commented 2025-08-30 22:15:59 +02:00 (Migrated from github.com)
Review

This resolve call should use await or return the Promise.all directly. Currently, it's resolving with a Promise object instead of the actual results. Change to resolve(await Promise.all(promises)) or restructure to return the Promise.all directly.

    if (options.recipients.length === 0) {
      throw new Error("No recipients specified.");
    }

    const {
      success,
      error,
      data: parsedMessage,
    } = MessageSchema.safeParse(message);

    if (!success) {
      throw new Error(`Message validation failed: ${error}`);
    }

    if (options.verbose) {
      console.log("Verbose mode enabled. Logging message and options:");
      console.log(parsedMessage);
      console.log(options);
      console.log("----------------------");
      console.log("Sending message...");
    }

    const promises = options.recipients.map((recipient) =>
      this.sendToSingleRecipient(
        parsedMessage,
        recipient,
        options.verbose ?? false,
      ),
    );

    return Promise.all(promises);
This resolve call should use `await` or return the Promise.all directly. Currently, it's resolving with a Promise object instead of the actual results. Change to `resolve(await Promise.all(promises))` or restructure to return the Promise.all directly. ```suggestion if (options.recipients.length === 0) { throw new Error("No recipients specified."); } const { success, error, data: parsedMessage, } = MessageSchema.safeParse(message); if (!success) { throw new Error(`Message validation failed: ${error}`); } if (options.verbose) { console.log("Verbose mode enabled. Logging message and options:"); console.log(parsedMessage); console.log(options); console.log("----------------------"); console.log("Sending message..."); } const promises = options.recipients.map((recipient) => this.sendToSingleRecipient( parsedMessage, recipient, options.verbose ?? false, ), ); return Promise.all(promises); ```
copilot-pull-request-reviewer[bot] commented 2025-08-30 22:15:59 +02:00 (Migrated from github.com)
Review

Missing comma after the array in the JSDoc example code block.

Missing comma after the array in the JSDoc example code block.
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<typeof MessageSchema>;
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<PushoverResponse[]> {
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<PushoverResponse> {
const params = new URLSearchParams();
copilot-pull-request-reviewer[bot] commented 2025-08-30 22:14:54 +02:00 (Migrated from github.com)
Review

Missing comma after the tags array in the JSDoc example. Should be tags: [\"critical\", \"infra\"],

 *     tags: ["critical", "infra"],
Missing comma after the `tags` array in the JSDoc example. Should be `tags: [\"critical\", \"infra\"],` ```suggestion * tags: ["critical", "infra"], ```
// 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<PushoverResponse> {
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();
});
}
}

View File

@@ -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<typeof MessageSchema>;
// 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<PushoverResponse[]> {
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<PushoverResponse> {
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<PushoverResponse> {
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);