@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/src/endpoint.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { TelegramClient, type TelegramFetch } from './telegram-client.js';
1
2
  import {
2
- Endpoint, createEndpointLifecycle,
3
+ createEndpointSpecification, endpointSpec, type Endpoint, type EndpointSpecification, createEndpointLifecycle,
3
4
  type EndpointLifecycle, type EndpointContentPort, type EndpointContentResolveContext,
4
5
  type EndpointControl, type EndpointSendRequest, type EndpointTransportState,
5
6
  } from 'zhin.js/adapter';
@@ -22,7 +23,6 @@ import { TelegramApiError } from './api-error.js';
22
23
  export { TelegramApiError } from './api-error.js';
23
24
  import { normalizeTelegramChatMember } from './platform-permit.js';
24
25
  import {
25
- botApiUrl,
26
26
  buildWebhookUrl,
27
27
  formatCallbackContent,
28
28
  formatCallbackSegments,
@@ -45,7 +45,6 @@ const CHAT_MEMBER_CACHE_TTL_MS = 60_000;
45
45
  const CHAT_MEMBER_CACHE_MAX = 2_000;
46
46
  const ADMITTED_UPDATE_LIMIT = 2_048;
47
47
  const INFLIGHT_UPDATE_LIMIT = 256;
48
- const DEFAULT_API_TIMEOUT_MS = 30_000;
49
48
 
50
49
  interface ChatMemberPermit {
51
50
  readonly at: number;
@@ -53,22 +52,13 @@ interface ChatMemberPermit {
53
52
  readonly permissions: string[];
54
53
  }
55
54
 
56
- export type TelegramFetch = (
57
- url: string,
58
- init?: {
59
- readonly method?: string;
60
- readonly headers?: Record<string, string>;
61
- readonly body?: string | FormData;
62
- readonly signal?: AbortSignal;
63
- },
64
- ) => Promise<{
65
- readonly ok: boolean;
66
- readonly status: number;
67
- text(): Promise<string>;
68
- json(): Promise<unknown>;
69
- arrayBuffer?(): Promise<ArrayBuffer>;
70
- readonly headers?: { get(name: string): string | null };
71
- }>;
55
+ /** One admitted platform message and its original framework lifetime. */
56
+ interface TelegramMessageAdmission {
57
+ readonly message: TelegramMessage;
58
+ readonly conversation: ConversationRef;
59
+ readonly signal: AbortSignal;
60
+ readonly event?: unknown;
61
+ }
72
62
 
73
63
  export interface TelegramEndpointOptions {
74
64
  readonly id: CapabilityId;
@@ -79,101 +69,24 @@ export interface TelegramEndpointOptions {
79
69
  readonly requestTimeoutMs?: number;
80
70
  }
81
71
 
82
- export interface TelegramClientApi {
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>;
85
- pinMessage(chatId: number, messageId: number): Promise<boolean>;
86
- unpinMessage(chatId: number, messageId?: number): Promise<boolean>;
87
- setChatDescription(chatId: number, description: string): Promise<boolean>;
88
- setMessageReaction(chatId: number, messageId: number, reaction: string): Promise<boolean>;
89
- getChatMemberCount(chatId: number): Promise<number>;
90
- getChatAdmins(chatId: number): Promise<TelegramChatMember[]>;
91
- sendStickerMessage(chatId: number, sticker: string): Promise<{ message_id: number }>;
92
- setChatPermissionsAll(chatId: number, permissions: Record<string, boolean | undefined>): Promise<boolean>;
93
- createInviteLink(chatId: number): Promise<string>;
94
- sendPoll(
95
- chatId: number,
96
- question: string,
97
- options: string[],
98
- isAnonymous?: boolean,
99
- allowsMultipleAnswers?: boolean,
100
- ): Promise<{ message_id: number }>;
101
- }
102
-
103
- /** Telegram Bot API client carried by message, update and lifecycle events. */
104
- export class TelegramClient implements TelegramClientApi {
105
- constructor(readonly api: TelegramClientApi) {}
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);
110
- pinMessage = (chatId: number, messageId: number) => this.api.pinMessage(chatId, messageId);
111
- unpinMessage = (chatId: number, messageId?: number) => this.api.unpinMessage(chatId, messageId);
112
- setChatDescription = (chatId: number, description: string) =>
113
- this.api.setChatDescription(chatId, description);
114
- setMessageReaction = (chatId: number, messageId: number, reaction: string) =>
115
- this.api.setMessageReaction(chatId, messageId, reaction);
116
- getChatMemberCount = (chatId: number) => this.api.getChatMemberCount(chatId);
117
- getChatAdmins = (chatId: number) => this.api.getChatAdmins(chatId);
118
- sendStickerMessage = (chatId: number, sticker: string) =>
119
- this.api.sendStickerMessage(chatId, sticker);
120
- setChatPermissionsAll = (chatId: number, permissions: Record<string, boolean | undefined>) =>
121
- this.api.setChatPermissionsAll(chatId, permissions);
122
- createInviteLink = (chatId: number) => this.api.createInviteLink(chatId);
123
- sendPoll = (
124
- chatId: number,
125
- question: string,
126
- options: string[],
127
- anonymous?: boolean,
128
- multiple?: boolean,
129
- ) => this.api.sendPoll(chatId, question, options, anonymous, multiple);
130
- }
131
-
132
- interface TelegramApiOk<T> {
133
- readonly ok: true;
134
- readonly result: T;
135
- }
136
-
137
- interface TelegramApiErr {
138
- readonly ok: false;
139
- readonly description?: string;
140
- readonly error_code?: number;
141
- readonly parameters?: { readonly retry_after?: number };
142
- }
72
+ export { TelegramClient, type TelegramClientApi, type TelegramFetch } from './telegram-client.js';
143
73
 
144
74
  /**
145
75
  * Telegram Bot API 无列表类接口(无 getMyChats/getChatMembers),
146
76
  * 仅 getChat/getChatMember 按已知 id 单查,不构成列表能力;
147
77
  * 因此本 endpoint 不暴露 EndpointManagement(Console 社交面 RPC 对该平台保持未接线)。
148
78
  */
149
- export class TelegramEndpoint extends Endpoint<TelegramClient> {
150
- readonly client = new TelegramClient({
151
- callApi: (method, params, signal) => this.callApi(method, params, signal),
152
- callApiForm: (method, form, signal) => this.callApiForm(method, form, signal),
153
- pinMessage: (chatId, messageId) => this.pinMessage(chatId, messageId),
154
- unpinMessage: (chatId, messageId) => this.unpinMessage(chatId, messageId),
155
- setChatDescription: (chatId, description) => this.setChatDescription(chatId, description),
156
- setMessageReaction: (chatId, messageId, reaction) =>
157
- this.setMessageReaction(chatId, messageId, reaction),
158
- getChatMemberCount: (chatId) => this.getChatMemberCount(chatId),
159
- getChatAdmins: (chatId) => this.getChatAdmins(chatId),
160
- sendStickerMessage: (chatId, sticker) => this.sendStickerMessage(chatId, sticker),
161
- setChatPermissionsAll: (chatId, permissions) =>
162
- this.setChatPermissionsAll(chatId, permissions),
163
- createInviteLink: (chatId) => this.createInviteLink(chatId),
164
- sendPoll: (chatId, question, options, anonymous, multiple) =>
165
- this.sendPoll(chatId, question, options, anonymous, multiple),
166
- });
79
+ export class TelegramEndpoint extends TelegramClient implements Endpoint {
80
+ readonly [endpointSpec]: EndpointSpecification;
167
81
  readonly #logger!: ReturnType<typeof getAdapterLogger>;
168
82
 
169
83
  readonly #options: TelegramEndpointOptions;
170
- readonly #fetch: TelegramFetch;
171
- #apiAbort = new AbortController();
172
84
  #pollAbort?: AbortController;
173
85
  #pollPromise?: Promise<void>;
174
86
  readonly #lifecycle: EndpointLifecycle;
175
87
  #open = false;
176
88
  #admission?: AbortController;
89
+ #executionSignal?: AbortSignal;
177
90
  #updateOffset = 0;
178
91
  #pollingHealthy = true;
179
92
  readonly #admittedUpdates = new Set<number>();
@@ -181,22 +94,25 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
181
94
  #botUserId?: number;
182
95
  #botUsername?: string;
183
96
  readonly #chatMemberCache = new Map<string, ChatMemberPermit>();
184
- readonly control: EndpointControl = Object.freeze({
185
- recall: (message: MessageRef) => this.recallMessage(message),
97
+ readonly #control: EndpointControl = Object.freeze<EndpointControl>({
98
+ recall: ({ message, signal }) => this.recallMessage(message, signal),
186
99
  });
187
- readonly content: EndpointContentPort = Object.freeze({
100
+ readonly #content: EndpointContentPort = Object.freeze({
188
101
  resolve: (reference: ConversationReference, context: EndpointContentResolveContext) => this.#resolveContent(reference, context.signal),
189
102
  });
190
103
 
191
104
  constructor(options: TelegramEndpointOptions) {
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
- }
105
+ super(options);
196
106
  this.#logger = getAdapterLogger('telegram', options.config.id);
197
107
  this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
198
108
  this.#options = options;
199
- this.#fetch = options.fetch ?? globalThis.fetch;
109
+ const endpoint = this;
110
+ this[endpointSpec] = createEndpointSpecification(this, {
111
+ start: signal => this.#startFramework(signal), open: () => this.#openFramework(), close: () => this.#closeFramework(),
112
+ stop: () => this.#stopFramework(), send: request => this.#sendFramework(request),
113
+ get name() { return options.config.id; }, get transportState() { return endpoint.#transportState; },
114
+ control: this.#control, content: this.#content,
115
+ });
200
116
  }
201
117
 
202
118
  /** Used by webhook handler. */
@@ -204,7 +120,7 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
204
120
  return this.#open;
205
121
  }
206
122
 
207
- override get transportState(): EndpointTransportState {
123
+ get #transportState(): EndpointTransportState {
208
124
  const state = this.#lifecycle.state;
209
125
  return state === 'open' && this.#options.config.mode === 'polling' && !this.#pollingHealthy
210
126
  ? 'reconnecting' : state;
@@ -230,12 +146,14 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
230
146
  this.#updateOffset = offset;
231
147
  }
232
148
 
233
- async start(): Promise<void> {
234
- if (this.#apiAbort.signal.aborted) this.#apiAbort = new AbortController();
149
+ async #startFramework(signal: AbortSignal): Promise<void> {
150
+ this.#executionSignal = signal;
151
+ this.restartRequests();
235
152
  await this.#lifecycle.start(async (handle) => {
236
153
  const abort = new AbortController();
237
154
  const routes: HttpRouteRegistration[] = [];
238
155
  this.#pollAbort = abort;
156
+ this.requestSignal = AbortSignal.any([abort.signal, signal]);
239
157
  const cleanup = () => {
240
158
  abort.abort();
241
159
  for (const release of routes.splice(0)) release();
@@ -302,21 +220,22 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
302
220
  });
303
221
  }
304
222
 
305
- open(): void {
223
+ #openFramework(): void {
306
224
  if (this.#open) return;
307
225
  this.#admission = new AbortController();
226
+ this.#chatMemberCache.clear();
308
227
  this.#open = true;
309
228
  }
310
229
 
311
- close(): void {
230
+ #closeFramework(): void {
312
231
  this.#open = false;
313
- this.#admission?.abort();
314
232
  this.#chatMemberCache.clear();
315
233
  }
316
234
 
317
- async stop(): Promise<void> {
318
- this.close();
319
- this.#apiAbort.abort();
235
+ async #stopFramework(): Promise<void> {
236
+ this.#admission?.abort();
237
+ this.#closeFramework();
238
+ this.cancelRequests();
320
239
  const polling = this.#pollPromise;
321
240
  this.#pollPromise = undefined;
322
241
  await this.#lifecycle.stop();
@@ -328,16 +247,18 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
328
247
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
329
248
  }
330
249
 
331
- async send({ conversation, payload }: EndpointSendRequest): Promise<string> {
250
+ async #sendFramework({ conversation, payload, signal }: EndpointSendRequest): Promise<string> {
332
251
  let lastId = '';
333
252
  let completedActions = 0;
334
253
  try {
335
254
  const plan = formatOutboundPlan(conversation.id, payload);
336
255
  for (const action of plan.actions) {
337
- const form = await this.#buildUploadForm(action.params, plan.uploads);
256
+ signal?.throwIfAborted();
257
+ const form = await this.#buildUploadForm(action.params, plan.uploads, signal);
258
+ signal?.throwIfAborted();
338
259
  const result = form
339
- ? await this.callApiForm<{ message_id?: number }>(action.method, form)
340
- : await this.callApi<{ message_id?: number }>(action.method, action.params);
260
+ ? await this.callApiForm<{ message_id?: number }>(action.method, form, signal)
261
+ : await this.callApi<{ message_id?: number }>(action.method, action.params, signal);
341
262
  if (!result || !Number.isSafeInteger(result.message_id) || result.message_id! <= 0) {
342
263
  throw new EndpointDeliveryError('delivery_unconfirmed',
343
264
  `Telegram API ${action.method} returned no valid message_id; delivery is unconfirmed`, 'unknown');
@@ -358,12 +279,12 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
358
279
  }
359
280
  }
360
281
 
361
- async recallMessage(message: MessageRef): Promise<void> {
282
+ async recallMessage(message: MessageRef, signal?: AbortSignal): Promise<void> {
362
283
  if (!message.id || message.id.startsWith('telegram-')) return;
363
284
  await this.callApi('deleteMessage', {
364
285
  chat_id: message.conversation.id,
365
286
  message_id: Number(message.id),
366
- });
287
+ }, signal);
367
288
  }
368
289
 
369
290
  async #resolveContent(
@@ -383,8 +304,8 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
383
304
  }, signal);
384
305
  if (!file.file_path) return Object.freeze({ status: 'not_found', code: 'telegram_file_not_found' });
385
306
  if ((file.file_size ?? 0) > 26_214_400) return Object.freeze({ status: 'forbidden', code: 'media_size_limit' });
386
- return await this.#withRequestDeadline<ConversationResolution>('downloadFile', async (downloadSignal) => {
387
- const response = await this.#fetch(
307
+ return await this.withRequestDeadline<ConversationResolution>({ method: 'downloadFile', run: async (downloadSignal) => {
308
+ const response = await this.fetchTransport(
388
309
  `${this.#options.config.apiBaseUrl}/file/bot${this.#options.config.token}/${file.file_path}`,
389
310
  { signal: downloadSignal },
390
311
  );
@@ -405,7 +326,7 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
405
326
  size: bytes.byteLength,
406
327
  }),
407
328
  });
408
- }, signal);
329
+ }, signal });
409
330
  } catch {
410
331
  if (signal.aborted) return Object.freeze({ status: 'expired', code: 'turn_aborted' });
411
332
  return Object.freeze({ status: 'failed', code: 'telegram_file_resolution_failed', message: 'Telegram file resolution failed' });
@@ -420,6 +341,7 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
420
341
  async #buildUploadForm(
421
342
  params: Record<string, unknown>,
422
343
  uploads: readonly TelegramOutboundUpload[],
344
+ signal?: AbortSignal,
423
345
  ): Promise<FormData | undefined> {
424
346
  const values = Object.values(params);
425
347
  if (!values.some((v) => typeof v === 'string' && v.startsWith('attach://'))) return undefined;
@@ -431,7 +353,7 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
431
353
  if (!upload) throw new Error(`Telegram upload 未登记: ${value}`);
432
354
  const data = upload.source.kind === 'base64'
433
355
  ? Buffer.from(upload.source.data, 'base64')
434
- : await readFile(upload.source.path);
356
+ : await readFile(upload.source.path, { signal });
435
357
  form.append(
436
358
  key,
437
359
  new Blob([data], upload.mimeType ? { type: upload.mimeType } : undefined),
@@ -446,23 +368,27 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
446
368
 
447
369
  /** Test / internal: admit a message when open. */
448
370
  admit(msg: TelegramMessage): void {
449
- if (!this.#open || !this.#admission) return;
450
- const conversation = telegramInboundConversation(String(this.#options.id), msg.chat);
451
- void this.#admitWithSenderRole(msg, conversation, this.#admission.signal).catch((err) => {
452
- this.#logger.warn(formatCompact({
453
- op: 'telegram_gateway_receive_failed',
454
- target: `${conversation.kind}:${conversation.id}`,
455
- error: err instanceof Error ? err.message : String(err),
456
- }));
457
- });
371
+ void this[endpointSpec].events.run(() => {
372
+ if (!this.#admission) return;
373
+ const conversation = telegramInboundConversation(String(this.#options.id), msg.chat);
374
+ return this.#admitWithSenderRole({ message: msg, conversation, signal: AbortSignal.any([this.#admission.signal, this.#executionSignal!]) }).catch((err) => {
375
+ this.#logger.warn(formatCompact({
376
+ op: 'telegram_gateway_receive_failed',
377
+ target: `${conversation.kind}:${conversation.id}`,
378
+ error: err instanceof Error ? err.message : String(err),
379
+ }));
380
+ });
381
+ }).catch(() => this.#logger.debug('Inbound callback was not admitted'));
458
382
  }
