nonotify 0.1.15 → 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/telegram.ts CHANGED
@@ -9,18 +9,33 @@ type TelegramApiResponse<T> =
9
9
  description: string;
10
10
  };
11
11
 
12
+ type TelegramChat = {
13
+ id: number;
14
+ username?: string;
15
+ };
16
+
17
+ type TelegramMessage = {
18
+ message_id: number;
19
+ from?: {
20
+ username?: string;
21
+ };
22
+ chat?: TelegramChat;
23
+ text?: string;
24
+ };
25
+
26
+ type TelegramCallbackQuery = {
27
+ id: string;
28
+ from?: {
29
+ username?: string;
30
+ };
31
+ message?: TelegramMessage;
32
+ data?: string;
33
+ };
34
+
12
35
  type TelegramUpdate = {
13
36
  update_id: number;
14
- message?: {
15
- from?: {
16
- username?: string;
17
- };
18
- chat?: {
19
- id: number;
20
- username?: string;
21
- };
22
- text?: string;
23
- };
37
+ message?: TelegramMessage;
38
+ callback_query?: TelegramCallbackQuery;
24
39
  };
25
40
 
26
41
  export type TelegramConnection = {
@@ -28,29 +43,76 @@ export type TelegramConnection = {
28
43
  username: string | null;
29
44
  };
30
45
 
46
+ export type TelegramInlineOption = {
47
+ label: string;
48
+ callbackData: string;
49
+ };
50
+
51
+ export type TelegramChoiceMessage = {
52
+ messageId: number;
53
+ };
54
+
55
+ export type TelegramCallbackSelection = {
56
+ callbackQueryId: string;
57
+ data: string;
58
+ };
59
+
60
+ type TelegramWaitOptions = {
61
+ timeoutMs?: number;
62
+ signal?: AbortSignal;
63
+ };
64
+
65
+ type TelegramWaiter<T> = {
66
+ match: (update: TelegramUpdate) => T | undefined;
67
+ resolve: (value: T) => void;
68
+ reject: (error: unknown) => void;
69
+ };
70
+
71
+ const allowedUpdateTypes = ["message", "callback_query"];
72
+ const updateStreams = new Map<string, TelegramUpdateStream>();
73
+ const shortPollIntervalMs = 1_000;
74
+
31
75
  async function telegramRequest<T>(
32
76
  botToken: string,
33
77
  method: string,
34
- payload: Record<string, unknown>
78
+ payload: Record<string, unknown>,
79
+ signal?: AbortSignal
35
80
  ): Promise<T> {
36
- const response = await fetch(
37
- `https://api.telegram.org/bot${botToken}/${method}`,
38
- {
39
- method: "POST",
40
- headers: {
41
- "content-type": "application/json",
42
- },
43
- body: JSON.stringify(payload),
44
- }
45
- );
81
+ let response: Response;
82
+
83
+ try {
84
+ response = await fetch(
85
+ `https://api.telegram.org/bot${botToken}/${method}`,
86
+ {
87
+ method: "POST",
88
+ signal,
89
+ headers: {
90
+ "content-type": "application/json",
91
+ },
92
+ body: JSON.stringify(payload),
93
+ }
94
+ );
95
+ } catch (error) {
96
+ throw new Error(formatTelegramFetchError(method, error));
97
+ }
46
98
 
47
99
  if (!response.ok) {
48
- throw new Error(`Telegram API HTTP ${response.status}`);
100
+ throw new Error(`Telegram ${method} failed with HTTP ${response.status}.`);
49
101
  }
50
102
 
51
103
  const json = (await response.json()) as TelegramApiResponse<T>;
52
104
 
53
105
  if (!json.ok) {
106
+ if (
107
+ method === "getUpdates" &&
108
+ /webhook/i.test(json.description) &&
109
+ /active|set/i.test(json.description)
110
+ ) {
111
+ throw new Error(
112
+ "This Telegram bot has an active webhook. `nnt ask` requires getUpdates, so disable the webhook or use a separate bot token for nonotify."
113
+ );
114
+ }
115
+
54
116
  throw new Error(json.description);
55
117
  }
56
118
 
@@ -63,7 +125,7 @@ export async function getLatestUpdateOffset(botToken: string): Promise<number> {
63
125
  "getUpdates",
64
126
  {
65
127
  timeout: 0,
66
- allowed_updates: ["message"],
128
+ allowed_updates: allowedUpdateTypes,
67
129
  }
68
130
  );
69
131
 
@@ -81,30 +143,18 @@ export async function getLatestUpdateOffset(botToken: string): Promise<number> {
81
143
  export async function waitForChatId(
82
144
  botToken: string,
83
145
  offset: number,
84
- timeoutSeconds = 120
146
+ timeoutSeconds = 120,
147
+ signal?: AbortSignal
85
148
  ): Promise<TelegramConnection> {
86
- const startedAt = Date.now();
87
- let currentOffset = offset;
88
-
89
- while ((Date.now() - startedAt) / 1000 < timeoutSeconds) {
90
- const remainingSeconds =
91
- timeoutSeconds - Math.floor((Date.now() - startedAt) / 1000);
92
- const pollTimeout = Math.max(1, Math.min(25, remainingSeconds));
93
-
94
- const updates = await telegramRequest<TelegramUpdate[]>(
95
- botToken,
96
- "getUpdates",
97
- {
98
- offset: currentOffset,
99
- timeout: pollTimeout,
100
- allowed_updates: ["message"],
101
- }
102
- );
149
+ const stream = getTelegramUpdateStream(botToken, offset);
103
150
 
104
- for (const update of updates) {
105
- currentOffset = Math.max(currentOffset, update.update_id + 1);
151
+ try {
152
+ return await stream.waitFor(
153
+ (update) => {
154
+ if (update.message?.chat?.id === undefined) {
155
+ return undefined;
156
+ }
106
157
 
107
- if (update.message?.chat?.id !== undefined) {
108
158
  return {
109
159
  chatId: String(update.message.chat.id),
110
160
  username:
@@ -112,13 +162,21 @@ export async function waitForChatId(
112
162
  update.message.chat.username ??
113
163
  null,
114
164
  };
165
+ },
166
+ {
167
+ timeoutMs: timeoutSeconds * 1000,
168
+ signal,
115
169
  }
170
+ );
171
+ } catch (error) {
172
+ if (error instanceof Error && error.message === "Timed out waiting") {
173
+ throw new Error(
174
+ "Timed out waiting for Telegram message. Send a message to your bot and try again."
175
+ );
116
176
  }
117
- }
118
177
 
119
- throw new Error(
120
- "Timed out waiting for Telegram message. Send a message to your bot and try again."
121
- );
178
+ throw error;
179
+ }
122
180
  }
123
181
 
124
182
  export async function sendTelegramMessage(
@@ -131,3 +189,348 @@ export async function sendTelegramMessage(
131
189
  text,
132
190
  });
133
191
  }
192
+
193
+ export async function sendTelegramChoiceMessage(
194
+ botToken: string,
195
+ chatId: string,
196
+ text: string,
197
+ options: readonly TelegramInlineOption[]
198
+ ): Promise<TelegramChoiceMessage> {
199
+ const message = await telegramRequest<TelegramMessage>(
200
+ botToken,
201
+ "sendMessage",
202
+ {
203
+ chat_id: chatId,
204
+ text,
205
+ reply_markup: {
206
+ inline_keyboard: options.map((option) => [
207
+ {
208
+ text: option.label,
209
+ callback_data: option.callbackData,
210
+ },
211
+ ]),
212
+ },
213
+ }
214
+ );
215
+
216
+ return {
217
+ messageId: message.message_id,
218
+ };
219
+ }
220
+
221
+ export async function waitForTelegramCallback(
222
+ botToken: string,
223
+ input: {
224
+ chatId: string;
225
+ messageId: number;
226
+ callbackData: readonly string[];
227
+ offset?: number;
228
+ timeoutMs?: number;
229
+ signal?: AbortSignal;
230
+ }
231
+ ): Promise<TelegramCallbackSelection> {
232
+ const stream = getTelegramUpdateStream(botToken, input.offset);
233
+ const allowedCallbackData = new Set(input.callbackData);
234
+
235
+ return stream.waitFor(
236
+ (update) => {
237
+ const callback = update.callback_query;
238
+
239
+ if (
240
+ !callback?.message ||
241
+ String(callback.message.chat?.id) !== input.chatId ||
242
+ callback.message.message_id !== input.messageId ||
243
+ typeof callback.data !== "string" ||
244
+ !allowedCallbackData.has(callback.data)
245
+ ) {
246
+ return undefined;
247
+ }
248
+
249
+ return {
250
+ callbackQueryId: callback.id,
251
+ data: callback.data,
252
+ };
253
+ },
254
+ {
255
+ timeoutMs: input.timeoutMs,
256
+ signal: input.signal,
257
+ }
258
+ );
259
+ }
260
+
261
+ export async function answerTelegramCallbackQuery(
262
+ botToken: string,
263
+ callbackQueryId: string
264
+ ): Promise<void> {
265
+ await telegramRequest(botToken, "answerCallbackQuery", {
266
+ callback_query_id: callbackQueryId,
267
+ });
268
+ }
269
+
270
+ export async function clearTelegramInlineKeyboard(
271
+ botToken: string,
272
+ chatId: string,
273
+ messageId: number
274
+ ): Promise<void> {
275
+ try {
276
+ await telegramRequest(botToken, "editMessageReplyMarkup", {
277
+ chat_id: chatId,
278
+ message_id: messageId,
279
+ reply_markup: {
280
+ inline_keyboard: [],
281
+ },
282
+ });
283
+ } catch (error) {
284
+ if (
285
+ error instanceof Error &&
286
+ /message is not modified/i.test(error.message)
287
+ ) {
288
+ return;
289
+ }
290
+
291
+ throw error;
292
+ }
293
+ }
294
+
295
+ export async function markTelegramSelectedOption(
296
+ botToken: string,
297
+ chatId: string,
298
+ messageId: number,
299
+ options: readonly TelegramInlineOption[],
300
+ selectedCallbackData: string
301
+ ): Promise<void> {
302
+ try {
303
+ await telegramRequest(botToken, "editMessageReplyMarkup", {
304
+ chat_id: chatId,
305
+ message_id: messageId,
306
+ reply_markup: {
307
+ inline_keyboard: options.map((option) => [
308
+ {
309
+ text:
310
+ option.callbackData === selectedCallbackData
311
+ ? `✅ ${option.label}`
312
+ : option.label,
313
+ callback_data: option.callbackData,
314
+ },
315
+ ]),
316
+ },
317
+ });
318
+ } catch (error) {
319
+ if (
320
+ error instanceof Error &&
321
+ /message is not modified/i.test(error.message)
322
+ ) {
323
+ return;
324
+ }
325
+
326
+ throw error;
327
+ }
328
+ }
329
+
330
+ function getTelegramUpdateStream(
331
+ botToken: string,
332
+ offset = 0
333
+ ): TelegramUpdateStream {
334
+ const existing = updateStreams.get(botToken);
335
+
336
+ if (existing) {
337
+ existing.setMinimumOffset(offset);
338
+ return existing;
339
+ }
340
+
341
+ const stream = new TelegramUpdateStream(botToken, offset);
342
+ updateStreams.set(botToken, stream);
343
+ return stream;
344
+ }
345
+
346
+ class TelegramUpdateStream {
347
+ private currentOffset: number;
348
+ private readonly waiters = new Set<TelegramWaiter<unknown>>();
349
+ private pollPromise: Promise<void> | null = null;
350
+ private pollAbortController: AbortController | null = null;
351
+
352
+ constructor(private readonly botToken: string, initialOffset: number) {
353
+ this.currentOffset = initialOffset;
354
+ }
355
+
356
+ setMinimumOffset(offset: number): void {
357
+ this.currentOffset = Math.max(this.currentOffset, offset);
358
+ }
359
+
360
+ async waitFor<T>(
361
+ match: (update: TelegramUpdate) => T | undefined,
362
+ options: TelegramWaitOptions = {}
363
+ ): Promise<T> {
364
+ if (options.signal?.aborted) {
365
+ throw options.signal.reason ?? new DOMException("Aborted", "AbortError");
366
+ }
367
+
368
+ return new Promise<T>((resolve, reject) => {
369
+ const abortListener = () => {
370
+ unregister();
371
+ reject(
372
+ options.signal?.reason ?? new DOMException("Aborted", "AbortError")
373
+ );
374
+ };
375
+ const timeout =
376
+ typeof options.timeoutMs === "number"
377
+ ? setTimeout(() => {
378
+ unregister();
379
+ reject(new Error("Timed out waiting"));
380
+ }, options.timeoutMs)
381
+ : null;
382
+
383
+ const waiter: TelegramWaiter<T> = {
384
+ match,
385
+ resolve: (value) => {
386
+ unregister();
387
+ resolve(value);
388
+ },
389
+ reject: (error) => {
390
+ unregister();
391
+ reject(error);
392
+ },
393
+ };
394
+
395
+ const unregister = () => {
396
+ if (timeout) {
397
+ clearTimeout(timeout);
398
+ }
399
+
400
+ options.signal?.removeEventListener("abort", abortListener);
401
+ this.waiters.delete(waiter as TelegramWaiter<unknown>);
402
+
403
+ if (this.waiters.size === 0) {
404
+ this.pollAbortController?.abort();
405
+ }
406
+ };
407
+
408
+ this.waiters.add(waiter as TelegramWaiter<unknown>);
409
+ options.signal?.addEventListener("abort", abortListener, { once: true });
410
+ this.ensurePolling();
411
+ });
412
+ }
413
+
414
+ private ensurePolling(): void {
415
+ if (this.pollPromise) {
416
+ return;
417
+ }
418
+
419
+ this.pollPromise = this.pollLoop().finally(() => {
420
+ this.pollPromise = null;
421
+
422
+ if (this.waiters.size === 0) {
423
+ updateStreams.delete(this.botToken);
424
+ }
425
+ });
426
+ }
427
+
428
+ private async pollLoop(): Promise<void> {
429
+ while (this.waiters.size > 0) {
430
+ this.pollAbortController = new AbortController();
431
+
432
+ try {
433
+ const updates = await telegramRequest<TelegramUpdate[]>(
434
+ this.botToken,
435
+ "getUpdates",
436
+ {
437
+ offset: this.currentOffset,
438
+ timeout: 0,
439
+ allowed_updates: allowedUpdateTypes,
440
+ },
441
+ this.pollAbortController.signal
442
+ );
443
+
444
+ for (const update of updates) {
445
+ this.currentOffset = Math.max(
446
+ this.currentOffset,
447
+ update.update_id + 1
448
+ );
449
+ this.dispatch(update);
450
+ }
451
+
452
+ if (updates.length === 0 && this.waiters.size > 0) {
453
+ await delay(shortPollIntervalMs, this.pollAbortController.signal);
454
+ }
455
+ } catch (error) {
456
+ if (isAbortError(error) && this.waiters.size === 0) {
457
+ return;
458
+ }
459
+
460
+ this.rejectAll(error);
461
+ return;
462
+ } finally {
463
+ this.pollAbortController = null;
464
+ }
465
+ }
466
+ }
467
+
468
+ private dispatch(update: TelegramUpdate): void {
469
+ for (const waiter of Array.from(this.waiters)) {
470
+ try {
471
+ const result = waiter.match(update);
472
+
473
+ if (result !== undefined) {
474
+ waiter.resolve(result);
475
+ }
476
+ } catch (error) {
477
+ waiter.reject(error);
478
+ }
479
+ }
480
+ }
481
+
482
+ private rejectAll(error: unknown): void {
483
+ for (const waiter of Array.from(this.waiters)) {
484
+ waiter.reject(error);
485
+ }
486
+ }
487
+ }
488
+
489
+ function isAbortError(error: unknown): boolean {
490
+ return error instanceof DOMException
491
+ ? error.name === "AbortError"
492
+ : error instanceof Error && error.name === "AbortError";
493
+ }
494
+
495
+ async function delay(ms: number, signal?: AbortSignal): Promise<void> {
496
+ if (signal?.aborted) {
497
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
498
+ }
499
+
500
+ await new Promise<void>((resolve, reject) => {
501
+ const onAbort = () => {
502
+ clearTimeout(timeout);
503
+ signal?.removeEventListener("abort", onAbort);
504
+ const abortReason =
505
+ signal?.reason ?? new DOMException("Aborted", "AbortError");
506
+ reject(abortReason);
507
+ };
508
+
509
+ const timeout = setTimeout(() => {
510
+ signal?.removeEventListener("abort", onAbort);
511
+ resolve();
512
+ }, ms);
513
+
514
+ signal?.addEventListener("abort", onAbort, { once: true });
515
+ });
516
+ }
517
+
518
+ function formatTelegramFetchError(method: string, error: unknown): string {
519
+ if (!(error instanceof Error)) {
520
+ return `Telegram ${method} request failed: ${String(error)}`;
521
+ }
522
+
523
+ const causeMessage =
524
+ typeof error.cause === "object" &&
525
+ error.cause !== null &&
526
+ "message" in error.cause &&
527
+ typeof error.cause.message === "string"
528
+ ? error.cause.message
529
+ : null;
530
+
531
+ if (causeMessage) {
532
+ return `Telegram ${method} request failed: ${error.message} (${causeMessage})`;
533
+ }
534
+
535
+ return `Telegram ${method} request failed: ${error.message}`;
536
+ }