feat: add Telegram private messaging channel

Connect a user-created Telegram bot to channel projects with encrypted credentials, numeric sender allowlists, unified execution and text replies. Use Electron networking for system proxy support, with polling recovery, cancellable delivery retries and live status updates.

Add schema 61 channel support, lifecycle regression tests, Electron integration probes and feature documentation. Require the performance principles in AGENTS.md and record three alternating baseline/current Main benchmark rounds.

Validation: typecheck, lint and diff checks passed. Full suite: 6087 passed, 87 skipped, one intermittent inline-help Electron failure that passed unchanged in isolation. Real Bot plus model acceptance remains pending; the local TLS proxy probe is opt-in.

Release note: 新增 Telegram 私聊通道,可配置 Bot Token 和用户白名单,通过通道项目处理文字请求并回复结果。支持系统代理、断线重连和停用取消;真实 Bot 与模型的完整联调仍待完成。
This commit is contained in:
mesalogo
2026-10-05 02:42:28 +08:00
parent 78afec7775
commit cf598121b1
56 changed files with 4144 additions and 194 deletions
+8
View File
@@ -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.
+4 -3
View File
@@ -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
+1 -1
View File
@@ -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 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)) |
## 产品界面
+8 -2
View File
@@ -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 证据。
## 术语与依赖
@@ -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 生产网络路径走通;固定回复成功也不能证明模型或工具任务成功。
@@ -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<T>(operation: Promise<T>, ms: number): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('Probe timeout')), ms)
})
])
} finally {
clearTimeout(timer)
}
}
async function main(): Promise<void> {
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<void>((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()
+24 -21
View File
@@ -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 的限制;若后续要求已接收任务跨崩溃恢复,再依据实际故障与业务需求评估存储和执行恢复成本。
| 后续方向 | 进入设计前需明确的内容 |
| --- | --- |
@@ -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)区分实测路径与未测路径。
@@ -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
})
@@ -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)。
@@ -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
})
+16 -12
View File
@@ -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` 配置均落到默认直连文本模型。
@@ -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-')
+44 -9
View File
@@ -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 {
@@ -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() }
})
+21
View File
@@ -19,6 +19,14 @@ export interface ChannelDriver {
stop(): void | Promise<void>
}
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<OutboxEntry>
markDelivered(id: string): void | Promise<void>
markFailed(id: string): void | Promise<void>
markTerminal?(id: string): void | Promise<void>
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
)
+370 -2
View File
@@ -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<typeof globalThis.fetch>())
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<void>((resolve) => { finish = resolve })
const execute = vi.fn<ChannelExecutor>(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<Response>((_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<typeof vi.fn>; stop: ReturnType<typeof vi.fn> }> = []
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)
+156 -26
View File
@@ -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<ChannelDriver>
settings: ResolvedChannelSettings,
onStatus?: (status: ChannelRuntimeStatus) => void
) => TestableChannelDriver | Promise<TestableChannelDriver>
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<ManagedChannelService>
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<TestableChannelDriver> {
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<void> = Promise.resolve()
private readonly serviceGenerations = new Map<ManagedChannel, symbol>()
private activeTelegram?: {
settings: Extract<ResolvedChannelSettings, { channel: 'telegram' }>
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<ChannelSettingsSnapshot> {
@@ -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<ChannelSettingsSnapshot> {
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<ChannelConnectionTestResult>
test(
channel: 'wecom',
settings?: WeComChannelSettingsInput
@@ -249,7 +309,7 @@ export class ChannelManager {
): Promise<ChannelConnectionTestResult>
async test(
channel: CredentialChannel,
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
): Promise<ChannelConnectionTestResult> {
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<ChannelConnectionTestResult>
testConnection(
channel: 'wecom',
settings?: WeComChannelSettingsInput
@@ -294,8 +372,11 @@ export class ChannelManager {
): Promise<ChannelConnectionTestResult>
testConnection(
channel: CredentialChannel,
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
): Promise<ChannelConnectionTestResult> {
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<void> {
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<void> {
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<void> {
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<ManagedChannelService> {
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<ResolvedChannelSettings> {
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<T>(operation: () => Promise<T>): Promise<T> {
let value!: T
const run = async (): Promise<void> => {
+283 -6
View File
@@ -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<never>(() => 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<boolean>((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<void>((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<void>((_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<void>((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,
+123 -25
View File
@@ -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<string>
private allowedSenderIds: ReadonlySet<string> = 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<string, Promise<void>>()
private state: ServiceState = 'idle'
private stopPromise?: Promise<void>
private readonly lifetime = new AbortController()
private readonly sending = new Set<string>()
private retryTimer?: ReturnType<typeof setTimeout>
private retryPromise?: Promise<void>
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<void> {
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<void> {
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<void> {
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<boolean> {
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<void> {
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
@@ -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(), {})
+111 -11
View File
@@ -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<typeof storedSettingsSchema>
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<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
): Promise<ChannelSettingsSnapshot> {
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<Extract<ResolvedChannelSettings, {
channel: 'telegram'
}>>
resolve(channel: 'wecom'): Promise<Extract<ResolvedChannelSettings, {
channel: 'wecom'
}>>
@@ -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<readonly [
Extract<ResolvedChannelSettings, { channel: 'weixin' }>,
Extract<ResolvedChannelSettings, { channel: 'wecom' }>,
Extract<ResolvedChannelSettings, { channel: 'dingtalk' }>
Extract<ResolvedChannelSettings, { channel: 'dingtalk' }>,
Extract<ResolvedChannelSettings, { channel: 'telegram' }>
]> {
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 =
@@ -55,7 +55,8 @@ describe('parseRemoteChannelPrompt', () => {
expect(projectChannelLabels).toEqual({
weixin: '微信 ClawBot',
wecom: '企业微信',
dingtalk: '钉钉'
dingtalk: '钉钉',
telegram: 'Telegram'
})
})
})
@@ -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)
}
})
})
@@ -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
@@ -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<Response>((_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<string, unknown> = {}) => ({
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<string, unknown>; signal: AbortSignal }[] = []
const polls: ((signal: AbortSignal) => Response | Promise<Response>)[] = []
const sends: (() => Response | Promise<Response>)[] = []
const onStatus = vi.fn()
const fetch = vi.fn<typeof globalThis.fetch>(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> = {}): 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 })
})
})
@@ -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<void> {
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<string> = new Set()
private readonly fetchImpl: typeof globalThis.fetch
private lifecycle = new AbortController()
private readonly operations = new Set<Promise<unknown>>()
private readonly feedback = new Map<string, number>()
private readonly progress = new Map<string, { offset: number; terminal?: TelegramError }>()
private identity?: BotIdentity
private terminalFailure?: TelegramError
private readonly readinessWaiters = new Set<() => void>()
private offset = 0
private pollPromise?: Promise<void>
private stopPromise?: Promise<void>
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<BotIdentity> {
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<void> {
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<void> {
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<void> {
return this.track(this.sendResult(message, AbortSignal.any([signal, this.lifecycle.signal])))
}
private async sendResult(message: ChannelResultMessage, signal: AbortSignal): Promise<void> {
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<void> {
checkSignal(signal)
if (!this.identity && !this.terminalFailure) {
await new Promise<void>((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<BotIdentity> {
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<void> {
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<TelegramUpdate[]>('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<void> {
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<T = unknown>(method: string, body: object, signal: AbortSignal, retryRateLimit = false): Promise<T> {
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<T>(promise: Promise<T>): Promise<T> {
this.operations.add(promise)
void promise.finally(() => this.operations.delete(promise)).catch(() => undefined)
return promise
}
}
+134 -2
View File
@@ -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<typeof fetch>(),
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<typeof registerIpcHandlers>[44],
nativeTerminalManager?: Parameters<typeof registerIpcHandlers>[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<typeof createHarness> | 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'
)
+15
View File
@@ -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)
+11 -1
View File
@@ -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<ChannelSettingsSnapshot>,
testConnection: (
channel: CredentialChannel,
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
) =>
ipcRenderer.invoke(ipcChannels.channelSettingsTest, {
channel,
+43 -1
View File
@@ -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'),
+12 -1
View File
@@ -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,
+1 -1
View File
@@ -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<string, unknown>).channel),
))) &&
typeof item.title === "string" &&
+4
View File
@@ -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'
@@ -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 () => {
+154 -50
View File
@@ -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
? <p className="settings-warning" role="alert">{status.lastError}</p>
: null
}
return <span>{unconfigured && (status.state === 'disabled' || status.state === 'stopped')
? t('channels.telegram.unconfigured')
: t(`channels.status.${status.state}`)}</span>
})
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')}
</small>
</div>
<span>{t(`channels.status.${settings.status.state}`)}</span>
<ChannelStatus channel={channel} fallback={settings.status} store={statusStore}
unconfigured={channel === 'telegram' && !settings.secretConfigured} />
</div>
{settings.readOnly && (
@@ -277,16 +319,12 @@ function ChannelEditor({
{t('channels.credential.readOnly')}
</p>
)}
{settings.status.lastError && (
<p className="settings-warning" role="alert">
{settings.status.lastError}
</p>
)}
<ChannelStatus channel={channel} fallback={settings.status} store={statusStore} errorOnly />
<label className="toggle-row" htmlFor={`${prefix}-enabled`}>
<input
checked={draft.enabled}
disabled={settings.readOnly}
disabled={settings.readOnly || busy}
id={`${prefix}-enabled`}
onChange={(event) =>
onChange({ ...draft, enabled: event.target.checked })
@@ -297,21 +335,39 @@ function ChannelEditor({
<span>{t('channels.credential.enable', { channel: title })}</span>
</label>
<label className="field">
{channel === 'telegram' && (
<>
<p><a href="https://t.me/BotFather" target="_blank" rel="noreferrer">{t('channels.telegram.botFather')}</a></p>
<p>{t('channels.telegram.setup')}</p>
<p className="settings-notice">{t('channels.telegram.access')}</p>
<span className="inline-help-label">
{t('channels.telegram.connectionHelp')}
<InlineHelp label={t('channels.telegram.connectionHelp')}>
{t('channels.telegram.network')} {t('channels.telegram.offline')}
</InlineHelp>
</span>
{allowedSenderIds(draft.allowedSenderIdsText).length === 0 && (
<p className="settings-notice">{t('channels.telegram.noUsers')}</p>
)}
{botUsername && <p>{t('channels.telegram.bot')} <a href={`https://t.me/${botUsername}`} target="_blank" rel="noreferrer">@{botUsername}</a></p>}
</>
)}
{channel !== 'telegram' && <label className="field">
<span>{identifierLabel}</span>
<input
aria-label={t('channels.credential.fieldAriaLabel', {
channel: title,
field: identifierLabel
})}
disabled={settings.readOnly}
disabled={settings.readOnly || busy}
maxLength={256}
onChange={(event) =>
onChange({ ...draft, identifier: event.target.value })
}
value={draft.identifier}
/>
</label>
</label>}
<label className="field">
<span>{secretLabel}</span>
@@ -321,15 +377,15 @@ function ChannelEditor({
field: secretLabel
})}
autoComplete="off"
disabled={settings.readOnly || draft.clearSecret}
disabled={settings.readOnly || busy || draft.clearSecret}
maxLength={4_096}
onChange={(event) =>
onChange({ ...draft, secret: event.target.value })
}
placeholder={
settings.secretConfigured
? t('channels.credential.keepSecret')
: t('channels.credential.enterSecret')
? t(channel === 'telegram' ? 'channels.telegram.keepToken' : 'channels.credential.keepSecret')
: t(channel === 'telegram' ? 'channels.telegram.enterToken' : 'channels.credential.enterSecret')
}
type="password"
value={draft.secret}
@@ -340,6 +396,7 @@ function ChannelEditor({
<label className="check-field">
<input
checked={draft.clearSecret}
disabled={busy}
onChange={(event) =>
onChange({
...draft,
@@ -349,18 +406,20 @@ function ChannelEditor({
}
type="checkbox"
/>
<span>{t('channels.credential.clearSecret')}</span>
<span>{t(channel === 'telegram' ? 'channels.telegram.clearToken' : 'channels.credential.clearSecret')}</span>
</label>
)}
<label className="field">
<span>{t('channels.credential.allowedSenders')}</span>
<textarea
aria-invalid={invalidSenders || undefined}
aria-describedby={invalidSenders ? `${prefix}-senders-error` : undefined}
aria-label={t(
'channels.credential.allowedSendersAriaLabel',
{ channel: title }
)}
disabled={settings.readOnly}
disabled={settings.readOnly || busy}
onChange={(event) =>
onChange({
...draft,
@@ -374,14 +433,15 @@ function ChannelEditor({
value={draft.allowedSenderIdsText}
/>
<small>
{t('channels.credential.allowedSendersHelp')}
{t(channel === 'telegram' ? 'channels.telegram.sendersHelp' : 'channels.credential.allowedSendersHelp')}
</small>
{invalidSenders && <small className="field-error" id={`${prefix}-senders-error`} role="alert">{t('channels.telegram.invalidSenders')}</small>}
</label>
<label className="toggle-row">
{channel !== 'telegram' && <label className="toggle-row">
<input
checked={draft.allowGroupMessages}
disabled={settings.readOnly}
disabled={settings.readOnly || busy}
onChange={(event) =>
onChange({
...draft,
@@ -392,11 +452,11 @@ function ChannelEditor({
type="checkbox"
/>
<span>{t('channels.credential.groupMessages')}</span>
</label>
</label>}
<button
className="secondary-button"
disabled={testing}
disabled={busy || draft.clearSecret || invalidSenders}
onClick={onTest}
type="button"
>
@@ -658,7 +718,8 @@ function WeixinChannelEditor({
onVerify,
project,
runtimeSettings,
settings
settings,
statusStore
}: {
binding: WeixinBindingSnapshot
bindingButtonRef: React.RefObject<HTMLButtonElement | null>
@@ -676,6 +737,7 @@ function WeixinChannelEditor({
project: ChannelProjectDraft
runtimeSettings: RuntimeSettings
settings: ChannelSettingsSnapshot['weixin']
statusStore: ChannelStatusStore
}): React.JSX.Element {
const { t } = useTranslation('integrations')
return (
@@ -697,14 +759,10 @@ function WeixinChannelEditor({
: t('channels.weixin.unbound')}
</small>
</div>
<span>{t(`channels.status.${settings.status.state}`)}</span>
<ChannelStatus channel="weixin" fallback={settings.status} store={statusStore} />
</div>
{settings.status.lastError && (
<p className="settings-warning" role="alert">
{settings.status.lastError}
</p>
)}
<ChannelStatus channel="weixin" fallback={settings.status} store={statusStore} errorOnly />
<label className="toggle-row" htmlFor="channel-weixin-enabled">
<input
@@ -799,9 +857,11 @@ export function ChannelSettingsSection({
const channelTabs = [
{ id: 'weixin', label: t('channels.tabs.weixin'), icon: <ChannelIcon channel="weixin" /> },
{ id: 'wecom', label: t('channels.tabs.wecom'), icon: <ChannelIcon channel="wecom" /> },
{ id: 'dingtalk', label: t('channels.tabs.dingtalk'), icon: <ChannelIcon channel="dingtalk" /> }
{ id: 'dingtalk', label: t('channels.tabs.dingtalk'), icon: <ChannelIcon channel="dingtalk" /> },
{ id: 'telegram', label: t('channels.tabs.telegram'), icon: <ChannelIcon channel="telegram" /> }
] as const
const [snapshot, setSnapshot] = useState<ChannelSettingsSnapshot>()
const [statusStore] = useState(createChannelStatusStore)
const [runtimeSettings, setRuntimeSettings] =
useState<RuntimeSettings>()
const [projectOverrides, setProjectOverrides] = useState<
@@ -819,10 +879,12 @@ export function ChannelSettingsSection({
Record<CredentialChannel, ChannelDraft>
>({
wecom: { ...emptyDraft },
dingtalk: { ...emptyDraft }
dingtalk: { ...emptyDraft },
telegram: { ...emptyDraft }
})
const [busy, setBusy] = useState(false)
const [testing, setTesting] = useState<CredentialChannel>()
const [telegramBotUsername, setTelegramBotUsername] = useState<string>()
const [error, setError] = useState<string>()
const bindingButtonRef = useRef<HTMLButtonElement>(null)
@@ -831,17 +893,20 @@ export function ChannelSettingsSection({
setBindingOpen(false)
}, [])
const applySnapshot = (next: ChannelSettingsSnapshot): void => {
const applySnapshot = useCallback((next: ChannelSettingsSnapshot): void => {
statusStore.seed(next)
setSnapshot(next)
setWeixinEnabled(next.weixin.enabled)
setDrafts({
wecom: draftFromSnapshot('wecom', next),
dingtalk: draftFromSnapshot('dingtalk', next)
dingtalk: draftFromSnapshot('dingtalk', next),
telegram: draftFromSnapshot('telegram', next)
})
}
}, [statusStore])
useEffect(() => {
const api = window.goodbuddy.channels
const disconnectStatus = statusStore.connect(api)
let active = true
void (async () => {
if (!api) {
@@ -882,14 +947,15 @@ export function ChannelSettingsSection({
)
return () => {
active = false
disconnectStatus()
removeBindingListener?.()
}
}, [closeBinding])
}, [applySnapshot, closeBinding, statusStore])
const persistedProjects = runtimeSettings
const persistedProjects = useMemo(() => runtimeSettings
? projectDraftsFrom(projectList, runtimeSettings)
: {}
const projects = Object.fromEntries(
: {}, [projectList, runtimeSettings])
const projects = useMemo(() => Object.fromEntries(
channelOrder.flatMap((channel) => {
const persisted = persistedProjects[channel]
if (!persisted) {
@@ -905,13 +971,17 @@ export function ChannelSettingsSection({
]
]
})
) as Partial<Record<ProjectChannel, ChannelProjectDraft>>
) as Partial<Record<ProjectChannel, ChannelProjectDraft>>, [persistedProjects, projectOverrides])
const save = async (): Promise<void> => {
const api = window.goodbuddy.channels
if (!api || !snapshot || !runtimeSettings) {
return
}
if (!snapshot.telegram.readOnly && !telegramAllowedSenderIdsSchema.safeParse(allowedSenderIds(drafts.telegram.allowedSenderIdsText)).success) {
setActiveChannel('telegram')
return
}
const channelProjects = channelOrder.map(
(channel) => projects[channel]
)
@@ -943,6 +1013,10 @@ export function ChannelSettingsSection({
...(!snapshot.dingtalk.readOnly &&
channelDraftChanged('dingtalk', drafts.dingtalk, snapshot)
? { dingtalk: inputFor('dingtalk', drafts.dingtalk) }
: {}),
...(!snapshot.telegram.readOnly &&
channelDraftChanged('telegram', drafts.telegram, snapshot)
? { telegram: inputFor('telegram', drafts.telegram) }
: {})
}
setBusy(true)
@@ -1096,21 +1170,25 @@ export function ChannelSettingsSection({
return
}
setTesting(channel)
if (channel === 'telegram') setTelegramBotUsername(undefined)
setError(undefined)
try {
const settings = snapshot[channel].readOnly
? undefined
: channel === 'wecom'
? inputFor('wecom', drafts.wecom)
: inputFor('dingtalk', drafts.dingtalk)
: channel === 'dingtalk'
? inputFor('dingtalk', drafts.dingtalk)
: inputFor('telegram', drafts.telegram)
const result: ChannelConnectionTestResult =
await api.testConnection(channel, settings)
if (!result.ok) {
throw new Error(result.error)
}
if (channel === 'telegram') setTelegramBotUsername(result.botUsername)
onNotify({
tone: 'success',
message: t('channels.connectionSuccess', {
message: t(channel === 'telegram' ? 'channels.telegram.verified' : 'channels.connectionSuccess', {
channel: t(`channels.tabs.${channel}`)
}),
dedupeKey: `channel-test-${channel}`
@@ -1127,12 +1205,14 @@ export function ChannelSettingsSection({
const weixinProject = projects.weixin
const wecomProject = projects.wecom
const dingtalkProject = projects.dingtalk
const telegramProject = projects.telegram
if (
!snapshot ||
!runtimeSettings ||
!weixinProject ||
!wecomProject ||
!dingtalkProject
!dingtalkProject ||
!telegramProject
) {
return (
<>
@@ -1156,7 +1236,7 @@ export function ChannelSettingsSection({
actions={
<button
className="primary-button"
disabled={busy}
disabled={busy || Boolean(testing)}
onClick={() => void save()}
type="button"
>
@@ -1212,9 +1292,11 @@ export function ChannelSettingsSection({
project={weixinProject}
runtimeSettings={runtimeSettings}
settings={snapshot.weixin}
statusStore={statusStore}
/>
) : activeChannel === 'wecom' ? (
<ChannelEditor
busy={busy || Boolean(testing)}
channel="wecom"
draft={drafts.wecom}
onChange={(next) =>
@@ -1226,10 +1308,31 @@ export function ChannelSettingsSection({
project={wecomProject}
runtimeSettings={runtimeSettings}
settings={snapshot.wecom}
statusStore={statusStore}
testing={testing === 'wecom'}
/>
) : activeChannel === 'telegram' ? (
<ChannelEditor
busy={busy || Boolean(testing)}
channel="telegram"
draft={drafts.telegram}
botUsername={telegramBotUsername}
onChange={(next) => {
if (next.secret !== drafts.telegram.secret || next.clearSecret !== drafts.telegram.clearSecret) setTelegramBotUsername(undefined)
setDrafts((current) => ({ ...current, telegram: next }))
}}
onProjectChange={(next) => updateProject('telegram', next)}
onSelectRoot={() => void selectRoot('telegram')}
onTest={() => void test('telegram')}
project={telegramProject}
runtimeSettings={runtimeSettings}
settings={snapshot.telegram}
statusStore={statusStore}
testing={testing === 'telegram'}
/>
) : (
<ChannelEditor
busy={busy || Boolean(testing)}
channel="dingtalk"
draft={drafts.dingtalk}
onChange={(next) =>
@@ -1243,6 +1346,7 @@ export function ChannelSettingsSection({
project={dingtalkProject}
runtimeSettings={runtimeSettings}
settings={snapshot.dingtalk}
statusStore={statusStore}
testing={testing === 'dingtalk'}
/>
)}
@@ -0,0 +1,11 @@
import { useSyncExternalStore } from 'react'
import type { ChannelRuntimeStatus, ManagedChannel } from '../../shared/channel-settings-contracts'
import type { ChannelStatusStore } from './channel-status-store'
export function useChannelStatus(
store: ChannelStatusStore,
channel: ManagedChannel,
fallback: ChannelRuntimeStatus
): ChannelRuntimeStatus {
return useSyncExternalStore(store.subscribe, () => store.getState()[channel] ?? fallback)
}
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest'
import type { ChannelRuntimeStatusChange, ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
import type { DesktopApi } from '../../shared/contracts'
import { createChannelStatusStore } from './channel-status-store'
const credential = {
enabled: true,
secretConfigured: true,
source: 'encrypted' as const,
readOnly: false,
allowedSenderIds: [],
allowGroupMessages: false as const,
status: { state: 'starting' as const }
}
const snapshot: ChannelSettingsSnapshot = {
weixin: { enabled: false, bindingConfigured: false, source: 'none', status: { state: 'disabled' } },
wecom: { ...credential, botId: 'test-bot' },
dingtalk: { ...credential, clientId: 'test-client' },
telegram: credential
}
describe('channel status store', () => {
it('keeps pushed status over a late snapshot, preserves other channel references, and deduplicates events', () => {
const store = createChannelStatusStore()
let receive!: (change: ChannelRuntimeStatusChange) => void
const unsubscribe = vi.fn()
const api = { onStatusChanged: vi.fn((listener: typeof receive) => { receive = listener; return unsubscribe }) } as unknown as DesktopApi['channels']
const disconnect = store.connect(api)
receive({ channel: 'telegram', status: { state: 'running' } })
store.seed(snapshot)
expect(store.getState().telegram).toEqual({ state: 'running' })
expect(store.getState().wecom).toEqual({ state: 'starting' })
const wecom = store.getState().wecom
const listener = vi.fn()
const removeListener = store.subscribe(listener)
receive({ channel: 'telegram', status: { state: 'running' } })
expect(listener).not.toHaveBeenCalled()
receive({ channel: 'telegram', status: { state: 'error', lastError: 'Network unavailable' } })
expect(listener).toHaveBeenCalledOnce()
expect(store.getState().wecom).toBe(wecom)
receive({ channel: 'telegram', status: { state: 'running' } })
expect(store.getState().telegram).toEqual({ state: 'running' })
removeListener()
disconnect()
expect(unsubscribe).toHaveBeenCalledOnce()
receive({ channel: 'telegram', status: { state: 'error', lastError: 'Late callback' } })
expect(store.getState().telegram).toEqual({ state: 'running' })
expect(listener).toHaveBeenCalledTimes(2)
})
it('uses updated snapshot baselines when the API has no status subscription', () => {
const store = createChannelStatusStore()
const disconnect = store.connect({} as DesktopApi['channels'])
store.seed(snapshot)
expect(store.getState().telegram).toEqual({ state: 'starting' })
store.seed({ ...snapshot, telegram: { ...credential, status: { state: 'disabled' } } })
expect(store.getState().telegram).toEqual({ state: 'disabled' })
expect(() => disconnect()).not.toThrow()
})
})
+49
View File
@@ -0,0 +1,49 @@
import type {
ChannelRuntimeStatus,
ChannelRuntimeStatusChange,
ChannelSettingsSnapshot,
ManagedChannel
} from '../../shared/channel-settings-contracts'
import type { DesktopApi } from '../../shared/contracts'
import { projectChannels } from '../../shared/assistant-contracts'
export function createChannelStatusStore() {
let state: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
const eventChannels = new Set<ManagedChannel>()
const listeners = new Set<() => void>()
const update = ({ channel, status }: ChannelRuntimeStatusChange): void => {
const current = state[channel]
if (current?.state === status.state && current.lastError === status.lastError) return
state = { ...state, [channel]: status }
listeners.forEach((listener) => listener())
}
return {
getState: () => state,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
connect: (api: DesktopApi['channels']) => {
let active = true
const unsubscribe = api?.onStatusChanged?.((change: ChannelRuntimeStatusChange) => {
if (!active) return
eventChannels.add(change.channel)
update(change)
})
return () => {
active = false
unsubscribe?.()
}
},
seed: (snapshot: ChannelSettingsSnapshot) => {
// A status push received during loading is newer than the snapshot baseline.
for (const channel of projectChannels) {
if (!eventChannels.has(channel)) update({ channel, status: snapshot[channel].status })
}
}
}
}
export type ChannelStatusStore = ReturnType<typeof createChannelStatusStore>
@@ -17,7 +17,8 @@ export const integrations = {
tabs: {
weixin: 'WeChat ClawBot',
wecom: 'WeCom',
dingtalk: 'DingTalk'
dingtalk: 'DingTalk',
telegram: 'Telegram'
},
status: {
disabled: 'Not enabled',
@@ -72,7 +73,8 @@ export const integrations = {
},
secrets: {
wecom: 'Secret',
dingtalk: 'Client Secret'
dingtalk: 'Client Secret',
telegram: 'Bot Token'
},
environmentSource: 'Provided by environment variables',
secretSaved: 'Secret saved with encryption',
@@ -94,6 +96,23 @@ export const integrations = {
testing: 'Testing…',
testConnection: 'Test {{channel}} connection'
},
telegram: {
unconfigured: 'Not configured',
botFather: 'Open official BotFather',
setup: 'Send /newbot to BotFather and enter the Token here. Test, enable and save the channel, then open a private chat with your Bot. You must send /start first, then /whoami to get your numeric user ID. Add that ID below and save again.',
access: 'Private text messages only. Allowed users can request operations using this project\'s working directory, Runtime and enabled tools.',
connectionHelp: 'Connection and offline messages',
network: 'GoodBuddy must stay running. Telegram follows the operating system proxy settings; configure your system proxy if needed, then test this connection. Browser access alone does not verify the channel. Use this Bot with only one GoodBuddy instance and remove any existing webhook at its source.',
offline: 'Re-enabling may process pending messages retained by Telegram for up to 24 hours, including tool requests. Interrupted tasks do not automatically resume after a crash. Check desktop task records and actual results before resending.',
noUsers: 'No users authorized. With the channel enabled and saved, /start and /whoami provide setup help only; business requests are not processed.',
sendersHelp: 'Enter numeric Telegram user IDs, one per line or separated by commas (up to 100). Usernames do not grant access.',
invalidSenders: 'Enter up to 100 positive numeric user IDs (at most 256 digits each), not @usernames.',
keepToken: 'Leave blank to keep the saved Token',
enterToken: 'Enter the BotFather Token',
clearToken: 'Clear the saved Token on save (stops the connection; keeps history)',
bot: 'Bot:',
verified: 'Telegram credentials verified. Testing does not enable message receiving.'
},
qr: {
title: 'Connect WeChat ClawBot',
instructions:
@@ -32,6 +32,10 @@ export const warnings = {
'The WeCom Secret cannot be read. Re-enter or clear this credential.',
'channel-dingtalk-credential-unreadable':
'The DingTalk Client Secret cannot be read. Re-enter or clear this credential.',
'channel-telegram-environment-invalid':
'The Telegram environment configuration is invalid or incomplete, so the channel remains off.',
'channel-telegram-credential-unreadable':
'The Telegram Bot Token cannot be read. Re-enter or clear this credential.',
'channel-runtime-selections-repaired':
'Repaired {{count}} unavailable backend selections for unattended channels. Review each channel project setting.'
} as const satisfies TranslationShape<typeof chineseWarnings>
@@ -14,7 +14,8 @@ export const integrations = {
tabs: {
weixin: '微信 ClawBot',
wecom: '企业微信',
dingtalk: '钉钉'
dingtalk: '钉钉',
telegram: 'Telegram'
},
status: {
disabled: '未启用',
@@ -63,7 +64,8 @@ export const integrations = {
},
secrets: {
wecom: 'Secret',
dingtalk: 'Client Secret'
dingtalk: 'Client Secret',
telegram: 'Bot Token'
},
environmentSource: '由环境变量提供',
secretSaved: 'Secret 已加密保存',
@@ -85,6 +87,23 @@ export const integrations = {
testing: '正在测试…',
testConnection: '测试{{channel}}连接'
},
telegram: {
unconfigured: '未配置',
botFather: '打开官方 BotFather',
setup: '向 BotFather 发送 /newbot,将获得的 Token 填入下方。测试后启用并保存通道,再打开与 Bot 的私聊。必须先由你发送 /start,再发送 /whoami 获取自己的数字用户 ID;将 ID 填入白名单后再次保存。',
access: '仅支持普通私聊文字。白名单内的用户可以使用此项目的工作目录、Runtime 和已启用工具发起操作。',
connectionHelp: '连接与离线消息说明',
network: 'GoodBuddy 需要保持运行。Telegram 使用操作系统代理设置;如有需要,请配置系统代理后测试连接。浏览器能够访问不代表通道可用。同一 Bot 仅供一个 GoodBuddy 实例使用,已有 webhook 需在原接入处解除。',
offline: '重新启用后可能处理 Telegram 保留的离线消息,包括工具请求;平台最多保留 24 小时。应用异常退出后不会自动续跑中断任务,请先核对桌面任务记录和实际结果,再决定是否重发。',
noUsers: '尚未授权用户。启用并保存后,/start 和 /whoami 仅提供配置帮助,不处理业务请求。',
sendersHelp: '填写 Telegram 数字用户 ID,每行一个或用逗号分隔,最多 100 个;用户名不能用于授权。',
invalidSenders: '请填写正整数用户 ID,最多 100 个,每个不超过 256 位,不要填写 @用户名。',
keepToken: '留空以保留已保存的 Token',
enterToken: '请输入 BotFather 提供的 Token',
clearToken: '保存时清除 Token(停止连接,保留历史)',
bot: 'Bot:',
verified: 'Telegram 凭据验证成功;测试不会启用消息接收。'
},
qr: {
title: '绑定微信 ClawBot',
instructions:
@@ -29,6 +29,10 @@ export const warnings = {
'企业微信 Secret 无法读取。请重新输入或清除该凭据。',
'channel-dingtalk-credential-unreadable':
'钉钉 Client Secret 无法读取。请重新输入或清除该凭据。',
'channel-telegram-environment-invalid':
'Telegram 环境变量配置无效或不完整,通道保持关闭。',
'channel-telegram-credential-unreadable':
'Telegram Bot Token 无法读取。请重新输入或清除该凭据。',
'channel-runtime-selections-repaired':
'已修复 {{count}} 个无人值守通道的不可用后端选择。请检查各通道项目设置。'
} as const
+4 -2
View File
@@ -29,13 +29,15 @@ export const projectKindSchema = z.enum(['user', 'channel'])
export const projectChannels = [
'weixin',
'wecom',
'dingtalk'
'dingtalk',
'telegram'
] as const
export const projectChannelSchema = z.enum(projectChannels)
export const projectChannelLabels: Record<ProjectChannel, string> = {
weixin: '微信 ClawBot',
wecom: '企业微信',
dingtalk: '钉钉'
dingtalk: '钉钉',
telegram: 'Telegram'
}
export type ProjectKind = z.infer<typeof projectKindSchema>
@@ -2,12 +2,57 @@ import { describe, expect, it } from 'vitest'
import {
CHANNEL_SETTINGS_LIMITS,
channelConnectionTestResultSchema,
channelRuntimeStatusChangeSchema,
channelSecretUpdateSchema,
channelSettingsApplySchema,
channelSettingsSnapshotSchema
} from './channel-settings-contracts'
describe('channel settings contracts', () => {
it('validates incremental runtime status changes without settings or credentials', () => {
const change = { channel: 'telegram', status: { state: 'running' } }
expect(channelRuntimeStatusChangeSchema.parse(change)).toEqual(change)
for (const invalid of [
{ ...change, secret: 'unexpected' },
{ ...change, channel: 'unknown' },
{ ...change, status: { state: 'unknown' } },
{ ...change, status: { state: 'error', lastError: 'x'.repeat(501) } }
]) {
expect(channelRuntimeStatusChangeSchema.safeParse(invalid).success).toBe(false)
}
})
it('accepts Telegram help-only settings and positive numeric sender IDs', () => {
const telegram = {
enabled: true,
secret: { action: 'keep' },
allowedSenderIds: [],
allowGroupMessages: false
}
expect(channelSettingsApplySchema.parse({ telegram })).toEqual({ telegram })
expect(channelSettingsApplySchema.parse({
telegram: { ...telegram, allowedSenderIds: [' 123 ', '123', '456'] }
}).telegram?.allowedSenderIds).toEqual(['123', '456'])
for (const sender of ['0', '-1', '1.2', '@user', '01', '1e3', '']) {
expect(channelSettingsApplySchema.safeParse({
telegram: { ...telegram, allowedSenderIds: [sender] }
}).success).toBe(false)
}
for (const extra of [{ botId: '123' }, { allowGroupMessages: true }]) {
expect(channelSettingsApplySchema.safeParse({ telegram: { ...telegram, ...extra } }).success).toBe(false)
}
})
it('only exposes botUsername on successful Telegram tests', () => {
expect(channelConnectionTestResultSchema.parse({
channel: 'telegram', ok: true, botUsername: 'example_bot'
}).botUsername).toBe('example_bot')
for (const result of [
{ channel: 'telegram', ok: false, error: 'failed' },
{ channel: 'wecom', ok: true }
]) {
expect(channelConnectionTestResultSchema.safeParse({ ...result, botUsername: 'example_bot' }).success).toBe(false)
}
})
it('accepts bounded strict WeCom and DingTalk updates', () => {
expect(
channelSettingsApplySchema.parse({
@@ -75,6 +120,15 @@ describe('channel settings contracts', () => {
it('models public credential source and runtime status without secrets', () => {
const snapshot = channelSettingsSnapshotSchema.parse({
telegram: {
enabled: false,
secretConfigured: false,
source: 'none',
readOnly: false,
allowedSenderIds: [],
allowGroupMessages: false,
status: { state: 'disabled' }
},
weixin: {
enabled: true,
bindingConfigured: true,
+43 -3
View File
@@ -15,7 +15,7 @@ export const CHANNEL_SETTINGS_LIMITS = {
export const managedChannelSchema = projectChannelSchema
export type ManagedChannel = ProjectChannel
export const credentialChannelSchema = z.enum(['wecom', 'dingtalk'])
export const credentialChannelSchema = z.enum(['wecom', 'dingtalk', 'telegram'])
export type CredentialChannel = z.infer<
typeof credentialChannelSchema
>
@@ -32,6 +32,11 @@ export const allowedSenderIdsSchema = z
.max(CHANNEL_SETTINGS_LIMITS.maximumAllowedSenders)
.transform((values) => [...new Set(values)])
export const telegramAllowedSenderIdsSchema = z
.array(senderIdentifierSchema.regex(/^[1-9][0-9]*$/u))
.max(CHANNEL_SETTINGS_LIMITS.maximumAllowedSenders)
.transform((values) => [...new Set(values)])
export const channelSecretUpdateSchema = z.discriminatedUnion('action', [
z.object({ action: z.literal('keep') }).strict(),
z
@@ -77,6 +82,15 @@ export type DingTalkChannelSettingsInput = z.infer<
typeof dingTalkChannelSettingsInputSchema
>
export const telegramChannelSettingsInputSchema = z.object({
...editableChannelFields,
allowedSenderIds: telegramAllowedSenderIdsSchema,
allowGroupMessages: z.literal(false)
}).strict()
export type TelegramChannelSettingsInput = z.infer<
typeof telegramChannelSettingsInputSchema
>
export const weixinChannelSettingsInputSchema = z
.object({
enabled: z.boolean()
@@ -90,14 +104,16 @@ export const channelSettingsApplySchema = z
.object({
weixin: weixinChannelSettingsInputSchema.optional(),
wecom: weComChannelSettingsInputSchema.optional(),
dingtalk: dingTalkChannelSettingsInputSchema.optional()
dingtalk: dingTalkChannelSettingsInputSchema.optional(),
telegram: telegramChannelSettingsInputSchema.optional()
})
.strict()
.refine(
(input) =>
input.weixin !== undefined ||
input.wecom !== undefined ||
input.dingtalk !== undefined,
input.dingtalk !== undefined ||
input.telegram !== undefined,
'至少需要提供一个通道设置'
)
export type ChannelSettingsApply = z.infer<
@@ -140,6 +156,14 @@ export type ChannelRuntimeStatus = z.infer<
typeof channelRuntimeStatusSchema
>
export const channelRuntimeStatusChangeSchema = z.object({
channel: managedChannelSchema,
status: channelRuntimeStatusSchema
}).strict()
export type ChannelRuntimeStatusChange = z.infer<
typeof channelRuntimeStatusChangeSchema
>
const publicChannelFields = {
enabled: z.boolean(),
secretConfigured: z.boolean(),
@@ -170,6 +194,13 @@ export type DingTalkChannelSettings = z.infer<
typeof dingTalkChannelSettingsSchema
>
export const telegramChannelSettingsSchema = z.object({
...publicChannelFields,
allowedSenderIds: telegramAllowedSenderIdsSchema,
allowGroupMessages: z.literal(false)
}).strict()
export type TelegramChannelSettings = z.infer<typeof telegramChannelSettingsSchema>
export const weixinChannelSettingsSchema = z
.object({
enabled: z.boolean(),
@@ -188,6 +219,7 @@ export const channelSettingsSnapshotSchema = z
weixin: weixinChannelSettingsSchema,
wecom: weComChannelSettingsSchema,
dingtalk: dingTalkChannelSettingsSchema,
telegram: telegramChannelSettingsSchema,
warnings: settingsWarningsSchema.optional()
})
.strict()
@@ -199,6 +231,7 @@ export const channelConnectionTestResultSchema = z
.object({
channel: managedChannelSchema,
ok: z.boolean(),
botUsername: identifierSchema.min(1).optional(),
error: z
.string()
.trim()
@@ -208,6 +241,13 @@ export const channelConnectionTestResultSchema = z
})
.strict()
.superRefine((result, context) => {
if (result.botUsername !== undefined && (!result.ok || result.channel !== 'telegram')) {
context.addIssue({
code: 'custom',
path: ['botUsername'],
message: 'Bot username requires a successful Telegram connection test'
})
}
if (result.ok && result.error !== undefined) {
context.addIssue({
code: 'custom',
+6 -1
View File
@@ -95,10 +95,12 @@ import type {
} from './magic-notes-contracts'
import type {
ChannelConnectionTestResult,
ChannelRuntimeStatusChange,
ChannelSettingsApply,
ChannelSettingsSnapshot,
CredentialChannel,
DingTalkChannelSettingsInput,
TelegramChannelSettingsInput,
WeComChannelSettingsInput
} from './channel-settings-contracts'
import type {
@@ -1906,10 +1908,13 @@ export type DesktopApi = {
}
channels?: {
getSnapshot: () => Promise<ChannelSettingsSnapshot>
onStatusChanged: (
listener: (change: ChannelRuntimeStatusChange) => void
) => () => void
apply: (input: ChannelSettingsApply) => Promise<ChannelSettingsSnapshot>
testConnection: (
channel: CredentialChannel,
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput
settings?: WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput
) => Promise<ChannelConnectionTestResult>
getWeixinBinding: () => Promise<WeixinBindingSnapshot>
startWeixinBinding: () => Promise<WeixinBindingSnapshot>
+1
View File
@@ -114,6 +114,7 @@ export const ipcChannels = {
channelSettingsGet: 'settings:channels:get',
channelSettingsApply: 'settings:channels:apply',
channelSettingsTest: 'settings:channels:test',
channelStatusChanged: 'channels:status-changed',
weixinBindingGet: 'settings:channels:weixin:binding:get',
weixinBindingStart: 'settings:channels:weixin:binding:start',
weixinBindingVerify: 'settings:channels:weixin:binding:verify',
+1 -1
View File
@@ -156,7 +156,7 @@ it('upgrades a fresh historical schema 59 through the real worker without recons
const upgrade = getPendingAssistantStorageUpgrade(databasePath)!
expect(upgrade).toEqual({ migrateNotes: false, reclaimSpace: false })
expect((await runWorker(databasePath, upgrade)).at(-1)).toEqual({ done: true })
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)
for (const [table, column] of [['projects', 'default_work_mode'], ['conversations', 'work_mode'], ['tasks', 'work_mode']] as const) {
expect(sql.prepare(`PRAGMA table_info(${table})`).all().map(row => row.name)).not.toContain(column)
}
+240
View File
@@ -0,0 +1,240 @@
import { app, BrowserWindow, net, safeStorage, session } from 'electron'
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { setTimeout as delay } from 'node:timers/promises'
import { registerIpcHandlers } from '../../src/main/ipc'
import { AssistantDatabase } from '../../src/main/assistant/assistant-database'
import { RuntimeSettingsStore } from '../../src/main/runtime-settings-store'
import { ChannelSettingsStore } from '../../src/main/channels/channel-settings-store'
import { ContextManager } from '../../src/main/context-manager'
import { createModelProfileRuntime } from '../../src/main/agent/create-runtime'
import { defaultRuntimeSettings } from '../../src/shared/contracts'
import type { ConversationSnapshot } from '../../src/shared/assistant-contracts'
const directory = process.env.GB_TELEGRAM_DIRECTORY!
assert(directory)
app.setPath('userData', join(directory, 'profile'))
app.setPath('sessionData', join(directory, 'session'))
app.commandLine.appendSwitch('disable-background-networking')
const token = '123456:telegram-smoke-not-a-real-token'
const prompt = 'Reply with the project codename Cedar.'
const answer = 'The project codename is Cedar.'
void app.whenReady().then(async () => {
const blocked: string[] = []
const local = (value: string) => new URL(value).hostname === '127.0.0.1'
session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
const forbidden = /^https?:|^wss?:/.test(details.url) && !local(details.url)
if (forbidden) blocked.push(new URL(details.url).origin)
callback({ cancel: forbidden })
})
const originalFetch = globalThis.fetch
globalThis.fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input)
if (!local(url)) { blocked.push(new URL(url).origin); throw new Error('Non-local model request blocked') }
return originalFetch(input, init)
}
const calls: { method: string; body: Record<string, unknown> }[] = []
const updates: object[] = []
let pollsAborted = 0
let pollingUnavailable = false
const originalNetFetch = net.fetch
// This is the only Telegram substitution: production driver construction and IPC stay intact.
net.fetch = async (input, init) => {
const url = new URL(input instanceof Request ? input.url : input)
if (url.origin !== 'https://api.telegram.org' || !url.pathname.startsWith(`/bot${token}/`)) {
blocked.push(url.origin)
throw new Error('Unexpected Electron net request blocked')
}
const method = url.pathname.split('/').at(-1)!
assert.equal(init?.method, 'POST')
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
calls.push({ method, body })
let result: unknown
switch (method) {
case 'getMe': result = { id: 123456, is_bot: true, username: 'goodbuddy_smoke_bot' }; break
case 'getWebhookInfo': result = { url: '' }; break
case 'getUpdates':
assert.deepEqual(body.allowed_updates, ['message'])
try { await delay(50, undefined, { signal: init?.signal ?? undefined }) }
catch (error) { pollsAborted++; throw error }
if (pollingUnavailable) {
return new Response(JSON.stringify({ ok: false, error_code: 503 }), { status: 503 })
}
result = updates.splice(0)
break
case 'sendChatAction': result = true; break
case 'sendMessage': result = { message_id: 1000 }; break
default: throw new Error(`Unexpected Telegram method: ${method}`)
}
return new Response(JSON.stringify({ ok: true, result }), { headers: { 'content-type': 'application/json' } })
}
const payloads: string[] = []
let cancelledModel = false
const model = createServer(async (request, response) => {
assert.equal(request.url, '/v1/chat/completions')
assert.equal(request.headers.authorization, undefined)
let body = ''
for await (const chunk of request) body += chunk
payloads.push(body)
response.setHeader('Content-Type', 'text/event-stream')
if (body.includes('Wait for cancellation')) {
response.write(': waiting\n\n')
response.on('close', () => { cancelledModel = true })
return
}
response.end(`data: ${JSON.stringify({ choices: [{ delta: { content: answer }, finish_reason: null }] })}\n\ndata: [DONE]\n\n`)
})
await new Promise<void>(resolve => model.listen(0, '127.0.0.1', resolve))
const address = model.address()
assert(address && typeof address !== 'string')
const cipher = { isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: (value: string) => safeStorage.encryptString(value), decrypt: (value: Buffer) => safeStorage.decryptString(value) }
assert(cipher.isAvailable(), 'Electron safeStorage must be available for the production settings save')
const settingsStore = new RuntimeSettingsStore(join(directory, 'runtime.json'), cipher, {})
const profileId = randomUUID()
await settingsStore.update({ ...defaultRuntimeSettings, apiKey: { action: 'keep' }, provider: 'model', workspacePath: directory,
deepseekHarnessModelSource: { kind: 'platform' },
modelProfiles: [{ id: profileId, name: 'Telegram local fixture', baseUrl: `http://127.0.0.1:${address.port}/v1`,
modelName: 'telegram-text-fixture', protocol: 'openai-chat-completions', authentication: 'none',
imageGenerationQuality: 'auto', apiKey: { action: 'keep' } }], defaultModelProfileId: profileId,
contextCompression: { ...defaultRuntimeSettings.contextCompression, enabled: false }
})
const settings = await settingsStore.getResolvedSettings()
const runtime = createModelProfileRuntime(directory, settings, settings.modelProfiles[0]!)
const channelsPath = join(directory, 'channels.json')
const channels = new ChannelSettingsStore(channelsPath, cipher, {})
const databasePath = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(databasePath)
database.initialize(directory)
database.ensureChannelProjects(directory)
const win = new BrowserWindow({ show: false, width: 1280, height: 900, webPreferences: {
sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false,
preload: join(directory, 'preload.cjs')
} })
// Unused capabilities/knowledge have no implementation; the text execution path uses real services.
const dispose = registerIpcHandlers(win, runtime, 'CommandOrControl+Shift+Space', settingsStore,
{} as never, new ContextManager(), {} as never, database, {} as never, async () => {},
undefined, undefined, undefined, channels,
undefined, undefined, undefined, undefined, undefined, undefined, undefined,
() => { throw new Error('This smoke must never launch a WeChat sidecar') })
const run = <T = unknown>(script: string): Promise<T> => win.webContents.executeJavaScript(script, true)
async function wait(check: () => Promise<unknown> | unknown, label: string): Promise<void> {
for (let i = 0; i < 300; i++) {
if (await check()) return
await delay(50)
}
throw new Error(`Timed out: ${label}; UI: ${await run('document.body.innerText')}`)
}
async function click(selector: string): Promise<void> {
await wait(() => run(`Boolean(document.querySelector(${JSON.stringify(selector)})) && !document.querySelector(${JSON.stringify(selector)}).disabled`), selector)
await run(`document.querySelector(${JSON.stringify(selector)}).click()`)
}
async function fill(selector: string, value: string): Promise<void> {
await run(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); Object.getOwnPropertyDescriptor(e instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, 'value').set.call(e, ${JSON.stringify(value)}); e.dispatchEvent(new Event('input', { bubbles: true })); })()`)
}
function inbound(id: number, text: string, sender = 700001) {
return { update_id: id, message: { message_id: id, date: Math.floor(Date.now() / 1000),
from: { id: sender, is_bot: false }, chat: { id: sender, type: 'private' }, text } }
}
try {
await win.loadURL(process.env.GB_TELEGRAM_RENDERER!)
await wait(() => run(`Boolean(document.querySelector('input[aria-label="Telegram Bot Token"]'))`), 'Telegram settings loaded')
await fill('input[aria-label="Telegram Bot Token"]', token)
await fill('textarea[aria-label="Telegram allowed sender IDs"]', '700001')
await click('#channel-settings-panel-telegram button.secondary-button')
await wait(() => run(`document.body.innerText.includes('@goodbuddy_smoke_bot')`), 'connection test identity shown')
assert.deepEqual(calls.map(call => call.method), ['getMe', 'getWebhookInfo'])
assert.equal((await channels.resolve('telegram')).enabled, false, 'Test must not enable receiving')
await click('#channel-settings-panel-telegram input[role="switch"]')
await click('button.primary-button')
await wait(async () => (await channels.resolve('telegram')).enabled && calls.some(call => call.method === 'getUpdates'), 'saved channel polling')
const saved = await new ChannelSettingsStore(channelsPath, cipher, {}).resolve('telegram')
assert.equal(saved.secret, token)
assert.deepEqual(saved.allowedSenderIds, ['700001'])
assert(!(await readFile(channelsPath, 'utf8')).includes(token), 'Token must not be persisted in plaintext')
const snapshot = await run<string>('window.goodbuddy.channels.getSnapshot().then(JSON.stringify)')
assert(!snapshot.includes(token), 'Snapshot must not expose the Token')
updates.push(inbound(10, 'Unauthorized request', 700002), inbound(11, prompt), inbound(11, prompt))
await wait(() => calls.some(call => call.method === 'sendMessage' && call.body.text === answer), 'model reply delivered through Telegram driver')
assert.equal(payloads.length, 1, 'Unauthorized/duplicate updates must not reach the model')
assert.equal(JSON.parse(payloads[0]!).model, 'telegram-text-fixture')
assert(payloads[0]!.includes(prompt))
const replies = calls.filter(call => call.method === 'sendMessage')
assert.equal(replies.length, 1)
assert.equal(replies[0]!.body.chat_id, '700001')
const conversations = await run<ConversationSnapshot[]>('window.goodbuddy.conversations.list()')
const conversation = conversations.find(item => item.messages.some(message => message.content === prompt))
assert(conversation, 'Channel conversation must be visible through the desktop IPC')
assert.deepEqual(conversation.messages.map(message => [message.role, message.content]), [['user', prompt], ['assistant', answer]])
assert.equal(database.getProject(conversation.projectId!).channel, 'telegram')
await wait(() => run(`!document.querySelector('button.primary-button').disabled && document.querySelector('.channel-settings-card .capability-card__header > span')?.textContent === 'Connected'`), 'saved connection visible')
await fill('input[aria-label="Telegram Bot Token"]', 'unsaved-token-placeholder')
await fill('textarea[aria-label="Telegram allowed sender IDs"]', '700001\n700003')
pollingUnavailable = true
await wait(() => run(`document.querySelector('.channel-settings-card .capability-card__header > span')?.textContent === 'Connecting' && document.querySelector('.channel-settings-card [role="alert"]')?.textContent.includes('503')`), 'poll failure pushed through production IPC')
pollingUnavailable = false
await wait(() => run(`document.querySelector('.channel-settings-card .capability-card__header > span')?.textContent === 'Connected' && !document.querySelector('.channel-settings-card [role="alert"]')`), 'reconnect pushed through production IPC')
assert.equal(await run(`document.querySelector('input[aria-label="Telegram Bot Token"]').value`), 'unsaved-token-placeholder')
assert.equal(await run(`document.querySelector('textarea[aria-label="Telegram allowed sender IDs"]').value`), '700001\n700003')
await win.reload()
await wait(() => run(`document.querySelector('input[aria-label="Telegram Bot Token"]')?.placeholder === 'Leave blank to keep the saved Token'`), 'settings restored after renderer reload')
assert.equal(await run(`document.querySelector('#channel-settings-panel-telegram input[role="switch"]').checked`), true)
const restored = await run<ConversationSnapshot>(`window.goodbuddy.conversations.get(${JSON.stringify(conversation.id)})`)
assert.deepEqual(restored.messages, conversation.messages)
const disk = new AssistantDatabase(databasePath)
try {
disk.openReadOnly()
assert.deepEqual(disk.getConversation(conversation.id).messages, conversation.messages)
}
finally { disk.close() }
updates.push(inbound(12, 'Wait for cancellation'))
await wait(() => payloads.length === 2, 'second model request running')
await click('#channel-settings-panel-telegram input[role="switch"]')
await click('button.primary-button')
await wait(() => cancelledModel, 'disable channel aborts model HTTP stream')
await wait(async () => !(await channels.resolve('telegram')).enabled, 'disabled settings persisted')
await wait(() => run(`!document.querySelector('button.primary-button').disabled`), 'disable and service shutdown completed')
const stopped = await run<{ telegram: { status: { state: string } } }>('window.goodbuddy.channels.getSnapshot()')
assert.equal(stopped.telegram.status.state, 'disabled')
assert.equal(calls.filter(call => call.method === 'sendMessage').length, 1, 'No reply after disabling')
await click('#channel-settings-panel-telegram input[role="switch"]')
await click('button.primary-button')
await wait(() => run(`!document.querySelector('button.primary-button').disabled && document.querySelector('.channel-settings-card .capability-card__header > span')?.textContent === 'Connected'`), 're-enabled before clearing credentials')
const projectBeforeClear = database.getProject(conversation.projectId!)
const historyBeforeClear = database.getConversation(conversation.id)
await click('#channel-settings-panel-telegram input[type="checkbox"]:not([role="switch"])')
await click('button.primary-button')
await wait(() => run(`!document.querySelector('button.primary-button').disabled && !document.querySelector('#channel-settings-panel-telegram input[role="switch"]').checked && !document.querySelector('#channel-settings-panel-telegram input[type="checkbox"]:not([role="switch"])') && document.querySelector('.channel-settings-card .capability-card__header > span')?.textContent === 'Not configured'`), 'cleared Token disables the enabled channel')
const cleared = await new ChannelSettingsStore(channelsPath, cipher, {}).resolve('telegram')
assert.equal(cleared.enabled, false)
assert.equal(cleared.secret, undefined)
assert.equal(cleared.source, 'none')
assert.deepEqual({ ...database.getProject(conversation.projectId!), updatedAt: projectBeforeClear.updatedAt }, projectBeforeClear)
assert.deepEqual(database.getConversation(conversation.id), historyBeforeClear)
assert.deepEqual(blocked, [], 'No unexpected external requests')
console.log(JSON.stringify({ telegramSmoke: 'passed', uiConfigSaveTest: true, productionPreloadIpc: true,
productionExecutor: true, desktopConversationPersisted: true, reloadRestored: true,
disableCancelsModel: true, liveStatusPreservesDrafts: true, clearTokenDisablesAndPreservesHistory: true,
modelCalls: payloads.length, telegramReplies: replies.length,
pollsAborted, externalNetworkCalls: 0 }))
} finally {
await dispose()
await runtime.dispose()
database.close()
win.destroy()
net.fetch = originalNetFetch
globalThis.fetch = originalFetch
model.closeAllConnections()
await new Promise<void>(resolve => model.close(() => resolve()))
}
app.exit(0)
}).catch(error => { console.error(error); app.exit(1) })
+137
View File
@@ -0,0 +1,137 @@
import { app, session } from 'electron'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { createServer as createProxy } from 'node:http'
import { createServer as createFixture } from 'node:https'
import { connect, type Socket } from 'node:net'
import { join } from 'node:path'
import { TelegramChannelDriver } from '../../src/main/channels/telegram-channel-driver'
const directory = process.env.GB_PROXY_DIRECTORY!
const trusted = process.env.GB_PROXY_TRUSTED === 'true'
assert(directory)
app.setPath('userData', join(directory, trusted ? 'trusted-profile' : 'untrusted-profile'))
app.setPath('sessionData', join(directory, trusted ? 'trusted-session' : 'untrusted-session'))
app.commandLine.appendSwitch('disable-background-networking')
app.commandLine.appendSwitch('disable-component-update')
app.commandLine.appendSwitch('host-resolver-rules', 'MAP * ~NOTFOUND, EXCLUDE 127.0.0.1')
void app.whenReady().then(async () => {
const secret = '123456:local-proxy-test-placeholder'
const calls: Array<{ method: string; body: Record<string, unknown> }> = []
const tunnels: string[] = []
const blockedConnects: string[] = []
const rejected: string[] = []
const sockets = new Set<Socket>()
let pollStarted!: () => void
const polling = new Promise<void>((resolve) => { pollStarted = resolve })
let pollClosed!: () => void
const closed = new Promise<void>((resolve) => { pollClosed = resolve })
const fixture = createFixture({
key: await readFile(join(directory, 'server.key')),
cert: await readFile(join(directory, 'server.pem'))
}, (request, response) => {
void (async () => {
assert.equal(request.method, 'POST')
assert.equal(request.headers.host, 'api.telegram.org')
assert(request.url && request.url.startsWith(`/bot${secret}/`))
const method = request.url.split('/').at(-1)!
let payload = ''
for await (const chunk of request) payload += chunk
const body = JSON.parse(payload) as Record<string, unknown>
calls.push({ method, body })
if (method === 'getUpdates') {
assert.equal(body.timeout, 30)
assert.deepEqual(body.allowed_updates, ['message'])
response.once('close', pollClosed)
pollStarted()
return // Hold the HTTPS response until driver.stop() aborts the real request.
}
const result = method === 'getMe' ? { id: 123456, is_bot: true, username: 'fixture_bot' }
: method === 'getWebhookInfo' ? { url: '' }
: method === 'sendMessage' ? { message_id: 1 } : undefined
assert(result, `Unexpected method: ${method}`)
response.setHeader('content-type', 'application/json')
response.end(JSON.stringify({ ok: true, result }))
})().catch((error: unknown) => { console.error(error); app.exit(1) })
})
const proxy = createProxy((_request, response) => { response.writeHead(405).end() })
for (const server of [fixture, proxy]) {
server.on('connection', (socket) => {
sockets.add(socket)
socket.once('close', () => sockets.delete(socket))
})
}
await new Promise<void>((resolve) => fixture.listen(0, '127.0.0.1', resolve))
const fixtureAddress = fixture.address()
assert(fixtureAddress && typeof fixtureAddress !== 'string')
proxy.on('connect', (request, downstream, head) => {
if (request.url !== 'api.telegram.org:443') {
blockedConnects.push(request.url ?? 'missing authority')
downstream.destroy()
return
}
tunnels.push(request.url)
// Never resolve or connect to the requested public hostname.
const upstream = connect(fixtureAddress.port, '127.0.0.1', () => {
downstream.write('HTTP/1.1 200 Connection Established\r\n\r\n')
if (head.length) upstream.write(head)
downstream.pipe(upstream).pipe(downstream)
})
sockets.add(upstream)
upstream.once('close', () => { sockets.delete(upstream); downstream.destroy() })
downstream.once('close', () => upstream.destroy())
upstream.on('error', () => downstream.destroy())
downstream.on('error', () => upstream.destroy())
})
await new Promise<void>((resolve) => proxy.listen(0, '127.0.0.1', resolve))
const proxyAddress = proxy.address()
assert(proxyAddress && typeof proxyAddress !== 'string')
await session.defaultSession.setProxy({ mode: 'fixed_servers', proxyRules: `http://127.0.0.1:${proxyAddress.port}` })
session.defaultSession.webRequest.onBeforeRequest((details, callback) => {
const url = new URL(details.url)
const allowed = url.origin === 'https://api.telegram.org' && url.pathname.startsWith(`/bot${secret}/`)
if (!allowed) rejected.push('unexpected Chromium request')
callback({ cancel: !allowed })
})
const statuses: string[] = []
// No injected fetch, net.fetch replacement, certificate callback or TLS bypass.
const driver = new TelegramChannelDriver({ secret, allowedSenderIds: ['7'],
onStatus: (status) => statuses.push(status.state) })
try {
assert.equal(await session.defaultSession.resolveProxy('https://api.telegram.org'), `PROXY 127.0.0.1:${proxyAddress.port}`)
if (!trusted) {
await assert.rejects(driver.testConnection(), /network request failed/)
assert(tunnels.length > 0)
assert.deepEqual(calls, [], 'Untrusted TLS must fail before an HTTPS request reaches the fixture')
assert.deepEqual(rejected, [])
console.log(JSON.stringify({ untrustedTlsRejected: true, blockedConnects }))
} else {
assert.deepEqual(await driver.testConnection(), { botId: '123456', botUsername: 'fixture_bot' })
assert.deepEqual(calls.map((call) => call.method), ['getMe', 'getWebhookInfo'])
await driver.start(() => { throw new Error('Held poll must not dispatch inbound messages') })
await polling
await driver.send({ channel: 'telegram', eventId: '1', conversationId: '123456:7',
recipientId: '7', status: 'completed', output: 'reply through local CONNECT' }, new AbortController().signal)
assert.deepEqual(calls.at(-1), { method: 'sendMessage', body: { chat_id: '7', text: 'reply through local CONNECT' } })
await driver.stop()
await closed
assert.equal(statuses.at(-1), 'stopped')
assert.deepEqual(calls.map((call) => call.method), ['getMe', 'getWebhookInfo', 'getMe', 'getWebhookInfo', 'getUpdates', 'sendMessage'])
assert(tunnels.length >= 2, 'Sending while polling requires another CONNECT tunnel')
assert.deepEqual(rejected, [])
await assert.rejects(driver.send({ channel: 'telegram', eventId: '2', conversationId: '123456:7',
recipientId: '7', status: 'completed', output: 'after stop' }, new AbortController().signal), { name: 'AbortError' })
assert.equal(calls.length, 6)
console.log(JSON.stringify({ telegramProxy: 'passed', defaultDriverFetch: true, trustedTls: true,
connectTunnels: tunnels.length, methods: calls.map((call) => call.method), pollAbortedAtServer: true,
sendAfterStopRejected: true, blockedConnects, externalNetworkCalls: 0 }))
}
} finally {
await driver.stop()
await session.defaultSession.closeAllConnections()
for (const socket of sockets) socket.destroy()
await Promise.all([fixture, proxy].map((server) => new Promise<void>((resolve) => server.close(() => resolve()))))
}
app.exit(0)
}).catch((error: unknown) => { console.error(error); app.exit(1) })
+15
View File
@@ -0,0 +1,15 @@
import { createRoot } from 'react-dom/client'
import { ChannelSettingsSection } from '../../src/renderer/src/ChannelSettingsSection'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import { changeUiLocale } from '../../src/renderer/src/i18n'
import '../../src/renderer/src/styles.css'
// All reads and actions use the unmodified production preload bridge.
await changeUiLocale('en-US')
const projects = await window.goodbuddy.projects.list()
createRoot(document.getElementById('root')!).render(
<UiLocaleProvider initialPreference="en-US">
<ChannelSettingsSection initialChannel="telegram" projectList={projects}
onUpdateProject={(id, input) => window.goodbuddy.projects.update(id, input)} />
</UiLocaleProvider>
)
+63
View File
@@ -0,0 +1,63 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { createRequire } from 'node:module'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import { createServer } from 'vite'
import react from '@vitejs/plugin-react'
import { expect, it } from 'vitest'
// Run: npx vitest run tests/telegram-channel.electron.test.ts
it('routes Telegram settings and private text through production Electron IPC, executor, model and SQLite', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-'))
const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
plugins: [react(), { name: 'telegram-smoke', configureServer(server) {
server.middlewares.use('/telegram.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html')
response.end(await server.transformIndexHtml('/telegram.html', '<html><body><div id="root"></div><script type="module" src="/tests/support/telegram-renderer.tsx"></script></body></html>'))
})
} }], server: { host: '127.0.0.1', port: 0, watch: null },
optimizeDeps: { entries: ['tests/support/telegram-renderer.tsx'] }
})
try {
const driver = join(directory, 'main.mjs')
await build({ entryPoints: ['tests/support/telegram-main.ts'], outfile: driver,
bundle: true, platform: 'node', format: 'esm', external: ['electron'], plugins: [
{ name: 'external-packages', setup(builder) {
builder.onResolve({ filter: /^[^./]/ }, async args => {
if (args.pluginData?.resolved || /^[A-Za-z]:/.test(args.path) || args.path.startsWith('node:') || args.path === 'electron') return
const result = await builder.resolve(args.path, { resolveDir: resolve('.'), kind: 'import-statement', pluginData: { resolved: true } })
return result.errors.length ? { errors: result.errors } : { path: result.external ? result.path : pathToFileURL(result.path).href, external: true }
})
} }
] })
await build({ entryPoints: ['src/preload/index.ts'], outfile: join(directory, 'preload.cjs'), bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
await server.listen()
const bootstrap = join(directory, 'bootstrap.cjs')
await writeFile(bootstrap, `import(${JSON.stringify(pathToFileURL(driver).href)}).catch(error => { console.error(error); require('electron').app.exit(1) })`)
const env: NodeJS.ProcessEnv = { ...process.env, GB_TELEGRAM_DIRECTORY: directory,
GB_TELEGRAM_RENDERER: server.resolvedUrls!.local[0] + 'telegram.html' }
delete env.ELECTRON_RUN_AS_NODE
// No inherited channel/provider credentials or proxy configuration in this process.
for (const key of Object.keys(env)) {
if (/TOKEN|SECRET|API_KEY|PROXY|^GOODBUDDY_|^TELEGRAM_|^WECOM_|^DINGTALK_|^WEIXIN_/i.test(key)) delete env[key]
}
const child = spawn(createRequire(import.meta.url)('electron'), [bootstrap], { env, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timer = setTimeout(() => child.kill(), 90000)
try {
const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
expect(code, output).toBe(0)
expect(output).toContain('"telegramSmoke":"passed"')
process.stdout.write(output)
} finally { clearTimeout(timer) }
} finally {
await server.close()
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
}
}, 120000)
+79
View File
@@ -0,0 +1,79 @@
// @vitest-environment node
import { execFile, spawn } from 'node:child_process'
import { X509Certificate } from 'node:crypto'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { build } from 'esbuild'
import { expect, it } from 'vitest'
const exec = promisify(execFile)
// Windows uses its real current-user trust store; other platforms need their own trust setup.
// Requires OpenSSL on PATH and Windows consent to temporarily trust the generated CA.
// Opt in with GB_TELEGRAM_PROXY_TLS_TEST=1, then run: npx vitest run tests/telegram-proxy.electron.test.ts
it.skipIf(process.platform !== 'win32' || process.env.GB_TELEGRAM_PROXY_TLS_TEST !== '1')('uses real Electron TLS and CONNECT for Telegram validation, polling, replies and stop', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-proxy-'))
let thumbprint: string | undefined
try {
const root = join(directory, 'root.pem')
const rootKey = join(directory, 'root.key')
await exec('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1',
'-subj', '/CN=GoodBuddy temporary Telegram proxy test CA',
'-addext', 'basicConstraints=critical,CA:TRUE', '-addext', 'keyUsage=critical,keyCertSign,cRLSign',
'-keyout', rootKey, '-out', root])
const key = join(directory, 'server.key')
const csr = join(directory, 'server.csr')
const cert = join(directory, 'server.pem')
const extensions = join(directory, 'server.ext')
await writeFile(extensions, 'subjectAltName=DNS:api.telegram.org\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n')
await exec('openssl', ['req', '-new', '-newkey', 'rsa:2048', '-nodes',
'-subj', '/CN=api.telegram.org', '-keyout', key, '-out', csr])
await exec('openssl', ['x509', '-req', '-in', csr, '-CA', root, '-CAkey', rootKey,
'-CAcreateserial', '-days', '1', '-extfile', extensions, '-out', cert])
const host = join(directory, 'main.cjs')
await build({ entryPoints: ['tests/support/telegram-proxy-main.ts'], outfile: host,
bundle: true, platform: 'node', format: 'cjs', external: ['electron'] })
const electron = createRequire(import.meta.url)('electron') as string
const env: NodeJS.ProcessEnv = {}
for (const name of ['PATH', 'SystemRoot', 'WINDIR', 'TEMP', 'TMP', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA']) {
if (process.env[name]) env[name] = process.env[name]
}
async function run(trusted: boolean): Promise<void> {
const child = spawn(electron, [host], { env: { ...env,
GB_PROXY_DIRECTORY: directory, GB_PROXY_TRUSTED: String(trusted)
}, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', (data) => { output += data })
child.stderr.on('data', (data) => { output += data })
const timer = setTimeout(() => child.kill(), 25_000)
try {
const code = await new Promise<number | null>((resolve, reject) => {
child.once('exit', resolve)
child.once('error', reject)
})
expect(code, output).toBe(0)
expect(output).toContain(trusted ? '"telegramProxy":"passed"' : '"untrustedTlsRejected":true')
process.stdout.write(output)
} finally {
clearTimeout(timer)
}
}
await run(false)
thumbprint = new X509Certificate(await readFile(root)).fingerprint.replaceAll(':', '')
try {
await exec('certutil', ['-user', '-addstore', 'Root', root], { timeout: 15_000 })
} catch (error) {
throw new Error('Temporary Windows CA import failed or timed out waiting for trust consent. TLS verification was not bypassed.', { cause: error })
}
await run(true)
} finally {
try {
if (thumbprint) await exec('certutil', ['-user', '-delstore', 'Root', thumbprint], { timeout: 10_000 })
} finally {
await rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 })
}
}
}, 75_000)