dsh-im-ops/docs/方案/Issue-85-Telegram长轮询与发送连接竞争修复方案.md
2026-08-30 18:16:52 +08:00

217 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Issue #85:Telegram 长轮询阻塞发送最小修复方案
日期:2026-08-30。代码基线:v4.1.0 / `e08c3e9`。状态:待实施。
需求来源:[Issue #85](https://github.com/xmanrui/dsh-im/issues/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`:
```js
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` | 核心传输回归 |
| `test/channels/telegram/telegram.test.mjs` | API 和 Runtime 最小测试 |
| `THIRD_PARTY_NOTICES.md`、`CHANGELOG.md` | 许可证和修复记录 |
| `lib/index.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 副本、不重试”保持不变,然后执行:
```text
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` 语义不变。
- 没有新增最终消息或文件上传重试。
- 完整构建、测试和干净发布包安装通过。
## 8. 后续加固
以下内容单独建 issue,不阻塞 #85:
- Runtime 生命周期串行化或 generation 机制。
- Telegram Token、代理凭据和 transport error 统一脱敏。
- `inspectTelegramToken()` 使用同一网络策略。
- poll 崩溃后的即时资源回收。
- 多聊天、大文件和短消息混合负载评估。
## 9. 参考资料
- [Issue #85](https://github.com/xmanrui/dsh-im/issues/85)
- [Undici EnvHttpProxyAgent v7.29.0](https://github.com/nodejs/undici/blob/v7.29.0/docs/docs/api/EnvHttpProxyAgent.md)
- [Undici Fetch v7.29.0](https://github.com/nodejs/undici/blob/v7.29.0/docs/docs/api/Fetch.md)
- [Node.js fetch 自定义 dispatcher](https://nodejs.org/dist/latest/docs/api/globals.html#custom-dispatcher)