8.9 KiB
Issue #85:Telegram 长轮询阻塞发送最小修复方案
方案日期:2026-08-30。实施基线:v4.2.0 / 02ddea0。状态:已实施并验证(2026-08-31)。
需求来源:Issue #85。本文定义并记录关闭该 Issue 所需的最小改动及验收结果。
1. 决策
为每个 Telegram Runtime 创建一个私有、代理感知、最多 4 条同源连接的 Undici dispatcher,并让该 Bot 的长轮询和全部 Telegram HTTP 请求使用它。
同时满足:
fetch、dispatcher 和FormData来自同一个固定版本的undici。- Runtime 在正常停止和启动失败时释放 dispatcher。
- 不修改进程全局 dispatcher。
- 不增加发送重试。
- 不修改现有超时和
CHANNEL_DELIVERY_UNCERTAIN语义。
本次不重构生命周期、不建设通用 Transport 框架、不顺带处理其他 hardening 项目。
2. 原因
当前错误链路是:
TelegramRuntime使用默认全局fetch创建TelegramApi。getUpdates(timeout: 25)与消息发送、草稿更新和文件请求并发。- 报告环境的全局 dispatcher、代理或连接路径把同源请求串行化。
- 发送请求排在长轮询后,先触发 15 秒超时。
- Telegram API 将超时标记为
telegram-timeout和deliveryOutcome = unknown。 - 上层正确转换为
CHANNEL_DELIVERY_UNCERTAIN。
Issue 中“Node 默认 Undici 只有一条连接”不是普遍成立的默认行为。独立 Agent 能恢复发送,只能证明问题位于实际 HTTP 传输路径。修复目标是让 Telegram 不再依赖进程全局传输状态。
3. 范围
本次包含:
- Telegram Bot API 长轮询和出站请求。
- Telegram 文件上传和平台文件下载。
- 每个 Runtime 私有的 dispatcher。
HTTP_PROXY、HTTPS_PROXY、NO_PROXY及对应小写变量。TelegramApi的FormData注入点。- 正常停止和启动失败时的资源释放。
- 核心回归、真实 multipart、基本资源所有权和发布包测试。
本次不包含:
start()/stop()generation 或状态机重构。- poll 崩溃后的新清理框架;继续由现有 supervisor 重连并调用
stop()。 inspectTelegramToken()的网络策略调整。- Token、代理凭据和嵌套错误的通用脱敏框架。
- 双连接池、请求优先级或可配置连接数。
- 其他 IM 渠道改造。
4. 最小实现
4.1 依赖
增加精确运行时依赖 undici: 7.29.0。该版本满足项目 Node >=22.19 的运行条件。
undici 必须作为 Host bundle 的外置运行时依赖,避免源码环境可用但发布包无法解析,或被 esbuild 打入 bundle 后产生另一份实现。
4.2 Telegram HTTP 工厂
新增 src/channels/telegram/telegram-http.mjs:
import {
EnvHttpProxyAgent,
FormData as UndiciFormData,
fetch as undiciFetch,
} from 'undici';
export function createTelegramHttpTransport() {
const dispatcher = new EnvHttpProxyAgent({ connections: 4 });
let destroyed = false;
return {
fetchImpl: (url, options = {}) => undiciFetch(url, {
...options,
dispatcher,
}),
FormDataImpl: UndiciFormData,
async destroy() {
if (destroyed) return;
destroyed = true;
await dispatcher.destroy();
},
};
}
约束:
- 使用
EnvHttpProxyAgent,不使用会绕过标准代理变量的裸Agent。 dispatcher写在...options后,调用方不能覆盖它。- 不调用
setGlobalDispatcher()。 - 不调整 keep-alive 参数,不记录包含 Bot Token 的请求 URL。
4.3 TelegramApi
构造函数增加 FormDataImpl = globalThis.FormData,保存为私有字段;文件上传从 new FormData() 改为 new this.#FormDataImpl()。
Runtime 注入配套的 undici.fetch + undici.FormData。其他直接构造 TelegramApi 的调用继续默认使用全局 fetch + FormData,保持兼容。
不修改请求 payload、超时、redirect、文件限制和错误映射。
4.4 TelegramRuntime
增加 #createHttpTransport 和 #httpTransport:
- 构造函数默认
createHttpTransport = createTelegramHttpTransport。 #start()在createApi前创建并登记 transport。createHttpTransport和createApi必须位于启动错误处理范围内。createApi接收fetchImpl和FormDataImpl。
stop() 只增加以下清理:
- 捕获并清空当前
#httpTransport。 - 沿用现有 abort 及 poll/bridge 有界等待。
- 调用捕获 transport 的幂等
destroy()。 - 销毁失败只记录固定 warning,不让
stop()抛错或覆盖原始启动错误。
本次不改变现有 start() / stop() 状态机;既有启动取消竞态留给后续 hardening。
4.5 保持不变
- 普通发送默认 15 秒超时。
getUpdates为 25 秒长轮询并保留现有 10 秒余量。- 文件上传默认 120 秒超时。
- timeout 继续映射为
telegram-timeout + deliveryOutcome: unknown。 - 上层继续产生
CHANNEL_DELIVERY_UNCERTAIN。 - 最终消息、编辑和文件上传均不自动重试。
5. 文件改动
| 文件 | 改动 |
|---|---|
package.json、package-lock.json |
增加并锁定 undici@7.29.0 |
src/channels/telegram/telegram-http.mjs |
新增私有 transport 工厂 |
src/channels/telegram/telegram-api.mjs |
注入匹配的 FormDataImpl |
src/channels/telegram/telegram-runtime.mjs |
创建、注入和释放 transport |
plugin-src/host/build.mjs |
外置 undici |
scripts/verify-package.mjs |
校验直接依赖和 bundle 外置 |
test/channels/telegram/telegram-http.test.mjs |
核心传输、真实 multipart 和 Runtime 资源所有权回归 |
test/channels/telegram/telegram.test.mjs |
复用现有 API、超时及 uncertain 语义测试,无需修改 |
THIRD_PARTY_NOTICES.md、CHANGELOG.md |
许可证和修复记录 |
lib/index.js、lib/client.js |
通过现有构建命令生成 |
6. 最小测试集
6.1 长轮询不阻塞发送
使用本地 server 和真实 transport:保持 /getUpdates 不返回,再调用 sendChatAction,断言发送在释放长轮询前已经完成。使用事件顺序断言,不设置严格耗时阈值。
测试须隔离大小写两套代理环境变量,避免 CI 环境代理污染本地请求。
6.2 不依赖全局 dispatcher
在独立子进程中把全局 dispatcher 限制为一条连接并占住它,断言 Telegram 私有 transport 的请求仍能完成。
6.3 真实 multipart
通过真实 undici.fetch 向本地 server 执行一次 sendDocument,验证 multipart boundary、chat_id、文件名、媒体类型和二进制内容。其他 payload 继续由现有 fake fetch 测试覆盖。
6.4 基本资源所有权
通过 fake transport 覆盖四种情况:
- 正常
start → stop只销毁一次。 - 连续两次
stop()不重复销毁。 getMe失败会销毁并保留原始错误。createApi抛错仍会销毁。
不在本次增加启动取消、重启代际和 poll 崩溃竞态矩阵。
6.5 现有语义与发布包
复用现有测试确认 uncertain 错误语义和“不发送 fallback 副本、不重试”保持不变,然后执行:
npm run check
npm pack
在空临时目录安装 tarball
导入 lib/index.js
通过发布包执行一次本地 Telegram transport 请求
7. 验收标准
满足以下条件即可关闭 Issue #85:
/getUpdates未返回时,短 Telegram API 请求仍能先完成。- Telegram transport 不受受限全局 dispatcher 影响。
sendDocument通过真实 multipart 成功。- 正常停止、重复停止和启动失败不会重复销毁或遗留 transport。
- 标准代理变量仍由 Undici 官方代理实现处理。
- 现有 timeout、unknown delivery 和
CHANNEL_DELIVERY_UNCERTAIN语义不变。 - 没有新增最终消息或文件上传重试。
- 完整构建、测试和干净发布包安装通过。
实施验收记录:npm run check 构建成功且 1949 项测试全部通过;实际 tarball 在空临时目录安装后可导入 lib/index.js,并可从安装包执行本地 Telegram transport 请求;本机 DSH 重启加载新构建后,在 Telegram Runtime 长轮询运行期间向真实会话发送成功,HTTP 投递返回 200。
8. 后续加固
以下内容单独建 issue,不阻塞 #85:
- Runtime 生命周期串行化或 generation 机制。
- Telegram Token、代理凭据和 transport error 统一脱敏。
inspectTelegramToken()使用同一网络策略。- poll 崩溃后的即时资源回收。
- 多聊天、大文件和短消息混合负载评估。