@zhin.js/adapter-telegram 1.1.4 → 1.1.6

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/endpoint.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  Endpoint, createEndpointLifecycle,
3
3
  type EndpointLifecycle, type EndpointContentPort, type EndpointContentResolveContext,
4
- type EndpointControl, type EndpointSendRequest,
4
+ type EndpointControl, type EndpointSendRequest, type EndpointTransportState,
5
5
  } from 'zhin.js/adapter';
6
6
  /**
7
7
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
@@ -9,6 +9,7 @@ import {
9
9
  import { readFile } from 'node:fs/promises';
10
10
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
11
11
  import {
12
+ EndpointDeliveryError,
12
13
  type ConversationRef,
13
14
  type ConversationReference,
14
15
  type ConversationResolution,
@@ -17,6 +18,8 @@ import {
17
18
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
18
19
  import type { CapabilityId } from 'zhin.js';
19
20
  import { runTelegramPollLoop } from './polling.js';
21
+ import { TelegramApiError } from './api-error.js';
22
+ export { TelegramApiError } from './api-error.js';
20
23
  import { normalizeTelegramChatMember } from './platform-permit.js';
21
24
  import {
22
25
  botApiUrl,
@@ -40,6 +43,9 @@ import { registerTelegramWebhookRoutes } from './webhook.js';
40
43
 
41
44
  const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
42
45
  const CHAT_MEMBER_CACHE_MAX = 2_000;
46
+ const ADMITTED_UPDATE_LIMIT = 2_048;
47
+ const INFLIGHT_UPDATE_LIMIT = 256;
48
+ const DEFAULT_API_TIMEOUT_MS = 30_000;
43
49
 
44
50
  interface ChatMemberPermit {
45
51
  readonly at: number;
@@ -69,11 +75,13 @@ export interface TelegramEndpointOptions {
69
75
  readonly config: ResolvedTelegramConfig;
70
76
  readonly http?: HttpHost;
71
77
  readonly fetch?: TelegramFetch;
78
+ /** Internal transport deadline override; long polling always allows its server timeout. */
79
+ readonly requestTimeoutMs?: number;
72
80
  }
73
81
 
