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} -

- )} + - }