mirror of
https://github.com/hansjone/dsh-im-ops.git
synced 2026-10-08 23:13:17 +08:00
fix(telegram): isolate long polling transport
This commit is contained in:
parent
02ddea0054
commit
14519060f0
13 changed files with 603 additions and 181 deletions
|
|
@ -6,6 +6,11 @@ This file records the notable changes in each dsh-im release. Its format follows
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed / 修复
|
||||||
|
|
||||||
|
- Telegram 机器人现在为长轮询和发送请求使用同一 Runtime 私有、代理感知的有限连接池,避免全局 HTTP 连接受限时 `getUpdates` 阻塞消息发送;停止或启动失败时会显式释放连接池。
|
||||||
|
Telegram bots now use a private, proxy-aware bounded connection pool per Runtime for both long polling and sends, preventing `getUpdates` from blocking delivery when global HTTP connections are constrained; the pool is explicitly released on stop and failed startup.
|
||||||
|
|
||||||
## [4.2.0] - 2026-08-31
|
## [4.2.0] - 2026-08-31
|
||||||
|
|
||||||
### Added / 新增
|
### Added / 新增
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ The WeCom QR-authorization request sequence is adapted from the official [`@weco
|
||||||
|
|
||||||
The Host bundle includes [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`@whiskeysockets/baileys`](https://github.com/WhiskeySockets/Baileys) 7.0.0-rc14, [`https-proxy-agent`](https://github.com/TooTallNate/proxy-agents) 5.0.1, and their [`protobufjs`](https://github.com/protobufjs/protobuf.js) 7.6.5 runtime. The Lark SDK, Baileys, and https-proxy-agent are licensed under the MIT License. protobufjs is licensed under the BSD 3-Clause License; both license texts are reproduced below.
|
The Host bundle includes [`@larksuiteoapi/node-sdk`](https://github.com/larksuite/node-sdk) 1.73.0, [`@whiskeysockets/baileys`](https://github.com/WhiskeySockets/Baileys) 7.0.0-rc14, [`https-proxy-agent`](https://github.com/TooTallNate/proxy-agents) 5.0.1, and their [`protobufjs`](https://github.com/protobufjs/protobuf.js) 7.6.5 runtime. The Lark SDK, Baileys, and https-proxy-agent are licensed under the MIT License. protobufjs is licensed under the BSD 3-Clause License; both license texts are reproduced below.
|
||||||
|
|
||||||
This package depends at runtime on [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, and [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队.
|
This package depends at runtime on [`dingtalk-stream`](https://github.com/open-dingtalk/dingtalk-stream-sdk-nodejs) 2.1.4, [`@wecom/aibot-node-sdk`](https://github.com/WecomTeam/aibot-node-sdk) 1.0.7, [`@tencent-connect/qqbot-nodejs`](https://github.com/tencent-connect/qqbot) 1.0.4, [`qrcode`](https://github.com/soldair/node-qrcode) 1.5.4, and [`undici`](https://github.com/nodejs/undici) 7.29.0. These packages are licensed under the MIT License; `dingtalk-stream` is copyright 2023 钉钉开放平台团队, and Undici is copyright Matteo Collina and Undici contributors.
|
||||||
|
|
||||||
QQ QR binding uses Tencent Connect's official [`@tencent-connect/qqbot-connector`](https://www.npmjs.com/package/@tencent-connect/qqbot-connector) 1.2.0 package as an external runtime dependency. Its npm metadata declares `UNLICENSED`; no connector source is copied into this project.
|
QQ QR binding uses Tencent Connect's official [`@tencent-connect/qqbot-connector`](https://www.npmjs.com/package/@tencent-connect/qqbot-connector) 1.2.0 package as an external runtime dependency. Its npm metadata declares `UNLICENSED`; no connector source is copied into this project.
|
||||||
|
|
||||||
|
|
@ -38,6 +38,30 @@ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
||||||
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
|
||||||
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
## Undici license
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) Matteo Collina and Undici contributors
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
## Lark Node SDK license
|
## Lark Node SDK license
|
||||||
|
|
||||||
MIT License
|
MIT License
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Issue #85:Telegram 长轮询阻塞发送最小修复方案
|
# Issue #85:Telegram 长轮询阻塞发送最小修复方案
|
||||||
|
|
||||||
日期:2026-08-30。代码基线:v4.1.0 / `e08c3e9`。状态:待实施。
|
方案日期:2026-08-30。实施基线:v4.2.0 / `02ddea0`。状态:已实施并验证(2026-08-31)。
|
||||||
|
|
||||||
需求来源:[Issue #85](https://github.com/xmanrui/dsh-im/issues/85)。本文只定义关闭该 Issue 所需的最小改动;列出的测试均为待补测试。
|
需求来源:[Issue #85](https://github.com/xmanrui/dsh-im/issues/85)。本文定义并记录关闭该 Issue 所需的最小改动及验收结果。
|
||||||
|
|
||||||
## 1. 决策
|
## 1. 决策
|
||||||
|
|
||||||
|
|
@ -142,10 +142,10 @@ Runtime 注入配套的 `undici.fetch + undici.FormData`。其他直接构造 `T
|
||||||
| `src/channels/telegram/telegram-runtime.mjs` | 创建、注入和释放 transport |
|
| `src/channels/telegram/telegram-runtime.mjs` | 创建、注入和释放 transport |
|
||||||
| `plugin-src/host/build.mjs` | 外置 `undici` |
|
| `plugin-src/host/build.mjs` | 外置 `undici` |
|
||||||
| `scripts/verify-package.mjs` | 校验直接依赖和 bundle 外置 |
|
| `scripts/verify-package.mjs` | 校验直接依赖和 bundle 外置 |
|
||||||
| `test/channels/telegram/telegram-http.test.mjs` | 核心传输回归 |
|
| `test/channels/telegram/telegram-http.test.mjs` | 核心传输、真实 multipart 和 Runtime 资源所有权回归 |
|
||||||
| `test/channels/telegram/telegram.test.mjs` | API 和 Runtime 最小测试 |
|
| `test/channels/telegram/telegram.test.mjs` | 复用现有 API、超时及 uncertain 语义测试,无需修改 |
|
||||||
| `THIRD_PARTY_NOTICES.md`、`CHANGELOG.md` | 许可证和修复记录 |
|
| `THIRD_PARTY_NOTICES.md`、`CHANGELOG.md` | 许可证和修复记录 |
|
||||||
| `lib/index.js` | 通过现有构建命令生成 |
|
| `lib/index.js`、`lib/client.js` | 通过现有构建命令生成 |
|
||||||
|
|
||||||
## 6. 最小测试集
|
## 6. 最小测试集
|
||||||
|
|
||||||
|
|
@ -199,6 +199,8 @@ npm pack
|
||||||
- 没有新增最终消息或文件上传重试。
|
- 没有新增最终消息或文件上传重试。
|
||||||
- 完整构建、测试和干净发布包安装通过。
|
- 完整构建、测试和干净发布包安装通过。
|
||||||
|
|
||||||
|
实施验收记录:`npm run check` 构建成功且 1949 项测试全部通过;实际 tarball 在空临时目录安装后可导入 `lib/index.js`,并可从安装包执行本地 Telegram transport 请求;本机 DSH 重启加载新构建后,在 Telegram Runtime 长轮询运行期间向真实会话发送成功,HTTP 投递返回 200。
|
||||||
|
|
||||||
## 8. 后续加固
|
## 8. 后续加固
|
||||||
|
|
||||||
以下内容单独建 issue,不阻塞 #85:
|
以下内容单独建 issue,不阻塞 #85:
|
||||||
|
|
|
||||||
|
|
@ -670,7 +670,8 @@ var package_default = {
|
||||||
"@tencent-connect/qqbot-nodejs": "1.0.4",
|
"@tencent-connect/qqbot-nodejs": "1.0.4",
|
||||||
"@wecom/aibot-node-sdk": "1.0.7",
|
"@wecom/aibot-node-sdk": "1.0.7",
|
||||||
"dingtalk-stream": "2.1.4",
|
"dingtalk-stream": "2.1.4",
|
||||||
qrcode: "1.5.4"
|
qrcode: "1.5.4",
|
||||||
|
undici: "7.29.0"
|
||||||
},
|
},
|
||||||
devDependencies: {
|
devDependencies: {
|
||||||
"@deepseek-ai/cordis": "4.0.1",
|
"@deepseek-ai/cordis": "4.0.1",
|
||||||
|
|
|
||||||
338
lib/index.js
338
lib/index.js
File diff suppressed because one or more lines are too long
12
package-lock.json
generated
12
package-lock.json
generated
|
|
@ -13,7 +13,8 @@
|
||||||
"@tencent-connect/qqbot-nodejs": "1.0.4",
|
"@tencent-connect/qqbot-nodejs": "1.0.4",
|
||||||
"@wecom/aibot-node-sdk": "1.0.7",
|
"@wecom/aibot-node-sdk": "1.0.7",
|
||||||
"dingtalk-stream": "2.1.4",
|
"dingtalk-stream": "2.1.4",
|
||||||
"qrcode": "1.5.4"
|
"qrcode": "1.5.4",
|
||||||
|
"undici": "7.29.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"dsh-im": "bin/dsh-im.mjs"
|
"dsh-im": "bin/dsh-im.mjs"
|
||||||
|
|
@ -2801,6 +2802,15 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.29.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
||||||
|
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,8 @@
|
||||||
"@tencent-connect/qqbot-nodejs": "1.0.4",
|
"@tencent-connect/qqbot-nodejs": "1.0.4",
|
||||||
"@wecom/aibot-node-sdk": "1.0.7",
|
"@wecom/aibot-node-sdk": "1.0.7",
|
||||||
"dingtalk-stream": "2.1.4",
|
"dingtalk-stream": "2.1.4",
|
||||||
"qrcode": "1.5.4"
|
"qrcode": "1.5.4",
|
||||||
|
"undici": "7.29.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@deepseek-ai/cordis": "4.0.1",
|
"@deepseek-ai/cordis": "4.0.1",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ const externalRuntimePackages = [
|
||||||
'@wecom/aibot-node-sdk',
|
'@wecom/aibot-node-sdk',
|
||||||
'dingtalk-stream',
|
'dingtalk-stream',
|
||||||
'qrcode',
|
'qrcode',
|
||||||
|
'undici',
|
||||||
];
|
];
|
||||||
const external = externalRuntimePackages.flatMap((name) => [name, `${name}/*`]);
|
const external = externalRuntimePackages.flatMap((name) => [name, `${name}/*`]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const required = [
|
||||||
'src/channels/slack/slack-runtime.mjs',
|
'src/channels/slack/slack-runtime.mjs',
|
||||||
'src/channels/wecom/wecom-runtime.mjs',
|
'src/channels/wecom/wecom-runtime.mjs',
|
||||||
'src/channels/telegram/telegram-runtime.mjs',
|
'src/channels/telegram/telegram-runtime.mjs',
|
||||||
|
'src/channels/telegram/telegram-http.mjs',
|
||||||
'src/channels/discord/discord-runtime.mjs',
|
'src/channels/discord/discord-runtime.mjs',
|
||||||
'src/channels/whatsapp/whatsapp-runtime.mjs',
|
'src/channels/whatsapp/whatsapp-runtime.mjs',
|
||||||
'src/channels/whatsapp/whatsapp-web-session.mjs',
|
'src/channels/whatsapp/whatsapp-web-session.mjs',
|
||||||
|
|
@ -198,6 +199,7 @@ const directDependencies = {
|
||||||
'@tencent-connect/qqbot-nodejs': '1.0.4',
|
'@tencent-connect/qqbot-nodejs': '1.0.4',
|
||||||
'@wecom/aibot-node-sdk': '1.0.7',
|
'@wecom/aibot-node-sdk': '1.0.7',
|
||||||
qrcode: '1.5.4',
|
qrcode: '1.5.4',
|
||||||
|
undici: '7.29.0',
|
||||||
};
|
};
|
||||||
for (const [name, version] of Object.entries(directDependencies)) {
|
for (const [name, version] of Object.entries(directDependencies)) {
|
||||||
if (manifest.dependencies?.[name] !== version) {
|
if (manifest.dependencies?.[name] !== version) {
|
||||||
|
|
@ -226,6 +228,9 @@ if (manifest.bin?.['dsh-im'] !== 'bin/dsh-im.mjs') {
|
||||||
if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whiskeysockets\/baileys|https-proxy-agent|protobufjs)(?:\/[^"']*)?["']/.test(host)) {
|
if (/(?:from\s*|import\s*\(|require\s*\()\s*["'](?:@larksuiteoapi\/node-sdk|@whiskeysockets\/baileys|https-proxy-agent|protobufjs)(?:\/[^"']*)?["']/.test(host)) {
|
||||||
throw new Error('host bundle must not import a bundled SDK, proxy agent, or protobufjs at runtime');
|
throw new Error('host bundle must not import a bundled SDK, proxy agent, or protobufjs at runtime');
|
||||||
}
|
}
|
||||||
|
if (!/(?:from\s*|import\s*\()\s*["']undici["']/.test(host)) {
|
||||||
|
throw new Error('host bundle must retain undici as an external runtime dependency');
|
||||||
|
}
|
||||||
if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
|
if ((executable.mode & 0o111) === 0) throw new Error('dsh-im CLI is not executable');
|
||||||
if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
|
if (/private-bot-token|must-be-rolled-back|DEEPSEEK_API_KEY=/.test(client + host)) {
|
||||||
throw new Error('built artifacts contain a test or environment secret marker');
|
throw new Error('built artifacts contain a test or environment secret marker');
|
||||||
|
|
|
||||||
|
|
@ -105,19 +105,23 @@ export const COMMANDS_MENU_BUTTON = Object.freeze({ type: 'commands' });
|
||||||
export class TelegramApi {
|
export class TelegramApi {
|
||||||
#token;
|
#token;
|
||||||
#fetch;
|
#fetch;
|
||||||
|
#FormDataImpl;
|
||||||
#baseUrl;
|
#baseUrl;
|
||||||
#fileUploadTimeoutMs;
|
#fileUploadTimeoutMs;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
token,
|
token,
|
||||||
fetchImpl = fetch,
|
fetchImpl = fetch,
|
||||||
|
FormDataImpl = globalThis.FormData,
|
||||||
baseUrl = DEFAULT_BASE_URL,
|
baseUrl = DEFAULT_BASE_URL,
|
||||||
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
|
||||||
}) {
|
}) {
|
||||||
if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
|
if (!validTelegramToken(token)) throw new TypeError('Telegram Bot Token is invalid');
|
||||||
if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
|
if (typeof fetchImpl !== 'function') throw new TypeError('TelegramApi requires fetch');
|
||||||
|
if (typeof FormDataImpl !== 'function') throw new TypeError('TelegramApi requires FormData');
|
||||||
this.#token = token.trim();
|
this.#token = token.trim();
|
||||||
this.#fetch = fetchImpl;
|
this.#fetch = fetchImpl;
|
||||||
|
this.#FormDataImpl = FormDataImpl;
|
||||||
this.#baseUrl = new URL(baseUrl);
|
this.#baseUrl = new URL(baseUrl);
|
||||||
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
this.#fileUploadTimeoutMs = positiveTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
|
||||||
}
|
}
|
||||||
|
|
@ -273,7 +277,7 @@ export class TelegramApi {
|
||||||
|| !Buffer.isBuffer(file.bytes)) {
|
|| !Buffer.isBuffer(file.bytes)) {
|
||||||
throw new TypeError(`A Telegram ${mediaLabel} is required`);
|
throw new TypeError(`A Telegram ${mediaLabel} is required`);
|
||||||
}
|
}
|
||||||
const payload = new FormData();
|
const payload = new this.#FormDataImpl();
|
||||||
payload.append('chat_id', String(chatId));
|
payload.append('chat_id', String(chatId));
|
||||||
payload.append(
|
payload.append(
|
||||||
fieldName,
|
fieldName,
|
||||||
|
|
|
||||||
21
src/channels/telegram/telegram-http.mjs
Normal file
21
src/channels/telegram/telegram-http.mjs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import {
|
||||||
|
EnvHttpProxyAgent,
|
||||||
|
FormData as UndiciFormData,
|
||||||
|
fetch as undiciFetch,
|
||||||
|
} from 'undici';
|
||||||
|
|
||||||
|
const TELEGRAM_CONNECTIONS = 4;
|
||||||
|
|
||||||
|
export function createTelegramHttpTransport() {
|
||||||
|
const dispatcher = new EnvHttpProxyAgent({ connections: TELEGRAM_CONNECTIONS });
|
||||||
|
let destroyed = false;
|
||||||
|
return {
|
||||||
|
fetchImpl: (url, options = {}) => undiciFetch(url, { ...options, dispatcher }),
|
||||||
|
FormDataImpl: UndiciFormData,
|
||||||
|
async destroy() {
|
||||||
|
if (destroyed) return;
|
||||||
|
destroyed = true;
|
||||||
|
await dispatcher.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
|
||||||
import { t } from '../shared/i18n.mjs';
|
import { t } from '../shared/i18n.mjs';
|
||||||
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
|
import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
|
||||||
import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs';
|
import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs';
|
||||||
|
import { createTelegramHttpTransport } from './telegram-http.mjs';
|
||||||
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
|
import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
|
||||||
import {
|
import {
|
||||||
splitTelegramRegularText,
|
splitTelegramRegularText,
|
||||||
|
|
@ -671,9 +672,11 @@ export class TelegramRuntime {
|
||||||
#logger;
|
#logger;
|
||||||
#replyTimeoutMs;
|
#replyTimeoutMs;
|
||||||
#createApi;
|
#createApi;
|
||||||
|
#createHttpTransport;
|
||||||
#accessMode;
|
#accessMode;
|
||||||
#allowedPrivateUserIds;
|
#allowedPrivateUserIds;
|
||||||
#status = createTelegramRuntimeStatus();
|
#status = createTelegramRuntimeStatus();
|
||||||
|
#httpTransport = null;
|
||||||
#api = null;
|
#api = null;
|
||||||
#bridge = null;
|
#bridge = null;
|
||||||
#abortController = null;
|
#abortController = null;
|
||||||
|
|
@ -689,6 +692,7 @@ export class TelegramRuntime {
|
||||||
logger = console,
|
logger = console,
|
||||||
replyTimeoutMs = 600_000,
|
replyTimeoutMs = 600_000,
|
||||||
createApi = (options) => new TelegramApi(options),
|
createApi = (options) => new TelegramApi(options),
|
||||||
|
createHttpTransport = createTelegramHttpTransport,
|
||||||
}) {
|
}) {
|
||||||
if (!config || !token || !harness || !state) {
|
if (!config || !token || !harness || !state) {
|
||||||
throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
|
throw new TypeError('TelegramRuntime requires config, token, Harness, and state');
|
||||||
|
|
@ -701,6 +705,7 @@ export class TelegramRuntime {
|
||||||
this.#logger = logger;
|
this.#logger = logger;
|
||||||
this.#replyTimeoutMs = replyTimeoutMs;
|
this.#replyTimeoutMs = replyTimeoutMs;
|
||||||
this.#createApi = createApi;
|
this.#createApi = createApi;
|
||||||
|
this.#createHttpTransport = createHttpTransport;
|
||||||
const accessPolicy = normalizeTelegramAccessPolicy(config);
|
const accessPolicy = normalizeTelegramAccessPolicy(config);
|
||||||
this.#accessMode = accessPolicy.accessMode;
|
this.#accessMode = accessPolicy.accessMode;
|
||||||
this.#allowedPrivateUserIds = new Set(accessPolicy.allowedUsers);
|
this.#allowedPrivateUserIds = new Set(accessPolicy.allowedUsers);
|
||||||
|
|
@ -762,9 +767,15 @@ export class TelegramRuntime {
|
||||||
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.#abortController = controller;
|
this.#abortController = controller;
|
||||||
const api = this.#createApi({ token: this.#token });
|
|
||||||
this.#api = api;
|
|
||||||
try {
|
try {
|
||||||
|
const transport = this.#createHttpTransport();
|
||||||
|
this.#httpTransport = transport;
|
||||||
|
const api = this.#createApi({
|
||||||
|
token: this.#token,
|
||||||
|
fetchImpl: transport.fetchImpl,
|
||||||
|
FormDataImpl: transport.FormDataImpl,
|
||||||
|
});
|
||||||
|
this.#api = api;
|
||||||
const bot = await api.getMe({ signal: controller.signal });
|
const bot = await api.getMe({ signal: controller.signal });
|
||||||
if (String(bot?.id ?? '') !== this.#config.platformId || bot?.is_bot !== true) {
|
if (String(bot?.id ?? '') !== this.#config.platformId || bot?.is_bot !== true) {
|
||||||
throw new Error('Telegram token identity does not match the saved bot');
|
throw new Error('Telegram token identity does not match the saved bot');
|
||||||
|
|
@ -878,9 +889,11 @@ export class TelegramRuntime {
|
||||||
async stop() {
|
async stop() {
|
||||||
const pollTask = this.#pollTask;
|
const pollTask = this.#pollTask;
|
||||||
const bridge = this.#bridge;
|
const bridge = this.#bridge;
|
||||||
|
const httpTransport = this.#httpTransport;
|
||||||
this.#abortController?.abort();
|
this.#abortController?.abort();
|
||||||
this.#abortController = null;
|
this.#abortController = null;
|
||||||
this.#pollTask = null;
|
this.#pollTask = null;
|
||||||
|
this.#httpTransport = null;
|
||||||
this.#api = null;
|
this.#api = null;
|
||||||
this.#bridge = null;
|
this.#bridge = null;
|
||||||
await Promise.race([
|
await Promise.race([
|
||||||
|
|
@ -891,6 +904,11 @@ export class TelegramRuntime {
|
||||||
bridge?.waitForIdle() ?? Promise.resolve(),
|
bridge?.waitForIdle() ?? Promise.resolve(),
|
||||||
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
new Promise((resolve) => setTimeout(resolve, 2_000)),
|
||||||
]);
|
]);
|
||||||
|
try {
|
||||||
|
await httpTransport?.destroy();
|
||||||
|
} catch {
|
||||||
|
this.#logger.warn?.('[dsh-im:telegram] Telegram HTTP transport cleanup failed');
|
||||||
|
}
|
||||||
this.#status.ready = false;
|
this.#status.ready = false;
|
||||||
this.#status.connectionState = 'idle';
|
this.#status.connectionState = 'idle';
|
||||||
return this.status;
|
return this.status;
|
||||||
|
|
|
||||||
330
test/channels/telegram/telegram-http.test.mjs
Normal file
330
test/channels/telegram/telegram-http.test.mjs
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { TelegramApi } from '../../../src/channels/telegram/telegram-api.mjs';
|
||||||
|
import { createTelegramHttpTransport } from '../../../src/channels/telegram/telegram-http.mjs';
|
||||||
|
import { TelegramRuntime } from '../../../src/channels/telegram/telegram-runtime.mjs';
|
||||||
|
|
||||||
|
const TOKEN = '123456789:ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef123456';
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const packageRoot = resolve(import.meta.dirname, '../../..');
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolvePromise;
|
||||||
|
let rejectPromise;
|
||||||
|
const promise = new Promise((resolveValue, rejectValue) => {
|
||||||
|
resolvePromise = resolveValue;
|
||||||
|
rejectPromise = rejectValue;
|
||||||
|
});
|
||||||
|
return { promise, resolve: resolvePromise, reject: rejectPromise };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bounded(promise, message, timeoutMs = 1_000) {
|
||||||
|
let timer;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listen(server) {
|
||||||
|
await new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
server.once('error', rejectPromise);
|
||||||
|
server.listen(0, '127.0.0.1', resolvePromise);
|
||||||
|
});
|
||||||
|
const address = server.address();
|
||||||
|
return `http://127.0.0.1:${address.port}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function close(server) {
|
||||||
|
await new Promise((resolvePromise, rejectPromise) => {
|
||||||
|
server.close((error) => (error ? rejectPromise(error) : resolvePromise()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function withLocalhostProxyBypass() {
|
||||||
|
const names = ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', 'no_proxy', 'NO_PROXY'];
|
||||||
|
const previous = Object.fromEntries(names.map((name) => [name, process.env[name]]));
|
||||||
|
for (const name of names) delete process.env[name];
|
||||||
|
process.env.no_proxy = '127.0.0.1,localhost';
|
||||||
|
process.env.NO_PROXY = '127.0.0.1,localhost';
|
||||||
|
return () => {
|
||||||
|
for (const name of names) {
|
||||||
|
if (previous[name] === undefined) delete process.env[name];
|
||||||
|
else process.env[name] = previous[name];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Telegram private transport sends while a long poll remains open', async () => {
|
||||||
|
const pollStarted = deferred();
|
||||||
|
const releasePoll = deferred();
|
||||||
|
const events = [];
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
request.resume();
|
||||||
|
if (request.url.endsWith('/getUpdates')) {
|
||||||
|
events.push('poll-started');
|
||||||
|
pollStarted.resolve();
|
||||||
|
void releasePoll.promise.then(() => {
|
||||||
|
events.push('poll-released');
|
||||||
|
response.setHeader('content-type', 'application/json');
|
||||||
|
response.end(JSON.stringify({ ok: true, result: [] }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.push('send-completed');
|
||||||
|
response.setHeader('content-type', 'application/json');
|
||||||
|
response.end(JSON.stringify({ ok: true, result: true }));
|
||||||
|
});
|
||||||
|
const baseUrl = await listen(server);
|
||||||
|
const restoreProxy = withLocalhostProxyBypass();
|
||||||
|
const transport = createTelegramHttpTransport();
|
||||||
|
const api = new TelegramApi({
|
||||||
|
token: TOKEN,
|
||||||
|
baseUrl,
|
||||||
|
fetchImpl: transport.fetchImpl,
|
||||||
|
FormDataImpl: transport.FormDataImpl,
|
||||||
|
});
|
||||||
|
let pollSettled = false;
|
||||||
|
const poll = api.getUpdates().finally(() => { pollSettled = true; });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bounded(pollStarted.promise, 'Telegram long poll did not start');
|
||||||
|
assert.equal(await bounded(
|
||||||
|
api.sendChatAction({ chatId: 42 }),
|
||||||
|
'Telegram send was blocked behind its long poll',
|
||||||
|
), true);
|
||||||
|
assert.equal(pollSettled, false);
|
||||||
|
assert.deepEqual(events, ['poll-started', 'send-completed']);
|
||||||
|
releasePoll.resolve();
|
||||||
|
assert.deepEqual(await poll, []);
|
||||||
|
assert.deepEqual(events, ['poll-started', 'send-completed', 'poll-released']);
|
||||||
|
} finally {
|
||||||
|
releasePoll.resolve();
|
||||||
|
await poll.catch(() => undefined);
|
||||||
|
await transport.destroy();
|
||||||
|
restoreProxy();
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Telegram private transport is independent from a saturated global dispatcher', async () => {
|
||||||
|
const source = String.raw`
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { Agent, fetch, setGlobalDispatcher } from 'undici';
|
||||||
|
import { createTelegramHttpTransport } from './src/channels/telegram/telegram-http.mjs';
|
||||||
|
|
||||||
|
let releaseHold;
|
||||||
|
let markHoldStarted;
|
||||||
|
const holdStarted = new Promise((resolve) => { markHoldStarted = resolve; });
|
||||||
|
const holdReleased = new Promise((resolve) => { releaseHold = resolve; });
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
request.resume();
|
||||||
|
if (request.url === '/hold') {
|
||||||
|
markHoldStarted();
|
||||||
|
void holdReleased.then(() => response.end('held'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.end('private');
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', resolve);
|
||||||
|
});
|
||||||
|
const origin = 'http://127.0.0.1:' + server.address().port;
|
||||||
|
const globalAgent = new Agent({ connections: 1 });
|
||||||
|
setGlobalDispatcher(globalAgent);
|
||||||
|
const occupied = fetch(origin + '/hold');
|
||||||
|
await holdStarted;
|
||||||
|
const transport = createTelegramHttpTransport();
|
||||||
|
let timeout;
|
||||||
|
try {
|
||||||
|
const response = await Promise.race([
|
||||||
|
transport.fetchImpl(origin + '/send'),
|
||||||
|
new Promise((_, reject) => { timeout = setTimeout(
|
||||||
|
() => reject(new Error('private transport used the saturated global dispatcher')),
|
||||||
|
1_000,
|
||||||
|
); }),
|
||||||
|
]);
|
||||||
|
if (await response.text() !== 'private') throw new Error('unexpected private response');
|
||||||
|
process.stdout.write('independent');
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
releaseHold();
|
||||||
|
await occupied;
|
||||||
|
await transport.destroy();
|
||||||
|
await globalAgent.destroy();
|
||||||
|
await new Promise((resolve, reject) => server.close(
|
||||||
|
(error) => error ? reject(error) : resolve(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
const childEnv = { ...process.env };
|
||||||
|
for (const name of ['http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY']) {
|
||||||
|
delete childEnv[name];
|
||||||
|
}
|
||||||
|
childEnv.no_proxy = '127.0.0.1,localhost';
|
||||||
|
childEnv.NO_PROXY = '127.0.0.1,localhost';
|
||||||
|
const { stdout, stderr } = await execFileAsync(process.execPath, [
|
||||||
|
'--input-type=module',
|
||||||
|
'--eval',
|
||||||
|
source,
|
||||||
|
], {
|
||||||
|
cwd: packageRoot,
|
||||||
|
env: childEnv,
|
||||||
|
timeout: 5_000,
|
||||||
|
});
|
||||||
|
assert.equal(stderr, '');
|
||||||
|
assert.equal(stdout, 'independent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Telegram API sends real undici multipart data without losing metadata or bytes', async () => {
|
||||||
|
const received = deferred();
|
||||||
|
const server = createServer((request, response) => {
|
||||||
|
const chunks = [];
|
||||||
|
request.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
request.on('end', () => {
|
||||||
|
received.resolve({
|
||||||
|
contentType: request.headers['content-type'],
|
||||||
|
body: Buffer.concat(chunks),
|
||||||
|
});
|
||||||
|
response.setHeader('content-type', 'application/json');
|
||||||
|
response.end(JSON.stringify({ ok: true, result: { message_id: 901 } }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const baseUrl = await listen(server);
|
||||||
|
const restoreProxy = withLocalhostProxyBypass();
|
||||||
|
const transport = createTelegramHttpTransport();
|
||||||
|
const bytes = Buffer.from([0, 1, 2, 3, 0xff, 0x80, 0x41]);
|
||||||
|
const api = new TelegramApi({
|
||||||
|
token: TOKEN,
|
||||||
|
baseUrl,
|
||||||
|
fetchImpl: transport.fetchImpl,
|
||||||
|
FormDataImpl: transport.FormDataImpl,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
assert.deepEqual(await api.sendDocument({
|
||||||
|
chatId: -100123,
|
||||||
|
replyToMessageId: 44,
|
||||||
|
messageThreadId: 55,
|
||||||
|
file: {
|
||||||
|
fileName: 'result.bin',
|
||||||
|
mediaType: 'application/octet-stream',
|
||||||
|
bytes,
|
||||||
|
},
|
||||||
|
}), { message_id: 901 });
|
||||||
|
const request = await bounded(received.promise, 'Telegram multipart request was not received');
|
||||||
|
assert.match(request.contentType, /^multipart\/form-data; boundary=/);
|
||||||
|
assert.ok(request.body.includes(Buffer.from('name="chat_id"\r\n\r\n-100123')));
|
||||||
|
assert.ok(request.body.includes(Buffer.from('name="message_thread_id"\r\n\r\n55')));
|
||||||
|
assert.ok(request.body.includes(Buffer.from('name="reply_parameters"')));
|
||||||
|
assert.ok(request.body.includes(Buffer.from('"message_id":44')));
|
||||||
|
assert.ok(request.body.includes(Buffer.from(
|
||||||
|
'name="document"; filename="result.bin"\r\nContent-Type: application/octet-stream',
|
||||||
|
)));
|
||||||
|
assert.ok(request.body.includes(bytes));
|
||||||
|
} finally {
|
||||||
|
await transport.destroy();
|
||||||
|
restoreProxy();
|
||||||
|
await close(server);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function runtimeState(cursor = 0) {
|
||||||
|
return {
|
||||||
|
cursor: () => cursor,
|
||||||
|
setCursor: async (value) => { cursor = value; },
|
||||||
|
hasSeen: () => false,
|
||||||
|
markSeen: async () => undefined,
|
||||||
|
sessionFor: () => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeConfig() {
|
||||||
|
return {
|
||||||
|
botId: 'telegram_transport_test',
|
||||||
|
platformId: '123456789',
|
||||||
|
username: 'HarnessBot',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthyApi() {
|
||||||
|
return {
|
||||||
|
getMe: async () => ({ id: 123456789, is_bot: true }),
|
||||||
|
getWebhookInfo: async () => ({ url: '' }),
|
||||||
|
setMyCommands: async () => true,
|
||||||
|
setChatMenuButton: async () => true,
|
||||||
|
getUpdates: async ({ signal }) => new Promise((_, reject) => {
|
||||||
|
if (signal.aborted) reject(signal.reason);
|
||||||
|
else signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function fakeTransport() {
|
||||||
|
let destroyCalls = 0;
|
||||||
|
class FakeFormData {}
|
||||||
|
const fetchImpl = async () => undefined;
|
||||||
|
return {
|
||||||
|
fetchImpl,
|
||||||
|
FormDataImpl: FakeFormData,
|
||||||
|
destroy: async () => { destroyCalls += 1; },
|
||||||
|
get destroyCalls() { return destroyCalls; },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Telegram Runtime injects and destroys one private transport exactly once', async () => {
|
||||||
|
const transport = fakeTransport();
|
||||||
|
let apiOptions;
|
||||||
|
const runtime = new TelegramRuntime({
|
||||||
|
config: runtimeConfig(),
|
||||||
|
token: TOKEN,
|
||||||
|
harness: { ensureRunning: async () => true },
|
||||||
|
state: runtimeState(),
|
||||||
|
createHttpTransport: () => transport,
|
||||||
|
createApi: (options) => {
|
||||||
|
apiOptions = options;
|
||||||
|
return healthyApi();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await runtime.start();
|
||||||
|
assert.equal(apiOptions.fetchImpl, transport.fetchImpl);
|
||||||
|
assert.equal(apiOptions.FormDataImpl, transport.FormDataImpl);
|
||||||
|
await runtime.stop();
|
||||||
|
await runtime.stop();
|
||||||
|
assert.equal(transport.destroyCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const failurePoint of ['getMe', 'createApi']) {
|
||||||
|
test(`Telegram Runtime destroys its private transport when ${failurePoint} fails`, async () => {
|
||||||
|
const transport = fakeTransport();
|
||||||
|
const failure = new Error(`${failurePoint} failed`);
|
||||||
|
const runtime = new TelegramRuntime({
|
||||||
|
config: runtimeConfig(),
|
||||||
|
token: TOKEN,
|
||||||
|
harness: { ensureRunning: async () => true },
|
||||||
|
state: runtimeState(),
|
||||||
|
createHttpTransport: () => transport,
|
||||||
|
createApi: failurePoint === 'createApi'
|
||||||
|
? () => { throw failure; }
|
||||||
|
: () => ({ ...healthyApi(), getMe: async () => { throw failure; } }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(runtime.start(), (error) => error === failure);
|
||||||
|
assert.equal(transport.destroyCalls, 1);
|
||||||
|
await runtime.stop();
|
||||||
|
assert.equal(transport.destroyCalls, 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue