dsh-im-ops/docs/方案/Issue-85-Telegram长轮询与发送连接竞争修复方案.md

8.9 KiB
Raw Blame History

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. 原因

当前错误链路是:

  1. TelegramRuntime 使用默认全局 fetch 创建 TelegramApi。
  2. getUpdates(timeout: 25) 与消息发送、草稿更新和文件请求并发。
  3. 报告环境的全局 dispatcher、代理或连接路径把同源请求串行化。
  4. 发送请求排在长轮询后,先触发 15 秒超时。
  5. Telegram API 将超时标记为 telegram-timeout 和 deliveryOutcome = unknown。
  6. 上层正确转换为 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() 只增加以下清理:

  1. 捕获并清空当前 #httpTransport。
  2. 沿用现有 abort 及 poll/bridge 有界等待。
  3. 调用捕获 transport 的幂等 destroy()。
  4. 销毁失败只记录固定 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 崩溃后的即时资源回收。
  • 多聊天、大文件和短消息混合负载评估。

9. 参考资料