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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
@@ -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) })
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user