From 5c9dcad96235cf8ba302b55b285c75e45dc99cd8 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 18:33:14 -0400 Subject: [PATCH 01/15] chore: ignore local .worktrees/ directory --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/ From 0f56aed887c6865e0b9a3823d2e3d2ea388f300b Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 18:52:54 -0400 Subject: [PATCH 02/15] plan: add Telegram Remote Control plan (port pi-telegram into Aiden) Vendor pi-telegram's TypeScript Telegram adapter into Aiden as an always-on main-process service, backed by the existing headless-turn seam in schedule-execution.ts. Full-unattended single-owner model with one persistent chat; MIT-sourced, ported with attribution. Adds the plan doc and a row in the plans index. --- docs/plans/README.md | 1 + docs/plans/telegram-remote-control-plan.md | 216 +++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 docs/plans/telegram-remote-control-plan.md diff --git a/docs/plans/README.md b/docs/plans/README.md index 2f7812c..6640af6 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -15,6 +15,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Pi Provider Integration](pi-provider-integration-plan.md) | Partial | Pi built-ins, stores, auth, native routing, refresh, and voice credential lookup ship; custom composition, provenance, scalable UX, and rollout cleanup remain. | | [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, and the canonical Phase 7B1 storage seam are complete; app-lifetime coordinator activation is next. | | [Taracodlab Learnings](taracodlab-learnings-plan.md) | Partial | Phases A–B and D, plus core Phase E, are implemented; the remaining roadmap is open. | +| [Telegram Remote Control](telegram-remote-control-plan.md) | Planned | Port `pi-telegram`'s TypeScript adapter into an always-on main-process service backed by Aiden's existing headless-turn seam (`schedule-execution.ts`); full-unattended, single-owner, one persistent chat. | | [Update, Microphone, and Computer Use Hardening](update-microphone-computer-use-hardening-plan.md) | Partial | Installed acceptance found a silent stalled download; observable full-download recovery is implemented, while repaired-build → next-release and clean-TCC acceptance remain. | ## Completed diff --git a/docs/plans/telegram-remote-control-plan.md b/docs/plans/telegram-remote-control-plan.md new file mode 100644 index 0000000..5593841 --- /dev/null +++ b/docs/plans/telegram-remote-control-plan.md @@ -0,0 +1,216 @@ +# Telegram Remote Control Plan + +Status: planned on 2026-08-11; not yet implemented +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. **One persistent chat.** A single Aiden chat backs the Telegram surface (`chatStore.create({ id: "telegram-", ... })` on first inbound), reused across turns. No per-thread or multi-session binding in v1. +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 chat via `chatStore.create({ id: "telegram-" })` | `schedule-execution.ts:129` | +| `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` PNG; data-driven gallery entry; onboarding test coverage. + +## 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. From ae73da145c2457f686a8976c7f189a4ec6bea3e1 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 19:24:54 -0400 Subject: [PATCH 03/15] =?UTF-8?q?feat:=20Telegram=20remote=20control=20?= =?UTF-8?q?=E2=80=94=20clean-slate=20port=20of=20pi-telegram?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Telegram Remote Control plan (Phases 0–5). A paired Telegram owner can send messages to an Aiden bot and receive headless, full-authority responses — the same trust boundary as scheduled tasks. What ships: - main/services/telegram/: Bot API client (long-polling transport), Markdown → HTML converter with 4096-char chunking, control/priority/default queue with dispatch gates, headless turn injection shim (mirrors schedule-execution.ts createBackgroundOwner + llmClient.start), config store (lastUpdateId via DataStore), and service core singleton with start/stop/stopAndSettle lifecycle. - main/handlers/telegram.ts: telegram:get/setKey/setEnabled/connect/ disconnect/resetPairing IPC handlers. - main/index.ts: service started in whenReady, stopped in cleanupApplication and shutdownAndQuit. - renderer/components/settings/telegram-settings.tsx: enable toggle, bot token input (safeStorage/Keychain), connection status, pairing controls, security notice. - AppSettings: telegramEnabled, telegramAllowedUserId. - UsageRequestSource: telegram. - MIT attribution to pi-telegram / badlogic/pi-telegram. - 50 tests (bot API 10, turn injection 9, queue 10, markdown 10, service core 11), all passing. Phase 6 (onboarding bento tile) deferred pending a 1024×1024 PNG asset. Design reference: pi-telegram (https://github.com/llblab/pi-telegram, MIT). --- docs/plans/README.md | 2 +- docs/plans/telegram-remote-control-plan.md | 38 +- main/handlers/index.ts | 4 +- main/handlers/telegram.ts | 72 ++ main/index.ts | 4 + main/services/portable-config-core.ts | 1 + .../telegram/telegram-bot-api.test.ts | 153 ++++ main/services/telegram/telegram-bot-api.ts | 204 ++++++ main/services/telegram/telegram-config.ts | 107 +++ .../telegram/telegram-markdown.test.ts | 78 +++ main/services/telegram/telegram-markdown.ts | 207 ++++++ main/services/telegram/telegram-queue.test.ts | 164 +++++ main/services/telegram/telegram-queue.ts | 102 +++ .../telegram/telegram-service-core.test.ts | 663 ++++++++++++++++++ .../telegram/telegram-service-core.ts | 390 +++++++++++ main/services/telegram/telegram-service.ts | 92 +++ main/services/telegram/telegram-turn.test.ts | 205 ++++++ main/services/telegram/telegram-turn.ts | 238 +++++++ main/services/types.ts | 4 + main/services/usage-store-core.ts | 4 +- package.json | 4 +- .../components/settings/telegram-settings.tsx | 150 ++++ renderer/lib/ipc.ts | 18 + renderer/lib/queries.ts | 7 +- renderer/main/settings-view.tsx | 4 + renderer/preload-channels.ts | 1 + renderer/shared/settings-section.ts | 2 + resources/telegram/LICENSE.pi-telegram.md | 32 + 28 files changed, 2942 insertions(+), 8 deletions(-) create mode 100644 main/handlers/telegram.ts create mode 100644 main/services/telegram/telegram-bot-api.test.ts create mode 100644 main/services/telegram/telegram-bot-api.ts create mode 100644 main/services/telegram/telegram-config.ts create mode 100644 main/services/telegram/telegram-markdown.test.ts create mode 100644 main/services/telegram/telegram-markdown.ts create mode 100644 main/services/telegram/telegram-queue.test.ts create mode 100644 main/services/telegram/telegram-queue.ts create mode 100644 main/services/telegram/telegram-service-core.test.ts create mode 100644 main/services/telegram/telegram-service-core.ts create mode 100644 main/services/telegram/telegram-service.ts create mode 100644 main/services/telegram/telegram-turn.test.ts create mode 100644 main/services/telegram/telegram-turn.ts create mode 100644 renderer/components/settings/telegram-settings.tsx create mode 100644 resources/telegram/LICENSE.pi-telegram.md diff --git a/docs/plans/README.md b/docs/plans/README.md index 6640af6..4d2d79d 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -15,7 +15,7 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Pi Provider Integration](pi-provider-integration-plan.md) | Partial | Pi built-ins, stores, auth, native routing, refresh, and voice credential lookup ship; custom composition, provenance, scalable UX, and rollout cleanup remain. | | [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, and the canonical Phase 7B1 storage seam are complete; app-lifetime coordinator activation is next. | | [Taracodlab Learnings](taracodlab-learnings-plan.md) | Partial | Phases A–B and D, plus core Phase E, are implemented; the remaining roadmap is open. | -| [Telegram Remote Control](telegram-remote-control-plan.md) | Planned | Port `pi-telegram`'s TypeScript adapter into an always-on main-process service backed by Aiden's existing headless-turn seam (`schedule-execution.ts`); full-unattended, single-owner, one persistent chat. | +| [Telegram Remote Control](telegram-remote-control-plan.md) | Partial | Phases 0–5 ship: clean-slate port of pi-telegram (Bot API, polling, pairing, queue, Markdown→HTML delivery, headless turn shim, service lifecycle, Settings UI, IPC handlers, 50 tests). Phase 6 onboarding bento deferred pending the 1024×1024 PNG asset. | | [Update, Microphone, and Computer Use Hardening](update-microphone-computer-use-hardening-plan.md) | Partial | Installed acceptance found a silent stalled download; observable full-download recovery is implemented, while repaired-build → next-release and clean-TCC acceptance remain. | ## Completed diff --git a/docs/plans/telegram-remote-control-plan.md b/docs/plans/telegram-remote-control-plan.md index 5593841..ad809e4 100644 --- a/docs/plans/telegram-remote-control-plan.md +++ b/docs/plans/telegram-remote-control-plan.md @@ -1,6 +1,6 @@ # Telegram Remote Control Plan -Status: planned on 2026-08-11; not yet implemented +Status: implemented on 2026-08-11; Phases 0–5 complete; Phase 6 (onboarding bento) deferred pending a 1024×1024 PNG asset 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. @@ -214,3 +214,39 @@ Threaded Mode + multi-instance bus; compaction menu; activity/thinking/tool rend - **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–5 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) +- `main/handlers/index.ts` — registerTelegramHandlers +- `main/index.ts` — lifecycle anchors (start/stop/stopAndSettle) +- `main/services/types.ts` — AppSettings: telegramEnabled, telegramAllowedUserId +- `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 +- `renderer/lib/queries.ts` — queryKeys.telegram, useTelegramSettings +- `renderer/components/settings/telegram-settings.tsx` — settings UI +- `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 + +**Tests** (50 passing): bot API (10), turn injection (9), queue (10), markdown (10), service core (11). + +**Phase 6 (onboarding)** deferred: requires a 1024×1024 transparent PNG asset for the bento gallery tile. diff --git a/main/handlers/index.ts b/main/handlers/index.ts index 0591885..cbbccef 100644 --- a/main/handlers/index.ts +++ b/main/handlers/index.ts @@ -22,7 +22,7 @@ import { registerDictationHandlers } from "./dictation.js"; import { registerScheduledTaskHandlers } from "./scheduled-tasks.js"; import { registerAssistantHandlers } from "./assistant.js"; import { registerShortcutHandlers } from "./shortcuts.js"; -import { registerSubagentHandlers } from "./subagents.js"; +import { registerTelegramHandlers } from "./telegram.js"; import { ipcMain, logger } from "../platform.js"; import { writeDevLog } from "../services/dev-log.js"; @@ -62,7 +62,7 @@ export function registerHandlers(): void { registerScheduledTaskHandlers(); registerAssistantHandlers(); registerShortcutHandlers(); - registerSubagentHandlers(); + registerTelegramHandlers(); logger.info("handlers", "✓ IPC handlers registered"); diff --git a/main/handlers/telegram.ts b/main/handlers/telegram.ts new file mode 100644 index 0000000..4508812 --- /dev/null +++ b/main/handlers/telegram.ts @@ -0,0 +1,72 @@ +// 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 { TELEGRAM_PROVIDER_ID } from "../services/telegram/telegram-service.js"; + +export interface TelegramStatusResponse { + enabled: boolean; + hasToken: boolean; + allowedUserId?: number; + polling: boolean; + 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, + 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 }; + }); +} 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..896fef0 --- /dev/null +++ b/main/services/telegram/telegram-markdown.ts @@ -0,0 +1,207 @@ +// 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; + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .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 `\x00CODESPAN${codeSpans.length - 1}\x00`; + }); + + // Inline code: `code` + working = working.replace(/`([^`]+)`/g, (_m, code: string) => { + codeSpans.push(`${escapeHtml(code)}`); + return `\x00CODESPAN${codeSpans.length - 1}\x00`; + }); + + // 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) + working = working.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1', + ); + + // Restore code spans. + working = working.replace(/\x00CODESPAN(\d+)\x00/g, (_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); + if (line.startsWith(fenceMarker) || line.trim() === fenceMarker.slice(0, 3)) { + 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("> ") || line.startsWith("> ")) { + const text = line.startsWith("> ") ? line.slice(5) : line.slice(2); + output.push(`
${convertInline(text)}
`); + 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. + */ +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) { + // Flush current chunk first. + if (current) { + chunks.push(current.trim()); + current = ""; + } + // Hard-split the oversized paragraph at line boundaries. + 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; +} + +/** 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) { + // Char-level split for pathologically long lines. + 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..f3565f4 --- /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 }; +} + +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..d375aa9 --- /dev/null +++ b/main/services/telegram/telegram-queue.ts @@ -0,0 +1,102 @@ +// 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; + readonly chatId: number; + readonly fromUsername?: 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..6c6e5a8 --- /dev/null +++ b/main/services/telegram/telegram-service-core.test.ts @@ -0,0 +1,663 @@ +// 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 { 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; the test owns termination. + return new Promise(() => {}); + }, + 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; +} + +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, + }); + + 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; + } + 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; +} + +/** 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 llmClient = { + beginChatTurn() { + if (opts.busy) return null; + return { + release: () => { + releasedLeases += 1; + }, + settleAsyncWork: () => { + settledLeases += 1; + }, + }; + }, + async start( + streamId: string, + _params: unknown, + owner: TurnOwner, + _options: unknown, + ): Promise { + startCalls += 1; + if (opts.pending) return new Promise(() => {}); + 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; + 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" }; + }, + broadcastMetadata(_chat: unknown) {}, + }; + + return { + turn, + startCalls: () => startCalls, + appendCalls: () => appendCalls, + createCalls: () => createCalls, + releasedLeases: () => releasedLeases, + 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); + }; + 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; +} + +function harness(o: HarnessOptions = {}) { + const config = createMockConfig({ + enabled: o.enabled ?? false, + hasToken: o.hasToken ?? true, + allowedUserId: o.allowedUserId, + }); + const turnMock = createMockTurn({ + reply: o.reply, + pending: o.pendingTurn, + busy: o.busyTurn, + failMessage: o.failMessage, + }); + 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, + 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"); + assert.equal(service.isActive, false, "active turn flag cleared"); + 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"); +}); + +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("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..793bf5f --- /dev/null +++ b/main/services/telegram/telegram-service-core.ts @@ -0,0 +1,390 @@ +// 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" + | "paired" + | "error"; + +export interface TelegramServiceState { + status: TelegramServiceStatus; + enabled: boolean; + hasToken: boolean; + allowedUserId?: number; + 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; +} + +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: deriveStatus(), + enabled: false, // set by caller via snapshot + hasToken: false, + allowedUserId: undefined, + botUsername, + lastError, + queuedCount: queue.size(), + }; + } + + function deriveStatus(): TelegramServiceStatus { + if (lastError) return "error"; + if (!started) return "disabled"; + return "idle"; + } + + 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(); + activeTurn = false; + dispatchPending = false; + } + + async function stopAndSettle(): Promise { + stop(); + // Give in-flight turns a brief grace period to settle. + // 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 { + // Verify bot identity on connect. + 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) { + // Persist offset ONLY after successful handling (pi-telegram rule). + try { + await handleUpdate(update); + } catch (cause) { + deps.error( + `Telegram handleUpdate failed for ${update.update_id}.`, + cause, + ); + } + // Monotonic max: always advance past this update. + offset = update.update_id + 1; + await deps.config.persistOffset(update.update_id); + } + + // Try dispatching after each batch. + 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; + + // Answer callback queries immediately. + if (update.callback_query) { + await deps.api.answerCallbackQuery(update.callback_query.id); + } + + const snap = await deps.config.snapshot(); + + // Authorization / pairing gate. + 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 }); + 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 abort an in-flight turn.", + }); + return; + } + + // Only the paired owner is authorized. + if (from.id !== snap.allowedUserId) { + deps.warn(`Telegram: unauthorized user ${from.id} ignored.`); + return; + } + + const text = extractText(message); + if (!text) return; + + // Control lane: commands are handled immediately (no LLM). + if (text.startsWith("/")) { + await handleCommand(text.trim(), message); + return; + } + + // Enqueue the prompt. + queue.enqueue({ + lane: classifyMessage(text), + text, + chatId: message.chat.id, + fromUsername: from.username, + }); + + // Try dispatching. + tryDispatch(); + } + + async function handleCommand(command: string, message: TelegramMessage): Promise { + const cmd = command.split(/\s+/)[0]?.toLowerCase() ?? ""; + const chatId = message.chat.id; + + if (cmd === "/start") { + await deps.api.sendMessage({ + chatId, + text: "🤖 Aiden Telegram Bridge\n\nSend any message and I'll respond as Aiden.\n\nCommands:\n/start — show this help\n/stop — abort the current turn\n/status — show bridge status", + }); + return; + } + + if (cmd === "/stop" || cmd === "/cancel") { + stop(); + // Restart polling after abort. + await connect(); + await deps.api.sendMessage({ + chatId, + text: "⏹ Turn aborted. Polling resumed.", + }); + 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; + } + + // Unknown command — treat as a prompt. + queue.enqueue({ + lane: "default", + text: command, + chatId, + fromUsername: message.from?.username, + }); + tryDispatch(); + } + + /** Attempt to dispatch the next queued turn. No-op if gates block. */ + function tryDispatch(): void { + if (activeTurn || dispatchPending) return; + const next = queue.dequeue(); + if (!next) return; + void dispatchTurn(next); + } + + async function dispatchTurn(turn: QueuedTelegramTurn): Promise { + dispatchPending = true; + + // Ensure the persistent chat exists. + const chatId = telegramChatId(turn.chatId); + try { + const settings = await deps.config.getSettings(); + await ensureTelegramChat( + deps.turn, + turn.chatId, + `Telegram${turn.fromUsername ? ` (@${turn.fromUsername})` : ""}`, + settings.lastProviderId, + settings.lastModel, + ); + } catch (cause) { + deps.error("Telegram: failed to ensure chat for turn.", cause); + } + + // Send typing indicator. + void sendTypingIndicator(turn.chatId).catch(() => undefined); + + activeTurn = true; + dispatchPending = false; + + try { + const result: TelegramTurnResult = await sendTelegramTurn( + deps.turn, + chatId, + turn.text, + ); + 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; + // Try dispatching the next queued turn. + 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) { + 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..2fe949e --- /dev/null +++ b/main/services/telegram/telegram-service.ts @@ -0,0 +1,92 @@ +// 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 { + createFetchTransport, + TelegramBotApi, +} from "./telegram-bot-api.js"; +import { createTelegramConfig } from "./telegram-config.js"; +import { createTelegramServiceCore } from "./telegram-service-core.js"; + +export const TELEGRAM_PROVIDER_ID = "telegram"; + +async function resolveProvider(): Promise<{ providerId: string; model: string } | null> { + const settings = await configStore.getSettings(); + const providerId = settings.lastProviderId; + if (!providerId) return null; + const provider = + (await providerRegistry.selectionProvider(providerId)) ?? + (await configStore.getProvider(providerId)); + if (!provider) return null; + const model = settings.lastModel ?? provider.defaultModel ?? provider.models[0]; + if (!model) return null; + return { providerId, model }; +} + +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() { + const api = new TelegramBotApi( + createFetchTransport(() => secrets.getKey(TELEGRAM_PROVIDER_ID)), + ); + return createTelegramServiceCore({ + api, + config: createTelegramConfig({ + getSettings: () => configStore.getSettings(), + setSettings: (patch) => configStore.setSettings(patch), + hasToken: () => secrets.hasKey(TELEGRAM_PROVIDER_ID), + resolveRootDir: () => app.getPath("userData"), + }), + turn: { + llmClient, + chatStore, + resolveProvider, + 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..b428ad1 --- /dev/null +++ b/main/services/telegram/telegram-turn.test.ts @@ -0,0 +1,205 @@ +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 }; +} + +function mockDeps(opts: { + llm?: TelegramLlmClient; + store?: TelegramChatStore; + provider?: { providerId: string; model: string } | null; +}): { 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" }) + : async () => opts.provider ?? null, + 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("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" }), + 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..045bc79 --- /dev/null +++ b/main/services/telegram/telegram-turn.ts @@ -0,0 +1,238 @@ +// 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"; + +/** 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; + }, + ): 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 } | null>; + broadcastMetadata(chat: { id: string; workspaceId?: string; title: string; updatedAt: number }): void; +} + +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: `telegram-`. */ +export function telegramChatId(ownerUserId: number): string { + return `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, +): Promise { + const chatId = telegramChatId(ownerUserId); + const existing = await deps.chatStore.get(chatId); + if (existing) { + deps.broadcastMetadata(existing); + return chatId; + } + const chat = await deps.chatStore.create({ + id: chatId, + title, + providerId, + 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, +): Promise { + 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 { + await deps.chatStore.appendMessage( + chatId, + { role: "user", content }, + { providerId: provider.providerId, model: provider.model }, + ); + turn.settleAsyncWork(); + + const started = await deps.llmClient.start( + streamId, + { + chatId, + providerId: provider.providerId, + model: provider.model, + mode: "assistant-unattended", + messages: [{ role: "user", content }], + }, + background.owner, + { + permission: "full", + allowComputerUse: false, + allowSubagents: false, + allowMcpTools: false, + usageSource: "telegram", + turnId: streamId, + }, + ); + + 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/types.ts b/main/services/types.ts index ec5e0d2..d4be7fe 100644 --- a/main/services/types.ts +++ b/main/services/types.ts @@ -490,6 +490,10 @@ 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; } 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..3e5c6be 100644 --- a/package.json +++ b/package.json @@ -72,8 +72,8 @@ "test:e2e:list": "playwright test --config=playwright.config.ts --list", "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", + "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/components/settings/telegram-settings.tsx b/renderer/components/settings/telegram-settings.tsx new file mode 100644 index 0000000..8ecc606 --- /dev/null +++ b/renderer/components/settings/telegram-settings.tsx @@ -0,0 +1,150 @@ +// Telegram settings — bot token, enable toggle, pairing status, and controls. +// Mirrors web-search-settings.tsx. 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, Switch, toast } from "../ui"; +import { telegramApi } from "../../lib/ipc"; +import { queryKeys, useTelegramSettings } from "../../lib/queries"; + +export function TelegramSettings() { + const qc = useQueryClient(); + const telegram = useTelegramSettings(); + 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 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."); + }; + + return ( +
+ + + + + +
+ setKeyDraft(e.target.value)} + placeholder={hasToken ? "••••••••••••" : "Paste your bot token from @BotFather"} + /> + +
+
+ + {hasToken && ( + +
+ + + + {polling ? "● Polling" : "○ Idle"} + {queuedCount > 0 ? ` · ${queuedCount} queued` : ""} + +
+
+ )} + + {allowedUserId !== undefined && ( + +
+ +
+
+ )} + + {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. Toggle Enable, then send /start to your bot from Telegram to pair.
  8. +
+
+ + +

+ 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..cb7ae08 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -333,6 +333,24 @@ export const exaApi = { invoke("exa:setEnabled", enabled), }; +// ── Telegram remote control ────────────────────────────────────────── +export interface TelegramStatus { + enabled: boolean; + hasToken: boolean; + allowedUserId?: number; + 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"), +}; + // ── Voice + shortcut ────────────────────────────────────────────────── export const voiceApi = { transcribe: (audioBase64: string, mimeType: string) => diff --git a/renderer/lib/queries.ts b/renderer/lib/queries.ts index 879dc2e..96921c4 100644 --- a/renderer/lib/queries.ts +++ b/renderer/lib/queries.ts @@ -23,7 +23,7 @@ import { settingsApi, shortcutApi, skillsApi, - titleProvidersApi, + telegramApi, usageApi, workspacesApi, } from "./ipc"; @@ -58,6 +58,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 +459,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/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. From 81f89af83ec03a9c8aa235ff54e232724a4f8c9c Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 19:57:48 -0400 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20Telegram=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20offset=20off-by-one,=20/stop,=20typing,=20chunker,?= =?UTF-8?q?=20link=20escape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review corrections from GPT-5.6 correctness audit: MUST-FIX: - Fix persisted polling offset off-by-one: persist update_id+1 (the resume offset) instead of raw update_id. Prevents duplicate processing on restart. - Fix /stop corrupting dispatch state: stop() no longer resets activeTurn mid-turn (the dispatchTurn finally block owns it). /stop now clears the queue and honestly reports that the in-flight turn continues. SHOULD-FIX: - Fix typing indicator never firing: set activeTurn=true BEFORE calling sendTypingIndicator so the loop condition evaluates correctly. - Fix settleAsyncWork not in a finally: wrap appendMessage in try/finally matching the proven schedule-execution.ts pattern. - Fix offset persisted on handleUpdate failure: only persist after success. - Fix link URL attribute injection: escape quotes in href values. - Fix chunker splitting inside
 tags: balancePreTags closes/reopens
  
 at chunk boundaries.
- Fix getStatus() hardcoded values: remove dead enabled/hasToken/allowedUserId
  fields; derive status from actual started/lastError state.
- Fix polling indicator lying on error: status reflects actual running state.

NIT:
- Remove dead '> ' blockquote branch (escaping happens later).
- Fix fence-close heuristic: closing fence must be >= opening fence length.
- Add private-chat guard: ignore group/supergroup messages.
- Move answerCallbackQuery after the authorization gate.

Test updates: mock sleep yields to event loop (setImmediate) to prevent
typing-loop microtask starvation; stop() test asserts activeTurn stays true
while a turn is in flight.
---
 main/services/telegram/telegram-markdown.ts   | 38 ++++++---
 .../telegram/telegram-service-core.test.ts    |  6 +-
 .../telegram/telegram-service-core.ts         | 84 +++++++++----------
 main/services/telegram/telegram-turn.ts       | 15 ++--
 4 files changed, 82 insertions(+), 61 deletions(-)

diff --git a/main/services/telegram/telegram-markdown.ts b/main/services/telegram/telegram-markdown.ts
index 896fef0..9b2070e 100644
--- a/main/services/telegram/telegram-markdown.ts
+++ b/main/services/telegram/telegram-markdown.ts
@@ -21,6 +21,11 @@ function escapeHtml(text: string): string {
     .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.
@@ -50,10 +55,11 @@ function convertInline(markdown: string): string {
   // Strikethrough: ~~text~~
   working = working.replace(/~~(.+?)~~/g, "$1");
 
-  // Links: [text](url)
+  // Links: [text](url) — escape quotes in the URL for safe attribute injection.
   working = working.replace(
     /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
-    '$1',
+    (_m, text: string, url: string) =>
+      `${text}`,
   );
 
   // Restore code spans.
@@ -99,7 +105,9 @@ export function markdownToTelegramHtml(markdown: string): string {
     }
     if (inFencedBlock) {
       codeLines.push(line);
-      if (line.startsWith(fenceMarker) || line.trim() === fenceMarker.slice(0, 3)) {
+      // 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")));
       }
@@ -114,9 +122,8 @@ export function markdownToTelegramHtml(markdown: string): string {
     }
 
     // Blockquote.
-    if (line.startsWith("> ") || line.startsWith("> ")) {
-      const text = line.startsWith("> ") ? line.slice(5) : line.slice(2);
-      output.push(`
${convertInline(text)}
`); + if (line.startsWith("> ")) { + output.push(`
${convertInline(line.slice(2))}
`); continue; } @@ -146,6 +153,7 @@ export function markdownToTelegramHtml(markdown: string): string { * 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;
@@ -157,12 +165,10 @@ export function chunkForTelegram(html: string): string[] {
 
   for (const para of paragraphs) {
     if (para.length > limit) {
-      // Flush current chunk first.
       if (current) {
         chunks.push(current.trim());
         current = "";
       }
-      // Hard-split the oversized paragraph at line boundaries.
       for (const piece of hardSplit(para, limit)) chunks.push(piece);
       continue;
     }
@@ -176,7 +182,20 @@ export function chunkForTelegram(html: string): string[] {
   }
 
   if (current.trim()) chunks.push(current.trim());
-  return chunks;
+  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. */
@@ -189,7 +208,6 @@ function hardSplit(text: string, limit: number): string[] {
     if ((current + "\n" + line).length > limit) {
       if (current) chunks.push(current.trim());
       if (line.length > limit) {
-        // Char-level split for pathologically long lines.
         for (let i = 0; i < line.length; i += limit) {
           chunks.push(line.slice(i, i + limit));
         }
diff --git a/main/services/telegram/telegram-service-core.test.ts b/main/services/telegram/telegram-service-core.test.ts
index 6c6e5a8..0c9729e 100644
--- a/main/services/telegram/telegram-service-core.test.ts
+++ b/main/services/telegram/telegram-service-core.test.ts
@@ -318,6 +318,8 @@ function createMockSleep() {
   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 };
 }
@@ -472,7 +474,9 @@ test("stop() halts polling and clears the queue", async () => {
   service.stop();
 
   assert.equal(service.queueSize, 0, "queue cleared");
-  assert.equal(service.isActive, false, "active turn flag 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");
 });
 
diff --git a/main/services/telegram/telegram-service-core.ts b/main/services/telegram/telegram-service-core.ts
index 793bf5f..70acc3d 100644
--- a/main/services/telegram/telegram-service-core.ts
+++ b/main/services/telegram/telegram-service-core.ts
@@ -35,14 +35,10 @@ export type TelegramServiceStatus =
   | "disabled"
   | "idle"
   | "polling"
-  | "paired"
   | "error";
 
 export interface TelegramServiceState {
   status: TelegramServiceStatus;
-  enabled: boolean;
-  hasToken: boolean;
-  allowedUserId?: number;
   botUsername?: string;
   lastError?: string;
   queuedCount: number;
@@ -75,22 +71,13 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
 
   function getStatus(): TelegramServiceState {
     return {
-      status: deriveStatus(),
-      enabled: false, // set by caller via snapshot
-      hasToken: false,
-      allowedUserId: undefined,
+      status: started ? (lastError ? "error" : "polling") : "disabled",
       botUsername,
       lastError,
       queuedCount: queue.size(),
     };
   }
 
-  function deriveStatus(): TelegramServiceStatus {
-    if (lastError) return "error";
-    if (!started) return "disabled";
-    return "idle";
-  }
-
   async function start(): Promise {
     if (started) return;
     const snap = await deps.config.snapshot();
@@ -107,13 +94,13 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
     abortController?.abort();
     abortController = undefined;
     queue.clear();
-    activeTurn = false;
-    dispatchPending = false;
+    // 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();
-    // Give in-flight turns a brief grace period to settle.
     // The abort signal cancels polling; active LLM turns settle on their own.
   }
 
@@ -138,7 +125,6 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
 
   async function runPollLoop(signal: AbortSignal): Promise {
     try {
-      // Verify bot identity on connect.
       const me = await deps.api.getMe();
       botUsername = me.username;
       deps.info(`Telegram bot connected as @${me.username}.`);
@@ -166,21 +152,25 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
       lastError = undefined;
 
       for (const update of updates) {
-        // Persist offset ONLY after successful handling (pi-telegram rule).
+        let handled = false;
         try {
           await handleUpdate(update);
+          handled = true;
         } catch (cause) {
           deps.error(
             `Telegram handleUpdate failed for ${update.update_id}.`,
             cause,
           );
         }
-        // Monotonic max: always advance past this update.
-        offset = update.update_id + 1;
-        await deps.config.persistOffset(update.update_id);
+        // 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);
+        }
       }
 
-      // Try dispatching after each batch.
       tryDispatch();
     }
   }
@@ -190,21 +180,23 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
     const from = update.message?.from ?? update.callback_query?.from;
     if (!message || !from) return;
 
-    // Answer callback queries immediately.
-    if (update.callback_query) {
-      await deps.api.answerCallbackQuery(update.callback_query.id);
-    }
+    // Restrict to private chats — group/supergroup messages are ignored.
+    if (message.chat.type !== "private") return;
 
     const snap = await deps.config.snapshot();
 
-    // Authorization / pairing gate.
+    // 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 abort an in-flight turn.",
+        text: "✅ Telegram bridge paired with this account.\n\nSend any message and I'll respond. Use /stop to clear queued messages.",
       });
       return;
     }
@@ -215,6 +207,11 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
       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;
 
@@ -232,7 +229,6 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
       fromUsername: from.username,
     });
 
-    // Try dispatching.
     tryDispatch();
   }
 
@@ -243,19 +239,19 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
     if (cmd === "/start") {
       await deps.api.sendMessage({
         chatId,
-        text: "🤖 Aiden Telegram Bridge\n\nSend any message and I'll respond as Aiden.\n\nCommands:\n/start — show this help\n/stop — abort the current turn\n/status — show bridge status",
+        text: "🤖 Aiden Telegram Bridge\n\nSend any message and I'll respond as Aiden.\n\nCommands:\n/start — show this help\n/stop — clear queued messages\n/status — show bridge status",
       });
       return;
     }
 
     if (cmd === "/stop" || cmd === "/cancel") {
-      stop();
-      // Restart polling after abort.
-      await connect();
-      await deps.api.sendMessage({
-        chatId,
-        text: "⏹ Turn aborted. Polling resumed.",
-      });
+      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;
     }
 
@@ -284,7 +280,7 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
 
   /** Attempt to dispatch the next queued turn. No-op if gates block. */
   function tryDispatch(): void {
-    if (activeTurn || dispatchPending) return;
+    if (!started || activeTurn || dispatchPending) return;
     const next = queue.dequeue();
     if (!next) return;
     void dispatchTurn(next);
@@ -308,12 +304,13 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
       deps.error("Telegram: failed to ensure chat for turn.", cause);
     }
 
-    // Send typing indicator.
-    void sendTypingIndicator(turn.chatId).catch(() => undefined);
-
     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);
+
     try {
       const result: TelegramTurnResult = await sendTelegramTurn(
         deps.turn,
@@ -329,7 +326,6 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
         .catch(() => undefined);
     } finally {
       activeTurn = false;
-      // Try dispatching the next queued turn.
       tryDispatch();
     }
   }
@@ -364,7 +360,7 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
     if (typingActive) return;
     typingActive = true;
     try {
-      while (activeTurn) {
+      while (activeTurn && started) {
         await deps.api.sendChatAction(chatId, "typing").catch(() => undefined);
         await deps.sleep(TYPING_INTERVAL_MS).catch(() => undefined);
       }
diff --git a/main/services/telegram/telegram-turn.ts b/main/services/telegram/telegram-turn.ts
index 045bc79..d824e46 100644
--- a/main/services/telegram/telegram-turn.ts
+++ b/main/services/telegram/telegram-turn.ts
@@ -182,12 +182,15 @@ export async function sendTelegramTurn(
   }
 
   try {
-    await deps.chatStore.appendMessage(
-      chatId,
-      { role: "user", content },
-      { providerId: provider.providerId, model: provider.model },
-    );
-    turn.settleAsyncWork();
+    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,

From 108626065385119b0e9dafb9799e8c860b60c9fc Mon Sep 17 00:00:00 2001
From: Sambit Biswas 
Date: Tue, 11 Aug 2026 20:01:30 -0400
Subject: [PATCH 05/15] fix: add ownerUserId to QueuedTelegramTurn (NIT #13)

Separate the Telegram chat ID (for API calls) from the owner's user ID
(for the persistent Aiden chat key). For private chats they're equal, but
the explicit fields prevent a subtle bug if group support is ever added.
---
 main/services/telegram/telegram-queue.test.ts   | 2 +-
 main/services/telegram/telegram-queue.ts        | 3 +++
 main/services/telegram/telegram-service-core.ts | 9 ++++++---
 3 files changed, 10 insertions(+), 4 deletions(-)

diff --git a/main/services/telegram/telegram-queue.test.ts b/main/services/telegram/telegram-queue.test.ts
index f3565f4..c8d1968 100644
--- a/main/services/telegram/telegram-queue.test.ts
+++ b/main/services/telegram/telegram-queue.test.ts
@@ -18,7 +18,7 @@ function makeDeps() {
 }
 
 function turn(lane: QueueLane, text: string, chatId = 1): QueuedTelegramTurn {
-  return { lane, text, chatId };
+  return { lane, text, chatId, ownerUserId: chatId };
 }
 
 test("dequeue order is control, then priority, then default", () => {
diff --git a/main/services/telegram/telegram-queue.ts b/main/services/telegram/telegram-queue.ts
index d375aa9..bd202a2 100644
--- a/main/services/telegram/telegram-queue.ts
+++ b/main/services/telegram/telegram-queue.ts
@@ -18,7 +18,10 @@ 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;
 }
 
diff --git a/main/services/telegram/telegram-service-core.ts b/main/services/telegram/telegram-service-core.ts
index 70acc3d..33a1da2 100644
--- a/main/services/telegram/telegram-service-core.ts
+++ b/main/services/telegram/telegram-service-core.ts
@@ -226,6 +226,7 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
       lane: classifyMessage(text),
       text,
       chatId: message.chat.id,
+      ownerUserId: from.id,
       fromUsername: from.username,
     });
 
@@ -273,6 +274,7 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
       lane: "default",
       text: command,
       chatId,
+      ownerUserId: message.from?.id ?? chatId,
       fromUsername: message.from?.username,
     });
     tryDispatch();
@@ -289,13 +291,14 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
   async function dispatchTurn(turn: QueuedTelegramTurn): Promise {
     dispatchPending = true;
 
-    // Ensure the persistent chat exists.
-    const chatId = telegramChatId(turn.chatId);
+    // Ensure the persistent chat exists. The Aiden chat key is derived from
+    // the owner's Telegram user ID; the Telegram chat ID is used for API calls.
+    const chatId = telegramChatId(turn.ownerUserId);
     try {
       const settings = await deps.config.getSettings();
       await ensureTelegramChat(
         deps.turn,
-        turn.chatId,
+        turn.ownerUserId,
         `Telegram${turn.fromUsername ? ` (@${turn.fromUsername})` : ""}`,
         settings.lastProviderId,
         settings.lastModel,

From 74a691ddc725d5c1661d25668a6b409ac87bf079 Mon Sep 17 00:00:00 2001
From: Sambit Biswas 
Date: Tue, 11 Aug 2026 20:43:31 -0400
Subject: [PATCH 06/15] feat: Telegram provider/model picker + fingerprint fix
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Settings UI now shows a provider/model Select populated from usable
providers. The choice persists to AppSettings (telegramProviderId /
telegramModel) and is preferred by resolveProvider() over the global
lastProviderId/lastModel fallback.

Fixes the 'configure in Aiden' turn failure: the provider fingerprint
assertion (assertScheduledProviderFingerprint) rejected Telegram turns
because no providerFingerprint was passed. sendTelegramTurn now computes
it on the fly via scheduledProviderFingerprint(provider) and passes it
through to llmClient.start options.

resolveProvider() now returns the full StoredProvider so the fingerprint
can be computed. Settings UI surfaces a clear empty state guiding users
to Settings → Providers when none are configured.
---
 main/handlers/telegram.ts                     | 11 +++
 .../telegram/telegram-service-core.test.ts    | 12 ++-
 .../telegram/telegram-service-core.ts         |  2 +
 main/services/telegram/telegram-service.ts    | 25 ++++-
 main/services/telegram/telegram-turn.test.ts  | 14 ++-
 main/services/telegram/telegram-turn.ts       | 12 ++-
 main/services/types.ts                        |  4 +
 .../components/settings/telegram-settings.tsx | 94 ++++++++++++++++++-
 renderer/lib/ipc.ts                           |  4 +
 renderer/lib/queries.ts                       |  1 +
 10 files changed, 165 insertions(+), 14 deletions(-)

diff --git a/main/handlers/telegram.ts b/main/handlers/telegram.ts
index 4508812..23be845 100644
--- a/main/handlers/telegram.ts
+++ b/main/handlers/telegram.ts
@@ -13,6 +13,8 @@ export interface TelegramStatusResponse {
   enabled: boolean;
   hasToken: boolean;
   allowedUserId?: number;
+  providerId?: string;
+  model?: string;
   polling: boolean;
   queuedCount: number;
   lastError?: string;
@@ -26,6 +28,8 @@ export function registerTelegramHandlers(): void {
       enabled: settings.telegramEnabled ?? false,
       hasToken: await secrets.hasKey(TELEGRAM_PROVIDER_ID),
       allowedUserId: settings.telegramAllowedUserId,
+      providerId: settings.telegramProviderId,
+      model: settings.telegramModel,
       polling: status.status !== "disabled",
       queuedCount: status.queuedCount,
       lastError: status.lastError,
@@ -69,4 +73,11 @@ export function registerTelegramHandlers(): void {
     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 };
+  });
 }
diff --git a/main/services/telegram/telegram-service-core.test.ts b/main/services/telegram/telegram-service-core.test.ts
index 0c9729e..0a6c194 100644
--- a/main/services/telegram/telegram-service-core.test.ts
+++ b/main/services/telegram/telegram-service-core.test.ts
@@ -293,7 +293,17 @@ function createMockTurn(opts: MockTurnOptions = {}) {
     llmClient,
     chatStore,
     async resolveProvider() {
-      return { providerId: "openai", model: "gpt-4" };
+      return {
+        providerId: "openai",
+        model: "gpt-4",
+        provider: {
+          id: "openai",
+          kind: "openai" as const,
+          label: "OpenAI",
+          baseUrl: "https://api.openai.com/v1",
+          needsKey: true,
+        },
+      };
     },
     broadcastMetadata(_chat: unknown) {},
   };
diff --git a/main/services/telegram/telegram-service-core.ts b/main/services/telegram/telegram-service-core.ts
index 33a1da2..0fdae1d 100644
--- a/main/services/telegram/telegram-service-core.ts
+++ b/main/services/telegram/telegram-service-core.ts
@@ -180,6 +180,8 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) {
     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;
 
diff --git a/main/services/telegram/telegram-service.ts b/main/services/telegram/telegram-service.ts
index 2fe949e..876150c 100644
--- a/main/services/telegram/telegram-service.ts
+++ b/main/services/telegram/telegram-service.ts
@@ -9,6 +9,7 @@ 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,
@@ -18,17 +19,22 @@ import { createTelegramServiceCore } from "./telegram-service-core.js";
 
 export const TELEGRAM_PROVIDER_ID = "telegram";
 
-async function resolveProvider(): Promise<{ providerId: string; model: string } | null> {
+async function resolveProvider(): Promise<{
+  providerId: string;
+  model: string;
+  provider: StoredProvider;
+} | null> {
   const settings = await configStore.getSettings();
-  const providerId = settings.lastProviderId;
+  // 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.lastModel ?? provider.defaultModel ?? provider.models[0];
+  const model = settings.telegramModel ?? settings.lastModel ?? provider.defaultModel ?? provider.models[0];
   if (!model) return null;
-  return { providerId, model };
+  return { providerId, model, provider };
 }
 
 function broadcastMetadata(chat: {
@@ -63,8 +69,17 @@ function sleep(ms: number, signal?: AbortSignal): Promise {
 }
 
 export function createTelegramService() {
+  let tokenLogged = false;
   const api = new TelegramBotApi(
-    createFetchTransport(() => secrets.getKey(TELEGRAM_PROVIDER_ID)),
+    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,
diff --git a/main/services/telegram/telegram-turn.test.ts b/main/services/telegram/telegram-turn.test.ts
index b428ad1..bad6068 100644
--- a/main/services/telegram/telegram-turn.test.ts
+++ b/main/services/telegram/telegram-turn.test.ts
@@ -58,6 +58,14 @@ function mockChatStore(existing: ChatRecord | null = null): {
   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;
@@ -69,8 +77,8 @@ function mockDeps(opts: {
     chatStore: opts.store ?? mockChatStore().store,
     resolveProvider:
       opts.provider === undefined
-        ? async () => ({ providerId: "openai", model: "gpt-4o" })
-        : async () => opts.provider ?? null,
+        ? async () => ({ providerId: "openai", model: "gpt-4o", provider: MOCK_PROVIDER })
+        : async () => opts.provider ? { ...opts.provider, provider: MOCK_PROVIDER } : null,
     broadcastMetadata: (chat) => {
       broadcasts.push(chat);
     },
@@ -175,7 +183,7 @@ test("ensureTelegramChat creates a chat when none exists and reuses an existing
   const deps: TelegramTurnDeps = {
     llmClient: mockLlm(),
     chatStore: store,
-    resolveProvider: async () => ({ providerId: "openai", model: "gpt-4o" }),
+    resolveProvider: async () => ({ providerId: "openai", model: "gpt-4o", provider: MOCK_PROVIDER }),
     broadcastMetadata: (chat) => {
       broadcasts.push(chat);
     },
diff --git a/main/services/telegram/telegram-turn.ts b/main/services/telegram/telegram-turn.ts
index d824e46..093070f 100644
--- a/main/services/telegram/telegram-turn.ts
+++ b/main/services/telegram/telegram-turn.ts
@@ -9,6 +9,7 @@ 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 {
@@ -31,6 +32,7 @@ export interface TelegramLlmClient {
       allowMcpTools: boolean;
       usageSource: UsageRequestSource;
       turnId: string;
+      providerFingerprint?: string;
     },
   ): Promise;
   isChatBusy(chatId: string): boolean;
@@ -62,7 +64,14 @@ export interface TelegramChatStore {
 export interface TelegramTurnDeps {
   llmClient: TelegramLlmClient;
   chatStore: TelegramChatStore;
-  resolveProvider(): Promise<{ providerId: string; model: string } | null>;
+  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;
 }
 
@@ -209,6 +218,7 @@ export async function sendTelegramTurn(
         allowMcpTools: false,
         usageSource: "telegram",
         turnId: streamId,
+        providerFingerprint: scheduledProviderFingerprint(provider.provider),
       },
     );
 
diff --git a/main/services/types.ts b/main/services/types.ts
index d4be7fe..d9f1f2c 100644
--- a/main/services/types.ts
+++ b/main/services/types.ts
@@ -494,6 +494,10 @@ export interface AppSettings {
   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;
 }
 
 export type ComputerUseStatusState =
diff --git a/renderer/components/settings/telegram-settings.tsx b/renderer/components/settings/telegram-settings.tsx
index 8ecc606..4de2b92 100644
--- a/renderer/components/settings/telegram-settings.tsx
+++ b/renderer/components/settings/telegram-settings.tsx
@@ -1,16 +1,29 @@
-// Telegram settings — bot token, enable toggle, pairing status, and controls.
-// Mirrors web-search-settings.tsx. The bot token is stored encrypted via
+// 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, Switch, toast } from "../ui";
+import {
+  Button,
+  Field,
+  FieldSet,
+  Input,
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+  Switch,
+  toast,
+} from "../ui";
 import { telegramApi } from "../../lib/ipc";
-import { queryKeys, useTelegramSettings } from "../../lib/queries";
+import { queryKeys, useProviders, useTelegramSettings } from "../../lib/queries";
 
 export function TelegramSettings() {
   const qc = useQueryClient();
   const telegram = useTelegramSettings();
+  const providers = useProviders();
   const [keyDraft, setKeyDraft] = React.useState("");
 
   const invalidate = () => qc.invalidateQueries({ queryKey: queryKeys.telegram });
@@ -20,6 +33,8 @@ export function TelegramSettings() {
   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 saveKey = async () => {
     const value = keyDraft.trim();
@@ -56,6 +71,20 @@ export function TelegramSettings() {
     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.");
+  };
+
+  // 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"} /> @@ -94,6 +124,60 @@ export function TelegramSettings() { + 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 && (
@@ -114,6 +198,7 @@ export function TelegramSettings() { {allowedUserId !== undefined && (
+ User ID: {allowedUserId} @@ -132,6 +217,7 @@ export function TelegramSettings() {
  • Open Telegram and message @BotFather.
  • Send /newbot and follow the prompts to create a bot.
  • Copy the bot token and paste it above, then Save.
  • +
  • Choose a provider above (or set one up in Settings → Providers).
  • Toggle Enable, then send /start to your bot from Telegram to pair.
  • diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index cb7ae08..8a74b32 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -338,6 +338,8 @@ export interface TelegramStatus { enabled: boolean; hasToken: boolean; allowedUserId?: number; + providerId?: string; + model?: string; polling: boolean; queuedCount: number; lastError?: string; @@ -349,6 +351,8 @@ export const telegramApi = { 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), }; // ── Voice + shortcut ────────────────────────────────────────────────── diff --git a/renderer/lib/queries.ts b/renderer/lib/queries.ts index 96921c4..04cc3a2 100644 --- a/renderer/lib/queries.ts +++ b/renderer/lib/queries.ts @@ -24,6 +24,7 @@ import { shortcutApi, skillsApi, telegramApi, + titleProvidersApi, usageApi, workspacesApi, } from "./ipc"; From 79b9b47aaefe681cb800d82946b56e54ed432127 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 21:04:46 -0400 Subject: [PATCH 07/15] docs: define Telegram workspace authority --- ...-11-telegram-workspace-authority-design.md | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-telegram-workspace-authority-design.md 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..0a10922 --- /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:** a user explicitly selects one configured workspace in Settings → Telegram. Turns execute with `assistant-automation`, `permission: "full"`, and that exact workspace ID. + +The selection is never inferred from the most recently opened workspace. A phone-originated command therefore cannot mutate an unintended project. + +## 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. Each option identifies the configured workspace by name and path. The current no-workspace option is labelled as an assistant-only mode. The selector has explicit empty and unavailable states. + +## Turn routing + +Every Telegram turn resolves the selected workspace 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 and explicit Settings opt-in remain the trust boundary. + +## Verification + +Tests cover workspace chat identity, selected-workspace generation mode and ID, missing workspace rejection, persisted IPC settings, and settings selector states. 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. From 0446234f2c466ac65f275105ea602516f56f98ca Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 21:12:55 -0400 Subject: [PATCH 08/15] docs: plan Telegram workspace authority --- ...2026-08-11-telegram-workspace-authority.md | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-telegram-workspace-authority.md 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" +``` From e5c6f930805b16bf8f3600549f895868edcec593 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 21:21:35 -0400 Subject: [PATCH 09/15] feat: persist Telegram workspace authority --- main/handlers/telegram.ts | 18 +++++++++++++++ .../telegram/telegram-workspace-core.test.ts | 22 +++++++++++++++++++ .../telegram/telegram-workspace-core.ts | 13 +++++++++++ main/services/types.ts | 5 +++++ package.json | 2 +- renderer/lib/ipc.ts | 3 +++ 6 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 main/services/telegram/telegram-workspace-core.test.ts create mode 100644 main/services/telegram/telegram-workspace-core.ts diff --git a/main/handlers/telegram.ts b/main/handlers/telegram.ts index 23be845..a6f4acf 100644 --- a/main/handlers/telegram.ts +++ b/main/handlers/telegram.ts @@ -7,6 +7,10 @@ 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 { @@ -16,6 +20,7 @@ export interface TelegramStatusResponse { providerId?: string; model?: string; polling: boolean; + workspaceId?: string; queuedCount: number; lastError?: string; } @@ -30,6 +35,7 @@ export function registerTelegramHandlers(): void { allowedUserId: settings.telegramAllowedUserId, providerId: settings.telegramProviderId, model: settings.telegramModel, + workspaceId: settings.telegramWorkspaceId, polling: status.status !== "disabled", queuedCount: status.queuedCount, lastError: status.lastError, @@ -80,4 +86,16 @@ export function registerTelegramHandlers(): void { 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/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/types.ts b/main/services/types.ts index d9f1f2c..d273145 100644 --- a/main/services/types.ts +++ b/main/services/types.ts @@ -498,6 +498,11 @@ export interface AppSettings { 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/package.json b/package.json index 3e5c6be..1184182 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test:e2e:list": "playwright test --config=playwright.config.ts --list", "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: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", + "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", "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", diff --git a/renderer/lib/ipc.ts b/renderer/lib/ipc.ts index 8a74b32..b59c860 100644 --- a/renderer/lib/ipc.ts +++ b/renderer/lib/ipc.ts @@ -340,6 +340,7 @@ export interface TelegramStatus { allowedUserId?: number; providerId?: string; model?: string; + workspaceId?: string; polling: boolean; queuedCount: number; lastError?: string; @@ -353,6 +354,8 @@ export const telegramApi = { 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 ────────────────────────────────────────────────── From c59179b840748fe66228c09603034bcfc3aeca1d Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 21:26:41 -0400 Subject: [PATCH 10/15] feat: run Telegram turns in selected workspace --- .../telegram/telegram-service-core.test.ts | 38 ++++++++++- .../telegram/telegram-service-core.ts | 48 ++++++++------ main/services/telegram/telegram-service.ts | 15 ++++- main/services/telegram/telegram-turn.test.ts | 64 +++++++++++++++++++ main/services/telegram/telegram-turn.ts | 32 ++++++++-- 5 files changed, 169 insertions(+), 28 deletions(-) diff --git a/main/services/telegram/telegram-service-core.test.ts b/main/services/telegram/telegram-service-core.test.ts index 0a6c194..2bf418b 100644 --- a/main/services/telegram/telegram-service-core.test.ts +++ b/main/services/telegram/telegram-service-core.test.ts @@ -221,6 +221,7 @@ interface MockTurnOptions { busy?: boolean; /** Resolve the turn as a chat:error with this message. */ failMessage?: string; + workspace?: { kind: "assistant" } | { kind: "project"; workspaceId: string } | { kind: "stale" }; } /** Minimal owner surface the turn shim drives back through send(). */ @@ -235,6 +236,8 @@ function createMockTurn(opts: MockTurnOptions = {}) { let releasedLeases = 0; let settledLeases = 0; + const createdChats: Array<{ id: string; workspaceId?: string }> = []; + const startedParams: Array<{ chatId: string; workspaceId?: string; mode?: string }> = []; const llmClient = { beginChatTurn() { if (opts.busy) return null; @@ -249,10 +252,11 @@ function createMockTurn(opts: MockTurnOptions = {}) { }, async start( streamId: string, - _params: unknown, + _params: { chatId: string; workspaceId?: string; mode?: string }, owner: TurnOwner, _options: unknown, ): Promise { + startedParams.push(_params); startCalls += 1; if (opts.pending) return new Promise(() => {}); if (opts.failMessage) { @@ -273,6 +277,7 @@ function createMockTurn(opts: MockTurnOptions = {}) { 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, @@ -305,6 +310,9 @@ function createMockTurn(opts: MockTurnOptions = {}) { }, }; }, + async resolveWorkspace() { + return opts.workspace ?? { kind: "assistant" as const }; + }, broadcastMetadata(_chat: unknown) {}, }; @@ -313,6 +321,8 @@ function createMockTurn(opts: MockTurnOptions = {}) { startCalls: () => startCalls, appendCalls: () => appendCalls, createCalls: () => createCalls, + createdChats: () => createdChats, + startedParams: () => startedParams, releasedLeases: () => releasedLeases, settledLeases: () => settledLeases, }; @@ -369,6 +379,7 @@ interface HarnessOptions { pendingTurn?: boolean; busyTurn?: boolean; failMessage?: string; + workspace?: { kind: "assistant" } | { kind: "project"; workspaceId: string } | { kind: "stale" }; } function harness(o: HarnessOptions = {}) { @@ -382,6 +393,7 @@ function harness(o: HarnessOptions = {}) { pending: o.pendingTurn, busy: o.busyTurn, failMessage: o.failMessage, + workspace: o.workspace, }); const sleepMock = createMockSleep(); const logs = createLogs(); @@ -649,6 +661,30 @@ test("a text message from the owner is dispatched as a headless turn and replied 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({ diff --git a/main/services/telegram/telegram-service-core.ts b/main/services/telegram/telegram-service-core.ts index 0fdae1d..53f5f93 100644 --- a/main/services/telegram/telegram-service-core.ts +++ b/main/services/telegram/telegram-service-core.ts @@ -292,35 +292,40 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { async function dispatchTurn(turn: QueuedTelegramTurn): Promise { dispatchPending = true; - - // Ensure the persistent chat exists. The Aiden chat key is derived from - // the owner's Telegram user ID; the Telegram chat ID is used for API calls. - const chatId = telegramChatId(turn.ownerUserId); try { - const settings = await deps.config.getSettings(); - await ensureTelegramChat( - deps.turn, - turn.ownerUserId, - `Telegram${turn.fromUsername ? ` (@${turn.fromUsername})` : ""}`, - settings.lastProviderId, - settings.lastModel, - ); - } catch (cause) { - deps.error("Telegram: failed to ensure chat for turn.", cause); - } + const workspace = await deps.turn.resolveWorkspace(); + 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; + 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); + // Start typing indicator AFTER activeTurn is set so the loop condition + // evaluates true on its first iteration. + void sendTypingIndicator(turn.chatId).catch(() => undefined); - try { const result: TelegramTurnResult = await sendTelegramTurn( deps.turn, chatId, turn.text, + workspace, ); await deliverReply(turn.chatId, result); } catch (cause) { @@ -331,6 +336,7 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { .catch(() => undefined); } finally { activeTurn = false; + dispatchPending = false; tryDispatch(); } } diff --git a/main/services/telegram/telegram-service.ts b/main/services/telegram/telegram-service.ts index 876150c..95aeedb 100644 --- a/main/services/telegram/telegram-service.ts +++ b/main/services/telegram/telegram-service.ts @@ -15,8 +15,9 @@ import { 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<{ @@ -37,6 +38,17 @@ async function resolveProvider(): Promise<{ return { providerId, model, provider }; } +async function resolveWorkspace(): Promise { + const workspaceId = (await configStore.getSettings()).telegramWorkspaceId; + 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; @@ -93,6 +105,7 @@ export function createTelegramService() { llmClient, chatStore, resolveProvider, + resolveWorkspace, broadcastMetadata, }, getToken: () => secrets.getKey(TELEGRAM_PROVIDER_ID), diff --git a/main/services/telegram/telegram-turn.test.ts b/main/services/telegram/telegram-turn.test.ts index bad6068..08601d2 100644 --- a/main/services/telegram/telegram-turn.test.ts +++ b/main/services/telegram/telegram-turn.test.ts @@ -70,6 +70,7 @@ 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 = { @@ -79,6 +80,7 @@ function mockDeps(opts: { 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); }, @@ -91,6 +93,67 @@ test("telegramChatId returns telegram-", () => { 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"); @@ -184,6 +247,7 @@ test("ensureTelegramChat creates a chat when none exists and reuses an existing llmClient: mockLlm(), chatStore: store, resolveProvider: async () => ({ providerId: "openai", model: "gpt-4o", provider: MOCK_PROVIDER }), + resolveWorkspace: async () => ({ kind: "assistant" }), broadcastMetadata: (chat) => { broadcasts.push(chat); }, diff --git a/main/services/telegram/telegram-turn.ts b/main/services/telegram/telegram-turn.ts index 093070f..ca9edb3 100644 --- a/main/services/telegram/telegram-turn.ts +++ b/main/services/telegram/telegram-turn.ts @@ -73,8 +73,14 @@ export interface TelegramTurnDeps { >; } | null>; broadcastMetadata(chat: { id: string; workspaceId?: string; title: string; updatedAt: number }): void; + resolveWorkspace(): Promise; } +export type TelegramWorkspaceResolution = + | { kind: "assistant" } + | { kind: "project"; workspaceId: string } + | { kind: "stale" }; + let telegramTurnSequence = 0; function telegramStreamId(): string { @@ -124,9 +130,11 @@ export function createTelegramBackgroundOwner(streamId: string): { }; } -/** Persistent chat id for a Telegram owner: `telegram-`. */ -export function telegramChatId(ownerUserId: number): string { - return `telegram-${ownerUserId}`; +/** 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}`; } /** @@ -139,8 +147,9 @@ export async function ensureTelegramChat( title: string, providerId?: string, model?: string, + workspaceId?: string, ): Promise { - const chatId = telegramChatId(ownerUserId); + const chatId = telegramChatId(ownerUserId, workspaceId); const existing = await deps.chatStore.get(chatId); if (existing) { deps.broadcastMetadata(existing); @@ -150,6 +159,7 @@ export async function ensureTelegramChat( id: chatId, title, providerId, + workspaceId, model, }); deps.broadcastMetadata(chat); @@ -177,7 +187,18 @@ 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 }; @@ -205,9 +226,10 @@ export async function sendTelegramTurn( streamId, { chatId, + workspaceId, providerId: provider.providerId, model: provider.model, - mode: "assistant-unattended", + mode: workspaceId ? "assistant-automation" : "assistant-unattended", messages: [{ role: "user", content }], }, background.owner, From ca2216ff0d944089e8de8de6183dd2ff41480b3a Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 21:29:41 -0400 Subject: [PATCH 11/15] feat: choose Telegram workspace scope --- package.json | 2 +- .../components/settings/telegram-settings.tsx | 69 ++++++++++++++++++- .../lib/telegram-workspace-options.test.ts | 42 +++++++++++ renderer/lib/telegram-workspace-options.ts | 34 +++++++++ 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 renderer/lib/telegram-workspace-options.test.ts create mode 100644 renderer/lib/telegram-workspace-options.ts diff --git a/package.json b/package.json index 1184182..599a34b 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test:e2e:list": "playwright test --config=playwright.config.ts --list", "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: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", + "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", diff --git a/renderer/components/settings/telegram-settings.tsx b/renderer/components/settings/telegram-settings.tsx index 4de2b92..55f611a 100644 --- a/renderer/components/settings/telegram-settings.tsx +++ b/renderer/components/settings/telegram-settings.tsx @@ -18,12 +18,22 @@ import { toast, } from "../ui"; import { telegramApi } from "../../lib/ipc"; -import { queryKeys, useProviders, useTelegramSettings } from "../../lib/queries"; +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 }); @@ -35,6 +45,15 @@ export function TelegramSettings() { 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(); @@ -77,6 +96,24 @@ export function TelegramSettings() { 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), @@ -124,6 +161,36 @@ export function TelegramSettings() {
    + + + {folderWorkspaceCount === 0 && ( +

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

    + )} +
    + { + 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; +} From ff248682d02d1bbf6ff5c680db6b5d3bc223d452 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Tue, 11 Aug 2026 21:53:19 -0400 Subject: [PATCH 12/15] feat: complete Telegram remote control onboarding --- docs/plans/README.md | 2 +- .../telegram-remote-control-plan.md | 27 +++++++++++------- main/services/telegram/telegram-markdown.ts | 9 ++++-- .../features/telegram-remote-control.png | Bin 0 -> 14437 bytes renderer/components/onboarding-flow.test.tsx | 7 +++-- renderer/components/onboarding-flow.tsx | 12 ++++++++ 6 files changed, 40 insertions(+), 17 deletions(-) rename docs/plans/{ => completed}/telegram-remote-control-plan.md (90%) create mode 100644 renderer/assets/onboarding/features/telegram-remote-control.png diff --git a/docs/plans/README.md b/docs/plans/README.md index 4d2d79d..1a3f770 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -15,7 +15,6 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [Pi Provider Integration](pi-provider-integration-plan.md) | Partial | Pi built-ins, stores, auth, native routing, refresh, and voice credential lookup ship; custom composition, provenance, scalable UX, and rollout cleanup remain. | | [Subagent Orchestration Expansion](subagent-orchestration-expansion-plan.md) | Active | Phases 0–6, Phase 7A durable lifecycle, and the canonical Phase 7B1 storage seam are complete; app-lifetime coordinator activation is next. | | [Taracodlab Learnings](taracodlab-learnings-plan.md) | Partial | Phases A–B and D, plus core Phase E, are implemented; the remaining roadmap is open. | -| [Telegram Remote Control](telegram-remote-control-plan.md) | Partial | Phases 0–5 ship: clean-slate port of pi-telegram (Bot API, polling, pairing, queue, Markdown→HTML delivery, headless turn shim, service lifecycle, Settings UI, IPC handlers, 50 tests). Phase 6 onboarding bento deferred pending the 1024×1024 PNG asset. | | [Update, Microphone, and Computer Use Hardening](update-microphone-computer-use-hardening-plan.md) | Partial | Installed acceptance found a silent stalled download; observable full-download recovery is implemented, while repaired-build → next-release and clean-TCC acceptance remain. | ## Completed @@ -29,5 +28,6 @@ This directory is the source of truth for Aiden's implementation plans. The engi | [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, isolated backing chats, Settings UI, 59 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/telegram-remote-control-plan.md b/docs/plans/completed/telegram-remote-control-plan.md similarity index 90% rename from docs/plans/telegram-remote-control-plan.md rename to docs/plans/completed/telegram-remote-control-plan.md index ad809e4..307bcfa 100644 --- a/docs/plans/telegram-remote-control-plan.md +++ b/docs/plans/completed/telegram-remote-control-plan.md @@ -1,6 +1,6 @@ # Telegram Remote Control Plan -Status: implemented on 2026-08-11; Phases 0–5 complete; Phase 6 (onboarding bento) deferred pending a 1024×1024 PNG asset +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. @@ -19,7 +19,7 @@ Hermes is a secondary reference for behavior (long-poll transport, owner allowli 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. **One persistent chat.** A single Aiden chat backs the Telegram surface (`chatStore.create({ id: "telegram-", ... })` on first inbound), reused across turns. No per-thread or multi-session binding in v1. +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. @@ -51,7 +51,7 @@ Hermes is a secondary reference for behavior (long-poll transport, owner allowli | `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 chat via `chatStore.create({ id: "telegram-" })` | `schedule-execution.ts:129` | +| `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()`). @@ -201,7 +201,8 @@ Acceptance: full enable/pair/disable flow from the UI; status reflects polling s ### Phase 6 — Onboarding -1. Bento tile + `1024x1024` PNG; data-driven gallery entry; onboarding test coverage. +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) @@ -217,7 +218,7 @@ Threaded Mode + multi-instance bus; compaction menu; activity/thinking/tool rend ## Implementation summary (2026-08-11) -Phases 0–5 shipped as a clean-slate port on branch `feature/text-control`. +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. @@ -232,21 +233,25 @@ implementation that captures the same behaviour with MIT attribution. - `telegram-service.ts` — production singleton with DI wiring **Files modified**: -- `main/handlers/telegram.ts` — IPC handlers (telegram:get/setKey/setEnabled/connect/disconnect/resetPairing) +- `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 +- `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 +- `renderer/lib/ipc.ts` — telegramApi, including `setWorkspace` - `renderer/lib/queries.ts` — queryKeys.telegram, useTelegramSettings -- `renderer/components/settings/telegram-settings.tsx` — settings UI +- `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 -**Tests** (50 passing): bot API (10), turn injection (9), queue (10), markdown (10), service core (11). +**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. -**Phase 6 (onboarding)** deferred: requires a 1024×1024 transparent PNG asset for the bento gallery tile. +**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/main/services/telegram/telegram-markdown.ts b/main/services/telegram/telegram-markdown.ts index 9b2070e..227e93a 100644 --- a/main/services/telegram/telegram-markdown.ts +++ b/main/services/telegram/telegram-markdown.ts @@ -14,6 +14,9 @@ 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, "&") @@ -32,13 +35,13 @@ function convertInline(markdown: string): string { const codeSpans: string[] = []; let working = markdown.replace(/```[\s\S]*?```/g, (match) => { codeSpans.push(match); - return `\x00CODESPAN${codeSpans.length - 1}\x00`; + 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 `\x00CODESPAN${codeSpans.length - 1}\x00`; + return `${CODE_SPAN_SENTINEL}CODESPAN${codeSpans.length - 1}${CODE_SPAN_SENTINEL}`; }); // Escape remaining HTML. @@ -63,7 +66,7 @@ function convertInline(markdown: string): string { ); // Restore code spans. - working = working.replace(/\x00CODESPAN(\d+)\x00/g, (_m, idx: string) => { + working = working.replace(CODE_SPAN_PATTERN, (_m, idx: string) => { const i = Number(idx); if (i < codeSpans.length && codeSpans[i].startsWith("```")) { return convertFencedBlock(codeSpans[i]); 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 0000000000000000000000000000000000000000..ab3143dd9433d8d941729cd71b3a2aa1b435d17b GIT binary patch literal 14437 zcmeHNeOOdw+JDZO0~`fqCNf7*8L-0E4aY12MHv;*dK2B&qz{cz+uXKR#+M3AWR6OR z3ZmPVeSo^l?rI-z*nT9LF{gz^+jdd2O*b)yaKo}>X2lqFnDhSb=bQnoegAx~_j<2u z{lgi~Jm>!S-M{Lz1%!G}MrGNjd_6uFi~r z6Zd7d?ybq1k8hPvr`&)Kr+35*Y?-58P^NVtg8%n+k;UN%BF0Vq#8z)3*HaI zvXzeZ-~^>`;9hrUJ!`FEvy{SFxYrt*Z&GivIAj;Mx5?vTr>2S(h8@AQXm&D(1XC%B z(e@0IYGaR&5X!<9jEqvojSIWj;_hJkpXAm9J=GVxg)I-Oi!iCX!s^sO@A6Uy+jpJt zn=)l$V`=D3&&M?shjRD(Y~d~9Xp5LT?%ng3&!3_wShWk=nQM{MaHI6Z*l%X7kHa;4 zaOv4R)*UUhyqoSNEGpe;z6r`ZE7h&0Tp)|U=0z&0P-u=I^c8>i@EG%S8ry&$!3$l0bBvs@7fl6OjP>)f}*;P{9D-F zm4g&LXAKdh${0XY14T(qo(I?qqk$9zb0j<3Z(|kxH#D{1VeNa)uK0>!_x-8T|n zk_=@01%U%Y8DbK4J1rDJSi6!H!gu#DdVON(^l=2~lUiB^^rSgL)`EVY z*Tah=RbuHIoD25QyN{338b!0PVS}>!RxYU0y)sBakfX)Y)@)*oJygqqo;gW9`ZBjb z1nF#Wwx=T150)rPW!uYw{3i6VAS;#A2pB`IwSO6&dVzKSv<>D9>CH%V`)eC zQ`WwM_D^A)3vAFq^LO7c+|5DHW-0Ke#pBN^>DkJX9eb_bmG?`;!_M|VQJ%4?Mr*td zmjpGCIO9t7aD@K!_aXypMhc)Bln00D!fL|e3j+1D=wfwL1qA@?)Gm0BcTZpzHBZP5 zi;s;D5kBCY2+ko&!KfHRkO751(H_i`aMutaSpg3T_YRK_SHff?x1_4hO1@l`?dILnP5u?tuleK+8mK+1lg7 zMujGr6qzVbw5@bgQm|~6myEKZD$j1S*R$jmj5hf=LrJ7dQ(#P)He~La>z+&nTyjkRa2PJE7pnHul9N)$Q35UIs(M%Md17wuS;M z{A_kVaFo$hV+_VrxdrD#$oXvLERP$^F9>dBjiSqMvE3xWcDj%N80%S$*?gU8;66B* z?3l;P6)6iwC@=70@TbaJOVc+ z*)k$44E>r2z4QN%^Z!yN2pD^ZHsV0daKMMOFxlmEnRf48KZ0+JQ%QEmW_imO`?f z!Dt|Nk?54IC}!^j!@2g$C@l8va|oy~a=FHhHxih{rWQ{$CCet*>gIttMzM#RuY#jkgJKkQI10}f1eR2w7MXPRZ zVFf#M)^+1vBNcp8hA2EXggKbcq%v0Utg$Q`%V0dn;9Ja>CBPZJj1r{axp3?Uq&bVk`@HN2?g{DGkdG3qpw^_=1~3sYM5 zJB}$0gkND&mVIg9Pgl$=_)WWEXulBr4I0)7zXII97f6}k%rjuui+@TN7=Y9Hy zaKmGnpGO#CNpW6Dk0+zx3@dL$eA9W>!L!I%cW0vN)e8hvfst+A;{^eewT~RvjTDuH z%qvxHR+mpBmyWpP5}MirQFdG>)2ZM|EUmaFLBoa`NKX@-J=6QvC^PQ(;~b)Rbt&{< zDj++XC6p5oN$H4HItoy=b0vAJK6qG<4XGos7+pKtNi2F>y2TkdmlhIe0%9&vouJS5 z%)XYil~V(qmclZ*rwPaB*2z3)TsYTcgbAUdoEKT)@Q)OuSh2Kpq z$eLPvU`<#(se<~Z9PNyMqKKV41+j)Lv@=;qMgj!7Ct*Kg)bTjJdNZ4yqisT)<~16i zB|AEJN{GsF&)+btAd3k1aD|MqhI$cmbd{0#rAtyGd5&hYI_;$n=%nd|bfW#HV9J|6 zSGx(=fH*|P3_+#Y?)cbI?BKT0e;gj9m3QrZ?AW?M{#2|*(|VOSkRj~(0dEKvTTxH` zH2zDGu7NY-VfMo9SL(-vk~!zNoj=B)Plz8G6?$+RETtR=AydS_P-NVHjZ>SCdk&Lh zI>S&zFvZEoQrX>O`___c_RyKA?i20pjrY0RkG@^7Dt~I)*OlvtTyrCpvJTHl{pS4> zOWUJaE3p(gQ5L2A{J5tJyFvKr)v>{Zmu}hfurx2eZ!TG44-G5jSV}%BRCh^AbBFt^ zE{*3#mxTCjW*>--FVE#us1W-rI&g3;S^b4zTh~(`uZeIdA=HAYNT-OiS9bb<=BUJ8;JvxrQ7V7^6c5-C)~x&DCVO7@_M7{UuT z&o&Naj+;mTck#c(y0W>u>bb-jxRuYo;fRxf*(89I1LY@$4G0j3j$D8Q0S!Lcd8iAN zL)iGVT$D>-MQ>#}Yd_bK8nk%3?A+glofuyXnt`QQoG@0Xe3D3~PqwJNAVk{jqVa?b;0giF(VGb8QPWrLl%2}ScNlz%VYR9z5NpO>27RC}BfgiRjLMX^rnII?D8f z9|)14F1%fmk3Ne@BaSh8; zP>gi&bUCa|K#}auAURM$9)K4X_1s7%ws_nzMkgb$IJK4SgiIjVUSU*|#TIpc{!V_v0*bbYUdO{ z5p`83EcGBxc$Bz_Od_(WT{KVfgf$$0EFIl;ebF6&^rAJneUt>QS;NS3`4`M za(@ z0y&d-gpeL+ImB(E95IjAKY(j1B)rhbDVHTBz?{H!2$>^Cv&eTiLB+c_qv3Ma6K82o z;q{M1hLR!DOTjk4q4FJw8sLpwp*L%;g)7Is?+NM)--6cSTnK0*l)4KS>q`UK2unZp zT+t?_V^uJbN+Y?783N`%h)v*Ag7-)V&EDJt#IbF?pw3GR<)Q9k>q0#Mvw-)hZ7lL2 zTeytUmdqtr*@33MFCMIG3)J6njo8z!p7!acrEK4A*Iq8}!9LUdhikRvHsxb@eH9TH ztc|89fBsLurR^L}hm@ZHS?(F;wiMPdW)s1hhw`E5Lt8O5Nu=h0^$qva9x8Q)xF|7{O#2i< zN@ohCk(u`+Ck+M?m5R%USxw=@s#1#@*qs$x7eY4ExbJFx7iESC&QN$EY=8To7Uw5$ zzEZ2>-kS84&kbWI=8~zSrw;=9jYVu;JXv4_mD|udCqnrMIeRuz84<>>sF(%1F7AG4 z2)la5y}N$W`O8yexGryxXN$w(XiF8mxBo14y2ZO}T+e4+k2SAtj2@@#pdAcaf{jSS z>^8P!@=H^yg`8Kc-pkJqWi9W@+sCrSXfGVUwnqeKb;lD=%J#dTVMmMBFgxb!PETvt z!KEXFZPwh8n^@B#*;letVkR75v7c;nYe5eOcB@I<;(ubXWY3+$j`kjtoxdj@eI0i2 zo$>7T(Fullb%=uf`uXoq`6MIXB(P1#pdvHZkj)vFgYwf+vuEKbCOW61!Eb>+6 zz1-Ma+IPA;#)Vt0{jA5MnK3OV){awF$0rJz?dL`H<;i~}*LwrKM>U?!@1(IO;umyR zvX)1mkbETvqV;LM2NQ`-soLA`edIf3j8BM^??F*-Ei!IXJvvufwb;t{a; z65lZVCPHa#GIDO`2)1PoGyU!2d>NMVI)+<$IfP#8zFh?inc0-BvXEL;g4Lys4@T0Fh*4LO3g8H8^`fI`<7w1(Tw?Y|2eRIDF9Rc@!gpw*Ltoz#T1)qE zFqD@B=Vv~n0UP0X9?xwYxkeNRq10qj%-bzOxBzhN_Jz~)1w8biHszp#hYsW^kgRu8!A55_^b;+Y#!n7Kmqa*go^EV}r`R%6N5K{@><8J@GFNY6gWRLbMr+fro*K^r zy#Q`k_Yp*(r$XQN=pmr)vi{40@TN@82_NGziSw&K zZwa_$Lj?$yUd0}aN)bsrnuk;8#@aD>mP}ZoZ^$k!kUyFotp4cFhW@DrZw>u>a%kv8 z_6)`0sPh44@6r1(=Jhpnmc8Pzr)vUOaUOFE6*Ik@COc~V`8M`M!Yj2c`tOg8Q+Q8l z$s%;<_laUk4N%{@RiMeYPn6)o06fgAVgZlYHRDaz5>yh%@{Rg7eb6+RPAV z+r9)=xkkrHQhq5o@0CuKK*4IR;KSMB_5pBOhqJlm%9(OJ_H~_II6pqb-TG)KD?h}% zyEPgUlt(Pidh`CPzJlw7Z{lBlzloy#;cUk<%-Nivm0krwu%9%kizj)H9d94c9=~I8 z)jA%pw_*loK6YYPKU06ZBkM-imiACpG3A$O+ZGyJNCH)s3O4yX> z-!~HHLWu+%?ddWs>WCNtMPv3EP*;x?A2*jAH&cM9`hfq~FJkoCZg%~Y**~mud-?{A zPp)Ch2lFRaa$$T*O7gZbXAPYcGag}*f^nie306C61|n}-R)8M&>zZ&CN$ zfCD7%ydF{5!m%XRQ9G=hrjU;%q}yPP4mS+t<%PpKaH;#En6yOE<10BN+RBLgVz zFc66tPVHoZq+h?jbD)!V&LEn&!5O^)kbVM*W?r>{AA|MtgMx-SPY*+|BkXJ+#dD6% zQgqn{IU#kk&O073(1S>)o9Vx`AIA!Q)*8mhhzLIq25I>DZx-Fy@D~n)9wi%Pq7fxA z9W|f8Bn0Tx9F!1Lefz-Aceuaw#1>2R$4ZDcO)P{z2Alr7$P*M-L`>hi^{=6asRR=z z8@$beZaPWf`(eaGV$3*;qAH!PGI@LW|OBGND17|=IH7P=zw9t%;;;3u%pR!}crVzx+vm$H_ZWCY;Y*|&zW z4SECtE(pcmL6c%pJw>M zFdHjtW-Ib#p7Idb)S~6NOZ=x!Podu`c(kapA&8qK<;1b9-b3q6+%o66t-R91GS7Pf z&%-@i1+vA67$AeeKfWz=V035}qa8$rkke@P5+^ZIM@K5t4|`<9!20FelT@|eDEBG6 zkHvnAeZsCWrkIi>?i8L#OdPAt9qvwIwrQgE>~dxGL`q;v9c_-_{0F}$SrJkPAsFcH zJFO+)NCH!?j+uz61{Z(VqWyIgUL>Iuu<Twy%ms=C}`! zzW>BIL>7UKQPo;hq*55w_w~mXH6cxX-R!&`@3$!ZaGkHx4zH{X_14xiRz?U?HX13E z#I{!;)mS=+AFA_CwXjpHCL5pQm!g{sGD36gR;Iu~q= z6>@$Hi*X;Th-EqN_}x8J+ta5?*7xb%65wEaI+*ZPi}ordo9l@f`}4GyMVWUz&g`eE zwEgX&t&BBB`tHEI%mp36X8oW)zvF<`$e8m-822i12WY0A^B3$((4OOI^NfFDq?MbM zt^9x=4o#p#d1Z^>Fx!I|meuLL3&R(1izAdVCiSo7lm=#2CeY!I*fzU7bQK*84`Vvr zUCi~n<79ZHcIN1Q9DU$^=U-m%oHeqyK88J*t-OY9Dsr%)w&0TwcI^CoMg0d_vs zD+{c-J#?Z)+aU8PpcUc9+e++pjNGA0?X2IuOe#Kf5>dq5QS^`6P|TssA;NOx9<$=` z!S7iu73W>w@TIotz#|~b(n-Pg&u_X57D>uke|OjW$mOC~R!!%@?biHE;EWg8%TW>Y{^yjejz(r2mnK(fy2|YapsT)dC6K+$Oclc;;lcC6nroU`v@9loQ^B%;&tP%W8vJ;8|4 zfij&kD7wPLMY?&HCCUmrU_UBiEOc?Mu9~+fa7;Tx5e;9gV5ulnD`^jc^z2U?n2?2X z`UOQ7MHG`4(28P$jdaaY_?(3Lf_paUZ`7b1dV}lQ_?n0N*ECUaw=s#{4djy&z227y z)`oM3l6Pg$q>gLKs@|DNB@lVtUFn9KP>s?bN8h+=ssuGuCU}ZaujN^jzIum6-Tmxf zBnojtku}b^3-z*$y7@}oT-`-PR#ueSvv`iM5%pcO?37@-+O#+HJp2w9E0p|O;1qqRc&fM2ZTu^;;=}lss=1*%v+}&pUdUE=p zUnSQD5ZFgP-yFpj#NpLYfsBiBGb^_FccS*o56{8N^f1P3Wtnpj8YE97`R8;oUk~VM z!#iFvrJ#}HNTByD!Z{vrUTyV7Dennt+-%A9OQ`sYRFp$SG*pP9dXu2$yJ^kMo$b%g zl3brD3>#73$sLnJ!k*R4R|Yxf6QkZ5tA$>2ffzAE*{&FjkIeC$fzvL9OYS9Ab>g?>88XaHTs~QDkhwwU~_|AoG%KajVtwLR=kZ6LZ3*riN)po zI^J<-qzmn>^~qSaYd4&vkrOmRz9I~TAkfM#V1w4kIc-9_^DgOS-nU1E7=0I$m6eYK zOtd&*EX$k-)X~m4-!W6s>_al~<&6^Ndz~cpUqlNcrO$!~Fu<_H2KHFgi<58@e#aIQ z_CqmFsXdTUhxO%*A{5E3lR=Q;$I_Tdf|UkM*ibA8)Gr@S)tb z { - 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", From 322511adda74dea93b8411cda36d0394e77077f4 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 12 Aug 2026 00:16:15 -0400 Subject: [PATCH 13/15] fix: snapshot Telegram workspace authority --- docs/plans/README.md | 20 +- ...-11-telegram-workspace-authority-design.md | 12 +- main/services/telegram/telegram-queue.test.ts | 2 +- main/services/telegram/telegram-queue.ts | 14 +- .../telegram/telegram-service-core.test.ts | 262 ++++++++++++++++-- .../telegram/telegram-service-core.ts | 159 ++++++++--- main/services/telegram/telegram-service.ts | 20 +- main/services/telegram/telegram-turn.ts | 33 ++- 8 files changed, 433 insertions(+), 89 deletions(-) diff --git a/docs/plans/README.md b/docs/plans/README.md index 1a3f770..e082044 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -19,15 +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. | -| [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, isolated backing chats, Settings UI, 59 focused tests, and onboarding coverage. | +| 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/superpowers/specs/2026-08-11-telegram-workspace-authority-design.md b/docs/superpowers/specs/2026-08-11-telegram-workspace-authority-design.md index 0a10922..5e08447 100644 --- a/docs/superpowers/specs/2026-08-11-telegram-workspace-authority-design.md +++ b/docs/superpowers/specs/2026-08-11-telegram-workspace-authority-design.md @@ -5,19 +5,19 @@ 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:** a user explicitly selects one configured workspace in Settings → Telegram. Turns execute with `assistant-automation`, `permission: "full"`, and that exact workspace ID. +- **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 the most recently opened workspace. A phone-originated command therefore cannot mutate an unintended project. +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. Each option identifies the configured workspace by name and path. The current no-workspace option is labelled as an assistant-only mode. The selector has explicit empty and unavailable states. +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 selected workspace in the main process. +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. @@ -27,11 +27,11 @@ Provider resolution remains independent and retains the provider fingerprint sup ## 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 and explicit Settings opt-in remain the trust 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, and settings selector states. A real paired-bot smoke verifies a configured workspace runs an explicit safe request. +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 diff --git a/main/services/telegram/telegram-queue.test.ts b/main/services/telegram/telegram-queue.test.ts index c8d1968..5270c68 100644 --- a/main/services/telegram/telegram-queue.test.ts +++ b/main/services/telegram/telegram-queue.test.ts @@ -18,7 +18,7 @@ function makeDeps() { } function turn(lane: QueueLane, text: string, chatId = 1): QueuedTelegramTurn { - return { lane, text, chatId, ownerUserId: chatId }; + return { lane, text, chatId, ownerUserId: chatId, workspaceId: undefined }; } test("dequeue order is control, then priority, then default", () => { diff --git a/main/services/telegram/telegram-queue.ts b/main/services/telegram/telegram-queue.ts index bd202a2..f54e3bc 100644 --- a/main/services/telegram/telegram-queue.ts +++ b/main/services/telegram/telegram-queue.ts @@ -23,6 +23,8 @@ export interface QueuedTelegramTurn { /** 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 { @@ -37,9 +39,15 @@ export function createTelegramQueue(deps: TelegramQueueDependencies) { 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; + case "control": + control.push(turn); + break; + case "priority": + priority.push(turn); + break; + default: + def.push(turn); + break; } } diff --git a/main/services/telegram/telegram-service-core.test.ts b/main/services/telegram/telegram-service-core.test.ts index 2bf418b..2ab2d40 100644 --- a/main/services/telegram/telegram-service-core.test.ts +++ b/main/services/telegram/telegram-service-core.test.ts @@ -12,6 +12,7 @@ // 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 { @@ -111,8 +112,10 @@ function createMockApi(opts: MockApiOptions) { opts.stop(); return []; } - // Park the loop without busy-spinning; the test owns termination. - return new Promise(() => {}); + // Park the loop without busy-spinning; stopping the service releases it. + if (!_signal) return []; + await once(_signal, "abort"); + return []; }, async sendMessage(p: { chatId: number; @@ -152,6 +155,7 @@ interface MockConfigState { enabled: boolean; hasToken: boolean; allowedUserId?: number; + telegramWorkspaceId?: string; } function createMockConfig(state: MockConfigState) { @@ -164,6 +168,7 @@ function createMockConfig(state: MockConfigState) { lastModel: "gpt-4", telegramEnabled: state.enabled, telegramAllowedUserId: state.allowedUserId, + telegramWorkspaceId: state.telegramWorkspaceId, }); const config = { @@ -200,6 +205,9 @@ function createMockConfig(state: MockConfigState) { if (patch.telegramEnabled !== undefined) { state.enabled = patch.telegramEnabled; } + if ("telegramWorkspaceId" in patch) { + state.telegramWorkspaceId = patch.telegramWorkspaceId; + } return { ...baseSettings(), ...patch }; }, async hasToken(): Promise { @@ -222,6 +230,9 @@ interface MockTurnOptions { /** 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(). */ @@ -236,6 +247,8 @@ function createMockTurn(opts: MockTurnOptions = {}) { 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 = { @@ -258,7 +271,11 @@ function createMockTurn(opts: MockTurnOptions = {}) { ): Promise { startedParams.push(_params); startCalls += 1; - if (opts.pending) return new Promise(() => {}); + if (opts.pending) { + pendingOwner = owner; + await once(pendingStart, "complete"); + return true; + } if (opts.failMessage) { owner.send("chat:error", { streamId, message: opts.failMessage }); return true; @@ -310,8 +327,10 @@ function createMockTurn(opts: MockTurnOptions = {}) { }, }; }, - async resolveWorkspace() { - return opts.workspace ?? { kind: "assistant" as const }; + async resolveWorkspace(workspaceId?: string) { + return ( + opts.workspaceResolver?.(workspaceId) ?? opts.workspace ?? { kind: "assistant" as const } + ); }, broadcastMetadata(_chat: unknown) {}, }; @@ -324,6 +343,10 @@ function createMockTurn(opts: MockTurnOptions = {}) { createdChats: () => createdChats, startedParams: () => startedParams, releasedLeases: () => releasedLeases, + completePendingTurn: () => { + pendingOwner?.send("chat:done", { streamId: "pending-turn", content: "Mock reply" }); + pendingStart.emit("complete"); + }, settledLeases: () => settledLeases, }; } @@ -380,6 +403,9 @@ interface HarnessOptions { 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 = {}) { @@ -387,6 +413,7 @@ function harness(o: HarnessOptions = {}) { enabled: o.enabled ?? false, hasToken: o.hasToken ?? true, allowedUserId: o.allowedUserId, + telegramWorkspaceId: o.telegramWorkspaceId, }); const turnMock = createMockTurn({ reply: o.reply, @@ -394,6 +421,16 @@ function harness(o: HarnessOptions = {}) { 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(); @@ -412,6 +449,7 @@ function harness(o: HarnessOptions = {}) { 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, @@ -594,18 +632,212 @@ test("/start replies with a help message and starts no LLM turn", async () => { }); await service.start(); - await waitFor(() => - api.sentMessages.some((m) => m.text.includes("Aiden Telegram Bridge")), - ); + 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"), - ); + 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 () => { @@ -645,17 +877,13 @@ test("a text message from the owner is dispatched as a headless turn and replied await service.start(); await waitFor(() => turnMock.startCalls() >= 1); - await waitFor(() => - api.sentMessages.some((m) => m.text.includes("Hi from Aiden")), - ); + 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"), - ); + 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"); diff --git a/main/services/telegram/telegram-service-core.ts b/main/services/telegram/telegram-service-core.ts index 53f5f93..2f564ad 100644 --- a/main/services/telegram/telegram-service-core.ts +++ b/main/services/telegram/telegram-service-core.ts @@ -9,11 +9,7 @@ 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 { sendTelegramTurn, ensureTelegramChat, telegramChatId } from "./telegram-turn.js"; import { createTelegramQueue, classifyMessage, @@ -31,11 +27,7 @@ function extractText(message: TelegramMessage): string | undefined { return message.text ?? message.caption; } -export type TelegramServiceStatus = - | "disabled" - | "idle" - | "polling" - | "error"; +export type TelegramServiceStatus = "disabled" | "idle" | "polling" | "error"; export interface TelegramServiceState { status: TelegramServiceStatus; @@ -54,8 +46,27 @@ export interface TelegramServiceDeps { 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, @@ -157,10 +168,7 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { await handleUpdate(update); handled = true; } catch (cause) { - deps.error( - `Telegram handleUpdate failed for ${update.update_id}.`, - 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. @@ -180,7 +188,9 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { 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)}"`); + 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; @@ -223,34 +233,121 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { return; } - // Enqueue the prompt. - queue.enqueue({ + 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() ?? ""; + const cmd = (command.split(/\s+/)[0]?.toLowerCase() ?? "").split("@")[0]; const chatId = message.chat.id; - if (cmd === "/start") { + 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: "🤖 Aiden Telegram Bridge\n\nSend any message and I'll respond as Aiden.\n\nCommands:\n/start — show this help\n/stop — clear queued messages\n/status — show bridge status", + 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."]; + 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."); } @@ -271,15 +368,13 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { return; } - // Unknown command — treat as a prompt. - queue.enqueue({ + await enqueuePrompt({ lane: "default", text: command, chatId, ownerUserId: message.from?.id ?? chatId, fromUsername: message.from?.username, }); - tryDispatch(); } /** Attempt to dispatch the next queued turn. No-op if gates block. */ @@ -293,9 +388,8 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { async function dispatchTurn(turn: QueuedTelegramTurn): Promise { dispatchPending = true; try { - const workspace = await deps.turn.resolveWorkspace(); - const workspaceId = - workspace.kind === "project" ? workspace.workspaceId : undefined; + 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") { @@ -341,15 +435,10 @@ export function createTelegramServiceCore(deps: TelegramServiceDeps) { } } - async function deliverReply( - chatId: number, - result: TelegramTurnResult, - ): Promise { + async function deliverReply(chatId: number, result: TelegramTurnResult): Promise { if (!result.ok) { if (result.error) { - await deps.api - .sendMessage({ chatId, text: `⚠️ ${result.error}` }) - .catch(() => undefined); + await deps.api.sendMessage({ chatId, text: `⚠️ ${result.error}` }).catch(() => undefined); } return; } diff --git a/main/services/telegram/telegram-service.ts b/main/services/telegram/telegram-service.ts index 95aeedb..4b84bba 100644 --- a/main/services/telegram/telegram-service.ts +++ b/main/services/telegram/telegram-service.ts @@ -10,10 +10,7 @@ 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 { 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"; @@ -33,13 +30,13 @@ async function resolveProvider(): Promise<{ (await providerRegistry.selectionProvider(providerId)) ?? (await configStore.getProvider(providerId)); if (!provider) return null; - const model = settings.telegramModel ?? settings.lastModel ?? provider.defaultModel ?? provider.models[0]; + const model = + settings.telegramModel ?? settings.lastModel ?? provider.defaultModel ?? provider.models[0]; if (!model) return null; return { providerId, model, provider }; } -async function resolveWorkspace(): Promise { - const workspaceId = (await configStore.getSettings()).telegramWorkspaceId; +async function resolveWorkspace(workspaceId?: string): Promise { if (!workspaceId) return { kind: "assistant" }; const workspace = await configStore.getWorkspace(workspaceId); @@ -88,7 +85,10 @@ export function createTelegramService() { 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)}…"`); + logger.info( + "telegram", + `Bot token: ${token.length} chars, format ${valid ? "ok" : "INVALID"}, prefix "${token.slice(0, 5)}…"`, + ); } return token; }), @@ -101,6 +101,10 @@ export function createTelegramService() { 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, diff --git a/main/services/telegram/telegram-turn.ts b/main/services/telegram/telegram-turn.ts index ca9edb3..2b1a39d 100644 --- a/main/services/telegram/telegram-turn.ts +++ b/main/services/telegram/telegram-turn.ts @@ -53,7 +53,9 @@ export interface TelegramChatStore { 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>; + get( + id: string, + ): Promise<{ id: string; workspaceId?: string; title: string; updatedAt: number } | null>; appendMessage( id: string, message: { role: "user" | "assistant"; content: string }, @@ -72,8 +74,14 @@ export interface TelegramTurnDeps { "id" | "kind" | "label" | "baseUrl" | "needsKey" | "deployment" | "isBuiltin" >; } | null>; - broadcastMetadata(chat: { id: string; workspaceId?: string; title: string; updatedAt: number }): void; - resolveWorkspace(): Promise; + 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 = @@ -132,9 +140,7 @@ export function createTelegramBackgroundOwner(streamId: string): { /** 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}`; + return workspaceId ? `telegram-${ownerUserId}-${workspaceId}` : `telegram-${ownerUserId}`; } /** @@ -193,7 +199,8 @@ export async function sendTelegramTurn( if (resolvedWorkspace.kind === "stale") { return { content: "", - error: "The Telegram workspace is no longer available. Choose a folder workspace in Aiden Settings.", + error: + "The Telegram workspace is no longer available. Choose a folder workspace in Aiden Settings.", ok: false, }; } @@ -201,7 +208,11 @@ export async function sendTelegramTurn( 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 }; + return { + content: "", + error: "No provider is configured. Choose a provider in Aiden first.", + ok: false, + }; } const streamId = telegramStreamId(); @@ -245,7 +256,11 @@ export async function sendTelegramTurn( ); if (!started) { - return { content: "", error: "The Telegram generation was cancelled before it started.", ok: false }; + return { + content: "", + error: "The Telegram generation was cancelled before it started.", + ok: false, + }; } const terminal = await background.terminal; From 7d90d2d0e911b7a5f969cbb0fa956d3e676a0d30 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 12 Aug 2026 00:33:05 -0400 Subject: [PATCH 14/15] fix: restore CI test entrypoint --- main/services/terminal.test.ts | 8 ++++++++ package.json | 1 + 2 files changed, 9 insertions(+) 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/package.json b/package.json index 599a34b..3bf0714 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "test:e2e:list": "playwright test --config=playwright.config.ts --list", "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: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", From 519723b2a8ab4532e0a9d237866d9a48e5cbf609 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Wed, 12 Aug 2026 00:56:07 -0400 Subject: [PATCH 15/15] fix: preserve subagent IPC registration --- main/handlers/index.ts | 2 ++ main/handlers/ipc-contract.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/main/handlers/index.ts b/main/handlers/index.ts index cbbccef..f02f01d 100644 --- a/main/handlers/index.ts +++ b/main/handlers/index.ts @@ -23,6 +23,7 @@ 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"; import { writeDevLog } from "../services/dev-log.js"; @@ -63,6 +64,7 @@ export function registerHandlers(): void { 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`, + ); + } +});