459
383
 
460
- async #admitWithSenderRole(msg: TelegramMessage, conversation: ConversationRef, signal: AbortSignal): Promise<void> {
384
+ async #admitWithSenderRole({ message: msg, conversation, signal, event = msg }: TelegramMessageAdmission): Promise<void> {
461
385
  const permit = await this.#resolveGroupSenderPermit(msg, signal);
462
386
  signal.throwIfAborted();
463
387
  // 新 Runtime Message.content 为纯文本:@ 本机只能经 metadata 传递
464
388
  const mentioned = this.#isBotMentioned(msg);
465
- await this.emitAccepted('message.receive', {
389
+ await this[endpointSpec].events.emitAccepted('message.receive', {
390
+ event,
391
+ ...(typeof msg.date === 'number' ? { platformSentAt: msg.date * 1_000 } : {}),
466
392
  conversation,
467
393
  message: { conversation, id: String(msg.message_id) },
468
394
  content: formatInboundContent(msg),
@@ -543,23 +469,25 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
543
469
 
544
470
  /** Test / internal: admit a callback query when open. */
545
471
  admitCallback(query: TelegramCallbackQuery): void {
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
- });
472
+ void this[endpointSpec].events.run(() => {
473
+ return this.#admitCallback(query).catch((err) => {
474
+ this.#logger.warn(formatCompact({
475
+ op: 'telegram_gateway_receive_failed',
476
+ error: err instanceof Error ? err.message : String(err),
477
+ }));
478
+ });
479
+ }).catch(() => this.#logger.debug('Inbound callback was not admitted'));
553
480
  }
