feat: add Telegram remote control - #23
Conversation
Vendor pi-telegram's TypeScript Telegram adapter into Aiden as an always-on main-process service, backed by the existing headless-turn seam in schedule-execution.ts. Full-unattended single-owner model with one persistent chat; MIT-sourced, ported with attribution. Adds the plan doc and a row in the plans index.
Implement the Telegram Remote Control plan (Phases 0–5). A paired Telegram owner can send messages to an Aiden bot and receive headless, full-authority responses — the same trust boundary as scheduled tasks. What ships: - main/services/telegram/: Bot API client (long-polling transport), Markdown → HTML converter with 4096-char chunking, control/priority/default queue with dispatch gates, headless turn injection shim (mirrors schedule-execution.ts createBackgroundOwner + llmClient.start), config store (lastUpdateId via DataStore), and service core singleton with start/stop/stopAndSettle lifecycle. - main/handlers/telegram.ts: telegram:get/setKey/setEnabled/connect/ disconnect/resetPairing IPC handlers. - main/index.ts: service started in whenReady, stopped in cleanupApplication and shutdownAndQuit. - renderer/components/settings/telegram-settings.tsx: enable toggle, bot token input (safeStorage/Keychain), connection status, pairing controls, security notice. - AppSettings: telegramEnabled, telegramAllowedUserId. - UsageRequestSource: telegram. - MIT attribution to pi-telegram / badlogic/pi-telegram. - 50 tests (bot API 10, turn injection 9, queue 10, markdown 10, service core 11), all passing. Phase 6 (onboarding bento tile) deferred pending a 1024×1024 PNG asset. Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT).
…r, link escape Post-review corrections from GPT-5.6 correctness audit: MUST-FIX: - Fix persisted polling offset off-by-one: persist update_id+1 (the resume offset) instead of raw update_id. Prevents duplicate processing on restart. - Fix /stop corrupting dispatch state: stop() no longer resets activeTurn mid-turn (the dispatchTurn finally block owns it). /stop now clears the queue and honestly reports that the in-flight turn continues. SHOULD-FIX: - Fix typing indicator never firing: set activeTurn=true BEFORE calling sendTypingIndicator so the loop condition evaluates correctly. - Fix settleAsyncWork not in a finally: wrap appendMessage in try/finally matching the proven schedule-execution.ts pattern. - Fix offset persisted on handleUpdate failure: only persist after success. - Fix link URL attribute injection: escape quotes in href values. - Fix chunker splitting inside <pre> tags: balancePreTags closes/reopens <pre> at chunk boundaries. - Fix getStatus() hardcoded values: remove dead enabled/hasToken/allowedUserId fields; derive status from actual started/lastError state. - Fix polling indicator lying on error: status reflects actual running state. NIT: - Remove dead '> ' blockquote branch (escaping happens later). - Fix fence-close heuristic: closing fence must be >= opening fence length. - Add private-chat guard: ignore group/supergroup messages. - Move answerCallbackQuery after the authorization gate. Test updates: mock sleep yields to event loop (setImmediate) to prevent typing-loop microtask starvation; stop() test asserts activeTurn stays true while a turn is in flight.
Separate the Telegram chat ID (for API calls) from the owner's user ID (for the persistent Aiden chat key). For private chats they're equal, but the explicit fields prevent a subtle bug if group support is ever added.
Settings UI now shows a provider/model Select populated from usable providers. The choice persists to AppSettings (telegramProviderId / telegramModel) and is preferred by resolveProvider() over the global lastProviderId/lastModel fallback. Fixes the 'configure in Aiden' turn failure: the provider fingerprint assertion (assertScheduledProviderFingerprint) rejected Telegram turns because no providerFingerprint was passed. sendTelegramTurn now computes it on the fly via scheduledProviderFingerprint(provider) and passes it through to llmClient.start options. resolveProvider() now returns the full StoredProvider so the fingerprint can be computed. Settings UI surfaces a clear empty state guiding users to Settings → Providers when none are configured.
There was a problem hiding this comment.
Caution
The registerSubagentHandlers import and function call were replaced by Telegram's equivalents in main/handlers/index.ts. This completely removes subagent IPC handler registration — all subagent functionality will break at runtime.
Reviewed changes — Telegram remote control: Bot API client, owner pairing, lane-based queue with dispatch gates, Markdown-to-HTML delivery, headless turn injection (matching the schedule-execution.ts pattern), encrypted token storage, provider/model picker, optional folder-workspace authority, onboarding bento tile, and 67 passing tests across 7 test files.
- Telegram bot API — injectable transport (
TelegramBotApiwithgetUpdates,sendMessage,sendChatAction), production fetch transport, abort-signal support. - Queue and dispatch — control / priority / default lanes; control bypasses gates; priority + default require idle + no pending dispatch.
- Turn injection —
sendTelegramTurnfollows the exactbeginChatTurn → appendMessage → start → terminal → releasepattern fromschedule-execution.ts, withpermission: "full"andallowComputerUse: false. - Workspace authority —
telegramWorkspaceIdsnapshotted at enqueue; project turns useassistant-automationwith workspace-isolated chat; stale selections fail before generation;/workspacepreserves internal whitespace and rejects case mismatches. - Settings UI — Token input (password field, safeStorage-backed), enable toggle, provider/model picker, workspace selector (with unavailable selection handling), connect/disconnect, security notice.
- Onboarding — bento tile in the feature gallery, 1024×1024 transparent PNG, asset contract test updated.
⚠️ Unwired test script
test:telegram is defined in package.json but not referenced in pretest or pretest:coverage. Per AGENTS.md and the project's learnings, new test scripts must be wired into the standard test flow. The 67 Telegram tests are not exercised by npm test or npm run test:coverage.
Technical details
# Wire test:telegram into pretest
## Affected sites
- `package.json:75` — `test:telegram` is defined but never invoked by `pretest` or `pretest:coverage`.
## Required outcome
- Add `&& npm run test:telegram` to both `pretest` and `pretest:coverage` so `npm test` and CI exercise all Telegram tests.DeepSeek Pro | 𝕏
There was a problem hiding this comment.
Caution
The critical issue from the prior review remains unaddressed: registerSubagentHandlers is still removed from main/handlers/index.ts, which will break all subagent functionality at runtime.
Reviewed changes — New commits since prior pullfrog review (322511a → 7d90d2d):
- Added null-safety terminal test —
flushHistory()is now tested as a no-op before a history store is installed. - Added Telegram AppSettings fields —
telegramEnabled,telegramAllowedUserId,telegramProviderId,telegramModel,telegramWorkspaceIdadded toAppSettings. - Added
"telegram"usage source — New entry inUsageRequestSourceunion type andREQUEST_SOURCESset. - Wired
test:telegramintonpm test— Telegram tests now execute vianpm test(via thetestscript, notpretestorpretest:coverage).
DeepSeek Pro | 𝕏
There was a problem hiding this comment.
✅ No new issues found. Prior critical issue (missing
registerSubagentHandlers) is now fixed with a regression test.
Reviewed changes — one commit since prior pullfrog review (7d90d2d):
- Restored
registerSubagentHandlers— import and call re-added tomain/handlers/index.ts, preserving both Telegram and subagent handler registrations. - Added IPC bootstrap regression test —
main/handlers/ipc-contract.test.tsnow verifies that both dedicated handler registration surfaces are imported and invoked from the bootstrap, preventing accidental removal in future refactors.
DeepSeek Pro | 𝕏

Summary
/workspacename selection preserves internal whitespace and rejects case mismatches.Verification
npm run type-checknpm run test:telegram— 67 passingnpm run lintnpm run buildKnown repository check
npm testcurrently stops in pretest because the unrelatedmain/services/terminal.tscoverage gate reports 79.83% branches against an 80% threshold. This branch does not change that file.