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 | 𝕏
There was a problem hiding this comment.
ℹ️ No critical issues — the first-class agent surface is coherent and the new security boundaries (path-fenced outbound attachments, callback authorization, per-turn workspace snapshotting) hold up. Two suggestions inline, one coverage note below.
Reviewed changes — one commit since the prior pullfrog review (519723b):
- Added the operator command palette and inline-keyboard menus —
/model,/thinking,/queue,/workspace,/settings,/compact,/abort,/next, plus a callback router covering every mutation. - Added inbound media normalization — photos, text documents, voice transcription, replies, forwards, and media-group coalescing into Aiden attachments.
- Added assistant-authored outbound actions —
telegram_button/telegram_attachdirectives with path-fenced file delivery and a 24h-expiry button store. - Added live drafts and technical activity projection — throttled Markdown drafts plus reasoning/tool activity gated by verbosity.
- Added manual session compaction and per-turn thinking-level +
interactionSurfaceplumbing into the system prompt.
ℹ️ Untested high-risk callback surface
handleCallback (main/services/telegram/telegram-service-core.ts:476) is the largest new state machine — it mutates settings, reorders and clears the queue, compacts sessions, and aborts turns — yet none of its branches are exercised. test:telegram covers the pure menu renderers, inbound/outbound normalization, and the pre-existing /start//workspace//status paths, but telegram-activity.ts (draft/thinking/tool projection, throttling, settle ordering) and telegram-session.ts (manual compaction) have no test files at all, and the callback router has no coverage.
Technical details
# Cover the callback router, activity projector, and compaction
## Affected sites
- main/services/telegram/telegram-service-core.ts:476 — handleCallback branches (model:set, thinking:set, workspace:set, queue:delete/priority/clear, compact:yes, turn:abort/next/stop) have no tests
- main/services/telegram/telegram-activity.ts — draft preview editing, reasoning throttle, settle ordering untested
- main/services/telegram/telegram-session.ts — compactTelegramSession untested
## Required outcome
- Add focused tests for the callback mutations that change state and for the activity projector's draft-edit and verbosity gating.
## Open questions for the human
- Is telegram-activity.ts meant to be covered end-to-end through the service-core harness, or does it warrant its own unit test file?DeepSeek Pro | 𝕏
| const delta = (payload as { delta?: string })?.delta; | ||
| if (!delta) return; | ||
| reasoning += delta; | ||
| enqueue(updateThinking); |
There was a problem hiding this comment.
updateThinking is enqueued on every chat:reasoning-delta with no throttle, unlike updateDraft which is gated to DRAFT_INTERVAL_MS. In thinking/verbose mode a long reasoning pass issues one editMessageText per token, and settle() awaits the entire queue before deliverReply runs, so the final answer is delayed by hundreds of sequential API calls (and can hit Telegram rate limits, after which each update is silently dropped by the .catch(() => undefined) chain).
Technical details
# Throttle reasoning-delta projection
## Affected sites
- main/services/telegram/telegram-activity.ts:92 — unthrottled enqueue(updateThinking) per reasoning delta
## Required outcome
- Bound the editMessageText rate for reasoning, mirroring the DRAFT_INTERVAL_MS gate on updateDraft (e.g. throttle or accumulate-then-flush).| } | ||
|
|
||
| function truncate(value: string, limit: number): string { | ||
| return value.length <= limit ? value : `${value.slice(0, Math.max(0, limit - 1))}…`; |
There was a problem hiding this comment.
value.slice(0, limit - 1) can split a surrogate pair (an emoji in a model or workspace name), producing a lone surrogate in a button label. The repo already ships boundedUnicodePrefix in renderer/shared/unicode-prefix.ts for this case — use it instead.
There was a problem hiding this comment.
ℹ️ No critical issues — the parity tranche is coherent and the new transport, ownership-lease, and thread-routing layers hold up. Three minor suggestions inline.
Reviewed changes — four commits since the prior pullfrog review (1e726c1) complete the Telegram parity tranche:
- Named bot profiles — per-profile token, offset, owner, and thread routing via
telegram-profile-config.tsandcreateTelegramProfileManager, with profile-scoped runtime and thread files. - Transport ownership lease —
telegram-ownership.tsadds a durable per-profile lease with heartbeat and stale-owner recovery. - Private-chat Threaded Mode —
createForumTopic/deleteForumTopicprovisioning and durable thread-to-workspace routing (telegram-thread-store.ts) with BotFather capability detection. - Native Rich Markdown delivery —
sendRichMessage/sendRichMessageDraftwith a block-aware rich chunker (chunkRichMarkdown,safeRichDraftPrefix) and a 400-triggered HTML fallback. - Inbound file inbox — arbitrary files persisted to a private inbox with local paths exposed to the turn (
localFiles), plushasVoiceInput. - Outbound voice policy —
hidden/mirror/alwaysmodes, atelegram_voicedirective, and OGG/Opus result validation. - Reaction shortcuts —
message_reactionhandling (promote/remove queued prompts) with variation-selector normalization. - Extension registry + direct-delivery tools — bounded
registerTelegramExtension, and target-awaretelegram_help/telegram_message/telegram_attach/telegram_voicegated byallowTelegramDirect. - Settings + diagnostics — profiles, rendering, voice, threads, and redacted
recentDiagnosticssurfaced in Settings;test:telegramextended with six new test files and broad service-core coverage.
DeepSeek Pro | 𝕏
| }, | ||
| async sendDirectAttachment(input: { path: string; caption?: string; thread?: string | number }) { | ||
| const target = await resolveDirectTarget(input.thread); | ||
| const file = await readWorkspaceAttachment(target.workspaceId, input.path); |
There was a problem hiding this comment.
telegram_attach reads from the destination target's workspace, not the caller's. sendDirectAttachment resolves the target and fences the path read to target.workspaceId, so an agent scoped to workspace A can attach and send files from any other configured workspace B by targeting B's thread (or the profile DM). These tools are also exposed to attended desktop agents (allowTelegramDirect), so this widens the agent's file-read scope beyond the workspace the current chat authorized.
Technical details
# Confirm the intended workspace fence for direct file delivery
## Affected sites
- main/services/telegram/telegram-service.ts:523 — `readWorkspaceAttachment(target.workspaceId, input.path)` reads from the destination workspace, not the caller's
- main/services/telegram/telegram-agent-tools.ts — `telegram_attach` exposes a free-form `path` + `profile`/`thread`
- main/services/tools.ts — `buildTelegramAgentTools()` is registered for `allowTelegramDirect === true` (attended desktop agents)
- main/services/llm-client.ts — `allowTelegramDirect: !assistantMode || attendedAssistant || interactionSurface === "telegram"`
## Required outcome
- Decide whether a desktop agent scoped to one workspace should be able to read files from another workspace via `telegram_attach`. If not, fence the read to the caller's workspace context (or require the target's workspace to match the caller's authorized workspace).
## Open questions for the human
- Is cross-workspace file delivery an intended capability (the tool description says "inside the destination target's authorized Aiden workspace"), or should the source workspace gate the read?| const result = await deps.compactChat( | ||
| currentSessionChatId(callback.from.id, threadWorkspaceId ?? settings.telegramWorkspaceId), | ||
| ); | ||
| await deps.api.sendMessage({ |
There was a problem hiding this comment.
The compaction result is sent without threadId. In Threaded Mode this lands in the General topic rather than the thread the owner issued /compact from; every other reply path in this delta threads message_thread_id.
| .filter((emoji) => !reaction.old_reaction.some( | ||
| (candidate) => candidate.emoji.replace(/[\uFE0E\uFE0F]/gu, "") === emoji, | ||
| )); | ||
| const item = queue.findBySource(reaction.chat.id, reaction.message_id); |
There was a problem hiding this comment.
findBySource(reaction.chat.id, reaction.message_id) with no threadId won't match a queued turn created in a thread (its threadId is set), and message_reaction updates don't carry message_thread_id. In Threaded Mode the 👍/👎 shortcuts silently no-op, and the ack messages below are sent without threadId too, landing in General. Matching by chatId + message_id alone would fix it.

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.