@zhin.js/adapter-telegram 8.0.8 → 9.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 9.0.1
4
+
5
+ ### Patch Changes
6
+
7
+ - f7ee28d: Reject sends without a valid platform message ID. Use the shared Endpoint lifecycle to cancel setup requests and polling, isolate late results from replacement connections, and release Webhook routes on failed startup. Release retry listeners and cancel permission lookups when admission closes, preventing stale messages and permissions from entering reopened endpoints. Correct endpoint ID and Webhook setup documentation.
8
+ - Updated dependencies [f7ee28d]
9
+ - @zhin.js/adapter@1.2.2
10
+ - @zhin.js/core@1.5.16
11
+ - zhin.js@7.0.1
12
+ - @zhin.js/agent@1.2.1
13
+
14
+ ## 9.0.0
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [12325e1]
19
+ - Updated dependencies [d19e976]
20
+ - @zhin.js/agent@1.2.0
21
+ - zhin.js@7.0.0
22
+
3
23
  ## 8.0.8
4
24
 
5
25
  ### Patch Changes
package/README.md CHANGED
@@ -9,7 +9,7 @@ Zhin.js Telegram Bot API 适配器(Plugin Runtime),默认通过 **长轮
9
9
  - 支持私聊与群组
10
10
  - 出站 `send({ conversation, payload })` → Bot API(Markdown→安全 HTML / media / keyboard)
11
11
  - 约定式 `defineAdapter` / `definePlugin`(无需 `usePlugin`)
12
- - Webhook 模式延期(需 `httpHostToken`);配置 `polling: false` 会明确报错
12
+ - Webhook 模式通过 `httpHostToken` 注册回调路由,支持 secretToken 校验
13
13
 
14
14
  ## 安装
15
15
 
