@zhin.js/adapter-telegram 1.1.4 → 1.1.5

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 CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - b9b3953: Fix adapter connection races, transport health reporting, candidate rollback and delivery acknowledgement boundaries. Separate generation admission from locally observed connectivity, reject unconfirmed message receipts, and mark uncertain delivery results without recommending automatic replay.
8
+
9
+ Class and compact endpoints should expose a `transportState` getter to report physical connection or local listener health. Unobserved endpoints now report an unknown transport and remain offline in diagnostics. Platform stability tiers remain unchanged until real account acceptance evidence is available.
10
+
11
+ - Updated dependencies [b9b3953]
12
+ - Updated dependencies [b9b3953]
13
+ - Updated dependencies [b9b3953]
14
+ - Updated dependencies [b9b3953]
15
+ - Updated dependencies [b9b3953]
16
+ - Updated dependencies [b9b3953]
17
+ - Updated dependencies [b9b3953]
18
+ - Updated dependencies [b9b3953]
19
+ - Updated dependencies [b9b3953]
20
+ - Updated dependencies [b9b3953]
21
+ - Updated dependencies [b9b3953]
22
+ - @zhin.js/adapter@1.1.16
23
+ - @zhin.js/im-contract@1.1.3
24
+ - @zhin.js/core@1.1.40
25
+ - @zhin.js/host-http@1.1.3
26
+ - @zhin.js/tool@1.1.3
27
+ - @zhin.js/skill@1.1.3
28
+ - @zhin.js/agent-feature@1.1.3
29
+
3
30
  ## 1.1.4
4
31
 
5
32
  ### Patch Changes
package/README.md CHANGED
@@ -11,6 +11,18 @@ Zhin.js Telegram Bot API 适配器(Plugin Runtime),默认通过 **长轮
11
11
  - 约定式 `defineAdapter` / `definePlugin`(无需 `usePlugin`)
12
12
  - Webhook 模式通过 `httpHostToken` 注册回调路由,支持 secretToken 校验
13
13
 
