@zhin.js/adapter-telegram 1.1.17 → 1.1.19

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/lib/endpoint.js CHANGED
@@ -1,4 +1,5 @@
1
- import { Endpoint, createEndpointLifecycle, } from 'zhin.js/adapter';
1
+ import { TelegramClient } from './telegram-client.js';
2
+ import { createEndpointSpecification, endpointSpec, createEndpointLifecycle, } from 'zhin.js/adapter';
2
3
  /**
3
4
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
4
5
  */
@@ -6,64 +7,30 @@ import { readFile } from 'node:fs/promises';
6
7
  import { EndpointDeliveryError, } from '@zhin.js/im-contract';
7
8
  import { formatCompact, getAdapterLogger } from '@zhin.js/logger';
8
9
  import { runTelegramPollLoop } from './polling.js';
9
- import { TelegramApiError } from './api-error.js';
10
10
  export { TelegramApiError } from './api-error.js';
11
11
  import { normalizeTelegramChatMember } from './platform-permit.js';
12
- import { botApiUrl, buildWebhookUrl, formatCallbackContent, formatCallbackSegments, formatInboundContent, formatInboundSegments, formatOutboundPlan, resolveTelegramChannelType, senderDisplayName, telegramInboundConversation, } from './protocol.js';
12
+ import { buildWebhookUrl, formatCallbackContent, formatCallbackSegments, formatInboundContent, formatInboundSegments, formatOutboundPlan, resolveTelegramChannelType, senderDisplayName, telegramInboundConversation, } from './protocol.js';
13
13
  import { registerTelegramWebhookRoutes } from './webhook.js';
14
14
  const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
15
15
  const CHAT_MEMBER_CACHE_MAX = 2_000;
16
16
  const ADMITTED_UPDATE_LIMIT = 2_048;
17
17
  const INFLIGHT_UPDATE_LIMIT = 256;
18
- const DEFAULT_API_TIMEOUT_MS = 30_000;
19
- /** Telegram Bot API client carried by message, update and lifecycle events. */
20
- export class TelegramClient {
21
- api;
22
- constructor(api) {
23
- this.api = api;
24
- }
25
- callApi = (method, params = {}, signal) => this.api.callApi(method, params, signal);
26
- callApiForm = (method, form, signal) => this.api.callApiForm(method, form, signal);
27
- pinMessage = (chatId, messageId) => this.api.pinMessage(chatId, messageId);
28
- unpinMessage = (chatId, messageId) => this.api.unpinMessage(chatId, messageId);
29
- setChatDescription = (chatId, description) => this.api.setChatDescription(chatId, description);
30
- setMessageReaction = (chatId, messageId, reaction) => this.api.setMessageReaction(chatId, messageId, reaction);
31
- getChatMemberCount = (chatId) => this.api.getChatMemberCount(chatId);
32
- getChatAdmins = (chatId) => this.api.getChatAdmins(chatId);
33
- sendStickerMessage = (chatId, sticker) => this.api.sendStickerMessage(chatId, sticker);
34
- setChatPermissionsAll = (chatId, permissions) => this.api.setChatPermissionsAll(chatId, permissions);
35
- createInviteLink = (chatId) => this.api.createInviteLink(chatId);
36
- sendPoll = (chatId, question, options, anonymous, multiple) => this.api.sendPoll(chatId, question, options, anonymous, multiple);
37
- }
18
+ export { TelegramClient } from './telegram-client.js';
38
19
  /**
39
20
  * Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
40
21
  * 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
41
22
  * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
42
23
  */