@@ -40,9 +40,9 @@ pnpm add @zhin.js/adapter-telegram
40
40
  | **Bot Token** | 通过 [@BotFather](https://t.me/botfather) 创建并获取 Token |
41
41
  | **Polling(默认)** | 本地/生产均可;主动拉取更新,无需公网 HTTPS |
42
42
  | **网络** | 出站可访问 `api.telegram.org` |
43
- | **host-http** | Polling **不需要**;Webhook 延期至下一棒 |
43
+ | **host-http** | Polling **不需要**;Webhook **需要** CLI HTTP Host |
44
44
 
45
- 必填字段(`endpoints[i]`):`name`、`token`。
45
+ 必填字段(`endpoints[i]`):`id`、`token`。
46
46
 
47
47
  ## 最小配置
48
48
 
@@ -52,7 +52,7 @@ plugins:
52
52
  telegram:
53
53
  # polling: true # 默认
54
54
  endpoints:
55
- - name: my-telegram-bot
55
+ - id: my-telegram-bot
56
56
  token: ${TELEGRAM_TOKEN}
57
57
  ```
58
58
 
@@ -65,13 +65,37 @@ plugins:
65
65
  | `TELEGRAM_TOKEN` / `TELEGRAM_BOT_TOKEN` | Bot Token |
66
66
  | `TELEGRAM_BOT_NAME` | 可选,默认 endpoint 名 |
67
67
 
68
- ## Webhook(延期)
68
+ ## Webhook
69
69
 
70
- `polling: false` + `webhook` 目前会抛出明确错误:
70
+ 配置 `polling: false`,启用 CLI HTTP Host,并提供公网 HTTPS 回调地址。
71
+ `webhook.path` 必须与反向代理转发路径一致,多账号使用不同路径。
71
72
 
72
- > Telegram webhook mode is deferred until httpHostToken wiring; use polling: true (default) for now
73
+ ```yaml
74
+ http:
75
+ host: 127.0.0.1
76
+ port: 8086
77
+ plugins:
78
+ telegram:
79
+ polling: false
80
+ endpoints:
81
+ - id: my-telegram-bot
82
+ token: ${TELEGRAM_TOKEN}
83
+ webhook:
84
+ domain: https://bot.example.com
85
+ path: /telegram/webhook
86
+ secretToken: ${TELEGRAM_WEBHOOK_SECRET}
87
+ ```
88
+
89
+ 启动时注册 Host 路由并调用 `setWebhook`;停止时释放路由。
90
+ 配置 `secretToken` 后,回调须携带匹配的 `X-Telegram-Bot-Api-Secret-Token`,否则返回 403。
91
+
92
+ ## 稳定化范围
73
93
 
74
- 下一棒将用 `httpHostToken` 注册 POST 路由,不再使用 Telegraf 自建监听或 legacy host-router。
94
+ 当前为 **Advanced / 首批升档候选**,完整适配器测试已纳入 `pnpm check:stable`。
95
+ 长轮询停止会中止请求与退避;即使传输层在中止后返回更新,也不会派发或推进 offset。
96
+ 轮询 offset 仅保存在当前 Endpoint 内存中,不承诺跨重启去重或业务处理恰好一次。
97
+ Webhook 接收确认不等于业务处理完成;需要幂等性的业务应自行持久化去重。
98
+ 详见[平台稳定验收](https://github.com/zhinjs/zhin/blob/main/docs/contributing/platform-acceptance.md)。
75
99
 
76
100
  ## 消息类型映射
77
101
 
@@ -100,7 +124,7 @@ plugins:
100
124
  |------|------|
101
125
  | 收不到消息 | Token 是否正确;进程已 `open()`;同一 Token 勿多进程同时 polling |
102
126
  | Polling 报错 | 检查能否访问 `api.telegram.org`;查看日志 `op: poll` |
103
- | Webhook 配置报错 | 当前仅支持 polling;去掉 `polling: false` |
127
+ | Webhook 配置报错 | 检查 HTTP Host、公网 HTTPS、反向代理路径与 secretToken |
104
128
  | 发送失败 | Token 是否被撤销;查看 Bot API 错误描述 |
105
129
 
106
130
  ## Documentation
package/lib/endpoint.d.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { Endpoint } from 'zhin.js/adapter';
2
- import type { EndpointContentPort, EndpointControl, EndpointSendRequest } from 'zhin.js/adapter';
1
+ import { Endpoint, type EndpointContentPort, type EndpointControl, type EndpointSendRequest } from 'zhin.js/adapter';
3
2
  import type { HttpHost } from '@zhin.js/host-http';
4
3
  import { type MessageRef } from '@zhin.js/im-contract';
5
4
  import type { CapabilityId } from 'zhin.js';
package/lib/endpoint.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Endpoint } from 'zhin.js/adapter';
1
+ import { Endpoint, createEndpointLifecycle, } from 'zhin.js/adapter';
2
2
  /**
3
3
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
4
4
  */
@@ -54,9 +54,9 @@ export class TelegramEndpoint extends Endpoint {
54
54
  #fetch;
55
55
  #pollAbort;
56
56
  #pollPromise;
57
- #routeReleases = [];
57
+ #lifecycle;
58
58
  #open = false;
59
- #started = false;
59
+ #admission;
60
60
  #updateOffset = 0;
61
61
  #botUserId;
62
62
  #botUsername;
@@ -70,6 +70,7 @@ export class TelegramEndpoint extends Endpoint {
70
70
  constructor(options) {
71
71
  super();
72
72
  this.#logger = getAdapterLogger('telegram', options.config.id);
73
+ this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
73
74
  this.#options = options;
74
75
  this.#fetch = options.fetch ?? globalThis.fetch;
75
76
  }
@@ -90,78 +91,101 @@ export class TelegramEndpoint extends Endpoint {
90
91
  this.#updateOffset = offset;
91
92
  }
92
93
  async start() {
93
- if (this.#started)
94
- return;
95
- this.#started = true;
96
- try {
97
- const me = await this.callApi('getMe');
98
- this.#botUserId = me.id;
99
- this.#botUsername = me.username;
100
- if (this.#options.config.mode === 'webhook') {
101
- if (!this.#options.http) {
102
- throw new TypeError('Telegram webhook mode requires httpHostToken');
94
+ await this.#lifecycle.start(async (handle) => {
95
+ const abort = new AbortController();
96
+ const routes = [];
97
+ this.#pollAbort = abort;
98
+ const cleanup = () => {
99
+ abort.abort();
100
+ for (const release of routes.splice(0))
101
+ release();
102
+ if (this.#pollAbort === abort) {
103
+ this.#botUserId = undefined;
104
+ this.#botUsername = undefined;
103
105
  }
104
- this.#routeReleases.push(...registerTelegramWebhookRoutes(this.#options.http, this));
105
- const webhook = this.#options.config.webhook;
106
- if (!webhook.secretToken) {
107
- // 未配 secretToken webhook 无鉴权:任何人知道 path 即可注入假 update。
108
- this.#logger.warn(formatCompact({
109
- op: 'webhook_no_secret',
106
+ };
107
+ handle.onForceClose(cleanup);
108
+ try {
109
+ const me = await this.callApi('getMe', {}, abort.signal);
110
+ if (abort.signal.aborted)
111
+ return;
112
+ this.#botUserId = me.id;
113
+ this.#botUsername = me.username;
114
+ if (this.#options.config.mode === 'webhook') {
115
+ if (!this.#options.http) {
116
+ throw new TypeError('Telegram webhook mode requires httpHostToken');
117
+ }
118
+ routes.push(...registerTelegramWebhookRoutes(this.#options.http, this));
119
+ const webhook = this.#options.config.webhook;
120
+ if (!webhook.secretToken) {
121
+ // 未配 secretToken 时 webhook 无鉴权:任何人知道 path 即可注入假 update。
122
+ this.#logger.warn(formatCompact({
123
+ op: 'webhook_no_secret',
124
+ endpoint: this.#options.config.id,
125
+ path: webhook.path,
126
+ hint: 'set webhook.secretToken to authenticate Telegram callbacks',
127
+ }));
128
+ }
129
+ const url = buildWebhookUrl(webhook);
130
+ await this.callApi('setWebhook', {
131
+ url,
132
+ allowed_updates: this.#options.config.allowedUpdates,
133
+ ...(webhook.secretToken ? { secret_token: webhook.secretToken } : {}),
134
+ }, abort.signal);
135
+ if (abort.signal.aborted)
136
+ return;
137
+ this.#logger.info(formatCompact({
138
+ op: 'connect',
110
139
  endpoint: this.#options.config.id,
140
+ mode: 'webhook',
111
141
  path: webhook.path,
112
- hint: 'set webhook.secretToken to authenticate Telegram callbacks',
142
+ username: me.username,
113
143
  }));
144
+ return;
114
145
  }
115
- const url = buildWebhookUrl(webhook);
116
- await this.callApi('setWebhook', {
117
- url,
118
- allowed_updates: this.#options.config.allowedUpdates,
119
- ...(webhook.secretToken ? { secret_token: webhook.secretToken } : {}),
120
- });
146
+ await this.callApi('deleteWebhook', { drop_pending_updates: false }, abort.signal);
147
+ if (abort.signal.aborted)
148
+ return;
149
+ this.#pollPromise = runTelegramPollLoop(this, abort.signal);
121
150
  this.#logger.info(formatCompact({
122
151
  op: 'connect',
123
152
  endpoint: this.#options.config.id,
124
- mode: 'webhook',
125
- path: webhook.path,
153
+ mode: 'polling',
126
154
  username: me.username,
127
155
  }));
128
- return;
129
156
  }
130
- await this.callApi('deleteWebhook', { drop_pending_updates: false });
131
- this.#pollAbort = new AbortController();
132
- this.#pollPromise = runTelegramPollLoop(this, this.#pollAbort.signal);
133
- this.#logger.info(formatCompact({
134
- op: 'connect',
135
- endpoint: this.#options.config.id,
136
- mode: 'polling',
137
- username: me.username,
138
- }));
139
- }
140
- catch (error) {
141
- await this.stop();
142
- this.#logger.error('Failed to connect Telegram bot:', error);
143
- throw error;
144
- }
157
+ catch (error) {
158
+ const cancelled = abort.signal.aborted;
159
+ cleanup();
160
+ if (cancelled)
161
+ return;
162
+ this.#logger.error('Failed to connect Telegram bot:', error);
163
+ throw error;
164
+ }
165
+ });
145
166
  }
146
167
  open() {
168
+ if (this.#open)
169
+ return;
170
+ this.#admission = new AbortController();
147
171
  this.#open = true;
148
172
  }
149
173
  close() {
150
174
  this.#open = false;
175
+ this.#admission?.abort();
176
+ this.#chatMemberCache.clear();
151
177
  }
152
178
  async stop() {
153
- this.#open = false;
154
- this.#pollAbort?.abort();
179
+ this.close();
180
+ const polling = this.#pollPromise;
181
+ this.#pollPromise = undefined;
182
+ await this.#lifecycle.stop();
155
183
  try {
156
- await this.#pollPromise;
184
+ await polling;
157
185
  }
158
186
  catch {
159
187
  /* poll loop exit */
160
188
  }
161
- for (const release of this.#routeReleases.splice(0))
162
- release();
163
- this.#chatMemberCache.clear();
164
- this.#started = false;
165
189
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
166
190
  }
167
191
  async send({ conversation, payload }) {
@@ -172,10 +196,14 @@ export class TelegramEndpoint extends Endpoint {
172
196
  const result = form
173
197
  ? await this.callApiForm(action.method, form)
174
198
  : await this.callApi(action.method, action.params);
175
- if (result.message_id != null)
176
- lastId = String(result.message_id);
199
+ if (!result || !Number.isSafeInteger(result.message_id) || result.message_id <= 0) {
200
+ throw new Error(`Telegram API ${action.method} returned no valid message_id; delivery is unconfirmed`);
201
+ }
202
+ lastId = String(result.message_id);
177
203
  }
178
- return lastId || `telegram-${Date.now()}`;
204
+ if (!lastId)
205
+ throw new Error('Telegram message contains no sendable actions');
206
+ return lastId;
179
207
  }
180
208
  async recallMessage(message) {
181
209
  if (!message.id || message.id.startsWith('telegram-'))
@@ -258,10 +286,10 @@ export class TelegramEndpoint extends Endpoint {
258
286
  }
259
287
  /** Test / internal: admit a message when open. */
260
288
  admit(msg) {
261
- if (!this.#open)
289
+ if (!this.#open || !this.#admission)
262
290
  return;
263
291
  const conversation = telegramInboundConversation(String(this.#options.id), msg.chat);
264
- void this.#admitWithSenderRole(msg, conversation).catch((err) => {
292
+ void this.#admitWithSenderRole(msg, conversation, this.#admission.signal).catch((err) => {
265
293
  this.#logger.warn(formatCompact({
266
294
  op: 'telegram_gateway_receive_failed',
267
295
  target: `${conversation.kind}:${conversation.id}`,
@@ -269,8 +297,10 @@ export class TelegramEndpoint extends Endpoint {
269
297
  }));
270
298
  });
271
299
  }
272
- async #admitWithSenderRole(msg, conversation) {
273
- const permit = await this.#resolveGroupSenderPermit(msg);
300
+ async #admitWithSenderRole(msg, conversation, signal) {
301
+ const permit = await this.#resolveGroupSenderPermit(msg, signal);
302
+ if (signal.aborted)
303
+ return;
274
304
  // 新 Runtime Message.content 为纯文本:@ 本机只能经 metadata 传递
275
305
  const mentioned = this.#isBotMentioned(msg);
276
306
  await this.emit('message.receive', {
@@ -314,7 +344,7 @@ export class TelegramEndpoint extends Endpoint {
314
344
  return false;
315
345
  }
316
346
  /** 群消息 sender role 解析:getChatMember + 60s 缓存(对齐旧 enrichGroupSender)。 */
317
- async #resolveGroupSenderPermit(msg) {
347
+ async #resolveGroupSenderPermit(msg, signal) {
318
348
  if (msg.chat.type === 'private' || !msg.from?.id)
319
349
  return undefined;
320
350
  const chatId = Number(msg.chat.id);
@@ -329,7 +359,9 @@ export class TelegramEndpoint extends Endpoint {
329
359
  const member = await this.callApi('getChatMember', {
330
360
  chat_id: chatId,
331
361
  user_id: userId,
332
- });
362
+ }, signal);
363
+ if (signal.aborted)
364
+ return undefined;
333
365
  const normalized = normalizeTelegramChatMember(member);
334
366
  const entry = { at: now, ...normalized };
335
367
  this.#chatMemberCache.set(key, entry);
package/lib/polling.js CHANGED
@@ -18,6 +18,9 @@ export async function runTelegramPollLoop(host, abortSignal) {
18
18
  }, abortSignal);
19
19
  consecutiveFailures = 0;
20
20
  for (const update of updates) {
21
+ // A transport may resolve after cancellation; never admit that late batch.
22
+ if (abortSignal.aborted)
23
+ return;
21
24
  host.setUpdateOffset(update.update_id + 1);
22
25
  host.handleUpdate(update);
23
26
  }
@@ -43,10 +46,12 @@ function sleep(ms, signal) {
43
46
  resolve();
44
47
  return;
45
48
  }
46
- const timer = setTimeout(resolve, ms);
47
- signal?.addEventListener('abort', () => {
49
+ const finish = () => {
48
50
  clearTimeout(timer);
51
+ signal?.removeEventListener('abort', finish);
49
52
  resolve();
50
- }, { once: true });
53
+ };
54
+ const timer = setTimeout(finish, ms);
55
+ signal?.addEventListener('abort', finish, { once: true });
51
56
  });
52
57
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/adapter-telegram",
3
- "version": "8.0.8",
3
+ "version": "9.0.1",
4
4
  "description": "Zhin.js Telegram Bot API adapter for Plugin Runtime (long-poll getUpdates)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -32,8 +32,8 @@
32
32
  "directory": "plugins/adapters/telegram"
33
33
  },
34
34
  "dependencies": {
35
- "@zhin.js/adapter": "1.2.1",
36
- "@zhin.js/core": "1.5.15",
35
+ "@zhin.js/adapter": "1.2.2",
36
+ "@zhin.js/core": "1.5.16",
37
37
  "@zhin.js/feature-kit": "1.0.13",
38
38
  "@zhin.js/host-http": "1.0.17",
39
39
  "@zhin.js/im-contract": "1.0.4",
@@ -41,12 +41,12 @@
41
41
  },
42
42
  "peerDependencies": {
43
43
  "zod": "^4.0.0",
44
- "@zhin.js/adapter": "1.2.1",
45
- "@zhin.js/agent": "1.1.22",
44
+ "@zhin.js/adapter": "1.2.2",
45
+ "@zhin.js/agent": "1.2.1",
46
46
  "@zhin.js/command": "1.0.16",
47
- "@zhin.js/core": "1.5.15",
47
+ "@zhin.js/core": "1.5.16",
48
48
  "@zhin.js/permission": "1.0.4",
49
- "zhin.js": "6.0.15"
49
+ "zhin.js": "7.0.1"
50
50
  },
51
51
  "peerDependenciesMeta": {
52
52
  "@zhin.js/agent": {
@@ -67,9 +67,9 @@
67
67
  "typescript": "^6.0.3",
68
68
  "vitest": "^4.1.10",
69
69
  "zod": "^4.4.3",
70
- "@zhin.js/agent": "1.1.22",
70
+ "@zhin.js/agent": "1.2.1",
71
71
  "@zhin.js/host-http": "1.0.17",
72
- "zhin.js": "6.0.15"
72
+ "zhin.js": "7.0.1"
73
73
  },
74
74
  "files": [
75
75
  "adapters",
package/src/endpoint.ts CHANGED
@@ -1,9 +1,12 @@
1
- import { Endpoint } from 'zhin.js/adapter';
1
+ import {
2
+ Endpoint, createEndpointLifecycle,
3
+ type EndpointLifecycle, type EndpointContentPort, type EndpointContentResolveContext,
4
+ type EndpointControl, type EndpointSendRequest,
5
+ } from 'zhin.js/adapter';
2
6
  /**
3
7
  * TelegramEndpoint — lifecycle, outbound, admit, Bot API helpers for agent tools.
4
8
  */
5
9
  import { readFile } from 'node:fs/promises';
6
- import type { EndpointContentPort, EndpointContentResolveContext, EndpointControl, EndpointSendRequest } from 'zhin.js/adapter';
7
10
  import type { HttpHost, HttpRouteRegistration } from '@zhin.js/host-http';
8
11
  import {
9
12
  type ConversationRef,
@@ -158,9 +161,9 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
158
161
  readonly #fetch: TelegramFetch;
159
162
  #pollAbort?: AbortController;
160
163
  #pollPromise?: Promise<void>;
161
- #routeReleases: HttpRouteRegistration[] = [];
164
+ readonly #lifecycle: EndpointLifecycle;
162
165
  #open = false;
163
- #started = false;
166
+ #admission?: AbortController;
164
167
  #updateOffset = 0;
165
168
  #botUserId?: number;
166
169
  #botUsername?: string;
@@ -175,6 +178,7 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
175
178
  constructor(options: TelegramEndpointOptions) {
176
179
  super();
177
180
  this.#logger = getAdapterLogger('telegram', options.config.id);
181
+ this.#lifecycle = createEndpointLifecycle({ name: options.config.id, reconnect: false });
178
182
  this.#options = options;
179
183
  this.#fetch = options.fetch ?? globalThis.fetch;
180
184
  }
@@ -201,79 +205,98 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
201
205
  }
202
206
 
203
207
  async start(): Promise<void> {
204
- if (this.#started) return;
205
- this.#started = true;
206
- try {
207
- const me = await this.callApi<{ id?: number; username?: string; first_name?: string }>('getMe');
208
- this.#botUserId = me.id;
209
- this.#botUsername = me.username;
210
-
211
- if (this.#options.config.mode === 'webhook') {
212
- if (!this.#options.http) {
213
- throw new TypeError('Telegram webhook mode requires httpHostToken');
208
+ await this.#lifecycle.start(async (handle) => {
209
+ const abort = new AbortController();
210
+ const routes: HttpRouteRegistration[] = [];
211
+ this.#pollAbort = abort;
212
+ const cleanup = () => {
213
+ abort.abort();
214
+ for (const release of routes.splice(0)) release();
215
+ if (this.#pollAbort === abort) {
216
+ this.#botUserId = undefined;
217
+ this.#botUsername = undefined;
214
218
  }
215
- this.#routeReleases.push(...registerTelegramWebhookRoutes(this.#options.http, this));
216
- const webhook = this.#options.config.webhook!;
217
- if (!webhook.secretToken) {
218
- // 未配 secretToken webhook 无鉴权:任何人知道 path 即可注入假 update。
219
- this.#logger.warn(formatCompact({
220
- op: 'webhook_no_secret',
219
+ };
220
+ handle.onForceClose(cleanup);
221
+ try {
222
+ const me = await this.callApi<{ id?: number; username?: string; first_name?: string }>('getMe', {}, abort.signal);
223
+ if (abort.signal.aborted) return;
224
+ this.#botUserId = me.id;
225
+ this.#botUsername = me.username;
226
+
227
+ if (this.#options.config.mode === 'webhook') {
228
+ if (!this.#options.http) {
229
+ throw new TypeError('Telegram webhook mode requires httpHostToken');
230
+ }
231
+ routes.push(...registerTelegramWebhookRoutes(this.#options.http, this));
232
+ const webhook = this.#options.config.webhook!;
233
+ if (!webhook.secretToken) {
234
+ // 未配 secretToken 时 webhook 无鉴权:任何人知道 path 即可注入假 update。
235
+ this.#logger.warn(formatCompact({
236
+ op: 'webhook_no_secret',
237
+ endpoint: this.#options.config.id,
238
+ path: webhook.path,
239
+ hint: 'set webhook.secretToken to authenticate Telegram callbacks',
240
+ }));
241
+ }
242
+ const url = buildWebhookUrl(webhook);
243
+ await this.callApi('setWebhook', {
244
+ url,
245
+ allowed_updates: this.#options.config.allowedUpdates,
246
+ ...(webhook.secretToken ? { secret_token: webhook.secretToken } : {}),
247
+ }, abort.signal);
248
+ if (abort.signal.aborted) return;
249
+ this.#logger.info(formatCompact({
250
+ op: 'connect',
221
251
  endpoint: this.#options.config.id,
252
+ mode: 'webhook',
222
253
  path: webhook.path,
223
- hint: 'set webhook.secretToken to authenticate Telegram callbacks',
254
+ username: me.username,
224
255
  }));
256
+ return;
225
257
  }
226
- const url = buildWebhookUrl(webhook);
227
- await this.callApi('setWebhook', {
228
- url,
229
- allowed_updates: this.#options.config.allowedUpdates,
230
- ...(webhook.secretToken ? { secret_token: webhook.secretToken } : {}),
231
- });
258
+
259
+ await this.callApi('deleteWebhook', { drop_pending_updates: false }, abort.signal);
260
+ if (abort.signal.aborted) return;
261
+ this.#pollPromise = runTelegramPollLoop(this, abort.signal);
232
262
  this.#logger.info(formatCompact({
233
263
  op: 'connect',
234
264
  endpoint: this.#options.config.id,
235
- mode: 'webhook',
236
- path: webhook.path,
265
+ mode: 'polling',
237
266
  username: me.username,
238
267
  }));
239
- return;
268
+ } catch (error) {
269
+ const cancelled = abort.signal.aborted;
270
+ cleanup();
271
+ if (cancelled) return;
272
+ this.#logger.error('Failed to connect Telegram bot:', error);
273
+ throw error;
240
274
  }
241
-
242
- await this.callApi('deleteWebhook', { drop_pending_updates: false });
243
- this.#pollAbort = new AbortController();
244
- this.#pollPromise = runTelegramPollLoop(this, this.#pollAbort.signal);
245
- this.#logger.info(formatCompact({
246
- op: 'connect',
247
- endpoint: this.#options.config.id,
248
- mode: 'polling',
249
- username: me.username,
250
- }));
251
- } catch (error) {
252
- await this.stop();
253
- this.#logger.error('Failed to connect Telegram bot:', error);
254
- throw error;
255
- }
275
+ });
256
276
  }
257
277
 
258
278
  open(): void {
279
+ if (this.#open) return;
280
+ this.#admission = new AbortController();
259
281
  this.#open = true;
260
282
  }
261
283
 
262
284
  close(): void {
263
285
  this.#open = false;
286
+ this.#admission?.abort();
287
+ this.#chatMemberCache.clear();
264
288
  }
265
289
 
266
290
  async stop(): Promise<void> {
267
- this.#open = false;
268
- this.#pollAbort?.abort();
291
+ this.close();
292
+ const polling = this.#pollPromise;
293
+ this.#pollPromise = undefined;
294
+ await this.#lifecycle.stop();
269
295
  try {
270
- await this.#pollPromise;
296
+ await polling;
271
297
  } catch {
272
298
  /* poll loop exit */
273
299
  }
274
- for (const release of this.#routeReleases.splice(0)) release();
275
- this.#chatMemberCache.clear();
276
- this.#started = false;
277
300
  this.#logger.debug(formatCompact({ op: 'disconnect' }));
278
301
  }
279
302
 
@@ -285,9 +308,13 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
285
308
  const result = form
286
309
  ? await this.callApiForm<{ message_id?: number }>(action.method, form)
287
310
  : await this.callApi<{ message_id?: number }>(action.method, action.params);
288
- if (result.message_id != null) lastId = String(result.message_id);
311
+ if (!result || !Number.isSafeInteger(result.message_id) || result.message_id! <= 0) {
312
+ throw new Error(`Telegram API ${action.method} returned no valid message_id; delivery is unconfirmed`);
313
+ }
314
+ lastId = String(result.message_id);
289
315
  }
290
- return lastId || `telegram-${Date.now()}`;
316
+ if (!lastId) throw new Error('Telegram message contains no sendable actions');
317
+ return lastId;
291
318
  }
292
319
 
293
320
  async recallMessage(message: MessageRef): Promise<void> {
@@ -376,9 +403,9 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
376
403
 
377
404
  /** Test / internal: admit a message when open. */
378
405
  admit(msg: TelegramMessage): void {
379
- if (!this.#open) return;
406
+ if (!this.#open || !this.#admission) return;
380
407
  const conversation = telegramInboundConversation(String(this.#options.id), msg.chat);
381
- void this.#admitWithSenderRole(msg, conversation).catch((err) => {
408
+ void this.#admitWithSenderRole(msg, conversation, this.#admission.signal).catch((err) => {
382
409
  this.#logger.warn(formatCompact({
383
410
  op: 'telegram_gateway_receive_failed',
384
411
  target: `${conversation.kind}:${conversation.id}`,
@@ -387,8 +414,9 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
387
414
  });
388
415
  }
389
416
 
390
- async #admitWithSenderRole(msg: TelegramMessage, conversation: ConversationRef): Promise<void> {
391
- const permit = await this.#resolveGroupSenderPermit(msg);
417
+ async #admitWithSenderRole(msg: TelegramMessage, conversation: ConversationRef, signal: AbortSignal): Promise<void> {
418
+ const permit = await this.#resolveGroupSenderPermit(msg, signal);
419
+ if (signal.aborted) return;
392
420
  // 新 Runtime Message.content 为纯文本:@ 本机只能经 metadata 传递
393
421
  const mentioned = this.#isBotMentioned(msg);
394
422
  await this.emit('message.receive', {
@@ -430,7 +458,7 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
430
458
  }
431
459
 
432
460
  /** 群消息 sender role 解析:getChatMember + 60s 缓存(对齐旧 enrichGroupSender)。 */
433
- async #resolveGroupSenderPermit(msg: TelegramMessage): Promise<ChatMemberPermit | undefined> {
461
+ async #resolveGroupSenderPermit(msg: TelegramMessage, signal: AbortSignal): Promise<ChatMemberPermit | undefined> {
434
462
  if (msg.chat.type === 'private' || !msg.from?.id) return undefined;
435
463
  const chatId = Number(msg.chat.id);
436
464
  const userId = msg.from.id;
@@ -443,7 +471,8 @@ export class TelegramEndpoint extends Endpoint<TelegramClient> {
443
471
  const member = await this.callApi<TelegramChatMember>('getChatMember', {
444
472
  chat_id: chatId,
445
473
  user_id: userId,
446
- });
474
+ }, signal);
475
+ if (signal.aborted) return undefined;
447
476
  const normalized = normalizeTelegramChatMember(member);
448
477
  const entry: ChatMemberPermit = { at: now, ...normalized };
449
478
  this.#chatMemberCache.set(key, entry);
package/src/polling.ts CHANGED
@@ -36,6 +36,8 @@ export async function runTelegramPollLoop(
36
36
  }, abortSignal);
37
37
  consecutiveFailures = 0;
38
38
  for (const update of updates) {
39
+ // A transport may resolve after cancellation; never admit that late batch.
40
+ if (abortSignal.aborted) return;
39
41
  host.setUpdateOffset(update.update_id + 1);
40
42
  host.handleUpdate(update);
41
43
  }
@@ -63,10 +65,12 @@ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
63
65
  resolve();
64
66
  return;
65
67
  }
66
- const timer = setTimeout(resolve, ms);
67
- signal?.addEventListener('abort', () => {
68
+ const finish = () => {
68
69
  clearTimeout(timer);
70
+ signal?.removeEventListener('abort', finish);
69
71
  resolve();
70
- }, { once: true });
72
+ };
73
+ const timer = setTimeout(finish, ms);
74
+ signal?.addEventListener('abort', finish, { once: true });
71
75
  });
72
76
  }