14
+ ### 更新确认与重投
15
+
16
+ - Polling 在 `handleUpdate()` 的框架接纳 Promise 成功后推进本地 offset;同批次接纳失败会停止处理后续 update,保留失败 update 供下一次重投。Telegram 在下一次携带更高 offset 的 `getUpdates` 请求时才确认旧 update。
17
+ - Webhook 等待框架接纳后返回 200;请求到达时端点已关闭、尚未进入 Core 的处理被取消或接纳失败返回 503,非法 JSON/update_id 返回 400,secret 不匹配返回 403。仅 native `platform.receive` 成功不等于消息接纳成功,还需等待 canonical `message.receive`。已经进入 Core 的业务由 generation lease 完成,随后成功仍返回 200,即使端点期间关闭,避免把完成的副作用错误地触发重投。
18
+ - 每个 Endpoint 最多保存 2,048 个已接纳 update_id,并合并相同 ID 的并发请求;最多允许 256 个不同 ID 的并发接纳,超过容量返回失败供重投。失败不计入已接纳集合。
19
+ - 去重状态和 polling offset 属于当前 Endpoint generation,**不是耐久 checkpoint**。HMR/进程重启会清空;已接纳但尚未由下一轮 offset 请求确认的尾部 update,或 webhook 200 尚未到达 Telegram 的 update,可能再次投递。当前提供至少一次重投路径,不保证跨重启恰好一次;有外部副作用的插件应使用 update/message ID 做持久幂等。
20
+ - JSON API 和 multipart/form-data 默认 30 秒 deadline;长轮询允许服务端 timeout 外加 15 秒。客户端 `callApi` / `callApiForm` 第三个参数可传 AbortSignal,端点停止也会取消当前 API 请求。`TelegramApiError.kind` 区分 timeout、cancelled、network、http、authentication、rate_limit、api、invalid_response;429 可读取 `retryAfterSeconds`。发送失败不会自动重试,超时/网络中断的送达结果可能未知。
21
+ - `TelegramApiError` 同时遵循 `EndpointDeliveryError` 合同:明确 Bot API 拒绝为 rejected,网络/超时或异常响应为 unknown,发出请求前取消为 not_sent。一个 payload 拆成多个发送动作时,已有动作成功而后续失败会报告 `partial_delivery` / unknown,并在 cause 保留后续错误;不能把整条消息视为全部拒绝或安全重发。
22
+ - Polling 请求失败时 transport 状态为 reconnecting,成功拉取后恢复 open;Webhook 的 open 代表本地路由与注册成功,不能证明公网代理实时可达。
23
+
24
+ 确认与重试规则依据 [Telegram Bot API getUpdates / setWebhook](https://core.telegram.org/bots/api#getupdates)。
25
+
14
26
  ## 安装
15
27
 
16
28
  ```bash
@@ -0,0 +1,13 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+ /** Machine-readable failures. Sending is never automatically retried. */
3
+ export type TelegramApiErrorKind = 'timeout' | 'cancelled' | 'network' | 'http' | 'authentication' | 'rate_limit' | 'api' | 'invalid_response';
4
+ export declare class TelegramApiError extends EndpointDeliveryError {
5
+ readonly kind: TelegramApiErrorKind;
6
+ readonly status?: number;
7
+ readonly retryAfterSeconds?: number;
8
+ constructor(kind: TelegramApiErrorKind, message: string, details?: {
9
+ readonly status?: number;
10
+ readonly retryAfterSeconds?: number;
11
+ readonly disposition?: EndpointDeliveryError['disposition'];
12
+ });
13
+ }
@@ -0,0 +1,16 @@
1
+ import { EndpointDeliveryError } from '@zhin.js/im-contract';
2
+ export class TelegramApiError extends EndpointDeliveryError {
3
+ kind;
4
+ status;
5
+ retryAfterSeconds;
6
+ constructor(kind, message, details = {}) {
7
+ const disposition = details.disposition ?? (kind === 'authentication' || kind === 'rate_limit' || kind === 'api' ? 'rejected' : 'unknown');
8
+ super(disposition === 'rejected' ? 'platform_rejected'
9
+ : kind === 'timeout' ? 'endpoint_timeout'
10
+ : kind === 'cancelled' ? 'endpoint_cancelled' : 'delivery_unconfirmed', message, disposition);
11
+ this.name = 'TelegramApiError';
12
+ this.kind = kind;
13
+ this.status = details.status;
14
+ this.retryAfterSeconds = details.retryAfterSeconds;
15
+ }
16
+ }
package/lib/endpoint.d.ts CHANGED
@@ -1,7 +1,8 @@
1
- import { Endpoint, type EndpointContentPort, type EndpointControl, type EndpointSendRequest } from 'zhin.js/adapter';
1
+ import { Endpoint, type EndpointContentPort, type EndpointControl, type EndpointSendRequest, type EndpointTransportState } from 'zhin.js/adapter';
2
2
  import type { HttpHost } from '@zhin.js/host-http';
3
3
  import { type MessageRef } from '@zhin.js/im-contract';
4
4
  import type { CapabilityId } from 'zhin.js';
5
+ export { TelegramApiError } from './api-error.js';
5
6
  import { type ResolvedTelegramConfig, type TelegramCallbackQuery, type TelegramChatMember, type TelegramMessage, type TelegramUpdate } from './protocol.js';
6
7
  export type TelegramFetch = (url: string, init?: {
7
8
  readonly method?: string;
@@ -23,10 +24,12 @@ export interface TelegramEndpointOptions {
23
24
  readonly config: ResolvedTelegramConfig;
24
25
  readonly http?: HttpHost;
25
26
  readonly fetch?: TelegramFetch;
27
+ /** Internal transport deadline override; long polling always allows its server timeout. */
28
+ readonly requestTimeoutMs?: number;
26
29
  }
27
30
  export interface TelegramClientApi {
28
- callApi<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>;
29
- callApiForm<T = unknown>(method: string, form: FormData): Promise<T>;
31
+ callApi<T = unknown>(method: string, params?: Record<string, unknown>, signal?: AbortSignal): Promise<T>;
32
+ callApiForm<T = unknown>(method: string, form: FormData, signal?: AbortSignal): Promise<T>;
30
33
  pinMessage(chatId: number, messageId: number): Promise<boolean>;
31
34
  unpinMessage(chatId: number, messageId?: number): Promise<boolean>;
32
35
  setChatDescription(chatId: number, description: string): Promise<boolean>;
@@ -46,8 +49,8 @@ export interface TelegramClientApi {
46
49
  export declare class TelegramClient implements TelegramClientApi {
47
50
  readonly api: TelegramClientApi;
48
51
  constructor(api: TelegramClientApi);
49
- callApi: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>;
50
- callApiForm: <T = unknown>(method: string, form: FormData) => Promise<T>;
52
+ callApi: <T = unknown>(method: string, params?: Record<string, unknown>, signal?: AbortSignal) => Promise<T>;
53
+ callApiForm: <T = unknown>(method: string, form: FormData, signal?: AbortSignal) => Promise<T>;
51
54
  pinMessage: (chatId: number, messageId: number) => Promise<boolean>;
52
55
  unpinMessage: (chatId: number, messageId?: number) => Promise<boolean>;
53
56
  setChatDescription: (chatId: number, description: string) => Promise<boolean>;
@@ -76,6 +79,8 @@ export declare class TelegramEndpoint extends Endpoint<TelegramClient> {
76
79
  constructor(options: TelegramEndpointOptions);
77
80
  /** Used by webhook handler. */
78
81
  get isOpen(): boolean;
82
+ get transportState(): EndpointTransportState;
83
+ setPollingHealthy(healthy: boolean): void;
79
84
  get config(): ResolvedTelegramConfig;
80
85
  get allowedUpdates(): readonly string[];
81
86
  getUpdateOffset(): number;
@@ -91,7 +96,7 @@ export declare class TelegramEndpoint extends Endpoint<TelegramClient> {
91
96
  /** Test / internal: admit a callback query when open. */
92
97
  admitCallback(query: TelegramCallbackQuery): void;
93
98
  /** Used by webhook / polling handlers. */
94
- handleUpdate(update: TelegramUpdate): void;
99
+ handleUpdate(update: TelegramUpdate): Promise<void>;
95
100
  callApi<T = unknown>(method: string, params?: Record<string, unknown>, signal?: AbortSignal): Promise<T>;
96
101
  /** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
97
102
  callApiForm<T = unknown>(method: string, form: FormData, signal?: AbortSignal): Promise<T>;
package/lib/endpoint.js CHANGED
@@ -3,21 +3,27 @@ import { Endpoint, createEndpointLifecycle, } from 'zhin.js/adapter';
3
3
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
4
4
  */
5
5
  import { readFile } from 'node:fs/promises';
6
+ import { EndpointDeliveryError, } from '@zhin.js/im-contract';
6
7
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
7
8
  import { runTelegramPollLoop } from './polling.js';
9
+ import { TelegramApiError } from './api-error.js';
10
+ export { TelegramApiError } from './api-error.js';
8
11
  import { normalizeTelegramChatMember } from './platform-permit.js';
9
12
  import { botApiUrl, buildWebhookUrl, formatCallbackContent, formatCallbackSegments, formatInboundContent, formatInboundSegments, formatOutboundPlan, resolveTelegramChannelType, senderDisplayName, telegramInboundConversation, } from './protocol.js';
10
13
  import { registerTelegramWebhookRoutes } from './webhook.js';
11
14
  const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
12
15
  const CHAT_MEMBER_CACHE_MAX = 2_000;
16
+ const ADMITTED_UPDATE_LIMIT = 2_048;
17
+ const INFLIGHT_UPDATE_LIMIT = 256;
18
+ const DEFAULT_API_TIMEOUT_MS = 30_000;
13
19
  /** Telegram Bot API client carried by message, update and lifecycle events. */
14
20
  export class TelegramClient {
15
21
  api;
16
22
  constructor(api) {
17
23
  this.api = api;
18
24
  }
19
- callApi = (method, params = {}) => this.api.callApi(method, params);
20
- callApiForm = (method, form) => this.api.callApiForm(method, form);
25
+ callApi = (method, params = {}, signal) => this.api.callApi(method, params, signal);
26
+ callApiForm = (method, form, signal) => this.api.callApiForm(method, form, signal);
21
27
  pinMessage = (chatId, messageId) => this.api.pinMessage(chatId, messageId);
22
28
  unpinMessage = (chatId, messageId) => this.api.unpinMessage(chatId, messageId);
23
29
  setChatDescription = (chatId, description) => this.api.setChatDescription(chatId, description);
@@ -36,8 +42,8 @@ export class TelegramClient {
36
42
  */
37
43
  export class TelegramEndpoint extends Endpoint {
38
44
  client = new TelegramClient({
39
- callApi: (method, params) => this.callApi(method, params),
40
- callApiForm: (method, form) => this.callApiForm(method, form),
45
+ callApi: (method, params, signal) => this.callApi(method, params, signal),
46
+ callApiForm: (method, form, signal) => this.callApiForm(method, form, signal),
41
47
  pinMessage: (chatId, messageId) => this.pinMessage(chatId, messageId),
42
48
  unpinMessage: (chatId, messageId) => this.unpinMessage(chatId, messageId),
43
49
  setChatDescription: (chatId, description) => this.setChatDescription(chatId, description),
@@ -52,12 +58,16 @@ export class TelegramEndpoint extends Endpoint {
52
58
  #logger;
53
59
  #options;
54
60
  #fetch;
61
+ #apiAbort = new AbortController();
55
62
  #pollAbort;
56
63
  #pollPromise;
57
64
  #lifecycle;
58
65
  #open = false;
59
66
  #admission;
60
67
  #updateOffset = 0;
68
+ #pollingHealthy = true;
69
+ #admittedUpdates = new Set();
70
+ #inflightUpdates = new Map();
61
71
  #botUserId;
62
72
  #botUsername;
63
73
  #chatMemberCache = new Map();
@@ -69,6 +79,9 @@ export class TelegramEndpoint extends Endpoint {
69
79
  });
70
80
  constructor(options) {
71
81
  super();
82
+ if (options.requestTimeoutMs !== undefined && (!Number.isFinite(options.requestTimeoutMs) || options.requestTimeoutMs <= 0)) {
83
+ throw new TypeError('Telegram requestTimeoutMs must be a positive finite number');
84
+ }
72
85
  this.#logger = getAdapterLogger('telegram', options.config.id);
73
86
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
74
87
  this.#options = options;
@@ -78,6 +91,14 @@ export class TelegramEndpoint extends Endpoint {
78
91
  get isOpen() {
79
92
  return this.#open;
80
93
  }
94
+ get transportState() {
95
+ const state = this.#lifecycle.state;
96
+ return state === 'open' && this.#options.config.mode === 'polling' && !this.#pollingHealthy
97
+ ? 'reconnecting' : state;
98
+ }
99
+ setPollingHealthy(healthy) {
100
+ this.#pollingHealthy = healthy;
101
+ }
81
102
  get config() {
82
103
  return this.#options.config;
83
104
  }
@@ -91,6 +112,8 @@ export class TelegramEndpoint extends Endpoint {
91
112
  this.#updateOffset = offset;
92
113
  }
93
114
  async start() {
115
+ if (this.#apiAbort.signal.aborted)
116
+ this.#apiAbort = new AbortController();
94
117
  await this.#lifecycle.start(async (handle) => {
95
118
  const abort = new AbortController();
96
119
  const routes = [];
@@ -177,6 +200,7 @@ export class TelegramEndpoint extends Endpoint {
177
200
  }
178
201
  async stop() {
179
202
  this.close();
203
+ this.#apiAbort.abort();
180
204
  const polling = this.#pollPromise;
181
205
  this.#pollPromise = undefined;
182
206
  await this.#lifecycle.stop();
@@ -189,21 +213,33 @@ export class TelegramEndpoint extends Endpoint {
189
213
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
190
214
  }
191
215
  async send({ conversation, payload }) {
192
- const plan = formatOutboundPlan(conversation.id, payload);
193
216
  let lastId = '';
194
- for (const action of plan.actions) {
195
- const form = await this.#buildUploadForm(action.params, plan.uploads);
196
- const result = form
197
- ? await this.callApiForm(action.method, form)
198
- : await this.callApi(action.method, action.params);
199
- if (!result || !Number.isSafeInteger(result.message_id) || result.message_id <= 0) {
200
- throw new Error(`Telegram API ${action.method} returned no valid message_id; delivery is unconfirmed`);
217
+ let completedActions = 0;
218
+ try {
219
+ const plan = formatOutboundPlan(conversation.id, payload);
220
+ for (const action of plan.actions) {
221
+ const form = await this.#buildUploadForm(action.params, plan.uploads);
222
+ const result = form
223
+ ? await this.callApiForm(action.method, form)
224
+ : await this.callApi(action.method, action.params);
225
+ if (!result || !Number.isSafeInteger(result.message_id) || result.message_id <= 0) {
226
+ throw new EndpointDeliveryError('delivery_unconfirmed', `Telegram API ${action.method} returned no valid message_id; delivery is unconfirmed`, 'unknown');
227
+ }
228
+ lastId = String(result.message_id);
229
+ completedActions += 1;
230
+ }
231
+ if (!lastId)
232
+ throw new EndpointDeliveryError('empty_message', 'Telegram message contains no sendable actions', 'not_sent');
233
+ return lastId;
234
+ }
235
+ catch (error) {
236
+ if (completedActions > 0) {
237
+ throw new EndpointDeliveryError('partial_delivery', `Telegram message failed after ${completedActions} confirmed action(s); complete delivery is unconfirmed`, 'unknown', { cause: error });
201
238
  }
202
- lastId = String(result.message_id);
239
+ if (error instanceof EndpointDeliveryError)
240
+ throw error;
241
+ throw new EndpointDeliveryError('invalid_payload', 'Telegram message could not be prepared', 'not_sent', { cause: error });
203
242
  }
204
- if (!lastId)
205
- throw new Error('Telegram message contains no sendable actions');
206
- return lastId;
207
243
  }
208
244
  async recallMessage(message) {
209
245
  if (!message.id || message.id.startsWith('telegram-'))
@@ -224,37 +260,39 @@ export class TelegramEndpoint extends Endpoint {
224
260
  signal.throwIfAborted();
225
261
  const file = await this.callApi('getFile', {
226
262
  file_id: reference.media.value,
227
- });
263
+ }, signal);
228
264
  if (!file.file_path)
229
265
  return Object.freeze({ status: 'not_found', code: 'telegram_file_not_found' });
230
266
  if ((file.file_size ?? 0) > 26_214_400)
231
267
  return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
232
- const response = await this.#fetch(`${this.#options.config.apiBaseUrl}/file/bot${this.#options.config.token}/${file.file_path}`, { signal });
233
- if (!response.ok)
234
- return Object.freeze({ status: 'failed', code: 'telegram_file_download_failed' });
235
- if (!response.arrayBuffer)
236
- return Object.freeze({ status: 'failed', code: 'telegram_binary_transport_unavailable' });
237
- const bytes = Buffer.from(await response.arrayBuffer());
238
- if (bytes.byteLength > 26_214_400)
239
- return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
240
- return Object.freeze({
241
- status: 'resolved',
242
- reference,
243
- value: Object.freeze({
244
- kind: 'base64',
245
- value: bytes.toString('base64'),
246
- ...(response.headers?.get('content-type')?.split(';')[0] || reference.media.mime_type
247
- ? { mime_type: response.headers?.get('content-type')?.split(';')[0] ?? reference.media.mime_type }
248
- : {}),
249
- ...(reference.media.file_name ? { file_name: reference.media.file_name } : {}),
250
- size: bytes.byteLength,
251
- }),
252
- });
268
+ return await this.#withRequestDeadline('downloadFile', async (downloadSignal) => {
269
+ const response = await this.#fetch(`${this.#options.config.apiBaseUrl}/file/bot${this.#options.config.token}/${file.file_path}`, { signal: downloadSignal });
270
+ if (!response.ok)
271
+ return Object.freeze({ status: 'failed', code: 'telegram_file_download_failed' });
272
+ if (!response.arrayBuffer)
273
+ return Object.freeze({ status: 'failed', code: 'telegram_binary_transport_unavailable' });
274
+ const bytes = Buffer.from(await response.arrayBuffer());
275
+ if (bytes.byteLength > 26_214_400)
276
+ return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
277
+ return Object.freeze({
278
+ status: 'resolved',
279
+ reference,
280
+ value: Object.freeze({
281
+ kind: 'base64',
282
+ value: bytes.toString('base64'),
283
+ ...(response.headers?.get('content-type')?.split(';')[0] || reference.media.mime_type
284
+ ? { mime_type: response.headers?.get('content-type')?.split(';')[0] ?? reference.media.mime_type }
285
+ : {}),
286
+ ...(reference.media.file_name ? { file_name: reference.media.file_name } : {}),
287
+ size: bytes.byteLength,
288
+ }),
289
+ });
290
+ }, signal);
253
291
  }
