nonotify 0.1.14 → 0.2.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/src/notifier.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import { readFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
2
3
  import { getConfigPath, getLegacyConfigPath } from "./config.js";
3
- import { sendTelegramMessage } from "./telegram.js";
4
+ import {
5
+ answerTelegramCallbackQuery,
6
+ clearTelegramInlineKeyboard,
7
+ getLatestUpdateOffset,
8
+ markTelegramSelectedOption,
9
+ sendTelegramChoiceMessage,
10
+ sendTelegramMessage,
11
+ waitForTelegramCallback,
12
+ } from "./telegram.js";
4
13
 
5
14
  export type NotifierProfile = {
6
15
  type: "telegram";
@@ -36,6 +45,20 @@ export type SendResult = {
36
45
  provider: "telegram";
37
46
  };
38
47
 
48
+ export type AskInput = {
49
+ message: string;
50
+ options: readonly string[];
51
+ profile?: string;
52
+ timeoutMs?: number;
53
+ signal?: AbortSignal;
54
+ };
55
+
56
+ export type AskResult = {
57
+ selected: string;
58
+ profile: string;
59
+ provider: "telegram";
60
+ };
61
+
39
62
  export class NotifierError extends Error {
40
63
  constructor(message: string) {
41
64
  super(message);
@@ -60,6 +83,20 @@ export class NoProfilesConfiguredError extends NotifierError {
60
83
  }
61
84
  }
62
85
 
86
+ export class AskTimeoutError extends NotifierError {
87
+ constructor() {
88
+ super("Timed out waiting for Telegram answer.");
89
+ this.name = "AskTimeoutError";
90
+ }
91
+ }
92
+
93
+ export class AskAbortedError extends NotifierError {
94
+ constructor() {
95
+ super("Telegram answer wait was aborted.");
96
+ this.name = "AskAbortedError";
97
+ }
98
+ }
99
+
63
100
  type RawEnvConfig = {
64
101
  defaultProfile?: unknown;
65
102
  profiles?: unknown;
@@ -151,6 +188,117 @@ export class Notifier {
151
188
  };
152
189
  }
153
190
 
191
+ async ask(input: AskInput): Promise<AskResult> {
192
+ const message = input.message.trim();
193
+
194
+ if (message === "") {
195
+ throw new NotifierError("Message cannot be empty.");
196
+ }
197
+
198
+ if (!Array.isArray(input.options)) {
199
+ throw new NotifierError("Options must be an array.");
200
+ }
201
+
202
+ if (input.options.length < 1 || input.options.length > 10) {
203
+ throw new NotifierError("Options must contain between 1 and 10 items.");
204
+ }
205
+
206
+ const options = input.options.map((option, index) => {
207
+ if (typeof option !== "string" || option.trim() === "") {
208
+ throw new NotifierError(
209
+ `Invalid option at index ${index}: expected non-empty string.`
210
+ );
211
+ }
212
+
213
+ return option.trim();
214
+ });
215
+
216
+ if (
217
+ input.timeoutMs !== undefined &&
218
+ (!Number.isFinite(input.timeoutMs) || input.timeoutMs < 0)
219
+ ) {
220
+ throw new NotifierError("timeoutMs must be a non-negative number.");
221
+ }
222
+
223
+ const selectedProfile = this.resolveProfile(input.profile);
224
+ const offset = await getLatestUpdateOffset(selectedProfile.botToken);
225
+ const requestId = randomUUID().replaceAll("-", "");
226
+ const callbackOptions = options.map((option, index) => ({
227
+ label: option,
228
+ callbackData: `nnt:${requestId}:${index}`,
229
+ }));
230
+ const sentMessage = await sendTelegramChoiceMessage(
231
+ selectedProfile.botToken,
232
+ selectedProfile.chatId,
233
+ message,
234
+ callbackOptions
235
+ );
236
+
237
+ try {
238
+ const callback = await waitForTelegramCallback(selectedProfile.botToken, {
239
+ chatId: selectedProfile.chatId,
240
+ messageId: sentMessage.messageId,
241
+ callbackData: callbackOptions.map((option) => option.callbackData),
242
+ offset,
243
+ timeoutMs: input.timeoutMs,
244
+ signal: input.signal,
245
+ });
246
+
247
+ const selectedIndex = callbackOptions.findIndex(
248
+ (option) => option.callbackData === callback.data
249
+ );
250
+
251
+ if (selectedIndex === -1) {
252
+ throw new NotifierError("Received an unknown Telegram answer.");
253
+ }
254
+
255
+ await answerTelegramCallbackQuery(
256
+ selectedProfile.botToken,
257
+ callback.callbackQueryId
258
+ );
259
+
260
+ try {
261
+ await markTelegramSelectedOption(
262
+ selectedProfile.botToken,
263
+ selectedProfile.chatId,
264
+ sentMessage.messageId,
265
+ callbackOptions,
266
+ callback.data
267
+ );
268
+ } catch {
269
+ // Keep the selected answer even if the visual update fails.
270
+ }
271
+
272
+ return {
273
+ selected: options[selectedIndex],
274
+ profile: selectedProfile.name,
275
+ provider: selectedProfile.type,
276
+ };
277
+ } catch (error) {
278
+ if (error instanceof Error && error.message === "Timed out waiting") {
279
+ throw new AskTimeoutError();
280
+ }
281
+
282
+ if (isAbortError(error)) {
283
+ throw new AskAbortedError();
284
+ }
285
+
286
+ throw error;
287
+ } finally {
288
+ if (input.signal?.aborted) {
289
+ try {
290
+ await clearTelegramInlineKeyboard(
291
+ selectedProfile.botToken,
292
+ selectedProfile.chatId,
293
+ sentMessage.messageId
294
+ );
295
+ } catch {
296
+ // Do not mask the original ask result or failure if cleanup fails.
297
+ }
298
+ }
299
+ }
300
+ }
301
+
154
302
  private resolveProfile(profileName?: string): Readonly<NotifierProfile> {
155
303
  if (profileName) {
156
304
  const profile = this.profilesByName.get(profileName);
@@ -324,3 +472,9 @@ function isConfigLoader(value: unknown): value is NotifierConfigLoader {
324
472
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
325
473
  return typeof error === "object" && error !== null && "code" in error;
326
474
  }
475
+
476
+ function isAbortError(error: unknown): boolean {
477
+ return error instanceof DOMException
478
+ ? error.name === "AbortError"
479
+ : error instanceof Error && error.name === "AbortError";
480
+ }
package/src/prompt.ts CHANGED
@@ -1,5 +1,14 @@
1
1
  import { cancel, confirm, isCancel, select, text } from "@clack/prompts";
2
2
 
3
+ function exitIfCancelled<T>(value: T | symbol): T {
4
+ if (isCancel(value)) {
5
+ cancel("Operation cancelled.");
6
+ process.exit(1);
7
+ }
8
+
9
+ return value;
10
+ }
11
+
3
12
  export async function askRequired(question: string): Promise<string> {
4
13
  return askRequiredWithInitial(question);
5
14
  }
@@ -22,12 +31,7 @@ export async function askRequiredWithInitial(
22
31
  },
23
32
  });
24
33
 
25
- if (isCancel(value)) {
26
- cancel("Operation cancelled.");
27
- process.exit(1);
28
- }
29
-
30
- return value.trim();
34
+ return exitIfCancelled(value).trim();
31
35
  }
32
36
 
33
37
  export async function askConfirm(
@@ -39,12 +43,7 @@ export async function askConfirm(
39
43
  initialValue,
40
44
  });
41
45
 
42
- if (isCancel(value)) {
43
- cancel("Operation cancelled.");
44
- process.exit(1);
45
- }
46
-
47
- return value;
46
+ return exitIfCancelled(value);
48
47
  }
49
48
 
50
49
  type SelectOption = {
@@ -63,12 +62,7 @@ export async function askSelect(
63
62
  options,
64
63
  });
65
64
 
66
- if (isCancel(value)) {
67
- cancel("Operation cancelled.");
68
- process.exit(1);
69
- }
70
-
71
- return value;
65
+ return exitIfCancelled(value);
72
66
  }
73
67
 
74
68
  function normalizeQuestion(question: string): string {