74
82
  export interface TelegramClientApi {
75
- callApi<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
76
- callApiForm<T = unknown>(method: string, form: FormData): Promise<T>;
83
+ callApi<T = unknown>(method: string, params?: Record<string, unknown>, signal?: AbortSignal): Promise<T>;
84
+ callApiForm<T = unknown>(method: string, form: FormData, signal?: AbortSignal): Promise<T>;
77
85
  pinMessage(chatId: number, messageId: number): Promise<boolean>;
78
86
  unpinMessage(chatId: number, messageId?: number): Promise<boolean>;
79
87
  setChatDescription(chatId: number, description: string): Promise<boolean>;
@@ -95,10 +103,10 @@ export interface TelegramClientApi {
95
103
  /** Telegram Bot API client carried by message, update and lifecycle events. */
96
104
  export class TelegramClient implements TelegramClientApi {
97
105
  constructor(readonly api: TelegramClientApi) {}
98
- callApi = <T = unknown>(method: string, params: Record<string, unknown> = {}) =>
99
- this.api.callApi<T>(method, params);
100
- callApiForm = <T = unknown>(method: string, form: FormData) =>
101
- this.api.callApiForm<T>(method, form);
106
+ callApi = <T = unknown>(method: string, params: Record<string, unknown> = {}, signal?: AbortSignal) =>
107
+ this.api.callApi<T>(method, params, signal);
108
+ callApiForm = <T = unknown>(method: string, form: FormData, signal?: AbortSignal) =>
109
+ this.api.callApiForm<T>(method, form, signal);
102
110
  pinMessage = (chatId: number, messageId: number) => this.api.pinMessage(chatId, messageId);
103
111
  unpinMessage = (chatId: number, messageId?: number) => this.api.unpinMessage(chatId, messageId);
104
112
  setChatDescription = (chatId: number, description: string) =>
@@ -130,6 +138,7 @@ interface TelegramApiErr {
130
138
  readonly ok: false;
131
139
  readonly description?: string;
132
140
  readonly error_code?: number;
141
+ readonly parameters?: { readonly retry_after?: number };
133
142
  }
134
143
 
135
144
  /**
@@ -139,8 +148,8 @@ interface TelegramApiErr {
139
148
  */
140
149
  export class TelegramEndpoint extends Endpoint<TelegramClient> {
141
150
  readonly client = new TelegramClient({
142
- callApi: (method, params) => this.callApi(method, params),
143
- callApiForm: (method, form) => this.callApiForm(method, form),
151
+ callApi: (method, params, signal) => this.callApi(method, params, signal),
152
+ callApiForm: (method, form, signal) => this.callApiForm(method, form, signal),
144
153
  pinMessage: (chatId, messageId) => this.pinMessage(chatId, messageId),
145
154
  unpinMessage: (chatId, messageId) => this.unpinMessage(chatId, messageId),
146
155
  setChatDescription: (chatId, description) => this.setChatDescription(chatId, description),
@@ -159,12 +168,16 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
159
168
 
160
169
  readonly #options: TelegramEndpointOptions;
161
170
  readonly #fetch: TelegramFetch;
171
+ #apiAbort = new AbortController();
162
172
  #pollAbort?: AbortController;
163
173
  #pollPromise?: Promise<void>;
164
174
  readonly #lifecycle: EndpointLifecycle;
165
175
  #open = false;
166
176
  #admission?: AbortController;
167
177
  #updateOffset = 0;
178
+ #pollingHealthy = true;
179
+ readonly #admittedUpdates = new Set<number>();
180
+ readonly #inflightUpdates = new Map<number, Promise<void>>();
168
181
  #botUserId?: number;
169
182
  #botUsername?: string;
170
183
  readonly #chatMemberCache = new Map<string, ChatMemberPermit>();
@@ -177,6 +190,9 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
177
190
 
178
191
  constructor(options: TelegramEndpointOptions) {
179
192
  super();
193
+ if (options.requestTimeoutMs !== undefined && (!Number.isFinite(options.requestTimeoutMs) || options.requestTimeoutMs <= 0)) {
194
+ throw new TypeError('Telegram requestTimeoutMs must be a positive finite number');
195
+ }
180
196
  this.#logger = getAdapterLogger('telegram', options.config.id);
181
197
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
182
198
  this.#options = options;
@@ -188,6 +204,16 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
188
204
  return this.#open;
189
205
  }
190
206
 
207
+ override get transportState(): EndpointTransportState {
208
+ const state = this.#lifecycle.state;
209
+ return state === 'open' && this.#options.config.mode === 'polling' && !this.#pollingHealthy
210
+ ? 'reconnecting' : state;
211
+ }
212
+
213
+ setPollingHealthy(healthy: boolean): void {
214
+ this.#pollingHealthy = healthy;
215
+ }
216
+
191
217
  get config(): ResolvedTelegramConfig {
192
218
  return this.#options.config;
193
219
  }
@@ -205,6 +231,7 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
205
231
  }
206
232
 
207
233
  async start(): Promise<void> {
234
+ if (this.#apiAbort.signal.aborted) this.#apiAbort = new AbortController();
208
235
  await this.#lifecycle.start(async (handle) => {
209
236
  const abort = new AbortController();
210
237
  const routes: HttpRouteRegistration[] = [];
@@ -289,6 +316,7 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
289
316
 
290
317
  async stop(): Promise<void> {
291
318
  this.close();
319
+ this.#apiAbort.abort();
292
320
  const polling = this.#pollPromise;
293
321
  this.#pollPromise = undefined;
294
322
  await this.#lifecycle.stop();
@@ -301,20 +329,33 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
301
329
  }
302
330
 
303
331
  async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
304
- const plan = formatOutboundPlan(conversation.id, payload);
305
332
  let lastId = '';
306
- for (const action of plan.actions) {
307
- const form = await this.#buildUploadForm(action.params, plan.uploads);
308
- const result = form
309
- ? await this.callApiForm<{ message_id?: number }>(action.method, form)
310
- : await this.callApi<{ message_id?: number }>(action.method, action.params);
311
- if (!result || !Number.isSafeInteger(result.message_id) || result.message_id! <= 0) {
312
- throw new Error(`Telegram API ${action.method} returned no valid message_id; delivery is unconfirmed`);
333
+ let completedActions = 0;
334
+ try {
335
+ const plan = formatOutboundPlan(conversation.id, payload);
336
+ for (const action of plan.actions) {
337
+ const form = await this.#buildUploadForm(action.params, plan.uploads);
338
+ const result = form
339
+ ? await this.callApiForm<{ message_id?: number }>(action.method, form)
340
+ : await this.callApi<{ message_id?: number }>(action.method, action.params);
341
+ if (!result || !Number.isSafeInteger(result.message_id) || result.message_id! <= 0) {
342
+ throw new EndpointDeliveryError('delivery_unconfirmed',
343
+ `Telegram API ${action.method} returned no valid message_id; delivery is unconfirmed`, 'unknown');
344
+ }
345
+ lastId = String(result.message_id);
346
+ completedActions += 1;
347
+ }
348
+ if (!lastId) throw new EndpointDeliveryError('empty_message', 'Telegram message contains no sendable actions', 'not_sent');
349
+ return lastId;
350
+ } catch (error) {
351
+ if (completedActions > 0) {
352
+ throw new EndpointDeliveryError('partial_delivery',
353
+ `Telegram message failed after ${completedActions} confirmed action(s); complete delivery is unconfirmed`,
354
+ 'unknown', { cause: error });
313
355
  }
314
- lastId = String(result.message_id);
356
+ if (error instanceof EndpointDeliveryError) throw error;
357
+ throw new EndpointDeliveryError('invalid_payload', 'Telegram message could not be prepared', 'not_sent', { cause: error });
315
358
  }
316
- if (!lastId) throw new Error('Telegram message contains no sendable actions');
317
- return lastId;
318
359
  }
319
360
 
320
361
  async recallMessage(message: MessageRef): Promise<void> {
@@ -339,33 +380,35 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
339
380
  signal.throwIfAborted();
340
381
  const file = await this.callApi<{ file_path?: string; file_size?: number }>('getFile', {
341
382
  file_id: reference.media.value,
342
- });
383
+ }, signal);
343
384
  if (!file.file_path) return Object.freeze({ status: 'not_found', code: 'telegram_file_not_found' });
344
385
  if ((file.file_size ?? 0) > 26_214_400) return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
345
- const response = await this.#fetch(
346
- `${this.#options.config.apiBaseUrl}/file/bot${this.#options.config.token}/${file.file_path}`,
347
- { signal },
348
- );
349
- if (!response.ok) return Object.freeze({ status: 'failed', code: 'telegram_file_download_failed' });
350
- if (!response.arrayBuffer) return Object.freeze({ status: 'failed', code: 'telegram_binary_transport_unavailable' });
351
- const bytes = Buffer.from(await response.arrayBuffer());
352
- if (bytes.byteLength > 26_214_400) return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
353
- return Object.freeze({
354
- status: 'resolved',
355
- reference,
356
- value: Object.freeze({
357
- kind: 'base64',
358
- value: bytes.toString('base64'),
359
- ...(response.headers?.get('content-type')?.split(';')[0] || reference.media.mime_type
360
- ? { mime_type: response.headers?.get('content-type')?.split(';')[0] ?? reference.media.mime_type }
361
- : {}),
362
- ...(reference.media.file_name ? { file_name: reference.media.file_name } : {}),
363
- size: bytes.byteLength,
364
- }),
365
- });
366
- } catch (error) {
386
+ return await this.#withRequestDeadline<ConversationResolution>('downloadFile', async (downloadSignal) => {
387
+ const response = await this.#fetch(
388
+ `${this.#options.config.apiBaseUrl}/file/bot${this.#options.config.token}/${file.file_path}`,
389
+ { signal: downloadSignal },
390
+ );
391
+ if (!response.ok) return Object.freeze({ status: 'failed', code: 'telegram_file_download_failed' });
392
+ if (!response.arrayBuffer) return Object.freeze({ status: 'failed', code: 'telegram_binary_transport_unavailable' });
393
+ const bytes = Buffer.from(await response.arrayBuffer());
394
+ if (bytes.byteLength > 26_214_400) return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
395
+ return Object.freeze({
396
+ status: 'resolved',
397
+ reference,
398
+ value: Object.freeze({
399
+ kind: 'base64',
400
+ value: bytes.toString('base64'),
401
+ ...(response.headers?.get('content-type')?.split(';')[0] || reference.media.mime_type
402
+ ? { mime_type: response.headers?.get('content-type')?.split(';')[0] ?? reference.media.mime_type }
403
+ : {}),
404
+ ...(reference.media.file_name ? { file_name: reference.media.file_name } : {}),
405
+ size: bytes.byteLength,
406
+ }),
407
+ });
408
+ }, signal);
409
+ } catch {
367
410
  if (signal.aborted) return Object.freeze({ status: 'expired', code: 'turn_aborted' });
368
- return Object.freeze({ status: 'failed', code: 'telegram_file_resolution_failed', message: error instanceof Error ? error.message : String(error) });
411
+ return Object.freeze({ status: 'failed', code: 'telegram_file_resolution_failed', message: 'Telegram file resolution failed' });
369
412
  }
370
413
  }
371
414
 
@@ -416,10 +459,10 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
416
459
 
417
460
  async #admitWithSenderRole(msg: TelegramMessage, conversation: ConversationRef, signal: AbortSignal): Promise<void> {
418
461
  const permit = await this.#resolveGroupSenderPermit(msg, signal);
419
- if (signal.aborted) return;
462
+ signal.throwIfAborted();
420
463
  // 新 Runtime Message.content 为纯文本:@ 本机只能经 metadata 传递
421
464
  const mentioned = this.#isBotMentioned(msg);
422
- await this.emit('message.receive', {
465
+ await this.emitAccepted('message.receive', {
423
466
  conversation,
424
467
  message: { conversation, id: String(msg.message_id) },
425
468
  content: formatInboundContent(msg),
@@ -501,13 +544,22 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
501
544
  /** Test / internal: admit a callback query when open. */
502
545
  admitCallback(query: TelegramCallbackQuery): void {
503
546
  if (!this.#open) return;
547
+ void this.#admitCallback(query).catch((err) => {
548
+ this.#logger.warn(formatCompact({
549
+ op: 'telegram_gateway_receive_failed',
550
+ error: err instanceof Error ? err.message : String(err),
551
+ }));
552
+ });
553
+ }
554
+
555
+ async #admitCallback(query: TelegramCallbackQuery): Promise<void> {
504
556
  const endpointKey = String(this.#options.id);
505
557
  const msg = query.message;
506
558
  // 无挂载消息的 callback(如 inline 模式)退化为 sender 私聊会话
507
559
  const conversation = msg
508
560
  ? telegramInboundConversation(endpointKey, msg.chat)
509
561
  : telegramInboundConversation(endpointKey, { id: query.from.id, type: 'private' });
510
- void this.emit('message.receive', {
562
+ await this.emitAccepted('message.receive', {
511
563
  conversation,
512
564
  message: { conversation, id: query.id },
513
565
  content: formatCallbackContent(query),
@@ -519,31 +571,55 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
519
571
  payload: query.data,
520
572
  sourceMessageId: msg ? String(msg.message_id) : undefined,
521
573
  }),
522
- }).catch((err) => {
523
- this.#logger.warn(formatCompact({
524
- op: 'telegram_gateway_receive_failed',
525
- target: `${conversation.kind}:${conversation.id}`,
526
- error: err instanceof Error ? err.message : String(err),
527
- }));
528
574
  });