254
- catch (error) {
292
+ catch {
255
293
  if (signal.aborted)
256
294
  return Object.freeze({ status: 'expired', code: 'turn_aborted' });
257
- return Object.freeze({ status: 'failed', code: 'telegram_file_resolution_failed', message: error instanceof Error ? error.message : String(error) });
295
+ return Object.freeze({ status: 'failed', code: 'telegram_file_resolution_failed', message: 'Telegram file resolution failed' });
258
296
  }
259
297
  }
260
298
  /**
@@ -299,11 +337,10 @@ export class TelegramEndpoint extends Endpoint {
299
337
  }
300
338
  async #admitWithSenderRole(msg, conversation, signal) {
301
339
  const permit = await this.#resolveGroupSenderPermit(msg, signal);
302
- if (signal.aborted)
303
- return;
340
+ signal.throwIfAborted();
304
341
  // 新 Runtime Message.content 为纯文本:@ 本机只能经 metadata 传递
305
342
  const mentioned = this.#isBotMentioned(msg);
306
- await this.emit('message.receive', {
343
+ await this.emitAccepted('message.receive', {
307
344
  conversation,
308
345
  message: { conversation, id: String(msg.message_id) },
309
346
  content: formatInboundContent(msg),
@@ -392,13 +429,21 @@ export class TelegramEndpoint extends Endpoint {
392
429
  admitCallback(query) {
393
430
  if (!this.#open)
394
431
  return;
432
+ void this.#admitCallback(query).catch((err) => {
433
+ this.#logger.warn(formatCompact({
434
+ op: 'telegram_gateway_receive_failed',
435
+ error: err instanceof Error ? err.message : String(err),
436
+ }));
437
+ });
438
+ }
439
+ async #admitCallback(query) {
395
440
  const endpointKey = String(this.#options.id);
396
441
  const msg = query.message;
397
442
  // 无挂载消息的 callback(如 inline 模式)退化为 sender 私聊会话
398
443
  const conversation = msg
399
444
  ? telegramInboundConversation(endpointKey, msg.chat)
400
445
  : telegramInboundConversation(endpointKey, { id: query.from.id, type: 'private' });
401
- void this.emit('message.receive', {
446
+ await this.emitAccepted('message.receive', {
402
447
  conversation,
403
448
  message: { conversation, id: query.id },
404
449
  content: formatCallbackContent(query),
@@ -410,30 +455,58 @@ export class TelegramEndpoint extends Endpoint {
410
455
  payload: query.data,
411
456
  sourceMessageId: msg ? String(msg.message_id) : undefined,
412
457
  }),
413
- }).catch((err) => {
414
- this.#logger.warn(formatCompact({
415
- op: 'telegram_gateway_receive_failed',
416
- target: `${conversation.kind}:${conversation.id}`,
417
- error: err instanceof Error ? err.message : String(err),
418
- }));
419
458
  });
420
459
  }
421
460
  /** Used by webhook / polling handlers. */
