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