529
575
  }
530
576
 
531
577
  /** Used by webhook / polling handlers. */
532
- handleUpdate(update: TelegramUpdate): void {
578
+ async handleUpdate(update: TelegramUpdate): Promise<void> {
579
+ if (!this.#open || !this.#admission) throw new Error('Telegram endpoint is not accepting updates');
580
+ if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
581
+ throw new TypeError('Telegram update_id must be a non-negative safe integer');
582
+ }
583
+ if (this.#admittedUpdates.has(update.update_id)) return;
584
+ const pending = this.#inflightUpdates.get(update.update_id);
585
+ if (pending) return pending;
586
+ if (this.#inflightUpdates.size >= INFLIGHT_UPDATE_LIMIT) {
587
+ throw new Error('Telegram update admission capacity exhausted');
588
+ }
589
+ const admission = this.#admission;
590
+ const receiving = (async () => {
591
+ // Once canonical dispatch enters Core, its generation lease drains the work.
592
+ // Closing the transport cannot turn successful business processing into a retry.
593
+ await this.#receiveUpdate(update, admission.signal);
594
+ this.#admittedUpdates.add(update.update_id);
595
+ if (this.#admittedUpdates.size > ADMITTED_UPDATE_LIMIT) {
596
+ this.#admittedUpdates.delete(this.#admittedUpdates.values().next().value!);
597
+ }
598
+ })();
599
+ this.#inflightUpdates.set(update.update_id, receiving);
600
+ try {
601
+ await receiving;
602
+ } finally {
603
+ this.#inflightUpdates.delete(update.update_id);
604
+ }
605
+ }
606
+
607
+ async #receiveUpdate(update: TelegramUpdate, signal: AbortSignal): Promise<void> {
533
608
  const eventName = update.message
534
609
  ? 'message'
535
610
  : update.callback_query
536
611
  ? 'callback_query'
537
612
  : 'update';
538
- void this.emitPlatform(eventName, update).catch((error) => {
539
- this.#logger.warn(formatCompact({
540
- op: 'telegram_platform_event_failed',
541
- event: eventName,
542
- error: error instanceof Error ? error.message : String(error),
543
- }));
544
- });
613
+ if (update.message || update.callback_query) {
614
+ await this.emitPlatform(eventName, update);
615
+ } else {
616
+ await this.emitAccepted('platform.receive', Object.freeze({ name: eventName, event: update }));
617
+ return;
618
+ }
619
+ signal.throwIfAborted();
545
620
  if (update.message) {
546
- this.admit(update.message);
621
+ const conversation = telegramInboundConversation(String(this.#options.id), update.message.chat);
622
+ await this.#admitWithSenderRole(update.message, conversation, signal);
547
623
  return;
548
624
  }
549
625
  if (update.callback_query) {
@@ -553,7 +629,7 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
553
629
  /* already answered */
554
630
  });
555
631
  }