422
- handleUpdate(update) {
461
+ async handleUpdate(update) {
462
+ if (!this.#open || !this.#admission)
463
+ throw new Error('Telegram endpoint is not accepting updates');
464
+ if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
465
+ throw new TypeError('Telegram update_id must be a non-negative safe integer');
466
+ }
467
+ if (this.#admittedUpdates.has(update.update_id))
468
+ return;
469
+ const pending = this.#inflightUpdates.get(update.update_id);
470
+ if (pending)
471
+ return pending;
472
+ if (this.#inflightUpdates.size >= INFLIGHT_UPDATE_LIMIT) {
473
+ throw new Error('Telegram update admission capacity exhausted');
474
+ }
475
+ const admission = this.#admission;
476
+ const receiving = (async () => {
477
+ // Once canonical dispatch enters Core, its generation lease drains the work.
478
+ // Closing the transport cannot turn successful business processing into a retry.
479
+ await this.#receiveUpdate(update, admission.signal);
480
+ this.#admittedUpdates.add(update.update_id);
481
+ if (this.#admittedUpdates.size > ADMITTED_UPDATE_LIMIT) {
482
+ this.#admittedUpdates.delete(this.#admittedUpdates.values().next().value);
483
+ }
484
+ })();
485
+ this.#inflightUpdates.set(update.update_id, receiving);
486
+ try {
487
+ await receiving;
488
+ }
489
+ finally {
490
+ this.#inflightUpdates.delete(update.update_id);
491
+ }
492
+ }
493
+ async #receiveUpdate(update, signal) {
423
494
  const eventName = update.message
424
495
  ? 'message'
425
496
  : update.callback_query
426
497
  ? 'callback_query'
427
498
  : 'update';
428
- void this.emitPlatform(eventName, update).catch((error) => {
429
- this.#logger.warn(formatCompact({
430
- op: 'telegram_platform_event_failed',
431
- event: eventName,
432
- error: error instanceof Error ? error.message : String(error),
433
- }));
434
- });
499
+ if (update.message || update.callback_query) {
500
+ await this.emitPlatform(eventName, update);
501
+ }
502
+ else {
503
+ await this.emitAccepted('platform.receive', Object.freeze({ name: eventName, event: update }));
504
+ return;
505
+ }
506
+ signal.throwIfAborted();
435
507
  if (update.message) {
436
- this.admit(update.message);
508
+ const conversation = telegramInboundConversation(String(this.#options.id), update.message.chat);
509
+ await this.#admitWithSenderRole(update.message, conversation, signal);
437
510
  return;
438
511
  }
439
512
  if (update.callback_query) {
@@ -443,28 +516,76 @@ export class TelegramEndpoint extends Endpoint {
443
516
  /* already answered */
444
517
  });
445
518
  }
446
- this.admitCallback(query);
519
+ await this.#admitCallback(query);
447
520
  }
