diff --git a/AGENTS.md b/AGENTS.md
index 3599d8e9..393622e7 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -140,6 +140,14 @@ additional hardening beyond the actual internal-network threat model:
## Implementation Conventions
+- Read and follow
+ [`docs/architecture/performance-principles.md`](./docs/architecture/performance-principles.md)
+ before designing, implementing, or reviewing a feature. Treat its performance
+ budgets, process boundaries, state ownership, startup rules, and review
+ checklist as implementation constraints. Include this reference in parallel
+ agent assignments so all agents follow the same rules. For performance or
+ hot-path changes, provide the applicable measurements and validation required
+ by that document; do not claim improvements without evidence.
- Follow surrounding TypeScript and React patterns.
- Reuse installed libraries and shared contracts before adding dependencies.
- Keep changes focused. Do not add unrelated refactors or documentation.
diff --git a/FEATURES.md b/FEATURES.md
index 84a3dc97..1f8fbb4c 100644
--- a/FEATURES.md
+++ b/FEATURES.md
@@ -931,12 +931,13 @@ records are listed separately and do not introduce another feature status.
action errors.
- [x] **Client-computer control tools**: Managed separately from the built-in
browser with scope, cancellation, timeout, output, and activity boundaries.
-- [x] **Remote messaging-channel projects**: WeChat ClawBot, WeCom, and
- DingTalk each have a system-managed project, independent remote
+- [x] **Remote messaging-channel projects**: WeChat ClawBot, WeCom,
+ DingTalk, and Telegram each have a system-managed project, independent remote
conversations, working directory, processing backend, and
task/activity ownership. Each channel controls complete-response
length and segmentation according to platform capabilities rather than
- relying on a shared service truncation.
+ relying on a shared service truncation. Telegram's live Bot + model chain
+ remains [pending acceptance](./docs/features/telegram-channel/progress.md).
- [x] **WeChat ClawBot QR login and media**: A separate Sidecar handles local
QR scanning, verification codes, encrypted credentials, and text messaging.
Personal WeChat chats support images and files, with at most four
diff --git a/FEATURES.zh-CN.md b/FEATURES.zh-CN.md
index 83ceae51..3d5749ef 100644
--- a/FEATURES.zh-CN.md
+++ b/FEATURES.zh-CN.md
@@ -144,7 +144,7 @@
- [x] **浏览器多页签与按需资源**:同一对话可打开多个共享登录状态、页面与导航独立的浏览器页签,模型请求固定使用开始时绑定的目标。未使用的请求预留不创建浏览器资源,已释放页面可重新打开;保留显式截图,普通操作不再触发无用的自动截图。
- [x] **浏览器关闭与刷新**:AI 使用中的页签也可关闭,不取消整个请求;后续显式导航会创建独立替代页。空白页不能刷新,切换或关闭面板后清除旧操作错误。
- [x] **客户端电脑控制工具**:与内置浏览器分开管理,并保留范围、取消、超时、输出边界和执行记录。
-- [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端及任务活动归属;完整回复交由各通道按平台能力控制长度与分段,不再由公共服务统一截断。
+- [x] **远程消息通道项目**:微信 ClawBot、企业微信、钉钉和 Telegram 分别拥有系统管理的项目、独立远程会话、工作目录、处理后端及任务活动归属;完整回复交由各通道按平台能力控制长度与分段,不再由公共服务统一截断。Telegram 的真实 Bot 与模型链路仍[待验收](./docs/features/telegram-channel/progress.md)。
- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。
- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。
- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
diff --git a/README.md b/README.md
index 1ef9f584..1a3e5742 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@ current limitations.
| Data and execution | Local SQLite and note-file storage, current-account tool execution, and recorded tool activity |
| Knowledge | File, folder, and web imports with full-text, Chinese phrase, vector, and knowledge graph retrieval |
| Desktop releases | Official Windows, macOS, and Linux builds for `x64` and `arm64`, plus an experimental LoongArch preview |
-| Integrations | Local or hosted model endpoints, custom MCP servers, WeChat ClawBot, WeCom, and DingTalk |
+| Integrations | Local or hosted model endpoints, custom MCP servers, WeChat ClawBot, WeCom, DingTalk, and Telegram ([live Bot + model acceptance pending](./docs/features/telegram-channel/progress.md)) |
## Product tour
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 4f133eb9..08fb28f6 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -41,7 +41,7 @@ GoodBuddy 是用于模型对话、编程 Agent、知识检索、笔记和定时
| 数据与执行 | 本地 SQLite 与笔记文件存储、当前账号权限下的工具执行、工具调用记录 |
| 知识库 | 导入文件、目录和网页,通过全文、中文词组、向量和知识图谱检索 |
| 桌面版本 | Windows、macOS、Linux 的 `x64` 与 `arm64` 正式版本,以及龙芯 LoongArch 实验预览版 |
-| 外部连接 | 本机或内网模型服务、自定义 MCP、微信 ClawBot、企业微信和钉钉 |
+| 外部连接 | 本机或内网模型服务、自定义 MCP、微信 ClawBot、企业微信、钉钉和 Telegram([真实 Bot 与模型链路待验收](./docs/features/telegram-channel/progress.md)) |
## 产品界面
diff --git a/docs/features/telegram-channel/README.md b/docs/features/telegram-channel/README.md
index 2e3f4ad6..4db35be6 100644
--- a/docs/features/telegram-channel/README.md
+++ b/docs/features/telegram-channel/README.md
@@ -1,14 +1,20 @@
# Telegram 消息通道
-Telegram 消息通道计划通过用户自行创建的 Bot,将 Telegram 私聊接入 GoodBuddy 通道项目,支持文字问答和执行任务。首版采用长轮询,运行 GoodBuddy 的电脑需要在线并能访问 Telegram。
+Telegram 消息通道通过用户自行创建的 Bot,将授权用户的普通私聊文字接入 GoodBuddy 通道项目,复用项目的模型、Runtime 和工作目录。首版采用长轮询,运行 GoodBuddy 的电脑需要在线;生产驱动通过 Electron `net.fetch` 使用系统代理访问 Telegram。
## 文档导航
| 文档 | 权威职责 |
| --- | --- |
| [Telegram 通道 PRD](./prd.md) | 产品目标、首版范围、配置流程、功能要求、限制与验收标准 |
+| [技术设计](./technical-design.md) | 生产模块、身份与持久化、IPC 状态、生命周期及实现边界 |
+| [实施与验证进度](./progress.md) | 日期化验证证据、当前阻塞与待验收项目 |
+| [API 联调操作说明](./api-testing.md) | 两类探针的命令、网络路径、输出含义及副作用 |
+| [独立 Node 探针](./telegram-probe.mjs) | 独立调用 Bot API,不经过生产驱动或模型 |
+| [生产探针启动器](./run-live-probe.mjs) | 构建探针、启动隔离 Electron 进程并清理临时目录 |
+| [生产探针入口](./live-probe-main.ts) | 生产驱动连接检查及可选 ChannelService 固定回复,不调用模型 |
-当前处于 PRD 草案阶段,尚未实现 Telegram 通道。详细逻辑、UI 和技术设计在实现前按需补充,不以本目录的建立作为功能完成证据。
+生产接入代码与本地自动化验证已落地,真实 Bot 与模型的完整链路尚未证明,不能记为全面验收通过。当前结果和待办以[进度记录](./progress.md)为准;2026-10-04 独立 Node 探针成功仅是历史 API 证据。
## 术语与依赖
diff --git a/docs/features/telegram-channel/api-testing.md b/docs/features/telegram-channel/api-testing.md
new file mode 100644
index 00000000..e41ee855
--- /dev/null
+++ b/docs/features/telegram-channel/api-testing.md
@@ -0,0 +1,109 @@
+# Telegram API 联调
+
+## 用途与边界
+
+[run-live-probe.mjs](./run-live-probe.mjs) 启动 Electron,运行 [live-probe-main.ts](./live-probe-main.ts),检查生产驱动及可选的 ChannelService 固定回复。[telegram-probe.mjs](./telegram-probe.mjs) 是独立 Node API 诊断脚本,没有接入 GoodBuddy 项目、会话、白名单、Runtime 或发件箱。
+
+两类探针都不调用模型,成功不能代替真实 Bot 与模型完整链路验收。生产驱动使用 Electron `net.fetch` 和系统代理,没有引入 grammY 或 Telegraf;独立脚本使用 Node `fetch`。当前结果和 2026-10-04 历史记录统一见[进度记录](./progress.md)。本次生产实时探针未运行,原因是 `TELEGRAM_BOT_TOKEN` 不存在。
+
+## 生产驱动探针
+
+从仓库根目录运行,需要已安装项目依赖(Electron、esbuild)。启动器在临时目录构建生产源码,使用隔离的 Electron 用户目录,退出后清理。可用 `--temp-parent PATH` 指定已存在的临时父目录。
+
+```powershell
+node "docs/features/telegram-channel/run-live-probe.mjs" --dry-run
+```
+
+`--dry-run` 只构建并检查语法,不启动 Electron,不发网络请求,不需要 Token。它不能证明代理路径可用。实时连接检查使用下面命令;隐藏输入仅放入进程环境,不写文件。
+
+```powershell
+$telegramProbeSecret = Read-Host 'Telegram Bot Token' -AsSecureString
+$env:TELEGRAM_BOT_TOKEN = [System.Net.NetworkCredential]::new('', $telegramProbeSecret).Password
+try {
+ node "docs/features/telegram-channel/run-live-probe.mjs"
+} finally {
+ Remove-Item Env:TELEGRAM_BOT_TOKEN -ErrorAction SilentlyContinue
+ $telegramProbeSecret = $null
+}
+```
+
+默认仅通过生产驱动执行 `getMe` / `getWebhookInfo`,不轮询、不发送。`TELEGRAM_BOT_TOKEN` 是探针输入,产品只读环境配置使用 `GOODBUDDY_TELEGRAM_BOT_TOKEN`,两者不能混用。
+
+要验证固定回复,先停用该专用测试 Bot 的其他消费者,在普通私聊发送精确文本 `/goodbuddy_test`,再将上述运行命令替换为:
+
+```powershell
+node "docs/features/telegram-channel/run-live-probe.mjs" --send-test
+```
+
+发送模式从首批最多 100 条积压中选择最近 24 小时内最新的合格私聊标记,临时授权该发送者,经生产驱动和 ChannelService 返回固定文字。网络适配层只把选中的事件交给驱动,其他事件不会触发帮助或任务;使用临时内存状态和固定 executor,不经过产品设置页、生产模型或持久化发件箱。
+
+发送模式会启动生产轮询、设置 `allowed_updates=['message']` 并可能推进 offset,因而可能确认或消费更早的积压。仅对专用空闲 Bot 使用;这与独立 Node 探针不推进 offset 的行为不同。无匹配标记时失败退出,不等待用户补发。
+
+| 输出 | 含义 |
+| --- | --- |
+| `PASS: production driver getMe/getWebhookInfo.` | 生产驱动身份及 webhook 检查成功 |
+| `PASS: production driver and ChannelService delivered the test reply.` | 固定回复 API 成功,仍需手机核对展示;未调用模型 |
+| `FAIL: no eligible marker...` | 首批积压无合格标记,收发未验证 |
+| 其他 `FAIL` 或非零退出 | 设置、构建、网络、停止或清理失败,不算通过 |
+
+启动器只转发固定结果码,抑制可能包含 Token URL 的 Electron 输出,并强制 TLS 校验。成功退出要求对应检查完成且子进程正常退出。
+
+## 独立 Node 探针
+
+默认只调用 `getMe` 和 `getWebhookInfo`,不读取消息或发送回复。添加 `--listen` 后,在无 webhook 的情况下进行约 45 秒长轮询,仅对运行开始后收到的私聊 `/goodbuddy_test` 回复一条固定测试文字,然后退出。不处理其他消息、不调用模型、不下载附件。
+
+脚本不推进更新 offset、不修改订阅类型、不删除 webhook。已读取的更新仍可能被后续消费者再次读取;请使用专用 Bot,避免与其他轮询进程同时运行。积压达到单批 100 条时停止测试,不主动确认或清空消息。固定回复发生网络结果不明时不自动重试。
+
+### 运行方法
+
+历史实测环境为 Windows、PowerShell 5.1、Node.js 24.21.0。以下命令从仓库根目录执行,Token 通过隐藏输入传入当前进程环境,不保存在文件中。不要把实际 Token 写进命令、文档或 Git。
+
+```powershell
+$telegramProbeSecret = Read-Host 'Telegram Bot Token' -AsSecureString
+$env:TELEGRAM_BOT_TOKEN = [System.Net.NetworkCredential]::new('', $telegramProbeSecret).Password
+try {
+ node --use-env-proxy "docs/features/telegram-channel/telegram-probe.mjs"
+} finally {
+ Remove-Item Env:TELEGRAM_BOT_TOKEN -ErrorAction SilentlyContinue
+ $telegramProbeSecret = $null
+}
+```
+
+`--use-env-proxy` 需要支持该选项的 Node 版本,脚本不会自动读取 Windows 系统代理。若机器依赖系统代理,先按下面方式将当前代理临时提供给 Node;这不会修改操作系统代理配置。结束后恢复原有环境值。
+
+```powershell
+$telegramProbePreviousProxy = $env:HTTPS_PROXY
+$telegramProbeProxy = [System.Net.WebRequest]::GetSystemWebProxy().GetProxy([Uri]'https://api.telegram.org')
+if ($telegramProbeProxy.Host -ne 'api.telegram.org') {
+ $env:HTTPS_PROXY = $telegramProbeProxy.AbsoluteUri
+}
+# 在此执行上面的凭据输入和测试命令。
+$env:HTTPS_PROXY = $telegramProbePreviousProxy
+```
+
+测试真实收发时,将运行命令改为:
+
+```powershell
+node --use-env-proxy "docs/features/telegram-channel/telegram-probe.mjs" --listen
+```
+
+看到提示后,在 Telegram 打开测试 Bot 的普通私聊,发送 `/goodbuddy_test`。脚本成功发送固定回复后输出 `privateMessageRoundTrip` 的 `ok: true`。用户还需在手机端确认实际收到回复。
+
+如果已发送测试指令,使用 `--pending` 替代 `--listen`。脚本单次查询排队消息,选择最近一小时内最新的私聊 `/goodbuddy_test` 并回复,无需在监听窗口内发送。该选项同样不推进 offset,重复运行可能再次回复同一条指令,仅在需要复测时使用。
+
+脚本强制开启 TLS 证书校验,不沿用外部环境中的关闭校验设置。输出仅包含检查结果、Bot 用户名、是否配置 webhook、积压数量和有界错误码;不打印 Token、完整请求地址、原始异常、消息正文或聊天 ID。
+
+### 结果解释
+
+| 输出或退出码 | 含义 |
+| --- | --- |
+| 退出码 0,默认检查 | Bot 身份及 webhook 状态查询成功;未验证收发 |
+| 退出码 0,`privateMessageRoundTrip` 成功 | 收到测试指令,发送回复 API 返回成功 |
+| 退出码 2,`no_matching_message` | 时间窗口内未收到符合条件的测试指令,收发待验证 |
+| 退出码 1 | 网络、凭据、平台错误或测试条件不满足 |
+| `UND_ERR_CONNECT_TIMEOUT` | 网络连接超时,优先检查当前进程的代理路径 |
+| API error 401 | 凭据无效或已撤销 |
+| API error 409 | 检查 webhook 或其他长轮询实例是否占用 |
+| API error 429 | 平台限流;脚本报告等待时间,不自动继续请求 |
+
+日期化实测结果见[进度记录](./progress.md)。独立 Node 请求走通不能证明 Electron 生产网络路径走通;固定回复成功也不能证明模型或工具任务成功。
diff --git a/docs/features/telegram-channel/live-probe-main.ts b/docs/features/telegram-channel/live-probe-main.ts
new file mode 100644
index 00000000..a2bee683
--- /dev/null
+++ b/docs/features/telegram-channel/live-probe-main.ts
@@ -0,0 +1,134 @@
+import { app, net } from 'electron'
+import { ChannelService } from '../../../src/main/channels/channel-service'
+import { TelegramChannelDriver } from '../../../src/main/channels/telegram-channel-driver'
+
+type Update = {
+ update_id: number
+ message?: {
+ date: number
+ text?: string
+ from?: { id: number; is_bot: boolean }
+ chat: { id: number; type: string }
+ message_thread_id?: number
+ is_topic_message?: boolean
+ }
+}
+
+function report(code: string): void {
+ process.send?.(code)
+}
+
+async function bounded(operation: Promise, ms: number): Promise {
+ let timer: ReturnType | undefined
+ try {
+ return await Promise.race([
+ operation,
+ new Promise((_resolve, reject) => {
+ timer = setTimeout(() => reject(new Error('Probe timeout')), ms)
+ })
+ ])
+ } finally {
+ clearTimeout(timer)
+ }
+}
+
+async function main(): Promise {
+ let driver: TelegramChannelDriver | undefined
+ let service: ChannelService | undefined
+ let exitCode = 1
+ try {
+ const userData = process.env.GOODBUDDY_PROBE_USER_DATA
+ const secret = process.env.TELEGRAM_BOT_TOKEN
+ if (!userData || !secret || !process.send) throw new Error('Runner required')
+ app.setPath('userData', userData)
+ app.setPath('sessionData', userData)
+ app.setPath('logs', userData)
+ // Keep Chromium caches and diagnostics inside the disposable profile.
+ app.commandLine.appendSwitch('disable-logging')
+ app.commandLine.appendSwitch('disable-http-cache')
+ await bounded(app.whenReady(), 15_000)
+
+ let selected: Update | undefined = undefined
+ const electronFetch: typeof globalThis.fetch = async (input, init) => {
+ const url = input instanceof Request ? input.url : String(input)
+ const response = await net.fetch(url, init)
+ if (!selected || !url.endsWith('/getUpdates') || !response.ok) return response
+ const data = await response.json() as { ok?: boolean; result?: Update[] }
+ if (data.ok !== true || !Array.isArray(data.result)) throw new Error('Invalid updates')
+ // This is the only network adaptation. Receiving, parsing, queuing and sending
+ // still run through production code; other queued commands cannot send help.
+ return new Response(JSON.stringify({
+ ok: true,
+ result: data.result.filter((update) =>
+ update.update_id === selected?.update_id &&
+ update.message?.text === '/goodbuddy_test' &&
+ update.message?.from?.id === selected?.message?.from?.id &&
+ update.message?.chat.id === selected?.message?.chat.id)
+ }), { headers: { 'content-type': 'application/json' } })
+ }
+ driver = new TelegramChannelDriver({ secret, allowedSenderIds: [], fetch: electronFetch })
+ await bounded(driver.testConnection(), 35_000)
+ report('connection')
+ if (!process.argv.includes('--send-test')) {
+ exitCode = 0
+ return
+ }
+
+ // No offset: discovery does not acknowledge the queue or alter allowed_updates.
+ const response = await net.fetch(`https://api.telegram.org/bot${secret}/getUpdates`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ timeout: 0, limit: 100 }), redirect: 'error',
+ signal: AbortSignal.timeout(15_000)
+ })
+ const data = await response.json() as { ok?: boolean; result?: Update[] }
+ if (!response.ok || data.ok !== true || !Array.isArray(data.result)) throw new Error('Discovery failed')
+ const now = Math.floor(Date.now() / 1000)
+ selected = data.result.filter((update) => {
+ const message = update.message
+ return Number.isSafeInteger(update.update_id) && update.update_id >= 0 &&
+ message?.text === '/goodbuddy_test' && message.chat?.type === 'private' &&
+ Number.isSafeInteger(message.from?.id) && message.from!.id > 0 &&
+ message.from?.is_bot === false && message.chat.id === message.from.id &&
+ message.message_thread_id === undefined && !message.is_topic_message &&
+ Number.isSafeInteger(message.date) && message.date >= now - 86_400 && message.date <= now
+ }).sort((a, b) => b.message!.date - a.message!.date || b.update_id - a.update_id)[0]
+ if (!selected) {
+ report('missing')
+ return
+ }
+ report('selected')
+ const allowedSenderIds = [String(selected.message!.from!.id)]
+ driver.updateAllowedSenderIds(allowedSenderIds)
+ let delivered!: () => void
+ const delivery = new Promise((resolve) => { delivered = resolve })
+ service = new ChannelService(driver, async (message) => {
+ if (message.eventId !== String(selected!.update_id) || message.text !== '/goodbuddy_test') {
+ throw new Error('Unexpected event')
+ }
+ return {
+ status: 'completed',
+ output: 'GoodBuddy Telegram channel test passed using the production driver and ChannelService. No AI model or tools were used.'
+ }
+ }, { allowedSenderIds, maximumConcurrency: 1, onDeliverySuccess: delivered })
+ await service.start()
+ await bounded(delivery, 45_000)
+ report('delivered')
+ exitCode = 0
+ } catch {
+ report('failed')
+ } finally {
+ try {
+ await bounded(Promise.all([service?.stop(), driver?.stop()]), 5_000)
+ } catch {
+ report('shutdown')
+ exitCode = 1
+ }
+ process.exitCode = exitCode
+ app.quit()
+ setTimeout(() => app.exit(exitCode), 1_000).unref()
+ }
+}
+
+process.on('uncaughtException', () => { report('failed'); app.exit(1) })
+process.on('unhandledRejection', () => { report('failed'); app.exit(1) })
+void main()
diff --git a/docs/features/telegram-channel/prd.md b/docs/features/telegram-channel/prd.md
index 378edcf5..bf82a859 100644
--- a/docs/features/telegram-channel/prd.md
+++ b/docs/features/telegram-channel/prd.md
@@ -4,20 +4,20 @@
| 项目 | 内容 |
| --- | --- |
-| 状态 | 草案,待评审;Telegram 通道尚未实现 |
-| 版本 | 0.1 |
-| 日期 | 2026-10-04 |
+| 状态 | 已按生产实现修订;真实 Bot 与模型完整链路待验收 |
+| 版本 | 0.2 |
+| 日期 | 2026-10-05 |
| 适用产品 | GoodBuddy 桌面端 |
| 首版范围 | 单 Bot、普通私聊、文字问答与工具任务、最终结果回复 |
| 接入方式 | 用户通过 BotFather 创建 Bot,GoodBuddy 使用 Bot API 长轮询 |
-本文定义拟交付的产品行为。验收表描述未来交付标准,不代表已经通过测试。BotFather 接入方向来自本次讨论;范围取舍和异常行为是本草案提出的方案。
+本文定义首版产品行为和验收标准,已按当前实现修订。验收表不代表已经通过测试;验证结果及剩余工作由[进度记录](./progress.md)维护,模块与协议细节见[技术设计](./technical-design.md)。
## 1. 用户问题与目标
用户离开电脑后,希望通过 Telegram 向 GoodBuddy 提问或发起任务,并在手机上收到结果。任务应使用桌面端已经配置的模型、Runtime 和工作目录,执行过程与对话历史仍可在 GoodBuddy 查看。
-当前远程消息通道已有微信 ClawBot、企业微信和钉钉,尚无 Telegram 入口。新增通道应沿用现有“通道项目 + 远程会话”结构,让用户通过创建 Bot、填写凭据和配置允许访问的用户完成接入,无需自行部署消息中转服务。
+Telegram 沿用现有“通道项目 + 远程会话”结构,与微信 ClawBot、企业微信和钉钉并列。用户通过创建 Bot、填写凭据和配置允许访问的用户完成接入,无需自行部署消息中转服务。
首版完成以下使用流程:
@@ -35,7 +35,7 @@
| Bot 数量 | 每个 GoodBuddy 用户配置一个 Telegram Bot |
| 会话类型 | 普通私聊;不同用户的会话相互隔离 |
| 输入 | 文字消息和受支持的命令 |
-| 执行 | 复用渠道项目的处理后端和统一执行规则,无产品模式 |
+| 执行 | 复用渠道项目的处理后端、已启用能力和统一执行规则 |
| 输出 | 任务进行提示、最终文字结果、长回复分段 |
| 管理 | Token 保存和替换、用户 ID 白名单、连接测试、启停和状态展示 |
| 故障处理 | 临时断网重连、平台限流等待、已保存结果的有限重试 |
@@ -63,7 +63,7 @@
1. 用户在 GoodBuddy 远程通道设置中打开 Telegram 卡片,点击官方 BotFather 链接。
2. 用户在 BotFather 发送 `/newbot`,设置显示名称和符合 Telegram 要求的用户名,获得 Token。
-3. 用户回到 GoodBuddy 填写 Token,执行“测试连接”。成功后显示 Bot 名称、用户名和打开 Bot 的链接。
+3. 用户回到 GoodBuddy 填写 Token,执行“测试连接”。成功后显示 API 返回的 Bot 用户名和打开 Bot 的链接,不展示 Bot 显示名称。
4. 用户先保存凭据并以空白名单启用连接,卡片显示“尚未授权用户”。此时仅提供私聊帮助,不接受业务请求。
5. 用户在 Telegram 打开该 Bot,发送 `/start`,再发送 `/whoami` 获取自己的数字用户 ID。卡片说明必须由用户先发起私聊。
6. 用户将该 ID 填入 GoodBuddy 白名单并保存,随后发送普通文本,收到回复,并在 Telegram 项目中看到对应会话。
@@ -82,9 +82,9 @@
### FR-2 凭据管理
-Token 使用现有加密凭据存储,保存后不在普通设置快照中返回明文。用户可以保留、替换或清除凭据。日志、连接错误和会话内容不得包含 Token 或含 Token 的完整请求地址。
+Token 使用现有加密凭据存储,保存后不在普通设置快照中返回明文。用户可以保留、替换或清除凭据;环境变量覆盖时配置只读。日志、连接错误和会话内容不得包含 Token 或含 Token 的完整请求地址。
-替换 Token 前验证 Bot 身份。若仍为同一个 Bot,继续使用原会话;若为另一个 Bot,新会话与旧 Bot 隔离,旧 Bot 的待发结果不得经新 Bot 发送。清除凭据停止连接,保留项目与历史。
+替换 Token 前验证 Bot 身份,验证失败保留原配置与连接。若仍为同一个 Bot,继续使用原会话;若为另一个 Bot,新会话与旧 Bot 隔离,旧 Bot 的待发结果不得经新 Bot 发送。在设置页清除凭据并保存时同时停用通道,保留项目与历史。
### FR-3 连接测试
@@ -102,14 +102,15 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返
### FR-5 会话与连续上下文
-同一 Bot 下同一普通私聊复用远程会话,不同用户和不同 Bot 的上下文隔离。会话显示来源和发送者名称;名称不可用时使用用户 ID 作为可识别的回退信息。名称变化不产生新身份。
+同一 Bot 下同一普通私聊复用远程会话,不同用户和不同 Bot 的上下文隔离。会话归属 Telegram 项目,发送者以数字用户 ID 标识;首版不采集发送者显示名称。
同一会话按接收顺序处理业务消息。用户连续发送消息时,不得使后发请求抢用尚未完成的上一轮上下文。不同会话沿用现有通道并发限制。
### FR-6 执行范围
-普通文本使用项目后端与能力配置;`/ask`、`/execute` 和 `/exec` 沿用现有渠道的退役规则,
-作为普通正文保留,不切换权限。仅含旧命令的非空消息也按普通消息处理。
+授权用户直接发送自然语言请求,使用项目后端与能力配置完成问答或工具任务,无需选择或切换工作模式。通道不提供模式选择器、默认模式设置或模式切换命令。
+
+`/ask`、`/execute` 和 `/exec` 按现有统一执行规则作为普通正文保留,不剥离前缀、不切换权限;仅含旧命令的非空消息也按普通消息处理。帮助文案不再介绍这些前缀。
按[统一执行规则](../unified-execution/logic-design.md)使用已启用工具,不增加 Telegram 专属
逐工具审批。设置页显示默认工作目录和后端,并说明允许访问的用户可发起操作。
@@ -119,7 +120,7 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返
`/start` 返回 Bot 用途、授权配置说明和基础用法;`/whoami` 仅返回当前发送者的数字用户 ID。上述命令不写入业务上下文。
-授权用户发送未知斜杠命令时,返回支持的命令列表,不将其当成执行请求。编辑历史消息不自动重新执行任务。
+除 `/start` 和 `/whoami` 外,授权用户发送的非空文字均按普通请求处理,包括带斜杠前缀的文字,不额外引入命令兼容规则。空白消息按现有输入规则拒绝。编辑历史消息不自动重新执行任务。
授权用户发送媒体时,提示“当前仅支持文字消息,请改用文字描述”。带说明文字的媒体首版同样提示不支持,不丢弃媒体后擅自执行说明文字。未授权媒体不触发处理。
@@ -127,7 +128,7 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返
请求进入处理后,Telegram 侧显示输入状态或简短进行提示。该提示不表示任务完成,也不表示任务可以在应用崩溃后恢复。
-任务完成后发送最终文字;失败时发送脱敏原因及必要的恢复建议。回复尽量关联原始消息;原消息不可引用时仍应尝试发送结果。长文本按平台上限顺序分段,不破坏 Unicode 字符,不因 Markdown 转义问题造成整条回复失败。
+任务完成后发送最终文字;失败时发送脱敏原因及必要的恢复建议。回复作为普通私聊消息发送,首版不引用原始消息。长文本以纯文本顺序分段,保留 Unicode 代理对,不启用 Markdown 解析。
首版不发送附件。若任务产生图片或文件,文字回复说明成果可在 GoodBuddy 中查看,不宣称附件已发送,也不向用户发送仅本机可访问的路径作为下载链接。
@@ -137,7 +138,7 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返
已生成并保存的结果沿用现有发件箱有限重试,发送失败与任务执行失败分别记录。补发回复不重新调用模型或重跑任务。用户阻止 Bot 等不可恢复错误应停止无效重试。
-多段回复部分成功后,应尽量从未发送部分继续。网络中断导致无法确定平台是否收到某段时,允许重试出现重复段落,首版不承诺平台级发送幂等。
+同一驱动实例内且分段进度仍在缓存时,多段回复从未发送部分继续。进度不跨重启持久化,重启或缓存淘汰后补发可能重复已发送段落;网络中断造成平台接收结果不明时也可能重复。首版不承诺平台级发送幂等。
### FR-10 启停与历史保留
@@ -157,7 +158,9 @@ Telegram 待接收更新最多保留 24 小时,超过平台保留期的离线
### FR-12 状态与诊断
-设置页区分未配置、已停用、正在连接、已连接、正在重连和连接失败。空白名单作为“尚未授权用户”的配置提示展示,不与网络状态混为一谈。
+设置页展示未配置、已停用、正在连接、已连接、已停止和连接失败。重连时显示正在连接,并附带错误原因与重连说明;没有独立的重连状态值。空白名单作为“尚未授权用户”的配置提示展示,不与网络状态混为一谈。
+
+状态和错误推送只更新状态区域,不覆盖尚未保存的 Token 或白名单草稿。解决轮询冲突后可重新保存当前配置以启动连接;单独测试连接成功不改变接收状态。
状态通过文字表达,不能只靠颜色。错误保留可操作原因,如 Token 无效、网络不可达、webhook 占用或另一实例正在轮询。测试成功、发送失败和模型失败使用各自准确文案,避免统一显示“连接失败”。
@@ -174,15 +177,15 @@ Telegram 待接收更新最多保留 24 小时,超过平台保留期的离线
| AC-03 | FR-2、3 | 通道运行时连续测试连接,不争抢轮询、不消费业务消息;无效 Token 与 webhook 占用分别提示 |
| AC-04 | FR-5 | 同一用户连续两轮对话保留上下文;两名授权用户的内容不相互进入上下文 |
| AC-05 | FR-6 | 普通请求可在专用测试目录完成一次无害工具任务;旧模式前缀作为正文保留,结果和活动可在桌面核对,无默认模式字段 |
-| AC-06 | FR-4、7 | 未授权用户、群消息、话题消息和媒体不触发任务;授权媒体、未知命令和空命令获得准确提示 |
+| AC-06 | FR-4、7 | 未授权用户、群消息、话题消息和媒体不触发任务;授权媒体获得不支持提示;内置帮助命令不调用模型,其他斜杠前缀按正文保留,空白消息被拒绝 |
| AC-07 | FR-8 | 超过单条平台上限的回复按序完整展示,中文和表情字符完整;任务失败返回脱敏原因 |
| AC-08 | FR-8 | 生成文件的任务在 Telegram 中提示桌面查看成果,不出现“附件已发送”的错误表述 |
| AC-09 | FR-9、12 | 模拟短暂断网、429 和永久发送错误,分别重连、等待和停止无效重试;任务执行结果不被误记为执行失败 |
| AC-10 | FR-9 | 已保存结果发送失败后恢复连接可补发,补发不增加模型调用或再次执行任务;验证部分分段成功情形 |
-| AC-11 | FR-10 | 停用通道后新消息不启动任务,当前与排队任务取消;历史与成果保留;重新启用不重跑取消任务 |
+| AC-11 | FR-2、10 | 停用通道后新消息不启动任务,当前与排队任务取消;启用时清除 Token 并保存会同时停用;项目、历史与成果保留;重新启用不重跑取消任务 |
| AC-12 | FR-2、5 | 同一 Bot 换 Token 保留会话;更换 Bot 后上下文隔离,旧待发结果不会由新 Bot 发出 |
| AC-13 | FR-4、11 | 重投同一更新不重复执行;离线后恢复可接收平台保留消息;已撤销用户的积压消息不执行 |
-| AC-14 | FR-2、12 | 设置快照、日志及错误不泄漏 Token;凭据轮换、网络错误和 Runtime 错误均可定位 |
+| AC-14 | FR-2、12 | 设置快照、日志及错误不泄漏 Token;凭据轮换、网络错误和 Runtime 错误均可定位;连接错误及重连状态推送保留未保存的 Token 和白名单草稿 |
| AC-15 | FR-1、10 | 新增 Telegram 后微信、企业微信和钉钉的设置、启停与历史读取保持原行为 |
| AC-16 | FR-9、10 | 有已保存回复等待重试时停用通道,确认重试停止;重新启用后补发该 Bot 的结果,任务执行次数不增加 |
| AC-17 | FR-11 | 在专用测试任务执行期间异常退出应用,重启后不自动重跑该中断任务;已有任务记录可查询,不把未取得的完成结果显示为成功;帮助说明保留人工核对后重发的限制 |
@@ -193,9 +196,9 @@ Telegram 待接收更新最多保留 24 小时,超过平台保留期的离线
通道项目、工作目录、Runtime 选择和授权语义复用[现有远程消息通道约定](../wechat-channel/prd.md)。Telegram 的外部网络可达性、Bot 状态和平台限额影响收发,连接测试无法证明后续每次任务或文件生成都能成功。
-开发前需核对主进程网络与代理路径,以及现有通道停用、去重和发件箱行为是否满足本文要求。差异应在对应技术设计中解决并关联 FR 编号,不能只增加设置入口后宣称完成。本文不规定新增 SDK、数据库表或通用恢复框架。
+生产驱动使用 Electron 网络栈和系统代理。停用、去重、发件箱与状态推送的实现见[技术设计](./technical-design.md),实际覆盖与未完成验证见[进度记录](./progress.md)。没有测量数据时,不宣称性能提升或性能预算通过。
-前期讨论提出过“持久化入站后再确认”的方案。本草案将其列为后续可靠性评估,首版采用 FR-11 的明确限制;若后续要求已接收任务跨崩溃恢复,再依据实际故障与业务需求评估存储和执行恢复成本。
+持久化入站后再确认仍属于后续可靠性评估,首版采用 FR-11 的限制;若后续要求已接收任务跨崩溃恢复,再依据实际故障与业务需求评估存储和执行恢复成本。
| 后续方向 | 进入设计前需明确的内容 |
| --- | --- |
diff --git a/docs/features/telegram-channel/progress.md b/docs/features/telegram-channel/progress.md
new file mode 100644
index 00000000..4c3cc263
--- /dev/null
+++ b/docs/features/telegram-channel/progress.md
@@ -0,0 +1,68 @@
+# Telegram 实施与验证进度
+
+## 2026-10-05 当前结果
+
+生产设置入口、驱动、项目路由、执行、持久化和状态推送已接入,行为见[技术设计](./technical-design.md)。真实 Telegram Bot 与模型的完整链路尚未证明,[PRD 验收](./prd.md#5-产品验收)仍待完成。
+
+以下计数来自本次开发会话提供的验证结果,本轮文档修订未重跑这些测试。集合可能重叠,不相加计算覆盖总数。
+
+| 验证范围 | 结果 | 证据边界 |
+| --- | --- | --- |
+| Driver / Service / Manager 聚焦测试 | 74 通过 | 解析、授权、重试、身份隔离与生命周期等自动化覆盖 |
+| 集成测试集合 | 251 通过,1 跳过 | 包含 Electron 生产 UI / IPC / store / executor 与本地模型 fixture,Telegram 网络响应为替身 |
+| Electron 场景 | 2 次本地模型调用,0 次外部调用 | 配置测试与加密保存、授权和去重、结果入库与回复、重载恢复、停用取消;错误及重连状态推送保留草稿;启用状态清除 Token 后停止连接并保留项目和历史 |
+| Typecheck / lint | 最终复跑均通过 | 静态检查,不能替代真实 Bot 联调 |
+| 全量测试原始结果 | 6086 通过,86 跳过,2 失败 | 保留当次失败记录;后续修复与复跑见下两行,尚无新的全量通过结论 |
+| Schema 断言修复 | 已改用 schema 版本常量;四个 suite 共 301 通过 | 修复原先预期 60、实际 61 的 worker 断言,不将局部复跑计为全量通过 |
+| OpenCode cleanup-timeout 复跑 | 单独运行 3 次通过,整个 runtime suite 通过 | 原因尚未证明,记录为全量运行中出现的间歇性失败,不宣称已修复 |
+| 最终全量测试 | 6087 通过,87 跳过,1 失败;耗时 879.68 秒 | 唯一失败为 `tests/inline-help.electron.test.ts` 的 Hover Escape 场景;原样单独复跑通过,仍保留间歇性失败记录。此前 schema 和 Runtime 失败未在本轮出现 |
+| 生产实时探针 | 本次未运行 | 当前会话缺少 `TELEGRAM_BOT_TOKEN` |
+| 生产探针 dry-run | 打包与语法检查通过 | 未启动 Electron,未发出网络请求 |
+| 真实本地代理 fixture | 真实 Electron 生产默认传输曾成功运行 1 次;最终运行因临时 CA 信任导入超时失败 | 后续 Windows 临时 CA 信任导入需要操作系统确认;测试已改为显式启用,默认跳过。不宣称可无人值守复现 |
+| Main 性能测量 | 基线与当前版本交替各运行 3 轮 | 结果见下节;没有证据证明性能改善或 Telegram 引入回退,不宣称全局预算通过 |
+
+Electron 证据对应 [`tests/telegram-channel.electron.test.ts`](../../../tests/telegram-channel.electron.test.ts) 与 [`tests/support/telegram-main.ts`](../../../tests/support/telegram-main.ts)。它使用生产组件、Preload、IPC、配置存储、executor 和 SQLite;在 `net.fetch` 边界替换 Telegram 响应,模型为本地 HTTP fixture。因此它验证内部产品路径,不能证明外部 Telegram 网络、系统代理或真实模型服务可用。
+
+可复跑的聚焦命令:
+
+```powershell
+npx vitest run src/main/channels/telegram-channel-driver.test.ts src/main/channels/channel-service.test.ts src/main/channels/channel-manager.test.ts
+npx vitest run tests/telegram-channel.electron.test.ts
+npm run typecheck
+npm run lint
+npm test
+```
+
+集成集合及 301 项复跑的完整命令未随计数提供,这里不推测其文件清单。真实本地代理 fixture 见 [`tests/telegram-proxy.electron.test.ts`](../../../tests/telegram-proxy.electron.test.ts);其本地代理结果不证明外部 Telegram Bot 可用。探针操作见 [API 联调](./api-testing.md),探针成功也不包含模型验收。
+
+## Main 性能对比
+
+基线为 `78afec77758f3c175f8e2b80e5be4327e13b4689` 的临时 detached worktree,当前为包含 Telegram 改动的工作区。按基线、当前交替执行 `npm run perf:main` 各 3 次。环境为 Windows x64、Intel Core Ultra X7 358H、Electron 44.5.1、Node 24.21.0;每轮使用隔离数据库,包含 1000 个会话、20008 条生成消息、24000 条活动记录,每个场景预热后测量 15 次,无外部模型调用。
+
+下表为每轮调用耗时 p95,单位为毫秒。对比相同生产路径,不将脚本内部的历史同步算法视为基线版本。
+
+| 场景 | 基线三轮 | 当前三轮 |
+| --- | --- | --- |
+| 会话摘要 worker | 25.0 / 20.9 / 29.3 | 18.8 / 26.6 / 53.3 |
+| 最大会话 worker | 1.1 / 0.6 / 1.1 | 0.8 / 1.0 / 1.2 |
+| 活动增量更新 | 1.3 / 2.3 / 2.0 | 1.3 / 1.5 / 1.7 |
+| 活动启动 worker | 17.7 / 18.7 / 29.2 | 13.7 / 23.0 / 25.2 |
+| 整份活动替换,一条变化 | 61.8 / 84.9 / 73.1 | 52.0 / 84.0 / 73.8 |
+
+worker 读取、活动启动、增量更新和同步首页查询在六轮中均满足 Main 阻塞与事件循环延迟预算;当前这些路径的最大循环间隔为 3.7 ms,最高循环延迟 p99 为 4.4 ms。整份活动替换在两个版本中均超预算,当前最大循环间隔为 47.0–84.2 ms;同步活动汇总也存在超过 16 ms 的情况。这些已有路径不属于本次 Telegram 优化范围。
+
+版本差异与轮次波动交叠,不能据此归因于 Telegram 或声称性能提升。测量在新数据库初始化后进行,不覆盖已填充 schema 60 数据库升级到 61 的耗时、Telegram 流量或完整 UI 响应。原始结果保存在本次机器临时目录 `telegram-main-results-20261005-a`,不作为仓库依赖。
+
+## 2026-10-04 历史 API 证据
+
+独立 `telegram-probe.mjs` 在 Windows / PowerShell 5.1 / Node.js 24.21.0 下测试。首次直连曾有关闭 TLS 校验的环境配置,不作为正常 HTTPS 证据;开启校验后直连超时。将 Windows 系统代理临时传给 Node 后,`getMe`、`getWebhookInfo` 成功,无 webhook。
+
+初次监听窗口未收到匹配指令;随后 `--pending` 从 2 条积压中匹配 `/goodbuddy_test`,`sendMessage` 返回成功,输出 `privateMessageRoundTrip: ok=true`。没有模型调用,手机端实际展示仍需用户确认。该记录只证明当日独立 Node API 路径,不证明当前凭据有效、生产驱动可用或完整产品验收通过。历史凭据曾公开,应撤销后换新,文档不保留 Token。
+
+## 待完成
+
+- 在可完成操作系统信任确认的环境中复跑显式启用的代理测试,记录最终结果;保留单次成功和后续导入超时两项证据,不把默认跳过视为通过。
+- 跟踪全量测试中的 Runtime 清理与帮助弹层间歇性失败;两者均有原样复跑通过证据,未为此修改无关产品逻辑。
+- 提供有效测试凭据后运行生产实时探针,记录连接及固定回复结果;本次缺少凭据的阻塞不以历史 Node 成功替代。
+- 从真实设置页保存并启用专用 Bot,在手机发送授权请求,经过实际模型产生回复并核对桌面历史;再按 PRD 验证连续上下文、隔离、无害工具任务和恢复场景。当前没有真实 Bot 与模型完整链路的通过记录。
+- Main 测量未覆盖迁移和完整 UI;后续验收需按[性能原则](../../architecture/performance-principles.md)区分实测路径与未测路径。
diff --git a/docs/features/telegram-channel/run-live-probe.mjs b/docs/features/telegram-channel/run-live-probe.mjs
new file mode 100644
index 00000000..28140962
--- /dev/null
+++ b/docs/features/telegram-channel/run-live-probe.mjs
@@ -0,0 +1,102 @@
+/* global process, console, setTimeout, clearTimeout, URL */
+import { spawn, spawnSync } from 'node:child_process'
+import { mkdtemp, mkdir, rm, stat } from 'node:fs/promises'
+import { createRequire } from 'node:module'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { build } from 'esbuild'
+
+const require = createRequire(import.meta.url)
+const messages = {
+ connection: 'PASS: production driver getMe/getWebhookInfo.',
+ selected: 'Selected a queued private test marker from the last 24 hours.',
+ delivered: 'PASS: production driver and ChannelService delivered the test reply.',
+ missing: 'FAIL: no eligible marker in the pending batch (maximum 100 updates).',
+ failed: 'FAIL: live probe failed; network details suppressed.',
+ shutdown: 'FAIL: probe shutdown did not complete.'
+}
+
+async function main() {
+ const args = process.argv.slice(2)
+ let tempParent = tmpdir()
+ for (let i = 0; i < args.length; i += 1) {
+ if (args[i] === '--temp-parent' && args[i + 1]) tempParent = args[++i]
+ else if (!['--dry-run', '--send-test'].includes(args[i])) {
+ console.error('Usage: node run-live-probe.mjs [--dry-run | --send-test] [--temp-parent PATH]')
+ process.exitCode = 1
+ return
+ }
+ }
+ const dry = args.includes('--dry-run')
+ const send = args.includes('--send-test')
+ if (dry && send) throw new Error('Invalid mode')
+ if (!dry && !/^\d+:[A-Za-z0-9_-]+$/.test(process.env.TELEGRAM_BOT_TOKEN ?? '')) {
+ console.error('Set TELEGRAM_BOT_TOKEN in the environment before running live.')
+ process.exitCode = 1
+ return
+ }
+ if (!(await stat(tempParent)).isDirectory()) throw new Error('Invalid temporary parent')
+ const directory = await mkdtemp(join(tempParent, 'goodbuddy-telegram-live-'))
+ try {
+ const outfile = join(directory, 'probe.cjs')
+ await build({
+ entryPoints: [fileURLToPath(new URL('./live-probe-main.ts', import.meta.url))],
+ outfile, bundle: true, platform: 'node', format: 'cjs', target: 'node22',
+ external: ['electron'], logLevel: 'silent'
+ })
+ const check = spawnSync(process.execPath, ['--check', outfile], {
+ stdio: 'ignore', timeout: 15_000
+ })
+ if (check.status !== 0) throw new Error('Bundle syntax check failed')
+ const electron = require('electron')
+ if (typeof electron !== 'string') throw new Error('Electron executable unavailable')
+ if (dry) {
+ console.log('PASS: bundled production sources and checked syntax; Electron not launched, no network calls.')
+ return
+ }
+ if (send) {
+ console.log('Send mode: use a dedicated idle bot. Polling may consume/acknowledge earlier queued updates.')
+ console.log('Only the selected exact /goodbuddy_test event receives a fixed reply; no model or tools run.')
+ }
+ const userData = join(directory, 'user-data')
+ await mkdir(userData)
+ const env = { ...process.env, GOODBUDDY_PROBE_USER_DATA: userData }
+ delete env.ELECTRON_RUN_AS_NODE
+ delete env.ELECTRON_ENABLE_LOGGING
+ delete env.ELECTRON_LOG_FILE
+ delete env.NODE_OPTIONS
+ env.NODE_TLS_REJECT_UNAUTHORIZED = '1'
+ process.exitCode = await new Promise((resolve) => {
+ // Never forward Electron/Chromium stderr: it may contain credential-bearing URLs.
+ const child = spawn(electron, [outfile, ...(send ? ['--send-test'] : [])], {
+ env, stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true
+ })
+ let passed = false
+ const timer = setTimeout(() => child.kill('SIGKILL'), 125_000)
+ const interrupt = () => child.kill('SIGKILL')
+ process.once('SIGINT', interrupt)
+ process.once('SIGTERM', interrupt)
+ child.on('message', (code) => {
+ if (typeof code !== 'string' || !Object.hasOwn(messages, code)) return
+ console.log(messages[code])
+ if (code === (send ? 'delivered' : 'connection')) passed = true
+ })
+ child.once('error', () => resolve(1))
+ child.once('close', (code) => {
+ clearTimeout(timer)
+ process.removeListener('SIGINT', interrupt)
+ process.removeListener('SIGTERM', interrupt)
+ if (code !== 0 || !passed) console.error('FAIL: Electron probe did not complete successfully.')
+ resolve(code === 0 && passed ? 0 : 1)
+ })
+ })
+ } finally {
+ await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
+ }
+}
+
+main().catch(() => {
+ console.error('FAIL: probe setup, build, or cleanup failed; details suppressed.')
+ process.exitCode = 1
+})
diff --git a/docs/features/telegram-channel/technical-design.md b/docs/features/telegram-channel/technical-design.md
new file mode 100644
index 00000000..3a5e4fb1
--- /dev/null
+++ b/docs/features/telegram-channel/technical-design.md
@@ -0,0 +1,40 @@
+# Telegram 通道技术设计
+
+本文对应 [PRD FR-1~12](./prd.md),记录当前生产实现。验证状态见[进度记录](./progress.md)。实现约束沿用[性能原则](../../architecture/performance-principles.md),下述结构不构成性能测量结论。
+
+## 生产路径与网络
+
+设置页经生产 Preload 和 IPC 调用 `ChannelManager`,由 `ChannelSettingsStore` 保存配置。Manager 按需加载 `TelegramChannelDriver`,向其注入 Electron `net.fetch`;驱动默认网络实现也使用 `net.fetch`。请求走 Electron 网络栈及系统代理,不依赖 Node 的 `HTTPS_PROXY` 配置,不使用 grammY 或 Telegraf。
+
+驱动先调用 `getMe` 与 `getWebhookInfo`,拒绝已有 webhook,不删除外部配置。测试连接只做这两项检查,不启动轮询。启用后使用 `getUpdates`,`timeout=30`、`limit=100`、`allowed_updates=['message']`;普通请求超时 15 秒,轮询请求超时 40 秒。临时错误以 1~30 秒退避重连,429 按 `retry_after` 等待;其他 4xx 停止轮询并报告错误。
+
+## 身份、执行与保存
+
+| 对象 | 实现约定 |
+| --- | --- |
+| 配置 | 单 Bot;Token 加密保存,快照只返回是否已配置;保留、替换、清除三种动作 |
+| 环境覆盖 | `GOODBUDDY_TELEGRAM_BOT_TOKEN`、`GOODBUDDY_TELEGRAM_ENABLED`、`GOODBUDDY_TELEGRAM_ALLOWED_SENDERS`;发送者为逗号分隔的数字 ID,空列表只提供帮助;配置只读,群聊不支持 |
+| 入站身份 | `accountId=Bot ID`,`conversationId=Bot ID:chat ID`,`eventId=update_id`,授权使用数字发送者 ID |
+| 执行 | 普通私聊文字经 `ChannelService` 队列进入现有远程通道 executor,使用 Telegram 项目后端与工作目录;同会话串行 |
+| 内置反馈 | `/start`、`/whoami` 和授权媒体提示由驱动直接回复,不调用模型或写业务上下文;每个用户每类反馈冷却 5 秒 |
+| 持久化 | 复用 SQLite 远程会话、去重和发件箱;schema 61 将相关 channel 约束扩展到 Telegram,保留既有数据;设置文件仍为版本 3,缺失 Telegram 配置时使用默认值 |
+
+驱动只接收普通私聊,忽略群、频道、话题、编辑更新及无有效发送者的消息。媒体附带文字也不作为业务输入。驱动与服务均检查授权,白名单更新可原位应用,不取消已开始的任务;排队任务执行前再次检查授权。
+
+入站进入内存队列后确认,驱动随后推进内存 offset。去重键为通道、Bot ID、事件 ID;入站队列和 offset 不持久化,不承诺崩溃后自动续跑或零丢失。重连接收平台仍保留的积压消息,工具请求也可能在重新启用后执行。
+
+## 发送与生命周期
+
+结果先进入现有发件箱,发送失败由服务有限重试,不重新调用模型;失败计数达到 5 或遇到永久错误后停止补发。发送前校验目标会话中的 Bot ID,拒绝把旧 Bot 结果交给新 Bot。附件只附加桌面查看提示。
+
+驱动按最多 4096 个 UTF-16 码元拆分纯文本,并避开代理对中间位置;不设置 `parse_mode` 或引用原消息。分段进度只在驱动内存保存,最多 128 条,停止时清空。单次发送的 429 最多等待重试 3 次,仍失败则交回发件箱处理。跨重启补发与不确定网络结果允许产生重复段落。
+
+停用会中止轮询、发送等待、活动执行与排队工作,等待活动操作结束后报告停止。清除凭据时设置页同时提交 `enabled=false`;底层拒绝启用但无 Token 的配置。Token 替换先验证,失败不停止旧服务。项目与历史不因停用或清除凭据删除。
+
+## 状态与界面
+
+Manager 通过 `{ channel, status }` 增量 IPC 推送状态,忽略旧服务实例的迟到事件。Telegram 连接状态由驱动维护,发送成功不会覆盖轮询错误。状态值为 `disabled`、`starting`、`running`、`stopped`、`error`;重连使用 `starting` 加错误说明,未配置由界面结合凭据状态显示。
+
+Renderer 的 `channel-status-store.ts` 订阅事件,selector 只向状态区域提供对应通道数据。首次快照不会覆盖已收到的较新事件;状态更新不重新加载整份设置,不重置 Token 或白名单草稿。配置页复用共享页签、开关、项目设置字段和通知控件。
+
+源码入口:[`telegram-channel-driver.ts`](../../../src/main/channels/telegram-channel-driver.ts)、[`channel-manager.ts`](../../../src/main/channels/channel-manager.ts)、[`channel-service.ts`](../../../src/main/channels/channel-service.ts)、[`channel-settings-store.ts`](../../../src/main/channels/channel-settings-store.ts)、[`channel-status-store.ts`](../../../src/renderer/src/channel-status-store.ts)、[`ChannelSettingsSection.tsx`](../../../src/renderer/src/ChannelSettingsSection.tsx)。
diff --git a/docs/features/telegram-channel/telegram-probe.mjs b/docs/features/telegram-channel/telegram-probe.mjs
new file mode 100644
index 00000000..81e92939
--- /dev/null
+++ b/docs/features/telegram-channel/telegram-probe.mjs
@@ -0,0 +1,86 @@
+/* global process, fetch, AbortSignal, console, setTimeout */
+// Credentials stay in the process environment; never print raw API errors.
+process.env.NODE_TLS_REJECT_UNAUTHORIZED = '1'
+const token = process.env.TELEGRAM_BOT_TOKEN?.trim()
+const listen = process.argv.includes('--listen')
+const pending = process.argv.includes('--pending')
+const startedAt = Math.floor(Date.now() / 1000)
+const marker = '/goodbuddy_test'
+
+async function call(method, body = {}, timeout = 15000) {
+ let response
+ try {
+ response = await fetch(`https://api.telegram.org/bot${token}/${method}`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(timeout)
+ })
+ } catch (error) {
+ const code = error?.cause?.code ?? error?.name
+ const safeCode = /^[A-Z_a-z0-9]+$/.test(code ?? '') ? code : 'NETWORK_ERROR'
+ // Raw network errors can embed the credential-bearing request URL.
+ // eslint-disable-next-line preserve-caught-error
+ throw new Error(`${method}: ${safeCode}`)
+ }
+ let data
+ try {
+ data = await response.json()
+ } catch {
+ throw new Error(`${method}: invalid JSON (HTTP ${response.status})`)
+ }
+ if (!response.ok || !data.ok) {
+ const code = Number.isInteger(data.error_code) ? data.error_code : response.status
+ const wait = Number.isInteger(data.parameters?.retry_after)
+ ? `; retry_after=${data.parameters.retry_after}` : ''
+ throw new Error(`${method}: API error ${code}${wait}`)
+ }
+ return data.result
+}
+
+async function main() {
+ if (!token || !/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
+ throw new Error('Set TELEGRAM_BOT_TOKEN to a valid BotFather token.')
+ }
+ const bot = await call('getMe')
+ console.log(JSON.stringify({ check: 'getMe', ok: true, username: bot.username }))
+ const webhook = await call('getWebhookInfo')
+ console.log(JSON.stringify({
+ check: 'getWebhookInfo', ok: true,
+ webhookConfigured: Boolean(webhook.url), pendingUpdates: webhook.pending_update_count
+ }))
+ if (!listen && !pending) return
+ if (webhook.url) throw new Error('Webhook is configured; polling skipped. No configuration changed.')
+ console.log(pending
+ ? `Checking queued private ${marker} messages from the last hour.`
+ : `Send ${marker} in a private chat with @${bot.username} within 45 seconds.`)
+ const deadline = Date.now() + 45000
+ while (Date.now() < deadline) {
+ // Do not advance offset or change allowed_updates on a diagnostic run.
+ const updates = await call('getUpdates', { timeout: pending ? 0 : 10, limit: 100 }, 15000)
+ const message = updates.map((update) => update.message).reverse().find((item) =>
+ item?.chat?.type === 'private' && !item.from?.is_bot &&
+ item.date >= startedAt - (pending ? 3600 : 0) && item.text === marker
+ )
+ if (message) {
+ await call('sendMessage', {
+ chat_id: message.chat.id,
+ text: 'GoodBuddy Telegram API test passed. This is a connectivity test, not an AI task.'
+ })
+ console.log(JSON.stringify({ check: 'privateMessageRoundTrip', ok: true }))
+ return
+ }
+ if (updates.length >= 100) {
+ throw new Error('Pending update batch is full; stopped without confirming existing updates.')
+ }
+ if (pending) break
+ if (updates.length) await new Promise((resolve) => setTimeout(resolve, 2000))
+ }
+ console.log(JSON.stringify({ check: 'privateMessageRoundTrip', ok: false, reason: 'no_matching_message' }))
+ process.exitCode = 2
+}
+
+main().catch((error) => {
+ console.error(error.message)
+ process.exitCode = 1
+})
diff --git a/docs/features/wechat-channel/prd.md b/docs/features/wechat-channel/prd.md
index d505d765..851efc32 100644
--- a/docs/features/wechat-channel/prd.md
+++ b/docs/features/wechat-channel/prd.md
@@ -10,6 +10,8 @@
| 适用产品 | GoodBuddy 桌面端 |
| 首期范围 | 微信 ClawBot、企业微信、钉钉的通道项目;微信文字、图片和文件请求 |
+2026-10-05 枚举更新:当前通道项目已增加 Telegram,下文同步更新通用项目、界面和契约枚举;首期范围、背景与第 17 节保留原实施范围。Telegram 的产品行为见[Telegram PRD](../telegram-channel/prd.md),验证状态见[进度记录](../telegram-channel/progress.md)。
+
## 1. 背景
GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入系统管理的通道项目。该信息架构解决了早期通道仅存在于设置页和后台服务中,远程消息进入任务系统后无法持续、明确识别以下信息的问题:
@@ -30,8 +32,8 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
## 2. 已确认的产品决策
-1. 微信 ClawBot、企业微信、钉钉分别对应一个系统管理的通道项目。
-2. 三个通道项目在通道设置服务初始化时幂等创建,不等待用户完成连接配置。
+1. 微信 ClawBot、企业微信、钉钉、Telegram 分别对应一个系统管理的通道项目。
+2. 四个通道项目在通道设置服务初始化时幂等创建,不等待用户完成连接配置。
3. Renderer 不直接创建通道项目。Main 进程保证项目存在,设置卡片首次展示时即可引用对应项目。
4. 通道项目默认根目录为当前操作系统用户目录。
5. 同一通道中的不同私聊用户或群聊分别建立独立远程会话。
@@ -48,7 +50,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
### 3.1 用户目标
-- 在项目切换器中一眼识别微信、企业微信和钉钉来源。
+- 在项目切换器中一眼识别微信、企业微信、钉钉和 Telegram 来源。
- 在通道项目中区分不同发送者和群聊。
- 在设置卡片中完成连接、启停、处理后端和工作目录配置。
- 通过微信问答或发起工具任务。
@@ -89,7 +91,8 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
远程通道
├─ 微信 ClawBot
├─ 企业微信
-└─ 钉钉
+├─ 钉钉
+└─ Telegram
```
选择器收起时显示当前项目名称;展开后每个选项至少显示项目名称和项目类型。本地项目补充目录摘要,远程通道项目补充平台来源和默认目录;接入可复用的通道状态数据后继续补充文字连接状态。未来接入其他远程项目时可以补充远程主机、工作区或服务来源,而不改变菜单的基本结构。
@@ -137,7 +140,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
应用初始化通道设置服务时,Main 进程执行 `ensureChannelProjects()`:
-1. 按稳定通道标识查找 `weixin`、`wecom`、`dingtalk` 对应项目。
+1. 按稳定通道标识查找 `weixin`、`wecom`、`dingtalk`、`telegram` 对应项目。
2. 缺失时创建系统管理项目。
3. 已存在时复用,不重复创建。
4. 如果存在同名普通项目,不得按名称占用或修改该项目。
@@ -150,6 +153,7 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
| `weixin` | 微信 ClawBot | 用户目录 | 默认直连文本模型 |
| `wecom` | 企业微信 | 用户目录 | 默认直连文本模型 |
| `dingtalk` | 钉钉 | 用户目录 | 默认直连文本模型 |
+| `telegram` | Telegram | 用户目录 | 默认直连文本模型 |
### 6.2 系统管理约束
@@ -176,11 +180,11 @@ GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入
设置页标签由“企业通信”改为:
- 标题:消息通道
-- 说明:微信 ClawBot、企业微信与钉钉
+- 说明:微信 ClawBot、企业微信、钉钉与 Telegram
### 7.2 卡片通用结构
-三个通道使用与模型设置一致的分段外观页签,并保留
+四个通道使用与模型设置一致的分段外观页签,并保留
`tablist`、`tab`、`tabpanel`、游标焦点和方向键语义。每次只显示一个
通道面板,窄窗口下页签单行横向滚动。
@@ -500,7 +504,7 @@ Main 到 Sidecar:
```ts
type ProjectKind = 'user' | 'channel'
-type ProjectChannel = 'weixin' | 'wecom' | 'dingtalk'
+type ProjectChannel = 'weixin' | 'wecom' | 'dingtalk' | 'telegram'
```
通道项目包含:
@@ -610,7 +614,7 @@ Renderer 快照只返回是否已配置和脱敏标识。
## 15. 可访问性与响应式
- 通道状态同时使用文字和图标。
-- 三个通道使用共享 `PageTabs` 的 `segmented` 外观,保留页签语义和方向键切换。
+- 四个通道使用共享 `PageTabs` 的 `segmented` 外观,保留页签语义和方向键切换。
- 后端选择保留语义化控件和键盘操作,不留已删除模式控件的焦点目标。
- 消息处理后端使用持久标签和分组选项,并说明当前选择的实际行为。
- 二维码提供状态文字和备用刷新操作,但不把敏感二维码链接作为可复制文本。
@@ -623,7 +627,7 @@ Renderer 快照只返回是否已配置和脱敏标识。
### 16.1 通道项目
-- [ ] 新安装首次启动后存在微信 ClawBot、企业微信和钉钉三个通道项目。
+- [ ] 新安装首次启动后存在微信 ClawBot、企业微信、钉钉和 Telegram 四个通道项目。
- [ ] 重启应用不会重复创建通道项目。
- [ ] 同名普通项目不会被占用或修改。
- [ ] 通道项目默认根目录为当前用户目录,不保存默认模式。
@@ -636,8 +640,8 @@ Renderer 快照只返回是否已配置和脱敏标识。
### 16.2 设置卡片
- [ ] 设置标签显示为“消息通道”。
-- [ ] 三个通道使用与模型设置一致的分段外观页签,并保留完整页签键盘语义。
-- [ ] 三个面板均显示项目、根目录、消息处理后端和连接状态。
+- [ ] 四个通道使用与模型设置一致的分段外观页签,并保留完整页签键盘语义。
+- [ ] 四个面板均显示项目、根目录、消息处理后端和连接状态。
- [ ] 微信卡片可以完成扫码、过期刷新、验证码和连接状态展示。
- [ ] “断开本机绑定”使用共享红色危险操作样式,并明确说明只清除本机凭据、不删除历史或承诺服务端解绑。
- [ ] 不显示“自动”后端;首次创建和旧版 `auto` 配置均落到默认直连文本模型。
diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts
index d76e69a2..5c49d814 100644
--- a/src/main/assistant/assistant-database.test.ts
+++ b/src/main/assistant/assistant-database.test.ts
@@ -2841,6 +2841,14 @@ describe('AssistantDatabase', () => {
expect.objectContaining({
kind: 'channel',
channel: 'dingtalk'
+ }),
+ expect.objectContaining({
+ name: 'Telegram',
+ kind: 'channel',
+ channel: 'telegram',
+ rootPath: 'C:\\Users\\test',
+ executionSpace: { kind: 'local', rootPath: 'C:\\Users\\test' },
+ runtimeSelection: { provider: 'model' }
})
])
expect(second.map((project) => project.id)).toEqual(
@@ -2891,6 +2899,101 @@ describe('AssistantDatabase', () => {
database.close()
})
+ it('relaxes schema 60 channel checks without rewriting or losing existing records', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-migration-'))
+ temporaryDirectories.push(directory)
+ const path = join(directory, 'assistant.sqlite')
+ const original = new AssistantDatabase(path)
+ original.initialize('C:\\Workspace')
+ const projects = original.ensureChannelProjects('C:\\Channels')
+ for (const project of projects.filter(project => project.channel !== 'telegram')) {
+ const conversation = original.getOrCreateRemoteConversation({
+ projectId: project.id, channel: project.channel!, accountId: 'legacy-account',
+ externalConversationId: 'legacy-chat', conversationType: 'direct',
+ title: 'Legacy conversation', accountDisplay: 'Legacy account',
+ runtimeSelection: { provider: 'model' }
+ })
+ original.appendRemoteConversationMessage({ conversationId: conversation.id, role: 'user', content: 'Preserve this message' })
+ }
+ original.close()
+ const legacy = new DatabaseSync(path)
+ legacy.enableDefensive(false)
+ legacy.exec(`PRAGMA foreign_keys = ON; DELETE FROM projects WHERE channel = 'telegram';
+ PRAGMA writable_schema = ON;
+ UPDATE sqlite_schema SET sql = replace(sql,
+ 'channel IN (''weixin'', ''wecom'', ''dingtalk'', ''telegram'')',
+ 'channel IN (''weixin'', ''wecom'', ''dingtalk'')')
+ WHERE type = 'table' AND name IN ('projects', 'conversations');
+ PRAGMA writable_schema = RESET; PRAGMA user_version = 60;`)
+ const tables = ['projects', 'project_execution_spaces', 'conversations', 'messages']
+ const rows = tables.map(table => legacy.prepare(`SELECT rowid, * FROM ${table} ORDER BY rowid`).all())
+ const schema = legacy.prepare('SELECT type, name, tbl_name, rootpage, sql FROM sqlite_schema ORDER BY name').all()
+ expect(() => legacy.prepare("UPDATE projects SET channel = 'telegram' WHERE channel = 'weixin'").run()).toThrow(/CHECK/)
+ legacy.close()
+
+ const migrated = new AssistantDatabase(path)
+ const progress = vi.fn()
+ migrated.upgradeStorage(progress, () => false, { migrateNotes: false, reclaimSpace: false })
+ expect(progress).not.toHaveBeenCalled()
+ const check = new DatabaseSync(path)
+ expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(61)
+ expect(tables.map(table => check.prepare(`SELECT rowid, * FROM ${table} ORDER BY rowid`).all())).toEqual(rows)
+ expect(check.prepare('SELECT type, name, tbl_name, rootpage, sql FROM sqlite_schema ORDER BY name').all()).toEqual(
+ schema.map(row => ['projects', 'conversations'].includes(row.name as string) && row.type === 'table'
+ ? { ...row, sql: (row.sql as string).replace("channel IN ('weixin', 'wecom', 'dingtalk')", "channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')") }
+ : row)
+ )
+ expect(check.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
+ expect(check.prepare('PRAGMA foreign_key_check').all()).toEqual([])
+ expect(() => check.prepare("UPDATE projects SET channel = 'invalid' WHERE channel = 'weixin'").run()).toThrow(/CHECK/)
+ expect(() => check.prepare("UPDATE conversations SET channel = 'invalid' WHERE channel = 'weixin'").run()).toThrow(/CHECK/)
+ check.close()
+
+ migrated.initialize('C:\\Workspace')
+ const telegram = migrated.ensureChannelProjects('C:\\Telegram').find(project => project.channel === 'telegram')!
+ const input = {
+ projectId: telegram.id, channel: 'telegram' as const, accountId: 'bot',
+ externalConversationId: '-100123', conversationType: 'group' as const,
+ title: 'Telegram chat', accountDisplay: 'Telegram', runtimeSelection: { provider: 'model' as const }
+ }
+ const conversation = migrated.getOrCreateRemoteConversation(input)
+ migrated.close()
+ const reopened = new AssistantDatabase(path)
+ reopened.initialize('C:\\Workspace')
+ expect(reopened.ensureChannelProjects('C:\\Ignored').find(project => project.channel === 'telegram')).toEqual(telegram)
+ expect(reopened.getOrCreateRemoteConversation(input).id).toBe(conversation.id)
+ expect(reopened.getConversation(conversation.id).remote?.channel).toBe('telegram')
+ reopened.close()
+ })
+
+ it('rolls back the channel CHECK migration if a schema definition is unexpected', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-rollback-'))
+ temporaryDirectories.push(directory)
+ const path = join(directory, 'assistant.sqlite')
+ const database = new AssistantDatabase(path)
+ database.initialize('C:\\Workspace')
+ database.close()
+ const legacy = new DatabaseSync(path)
+ legacy.enableDefensive(false)
+ legacy.exec(`PRAGMA writable_schema = ON;
+ UPDATE sqlite_schema SET sql = replace(sql,
+ 'channel IN (''weixin'', ''wecom'', ''dingtalk'', ''telegram'')',
+ 'channel IN (''weixin'', ''wecom'', ''dingtalk'')') WHERE name = 'projects';
+ UPDATE sqlite_schema SET sql = replace(sql,
+ 'channel IN (''weixin'', ''wecom'', ''dingtalk'', ''telegram'')',
+ 'channel IN (''weixin'')') WHERE name = 'conversations';
+ PRAGMA writable_schema = RESET; PRAGMA user_version = 60;`)
+ const before = legacy.prepare('SELECT * FROM sqlite_schema ORDER BY name').all()
+ legacy.close()
+ expect(() => database.upgradeStorage(() => undefined, () => false, { migrateNotes: false, reclaimSpace: false }))
+ .toThrow('Unrecognized channel constraint: conversations')
+ const check = new DatabaseSync(path)
+ expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
+ expect(check.prepare('SELECT * FROM sqlite_schema ORDER BY name').all()).toEqual(before)
+ expect(check.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
+ check.close()
+ })
+
it('persists one protected remote conversation per channel identity', async () => {
const database = await createDatabase()
const project = database.ensureChannelProjects(
@@ -3099,6 +3202,31 @@ describe('AssistantDatabase', () => {
database.close()
})
+ it('persists an immediate terminal result without attachments or repeated attempt counts', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-channel-terminal-'))
+ temporaryDirectories.push(directory)
+ const path = join(directory, 'assistant.sqlite')
+ const database = new AssistantDatabase(path)
+ database.initialize('C:\\Workspace')
+ const entry = database.enqueueChannelResult({
+ channel: 'telegram', eventId: 'permanent-failure', conversationId: 'chat',
+ recipientId: 'user', status: 'completed', output: 'Keep result text',
+ attachments: [{ name: 'result.txt', mimeType: 'text/plain', size: 1, kind: 'file', dataBase64: 'eA==' }]
+ })
+ database.markChannelResult(entry.id, 'terminal')
+ database.close()
+ const reopened = new AssistantDatabase(path)
+ reopened.initialize('C:\\Workspace')
+ reopened.markChannelResult(entry.id, 'terminal')
+ reopened.markChannelResult(entry.id, 'failed')
+ expect(reopened.listUndeliveredChannelResults('telegram')).toEqual([
+ expect.objectContaining({ id: entry.id, state: 'terminal', attempts: 1,
+ message: expect.objectContaining({ output: 'Keep result text' }) })
+ ])
+ expect(reopened.listUndeliveredChannelResults('telegram')[0]!.message).not.toHaveProperty('attachments')
+ reopened.close()
+ })
+
it('migrates exhausted legacy outbox failures to terminal state', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-channel-terminal-migration-')
diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts
index fcd20be1..3e7460e6 100644
--- a/src/main/assistant/assistant-database.ts
+++ b/src/main/assistant/assistant-database.ts
@@ -136,7 +136,7 @@ import {
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
} from './subagent-progress-storage'
-export const ASSISTANT_DATABASE_SCHEMA_VERSION = 60
+export const ASSISTANT_DATABASE_SCHEMA_VERSION = 61
function withoutHistoricalWorkMode(value: unknown): unknown {
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
@@ -2623,6 +2623,11 @@ export class AssistantDatabase {
channel: 'dingtalk',
name: '钉钉',
description: '钉钉远程消息与受控任务'
+ },
+ {
+ channel: 'telegram',
+ name: 'Telegram',
+ description: 'Telegram 远程消息与受控任务'
}
]
const find = database.prepare(
@@ -4084,7 +4089,7 @@ export class AssistantDatabase {
markChannelResult(
id: string,
- state: 'delivered' | 'failed'
+ state: 'delivered' | 'failed' | 'terminal'
): void {
this.requireDatabase()
.prepare(
@@ -4096,11 +4101,11 @@ export class AssistantDatabase {
END,
attempts = attempts + 1,
message_json = CASE
- WHEN ? = 'delivered' OR attempts + 1 >= 5
+ WHEN ? IN ('delivered', 'terminal') OR attempts + 1 >= 5
THEN json_remove(message_json, '$.attachments')
ELSE message_json
END
- WHERE id = ?`
+ WHERE id = ? AND state NOT IN ('delivered', 'terminal')`
)
.run(state, state, state, id)
}
@@ -4124,10 +4129,10 @@ export class AssistantDatabase {
`WITH pending AS (
SELECT id, message_json, state, attempts, created_at,
ROW_NUMBER() OVER (
- ORDER BY attempts ASC, created_at ASC
+ ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
) AS position,
SUM(LENGTH(CAST(message_json AS BLOB))) OVER (
- ORDER BY attempts ASC, created_at ASC
+ ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
) AS cumulative_bytes
FROM channel_outbox
WHERE state != 'delivered'
@@ -4136,7 +4141,7 @@ export class AssistantDatabase {
SELECT id, message_json, state, attempts, created_at
FROM pending
WHERE position = 1 OR cumulative_bytes <= ?
- ORDER BY attempts ASC, created_at ASC
+ ORDER BY (state = 'terminal') ASC, attempts ASC, created_at ASC, id ASC
LIMIT ?`
)
.all(
@@ -10663,7 +10668,7 @@ export class AssistantDatabase {
ADD COLUMN channel TEXT
CHECK(
channel IS NULL OR
- channel IN ('weixin', 'wecom', 'dingtalk')
+ channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')
);
`)
}
@@ -10694,7 +10699,7 @@ export class AssistantDatabase {
ALTER TABLE conversations ADD COLUMN channel TEXT
CHECK(
channel IS NULL OR
- channel IN ('weixin', 'wecom', 'dingtalk')
+ channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')
);
`)
}
@@ -12299,6 +12304,36 @@ export class AssistantDatabase {
`)
} catch (error) { database.exec('ROLLBACK'); throw error }
}
+ if (version.user_version < 61) {
+ database.exec('BEGIN IMMEDIATE')
+ try {
+ // SQLite's metadata-only CHECK relaxation preserves table rows, rowids,
+ // indexes and references without rebuilding potentially large conversations.
+ const previous = "channel IN ('weixin', 'wecom', 'dingtalk')"
+ const expanded = "channel IN ('weixin', 'wecom', 'dingtalk', 'telegram')"
+ const schemaVersion = database.prepare('PRAGMA schema_version').get() as { schema_version: number }
+ database.enableDefensive(false)
+ for (const table of ['projects', 'conversations']) {
+ const definition = database.prepare("SELECT sql FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table) as { sql: string }
+ if (definition.sql.includes(expanded)) continue
+ if (!definition.sql.includes(previous)) throw new Error(`Unrecognized channel constraint: ${table}`)
+ database.exec('PRAGMA writable_schema = ON')
+ database.prepare("UPDATE sqlite_schema SET sql = ? WHERE type = 'table' AND name = ?")
+ .run(definition.sql.replace(previous, expanded), table)
+ }
+ database.exec(`PRAGMA schema_version = ${schemaVersion.schema_version + 1}; PRAGMA writable_schema = RESET;`)
+ // Reparse with writable_schema disabled so invalid definitions roll back.
+ database.prepare('SELECT channel FROM projects LIMIT 0').all()
+ database.prepare('SELECT channel FROM conversations LIMIT 0').all()
+ database.exec('PRAGMA user_version = 61; COMMIT;')
+ } catch (error) {
+ database.exec('ROLLBACK')
+ throw error
+ } finally {
+ database.exec('PRAGMA writable_schema = RESET')
+ database.enableDefensive(true)
+ }
+ }
}
supervisionExperiences(): SupervisionExperienceStore {
diff --git a/src/main/assistant/assistant-storage-upgrade.test.ts b/src/main/assistant/assistant-storage-upgrade.test.ts
index e1db2c1f..46c607fa 100644
--- a/src/main/assistant/assistant-storage-upgrade.test.ts
+++ b/src/main/assistant/assistant-storage-upgrade.test.ts
@@ -110,7 +110,7 @@ describe('subagent progress storage', () => {
for (const [table, column] of [['projects', 'default_work_mode'], ['conversations', 'work_mode'], ['tasks', 'work_mode']]) {
expect(check.prepare(`PRAGMA table_info(${table})`).all().map(row => row.name)).not.toContain(column)
}
- expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
+ expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
const readJson = (table: string, column: string, id: string) =>
JSON.parse(check.prepare(`SELECT ${column} AS value FROM ${table} WHERE id = ?`).get(id)!.value as string)
expect(readJson('conversations', 'context_state_json', header.id))
@@ -164,7 +164,7 @@ describe('subagent progress storage', () => {
sql.prepare('UPDATE task_events SET payload_json = ? WHERE id = ?').run('{"workMode":"ask"}', damagedId)
upgradeAssistantStorage(path, () => undefined)
expect(sql.prepare('SELECT payload_json FROM task_events WHERE id = ?').get(damagedId)!.payload_json).toBe('{}')
- expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(60)
+ expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
expect(sql.prepare('PRAGMA integrity_check').get()!.integrity_check).toBe('ok')
} finally { sql.close() }
})
diff --git a/src/main/channels/channel-driver.ts b/src/main/channels/channel-driver.ts
index 3df337cd..546b2905 100644
--- a/src/main/channels/channel-driver.ts
+++ b/src/main/channels/channel-driver.ts
@@ -19,6 +19,14 @@ export interface ChannelDriver {
stop(): void | Promise
}
+export function isPermanentChannelError(error: unknown): boolean {
+ return (
+ error instanceof Error &&
+ 'permanent' in error &&
+ error.permanent === true
+ )
+}
+
export interface DedupStore {
claim(
channel: string,
@@ -83,6 +91,7 @@ export interface Outbox {
enqueue(message: ChannelResultMessage): OutboxEntry | Promise
markDelivered(id: string): void | Promise
markFailed(id: string): void | Promise
+ markTerminal?(id: string): void | Promise
listUndelivered(
channel?: string,
limit?: number
@@ -134,6 +143,16 @@ export class MemoryOutbox implements Outbox {
}
}
+ markTerminal(id: string): void {
+ const entry = this.entries.get(id)
+ if (!entry) {
+ return
+ }
+ entry.state = 'terminal'
+ entry.attempts += 1
+ entry.message = this.withoutAttachments(entry.message)
+ }
+
listUndelivered(
channel?: string,
limit = this.maximumEntries
@@ -146,6 +165,8 @@ export class MemoryOutbox implements Outbox {
)
.sort(
(left, right) =>
+ Number(left.state === 'terminal') -
+ Number(right.state === 'terminal') ||
left.attempts - right.attempts ||
left.createdAt - right.createdAt
)
diff --git a/src/main/channels/channel-manager.test.ts b/src/main/channels/channel-manager.test.ts
index 3f0e17c3..60199193 100644
--- a/src/main/channels/channel-manager.test.ts
+++ b/src/main/channels/channel-manager.test.ts
@@ -1,8 +1,9 @@
-import { mkdtemp, rm } from 'node:fs/promises'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import type { ChannelDriver } from './channel-driver'
+import type { ChannelDriver, ChannelExecutor, ChannelInboundHandler } from './channel-driver'
+import type { ChannelRuntimeStatus, ChannelRuntimeStatusChange } from '../../shared/channel-settings-contracts'
import {
ChannelManager,
type ManagedChannelService
@@ -15,7 +16,11 @@ import {
const roots: string[] = []
+const electronFetch = vi.hoisted(() => vi.fn())
+vi.mock('electron', () => ({ net: { fetch: electronFetch } }))
+
afterEach(async () => {
+ electronFetch.mockReset()
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
)
@@ -102,6 +107,369 @@ function managerHarness(
}
describe('ChannelManager', () => {
+ it('emits deduplicated status deltas for async transitions and ignores late callbacks', async () => {
+ const settingsStore = await store()
+ const input = { enabled: true, allowedSenderIds: [], allowGroupMessages: false as const }
+ await settingsStore.apply({ telegram: {
+ ...input, secret: { action: 'replace', value: 'status-event-test-secret' }
+ } })
+ const changes: ChannelRuntimeStatusChange[] = []
+ const callbacks: Array<(status: ChannelRuntimeStatus) => void> = []
+ const manager = new ChannelManager(settingsStore, executor, {
+ onStatusChanged: (change) => { changes.push(change) },
+ createDriver: (settings, onStatus) => {
+ if (onStatus) callbacks.push(onStatus)
+ return {
+ ...inertDriver(settings.channel),
+ testConnection: async () => ({ botId: '123' })
+ }
+ },
+ createService: () => {
+ const onStatus = callbacks.at(-1)!
+ return {
+ start: async () => { onStatus({ state: 'starting' }) },
+ stop: async () => { onStatus({ state: 'stopped' }) }
+ }
+ }
+ })
+ await manager.initialize()
+ expect(changes).toEqual([
+ { channel: 'weixin', status: { state: 'disabled' } },
+ { channel: 'wecom', status: { state: 'disabled' } },
+ { channel: 'dingtalk', status: { state: 'disabled' } },
+ { channel: 'telegram', status: { state: 'starting' } }
+ ])
+ changes.length = 0
+ callbacks[0]!({ state: 'running' })
+ callbacks[0]!({ state: 'running' })
+ callbacks[0]!({ state: 'error', lastError: 'status-event-test-secret disconnected' })
+ callbacks[0]!({ state: 'error', lastError: 'status-event-test-secret disconnected' })
+ callbacks[0]!({ state: 'error', lastError: 'polling conflict' })
+ callbacks[0]!({ state: 'running' })
+ expect(changes.map((change) => change.status.state)).toEqual(['running', 'error', 'error', 'running'])
+ expect(changes.every((change) => change.channel === 'telegram')).toBe(true)
+ expect(JSON.stringify(changes)).not.toContain('status-event-test-secret')
+ expect(changes.at(-1)).toEqual({ channel: 'telegram', status: { state: 'running' } })
+ changes.length = 0
+ await manager.testConnection('telegram')
+ expect(changes).toEqual([])
+ await manager.reload('telegram')
+ callbacks[0]!({ state: 'error', lastError: 'retired' })
+ callbacks[1]!({ state: 'running' })
+ expect(changes).toEqual([
+ { channel: 'telegram', status: { state: 'starting' } },
+ { channel: 'telegram', status: { state: 'running' } }
+ ])
+ changes.length = 0
+ await expect(manager.apply({ telegram: { ...input, secret: { action: 'clear' } } })).rejects.toThrow('Bot Token')
+ expect((await settingsStore.resolve('telegram')).secret).toBe('status-event-test-secret')
+ expect(changes).toEqual([])
+ await manager.apply({ telegram: { ...input, enabled: false, secret: { action: 'clear' } } })
+ callbacks[1]!({ state: 'running' })
+ await manager.reload('telegram')
+ await manager.stopAll()
+ expect(changes).toEqual([{ channel: 'telegram', status: { state: 'disabled' } }])
+ expect((await settingsStore.resolve('telegram')).secret).toBeUndefined()
+ })
+
+ it('leaves Telegram connection status to the driver during startup and delivery', async () => {
+ const settingsStore = await store()
+ await settingsStore.apply({ telegram: {
+ enabled: true, secret: { action: 'replace', value: 'status-test-secret' },
+ allowedSenderIds: [], allowGroupMessages: false
+ } })
+ let status!: (status: ChannelRuntimeStatus) => void
+ let deliverySuccess!: () => void
+ let deliveryFailure!: (error: Error) => void
+ const manager = new ChannelManager(settingsStore, executor, {
+ createDriver: (settings, onStatus) => {
+ status = onStatus!
+ return inertDriver(settings.channel)
+ },
+ createService: (_driver, _executor, options) => {
+ deliverySuccess = () => options.onDeliverySuccess?.()
+ deliveryFailure = (error) => options.onDeliveryFailure?.(error)
+ return { start: async () => undefined, stop: async () => undefined }
+ }
+ })
+ expect((await manager.initialize()).telegram.status).toEqual({ state: 'starting' })
+ deliverySuccess()
+ expect((await manager.snapshot()).telegram.status.state).toBe('starting')
+ status({ state: 'error', lastError: 'Telegram polling conflict (409)' })
+ deliverySuccess()
+ deliveryFailure(new Error('send failed'))
+ expect((await manager.snapshot()).telegram.status).toEqual({
+ state: 'error', lastError: 'Telegram polling conflict (409)'
+ })
+ status({ state: 'running' })
+ deliveryFailure(new Error('send failed'))
+ expect((await manager.snapshot()).telegram.status).toEqual({ state: 'running' })
+ await manager.stopAll()
+ })
+
+ it('updates Telegram whitelists without cancelling accepted work or restarting the service', async () => {
+ const settingsStore = await store()
+ const input = { enabled: true, allowedSenderIds: ['123'], allowGroupMessages: false as const }
+ await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: 'active-test-secret' } } })
+ let inbound!: ChannelInboundHandler
+ let activeSignal!: AbortSignal
+ let finish!: () => void
+ const work = new Promise((resolve) => { finish = resolve })
+ const execute = vi.fn(async (_message, signal) => {
+ activeSignal = signal
+ await work
+ return { status: 'completed', output: 'finished' }
+ })
+ const updateAllowedSenderIds = vi.fn()
+ const stop = vi.fn(async () => undefined)
+ const send = vi.fn(async () => undefined)
+ const createDriver = vi.fn((settings: ResolvedChannelSettings) => ({
+ ...inertDriver(settings.channel),
+ start: async (handler: ChannelInboundHandler) => { inbound = handler },
+ updateAllowedSenderIds, stop, send
+ }))
+ const manager = new ChannelManager(settingsStore, execute, { createDriver })
+ await manager.initialize()
+ const message = {
+ channel: 'telegram', accountId: '456', eventId: '1', senderId: '123',
+ conversationId: '456:123', conversationType: 'direct', text: 'hello'
+ }
+ await inbound(message, () => undefined)
+ await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce())
+ await manager.apply({ telegram: { ...input, allowedSenderIds: [], secret: { action: 'keep' } } })
+ expect(updateAllowedSenderIds).toHaveBeenLastCalledWith([])
+ expect(createDriver).toHaveBeenCalledOnce()
+ expect(stop).not.toHaveBeenCalled()
+ expect(activeSignal.aborted).toBe(false)
+ await inbound({ ...message, eventId: '2' }, () => undefined)
+ finish()
+ await vi.waitFor(() => expect(send).toHaveBeenCalled())
+ expect(execute).toHaveBeenCalledOnce()
+ await manager.apply({ telegram: { ...input, allowedSenderIds: ['789'], secret: { action: 'keep' } } })
+ await inbound({ ...message, eventId: '3', senderId: '789', conversationId: '456:789' }, () => undefined)
+ await vi.waitFor(() => expect(execute).toHaveBeenCalledTimes(2))
+ expect(createDriver).toHaveBeenCalledOnce()
+ await manager.stopAll()
+ })
+
+ it.each(['keep', 'replace'] as const)('restarts a conflicted Telegram poller when saving the same token with %s', async (action) => {
+ const settingsStore = await store()
+ const input = { enabled: true, allowedSenderIds: ['123'], allowGroupMessages: false as const }
+ const secret = 'recovery-test-placeholder'
+ await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: secret } } })
+ let conflict = true
+ let polls = 0
+ electronFetch.mockImplementation(async (input, init) => {
+ const method = String(input).split('/').at(-1)
+ if (method === 'getMe') return Response.json({ ok: true, result: { id: 456, is_bot: true } })
+ if (method === 'getWebhookInfo') return Response.json({ ok: true, result: { url: '' } })
+ if (method === 'getUpdates') {
+ polls++
+ if (conflict) return Response.json({ ok: false }, { status: 409 })
+ return new Promise((_resolve, reject) => {
+ const signal = init!.signal!
+ if (signal.aborted) reject(signal.reason)
+ else signal.addEventListener('abort', () => reject(signal.reason), { once: true })
+ })
+ }
+ throw new Error(`Unexpected method: ${method}`)
+ })
+ const manager = new ChannelManager(settingsStore, executor)
+ try {
+ await manager.initialize()
+ await vi.waitFor(async () => expect((await manager.snapshot()).telegram.status.state).toBe('error'))
+ conflict = false
+ expect((await manager.testConnection('telegram')).ok).toBe(true)
+ await manager.apply({ telegram: { ...input, secret: action === 'keep' ? { action } : { action, value: secret } } })
+ await vi.waitFor(async () => {
+ expect(polls).toBe(2)
+ expect((await manager.snapshot()).telegram.status.state).toBe('running')
+ })
+ } finally {
+ await manager.stopAll()
+ }
+ })
+
+ it('validates replacement tokens before saving or stopping the active Telegram service', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'goodbuddy-manager-'))
+ roots.push(root)
+ const path = join(root, 'settings.json')
+ const settingsStore = new ChannelSettingsStore(path, cipher(), {})
+ const input = { enabled: true, allowedSenderIds: ['123'], allowGroupMessages: false as const }
+ await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: 'working-test-secret' } } })
+ const original = await readFile(path, 'utf8')
+ const events: string[] = []
+ const probes: Array<{ start: ReturnType; stop: ReturnType }> = []
+ let activeStatus!: (status: ChannelRuntimeStatus) => void
+ const updateAllowedSenderIds = vi.fn()
+ const manager = new ChannelManager(settingsStore, executor, {
+ createDriver: (settings, onStatus) => {
+ const secret = settings.channel === 'telegram' ? settings.secret : ''
+ if (onStatus) {
+ activeStatus = onStatus
+ return { ...inertDriver(settings.channel), updateAllowedSenderIds }
+ }
+ const driver = {
+ ...inertDriver(settings.channel), start: vi.fn(), stop: vi.fn(() => { events.push('probe-stop') }),
+ testConnection: vi.fn(async () => {
+ events.push('probe')
+ expect(await readFile(path, 'utf8')).toBe(original)
+ expect((await manager.snapshot()).telegram.status.state).toBe('running')
+ if (secret === 'invalid-test-secret') throw new Error('invalid-test-secret invalid token')
+ return { botId: '456' }
+ })
+ }
+ probes.push(driver)
+ return driver
+ },
+ createService: () => ({
+ start: async () => { events.push('service-start'); activeStatus({ state: 'running' }) },
+ stop: async () => { events.push('service-stop') },
+ updateAllowedSenderIds
+ })
+ })
+ await manager.initialize()
+ events.length = 0
+ await expect(manager.apply({ telegram: {
+ ...input, secret: { action: 'replace', value: 'invalid-test-secret' }
+ } })).rejects.not.toThrow('invalid-test-secret')
+ expect(events).toEqual(['probe', 'probe-stop'])
+ expect(await readFile(path, 'utf8')).toBe(original)
+ expect((await settingsStore.resolve('telegram')).secret).toBe('working-test-secret')
+ expect((await manager.snapshot()).telegram.status.state).toBe('running')
+ events.length = 0
+ await manager.apply({ telegram: { ...input, secret: { action: 'replace', value: 'valid-test-secret' } } })
+ expect(events).toEqual(['probe', 'probe-stop', 'service-stop', 'service-start'])
+ expect((await settingsStore.resolve('telegram')).secret).toBe('valid-test-secret')
+ for (const probe of probes) {
+ expect(probe.start).not.toHaveBeenCalled()
+ expect(probe.stop).toHaveBeenCalledOnce()
+ }
+ expect(updateAllowedSenderIds).not.toHaveBeenCalled()
+ await manager.stopAll()
+ })
+
+ it('uses Electron fetch in the default Telegram factory and only probes identity and webhook', async () => {
+ electronFetch.mockImplementation(async (input) => {
+ const method = String(input).split('/').at(-1)
+ if (method === 'getMe') {
+ return new Response(JSON.stringify({ ok: true, result: { id: 123, is_bot: true, username: 'example_bot' } }))
+ }
+ if (method === 'getWebhookInfo') {
+ return new Response(JSON.stringify({ ok: true, result: { url: '' } }))
+ }
+ throw new Error(`Unexpected Telegram method: ${method}`)
+ })
+ const manager = new ChannelManager(await store(), executor)
+ expect(await manager.testConnection('telegram', {
+ enabled: false, secret: { action: 'replace', value: 'test-only-secret' },
+ allowedSenderIds: [], allowGroupMessages: false
+ })).toEqual({ channel: 'telegram', ok: true, botUsername: 'example_bot' })
+ expect(electronFetch.mock.calls.map(([input]) => String(input).split('/').at(-1)))
+ .toEqual(['getMe', 'getWebhookInfo'])
+ expect((await manager.snapshot()).telegram.status.state).toBe('disabled')
+ })
+ it('tests Telegram identity without creating a second poller or changing active settings', async () => {
+ const settingsStore = await store()
+ const starts = vi.fn(async () => undefined)
+ const stops = vi.fn(async () => undefined)
+ const probe = vi.fn(async () => ({ botId: '123', botUsername: 'example_bot' }))
+ const createService = vi.fn(() => ({ start: starts, stop: stops }))
+ const testDrivers: ChannelDriver[] = []
+ const manager = new ChannelManager(settingsStore, executor, {
+ createDriver: (settings, onStatus) => {
+ onStatus?.({ state: 'running' })
+ const driver = { ...inertDriver(settings.channel), start: vi.fn(), stop: vi.fn(), testConnection: probe }
+ testDrivers.push(driver)
+ return driver
+ },
+ createService
+ })
+ const input = { enabled: true, allowedSenderIds: [], allowGroupMessages: false as const }
+ await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: 'stored-test-secret' } } })
+ await manager.initialize()
+ expect((await manager.snapshot()).telegram.status.state).toBe('running')
+ expect(await manager.testConnection('telegram', {
+ ...input, secret: { action: 'replace', value: 'temporary-test-secret' }
+ })).toEqual({ channel: 'telegram', ok: true, botUsername: 'example_bot' })
+ expect(await manager.test('telegram')).toEqual({ channel: 'telegram', ok: true, botUsername: 'example_bot' })
+ expect(probe).toHaveBeenCalledTimes(2)
+ expect(createService).toHaveBeenCalledOnce()
+ expect(starts).toHaveBeenCalledOnce()
+ expect(stops).not.toHaveBeenCalled()
+ for (const driver of testDrivers.slice(1)) {
+ expect(driver.start).not.toHaveBeenCalled()
+ expect(driver.stop).toHaveBeenCalledOnce()
+ }
+ expect((await settingsStore.resolve('telegram')).secret).toBe('stored-test-secret')
+ expect((await manager.snapshot()).telegram.status.state).toBe('running')
+ probe.mockRejectedValueOnce(new Error('temporary-test-secret failed'))
+ const failure = await manager.test('telegram', { ...input, secret: { action: 'replace', value: 'temporary-test-secret' } })
+ expect(failure.ok).toBe(false)
+ expect(failure.error).not.toContain('temporary-test-secret')
+ await manager.stopAll()
+ })
+
+ it('tracks Telegram runtime statuses and ignores retired driver and service callbacks', async () => {
+ const settingsStore = await store()
+ const callbacks: Array<(status: ChannelRuntimeStatus) => void> = []
+ const deliveryCallbacks: Array<() => void> = []
+ const lifecycle: string[] = []
+ const manager = new ChannelManager(settingsStore, executor, {
+ createDriver: (settings, onStatus) => {
+ callbacks.push(onStatus!)
+ return inertDriver(settings.channel)
+ },
+ createService: (_driver, _executor, options) => {
+ const index = callbacks.length - 1
+ deliveryCallbacks.push(() => options.onDeliverySuccess?.())
+ return {
+ start: async () => {
+ lifecycle.push(`start-${index}`)
+ callbacks[index]?.({ state: 'running' })
+ },
+ stop: async () => {
+ lifecycle.push(`stop-${index}`)
+ callbacks[index]?.({ state: 'error', lastError: 'retired' })
+ }
+ }
+ }
+ })
+ const input = { enabled: true, allowedSenderIds: [], allowGroupMessages: false as const }
+ await settingsStore.apply({ telegram: { ...input, secret: { action: 'replace', value: 'status-test-secret' } } })
+ await manager.initialize()
+ callbacks[0]?.({ state: 'error', lastError: 'status-test-secret network failure' })
+ expect((await manager.snapshot()).telegram.status).toMatchObject({ state: 'error' })
+ expect((await manager.snapshot()).telegram.status.lastError).not.toContain('status-test-secret')
+ callbacks[0]?.({ state: 'running' })
+ expect((await manager.snapshot()).telegram.status).toEqual({ state: 'running' })
+ await manager.apply({ telegram: { ...input, secret: { action: 'keep' }, allowedSenderIds: ['123'] } })
+ expect(lifecycle).toEqual(['start-0', 'stop-0', 'start-1'])
+ callbacks[0]?.({ state: 'error', lastError: 'late failure' })
+ expect((await manager.snapshot()).telegram.status.state).toBe('running')
+ await manager.apply({ telegram: { ...input, enabled: false, secret: { action: 'keep' } } })
+ callbacks[1]?.({ state: 'running' })
+ deliveryCallbacks[1]?.()
+ expect((await manager.snapshot()).telegram.status.state).toBe('disabled')
+ await manager.apply({ telegram: { ...input, secret: { action: 'keep' } } })
+ await manager.stopAll()
+ callbacks[2]?.({ state: 'running' })
+ expect((await manager.snapshot()).telegram.status.state).toBe('stopped')
+ })
+
+ it('rejects temporary Telegram environment overrides', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'goodbuddy-manager-'))
+ roots.push(root)
+ const settingsStore = new ChannelSettingsStore(join(root, 'settings.json'), cipher(), {
+ GOODBUDDY_TELEGRAM_BOT_TOKEN: 'environment-test-secret'
+ })
+ const createDriver = vi.fn(() => inertDriver('telegram'))
+ const manager = new ChannelManager(settingsStore, executor, { createDriver })
+ expect((await manager.testConnection('telegram', {
+ enabled: true, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false
+ })).ok).toBe(false)
+ expect(createDriver).not.toHaveBeenCalled()
+ })
it('applies settings and dynamically starts, replaces, and disables services', async () => {
const settingsStore = await store()
const { manager, services } = managerHarness(settingsStore)
diff --git a/src/main/channels/channel-manager.ts b/src/main/channels/channel-manager.ts
index 54f9d988..f2de15e9 100644
--- a/src/main/channels/channel-manager.ts
+++ b/src/main/channels/channel-manager.ts
@@ -1,15 +1,19 @@
import {
CHANNEL_SETTINGS_LIMITS,
channelConnectionTestResultSchema,
+ channelSettingsApplySchema,
dingTalkChannelSettingsInputSchema,
+ telegramChannelSettingsInputSchema,
weComChannelSettingsInputSchema,
type ChannelConnectionTestResult,
type ChannelRuntimeStatus,
+ type ChannelRuntimeStatusChange,
type ChannelSettingsApply,
type ChannelSettingsSnapshot,
type CredentialChannel,
type DingTalkChannelSettingsInput,
type ManagedChannel,
+ type TelegramChannelSettingsInput,
type WeComChannelSettingsInput
} from '../../shared/channel-settings-contracts'
import type {
@@ -33,11 +37,17 @@ import type { WechatSidecarLauncher } from './wechat-sidecar-client'
export type ManagedChannelService = Pick<
ChannelService,
'start' | 'stop'
->
+> & { updateAllowedSenderIds?: (ids: readonly string[]) => void }
export type ChannelDriverFactory = (
- settings: ResolvedChannelSettings
-) => ChannelDriver | Promise
+ settings: ResolvedChannelSettings,
+ onStatus?: (status: ChannelRuntimeStatus) => void
+) => TestableChannelDriver | Promise
+
+type TestableChannelDriver = ChannelDriver & {
+ testConnection?: () => Promise<{ botId: string; botUsername?: string }>
+ updateAllowedSenderIds?: (ids: readonly string[]) => void
+}
export type ChannelServiceFactory = (
driver: ChannelDriver,
@@ -53,6 +63,7 @@ export type ChannelServiceFactory = (
) => ManagedChannelService | Promise
export type ChannelManagerOptions = {
+ onStatusChanged?: (change: ChannelRuntimeStatusChange) => void
createDriver?: ChannelDriverFactory
createService?: ChannelServiceFactory
launchWechatSidecar?: WechatSidecarLauncher
@@ -61,6 +72,10 @@ export type ChannelManagerOptions = {
}
type TestSettingsInput =
+ | {
+ channel: 'telegram'
+ settings?: TelegramChannelSettingsInput
+ }
| {
channel: 'wecom'
settings?: WeComChannelSettingsInput
@@ -70,10 +85,11 @@ type TestSettingsInput =
settings?: DingTalkChannelSettingsInput
}
-function defaultDriverFactory(
+async function defaultDriverFactory(
settings: ResolvedChannelSettings,
- launchWechatSidecar?: WechatSidecarLauncher
-): ChannelDriver {
+ launchWechatSidecar?: WechatSidecarLauncher,
+ onStatus?: (status: ChannelRuntimeStatus) => void
+): Promise {
if (settings.channel === 'weixin') {
if (!launchWechatSidecar) {
throw new Error('微信 Sidecar 启动器不可用')
@@ -83,6 +99,18 @@ function defaultDriverFactory(
if (settings.secret === undefined) {
throw new Error('通道 Secret 尚未配置')
}
+ if (settings.channel === 'telegram') {
+ const [{ TelegramChannelDriver }, { net }] = await Promise.all([
+ import('./telegram-channel-driver'),
+ import('electron')
+ ])
+ return new TelegramChannelDriver({
+ secret: settings.secret,
+ allowedSenderIds: settings.allowedSenderIds,
+ fetch: (input, init) => net.fetch(input instanceof URL ? input.href : input, init),
+ onStatus
+ })
+ }
return settings.channel === 'wecom'
? new WeComChannelDriver({
botId: settings.botId,
@@ -136,6 +164,10 @@ function sanitizedManagerFailure(message: string): Error {
}
function validateResolved(settings: ResolvedChannelSettings): void {
+ if (settings.channel === 'telegram') {
+ if (!settings.secret) throw new Error('Telegram 需要 Bot Token')
+ return
+ }
if (settings.channel === 'weixin') {
if (
settings.accountId.length === 0 ||
@@ -175,7 +207,13 @@ export class ChannelManager {
private readonly createService: ChannelServiceFactory
private readonly dedupStore?: ChannelServiceOptions['dedupStore']
private readonly outbox?: ChannelServiceOptions['outbox']
+ private readonly onStatusChanged?: ChannelManagerOptions['onStatusChanged']
private operationQueue: Promise = Promise.resolve()
+ private readonly serviceGenerations = new Map()
+ private activeTelegram?: {
+ settings: Extract
+ driver: TestableChannelDriver
+ }
constructor(
private readonly store: ChannelSettingsStore,
@@ -184,11 +222,12 @@ export class ChannelManager {
) {
this.createDriver =
options.createDriver ??
- ((settings) =>
- defaultDriverFactory(settings, options.launchWechatSidecar))
+ ((settings, onStatus) =>
+ defaultDriverFactory(settings, options.launchWechatSidecar, onStatus))
this.createService = options.createService ?? defaultServiceFactory
this.dedupStore = options.dedupStore
this.outbox = options.outbox
+ this.onStatusChanged = options.onStatusChanged
}
snapshot(): Promise {
@@ -204,7 +243,7 @@ export class ChannelManager {
const settings = await this.store.resolveAll()
for (const channelSettings of settings) {
if (!channelSettings.enabled) {
- this.statuses.set(channelSettings.channel, {
+ this.setStatus(channelSettings.channel, {
state: 'disabled'
})
continue
@@ -221,11 +260,17 @@ export class ChannelManager {
apply(input: ChannelSettingsApply): Promise {
return this.enqueue(async () => {
+ input = channelSettingsApplySchema.parse(input)
+ if (input.telegram?.secret.action === 'replace') {
+ const result = await this.test('telegram', input.telegram)
+ if (!result.ok) throw sanitizedManagerFailure(result.error!)
+ }
await this.store.apply(input)
const channels: ManagedChannel[] = [
...(input.weixin === undefined ? [] : (['weixin'] as const)),
...(input.wecom === undefined ? [] : (['wecom'] as const)),
- ...(input.dingtalk === undefined ? [] : (['dingtalk'] as const))
+ ...(input.dingtalk === undefined ? [] : (['dingtalk'] as const)),
+ ...(input.telegram === undefined ? [] : (['telegram'] as const))
]
for (const channel of channels) {
const settings = await this.store.resolve(channel)
@@ -233,12 +278,27 @@ export class ChannelManager {
await this.disableService(channel)
continue
}
+ const active = this.activeTelegram
+ const service = this.services.get(channel)
+ if (settings.channel === 'telegram' && active &&
+ active.settings.secret === settings.secret &&
+ (this.statuses.get(channel)?.state === 'running' || this.statuses.get(channel)?.state === 'starting') &&
+ active.driver.updateAllowedSenderIds && service?.updateAllowedSenderIds) {
+ active.driver.updateAllowedSenderIds(settings.allowedSenderIds)
+ service.updateAllowedSenderIds(settings.allowedSenderIds)
+ active.settings = settings
+ continue
+ }
await this.replaceService(settings)
}
return this.snapshot()
})
}
+ test(
+ channel: 'telegram',
+ settings?: TelegramChannelSettingsInput
+ ): Promise
test(
channel: 'wecom',
settings?: WeComChannelSettingsInput
@@ -249,7 +309,7 @@ export class ChannelManager {
): Promise
async test(
channel: CredentialChannel,
- settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
+ settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
): Promise {
let resolved: ResolvedChannelSettings | undefined
try {
@@ -258,7 +318,21 @@ export class ChannelManager {
...(settings === undefined ? {} : { settings })
} as TestSettingsInput)
validateResolved(resolved)
- const service = await this.buildService(resolved)
+ if (channel === 'telegram') {
+ const driver = await this.createDriver(resolved)
+ try {
+ if (!driver.testConnection) throw new Error('Telegram connection test is unavailable')
+ const identity = await driver.testConnection()
+ return channelConnectionTestResultSchema.parse({
+ channel,
+ ok: true,
+ ...(identity.botUsername ? { botUsername: identity.botUsername } : {})
+ })
+ } finally {
+ await Promise.resolve(driver.stop()).catch(() => undefined)
+ }
+ }
+ const { service } = await this.buildService(resolved)
try {
await service.start()
} finally {
@@ -284,6 +358,10 @@ export class ChannelManager {
}
}
+ testConnection(
+ channel: 'telegram',
+ settings?: TelegramChannelSettingsInput
+ ): Promise
testConnection(
channel: 'wecom',
settings?: WeComChannelSettingsInput
@@ -294,8 +372,11 @@ export class ChannelManager {
): Promise
testConnection(
channel: CredentialChannel,
- settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
+ settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
): Promise {
+ if (channel === 'telegram') {
+ return this.test(channel, settings as TelegramChannelSettingsInput | undefined)
+ }
return channel === 'wecom'
? this.test(
channel,
@@ -310,13 +391,15 @@ export class ChannelManager {
stopAll(): Promise {
return this.enqueue(async () => {
const active = [...this.services.entries()]
+ this.serviceGenerations.clear()
+ this.activeTelegram = undefined
this.services.clear()
const results = await Promise.allSettled(
active.map(([, service]) => Promise.resolve(service.stop()))
)
const resolved = await this.store.resolveAll()
for (const settings of resolved) {
- this.statuses.set(settings.channel, {
+ this.setStatus(settings.channel, {
state: settings.enabled ? 'stopped' : 'disabled'
})
}
@@ -344,17 +427,24 @@ export class ChannelManager {
): Promise {
const channel = settings.channel
const previous = this.services.get(channel)
- this.statuses.set(channel, { state: 'starting' })
+ const generation = Symbol(channel)
+ this.serviceGenerations.set(channel, generation)
+ this.setStatus(channel, { state: 'starting' })
let replacement: ManagedChannelService | undefined
+ let driver: TestableChannelDriver | undefined
try {
validateResolved(settings)
- replacement = await this.buildService(settings)
+ const built = await this.buildService(settings, generation)
+ replacement = built.service
+ driver = built.driver
if (previous !== undefined) {
await previous.stop()
this.services.delete(channel)
}
await replacement.start()
} catch (error) {
+ if (channel === 'telegram') this.activeTelegram = undefined
+ this.serviceGenerations.delete(channel)
await Promise.resolve(replacement?.stop()).catch(() => undefined)
if (
previous !== undefined &&
@@ -368,7 +458,7 @@ export class ChannelManager {
? settings.token
: settings.secret
])
- this.statuses.set(channel, {
+ this.setStatus(channel, {
state: 'error',
lastError: redacted
})
@@ -376,29 +466,48 @@ export class ChannelManager {
}
this.services.set(channel, replacement)
- this.statuses.set(channel, { state: 'running' })
+ if (settings.channel === 'telegram') {
+ this.activeTelegram = { settings, driver: driver! }
+ } else if (this.statuses.get(channel)?.state === 'starting') {
+ this.setStatus(channel, { state: 'running' })
+ }
}
private async disableService(channel: ManagedChannel): Promise {
+ if (channel === 'telegram') this.activeTelegram = undefined
+ this.serviceGenerations.delete(channel)
const previous = this.services.get(channel)
if (previous !== undefined) {
await previous.stop()
this.services.delete(channel)
}
- this.statuses.set(channel, { state: 'disabled' })
+ this.setStatus(channel, { state: 'disabled' })
}
private async buildService(
- settings: ResolvedChannelSettings
- ): Promise {
- const driver = await this.createDriver(settings)
- return this.createService(driver, this.executor, {
+ settings: ResolvedChannelSettings,
+ generation?: symbol
+ ): Promise<{ service: ManagedChannelService; driver: TestableChannelDriver }> {
+ const onStatus = (status: ChannelRuntimeStatus): void => {
+ if (generation === undefined || this.serviceGenerations.get(settings.channel) !== generation) return
+ this.setStatus(settings.channel, {
+ state: status.state,
+ ...(status.lastError ? {
+ lastError: redactManagerError(status.lastError, [
+ settings.channel === 'weixin' ? settings.token : settings.secret
+ ])
+ } : {})
+ })
+ }
+ const driver = await this.createDriver(settings, onStatus)
+ const service = await this.createService(driver, this.executor, {
allowedSenderIds: settings.allowedSenderIds,
allowGroupMessages: settings.allowGroupMessages,
dedupStore: this.dedupStore,
outbox: this.outbox,
onDeliveryFailure: (error) => {
- this.statuses.set(settings.channel, {
+ if (settings.channel === 'telegram') return
+ onStatus({
state: 'error',
lastError: redactManagerError(error, [
settings.channel === 'weixin'
@@ -408,14 +517,28 @@ export class ChannelManager {
})
},
onDeliverySuccess: () => {
- this.statuses.set(settings.channel, { state: 'running' })
+ if (settings.channel === 'telegram') return
+ onStatus({ state: 'running' })
}
})
+ return { service, driver }
}
private async settingsForTest(
input: TestSettingsInput
): Promise {
+ if (input.channel === 'telegram') {
+ const current = await this.store.resolve('telegram')
+ if (input.settings === undefined) return current
+ if (current.readOnly) throw new Error('环境变量通道配置为只读,不能使用临时设置')
+ const parsed = telegramChannelSettingsInputSchema.parse(input.settings)
+ return {
+ channel: 'telegram',
+ enabled: parsed.enabled,
+ ...this.testCommonSettings(current.secret, parsed),
+ allowGroupMessages: false
+ }
+ }
if (input.channel === 'wecom') {
const current = await this.store.resolve('wecom')
if (input.settings === undefined) {
@@ -450,7 +573,7 @@ export class ChannelManager {
private testCommonSettings(
currentSecret: string | undefined,
- input: WeComChannelSettingsInput | DingTalkChannelSettingsInput
+ input: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
): {
secret?: string
allowedSenderIds: readonly string[]
@@ -473,6 +596,13 @@ export class ChannelManager {
}
}
+ private setStatus(channel: ManagedChannel, status: ChannelRuntimeStatus): void {
+ const previous = this.statuses.get(channel)
+ if (previous?.state === status.state && previous.lastError === status.lastError) return
+ this.statuses.set(channel, status)
+ this.onStatusChanged?.({ channel, status: { ...status } })
+ }
+
private enqueue(operation: () => Promise): Promise {
let value!: T
const run = async (): Promise => {
diff --git a/src/main/channels/channel-service.test.ts b/src/main/channels/channel-service.test.ts
index d0ba8624..f730cedf 100644
--- a/src/main/channels/channel-service.test.ts
+++ b/src/main/channels/channel-service.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import {
channelInboundTextSchema,
type ChannelInboundText,
@@ -7,6 +7,7 @@ import {
import {
MemoryDedupStore,
MemoryOutbox,
+ isPermanentChannelError,
type ChannelDriver,
type ChannelInboundHandler
} from './channel-driver'
@@ -135,6 +136,280 @@ describe('channel contracts', () => {
})
describe('ChannelService', () => {
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('stops the active request without launching queued conversation requests', async () => {
+ const driver = new FakeChannelDriver()
+ const executor = vi.fn((_message: ChannelInboundText, _signal: AbortSignal) => {
+ void _message
+ void _signal
+ return new Promise(() => undefined)
+ })
+ const service = new ChannelService(driver, executor, {
+ allowedSenderIds: ['allowed-user']
+ })
+ await service.start()
+ await driver.emit(inbound())
+ await driver.emit(inbound({ eventId: 'queued' }))
+ await service.stop()
+ expect(executor).toHaveBeenCalledOnce()
+ expect(executor.mock.calls[0]?.[1].aborted).toBe(true)
+ expect(driver.sent).toEqual([])
+ })
+
+ it('does not launch a request whose asynchronous event claim finishes after stop', async () => {
+ const driver = new FakeChannelDriver()
+ let finishClaim!: (claimed: boolean) => void
+ const release = vi.fn()
+ const executor = vi.fn()
+ const service = new ChannelService(driver, executor, {
+ allowedSenderIds: ['allowed-user'],
+ dedupStore: {
+ claim: () => new Promise((resolve) => { finishClaim = resolve }),
+ release
+ }
+ })
+ await service.start()
+ await driver.emit(inbound())
+ const stopped = service.stop()
+ finishClaim(true)
+ await stopped
+ expect(executor).not.toHaveBeenCalled()
+ expect(release).toHaveBeenCalledWith('fake', 'default', 'event-1')
+ })
+
+ it('updates and validates the whitelist without cancelling active work', async () => {
+ const driver = new FakeChannelDriver()
+ let finish!: (result: { status: string }) => void
+ const executor = vi.fn((_message: ChannelInboundText, _signal: AbortSignal) => {
+ void _message
+ void _signal
+ return new Promise<{ status: string }>((resolve) => { finish = resolve })
+ })
+ const service = new ChannelService(driver, executor, {
+ allowedSenderIds: ['allowed-user']
+ })
+ await service.start()
+ await driver.emit(inbound())
+ await driver.emit(inbound({ eventId: 'queued' }))
+ service.updateAllowedSenderIds([' new-user '])
+ expect(() => service.updateAllowedSenderIds([' '])).toThrow('白名单')
+ expect(executor.mock.calls[0]?.[1].aborted).toBe(false)
+ finish({ status: 'completed' })
+ await waitForSent(driver, 1)
+ await driver.emit(inbound({ senderId: 'new-user', eventId: 'new-user' }))
+ await vi.waitFor(() => expect(executor).toHaveBeenCalledTimes(2))
+ finish({ status: 'completed' })
+ await waitForSent(driver, 2)
+ expect(driver.sent.map((message) => message.eventId)).toEqual(['event-1', 'new-user'])
+ await service.stop()
+ })
+
+ it('starts without waiting for a blocked retry, then cancels and awaits it on stop', async () => {
+ vi.useFakeTimers()
+ const driver = new FakeChannelDriver()
+ const outbox = new MemoryOutbox()
+ outbox.enqueue({ ...inbound(), status: 'completed', recipientId: 'allowed-user' })
+ let finish!: () => void
+ let retrySignal!: AbortSignal
+ const send = vi.spyOn(driver, 'send').mockImplementation((_message, signal) => {
+ retrySignal = signal
+ return new Promise((resolve) => { finish = resolve })
+ })
+ const service = new ChannelService(driver, vi.fn(), { outbox })
+ await service.start()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(send).toHaveBeenCalledOnce()
+ await vi.advanceTimersByTimeAsync(120_000)
+ expect(send).toHaveBeenCalledOnce()
+ let stopped = false
+ const stop = service.stop().then(() => { stopped = true })
+ await Promise.resolve()
+ expect(retrySignal.aborted).toBe(true)
+ expect(stopped).toBe(false)
+ finish()
+ await stop
+ expect(stopped).toBe(true)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('retries failed runtime results on recovery and does not poll an empty outbox', async () => {
+ vi.useFakeTimers()
+ const driver = new FakeChannelDriver()
+ const outbox = new MemoryOutbox()
+ const list = vi.spyOn(outbox, 'listUndelivered')
+ const send = vi.spyOn(driver, 'send').mockRejectedValueOnce(new Error('offline'))
+ const executor = vi.fn(async () => ({ status: 'failed', error: 'runtime failed' }))
+ const service = new ChannelService(driver, executor, {
+ outbox, allowedSenderIds: ['allowed-user']
+ })
+ await service.start()
+ await vi.advanceTimersByTimeAsync(0)
+ const idleReads = list.mock.calls.length
+ await vi.advanceTimersByTimeAsync(120_000)
+ expect(list).toHaveBeenCalledTimes(idleReads)
+ await driver.emit(inbound())
+ await vi.advanceTimersByTimeAsync(0)
+ expect(send).toHaveBeenCalledOnce()
+ await vi.advanceTimersByTimeAsync(999)
+ expect(send).toHaveBeenCalledOnce()
+ await vi.advanceTimersByTimeAsync(1)
+ expect(send).toHaveBeenCalledTimes(2)
+ expect(driver.sent[0]?.status).toBe('failed')
+ expect(outbox.listUndelivered()).toEqual([])
+ expect(executor).toHaveBeenCalledOnce()
+ expect(vi.getTimerCount()).toBe(0)
+ await service.stop()
+ })
+
+ it('backs off and stops retrying after five failed sends', async () => {
+ vi.useFakeTimers()
+ const driver = new FakeChannelDriver()
+ const outbox = new MemoryOutbox()
+ const send = vi.spyOn(driver, 'send').mockRejectedValue(new Error('offline'))
+ const service = new ChannelService(driver, async () => ({ status: 'completed' }), {
+ outbox, allowedSenderIds: ['allowed-user']
+ })
+ await service.start()
+ await vi.advanceTimersByTimeAsync(0)
+ await driver.emit(inbound())
+ await vi.advanceTimersByTimeAsync(0)
+ for (const [index, delay] of [1_000, 2_000, 4_000, 8_000].entries()) {
+ await vi.advanceTimersByTimeAsync(delay - 1)
+ expect(send).toHaveBeenCalledTimes(index + 1)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(send).toHaveBeenCalledTimes(index + 2)
+ }
+ expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'terminal', attempts: 5 })
+ expect(vi.getTimerCount()).toBe(0)
+ await vi.advanceTimersByTimeAsync(120_000)
+ expect(send).toHaveBeenCalledTimes(5)
+ await service.stop()
+ })
+
+ it('bounds recovery passes and backs off after three consecutive failures', async () => {
+ vi.useFakeTimers()
+ const driver = new FakeChannelDriver()
+ const outbox = new MemoryOutbox()
+ for (let index = 0; index < 101; index += 1) {
+ outbox.enqueue({
+ channel: 'fake', eventId: `saved-${index}`, conversationId: 'conversation-1',
+ recipientId: 'allowed-user', status: 'completed'
+ })
+ }
+ const send = vi.spyOn(driver, 'send').mockRejectedValue(new Error('offline'))
+ const service = new ChannelService(driver, vi.fn(), { outbox })
+ await service.start()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(send).toHaveBeenCalledTimes(3)
+ send.mockResolvedValue(undefined)
+ await vi.advanceTimersByTimeAsync(1_999)
+ expect(send).toHaveBeenCalledTimes(3)
+ await vi.advanceTimersByTimeAsync(1)
+ expect(send).toHaveBeenCalledTimes(103)
+ expect(outbox.listUndelivered()).toHaveLength(1)
+ await vi.advanceTimersByTimeAsync(1_000)
+ expect(send).toHaveBeenCalledTimes(104)
+ expect(outbox.listUndelivered()).toEqual([])
+ expect(vi.getTimerCount()).toBe(0)
+ await service.stop()
+ })
+
+ it('does not retry an in-flight live delivery and aborts its send on stop', async () => {
+ vi.useFakeTimers()
+ const driver = new FakeChannelDriver()
+ let liveSignal!: AbortSignal
+ const send = vi.spyOn(driver, 'send').mockImplementation((message, signal) => {
+ if (message.eventId === 'held') {
+ liveSignal = signal
+ return new Promise((_resolve, reject) => {
+ signal.addEventListener('abort', () => reject(signal.reason), { once: true })
+ })
+ }
+ return Promise.reject(new Error('offline'))
+ })
+ const service = new ChannelService(driver, async () => ({ status: 'completed' }), {
+ allowedSenderIds: ['allowed-user']
+ })
+ await service.start()
+ await vi.advanceTimersByTimeAsync(0)
+ await driver.emit(inbound({ eventId: 'held' }))
+ await driver.emit(inbound({ eventId: 'failed', conversationId: 'another' }))
+ await vi.advanceTimersByTimeAsync(1_000)
+ expect(send.mock.calls.filter(([message]) => message.eventId === 'held')).toHaveLength(1)
+ expect(send.mock.calls.filter(([message]) => message.eventId === 'failed')).toHaveLength(2)
+ await service.stop()
+ expect(liveSignal.aborted).toBe(true)
+ expect(vi.getTimerCount()).toBe(0)
+ })
+
+ it('excludes live deliveries from a retry snapshot even if they finish before the retry reaches them', async () => {
+ vi.useFakeTimers()
+ const driver = new FakeChannelDriver()
+ const outbox = new MemoryOutbox()
+ let finishLive!: () => void
+ let finishRetry!: () => void
+ const send = vi.spyOn(driver, 'send').mockImplementation((message) =>
+ new Promise((resolve) => {
+ if (message.eventId === 'live') finishLive = resolve
+ else finishRetry = resolve
+ })
+ )
+ const service = new ChannelService(driver, async () => ({ status: 'completed' }), {
+ outbox, allowedSenderIds: ['allowed-user']
+ })
+ await service.start()
+ await vi.advanceTimersByTimeAsync(0)
+ outbox.enqueue({ ...inbound({ eventId: 'retry' }), recipientId: 'allowed-user', status: 'completed' })
+ await driver.emit(inbound({ eventId: 'live' }))
+ await vi.advanceTimersByTimeAsync(0)
+ // A failed delivery schedules the recovery pass through the public path.
+ send.mockRejectedValueOnce(new Error('offline'))
+ await driver.emit(inbound({ eventId: 'failed', conversationId: 'another' }))
+ await vi.advanceTimersByTimeAsync(1_000)
+ expect(send.mock.calls.map(([message]) => message.eventId)).toEqual(['live', 'failed', 'retry'])
+ finishLive()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(outbox.listUndelivered().some((entry) => entry.message.eventId === 'live')).toBe(false)
+ send.mockResolvedValue(undefined)
+ finishRetry()
+ await vi.advanceTimersByTimeAsync(0)
+ expect(send.mock.calls.filter(([message]) => message.eventId === 'live')).toHaveLength(1)
+ expect(outbox.listUndelivered()).toEqual([])
+ await service.stop()
+ })
+
+ it.each(['field', 'getter'] as const)('terminates permanent %s errors without resending after restart', async (kind) => {
+ vi.useFakeTimers()
+ const error = kind === 'field'
+ ? Object.assign(new Error('forbidden'), { permanent: true })
+ : new class extends Error { get permanent(): boolean { return true } }('forbidden')
+ expect(isPermanentChannelError(error)).toBe(true)
+ expect(isPermanentChannelError(new Error('offline'))).toBe(false)
+ const driver = new FakeChannelDriver()
+ const outbox = new MemoryOutbox()
+ const send = vi.spyOn(driver, 'send').mockRejectedValue(error)
+ const service = new ChannelService(driver, async () => ({
+ status: 'completed',
+ attachments: [{ name: 'result.txt', mimeType: 'text/plain', size: 2, kind: 'file', dataBase64: 'b2s=' }]
+ }), { outbox, allowedSenderIds: ['allowed-user'] })
+ await service.start()
+ await vi.advanceTimersByTimeAsync(0)
+ await driver.emit(inbound())
+ await vi.advanceTimersByTimeAsync(0)
+ expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'terminal', attempts: 1 })
+ expect(outbox.listUndelivered()[0]?.message.attachments).toBeUndefined()
+ await service.stop()
+ const restarted = new ChannelService(driver, vi.fn(), { outbox })
+ await restarted.start()
+ await vi.advanceTimersByTimeAsync(120_000)
+ expect(send).toHaveBeenCalledOnce()
+ expect(vi.getTimerCount()).toBe(0)
+ await restarted.stop()
+ })
+
it('acknowledges after accepting input and denies all senders when no allowlist is configured', async () => {
const driver = new FakeChannelDriver()
const executor = vi.fn()
@@ -462,11 +737,13 @@ describe('ChannelService', () => {
await service.start()
expect(driver.sent).toEqual([])
- expect(deliveryFailure).toHaveBeenCalledWith(
- expect.objectContaining({
- message: expect.stringContaining('已达到重试上限')
- })
- )
+ await vi.waitFor(() => {
+ expect(deliveryFailure).toHaveBeenCalledWith(
+ expect.objectContaining({
+ message: expect.stringContaining('已达到重试上限')
+ })
+ )
+ })
expect(await outbox.listUndelivered()).toEqual([
expect.objectContaining({
id: entry.id,
diff --git a/src/main/channels/channel-service.ts b/src/main/channels/channel-service.ts
index 1d25acf3..660b5f01 100644
--- a/src/main/channels/channel-service.ts
+++ b/src/main/channels/channel-service.ts
@@ -9,6 +9,7 @@ import {
import {
MemoryDedupStore,
MemoryOutbox,
+ isPermanentChannelError,
type ChannelDriver,
type ChannelExecutor,
type DedupStore,
@@ -64,7 +65,7 @@ export function redactChannelError(value: string): string {
}
export class ChannelService {
- private readonly allowedSenderIds: ReadonlySet
+ private allowedSenderIds: ReadonlySet = new Set()
private readonly allowGroupMessages: boolean
private readonly maximumConcurrency: number
private readonly maximumInputLength: number
@@ -76,6 +77,12 @@ export class ChannelService {
private readonly conversationTails = new Map>()
private state: ServiceState = 'idle'
private stopPromise?: Promise
+ private readonly lifetime = new AbortController()
+ private readonly sending = new Set()
+ private retryTimer?: ReturnType
+ private retryPromise?: Promise
+ private retryRequested = false
+ private retryDelay = 1_000
constructor(
private readonly driver: ChannelDriver,
@@ -90,12 +97,7 @@ export class ChannelService {
throw new Error('通道标识无效')
}
- this.allowedSenderIds = new Set(
- (options.allowedSenderIds ?? []).map((senderId) => senderId.trim())
- )
- if (this.allowedSenderIds.has('')) {
- throw new Error('通道白名单包含无效身份')
- }
+ this.updateAllowedSenderIds(options.allowedSenderIds ?? [])
this.allowGroupMessages = options.allowGroupMessages ?? false
this.maximumConcurrency = boundedInteger(
options.maximumConcurrency,
@@ -115,6 +117,14 @@ export class ChannelService {
this.onDeliverySuccess = options.onDeliverySuccess
}
+ updateAllowedSenderIds(senderIds: readonly string[]): void {
+ const allowed = new Set(senderIds.map((senderId) => senderId.trim()))
+ if (allowed.has('')) {
+ throw new Error('通道白名单包含无效身份')
+ }
+ this.allowedSenderIds = allowed
+ }
+
async start(): Promise {
if (this.state === 'running') {
return
@@ -138,9 +148,11 @@ export class ChannelService {
this.onDeliveryFailure?.(error)
}
})
- await this.retryUndelivered()
+ this.scheduleRetry(0)
} catch (error) {
- this.state = 'idle'
+ if (!this.lifetime.signal.aborted) {
+ this.state = 'idle'
+ }
throw error
}
}
@@ -168,6 +180,9 @@ export class ChannelService {
}
this.state = 'stopped'
+ this.lifetime.abort(new Error('通道服务已停止'))
+ clearTimeout(this.retryTimer)
+ this.retryTimer = undefined
for (const controller of this.active.values()) {
controller.abort(new Error('通道服务已停止'))
}
@@ -180,6 +195,7 @@ export class ChannelService {
const driverStop = Promise.resolve().then(() => this.driver.stop())
const results = await Promise.allSettled([
driverStop,
+ this.retryPromise,
...this.conversationTails.values()
])
const driverResult = results[0]
@@ -188,12 +204,52 @@ export class ChannelService {
}
}
+ private scheduleRetry(delay = this.retryDelay): void {
+ if (this.state !== 'running') {
+ return
+ }
+ if (this.retryPromise) {
+ this.retryRequested = true
+ return
+ }
+ if (this.retryTimer) {
+ return
+ }
+ const run = (): void => {
+ this.retryTimer = undefined
+ this.retryRequested = false
+ this.retryPromise = this.retryUndelivered()
+ .catch((error: unknown) => {
+ if (!this.lifetime.signal.aborted) {
+ this.onDeliveryFailure?.(error)
+ }
+ })
+ .finally(() => {
+ this.retryPromise = undefined
+ if (this.retryRequested) {
+ this.scheduleRetry()
+ } else {
+ this.retryDelay = 1_000
+ }
+ })
+ }
+ if (delay === 0) {
+ run()
+ } else {
+ this.retryTimer = setTimeout(run, delay)
+ this.retryTimer.unref?.()
+ }
+ }
+
private async retryUndelivered(): Promise {
- const entries = await this.outbox.listUndelivered(
+ // Live sends may finish while this snapshot is read or earlier retries await I/O.
+ const liveDeliveries = new Set(this.sending)
+ const entries = (await this.outbox.listUndelivered(
this.driver.channel,
100
- )
+ )).filter((entry) => !liveDeliveries.has(entry.id) && !this.sending.has(entry.id))
let consecutiveFailures = 0
+ let failed = false
for (const entry of entries) {
if (this.state !== 'running') {
return
@@ -201,31 +257,49 @@ export class ChannelService {
if (entry.state === 'terminal' || entry.attempts >= 5) {
this.onDeliveryFailure?.(
new Error(
- `通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
+ entry.attempts >= 5
+ ? `通道结果已达到重试上限,发件箱记录 ${entry.id} 已终止`
+ : `通道结果无法投递,发件箱记录 ${entry.id} 已终止`
)
)
continue
}
+ if (this.sending.has(entry.id)) {
+ continue
+ }
try {
await this.driver.send(
entry.message,
- new AbortController().signal
+ this.lifetime.signal
)
await this.outbox.markDelivered(entry.id)
this.onDeliverySuccess?.()
consecutiveFailures = 0
} catch (error) {
- await this.outbox.markFailed(entry.id)
- this.onDeliveryFailure?.(error)
- consecutiveFailures += 1
- if (consecutiveFailures >= 3) {
+ if (this.lifetime.signal.aborted) {
return
}
+ await this.recordDeliveryFailure(entry.id, error)
+ failed = true
+ consecutiveFailures += 1
+ if (consecutiveFailures >= 3) {
+ break
+ }
}
}
+ if (this.state === 'running') {
+ const remaining = await this.outbox.listUndelivered(this.driver.channel, 100)
+ this.retryRequested ||= remaining.some((entry) =>
+ entry.state !== 'terminal' && entry.attempts < 5 && !this.sending.has(entry.id)
+ )
+ this.retryDelay = failed ? Math.min(this.retryDelay * 2, 30_000) : 1_000
+ }
}
private async process(rawMessage: unknown): Promise {
+ if (this.state !== 'running') {
+ return
+ }
const parsed = channelInboundTextSchema.safeParse(rawMessage)
if (!parsed.success) {
return
@@ -252,13 +326,16 @@ export class ChannelService {
let durableResult = false
try {
+ if (this.lifetime.signal.aborted || !this.allowedSenderIds.has(message.senderId)) {
+ return
+ }
if (message.text.length > this.maximumInputLength) {
durableResult = await this.tryDeliver(
this.result(message, {
status: 'rejected',
error: `消息过长,最多允许 ${this.maximumInputLength} 个字符`
}),
- new AbortController().signal
+ this.lifetime.signal
)
return
}
@@ -269,7 +346,7 @@ export class ChannelService {
status: 'busy',
error: '当前请求较多,请稍后重试'
}),
- new AbortController().signal
+ this.lifetime.signal
)
return
}
@@ -292,7 +369,7 @@ export class ChannelService {
status: cancelled ? 'cancelled' : 'failed',
error: cancelled ? '请求已取消' : '请求处理失败'
}),
- new AbortController().signal
+ this.lifetime.signal
)
return
}
@@ -302,7 +379,7 @@ export class ChannelService {
status: 'cancelled',
error: '请求已取消'
}),
- new AbortController().signal
+ this.lifetime.signal
)
return
}
@@ -382,7 +459,10 @@ export class ChannelService {
)
}
void Promise.resolve()
- .then(() => this.executor(message, signal, reportProgress))
+ .then(() => {
+ signal.throwIfAborted()
+ return this.executor(message, signal, reportProgress)
+ })
.then(
(result) => finish(resolve, result),
(error: unknown) => finish(reject, error)
@@ -429,17 +509,35 @@ export class ChannelService {
signal: AbortSignal
): Promise {
const entry = await this.outbox.enqueue(message)
+ this.sending.add(entry.id)
try {
- await this.driver.send(message, signal)
+ const deliverySignal = AbortSignal.any([signal, this.lifetime.signal])
+ deliverySignal.throwIfAborted()
+ await this.driver.send(message, deliverySignal)
await this.outbox.markDelivered(entry.id)
this.onDeliverySuccess?.()
} catch (error) {
- await this.outbox.markFailed(entry.id)
- this.onDeliveryFailure?.(error)
+ if (!this.lifetime.signal.aborted) {
+ await this.recordDeliveryFailure(entry.id, error)
+ if (!isPermanentChannelError(error)) {
+ this.scheduleRetry()
+ }
+ }
+ } finally {
+ this.sending.delete(entry.id)
}
return true
}
+ private async recordDeliveryFailure(id: string, error: unknown): Promise {
+ if (isPermanentChannelError(error) && this.outbox.markTerminal) {
+ await this.outbox.markTerminal(id)
+ } else {
+ await this.outbox.markFailed(id)
+ }
+ this.onDeliveryFailure?.(error)
+ }
+
private async tryDeliver(
message: ChannelResultMessage,
signal: AbortSignal
diff --git a/src/main/channels/channel-settings-store.test.ts b/src/main/channels/channel-settings-store.test.ts
index 27b08e21..cb819e3e 100644
--- a/src/main/channels/channel-settings-store.test.ts
+++ b/src/main/channels/channel-settings-store.test.ts
@@ -40,6 +40,75 @@ function createCipher(available = true): ChannelCredentialCipher {
}
describe('ChannelSettingsStore', () => {
+ it('persists encrypted Telegram settings with empty whitelist and secret actions', async () => {
+ const filePath = await settingsPath()
+ const store = new ChannelSettingsStore(filePath, createCipher(), {})
+ const input = { enabled: true, allowedSenderIds: [], allowGroupMessages: false as const }
+ const snapshot = await store.apply({ telegram: {
+ ...input, secret: { action: 'replace', value: 'test-only-secret' }
+ } })
+ expect(snapshot.telegram).toMatchObject({ enabled: true, secretConfigured: true, source: 'encrypted' })
+ expect(JSON.stringify(snapshot)).not.toContain('test-only-secret')
+ expect(snapshot.telegram).not.toHaveProperty('botId')
+ expect(await readFile(filePath, 'utf8')).not.toContain('test-only-secret')
+ const reloaded = new ChannelSettingsStore(filePath, createCipher(), {})
+ expect(await reloaded.resolve('telegram')).toEqual({
+ channel: 'telegram', ...input, secret: 'test-only-secret', source: 'encrypted', readOnly: false
+ })
+ await reloaded.apply({ telegram: { ...input, secret: { action: 'keep' }, allowedSenderIds: ['123'] } })
+ expect((await reloaded.resolve('telegram')).secret).toBe('test-only-secret')
+ await reloaded.apply({ telegram: { ...input, secret: { action: 'replace', value: 'replacement-test-secret' } } })
+ expect((await reloaded.resolve('telegram')).secret).toBe('replacement-test-secret')
+ const unreadable = new ChannelSettingsStore(filePath, createCipher(false), {})
+ expect((await unreadable.snapshot()).telegram).toMatchObject({ source: 'unreadable', secretConfigured: false })
+ await reloaded.apply({ telegram: { ...input, enabled: false, secret: { action: 'clear' } } })
+ expect((await reloaded.snapshot()).telegram).toMatchObject({ enabled: false, source: 'none', secretConfigured: false })
+ await expect(reloaded.apply({ telegram: { ...input, secret: { action: 'keep' } } })).rejects.toThrow('Bot Token')
+ })
+
+ it('loads existing version 3 settings with Telegram defaults without rewriting', async () => {
+ const filePath = await settingsPath()
+ const original = JSON.stringify({
+ version: 3,
+ weixin: { enabled: false },
+ wecom: { enabled: false, botId: '', allowedSenderIds: [], allowGroupMessages: false },
+ dingtalk: { enabled: false, clientId: '', allowedSenderIds: [], allowGroupMessages: false }
+ })
+ await writeFile(filePath, original)
+ const store = new ChannelSettingsStore(filePath, createCipher(), {})
+ expect((await store.snapshot()).telegram).toEqual({
+ enabled: false, secretConfigured: false, source: 'none', readOnly: false,
+ allowedSenderIds: [], allowGroupMessages: false, status: { state: 'disabled' }
+ })
+ expect(await readFile(filePath, 'utf8')).toBe(original)
+ })
+
+ it('uses read-only Telegram environment credentials, including help-only startup', async () => {
+ const filePath = await settingsPath()
+ const environment = { GOODBUDDY_TELEGRAM_BOT_TOKEN: 'environment-test-secret' }
+ const store = new ChannelSettingsStore(filePath, createCipher(), environment)
+ expect(await store.resolve('telegram')).toEqual({
+ channel: 'telegram', enabled: true, secret: 'environment-test-secret',
+ allowedSenderIds: [], allowGroupMessages: false, source: 'environment', readOnly: true
+ })
+ await expect(store.apply({ telegram: {
+ enabled: false, secret: { action: 'clear' }, allowedSenderIds: [], allowGroupMessages: false
+ } })).rejects.toThrow('环境变量配置')
+ const configured = new ChannelSettingsStore(filePath, createCipher(), {
+ ...environment, GOODBUDDY_TELEGRAM_ALLOWED_SENDERS: '123, 456,123', GOODBUDDY_TELEGRAM_ENABLED: 'false'
+ })
+ expect(await configured.resolve('telegram')).toMatchObject({ enabled: false, allowedSenderIds: ['123', '456'] })
+ for (const invalid of [
+ { GOODBUDDY_TELEGRAM_ALLOWED_SENDERS: '@user' },
+ { GOODBUDDY_TELEGRAM_ALLOW_GROUPS: 'true' },
+ { GOODBUDDY_TELEGRAM_ENABLED: 'invalid' }
+ ]) {
+ const invalidStore = new ChannelSettingsStore(filePath, createCipher(), { ...environment, ...invalid })
+ expect((await invalidStore.snapshot()).telegram).toMatchObject({
+ enabled: false, readOnly: true, allowGroupMessages: false, status: { state: 'error' }
+ })
+ }
+ })
it('encrypts secrets and supports keep, replace, and clear', async () => {
const filePath = await settingsPath()
const store = new ChannelSettingsStore(filePath, createCipher(), {})
diff --git a/src/main/channels/channel-settings-store.ts b/src/main/channels/channel-settings-store.ts
index 60587f8d..2c37b33f 100644
--- a/src/main/channels/channel-settings-store.ts
+++ b/src/main/channels/channel-settings-store.ts
@@ -3,6 +3,7 @@ import { z } from 'zod'
import {
CHANNEL_SETTINGS_LIMITS,
allowedSenderIdsSchema,
+ telegramAllowedSenderIdsSchema,
channelSettingsApplySchema,
type ChannelRuntimeStatus,
type ChannelSettingsApply,
@@ -10,6 +11,7 @@ import {
type CredentialChannel,
type DingTalkChannelSettingsInput,
type ManagedChannel,
+ type TelegramChannelSettingsInput,
type WeComChannelSettingsInput
} from '../../shared/channel-settings-contracts'
import { weixinAccountDisplay } from '../../shared/weixin-channel-contracts'
@@ -103,7 +105,16 @@ const storedSettingsSchema = z
})
.strict(),
wecom: legacyStoredSettingsSchema.shape.wecom,
- dingtalk: legacyStoredSettingsSchema.shape.dingtalk
+ dingtalk: legacyStoredSettingsSchema.shape.dingtalk,
+ telegram: z.object({
+ ...storedChannelFields,
+ allowedSenderIds: telegramAllowedSenderIdsSchema,
+ allowGroupMessages: z.literal(false)
+ }).strict().default({
+ enabled: false,
+ allowedSenderIds: [],
+ allowGroupMessages: false
+ })
})
.strict()
@@ -111,6 +122,7 @@ type StoredSettings = z.infer
type StoredCredentialChannel =
| StoredSettings['wecom']
| StoredSettings['dingtalk']
+ | StoredSettings['telegram']
type StoredEncryptedCredential = z.infer<
typeof encryptedCredentialSchema
>
@@ -120,7 +132,7 @@ class DeferredWeixinMigrationError extends Error {}
const credentialPayloadSchema = z
.object({
version: z.literal(1),
- channel: z.enum(['weixin', 'wecom', 'dingtalk']),
+ channel: z.enum(['weixin', 'wecom', 'dingtalk', 'telegram']),
secret: z
.string()
.min(1)
@@ -167,9 +179,19 @@ type EnvironmentChannel = {
allowedSenderIds: readonly string[]
allowGroupMessages: boolean
warning?: SettingsWarning
+ configurationError?: string
}
export type ResolvedChannelSettings =
+ | {
+ channel: 'telegram'
+ enabled: boolean
+ secret?: string
+ allowedSenderIds: readonly string[]
+ allowGroupMessages: false
+ source: 'none' | 'encrypted' | 'environment' | 'unreadable'
+ readOnly: boolean
+ }
| {
channel: 'weixin'
enabled: boolean
@@ -205,6 +227,11 @@ export type ResolvedChannelSettings =
const defaultStoredSettings: StoredSettings = {
version: 3,
+ telegram: {
+ enabled: false,
+ allowedSenderIds: [],
+ allowGroupMessages: false
+ },
weixin: {
enabled: false
},
@@ -337,20 +364,23 @@ export class ChannelSettingsStore {
) {
this.environmentChannels = {
wecom: this.readEnvironmentChannel('wecom'),
- dingtalk: this.readEnvironmentChannel('dingtalk')
+ dingtalk: this.readEnvironmentChannel('dingtalk'),
+ telegram: this.readEnvironmentChannel('telegram')
}
}
async snapshot(
statuses: Partial> = {}
): Promise {
- const [weixin, wecom, dingtalk] = await Promise.all([
+ const [weixin, wecom, dingtalk, telegram] = await Promise.all([
this.resolve('weixin'),
this.resolve('wecom'),
- this.resolve('dingtalk')
+ this.resolve('dingtalk'),
+ this.resolve('telegram')
])
const weComEnvironment = this.environmentChannel('wecom')
const dingTalkEnvironment = this.environmentChannel('dingtalk')
+ const telegramEnvironment = this.environmentChannel('telegram')
const warnings = [
...this.warnings,
...(this.runtimeRepairWarning ? [this.runtimeRepairWarning] : []),
@@ -363,6 +393,17 @@ export class ChannelSettingsStore {
) === index
)
return {
+ telegram: {
+ enabled: telegram.enabled,
+ secretConfigured: telegram.secret !== undefined,
+ source: telegram.source,
+ readOnly: telegram.readOnly,
+ allowedSenderIds: [...telegram.allowedSenderIds],
+ allowGroupMessages: false,
+ status: telegramEnvironment.configurationError
+ ? { state: 'error', lastError: telegramEnvironment.configurationError }
+ : statuses.telegram ?? defaultStatus(telegram.enabled)
+ },
weixin: {
enabled: weixin.enabled,
bindingConfigured: weixin.token !== undefined,
@@ -418,6 +459,9 @@ export class ChannelSettingsStore {
return this.snapshot(statuses)
}
+ resolve(channel: 'telegram'): Promise>
resolve(channel: 'wecom'): Promise>
@@ -463,6 +507,9 @@ export class ChannelSettingsStore {
source: 'environment' as const,
readOnly: true
}
+ if (channel === 'telegram') {
+ return { channel, ...common, allowGroupMessages: false }
+ }
return channel === 'wecom'
? {
channel,
@@ -493,6 +540,9 @@ export class ChannelSettingsStore {
: ('encrypted' as const),
readOnly: false
}
+ if (channel === 'telegram') {
+ return { channel, ...common, allowGroupMessages: false }
+ }
return channel === 'wecom'
? { channel, botId: settings.wecom.botId, ...common }
: { channel, clientId: settings.dingtalk.clientId, ...common }
@@ -501,12 +551,14 @@ export class ChannelSettingsStore {
resolveAll(): Promise,
Extract,
- Extract
+ Extract,
+ Extract
]> {
return Promise.all([
this.resolve('weixin'),
this.resolve('wecom'),
- this.resolve('dingtalk')
+ this.resolve('dingtalk'),
+ this.resolve('telegram')
])
}
@@ -583,6 +635,12 @@ export class ChannelSettingsStore {
if (input.weixin !== undefined) {
current.weixin.enabled = input.weixin.enabled
}
+ if (input.telegram !== undefined) {
+ if (this.environmentChannel('telegram').owned) {
+ throw new Error('Telegram 由环境变量配置,不能在设置中修改')
+ }
+ current.telegram = this.updateStoredChannel('telegram', current.telegram, input.telegram)
+ }
if (input.wecom !== undefined) {
if (this.environmentChannel('wecom').owned) {
throw new Error('企业微信由环境变量配置,不能在设置中修改')
@@ -609,6 +667,7 @@ export class ChannelSettingsStore {
}
this.validateEnabledCredentialChannel('wecom', current.wecom)
this.validateEnabledCredentialChannel('dingtalk', current.dingtalk)
+ this.validateEnabledCredentialChannel('telegram', current.telegram)
await this.persist(current)
this.settings = current
if (!this.temporarilyDisabledWeixin) {
@@ -631,6 +690,11 @@ export class ChannelSettingsStore {
return this.snapshot()
}
+ private updateStoredChannel(
+ channel: 'telegram',
+ current: StoredSettings['telegram'],
+ input: TelegramChannelSettingsInput
+ ): StoredSettings['telegram']
private updateStoredChannel(
channel: 'wecom',
current: StoredSettings['wecom'],
@@ -644,7 +708,7 @@ export class ChannelSettingsStore {
private updateStoredChannel(
channel: CredentialChannel,
current: StoredCredentialChannel,
- input: WeComChannelSettingsInput | DingTalkChannelSettingsInput
+ input: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
): StoredCredentialChannel {
const credential =
input.secret.action === 'keep'
@@ -662,6 +726,9 @@ export class ChannelSettingsStore {
allowedSenderIds,
allowGroupMessages: input.allowGroupMessages
}
+ if (channel === 'telegram') {
+ return { ...common, allowGroupMessages: false }
+ }
return channel === 'wecom'
? {
...common,
@@ -693,6 +760,12 @@ export class ChannelSettingsStore {
if (!stored.enabled) {
return
}
+ if (channel === 'telegram') {
+ if (this.decryptCredential(channel, stored) === undefined) {
+ throw new Error('启用 Telegram 前需要配置 Bot Token')
+ }
+ return
+ }
const identifier =
channel === 'wecom'
? (stored as StoredSettings['wecom']).botId
@@ -732,6 +805,7 @@ export class ChannelSettingsStore {
return undefined
}
const warn = (): undefined => {
+ if (channel === 'telegram') return undefined
this.addWarning({
code:
channel === 'wecom'
@@ -750,7 +824,7 @@ export class ChannelSettingsStore {
if (payload.channel !== channel) {
return warn()
}
- this.removeWarnings([
+ if (channel !== 'telegram') this.removeWarnings([
channel === 'wecom'
? 'channel-wecom-credential-unreadable'
: 'channel-dingtalk-credential-unreadable'
@@ -825,7 +899,8 @@ export class ChannelSettingsStore {
enabled: false
},
wecom: legacy.wecom,
- dingtalk: legacy.dingtalk
+ dingtalk: legacy.dingtalk,
+ telegram: structuredClone(defaultStoredSettings.telegram)
}
}
await this.persist(this.settings)
@@ -924,7 +999,8 @@ export class ChannelSettingsStore {
: {})
},
wecom: settings.wecom,
- dingtalk: settings.dingtalk
+ dingtalk: settings.dingtalk,
+ telegram: structuredClone(defaultStoredSettings.telegram)
}
}
@@ -939,6 +1015,30 @@ export class ChannelSettingsStore {
private readEnvironmentChannel(
channel: CredentialChannel
): EnvironmentChannel {
+ if (channel === 'telegram') {
+ const secret = boundedEnvironmentValue(
+ this.environment, 'GOODBUDDY_TELEGRAM_BOT_TOKEN',
+ CHANNEL_SETTINGS_LIMITS.maximumSecretLength
+ )
+ const owned = secret.value !== undefined || secret.invalid
+ const enabled = environmentBoolean(this.environment, 'GOODBUDDY_TELEGRAM_ENABLED', true)
+ const senders = telegramAllowedSenderIdsSchema.safeParse(
+ this.environment.GOODBUDDY_TELEGRAM_ALLOWED_SENDERS?.trim()
+ ? this.environment.GOODBUDDY_TELEGRAM_ALLOWED_SENDERS.split(',')
+ : []
+ )
+ const groups = environmentBoolean(this.environment, 'GOODBUDDY_TELEGRAM_ALLOW_GROUPS', false)
+ const invalid = secret.invalid || enabled.invalid || !senders.success || groups.invalid || groups.value
+ return {
+ owned,
+ enabled: owned && !invalid && enabled.value,
+ id: '',
+ secret: secret.value,
+ allowedSenderIds: senders.success ? senders.data : [],
+ allowGroupMessages: false,
+ ...(owned && invalid ? { configurationError: 'Telegram 环境变量配置无效,请检查 Token、数字用户 ID 和私聊设置' } : {})
+ }
+ }
const prefix =
channel === 'wecom' ? 'GOODBUDDY_WECOM' : 'GOODBUDDY_DINGTALK'
const idName =
diff --git a/src/main/channels/remote-channel-routing.test.ts b/src/main/channels/remote-channel-routing.test.ts
index 11e9f589..86819a8d 100644
--- a/src/main/channels/remote-channel-routing.test.ts
+++ b/src/main/channels/remote-channel-routing.test.ts
@@ -55,7 +55,8 @@ describe('parseRemoteChannelPrompt', () => {
expect(projectChannelLabels).toEqual({
weixin: '微信 ClawBot',
wecom: '企业微信',
- dingtalk: '钉钉'
+ dingtalk: '钉钉',
+ telegram: 'Telegram'
})
})
})
diff --git a/src/main/channels/sqlite-channel-state.test.ts b/src/main/channels/sqlite-channel-state.test.ts
new file mode 100644
index 00000000..bdfd1ee7
--- /dev/null
+++ b/src/main/channels/sqlite-channel-state.test.ts
@@ -0,0 +1,72 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { AssistantDatabase } from '../assistant/assistant-database'
+import { SqliteChannelOutbox } from './sqlite-channel-state'
+
+const databases: AssistantDatabase[] = []
+afterEach(() => {
+ for (const database of databases.splice(0)) database.close()
+})
+
+function createOutbox(): SqliteChannelOutbox {
+ const database = new AssistantDatabase(':memory:')
+ databases.push(database)
+ database.initialize('C:\\Workspace')
+ return new SqliteChannelOutbox(database)
+}
+
+const message = {
+ channel: 'telegram' as const,
+ eventId: 'event-1',
+ conversationId: 'chat-1',
+ recipientId: 'user-1',
+ status: 'completed' as const,
+ output: 'Result',
+ attachments: [{ name: 'result.txt', mimeType: 'text/plain', size: 1, kind: 'file' as const, dataBase64: 'eA==' }]
+}
+
+describe('SqliteChannelOutbox', () => {
+ it('marks a permanent failure terminal, releases media and counts the attempt once', () => {
+ const outbox = createOutbox()
+ const entry = outbox.enqueue(message)
+ outbox.markTerminal(entry.id)
+ outbox.markTerminal(entry.id)
+ outbox.markFailed(entry.id)
+ outbox.markDelivered(entry.id)
+ expect(outbox.listUndelivered()).toEqual([{
+ ...entry, state: 'terminal', attempts: 1,
+ message: { ...message, attachments: undefined }
+ }])
+ expect(outbox.listUndelivered()[0]!.message).not.toHaveProperty('attachments')
+ })
+
+ it('keeps retryable entries ahead of terminal entries under channel and global limits', () => {
+ const outbox = createOutbox()
+ const terminal = outbox.enqueue(message)
+ outbox.markTerminal(terminal.id)
+ const retry = outbox.enqueue({ ...message, eventId: 'retry' })
+ outbox.markFailed(retry.id)
+ outbox.markFailed(retry.id)
+ for (const channel of [undefined, 'telegram']) {
+ expect(outbox.listUndelivered(channel, 1).map(entry => entry.id)).toEqual([retry.id])
+ expect(outbox.listUndelivered(channel).map(entry => entry.id)).toEqual([retry.id, terminal.id])
+ }
+ expect(outbox.listUndelivered('weixin')).toEqual([])
+ })
+
+ it('budgets retry bytes after putting terminal results last in both windows', () => {
+ const outbox = createOutbox()
+ // Retained terminal text alone exceeds the 20 MiB retry window.
+ for (let index = 0; index < 22; index++) {
+ const entry = outbox.enqueue({ ...message, eventId: `terminal-${index}`, output: 'x'.repeat(1024 * 1024) })
+ outbox.markTerminal(entry.id)
+ }
+ const retry = outbox.enqueue({ ...message, eventId: 'retry' })
+ outbox.markFailed(retry.id)
+ outbox.markFailed(retry.id)
+ for (const channel of [undefined, 'telegram']) {
+ const entries = outbox.listUndelivered(channel)
+ expect(entries[0]!.id).toBe(retry.id)
+ expect(entries.length).toBeLessThan(23)
+ }
+ })
+})
diff --git a/src/main/channels/sqlite-channel-state.ts b/src/main/channels/sqlite-channel-state.ts
index 53f4450c..ef5ec95c 100644
--- a/src/main/channels/sqlite-channel-state.ts
+++ b/src/main/channels/sqlite-channel-state.ts
@@ -33,6 +33,10 @@ export class SqliteChannelOutbox implements Outbox {
this.database.markChannelResult(id, 'failed')
}
+ markTerminal(id: string): void {
+ this.database.markChannelResult(id, 'terminal')
+ }
+
listUndelivered(
channel?: string,
limit?: number
diff --git a/src/main/channels/telegram-channel-driver.test.ts b/src/main/channels/telegram-channel-driver.test.ts
new file mode 100644
index 00000000..dbba3289
--- /dev/null
+++ b/src/main/channels/telegram-channel-driver.test.ts
@@ -0,0 +1,387 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { channelInboundTextSchema, type ChannelResultMessage } from '../../shared/channel-contracts'
+import { TelegramChannelDriver } from './telegram-channel-driver'
+import { ChannelService } from './channel-service'
+import { MemoryOutbox } from './channel-driver'
+
+const netFetch = vi.hoisted(() => vi.fn())
+vi.mock('electron', () => ({ net: { fetch: netFetch } }))
+
+const secret = 'test-only-placeholder'
+const drivers: TelegramChannelDriver[] = []
+const ok = (result: unknown) => Response.json({ ok: true, result })
+const error = (code: number, retryAfter = 2) => Response.json({ ok: false, error_code: code, parameters: { retry_after: retryAfter }, description: `https://api.telegram.org/bot${secret}/getUpdates` }, { status: code })
+const pending = (signal: AbortSignal) => new Promise((_resolve, reject) => {
+ if (signal.aborted) reject(new Error(secret))
+ else signal.addEventListener('abort', () => reject(new Error(secret)), { once: true })
+})
+const update = (id: number, text = 'hello', extra: Record = {}) => ({
+ update_id: id,
+ message: { message_id: id + 100, date: 1234, from: { id: 7, is_bot: false }, chat: { id: 7, type: 'private' }, text, ...extra }
+})
+function setup(allowedSenderIds: string[] = ['7']) {
+ const calls: { method: string; body: Record; signal: AbortSignal }[] = []
+ const polls: ((signal: AbortSignal) => Response | Promise)[] = []
+ const sends: (() => Response | Promise)[] = []
+ const onStatus = vi.fn()
+ const fetch = vi.fn(async (input, init) => {
+ const method = String(input).split('/').at(-1)!
+ const signal = init!.signal!
+ calls.push({ method, body: JSON.parse(String(init!.body)), signal })
+ if (method === 'getMe') return ok({ id: 42, is_bot: true, username: 'example_bot' })
+ if (method === 'getWebhookInfo') return ok({ url: '' })
+ if (method === 'getUpdates') return polls.shift()?.(signal) ?? pending(signal)
+ if (method === 'sendMessage') return sends.shift()?.() ?? ok({ message_id: 99 })
+ return ok(true)
+ })
+ const driver = new TelegramChannelDriver({ secret, allowedSenderIds, fetch, onStatus })
+ drivers.push(driver)
+ return { driver, calls, polls, sends, fetch, onStatus }
+}
+
+afterEach(async () => {
+ await Promise.all(drivers.splice(0).map((driver) => driver.stop()))
+ vi.useRealTimers()
+})
+
+const result = (overrides: Partial = {}): ChannelResultMessage => ({
+ channel: 'telegram', eventId: '1', conversationId: '42:7', recipientId: '7', status: 'completed', output: 'answer', ...overrides
+})
+
+describe('TelegramChannelDriver', () => {
+ it('is lazy, validates with getMe/getWebhookInfo only, and never polls during repeated tests', async () => {
+ const { driver, calls } = setup()
+ expect(calls).toEqual([])
+ expect(await driver.testConnection()).toEqual({ botId: '42', botUsername: 'example_bot' })
+ await driver.testConnection()
+ expect(calls.map((call) => call.method)).toEqual(['getMe', 'getWebhookInfo', 'getMe', 'getWebhookInfo'])
+ })
+
+ it('refuses a webhook without deleting it or polling', async () => {
+ const { driver, fetch, calls } = setup()
+ fetch.mockResolvedValueOnce(ok({ id: 42, is_bot: true })).mockResolvedValueOnce(ok({ url: 'https://example.invalid/hook' }))
+ await expect(driver.testConnection()).rejects.toThrow('webhook')
+ expect(calls).toHaveLength(0)
+ expect(fetch).toHaveBeenCalledTimes(2)
+ })
+
+ it('keeps test requests separate from the running poller', async () => {
+ const { driver, calls } = setup()
+ await driver.start(vi.fn())
+ await driver.testConnection()
+ expect(calls.filter((call) => call.method === 'getUpdates')).toHaveLength(1)
+ })
+
+ it('allows bounded private help with an empty whitelist but no ordinary requests', async () => {
+ const { driver, polls, calls } = setup([])
+ polls.push(() => ok([update(1, '/whoami'), update(2, '/whoami'), update(3, 'ordinary'), update(4, '/start')]))
+ const handler = vi.fn()
+ await driver.start(handler)
+ await vi.waitFor(() => expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(2))
+ expect(handler).not.toHaveBeenCalled()
+ expect(calls.find((call) => call.method === 'sendMessage')?.body.text).toBe('7')
+ expect(calls.filter((call) => call.method === 'sendMessage')[1]?.body.text).toContain('Offline messages')
+ })
+
+ it('requires numeric whitelist IDs', () => {
+ expect(() => setup(['@somebody'])).toThrow('numeric')
+ })
+
+ it('preserves all unknown slash text, Bot scope, event ID and millisecond date', async () => {
+ const { driver, polls } = setup()
+ polls.push(() => ok(['/ask', '/execute something', '/exec', '/unknown'].map((text, i) => update(i + 1, text))))
+ const handler = vi.fn(async (message, ack) => { channelInboundTextSchema.parse(message); ack() })
+ await driver.start(handler)
+ await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(4))
+ expect(handler.mock.calls.map(([message]) => message.text)).toEqual(['/ask', '/execute something', '/exec', '/unknown'])
+ expect(handler.mock.calls[0]?.[0]).toEqual({ channel: 'telegram', accountId: '42', eventId: '1', senderId: '7', conversationId: '42:7', conversationType: 'direct', text: '/ask', mentioned: false, receivedAt: 1234000 })
+ })
+
+ it('ignores groups, topics, bots, edits, unauthorized users and media captions', async () => {
+ const { driver, polls, calls } = setup()
+ polls.push(() => ok([
+ update(1, '/whoami', { chat: { id: -7, type: 'group' } }),
+ update(2, 'topic', { message_thread_id: 1 }),
+ update(3, 'bot', { from: { id: 7, is_bot: true } }),
+ { update_id: 4, edited_message: update(4).message },
+ update(5, 'unauthorized', { from: { id: 9, is_bot: false } }),
+ update(6, '/start', { photo: [], caption: 'execute caption' }),
+ update(7, '', { photo: [], from: { id: 9, is_bot: false } }),
+ update(8, ' '), update(9, 'valid')
+ ]))
+ const handler = vi.fn(async (_message, ack) => ack())
+ await driver.start(handler)
+ await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1))
+ expect(handler.mock.calls[0]?.[0].text).toBe('valid')
+ expect(calls.filter((call) => call.method === 'sendMessage').map((call) => call.body.text)).toEqual(['Only text messages are supported. Please describe your request in text.'])
+ })
+
+ it('does not advance past unacknowledged input and skips duplicates after acknowledgment', async () => {
+ const { driver, polls, calls } = setup()
+ polls.push(() => ok([update(10)]), () => ok([update(10), update(11)]), () => ok([update(11)]))
+ let attempts = 0
+ const handler = vi.fn(async (_message, ack) => { if (++attempts > 1) ack() })
+ await driver.start(handler)
+ await vi.waitFor(() => expect(calls.filter((call) => call.method === 'getUpdates')).toHaveLength(4), { timeout: 3000 })
+ expect(calls.filter((call) => call.method === 'getUpdates').map((call) => call.body.offset)).toEqual([0, 0, 12, 12])
+ expect(handler).toHaveBeenCalledTimes(3)
+ })
+
+ it('stop aborts long polling and waits for fetch cleanup', async () => {
+ const { driver, calls } = setup()
+ await driver.start(vi.fn())
+ await vi.waitFor(() => expect(calls.some((call) => call.method === 'getUpdates')).toBe(true))
+ await driver.stop()
+ expect(calls.find((call) => call.method === 'getUpdates')?.signal.aborted).toBe(true)
+ await expect(driver.send(result(), new AbortController().signal)).rejects.toMatchObject({ name: 'AbortError' })
+ })
+
+ it('cancels validation without starting a poller', async () => {
+ const { driver, fetch } = setup()
+ fetch.mockImplementationOnce((_input, init) => pending(init!.signal!))
+ await driver.start(vi.fn())
+ const sending = driver.send(result(), new AbortController().signal)
+ const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
+ await driver.stop()
+ await rejected
+ expect(fetch).toHaveBeenCalledTimes(1)
+ })
+
+ it('waits on 429 and cancellation interrupts retry_after', async () => {
+ const { driver, polls, calls, onStatus } = setup()
+ polls.push(() => error(429, 60))
+ await driver.start(vi.fn())
+ await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ state: 'starting', lastError: expect.stringContaining('rate limit') })))
+ await driver.stop()
+ expect(calls.filter((call) => call.method === 'getUpdates')).toHaveLength(1)
+ })
+
+ it('backs off transient failures and recovers status', async () => {
+ const { driver, polls, onStatus, calls } = setup()
+ polls.push(() => error(503), () => ok([]))
+ await driver.start(vi.fn())
+ await vi.waitFor(() => expect(calls.filter((call) => call.method === 'getUpdates').length).toBeGreaterThanOrEqual(2), { timeout: 2500 })
+ expect(onStatus.mock.calls.some(([status]) => status.lastError?.includes('503'))).toBe(true)
+ expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'running' })
+ })
+
+ it.each([401, 403, 409])('stops polling with a clear redacted %i error', async (code) => {
+ const { driver, polls, calls, onStatus } = setup()
+ polls.push(() => error(code))
+ await driver.start(vi.fn())
+ await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ state: 'error' })))
+ const status = onStatus.mock.calls.at(-1)![0]
+ expect(status.lastError).not.toContain(secret)
+ expect(status.lastError).not.toContain('https://')
+ expect(calls.filter((call) => call.method === 'getUpdates')).toHaveLength(1)
+ })
+
+ it('delivers accepted work after a polling conflict without terminating its outbox entry', async () => {
+ const { driver, polls, calls, onStatus } = setup()
+ const outbox = new MemoryOutbox()
+ let finish!: (value: { status: string; output: string }) => void
+ const executor = vi.fn(() => new Promise<{ status: string; output: string }>((resolve) => { finish = resolve }))
+ const service = new ChannelService(driver, executor, { allowedSenderIds: ['7'], outbox })
+ polls.push(() => ok([update(1)]), () => error(409))
+ try {
+ await service.start()
+ await vi.waitFor(() => expect(executor).toHaveBeenCalledOnce())
+ await vi.waitFor(() => expect(onStatus.mock.calls.at(-1)?.[0].state).toBe('error'))
+ finish({ status: 'completed', output: 'accepted work result' })
+ await vi.waitFor(() => expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(1))
+ expect(calls.find((call) => call.method === 'sendMessage')?.body.text).toBe('accepted work result')
+ expect(outbox.listUndelivered()).toEqual([])
+ expect(onStatus.mock.calls.at(-1)?.[0].state).toBe('error')
+ } finally {
+ await service.stop()
+ }
+ })
+
+ it('splits pure text at 4096 UTF16 units without splitting emoji and includes error and attachment notice', async () => {
+ const { driver, calls } = setup()
+ await driver.start(vi.fn())
+ const output = `${'a'.repeat(4095)}\uD83D\uDE00${'b'.repeat(4100)}`
+ await driver.send(result({ output, error: 'task failed', attachments: [{ name: 'file.txt', mimeType: 'text/plain', size: 1, kind: 'file', dataBase64: 'YQ==' }] }), new AbortController().signal)
+ const parts = calls.filter((call) => call.method === 'sendMessage').map((call) => call.body.text as string)
+ expect(parts).toHaveLength(3)
+ expect(parts.every((part) => part.length <= 4096 && !/[\uD800-\uDBFF]$/u.test(part) && !/^[\uDC00-\uDFFF]/u.test(part))).toBe(true)
+ expect(parts.join('')).toBe(`${output}\n\ntask failed\n\nAttachments are available in GoodBuddy on the desktop; no attachments were sent to Telegram.`)
+ expect(calls.filter((call) => call.method === 'sendMessage').every((call) => call.body.parse_mode === undefined)).toBe(true)
+ })
+
+ it('resumes unsent chunks within the bounded in-memory progress cache', async () => {
+ const { driver, sends, calls } = setup()
+ await driver.start(vi.fn())
+ sends.push(() => ok(true), () => error(503))
+ const message = result({ output: 'a'.repeat(4096) + 'tail' })
+ await expect(driver.send(message, new AbortController().signal)).rejects.toThrow('503')
+ await driver.send(message, new AbortController().signal)
+ expect(calls.filter((call) => call.method === 'sendMessage').map((call) => call.body.text)).toEqual(['a'.repeat(4096), 'tail', 'tail'])
+ })
+
+ it('refuses persisted outbox replies from another Bot and unscoped IDs', async () => {
+ const { driver, calls } = setup()
+ await driver.start(vi.fn())
+ for (const conversationId of ['41:7', '7', '42:-7', '42:8']) {
+ await expect(driver.send(result({ conversationId }), new AbortController().signal)).rejects.toMatchObject({ message: expect.stringContaining('another Bot'), permanent: true, terminal: true })
+ }
+ expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(0)
+ })
+
+ it('redacts fetch errors, raw Telegram URLs, Token and runtime errors', async () => {
+ const { driver, fetch, calls } = setup()
+ fetch.mockRejectedValueOnce(new Error(`https://api.telegram.org/bot${secret}/getMe`))
+ await expect(driver.testConnection()).rejects.toThrow('network')
+ await driver.start(vi.fn())
+ await driver.send(result({ output: secret, error: `https://api.telegram.org/bot${secret}/getUpdates token=private` }), new AbortController().signal)
+ const text = String(calls.find((call) => call.method === 'sendMessage')?.body.text)
+ expect(text).not.toContain(secret)
+ expect(text).not.toContain('api.telegram.org')
+ expect(text).not.toContain('private')
+ })
+
+ it('cancels outbound rate-limit waits and avoids repeating permanent send failures', async () => {
+ const { driver, sends, calls } = setup()
+ await driver.start(vi.fn())
+ sends.push(() => error(429, 60))
+ const controller = new AbortController()
+ const sending = driver.send(result(), controller.signal)
+ const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
+ await vi.waitFor(() => expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(1))
+ controller.abort(new Error(secret))
+ await rejected
+ sends.push(() => error(403))
+ await expect(driver.send(result(), new AbortController().signal)).rejects.toMatchObject({ permanent: true, terminal: true })
+ await expect(driver.send(result(), new AbortController().signal)).rejects.toThrow('forbidden')
+ expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(2)
+ })
+
+ it('uses Electron net.fetch for the production network path', async () => {
+ netFetch.mockResolvedValueOnce(ok({ id: 42, is_bot: true })).mockResolvedValueOnce(ok({ url: '' }))
+ const driver = new TelegramChannelDriver({ secret, allowedSenderIds: [] })
+ drivers.push(driver)
+ expect(netFetch).not.toHaveBeenCalled()
+ await expect(driver.testConnection()).resolves.toEqual({ botId: '42' })
+ expect(netFetch).toHaveBeenCalledTimes(2)
+ })
+
+ it('honors retry_after before retrying an outbound chunk', async () => {
+ const { driver, sends, calls } = setup()
+ await driver.start(vi.fn())
+ sends.push(() => error(429, 1), () => ok(true))
+ const started = Date.now()
+ await driver.send(result(), new AbortController().signal)
+ expect(Date.now() - started).toBeGreaterThanOrEqual(990)
+ expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(2)
+ })
+
+ it('stop cancels and joins active outbound requests', async () => {
+ const { driver, fetch, calls } = setup()
+ await driver.start(vi.fn())
+ await vi.waitFor(() => expect(calls.some((call) => call.method === 'getUpdates')).toBe(true))
+ fetch.mockImplementationOnce((_input, init) => pending(init!.signal!))
+ const sending = driver.send(result(), new AbortController().signal)
+ const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
+ await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4))
+ await driver.stop()
+ await rejected
+ })
+
+ it('retains HTTP authentication errors even for non-JSON responses', async () => {
+ const { driver, fetch } = setup()
+ fetch.mockResolvedValueOnce(new Response('not JSON', { status: 401 }))
+ await expect(driver.testConnection()).rejects.toThrow('Token is invalid')
+ })
+
+ it('returns from start while offline, reconnects and releases queued sends only after validation', async () => {
+ const { driver, fetch, calls, onStatus } = setup()
+ fetch.mockRejectedValueOnce(new Error(secret))
+ await driver.start(vi.fn())
+ const sending = driver.send(result(), new AbortController().signal)
+ await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ state: 'starting', lastError: expect.stringContaining('Reconnecting') })))
+ expect(calls).toHaveLength(0)
+ await sending
+ expect(calls.map((call) => call.method)).toEqual(['getMe', 'getWebhookInfo', 'getUpdates', 'sendMessage'])
+ expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'running' })
+ })
+
+ it('stop cancels startup backoff and all identity waiters', async () => {
+ const { driver, fetch, onStatus } = setup()
+ fetch.mockRejectedValueOnce(new Error(secret))
+ await driver.start(vi.fn())
+ const sending = driver.send(result(), new AbortController().signal)
+ const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
+ await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ lastError: expect.stringContaining('Reconnecting') })))
+ await driver.stop()
+ await rejected
+ expect(fetch).toHaveBeenCalledTimes(1)
+ expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'stopped' })
+ })
+
+ it('caller cancellation releases an identity waiter without cancelling startup', async () => {
+ const { driver, fetch, onStatus } = setup()
+ let validationSignal: AbortSignal | undefined
+ fetch.mockImplementationOnce((_input, init) => {
+ validationSignal = init!.signal!
+ return pending(validationSignal)
+ })
+ await driver.start(vi.fn())
+ const controller = new AbortController()
+ const sending = driver.send(result(), controller.signal)
+ const rejected = expect(sending).rejects.toMatchObject({ name: 'AbortError' })
+ controller.abort()
+ await rejected
+ expect(validationSignal?.aborted).toBe(false)
+ expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'starting' })
+ })
+
+ it.each([401, 409])('reports terminal startup %i and rejects waiting sends without retrying', async (code) => {
+ const { driver, fetch, onStatus } = setup()
+ fetch.mockResolvedValueOnce(error(code))
+ await driver.start(vi.fn())
+ await expect(driver.send(result(), new AbortController().signal)).rejects.toMatchObject({ code, permanent: true, terminal: true })
+ expect(fetch).toHaveBeenCalledTimes(1)
+ expect(onStatus.mock.calls.at(-1)?.[0].state).toBe('error')
+ })
+
+ it('updates the numeric whitelist atomically for subsequent input without restarting', async () => {
+ const { driver, polls, calls } = setup()
+ expect(() => driver.updateAllowedSenderIds(['invalid'])).toThrow('numeric')
+ polls.push(() => ok([update(1), update(2), update(3, 'new user', { from: { id: 8, is_bot: false }, chat: { id: 8, type: 'private' } })]))
+ const handler = vi.fn(async (_message, ack) => {
+ ack()
+ driver.updateAllowedSenderIds(['8'])
+ })
+ await driver.start(handler)
+ await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(2))
+ expect(handler.mock.calls.map(([message]) => message.senderId)).toEqual(['7', '8'])
+ expect(calls.filter((call) => call.method === 'getMe')).toHaveLength(1)
+ expect(calls.every((call) => !call.signal.aborted)).toBe(true)
+ // Previously authorized work can still deliver after its sender is removed.
+ await driver.send(result(), new AbortController().signal)
+ expect(calls.at(-1)?.method).toBe('sendMessage')
+ })
+
+ it('help rate limits never hold up subsequent inbound requests', async () => {
+ const { driver, polls, sends, calls } = setup()
+ sends.push(() => error(429, 86400))
+ polls.push(() => ok([update(1, '/start'), update(2, 'business request')]))
+ const handler = vi.fn(async (_message, ack) => ack())
+ await driver.start(handler)
+ await vi.waitFor(() => expect(handler).toHaveBeenCalledTimes(1))
+ expect(calls.filter((call) => call.method === 'sendMessage')).toHaveLength(1)
+ })
+
+ it('testConnection fails fast on rate limiting and cannot replace the running identity', async () => {
+ const { driver, fetch, calls } = setup()
+ fetch.mockResolvedValueOnce(error(429, 86400))
+ await expect(driver.testConnection()).rejects.toMatchObject({ code: 429 })
+ expect(fetch).toHaveBeenCalledTimes(1)
+ await driver.start(vi.fn())
+ await vi.waitFor(() => expect(calls.some((call) => call.method === 'getUpdates')).toBe(true))
+ fetch.mockResolvedValueOnce(ok({ id: 99, is_bot: true })).mockResolvedValueOnce(ok({ url: '' }))
+ await expect(driver.testConnection()).resolves.toEqual({ botId: '99' })
+ await driver.send(result(), new AbortController().signal)
+ await expect(driver.send(result({ conversationId: '99:7' }), new AbortController().signal)).rejects.toMatchObject({ terminal: true })
+ })
+})
diff --git a/src/main/channels/telegram-channel-driver.ts b/src/main/channels/telegram-channel-driver.ts
new file mode 100644
index 00000000..a3f11e48
--- /dev/null
+++ b/src/main/channels/telegram-channel-driver.ts
@@ -0,0 +1,347 @@
+import { setTimeout as delay } from 'node:timers/promises'
+import type { ChannelInboundText, ChannelResultMessage } from '../../shared/channel-contracts'
+import type { ChannelRuntimeStatus } from '../../shared/channel-settings-contracts'
+import type { ChannelDriver, ChannelInboundHandler } from './channel-driver'
+import { redactChannelError } from './channel-service'
+
+export type TelegramChannelDriverOptions = {
+ secret: string
+ allowedSenderIds: readonly string[]
+ fetch?: typeof globalThis.fetch
+ onStatus?: (status: ChannelRuntimeStatus) => void
+}
+
+type BotIdentity = { botId: string; botUsername?: string }
+type TelegramUpdate = {
+ update_id: number
+ message?: {
+ message_id: number
+ date: number
+ from?: { id: number; is_bot?: boolean }
+ chat: { id: number; type: string }
+ text?: string
+ message_thread_id?: number
+ is_topic_message?: boolean
+ [key: string]: unknown
+ }
+}
+
+export class TelegramError extends Error {
+ constructor(readonly code: number, readonly retryAfter = 1) {
+ super(code === 401 ? 'Telegram Token is invalid. Replace the Bot Token.'
+ : code === 409 ? 'Telegram polling conflict. Stop the other poller or remove the existing webhook.'
+ : code === 403 ? 'Telegram access forbidden. The Bot may be blocked or disabled.'
+ : code === 429 ? 'Telegram rate limit. Waiting before retrying.'
+ : code === 0 ? 'Telegram network request failed. Check the network and OS proxy.'
+ : `Telegram request failed (HTTP ${code}).`)
+ }
+
+ get permanent(): boolean {
+ return this.code >= 400 && this.code < 500 && this.code !== 429
+ }
+
+ get terminal(): boolean {
+ return this.permanent
+ }
+}
+
+function cancelled(): Error {
+ const error = new Error('Telegram operation cancelled.')
+ error.name = 'AbortError'
+ return error
+}
+
+function checkSignal(signal: AbortSignal): void {
+ if (signal.aborted) throw cancelled()
+}
+
+async function wait(ms: number, signal: AbortSignal): Promise {
+ try {
+ await delay(ms, undefined, { signal })
+ } catch {
+ throw cancelled()
+ }
+}
+
+function positiveId(value: unknown): value is number {
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
+}
+
+export class TelegramChannelDriver implements ChannelDriver {
+ readonly channel = 'telegram'
+ private allowed: ReadonlySet = new Set()
+ private readonly fetchImpl: typeof globalThis.fetch
+ private lifecycle = new AbortController()
+ private readonly operations = new Set>()
+ private readonly feedback = new Map()
+ private readonly progress = new Map()
+ private identity?: BotIdentity
+ private terminalFailure?: TelegramError
+ private readonly readinessWaiters = new Set<() => void>()
+ private offset = 0
+ private pollPromise?: Promise
+ private stopPromise?: Promise
+ private lastStatus?: string
+
+ constructor(private readonly options: TelegramChannelDriverOptions) {
+ if (!options.secret.trim() || /[\s/?#]/u.test(options.secret)) {
+ throw new Error('Telegram Bot Token is invalid.')
+ }
+ this.updateAllowedSenderIds(options.allowedSenderIds)
+ this.fetchImpl = options.fetch ?? (async (input, init) => {
+ const { net } = await import('electron')
+ return net.fetch(input instanceof Request ? input.url : String(input), init)
+ })
+ }
+
+ testConnection(): Promise {
+ return this.track(this.validate(this.lifecycle.signal))
+ }
+
+ updateAllowedSenderIds(ids: readonly string[]): void {
+ if (ids.some((id) => !/^[1-9]\d*$/u.test(id))) {
+ throw new Error('Telegram whitelist must contain numeric user IDs.')
+ }
+ this.allowed = new Set(ids)
+ }
+
+ start(handler: ChannelInboundHandler): Promise {
+ if (this.stopPromise) return Promise.reject(new Error('Telegram driver has stopped. Create a new driver to restart.'))
+ if (this.pollPromise) return Promise.resolve()
+ this.status({ state: 'starting' })
+ this.pollPromise = this.poll(handler, this.lifecycle.signal)
+ return Promise.resolve()
+ }
+
+ stop(): Promise {
+ if (this.stopPromise) return this.stopPromise
+ this.lifecycle.abort()
+ this.stopPromise = (async () => {
+ await Promise.allSettled([...this.operations, this.pollPromise])
+ this.feedback.clear()
+ this.progress.clear()
+ this.status({ state: 'stopped' })
+ })()
+ return this.stopPromise
+ }
+
+ send(message: ChannelResultMessage, signal: AbortSignal): Promise {
+ return this.track(this.sendResult(message, AbortSignal.any([signal, this.lifecycle.signal])))
+ }
+
+ private async sendResult(message: ChannelResultMessage, signal: AbortSignal): Promise {
+ checkSignal(signal)
+ await this.waitForIdentity(signal)
+ const match = /^([1-9]\d*):([1-9]\d*)$/u.exec(message.conversationId)
+ if (!this.identity || message.channel !== this.channel || !match ||
+ match[1] !== this.identity.botId || match[2] !== message.recipientId) {
+ throw Object.assign(new Error('Telegram reply belongs to another Bot or has an invalid private conversation.'), { permanent: true, terminal: true })
+ }
+ const text = this.redact([
+ message.output?.trim(), message.error?.trim(),
+ message.attachments?.length ? 'Attachments are available in GoodBuddy on the desktop; no attachments were sent to Telegram.' : undefined
+ ].filter(Boolean).join('\n\n') || 'Request completed.')
+ const key = `${message.conversationId}:${message.eventId}:${message.status}`
+ let record = this.progress.get(key)
+ if (!record) {
+ record = { offset: 0 }
+ this.progress.set(key, record)
+ if (this.progress.size > 128) this.progress.delete(this.progress.keys().next().value!)
+ }
+ if (record.terminal) throw record.terminal
+ while (record.offset < text.length) {
+ let end = Math.min(record.offset + 4096, text.length)
+ if (end < text.length && /[\uD800-\uDBFF]/u.test(text.charAt(end - 1)) && /[\uDC00-\uDFFF]/u.test(text.charAt(end))) end -= 1
+ try {
+ await this.request('sendMessage', { chat_id: match[2], text: text.slice(record.offset, end) }, signal, true)
+ record.offset = end
+ } catch (error) {
+ if (error instanceof TelegramError && error.permanent) record.terminal = error
+ throw error
+ }
+ }
+ this.progress.delete(key)
+ }
+
+ private async waitForIdentity(signal: AbortSignal): Promise {
+ checkSignal(signal)
+ if (!this.identity && !this.terminalFailure) {
+ await new Promise((resolve, reject) => {
+ const finish = () => {
+ this.readinessWaiters.delete(finish)
+ signal.removeEventListener('abort', finish)
+ if (signal.aborted) reject(cancelled())
+ else resolve()
+ }
+ this.readinessWaiters.add(finish)
+ signal.addEventListener('abort', finish, { once: true })
+ })
+ }
+ checkSignal(signal)
+ // A polling conflict does not prevent an identified Bot from sending replies.
+ if (this.terminalFailure && !(this.identity && this.terminalFailure.code === 409)) {
+ throw this.terminalFailure
+ }
+ }
+
+ private async validate(signal: AbortSignal): Promise {
+ const me = await this.request<{ id: number; is_bot: boolean; username?: string }>('getMe', {}, signal)
+ if (!positiveId(me?.id) || me.is_bot !== true) throw new Error('Telegram returned an invalid Bot identity.')
+ const webhook = await this.request<{ url: string }>('getWebhookInfo', {}, signal)
+ if (typeof webhook?.url !== 'string') throw new Error('Telegram returned invalid webhook information.')
+ if (webhook.url) throw new TelegramError(409)
+ return { botId: String(me.id), ...(me.username ? { botUsername: me.username } : {}) }
+ }
+
+ private async poll(handler: ChannelInboundHandler, signal: AbortSignal): Promise {
+ let failures = 0
+ while (!signal.aborted) {
+ const started = Date.now()
+ try {
+ if (!this.identity) {
+ this.identity = await this.validate(signal)
+ checkSignal(signal)
+ for (const notify of this.readinessWaiters) notify()
+ failures = 0
+ this.status({ state: 'running' })
+ }
+ const updates = await this.request('getUpdates', {
+ offset: this.offset, timeout: 30, limit: 100, allowed_updates: ['message']
+ }, signal)
+ if (!Array.isArray(updates)) throw new TelegramError(0)
+ for (const update of updates) {
+ checkSignal(signal)
+ if (!Number.isSafeInteger(update.update_id) || update.update_id < this.offset) continue
+ let acknowledged = false
+ await this.receive(update, handler, () => { acknowledged = true }, signal)
+ checkSignal(signal)
+ if (!acknowledged) throw new Error('Telegram inbound message was not acknowledged.')
+ this.offset = update.update_id + 1
+ }
+ failures = 0
+ this.status({ state: 'running' })
+ // A server/proxy returning immediately must not turn long polling into a busy loop.
+ await wait(Math.max(0, 250 - (Date.now() - started)), signal)
+ } catch (error) {
+ if (signal.aborted) break
+ const safe = this.safeError(error)
+ if (safe instanceof TelegramError && safe.permanent) {
+ this.terminalFailure = safe
+ for (const notify of this.readinessWaiters) notify()
+ this.status({ state: 'error', lastError: safe.message })
+ break
+ }
+ this.status({ state: 'starting', lastError: `${safe.message} Reconnecting.` })
+ const pause = safe instanceof TelegramError && safe.code === 429
+ ? safe.retryAfter * 1000 : Math.min(30_000, 1000 * 2 ** Math.min(failures++, 5))
+ try { await wait(pause, signal) } catch { break }
+ }
+ }
+ }
+
+ private async receive(update: TelegramUpdate, handler: ChannelInboundHandler, acknowledge: () => void, signal: AbortSignal): Promise {
+ const message = update.message
+ if (!message || message.chat?.type !== 'private' || !positiveId(message.chat.id) ||
+ !positiveId(message.from?.id) || message.from.is_bot !== false ||
+ message.message_thread_id !== undefined || message.is_topic_message) {
+ acknowledge()
+ return
+ }
+ const senderId = String(message.from.id)
+ const media = ['caption', 'photo', 'video', 'animation', 'audio', 'voice', 'document', 'sticker', 'video_note', 'contact', 'location', 'venue', 'poll', 'dice', 'paid_media'].some((key) => message[key] !== undefined)
+ const text = typeof message.text === 'string' && !media ? message.text.trim() : ''
+ const command = /^\/(start|whoami)(?:@([^\s]+))?(?:\s|$)/u.exec(text)
+ const help = command && (!command[2] || command[2].toLowerCase() === this.identity?.botUsername?.toLowerCase())
+ if (help || (media && this.allowed.has(senderId))) {
+ const now = Date.now()
+ const feedbackKey = `${senderId}:${help ? command[1] : 'media'}`
+ if (now - (this.feedback.get(feedbackKey) ?? -Infinity) >= 5000) {
+ this.feedback.delete(feedbackKey)
+ this.feedback.set(feedbackKey, now)
+ if (this.feedback.size > 128) this.feedback.delete(this.feedback.keys().next().value!)
+ const reply = help ? command[1] === 'whoami' ? senderId
+ : 'GoodBuddy accepts private text requests from authorized users. Send /whoami and add your numeric ID to the desktop whitelist. Offline messages retained by Telegram may run when reconnected. After a crash, check desktop task records and results before resending; tasks do not automatically resume.'
+ : 'Only text messages are supported. Please describe your request in text.'
+ try {
+ await this.request('sendMessage', { chat_id: String(message.chat.id), text: reply }, signal)
+ } catch (error) {
+ if (signal.aborted) throw error
+ this.status({ state: 'running', lastError: this.safeError(error).message })
+ }
+ }
+ acknowledge()
+ return
+ }
+ if (!text || !this.allowed.has(senderId) || !Number.isSafeInteger(message.date) || message.date < 0) {
+ acknowledge()
+ return
+ }
+ const inbound: ChannelInboundText = {
+ channel: this.channel, accountId: this.identity!.botId,
+ eventId: String(update.update_id), senderId,
+ conversationId: `${this.identity!.botId}:${message.chat.id}`,
+ conversationType: 'direct', text, mentioned: false, receivedAt: message.date * 1000
+ }
+ await handler(inbound, acknowledge)
+ checkSignal(signal)
+ // Help commands have separate cooldowns so /start does not suppress /whoami.
+ const typingKey = `${senderId}:typing`
+ if (Date.now() - (this.feedback.get(typingKey) ?? -Infinity) >= 5000) {
+ this.feedback.delete(typingKey)
+ this.feedback.set(typingKey, Date.now())
+ if (this.feedback.size > 128) this.feedback.delete(this.feedback.keys().next().value!)
+ try { await this.request('sendChatAction', { chat_id: String(message.chat.id), action: 'typing' }, signal) } catch { /* Feedback must not reject an already queued request. */ }
+ }
+ }
+
+ private async request(method: string, body: object, signal: AbortSignal, retryRateLimit = false): Promise {
+ for (let attempt = 0; ; attempt += 1) {
+ checkSignal(signal)
+ const timeout = new AbortController()
+ const timer = setTimeout(() => timeout.abort(), method === 'getUpdates' ? 40_000 : 15_000)
+ let failure: TelegramError
+ try {
+ const response = await this.fetchImpl(`https://api.telegram.org/bot${this.options.secret}/${method}`, {
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
+ redirect: 'error', signal: AbortSignal.any([signal, timeout.signal])
+ })
+ const data = await response.json().catch(() => ({})) as { ok?: boolean; result?: T; error_code?: number; parameters?: { retry_after?: number } }
+ checkSignal(signal)
+ if (response.ok && data.ok === true) return data.result as T
+ const code = response.ok ? (Number.isSafeInteger(data.error_code) ? data.error_code! : 0) : response.status
+ const seconds = data.parameters?.retry_after
+ failure = new TelegramError(code, typeof seconds === 'number' && Number.isFinite(seconds) ? Math.max(1, Math.min(seconds, 86400)) : 1)
+ } catch (error) {
+ if (signal.aborted) throw cancelled()
+ failure = error instanceof TelegramError ? error : new TelegramError(0)
+ } finally {
+ clearTimeout(timer)
+ }
+ if (failure.code !== 429 || !retryRateLimit || attempt >= 3) throw failure
+ await wait(failure.retryAfter * 1000, signal)
+ }
+ }
+
+ private redact(value: string): string {
+ return redactChannelError(value
+ .replace(/https?:\/\/api\.telegram\.org\/[^\s"'<>]+/giu, '[Telegram URL hidden]')
+ .split(this.options.secret).join('[Token hidden]'))
+ }
+
+ private safeError(error: unknown): Error {
+ return error instanceof TelegramError ? error : new Error('Telegram operation failed. Check the connection and inbound queue.')
+ }
+
+ private status(status: ChannelRuntimeStatus): void {
+ const key = JSON.stringify(status)
+ if (key === this.lastStatus) return
+ this.lastStatus = key
+ this.options.onStatus?.(status)
+ }
+
+ private track(promise: Promise): Promise {
+ this.operations.add(promise)
+ void promise.finally(() => this.operations.delete(promise)).catch(() => undefined)
+ return promise
+ }
+}
diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts
index c94aba0d..9dda74f3 100644
--- a/src/main/ipc.test.ts
+++ b/src/main/ipc.test.ts
@@ -35,6 +35,7 @@ import type {
} from '../shared/ssh-host-contracts'
import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology'
import { AssistantDatabase } from './assistant/assistant-database'
+import { ChannelSettingsStore } from './channels/channel-settings-store'
import { SubagentService, createSubagentRuntime } from './assistant/subagent-service'
import { ConversationAttachmentStorage } from './conversation-attachment-storage'
import { DocumentResultStorage } from './document-result-storage'
@@ -350,6 +351,7 @@ const electronMocks = vi.hoisted(() => {
return {
handlers,
invoke: vi.fn(),
+ fetch: vi.fn(),
handle: vi.fn((channel: string, handler: InvokeHandler) => {
handlers.set(channel, handler)
}),
@@ -1391,6 +1393,7 @@ describe('registerIpcHandlers model download source routing', () => {
})
vi.mock('electron', () => ({
+ net: { fetch: electronMocks.fetch },
contextBridge: { exposeInMainWorld: (name: string, value: unknown) => vi.stubGlobal(name, value) },
ipcRenderer: { invoke: electronMocks.invoke },
webUtils: {},
@@ -5374,7 +5377,8 @@ describe('registerIpcHandlers agent terminal state', () => {
obsidianService?: ObsidianService,
nativeClientCoordinator?: Parameters[44],
nativeTerminalManager?: Parameters[40],
- applicationSettingsStore?: ApplicationSettingsStore
+ applicationSettingsStore?: ApplicationSettingsStore,
+ channelSettingsStore?: ChannelSettingsStore
) {
runtime.getStatus ??= vi.fn(async () => ({
id: runtime.runtimeId ?? 'model', available: true,
@@ -5625,6 +5629,7 @@ describe('registerIpcHandlers agent terminal state', () => {
args[44] = nativeClientCoordinator
args[40] = nativeTerminalManager
if (applicationSettingsStore) args[14] = applicationSettingsStore
+ args[13] = channelSettingsStore
runtimeFactoryMocks.createModelProfileRuntime.mockReturnValue(runtime)
const dispose = registerIpcHandlers(...args)
return {
@@ -12150,6 +12155,131 @@ describe('registerIpcHandlers agent terminal state', () => {
}
})
+ it('routes Telegram settings and incoming messages through real channel handlers without modes', async () => {
+ const root = await mkdtemp(join(tmpdir(), 'goodbuddy-ipc-telegram-'))
+ const database = new AssistantDatabase(':memory:')
+ database.initialize(root)
+ database.ensureChannelProjects(root)
+ const routeConversation = vi.spyOn(database, 'getOrCreateRemoteConversation')
+ const settingsStore = new ChannelSettingsStore(join(root, 'channels.json'), {
+ isAvailable: () => true,
+ encrypt: value => Buffer.from(value),
+ decrypt: value => value.toString()
+ }, {})
+ const requests: AgentExecutionRequest[] = []
+ const runtime = {
+ capability: 'chat', supportsToolExecution: true,
+ async *run(request: AgentExecutionRequest, _signal: AbortSignal, authorize?: unknown) {
+ expect(authorize).toBeUndefined()
+ requests.push(request)
+ yield { requestId: request.requestId, type: 'text', delta: 'Telegram result' }
+ yield { requestId: request.requestId, type: 'done' }
+ }
+ }
+ let delivered = false
+ const replies: unknown[] = []
+ electronMocks.fetch.mockImplementation(async (input, init) => {
+ const method = String(input).split('/').at(-1)
+ let result: unknown
+ if (method === 'getMe') result = { id: 456, is_bot: true, username: 'ipc_test_bot' }
+ else if (method === 'getWebhookInfo') result = { url: '' }
+ else if (method === 'getUpdates') {
+ result = delivered ? [] : [{
+ update_id: 789,
+ message: {
+ message_id: 1, date: Math.floor(Date.now() / 1000),
+ from: { id: 123, is_bot: false }, chat: { id: 123, type: 'private' },
+ text: '/execute preserve this literal prefix'
+ }
+ }]
+ delivered = true
+ } else if (method === 'sendMessage') {
+ replies.push(JSON.parse(String(init?.body)))
+ result = { message_id: 2 }
+ } else if (method === 'sendChatAction') result = true
+ else throw new Error(`Unexpected Telegram method: ${method}`)
+ return new Response(JSON.stringify({ ok: true, result }))
+ })
+ let harness: ReturnType | undefined
+ try {
+ harness = createHarness(runtime, undefined, undefined, false, undefined, undefined,
+ undefined, false, undefined, undefined, undefined, undefined, database,
+ undefined, undefined, undefined, undefined, undefined, settingsStore)
+ const event = trustedEvent(harness.webContents)
+ const get = electronMocks.handlers.get(ipcChannels.channelSettingsGet)!
+ const apply = electronMocks.handlers.get(ipcChannels.channelSettingsApply)!
+ const test = electronMocks.handlers.get(ipcChannels.channelSettingsTest)!
+ const settings = {
+ enabled: false, secret: { action: 'replace', value: '456:test-token' },
+ allowedSenderIds: ['123'], allowGroupMessages: false
+ }
+ await expect(test(event, { channel: 'telegram', settings })).resolves.toEqual({
+ channel: 'telegram', ok: true, botUsername: 'ipc_test_bot'
+ })
+ await expect(get(event)).resolves.toMatchObject({ telegram: { secretConfigured: false } })
+ expect(delivered).toBe(false)
+ expect(() => test(event, {
+ channel: 'telegram', settings: { ...settings, allowedSenderIds: ['username'] }
+ })).toThrow()
+ expect(() => apply(event, { telegram: { ...settings, allowGroupMessages: true } })).toThrow()
+ expect(() => test({ sender: {}, senderFrame: harness!.webContents.mainFrame }, {
+ channel: 'telegram', settings
+ })).toThrow('拒绝来自未知窗口的 IPC 请求')
+ await expect(apply(event, { telegram: settings })).resolves.toMatchObject({
+ telegram: { enabled: false, secretConfigured: true, allowedSenderIds: ['123'] }
+ })
+ await expect(test(event, { channel: 'telegram' })).resolves.toEqual({
+ channel: 'telegram', ok: true, botUsername: 'ipc_test_bot'
+ })
+ await apply(event, { telegram: { ...settings, enabled: true, secret: { action: 'keep' } } })
+ await vi.waitFor(() => expect(replies).toContainEqual({ chat_id: '123', text: 'Telegram result' }))
+ expect(requests).toHaveLength(1)
+ const request = requests[0]!
+ const project = database.listProjects().find(item => item.channel === 'telegram')!
+ expect(project).toMatchObject({ kind: 'channel', name: 'Telegram' })
+ expect(routeConversation).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({
+ projectId: project.id, channel: 'telegram', accountId: '456',
+ externalConversationId: '456:123', conversationType: 'direct'
+ }))
+ expect(database.getConversation(request.conversationId!)).toMatchObject({
+ projectId: project.id,
+ remote: { channel: 'telegram', conversationType: 'direct' },
+ messages: [
+ expect.objectContaining({ role: 'user', content: '/execute preserve this literal prefix' }),
+ expect.objectContaining({ role: 'assistant', content: 'Telegram result' })
+ ]
+ })
+ expect(database.getTask(request.requestId)).toMatchObject({
+ projectId: project.id, conversationId: request.conversationId,
+ origin: 'delegation', status: 'completed', instructions: '/execute preserve this literal prefix'
+ })
+ expect(request.prompt).toContain('/execute preserve this literal prefix')
+ expect(request).not.toHaveProperty('workMode')
+ expect(request).not.toHaveProperty('mode')
+ expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
+ channel: 'telegram', status: { state: 'starting' }
+ })
+ expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
+ channel: 'telegram', status: { state: 'running' }
+ })
+ const history = database.getConversation(request.conversationId!)
+ await expect(apply(event, { telegram: { ...settings, secret: { action: 'clear' } } })).resolves.toMatchObject({
+ telegram: { enabled: false, secretConfigured: false, status: { state: 'disabled' } }
+ })
+ expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.channelStatusChanged, {
+ channel: 'telegram', status: { state: 'disabled' }
+ })
+ expect((await settingsStore.resolve('telegram')).secret).toBeUndefined()
+ expect(database.getProject(project.id)).toEqual(project)
+ expect(database.getConversation(request.conversationId!)).toEqual(history)
+ } finally {
+ await harness?.dispose()
+ electronMocks.fetch.mockReset()
+ database.close()
+ await rm(root, { recursive: true, force: true })
+ }
+ })
+
it('bridges explanation-only channel requests without an authorizer', async () => {
let received:
| {
@@ -12231,7 +12361,8 @@ describe('registerIpcHandlers agent terminal state', () => {
it.each([
['weixin', '微信 ClawBot'],
['wecom', '企业微信'],
- ['dingtalk', '钉钉']
+ ['dingtalk', '钉钉'],
+ ['telegram', 'Telegram']
] as const)(
'grants enabled Magic Notes write tools to %s channel requests',
async (channel, channelLabel) => {
@@ -12341,6 +12472,7 @@ describe('registerIpcHandlers agent terminal state', () => {
)
})
expect(receivedRequest?.prompt).toContain('读取我的笔记')
+ expect(receivedRequest).not.toHaveProperty('workMode')
expect(knowledgeGateway.revoke).toHaveBeenCalledWith(
'channel-notes-capability'
)
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 76ddcdc8..249f502f 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -81,6 +81,7 @@ import {
import {
channelSettingsApplySchema,
dingTalkChannelSettingsInputSchema,
+ telegramChannelSettingsInputSchema,
weComChannelSettingsInputSchema
} from '../shared/channel-settings-contracts'
import { applicationSettingsUpdateSchema, defaultSupervisorModelConcurrency } from '../shared/application-settings-contracts'
@@ -346,6 +347,12 @@ const channelSettingsTestRequestSchema = z.discriminatedUnion('channel', [
channel: z.literal('dingtalk'),
settings: dingTalkChannelSettingsInputSchema.optional()
})
+ .strict(),
+ z
+ .object({
+ channel: z.literal('telegram'),
+ settings: telegramChannelSettingsInputSchema.optional()
+ })
.strict()
])
@@ -3057,6 +3064,11 @@ export function registerIpcHandlers(
const channelManager = channelSettingsStore
? new ChannelManager(channelSettingsStore, channelExecutor, {
launchWechatSidecar,
+ onStatusChanged: (change) => {
+ if (!window.isDestroyed()) {
+ window.webContents.send(ipcChannels.channelStatusChanged, change)
+ }
+ },
dedupStore: new SqliteChannelDedupStore(assistantDatabase),
outbox: new SqliteChannelOutbox(assistantDatabase)
})
@@ -5078,6 +5090,9 @@ export function registerIpcHandlers(
throw new Error('消息通道设置服务不可用')
}
const request = channelSettingsTestRequestSchema.parse(input)
+ if (request.channel === 'telegram') {
+ return channelManager.testConnection('telegram', request.settings)
+ }
return request.channel === 'wecom'
? channelManager.testConnection('wecom', request.settings)
: channelManager.testConnection('dingtalk', request.settings)
diff --git a/src/preload/index.ts b/src/preload/index.ts
index ab8f1dfc..67c97204 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -92,10 +92,12 @@ import type {
} from '../shared/assistant-contracts'
import type {
ChannelConnectionTestResult,
+ ChannelRuntimeStatusChange,
ChannelSettingsApply,
ChannelSettingsSnapshot,
CredentialChannel,
DingTalkChannelSettingsInput,
+ TelegramChannelSettingsInput,
WeComChannelSettingsInput
} from '../shared/channel-settings-contracts'
import type {
@@ -626,6 +628,14 @@ const desktopApi: DesktopApi = {
}
},
channels: {
+ onStatusChanged: (listener) => {
+ const handler = (
+ _event: Electron.IpcRendererEvent,
+ change: ChannelRuntimeStatusChange
+ ): void => listener(change)
+ ipcRenderer.on(ipcChannels.channelStatusChanged, handler)
+ return () => ipcRenderer.removeListener(ipcChannels.channelStatusChanged, handler)
+ },
getSnapshot: () =>
ipcRenderer.invoke(
ipcChannels.channelSettingsGet
@@ -637,7 +647,7 @@ const desktopApi: DesktopApi = {
) as Promise,
testConnection: (
channel: CredentialChannel,
- settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
+ settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
) =>
ipcRenderer.invoke(ipcChannels.channelSettingsTest, {
channel,
diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts
index 81f30e6f..81825b9a 100644
--- a/src/preload/preload-sandbox.test.ts
+++ b/src/preload/preload-sandbox.test.ts
@@ -1,8 +1,50 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
+import type { DesktopApi } from '../shared/contracts'
+import { ipcChannels } from '../shared/ipc-channels'
+
+const electron = vi.hoisted(() => ({
+ contextBridge: { exposeInMainWorld: vi.fn() },
+ ipcRenderer: { invoke: vi.fn(), on: vi.fn(), removeListener: vi.fn() },
+ webUtils: {}
+}))
+vi.mock('electron', () => electron)
describe('sandboxed preload', () => {
+ it('forwards status deltas without the Electron event and removes the exact subscription', async () => {
+ await import('./index')
+ const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
+ const listener = vi.fn()
+ const unsubscribe = api.channels!.onStatusChanged(listener)
+ const [channel, handler] = electron.ipcRenderer.on.mock.calls.at(-1)!
+ expect(channel).toBe(ipcChannels.channelStatusChanged)
+ const change = { channel: 'telegram', status: { state: 'error', lastError: 'Network unavailable' } }
+ handler({ sender: 'main' }, change)
+ expect(listener).toHaveBeenCalledExactlyOnceWith(change)
+ unsubscribe()
+ expect(electron.ipcRenderer.removeListener).toHaveBeenCalledExactlyOnceWith(channel, handler)
+ })
+
+ it('forwards Telegram settings and draft or saved connection tests through channel IPC', async () => {
+ await import('./index')
+ const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
+ const settings = {
+ enabled: false, secret: { action: 'replace' as const, value: '456:test-token' },
+ allowedSenderIds: ['123'], allowGroupMessages: false as const
+ }
+ await api.channels!.getSnapshot()
+ await api.channels!.apply({ telegram: settings })
+ await api.channels!.testConnection('telegram', settings)
+ await api.channels!.testConnection('telegram')
+ expect(electron.ipcRenderer.invoke.mock.calls).toEqual([
+ [ipcChannels.channelSettingsGet],
+ [ipcChannels.channelSettingsApply, { telegram: settings }],
+ [ipcChannels.channelSettingsTest, { channel: 'telegram', settings }],
+ [ipcChannels.channelSettingsTest, { channel: 'telegram', settings: undefined }]
+ ])
+ })
+
it('does not import Node built-ins unavailable in Electron sandbox', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx
index 84082372..b83aab02 100644
--- a/src/renderer/src/App.test.tsx
+++ b/src/renderer/src/App.test.tsx
@@ -9951,13 +9951,14 @@ describe("App", () => {
["weixin", "微信 ClawBot"],
["wecom", "企业微信"],
["dingtalk", "钉钉"],
+ ["telegram", "Telegram"],
].map(([channel, name], index) => ({
...project,
id: `00000000-0000-4000-8000-00000000020${index + 1}`,
name: name!,
description: `${name}通道项目`,
kind: "channel" as const,
- channel: channel as "weixin" | "wecom" | "dingtalk",
+ channel: channel as AssistantProject["channel"],
runtimeSelection: {
provider: "model" as const,
profileId: modelProfileId,
@@ -9991,7 +9992,17 @@ describe("App", () => {
},
);
api.channels = {
+ onStatusChanged: vi.fn(() => () => undefined),
getSnapshot: vi.fn(async () => ({
+ telegram: {
+ enabled: false,
+ secretConfigured: false,
+ source: "none" as const,
+ readOnly: false,
+ allowedSenderIds: [],
+ allowGroupMessages: false as const,
+ status: { state: "disabled" as const },
+ },
weixin: {
enabled: false,
bindingConfigured: false,
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 3f36e0eb..d1c3c724 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -764,7 +764,7 @@ function isConversation(value: unknown): value is Conversation {
(item.remote === undefined ||
(typeof item.remote === "object" &&
item.remote !== null &&
- ["weixin", "wecom", "dingtalk"].includes(
+ ["weixin", "wecom", "dingtalk", "telegram"].includes(
String((item.remote as Record).channel),
))) &&
typeof item.title === "string" &&
diff --git a/src/renderer/src/ChannelIcon.tsx b/src/renderer/src/ChannelIcon.tsx
index 3a718c2d..43556d54 100644
--- a/src/renderer/src/ChannelIcon.tsx
+++ b/src/renderer/src/ChannelIcon.tsx
@@ -25,6 +25,10 @@ import type { ProjectChannel } from '../../shared/assistant-contracts'
* THE SOFTWARE.
*/
const channelMarks = {
+ telegram: {
+ viewBox: '0 0 24 24',
+ path: 'M22 2 2 10l7 3 3 9 10-20ZM5.6 10l12-4.8-8 6.1L5.6 10Zm5.2 3 7.4-6.2-6 11.9-1.4-5.7Z'
+ },
weixin: {
viewBox: '0 0 24 24',
path: 'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z'
diff --git a/src/renderer/src/ChannelSettingsSection.test.tsx b/src/renderer/src/ChannelSettingsSection.test.tsx
index 4570f5d6..617fb665 100644
--- a/src/renderer/src/ChannelSettingsSection.test.tsx
+++ b/src/renderer/src/ChannelSettingsSection.test.tsx
@@ -1,4 +1,5 @@
import {
+ act,
cleanup,
fireEvent,
render,
@@ -8,7 +9,7 @@ import {
} from '@testing-library/react'
import type { ComponentProps } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
-import type { ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
+import type { ChannelRuntimeStatusChange, ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
import {
defaultRuntimeSettings,
type DesktopApi,
@@ -71,6 +72,15 @@ const runtimeSettings: RuntimeSettings = {
}
const snapshot: ChannelSettingsSnapshot = {
+ telegram: {
+ enabled: false,
+ secretConfigured: false,
+ source: 'none',
+ readOnly: false,
+ allowedSenderIds: [],
+ allowGroupMessages: false,
+ status: { state: 'disabled' }
+ },
weixin: {
enabled: false,
bindingConfigured: false,
@@ -102,7 +112,8 @@ const snapshot: ChannelSettingsSnapshot = {
const projects: AssistantProject[] = [
['weixin', '微信 ClawBot'],
['wecom', '企业微信'],
- ['dingtalk', '钉钉']
+ ['dingtalk', '钉钉'],
+ ['telegram', 'Telegram']
].map(([channel, name], index) => ({
id: `00000000-0000-4000-8000-00000000000${index + 1}`,
name: name!,
@@ -114,7 +125,7 @@ const projects: AssistantProject[] = [
},
runtimeSelection: { provider: 'model' },
kind: 'channel',
- channel: channel as 'weixin' | 'wecom' | 'dingtalk',
+ channel: channel as AssistantProject['channel'],
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
@@ -167,6 +178,223 @@ afterEach(async () => {
})
describe('ChannelSettingsSection', () => {
+ it('keeps pushed channel status newer than loading and updates reconnect errors without resetting drafts', async () => {
+ let receive!: (change: ChannelRuntimeStatusChange) => void
+ const unsubscribe = vi.fn()
+ const onStatusChanged = vi.fn((listener: typeof receive) => {
+ receive = listener
+ return unsubscribe
+ })
+ const getSnapshot = vi.fn(async () => {
+ receive({ channel: 'telegram', status: { state: 'running' } })
+ return { ...snapshot, telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, status: { state: 'starting' } } }
+ })
+ const apply = vi.fn()
+ Object.defineProperty(window, 'goodbuddy', {
+ configurable: true,
+ value: {
+ channels: { ...bindingApi(), onStatusChanged, getSnapshot, apply, testConnection: vi.fn() },
+ settings: settingsApi(), projects: { update: vi.fn() }
+ } as unknown as DesktopApi
+ })
+ const { unmount } = renderChannelSettings({ initialChannel: 'telegram' })
+ const token = await screen.findByLabelText('TelegramBot Token')
+ expect(screen.getByText('已连接')).toBeInTheDocument()
+ expect(screen.queryByText('正在连接')).not.toBeInTheDocument()
+ fireEvent.change(token, { target: { value: 'unsaved-token-placeholder' } })
+ const senders = screen.getByLabelText('Telegram允许的发送者 ID')
+ fireEvent.change(senders, { target: { value: '12345\n67890' } })
+ act(() => receive({ channel: 'telegram', status: { state: 'error', lastError: 'Network unavailable' } }))
+ expect(screen.getByText('连接失败')).toBeInTheDocument()
+ expect(screen.getByRole('alert')).toHaveTextContent('Network unavailable')
+ act(() => receive({ channel: 'telegram', status: { state: 'starting' } }))
+ expect(screen.getByText('正在连接')).toBeInTheDocument()
+ expect(screen.queryByText('Network unavailable')).not.toBeInTheDocument()
+ act(() => receive({ channel: 'telegram', status: { state: 'running' } }))
+ expect(screen.getByText('已连接')).toBeInTheDocument()
+ expect(token).toHaveValue('unsaved-token-placeholder')
+ expect(senders).toHaveValue('12345\n67890')
+ expect(getSnapshot).toHaveBeenCalledOnce()
+ expect(onStatusChanged).toHaveBeenCalledOnce()
+ expect(apply).not.toHaveBeenCalled()
+ unmount()
+ expect(unsubscribe).toHaveBeenCalledOnce()
+ })
+
+ it('tests and saves Telegram credentials, keeps them on whitelist edits, and clears them explicitly', async () => {
+ const configured = {
+ ...snapshot,
+ telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, source: 'encrypted' as const }
+ }
+ const apply = vi.fn()
+ .mockResolvedValueOnce(configured)
+ .mockResolvedValueOnce({ ...configured, telegram: { ...configured.telegram, allowedSenderIds: ['12345', '67890'] } })
+ .mockResolvedValueOnce(snapshot)
+ const testConnection = vi.fn(async () => ({ channel: 'telegram', ok: true, botUsername: 'goodbuddy_test_bot' }))
+ const updateProject = vi.fn(async (id: string, input: ProjectCreateInput) => ({ ...projects.find((p) => p.id === id)!, ...input }))
+ Object.defineProperty(window, 'goodbuddy', {
+ configurable: true,
+ value: {
+ channels: { ...bindingApi(), getSnapshot: vi.fn(async () => snapshot), apply, testConnection },
+ settings: settingsApi(),
+ projects: { update: updateProject }
+ } as unknown as DesktopApi
+ })
+ const onNotify = vi.fn()
+ renderChannelSettings({ initialChannel: 'telegram', onNotify })
+ const token = await screen.findByLabelText('TelegramBot Token')
+ expect(token).toHaveAttribute('type', 'password')
+ expect(screen.getByRole('link', { name: '打开官方 BotFather' })).toHaveAttribute('href', 'https://t.me/BotFather')
+ expect(screen.getByText(/尚未授权用户/u)).toBeInTheDocument()
+ expect(screen.queryByRole('switch', { name: /群聊/u })).not.toBeInTheDocument()
+ expect(screen.queryByLabelText(/机器人 ID/u)).not.toBeInTheDocument()
+ expect(screen.queryByRole('group', { name: /默认模式/u })).not.toBeInTheDocument()
+ fireEvent.change(token, { target: { value: 'test-token-placeholder' } })
+ fireEvent.click(screen.getByRole('button', { name: '测试Telegram连接' }))
+ await waitFor(() => {
+ expect(testConnection).toHaveBeenCalledWith('telegram', {
+ enabled: false, secret: { action: 'replace', value: 'test-token-placeholder' }, allowedSenderIds: [], allowGroupMessages: false
+ })
+ expect(screen.getByRole('link', { name: '@goodbuddy_test_bot' })).toHaveAttribute('href', 'https://t.me/goodbuddy_test_bot')
+ expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
+ })
+ expect(apply).not.toHaveBeenCalled()
+ expect(screen.getByRole('switch', { name: '启用Telegram通道' })).not.toBeChecked()
+ expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'Telegram 凭据验证成功;测试不会启用消息接收。' }))
+ fireEvent.click(screen.getByRole('switch', { name: '启用Telegram通道' }))
+ fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
+ await waitFor(() => {
+ expect(apply).toHaveBeenLastCalledWith({ telegram: {
+ enabled: true, secret: { action: 'replace', value: 'test-token-placeholder' }, allowedSenderIds: [], allowGroupMessages: false
+ } })
+ expect(token).toHaveValue('')
+ expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
+ })
+ fireEvent.change(screen.getByLabelText('Telegram允许的发送者 ID'), { target: { value: '12345,67890\n12345' } })
+ fireEvent.change(screen.getByLabelText('Telegram 默认工作目录'), { target: { value: 'C:\\TelegramWorkspace' } })
+ fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
+ await waitFor(() => {
+ expect(apply).toHaveBeenLastCalledWith({ telegram: {
+ enabled: true, secret: { action: 'keep' }, allowedSenderIds: ['12345', '67890'], allowGroupMessages: false
+ } })
+ expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
+ })
+ expect(updateProject).toHaveBeenCalledWith(projects[3]!.id, expect.objectContaining({ rootPath: 'C:\\TelegramWorkspace' }))
+ fireEvent.click(screen.getByRole('checkbox', { name: /保存时清除 Token/u }))
+ expect(screen.queryByRole('link', { name: '@goodbuddy_test_bot' })).not.toBeInTheDocument()
+ expect(token).toBeDisabled()
+ expect(screen.getByRole('button', { name: '测试Telegram连接' })).toBeDisabled()
+ fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
+ await waitFor(() => {
+ expect(apply).toHaveBeenLastCalledWith({ telegram: {
+ enabled: false, secret: { action: 'clear' }, allowedSenderIds: ['12345', '67890'], allowGroupMessages: false
+ } })
+ expect(screen.queryByRole('checkbox', { name: /保存时清除 Token/u })).not.toBeInTheDocument()
+ expect(token).toHaveValue('')
+ expect(token).toBeEnabled()
+ expect(screen.getByRole('switch', { name: '启用Telegram通道' })).not.toBeChecked()
+ })
+ })
+
+ it('validates Telegram IDs and retains the draft after test and save failures', async () => {
+ const apply = vi.fn().mockRejectedValue(new Error('Save failed'))
+ const testConnection = vi.fn().mockResolvedValue({ channel: 'telegram', ok: false, error: 'Webhook in use' })
+ Object.defineProperty(window, 'goodbuddy', {
+ configurable: true,
+ value: {
+ channels: { ...bindingApi(), getSnapshot: vi.fn(async () => snapshot), apply, testConnection },
+ settings: settingsApi(), projects: { update: vi.fn(async () => projects[3]) }
+ } as unknown as DesktopApi
+ })
+ renderChannelSettings({ initialChannel: 'telegram' })
+ const token = await screen.findByLabelText('TelegramBot Token')
+ fireEvent.change(token, { target: { value: 'test-token-placeholder' } })
+ const senders = screen.getByLabelText('Telegram允许的发送者 ID')
+ fireEvent.change(senders, { target: { value: '@username' } })
+ expect(senders).toHaveAttribute('aria-invalid', 'true')
+ expect(senders).toHaveAccessibleDescription('请填写正整数用户 ID,最多 100 个,每个不超过 256 位,不要填写 @用户名。')
+ fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
+ expect(apply).not.toHaveBeenCalled()
+ expect(screen.getByRole('button', { name: '测试Telegram连接' })).toBeDisabled()
+ fireEvent.change(senders, { target: { value: '12345' } })
+ fireEvent.click(screen.getByRole('button', { name: '测试Telegram连接' }))
+ await waitFor(() => {
+ expect(screen.getByText('Webhook in use')).toBeInTheDocument()
+ expect(token).toBeEnabled()
+ expect(token).toHaveValue('test-token-placeholder')
+ })
+ fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
+ await waitFor(() => {
+ expect(screen.getByText('Save failed')).toBeInTheDocument()
+ expect(token).toHaveValue('test-token-placeholder')
+ expect(senders).toHaveValue('12345')
+ expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
+ })
+ })
+
+ it('tests saved Telegram Tokens without replacement and locks edits until the result arrives', async () => {
+ let resolveTest!: (value: { channel: 'telegram'; ok: boolean; botUsername: string }) => void
+ const testConnection = vi.fn(() => new Promise((resolve) => { resolveTest = resolve }))
+ Object.defineProperty(window, 'goodbuddy', {
+ configurable: true,
+ value: {
+ channels: {
+ ...bindingApi(),
+ getSnapshot: vi.fn(async () => ({ ...snapshot, telegram: { ...snapshot.telegram, secretConfigured: true, source: 'encrypted' } })),
+ apply: vi.fn(), testConnection
+ },
+ settings: settingsApi(), projects: { update: vi.fn() }
+ } as unknown as DesktopApi
+ })
+ renderChannelSettings({ initialChannel: 'telegram' })
+ const token = await screen.findByLabelText('TelegramBot Token')
+ fireEvent.click(screen.getByRole('button', { name: '测试Telegram连接' }))
+ expect(testConnection).toHaveBeenCalledWith('telegram', {
+ enabled: false, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false
+ })
+ expect(token).toBeDisabled()
+ expect(screen.getByRole('checkbox', { name: /保存时清除 Token/u })).toBeDisabled()
+ expect(screen.getByRole('button', { name: '保存通道设置' })).toBeDisabled()
+ await act(async () => resolveTest({ channel: 'telegram', ok: true, botUsername: 'saved_test_bot' }))
+ await waitFor(() => {
+ expect(token).toBeEnabled()
+ expect(screen.getByRole('link', { name: '@saved_test_bot' })).toBeInTheDocument()
+ })
+ fireEvent.change(token, { target: { value: 'replacement-placeholder' } })
+ expect(screen.queryByRole('link', { name: '@saved_test_bot' })).not.toBeInTheDocument()
+ })
+
+ it('renders English Telegram setup and tests read-only environment credentials', async () => {
+ const testConnection = vi.fn(async () => ({ channel: 'telegram', ok: true }))
+ Object.defineProperty(window, 'goodbuddy', {
+ configurable: true,
+ value: {
+ channels: {
+ ...bindingApi(),
+ getSnapshot: vi.fn(async () => ({ ...snapshot, telegram: { ...snapshot.telegram, secretConfigured: true, readOnly: true, source: 'environment' } })),
+ apply: vi.fn(), testConnection
+ },
+ settings: settingsApi(), projects: { update: vi.fn() }
+ } as unknown as DesktopApi
+ })
+ await i18n.changeLanguage('en-US')
+ renderChannelSettings({ initialChannel: 'telegram' })
+ expect(await screen.findByLabelText('Telegram Bot Token')).toBeDisabled()
+ expect(screen.getByRole('switch', { name: 'Enable the Telegram channel' })).toBeDisabled()
+ expect(screen.getByLabelText('Telegram allowed sender IDs')).toBeDisabled()
+ expect(screen.getByText(/Send \/newbot to BotFather/u)).toHaveTextContent('/whoami')
+ expect(screen.getByText(/No users authorized/u)).toBeInTheDocument()
+ expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
+ fireEvent.click(screen.getByRole('button', { name: 'Connection and offline messages' }))
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('operating system proxy')
+ fireEvent.click(screen.getByRole('button', { name: 'Test Telegram connection' }))
+ await waitFor(() => {
+ expect(testConnection).toHaveBeenCalledWith('telegram', undefined)
+ expect(screen.getByRole('button', { name: 'Test Telegram connection' })).toBeEnabled()
+ })
+ expect(screen.queryByRole('link', { name: /^@/u })).not.toBeInTheDocument()
+ })
+
it('saves editable channel settings without returning stored secrets', async () => {
const updateProject = vi.fn(async (
projectId: string,
@@ -545,7 +773,7 @@ describe('ChannelSettingsSection', () => {
expect(apply).not.toHaveBeenCalled()
})
- it('presents the three channel configurations as keyboard tabs', async () => {
+ it('presents the four channel configurations as keyboard tabs', async () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
@@ -581,6 +809,7 @@ describe('ChannelSettingsSection', () => {
expect(weixinTab).toHaveAttribute('aria-selected', 'true')
expect(wecomTab).toHaveAttribute('tabindex', '-1')
expect(dingtalkTab).toHaveAttribute('tabindex', '-1')
+ expect(within(tablist).getByRole('tab', { name: 'Telegram' })).toHaveAttribute('tabindex', '-1')
expect(screen.getByText('项目设置')).toBeInTheDocument()
expect(
screen.getByText('与左上角当前通道项目的设置保持同步。')
@@ -601,6 +830,9 @@ describe('ChannelSettingsSection', () => {
expect(
screen.getByRole('switch', { name: '启用企业微信通道' })
).toBeInTheDocument()
+ fireEvent.keyDown(wecomTab, { key: 'End' })
+ expect(within(tablist).getByRole('tab', { name: 'Telegram' })).toHaveFocus()
+ expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'channel-settings-tab-telegram')
})
it('opens the requested channel configuration', async () => {
diff --git a/src/renderer/src/ChannelSettingsSection.tsx b/src/renderer/src/ChannelSettingsSection.tsx
index 7750c0b1..3be35430 100644
--- a/src/renderer/src/ChannelSettingsSection.tsx
+++ b/src/renderer/src/ChannelSettingsSection.tsx
@@ -4,17 +4,20 @@ import {
Smartphone,
Unplug
} from 'lucide-react'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import { createPortal } from 'react-dom'
import QRCode from 'qrcode'
+import { telegramAllowedSenderIdsSchema } from '../../shared/channel-settings-contracts'
import type {
ChannelConnectionTestResult,
+ ChannelRuntimeStatus,
ChannelSettingsApply,
ChannelSettingsSnapshot,
CredentialChannel,
DingTalkChannelSettingsInput,
+ TelegramChannelSettingsInput,
WeComChannelSettingsInput
} from '../../shared/channel-settings-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
@@ -26,6 +29,8 @@ import {
} from '../../shared/assistant-contracts'
import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contracts'
import type { AppNotificationInput } from './notifications'
+import { createChannelStatusStore, type ChannelStatusStore } from './channel-status-store'
+import { useChannelStatus } from './channel-status-selectors'
import { trapTabFocus } from './dialog-focus'
import { PageTabs } from './WorkspacePrimitives'
import { ChannelIcon } from './ChannelIcon'
@@ -96,7 +101,7 @@ function draftFromSnapshot(
identifier:
channel === 'wecom'
? snapshot.wecom.botId
- : snapshot.dingtalk.clientId,
+ : channel === 'dingtalk' ? snapshot.dingtalk.clientId : '',
secret: '',
clearSecret: false,
allowedSenderIdsText: settings.allowedSenderIds.join('\n'),
@@ -112,17 +117,23 @@ function inputFor(
channel: 'dingtalk',
draft: ChannelDraft
): DingTalkChannelSettingsInput
+function inputFor(
+ channel: 'telegram',
+ draft: ChannelDraft
+): TelegramChannelSettingsInput
function inputFor(
channel: CredentialChannel,
draft: ChannelDraft
-): WeComChannelSettingsInput | DingTalkChannelSettingsInput {
+): WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput {
const common = {
enabled: draft.enabled,
secret: secretUpdate(draft),
allowedSenderIds: allowedSenderIds(draft.allowedSenderIdsText),
allowGroupMessages: draft.allowGroupMessages
}
- return channel === 'wecom'
+ return channel === 'telegram'
+ ? { ...common, enabled: draft.enabled && !draft.clearSecret, allowGroupMessages: false }
+ : channel === 'wecom'
? { ...common, botId: draft.identifier.trim() }
: { ...common, clientId: draft.identifier.trim() }
}
@@ -139,7 +150,7 @@ function channelDraftChanged(
const currentIdentifier =
channel === 'wecom'
? snapshot.wecom.botId
- : snapshot.dingtalk.clientId
+ : channel === 'dingtalk' ? snapshot.dingtalk.clientId : ''
return (
draft.enabled !== current.enabled ||
draft.identifier.trim() !== currentIdentifier ||
@@ -222,7 +233,33 @@ function ChannelProjectCard({
)
}
+const ChannelStatus = memo(function ChannelStatus({
+ channel,
+ fallback,
+ store,
+ unconfigured = false,
+ errorOnly = false
+}: {
+ channel: ProjectChannel
+ fallback: ChannelRuntimeStatus
+ store: ChannelStatusStore
+ unconfigured?: boolean
+ errorOnly?: boolean
+}): React.JSX.Element | null {
+ const { t } = useTranslation('integrations')
+ const status = useChannelStatus(store, channel, fallback)
+ if (errorOnly) {
+ return status.lastError
+ ? {status.lastError}
+ : null
+ }
+ return {unconfigured && (status.state === 'disabled' || status.state === 'stopped')
+ ? t('channels.telegram.unconfigured')
+ : t(`channels.status.${status.state}`)}
+})
+
function ChannelEditor({
+ busy,
channel,
draft,
onChange,
@@ -232,8 +269,11 @@ function ChannelEditor({
project,
runtimeSettings,
settings,
- testing
+ statusStore,
+ testing,
+ botUsername
}: {
+ busy: boolean
channel: CredentialChannel
draft: ChannelDraft
onChange: (next: ChannelDraft) => void
@@ -243,15 +283,16 @@ function ChannelEditor({
project: ChannelProjectDraft
runtimeSettings: RuntimeSettings
settings: ChannelSettingsSnapshot[CredentialChannel]
+ statusStore: ChannelStatusStore
testing: boolean
+ botUsername?: string
}): React.JSX.Element {
const { t } = useTranslation('integrations')
const title = t(`channels.tabs.${channel}`)
- const identifierLabel = t(
- `channels.credential.identifiers.${channel}`
- )
+ const identifierLabel = channel === 'telegram' ? '' : t(`channels.credential.identifiers.${channel}`)
const secretLabel = t(`channels.credential.secrets.${channel}`)
const prefix = `channel-${channel}`
+ const invalidSenders = channel === 'telegram' && !telegramAllowedSenderIdsSchema.safeParse(allowedSenderIds(draft.allowedSenderIdsText)).success
return (
<>
@@ -269,7 +310,8 @@ function ChannelEditor({
: t('channels.credential.secretMissing')}
- {t(`channels.status.${settings.status.state}`)}
+
{settings.readOnly && (
@@ -277,16 +319,12 @@ function ChannelEditor({
{t('channels.credential.readOnly')}
)}
- {settings.status.lastError && (
-
- {settings.status.lastError}
-
- )}
+
- }
{secretLabel}
@@ -321,15 +377,15 @@ function ChannelEditor({
field: secretLabel
})}
autoComplete="off"
- disabled={settings.readOnly || draft.clearSecret}
+ disabled={settings.readOnly || busy || draft.clearSecret}
maxLength={4_096}
onChange={(event) =>
onChange({ ...draft, secret: event.target.value })
}
placeholder={
settings.secretConfigured
- ? t('channels.credential.keepSecret')
- : t('channels.credential.enterSecret')
+ ? t(channel === 'telegram' ? 'channels.telegram.keepToken' : 'channels.credential.keepSecret')
+ : t(channel === 'telegram' ? 'channels.telegram.enterToken' : 'channels.credential.enterSecret')
}
type="password"
value={draft.secret}
@@ -340,6 +396,7 @@ function ChannelEditor({
onChange({
...draft,
@@ -349,18 +406,20 @@ function ChannelEditor({
}
type="checkbox"
/>
- {t('channels.credential.clearSecret')}
+ {t(channel === 'telegram' ? 'channels.telegram.clearToken' : 'channels.credential.clearSecret')}
)}
{t('channels.credential.allowedSenders')}
-
+ {channel !== 'telegram' &&
onChange({
...draft,
@@ -392,11 +452,11 @@ function ChannelEditor({
type="checkbox"
/>
{t('channels.credential.groupMessages')}
-
+ }