Skip to content

feat: add Telegram remote control - #23

Open
sambitcreate wants to merge 20 commits into
mainfrom
feature/text-control
Open

feat: add Telegram remote control#23
sambitcreate wants to merge 20 commits into
mainfrom
feature/text-control

Conversation

@sambitcreate

@sambitcreate sambitcreate commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add owner-paired Telegram long-polling remote control with queued, Markdown-formatted replies.
  • Add Settings, IPC, encrypted token storage, provider/model selection, optional selected-folder workspace automation, and onboarding coverage.
  • Snapshot selected workspace authority when each prompt is accepted; strict /workspace name selection preserves internal whitespace and rejects case mismatches.

Verification

  • npm run type-check
  • npm run test:telegram — 67 passing
  • npm run lint
  • npm run build

Known repository check

  • npm test currently stops in pretest because the unrelated main/services/terminal.ts coverage gate reports 79.83% branches against an 80% threshold. This branch does not change that file.

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 '&gt; ' 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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (TelegramBotApi with getUpdates, 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 injectionsendTelegramTurn follows the exact beginChatTurn → appendMessage → start → terminal → release pattern from schedule-execution.ts, with permission: "full" and allowComputerUse: false.
  • Workspace authoritytelegramWorkspaceId snapshotted at enqueue; project turns use assistant-automation with workspace-isolated chat; stale selections fail before generation; /workspace preserves 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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Pro𝕏

Comment thread main/handlers/index.ts
Comment thread main/handlers/index.ts

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (322511a7d90d2d):

  • Added null-safety terminal testflushHistory() is now tested as a no-op before a history store is installed.
  • Added Telegram AppSettings fieldstelegramEnabled, telegramAllowedUserId, telegramProviderId, telegramModel, telegramWorkspaceId added to AppSettings.
  • Added "telegram" usage source — New entry in UsageRequestSource union type and REQUEST_SOURCES set.
  • Wired test:telegram into npm test — Telegram tests now execute via npm test (via the test script, not pretest or pretest:coverage).

Pullfrog  | Fix it ➔View workflow run | Using DeepSeek Pro𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ 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 to main/handlers/index.ts, preserving both Telegram and subagent handler registrations.
  • Added IPC bootstrap regression testmain/handlers/ipc-contract.test.ts now verifies that both dedicated handler registration surfaces are imported and invoked from the bootstrap, preventing accidental removal in future refactors.

Pullfrog  | View workflow run | Using DeepSeek Pro𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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 actionstelegram_button / telegram_attach directives 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 + interactionSurface plumbing 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?

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Pro𝕏

const delta = (payload as { delta?: string })?.delta;
if (!delta) return;
reasoning += delta;
enqueue(updateThinking);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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))}…`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ 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.ts and createTelegramProfileManager, with profile-scoped runtime and thread files.
  • Transport ownership leasetelegram-ownership.ts adds a durable per-profile lease with heartbeat and stale-owner recovery.
  • Private-chat Threaded ModecreateForumTopic/deleteForumTopic provisioning and durable thread-to-workspace routing (telegram-thread-store.ts) with BotFather capability detection.
  • Native Rich Markdown deliverysendRichMessage/sendRichMessageDraft with 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), plus hasVoiceInput.
  • Outbound voice policyhidden/mirror/always modes, a telegram_voice directive, and OGG/Opus result validation.
  • Reaction shortcutsmessage_reaction handling (promote/remove queued prompts) with variation-selector normalization.
  • Extension registry + direct-delivery tools — bounded registerTelegramExtension, and target-aware telegram_help/telegram_message/telegram_attach/telegram_voice gated by allowTelegramDirect.
  • Settings + diagnostics — profiles, rendering, voice, threads, and redacted recentDiagnostics surfaced in Settings; test:telegram extended with six new test files and broad service-core coverage.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using 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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant