@zhin.js/adapter-telegram 9.0.0 → 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 +11 -0
- package/README.md +33 -9
- package/lib/endpoint.d.ts +1 -2
- package/lib/endpoint.js +92 -60
- package/lib/polling.js +8 -3
- package/package.json +9 -9
- package/src/endpoint.ts +88 -59
- package/src/polling.ts +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
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
|
+
|
|
3
14
|
## 9.0.0
|
|
4
15
|
|
|
5
16
|
### 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
|
|
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]`):`
|
|
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
|
-
-
|
|
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
|
|
70
|
+
配置 `polling: false`,启用 CLI HTTP Host,并提供公网 HTTPS 回调地址。
|
|
71
|
+
`webhook.path` 必须与反向代理转发路径一致,多账号使用不同路径。
|
|
71
72
|
|
|
72
|
-
|
|
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
|
-
|
|
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 配置报错 |
|
|
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
|
-
#
|
|
57
|
+
#lifecycle;
|
|
58
58
|
#open = false;
|
|
59
|
-
#
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
142
|
+
username: me.username,
|
|
113
143
|
}));
|
|
144
|
+
return;
|
|
114
145
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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: '
|
|
125
|
-
path: webhook.path,
|
|
153
|
+
mode: 'polling',
|
|
126
154
|
username: me.username,
|
|
127
155
|
}));
|
|
128
|
-
return;
|
|
129
156
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
154
|
-
this.#
|
|
179
|
+
this.close();
|
|
180
|
+
const polling = this.#pollPromise;
|
|
181
|
+
this.#pollPromise = undefined;
|
|
182
|
+
await this.#lifecycle.stop();
|
|
155
183
|
try {
|
|
156
|
-
await
|
|
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
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
signal?.addEventListener('abort', () => {
|
|
49
|
+
const finish = () => {
|
|
48
50
|
clearTimeout(timer);
|
|
51
|
+
signal?.removeEventListener('abort', finish);
|
|
49
52
|
resolve();
|
|
50
|
-
}
|
|
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": "9.0.
|
|
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.
|
|
36
|
-
"@zhin.js/core": "1.5.
|
|
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.
|
|
45
|
-
"@zhin.js/agent": "1.2.
|
|
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.
|
|
47
|
+
"@zhin.js/core": "1.5.16",
|
|
48
48
|
"@zhin.js/permission": "1.0.4",
|
|
49
|
-
"zhin.js": "7.0.
|
|
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.2.
|
|
70
|
+
"@zhin.js/agent": "1.2.1",
|
|
71
71
|
"@zhin.js/host-http": "1.0.17",
|
|
72
|
-
"zhin.js": "7.0.
|
|
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 {
|
|
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
|
-
#
|
|
164
|
+
readonly #lifecycle: EndpointLifecycle;
|
|
162
165
|
#open = false;
|
|
163
|
-
#
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
254
|
+
username: me.username,
|
|
224
255
|
}));
|
|
256
|
+
return;
|
|
225
257
|
}
|
|
226
|
-
|
|
227
|
-
await this.callApi('
|
|
228
|
-
|
|
229
|
-
|
|
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: '
|
|
236
|
-
path: webhook.path,
|
|
265
|
+
mode: 'polling',
|
|
237
266
|
username: me.username,
|
|
238
267
|
}));
|
|
239
|
-
|
|
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
|
|
268
|
-
this.#
|
|
291
|
+
this.close();
|
|
292
|
+
const polling = this.#pollPromise;
|
|
293
|
+
this.#pollPromise = undefined;
|
|
294
|
+
await this.#lifecycle.stop();
|
|
269
295
|
try {
|
|
270
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
|
67
|
-
signal?.addEventListener('abort', () => {
|
|
68
|
+
const finish = () => {
|
|
68
69
|
clearTimeout(timer);
|
|
70
|
+
signal?.removeEventListener('abort', finish);
|
|
69
71
|
resolve();
|
|
70
|
-
}
|
|
72
|
+
};
|
|
73
|
+
const timer = setTimeout(finish, ms);
|
|
74
|
+
signal?.addEventListener('abort', finish, { once: true });
|
|
71
75
|
});
|
|
72
76
|
}
|