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