554
481
 
555
- async #admitCallback(query: TelegramCallbackQuery): Promise<void> {
482
+ async #admitCallback(query: TelegramCallbackQuery, event: unknown = query): Promise<void> {
556
483
  const endpointKey = String(this.#options.id);
557
484
  const msg = query.message;
558
485
  // 无挂载消息的 callback(如 inline 模式)退化为 sender 私聊会话
559
486
  const conversation = msg
560
487
  ? telegramInboundConversation(endpointKey, msg.chat)
561
488
  : telegramInboundConversation(endpointKey, { id: query.from.id, type: 'private' });
562
- await this.emitAccepted('message.receive', {
489
+ await this[endpointSpec].events.emitAccepted('message.receive', {
490
+ event,
563
491
  conversation,
564
492
  message: { conversation, id: query.id },
565
493
  content: formatCallbackContent(query),
@@ -576,32 +504,34 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
576
504
 
577
505
  /** Used by webhook / polling handlers. */
578
506
  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!);
507
+ return this[endpointSpec].events.run(async () => {
508
+ if (!this.#admission) throw new Error('Telegram endpoint is not accepting updates');
509
+ if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
510
+ throw new TypeError('Telegram update_id must be a non-negative safe integer');
597
511
  }
598
- })();
599
- this.#inflightUpdates.set(update.update_id, receiving);
600
- try {
601
- await receiving;
602
- } finally {
603
- this.#inflightUpdates.delete(update.update_id);
604
- }
512
+ if (this.#admittedUpdates.has(update.update_id)) return;
513
+ const pending = this.#inflightUpdates.get(update.update_id);
514
+ if (pending) return pending;
515
+ if (this.#inflightUpdates.size >= INFLIGHT_UPDATE_LIMIT) {
516
+ throw new Error('Telegram update admission capacity exhausted');
517
+ }
518
+ const admission = this.#admission;
519
+ const receiving = (async () => {
520
+ // Once canonical dispatch enters Core, its generation lease drains the work.
521
+ // Closing the transport cannot turn successful business processing into a retry.
522
+ await this.#receiveUpdate(update, AbortSignal.any([admission.signal, this.#executionSignal!]));
523
+ this.#admittedUpdates.add(update.update_id);
524
+ if (this.#admittedUpdates.size > ADMITTED_UPDATE_LIMIT) {
525
+ this.#admittedUpdates.delete(this.#admittedUpdates.values().next().value!);
526
+ }
527
+ })();
528
+ this.#inflightUpdates.set(update.update_id, receiving);
529
+ try {
530
+ await receiving;
531
+ } finally {
532
+ this.#inflightUpdates.delete(update.update_id);
533
+ }
534
+ });
605
535
  }
606
536
 
607
537
  async #receiveUpdate(update: TelegramUpdate, signal: AbortSignal): Promise<void> {
@@ -611,208 +541,26 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
611
541
  ? 'callback_query'
612
542
  : 'update';
613
543
  if (update.message || update.callback_query) {
614
- await this.emitPlatform(eventName, update);
544
+ await this[endpointSpec].events.platform(eventName, update);
615
545
  } else {
616
- await this.emitAccepted('platform.receive', Object.freeze({ name: eventName, event: update }));
546
+ await this[endpointSpec].events.emitAccepted('platform.receive', Object.freeze({ name: eventName, event: update }));
617
547
  return;
618
548
  }
619
549
  signal.throwIfAborted();
620
550
  if (update.message) {
621
551
  const conversation = telegramInboundConversation(String(this.#options.id), update.message.chat);
622
- await this.#admitWithSenderRole(update.message, conversation, signal);
552
+ await this.#admitWithSenderRole({ message: update.message, conversation, signal, event: update });
623
553
  return;
624
554
  }
625
555
  if (update.callback_query) {
626
556
  const query = update.callback_query;
627
557
  if (query.data) {
628
- void this.callApi('answerCallbackQuery', { callback_query_id: query.id }).catch(() => {
558
+ void this.callApi('answerCallbackQuery', { callback_query_id: query.id }, signal).catch(() => {
629
559
  /* already answered */
630
560
  });
631
561
  }
632
- await this.#admitCallback(query);
633
- }
634
- }
635
-
636
- async callApi<T = unknown>(
637
- method: string,
638
- params: Record<string, unknown> = {},
639
- signal?: AbortSignal,
640
- ): Promise<T> {
641
- return this.#requestApi<T>(method, {
642
- method: 'POST',
643
- headers: { 'Content-Type': 'application/json' },
644
- body: JSON.stringify(params),
645
- }, signal, method === 'getUpdates'
646
- ? Math.max(this.#options.requestTimeoutMs ?? DEFAULT_API_TIMEOUT_MS, (Number(params.timeout) || 0) * 1_000 + 15_000)
647
- : undefined);
648
- }
649
-
650
- /** multipart/form-data 变体(attach:// 媒体上传;Content-Type 边界由 FormData 自带)。 */
651
- async callApiForm<T = unknown>(
652
- method: string,
653
- form: FormData,
654
- signal?: AbortSignal,
655
- ): Promise<T> {
656
- return this.#requestApi<T>(method, {
657
- method: 'POST',
658
- body: form,
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);
562
+ await this.#admitCallback(query, update);
716
563
  }
717
564
  }
718
565
 
719
- async #parseApiResponse<T>(
720
- method: string,
721
- response: { readonly status: number; text(): Promise<string> },
722
- ): Promise<T> {
723
- const text = await response.text();
724
- let body: TelegramApiOk<T> | TelegramApiErr;
725
- try {
726
- body = JSON.parse(text) as TelegramApiOk<T> | TelegramApiErr;
727
- } catch {
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 });
732
- }
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 } : {}) },
742
- );
743
- }
744
- if (!('result' in body)) throw new TelegramApiError('invalid_response', `Telegram API ${method} missing result`);
745
- return body.result;
746
- }
747
-
748
- // ── Agent tool surface ──────────────────────────────────────────────
749
-
750
- async pinMessage(chatId: number, messageId: number): Promise<boolean> {
751
- await this.callApi('pinChatMessage', { chat_id: chatId, message_id: messageId });
752
- return true;
753
- }
754
-
755
- async unpinMessage(chatId: number, messageId?: number): Promise<boolean> {
756
- if (messageId != null) {
757
- await this.callApi('unpinChatMessage', { chat_id: chatId, message_id: messageId });
758
- } else {
759
- await this.callApi('unpinAllChatMessages', { chat_id: chatId });
760
- }
761
- return true;
762
- }
763
-
764
- async setChatDescription(chatId: number, description: string): Promise<boolean> {
765
- await this.callApi('setChatDescription', { chat_id: chatId, description });
766
- return true;
767
- }
768
-
769
- async setMessageReaction(chatId: number, messageId: number, reaction: string): Promise<boolean> {
770
- await this.callApi('setMessageReaction', {
771
- chat_id: chatId,
772
- message_id: messageId,
773
- reaction: [{ type: 'emoji', emoji: reaction }],
774
- });
775
- return true;
776
- }
777
-
778
- async getChatMemberCount(chatId: number): Promise<number> {
779
- return this.callApi<number>('getChatMemberCount', { chat_id: chatId });
780
- }
781
-
782
- async getChatAdmins(chatId: number): Promise<TelegramChatMember[]> {
783
- return this.callApi<TelegramChatMember[]>('getChatAdministrators', { chat_id: chatId });
784
- }
785
-
786
- async sendStickerMessage(chatId: number, sticker: string): Promise<{ message_id: number }> {
787
- return this.callApi<{ message_id: number }>('sendSticker', { chat_id: chatId, sticker });
788
- }
789
-
790
- async setChatPermissionsAll(
791
- chatId: number,
792
- permissions: Record<string, boolean | undefined>,
793
- ): Promise<boolean> {
794
- await this.callApi('setChatPermissions', { chat_id: chatId, permissions });
795
- return true;
796
- }
797
-
798
- async createInviteLink(chatId: number): Promise<string> {
799
- const link = await this.callApi<{ invite_link: string }>('createChatInviteLink', { chat_id: chatId });
800
- return link.invite_link;
801
- }
802
-
803
- async sendPoll(
804
- chatId: number,
805
- question: string,
806
- options: string[],
807
- isAnonymous = true,
808
- allowsMultipleAnswers = false,
809
- ): Promise<{ message_id: number }> {
810
- return this.callApi<{ message_id: number }>('sendPoll', {
811
- chat_id: chatId,
812
- question,
813
- options,
814
- is_anonymous: isAnonymous,
815
- allows_multiple_answers: allowsMultipleAnswers,
816
- });
817
- }
818
566
  }