556
- this.admitCallback(query);
632
+ await this.#admitCallback(query);
557
633
  }
558
634
  }
559
635
 
@@ -562,14 +638,13 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
562
638
  params: Record<string, unknown> = {},
563
639
  signal?: AbortSignal,
564
640
  ): Promise<T> {
565
- const url = botApiUrl(this.#options.config, method);
566
- const response = await this.#fetch(url, {
641
+ return this.#requestApi<T>(method, {
567
642
  method: 'POST',
568
643
  headers: { 'Content-Type': 'application/json' },
569
644
  body: JSON.stringify(params),
570
- signal,
571
- });
572
- return this.#parseApiResponse<T>(method, response);
645
+ }, signal, method === 'getUpdates'
646
+ ? Math.max(this.#options.requestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS, (Number(params.timeout) || 0) * 1_000 + 15_000)
647
+ : undefined);
573
648
  }
574
649
 
575
650
  /** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
@@ -578,13 +653,67 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
578
653
  form: FormData,
579
654
  signal?: AbortSignal,
580
655
  ): Promise<T> {
581
- const url = botApiUrl(this.#options.config, method);
582
- const response = await this.#fetch(url, {
656
+ return this.#requestApi<T>(method, {
583
657
  method: 'POST',
584
658
  body: form,
585
- signal,
586
- });
587
- return this.#parseApiResponse<T>(method, response);
659
+ }, signal);
660
+ }
661
+
662
+ async #requestApi<T>(
663
+ method: string,
664
+ init: NonNullable<Parameters<TelegramFetch>[1]>,
665
+ signal?: AbortSignal,
666
+ timeoutMs = this.#options.requestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS,
667
+ ): Promise<T> {
668
+ return this.#withRequestDeadline(method, async (requestSignal) => {
669
+ const response = await this.#fetch(botApiUrl(this.#options.config, method), { ...init, signal: requestSignal });
670
+ return this.#parseApiResponse<T>(method, response);
671
+ }, signal, timeoutMs);
672
+ }
673
+
674
+ async #withRequestDeadline<T>(
675
+ method: string,
676
+ requestOperation: (signal: AbortSignal) => Promise<T>,
677
+ signal?: AbortSignal,
678
+ timeoutMs = this.#options.requestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS,
679
+ ): Promise<T> {
680
+ const abort = new AbortController();
681
+ let issued = false;
682
+ const sources = [...new Set([signal, this.#pollAbort?.signal, this.#apiAbort.signal].filter((item): item is AbortSignal => !!item))];
683
+ let rejectAbort!: (error: unknown) => void;
684
+ const cancelled = new Promise<never>((_resolve, reject) => { rejectAbort = reject; });
685
+ const onCancel = () => {
686
+ const error = new TelegramApiError('cancelled', `Telegram API ${method} cancelled`, {
687
+ disposition: issued ? 'unknown' : 'not_sent',
688
+ });
689
+ abort.abort(error);
690
+ rejectAbort(error);
691
+ };
692
+ const timer = setTimeout(() => {
693
+ const error = new TelegramApiError('timeout', `Telegram API ${method} timed out`);
694
+ abort.abort(error);
695
+ rejectAbort(error);
696
+ }, timeoutMs);
697
+ for (const source of sources) source.addEventListener('abort', onCancel, { once: true });
698
+ try {
699
+ if (sources.some((source) => source.aborted)) onCancel();
700
+ const request = Promise.resolve().then(async () => {
701
+ abort.signal.throwIfAborted();
702
+ issued = true;
703
+ return requestOperation(abort.signal);
704
+ });
705
+ return await Promise.race([request, cancelled]);
706
+ } catch (error) {
707
+ if (abort.signal.aborted) throw abort.signal.reason;
708
+ if (error instanceof TelegramApiError) throw error;
709
+ // Network exceptions can contain credential-bearing URLs. Keep those out of errors/logs.
710
+ const detail = error instanceof Error
711
+ ? error.message.replaceAll(this.#options.config.token, '[redacted]') : 'transport failure';
712
+ throw new TelegramApiError('network', `Telegram API ${method} network request failed: ${detail}`);
713
+ } finally {
714
+ clearTimeout(timer);
715
+ for (const source of sources) source.removeEventListener('abort', onCancel);
716
+ }
588
717
  }
589
718
 
590
719
  async #parseApiResponse<T>(
@@ -596,13 +725,23 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
596
725
  try {
597
726
  body = JSON.parse(text) as TelegramApiOk<T> | TelegramApiErr;
598
727
  } catch {
599
- throw new Error(`Telegram API ${method} invalid JSON (${response.status}): ${text.slice(0, 200)}`);
728
+ throw new TelegramApiError('invalid_response', `Telegram API ${method} invalid JSON (${response.status})`, { status: response.status });
729
+ }
730
+ if (!body || typeof body !== 'object' || typeof body.ok !== 'boolean') {
731
+ throw new TelegramApiError('invalid_response', `Telegram API ${method} invalid response envelope`, { status: response.status });
600
732
  }
601
- if (!body.ok) {
602
- throw new Error(
603
- `Telegram API ${method} failed (${body.error_code ?? response.status}): ${body.description ?? text}`,
733
+ if (!body.ok || response.status < 200 || response.status >= 300) {
734
+ const status = !body.ok ? body.error_code ?? response.status : response.status;
735
+ const kind = status === 401 || status === 403 ? 'authentication'
736
+ : status === 429 ? 'rate_limit' : response.status >= 400 ? 'http' : 'api';
737
+ const description = !body.ok ? body.description?.replaceAll(this.#options.config.token, '[redacted]') : undefined;
738
+ throw new TelegramApiError(kind,
739
+ `Telegram API ${method} failed (${status}): ${description ?? 'request rejected'}`,
740
+ { status, disposition: !body.ok ? 'rejected' : 'unknown', ...(!body.ok && body.parameters?.retry_after != null
741
+ ? { retryAfterSeconds: body.parameters.retry_after } : {}) },
604
742
  );
605
743
  }
744
+ if (!('result' in body)) throw new TelegramApiError('invalid_response', `Telegram API ${method} missing result`);
606
745
  return body.result;
607
746
  }
608
747
 
package/src/polling.ts CHANGED
@@ -19,7 +19,8 @@ export interface TelegramPollingHost {
19
19
  ): Promise<T>;
20
20
  getUpdateOffset(): number;
21
21
  setUpdateOffset(offset: number): void;
22
- handleUpdate(update: TelegramUpdate): void;
22
+ handleUpdate(update: TelegramUpdate): void | Promise<void>;
23
+ setPollingHealthy?(healthy: boolean): void;
23
24
  }
24
25
 
25
26
  export async function runTelegramPollLoop(
@@ -34,15 +35,19 @@ export async function runTelegramPollLoop(
34
35
  timeout: DEFAULT_POLL_TIMEOUT_SEC,
35
36
  allowed_updates: host.allowedUpdates,
36
37
  }, abortSignal);
37
- consecutiveFailures = 0;
38
+ host.setPollingHealthy?.(true);
38
39
  for (const update of updates) {
39
40
  // A transport may resolve after cancellation; never admit that late batch.
40
41
  if (abortSignal.aborted) return;
42
+ if (update.update_id < host.getUpdateOffset()) continue;
43
+ await host.handleUpdate(update);
44
+ // Acknowledgement follows successful framework admission, never receipt.
41
45
  host.setUpdateOffset(update.update_id + 1);
42
- host.handleUpdate(update);
43
46
  }
47
+ consecutiveFailures = 0;
44
48
  } catch (err) {
45
49
  if (abortSignal.aborted) return;
50
+ host.setPollingHealthy?.(false);
46
51
  consecutiveFailures += 1;
47
52
  logger.error(formatCompact({
48
53
  op: 'poll',
package/src/protocol.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import type { IncomingMessage } from 'node:http';
7
- import { isMediaRef, type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
7
+ import { EndpointDeliveryError, isMediaRef, type ConversationKind, type ConversationRef } from '@zhin.js/im-contract';
8
8
  import type { Segment } from '@zhin.js/core/runtime';
9
9
  import { formatCompact, getLogger } from '@zhin.js/logger';
10
10
  import { escapeTelegramHtml, markdownToTelegramHtml } from './markdown-to-html.js';
@@ -495,7 +495,7 @@ function buildOutboundActions(
495
495
  : payload && typeof payload === 'object' && 'type' in (payload as object)
496
496
  ? [payload as TelegramWireSegment]
497
497
  : [];
498
- const hasMarkdown = items.some((item) => typeof item !== 'string' && item.type === 'markdown');
498
+ const hasMarkdown = items.some((item) => typeof item !== 'string' && ['markdown', 'share'].includes(item.type));
499
499
  const appendPlain = (value: unknown): string => hasMarkdown
500
500
  ? escapeTelegramHtml(String(value ?? ''))
501
501
  : String(value ?? '');
@@ -576,6 +576,15 @@ function buildOutboundActions(
576
576
  case 'markdown':
577
577
  textContent += markdownToTelegramHtml(String(data.content ?? data.text ?? ''));
578
578
  break;
579
+ case 'share': {
580
+ let url: URL;
581
+ try { url = new URL(String(data.url ?? '')); } catch { throw new EndpointDeliveryError('invalid_payload', 'Telegram share requires an HTTP(S) URL', 'not_sent'); }
582
+ if (!['http:', 'https:'].includes(url.protocol) || typeof data.title !== 'string' || !data.title.trim()) throw new EndpointDeliveryError('invalid_payload', 'Telegram share requires an HTTP(S) URL and title', 'not_sent');
583
+ if (['image', 'audio', 'artist', 'duration', 'config'].some(key => data[key] !== undefined)) throw new EndpointDeliveryError('unsupported_operation', 'Telegram share media and app metadata are not implemented', 'not_sent');
584
+ textContent += `<a href="${escapeTelegramHtml(url.href)}">${escapeTelegramHtml(data.title)}</a>`;
585
+ for (const value of [data.description, data.content]) if (typeof value === 'string' && value) textContent += `\n${escapeTelegramHtml(value)}`;
586
+ break;
587
+ }
579
588
  case 'at':
580
589
  if (data.id) textContent += `@${appendPlain(data.name || data.id)}`;
581
590
  break;
@@ -586,15 +595,20 @@ function buildOutboundActions(
586
595
  }
587
596
  case 'keyboard': {
588
597
  const rows = Array.isArray(data.rows) ? data.rows : [];
598
+ if (!rows.length || rows.some(row => !Array.isArray(row) || !row.length)) throw new EndpointDeliveryError('invalid_payload', 'Telegram keyboard requires nonempty rows', 'not_sent');
589
599
  keyboard = rows.map((row) => {
590
600
  const buttons = Array.isArray(row) ? row : [];
591
601
  return buttons.map((btn) => {
592
602
  const record = btn && typeof btn === 'object'
593
- ? btn as { label?: string; text?: string; payload?: string; callback_data?: string }
603
+ ? btn as { label?: string; text?: string; payload?: string; callback_data?: string; disabled?: boolean; mode?: string }
594
604
  : {};
605
+ const text = String(record.label ?? record.text ?? '');
606
+ const callback = String(record.payload ?? record.callback_data ?? '');
607
+ if (record.disabled || record.mode === 'command') throw new EndpointDeliveryError('unsupported_operation', 'Telegram disabled/command buttons are not implemented', 'not_sent');
608
+ if (!text.trim() || Buffer.byteLength(callback, 'utf8') < 1 || Buffer.byteLength(callback, 'utf8') > 64) throw new EndpointDeliveryError('invalid_payload', 'Telegram callback payload must be 1-64 UTF-8 bytes and label nonempty', 'not_sent');
595
609
  return {
596
- text: String(record.label ?? record.text ?? ''),
597
- callback_data: String(record.payload ?? record.callback_data ?? '').slice(0, 64),
610
+ text,
611
+ callback_data: callback,
598
612
  };
599
613
  });
600
614
  });
package/src/webhook.ts CHANGED
@@ -19,7 +19,7 @@ export function safeTokenEqual(a: string, b: string): boolean {
19
19
  export interface TelegramWebhookHandler {
20
20
  readonly config: ResolvedTelegramConfig;
21
21
  readonly isOpen: boolean;
22
- handleUpdate(update: TelegramUpdate): void;
22
+ handleUpdate(update: TelegramUpdate): void | Promise<void>;
23
23
  }
24
24
 
25
25
  export function registerTelegramWebhookRoutes(
@@ -56,20 +56,23 @@ export async function handleTelegramWebhookRequest(
56
56
  try {
57
57
  update = JSON.parse(rawBody) as TelegramUpdate;
58
58
  } catch {
59
- response.writeHead(200, { 'Content-Type': 'application/json' });
60
- response.end(JSON.stringify({ ok: true }));
59
+ response.writeHead(400, { 'Content-Type': 'application/json' });
60
+ response.end(JSON.stringify({ ok: false, description: 'Invalid JSON' }));
61
61
  return;
62
62
  }
63
-
64
- if (handler.isOpen) {
65
- handler.handleUpdate(update);
63
+ if (!update || !Number.isSafeInteger(update.update_id) || update.update_id < 0) {
64
+ response.writeHead(400, { 'Content-Type': 'application/json' });
65
+ response.end(JSON.stringify({ ok: false, description: 'Invalid update_id' }));
66
+ return;
66
67
  }
68
+ if (!handler.isOpen) throw new Error('Telegram endpoint is not accepting updates');
69
+ await handler.handleUpdate(update);
67
70
 
68
71
  response.writeHead(200, { 'Content-Type': 'application/json' });
69
72
  response.end(JSON.stringify({ ok: true }));
70
73
  } catch (error) {
71
74
  logger.error('Telegram webhook error:', error);
72
- response.writeHead(200, { 'Content-Type': 'application/json' });
73
- response.end(JSON.stringify({ ok: true }));
75
+ response.writeHead(503, { 'Content-Type': 'application/json' });
76
+ response.end(JSON.stringify({ ok: false, description: 'Update was not admitted' }));
74
77
  }
75
78
  }