diff --git a/.gitignore b/.gitignore index a3495c9..ff13ae2 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ tmp/* # Local agent troubleshooting notes .papercuts/ + +# Local git worktrees +.worktrees/ diff --git a/docs/plans/README.md b/docs/plans/README.md index 2f7812c..e082044 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -19,14 +19,15 @@ This directory is the source of truth for Aiden's implementation plans. The engi ## Completed -| Plan | Status | Completion note | -| ---------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| [Keyboard Command System](completed/keyboard-command-system-plan.md) | Complete | One command catalog now powers transactional global hotkeys, scoped app shortcuts, native menus, canonical settings, and the `Command-K` palette. | -| [Slash Commands and Skill Invocation](completed/slash-commands-and-skill-invocation-plan.md) | Complete | Separate `/` command and `$` skill palettes dispatch canonical app actions and fresh workspace-bound one-turn skills with safe provenance. | -| [Aiden-Native Subagents](completed/aiden-native-subagents-plan.md) | Complete | All five phases passed focused/package gates, two final fresh reviews, and the default 100-cycle packaged lifecycle soak. | -| [Development and Production Coexistence](completed/development-production-coexistence-plan.md) | Complete | Development now has a visibly distinct app identity, isolated state roots, opt-in global shortcuts, and production-only updates. | -| [Gemini Native Upgrade](completed/gemini-native-upgrade-plan.md) | Complete | Its funded delivery phases shipped; deliberately deferred Gemini tracks remain future work. | -| [Scheduled Tasks](completed/scheduled-tasks-plan.md) | Complete | Implemented through the plan's original Phase 4 scope. | -| [Pi-native Compaction](completed/pi-native-compaction-plan.md) | Complete | Pi `0.80.10` session checkpoints, retained-tail reconstruction, overflow recovery, private journals, child parity, and activity milestones ship. | +| Plan | Status | Completion note | +| ---------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [Keyboard Command System](completed/keyboard-command-system-plan.md) | Complete | One command catalog now powers transactional global hotkeys, scoped app shortcuts, native menus, canonical settings, and the `Command-K` palette. | +| [Slash Commands and Skill Invocation](completed/slash-commands-and-skill-invocation-plan.md) | Complete | Separate `/` command and `$` skill palettes dispatch canonical app actions and fresh workspace-bound one-turn skills with safe provenance. | +| [Aiden-Native Subagents](completed/aiden-native-subagents-plan.md) | Complete | All five phases passed focused/package gates, two final fresh reviews, and the default 100-cycle packaged lifecycle soak. | +| [Development and Production Coexistence](completed/development-production-coexistence-plan.md) | Complete | Development now has a visibly distinct app identity, isolated state roots, opt-in global shortcuts, and production-only updates. | +| [Gemini Native Upgrade](completed/gemini-native-upgrade-plan.md) | Complete | Its funded delivery phases shipped; deliberately deferred Gemini tracks remain future work. | +| [Scheduled Tasks](completed/scheduled-tasks-plan.md) | Complete | Implemented through the plan's original Phase 4 scope. | +| [Pi-native Compaction](completed/pi-native-compaction-plan.md) | Complete | Pi `0.80.10` session checkpoints, retained-tail reconstruction, overflow recovery, private journals, child parity, and activity milestones ship. | +| [Telegram Remote Control](completed/telegram-remote-control-plan.md) | Complete | Long-polling remote control ships with owner pairing, queueing, Markdown delivery, explicit optional folder-workspace authority via Settings or `/workspace`, isolated backing chats, Settings UI, focused tests, and onboarding coverage. | Move a plan to `completed/` only when its original delivery scope is complete. Keep the original plan as historical documentation; follow-on work belongs in a new active plan. diff --git a/docs/plans/completed/telegram-remote-control-plan.md b/docs/plans/completed/telegram-remote-control-plan.md new file mode 100644 index 0000000..307bcfa --- /dev/null +++ b/docs/plans/completed/telegram-remote-control-plan.md @@ -0,0 +1,257 @@ +# Telegram Remote Control Plan + +Status: implemented on 2026-08-11; Phases 0–6 complete +Date: 2026-08-11 +UI reference: ChatGPT/Codex settings surfaces and Aiden's existing settings tokens; the Telegram operator UI is provided by the ported `pi-telegram` adapter (menus rendered as Telegram inline keyboards, not Aiden UI). +License basis: `pi-telegram` is MIT-licensed (a fork of `badlogic/pi-telegram`). Vendor with attribution. + +Source basis: the `pi-telegram` TypeScript adapter (`https://github.com/llblab/pi-telegram`, MIT) built as an extension for the Earendil Works Pi coding agent (`@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`); the Hermes Agent messaging gateway (`/Users/sambitbiswas/projects/opp/hermes-agent`: `gateway/platforms/`, `gateway/run.py`, `tools/send_message_tool.py`) as a second reference for behavior; and current Aiden source — in particular the already-shipped headless-turn precedent in `main/services/schedule-execution.ts`. + +## Verdict + +`pi-telegram` is an excellent fit and we **port** it into Aiden; we do **not** install it. It is an extension for the Pi coding agent, and Aiden does not expose Pi's extension loader (repo grep for `pi-telegram`, `.pi/agent`, `pi install`, `agent_settled` returns zero) — even though Aiden's agent core is itself the Pi runtime (it embeds `@earendil-works/pi-coding-agent` behind `llmClient`, per the root README). Because that is the same runtime `pi-telegram` was built against, its event vocabulary maps onto primitives Aiden already uses internally; we re-host the adapter as a library wired to `llmClient`, not through Pi's extension loader. The port is low-risk in the place that matters: `pi-telegram`'s host-agent contract is tiny, and Aiden already implements that exact contract headlessly today through the scheduled-tasks pipeline (`main/services/schedule-execution.ts` runs full agent turns with no `BrowserWindow`). + +The build reduces to: keep `pi-telegram`'s Telegram layer verbatim (with attribution), drop its Pi-SDK boundary and Threaded-Mode bus, and write one thin Aiden shim that exposes `pi-telegram`'s expected host ports (`sendUserMessage`, idle/pending gates, lifecycle events) against Aiden's `llmClient` + the `createBackgroundOwner` pattern. + +Hermes is a secondary reference for behavior (long-poll transport, owner allowlists, queue discipline). `pi-telegram` is the code basis. + +## Confirmed product decisions (frozen) + +1. **Port, not install.** Vendor `pi-telegram`'s Telegram layer under `main/services/telegram/`; replace its Pi-SDK boundary with an Aiden shim. MIT license retained with attribution. +2. **Full unattended authority after enablement.** Once the user enables Telegram in Settings and creates/pairs the bot, Telegram-originated turns run with `permission: "full"` and an unattended mode (no approval surface). This matches the existing scheduled-tasks trust boundary. Pairing is restricted to a single configured owner. +3. **Persistent chat isolation.** Assistant-only turns reuse one Aiden chat (`telegram-`). A selected folder workspace creates and reuses its own isolated backing chat (`telegram--`), so project context never crosses between workspaces. +4. **Transport = Telegram long-polling.** Outbound `getUpdates` only — no inbound HTTP server, no tray, no public URL. Works behind NAT and while the Aiden window is closed (Aiden already runs window-less on macOS). +5. **Owner pairing.** First user to `/start` the bot becomes the allowed owner (`allowedUserId`); all other users are ignored, mirroring `pi-telegram`'s built-in flow. +6. **Settings-gated enablement.** The service starts polling only when Telegram is enabled in Settings and a bot token is present; otherwise it stays dormant. + +## What `pi-telegram` provides (reference) + +- **Transport**: Telegram Bot API long-polling (`getUpdates`), single-user pairing, offset persistence after successful handling. +- **Inbound turn flow**: poll → filter to paired user → coalesce media groups / split long text → download files → build a `PendingTelegramTurn` → enqueue in a local queue → dispatch when idle gates pass. +- **Queue + dispatch safety**: separate `control` / `priority` / `default` lanes; dispatch requires no active turn, no pending dispatch, and idle host. +- **Outbound**: streaming draft previews (Rich/HTML), final Markdown reply delivery, file/artifact delivery, voice, inline keyboards, `/start` operator menu, status/queue controls. +- **Config**: `~/.pi/agent/telegram.json` (profiles, handlers, assistant/voice/time/threads settings). Aiden will store this under its own config roots instead. +- **Host contract** (the only Pi-coupled surface): `ExtensionAPI.sendUserMessage(content)`, `ctx.isIdle()`, `ctx.hasPendingMessages()`, lifecycle events (`agent_start` / `agent_end` / `agent_settled`, `AssistantMessageEvent`, tool execution events), and `setModel` / `compact` / `exec` controls. All defined in `lib/pi.ts`. + +## What Aiden adds + +- The bridge runs inside Electron main as a module-scoped service singleton (mirroring `geminiLiveService` / `scheduleService`), started at the tail of `app.whenReady()` and torn down on quit. +- Turn injection reuses the proven headless pattern: synthetic `ChatGenerationOwner` + `llmClient.start` + `await terminal`, exactly as scheduled LLM tasks already do. +- Credentials use `safeStorage` (macOS Keychain); enable/owner state uses `configStore` settings — copying the existing Exa web-search settings template. +- One persistent Aiden chat per owner instead of Pi's active-session binding. + +## Contract mapping (the heart of the port) + +| `pi-telegram` host API | Aiden primitive (verified) | Source | +| --- | --- | --- | +| `ExtensionAPI.sendUserMessage(content)` | `chatStore.appendMessage` → `llmClient.beginChatTurn` → `llmClient.start(streamId, params, bgOwner, opts)` → `await bgOwner.terminal` | `schedule-execution.ts:32-61, 291-368`; `llm-client.ts:750` | +| `ctx.isIdle()` | `!llmClient.isChatBusy(chatId)` | `llm-client.ts:1907` | +| `ctx.hasPendingMessages()` / turn admission | `llmClient.beginChatTurn(chatId, turnId, ownerId)` returns null while a turn is in flight | `llm-client.ts:2011` | +| `agent_start` / `agent_end` | `llmClient.start(...)` resolves; `bgOwner.terminal` settles on `chat:done` / `chat:error` | `schedule-execution.ts:49, 344` | +| `agent_settled` | `bgOwner.terminal` (terminal state) — **not** the `chats:settled` broadcast | broadcast only reaches windows | +| `AssistantMessageEvent` (streaming preview) | capture `chat:delta` in the synthetic owner's `send()` | currently ignored at `:49`; extend the switch | +| Tool execution events | `chat:tool` phase events via `owner.send` | optional; deferred to v2 activity mode | +| `ctx.cwd` / session binding | one persistent assistant chat (`telegram-`) or a workspace-isolated chat (`telegram--`) | `telegram-turn.ts` | +| `api.exec` / `setModel` / `compact` | deferred (v2) | — | + +**Mode/permission (load-bearing):** Telegram turns use main-only unattended modes (`assistant-unattended` / `assistant-automation`) with `permission: "full"`, identical to the scheduled path, so phone-originated turns never block on a GUI approval. Headless code must use `bgOwner.terminal` + `llmClient.waitForChatIdle`; it must **not** rely on `ipcMain.broadcast` (`chats:settled` and friends only reach `BrowserWindow.getAllWindows()`). + +## Architecture and files + +``` +main/services/telegram/ # NEW — ported pi-telegram layer + Aiden shim + api/ # vendored: Bot API helpers, retries, uploads/downloads + lib/ # vendored host-agnostic domains: + polling.ts inbound.ts queue.ts # receive, prompt intake, queue + dispatch gates + outbound.ts delivery.ts # reply/file delivery, chunking + preview.ts replies.ts rendering.ts # streaming drafts, final reply, Markdown/HTML + config.ts setup.ts commands.ts menu.ts sections.ts # config, pairing, menus (trimmed) + aiden-pi-shim.ts # NEW — host ports backed by llmClient (replaces lib/pi.ts) + aiden-lifecycle.ts # NEW — start/stop wiring (replaces lib/bindings.ts + lifecycle.ts) + service.ts # TelegramService singleton (start/stop/status), mirrors gemini-live + service-main.ts # production singleton + DI (token store, connector factory) + telegram-config.ts # config read/write under Aiden config roots (replaces ~/.pi/agent/telegram.json) +main/handlers/ + telegram.ts # NEW — telegram:get/setKey/setEnabled/setAllowedUser/connect/disconnect/status + index.ts # register registerTelegramHandlers() +renderer/ + preload-channels.ts # + "telegram:" INVOKE_PREFIX; + any new notification channel + lib/ipc.ts # + telegramApi + lib/queries.ts # + useTelegramSettings, useTelegramStatus + components/settings/telegram-settings.tsx # NEW — mirrors web-search-settings.tsx + shared/settings-section.ts # + Telegram nav entry + main/settings-view.tsx # + route binding + assets/onboarding/telegram.png # NEW — 1024x1024 transparent PNG + (onboarding bento gallery data) # + Telegram tile +``` + +Dropped from the vendor: `lib/pi.ts`, `lib/bindings.ts`, `lib/lifecycle.ts`, `lib/prompts.ts` (Pi-SDK boundary — replaced by the shim); the Threaded-Mode multi-instance bus (`bus*`, `ownership`, `target`, `thread-reconciler`, `sync`, `threads`); and companion-extension platform code (`sections`/`status`/`activity`/`voice` provider registries) — all v2+. + +### Service lifecycle (`service.ts`) + +- Constructed as `export const telegramService = new TelegramService({...})` in `service-main.ts` (DI: token store via `secrets`, connector factory). +- `start()` in `app.whenReady()` after `createMainWindow()` (next to `scheduleService.start()`). Idempotent; no-op unless enabled + token present. +- `stopAndSettle()` in `shutdownAndQuit` (async settle, unref'd timeout); `stop()` in `cleanupApplication` — mirroring `geminiLiveService` and `scheduleService`. +- Polling loop runs as a long-lived async task in main; offset persisted only after successful handling (pi-telegram rule). + +### Turn injection (`lib/aiden-pi-shim.ts`) + +`sendUserMessage(content)`: + +1. Ensure the persistent chat exists (`chatStore.create` once, reuse). +2. `const bg = createTelegramOwner(streamId)` — clone `createBackgroundOwner`; extend `send()` to also surface `chat:delta` for previews. +3. `const turn = llmClient.beginChatTurn(chatId, streamId, bg.owner.documentId)` — if null, the bridge queues the prompt (busy). +4. `chatStore.appendMessage(chatId, { role: "user", content })`. +5. `await llmClient.start(streamId, { chatId, workspaceId, providerId, model, mode: "assistant-unattended", messages: [user] }, bg.owner, { permission: "full", usageSource: "telegram", turnId: streamId })`. +6. `const terminal = await bg.terminal` → `terminal.content` is the final assistant text; deliver via the ported outbound layer. +7. `finally { turn.release(); bg.destroy(); }`. + +## Config and secrets (copy the Exa web-search template) + +- **Bot token** → `main/services/secrets.ts` (`setKey` / `getKeyStrict`): `safeStorage` (Keychain) → base64 in `/provider-keys.json`. +- **Enable flag + `allowedUserId` + runtime `lastUpdateId`** → `configStore.setSettings` into local `/settings.json` (`AppSettings` additions) or a dedicated `/telegram.json` via a `DataStore`. +- **IPC handlers** (`main/handlers/telegram.ts`): `telegram:get` → `{ enabled, hasToken, allowedUserId, status }`; `telegram:setKey`; `telegram:setEnabled`; `telegram:setAllowedUser`; `telegram:connect`; `telegram:disconnect`; `telegram:status`. Modeled on the `exa:*` block in `main/handlers/phase2.ts`. + +### Settings added to `AppSettings` + +```ts +telegramEnabled?: boolean; // default false; gates polling +telegramAllowedUserId?: number; // paired owner; undefined until first /start +``` + +`lastUpdateId` / bot identity stays in a runtime store, not `AppSettings`. + +## UI plan + +A **Telegram** section under the **Agent** (or Integrations) nav group, mirroring `web-search-settings.tsx`: + +- Enable `Switch` (off until the user opts in). +- Bot token input (password field) + Save — stored via `secrets`; never returned to the renderer. +- Status row: connected / polling / idle, paired owner id, last error. +- "How to connect" steps: create a bot via @BotFather, paste the token, enable, then `/start` the bot from Telegram to pair. +- Disconnect + re-pair controls. + +There is no Aiden-side chat surface for Telegram; the persistent backing chat is reachable from the normal chat list once created. + +## Onboarding (per AGENTS.md) + +A durable new capability requires a feature-tour bento tile and a `1024x1024` transparent PNG in `renderer/assets/onboarding/`. Add a "Telegram remote control" tile to the data-driven bento gallery, keep it cohesive with Aiden's visual language, and cover the asset contract in the onboarding test. + +## Security model + +| Concern | Mitigation | +| --- | --- | +| No live approver | `permission: "full"` + unattended mode; no `"ask"` path (same as scheduled tasks). Documented in Settings before enable. | +| Unauthorized users | Single paired owner (`allowedUserId`); all others ignored; pairing only via `/start` after enablement. | +| Credential exposure | Bot token in `safeStorage`/Keychain, never sent to renderer (`telegram:get` returns `hasToken`, not the token). | +| Token in Telegram webhook URL | N/A — long-polling only, no inbound webhook URL, no password-in-URL (unlike Hermes BlueBubbles). | +| Always-on surface | Polling starts only when enabled + token present; `disconnect` and disable stop it immediately. | +| Headless broadcast pitfall | Use `bgOwner.terminal` + `waitForChatIdle`; never `ipcMain.broadcast` for control flow. | +| License/attribution | MIT; retain `pi-telegram` / `badlogic/pi-telegram` attribution in vendored files and NOTICE. | + +## Implementation phases + +### Phase 0 — Vendor the Telegram layer + +1. Copy `pi-telegram` `api/` and the host-agnostic `lib/` domains into `main/services/telegram/` with MIT attribution headers. +2. Delete Pi-SDK + Threaded-Mode + companion-platform files; strip `@earendil-works/*` imports. +3. Re-point config to Aiden roots; stub the host ports so it compiles. + +Acceptance: the vendored tree type-checks in isolation with no Pi dependencies. + +### Phase 1 — Aiden shim + persistent chat + +1. `lib/aiden-pi-shim.ts`: `createTelegramOwner` (extends `createBackgroundOwner` to capture `chat:delta`), `sendUserMessage`, idle/pending gates, lifecycle event mapping. +2. `lib/aiden-lifecycle.ts`: start/stop registration. +3. Persistent chat creation (`telegram-`). +4. Wire `sendUserMessage` → ported inbound queue → ported outbound final-reply delivery. + +Acceptance: in a dev build, an injected prompt produces a final reply string delivered through the outbound layer (mock transport). + +### Phase 2 — Service lifecycle + +1. `service.ts` + `service-main.ts` singleton; polling start/stop/status. +2. Start in `whenReady`; `stopAndSettle`/`stop` in the two shutdown paths. +3. Owner pairing (`/start` → `allowedUserId`). + +Acceptance: with a real bot token, `/start` pairs; a text message from the owner triggers a headless Aiden turn and a Telegram reply; works with the Aiden window closed. + +### Phase 3 — Config, secrets, IPC contract + +1. `secrets.ts` token storage; `AppSettings` fields; `telegram-config.ts` runtime store. +2. `main/handlers/telegram.ts` handlers; register in `handlers/index.ts`. +3. `renderer/preload-channels.ts` `telegram:` prefix + any notification channel. +4. Satisfy `main/handlers/ipc-contract.test.ts` (prefix + broadcast allowlists). + +Acceptance: enable/token/owner flow works end-to-end from Settings; `npm run test` passes including the IPC-contract guard. + +### Phase 4 — Settings UI + +1. `telegram-settings.tsx` mirroring `web-search-settings.tsx`. +2. `lib/ipc.ts` `telegramApi`, `lib/queries.ts` hooks, nav + route binding. + +Acceptance: full enable/pair/disable flow from the UI; status reflects polling state. + +### Phase 5 — Queue + hardening + +1. Port the queue lanes and dispatch gates; verify queued messages wait while busy and dispatch on idle. +2. Markdown reply chunking at Telegram limits; basic `/start` menu + `/stop` (abort). +3. Usage accounting (`usageSource: "telegram"`). +4. Full verification: `npm run type-check`, `npm run lint`, `npm run test`, `npm run build`, packaged smoke. + +### Phase 6 — Onboarding + +1. Bento tile + `1024x1024` transparent PNG; data-driven gallery entry; onboarding test coverage. +2. Explicit workspace authority: settings persist `telegramWorkspaceId`; a selected, folder-backed workspace runs `assistant-automation` with a workspace-isolated chat; no selection remains assistant-only; stale selections fail before generation. + +## Deferred (not v1) + +Threaded Mode + multi-instance bus; compaction menu; activity/thinking/tool rendering; voice in/out; prompt-template commands; the `telegram_attach` / `telegram_message` / `telegram_help` agent tools; streaming Rich draft previews (`sendRichMessageDraft`); model/thinking switching from Telegram; companion-extension platform (sections/status/activity/voice provider registries); guest mode. + +## Risks + +- **Full unattended authority from a phone** is the one real security call. It matches the scheduled-tasks boundary but grants the paired owner silent mutating-tool access. Mitigated by single-owner pairing + explicit Settings opt-in; still worth a one-line warning in the UI. +- **Hidden Pi assumptions** in host-agnostic-looking `lib/` files may surface during Phase 0 (sized from the architecture/public-API docs, not a file-by-file audit of all ~100 modules). +- **Delta-capture extension** to `createBackgroundOwner`'s `send()` is a real code change, not a copy, if streaming previews ship in v1 (otherwise defer previews to v2 and keep `send()` as-is). +- **Bundling**: the vendored TypeScript must esbuild cleanly into the main-process bundle (no native deps; `pi-telegram` is pure TS). Verify in Phase 5 packaged smoke. +- **Chat visibility**: the persistent backing chat is created from main without renderer involvement; emit the existing `chats:metadata-updated` broadcast so the sidebar reflects it. + +## Implementation summary (2026-08-11) + +Phases 0–6 shipped as a clean-slate port on branch `feature/text-control`. +Rather than vendoring pi-telegram's 57-file, ~1.5 MB tree (deeply entangled +with Threaded-Mode, Pi-SDK, and companion-extension code), we wrote a focused +implementation that captures the same behaviour with MIT attribution. + +**Files created** (`main/services/telegram/`): +- `telegram-bot-api.ts` — Bot API HTTP client (getUpdates, sendMessage, getMe, etc.) +- `telegram-markdown.ts` — Markdown → Telegram HTML conversion + 4096-char chunking +- `telegram-queue.ts` — control/priority/default lane queue with dispatch gates +- `telegram-turn.ts` — headless turn injection shim (createTelegramBackgroundOwner, sendTelegramTurn) +- `telegram-config.ts` — runtime config store (lastUpdateId via DataStore, settings bridge) +- `telegram-service-core.ts` — pure service factory (polling loop, pairing, dispatch, delivery) +- `telegram-service.ts` — production singleton with DI wiring + +**Files modified**: +- `main/handlers/telegram.ts` — IPC handlers (telegram:get/setKey/setEnabled/connect/disconnect/resetPairing/setProvider/setWorkspace) +- `main/handlers/index.ts` — registerTelegramHandlers +- `main/index.ts` — lifecycle anchors (start/stop/stopAndSettle) +- `main/services/types.ts` — AppSettings: telegramEnabled, telegramAllowedUserId, telegramWorkspaceId +- `main/services/usage-store-core.ts` — UsageRequestSource: "telegram" +- `main/services/portable-config-core.ts` — keepBoolean: telegramEnabled +- `renderer/preload-channels.ts` — INVOKE_PREFIXES: "telegram:" +- `renderer/lib/ipc.ts` — telegramApi, including `setWorkspace` +- `renderer/lib/queries.ts` — queryKeys.telegram, useTelegramSettings +- `renderer/components/settings/telegram-settings.tsx` — settings UI with explicit folder-workspace scope +- `renderer/shared/settings-section.ts` — nav entry +- `renderer/main/settings-view.tsx` — NAV_ICONS + CONTENT binding +- `package.json` — test:telegram script +- `resources/telegram/LICENSE.pi-telegram.md` — MIT attribution + +**Workspace authority and onboarding (Phase 6):** +- `telegramWorkspaceId` is optional and can only be set to a configured folder workspace through `telegram:setWorkspace`; it is shown by `telegram:get`. +- Project turns resolve that exact workspace for every dispatch, retain `permission: "full"` with computer/subagent/MCP access disabled, pass the provider fingerprint, and use `assistant-automation` in the workspace-isolated backing chat. +- Without a selected workspace, turns retain the `assistant-unattended` assistant-only path. A stale configured selection returns a concrete error before generation. +- The onboarding gallery includes a `Telegram Remote Control` control bento and a transparent 1024 × 1024 `features/telegram-remote-control.png` illustration. + +**Tests** (59 passing): bot API (10), turn injection (12), queue (10), markdown (10), service core (12), workspace authority (3), workspace options (2). diff --git a/docs/superpowers/plans/2026-08-11-telegram-workspace-authority.md b/docs/superpowers/plans/2026-08-11-telegram-workspace-authority.md new file mode 100644 index 0000000..b7b7217 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-telegram-workspace-authority.md @@ -0,0 +1,381 @@ +# Telegram Workspace Authority Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let the paired Telegram owner explicitly bind remote full-access turns to one configured local workspace, while preserving assistant-only behavior when no workspace is selected. + +**Architecture:** Store an optional `telegramWorkspaceId` with the existing Telegram provider/model settings and expose it through the current `telegram:*` IPC contract. On every turn, the main-process service resolves that exact configured folder workspace: it routes a valid selection through `assistant-automation` and a workspace-isolated backing chat; no selection remains `assistant-unattended`; a stale selection errors before generation. The Settings selector is the sole authority-granting UI. + +**Tech Stack:** Electron IPC, TypeScript, React, TanStack Query, Aiden `llmClient`, Node test runner, existing `Select` primitives. + +## Global Constraints + +- Telegram MUST NOT infer a workspace from recent activity. +- A persisted workspace ID that is unknown or no longer folder-backed MUST fail the turn; it MUST NOT select a replacement or silently downgrade authority. +- Project-mode turns MUST retain `permission: "full"`, `allowComputerUse: false`, `allowSubagents: false`, and `allowMcpTools: false`. +- Provider/model binding MUST retain `scheduledProviderFingerprint(provider)`. +- No workspace selection MUST preserve the existing `telegram-` backing chat and assistant-only mode. +- The selector MUST use existing semantic UI primitives and keyboard-accessible behavior. +- The tour tile MUST have its own 1024 × 1024 transparent RGBA PNG and preserve the existing asset test. + +--- + +### Task 1: Persist the explicit Telegram workspace authority + +**Files:** +- Create: `main/services/telegram/telegram-workspace-core.ts` +- Create: `main/services/telegram/telegram-workspace-core.test.ts` +- Modify: `main/services/types.ts:493-500` +- Modify: `main/handlers/telegram.ts:12-82` +- Modify: `renderer/lib/ipc.ts` Telegram API declaration +- Modify: `package.json` `test:telegram` script + +**Interfaces:** +- Produces: + +```ts +export function telegramWorkspaceSelectionId(value: unknown): string | undefined; +export function isTelegramFolderWorkspace( + workspace: Pick | null | undefined, +): boolean; +``` + +- Produces: `AppSettings.telegramWorkspaceId?: string`; `TelegramStatusResponse.workspaceId?: string`; `telegramApi.setWorkspace(workspaceId?: string): Promise<{ workspaceId?: string }>`. + +- [ ] **Step 1: Write failing selection-normalization tests** + +```ts +test("telegramWorkspaceSelectionId trims a non-empty workspace id", () => { + assert.equal(telegramWorkspaceSelectionId(" workspace-a "), "workspace-a"); +}); + +test("telegramWorkspaceSelectionId clears non-string and blank values", () => { + assert.equal(telegramWorkspaceSelectionId(undefined), undefined); + assert.equal(telegramWorkspaceSelectionId(" "), undefined); + assert.equal(telegramWorkspaceSelectionId(12), undefined); +}); + +test("isTelegramFolderWorkspace accepts only a configured folder workspace", () => { + assert.equal(isTelegramFolderWorkspace({ folderPath: "/tmp/aiden" }), true); + assert.equal(isTelegramFolderWorkspace({}), false); + assert.equal(isTelegramFolderWorkspace(null), false); +}); +``` + +- [ ] **Step 2: Run the core test to verify it fails** + +Run: `npx tsx --test main/services/telegram/telegram-workspace-core.test.ts` + +Expected: FAIL because the core module does not exist. + +- [ ] **Step 3: Implement the pure authority helpers** + +```ts +export function telegramWorkspaceSelectionId(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function isTelegramFolderWorkspace( + workspace: Pick | null | undefined, +): boolean { + return Boolean(workspace?.folderPath); +} +``` + +Use `telegramWorkspaceSelectionId` in the new handler. When a non-empty id is supplied, call `configStore.getWorkspace(id)` and reject unless `isTelegramFolderWorkspace(workspace)` is true: + +```ts +throw new Error("Choose a configured folder workspace for Telegram project automation."); +``` + +Persist the validated id or `undefined`; return the same result. Add `workspaceId: settings.telegramWorkspaceId` to `telegram:get`; declare the optional field in `TelegramStatusResponse`, `AppSettings`, and `telegramApi`. Do not modify token, enablement, pairing, or provider/model behavior. + +- [ ] **Step 4: Run focused tests to verify they pass** + +Run: `npx tsx --test main/services/telegram/telegram-workspace-core.test.ts main/handlers/ipc-contract.test.ts` + +Expected: PASS. The pure contract accepts only a trimmed id and folder-backed workspace; the IPC contract still discovers every registered `telegram:` channel. + +- [ ] **Step 5: Register the new core test and commit** + +```bash +# Append main/services/telegram/telegram-workspace-core.test.ts to test:telegram. +git add main/services/telegram/telegram-workspace-core.ts main/services/telegram/telegram-workspace-core.test.ts main/services/types.ts main/handlers/telegram.ts renderer/lib/ipc.ts package.json +git commit -m "feat: persist Telegram workspace authority" +``` + +### Task 2: Route scoped Telegram turns through project automation + +**Files:** +- Modify: `main/services/telegram/telegram-service.ts:22-38` +- Modify: `main/services/telegram/telegram-service-core.ts:292-332` +- Modify: `main/services/telegram/telegram-turn.ts:64-237` +- Modify: `main/services/telegram/telegram-turn.test.ts` +- Modify: `main/services/telegram/telegram-service-core.test.ts` + +**Interfaces:** +- Produces: + +```ts +export type TelegramWorkspaceResolution = + | { kind: "assistant" } + | { kind: "project"; workspaceId: string } + | { kind: "stale" }; + +resolveWorkspace(): Promise; +telegramChatId(ownerUserId: number, workspaceId?: string): string; +``` + +- `TelegramTurnDeps.resolveWorkspace` returns the three-state resolution. The production service maps absent `telegramWorkspaceId` to `assistant`, a configured folder workspace to `project`, and a missing/folderless one to `stale`. + +- [ ] **Step 1: Write failing workspace-routing tests** + +```ts +test("workspace Telegram turn starts assistant automation with the selected workspace", async () => { + let startedParams: { chatId: string; workspaceId?: string; mode?: string } | undefined; + const llm = mockLlm(async (streamId, params, owner) => { + startedParams = params; + owner.send("chat:done", { streamId, content: "done" }); + return true; + }); + const { deps } = mockDeps({ llm, workspace: { kind: "project", workspaceId: "workspace-a" } }); + const chatId = telegramChatId(123, "workspace-a"); + + await sendTelegramTurn(deps, chatId, "list files"); + + assert.equal(chatId, "telegram-123-workspace-a"); + assert.equal(startedParams?.chatId, chatId); + assert.equal(startedParams?.workspaceId, "workspace-a"); + assert.equal(startedParams?.mode, "assistant-automation"); +}); + +test("stale Telegram workspace errors before generation", async () => { + const { deps } = mockDeps({ workspace: { kind: "stale" } }); + const result = await sendTelegramTurn(deps, telegramChatId(123, "missing"), "list files"); + + assert.deepEqual(result, { + ok: false, + content: "", + error: "The Telegram workspace is no longer available. Choose a folder workspace in Aiden Settings.", + }); +}); + +test("assistant-only Telegram turn preserves the owner chat and assistant mode", async () => { + let startedParams: { chatId: string; workspaceId?: string; mode?: string } | undefined; + const llm = mockLlm(async (streamId, params, owner) => { + startedParams = params; + owner.send("chat:done", { streamId, content: "done" }); + return true; + }); + const { deps } = mockDeps({ llm, workspace: { kind: "assistant" } }); + + await sendTelegramTurn(deps, telegramChatId(123), "settings help"); + + assert.equal(startedParams?.chatId, "telegram-123"); + assert.equal(startedParams?.workspaceId, undefined); + assert.equal(startedParams?.mode, "assistant-unattended"); +}); +``` + +- [ ] **Step 2: Run the focused Telegram test files to verify they fail** + +Run: `npx tsx --test main/services/telegram/telegram-turn.test.ts main/services/telegram/telegram-service-core.test.ts` + +Expected: FAIL because `TelegramTurnDeps` has no workspace resolution and the current shim always uses `assistant-unattended` with an unscoped chat id. + +- [ ] **Step 3: Implement the three-state resolver and mode-aware injection** + +In `telegram-service.ts`, resolve the persisted id on every dispatched turn: + +```ts +async function resolveWorkspace(): Promise { + const workspaceId = (await configStore.getSettings()).telegramWorkspaceId; + if (!workspaceId) return { kind: "assistant" }; + const workspace = await configStore.getWorkspace(workspaceId); + return workspace?.folderPath ? { kind: "project", workspaceId: workspace.id } : { kind: "stale" }; +} +``` + +In `telegram-turn.ts`, derive the chat id with the selected workspace only when `kind === "project"`, call `chatStore.create` with the same `workspaceId`, and start the LLM with: + +```ts +const workspace = await deps.resolveWorkspace(); +if (workspace.kind === "stale") { + return { ok: false, content: "", error: "The Telegram workspace is no longer available. Choose a folder workspace in Aiden Settings." }; +} +const projectWorkspaceId = workspace.kind === "project" ? workspace.workspaceId : undefined; +const mode = projectWorkspaceId ? "assistant-automation" : "assistant-unattended"; +``` + +The existing start options retain `permission: "full"`, all three false access flags, `usageSource: "telegram"`, `turnId`, and the provider fingerprint. Update `telegram-service-core.ts` so `dispatchTurn` resolves the workspace before calling `ensureTelegramChat` and `sendTelegramTurn`; its queue/busy gate must use the same workspace-scoped chat ID. Remove the temporary success-delivery logs from `telegram-service-core.ts`; they were diagnostic-only and should not become routine noise. + +- [ ] **Step 4: Run focused Telegram tests to verify they pass** + +Run: `npm run test:telegram` + +Expected: PASS. Scoped turns use only the selected workspace and project automation; unscoped turns preserve the assistant-only path; stale selection returns the concrete error without `llmClient.start`; all existing queue, markdown, Bot API, and service behavior remains green. + +- [ ] **Step 5: Commit the routing change** + +```bash +git add main/services/telegram/telegram-service.ts main/services/telegram/telegram-service-core.ts main/services/telegram/telegram-turn.ts main/services/telegram/telegram-turn.test.ts main/services/telegram/telegram-service-core.test.ts +git commit -m "feat: run Telegram turns in selected workspace" +``` + +### Task 3: Add a workspace selector to Telegram settings + +**Files:** +- Create: `renderer/lib/telegram-workspace-options.ts` +- Create: `renderer/lib/telegram-workspace-options.test.ts` +- Modify: `renderer/components/settings/telegram-settings.tsx:21-179` +- Modify: `package.json` `test:telegram` script + +**Interfaces:** +- Produces: + +```ts +export const TELEGRAM_ASSISTANT_ONLY_VALUE = "__none__"; +export interface TelegramWorkspaceOption { + value: string; + label: string; + unavailable?: boolean; +} +export function telegramWorkspaceOptions( + workspaces: readonly Workspace[], + selectedId: string | undefined, +): TelegramWorkspaceOption[]; +``` + +- `telegramWorkspaceOptions` returns the assistant-only option first, then folder-backed workspaces with `"${name} — ${folderPath}"` labels. A persisted id absent from the current list is retained as an unavailable option so the user can understand and clear it. + +- [ ] **Step 1: Write failing option-data tests** + +```ts +test("Telegram workspace options include assistant-only and configured folders", () => { + assert.deepEqual( + telegramWorkspaceOptions([ + { id: "folder", name: "Aiden", folderPath: "/tmp/aiden", permission: "ask", createdAt: 1, updatedAt: 1 }, + { id: "scratch", name: "Scratch", permission: "ask", createdAt: 1, updatedAt: 1 }, + ], undefined), + [ + { value: "__none__", label: "Assistant-only mode" }, + { value: "folder", label: "Aiden — /tmp/aiden" }, + ], + ); +}); + +test("Telegram workspace options retain an unavailable saved selection", () => { + assert.deepEqual(telegramWorkspaceOptions([], "missing"), [ + { value: "__none__", label: "Assistant-only mode" }, + { value: "missing", label: "Selected workspace is unavailable", unavailable: true }, + ]); +}); +``` + +- [ ] **Step 2: Run the option-data test to verify it fails** + +Run: `npx tsx --test renderer/lib/telegram-workspace-options.test.ts` + +Expected: FAIL because the option-data module does not exist. + +- [ ] **Step 3: Implement the pure option data and bind the existing controls** + +```ts +export const TELEGRAM_ASSISTANT_ONLY_VALUE = "__none__"; + +export function telegramWorkspaceOptions( + workspaces: readonly Workspace[], + selectedId: string | undefined, +): TelegramWorkspaceOption[] { + const options: TelegramWorkspaceOption[] = [ + { value: TELEGRAM_ASSISTANT_ONLY_VALUE, label: "Assistant-only mode" }, + ...workspaces.filter((workspace) => workspace.folderPath).map((workspace) => ({ + value: workspace.id, + label: `${workspace.name} — ${workspace.folderPath}`, + })), + ]; + if (selectedId && !options.some((option) => option.value === selectedId)) { + options.push({ value: selectedId, label: "Selected workspace is unavailable", unavailable: true }); + } + return options; +} +``` + +In `telegram-settings.tsx`, import `useWorkspaces` and the option helper. Place a `Field` before Provider with exact description: + +> Project automation runs only in this folder. Assistant-only mode cannot access project files or tools. + +Render an existing small `Select` with `aria-label="Telegram workspace"`. Map the `"__none__"` item to `undefined` when calling `telegramApi.setWorkspace`, invalidate `queryKeys.telegram`, and use existing action-specific `toast.success`/`toast.error` behavior. Render unavailable options disabled. When there are no folder workspace options other than assistant-only, show a muted line directing the user to add a folder workspace in Settings → Workspaces. + +- [ ] **Step 4: Run renderer tests and type-check to verify they pass** + +Run: `npx tsx --test renderer/lib/telegram-workspace-options.test.ts && npx tsc --noEmit` + +Expected: PASS. The pure data contract protects the rendered control’s assistant-only, folder-only, and stale-selection states; TypeScript confirms the IPC response and setter agree. + +- [ ] **Step 5: Register the new test and commit** + +```bash +# Append renderer/lib/telegram-workspace-options.test.ts to test:telegram. +git add renderer/lib/telegram-workspace-options.ts renderer/lib/telegram-workspace-options.test.ts renderer/components/settings/telegram-settings.tsx package.json +git commit -m "feat: choose Telegram workspace scope" +``` + +### Task 4: Complete onboarding, docs, and full verification + +**Files:** +- Modify: `renderer/components/onboarding-flow.tsx:62-87,135-355` +- Modify: `renderer/components/onboarding-flow.test.tsx:222-292` +- Create: `renderer/assets/onboarding/features/telegram-remote-control.png` +- Modify: `docs/plans/telegram-remote-control-plan.md:202-252` +- Modify: `docs/plans/README.md` + +**Interfaces:** +- Produces: `FEATURE_ILLUSTRATIONS.telegram`, a Control-group `Telegram Remote Control` bento, and an onboarding asset path of `features/telegram-remote-control.png`. + +- [ ] **Step 1: Write failing onboarding assertions** + +```ts +assert.match(featurePresentation, /Telegram Remote Control/u); +assert.equal(featurePresentation.match(/imageUrl: FEATURE_ILLUSTRATIONS\./gu)?.length, 23); +assert.equal(featureAssetPaths.length, 23); +assert.ok(featureAssetPaths.includes("features/telegram-remote-control.png")); +``` + +Keep the existing PNG-signature, 1024 × 1024 dimension, and RGBA-alpha assertions unchanged. + +- [ ] **Step 2: Run the onboarding test to verify it fails** + +Run: `npx tsx --test renderer/components/onboarding-flow.test.tsx` + +Expected: FAIL because Telegram is not an advertised bento feature and its asset does not exist. + +- [ ] **Step 3: Add the feature tile and a real transparent illustration** + +Add `telegram` to `FEATURE_ILLUSTRATIONS`; add a Control-group bento with the `Send` Lucide icon, title `Telegram Remote Control`, description `Run trusted workspace automations from your paired Telegram account.`, and a standard layout. Create `renderer/assets/onboarding/features/telegram-remote-control.png` as a 1024 × 1024 RGBA PNG with a transparent background, an Aiden conversation bubble and abstract paper-plane composition, and no embedded text. Update the list/count/path assertions to 23 without modifying gallery mechanics. + +- [ ] **Step 4: Update plan and inventory documentation** + +Update the Telegram plan implementation summary to record `telegramWorkspaceId`, `telegram:setWorkspace`, the explicit Workspace selector, `assistant-automation` for selected folder workspaces, and assistant-only fallback. Mark Phase 6 complete, remove the stale deferral line, and revise the plan index status/current state. Move the plan into `docs/plans/completed/` only when every original Phase 0–6 acceptance criterion has passed. + +- [ ] **Step 5: Run full verification and live smoke** + +Run: + +```bash +npm run test:telegram +npm run test:onboarding +npx tsc --noEmit +npm run lint +npm run test +npm run build +``` + +Launch the dev app. In Settings → Telegram, select a configured folder workspace and send the paired bot: `List the top-level files in this workspace.` Verify the Telegram reply reflects the selected project’s tool output. Then clear the selection, send a settings question, and verify the reply is assistant-only. Verify a stale selection gives the specified concrete error. + +- [ ] **Step 6: Commit the complete capability** + +```bash +git add renderer/components/onboarding-flow.tsx renderer/components/onboarding-flow.test.tsx renderer/assets/onboarding/features/telegram-remote-control.png docs/plans/telegram-remote-control-plan.md docs/plans/README.md package.json +git commit -m "feat: complete Telegram remote control onboarding" +``` diff --git a/docs/superpowers/specs/2026-08-11-telegram-workspace-authority-design.md b/docs/superpowers/specs/2026-08-11-telegram-workspace-authority-design.md new file mode 100644 index 0000000..5e08447 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-telegram-workspace-authority-design.md @@ -0,0 +1,38 @@ +# Telegram Workspace Authority Design + +## Decision + +Telegram remote control has two explicit operation modes: + +- **Assistant mode:** no workspace selected. Turns retain the existing `assistant-unattended` behavior and cannot access a local project. +- **Project automation mode:** the paired Telegram owner explicitly selects one configured workspace in Settings or with `/workspace`. Turns execute with `assistant-automation`, `permission: "full"`, and that exact workspace ID. + +The selection is never inferred from recently opened activity. `/workspace` can select only a configured folder workspace and changes the persisted Telegram scope; it never accepts a filesystem path. + +## Configuration + +`AppSettings` gains `telegramWorkspaceId?: string`. The Telegram status IPC response includes it, and a dedicated `telegram:setWorkspace` handler persists a selected configured workspace ID or clears the selection. + +The Settings → Telegram page shows a Workspace selector near Provider and Model. The paired owner can also run `/workspace` in the private bot chat to list configured folders, select one by number, ID, or an exact name, or run `/workspace off` for assistant-only mode. The list identifies the active selection. Changing scope clears queued prompts; each accepted prompt also records its workspace ID before joining the queue, so a concurrent Settings change cannot retarget it. An already-active turn remains bound to the scope it began with. + +## Turn routing + +Every Telegram turn resolves the workspace ID captured when its prompt was accepted in the main process. + +- With no selection, it retains the pre-existing `telegram-` backing chat and `assistant-unattended` mode. +- With a valid selection, it uses `assistant-automation`, passes `workspaceId`, and derives a distinct backing-chat ID from the Telegram owner and workspace ID. +- If the saved workspace no longer exists, the turn fails with a concrete explanation. It never silently falls back to another workspace or to assistant mode. + +Provider resolution remains independent and retains the provider fingerprint supplied to `llmClient.start`. + +## Authority boundary + +Project-mode Telegram turns use `permission: "full"` only for the selected workspace’s coding-tool allowlist. They keep `allowComputerUse: false`, `allowSubagents: false`, and `allowMcpTools: false`. The single paired Telegram owner, Settings enablement, and the configured-folder-only `/workspace` command remain the trust boundary. + +## Verification + +Tests cover workspace chat identity, selected-workspace generation mode and ID, missing workspace rejection, persisted IPC settings, settings selector states, and owner-authorized `/workspace` list/select/clear flows. A real paired-bot smoke verifies a configured workspace runs an explicit safe request. + +## Onboarding + +The final feature-tour bento gallery gets a Telegram Remote Control tile plus its optimized 1024 × 1024 transparent PNG, covered by the existing onboarding asset contract. diff --git a/main/handlers/index.ts b/main/handlers/index.ts index 0591885..f02f01d 100644 --- a/main/handlers/index.ts +++ b/main/handlers/index.ts @@ -22,6 +22,7 @@ import { registerDictationHandlers } from "./dictation.js"; import { registerScheduledTaskHandlers } from "./scheduled-tasks.js"; import { registerAssistantHandlers } from "./assistant.js"; import { registerShortcutHandlers } from "./shortcuts.js"; +import { registerTelegramHandlers } from "./telegram.js"; import { registerSubagentHandlers } from "./subagents.js"; import { ipcMain, logger } from "../platform.js"; @@ -62,6 +63,7 @@ export function registerHandlers(): void { registerScheduledTaskHandlers(); registerAssistantHandlers(); registerShortcutHandlers(); + registerTelegramHandlers(); registerSubagentHandlers(); logger.info("handlers", "✓ IPC handlers registered"); diff --git a/main/handlers/ipc-contract.test.ts b/main/handlers/ipc-contract.test.ts index 39fb4b8..df6d486 100644 --- a/main/handlers/ipc-contract.test.ts +++ b/main/handlers/ipc-contract.test.ts @@ -37,6 +37,7 @@ const PRELOAD_PATH = path.join(REPO_ROOT, "renderer", "preload.ts"); const RENDERER_IPC_PATH = path.join(REPO_ROOT, "renderer", "lib", "ipc.ts"); const ATTACHMENT_HANDLER_PATH = path.join(MAIN_ROOT, "handlers", "attachments.ts"); +const HANDLER_BOOTSTRAP_PATH = path.join(MAIN_ROOT, "handlers", "index.ts"); function calleeName(expression: ts.LeftHandSideExpression): string | undefined { if (ts.isIdentifier(expression)) return expression.text; if (ts.isPropertyAccessExpression(expression)) return expression.name.text; @@ -267,3 +268,20 @@ test("live notification sites exactly match the preload notification allowlist", "main notification sites and preload NOTIFICATION_CHANNELS drifted", ); }); + +test("handler bootstrap registers every dedicated handler surface", async () => { + const bootstrap = await fs.readFile(HANDLER_BOOTSTRAP_PATH, "utf8"); + + for (const registration of ["registerSubagentHandlers", "registerTelegramHandlers"]) { + assert.match( + bootstrap, + new RegExp(`import \\{ ${registration} \\} from "\\./[a-z-]+\\.js";`, "u"), + `${registration} must be imported by the IPC bootstrap`, + ); + assert.match( + bootstrap, + new RegExp(`\\b${registration}\\(\\);`, "u"), + `${registration} must be invoked by the IPC bootstrap`, + ); + } +}); diff --git a/main/handlers/telegram.ts b/main/handlers/telegram.ts new file mode 100644 index 0000000..a6f4acf --- /dev/null +++ b/main/handlers/telegram.ts @@ -0,0 +1,101 @@ +// Telegram IPC handlers — enable/token/connect/disconnect/status flow. +// Modeled on the exa:* block in phase2.ts. +// +// Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT). + +import { ipcMain } from "../platform.js"; +import { configStore } from "../services/config-store.js"; +import { secrets } from "../services/secrets.js"; +import { telegramService } from "../services/telegram/telegram-service.js"; +import { + isTelegramFolderWorkspace, + telegramWorkspaceSelectionId, +} from "../services/telegram/telegram-workspace-core.js"; +import { TELEGRAM_PROVIDER_ID } from "../services/telegram/telegram-service.js"; + +export interface TelegramStatusResponse { + enabled: boolean; + hasToken: boolean; + allowedUserId?: number; + providerId?: string; + model?: string; + polling: boolean; + workspaceId?: string; + queuedCount: number; + lastError?: string; +} + +export function registerTelegramHandlers(): void { + ipcMain.handle("telegram:get", async () => { + const settings = await configStore.getSettings(); + const status = telegramService.getStatus(); + return { + enabled: settings.telegramEnabled ?? false, + hasToken: await secrets.hasKey(TELEGRAM_PROVIDER_ID), + allowedUserId: settings.telegramAllowedUserId, + providerId: settings.telegramProviderId, + model: settings.telegramModel, + workspaceId: settings.telegramWorkspaceId, + polling: status.status !== "disabled", + queuedCount: status.queuedCount, + lastError: status.lastError, + } satisfies TelegramStatusResponse; + }); + + ipcMain.handle("telegram:setKey", async (_event, key: unknown) => { + const value = typeof key === "string" ? key.trim() : ""; + if (value) { + await secrets.setKey(TELEGRAM_PROVIDER_ID, value); + } else { + await secrets.deleteKey(TELEGRAM_PROVIDER_ID); + await configStore.setSettings({ telegramEnabled: false, telegramAllowedUserId: undefined }); + telegramService.stop(); + } + return { hasKey: Boolean(value) }; + }); + + ipcMain.handle("telegram:setEnabled", async (_event, enabled: unknown) => { + const value = enabled === true; + await configStore.setSettings({ telegramEnabled: value }); + if (value) { + await telegramService.start(); + } else { + telegramService.stop(); + } + return value; + }); + + ipcMain.handle("telegram:connect", async () => { + await telegramService.connect(); + return { connected: true }; + }); + + ipcMain.handle("telegram:disconnect", async () => { + await telegramService.disconnect(); + return { connected: false }; + }); + + ipcMain.handle("telegram:resetPairing", async () => { + await configStore.setSettings({ telegramAllowedUserId: undefined }); + return { reset: true }; + }); + + ipcMain.handle("telegram:setProvider", async (_event, providerId: unknown, model: unknown) => { + const pid = typeof providerId === "string" && providerId.trim() ? providerId.trim() : undefined; + const m = typeof model === "string" && model.trim() ? model.trim() : undefined; + await configStore.setSettings({ telegramProviderId: pid, telegramModel: m }); + return { providerId: pid, model: m }; + }); + + ipcMain.handle("telegram:setWorkspace", async (_event, workspaceId: unknown) => { + const selectedWorkspaceId = telegramWorkspaceSelectionId(workspaceId); + if (selectedWorkspaceId) { + const workspace = await configStore.getWorkspace(selectedWorkspaceId); + if (!isTelegramFolderWorkspace(workspace)) { + throw new Error("Choose a configured folder workspace for Telegram project automation."); + } + } + await configStore.setSettings({ telegramWorkspaceId: selectedWorkspaceId }); + return { workspaceId: selectedWorkspaceId }; + }); +} diff --git a/main/index.ts b/main/index.ts index cef1be0..cbe7051 100644 --- a/main/index.ts +++ b/main/index.ts @@ -55,6 +55,7 @@ import type { } from "../renderer/shared/app-update.js"; import { devLogPath, initDevLog } from "./services/dev-log.js"; import { scheduleService } from "./services/schedule-service.js"; +import { telegramService } from "./services/telegram/telegram-service.js"; import { registerAppPathOpener } from "./services/app-navigation.js"; import { effectiveBindings, migrateLegacyKeybindings } from "../renderer/shared/keybindings.js"; import type { NotificationChannel } from "../renderer/preload-channels.js"; @@ -236,6 +237,7 @@ function cleanupApplication(): void { computerUseStatus.invalidate(); scheduleService.stop(); llmClient.abortAll(); + telegramService.stop(); subagentRuntimeRegistry.abortAll(); void mcpManager.closeAll(); } @@ -321,6 +323,7 @@ async function shutdownAndQuit(settingsPrepared = false): Promise { shutdownProviderAuthFlow(), computerUseStatus.shutdown(), scheduleService.stopAndSettle(), + telegramService.stopAndSettle(), (async () => { await subagentRunStore.flush(); await subagentRunStore.close(); @@ -1340,6 +1343,7 @@ if (!ownsSingleInstanceLock) { return; } await scheduleService.start(); + await telegramService.start(); appUpdateService.start(); }) .catch((error: unknown) => { diff --git a/main/services/portable-config-core.ts b/main/services/portable-config-core.ts index d98852b..48754e0 100644 --- a/main/services/portable-config-core.ts +++ b/main/services/portable-config-core.ts @@ -480,6 +480,7 @@ function normalizeSettingsShape(value: unknown): SettingsShape { } for (const key of [ "exaEnabled", + "telegramEnabled", "shortcutEnabled", "dictationEnabled", "computerUseEnabled", diff --git a/main/services/telegram/telegram-bot-api.test.ts b/main/services/telegram/telegram-bot-api.test.ts new file mode 100644 index 0000000..0468f5c --- /dev/null +++ b/main/services/telegram/telegram-bot-api.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + TelegramApiError, + TelegramBotApi, + type TelegramApiResponse, + type TelegramTransport, + type TelegramUpdate, + type TelegramUser, +} from "./telegram-bot-api.js"; + +/** Build an api backed by a recording transport that always returns `response`. */ +function harness(response: TelegramApiResponse): { + api: TelegramBotApi; + calls: Array<{ method: string; body: Record }>; +} { + const calls: Array<{ method: string; body: Record }> = []; + const transport: TelegramTransport = async (method, body) => { + calls.push({ method, body }); + return response; + }; + return { api: new TelegramBotApi(transport), calls }; +} + +test("getUpdates calls transport with offset, timeout, and allowed_updates", async () => { + const updates: TelegramUpdate[] = [{ update_id: 7, message: undefined }]; + const { api, calls } = harness({ ok: true, result: updates }); + const result = await api.getUpdates(42, 25); + + assert.deepEqual(result, updates); + assert.equal(calls.length, 1); + assert.equal(calls[0].method, "getUpdates"); + assert.deepEqual(calls[0].body, { + timeout: 25, + allowed_updates: ["message", "callback_query"], + offset: 42, + }); +}); + +test("getUpdates without offset omits the offset field", async () => { + const { api, calls } = harness({ ok: true, result: [] }); + await api.getUpdates(undefined, 0); + + assert.equal(calls[0].method, "getUpdates"); + assert.deepEqual(calls[0].body, { + timeout: 0, + allowed_updates: ["message", "callback_query"], + }); + assert.equal("offset" in calls[0].body, false); +}); + +test("getMe returns the user from a successful response", async () => { + const user: TelegramUser = { + id: 99, + is_bot: true, + first_name: "Aiden", + username: "aiden_bot", + }; + const { api, calls } = harness({ ok: true, result: user }); + const me = await api.getMe(); + + assert.deepEqual(me, user); + assert.equal(calls[0].method, "getMe"); + assert.deepEqual(calls[0].body, {}); +}); + +test("sendMessage builds body with chat_id, text, and parse_mode", async () => { + const { api, calls } = harness({ + ok: true, + result: { message_id: 1, chat: { id: 5, type: "private" }, date: 0 }, + }); + await api.sendMessage({ chatId: 5, text: "hello", parseMode: "HTML" }); + + assert.equal(calls[0].method, "sendMessage"); + assert.deepEqual(calls[0].body, { chat_id: 5, text: "hello", parse_mode: "HTML" }); +}); + +test("sendMessage omits parse_mode when it is not provided", async () => { + const { api, calls } = harness({ + ok: true, + result: { message_id: 1, chat: { id: 5, type: "private" }, date: 0 }, + }); + await api.sendMessage({ chatId: 5, text: "hello" }); + + assert.deepEqual(calls[0].body, { chat_id: 5, text: "hello" }); + assert.equal("parse_mode" in calls[0].body, false); +}); + +test("API error (ok: false) throws TelegramApiError with description and error_code", async () => { + const { api } = harness({ ok: false, description: "Unauthorized", error_code: 401 }); + await assert.rejects( + () => api.getMe(), + (err: unknown) => { + assert.ok(err instanceof TelegramApiError, "expected TelegramApiError"); + assert.equal((err as TelegramApiError).message, "Unauthorized"); + assert.equal((err as TelegramApiError).code, 401); + assert.equal((err as TelegramApiError).retryAfter, undefined); + return true; + }, + ); +}); + +test("TelegramApiError carries retryAfter from parameters.retry_after", async () => { + const { api } = harness({ + ok: false, + description: "Too Many Requests", + error_code: 429, + parameters: { retry_after: 30 }, + }); + await assert.rejects( + () => api.getMe(), + (err: unknown) => { + assert.ok(err instanceof TelegramApiError); + assert.equal((err as TelegramApiError).retryAfter, 30); + assert.equal((err as TelegramApiError).code, 429); + return true; + }, + ); +}); + +test("sendChatAction calls transport with chat_id and action", async () => { + const { api, calls } = harness({ ok: true, result: true }); + await api.sendChatAction(7, "typing"); + + assert.equal(calls[0].method, "sendChatAction"); + assert.deepEqual(calls[0].body, { chat_id: 7, action: "typing" }); +}); + +test("answerCallbackQuery includes callback_query_id and optional text", async () => { + const withoutText = harness({ ok: true, result: true }); + await withoutText.api.answerCallbackQuery("cq-1"); + assert.equal(withoutText.calls[0].method, "answerCallbackQuery"); + assert.deepEqual(withoutText.calls[0].body, { callback_query_id: "cq-1" }); + + const withText = harness({ ok: true, result: true }); + await withText.api.answerCallbackQuery("cq-2", "Acknowledged"); + assert.deepEqual(withText.calls[0].body, { + callback_query_id: "cq-2", + text: "Acknowledged", + }); +}); + +test("getUpdates with an already-aborted signal rejects immediately without calling transport", async () => { + let called = false; + const transport: TelegramTransport = async () => { + called = true; + return { ok: true, result: [] }; + }; + const api = new TelegramBotApi(transport); + + await assert.rejects(() => api.getUpdates(undefined, 0, AbortSignal.abort()), /aborted/i); + assert.equal(called, false, "transport must not be invoked for an aborted signal"); +}); diff --git a/main/services/telegram/telegram-bot-api.ts b/main/services/telegram/telegram-bot-api.ts new file mode 100644 index 0000000..c606bf5 --- /dev/null +++ b/main/services/telegram/telegram-bot-api.ts @@ -0,0 +1,204 @@ +// Telegram Bot API client — long-polling transport, message delivery, and bot identity. +// +// Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT) +// — a fork of badlogic/pi-telegram. The host-agent coupling in the original +// is replaced by Aiden's llmClient turn-injection shim (telegram-turn.ts). +// This module is pure transport: it knows nothing about Aiden's chat model. + +export interface TelegramUser { + id: number; + is_bot: boolean; + first_name: string; + last_name?: string; + username?: string; +} + +export interface TelegramChat { + id: number; + type: "private" | "group" | "supergroup" | "channel"; + title?: string; + username?: string; + first_name?: string; + last_name?: string; +} + +export interface TelegramMessage { + message_id: number; + from?: TelegramUser; + chat: TelegramChat; + date: number; + text?: string; + caption?: string; + entities?: TelegramMessageEntity[]; +} + +export interface TelegramCallbackQuery { + id: string; + from: TelegramUser; + message?: TelegramMessage; + data?: string; +} + +export interface TelegramUpdate { + update_id: number; + message?: TelegramMessage; + edited_message?: TelegramMessage; + callback_query?: TelegramCallbackQuery; +} + +export type TelegramMessageEntityType = + | "bold" + | "italic" + | "code" + | "pre" + | "text_link" + | "mention" + | "bot_command"; + +export interface TelegramMessageEntity { + type: TelegramMessageEntityType; + offset: number; + length: number; + url?: string; +} + +export interface TelegramApiResponse { + ok: boolean; + result?: T; + description?: string; + error_code?: number; + parameters?: { retry_after?: number }; +} + +/** Inject-able transport so tests can mock the network entirely. */ +export type TelegramTransport = ( + method: string, + body: Record, +) => Promise>; + +/** Production transport: posts JSON to api.telegram.org. Resolves the token + * per request so runtime key changes are picked up without re-creation. */ +export function createFetchTransport(tokenResolver: () => Promise): TelegramTransport { + return async (method: string, body: Record) => { + const token = await tokenResolver(); + if (!token) throw new Error("No Telegram bot token configured."); + const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return (await response.json()) as TelegramApiResponse; + }; +} + +/** Retryable error with optional retry_after (seconds) from Telegram. */ +export class TelegramApiError extends Error { + readonly retryAfter: number | undefined; + readonly code: number; + constructor(message: string, code: number, retryAfter?: number) { + super(message); + this.name = "TelegramApiError"; + this.code = code; + this.retryAfter = retryAfter; + } +} + +function unwrap(res: TelegramApiResponse): T { + if (!res.ok) { + const retry = res.parameters?.retry_after; + const msg = res.description ?? "Telegram API request failed."; + throw new TelegramApiError(msg, res.error_code ?? 0, retry); + } + return res.result as T; +} + +/** Race a transport call against an abort signal for cooperative cancellation. */ +function callWithAbort( + transport: () => Promise>, + signal?: AbortSignal, +): Promise> { + if (!signal) return transport(); + if (signal.aborted) return Promise.reject(new Error("Telegram polling aborted.")); + return new Promise>((resolve, reject) => { + const onAbort = () => reject(new Error("Telegram polling aborted.")); + signal.addEventListener("abort", onAbort, { once: true }); + transport() + .then((result) => { + signal.removeEventListener("abort", onAbort); + resolve(result); + }) + .catch((error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }); + }); +} + +export class TelegramBotApi { + constructor(private readonly transport: TelegramTransport) {} + + /** Long-poll for updates. Returns immediately on timeout (empty array). */ + async getUpdates( + offset: number | undefined, + timeoutSeconds: number, + signal?: AbortSignal, + ): Promise { + const body: Record = { + timeout: timeoutSeconds, + allowed_updates: ["message", "callback_query"], + }; + if (offset !== undefined) body.offset = offset; + return callWithAbort( + () => this.transport("getUpdates", body), + signal, + ).then((r) => unwrap(r)); + } + + async getMe(): Promise { + return unwrap(await this.transport("getMe", {})); + } + + async sendMessage(params: { + chatId: number; + text: string; + parseMode?: "HTML" | "MarkdownV2"; + replyMarkup?: unknown; + disablePreview?: boolean; + }): Promise { + const body: Record = { + chat_id: params.chatId, + text: params.text, + }; + if (params.parseMode) body.parse_mode = params.parseMode; + if (params.replyMarkup) body.reply_markup = params.replyMarkup; + if (params.disablePreview) body.disable_web_page_preview = true; + return unwrap(await this.transport("sendMessage", body)); + } + + async editMessageText(params: { + chatId: number; + messageId: number; + text: string; + parseMode?: "HTML" | "MarkdownV2"; + replyMarkup?: unknown; + }): Promise { + const body: Record = { + chat_id: params.chatId, + message_id: params.messageId, + text: params.text, + }; + if (params.parseMode) body.parse_mode = params.parseMode; + if (params.replyMarkup !== undefined) body.reply_markup = params.replyMarkup; + await this.transport("editMessageText", body); + } + + async sendChatAction(chatId: number, action: string): Promise { + await this.transport("sendChatAction", { chat_id: chatId, action }); + } + + async answerCallbackQuery(id: string, text?: string): Promise { + const body: Record = { callback_query_id: id }; + if (text) body.text = text; + await this.transport("answerCallbackQuery", body); + } +} diff --git a/main/services/telegram/telegram-config.ts b/main/services/telegram/telegram-config.ts new file mode 100644 index 0000000..c96f908 --- /dev/null +++ b/main/services/telegram/telegram-config.ts @@ -0,0 +1,107 @@ +// Telegram runtime config store: persists polling offset (lastUpdateId) +// and reads pairing/enablement state from Aiden's AppSettings. +// +// allowedUserId and telegramEnabled live in AppSettings (configStore). +// lastUpdateId is runtime state persisted in /telegram-runtime.json +// via DataStore so a restart resumes polling without re-processing old updates. +// +// Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT) +// — offset persisted ONLY after successful handling (monotonic max). + +import { DataStore } from "../data-store.js"; +import type { AppSettings } from "../types.js"; + +export interface TelegramRuntimeState { + /** Monotonically increasing offset for getUpdates. Undefined on first run. */ + lastUpdateId?: number; +} + +export interface TelegramConfigSnapshot { + enabled: boolean; + hasToken: boolean; + allowedUserId?: number; + lastUpdateId?: number; +} + +export interface TelegramConfigDeps { + getSettings(): Promise; + setSettings(patch: Partial): Promise; + hasToken(): Promise; + resolveRootDir(): string; +} + +export function createTelegramConfig(deps: TelegramConfigDeps) { + let store: DataStore | undefined; + + async function getStore(): Promise> { + if (!store) { + store = new DataStore( + "telegram-runtime.json", + {}, + () => deps.resolveRootDir(), + ); + } + return store; + } + + async function getRuntimeState(): Promise { + return (await getStore()).load(); + } + + async function saveRuntimeState(state: TelegramRuntimeState): Promise { + await (await getStore()).save(state); + } + + /** + * Persist the polling offset. Uses monotonic max so an older success + * cannot rewind past a newer one (pi-telegram rule). + */ + async function persistOffset(updateId: number): Promise { + const current = await getRuntimeState(); + if (current.lastUpdateId === undefined || updateId > current.lastUpdateId) { + await saveRuntimeState({ lastUpdateId: updateId }); + } + } + + async function getOffset(): Promise { + return (await getRuntimeState()).lastUpdateId; + } + + async function clearOffset(): Promise { + await saveRuntimeState({}); + } + + async function getSettings(): Promise { + return deps.getSettings(); + } + + async function setSettings(patch: Partial): Promise { + return deps.setSettings(patch); + } + + async function hasToken(): Promise { + return deps.hasToken(); + } + + async function snapshot(): Promise { + const settings = await deps.getSettings(); + return { + enabled: settings.telegramEnabled ?? false, + hasToken: await deps.hasToken(), + allowedUserId: settings.telegramAllowedUserId, + lastUpdateId: await getOffset(), + }; + } + + return { + persistOffset, + getOffset, + clearOffset, + getSettings, + setSettings, + hasToken, + snapshot, + }; +} + +export type TelegramConfig = ReturnType; diff --git a/main/services/telegram/telegram-markdown.test.ts b/main/services/telegram/telegram-markdown.test.ts new file mode 100644 index 0000000..5545b7f --- /dev/null +++ b/main/services/telegram/telegram-markdown.test.ts @@ -0,0 +1,78 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + markdownToTelegramHtml, + chunkForTelegram, + TELEGRAM_MESSAGE_LIMIT, +} from "./telegram-markdown.js"; + +test("converts **bold** to bold", () => { + assert.equal(markdownToTelegramHtml("**bold**"), "bold"); +}); + +test("converts `code` to code", () => { + assert.equal(markdownToTelegramHtml("`code`"), "code"); +}); + +test("converts fenced code block with language to pre/code", () => { + const md = "```ts\nconst x = 1;\n```"; + assert.equal( + markdownToTelegramHtml(md), + '
const x = 1;
', + ); +}); + +test("converts [text](url) links", () => { + assert.equal( + markdownToTelegramHtml("[text](https://url)"), + 'text', + ); +}); + +test("escapes <, >, & in plain text", () => { + assert.equal(markdownToTelegramHtml("a < b > c & d"), "a < b > c & d"); +}); + +test("converts # heading to bold", () => { + assert.equal(markdownToTelegramHtml("# Title"), "Title"); +}); + +test("chunkForTelegram returns a single chunk for short text", () => { + assert.deepEqual(chunkForTelegram("hello world"), ["hello world"]); +}); + +test("chunkForTelegram splits text over the limit into multiple chunks", () => { + const long = "x".repeat(5000); + const chunks = chunkForTelegram(long); + assert.ok(chunks.length >= 2, "expected more than one chunk"); + for (const chunk of chunks) { + assert.ok( + chunk.length <= TELEGRAM_MESSAGE_LIMIT, + `chunk length ${chunk.length} exceeds limit ${TELEGRAM_MESSAGE_LIMIT}`, + ); + } + // Char-level splits preserve content exactly. + assert.equal(chunks.join(""), long); +}); + +test("chunkForTelegram splits at paragraph boundaries when possible", () => { + const para1 = "a".repeat(2000); + const para2 = "b".repeat(2000); + const para3 = "c".repeat(2000); + const html = `${para1}\n\n${para2}\n\n${para3}`; + + const chunks = chunkForTelegram(html); + assert.equal(chunks.length, 2); + for (const chunk of chunks) { + assert.ok( + chunk.length <= TELEGRAM_MESSAGE_LIMIT, + `chunk length ${chunk.length} exceeds limit ${TELEGRAM_MESSAGE_LIMIT}`, + ); + } + // Paragraph-boundary splits reconstruct the original via double newlines. + assert.equal(chunks.join("\n\n"), html); +}); + +test("chunkForTelegram returns empty array for empty string", () => { + assert.deepEqual(chunkForTelegram(""), []); +}); diff --git a/main/services/telegram/telegram-markdown.ts b/main/services/telegram/telegram-markdown.ts new file mode 100644 index 0000000..227e93a --- /dev/null +++ b/main/services/telegram/telegram-markdown.ts @@ -0,0 +1,228 @@ +// Markdown → Telegram HTML conversion and message chunking. +// +// Telegram supports a restricted HTML subset: , , ,
,
+// , , , 
. This converter handles the common +// Markdown that LLMs emit (headings, bold, italic, inline code, fenced +// code blocks, links, lists) and chunks the result under Telegram's +// 4096-character message limit, respecting code-block boundaries. +// +// Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT). + +/** Telegram message text limit. */ +export const TELEGRAM_MESSAGE_LIMIT = 4096; + +/** Safety margin so HTML entity expansion doesn't push past the limit. */ +const CHUNK_HEADROOM = 64; + +const CODE_SPAN_SENTINEL = String.fromCharCode(0); +const CODE_SPAN_PATTERN = new RegExp(`${CODE_SPAN_SENTINEL}CODESPAN(\\d+)${CODE_SPAN_SENTINEL}`, "g"); + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">"); +} + +/** Escape a URL for safe placement inside an HTML attribute value. */ +function escapeHref(url: string): string { + return url.replace(/"/g, """); +} + +/** Convert inline Markdown to Telegram HTML (no block-level handling). */ +function convertInline(markdown: string): string { + // Protect fenced code spans first so their content is not reformatted. + const codeSpans: string[] = []; + let working = markdown.replace(/```[\s\S]*?```/g, (match) => { + codeSpans.push(match); + return `${CODE_SPAN_SENTINEL}CODESPAN${codeSpans.length - 1}${CODE_SPAN_SENTINEL}`; + }); + + // Inline code: `code` + working = working.replace(/`([^`]+)`/g, (_m, code: string) => { + codeSpans.push(`${escapeHtml(code)}`); + return `${CODE_SPAN_SENTINEL}CODESPAN${codeSpans.length - 1}${CODE_SPAN_SENTINEL}`; + }); + + // Escape remaining HTML. + working = escapeHtml(working); + + // Bold: **text** or __text__ + working = working.replace(/\*\*(.+?)\*\*/g, "$1"); + working = working.replace(/__(.+?)__/g, "$1"); + + // Italic: *text* or _text_ + working = working.replace(/(?$1"); + working = working.replace(/(?$1"); + + // Strikethrough: ~~text~~ + working = working.replace(/~~(.+?)~~/g, "$1"); + + // Links: [text](url) — escape quotes in the URL for safe attribute injection. + working = working.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + (_m, text: string, url: string) => + `${text}`, + ); + + // Restore code spans. + working = working.replace(CODE_SPAN_PATTERN, (_m, idx: string) => { + const i = Number(idx); + if (i < codeSpans.length && codeSpans[i].startsWith("```")) { + return convertFencedBlock(codeSpans[i]); + } + return codeSpans[i] ?? ""; + }); + + return working; +} + +function convertFencedBlock(raw: string): string { + const match = /^```(\w*)\n?([\s\S]*?)```$/.exec(raw); + const lang = match?.[1]; + const code = match?.[2] ?? raw.slice(3, -3); + const escaped = escapeHtml(code.replace(/\n$/, "")); + return lang ? `
${escaped}
` : `
${escaped}
`; +} + +/** + * Convert a Markdown string to Telegram HTML. + * Handles headings, fenced code blocks, inline formatting, links, lists, and blockquotes. + */ +export function markdownToTelegramHtml(markdown: string): string { + const lines = markdown.split("\n"); + const output: string[] = []; + + let inFencedBlock = false; + let fenceMarker = ""; + let codeLines: string[] = []; + + for (const line of lines) { + // Detect fenced code block boundaries. + const fenceMatch = /^(`{3,})(\w*)/.exec(line); + if (fenceMatch && !inFencedBlock) { + inFencedBlock = true; + fenceMarker = fenceMatch[1]; + codeLines = [line]; + continue; + } + if (inFencedBlock) { + codeLines.push(line); + // Closing fence: backticks-only line, at least as long as the opening. + const trimmed = line.trim(); + if (trimmed.length >= fenceMarker.length && /^`+$/.test(trimmed)) { + inFencedBlock = false; + output.push(convertFencedBlock(codeLines.join("\n"))); + } + continue; + } + + // Headings → bold. + const headingMatch = /^(#{1,6})\s+(.*)$/.exec(line); + if (headingMatch) { + output.push(`${convertInline(headingMatch[2])}`); + continue; + } + + // Blockquote. + if (line.startsWith("> ")) { + output.push(`
${convertInline(line.slice(2))}
`); + continue; + } + + // Horizontal rule. + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { + output.push("\n\u2500\u2500\u2500\u2500\u2500\n"); + continue; + } + + // Blank line — preserve as paragraph break. + if (line.trim() === "") { + output.push(""); + continue; + } + + // Everything else: inline conversion. + output.push(convertInline(line)); + } + + // Unclosed fenced block: emit raw. + if (inFencedBlock) output.push(convertFencedBlock(codeLines.join("\n"))); + + return output.join("\n").trim(); +} + +/** + * Split HTML text into chunks under the Telegram message limit. + * Prefers splitting at double-newline boundaries (paragraph breaks); + * falls back to hard splits if a single paragraph exceeds the limit. + * Tracks
 state across chunk boundaries so tags stay balanced.
+ */
+export function chunkForTelegram(html: string): string[] {
+  const limit = TELEGRAM_MESSAGE_LIMIT - CHUNK_HEADROOM;
+  if (html.length <= limit) return html.length > 0 ? [html] : [];
+
+  const chunks: string[] = [];
+  const paragraphs = html.split(/\n\n+/);
+  let current = "";
+
+  for (const para of paragraphs) {
+    if (para.length > limit) {
+      if (current) {
+        chunks.push(current.trim());
+        current = "";
+      }
+      for (const piece of hardSplit(para, limit)) chunks.push(piece);
+      continue;
+    }
+
+    if ((current + "\n\n" + para).length > limit) {
+      chunks.push(current.trim());
+      current = para;
+    } else {
+      current = current ? `${current}\n\n${para}` : para;
+    }
+  }
+
+  if (current.trim()) chunks.push(current.trim());
+  return chunks.map(balancePreTags);
+}
+
+/**
+ * Ensure each chunk has balanced 
 tags. If a chunk opens 
+ * without closing it, append 
; if it closes
without + * opening one, prepend
.
+ */
+function balancePreTags(chunk: string): string {
+  const opens = (chunk.match(/]*>/g) ?? []).length;
+  const closes = (chunk.match(/<\/pre>/g) ?? []).length;
+  if (opens === closes) return chunk;
+  if (opens > closes) return chunk + "\n
".repeat(opens - closes); + return "
".repeat(closes - opens) + chunk;
+}
+
+/** Hard-split a long block at newline boundaries, then by char count. */
+function hardSplit(text: string, limit: number): string[] {
+  const chunks: string[] = [];
+  const lines = text.split("\n");
+  let current = "";
+
+  for (const line of lines) {
+    if ((current + "\n" + line).length > limit) {
+      if (current) chunks.push(current.trim());
+      if (line.length > limit) {
+        for (let i = 0; i < line.length; i += limit) {
+          chunks.push(line.slice(i, i + limit));
+        }
+        current = "";
+      } else {
+        current = line;
+      }
+    } else {
+      current = current ? `${current}\n${line}` : line;
+    }
+  }
+
+  if (current.trim()) chunks.push(current.trim());
+  return chunks;
+}
diff --git a/main/services/telegram/telegram-queue.test.ts b/main/services/telegram/telegram-queue.test.ts
new file mode 100644
index 0000000..5270c68
--- /dev/null
+++ b/main/services/telegram/telegram-queue.test.ts
@@ -0,0 +1,164 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+  createTelegramQueue,
+  classifyMessage,
+  type QueuedTelegramTurn,
+  type QueueLane,
+} from "./telegram-queue.js";
+
+/** Mutable deps so tests can flip gate state mid-scenario. */
+function makeDeps() {
+  const state = { active: false, pending: false };
+  return {
+    state,
+    isActive: () => state.active,
+    hasPendingDispatch: () => state.pending,
+  };
+}
+
+function turn(lane: QueueLane, text: string, chatId = 1): QueuedTelegramTurn {
+  return { lane, text, chatId, ownerUserId: chatId, workspaceId: undefined };
+}
+
+test("dequeue order is control, then priority, then default", () => {
+  const deps = makeDeps();
+  const queue = createTelegramQueue(deps);
+  // Enqueue out of lane precedence order to prove ordering is lane-driven.
+  queue.enqueue(turn("default", "d1"));
+  queue.enqueue(turn("priority", "p1"));
+  queue.enqueue(turn("control", "c1"));
+
+  assert.equal(queue.size(), 3);
+  assert.equal(queue.dequeue()?.text, "c1");
+  assert.equal(queue.dequeue()?.text, "p1");
+  assert.equal(queue.dequeue()?.text, "d1");
+  assert.equal(queue.dequeue(), null);
+});
+
+test("gates block non-control lanes when isActive() is true", () => {
+  const deps = makeDeps();
+  const queue = createTelegramQueue(deps);
+  queue.enqueue(turn("priority", "p1"));
+  queue.enqueue(turn("default", "d1"));
+  deps.state.active = true;
+
+  assert.equal(queue.peek(), null);
+  assert.equal(queue.dequeue(), null);
+  // Gates block dispatch without dropping queued content.
+  assert.equal(queue.size(), 2);
+
+  // Releasing the gate resumes dispatch in lane order.
+  deps.state.active = false;
+  assert.equal(queue.peek()?.text, "p1");
+  assert.equal(queue.dequeue()?.text, "p1");
+});
+
+test("gates block non-control lanes when hasPendingDispatch() is true", () => {
+  const deps = makeDeps();
+  const queue = createTelegramQueue(deps);
+  queue.enqueue(turn("priority", "p1"));
+  queue.enqueue(turn("default", "d1"));
+  deps.state.pending = true;
+
+  assert.equal(queue.peek(), null);
+  assert.equal(queue.dequeue(), null);
+  assert.equal(queue.size(), 2);
+
+  deps.state.pending = false;
+  assert.equal(queue.dequeue()?.text, "p1");
+});
+
+test("control lane bypasses gates and peeks even when active", () => {
+  const deps = makeDeps();
+  const queue = createTelegramQueue(deps);
+  queue.enqueue(turn("default", "d1"));
+  queue.enqueue(turn("control", "c1"));
+  // Both gates held — non-control lanes must stay blocked.
+  deps.state.active = true;
+  deps.state.pending = true;
+
+  assert.equal(queue.peek()?.text, "c1");
+  assert.equal(queue.dequeue()?.text, "c1");
+  // Control drained → gates now block the remaining default item.
+  assert.equal(queue.peek(), null);
+  assert.equal(queue.dequeue(), null);
+  assert.equal(queue.size(), 1);
+});
+
+test("drainControl() removes and returns all control items", () => {
+  const deps = makeDeps();
+  const queue = createTelegramQueue(deps);
+  queue.enqueue(turn("control", "c1"));
+  queue.enqueue(turn("control", "c2"));
+  queue.enqueue(turn("default", "d1"));
+  assert.equal(queue.size(), 3);
+
+  const drained = queue.drainControl();
+  assert.deepEqual(
+    drained.map((t) => t.text),
+    ["c1", "c2"],
+  );
+  assert.equal(queue.size(), 1);
+  // No control items remain after draining.
+  assert.deepEqual(queue.drainControl(), []);
+});
+
+test("clear() empties all lanes", () => {
+  const deps = makeDeps();
+  const queue = createTelegramQueue(deps);
+  queue.enqueue(turn("control", "c1"));
+  queue.enqueue(turn("priority", "p1"));
+  queue.enqueue(turn("default", "d1"));
+  assert.equal(queue.isEmpty(), false);
+
+  queue.clear();
+  assert.equal(queue.size(), 0);
+  assert.equal(queue.isEmpty(), true);
+  assert.equal(queue.peek(), null);
+});
+
+test("classifyMessage routes slash commands to control and the rest to default", () => {
+  assert.equal(classifyMessage("/start"), "control");
+  assert.equal(classifyMessage("/stop"), "control");
+  assert.equal(classifyMessage("/status"), "control");
+  assert.equal(classifyMessage("hello"), "default");
+  // Leading whitespace is trimmed before classification.
+  assert.equal(classifyMessage("   /start"), "control");
+  assert.equal(classifyMessage("   hello"), "default");
+});
+
+test("FIFO ordering within the control lane", () => {
+  const deps = makeDeps();
+  const queue = createTelegramQueue(deps);
+  queue.enqueue(turn("control", "c1"));
+  queue.enqueue(turn("control", "c2"));
+  queue.enqueue(turn("control", "c3"));
+
+  assert.equal(queue.dequeue()?.text, "c1");
+  assert.equal(queue.dequeue()?.text, "c2");
+  assert.equal(queue.dequeue()?.text, "c3");
+  assert.equal(queue.dequeue(), null);
+});
+
+test("FIFO ordering within the priority lane", () => {
+  const deps = makeDeps();
+  const queue = createTelegramQueue(deps);
+  queue.enqueue(turn("priority", "p1"));
+  queue.enqueue(turn("priority", "p2"));
+
+  assert.equal(queue.dequeue()?.text, "p1");
+  assert.equal(queue.dequeue()?.text, "p2");
+});
+
+test("FIFO ordering within the default lane", () => {
+  const deps = makeDeps();
+  const queue = createTelegramQueue(deps);
+  queue.enqueue(turn("default", "d1"));
+  queue.enqueue(turn("default", "d2"));
+  queue.enqueue(turn("default", "d3"));
+
+  assert.equal(queue.dequeue()?.text, "d1");
+  assert.equal(queue.dequeue()?.text, "d2");
+  assert.equal(queue.dequeue()?.text, "d3");
+});
diff --git a/main/services/telegram/telegram-queue.ts b/main/services/telegram/telegram-queue.ts
new file mode 100644
index 0000000..f54e3bc
--- /dev/null
+++ b/main/services/telegram/telegram-queue.ts
@@ -0,0 +1,113 @@
+// Turn queue and dispatch gates for Telegram-originated prompts.
+//
+// Messages are classified into three lanes:
+//   control  — operator commands (/start, /stop, /status) that always
+//              jump the queue and bypass the LLM entirely.
+//   priority — short, user-explicit messages that should dispatch next.
+//   default  — normal prompts that wait their turn.
+//
+// Dispatch requires: no active turn AND no pending dispatch. This prevents
+// overlapping generations on the persistent Telegram chat (the llmClient
+// beginChatTurn admission gate is the hard backstop; this queue is the
+// cooperative layer that avoids hammering it).
+//
+// Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT).
+
+export type QueueLane = "control" | "priority" | "default";
+
+export interface QueuedTelegramTurn {
+  readonly lane: QueueLane;
+  readonly text: string;
+  /** Telegram chat ID — used for API calls (sendMessage, sendChatAction). */
+  readonly chatId: number;
+  /** Paired owner's Telegram user ID — used for the persistent Aiden chat key. */
+  readonly ownerUserId: number;
+  readonly fromUsername?: string;
+  /** Workspace selection captured when the prompt was accepted. */
+  readonly workspaceId?: string;
+}
+
+export interface TelegramQueueDependencies {
+  isActive(): boolean;
+  hasPendingDispatch(): boolean;
+}
+
+export function createTelegramQueue(deps: TelegramQueueDependencies) {
+  const control: QueuedTelegramTurn[] = [];
+  const priority: QueuedTelegramTurn[] = [];
+  const def: QueuedTelegramTurn[] = [];
+
+  function enqueue(turn: QueuedTelegramTurn): void {
+    switch (turn.lane) {
+      case "control":
+        control.push(turn);
+        break;
+      case "priority":
+        priority.push(turn);
+        break;
+      default:
+        def.push(turn);
+        break;
+    }
+  }
+
+  function size(): number {
+    return control.length + priority.length + def.length;
+  }
+
+  function isEmpty(): boolean {
+    return size() === 0;
+  }
+
+  /** Peek at the next dispatchable turn, or null if gates block or queue is empty. */
+  function peek(): QueuedTelegramTurn | null {
+    if (isEmpty()) return null;
+    // Control lane always dispatches next.
+    if (control.length > 0) return control[0]!;
+    // Priority + default require idle host and no active dispatch.
+    if (deps.isActive() || deps.hasPendingDispatch()) return null;
+    if (priority.length > 0) return priority[0]!;
+    return def[0] ?? null;
+  }
+
+  /** Dequeue the next dispatchable turn, or null if gates block. */
+  function dequeue(): QueuedTelegramTurn | null {
+    const next = peek();
+    if (!next) return null;
+    // Remove from the correct lane.
+    if (control.length > 0 && control[0] === next) control.shift();
+    else if (priority.length > 0 && priority[0] === next) priority.shift();
+    else def.shift();
+    return next;
+  }
+
+  function clear(): void {
+    control.length = 0;
+    priority.length = 0;
+    def.length = 0;
+  }
+
+  /** Drain control messages without dispatch gates (commands bypass the LLM). */
+  function drainControl(): QueuedTelegramTurn[] {
+    return control.splice(0, control.length);
+  }
+
+  return { enqueue, dequeue, peek, size, isEmpty, clear, drainControl };
+}
+
+export interface TelegramQueue {
+  enqueue(turn: QueuedTelegramTurn): void;
+  dequeue(): QueuedTelegramTurn | null;
+  peek(): QueuedTelegramTurn | null;
+  size(): number;
+  isEmpty(): boolean;
+  clear(): void;
+  drainControl(): QueuedTelegramTurn[];
+}
+
+/** Classify an inbound message into a queue lane. */
+export function classifyMessage(text: string): QueueLane {
+  const trimmed = text.trim();
+  if (trimmed.startsWith("/")) return "control";
+  return "default";
+}
diff --git a/main/services/telegram/telegram-service-core.test.ts b/main/services/telegram/telegram-service-core.test.ts
new file mode 100644
index 0000000..2ab2d40
--- /dev/null
+++ b/main/services/telegram/telegram-service-core.test.ts
@@ -0,0 +1,941 @@
+// Tests for the Telegram service core (telegram-service-core.ts).
+//
+// The service core is a DI factory that orchestrates: inbound long-polling,
+// owner pairing, queue dispatch, and outbound reply delivery. These tests
+// exercise the lifecycle and message-handling contract entirely through mock
+// dependencies — no network, no real LLM client, no real chat store.
+//
+// The poll loop (runPollLoop) is an detached async loop. To keep tests
+// deterministic, the mock getUpdates serves configured "batches" of updates
+// in order; once batches are exhausted it either (a) self-terminates the loop
+// via stop() or (b) parks on a never-resolving promise so the loop idles
+// without spinning. The mock sleep resolves instantly and just counts calls.
+
+import assert from "node:assert/strict";
+import { EventEmitter, once } from "node:events";
+import { test } from "node:test";
+import { createTelegramServiceCore } from "./telegram-service-core.js";
+import type {
+  TelegramBotApi,
+  TelegramMessage,
+  TelegramUpdate,
+  TelegramUser,
+} from "./telegram-bot-api.js";
+import type { TelegramConfig } from "./telegram-config.js";
+import type { TelegramTurnDeps } from "./telegram-turn.js";
+import type { AppSettings } from "../types.js";
+
+// ---------------------------------------------------------------------------
+// Fixtures
+// ---------------------------------------------------------------------------
+
+const BOT: TelegramUser = {
+  id: 1,
+  is_bot: true,
+  first_name: "Aiden",
+  username: "aiden_bot",
+};
+
+/** A human (non-bot) Telegram user. */
+function person(id: number, username?: string): TelegramUser {
+  return { id, is_bot: false, first_name: "Owner", username };
+}
+
+function makeMessage(
+  messageId: number,
+  from: TelegramUser,
+  text: string,
+  chatId = 100,
+): TelegramMessage {
+  return {
+    message_id: messageId,
+    from,
+    chat: { id: chatId, type: "private" },
+    date: 0,
+    text,
+  };
+}
+
+function makeUpdate(updateId: number, message: TelegramMessage): TelegramUpdate {
+  return { update_id: updateId, message };
+}
+
+// ---------------------------------------------------------------------------
+// Mock TelegramBotApi
+// ---------------------------------------------------------------------------
+
+interface MockApiOptions {
+  me?: TelegramUser;
+  batches?: TelegramUpdate[][];
+  autoStop?: boolean;
+  /** Invoked to break the poll loop (wired to service.stop()). */
+  stop: () => void;
+}
+
+interface SentMessage {
+  chatId: number;
+  text: string;
+  parseMode?: "HTML" | "MarkdownV2";
+  disablePreview?: boolean;
+}
+
+function createMockApi(opts: MockApiOptions) {
+  const pending = [...(opts.batches ?? [])];
+  const sentMessages: SentMessage[] = [];
+  const calls: string[] = [];
+  let getMeCalls = 0;
+  let getUpdatesCalls = 0;
+  let sendChatActionCalls = 0;
+  let answerCallbackQueryCalls = 0;
+
+  const api = {
+    sentMessages,
+    calls,
+    getMeCalls: () => getMeCalls,
+    getUpdatesCalls: () => getUpdatesCalls,
+    sendChatActionCalls: () => sendChatActionCalls,
+    answerCallbackQueryCalls: () => answerCallbackQueryCalls,
+    async getMe(): Promise {
+      getMeCalls += 1;
+      calls.push("getMe");
+      return opts.me ?? BOT;
+    },
+    async getUpdates(
+      _offset?: number,
+      _timeoutSeconds?: number,
+      _signal?: AbortSignal,
+    ): Promise {
+      getUpdatesCalls += 1;
+      calls.push("getUpdates");
+      if (pending.length > 0) return pending.shift() as TelegramUpdate[];
+      if (opts.autoStop ?? true) {
+        opts.stop();
+        return [];
+      }
+      // Park the loop without busy-spinning; stopping the service releases it.
+      if (!_signal) return [];
+      await once(_signal, "abort");
+      return [];
+    },
+    async sendMessage(p: {
+      chatId: number;
+      text: string;
+      parseMode?: "HTML" | "MarkdownV2";
+      disablePreview?: boolean;
+    }): Promise {
+      sentMessages.push({
+        chatId: p.chatId,
+        text: p.text,
+        parseMode: p.parseMode,
+        disablePreview: p.disablePreview,
+      });
+      return {
+        message_id: sentMessages.length,
+        chat: { id: p.chatId, type: "private" },
+        date: 0,
+        text: p.text,
+      };
+    },
+    async sendChatAction(_chatId: number, _action: string): Promise {
+      sendChatActionCalls += 1;
+    },
+    async editMessageText(): Promise {},
+    async answerCallbackQuery(): Promise {
+      answerCallbackQueryCalls += 1;
+    },
+  };
+  return api;
+}
+
+// ---------------------------------------------------------------------------
+// Mock TelegramConfig
+// ---------------------------------------------------------------------------
+
+interface MockConfigState {
+  enabled: boolean;
+  hasToken: boolean;
+  allowedUserId?: number;
+  telegramWorkspaceId?: string;
+}
+
+function createMockConfig(state: MockConfigState) {
+  const setSettingsCalls: Array> = [];
+  let clearOffsetCalls = 0;
+  let persistOffsetCalls = 0;
+
+  const baseSettings = (): AppSettings => ({
+    lastProviderId: "openai",
+    lastModel: "gpt-4",
+    telegramEnabled: state.enabled,
+    telegramAllowedUserId: state.allowedUserId,
+    telegramWorkspaceId: state.telegramWorkspaceId,
+  });
+
+  const config = {
+    state,
+    setSettingsCalls,
+    clearOffsetCalls: () => clearOffsetCalls,
+    persistOffsetCalls: () => persistOffsetCalls,
+    async snapshot() {
+      return {
+        enabled: state.enabled,
+        hasToken: state.hasToken,
+        allowedUserId: state.allowedUserId,
+        lastUpdateId: undefined,
+      };
+    },
+    async getOffset(): Promise {
+      return undefined;
+    },
+    async persistOffset(updateId: number): Promise {
+      persistOffsetCalls += 1;
+      void updateId;
+    },
+    async clearOffset(): Promise {
+      clearOffsetCalls += 1;
+    },
+    async getSettings(): Promise {
+      return baseSettings();
+    },
+    async setSettings(patch: Partial): Promise {
+      setSettingsCalls.push(patch);
+      if (patch.telegramAllowedUserId !== undefined) {
+        state.allowedUserId = patch.telegramAllowedUserId;
+      }
+      if (patch.telegramEnabled !== undefined) {
+        state.enabled = patch.telegramEnabled;
+      }
+      if ("telegramWorkspaceId" in patch) {
+        state.telegramWorkspaceId = patch.telegramWorkspaceId;
+      }
+      return { ...baseSettings(), ...patch };
+    },
+    async hasToken(): Promise {
+      return state.hasToken;
+    },
+  };
+  return config;
+}
+
+// ---------------------------------------------------------------------------
+// Mock TelegramTurnDeps
+// ---------------------------------------------------------------------------
+
+interface MockTurnOptions {
+  reply?: string;
+  /** Never resolve llmClient.start so the turn stays "active". */
+  pending?: boolean;
+  /** beginChatTurn returns null, simulating an already-busy chat. */
+  busy?: boolean;
+  /** Resolve the turn as a chat:error with this message. */
+  failMessage?: string;
+  workspace?: { kind: "assistant" } | { kind: "project"; workspaceId: string } | { kind: "stale" };
+  workspaceResolver?: (
+    workspaceId?: string,
+  ) => { kind: "assistant" } | { kind: "project"; workspaceId: string } | { kind: "stale" };
+}
+
+/** Minimal owner surface the turn shim drives back through send(). */
+interface TurnOwner {
+  send(channel: string, payload: unknown): void;
+}
+
+function createMockTurn(opts: MockTurnOptions = {}) {
+  let startCalls = 0;
+  let appendCalls = 0;
+  let createCalls = 0;
+  let releasedLeases = 0;
+  let settledLeases = 0;
+
+  const pendingStart = new EventEmitter();
+  let pendingOwner: TurnOwner | undefined;
+  const createdChats: Array<{ id: string; workspaceId?: string }> = [];
+  const startedParams: Array<{ chatId: string; workspaceId?: string; mode?: string }> = [];
+  const llmClient = {
+    beginChatTurn() {
+      if (opts.busy) return null;
+      return {
+        release: () => {
+          releasedLeases += 1;
+        },
+        settleAsyncWork: () => {
+          settledLeases += 1;
+        },
+      };
+    },
+    async start(
+      streamId: string,
+      _params: { chatId: string; workspaceId?: string; mode?: string },
+      owner: TurnOwner,
+      _options: unknown,
+    ): Promise {
+      startedParams.push(_params);
+      startCalls += 1;
+      if (opts.pending) {
+        pendingOwner = owner;
+        await once(pendingStart, "complete");
+        return true;
+      }
+      if (opts.failMessage) {
+        owner.send("chat:error", { streamId, message: opts.failMessage });
+        return true;
+      }
+      owner.send("chat:done", { streamId, content: opts.reply ?? "Mock reply" });
+      return true;
+    },
+    isChatBusy() {
+      return false;
+    },
+    async waitForChatIdle(): Promise {
+      return true;
+    },
+  };
+
+  const chatStore = {
+    async create(input: { id: string; title: string; workspaceId?: string }) {
+      createCalls += 1;
+      createdChats.push({ id: input.id, workspaceId: input.workspaceId });
+      return {
+        id: input.id,
+        title: input.title,
+        updatedAt: 0,
+        workspaceId: input.workspaceId,
+      };
+    },
+    async get(_id: string) {
+      return null;
+    },
+    async appendMessage(id: string, _message: unknown) {
+      appendCalls += 1;
+      return { id, title: "Telegram", updatedAt: 0 };
+    },
+  };
+
+  const turn = {
+    llmClient,
+    chatStore,
+    async resolveProvider() {
+      return {
+        providerId: "openai",
+        model: "gpt-4",
+        provider: {
+          id: "openai",
+          kind: "openai" as const,
+          label: "OpenAI",
+          baseUrl: "https://api.openai.com/v1",
+          needsKey: true,
+        },
+      };
+    },
+    async resolveWorkspace(workspaceId?: string) {
+      return (
+        opts.workspaceResolver?.(workspaceId) ?? opts.workspace ?? { kind: "assistant" as const }
+      );
+    },
+    broadcastMetadata(_chat: unknown) {},
+  };
+
+  return {
+    turn,
+    startCalls: () => startCalls,
+    appendCalls: () => appendCalls,
+    createCalls: () => createCalls,
+    createdChats: () => createdChats,
+    startedParams: () => startedParams,
+    releasedLeases: () => releasedLeases,
+    completePendingTurn: () => {
+      pendingOwner?.send("chat:done", { streamId: "pending-turn", content: "Mock reply" });
+      pendingStart.emit("complete");
+    },
+    settledLeases: () => settledLeases,
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Mock sleep + log capture
+// ---------------------------------------------------------------------------
+
+function createMockSleep() {
+  let calls = 0;
+  const lastArgs: number[] = [];
+  const sleep = async (ms: number, _signal?: AbortSignal): Promise => {
+    calls += 1;
+    lastArgs.push(ms);
+    // Yield to the event loop so concurrent loops (typing indicator) don't starve macrotasks.
+    await new Promise((resolve) => setImmediate(resolve));
+  };
+  return { sleep, calls: () => calls, lastArgs: () => lastArgs };
+}
+
+function createLogs() {
+  const info: string[] = [];
+  const warn: string[] = [];
+  const errors: Array<{ message: string; cause?: unknown }> = [];
+  return {
+    info,
+    warn,
+    errors,
+    infoFn: (m: string) => {
+      info.push(m);
+    },
+    warnFn: (m: string) => {
+      warn.push(m);
+    },
+    errorFn: (m: string, cause?: unknown) => {
+      errors.push({ message: m, cause });
+    },
+  };
+}
+
+// ---------------------------------------------------------------------------
+// Harness
+// ---------------------------------------------------------------------------
+
+interface HarnessOptions {
+  enabled?: boolean;
+  hasToken?: boolean;
+  allowedUserId?: number;
+  me?: TelegramUser;
+  batches?: TelegramUpdate[][];
+  autoStop?: boolean;
+  reply?: string;
+  pendingTurn?: boolean;
+  busyTurn?: boolean;
+  failMessage?: string;
+  workspace?: { kind: "assistant" } | { kind: "project"; workspaceId: string } | { kind: "stale" };
+  workspaces?: Array<{ id: string; name: string; folderPath: string }>;
+  telegramWorkspaceId?: string;
+  workspaceResolver?: MockTurnOptions["workspaceResolver"];
+}
+
+function harness(o: HarnessOptions = {}) {
+  const config = createMockConfig({
+    enabled: o.enabled ?? false,
+    hasToken: o.hasToken ?? true,
+    allowedUserId: o.allowedUserId,
+    telegramWorkspaceId: o.telegramWorkspaceId,
+  });
+  const turnMock = createMockTurn({
+    reply: o.reply,
+    pending: o.pendingTurn,
+    busy: o.busyTurn,
+    failMessage: o.failMessage,
+    workspace: o.workspace,
+    workspaceResolver:
+      o.workspaceResolver ??
+      ((workspaceId) => {
+        if (workspaceId) {
+          return o.workspaces?.some((workspace) => workspace.id === workspaceId)
+            ? { kind: "project" as const, workspaceId }
+            : { kind: "stale" as const };
+        }
+        return o.workspace ?? { kind: "assistant" as const };
+      }),
+  });
+  const sleepMock = createMockSleep();
+  const logs = createLogs();
+
+  // The api's termination hook calls stop(); wire it after the service exists.
+  let stopFn: () => void = () => undefined;
+
+  const api = createMockApi({
+    me: o.me,
+    batches: o.batches,
+    autoStop: o.autoStop,
+    stop: () => stopFn(),
+  });
+
+  const service = createTelegramServiceCore({
+    api: api as unknown as TelegramBotApi,
+    config: config as unknown as TelegramConfig,
+    turn: turnMock.turn as unknown as TelegramTurnDeps,
+    listWorkspaces: async () => o.workspaces ?? [],
+    getToken: () => Promise.resolve("mock-token"),
+    now: () => 0,
+    sleep: sleepMock.sleep,
+    info: logs.infoFn,
+    warn: logs.warnFn,
+    error: logs.errorFn,
+  });
+  stopFn = () => service.stop();
+
+  return { service, api, config, turnMock, sleepMock, logs };
+}
+
+/** Spin the event loop until predicate holds (or time out). */
+async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise {
+  const deadline = Date.now() + timeoutMs;
+  while (!predicate()) {
+    if (Date.now() > deadline) {
+      throw new Error(`waitFor timed out after ${timeoutMs}ms`);
+    }
+    await new Promise((resolve) => setImmediate(resolve));
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle
+// ---------------------------------------------------------------------------
+
+test("start() is a no-op when the bridge is not enabled (status stays disabled)", async () => {
+  const { service, api } = harness({ enabled: false, hasToken: true });
+
+  await service.start();
+
+  assert.equal(api.getMeCalls(), 0, "getMe not called");
+  assert.equal(api.getUpdatesCalls(), 0, "getUpdates not called");
+  assert.equal(service.getStatus().status, "disabled");
+});
+
+test("start() begins polling when enabled with a token (getMe then getUpdates)", async () => {
+  const { service, api } = harness({
+    enabled: true,
+    hasToken: true,
+    batches: [],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => api.getMeCalls() >= 1 && api.getUpdatesCalls() >= 1);
+
+  assert.ok(api.getMeCalls() >= 1, "getMe was called");
+  assert.ok(api.getUpdatesCalls() >= 1, "getUpdates was called");
+  assert.ok(
+    api.calls.indexOf("getMe") < api.calls.indexOf("getUpdates"),
+    "getMe precedes getUpdates",
+  );
+  assert.equal(service.getStatus().botUsername, "aiden_bot");
+});
+
+test("stop() halts polling and clears the queue", async () => {
+  const owner = person(42, "owner");
+  const { service, api } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    batches: [
+      [
+        makeUpdate(1, makeMessage(10, owner, "first")),
+        makeUpdate(2, makeMessage(11, owner, "second")),
+      ],
+    ],
+    autoStop: false,
+    pendingTurn: true,
+  });
+
+  await service.start();
+  // First message dispatches and hangs (pending turn); second is gated behind it.
+  await waitFor(() => service.isActive && service.queueSize >= 1);
+
+  assert.ok(api.getMeCalls() >= 1, "polling had begun");
+  assert.ok(api.getUpdatesCalls() >= 1, "polling had begun");
+  assert.ok(service.queueSize >= 1, "queue held the gated turn");
+
+  service.stop();
+
+  assert.equal(service.queueSize, 0, "queue cleared");
+  // activeTurn stays true while the in-flight turn is running — stop() must
+  // not reset it, or a concurrent dispatch on the same chat would be allowed.
+  assert.equal(service.isActive, true, "in-flight turn still tracked as active");
+  assert.equal(service.getStatus().status, "disabled");
+});
+
+test("connect() throws when no bot token is configured", async () => {
+  const { service, api } = harness({ enabled: true, hasToken: false });
+
+  await assert.rejects(service.connect(), /Cannot connect without a bot token/);
+
+  assert.equal(api.getMeCalls(), 0, "no polling attempted");
+  assert.equal(api.getUpdatesCalls(), 0, "no polling attempted");
+  assert.equal(service.getStatus().status, "disabled");
+});
+
+test("disconnect() clears polling and resets the persisted offset", async () => {
+  const { service, api, config } = harness({
+    enabled: true,
+    hasToken: true,
+    batches: [],
+    autoStop: false,
+  });
+
+  await service.start();
+  await waitFor(() => api.getMeCalls() >= 1);
+  assert.equal(service.getStatus().botUsername, "aiden_bot", "polling had begun");
+
+  await service.disconnect();
+
+  assert.equal(config.clearOffsetCalls(), 1, "offset cleared on disconnect");
+  assert.equal(service.getStatus().status, "disabled");
+});
+
+// ---------------------------------------------------------------------------
+// Authorization / pairing
+// ---------------------------------------------------------------------------
+
+test("first message from a non-bot user pairs the owner (sets telegramAllowedUserId)", async () => {
+  const newcomer = person(777, "newcomer");
+  const { service, api, config } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: undefined,
+    batches: [[makeUpdate(1, makeMessage(10, newcomer, "hi"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => config.setSettingsCalls.length >= 1);
+
+  assert.deepEqual(config.setSettingsCalls[0], { telegramAllowedUserId: 777 });
+  assert.equal(config.state.allowedUserId, 777, "pairing reflected in snapshot state");
+
+  await waitFor(() => api.sentMessages.some((m) => m.text.includes("paired")));
+  assert.ok(
+    api.sentMessages.some((m) => m.text.includes("paired")),
+    "pairing acknowledgement sent",
+  );
+});
+
+test("messages from a user other than the paired owner are ignored", async () => {
+  const intruder = person(999, "intruder");
+  const { service, api, turnMock, logs } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    batches: [[makeUpdate(1, makeMessage(10, intruder, "let me in"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => logs.warn.length >= 1);
+
+  assert.ok(
+    logs.warn.some((m) => m.includes("999")),
+    "unauthorized user id logged",
+  );
+  assert.equal(turnMock.startCalls(), 0, "no LLM turn started");
+  assert.equal(service.queueSize, 0, "nothing enqueued");
+  assert.equal(api.sentMessages.length, 0, "no reply sent");
+});
+
+// ---------------------------------------------------------------------------
+// Control commands
+// ---------------------------------------------------------------------------
+
+test("/start replies with a help message and starts no LLM turn", async () => {
+  const owner = person(42, "owner");
+  const { service, api, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    batches: [[makeUpdate(1, makeMessage(10, owner, "/start"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => api.sentMessages.some((m) => m.text.includes("Aiden Telegram Bridge")));
+
+  assert.equal(turnMock.startCalls(), 0, "no LLM turn for /start");
+  assert.equal(service.queueSize, 0, "command not enqueued");
+
+  const help = api.sentMessages.find((m) => m.text.includes("Aiden Telegram Bridge"));
+  assert.ok(help, "help message present");
+  assert.equal(help?.parseMode, undefined, "help sent as plain text");
+  assert.match(help.text, /\/workspace — list and choose a workspace/);
+});
+
+test("/workspace lists configured folders without creating a turn", async () => {
+  const owner = person(42, "owner");
+  const { service, api, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    workspaces: [
+      { id: "aiden", name: "Aiden", folderPath: "/tmp/aiden" },
+      { id: "notes", name: "Notes", folderPath: "/tmp/notes" },
+    ],
+    batches: [[makeUpdate(1, makeMessage(10, owner, "/workspace"))]],
+
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => api.sentMessages.some((message) => message.text.includes("Aiden")));
+
+  const reply = api.sentMessages.find((message) => message.text.includes("Aiden"));
+  assert.ok(reply, "workspace list sent");
+  assert.match(reply.text, /1\. Aiden/);
+  assert.match(reply.text, /2\. Notes/);
+  assert.match(reply.text, /\/workspace /);
+  assert.equal(turnMock.startCalls(), 0, "workspace command is never an LLM prompt");
+});
+test("/workspace identifies the currently selected workspace", async () => {
+  const owner = person(42, "owner");
+  const { service, api } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    telegramWorkspaceId: "notes",
+    workspaces: [{ id: "notes", name: "Notes", folderPath: "/tmp/notes" }],
+    batches: [[makeUpdate(1, makeMessage(10, owner, "/workspace"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => api.sentMessages.some((message) => message.text.includes("Current: Notes")));
+});
+
+test("/workspace number persists the selected configured workspace", async () => {
+  const owner = person(42, "owner");
+  const { service, api, config, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    workspaces: [
+      { id: "aiden", name: "Aiden", folderPath: "/tmp/aiden" },
+      { id: "notes", name: "Notes", folderPath: "/tmp/notes" },
+    ],
+    batches: [[makeUpdate(1, makeMessage(10, owner, "/workspace 2"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => config.setSettingsCalls.some((patch) => "telegramWorkspaceId" in patch));
+
+  assert.deepEqual(config.setSettingsCalls, [{ telegramWorkspaceId: "notes" }]);
+  assert.equal(config.state.telegramWorkspaceId, "notes");
+  assert.equal(turnMock.startCalls(), 0, "workspace command is never an LLM prompt");
+  assert.ok(
+    api.sentMessages.some((message) => message.text.includes("Notes")),
+    "selection acknowledgement sent",
+  );
+});
+
+test("/workspace selection scopes the following Telegram prompt", async () => {
+  const owner = person(42, "owner");
+  const { service, api, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    workspaces: [{ id: "notes", name: "Notes", folderPath: "/tmp/notes" }],
+    batches: [
+      [
+        makeUpdate(1, makeMessage(10, owner, "/workspace notes")),
+        makeUpdate(2, makeMessage(11, owner, "list files")),
+      ],
+    ],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => turnMock.startCalls() === 1);
+
+  assert.deepEqual(turnMock.createdChats(), [{ id: "telegram-42-notes", workspaceId: "notes" }]);
+  const [started] = turnMock.startedParams();
+  assert.equal(started?.chatId, "telegram-42-notes");
+  assert.equal(started?.workspaceId, "notes");
+  assert.equal(started?.mode, "assistant-automation");
+  assert.ok(
+    api.sentMessages.some((message) => message.text.includes("Mock reply")),
+    "scoped reply delivered",
+  );
+});
+
+test("/workspace preserves consecutive spaces in an exact workspace name", async () => {
+  const owner = person(42, "owner");
+  const { service, api, config, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    workspaces: [{ id: "team-notes", name: "Team  Notes", folderPath: "/tmp/team-notes" }],
+    batches: [[makeUpdate(1, makeMessage(10, owner, "/workspace Team  Notes"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => config.setSettingsCalls.length === 1);
+
+  assert.deepEqual(config.setSettingsCalls, [{ telegramWorkspaceId: "team-notes" }]);
+  assert.equal(turnMock.startCalls(), 0, "workspace command is never an LLM prompt");
+  assert.ok(
+    api.sentMessages.some((message) =>
+      message.text.includes("Telegram workspace set to Team  Notes."),
+    ),
+    "exact workspace name is acknowledged",
+  );
+});
+
+test("/workspace rejects a case-mismatched workspace name", async () => {
+  const owner = person(42, "owner");
+  const { service, api, config, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    workspaces: [{ id: "workspace-notes", name: "Notes", folderPath: "/tmp/notes" }],
+    batches: [[makeUpdate(1, makeMessage(10, owner, "/workspace notes"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() =>
+    api.sentMessages.some((message) => message.text.includes('Workspace "notes" was not found.')),
+  );
+
+  assert.deepEqual(config.setSettingsCalls, []);
+  assert.equal(turnMock.startCalls(), 0, "workspace command is never an LLM prompt");
+});
+
+test("queued prompts retain the workspace selection present at receipt", async () => {
+  const owner = person(42, "owner");
+  const { service, config, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    pendingTurn: true,
+    workspaces: [{ id: "notes", name: "Notes", folderPath: "/tmp/notes" }],
+    batches: [
+      [
+        makeUpdate(1, makeMessage(10, owner, "first prompt")),
+        makeUpdate(2, makeMessage(11, owner, "queued prompt")),
+      ],
+    ],
+    autoStop: false,
+  });
+
+  await service.start();
+  await waitFor(() => turnMock.startCalls() === 1);
+  await config.setSettings({ telegramWorkspaceId: "notes" });
+  turnMock.completePendingTurn();
+  await waitFor(() => turnMock.startCalls() === 2);
+
+  assert.deepEqual(
+    turnMock.startedParams().map(({ workspaceId, mode }) => ({ workspaceId, mode })),
+    [
+      { workspaceId: undefined, mode: "assistant-unattended" },
+      { workspaceId: undefined, mode: "assistant-unattended" },
+    ],
+  );
+  service.stop();
+});
+
+test("/workspace off restores assistant-only mode", async () => {
+  const owner = person(42, "owner");
+  const { service, api, config, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    telegramWorkspaceId: "notes",
+    batches: [[makeUpdate(1, makeMessage(10, owner, "/workspace off"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => config.setSettingsCalls.some((patch) => "telegramWorkspaceId" in patch));
+
+  assert.deepEqual(config.setSettingsCalls, [{ telegramWorkspaceId: undefined }]);
+  assert.equal(config.state.telegramWorkspaceId, undefined);
+  assert.equal(turnMock.startCalls(), 0, "workspace command is never an LLM prompt");
+  assert.ok(
+    api.sentMessages.some((message) => message.text.includes("assistant-only mode")),
+    "assistant-only acknowledgement sent",
+  );
+});
+
+test("/status replies with bridge status info", async () => {
+  const owner = person(42, "owner");
+  const { service, api } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    batches: [[makeUpdate(1, makeMessage(10, owner, "/status"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => api.sentMessages.some((m) => m.text.includes("Bot:")));
+
+  const status = api.sentMessages.find((m) => m.text.includes("Bot:"));
+  assert.ok(status, "status message sent");
+  assert.match(status?.text ?? "", /Bot: @aiden_bot/);
+  assert.match(status?.text ?? "", /Paired owner: 42/);
+  assert.match(status?.text ?? "", /Queue:/);
+});
+
+// ---------------------------------------------------------------------------
+// Turn dispatch
+// ---------------------------------------------------------------------------
+
+test("a text message from the owner is dispatched as a headless turn and replied", async () => {
+  const owner = person(42, "owner");
+  const { service, api, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    batches: [[makeUpdate(1, makeMessage(10, owner, "hello there"))]],
+    autoStop: true,
+    reply: "Hi from Aiden",
+  });
+
+  await service.start();
+  await waitFor(() => turnMock.startCalls() >= 1);
+  await waitFor(() => api.sentMessages.some((m) => m.text.includes("Hi from Aiden")));
+
+  assert.equal(turnMock.startCalls(), 1, "exactly one turn dispatched");
+  assert.equal(service.queueSize, 0, "queue drained after dispatch");
+  assert.equal(service.isActive, false, "turn settled");
+
+  const reply = api.sentMessages.find((m) => m.text.includes("Hi from Aiden"));
+  assert.ok(reply, "reply delivered");
+  assert.equal(reply?.parseMode, "HTML", "reply delivered as Telegram HTML");
+  assert.equal(reply?.disablePreview, true, "link preview disabled");
+});
+
+test("a scoped Telegram prompt uses an isolated project chat", async () => {
+  const owner = person(42, "owner");
+  const { service, api, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    workspace: { kind: "project", workspaceId: "workspace-a" },
+    batches: [[makeUpdate(1, makeMessage(10, owner, "list files"))]],
+    autoStop: true,
+  });
+
+  await service.start();
+  await waitFor(() => turnMock.startCalls() === 1);
+  await waitFor(() => api.sentMessages.some((message) => message.text.includes("Mock reply")));
+
+  assert.deepEqual(turnMock.createdChats(), [
+    { id: "telegram-42-workspace-a", workspaceId: "workspace-a" },
+  ]);
+  const [started] = turnMock.startedParams();
+  assert.equal(started?.chatId, "telegram-42-workspace-a");
+  assert.equal(started?.workspaceId, "workspace-a");
+  assert.equal(started?.mode, "assistant-automation");
+});
+
+test("dispatch gate blocks a second turn while one is already active", async () => {
+  const owner = person(42, "owner");
+  const { service, turnMock } = harness({
+    enabled: true,
+    hasToken: true,
+    allowedUserId: 42,
+    batches: [
+      [
+        makeUpdate(1, makeMessage(10, owner, "first")),
+        makeUpdate(2, makeMessage(11, owner, "second")),
+      ],
+    ],
+    autoStop: false,
+    pendingTurn: true,
+  });
+
+  await service.start();
+  await waitFor(() => service.isActive);
+  await waitFor(() => service.queueSize >= 1);
+
+  assert.equal(turnMock.startCalls(), 1, "only the first turn was dispatched");
+  assert.equal(service.isActive, true, "a turn is active");
+  assert.ok(service.queueSize >= 1, "second turn held behind the active turn");
+
+  service.stop();
+});
diff --git a/main/services/telegram/telegram-service-core.ts b/main/services/telegram/telegram-service-core.ts
new file mode 100644
index 0000000..2f564ad
--- /dev/null
+++ b/main/services/telegram/telegram-service-core.ts
@@ -0,0 +1,486 @@
+// Telegram service core — the polling orchestrator that ties together
+// inbound handling, owner pairing, queue dispatch, and outbound reply delivery.
+//
+// This is the pure factory that schedule-service-core.ts / schedule-service.ts
+// models: start() / stop() / stopAndSettle(), idempotent lifecycle, DI for tests.
+//
+// Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT).
+
+import type { TelegramBotApi, TelegramUpdate, TelegramMessage } from "./telegram-bot-api.js";
+import type { TelegramConfig } from "./telegram-config.js";
+import type { TelegramTurnDeps, TelegramTurnResult } from "./telegram-turn.js";
+import { sendTelegramTurn, ensureTelegramChat, telegramChatId } from "./telegram-turn.js";
+import {
+  createTelegramQueue,
+  classifyMessage,
+  type TelegramQueue,
+  type QueuedTelegramTurn,
+} from "./telegram-queue.js";
+import { markdownToTelegramHtml, chunkForTelegram } from "./telegram-markdown.js";
+
+const POLL_TIMEOUT_SECONDS = 30;
+const ERROR_SLEEP_MS = 3_000;
+const TYPING_INTERVAL_MS = 2_500;
+
+/** Extract text content from a Telegram message (text or caption). */
+function extractText(message: TelegramMessage): string | undefined {
+  return message.text ?? message.caption;
+}
+
+export type TelegramServiceStatus = "disabled" | "idle" | "polling" | "error";
+
+export interface TelegramServiceState {
+  status: TelegramServiceStatus;
+  botUsername?: string;
+  lastError?: string;
+  queuedCount: number;
+}
+
+export interface TelegramServiceDeps {
+  api: TelegramBotApi;
+  config: TelegramConfig;
+  turn: TelegramTurnDeps;
+  getToken(): Promise;
+  now(): number;
+  sleep(ms: number, signal?: AbortSignal): Promise;
+  warn(message: string): void;
+  error(message: string, cause?: unknown): void;
+  info(message: string): void;
+  listWorkspaces(): Promise;
+}
+
+interface TelegramSelectableWorkspace {
+  id: string;
+  name: string;
+  folderPath: string;
+}
+
+const TELEGRAM_HELP_TEXT = [
+  "🤖 Aiden Telegram Bridge",
+  "",
+  "Send any message and I'll respond as Aiden.",
+  "",
+  "Commands:",
+  "/start — show this help",
+  "/stop — clear queued messages",
+  "/status — show bridge status",
+  "/workspace — list and choose a workspace",
+].join("\n");
+
+export function createTelegramServiceCore(deps: TelegramServiceDeps) {
+  const queue: TelegramQueue = createTelegramQueue({
+    isActive: () => activeTurn,
+    hasPendingDispatch: () => dispatchPending,
+  });
+
+  let started = false;
+  let abortController: AbortController | undefined;
+  let activeTurn = false;
+  let dispatchPending = false;
+  let botUsername: string | undefined;
+  let lastError: string | undefined;
+
+  function getStatus(): TelegramServiceState {
+    return {
+      status: started ? (lastError ? "error" : "polling") : "disabled",
+      botUsername,
+      lastError,
+      queuedCount: queue.size(),
+    };
+  }
+
+  async function start(): Promise {
+    if (started) return;
+    const snap = await deps.config.snapshot();
+    if (!snap.enabled || !snap.hasToken) return;
+    started = true;
+    lastError = undefined;
+    abortController = new AbortController();
+    void runPollLoop(abortController.signal);
+    deps.info("Telegram bridge started.");
+  }
+
+  function stop(): void {
+    started = false;
+    abortController?.abort();
+    abortController = undefined;
+    queue.clear();
+    // Do NOT reset activeTurn/dispatchPending here — the in-flight
+    // dispatchTurn's finally block owns those. Resetting mid-turn
+    // would allow a concurrent dispatch on the same chat.
+  }
+
+  async function stopAndSettle(): Promise {
+    stop();
+    // The abort signal cancels polling; active LLM turns settle on their own.
+  }
+
+  /** Force-start after enablement/token changes (bypasses the enabled gate). */
+  async function connect(): Promise {
+    const snap = await deps.config.snapshot();
+    if (!snap.hasToken) throw new Error("Cannot connect without a bot token.");
+    if (started) return;
+    started = true;
+    lastError = undefined;
+    abortController = new AbortController();
+    void runPollLoop(abortController.signal);
+    deps.info("Telegram bridge connected.");
+  }
+
+  /** Disconnect and clear pairing (keeps the token). */
+  async function disconnect(): Promise {
+    stop();
+    await deps.config.clearOffset();
+    deps.info("Telegram bridge disconnected.");
+  }
+
+  async function runPollLoop(signal: AbortSignal): Promise {
+    try {
+      const me = await deps.api.getMe();
+      botUsername = me.username;
+      deps.info(`Telegram bot connected as @${me.username}.`);
+    } catch (cause) {
+      lastError = cause instanceof Error ? cause.message : String(cause);
+      deps.error("Telegram getMe failed.", cause);
+      started = false;
+      return;
+    }
+
+    let offset = await deps.config.getOffset();
+
+    while (started && !signal.aborted) {
+      let updates: TelegramUpdate[];
+      try {
+        updates = await deps.api.getUpdates(offset, POLL_TIMEOUT_SECONDS, signal);
+      } catch (cause) {
+        if (signal.aborted || !started) break;
+        lastError = cause instanceof Error ? cause.message : String(cause);
+        deps.error("Telegram getUpdates failed.", cause);
+        await deps.sleep(ERROR_SLEEP_MS, signal).catch(() => undefined);
+        continue;
+      }
+
+      lastError = undefined;
+
+      for (const update of updates) {
+        let handled = false;
+        try {
+          await handleUpdate(update);
+          handled = true;
+        } catch (cause) {
+          deps.error(`Telegram handleUpdate failed for ${update.update_id}.`, cause);
+        }
+        // Persist the resume offset (update_id + 1) ONLY after successful
+        // handling. On failure the update will be retried on the next poll.
+        // Monotonic max is enforced inside persistOffset.
+        if (handled) {
+          offset = update.update_id + 1;
+          await deps.config.persistOffset(update.update_id + 1);
+        }
+      }
+
+      tryDispatch();
+    }
+  }
+
+  async function handleUpdate(update: TelegramUpdate): Promise {
+    const message = update.message ?? update.callback_query?.message;
+    const from = update.message?.from ?? update.callback_query?.from;
+    if (!message || !from) return;
+
+    deps.info(
+      `Telegram update ${update.update_id}: from=${from.id} (${from.username ?? "no-username"}) chat=${message.chat.id} type=${message.chat.type} text="${(message.text ?? "").slice(0, 80)}"`,
+    );
+
+    // Restrict to private chats — group/supergroup messages are ignored.
+    if (message.chat.type !== "private") return;
+
+    const snap = await deps.config.snapshot();
+
+    // Authorization / pairing gate (checked before answering callbacks).
+    if (snap.allowedUserId === undefined) {
+      // First non-bot user to message becomes the owner.
+      if (from.is_bot) return;
+      await deps.config.setSettings({ telegramAllowedUserId: from.id });
+      // Answer any pending callback after pairing.
+      if (update.callback_query) {
+        await deps.api.answerCallbackQuery(update.callback_query.id);
+      }
+      await deps.api.sendMessage({
+        chatId: message.chat.id,
+        text: "✅ Telegram bridge paired with this account.\n\nSend any message and I'll respond. Use /stop to clear queued messages.",
+      });
+      return;
+    }
+
+    // Only the paired owner is authorized.
+    if (from.id !== snap.allowedUserId) {
+      deps.warn(`Telegram: unauthorized user ${from.id} ignored.`);
+      return;
+    }
+
+    // Answer callback queries after the authorization gate.
+    if (update.callback_query) {
+      await deps.api.answerCallbackQuery(update.callback_query.id);
+    }
+
+    const text = extractText(message);
+    if (!text) return;
+
+    // Control lane: commands are handled immediately (no LLM).
+    if (text.startsWith("/")) {
+      await handleCommand(text.trim(), message);
+      return;
+    }
+
+    await enqueuePrompt({
+      lane: classifyMessage(text),
+      text,
+      chatId: message.chat.id,
+      ownerUserId: from.id,
+      fromUsername: from.username,
+    });
+  }
+
+  /**
+   * Capture workspace authority before a prompt joins the queue. A later local
+   * Settings or Telegram /workspace change must not retarget an accepted prompt.
+   */
+  async function enqueuePrompt(turn: Omit): Promise {
+    const settings = await deps.config.getSettings();
+    queue.enqueue({ ...turn, workspaceId: settings.telegramWorkspaceId });
+    tryDispatch();
+  }
+
+  async function handleCommand(command: string, message: TelegramMessage): Promise {
+    const cmd = (command.split(/\s+/)[0]?.toLowerCase() ?? "").split("@")[0];
+    const chatId = message.chat.id;
+
+    if (cmd === "/start" || cmd === "/help") {
+      await deps.api.sendMessage({ chatId, text: TELEGRAM_HELP_TEXT });
+      return;
+    }
+
+    if (cmd === "/workspace") {
+      const workspaces = await deps.listWorkspaces();
+      const settings = await deps.config.getSettings();
+      const selectedWorkspaceId = settings.telegramWorkspaceId;
+      const selectedWorkspace = workspaces.find(
+        (workspace) => workspace.id === selectedWorkspaceId,
+      );
+      const separator = command.search(/\s/u);
+      const selection = separator === -1 ? "" : command.slice(separator).trim();
+      if (!selection) {
+        const lines =
+          workspaces.length === 0
+            ? [
+                "No configured folder workspaces are available.",
+                "Add a folder workspace in Aiden Settings, then try /workspace again.",
+              ]
+            : [
+                "🗂️ Telegram workspace",
+                `Current: ${
+                  selectedWorkspace
+                    ? selectedWorkspace.name
+                    : selectedWorkspaceId
+                      ? "saved workspace unavailable"
+                      : "assistant-only mode"
+                }`,
+                "",
+                ...workspaces.flatMap((workspace, index) => [
+                  `${index + 1}. ${workspace.name}`,
+                  `   ${workspace.folderPath}`,
+                ]),
+                "",
+                "Choose one with /workspace .",
+                "Use /workspace off for assistant-only mode.",
+              ];
+        await deps.api.sendMessage({ chatId, text: lines.join("\n") });
+        return;
+      }
+
+      if (selection.toLowerCase() === "off") {
+        const hadQueued = queue.size();
+        queue.clear();
+        await deps.config.setSettings({ telegramWorkspaceId: undefined });
+        await deps.api.sendMessage({
+          chatId,
+          text: [
+            "Telegram workspace cleared. Future turns will run in assistant-only mode.",
+            ...(hadQueued > 0 ? [`Cleared ${hadQueued} queued message(s).`] : []),
+          ].join("\n"),
+        });
+        return;
+      }
+
+      const position = /^\d+$/u.test(selection) ? Number(selection) - 1 : -1;
+      const matches = workspaces.filter(
+        (workspace) => workspace.id === selection || workspace.name === selection,
+      );
+      const workspace =
+        position >= 0 ? workspaces[position] : matches.length === 1 ? matches[0] : undefined;
+      if (!workspace) {
+        await deps.api.sendMessage({
+          chatId,
+          text:
+            matches.length > 1
+              ? `More than one workspace is named "${selection}". Choose by number from /workspace.`
+              : `Workspace "${selection}" was not found. Run /workspace to see configured folders.`,
+        });
+        return;
+      }
+
+      const hadQueued = queue.size();
+      queue.clear();
+      await deps.config.setSettings({ telegramWorkspaceId: workspace.id });
+      await deps.api.sendMessage({
+        chatId,
+        text: [
+          `Telegram workspace set to ${workspace.name}. Future turns will run in ${workspace.folderPath}.`,
+          ...(hadQueued > 0 ? [`Cleared ${hadQueued} queued message(s).`] : []),
+        ].join("\n"),
+      });
+      return;
+    }
+    if (cmd === "/stop" || cmd === "/cancel") {
+      const hadQueued = queue.size();
+      queue.clear();
+      const lines = [
+        hadQueued > 0 ? `🧹 Cleared ${hadQueued} queued message(s).` : "No messages were queued.",
+      ];
+      if (activeTurn) {
+        lines.push("The current turn is still running — it will finish on its own.");
+      }
+      await deps.api.sendMessage({ chatId, text: lines.join("\n") });
+      return;
+    }
+
+    if (cmd === "/status") {
+      const snap = await deps.config.snapshot();
+      const lines = [
+        `Bot: @${botUsername ?? "unknown"}`,
+        `Paired owner: ${snap.allowedUserId ?? "none"}`,
+        `Queue: ${queue.size()} pending`,
+        `Active turn: ${activeTurn ? "yes" : "no"}`,
+      ];
+      if (lastError) lines.push(`Last error: ${lastError}`);
+      await deps.api.sendMessage({ chatId, text: lines.join("\n") });
+      return;
+    }
+
+    await enqueuePrompt({
+      lane: "default",
+      text: command,
+      chatId,
+      ownerUserId: message.from?.id ?? chatId,
+      fromUsername: message.from?.username,
+    });
+  }
+
+  /** Attempt to dispatch the next queued turn. No-op if gates block. */
+  function tryDispatch(): void {
+    if (!started || activeTurn || dispatchPending) return;
+    const next = queue.dequeue();
+    if (!next) return;
+    void dispatchTurn(next);
+  }
+
+  async function dispatchTurn(turn: QueuedTelegramTurn): Promise {
+    dispatchPending = true;
+    try {
+      const workspace = await deps.turn.resolveWorkspace(turn.workspaceId);
+      const workspaceId = workspace.kind === "project" ? workspace.workspaceId : undefined;
+      const chatId = telegramChatId(turn.ownerUserId, workspaceId);
+
+      if (workspace.kind !== "stale") {
+        try {
+          const settings = await deps.config.getSettings();
+          await ensureTelegramChat(
+            deps.turn,
+            turn.ownerUserId,
+            `Telegram${turn.fromUsername ? ` (@${turn.fromUsername})` : ""}`,
+            settings.lastProviderId,
+            settings.lastModel,
+            workspaceId,
+          );
+        } catch (cause) {
+          deps.error("Telegram: failed to ensure chat for turn.", cause);
+        }
+      }
+
+      activeTurn = true;
+      dispatchPending = false;
+
+      // Start typing indicator AFTER activeTurn is set so the loop condition
+      // evaluates true on its first iteration.
+      void sendTypingIndicator(turn.chatId).catch(() => undefined);
+
+      const result: TelegramTurnResult = await sendTelegramTurn(
+        deps.turn,
+        chatId,
+        turn.text,
+        workspace,
+      );
+      await deliverReply(turn.chatId, result);
+    } catch (cause) {
+      const msg = cause instanceof Error ? cause.message : String(cause);
+      deps.error("Telegram turn failed.", cause);
+      await deps.api
+        .sendMessage({ chatId: turn.chatId, text: `⚠️ Error: ${msg}` })
+        .catch(() => undefined);
+    } finally {
+      activeTurn = false;
+      dispatchPending = false;
+      tryDispatch();
+    }
+  }
+
+  async function deliverReply(chatId: number, result: TelegramTurnResult): Promise {
+    if (!result.ok) {
+      if (result.error) {
+        await deps.api.sendMessage({ chatId, text: `⚠️ ${result.error}` }).catch(() => undefined);
+      }
+      return;
+    }
+
+    const html = markdownToTelegramHtml(result.content);
+    const chunks = chunkForTelegram(html);
+    for (const chunk of chunks) {
+      await deps.api.sendMessage({
+        chatId,
+        text: chunk,
+        parseMode: "HTML",
+        disablePreview: true,
+      });
+    }
+  }
+
+  let typingActive = false;
+  async function sendTypingIndicator(chatId: number): Promise {
+    if (typingActive) return;
+    typingActive = true;
+    try {
+      while (activeTurn && started) {
+        await deps.api.sendChatAction(chatId, "typing").catch(() => undefined);
+        await deps.sleep(TYPING_INTERVAL_MS).catch(() => undefined);
+      }
+    } finally {
+      typingActive = false;
+    }
+  }
+
+  return {
+    start,
+    stop,
+    stopAndSettle,
+    connect,
+    disconnect,
+    getStatus,
+    get queueSize() {
+      return queue.size();
+    },
+    get isActive() {
+      return activeTurn;
+    },
+  };
+}
diff --git a/main/services/telegram/telegram-service.ts b/main/services/telegram/telegram-service.ts
new file mode 100644
index 0000000..4b84bba
--- /dev/null
+++ b/main/services/telegram/telegram-service.ts
@@ -0,0 +1,124 @@
+// Telegram service production singleton.
+// Mirrors schedule-service.ts: factory + DI, export const at import time.
+//
+// Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT).
+
+import { app, ipcMain, logger } from "../../platform.js";
+import { configStore } from "../config-store.js";
+import { chatStore } from "../chat-store.js";
+import { llmClient } from "../llm-client.js";
+import { providerRegistry } from "../provider-registry.js";
+import { secrets } from "../secrets.js";
+import type { StoredProvider } from "../types.js";
+import { createFetchTransport, TelegramBotApi } from "./telegram-bot-api.js";
+import { createTelegramConfig } from "./telegram-config.js";
+import { isTelegramFolderWorkspace } from "./telegram-workspace-core.js";
+import type { TelegramWorkspaceResolution } from "./telegram-turn.js";
+import { createTelegramServiceCore } from "./telegram-service-core.js";
+export const TELEGRAM_PROVIDER_ID = "telegram";
+
+async function resolveProvider(): Promise<{
+  providerId: string;
+  model: string;
+  provider: StoredProvider;
+} | null> {
+  const settings = await configStore.getSettings();
+  // Prefer Telegram-specific provider/model, fall back to the global default.
+  const providerId = settings.telegramProviderId ?? settings.lastProviderId;
+  if (!providerId) return null;
+  const provider =
+    (await providerRegistry.selectionProvider(providerId)) ??
+    (await configStore.getProvider(providerId));
+  if (!provider) return null;
+  const model =
+    settings.telegramModel ?? settings.lastModel ?? provider.defaultModel ?? provider.models[0];
+  if (!model) return null;
+  return { providerId, model, provider };
+}
+
+async function resolveWorkspace(workspaceId?: string): Promise {
+  if (!workspaceId) return { kind: "assistant" };
+  const workspace = await configStore.getWorkspace(workspaceId);
+
+  if (!workspace || !isTelegramFolderWorkspace(workspace)) {
+    return { kind: "stale" };
+  }
+  return { kind: "project", workspaceId: workspace.id };
+}
+
+function broadcastMetadata(chat: {
+  id: string;
+  workspaceId?: string;
+  title: string;
+  updatedAt: number;
+}): void {
+  ipcMain.broadcast("chats:metadata-updated", {
+    chatId: chat.id,
+    workspaceId: chat.workspaceId,
+    title: chat.title,
+    updatedAt: chat.updatedAt,
+  });
+}
+
+function sleep(ms: number, signal?: AbortSignal): Promise {
+  if (signal?.aborted) return Promise.reject(new Error("aborted"));
+  return new Promise((resolve, reject) => {
+    const timer = setTimeout(resolve, ms);
+    if (signal) {
+      signal.addEventListener(
+        "abort",
+        () => {
+          clearTimeout(timer);
+          reject(new Error("aborted"));
+        },
+        { once: true },
+      );
+    }
+  });
+}
+
+export function createTelegramService() {
+  let tokenLogged = false;
+  const api = new TelegramBotApi(
+    createFetchTransport(async () => {
+      const token = await secrets.getKey(TELEGRAM_PROVIDER_ID);
+      if (token && !tokenLogged) {
+        tokenLogged = true;
+        const valid = /^\d{5,16}:[A-Za-z0-9_-]{20,}$/.test(token);
+        logger.info(
+          "telegram",
+          `Bot token: ${token.length} chars, format ${valid ? "ok" : "INVALID"}, prefix "${token.slice(0, 5)}…"`,
+        );
+      }
+      return token;
+    }),
+  );
+  return createTelegramServiceCore({
+    api,
+    config: createTelegramConfig({
+      getSettings: () => configStore.getSettings(),
+      setSettings: (patch) => configStore.setSettings(patch),
+      hasToken: () => secrets.hasKey(TELEGRAM_PROVIDER_ID),
+      resolveRootDir: () => app.getPath("userData"),
+    }),
+    listWorkspaces: async () =>
+      (await configStore.listWorkspaces())
+        .filter(isTelegramFolderWorkspace)
+        .map(({ id, name, folderPath }) => ({ id, name, folderPath: folderPath as string })),
+    turn: {
+      llmClient,
+      chatStore,
+      resolveProvider,
+      resolveWorkspace,
+      broadcastMetadata,
+    },
+    getToken: () => secrets.getKey(TELEGRAM_PROVIDER_ID),
+    now: () => Date.now(),
+    sleep,
+    warn: (message) => logger.warn("telegram", message),
+    error: (message, cause) => logger.error("telegram", message, cause),
+    info: (message) => logger.info("telegram", message),
+  });
+}
+
+export const telegramService = createTelegramService();
diff --git a/main/services/telegram/telegram-turn.test.ts b/main/services/telegram/telegram-turn.test.ts
new file mode 100644
index 0000000..08601d2
--- /dev/null
+++ b/main/services/telegram/telegram-turn.test.ts
@@ -0,0 +1,277 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+  createTelegramBackgroundOwner,
+  ensureTelegramChat,
+  sendTelegramTurn,
+  telegramChatId,
+  type ChatTurnLease,
+  type TelegramChatStore,
+  type TelegramLlmClient,
+  type TelegramTurnDeps,
+} from "./telegram-turn.js";
+
+type ChatRecord = { id: string; workspaceId?: string; title: string; updatedAt: number };
+
+function lease(): ChatTurnLease {
+  return {
+    release() {},
+    settleAsyncWork() {},
+  };
+}
+
+/** Build a mock llmClient. `start` receives the synthetic owner so it can settle the turn. */
+function mockLlm(
+  start?: TelegramLlmClient["start"],
+  begin: ChatTurnLease | null = lease(),
+): TelegramLlmClient {
+  return {
+    beginChatTurn: () => begin,
+    start:
+      start ??
+      (async () => true),
+    isChatBusy: () => false,
+    waitForChatIdle: async () => true,
+  };
+}
+
+function mockChatStore(existing: ChatRecord | null = null): {
+  store: TelegramChatStore;
+  created: string[];
+  appended: Array<{ id: string; role: string; content: string }>;
+} {
+  const created: string[] = [];
+  const appended: Array<{ id: string; role: string; content: string }> = [];
+  const store: TelegramChatStore = {
+    async create(input) {
+      created.push(input.id);
+      return { id: input.id, title: input.title, updatedAt: 1 };
+    },
+    async get(id) {
+      return existing && existing.id === id ? existing : null;
+    },
+    async appendMessage(id, message) {
+      appended.push({ id, role: message.role, content: message.content });
+      return { id, title: "Telegram", updatedAt: 2 };
+    },
+  };
+  return { store, created, appended };
+}
+
+const MOCK_PROVIDER = {
+  id: "openai",
+  kind: "openai" as const,
+  label: "OpenAI",
+  baseUrl: "https://api.openai.com/v1",
+  needsKey: true,
+};
+
+function mockDeps(opts: {
+  llm?: TelegramLlmClient;
+  store?: TelegramChatStore;
+  provider?: { providerId: string; model: string } | null;
+  workspace?: { kind: "assistant" } | { kind: "project"; workspaceId: string } | { kind: "stale" };
+}): { deps: TelegramTurnDeps; broadcasts: ChatRecord[] } {
+  const broadcasts: ChatRecord[] = [];
+  const deps: TelegramTurnDeps = {
+    llmClient: opts.llm ?? mockLlm(),
+    chatStore: opts.store ?? mockChatStore().store,
+    resolveProvider:
+      opts.provider === undefined
+        ? async () => ({ providerId: "openai", model: "gpt-4o", provider: MOCK_PROVIDER })
+        : async () => opts.provider ? { ...opts.provider, provider: MOCK_PROVIDER } : null,
+    resolveWorkspace: async () => opts.workspace ?? { kind: "assistant" },
+    broadcastMetadata: (chat) => {
+      broadcasts.push(chat);
+    },
+  };
+  return { deps, broadcasts };
+}
+
+test("telegramChatId returns telegram-", () => {
+  assert.equal(telegramChatId(123), "telegram-123");
+  assert.equal(telegramChatId(0), "telegram-0");
+});
+
+test("workspace Telegram turn starts assistant automation with the selected workspace", async () => {
+  let startedParams:
+    | { chatId: string; workspaceId?: string; mode?: string }
+    | undefined;
+  const llm = mockLlm(async (streamId, params, owner) => {
+    startedParams = params;
+    owner.send("chat:done", { streamId, content: "done" });
+    return true;
+  });
+  const { deps } = mockDeps({
+    llm,
+    workspace: { kind: "project", workspaceId: "workspace-a" },
+  });
+  const chatId = telegramChatId(123, "workspace-a");
+
+  await sendTelegramTurn(deps, chatId, "list files");
+
+  assert.equal(chatId, "telegram-123-workspace-a");
+  assert.equal(startedParams?.chatId, chatId);
+  assert.equal(startedParams?.workspaceId, "workspace-a");
+  assert.equal(startedParams?.mode, "assistant-automation");
+});
+
+test("stale Telegram workspace errors before generation", async () => {
+  let startCalls = 0;
+  const llm = mockLlm(async (streamId, _params, owner) => {
+    startCalls += 1;
+    owner.send("chat:done", { streamId, content: "done" });
+    return true;
+  });
+  const { deps } = mockDeps({ llm, workspace: { kind: "stale" } });
+
+  const result = await sendTelegramTurn(deps, telegramChatId(123, "missing"), "list files");
+
+  assert.deepEqual(result, {
+    ok: false,
+    content: "",
+    error: "The Telegram workspace is no longer available. Choose a folder workspace in Aiden Settings.",
+  });
+  assert.equal(startCalls, 0);
+});
+
+test("assistant-only Telegram turn preserves the owner chat and assistant mode", async () => {
+  let startedParams:
+    | { chatId: string; workspaceId?: string; mode?: string }
+    | undefined;
+  const llm = mockLlm(async (streamId, params, owner) => {
+    startedParams = params;
+    owner.send("chat:done", { streamId, content: "done" });
+    return true;
+  });
+  const { deps } = mockDeps({ llm, workspace: { kind: "assistant" } });
+  const chatId = telegramChatId(123);
+
+  await sendTelegramTurn(deps, chatId, "settings help");
+
+  assert.equal(startedParams?.chatId, "telegram-123");
+  assert.equal(startedParams?.workspaceId, undefined);
+  assert.equal(startedParams?.mode, "assistant-unattended");
+});
+
+test("createTelegramBackgroundOwner exposes telegram: documentId and resolves terminal on chat:done", async () => {
+  const { owner, terminal } = createTelegramBackgroundOwner("stream-abc");
+  assert.equal(owner.documentId, "telegram:stream-abc");
+
+  owner.send("chat:done", { streamId: "stream-abc", content: "hello" });
+  const result = (await terminal) as { content: string };
+  assert.equal(result.content, "hello");
+});
+
+test("createTelegramBackgroundOwner captures chat:delta events into deltas", () => {
+  const { owner, deltas } = createTelegramBackgroundOwner("stream-1");
+  owner.send("chat:delta", { delta: "Hello" });
+  owner.send("chat:delta", { delta: " " });
+  owner.send("chat:delta", { delta: "world" });
+  // A delta payload without a `delta` string is ignored.
+  owner.send("chat:delta", {});
+
+  assert.deepEqual(deltas, ["Hello", " ", "world"]);
+});
+
+test("sendTelegramTurn returns ok with content when start resolves true and terminal has content", async () => {
+  const llm = mockLlm(async (streamId, _params, owner) => {
+    owner.send("chat:done", { streamId, content: "hello world" });
+    return true;
+  });
+  const { store, appended } = mockChatStore();
+  const { deps } = mockDeps({ llm, store });
+
+  const result = await sendTelegramTurn(deps, "telegram-123", "ping");
+
+  assert.deepEqual(result, { ok: true, content: "hello world", error: null });
+  assert.equal(appended.length, 1);
+  assert.equal(appended[0].role, "user");
+  assert.equal(appended[0].content, "ping");
+});
+
+test("sendTelegramTurn reports in-progress when beginChatTurn returns null", async () => {
+  const llm = mockLlm(undefined, null);
+  const { deps } = mockDeps({ llm });
+
+  const result = await sendTelegramTurn(deps, "telegram-123", "ping");
+
+  assert.equal(result.ok, false);
+  assert.match(result.error ?? "", /in progress/i);
+});
+
+test("sendTelegramTurn reports no provider when resolveProvider returns null", async () => {
+  const { deps } = mockDeps({ provider: null });
+
+  const result = await sendTelegramTurn(deps, "telegram-123", "ping");
+
+  assert.equal(result.ok, false);
+  assert.match(result.error ?? "", /provider/i);
+});
+
+test("sendTelegramTurn returns error when the terminal is a ChatError", async () => {
+  const llm = mockLlm(async (streamId, _params, owner) => {
+    owner.send("chat:error", { streamId, message: "boom", content: "partial" });
+    return true;
+  });
+  const { store } = mockChatStore();
+  const { deps } = mockDeps({ llm, store });
+
+  const result = await sendTelegramTurn(deps, "telegram-123", "ping");
+
+  assert.equal(result.ok, false);
+  assert.equal(result.error, "boom");
+  assert.equal(result.content, "partial");
+});
+
+test("ensureTelegramChat creates a chat when none exists and reuses an existing one", async () => {
+  const createCalls: string[] = [];
+  const gets: string[] = [];
+  let existing: ChatRecord | null = null;
+  const store: TelegramChatStore = {
+    async create(input) {
+      createCalls.push(input.id);
+      existing = { id: input.id, title: input.title, updatedAt: 1 };
+      return existing;
+    },
+    async get(id) {
+      gets.push(id);
+      return existing;
+    },
+    async appendMessage(id) {
+      return { id, title: "Telegram", updatedAt: 2 };
+    },
+  };
+  const broadcasts: ChatRecord[] = [];
+  const deps: TelegramTurnDeps = {
+    llmClient: mockLlm(),
+    chatStore: store,
+    resolveProvider: async () => ({ providerId: "openai", model: "gpt-4o", provider: MOCK_PROVIDER }),
+    resolveWorkspace: async () => ({ kind: "assistant" }),
+    broadcastMetadata: (chat) => {
+      broadcasts.push(chat);
+    },
+  };
+
+  // First call: chat does not exist → create is invoked.
+  const first = await ensureTelegramChat(deps, 123, "Telegram Owner", "openai", "gpt-4o");
+  assert.equal(first, "telegram-123");
+  assert.deepEqual(gets, ["telegram-123"]);
+  assert.deepEqual(createCalls, ["telegram-123"]);
+  assert.equal(broadcasts.length, 1);
+
+  // Second call: chat now exists → reuse, no new create.
+  const second = await ensureTelegramChat(deps, 123, "Telegram Owner", "openai", "gpt-4o");
+  assert.equal(second, "telegram-123");
+  assert.deepEqual(createCalls, ["telegram-123"]);
+  assert.equal(broadcasts.length, 2);
+});
+
+test("owner.send throws after destroy is called", () => {
+  const bg = createTelegramBackgroundOwner("stream-x");
+  assert.equal(bg.owner.isDestroyed(), false);
+
+  bg.destroy();
+  assert.equal(bg.owner.isDestroyed(), true);
+  assert.throws(() => bg.owner.send("chat:done", { content: "late" }), /no longer active/);
+});
diff --git a/main/services/telegram/telegram-turn.ts b/main/services/telegram/telegram-turn.ts
new file mode 100644
index 0000000..2b1a39d
--- /dev/null
+++ b/main/services/telegram/telegram-turn.ts
@@ -0,0 +1,288 @@
+// Telegram headless turn injection — the Aiden shim that replaces pi-telegram's
+// Pi-SDK host contract (lib/pi.ts). Maps the original ExtensionAPI ports onto
+// Aiden's llmClient + chatStore primitives, mirroring the proven headless-turn
+// pattern in schedule-execution.ts.
+//
+// Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT).
+
+import type { NotificationChannel } from "../../../renderer/preload-channels.js";
+import type { ChatDone, ChatError } from "../types.js";
+import type { UsageRequestSource } from "../usage-store-core.js";
+import type { ChatGenerationOwner } from "../chat-generation-owner.js";
+import { scheduledProviderFingerprint } from "../schedule-provider-binding.js";
+
+/** Minimal llmClient surface the shim needs. */
+export interface TelegramLlmClient {
+  beginChatTurn(chatId: string, turnId: string, ownerId: string): ChatTurnLease | null;
+  start(
+    streamId: string,
+    params: {
+      chatId: string;
+      workspaceId?: string;
+      providerId: string;
+      model: string;
+      mode?: "assistant-unattended" | "assistant-automation";
+      messages: Array<{ role: "user"; content: string }>;
+    },
+    owner: ChatGenerationOwner,
+    options: {
+      permission: "full";
+      allowComputerUse: boolean;
+      allowSubagents: boolean;
+      allowMcpTools: boolean;
+      usageSource: UsageRequestSource;
+      turnId: string;
+      providerFingerprint?: string;
+    },
+  ): Promise;
+  isChatBusy(chatId: string): boolean;
+  waitForChatIdle(chatId: string): Promise;
+}
+
+export interface ChatTurnLease {
+  release(): void;
+  settleAsyncWork(): void;
+}
+
+/** Minimal chatStore surface the shim needs. */
+export interface TelegramChatStore {
+  create(input: {
+    id: string;
+    title: string;
+    workspaceId?: string;
+    providerId?: string;
+    model?: string;
+  }): Promise<{ id: string; workspaceId?: string; title: string; updatedAt: number }>;
+  get(
+    id: string,
+  ): Promise<{ id: string; workspaceId?: string; title: string; updatedAt: number } | null>;
+  appendMessage(
+    id: string,
+    message: { role: "user" | "assistant"; content: string },
+    meta?: { providerId?: string; model?: string },
+  ): Promise<{ id: string; workspaceId?: string; title: string; updatedAt: number }>;
+}
+
+export interface TelegramTurnDeps {
+  llmClient: TelegramLlmClient;
+  chatStore: TelegramChatStore;
+  resolveProvider(): Promise<{
+    providerId: string;
+    model: string;
+    provider: Pick<
+      import("../types.js").StoredProvider,
+      "id" | "kind" | "label" | "baseUrl" | "needsKey" | "deployment" | "isBuiltin"
+    >;
+  } | null>;
+  broadcastMetadata(chat: {
+    id: string;
+    workspaceId?: string;
+    title: string;
+    updatedAt: number;
+  }): void;
+  /** Resolve the selection captured when the Telegram prompt was accepted. */
+  resolveWorkspace(workspaceId?: string): Promise;
+}
+
+export type TelegramWorkspaceResolution =
+  | { kind: "assistant" }
+  | { kind: "project"; workspaceId: string }
+  | { kind: "stale" };
+
+let telegramTurnSequence = 0;
+
+function telegramStreamId(): string {
+  telegramTurnSequence += 1;
+  return `telegram-${Date.now().toString(36)}-${telegramTurnSequence.toString(36)}`;
+}
+
+/**
+ * Synthetic ChatGenerationOwner for Telegram-originated turns.
+ * Mirrors schedule-execution.ts createBackgroundOwner, extended to capture
+ * chat:delta for optional streaming previews.
+ */
+export function createTelegramBackgroundOwner(streamId: string): {
+  owner: ChatGenerationOwner;
+  terminal: Promise;
+  deltas: string[];
+  destroy(): void;
+} {
+  let destroyed = false;
+  const deltas: string[] = [];
+  let settle: ((payload: ChatDone | ChatError) => void) | undefined;
+  const terminal = new Promise((resolve) => {
+    settle = resolve;
+  });
+  const owner: ChatGenerationOwner = {
+    id: 0,
+    documentId: `telegram:${streamId}`,
+    isDestroyed: () => destroyed,
+    send: (channel: NotificationChannel, payload: unknown) => {
+      if (destroyed) throw new Error("The Telegram generation is no longer active.");
+      if (channel === "chat:done" || channel === "chat:error") {
+        settle?.(payload as ChatDone | ChatError);
+      } else if (channel === "chat:delta") {
+        const delta = (payload as { delta?: string })?.delta;
+        if (delta) deltas.push(delta);
+      }
+    },
+    onInvalidated: () => () => undefined,
+  };
+  return {
+    owner,
+    terminal,
+    deltas,
+    destroy: () => {
+      destroyed = true;
+    },
+  };
+}
+
+/** Persistent chat id for a Telegram owner and optional project workspace. */
+export function telegramChatId(ownerUserId: number, workspaceId?: string): string {
+  return workspaceId ? `telegram-${ownerUserId}-${workspaceId}` : `telegram-${ownerUserId}`;
+}
+
+/**
+ * Ensure the persistent backing chat exists for a Telegram owner.
+ * Created once on first inbound message, reused across all turns.
+ */
+export async function ensureTelegramChat(
+  deps: TelegramTurnDeps,
+  ownerUserId: number,
+  title: string,
+  providerId?: string,
+  model?: string,
+  workspaceId?: string,
+): Promise {
+  const chatId = telegramChatId(ownerUserId, workspaceId);
+  const existing = await deps.chatStore.get(chatId);
+  if (existing) {
+    deps.broadcastMetadata(existing);
+    return chatId;
+  }
+  const chat = await deps.chatStore.create({
+    id: chatId,
+    title,
+    providerId,
+    workspaceId,
+    model,
+  });
+  deps.broadcastMetadata(chat);
+  return chatId;
+}
+
+/** Result of a Telegram headless turn. */
+export interface TelegramTurnResult {
+  readonly content: string;
+  readonly error: string | null;
+  readonly ok: boolean;
+}
+
+/**
+ * Inject a Telegram-originated prompt as a headless Aiden turn.
+ *
+ * This is the shim for pi-telegram's `ExtensionAPI.sendUserMessage(content)`.
+ * It follows the exact schedule-execution.ts executeLlm pattern:
+ *   beginChatTurn → appendMessage → llmClient.start → await terminal → release.
+ *
+ * Permission is always "full" with an unattended mode — no GUI approval surface.
+ * The chat is always the persistent `telegram-` chat.
+ */
+export async function sendTelegramTurn(
+  deps: TelegramTurnDeps,
+  chatId: string,
+  content: string,
+  workspace?: TelegramWorkspaceResolution,
+): Promise {
+  const resolvedWorkspace = workspace ?? (await deps.resolveWorkspace());
+  if (resolvedWorkspace.kind === "stale") {
+    return {
+      content: "",
+      error:
+        "The Telegram workspace is no longer available. Choose a folder workspace in Aiden Settings.",
+      ok: false,
+    };
+  }
+  const workspaceId =
+    resolvedWorkspace.kind === "project" ? resolvedWorkspace.workspaceId : undefined;
+  const provider = await deps.resolveProvider();
+  if (!provider) {
+    return {
+      content: "",
+      error: "No provider is configured. Choose a provider in Aiden first.",
+      ok: false,
+    };
+  }
+
+  const streamId = telegramStreamId();
+  const background = createTelegramBackgroundOwner(streamId);
+  const turn = deps.llmClient.beginChatTurn(chatId, streamId, background.owner.documentId);
+  if (!turn) {
+    return { content: "", error: "The Telegram chat already has a turn in progress.", ok: false };
+  }
+
+  try {
+    try {
+      await deps.chatStore.appendMessage(
+        chatId,
+        { role: "user", content },
+        { providerId: provider.providerId, model: provider.model },
+      );
+    } finally {
+      turn.settleAsyncWork();
+    }
+
+    const started = await deps.llmClient.start(
+      streamId,
+      {
+        chatId,
+        workspaceId,
+        providerId: provider.providerId,
+        model: provider.model,
+        mode: workspaceId ? "assistant-automation" : "assistant-unattended",
+        messages: [{ role: "user", content }],
+      },
+      background.owner,
+      {
+        permission: "full",
+        allowComputerUse: false,
+        allowSubagents: false,
+        allowMcpTools: false,
+        usageSource: "telegram",
+        turnId: streamId,
+        providerFingerprint: scheduledProviderFingerprint(provider.provider),
+      },
+    );
+
+    if (!started) {
+      return {
+        content: "",
+        error: "The Telegram generation was cancelled before it started.",
+        ok: false,
+      };
+    }
+
+    const terminal = await background.terminal;
+    if ("message" in terminal) {
+      return { content: terminal.content ?? "", error: terminal.message, ok: false };
+    }
+    return { content: terminal.content, error: null, ok: true };
+  } finally {
+    turn.release();
+    background.destroy();
+  }
+}
+
+/** Check if the persistent chat is currently busy (a turn is in flight). */
+export function isTelegramChatIdle(deps: TelegramTurnDeps, chatId: string): boolean {
+  return !deps.llmClient.isChatBusy(chatId);
+}
+
+/** Wait for the chat to become idle (used during abort/cancel). */
+export async function waitForTelegramChatIdle(
+  deps: TelegramTurnDeps,
+  chatId: string,
+): Promise {
+  return deps.llmClient.waitForChatIdle(chatId);
+}
diff --git a/main/services/telegram/telegram-workspace-core.test.ts b/main/services/telegram/telegram-workspace-core.test.ts
new file mode 100644
index 0000000..8b4a243
--- /dev/null
+++ b/main/services/telegram/telegram-workspace-core.test.ts
@@ -0,0 +1,22 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+  isTelegramFolderWorkspace,
+  telegramWorkspaceSelectionId,
+} from "./telegram-workspace-core.js";
+
+test("telegramWorkspaceSelectionId trims a non-empty workspace id", () => {
+  assert.equal(telegramWorkspaceSelectionId("  workspace-a  "), "workspace-a");
+});
+
+test("telegramWorkspaceSelectionId clears non-string and blank values", () => {
+  assert.equal(telegramWorkspaceSelectionId(undefined), undefined);
+  assert.equal(telegramWorkspaceSelectionId("  "), undefined);
+  assert.equal(telegramWorkspaceSelectionId(12), undefined);
+});
+
+test("isTelegramFolderWorkspace accepts only a configured folder workspace", () => {
+  assert.equal(isTelegramFolderWorkspace({ folderPath: "/tmp/aiden" }), true);
+  assert.equal(isTelegramFolderWorkspace({}), false);
+  assert.equal(isTelegramFolderWorkspace(null), false);
+});
diff --git a/main/services/telegram/telegram-workspace-core.ts b/main/services/telegram/telegram-workspace-core.ts
new file mode 100644
index 0000000..e37bed6
--- /dev/null
+++ b/main/services/telegram/telegram-workspace-core.ts
@@ -0,0 +1,13 @@
+import type { Workspace } from "../types.js";
+
+/** Normalize an optional persisted Telegram workspace selection. */
+export function telegramWorkspaceSelectionId(value: unknown): string | undefined {
+  return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
+
+/** A Telegram project turn requires a workspace with a real folder root. */
+export function isTelegramFolderWorkspace(
+  workspace: Pick | null | undefined,
+): boolean {
+  return Boolean(workspace?.folderPath);
+}
diff --git a/main/services/terminal.test.ts b/main/services/terminal.test.ts
index 5926a3a..2c8cd9d 100644
--- a/main/services/terminal.test.ts
+++ b/main/services/terminal.test.ts
@@ -610,3 +610,11 @@ test("history flush falls back to one flush per active workspace", async () => {
   await service.flushHistory();
   assert.deepEqual(flushed.sort(), ["workspace-1", "workspace-2"]);
 });
+
+test("history flush is a no-op before a history store is installed", async () => {
+  const service = new TerminalService({
+    prepareSpawnHelper: async () => undefined,
+  });
+
+  await assert.doesNotReject(service.flushHistory());
+});
diff --git a/main/services/types.ts b/main/services/types.ts
index ec5e0d2..d273145 100644
--- a/main/services/types.ts
+++ b/main/services/types.ts
@@ -490,6 +490,19 @@ export interface AppSettings {
   assistant?: AssistantConfig;
   /** Device-local display name used by the private usage profile. */
   profileName?: string;
+  /** Telegram remote-control enable flag; gates long-poll polling. */
+  telegramEnabled?: boolean;
+  /** Paired Telegram owner chat id; undefined until first /start pairs. */
+  telegramAllowedUserId?: number;
+  /** Provider for Telegram turns; falls back to lastProviderId when unset. */
+  telegramProviderId?: string;
+  /** Model for Telegram turns; falls back to provider default when unset. */
+  telegramModel?: string;
+  /**
+   * Explicit folder workspace authorized for Telegram project automation.
+   * Omitted keeps Telegram turns assistant-only.
+   */
+  telegramWorkspaceId?: string;
 }
 
 export type ComputerUseStatusState =
diff --git a/main/services/usage-store-core.ts b/main/services/usage-store-core.ts
index 97e1571..089e0f1 100644
--- a/main/services/usage-store-core.ts
+++ b/main/services/usage-store-core.ts
@@ -10,7 +10,8 @@ export type UsageRequestSource =
   | "chat-title"
   | "voice-transcription"
   | "scheduled"
-  | "subagent";
+  | "subagent"
+  | "telegram";
 export type UsageRequestStatus = "completed" | "failed" | "cancelled";
 export type UsageCostStatus = "reported" | "unavailable" | "not-applicable";
 
@@ -75,6 +76,7 @@ const REQUEST_SOURCES = new Set([
   "voice-transcription",
   "scheduled",
   "subagent",
+  "telegram",
 ]);
 
 export function emptyUsageTokens(): UsageTokenBreakdown {
diff --git a/package.json b/package.json
index c03b611..3bf0714 100644
--- a/package.json
+++ b/package.json
@@ -73,7 +73,8 @@
     "test:e2e:live:lmstudio": "npm run type-check:e2e && npm run build && AIDEN_E2E_LIVE_LMSTUDIO=1 playwright test --config=playwright.config.ts",
     "test:terminal:coverage": "tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal-spawn-helper.ts --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 main/services/terminal.test.ts && tsx --test --experimental-test-coverage --test-coverage-include=main/services/terminal.ts --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-functions=90 main/services/terminal.test.ts",
     "test:compaction": "tsx --test main/services/pi-compaction-core.test.ts",
-    "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:worktree-remover:native && npm run test:computer-use:native",
+    "test:telegram": "tsx --test main/services/telegram/telegram-queue.test.ts main/services/telegram/telegram-markdown.test.ts main/services/telegram/telegram-bot-api.test.ts main/services/telegram/telegram-turn.test.ts main/services/telegram/telegram-service-core.test.ts main/services/telegram/telegram-workspace-core.test.ts renderer/lib/telegram-workspace-options.test.ts",
+    "test": "tsx --test main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/secret-map-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/gemini-context-cache.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/activity-feed.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/model-picker-data.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/hide-dmg-support-files.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:telegram && npm run test:worktree-remover:native && npm run test:computer-use:native",
     "test:coverage": "tsx --test --experimental-test-coverage main/handlers/assistant-parse.test.ts main/services/assistant/system-prompt.test.ts main/services/chat-generation-start.test.ts main/services/chat-title-policy.test.ts main/services/chat-title-routing.test.ts main/services/chat-store-core.test.ts main/services/codex-provider.test.ts main/services/coding-tools.test.ts main/services/config-store-core.test.ts main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/data-store.resilience.test.ts main/services/terminal.test.ts main/services/terminal-history.test.ts main/services/aiden-config-dir.test.ts main/services/portable-config-core.test.ts main/services/portable-config-core.roundtrip.test.ts main/services/portable-config-watch-core.test.ts main/services/dev-log.test.ts main/services/dictation-coordinator.test.ts main/services/dictation-paste.test.ts main/services/foundation-models-connection.test.ts main/services/foundation-models-connection-core.test.ts main/services/generation-bound-connection-cache.test.ts main/services/generation-context.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/external-editors.test.ts main/services/git.test.ts main/services/model-runtime-core.test.ts main/services/pi-compaction-core.test.ts main/services/models.test.ts main/services/mcp-oauth-operation.test.ts main/services/mcp-oauth-session.test.ts main/services/mcp-presets.test.ts main/services/pi-credential-store-core.test.ts main/services/pi-provider-contract.test.ts main/services/profile-share-core.test.ts main/services/profile-share-files.test.ts main/services/profile.test.ts main/services/provider-auth-flow-core.test.ts main/services/provider-auth-owner.test.ts main/services/provider-key-policy.test.ts main/services/provider-list-core.test.ts main/services/quit-barrier.test.ts main/services/scratch-workspace.test.ts main/services/skills-discovery.test.ts main/services/tool-approval.test.ts main/services/local-runtime-status.test.ts main/services/usage-store-core.test.ts main/services/workspace-files.test.ts main/windows/pill-window-security.test.ts renderer/components/assistant/use-assistant-chat.test.ts renderer/components/assistant/assistant-ui.test.tsx renderer/components/environment-subagents-contract.test.ts renderer/components/subagents-panel.test.tsx renderer/components/chat-sidebar.test.tsx renderer/components/composer.test.tsx renderer/main/chat-transition.test.tsx renderer/components/usage/profile-share-card.test.tsx renderer/lib/accessibility-refresh.test.ts renderer/lib/agent-activity.test.ts renderer/lib/assistant-dock.test.ts renderer/lib/assistant-motion-contract.test.ts renderer/lib/dialog-motion-contract.test.ts renderer/lib/chat-deletion-cache.test.ts renderer/lib/chat-terminal-sync.test.ts renderer/lib/ipc-stream.test.ts renderer/lib/chat-title-reveal.test.ts renderer/lib/codex-auth-session.test.ts renderer/lib/codex-auth-view-state.test.ts renderer/lib/codex-provider-cache.test.ts renderer/lib/composer-placeholder.test.ts renderer/lib/computer-use-notice.test.ts renderer/lib/dictation-operation-gate.test.ts renderer/lib/editor-preference.test.ts renderer/lib/environment-panel-layout.test.ts renderer/lib/subagent-view-state.test.ts renderer/lib/truncate-path.test.ts renderer/lib/mcp-preset-state.test.ts renderer/lib/model-display.test.ts renderer/lib/profile-share-data.test.ts renderer/lib/sidebar-chat-shortcuts.test.ts renderer/lib/usage-profile-data.test.ts renderer/shared/appearance.test.ts renderer/shared/provider-deployment.test.ts main/handlers/ipc-contract.test.ts main/handlers/chat.parse.test.ts main/handlers/voice-codec.test.ts main/handlers/phase2-parse.test.ts scripts/apple-developer-tools.test.mjs scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/model-snapshot-core.test.mjs scripts/prepare-macos-dev-runtime.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/update-model-capabilities.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs",
     "test:computer-use": "tsx --test main/services/computer-use/computer-use-foundation.test.ts main/services/computer-use/computer-use-tool.test.ts main/services/computer-use/generation-gate.test.ts main/services/computer-use/safety.test.ts main/services/computer-use/settings-core.test.ts main/services/computer-use/status-core.test.ts main/services/data-store.test.ts main/services/generation-messages.test.ts main/services/generation-runtime.test.ts main/services/quit-barrier.test.ts main/services/tool-approval.test.ts scripts/check-macos-release.test.mjs scripts/computer-use-packaged-acceptance.test.mjs scripts/configure-electron-fuses.test.mjs scripts/prepare-macos-package-output.test.mjs scripts/run-macos-distribution.test.mjs scripts/sign-macos.test.mjs scripts/vendor-cua-driver.test.mjs scripts/verify-macos-package.test.mjs && npm run test:computer-use:native",
     "test:computer-use:packaged": "node scripts/computer-use-packaged-acceptance.mjs",
diff --git a/renderer/assets/onboarding/features/telegram-remote-control.png b/renderer/assets/onboarding/features/telegram-remote-control.png
new file mode 100644
index 0000000..ab3143d
Binary files /dev/null and b/renderer/assets/onboarding/features/telegram-remote-control.png differ
diff --git a/renderer/components/onboarding-flow.test.tsx b/renderer/components/onboarding-flow.test.tsx
index 3e88eaf..84f0e38 100644
--- a/renderer/components/onboarding-flow.test.tsx
+++ b/renderer/components/onboarding-flow.test.tsx
@@ -30,6 +30,7 @@ const featureAssetPaths = [
   "features/terminal.png",
   "features/themes-accessibility.png",
   "features/thinking-controls.png",
+  "features/telegram-remote-control.png",
   "features/usage-profile.png",
   "features/voice-dictation.png",
   "features/web-search.png",
@@ -265,16 +266,18 @@ test("the final step is a complete grouped bento gallery with hover and keyboard
     "Private Usage Profile",
     "Permissioned by Default",
     "Themes & Accessibility",
+    "Telegram Remote Control",
   ]) {
     assert.match(featurePresentation, new RegExp(title, "u"));
   }
   assert.match(featurePresentation, /reopen it with sanitized local history/u);
-  assert.equal(featurePresentation.match(/imageUrl: FEATURE_ILLUSTRATIONS\./gu)?.length, 22);
+  assert.equal(featurePresentation.match(/imageUrl: FEATURE_ILLUSTRATIONS\./gu)?.length, 23);
   assert.doesNotMatch(featurePresentation, /Designer Mode|Image Generation|Proactive nudges/u);
 });
 
 test("every advertised feature has its own one-megapixel PNG with alpha", () => {
-  assert.equal(featureAssetPaths.length, 22);
+  assert.equal(featureAssetPaths.length, 23);
+  assert.ok(featureAssetPaths.includes("features/telegram-remote-control.png"));
   assert.equal(new Set(featureAssetPaths).size, featureAssetPaths.length);
   for (const assetPath of featureAssetPaths) {
     const illustration = readFileSync(
diff --git a/renderer/components/onboarding-flow.tsx b/renderer/components/onboarding-flow.tsx
index 8c1cae4..8e4d02c 100644
--- a/renderer/components/onboarding-flow.tsx
+++ b/renderer/components/onboarding-flow.tsx
@@ -23,6 +23,7 @@ import {
   Network,
   Palette,
   Plug,
+  Send,
   ShieldCheck,
   SquareTerminal,
   UserRound,
@@ -84,6 +85,8 @@ const FEATURE_ILLUSTRATIONS = {
   usage: new URL("../assets/onboarding/features/usage-profile.png", import.meta.url).href,
   permissions: new URL("../assets/onboarding/features/permissions.png", import.meta.url).href,
   themes: new URL("../assets/onboarding/features/themes-accessibility.png", import.meta.url).href,
+  telegram: new URL("../assets/onboarding/features/telegram-remote-control.png", import.meta.url)
+    .href,
 } as const;
 
 const providerChoices: Array<{
@@ -325,6 +328,15 @@ const featureBentos: FeatureBento[] = [
     imageUrl: FEATURE_ILLUSTRATIONS.commands,
     size: "standard",
   },
+  {
+    id: "telegram",
+    group: "control",
+    title: "Telegram Remote Control",
+    description: "Run trusted workspace automations from your paired Telegram account.",
+    icon: Send,
+    imageUrl: FEATURE_ILLUSTRATIONS.telegram,
+    size: "standard",
+  },
   {
     id: "usage",
     group: "control",
diff --git a/renderer/components/settings/telegram-settings.tsx b/renderer/components/settings/telegram-settings.tsx
new file mode 100644
index 0000000..55f611a
--- /dev/null
+++ b/renderer/components/settings/telegram-settings.tsx
@@ -0,0 +1,303 @@
+// Telegram settings — bot token, enable toggle, pairing status, provider/model
+// picker, and connection controls. The bot token is stored encrypted via
+// safeStorage and never returned to the renderer.
+
+import * as React from "react";
+import { useQueryClient } from "@tanstack/react-query";
+import {
+  Button,
+  Field,
+  FieldSet,
+  Input,
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+  Switch,
+  toast,
+} from "../ui";
+import { telegramApi } from "../../lib/ipc";
+import {
+  queryKeys,
+  useProviders,
+  useTelegramSettings,
+  useWorkspaces,
+} from "../../lib/queries";
+import {
+  TELEGRAM_ASSISTANT_ONLY_VALUE,
+  telegramWorkspaceOptions,
+} from "../../lib/telegram-workspace-options";
+
+export function TelegramSettings() {
+  const qc = useQueryClient();
+  const telegram = useTelegramSettings();
+  const providers = useProviders();
+  const workspaces = useWorkspaces();
+  const [keyDraft, setKeyDraft] = React.useState("");
+
+  const invalidate = () => qc.invalidateQueries({ queryKey: queryKeys.telegram });
+  const enabled = telegram.data?.enabled ?? false;
+  const hasToken = telegram.data?.hasToken ?? false;
+  const allowedUserId = telegram.data?.allowedUserId;
+  const polling = telegram.data?.polling ?? false;
+  const lastError = telegram.data?.lastError;
+  const queuedCount = telegram.data?.queuedCount ?? 0;
+  const telegramProviderId = telegram.data?.providerId ?? "";
+  const telegramModel = telegram.data?.model ?? "";
+  const telegramWorkspaceId = telegram.data?.workspaceId;
+  const workspaceOptions = telegramWorkspaceOptions(
+    workspaces.data ?? [],
+    telegramWorkspaceId,
+  );
+  const folderWorkspaceCount = workspaceOptions.filter(
+    (workspace) =>
+      workspace.value !== TELEGRAM_ASSISTANT_ONLY_VALUE && !workspace.unavailable,
+  ).length;
+
+  const saveKey = async () => {
+    const value = keyDraft.trim();
+    await telegramApi.setKey(value);
+    setKeyDraft("");
+    await invalidate();
+    toast.success(value ? "Telegram bot token saved." : "Telegram bot token removed and bridge disabled.");
+  };
+
+  const toggle = async (value: boolean) => {
+    await telegramApi.setEnabled(value);
+    await invalidate();
+  };
+
+  const connect = async () => {
+    try {
+      await telegramApi.connect();
+      await invalidate();
+      toast.success("Telegram bridge connected.");
+    } catch (error) {
+      toast.error(error instanceof Error ? error.message : "Failed to connect.");
+    }
+  };
+
+  const disconnect = async () => {
+    await telegramApi.disconnect();
+    await invalidate();
+    toast.success("Telegram bridge disconnected.");
+  };
+
+  const resetPairing = async () => {
+    await telegramApi.resetPairing();
+    await invalidate();
+    toast.success("Pairing reset. The next /start will claim a new owner.");
+  };
+
+  const saveProvider = async (providerId: string, model: string) => {
+    await telegramApi.setProvider(providerId, model);
+    await invalidate();
+    toast.success("Telegram provider saved.");
+  };
+
+  const saveWorkspace = async (workspaceId: string) => {
+    try {
+      await telegramApi.setWorkspace(
+        workspaceId === TELEGRAM_ASSISTANT_ONLY_VALUE ? undefined : workspaceId,
+      );
+      await invalidate();
+      toast.success(
+        workspaceId === TELEGRAM_ASSISTANT_ONLY_VALUE
+          ? "Telegram workspace scope cleared."
+          : "Telegram workspace scope saved.",
+      );
+    } catch (error) {
+      toast.error(
+        error instanceof Error ? error.message : "Failed to save Telegram workspace scope.",
+      );
+    }
+  };
+
+  // Providers that have at least one model and a key (or don't need one).
+  const usableProviders = (providers.data ?? []).filter(
+    (p) => p.models.length > 0 && (p.hasKey || !p.needsKey),
+  );
+
+  const selectedProvider = usableProviders.find((p) => p.id === telegramProviderId);
+  const selectedModel = telegramModel || selectedProvider?.defaultModel || selectedProvider?.models[0] || "";
+
+  return (
+    
+ + + + + +
+ setKeyDraft(e.target.value)} + placeholder={hasToken ? "••••••••••••" : "Paste your bot token from @BotFather"} + /> + +
+
+ + + + {folderWorkspaceCount === 0 && ( +

+ Add a folder workspace in Settings → Workspaces to enable project automation. +

+ )} +
+ + 0 + ? "The AI provider and model used for Telegram turns." + : "No providers configured. Go to Settings → Providers to add one." + } + > + {usableProviders.length > 0 ? ( +
+ + {selectedProvider && selectedProvider.models.length > 1 && ( + + )} +
+ ) : ( +

+ Configure at least one provider in Settings → Providers, then return here to select it for Telegram. +

+ )} +
+ + {hasToken && ( + +
+ + + + {polling ? "● Polling" : "○ Idle"} + {queuedCount > 0 ? ` · ${queuedCount} queued` : ""} + +
+
+ )} + + {allowedUserId !== undefined && ( + +
+ User ID: {allowedUserId} + +
+
+ )} + + {lastError && ( + +

{lastError}

+
+ )} + + +
    +
  1. Open Telegram and message @BotFather.
  2. +
  3. Send /newbot and follow the prompts to create a bot.
  4. +
  5. Copy the bot token and paste it above, then Save.
  6. +
  7. Choose a provider above (or set one up in Settings → Providers).
  8. +
  9. Toggle Enable, then send /start to your bot from Telegram to pair.
  10. +
+
+ + +

+ This is the same trust boundary as scheduled tasks: the paired owner can run + mutating tools silently from their phone. Keep the bot private. +

+
+
+ ); +} diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 3c4d980..b59c860 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -333,6 +333,31 @@ export const exaApi = { invoke("exa:setEnabled", enabled), }; +// ── Telegram remote control ────────────────────────────────────────── +export interface TelegramStatus { + enabled: boolean; + hasToken: boolean; + allowedUserId?: number; + providerId?: string; + model?: string; + workspaceId?: string; + polling: boolean; + queuedCount: number; + lastError?: string; +} +export const telegramApi = { + get: () => invoke("telegram:get"), + setKey: (key: string) => invoke<{ hasKey: boolean }>("telegram:setKey", key), + setEnabled: (enabled: boolean) => invoke("telegram:setEnabled", enabled), + connect: () => invoke<{ connected: boolean }>("telegram:connect"), + disconnect: () => invoke<{ connected: boolean }>("telegram:disconnect"), + resetPairing: () => invoke<{ reset: boolean }>("telegram:resetPairing"), + setProvider: (providerId: string, model: string) => + invoke<{ providerId: string; model: string }>("telegram:setProvider", providerId, model), + setWorkspace: (workspaceId?: string) => + invoke<{ workspaceId?: string }>("telegram:setWorkspace", workspaceId), +}; + // ── Voice + shortcut ────────────────────────────────────────────────── export const voiceApi = { transcribe: (audioBase64: string, mimeType: string) => diff --git a/renderer/lib/queries.ts b/renderer/lib/queries.ts index 879dc2e..04cc3a2 100644 --- a/renderer/lib/queries.ts +++ b/renderer/lib/queries.ts @@ -23,6 +23,7 @@ import { settingsApi, shortcutApi, skillsApi, + telegramApi, titleProvidersApi, usageApi, workspacesApi, @@ -58,6 +59,7 @@ export const queryKeys = { mcpServers: ["mcpServers"] as const, mcpPresets: ["mcpPresets"] as const, exa: ["exa"] as const, + telegram: ["telegram"] as const, engineStatus: ["engineStatus"] as const, localModels: ["localModels"] as const, workspaces: ["workspaces"] as const, @@ -458,6 +460,10 @@ export function useExaConfig() { return useQuery({ queryKey: queryKeys.exa, queryFn: exaApi.get }); } +export function useTelegramSettings() { + return useQuery({ queryKey: queryKeys.telegram, queryFn: telegramApi.get }); +} + export function useEngineStatus(enabled = true) { return useQuery({ queryKey: queryKeys.engineStatus, queryFn: localVoiceApi.status, enabled }); } diff --git a/renderer/lib/telegram-workspace-options.test.ts b/renderer/lib/telegram-workspace-options.test.ts new file mode 100644 index 0000000..822c869 --- /dev/null +++ b/renderer/lib/telegram-workspace-options.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + TELEGRAM_ASSISTANT_ONLY_VALUE, + telegramWorkspaceOptions, +} from "./telegram-workspace-options.js"; + +test("Telegram workspace options include assistant-only and configured folders", () => { + assert.deepEqual( + telegramWorkspaceOptions( + [ + { + id: "folder", + name: "Aiden", + folderPath: "/tmp/aiden", + permission: "ask", + createdAt: 1, + updatedAt: 1, + }, + { + id: "scratch", + name: "Scratch", + permission: "ask", + createdAt: 1, + updatedAt: 1, + }, + ], + undefined, + ), + [ + { value: TELEGRAM_ASSISTANT_ONLY_VALUE, label: "Assistant-only mode" }, + { value: "folder", label: "Aiden — /tmp/aiden" }, + ], + ); +}); + +test("Telegram workspace options retain an unavailable saved selection", () => { + assert.deepEqual(telegramWorkspaceOptions([], "missing"), [ + { value: TELEGRAM_ASSISTANT_ONLY_VALUE, label: "Assistant-only mode" }, + { value: "missing", label: "Selected workspace is unavailable", unavailable: true }, + ]); +}); diff --git a/renderer/lib/telegram-workspace-options.ts b/renderer/lib/telegram-workspace-options.ts new file mode 100644 index 0000000..284743e --- /dev/null +++ b/renderer/lib/telegram-workspace-options.ts @@ -0,0 +1,34 @@ +import type { Workspace } from "./types"; + +export const TELEGRAM_ASSISTANT_ONLY_VALUE = "__none__"; + +export interface TelegramWorkspaceOption { + value: string; + label: string; + unavailable?: boolean; +} + +export function telegramWorkspaceOptions( + workspaces: readonly Workspace[], + selectedId: string | undefined, +): TelegramWorkspaceOption[] { + const options: TelegramWorkspaceOption[] = [ + { value: TELEGRAM_ASSISTANT_ONLY_VALUE, label: "Assistant-only mode" }, + ...workspaces + .filter((workspace) => workspace.folderPath) + .map((workspace) => ({ + value: workspace.id, + label: `${workspace.name} — ${workspace.folderPath}`, + })), + ]; + + if (selectedId && !options.some((option) => option.value === selectedId)) { + options.push({ + value: selectedId, + label: "Selected workspace is unavailable", + unavailable: true, + }); + } + + return options; +} diff --git a/renderer/main/settings-view.tsx b/renderer/main/settings-view.tsx index af18c41..fb2188a 100644 --- a/renderer/main/settings-view.tsx +++ b/renderer/main/settings-view.tsx @@ -19,12 +19,14 @@ import { Info, Clock3, Sparkles, + Send, } from "lucide-react"; import { ProvidersSettings } from "../components/settings/providers-settings"; import { AppearanceSettings } from "../components/settings/appearance-settings"; import { SkillsSettings } from "../components/settings/skills-settings"; import { McpSettings } from "../components/settings/mcp-settings"; import { WebSearchSettings } from "../components/settings/web-search-settings"; +import { TelegramSettings } from "../components/settings/telegram-settings"; import { VoiceSettings } from "../components/settings/voice-settings"; import { ShortcutSettings } from "../components/settings/shortcut-settings"; import { ComputerUseSettings } from "../components/settings/computer-use-settings"; @@ -49,6 +51,7 @@ const NAV_ICONS: Record = { modelData: , skills: , mcp: , + telegram: , websearch: , scheduledTasks: , assistant: , @@ -71,6 +74,7 @@ const CONTENT: Record = { providers: ProvidersSettings, modelData: ModelDataSettings, skills: SkillsSettings, + telegram: TelegramSettings, mcp: McpSettings, websearch: WebSearchSettings, computerUse: ComputerUseSettings, diff --git a/renderer/preload-channels.ts b/renderer/preload-channels.ts index a72dd04..357a263 100644 --- a/renderer/preload-channels.ts +++ b/renderer/preload-channels.ts @@ -26,6 +26,7 @@ export const INVOKE_PREFIXES = [ "settings:", "shortcut:", "skills:", + "telegram:", "subagents:", "terminal:", "titleProviders:", diff --git a/renderer/shared/settings-section.ts b/renderer/shared/settings-section.ts index ca18d10..4221a16 100644 --- a/renderer/shared/settings-section.ts +++ b/renderer/shared/settings-section.ts @@ -4,6 +4,7 @@ export const SETTINGS_SECTIONS = [ "skills", "mcp", "websearch", + "telegram", "computerUse", "scheduledTasks", "assistant", @@ -40,6 +41,7 @@ export const SETTINGS_DESTINATIONS: ReadonlyArray<{ { id: "skills", title: "Skills", group: "Agent", keywords: ["instructions", "tools"] }, { id: "mcp", title: "MCP Servers", group: "Agent", keywords: ["connections", "protocol"] }, { id: "websearch", title: "Web Search", group: "Agent", keywords: ["internet", "exa"] }, + { id: "telegram", title: "Telegram", group: "Agent", keywords: ["remote", "bot", "phone", "control"] }, { id: "scheduledTasks", title: "Scheduled tasks", diff --git a/resources/telegram/LICENSE.pi-telegram.md b/resources/telegram/LICENSE.pi-telegram.md new file mode 100644 index 0000000..c1eb961 --- /dev/null +++ b/resources/telegram/LICENSE.pi-telegram.md @@ -0,0 +1,32 @@ +MIT License + +Copyright (c) 2024 llblab (https://github.com/llblab/pi-telegram) +Copyright (c) 2024 badlogic (https://github.com/badlogic/pi-telegram) + +The Telegram Bot API transport, queue discipline, pairing flow, polling +algorithm, and Markdown-to-HTML rendering in main/services/telegram/ are +adapted from pi-telegram, a fork of badlogic/pi-telegram, both MIT-licensed. + +The original Pi-SDK host contract (sendUserMessage, isIdle, +hasPendingMessages, lifecycle events) is replaced by Aiden's own +llmClient turn-injection shim (telegram-turn.ts). Threaded-Mode, +companion-extension, and Pi extension-loader code from the original +project are not included. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.