448
521
  }
449
522
  async callApi(method, params = {}, signal) {
450
- const url = botApiUrl(this.#options.config, method);
451
- const response = await this.#fetch(url, {
523
+ return this.#requestApi(method, {
452
524
  method: 'POST',
453
525
  headers: { 'Content-Type': 'application/json' },
454
526
  body: JSON.stringify(params),
455
- signal,
456
- });
457
- return this.#parseApiResponse(method, response);
527
+ }, signal, method === 'getUpdates'
528
+ ? Math.max(this.#options.requestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS, (Number(params.timeout) || 0) * 1_000 + 15_000)
529
+ : undefined);
458
530
  }
459
531
  /** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
460
532
  async callApiForm(method, form, signal) {
461
- const url = botApiUrl(this.#options.config, method);
462
- const response = await this.#fetch(url, {
533
+ return this.#requestApi(method, {
463
534
  method: 'POST',
464
535
  body: form,
465
- signal,
466
- });
467
- return this.#parseApiResponse(method, response);
536
+ }, signal);
537
+ }
538
+ async #requestApi(method, init, signal, timeoutMs = this.#options.requestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS) {
539
+ return this.#withRequestDeadline(method, async (requestSignal) => {
540
+ const response = await this.#fetch(botApiUrl(this.#options.config, method), { ...init, signal: requestSignal });
541
+ return this.#parseApiResponse(method, response);
542
+ }, signal, timeoutMs);
543
+ }
544
+ async #withRequestDeadline(method, requestOperation, signal, timeoutMs = this.#options.requestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS) {
545
+ const abort = new AbortController();
546
+ let issued = false;
547
+ const sources = [...new Set([signal, this.#pollAbort?.signal, this.#apiAbort.signal].filter((item) => !!item))];
548
+ let rejectAbort;
549
+ const cancelled = new Promise((_resolve, reject) => { rejectAbort = reject; });
550
+ const onCancel = () => {
551
+ const error = new TelegramApiError('cancelled', `Telegram API ${method} cancelled`, {
552
+ disposition: issued ? 'unknown' : 'not_sent',
553
+ });
554
+ abort.abort(error);
555
+ rejectAbort(error);
556
+ };
557
+ const timer = setTimeout(() => {
558
+ const error = new TelegramApiError('timeout', `Telegram API ${method} timed out`);
559
+ abort.abort(error);
560
+ rejectAbort(error);
561
+ }, timeoutMs);
562
+ for (const source of sources)
563
+ source.addEventListener('abort', onCancel, { once: true });
564
+ try {
565
+ if (sources.some((source) => source.aborted))
566
+ onCancel();
567
+ const request = Promise.resolve().then(async () => {
568
+ abort.signal.throwIfAborted();
569
+ issued = true;
570
+ return requestOperation(abort.signal);
571
+ });
572
+ return await Promise.race([request, cancelled]);
573
+ }
574
+ catch (error) {
575
+ if (abort.signal.aborted)
576
+ throw abort.signal.reason;
577
+ if (error instanceof TelegramApiError)
578
+ throw error;
579
+ // Network exceptions can contain credential-bearing URLs. Keep those out of errors/logs.
580
+ const detail = error instanceof Error
581
+ ? error.message.replaceAll(this.#options.config.token, '[redacted]') : 'transport failure';
582
+ throw new TelegramApiError('network', `Telegram API ${method} network request failed: ${detail}`);
583
+ }
584
+ finally {
585
+ clearTimeout(timer);
586
+ for (const source of sources)
587
+ source.removeEventListener('abort', onCancel);
588
+ }
468
589
  }
469
590
  async #parseApiResponse(method, response) {
470
591
  const text = await response.text();
@@ -473,11 +594,21 @@ export class TelegramEndpoint extends Endpoint {
473
594
  body = JSON.parse(text);
474
595
  }
475
596
  catch {
476
- throw new Error(`Telegram API ${method} invalid JSON (${response.status}): ${text.slice(0, 200)}`);
597
+ throw new TelegramApiError('invalid_response', `Telegram API ${method} invalid JSON (${response.status})`, { status: response.status });
598
+ }
599
+ if (!body || typeof body !== 'object' || typeof body.ok !== 'boolean') {
600
+ throw new TelegramApiError('invalid_response', `Telegram API ${method} invalid response envelope`, { status: response.status });
477
601
  }
478
- if (!body.ok) {
479
- throw new Error(`Telegram API ${method} failed (${body.error_code ?? response.status}): ${body.description ?? text}`);
602
+ if (!body.ok || response.status < 200 || response.status >= 300) {
603
+ const status = !body.ok ? body.error_code ?? response.status : response.status;
604
+ const kind = status === 401 || status === 403 ? 'authentication'
605
+ : status === 429 ? 'rate_limit' : response.status >= 400 ? 'http' : 'api';
606
+ const description = !body.ok ? body.description?.replaceAll(this.#options.config.token, '[redacted]') : undefined;
607
+ throw new TelegramApiError(kind, `Telegram API ${method} failed (${status}): ${description ?? 'request rejected'}`, { status, disposition: !body.ok ? 'rejected' : 'unknown', ...(!body.ok && body.parameters?.retry_after != null
608
+ ? { retryAfterSeconds: body.parameters.retry_after } : {}) });
480
609
  }
610
+ if (!('result' in body))
611
+ throw new TelegramApiError('invalid_response', `Telegram API ${method} missing result`);
481
612
  return body.result;
482
613
  }
483
614
  // ── Agent tool surface ──────────────────────────────────────────────
package/lib/polling.d.ts CHANGED
@@ -4,6 +4,7 @@ export interface TelegramPollingHost {
4
4
  callApi<T>(method: string, params?: Record<string, unknown>, signal?: AbortSignal): Promise<T>;
5
5
  getUpdateOffset(): number;
6
6
  setUpdateOffset(offset: number): void;
7
- handleUpdate(update: TelegramUpdate): void;
7
+ handleUpdate(update: TelegramUpdate): void | Promise<void>;
8
+ setPollingHealthy?(healthy: boolean): void;
8
9
  }
9
10
  export declare function runTelegramPollLoop(host: TelegramPollingHost, abortSignal: AbortSignal): Promise<void>;
package/lib/polling.js CHANGED
@@ -16,18 +16,23 @@ export async function runTelegramPollLoop(host, abortSignal) {
16
16
  timeout: DEFAULT_POLL_TIMEOUT_SEC,
17
17
  allowed_updates: host.allowedUpdates,
18
18
  }, abortSignal);
19
- consecutiveFailures = 0;
19
+ host.setPollingHealthy?.(true);
20
20
  for (const update of updates) {
21
21
  // A transport may resolve after cancellation; never admit that late batch.
22
22
  if (abortSignal.aborted)
23
23
  return;
24
+ if (update.update_id < host.getUpdateOffset())
25
+ continue;
26
+ await host.handleUpdate(update);
27
+ // Acknowledgement follows successful framework admission, never receipt.
24
28
  host.setUpdateOffset(update.update_id + 1);
25
- host.handleUpdate(update);
26
29
  }
30
+ consecutiveFailures = 0;
27
31
  }
28
32
  catch (err) {
29
33
  if (abortSignal.aborted)
30
34
  return;
35
+ host.setPollingHealthy?.(false);
31
36
  consecutiveFailures += 1;
32
37
  logger.error(formatCompact({
33
38
  op: 'poll',