diff --git a/README.en.md b/README.en.md
index 55fdc68..2e56c74 100644
--- a/README.en.md
+++ b/README.en.md
@@ -301,11 +301,12 @@ Without a setting, Chinese is used. Chinese is always the fallback language —
## Contact
-You can reach me by email, WeChat, Xiaohongshu, or WhatsApp.
+Join the WeCom community group, or reach me by email, WeChat, Xiaohongshu, or WhatsApp.
| Email |
+ WeCom Group |
WeChat |
Xiaohongshu |
WhatsApp |
@@ -314,6 +315,9 @@ You can reach me by email, WeChat, Xiaohongshu, or WhatsApp.
longmanr307@gmail.com
|
+
+
+ |
|
diff --git a/README.md b/README.md
index 7eb3669..640c366 100644
--- a/README.md
+++ b/README.md
@@ -304,11 +304,12 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任
## 联系方式
-欢迎通过邮箱、微信、小红书或 WhatsApp 联系我。
+欢迎加入企业微信群,或通过邮箱、微信、小红书或 WhatsApp 联系我。
| 邮箱 |
+ 企业微信群 |
微信 |
小红书 |
WhatsApp |
@@ -317,6 +318,9 @@ IM 管理 RPC 默认仅接受回环浏览器。如果 Web profile 在受信任
longmanr307@gmail.com
|
+
+
+ |
|
diff --git a/docs/images/wecom.jpg b/docs/images/wecom.jpg
new file mode 100644
index 0000000..a303500
Binary files /dev/null and b/docs/images/wecom.jpg differ
diff --git a/docs/方案/Issue-85-Telegram长轮询与发送连接竞争修复方案.md b/docs/方案/Issue-85-Telegram长轮询与发送连接竞争修复方案.md
index 24c81bb..9a519cf 100644
--- a/docs/方案/Issue-85-Telegram长轮询与发送连接竞争修复方案.md
+++ b/docs/方案/Issue-85-Telegram长轮询与发送连接竞争修复方案.md
@@ -1,81 +1,68 @@
-# Issue #85:Telegram 长轮询与发送连接竞争修复方案
+# Issue #85:Telegram 长轮询阻塞发送最小修复方案
-日期:2026-08-30。代码基线:v4.0.1 / `92ec91b`。状态:待实施。
+日期:2026-08-30。代码基线:v4.1.0 / `e08c3e9`。状态:待实施。
-需求来源:[Issue #85](https://github.com/xmanrui/dsh-im/issues/85)。本文记录风险分析后的最终实施方案和验收要求;其中列出的测试均为待补测试,不能视为已经通过。
+需求来源:[Issue #85](https://github.com/xmanrui/dsh-im/issues/85)。本文只定义关闭该 Issue 所需的最小改动;列出的测试均为待补测试。
-## 1. 结论
+## 1. 决策
-本问题不通过增大发送超时、关闭 keep-alive 或重试最终消息解决。最终方案是:
+为每个 Telegram Runtime 创建一个私有、代理感知、最多 4 条同源连接的 Undici dispatcher,并让该 Bot 的长轮询和全部 Telegram HTTP 请求使用它。
-- 每个 Telegram Runtime 创建一个私有、代理感知的 Undici dispatcher。
-- 同一 Bot 的长轮询、普通消息、富消息、草稿、文件上传和文件下载共用该 dispatcher。
-- 每个 Telegram Bot 对同一来源最多使用 4 条连接;一条长轮询最多占用其中一条,至少保留三条并发发送能力。
-- `fetch`、dispatcher 和 `FormData` 使用同一个固定版本的 `undici` 实现。
-- Runtime 明确拥有并释放 dispatcher,不修改进程全局 dispatcher。
-- 不增加发送重试,不修改现有超时和 `CHANNEL_DELIVERY_UNCERTAIN` 语义。
+同时满足:
-这个方案只增加一个 Telegram 专用 HTTP 工厂、一个 Runtime 生命周期资源和一个 `FormData` 注入点,不引入通用 Transport 框架、双连接池或用户配置项。
+- `fetch`、dispatcher 和 `FormData` 来自同一个固定版本的 `undici`。
+- Runtime 在正常停止和启动失败时释放 dispatcher。
+- 不修改进程全局 dispatcher。
+- 不增加发送重试。
+- 不修改现有超时和 `CHANNEL_DELIVERY_UNCERTAIN` 语义。
-## 2. 根因边界
+本次不重构生命周期、不建设通用 Transport 框架、不顺带处理其他 hardening 项目。
-### 2.1 已确认的错误链路
+## 2. 原因
-当前 Telegram Runtime 只创建一个 `TelegramApi`,长轮询和所有出站调用都通过它使用默认全局 `fetch`:
+当前错误链路是:
-1. Runtime 启动 `getUpdates(timeout: 25)` 长轮询。
-2. 收到消息后,`bridge.accept()` 以异步方式继续处理,poller 会立即进入下一次长轮询。
-3. 发送消息、草稿、正在输入状态、文件上传等操作因此会与下一次长轮询并发。
-4. 如果宿主进程的全局 dispatcher、代理或连接路径把这些请求串行化,发送调用会排在长轮询后面。
-5. 普通发送默认 15 秒超时,而长轮询可持续 25 秒,发送先发生超时。
-6. Telegram API 将超时标记为 `deliveryOutcome = unknown`,共享错误链最终转换为 `CHANNEL_DELIVERY_UNCERTAIN`。
+1. `TelegramRuntime` 使用默认全局 `fetch` 创建 `TelegramApi`。
+2. `getUpdates(timeout: 25)` 与消息发送、草稿更新和文件请求并发。
+3. 报告环境的全局 dispatcher、代理或连接路径把同源请求串行化。
+4. 发送请求排在长轮询后,先触发 15 秒超时。
+5. Telegram API 将超时标记为 `telegram-timeout` 和 `deliveryOutcome = unknown`。
+6. 上层正确转换为 `CHANNEL_DELIVERY_UNCERTAIN`。
-因此,`CHANNEL_DELIVERY_UNCERTAIN` 是发送结果确实无法确认时的正确下游表现,不应通过改错误分类掩盖。
+Issue 中“Node 默认 Undici 只有一条连接”不是普遍成立的默认行为。独立 Agent 能恢复发送,只能证明问题位于实际 HTTP 传输路径。修复目标是让 Telegram 不再依赖进程全局传输状态。
-### 2.2 Issue 中不能直接当成根因的部分
+## 3. 范围
-“Node 默认 Undici 只有一条 HTTP/1.1 连接”并不是正常默认配置。Undici 默认 Agent 会为同一来源使用 Pool,并可在已有连接繁忙时建立新连接。独立 Agent 能改善报告环境中的表现,说明问题位于 HTTP 传输路径,但不能单独证明默认连接池固定为一条连接。
+本次包含:
-更准确的工程根因是:
+- Telegram Bot API 长轮询和出站请求。
+- Telegram 文件上传和平台文件下载。
+- 每个 Runtime 私有的 dispatcher。
+- `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 及对应小写变量。
+- `TelegramApi` 的 `FormData` 注入点。
+- 正常停止和启动失败时的资源释放。
+- 核心回归、真实 multipart、基本资源所有权和发布包测试。
-> dsh-im 明知 Telegram 长轮询与出站请求会并发,却让两类请求共同依赖进程中可变、不可控的全局 HTTP 传输策略。
+本次不包含:
-实际触发因素可能是宿主安装过自定义全局 dispatcher、代理连接池受限、连接器异常,或特定网络路径发生串行化。修复目标不是猜测报告环境的唯一配置,而是让 Telegram 的并发能力不再依赖该全局状态。
+- `start()` / `stop()` generation 或状态机重构。
+- poll 崩溃后的新清理框架;继续由现有 supervisor 重连并调用 `stop()`。
+- `inspectTelegramToken()` 的网络策略调整。
+- Token、代理凭据和嵌套错误的通用脱敏框架。
+- 双连接池、请求优先级或可配置连接数。
+- 其他 IM 渠道改造。
-## 3. 范围与非目标
+## 4. 最小实现
-| 项目 | 决定 |
-| --- | --- |
-| 修复范围 | Telegram Bot API、Telegram 文件上传和平台文件下载 |
-| dispatcher 作用域 | 每个 Telegram Runtime / Bot 一个私有实例 |
-| 同源连接数 | 固定 4 条,不新增用户设置 |
-| 代理 | 支持 `HTTP_PROXY`、`HTTPS_PROXY`、`NO_PROXY` 及对应小写变量 |
-| 全局 dispatcher | 不读取、不替换、不修改 |
-| 发送超时 | 保持现有默认值 |
-| 长轮询超时 | 保持现有 25 秒及现有超时余量 |
-| 发送重试 | 不新增,防止消息重复 |
-| 错误分类 | 保持现有 uncertain 语义 |
-| 双连接池 | 不做;一个 4 连接池已经能隔离全局状态并满足并发 |
-| 通用 Transport 抽象 | 不做;只增加 Telegram 专用工厂函数 |
-| UI 和配置迁移 | 不涉及 |
+### 4.1 依赖
-## 4. HTTP 实现
+增加精确运行时依赖 `undici: 7.29.0`。该版本满足项目 Node `>=22.19` 的运行条件。
-### 4.1 固定同一份 Undici 实现
+`undici` 必须作为 Host bundle 的外置运行时依赖,避免源码环境可用但发布包无法解析,或被 esbuild 打入 bundle 后产生另一份实现。
-增加精确版本运行时依赖 `undici@7.29.0`。该版本要求 Node `>=20.18.1`,低于项目当前的 Node `>=22.19` 下限。
+### 4.2 Telegram HTTP 工厂
-不能采用以下组合:
-
-```js
-globalThis.fetch(url, { dispatcher: npmUndiciAgent });
-```
-
-也不能把全局 `FormData` 直接交给 `undici.fetch()`。Undici 明确要求 `fetch` 与 `FormData` 来自同一个实现;混用不同版本或不同实现创建的 Web API 对象可能抛错,文件上传是最容易受影响的路径。
-
-### 4.2 新增 Telegram 专用工厂
-
-新增 `src/channels/telegram/telegram-http.mjs`,只提供一个小型工厂函数:
+新增 `src/channels/telegram/telegram-http.mjs`:
```js
import {
@@ -84,12 +71,9 @@ import {
fetch as undiciFetch,
} from 'undici';
-const TELEGRAM_HTTP_CONNECTIONS = 4;
-
export function createTelegramHttpTransport() {
- const dispatcher = new EnvHttpProxyAgent({
- connections: TELEGRAM_HTTP_CONNECTIONS,
- });
+ const dispatcher = new EnvHttpProxyAgent({ connections: 4 });
+ let destroyed = false;
return {
fetchImpl: (url, options = {}) => undiciFetch(url, {
@@ -97,371 +81,137 @@ export function createTelegramHttpTransport() {
dispatcher,
}),
FormDataImpl: UndiciFormData,
- destroy: () => dispatcher.destroy(),
+ async destroy() {
+ if (destroyed) return;
+ destroyed = true;
+ await dispatcher.destroy();
+ },
};
}
```
约束:
-- `dispatcher` 必须在展开 `options` 后写入,调用方不能意外覆盖 Runtime 所拥有的 dispatcher。
+- 使用 `EnvHttpProxyAgent`,不使用会绕过标准代理变量的裸 `Agent`。
+- `dispatcher` 写在 `...options` 后,调用方不能覆盖它。
- 不调用 `setGlobalDispatcher()`。
-- 不设置 `keepAliveTimeout` 或 `keepAliveMaxTimeout`;Issue 建议中的 `30`、`60` 在 Undici 中是毫秒,不是秒,而且本问题不需要靠调整它们解决。
-- transport 不记录请求 URL;Telegram Token 位于 URL path 中。
-- `destroy()` 由资源所有者调用,transport 本身不实现重试或超时。
+- 不调整 keep-alive 参数,不记录包含 Bot Token 的请求 URL。
-### 4.3 为什么使用 EnvHttpProxyAgent
+### 4.3 TelegramApi
-裸 `Agent` 会绕过宿主通过标准代理环境变量表达的网络策略。`EnvHttpProxyAgent` 会读取:
+构造函数增加 `FormDataImpl = globalThis.FormData`,保存为私有字段;文件上传从 `new FormData()` 改为 `new this.#FormDataImpl()`。
-- `HTTP_PROXY` / `http_proxy`
-- `HTTPS_PROXY` / `https_proxy`
-- `NO_PROXY` / `no_proxy`
+Runtime 注入配套的 `undici.fetch + undici.FormData`。其他直接构造 `TelegramApi` 的调用继续默认使用全局 `fetch + FormData`,保持兼容。
-没有代理变量时直接连接;存在代理变量时复用 Undici 已有代理机制,不在项目内自行解析代理地址或实现 CONNECT。
+不修改请求 payload、超时、redirect、文件限制和错误映射。
-任意通过代码安装的全局 dispatcher、拦截器或自定义 connector 不会自动继承。这是有意的隔离边界,否则会重新引入 Issue #85 的全局耦合。特殊嵌入环境通过现有 `createApi`、`fetchImpl` 以及新增的 `createHttpTransport` 注入点覆盖,不新增用户界面配置。
+### 4.4 TelegramRuntime
-## 5. TelegramApi 调整
+增加 `#createHttpTransport` 和 `#httpTransport`:
-修改 `src/channels/telegram/telegram-api.mjs`:
+- 构造函数默认 `createHttpTransport = createTelegramHttpTransport`。
+- `#start()` 在 `createApi` 前创建并登记 transport。
+- `createHttpTransport` 和 `createApi` 必须位于启动错误处理范围内。
+- `createApi` 接收 `fetchImpl` 和 `FormDataImpl`。
-1. 构造函数增加 `FormDataImpl = globalThis.FormData`。
-2. 校验 `FormDataImpl` 可构造并保存为私有字段。
-3. `#sendArtifact()` 使用注入的实现创建 multipart body。
-4. 直接构造 `new TelegramApi({ token })` 时仍使用全局 `fetch + FormData`,保持已有调用兼容。
-5. Runtime 注入时使用 `undici.fetch + undici.FormData`,保证实现匹配。
+`stop()` 只增加以下清理:
-示意:
+1. 捕获并清空当前 `#httpTransport`。
+2. 沿用现有 abort 及 poll/bridge 有界等待。
+3. 调用捕获 transport 的幂等 `destroy()`。
+4. 销毁失败只记录固定 warning,不让 `stop()` 抛错或覆盖原始启动错误。
-```js
-constructor({
- token,
- fetchImpl = fetch,
- FormDataImpl = FormData,
- baseUrl = DEFAULT_BASE_URL,
- fileUploadTimeoutMs = DEFAULT_FILE_UPLOAD_TIMEOUT_MS,
-}) {
- // 现有校验保持不变
- this.#fetch = fetchImpl;
- this.#FormDataImpl = FormDataImpl;
-}
+本次不改变现有 `start()` / `stop()` 状态机;既有启动取消竞态留给后续 hardening。
-async #sendArtifact(/* ... */) {
- const payload = new this.#FormDataImpl();
- // 沿用现有 append 和错误映射
-}
-```
+### 4.5 保持不变
-不改变请求 payload、超时、redirect 策略、文件大小处理和 provider 错误映射。
+- 普通发送默认 15 秒超时。
+- `getUpdates` 为 25 秒长轮询并保留现有 10 秒余量。
+- 文件上传默认 120 秒超时。
+- timeout 继续映射为 `telegram-timeout + deliveryOutcome: unknown`。
+- 上层继续产生 `CHANNEL_DELIVERY_UNCERTAIN`。
+- 最终消息、编辑和文件上传均不自动重试。
-## 6. TelegramRuntime 调整
+## 5. 文件改动
-### 6.1 创建和注入
-
-修改 `src/channels/telegram/telegram-runtime.mjs`:
-
-- 增加 `#createHttpTransport` 和 `#httpTransport`。
-- 构造函数默认 `createHttpTransport = createTelegramHttpTransport`,作为生命周期测试和特殊嵌入环境的最小注入点。
-- `#start()` 在 Harness 就绪后创建 transport,并复用现有 `createApi`:
-
-```js
-const transport = this.#createHttpTransport();
-this.#httpTransport = transport;
-
-const api = this.#createApi({
- token: this.#token,
- fetchImpl: transport.fetchImpl,
- FormDataImpl: transport.FormDataImpl,
-});
-this.#api = api;
-```
-
-transport 必须在调用 `createApi` 前登记到 Runtime;这样 `createApi` 或随后任意启动步骤抛错时,统一的 `stop()` 都能释放它。
-
-### 6.2 正常停止顺序
-
-`stop()` 按以下顺序执行:
-
-1. 捕获当前 `pollTask`、bridge 和 transport。
-2. 立即把 `#httpTransport` 清空,防止重复销毁或旧周期销毁新周期资源。
-3. abort 当前 Runtime 的 `AbortController`。
-4. 清空 `#abortController`、`#pollTask`、`#api`、`#bridge`。
-5. 沿用现有有界等待,等待 poll 和 bridge 退出。
-6. 调用捕获的 `transport.destroy()`。
-7. transport 清理失败只记录脱敏 warning,不覆盖原始启动或运行错误,也不让 `stop()` 失败。
-8. 最后进入 `idle`。
-
-这里使用 `destroy()` 而不是 `close()`:停止 Runtime 本身就是取消语义,`close()` 可能继续等待尚未返回的 25 秒长轮询。先 abort、再有界等待、最后 destroy,可以保留现有清理窗口,又不会让停止过程无限挂起。
-
-### 6.3 启动失败和竞态
-
-以下路径全部复用同一清理逻辑:
-
-- `getMe` 失败。
-- Token 身份不匹配。
-- Webhook 已配置。
-- 命令菜单以外的启动步骤失败。
-- transport 或 `createApi` 创建失败。
-- 启动过程中外部调用 `stop()`。
-- 重连时先停止旧 Runtime,再创建新 transport。
-
-异步回调必须捕获本次启动使用的 controller 和 transport,并在操作字段前比较身份。旧 poll 或旧清理任务不能销毁重启后新建的 transport。
-
-### 6.4 poll 意外终止
-
-非主动 abort 导致 poll 退出时:
-
-1. 立即把状态标记为 `failed`,保留原始错误。
-2. 停止接受新的发送测试。
-3. 给已开始的 bridge 工作保留现有有界清理窗口。
-4. 随后 abort 本启动周期并销毁它拥有的 transport。
-5. 清理过程不能把 `failed` 状态覆盖成 `idle`;后续由现有连接 supervisor 触发重建。
-
-这部分可提取一个 Runtime 私有清理函数复用,但不新增公共生命周期抽象。
-
-## 7. Token 检查的一致性
-
-`inspectTelegramToken()` 是接入机器人前的 `getMe` 调用。如果 Runtime 使用环境代理,而 Token 检查仍使用另一套全局网络策略,可能出现检查和运行行为不一致。
-
-调整规则:
-
-- 调用者显式传入 `fetchImpl` 时继续使用它,函数不拥有也不释放该 fetch 的资源。
-- 未传入 `fetchImpl` 时,临时创建 `createTelegramHttpTransport()`。
-- 使用其 `fetchImpl` 和 `FormDataImpl` 创建一次性 `TelegramApi`。
-- 无论成功或失败,都在 `finally` 中销毁临时 transport。
-- 不能把 Bot Token 或完整请求 URL写入错误和日志。
-
-Token 检查不与已运行 Bot 共享 dispatcher,避免短生命周期检查错误地关闭 Runtime 连接。
-
-## 8. 依赖、构建和文档改动
-
-| 文件 | 计划改动 |
+| 文件 | 改动 |
| --- | --- |
-| `package.json` | 增加精确运行时依赖 `undici: 7.29.0` |
-| `package-lock.json` | 更新锁文件 |
-| `src/channels/telegram/telegram-http.mjs` | 新增 Telegram 专用 transport 工厂 |
-| `src/channels/telegram/telegram-api.mjs` | 增加匹配的 `FormDataImpl` 注入和一次性 Token 检查 transport |
-| `src/channels/telegram/telegram-runtime.mjs` | 创建、持有并释放私有 transport |
-| `plugin-src/host/build.mjs` | 把 `undici` 加入 `externalRuntimePackages`,不打入 Host bundle |
-| `scripts/verify-package.mjs` | 校验 `undici` 为精确直接依赖,并验证 bundle 外置约束 |
-| `THIRD_PARTY_NOTICES.md` | 增加 Undici 许可信息 |
-| `README.md`、`README.en.md` | 简述 Telegram 私有连接池及标准代理变量支持 |
-| `CHANGELOG.md` | 记录 Issue #85 修复、网络策略边界和无自动重试 |
-| `lib/index.js` | 通过现有构建命令重新生成,不手工编辑 |
+| `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` | 通过现有构建命令生成 |
-## 9. 自动化测试方案
+## 6. 最小测试集
-新增 `test/channels/telegram/telegram-http.test.mjs`,并在现有 `test/channels/telegram/telegram.test.mjs` 补充 API 和 Runtime 测试。所有网络测试只使用本地 server,不访问真实 Telegram。
+### 6.1 长轮询不阻塞发送
-### 9.1 核心回归:长轮询不能阻塞发送
+使用本地 server 和真实 transport:保持 `/getUpdates` 不返回,再调用 `sendChatAction`,断言发送在释放长轮询前已经完成。使用事件顺序断言,不设置严格耗时阈值。
-测试步骤:
+测试须隔离大小写两套代理环境变量,避免 CI 环境代理污染本地请求。
-1. 启动本地 HTTP Server。
-2. 构造使用真实 Telegram transport 的 `TelegramApi`,把 `baseUrl` 指向本地 server。
-3. server 收到 `/getUpdates` 后记录事件并保持响应未完成。
-4. 确认长轮询已经开始后调用 `sendChatAction`。
-5. server 立即响应发送请求。
-6. 断言 `sendChatAction` 在释放 `/getUpdates` 前已经完成。
-7. 最后释放长轮询并清理 server 和 transport。
+### 6.2 不依赖全局 dispatcher
-断言依据是 Promise 和服务端事件的先后顺序,不使用“必须在 500ms 内完成”之类容易抖动的性能阈值;只保留一个较宽的失败截止时间防止测试挂死。
+在独立子进程中把全局 dispatcher 限制为一条连接并占住它,断言 Telegram 私有 transport 的请求仍能完成。
-### 9.2 连接容量
+### 6.3 真实 multipart
-同时阻塞:
+通过真实 `undici.fetch` 向本地 server 执行一次 `sendDocument`,验证 multipart boundary、`chat_id`、文件名、媒体类型和二进制内容。其他 payload 继续由现有 fake fetch 测试覆盖。
-- 1 个 `getUpdates`。
-- 3 个短 API 请求。
+### 6.4 基本资源所有权
-断言四个请求都已经到达 server,再统一释放。这证明长轮询占用一条连接时仍有三条独立发送连接。
+通过 fake transport 覆盖四种情况:
-不要求第 5 个请求必须排队,避免测试绑定 Undici 不必要的内部调度细节。
+- 正常 `start → stop` 只销毁一次。
+- 连续两次 `stop()` 不重复销毁。
+- `getMe` 失败会销毁并保留原始错误。
+- `createApi` 抛错仍会销毁。
-### 9.3 全局 dispatcher 隔离
+不在本次增加启动取消、重启代际和 poll 崩溃竞态矩阵。
-在独立子进程中:
+### 6.5 现有语义与发布包
-1. 把全局 dispatcher 设置为 `connections: 1`。
-2. 通过全局 fetch 发起一个不会立即返回的请求,占住全局连接。
-3. 创建 Telegram 私有 transport。
-4. 断言 Telegram 请求仍然到达并完成。
-
-该测试必须使用子进程,不能在主测试进程中修改全局 dispatcher 后依赖恢复,以免并行测试互相污染。
-
-### 9.4 代理与 NO_PROXY
-
-使用独立子进程、本地目标 server 和本地 HTTP 代理:
-
-- 设置 `HTTP_PROXY` 后,请求必须经过代理。
-- 设置 `NO_PROXY` 匹配目标地址后,请求必须直连。
-- transport 销毁后代理侧连接最终关闭。
-- 测试环境变量不泄漏到其他测试。
-
-不在测试中实现企业 TLS 中间人或连接池;只验证项目选择的标准代理语义。
-
-### 9.5 真实 multipart 上传
-
-不能只保留 fake fetch 对 `FormData` 字段的检查。新增通过真实 `undici.fetch` 发往本地 server 的集成测试:
-
-- `sendDocument` 的 `Content-Type` 含合法 multipart boundary。
-- 正确包含 `chat_id`、`message_thread_id` 和 `reply_parameters`。
-- 文件名、媒体类型和二进制内容正确。
-- `sendPhoto` 覆盖相同路径。
-- 请求能够完成,不出现跨实现 `FormData` 错误。
-
-现有注入 fake fetch 的单元测试继续保留,用于检查 API payload;新测试只补真实序列化和传输风险。
-
-### 9.6 Runtime 生命周期
-
-通过 `createHttpTransport` 注入带计数器的 fake transport,覆盖:
-
-| 场景 | 必须断言 |
-| --- | --- |
-| 正常 `start → stop` | transport 只销毁一次 |
-| 连续两次 `stop()` | 不重复销毁、不抛错 |
-| `getMe` 失败 | 已创建 transport 被销毁 |
-| Bot 身份不匹配 | transport 被销毁,原错误保留 |
-| Webhook 已配置 | transport 被销毁,原错误保留 |
-| `createApi` 抛错 | transport 仍被销毁 |
-| 启动期间调用 `stop()` | 启动被取消,无未处理 rejection |
-| `start → stop → start` | 旧 transport 在新周期前销毁,两者互不影响 |
-| poll 非主动失败 | 状态保持 `failed`,对应 transport 最终销毁 |
-| 两个 Runtime 并存 | 停止其中一个不会销毁另一个的 transport |
-
-测试不能通过读取活跃句柄数量推测资源是否释放;应直接验证注入资源的所有权和调用次数。
-
-### 9.7 Token 检查生命周期
-
-- 默认路径成功时销毁一次临时 transport。
-- `getMe` 失败时仍销毁一次。
-- 显式传入 `fetchImpl` 时不创建默认 transport。
-- Token 检查和 Runtime 使用独立资源,关闭前者不影响后者。
-
-### 9.8 错误语义和重复投递
-
-保留并加强现有超时测试:
-
-- transport timeout 继续映射为 `telegram-timeout`。
-- `deliveryOutcome` 继续为 `unknown`。
-- 上层继续得到 `CHANNEL_DELIVERY_UNCERTAIN`。
-- 一次发送只调用一次 fetch。
-- 不自动重试 `sendMessage`、`sendRichMessage`、`editMessageText` 或文件上传。
-- 不改变现有 15 秒发送超时和 120 秒文件上传超时。
-
-### 9.9 Token 泄漏
-
-分别模拟:
-
-- 连接失败。
-- 代理失败。
-- poll 失败。
-- 文件上传失败。
-- dispatcher 销毁失败。
-
-检查 error message、stack、cause 的安全格式和 logger 参数序列化结果,均不能包含完整 Bot Token 或 `/bot/...` URL。
-
-### 9.10 发布包和 Node 版本
-
-至少执行:
+复用现有测试确认 uncertain 错误语义和“不发送 fallback 副本、不重试”保持不变,然后执行:
```text
-Node 22.19:npm run check
-Node 24:npm run check
+npm run check
npm pack
-在空临时目录安装生成的 tarball
-导入发布包 lib/index.js
+在空临时目录安装 tarball
+导入 lib/index.js
通过发布包执行一次本地 Telegram transport 请求
```
-发布包 smoke test 用于发现:
+## 7. 验收标准
-- `undici` 未声明为运行时依赖。
-- `undici` 被错误打入 Host bundle。
-- lockfile 与清单版本不一致。
-- 生成文件没有更新。
-- 源码环境能运行、干净安装后却无法解析依赖。
+满足以下条件即可关闭 Issue #85:
-## 10. 风险与闭环
+- `/getUpdates` 未返回时,短 Telegram API 请求仍能先完成。
+- Telegram transport 不受受限全局 dispatcher 影响。
+- `sendDocument` 通过真实 multipart 成功。
+- 正常停止、重复停止和启动失败不会重复销毁或遗留 transport。
+- 标准代理变量仍由 Undici 官方代理实现处理。
+- 现有 timeout、unknown delivery 和 `CHANNEL_DELIVERY_UNCERTAIN` 语义不变。
+- 没有新增最终消息或文件上传重试。
+- 完整构建、测试和干净发布包安装通过。
-| 风险 | 等级 | 方案内处理 | 验证方式 |
-| --- | --- | --- | --- |
-| 私有 Agent 绕过代理 | 高 | 使用 `EnvHttpProxyAgent` | 代理与 `NO_PROXY` 子进程测试 |
-| 不继承自定义全局 dispatcher | 中 | 明确隔离边界,保留注入点,不偷偷回退全局 | 自定义 transport 注入测试和文档 |
-| Node 内置 fetch 与 npm Agent 不兼容 | 高 | fetch、dispatcher、FormData 全部来自同一固定依赖 | Node 22/24 与真实 multipart 测试 |
-| 固定 4 连接仍可能排队 | 中 | 一条 poll + 三条发送;不无限开连接 | 一加三并发屏障测试 |
-| 多 Bot 增加 socket 上限 | 低 | 每 Bot 固定最多 4 条且按需建立 | 双 Runtime 所有权测试 |
-| 启动失败泄漏 dispatcher | 中 | 创建后立即登记,所有失败统一清理 | 各启动失败测试 |
-| stop/start 销毁错代资源 | 中 | 捕获本周期资源并做身份比较 | 启停竞态和重启测试 |
-| destroy 导致在途发送 uncertain | 中 | 先 abort 和有界等待;只在停止或运行周期失败时 destroy | 停止期间在途请求测试 |
-| 自动重试产生重复消息 | 高 | 本期明确不增加重试 | fetch 调用次数断言 |
-| Token 出现在 Undici 诊断中 | 高 | 不记录 URL,增加日志脱敏验收 | 多错误路径 Token 泄漏测试 |
-| 修改全局 dispatcher 的测试污染套件 | 中 | 使用独立子进程 | 并行运行完整测试套件 |
-| 外部代理本身只允许单路并发 | 残余 | 应用无法绕过外部基础设施限制,保持明确超时和诊断 | 文档说明;不承诺代码内解决 |
+## 8. 后续加固
-## 11. 明确不采用的替代方案
+以下内容单独建 issue,不阻塞 #85:
-### 11.1 只增加测试、不调整设计
+- Runtime 生命周期串行化或 generation 机制。
+- Telegram Token、代理凭据和 transport error 统一脱敏。
+- `inspectTelegramToken()` 使用同一网络策略。
+- poll 崩溃后的即时资源回收。
+- 多聊天、大文件和短消息混合负载评估。
-测试只能暴露问题,不能消除代理绕过、跨 Undici 实现混用和生命周期泄漏,因此不足以作为最终方案。
-
-### 11.2 增大发送超时
-
-只会延迟失败;如果请求仍排在长轮询后面,无法保证任何固定值足够,还会让用户更晚看到错误。
-
-### 11.3 自动重试最终发送
-
-超时发生在请求结果未知的阶段,Telegram 可能已经收到消息。盲目重试会产生重复消息或重复文件。
-
-### 11.4 `Connection: close`
-
-会牺牲所有请求的连接复用,增加 TLS 建连成本,不能表达清晰的资源所有权,也不能可靠继承代理策略。
-
-### 11.5 等待 bridge 完成后再继续 poll
-
-会破坏当前交互能力:Harness 等待用户回答时,Telegram 必须继续拉取下一条 update 才能收到答案。
-
-### 11.6 poll 和 send 分成两套连接池
-
-会增加两份代理配置、生命周期和测试面。一个 4 连接的私有 dispatcher 已经保证长轮询不会独占所有连接,没有必要增加第二套资源。
-
-### 11.7 修改全局 dispatcher
-
-会影响其他 IM 渠道、更新检查和宿主自身网络调用,并可能覆盖宿主已有代理或安全策略,风险明显大于局部修复。
-
-## 12. 实施顺序
-
-1. 增加并外置固定版本的 `undici` 依赖,更新清单、锁文件、校验器和第三方声明。
-2. 新增 Telegram HTTP 工厂及其直接并发、代理和全局隔离测试。
-3. 为 `TelegramApi` 增加匹配的 `FormDataImpl`,补真实 multipart 测试。
-4. 在 Runtime 中接入 transport 所有权和正常停止清理,补启动失败和重启测试。
-5. 处理 poll 非主动失败的有界清理和代际竞态测试。
-6. 让默认 Token 检查使用一次性同策略 transport,补成功和失败清理测试。
-7. 运行 Telegram 现有测试,确认错误分类和交互轮询行为没有变化。
-8. 更新中英文文档和 changelog,通过 Node 22.19、Node 24、`npm run check` 与发布包 smoke test。
-
-## 13. 验收标准
-
-以下条件全部满足才能认为 Issue #85 已彻底修复:
-
-- `getUpdates` 被无限期阻塞时,短 Telegram API 请求仍能在释放 poll 前完成。
-- 一条 poll 与三条并发发送可以同时到达服务端。
-- 即使全局 dispatcher 被限制为一条连接,Telegram 私有 transport 仍可正常请求。
-- 标准代理和 `NO_PROXY` 行为正确。
-- 文档、图片上传使用真实 multipart 成功,不存在混用 `FormData`。
-- 正常停止、启动失败、重连和 poll 崩溃均不泄漏或误销毁 transport。
-- 现有 Harness 交互场景继续在原 Turn 未结束时拉取回答 update。
-- 现有 timeout、unknown delivery 和 `CHANNEL_DELIVERY_UNCERTAIN` 语义保持不变。
-- 没有新增最终消息重试或重复投递。
-- 任何日志和错误中都没有完整 Telegram Token。
-- Node 22.19、Node 24、完整测试、构建校验和干净发布包安装全部通过。
-
-## 14. 参考资料
+## 9. 参考资料
- [Issue #85](https://github.com/xmanrui/dsh-im/issues/85)
-- [Undici Agent](https://github.com/nodejs/undici/blob/main/docs/docs/api/Agent.md)
- [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)
-- [Undici v7.29.0 package.json](https://github.com/nodejs/undici/blob/v7.29.0/package.json)
- [Node.js fetch 自定义 dispatcher](https://nodejs.org/dist/latest/docs/api/globals.html#custom-dispatcher)