Files
goodbuddy/src/main/channels/telegram-delivery.test.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

156 lines
8.1 KiB
TypeScript

// @vitest-environment node
import { createServer } from 'node:http'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { performance } from 'node:perf_hooks'
import { describe, expect, it, vi } from 'vitest'
import { AssistantDatabase } from '../assistant/assistant-database'
import { ChannelManager } from './channel-manager'
import { ChannelSettingsStore } from './channel-settings-store'
import { SqliteChannelOutbox } from './sqlite-channel-state'
import { TelegramChannelDriver } from './telegram-channel-driver'
import type { ChannelRuntimeStatusChange } from '../../shared/channel-settings-contracts'
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-delivery-'))
const database = new AssistantDatabase(':memory:')
database.initialize(root)
const outbox = new SqliteChannelOutbox(database)
const settings = new ChannelSettingsStore(join(root, 'channels.json'), {
isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString()
}, {})
const secret = 'synthetic-telegram-delivery-token'
await settings.apply({ telegram: { enabled: true, secret: { action: 'replace', value: secret },
allowedSenderIds: ['7'], allowGroupMessages: false } })
const state = { webhook: '', pollingCode: 0, polls: 0,
updates: [] as object[], sends: [] as { text: string; time: number }[],
sendErrors: [] as { code: number; retryAfter?: number }[] }
const server = createServer(async (request, response) => {
let body = ''
for await (const chunk of request) body += String(chunk)
const method = request.url!.slice(1)
let result: unknown
let failure: { code: number; retryAfter?: number } | undefined
if (method === 'getMe') result = { id: 42, is_bot: true }
else if (method === 'getWebhookInfo') result = { url: state.webhook }
else if (method === 'getUpdates') {
state.polls++
if (state.pollingCode) failure = { code: state.pollingCode }
else result = state.updates.splice(0)
} else if (method === 'sendMessage') {
state.sends.push({ text: JSON.parse(body).text, time: performance.now() })
failure = state.sendErrors.shift()
result = { message_id: state.sends.length }
} else if (method === 'sendChatAction') result = true
else throw new Error('Unexpected fixture method')
response.setHeader('content-type', 'application/json')
response.statusCode = failure?.code ?? 200
response.end(JSON.stringify(failure
? { ok: false, error_code: failure.code, parameters: { retry_after: failure.retryAfter },
description: `token=${secret} https://api.telegram.org/bot${secret}/sendMessage` }
: { ok: true, result }))
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as { port: number }
const execute = vi.fn(async () => ({ status: 'completed', output: 'synthetic result' }))
const changes: ChannelRuntimeStatusChange[] = []
const manager = new ChannelManager(settings, execute, {
outbox, onStatusChanged: change => changes.push(change),
createDriver: (resolved, onStatus) => new TelegramChannelDriver({
secret: resolved.channel === 'telegram' ? resolved.secret! : '',
allowedSenderIds: resolved.allowedSenderIds, onStatus,
fetch: (input, init) => fetch(`http://127.0.0.1:${address.port}/${String(input).split('/').at(-1)}`, init)
})
})
return { state, outbox, manager, execute, changes, secret,
enqueue: (eventId: string, bot = '42') => outbox.enqueue({ channel: 'telegram', eventId,
conversationId: `${bot}:7`, recipientId: '7', status: 'completed', output: eventId }),
inbound: (id: number) => state.updates.push({ update_id: id, message: { message_id: id,
date: 1234, from: { id: 7, is_bot: false }, chat: { id: 7, type: 'private' }, text: 'request' } }),
async close() {
await manager.stopAll()
server.closeAllConnections()
await new Promise<void>(resolve => server.close(() => resolve()))
database.close()
await rm(root, { recursive: true, force: true })
}
}
}
describe('Telegram delivery through loopback HTTP and SQLite', () => {
it('delivers identity-matched pending replies during a startup webhook conflict without sending another Bot results', async () => {
const f = await fixture()
try {
f.state.webhook = 'https://example.invalid/webhook'
f.enqueue('same-bot')
f.enqueue('other-bot', '41')
await f.manager.initialize()
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toMatchObject({
state: 'error', lastError: expect.stringContaining('polling conflict')
}))
await vi.waitFor(async () => expect((await f.outbox.listUndelivered()).every(entry => entry.state === 'terminal')).toBe(true))
expect(f.state.sends.map(send => send.text)).toEqual(['same-bot'])
expect((await f.outbox.listUndelivered()).map(entry => [entry.message.eventId, entry.state])).toEqual([['other-bot', 'terminal']])
expect(f.state.polls).toBe(0)
f.state.webhook = ''
await f.manager.reload('telegram')
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status.state).toBe('running'))
expect(f.state.sends.map(send => send.text)).toEqual(['same-bot'])
expect(f.execute).not.toHaveBeenCalled()
} finally { await f.close() }
})
it('surfaces sanitized sending failures without changing polling state or clearing them on healthy polls', async () => {
const f = await fixture()
try {
f.state.sendErrors.push({ code: 403 })
f.inbound(1)
await f.manager.initialize()
await vi.waitFor(async () => expect((await f.outbox.listUndelivered())[0]).toMatchObject({ state: 'terminal', attempts: 1 }))
await vi.waitFor(() => expect(f.state.polls).toBeGreaterThanOrEqual(2))
expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running',
lastError: expect.stringMatching(/sending failed.*forbidden/i) })
expect(JSON.stringify(f.changes)).not.toContain(f.secret)
expect(JSON.stringify(f.changes)).not.toContain('api.telegram.org')
f.inbound(2)
await vi.waitFor(() => expect(f.state.sends).toHaveLength(2))
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running' }))
expect(f.execute).toHaveBeenCalledTimes(2)
} finally { await f.close() }
})
it('honors the latest retry_after when an exhausted driver attempt passes to the outbox', async () => {
const f = await fixture()
try {
f.state.sendErrors.push({ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 1 },
{ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 3 })
f.inbound(1)
await f.manager.initialize()
await vi.waitFor(() => expect(f.state.sends).toHaveLength(5), { timeout: 10_000 })
expect(f.state.sends[4]!.time - f.state.sends[3]!.time).toBeGreaterThanOrEqual(2995)
await vi.waitFor(async () => expect(await f.outbox.listUndelivered()).toEqual([]))
expect(f.state.sends.every(send => send.text === 'synthetic result')).toBe(true)
expect(f.execute).toHaveBeenCalledOnce()
} finally { await f.close() }
}, 15_000)
it('cancels the final rate-limit wait without consuming or duplicating the pending reply', async () => {
const f = await fixture()
try {
f.state.sendErrors.push({ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 1 },
{ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 86400 })
f.enqueue('cancelled-wait')
await f.manager.initialize()
await vi.waitFor(() => expect(f.state.sends).toHaveLength(4), { timeout: 5000 })
await f.manager.stopAll()
expect((await f.outbox.listUndelivered())[0]).toMatchObject({ state: 'pending', attempts: 0 })
expect(f.state.sends).toHaveLength(4)
await f.manager.initialize()
await vi.waitFor(async () => expect(await f.outbox.listUndelivered()).toEqual([]))
expect(f.state.sends).toHaveLength(5)
expect(f.execute).not.toHaveBeenCalled()
} finally { await f.close() }
}, 10_000)
})