43
- export class TelegramEndpoint extends Endpoint {
44
- client = new TelegramClient({
45
- callApi: (method, params, signal) => this.callApi(method, params, signal),
46
- callApiForm: (method, form, signal) => this.callApiForm(method, form, signal),
47
- pinMessage: (chatId, messageId) => this.pinMessage(chatId, messageId),
48
- unpinMessage: (chatId, messageId) => this.unpinMessage(chatId, messageId),
49
- setChatDescription: (chatId, description) => this.setChatDescription(chatId, description),
50
- setMessageReaction: (chatId, messageId, reaction) => this.setMessageReaction(chatId, messageId, reaction),
51
- getChatMemberCount: (chatId) => this.getChatMemberCount(chatId),
52
- getChatAdmins: (chatId) => this.getChatAdmins(chatId),
53
- sendStickerMessage: (chatId, sticker) => this.sendStickerMessage(chatId, sticker),
54
- setChatPermissionsAll: (chatId, permissions) => this.setChatPermissionsAll(chatId, permissions),
55
- createInviteLink: (chatId) => this.createInviteLink(chatId),
56
- sendPoll: (chatId, question, options, anonymous, multiple) => this.sendPoll(chatId, question, options, anonymous, multiple),
57
- });
24
+ export class TelegramEndpoint extends TelegramClient {
25
+ [endpointSpec];
58
26
  #logger;
59
27
  #options;
60
- #fetch;
61
- #apiAbort = new AbortController();
62
28
  #pollAbort;
63
29
  #pollPromise;
64
30
  #lifecycle;
65
31
  #open = false;
66
32
  #admission;
33
+ #executionSignal;
67
34
  #updateOffset = 0;
68
35
  #pollingHealthy = true;
69
36
  #admittedUpdates = new Set();
@@ -71,27 +38,30 @@ export class TelegramEndpoint extends Endpoint {
71
38
  #botUserId;
72
39
  #botUsername;
73
40
  #chatMemberCache = new Map();
74
- control = Object.freeze({
75
- recall: (message) => this.recallMessage(message),
41
+ #control = Object.freeze({
42
+ recall: ({ message, signal }) => this.recallMessage(message, signal),
76
43
  });
77
- content = Object.freeze({
44
+ #content = Object.freeze({
78
45
  resolve: (reference, context) => this.#resolveContent(reference, context.signal),
79
46
  });
80
47
  constructor(options) {
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
- }
48
+ super(options);
85
49
  this.#logger = getAdapterLogger('telegram', options.config.id);
86
50
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
87
51
  this.#options = options;
88
- this.#fetch = options.fetch ?? globalThis.fetch;
52
+ const endpoint = this;
53
+ this[endpointSpec] = createEndpointSpecification(this, {
54
+ start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
55
+ stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
56
+ get name() { return options.config.id; }, get transportState() { return endpoint.#transportState; },
57
+ control: this.#control, content: this.#content,
58
+ });
89
59
  }
90
60
  /** Used by webhook handler. */
91
61
  get isOpen() {
92
62
  return this.#open;
93
63
  }
94
- get transportState() {
64
+ get #transportState() {
95
65
  const state = this.#lifecycle.state;
96
66
  return state === 'open' && this.#options.config.mode === 'polling' && !this.#pollingHealthy
97
67
  ? 'reconnecting' : state;
@@ -111,13 +81,14 @@ export class TelegramEndpoint extends Endpoint {
111
81
  setUpdateOffset(offset) {
112
82
  this.#updateOffset = offset;
113
83
  }
114
- async start() {
115
- if (this.#apiAbort.signal.aborted)
116
- this.#apiAbort = new AbortController();
84
+ async #startFramework(signal) {
85
+ this.#executionSignal = signal;
86
+ this.restartRequests();
117
87
  await this.#lifecycle.start(async (handle) => {
118
88
  const abort = new AbortController();
119
89
  const routes = [];
120
90
  this.#pollAbort = abort;
91
+ this.requestSignal = AbortSignal.any([abort.signal, signal]);
121
92
  const cleanup = () => {
122
93
  abort.abort();
123
94
  for (const release of routes.splice(0))
@@ -187,20 +158,21 @@ export class TelegramEndpoint extends Endpoint {
187
158
  }
188
159
  });
189
160
  }
190
- open() {
161
+ #openFramework() {
191
162
  if (this.#open)
192
163
  return;
193
164
  this.#admission = new AbortController();
165
+ this.#chatMemberCache.clear();
194
166
  this.#open = true;
195
167
  }
196
- close() {
168
+ #closeFramework() {
197
169
  this.#open = false;
198
- this.#admission?.abort();
199
170
  this.#chatMemberCache.clear();
200
171
  }
201
- async stop() {
202
- this.close();
203
- this.#apiAbort.abort();
172
+ async #stopFramework() {
173
+ this.#admission?.abort();
174
+ this.#closeFramework();
175
+ this.cancelRequests();
204
176
  const polling = this.#pollPromise;
205
177
  this.#pollPromise = undefined;
206
178
  await this.#lifecycle.stop();
@@ -212,16 +184,18 @@ export class TelegramEndpoint extends Endpoint {
212
184
  }
213
185
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
214
186
  }
215
- async send({ conversation, payload }) {
187
+ async #sendFramework({ conversation, payload, signal }) {
216
188
  let lastId = '';
217
189
  let completedActions = 0;
218
190
  try {
219
191
  const plan = formatOutboundPlan(conversation.id, payload);
220
192
  for (const action of plan.actions) {
221
- const form = await this.#buildUploadForm(action.params, plan.uploads);
193
+ signal?.throwIfAborted();
194
+ const form = await this.#buildUploadForm(action.params, plan.uploads, signal);
195
+ signal?.throwIfAborted();
222
196
  const result = form
223
- ? await this.callApiForm(action.method, form)
224
- : await this.callApi(action.method, action.params);
197
+ ? await this.callApiForm(action.method, form, signal)
198
+ : await this.callApi(action.method, action.params, signal);
225
199
  if (!result || !Number.isSafeInteger(result.message_id) || result.message_id <= 0) {
226
200
  throw new EndpointDeliveryError('delivery_unconfirmed', `Telegram API ${action.method} returned no valid message_id; delivery is unconfirmed`, 'unknown');
227
201
  }
@@ -241,13 +215,13 @@ export class TelegramEndpoint extends Endpoint {
241
215
  throw new EndpointDeliveryError('invalid_payload', 'Telegram message could not be prepared', 'not_sent', { cause: error });
242
216
  }
243
217
  }
244
- async recallMessage(message) {
218
+ async recallMessage(message, signal) {
245
219
  if (!message.id || message.id.startsWith('telegram-'))
246
220
  return;
247
221
  await this.callApi('deleteMessage', {
248
222
  chat_id: message.conversation.id,
249
223
  message_id: Number(message.id),
250
- });
224
+ }, signal);
251
225
  }
252
226
  async #resolveContent(reference, signal) {
253
227
  if (reference.kind !== 'media') {
@@ -265,29 +239,29 @@ export class TelegramEndpoint extends Endpoint {
265
239
  return Object.freeze({ status: 'not_found', code: 'telegram_file_not_found' });
266
240
  if ((file.file_size ?? 0) > 26_214_400)
267
241
  return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
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);
242
+ return await this.withRequestDeadline({ method: 'downloadFile', run: async (downloadSignal) => {
243
+ const response = await this.fetchTransport(`${this.#options.config.apiBaseUrl}/file/bot${this.#options.config.token}/${file.file_path}`, { signal: downloadSignal });
244
+ if (!response.ok)
245
+ return Object.freeze({ status: 'failed', code: 'telegram_file_download_failed' });
246
+ if (!response.arrayBuffer)
247
+ return Object.freeze({ status: 'failed', code: 'telegram_binary_transport_unavailable' });
248
+ const bytes = Buffer.from(await response.arrayBuffer());
249
+ if (bytes.byteLength > 26_214_400)
250
+ return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
251
+ return Object.freeze({
252
+ status: 'resolved',
253
+ reference,
254
+ value: Object.freeze({
255
+ kind: 'base64',
256
+ value: bytes.toString('base64'),
257
+ ...(response.headers?.get('content-type')?.split(';')[0] || reference.media.mime_type
258
+ ? { mime_type: response.headers?.get('content-type')?.split(';')[0] ?? reference.media.mime_type }
259
+ : {}),
260
+ ...(reference.media.file_name ? { file_name: reference.media.file_name } : {}),
261
+ size: bytes.byteLength,
262
+ }),
263
+ });
264
+ }, signal });
291
265
  }
292
266
  catch {
293
267
  if (signal.aborted)
@@ -300,7 +274,7 @@ export class TelegramEndpoint extends Endpoint {
300
274
  * 标量参数原样、对象参数 JSON 序列化、attach 占位替换为文件 part
301
275
  * (base64 直接解码,本地路径读盘)。无上传时返回 undefined(走 JSON 调用)。
302
276
  */
303
- async #buildUploadForm(params, uploads) {
277
+ async #buildUploadForm(params, uploads, signal) {
304
278
  const values = Object.values(params);
305
279
  if (!values.some((v) => typeof v === 'string' && v.startsWith('attach://')))
306
280
  return undefined;
@@ -314,7 +288,7 @@ export class TelegramEndpoint extends Endpoint {
314
288
  throw new Error(`Telegram upload 未登记: ${value}`);
315
289
  const data = upload.source.kind === 'base64'
316
290
  ? Buffer.from(upload.source.data, 'base64')
317
- : await readFile(upload.source.path);
291
+ : await readFile(upload.source.path, { signal });
318
292
  form.append(key, new Blob([data], upload.mimeType ? { type: upload.mimeType } : undefined), upload.filename);
319
293
  continue;
320
294
  }
@@ -324,23 +298,27 @@ export class TelegramEndpoint extends Endpoint {
324
298
  }
325
299
  /** Test / internal: admit a message when open. */
326
300
  admit(msg) {
327
- if (!this.#open || !this.#admission)
328
- return;
329
- const conversation = telegramInboundConversation(String(this.#options.id), msg.chat);
330
- void this.#admitWithSenderRole(msg, conversation, this.#admission.signal).catch((err) => {
331
- this.#logger.warn(formatCompact({
332
- op: 'telegram_gateway_receive_failed',
333
- target: `${conversation.kind}:${conversation.id}`,
334
- error: err instanceof Error ? err.message : String(err),
335
- }));
336
- });
301
+ void this[endpointSpec].events.run(() => {
302
+ if (!this.#admission)
303
+ return;
304
+ const conversation = telegramInboundConversation(String(this.#options.id), msg.chat);
305
+ return this.#admitWithSenderRole({ message: msg, conversation, signal: AbortSignal.any([this.#admission.signal, this.#executionSignal]) }).catch((err) => {
306
+ this.#logger.warn(formatCompact({
307
+ op: 'telegram_gateway_receive_failed',
308
+ target: `${conversation.kind}:${conversation.id}`,
309
+ error: err instanceof Error ? err.message : String(err),
310
+ }));
311
+ });
312
+ }).catch(() => this.#logger.debug('Inbound callback was not admitted'));
337
313
  }
338
- async #admitWithSenderRole(msg, conversation, signal) {
314
+ async #admitWithSenderRole({ message: msg, conversation, signal, event = msg }) {
339
315
  const permit = await this.#resolveGroupSenderPermit(msg, signal);
340
316
  signal.throwIfAborted();
341
317
  // 新 Runtime Message.content 为纯文本:@ 本机只能经 metadata 传递
342
318
  const mentioned = this.#isBotMentioned(msg);
343
- await this.emitAccepted('message.receive', {
319
+ await this[endpointSpec].events.emitAccepted('message.receive', {
320
+ event,
321
+ ...(typeof msg.date === 'number' ? { platformSentAt: msg.date * 1_000 } : {}),
344
322
  conversation,
345
323
  message: { conversation, id: String(msg.message_id) },
346
324
  content: formatInboundContent(msg),
@@ -427,23 +405,24 @@ export class TelegramEndpoint extends Endpoint {
427
405
  }
428
406
  /** Test / internal: admit a callback query when open. */
429
407
  admitCallback(query) {
430
- if (!this.#open)
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
- });
408
+ void this[endpointSpec].events.run(() => {
409
+ return this.#admitCallback(query).catch((err) => {
410
+ this.#logger.warn(formatCompact({
411
+ op: 'telegram_gateway_receive_failed',
412
+ error: err instanceof Error ? err.message : String(err),
413
+ }));
414
+ });
415
+ }).catch(() => this.#logger.debug('Inbound callback was not admitted'));
438
416
  }
439
- async #admitCallback(query) {
417
+ async #admitCallback(query, event = query) {
440
418
  const endpointKey = String(this.#options.id);
441
419
  const msg = query.message;
442
420
  // 无挂载消息的 callback(如 inline 模式)退化为 sender 私聊会话
443
421
  const conversation = msg
444
422
  ? telegramInboundConversation(endpointKey, msg.chat)
445
423
  : telegramInboundConversation(endpointKey, { id: query.from.id, type: 'private' });
446
- await this.emitAccepted('message.receive', {
424
+ await this[endpointSpec].events.emitAccepted('message.receive', {
425
+ event,
447
426
  conversation,
448
427
  message: { conversation, id: query.id },
449
428
  content: formatCallbackContent(query),
@@ -459,36 +438,38 @@ export class TelegramEndpoint extends Endpoint {
459
438
  }
460
439
  /** Used by webhook / polling handlers. */
461
440
  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);
441
+ return this[endpointSpec].events.run(async () => {
442
+ if (!this.#admission)
443
+ throw new Error('Telegram endpoint is not accepting updates');
444
+ if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
445
+ throw new TypeError('Telegram update_id must be a non-negative safe integer');
483
446
  }
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
- }
447
+ if (this.#admittedUpdates.has(update.update_id))
448
+ return;
449
+ const pending = this.#inflightUpdates.get(update.update_id);
450
+ if (pending)
451
+ return pending;
452
+ if (this.#inflightUpdates.size >= INFLIGHT_UPDATE_LIMIT) {
453
+ throw new Error('Telegram update admission capacity exhausted');
454
+ }
455
+ const admission = this.#admission;
456
+ const receiving = (async () => {
457
+ // Once canonical dispatch enters Core, its generation lease drains the work.
458
+ // Closing the transport cannot turn successful business processing into a retry.
459
+ await this.#receiveUpdate(update, AbortSignal.any([admission.signal, this.#executionSignal]));
460
+ this.#admittedUpdates.add(update.update_id);
461
+ if (this.#admittedUpdates.size > ADMITTED_UPDATE_LIMIT) {
462
+ this.#admittedUpdates.delete(this.#admittedUpdates.values().next().value);
463
+ }
464
+ })();
465
+ this.#inflightUpdates.set(update.update_id, receiving);
466
+ try {
467
+ await receiving;
468
+ }
469
+ finally {
470
+ this.#inflightUpdates.delete(update.update_id);
471
+ }
472
+ });
492
473
  }
493
474
  async #receiveUpdate(update, signal) {
494
475
  const eventName = update.message
@@ -497,170 +478,26 @@ export class TelegramEndpoint extends Endpoint {
497
478
  ? 'callback_query'
498
479
  : 'update';
499
480
  if (update.message || update.callback_query) {
500
- await this.emitPlatform(eventName, update);
481
+ await this[endpointSpec].events.platform(eventName, update);
501
482
  }
502
483
  else {
503
- await this.emitAccepted('platform.receive', Object.freeze({ name: eventName, event: update }));
484
+ await this[endpointSpec].events.emitAccepted('platform.receive', Object.freeze({ name: eventName, event: update }));
504
485
  return;
505
486
  }
506
487
  signal.throwIfAborted();
507
488
  if (update.message) {
508
489
  const conversation = telegramInboundConversation(String(this.#options.id), update.message.chat);
509
- await this.#admitWithSenderRole(update.message, conversation, signal);
490
+ await this.#admitWithSenderRole({ message: update.message, conversation, signal, event: update });
510
491
  return;
511
492
  }
512
493
  if (update.callback_query) {
513
494
  const query = update.callback_query;
514
495
  if (query.data) {
515
- void this.callApi('answerCallbackQuery', { callback_query_id: query.id }).catch(() => {
496
+ void this.callApi('answerCallbackQuery', { callback_query_id: query.id }, signal).catch(() => {
516
497
  /* already answered */
517
498
  });
518
499
  }
519
- await this.#admitCallback(query);
500
+ await this.#admitCallback(query, update);
520
501
  }
521
502
  }
522
- async callApi(method, params = {}, signal) {
523
- return this.#requestApi(method, {
524
- method: 'POST',
525
- headers: { 'Content-Type': 'application/json' },
526
- body: JSON.stringify(params),
527
- }, signal, method === 'getUpdates'
528
- ? Math.max(this.#options.requestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS, (Number(params.timeout) || 0) * 1_000 + 15_000)
529
- : undefined);
530
- }
531
- /** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
532
- async callApiForm(method, form, signal) {
533
- return this.#requestApi(method, {
534
- method: 'POST',
535
- body: form,
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
- }
589
- }
590
- async #parseApiResponse(method, response) {
591
- const text = await response.text();
592
- let body;
593
- try {
594
- body = JSON.parse(text);
595
- }
596
- catch {
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 });
601
- }
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 } : {}) });
609
- }
610
- if (!('result' in body))
611
- throw new TelegramApiError('invalid_response', `Telegram API ${method} missing result`);
612
- return body.result;
613
- }
614
- // ── Agent tool surface ──────────────────────────────────────────────
615
- async pinMessage(chatId, messageId) {
616
- await this.callApi('pinChatMessage', { chat_id: chatId, message_id: messageId });
617
- return true;
618
- }
619
- async unpinMessage(chatId, messageId) {
620
- if (messageId != null) {
621
- await this.callApi('unpinChatMessage', { chat_id: chatId, message_id: messageId });
622
- }
623
- else {
624
- await this.callApi('unpinAllChatMessages', { chat_id: chatId });
625
- }
626
- return true;
627
- }
628
- async setChatDescription(chatId, description) {
629
- await this.callApi('setChatDescription', { chat_id: chatId, description });
630
- return true;
631
- }
632
- async setMessageReaction(chatId, messageId, reaction) {
633
- await this.callApi('setMessageReaction', {
634
- chat_id: chatId,
635
- message_id: messageId,
636
- reaction: [{ type: 'emoji', emoji: reaction }],
637
- });
638
- return true;
639
- }
640
- async getChatMemberCount(chatId) {
641
- return this.callApi('getChatMemberCount', { chat_id: chatId });
642
- }
643
- async getChatAdmins(chatId) {
644
- return this.callApi('getChatAdministrators', { chat_id: chatId });
645
- }
646
- async sendStickerMessage(chatId, sticker) {
647
- return this.callApi('sendSticker', { chat_id: chatId, sticker });
648
- }
649
- async setChatPermissionsAll(chatId, permissions) {
650
- await this.callApi('setChatPermissions', { chat_id: chatId, permissions });
651
- return true;
652
- }
653
- async createInviteLink(chatId) {
654
- const link = await this.callApi('createChatInviteLink', { chat_id: chatId });
655
- return link.invite_link;
656
- }
657
- async sendPoll(chatId, question, options, isAnonymous = true, allowsMultipleAnswers = false) {
658
- return this.callApi('sendPoll', {
659
- chat_id: chatId,
660
- question,
661
- options,
662
- is_anonymous: isAnonymous,
663
- allows_multiple_answers: allowsMultipleAnswers,
664
- });
665
- }
666
503
  }
package/lib/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  export { TelegramClient, TelegramEndpoint, type TelegramClientApi, type TelegramEndpointOptions, type TelegramFetch, } from './endpoint.js';
2
2
  export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, formatOutboundPlan, normalizeWebhookPath, resolveTelegramConfig, senderDisplayName, type ResolvedTelegramConfig, type TelegramEndpointConfig, type TelegramCallbackQuery, type TelegramChat, type TelegramChatMember, type TelegramMessage, type TelegramOutboundAction, type TelegramOutboundPlan, type TelegramOutboundUpload, type TelegramUpdate, type TelegramUser, type TelegramWireSegment, } from './protocol.js';
3
- export { telegramClient, type TelegramClientEventMap } from './client.js';
3
+ export { type TelegramClientEventMap } from './client.js';
4
+ export { TelegramAdapter } from './adapter.js';
4
5
  export { checkTelegramPlatformPermit, normalizeTelegramChatMember, platformPermit, telegramGroupPermitResolver, } from './platform-permit.js';
6
+ export type { TelegramPollRequest } from './telegram-client.js';
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { TelegramClient, TelegramEndpoint, } from './endpoint.js';
2
2
  export { botApiUrl, formatCallbackContent, formatInboundContent, formatOutboundActions, formatOutboundPlan, normalizeWebhookPath, resolveTelegramConfig, senderDisplayName, } from './protocol.js';
3
- export { telegramClient } from './client.js';
3
+ export { TelegramAdapter } from './adapter.js';
4
4
  export { checkTelegramPlatformPermit, normalizeTelegramChatMember, platformPermit, telegramGroupPermitResolver, } from './platform-permit.js';