@procedures/notify 0.55.1 → 0.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +63 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/procedures.d.ts +1 -0
- package/dist/procedures.d.ts.map +1 -1
- package/dist/procedures.js +3 -1
- package/dist/procedures.js.map +1 -1
- package/dist/pushover-procedures.d.ts +20 -0
- package/dist/pushover-procedures.d.ts.map +1 -0
- package/dist/pushover-procedures.js +169 -0
- package/dist/pushover-procedures.js.map +1 -0
- package/dist/pushover.d.ts +247 -0
- package/dist/pushover.d.ts.map +1 -0
- package/dist/pushover.js +350 -0
- package/dist/pushover.js.map +1 -0
- package/package.json +18 -6
- package/src/index.ts +2 -0
- package/src/procedures.ts +3 -1
- package/src/pushover-procedures.ts +229 -0
- package/src/pushover.ts +530 -0
package/src/pushover.ts
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pushover (https://pushover.net) as pure functions. A request builder gives the URL, the method,
|
|
3
|
+
* the headers and the body of one call. An extractor reads the HTTP status, the headers and the
|
|
4
|
+
* body text of the response into a typed result. Neither function sends a request, so the caller
|
|
5
|
+
* gives the fetch: `globalThis.fetch`, a polite client or a fake server of a test.
|
|
6
|
+
*
|
|
7
|
+
* This entry imports no Node built-in, so a browser can also use it.
|
|
8
|
+
*
|
|
9
|
+
* Each time in this file is in milliseconds. The builders change a time to the whole seconds of
|
|
10
|
+
* Pushover, and the extractors change the seconds of Pushover to milliseconds.
|
|
11
|
+
*
|
|
12
|
+
* A request holds the API token of the application: in the body of a POST, or in the query of a
|
|
13
|
+
* GET. Do not log a request or its URL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** The base of the Pushover API. */
|
|
17
|
+
export const PUSHOVER_API = "https://api.pushover.net/1";
|
|
18
|
+
|
|
19
|
+
/** The limits of the Pushover API, in characters (Unicode code points). */
|
|
20
|
+
export const PUSHOVER_LIMITS: {
|
|
21
|
+
readonly message: 1024;
|
|
22
|
+
readonly title: 250;
|
|
23
|
+
readonly url: 512;
|
|
24
|
+
readonly urlTitle: 100;
|
|
25
|
+
readonly device: 25;
|
|
26
|
+
readonly users: 50;
|
|
27
|
+
} = { message: 1024, title: 250, url: 512, urlTitle: 100, device: 25, users: 50 };
|
|
28
|
+
|
|
29
|
+
/** The shortest time between two repeats of an emergency message: 30 seconds. */
|
|
30
|
+
export const PUSHOVER_RETRY_MIN_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
/** The longest time that Pushover repeats an emergency message: 3 hours. */
|
|
33
|
+
export const PUSHOVER_EXPIRE_MAX_MS = 10_800_000;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The shortest wait before a call goes again after a server error or no answer: 5 seconds. It is
|
|
37
|
+
* also the shortest time between two reads of one receipt.
|
|
38
|
+
*/
|
|
39
|
+
export const PUSHOVER_MIN_WAIT_MS = 5_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The priority of a message. `-2`: no notification. `-1`: no sound. `0`: normal. `1`: high, it
|
|
43
|
+
* passes the quiet hours of the user. `2`: emergency, it repeats until the user acknowledges it.
|
|
44
|
+
*/
|
|
45
|
+
export type PushoverPriority = -2 | -1 | 0 | 1 | 2;
|
|
46
|
+
|
|
47
|
+
/** The repeats of an emergency message (priority 2). */
|
|
48
|
+
export interface PushoverEmergency {
|
|
49
|
+
/** The time between two repeats: 30 seconds or more. Pushover sends 50 repeats at most. */
|
|
50
|
+
readonly retryMs: number;
|
|
51
|
+
/** The time after which the repeats stop: 3 hours or less. */
|
|
52
|
+
readonly expireMs: number;
|
|
53
|
+
/** An address on the internet that gets a POST when the user acknowledges the message. */
|
|
54
|
+
readonly callback?: string | undefined;
|
|
55
|
+
/** The tags of the receipt. `pushoverCancelByTagRequest` stops the repeats of each message with a tag. */
|
|
56
|
+
readonly tags?: readonly string[] | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One message of the messages API. */
|
|
60
|
+
export interface PushoverMessage {
|
|
61
|
+
/** The user key or the group key of the recipient, or up to 50 user keys with commas. */
|
|
62
|
+
readonly user: string;
|
|
63
|
+
readonly message: string;
|
|
64
|
+
/** The title. Without a title, the device shows the name of the application. */
|
|
65
|
+
readonly title?: string | undefined;
|
|
66
|
+
/** An address that the notification opens. */
|
|
67
|
+
readonly url?: string | undefined;
|
|
68
|
+
readonly urlTitle?: string | undefined;
|
|
69
|
+
/** The priority (default `0`). Priority `2` needs `emergency`. */
|
|
70
|
+
readonly priority?: PushoverPriority | undefined;
|
|
71
|
+
/** The repeats of an emergency message. Only priority `2` takes them. */
|
|
72
|
+
readonly emergency?: PushoverEmergency | undefined;
|
|
73
|
+
/** The name of a sound of Pushover, or of a custom sound of the user. */
|
|
74
|
+
readonly sound?: string | undefined;
|
|
75
|
+
/** The names of the devices of the user that get the message. Without names, each device gets it. */
|
|
76
|
+
readonly devices?: readonly string[] | undefined;
|
|
77
|
+
/** The time that the notification shows, in milliseconds since the Unix epoch. */
|
|
78
|
+
readonly timestampMs?: number | undefined;
|
|
79
|
+
/** The format of the text (default `text`). */
|
|
80
|
+
readonly format?: "text" | "html" | "monospace" | undefined;
|
|
81
|
+
/** The time after which the device deletes the message. Priority `2` ignores it. */
|
|
82
|
+
readonly ttlMs?: number | undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One call of the Pushover API. Its fields fit the `init` of `fetch`. */
|
|
86
|
+
export interface PushoverRequest {
|
|
87
|
+
readonly url: string;
|
|
88
|
+
readonly method: "GET" | "POST";
|
|
89
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
90
|
+
/** The form body of a POST. It holds the API token. */
|
|
91
|
+
readonly body?: string;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The parts of an HTTP response that an extractor reads. A `Response` of `fetch` and its text fit it. */
|
|
95
|
+
export interface PushoverResponse {
|
|
96
|
+
/** The HTTP status. */
|
|
97
|
+
readonly status: number;
|
|
98
|
+
/** The body text. */
|
|
99
|
+
readonly text: string;
|
|
100
|
+
/** The headers. Only `pushoverSendRequest` gives headers that the extractor reads. */
|
|
101
|
+
readonly headers?: { get(name: string): string | null } | undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The kind of a failure.
|
|
106
|
+
* - `network`: the request did not reach Pushover, or no answer came.
|
|
107
|
+
* - `timeout`: no answer came in the time limit.
|
|
108
|
+
* - `server`: Pushover had an error. The same request can go again after `retryAfterMs`.
|
|
109
|
+
* - `rate-limited`: the account sent its messages of the month. Each request fails until `resetAt`.
|
|
110
|
+
* - `auth`: Pushover refused the API token.
|
|
111
|
+
* - `refused`: Pushover refused the input, for example a user key that it does not know. The same
|
|
112
|
+
* request fails again, and many refusals in a few minutes can block the address of the caller.
|
|
113
|
+
*/
|
|
114
|
+
export type PushoverFailureKind = "network" | "timeout" | "server" | "rate-limited" | "auth" | "refused";
|
|
115
|
+
|
|
116
|
+
/** A call that failed. An extractor does not throw: it gives a failure. */
|
|
117
|
+
export interface PushoverFailure {
|
|
118
|
+
readonly ok: false;
|
|
119
|
+
readonly kind: PushoverFailureKind;
|
|
120
|
+
/** The HTTP status. It is `0` when no answer came. */
|
|
121
|
+
readonly status: number;
|
|
122
|
+
/** The errors that Pushover gives, or a description of the failure. */
|
|
123
|
+
readonly errors: readonly string[];
|
|
124
|
+
/** The id of the request, for the support of Pushover. */
|
|
125
|
+
readonly request?: string;
|
|
126
|
+
/** The shortest wait before the same request goes again. Only `server`, `network` and `timeout` have it. */
|
|
127
|
+
readonly retryAfterMs?: number;
|
|
128
|
+
/** The time of the reset of the monthly limit, in milliseconds since the epoch, when Pushover gives it. */
|
|
129
|
+
readonly resetAt?: number;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The monthly limit of the application, from the headers of a send or from `pushoverLimitsRequest`. */
|
|
133
|
+
export interface PushoverLimits {
|
|
134
|
+
readonly limit: number;
|
|
135
|
+
readonly remaining: number;
|
|
136
|
+
/** The time of the reset, in milliseconds since the epoch. */
|
|
137
|
+
readonly resetAt: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A message that Pushover queued. */
|
|
141
|
+
export interface PushoverSent {
|
|
142
|
+
readonly ok: true;
|
|
143
|
+
readonly request: string;
|
|
144
|
+
/** The receipt of an emergency message. Only priority `2` gives a receipt. */
|
|
145
|
+
readonly receipt?: string;
|
|
146
|
+
readonly limits?: PushoverLimits;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The state of the receipt of an emergency message. */
|
|
150
|
+
export interface PushoverReceipt {
|
|
151
|
+
readonly ok: true;
|
|
152
|
+
readonly acknowledged: boolean;
|
|
153
|
+
/** The time of the acknowledgment, in milliseconds since the epoch. */
|
|
154
|
+
readonly acknowledgedAt?: number;
|
|
155
|
+
/** The user key of the first user that acknowledged. */
|
|
156
|
+
readonly acknowledgedBy?: string;
|
|
157
|
+
/** The device of the first user that acknowledged. */
|
|
158
|
+
readonly acknowledgedByDevice?: string;
|
|
159
|
+
/** The time of the last repeat. */
|
|
160
|
+
readonly lastDeliveredAt?: number;
|
|
161
|
+
/** True when the repeats stopped at their expiry. */
|
|
162
|
+
readonly expired: boolean;
|
|
163
|
+
/** The time at which the repeats stop. */
|
|
164
|
+
readonly expiresAt: number;
|
|
165
|
+
readonly calledBack: boolean;
|
|
166
|
+
readonly calledBackAt?: number;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A cancel that Pushover accepted. */
|
|
170
|
+
export interface PushoverCancelled {
|
|
171
|
+
readonly ok: true;
|
|
172
|
+
readonly request?: string;
|
|
173
|
+
/** The count of messages that a cancel by tag stopped, when Pushover gives it. */
|
|
174
|
+
readonly cancelled?: number;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A user key that Pushover knows. */
|
|
178
|
+
export interface PushoverValidated {
|
|
179
|
+
readonly ok: true;
|
|
180
|
+
/** True for the key of a group. */
|
|
181
|
+
readonly group: boolean;
|
|
182
|
+
/** The names of the active devices. */
|
|
183
|
+
readonly devices: readonly string[];
|
|
184
|
+
/** The platforms of the licenses of the user, for example `iOS`. */
|
|
185
|
+
readonly licenses: readonly string[];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The monthly limit, from `pushoverLimitsRequest`. */
|
|
189
|
+
export interface PushoverLimitsRead {
|
|
190
|
+
readonly ok: true;
|
|
191
|
+
readonly limits: PushoverLimits;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The acknowledgment that Pushover sends to the `callback` address of an emergency message. */
|
|
195
|
+
export interface PushoverCallback {
|
|
196
|
+
readonly receipt: string;
|
|
197
|
+
readonly acknowledgedAt: number;
|
|
198
|
+
readonly acknowledgedBy?: string;
|
|
199
|
+
readonly acknowledgedByDevice?: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// =============================================================================
|
|
203
|
+
// Request builders
|
|
204
|
+
// =============================================================================
|
|
205
|
+
|
|
206
|
+
/** The form of an API token, a user key, a group key and a receipt: 30 letters and digits. */
|
|
207
|
+
export const PUSHOVER_KEY: RegExp = /^[A-Za-z0-9]{30}$/;
|
|
208
|
+
const KEY = PUSHOVER_KEY;
|
|
209
|
+
const DEVICE = /^[A-Za-z0-9_-]{1,25}$/;
|
|
210
|
+
const FORM = "application/x-www-form-urlencoded";
|
|
211
|
+
|
|
212
|
+
const chars = (value: string): number => [...value].length;
|
|
213
|
+
|
|
214
|
+
function assertKey(value: string, what: string): void {
|
|
215
|
+
if (!KEY.test(value)) throw new Error(`The ${what} is not 30 letters and digits.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function assertMax(text: string | undefined, max: number, what: string): void {
|
|
219
|
+
if (text !== undefined && chars(text) > max) throw new Error(`The ${what} has more than ${max} characters.`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function assertTag(tag: string): void {
|
|
223
|
+
if (tag === "" || tag.includes(",")) throw new Error(`The tag "${tag}" is empty or has a comma.`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const seconds = (ms: number, round: (value: number) => number): string => String(round(ms / 1000));
|
|
227
|
+
|
|
228
|
+
function post(path: string, fields: Record<string, string>): PushoverRequest {
|
|
229
|
+
return { url: `${PUSHOVER_API}${path}`, method: "POST", headers: { "content-type": FORM, accept: "application/json" }, body: new URLSearchParams(fields).toString() };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function get(path: string, token: string): PushoverRequest {
|
|
233
|
+
return { url: `${PUSHOVER_API}${path}?${new URLSearchParams({ token }).toString()}`, method: "GET", headers: { accept: "application/json" } };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* This function gives the request that sends one message. It throws an error when the input
|
|
238
|
+
* breaks a rule of the API. Pushover refuses such a request, and many refusals can block the
|
|
239
|
+
* address of the caller.
|
|
240
|
+
*/
|
|
241
|
+
export function pushoverSendRequest(token: string, message: PushoverMessage): PushoverRequest {
|
|
242
|
+
assertKey(token, "API token");
|
|
243
|
+
const users = message.user.split(",");
|
|
244
|
+
if (users.length > PUSHOVER_LIMITS.users) throw new Error(`A message goes to ${PUSHOVER_LIMITS.users} users at most.`);
|
|
245
|
+
for (const user of users) assertKey(user, "user key");
|
|
246
|
+
if (message.message.trim() === "") throw new Error("The message is empty.");
|
|
247
|
+
assertMax(message.message, PUSHOVER_LIMITS.message, "message");
|
|
248
|
+
assertMax(message.title, PUSHOVER_LIMITS.title, "title");
|
|
249
|
+
assertMax(message.url, PUSHOVER_LIMITS.url, "URL");
|
|
250
|
+
assertMax(message.urlTitle, PUSHOVER_LIMITS.urlTitle, "URL title");
|
|
251
|
+
const priority = message.priority ?? 0;
|
|
252
|
+
if (![-2, -1, 0, 1, 2].includes(priority)) throw new Error(`${String(priority)} is not a priority of Pushover.`);
|
|
253
|
+
const fields: Record<string, string> = { token, user: message.user, message: message.message };
|
|
254
|
+
if (message.title !== undefined) fields["title"] = message.title;
|
|
255
|
+
if (message.url !== undefined) fields["url"] = message.url;
|
|
256
|
+
if (message.urlTitle !== undefined) fields["url_title"] = message.urlTitle;
|
|
257
|
+
if (priority !== 0) fields["priority"] = String(priority);
|
|
258
|
+
const emergency = message.emergency;
|
|
259
|
+
if (priority === 2) {
|
|
260
|
+
if (emergency === undefined) throw new Error("Priority 2 needs the emergency repeats: retryMs and expireMs.");
|
|
261
|
+
if (!(emergency.retryMs >= PUSHOVER_RETRY_MIN_MS)) throw new Error("The time between two repeats is less than 30 seconds.");
|
|
262
|
+
if (!(emergency.expireMs > 0 && emergency.expireMs <= PUSHOVER_EXPIRE_MAX_MS)) throw new Error("The repeats do not stop in 3 hours or less.");
|
|
263
|
+
fields["retry"] = seconds(emergency.retryMs, Math.ceil);
|
|
264
|
+
fields["expire"] = seconds(emergency.expireMs, Math.floor);
|
|
265
|
+
if (emergency.callback !== undefined) {
|
|
266
|
+
if (!/^https?:\/\//.test(emergency.callback)) throw new Error("The callback is not an http or https address.");
|
|
267
|
+
fields["callback"] = emergency.callback;
|
|
268
|
+
}
|
|
269
|
+
if (emergency.tags !== undefined && emergency.tags.length > 0) {
|
|
270
|
+
emergency.tags.forEach(assertTag);
|
|
271
|
+
fields["tags"] = emergency.tags.join(",");
|
|
272
|
+
}
|
|
273
|
+
} else if (emergency !== undefined) {
|
|
274
|
+
throw new Error("Only priority 2 takes the emergency repeats.");
|
|
275
|
+
}
|
|
276
|
+
if (message.sound !== undefined) {
|
|
277
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(message.sound)) throw new Error(`"${message.sound}" is not the name of a sound.`);
|
|
278
|
+
fields["sound"] = message.sound;
|
|
279
|
+
}
|
|
280
|
+
if (message.devices !== undefined && message.devices.length > 0) {
|
|
281
|
+
for (const device of message.devices) if (!DEVICE.test(device)) throw new Error(`"${device}" is not the name of a device.`);
|
|
282
|
+
fields["device"] = message.devices.join(",");
|
|
283
|
+
}
|
|
284
|
+
if (message.timestampMs !== undefined) fields["timestamp"] = seconds(message.timestampMs, Math.floor);
|
|
285
|
+
if (message.format === "html") fields["html"] = "1";
|
|
286
|
+
if (message.format === "monospace") fields["monospace"] = "1";
|
|
287
|
+
if (message.ttlMs !== undefined && priority !== 2) {
|
|
288
|
+
if (!(message.ttlMs >= 1000)) throw new Error("The time to live is less than 1 second.");
|
|
289
|
+
fields["ttl"] = seconds(message.ttlMs, Math.floor);
|
|
290
|
+
}
|
|
291
|
+
return post("/messages.json", fields);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* This function gives the request that reads the receipt of an emergency message. Read one receipt
|
|
296
|
+
* at most once in 5 seconds (`PUSHOVER_MIN_WAIT_MS`). Pushover keeps a receipt for one week.
|
|
297
|
+
*/
|
|
298
|
+
export function pushoverReceiptRequest(token: string, receipt: string): PushoverRequest {
|
|
299
|
+
assertKey(token, "API token");
|
|
300
|
+
assertKey(receipt, "receipt");
|
|
301
|
+
return get(`/receipts/${receipt}.json`, token);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** This function gives the request that stops the repeats of one emergency message. A second cancel has no effect. */
|
|
305
|
+
export function pushoverCancelRequest(token: string, receipt: string): PushoverRequest {
|
|
306
|
+
assertKey(token, "API token");
|
|
307
|
+
assertKey(receipt, "receipt");
|
|
308
|
+
return post(`/receipts/${receipt}/cancel.json`, { token });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* This function gives the request that stops the repeats of each emergency message of the
|
|
313
|
+
* application with the tag. A copy of a system that did not send the message can stop it.
|
|
314
|
+
*/
|
|
315
|
+
export function pushoverCancelByTagRequest(token: string, tag: string): PushoverRequest {
|
|
316
|
+
assertKey(token, "API token");
|
|
317
|
+
assertTag(tag);
|
|
318
|
+
return post(`/receipts/cancel_by_tag/${encodeURIComponent(tag)}.json`, { token });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** This function gives the request that examines a user key, and one device of the user when `device` names it. */
|
|
322
|
+
export function pushoverValidateRequest(token: string, user: string, device?: string): PushoverRequest {
|
|
323
|
+
assertKey(token, "API token");
|
|
324
|
+
assertKey(user, "user key");
|
|
325
|
+
if (device !== undefined && !DEVICE.test(device)) throw new Error(`"${device}" is not the name of a device.`);
|
|
326
|
+
return post("/users/validate.json", device === undefined ? { token, user } : { token, user, device });
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** This function gives the request that reads the monthly limit of the application. */
|
|
330
|
+
export function pushoverLimitsRequest(token: string): PushoverRequest {
|
|
331
|
+
assertKey(token, "API token");
|
|
332
|
+
return get("/apps/limits.json", token);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// =============================================================================
|
|
336
|
+
// Extractors
|
|
337
|
+
// =============================================================================
|
|
338
|
+
|
|
339
|
+
type Body = Readonly<Record<string, unknown>>;
|
|
340
|
+
|
|
341
|
+
function parse(text: string): Body | undefined {
|
|
342
|
+
try {
|
|
343
|
+
const value: unknown = JSON.parse(text);
|
|
344
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Body) : undefined;
|
|
345
|
+
} catch {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const text = (value: unknown): string | undefined => (typeof value === "string" && value !== "" ? value : undefined);
|
|
351
|
+
const count = (value: unknown): number | undefined => (typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : undefined);
|
|
352
|
+
/** Pushover gives `0` for a time that did not occur. */
|
|
353
|
+
const instant = (value: unknown): number | undefined => {
|
|
354
|
+
const at = count(value);
|
|
355
|
+
return at === undefined || at === 0 ? undefined : at * 1000;
|
|
356
|
+
};
|
|
357
|
+
const flag = (value: unknown): boolean => value === 1 || value === true;
|
|
358
|
+
/** This function gives `{ [key]: value }`, or an empty object when the value is undefined. */
|
|
359
|
+
const some = <K extends string, V>(key: K, value: V | undefined): { [P in K]?: V } => (value === undefined ? {} : { [key]: value }) as { [P in K]?: V };
|
|
360
|
+
|
|
361
|
+
function headerNumber(response: PushoverResponse, name: string): number | undefined {
|
|
362
|
+
const value = response.headers?.get(name);
|
|
363
|
+
return value === null || value === undefined || !/^\d+$/.test(value.trim()) ? undefined : Number(value.trim());
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function limitsOf(response: PushoverResponse): PushoverLimits | undefined {
|
|
367
|
+
const limit = headerNumber(response, "x-limit-app-limit");
|
|
368
|
+
const remaining = headerNumber(response, "x-limit-app-remaining");
|
|
369
|
+
const reset = headerNumber(response, "x-limit-app-reset");
|
|
370
|
+
return limit === undefined || remaining === undefined || reset === undefined ? undefined : { limit, remaining, resetAt: reset * 1000 };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function errorsOf(body: Body | undefined, fallback: string): string[] {
|
|
374
|
+
const errors = body?.["errors"];
|
|
375
|
+
const list = Array.isArray(errors) ? errors.filter((error): error is string => typeof error === "string") : [];
|
|
376
|
+
return list.length > 0 ? list : [fallback];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function failure(kind: PushoverFailureKind, response: PushoverResponse, body: Body | undefined, fallback: string): PushoverFailure {
|
|
380
|
+
const request = text(body?.["request"]);
|
|
381
|
+
const reset = kind === "rate-limited" ? limitsOf(response)?.resetAt : undefined;
|
|
382
|
+
return {
|
|
383
|
+
ok: false,
|
|
384
|
+
kind,
|
|
385
|
+
status: response.status,
|
|
386
|
+
errors: errorsOf(body, fallback),
|
|
387
|
+
...some("request", request),
|
|
388
|
+
...some("retryAfterMs", kind === "server" ? PUSHOVER_MIN_WAIT_MS : undefined),
|
|
389
|
+
...some("resetAt", reset),
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* This function sorts a response. It gives the body of a success, or a failure. Pushover has
|
|
395
|
+
* accepted a call only for an HTTP status 2xx with `status: 1` in the body.
|
|
396
|
+
*/
|
|
397
|
+
function sort(response: PushoverResponse): { readonly ok: true; readonly body: Body } | PushoverFailure {
|
|
398
|
+
const body = parse(response.text);
|
|
399
|
+
const status = response.status;
|
|
400
|
+
if (status === 429) return failure("rate-limited", response, body, "The application sent its messages of the month.");
|
|
401
|
+
if (status === 401 || status === 403 || body?.["token"] === "invalid") return failure("auth", response, body, "Pushover refused the API token.");
|
|
402
|
+
if (status >= 400 && status < 500) return failure("refused", response, body, `Pushover refused the request with HTTP ${status}.`);
|
|
403
|
+
if (status < 200 || status >= 300) return failure("server", response, body, `Pushover gave HTTP ${status}.`);
|
|
404
|
+
// A 2xx answer that is not JSON tells nothing. Thus the same request can go again.
|
|
405
|
+
if (body === undefined) return failure("server", response, body, "The answer of Pushover is not a JSON object.");
|
|
406
|
+
if (body["status"] !== 1) return failure("refused", response, body, "Pushover did not accept the request.");
|
|
407
|
+
return { ok: true, body };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** This function reads the response of `pushoverSendRequest`. It does not throw. */
|
|
411
|
+
export function extractPushoverSent(response: PushoverResponse): PushoverSent | PushoverFailure {
|
|
412
|
+
const sorted = sort(response);
|
|
413
|
+
if (!sorted.ok) return sorted;
|
|
414
|
+
const request = text(sorted.body["request"]);
|
|
415
|
+
if (request === undefined) return failure("server", response, sorted.body, "The answer of Pushover has no request id.");
|
|
416
|
+
const receipt = text(sorted.body["receipt"]);
|
|
417
|
+
const limits = limitsOf(response);
|
|
418
|
+
return { ok: true, request, ...some("receipt", receipt), ...some("limits", limits) };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** This function reads the response of `pushoverReceiptRequest`. It does not throw. */
|
|
422
|
+
export function extractPushoverReceipt(response: PushoverResponse): PushoverReceipt | PushoverFailure {
|
|
423
|
+
const sorted = sort(response);
|
|
424
|
+
if (!sorted.ok) return sorted;
|
|
425
|
+
const body = sorted.body;
|
|
426
|
+
const expiresAt = instant(body["expires_at"]);
|
|
427
|
+
if (expiresAt === undefined) return failure("server", response, body, "The receipt has no expiry time.");
|
|
428
|
+
return {
|
|
429
|
+
ok: true,
|
|
430
|
+
acknowledged: flag(body["acknowledged"]),
|
|
431
|
+
...some("acknowledgedAt", instant(body["acknowledged_at"])),
|
|
432
|
+
...some("acknowledgedBy", text(body["acknowledged_by"])),
|
|
433
|
+
...some("acknowledgedByDevice", text(body["acknowledged_by_device"])),
|
|
434
|
+
...some("lastDeliveredAt", instant(body["last_delivered_at"])),
|
|
435
|
+
expired: flag(body["expired"]),
|
|
436
|
+
expiresAt,
|
|
437
|
+
calledBack: flag(body["called_back"]),
|
|
438
|
+
...some("calledBackAt", instant(body["called_back_at"])),
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** This function reads the response of `pushoverCancelRequest` or `pushoverCancelByTagRequest`. It does not throw. */
|
|
443
|
+
export function extractPushoverCancelled(response: PushoverResponse): PushoverCancelled | PushoverFailure {
|
|
444
|
+
const sorted = sort(response);
|
|
445
|
+
if (!sorted.ok) return sorted;
|
|
446
|
+
const request = text(sorted.body["request"]);
|
|
447
|
+
const cancelled = count(sorted.body["canceled"]);
|
|
448
|
+
return { ok: true, ...some("request", request), ...some("cancelled", cancelled) };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** This function reads the response of `pushoverValidateRequest`. A user key that Pushover does not know is a `refused` failure. */
|
|
452
|
+
export function extractPushoverValidated(response: PushoverResponse): PushoverValidated | PushoverFailure {
|
|
453
|
+
const sorted = sort(response);
|
|
454
|
+
if (!sorted.ok) return sorted;
|
|
455
|
+
const strings = (value: unknown): string[] => (Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []);
|
|
456
|
+
return { ok: true, group: flag(sorted.body["group"]), devices: strings(sorted.body["devices"]), licenses: strings(sorted.body["licenses"]) };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** This function reads the response of `pushoverLimitsRequest`. It does not throw. */
|
|
460
|
+
export function extractPushoverLimits(response: PushoverResponse): PushoverLimitsRead | PushoverFailure {
|
|
461
|
+
const sorted = sort(response);
|
|
462
|
+
if (!sorted.ok) return sorted;
|
|
463
|
+
const limit = count(sorted.body["limit"]);
|
|
464
|
+
const remaining = count(sorted.body["remaining"]);
|
|
465
|
+
const reset = count(sorted.body["reset"]);
|
|
466
|
+
if (limit === undefined || remaining === undefined || reset === undefined) return failure("server", response, sorted.body, "The answer has no limit, remaining or reset.");
|
|
467
|
+
return { ok: true, limits: { limit, remaining, resetAt: reset * 1000 } };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* This function reads the form body that Pushover posts to the `callback` address when a user
|
|
472
|
+
* acknowledges an emergency message. It gives `undefined` for another body. Pushover sends the post
|
|
473
|
+
* again each minute until the address answers 2xx.
|
|
474
|
+
*/
|
|
475
|
+
export function extractPushoverCallback(body: string): PushoverCallback | undefined {
|
|
476
|
+
const form = new URLSearchParams(body);
|
|
477
|
+
const receipt = form.get("receipt") ?? "";
|
|
478
|
+
const at = form.get("acknowledged_at") ?? "";
|
|
479
|
+
if (!KEY.test(receipt) || form.get("acknowledged") !== "1" || !/^\d+$/.test(at) || at === "0") return undefined;
|
|
480
|
+
return {
|
|
481
|
+
receipt,
|
|
482
|
+
acknowledgedAt: Number(at) * 1000,
|
|
483
|
+
...some("acknowledgedBy", text(form.get("acknowledged_by"))),
|
|
484
|
+
...some("acknowledgedByDevice", text(form.get("acknowledged_by_device"))),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// =============================================================================
|
|
489
|
+
// The call
|
|
490
|
+
// =============================================================================
|
|
491
|
+
|
|
492
|
+
/** The part of `fetch` that `callPushover` uses. `globalThis.fetch` fits this type. */
|
|
493
|
+
export type PushoverFetch = (
|
|
494
|
+
url: string,
|
|
495
|
+
init: { method: string; headers: Record<string, string>; body?: string; signal: AbortSignal },
|
|
496
|
+
) => Promise<{ readonly status: number; text(): Promise<string>; readonly headers: { get(name: string): string | null } }>;
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* This function sends one request through the fetch and reads the response with the extractor. It
|
|
500
|
+
* does not throw for a failure of the network or a time limit: it gives a failure of the kind
|
|
501
|
+
* `network` or `timeout`. It throws only the reason of `signal`, when the caller aborts. It does not
|
|
502
|
+
* send a request again: the caller keeps the pace and the retries.
|
|
503
|
+
*/
|
|
504
|
+
export async function callPushover<Result>(
|
|
505
|
+
fetch: PushoverFetch,
|
|
506
|
+
request: PushoverRequest,
|
|
507
|
+
extract: (response: PushoverResponse) => Result | PushoverFailure,
|
|
508
|
+
options: { readonly timeoutMs: number; readonly signal?: AbortSignal | undefined },
|
|
509
|
+
): Promise<Result | PushoverFailure> {
|
|
510
|
+
const signals = [AbortSignal.timeout(options.timeoutMs), ...(options.signal ? [options.signal] : [])];
|
|
511
|
+
try {
|
|
512
|
+
const response = await fetch(request.url, {
|
|
513
|
+
method: request.method,
|
|
514
|
+
headers: { ...request.headers },
|
|
515
|
+
...(request.body === undefined ? {} : { body: request.body }),
|
|
516
|
+
signal: AbortSignal.any(signals),
|
|
517
|
+
});
|
|
518
|
+
return extract({ status: response.status, text: await response.text(), headers: response.headers });
|
|
519
|
+
} catch (error) {
|
|
520
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
521
|
+
const timedOut = (error as { name?: string } | undefined)?.name === "TimeoutError";
|
|
522
|
+
return {
|
|
523
|
+
ok: false,
|
|
524
|
+
kind: timedOut ? "timeout" : "network",
|
|
525
|
+
status: 0,
|
|
526
|
+
errors: [timedOut ? `Pushover did not answer in ${options.timeoutMs} ms.` : "The request did not reach Pushover."],
|
|
527
|
+
retryAfterMs: PUSHOVER_MIN_WAIT_MS,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
}
|