From c361eaca8b5cde3386675c58273af470b80c708f Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 24 Mar 2026 00:18:38 +0800 Subject: [PATCH 1/7] feat(remote): add telegram control --- docs/specs/telegram-remote-control/plan.md | 90 +++ docs/specs/telegram-remote-control/spec.md | 45 ++ docs/specs/telegram-remote-control/tasks.md | 31 + .../presenter/deepchatAgentPresenter/index.ts | 22 + src/main/presenter/index.ts | 18 + src/main/presenter/newAgentPresenter/index.ts | 69 +++ .../presenter/remoteControlPresenter/index.ts | 252 ++++++++ .../remoteControlPresenter/interface.ts | 31 + .../services/remoteAuthGuard.ts | 84 +++ .../services/remoteBindingStore.ts | 166 ++++++ .../services/remoteCommandRouter.ts | 189 ++++++ .../services/remoteConversationRunner.ts | 365 ++++++++++++ .../telegram/telegramClient.ts | 154 +++++ .../telegram/telegramOutbound.ts | 109 ++++ .../telegram/telegramParser.ts | 35 ++ .../telegram/telegramPoller.ts | 278 +++++++++ .../presenter/remoteControlPresenter/types.ts | 201 +++++++ .../components/NotificationsHooksSettings.vue | 196 +------ .../settings/components/RemoteSettings.vue | 553 ++++++++++++++++++ src/renderer/settings/main.ts | 10 + src/renderer/src/i18n/da-DK/routes.json | 3 +- src/renderer/src/i18n/da-DK/settings.json | 47 ++ src/renderer/src/i18n/en-US/routes.json | 3 +- src/renderer/src/i18n/en-US/settings.json | 47 ++ src/renderer/src/i18n/fa-IR/routes.json | 3 +- src/renderer/src/i18n/fa-IR/settings.json | 47 ++ src/renderer/src/i18n/fr-FR/routes.json | 3 +- src/renderer/src/i18n/fr-FR/settings.json | 47 ++ src/renderer/src/i18n/he-IL/routes.json | 3 +- src/renderer/src/i18n/he-IL/settings.json | 47 ++ src/renderer/src/i18n/ja-JP/routes.json | 3 +- src/renderer/src/i18n/ja-JP/settings.json | 47 ++ src/renderer/src/i18n/ko-KR/routes.json | 3 +- src/renderer/src/i18n/ko-KR/settings.json | 47 ++ src/renderer/src/i18n/pt-BR/routes.json | 3 +- src/renderer/src/i18n/pt-BR/settings.json | 47 ++ src/renderer/src/i18n/ru-RU/routes.json | 3 +- src/renderer/src/i18n/ru-RU/settings.json | 47 ++ src/renderer/src/i18n/zh-CN/routes.json | 3 +- src/renderer/src/i18n/zh-CN/settings.json | 47 ++ src/renderer/src/i18n/zh-HK/routes.json | 3 +- src/renderer/src/i18n/zh-HK/settings.json | 47 ++ src/renderer/src/i18n/zh-TW/routes.json | 3 +- src/renderer/src/i18n/zh-TW/settings.json | 47 ++ src/shared/types/agent-interface.d.ts | 12 + src/shared/types/presenters/index.d.ts | 8 + .../types/presenters/legacy.presenters.d.ts | 2 + .../types/presenters/new-agent.presenter.d.ts | 2 + .../presenters/remote-control.presenter.d.ts | 49 ++ .../deepchatAgentPresenter.test.ts | 16 + .../newAgentPresenter.test.ts | 28 + .../remoteAuthGuard.test.ts | 82 +++ .../remoteBindingStore.test.ts | 48 ++ .../remoteCommandRouter.test.ts | 186 ++++++ .../remoteControlPresenter.test.ts | 159 +++++ .../telegramOutbound.test.ts | 58 ++ .../telegramPoller.test.ts | 97 +++ 57 files changed, 4040 insertions(+), 205 deletions(-) create mode 100644 docs/specs/telegram-remote-control/plan.md create mode 100644 docs/specs/telegram-remote-control/spec.md create mode 100644 docs/specs/telegram-remote-control/tasks.md create mode 100644 src/main/presenter/remoteControlPresenter/index.ts create mode 100644 src/main/presenter/remoteControlPresenter/interface.ts create mode 100644 src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts create mode 100644 src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts create mode 100644 src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts create mode 100644 src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts create mode 100644 src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts create mode 100644 src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts create mode 100644 src/main/presenter/remoteControlPresenter/telegram/telegramParser.ts create mode 100644 src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts create mode 100644 src/main/presenter/remoteControlPresenter/types.ts create mode 100644 src/renderer/settings/components/RemoteSettings.vue create mode 100644 src/shared/types/presenters/remote-control.presenter.d.ts create mode 100644 test/main/presenter/remoteControlPresenter/remoteAuthGuard.test.ts create mode 100644 test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts create mode 100644 test/main/presenter/remoteControlPresenter/remoteCommandRouter.test.ts create mode 100644 test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts create mode 100644 test/main/presenter/remoteControlPresenter/telegramOutbound.test.ts create mode 100644 test/main/presenter/remoteControlPresenter/telegramPoller.test.ts diff --git a/docs/specs/telegram-remote-control/plan.md b/docs/specs/telegram-remote-control/plan.md new file mode 100644 index 000000000..1847dd890 --- /dev/null +++ b/docs/specs/telegram-remote-control/plan.md @@ -0,0 +1,90 @@ +# Implementation Plan + +## Architecture + +- Add `src/main/presenter/remoteControlPresenter/` as a main-process presenter that exposes a small shared contract to the renderer through the existing `presenter:call` IPC path. +- Keep Telegram transport in Electron main using native `fetch` and Bot API long polling. +- Reuse `newAgentPresenter.sendMessage()` and `DeepChatAgentPresenter` for message persistence, stream state, title generation, and stop behavior. +- Add detached session creation to `newAgentPresenter` so remote conversations do not require a renderer-bound window. + +## Main-Process Modules + +- `remoteBindingStore` + - Stores `remoteControl.telegram` config in Electron Store. + - Persists poll offset, allowlist, pair code, stream mode, and endpoint bindings. + - Keeps active event IDs and `/sessions` snapshots in memory. +- `remoteAuthGuard` + - Enforces private-chat-only usage. + - Authenticates strictly by numeric `from.id`. + - Supports one-time `/pair ` flow. +- `remoteConversationRunner` + - Creates detached sessions when needed. + - Reuses `newAgentPresenter.sendMessage()` for plain-text Telegram input. + - Tracks the active assistant message/event for `/stop`. + - Reuses existing chat-window activation logic for `/open`. +- `remoteCommandRouter` + - Handles `/start`, `/help`, `/pair`, `/new`, `/sessions`, `/use`, `/stop`, `/open`, `/status`, and plain text. +- `telegramClient` + - Calls `getMe`, `getUpdates`, `sendMessageDraft`, `sendMessage`, and `sendChatAction`. +- `telegramParser` + - Parses private text updates and bot commands. +- `telegramOutbound` + - Builds plain-text assistant output, detects “desktop confirmation required” states, and chunks output to 4096 characters. +- `telegramPoller` + - Runs a single sequential long-poll loop. + - Advances the stored offset only after a specific update is handled successfully. + - Uses exponential backoff on failures. + +## Shared / IPC Contract + +- Add `src/shared/types/presenters/remote-control.presenter.d.ts`. +- Expose methods for reading/saving Telegram settings, reading runtime status, generating/clearing pair codes, clearing bindings, and testing Telegram hooks. + +## Renderer Plan + +- Add a new `Remote` settings route and `RemoteSettings.vue`. +- Move Telegram configuration out of `NotificationsHooksSettings.vue`. +- Keep `Hooks` for Discord, Confirmo, and command hooks only. +- Reuse existing i18n flow for all renderer-visible strings. + +## Data Model + +- SQLite + - No schema change. + - Sessions/messages continue to use existing new-agent tables. +- Electron Store + - `hooksNotifications.telegram` + - Shared Telegram bot token and hook notification target settings. + - `remoteControl.telegram` + - `enabled` + - `allowlist` + - `streamMode` + - `pairing` + - `pollOffset` + - `bindings` + +## Event / Request Flow + +1. Renderer saves Remote settings through `remoteControlPresenter`. +2. Main presenter updates `hooksNotifications.telegram` and `remoteControl.telegram`, then rebuilds the Telegram runtime if required. +3. Telegram poller receives private updates through `getUpdates`. +4. Parser normalizes text/command payloads. +5. Router applies auth and command handling. +6. Plain text enters `newAgentPresenter.sendMessage()` using the bound or newly created detached session. +7. Poller watches assistant message state and sends draft/final Telegram output. +8. If the assistant pauses on a permission/question action, Telegram returns a desktop-confirmation notice instead of bypassing approval. + +## Testing Strategy + +- Unit tests for `remoteAuthGuard`. +- Unit tests for `remoteBindingStore`. +- Unit tests for `remoteCommandRouter`. +- Unit tests for `telegramOutbound` chunking/final-text behavior. +- Presenter-level tests for detached session creation. +- Presenter-level tests for stop-by-event behavior. + +## Migration Note + +- No SQLite migration is required. +- Existing Telegram hook settings remain compatible. +- New remote state is additive and can be removed cleanly by disabling remote control or clearing the config blob. diff --git a/docs/specs/telegram-remote-control/spec.md b/docs/specs/telegram-remote-control/spec.md new file mode 100644 index 000000000..873214c32 --- /dev/null +++ b/docs/specs/telegram-remote-control/spec.md @@ -0,0 +1,45 @@ +# Telegram Remote Control + +## Summary + +Add Telegram private-chat remote control to the `dev` branch without changing DeepChat's main architecture. The bot runtime lives in Electron main, remote messages reuse the existing DeepChat session/message/stream pipeline, and the settings UI moves Telegram-related controls into a new `Remote` page. + +## User Stories + +- As a DeepChat desktop user, I can pair my Telegram account and send a DM to my bot to continue a DeepChat conversation remotely. +- As a paired user, my first Telegram DM can create a detached DeepChat session even when no chat window is focused. +- As a paired user, I can stop an active generation with `/stop` and later focus the local desktop session with `/open`. +- As a user configuring integrations, I can manage Telegram remote control and Telegram hook notifications from a single `Remote` settings page. + +## Acceptance Criteria + +- An authorized Telegram private-chat user can DM the bot and receive a DeepChat assistant reply. +- The first DM can create a detached DeepChat session without a focused window or existing `webContents` binding. +- Subsequent DMs continue the currently bound DeepChat session for that Telegram endpoint. +- `/stop` cancels the active generation for that remote endpoint through the existing stop path. +- `/open` focuses an existing local chat window or creates one, then activates the bound session. +- Remote-triggered conversations do not bypass the existing permission flow for tools, files, or settings. +- Telegram settings appear under a new `Remote` settings page, and the old Telegram section is removed from `Hooks`. +- Existing local desktop chat behavior remains unchanged. + +## Constraints + +- Telegram only for v1. +- No generic channel registry or plugin system. +- Bot runtime lives in Electron main, not renderer or preload. +- SQLite remains the source of truth for sessions and messages. +- Config/state uses the existing Electron Store path; no new SQLite migration is introduced. +- v1 is private-chat only. No group support, no media upload, no remote permission approvals. + +## Non-Goals + +- Group chats, forum moderation, or multi-platform messaging channels. +- Telegram media uploads, buttons, or Markdown/HTML rich formatting. +- Remote approval of tool/file/settings permission requests. +- A standalone helper daemon or public remote-control SDK. + +## Compatibility + +- Existing Telegram hook settings remain valid and are reused by the new `Remote` page. +- New remote-specific state lives under `remoteControl.telegram` in Electron Store. +- Disabling remote control or clearing the bot token cleanly stops polling without affecting local chats or persisted SQLite data. diff --git a/docs/specs/telegram-remote-control/tasks.md b/docs/specs/telegram-remote-control/tasks.md new file mode 100644 index 000000000..72fa2b135 --- /dev/null +++ b/docs/specs/telegram-remote-control/tasks.md @@ -0,0 +1,31 @@ +# Tasks + +1. Main presenter + - Add `remoteControlPresenter` contract and register it in main `Presenter`. + - Rebuild runtime on settings changes and app init. + +2. Detached session support + - Add `createDetachedSession()` to `newAgentPresenter`. + - Ensure first remote message still triggers title generation through the shared send path. + +3. Remote runtime services + - Implement auth guard, binding store, command router, and conversation runner. + - Reuse existing stop/open/session listing behavior. + +4. Telegram transport + - Implement native-fetch Telegram client. + - Implement long polling with offset persistence and backoff. + - Implement plain-text outbound chunking and draft/final delivery. + +5. Renderer + - Add `RemoteSettings.vue`. + - Add `settings-remote` route. + - Remove Telegram UI from `NotificationsHooksSettings.vue`. + - Add i18n keys for `Remote`. + +6. Tests + - Add main tests for auth guard, bindings, command routing, and chunking. + - Extend existing presenter tests for detached session creation and stop-by-event behavior. + +7. Validation + - Run formatting, i18n check, lint, and targeted tests when dependencies are available in the worktree. diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts index 949460e37..cbc038790 100644 --- a/src/main/presenter/deepchatAgentPresenter/index.ts +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -884,6 +884,28 @@ export class DeepChatAgentPresenter implements IAgentImplementation { this.setSessionStatus(sessionId, 'idle') } + getActiveGeneration(sessionId: string): { eventId: string; runId: string } | null { + const activeGeneration = this.activeGenerations.get(sessionId) + if (!activeGeneration) { + return null + } + + return { + eventId: activeGeneration.messageId, + runId: activeGeneration.runId + } + } + + async cancelGenerationByEventId(sessionId: string, eventId: string): Promise { + const activeGeneration = this.activeGenerations.get(sessionId) + if (!activeGeneration || activeGeneration.messageId !== eventId) { + return false + } + + await this.cancelGeneration(sessionId) + return true + } + private dispatchTerminalHooks( sessionId: string, state: DeepChatSessionState | undefined, diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 8f01bb612..3dd6c7122 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -66,6 +66,8 @@ import { NewSessionHooksBridge } from './hooksNotifications/newSessionBridge' import { NewAgentPresenter } from './newAgentPresenter' import { DeepChatAgentPresenter } from './deepchatAgentPresenter' import { ProjectPresenter } from './projectPresenter' +import { RemoteControlPresenter } from './remoteControlPresenter' +import type { RemoteControlPresenterLike } from './remoteControlPresenter/interface' // IPC调用上下文接口 interface IPCCallContext { @@ -112,6 +114,7 @@ export class Presenter implements IPresenter { skillSyncPresenter: ISkillSyncPresenter newAgentPresenter: INewAgentPresenter projectPresenter: IProjectPresenter + remoteControlPresenter: RemoteControlPresenterLike hooksNotifications: HooksNotificationsService commandPermissionService: CommandPermissionService filePermissionService: FilePermissionService @@ -290,6 +293,17 @@ export class Presenter implements IPresenter { this.sqlitePresenter as unknown as import('./sqlitePresenter').SQLitePresenter, this.devicePresenter ) + this.remoteControlPresenter = new RemoteControlPresenter({ + configPresenter: this.configPresenter, + newAgentPresenter: this.newAgentPresenter, + deepchatAgentPresenter, + windowPresenter: this.windowPresenter, + tabPresenter: this.tabPresenter, + getHooksNotificationsConfig: () => this.configPresenter.getHooksNotificationsConfig(), + setHooksNotificationsConfig: (config) => + this.configPresenter.setHooksNotificationsConfig(config), + testTelegramHookNotification: () => this.configPresenter.testTelegramNotification() + }) // Update hooksNotifications with actual dependencies now that newAgentPresenter is ready this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { @@ -392,6 +406,9 @@ export class Presenter implements IPresenter { // 初始化 Skills 系统 this.initializeSkills() + + // Initialize remote control runtime + void this.remoteControlPresenter.initialize() } // 初始化悬浮按钮 @@ -453,6 +470,7 @@ export class Presenter implements IPresenter { // 在应用退出时进行清理,关闭数据库连接 destroy() { + void this.remoteControlPresenter.destroy() this.floatingButtonPresenter.destroy() // 销毁悬浮按钮 this.tabPresenter.destroy() this.sqlitePresenter.close() // 关闭数据库连接 diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts index 2e2313ae6..ea9b7cb6b 100644 --- a/src/main/presenter/newAgentPresenter/index.ts +++ b/src/main/presenter/newAgentPresenter/index.ts @@ -2,6 +2,7 @@ import type { Agent, IAgentImplementation, CreateSessionInput, + CreateDetachedSessionInput, SessionRecord, SessionWithState, ChatMessageRecord, @@ -204,6 +205,66 @@ export class NewAgentPresenter { return sessionResult } + async createDetachedSession(input: CreateDetachedSessionInput): Promise { + const agentId = input.agentId?.trim() || 'deepchat' + const projectDir = input.projectDir?.trim() ? input.projectDir.trim() : null + const title = input.title?.trim() || 'New Chat' + const disabledAgentTools = + agentId === 'deepchat' ? this.normalizeDisabledAgentTools(input.disabledAgentTools) : [] + const agent = await this.resolveAgentImplementation(agentId) + + const defaultModel = this.configPresenter.getDefaultModel() + const providerId = input.providerId ?? defaultModel?.providerId ?? '' + const modelId = input.modelId ?? defaultModel?.modelId ?? '' + const permissionMode: PermissionMode = + input.permissionMode === 'default' ? 'default' : 'full_access' + + if (!providerId || !modelId) { + throw new Error('No provider or model configured. Please set a default model in settings.') + } + this.assertAcpSessionHasWorkdir(providerId, projectDir) + + const sessionId = this.sessionManager.create(agentId, title, projectDir, { + isDraft: false, + disabledAgentTools + }) + + try { + await this.initializeSessionRuntime(agent, sessionId, { + agentId, + providerId, + modelId, + projectDir, + permissionMode, + generationSettings: input.generationSettings + }) + } catch (error) { + await this.cleanupFailedSessionInitialization(agent, sessionId) + throw error + } + + if (input.activeSkills && input.activeSkills.length > 0 && this.skillPresenter) { + await this.skillPresenter.setActiveSkills(sessionId, input.activeSkills) + } + + this.emitSessionListUpdated() + + const state = await agent.getSessionState(sessionId) + return { + id: sessionId, + agentId, + title, + projectDir, + isPinned: false, + isDraft: false, + createdAt: Date.now(), + updatedAt: Date.now(), + status: state?.status ?? 'idle', + providerId: state?.providerId ?? providerId, + modelId: state?.modelId ?? modelId + } + } + async ensureAcpDraftSession(input: { agentId: string projectDir: string @@ -270,6 +331,7 @@ export class NewAgentPresenter { async sendMessage(sessionId: string, content: string | SendMessageInput): Promise { let session = this.sessionManager.get(sessionId) if (!session) throw new Error(`Session not found: ${sessionId}`) + const wasDraft = session.isDraft const normalizedInput = this.normalizeSendMessageInput(content) if (session.isDraft) { @@ -282,6 +344,7 @@ export class NewAgentPresenter { const agent = await this.resolveAgentImplementation(session.agentId) const state = await agent.getSessionState(sessionId) + const hadMessages = (await agent.getMessages(sessionId)).length > 0 let providerId = state?.providerId ?? '' if (!providerId) { const acpAgents = await this.configPresenter.getAcpAgents() @@ -298,11 +361,17 @@ export class NewAgentPresenter { ) if (agent.queuePendingInput) { await agent.queuePendingInput(sessionId, normalizedInput) + if (!hadMessages && !wasDraft) { + void this.generateSessionTitle(sessionId, session.title, providerId, state?.modelId ?? '') + } return } await agent.processMessage(sessionId, normalizedInput, { projectDir: session.projectDir ?? null }) + if (!hadMessages && !wasDraft) { + void this.generateSessionTitle(sessionId, session.title, providerId, state?.modelId ?? '') + } } async listPendingInputs(sessionId: string) { diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts new file mode 100644 index 000000000..8a7748d72 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -0,0 +1,252 @@ +import type { HookTestResult, TelegramNotificationsConfig } from '@shared/hooksNotifications' +import type { TelegramRemoteSettings, TelegramRemoteStatus } from '@shared/presenter' +import { normalizeTelegramSettingsInput, type TelegramPollerStatusSnapshot } from './types' +import type { RemoteControlPresenterDeps } from './interface' +import { RemoteBindingStore } from './services/remoteBindingStore' +import { RemoteAuthGuard } from './services/remoteAuthGuard' +import { RemoteConversationRunner } from './services/remoteConversationRunner' +import { RemoteCommandRouter } from './services/remoteCommandRouter' +import { TelegramClient } from './telegram/telegramClient' +import { TelegramParser } from './telegram/telegramParser' +import { TelegramPoller } from './telegram/telegramPoller' + +const DEFAULT_POLLER_STATUS: TelegramPollerStatusSnapshot = { + state: 'stopped', + lastError: null, + botUser: null +} + +export class RemoteControlPresenter { + private readonly bindingStore: RemoteBindingStore + private telegramPoller: TelegramPoller | null = null + private telegramPollerStatus: TelegramPollerStatusSnapshot = { ...DEFAULT_POLLER_STATUS } + private activeBotToken: string | null = null + private runtimeOperation: Promise = Promise.resolve() + + constructor(private readonly deps: RemoteControlPresenterDeps) { + this.bindingStore = new RemoteBindingStore(this.deps.configPresenter) + } + + async initialize(): Promise { + await this.enqueueRuntimeOperation(async () => { + await this.rebuildTelegramRuntime() + }) + } + + async destroy(): Promise { + await this.enqueueRuntimeOperation(async () => { + await this.stopTelegramRuntime() + }) + } + + buildTelegramSettingsSnapshot(): TelegramRemoteSettings { + const hooksConfig = this.deps.getHooksNotificationsConfig().telegram + const remoteConfig = this.bindingStore.getTelegramConfig() + + return { + botToken: hooksConfig.botToken, + remoteEnabled: remoteConfig.enabled, + allowedUserIds: remoteConfig.allowlist, + streamMode: remoteConfig.streamMode, + pairCode: remoteConfig.pairing.code, + pairCodeExpiresAt: remoteConfig.pairing.expiresAt, + hookNotifications: { + enabled: hooksConfig.enabled, + chatId: hooksConfig.chatId, + threadId: hooksConfig.threadId, + events: hooksConfig.events + } + } + } + + async getTelegramSettings(): Promise { + return this.buildTelegramSettingsSnapshot() + } + + async saveTelegramSettings(input: TelegramRemoteSettings): Promise { + const normalized = normalizeTelegramSettingsInput(input) + const currentHooksConfig = this.deps.getHooksNotificationsConfig() + + this.deps.setHooksNotificationsConfig({ + ...currentHooksConfig, + telegram: this.buildTelegramHookConfig(normalized, currentHooksConfig.telegram) + }) + + this.bindingStore.updateTelegramConfig((config) => ({ + ...config, + enabled: normalized.remoteEnabled, + allowlist: normalized.allowedUserIds, + streamMode: normalized.streamMode, + pairing: { + code: normalized.pairCode, + expiresAt: normalized.pairCodeExpiresAt + } + })) + + await this.enqueueRuntimeOperation(async () => { + await this.rebuildTelegramRuntime() + }) + return this.buildTelegramSettingsSnapshot() + } + + async getTelegramStatus(): Promise { + const remoteConfig = this.bindingStore.getTelegramConfig() + const hooksConfig = this.deps.getHooksNotificationsConfig().telegram + const runtimeStatus = this.getEffectivePollerStatus(hooksConfig.botToken, remoteConfig.enabled) + + return { + state: runtimeStatus.state, + pollOffset: remoteConfig.pollOffset, + bindingCount: Object.keys(remoteConfig.bindings).length, + allowedUserCount: remoteConfig.allowlist.length, + lastError: runtimeStatus.lastError, + botUser: runtimeStatus.botUser + } + } + + async createTelegramPairCode(): Promise<{ code: string; expiresAt: number }> { + return this.bindingStore.createPairCode() + } + + async clearTelegramPairCode(): Promise { + this.bindingStore.clearPairCode() + } + + async clearTelegramBindings(): Promise { + return this.bindingStore.clearBindings() + } + + async testTelegramHookNotification(): Promise { + return await this.deps.testTelegramHookNotification() + } + + private buildTelegramHookConfig( + settings: TelegramRemoteSettings, + previous: TelegramNotificationsConfig + ): TelegramNotificationsConfig { + return { + ...previous, + enabled: settings.hookNotifications.enabled, + botToken: settings.botToken, + chatId: settings.hookNotifications.chatId, + threadId: settings.hookNotifications.threadId, + events: settings.hookNotifications.events + } + } + + private async rebuildTelegramRuntime(): Promise { + const settings = this.buildTelegramSettingsSnapshot() + const botToken = settings.botToken.trim() + + if (!settings.remoteEnabled) { + await this.stopTelegramRuntime() + this.telegramPollerStatus = { + state: 'disabled', + lastError: null, + botUser: null + } + return + } + + if (!botToken) { + await this.stopTelegramRuntime() + this.telegramPollerStatus = { + state: 'error', + lastError: 'Bot token is required.', + botUser: null + } + return + } + + if (this.telegramPoller && this.activeBotToken === botToken) { + return + } + + await this.stopTelegramRuntime() + this.activeBotToken = botToken + this.telegramPollerStatus = { + state: 'starting', + lastError: null, + botUser: null + } + + const authGuard = new RemoteAuthGuard(this.bindingStore) + const runner = new RemoteConversationRunner( + { + newAgentPresenter: this.deps.newAgentPresenter, + deepchatAgentPresenter: this.deps.deepchatAgentPresenter, + windowPresenter: this.deps.windowPresenter, + tabPresenter: this.deps.tabPresenter + }, + this.bindingStore + ) + const router = new RemoteCommandRouter({ + authGuard, + runner, + bindingStore: this.bindingStore, + getPollerStatus: () => this.getEffectivePollerStatus(botToken, true) + }) + + this.telegramPoller = new TelegramPoller({ + client: new TelegramClient(botToken), + parser: new TelegramParser(), + router, + bindingStore: this.bindingStore, + onStatusChange: (snapshot) => { + this.telegramPollerStatus = snapshot + } + }) + + try { + await this.telegramPoller.start() + } catch (error) { + this.telegramPollerStatus = { + state: 'error', + lastError: error instanceof Error ? error.message : String(error), + botUser: null + } + await this.stopTelegramRuntime() + } + } + + private async stopTelegramRuntime(): Promise { + const poller = this.telegramPoller + this.telegramPoller = null + this.activeBotToken = null + + if (!poller) { + return + } + + await poller.stop() + } + + private getEffectivePollerStatus( + botToken: string, + remoteEnabled: boolean + ): TelegramPollerStatusSnapshot { + if (!remoteEnabled) { + return { + state: 'disabled', + lastError: null, + botUser: null + } + } + + if (!botToken.trim()) { + return { + state: 'error', + lastError: 'Bot token is required.', + botUser: null + } + } + + return { ...this.telegramPollerStatus } + } + + private enqueueRuntimeOperation(operation: () => Promise): Promise { + const nextOperation = this.runtimeOperation.then(operation, operation) + this.runtimeOperation = nextOperation.catch(() => {}) + return nextOperation + } +} diff --git a/src/main/presenter/remoteControlPresenter/interface.ts b/src/main/presenter/remoteControlPresenter/interface.ts new file mode 100644 index 000000000..b955446c6 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/interface.ts @@ -0,0 +1,31 @@ +import type { HookTestResult, HooksNotificationsSettings } from '@shared/hooksNotifications' +import type { + IConfigPresenter, + INewAgentPresenter, + IRemoteControlPresenter, + ITabPresenter, + IWindowPresenter, + TelegramRemoteSettings +} from '@shared/presenter' +import type { DeepChatAgentPresenter } from '../deepchatAgentPresenter' + +export interface RemoteControlPresenterDeps { + configPresenter: IConfigPresenter + newAgentPresenter: INewAgentPresenter + deepchatAgentPresenter: DeepChatAgentPresenter + windowPresenter: IWindowPresenter + tabPresenter: ITabPresenter + getHooksNotificationsConfig: () => HooksNotificationsSettings + setHooksNotificationsConfig: (config: HooksNotificationsSettings) => HooksNotificationsSettings + testTelegramHookNotification: () => Promise +} + +export interface RemoteRuntimeLifecycle { + initialize(): Promise + destroy(): Promise +} + +export interface RemoteControlPresenterLike + extends IRemoteControlPresenter, RemoteRuntimeLifecycle { + buildTelegramSettingsSnapshot(): TelegramRemoteSettings +} diff --git a/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts b/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts new file mode 100644 index 000000000..7b3198b70 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts @@ -0,0 +1,84 @@ +import type { TelegramInboundMessage } from '../types' +import { RemoteBindingStore } from './remoteBindingStore' + +export type RemoteAuthResult = + | { + ok: true + userId: number + } + | { + ok: false + message: string + } + +export class RemoteAuthGuard { + constructor(private readonly bindingStore: RemoteBindingStore) {} + + ensureAuthorized(message: TelegramInboundMessage): RemoteAuthResult { + if (message.chatType !== 'private') { + return { + ok: false, + message: 'Telegram remote control only supports private chats in v1.' + } + } + + if (!message.fromId || !Number.isInteger(message.fromId) || message.fromId <= 0) { + return { + ok: false, + message: 'Unable to verify your Telegram user ID.' + } + } + + if (this.bindingStore.isAllowedUser(message.fromId)) { + return { + ok: true, + userId: message.fromId + } + } + + return { + ok: false, + message: + 'This Telegram account is not paired. Use /pair from DeepChat Remote settings.' + } + } + + pair(message: TelegramInboundMessage, rawCode: string): string { + const authorization = this.ensurePrivatePairingContext(message) + if (authorization) { + return authorization + } + + const normalizedCode = rawCode.trim() + if (!/^\d{6}$/.test(normalizedCode)) { + return 'Usage: /pair <6-digit-code>' + } + + const pairing = this.bindingStore.getPairingState() + if (!pairing.code || !pairing.expiresAt || pairing.expiresAt <= Date.now()) { + this.bindingStore.clearPairCode() + return 'Pairing code is missing or expired. Generate a new code from DeepChat Remote settings.' + } + + if (pairing.code !== normalizedCode) { + return 'Pairing code is invalid.' + } + + const userId = message.fromId as number + this.bindingStore.addAllowedUser(userId) + this.bindingStore.clearPairCode() + return `Pairing complete. Telegram user ${userId} is now authorized.` + } + + private ensurePrivatePairingContext(message: TelegramInboundMessage): string | null { + if (message.chatType !== 'private') { + return 'Pairing is only available in a private chat with the bot.' + } + + if (!message.fromId || !Number.isInteger(message.fromId) || message.fromId <= 0) { + return 'Unable to verify your Telegram user ID for pairing.' + } + + return null + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts new file mode 100644 index 000000000..7b7a68207 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts @@ -0,0 +1,166 @@ +import type { IConfigPresenter } from '@shared/presenter' +import { + REMOTE_CONTROL_SETTING_KEY, + buildTelegramEndpointKey, + normalizeRemoteControlConfig, + createPairCode, + type RemoteControlConfig, + type TelegramEndpointBinding, + type TelegramInboundMessage, + type TelegramRemoteRuntimeConfig +} from '../types' + +export class RemoteBindingStore { + private readonly activeEvents = new Map() + private readonly sessionSnapshots = new Map() + + constructor(private readonly configPresenter: IConfigPresenter) {} + + getConfig(): RemoteControlConfig { + return normalizeRemoteControlConfig( + this.configPresenter.getSetting(REMOTE_CONTROL_SETTING_KEY) + ) + } + + getTelegramConfig(): TelegramRemoteRuntimeConfig { + return this.getConfig().telegram + } + + updateTelegramConfig( + updater: (config: TelegramRemoteRuntimeConfig) => TelegramRemoteRuntimeConfig + ): TelegramRemoteRuntimeConfig { + const current = this.getConfig() + const next = normalizeRemoteControlConfig({ + ...current, + telegram: updater(current.telegram) + }) + this.configPresenter.setSetting(REMOTE_CONTROL_SETTING_KEY, next) + return next.telegram + } + + getEndpointKey( + target: { chatId: number; messageThreadId?: number } | TelegramInboundMessage + ): string { + return buildTelegramEndpointKey(target.chatId, target.messageThreadId ?? 0) + } + + getBinding(endpointKey: string): TelegramEndpointBinding | null { + return this.getTelegramConfig().bindings[endpointKey] ?? null + } + + setBinding(endpointKey: string, sessionId: string): void { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: { + ...config.bindings, + [endpointKey]: { + sessionId, + updatedAt: Date.now() + } + } + })) + this.activeEvents.delete(endpointKey) + } + + clearBinding(endpointKey: string): void { + this.updateTelegramConfig((config) => { + const bindings = { ...config.bindings } + delete bindings[endpointKey] + return { + ...config, + bindings + } + }) + this.activeEvents.delete(endpointKey) + this.sessionSnapshots.delete(endpointKey) + } + + clearBindings(): number { + const count = Object.keys(this.getTelegramConfig().bindings).length + this.updateTelegramConfig((config) => ({ + ...config, + bindings: {} + })) + this.activeEvents.clear() + this.sessionSnapshots.clear() + return count + } + + countBindings(): number { + return Object.keys(this.getTelegramConfig().bindings).length + } + + getPollOffset(): number { + return this.getTelegramConfig().pollOffset + } + + setPollOffset(offset: number): void { + this.updateTelegramConfig((config) => ({ + ...config, + pollOffset: Math.max(0, Math.trunc(offset)) + })) + } + + getAllowedUserIds(): number[] { + return this.getTelegramConfig().allowlist + } + + isAllowedUser(userId: number | null | undefined): boolean { + if (!userId) { + return false + } + return this.getAllowedUserIds().includes(userId) + } + + addAllowedUser(userId: number): void { + this.updateTelegramConfig((config) => ({ + ...config, + allowlist: Array.from(new Set([...config.allowlist, userId])).sort( + (left, right) => left - right + ) + })) + } + + getPairingState() { + return this.getTelegramConfig().pairing + } + + createPairCode(): { code: string; expiresAt: number } { + const pairing = createPairCode() + this.updateTelegramConfig((config) => ({ + ...config, + pairing + })) + return pairing + } + + clearPairCode(): void { + this.updateTelegramConfig((config) => ({ + ...config, + pairing: { + code: null, + expiresAt: null + } + })) + } + + rememberActiveEvent(endpointKey: string, eventId: string): void { + this.activeEvents.set(endpointKey, eventId) + } + + getActiveEvent(endpointKey: string): string | null { + return this.activeEvents.get(endpointKey) ?? null + } + + clearActiveEvent(endpointKey: string): void { + this.activeEvents.delete(endpointKey) + } + + rememberSessionSnapshot(endpointKey: string, sessionIds: string[]): void { + this.sessionSnapshots.set(endpointKey, [...sessionIds]) + } + + getSessionSnapshot(endpointKey: string): string[] { + return this.sessionSnapshots.get(endpointKey) ?? [] + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts new file mode 100644 index 000000000..07093904e --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts @@ -0,0 +1,189 @@ +import type { TelegramPollerStatusSnapshot, TelegramInboundMessage } from '../types' +import type { RemoteConversationExecution } from './remoteConversationRunner' +import { RemoteAuthGuard } from './remoteAuthGuard' +import { RemoteBindingStore } from './remoteBindingStore' +import { RemoteConversationRunner } from './remoteConversationRunner' + +export interface RemoteCommandRouteResult { + replies: string[] + conversation?: RemoteConversationExecution +} + +type RemoteCommandRouterDeps = { + authGuard: RemoteAuthGuard + runner: RemoteConversationRunner + bindingStore: RemoteBindingStore + getPollerStatus: () => TelegramPollerStatusSnapshot +} + +export class RemoteCommandRouter { + constructor(private readonly deps: RemoteCommandRouterDeps) {} + + async handleMessage(message: TelegramInboundMessage): Promise { + const endpointKey = this.deps.bindingStore.getEndpointKey(message) + const command = message.command?.name + + if (command === 'start') { + const auth = this.deps.authGuard.ensureAuthorized(message) + return { + replies: [this.formatStartMessage(auth.ok)] + } + } + + if (command === 'help') { + return { + replies: [this.formatHelpMessage()] + } + } + + if (command === 'pair') { + return { + replies: [this.deps.authGuard.pair(message, message.command?.args ?? '')] + } + } + + const auth = this.deps.authGuard.ensureAuthorized(message) + if (!auth.ok) { + return { + replies: [auth.message] + } + } + + try { + switch (command) { + case 'new': { + const title = message.command?.args?.trim() + const session = await this.deps.runner.createNewSession(endpointKey, title) + return { + replies: [`Started a new session: ${this.formatSessionLabel(session)}`] + } + } + + case 'sessions': { + const sessions = await this.deps.runner.listSessions(endpointKey) + if (sessions.length === 0) { + return { + replies: ['No DeepChat sessions were found.'] + } + } + + return { + replies: [ + [ + 'Recent sessions:', + ...sessions.map((session, index) => this.formatSessionLine(session, index + 1)) + ].join('\n') + ] + } + } + + case 'use': { + const rawIndex = message.command?.args?.trim() + const index = Number.parseInt(rawIndex ?? '', 10) + if (!Number.isInteger(index) || index <= 0) { + return { + replies: ['Usage: /use '] + } + } + + const session = await this.deps.runner.useSessionByIndex(endpointKey, index - 1) + return { + replies: [`Now using: ${this.formatSessionLabel(session)}`] + } + } + + case 'stop': { + const stopped = await this.deps.runner.stop(endpointKey) + return { + replies: [ + stopped ? 'Stopped the active generation.' : 'There is no active generation to stop.' + ] + } + } + + case 'open': { + const session = await this.deps.runner.open(endpointKey) + return { + replies: [ + session + ? `Opened desktop session: ${this.formatSessionLabel(session)}` + : 'No bound session to open. Send a message or use /new first.' + ] + } + } + + case 'status': { + const runtime = this.deps.getPollerStatus() + const status = await this.deps.runner.getStatus(endpointKey) + const telegramConfig = this.deps.bindingStore.getTelegramConfig() + return { + replies: [ + [ + 'DeepChat Telegram Remote', + `Runtime: ${runtime.state}`, + `Stream mode: ${telegramConfig.streamMode}`, + `Current session: ${status.session ? this.formatSessionLabel(status.session) : 'none'}`, + `Generating: ${status.isGenerating ? 'yes' : 'no'}`, + `Allowed users: ${telegramConfig.allowlist.length}`, + `Bindings: ${Object.keys(telegramConfig.bindings).length}`, + `Last error: ${runtime.lastError ?? 'none'}` + ].join('\n') + ] + } + } + + default: + break + } + + return { + replies: [], + conversation: await this.deps.runner.sendText(endpointKey, message.text) + } + } catch (error) { + return { + replies: [error instanceof Error ? error.message : String(error)] + } + } + } + + private formatStartMessage(isAuthorized: boolean): string { + const statusLine = isAuthorized + ? 'Status: paired' + : 'Status: not paired. Use /pair from DeepChat Remote settings.' + + return [ + 'DeepChat Telegram remote control is ready.', + statusLine, + 'Use /help to see the available commands.' + ].join('\n') + } + + private formatHelpMessage(): string { + return [ + 'Commands:', + '/start', + '/help', + '/pair ', + '/new [title]', + '/sessions', + '/use ', + '/stop', + '/open', + '/status', + 'Plain text sends to the current bound session.' + ].join('\n') + } + + private formatSessionLine( + session: { title: string; id: string; status: string }, + index: number + ): string { + return `${index}. ${session.title || 'Untitled'} (${session.status})` + } + + private formatSessionLabel(session: { title: string; id: string }): string { + const title = session.title?.trim() || 'Untitled' + return `${title} [${session.id}]` + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts new file mode 100644 index 000000000..ef4a79a59 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts @@ -0,0 +1,365 @@ +import { BrowserWindow } from 'electron' +import type { ChatMessageRecord, SessionWithState } from '@shared/types/agent-interface' +import type { INewAgentPresenter, ITabPresenter, IWindowPresenter } from '@shared/presenter' +import type { DeepChatAgentPresenter } from '../../deepchatAgentPresenter' +import { TELEGRAM_RECENT_SESSION_LIMIT, TELEGRAM_STREAM_POLL_INTERVAL_MS } from '../types' +import { + buildTelegramFinalText, + extractTelegramStreamText, + safeParseAssistantBlocks +} from '../telegram/telegramOutbound' +import { RemoteBindingStore } from './remoteBindingStore' + +const sleep = async (ms: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +export interface RemoteConversationSnapshot { + messageId: string | null + text: string + completed: boolean +} + +export interface RemoteConversationExecution { + sessionId: string + eventId: string | null + getSnapshot(): Promise +} + +export interface RemoteRunnerStatus { + session: SessionWithState | null + activeEventId: string | null + isGenerating: boolean +} + +type RemoteConversationRunnerDeps = { + newAgentPresenter: INewAgentPresenter + deepchatAgentPresenter: DeepChatAgentPresenter + windowPresenter: IWindowPresenter + tabPresenter: ITabPresenter +} + +type ChatWindowLookupPresenter = ITabPresenter & { + getWindowType(windowId: number): 'chat' | 'browser' +} + +export class RemoteConversationRunner { + constructor( + private readonly deps: RemoteConversationRunnerDeps, + private readonly bindingStore: RemoteBindingStore + ) {} + + async createNewSession(endpointKey: string, title?: string): Promise { + const session = await this.deps.newAgentPresenter.createDetachedSession({ + title: title?.trim() || 'New Chat', + agentId: 'deepchat' + }) + this.bindingStore.setBinding(endpointKey, session.id) + return session + } + + async getCurrentSession(endpointKey: string): Promise { + const binding = this.bindingStore.getBinding(endpointKey) + if (!binding) { + return null + } + + const session = await this.deps.newAgentPresenter.getSession(binding.sessionId) + if (!session) { + this.bindingStore.clearBinding(endpointKey) + return null + } + + return session + } + + async ensureBoundSession(endpointKey: string): Promise { + const existing = await this.getCurrentSession(endpointKey) + if (existing) { + return existing + } + + return await this.createNewSession(endpointKey) + } + + async listSessions(endpointKey: string): Promise { + const sessions = await this.deps.newAgentPresenter.getSessionList({ + agentId: 'deepchat' + }) + const sorted = [...sessions] + .sort((left, right) => right.updatedAt - left.updatedAt) + .slice(0, TELEGRAM_RECENT_SESSION_LIMIT) + this.bindingStore.rememberSessionSnapshot( + endpointKey, + sorted.map((session) => session.id) + ) + return sorted + } + + async useSessionByIndex(endpointKey: string, index: number): Promise { + const snapshot = this.bindingStore.getSessionSnapshot(endpointKey) + if (snapshot.length === 0) { + throw new Error('Run /sessions first before using /use.') + } + + const sessionId = snapshot[index] + if (!sessionId) { + throw new Error('Session index is out of range.') + } + + const session = await this.deps.newAgentPresenter.getSession(sessionId) + if (!session) { + throw new Error('Selected session no longer exists.') + } + + this.bindingStore.setBinding(endpointKey, session.id) + return session + } + + async sendText(endpointKey: string, text: string): Promise { + const session = await this.ensureBoundSession(endpointKey) + const beforeMessages = await this.deps.newAgentPresenter.getMessages(session.id) + const lastOrderSeq = beforeMessages.at(-1)?.orderSeq ?? 0 + const previousActiveEventId = + this.deps.deepchatAgentPresenter.getActiveGeneration(session.id)?.eventId ?? null + + await this.deps.newAgentPresenter.sendMessage(session.id, text) + + const seededMessage = await this.waitForAssistantMessage(session.id, lastOrderSeq, 800, { + ignoreMessageId: previousActiveEventId + }) + if (seededMessage) { + this.bindingStore.rememberActiveEvent(endpointKey, seededMessage.id) + } + + return { + sessionId: session.id, + eventId: seededMessage?.id ?? null, + getSnapshot: async () => + await this.getConversationSnapshot(endpointKey, session.id, { + afterOrderSeq: lastOrderSeq, + preferredMessageId: seededMessage?.id ?? null, + ignoreMessageId: previousActiveEventId + }) + } + } + + async stop(endpointKey: string): Promise { + const session = await this.getCurrentSession(endpointKey) + if (!session) { + return false + } + + const activeEventId = + this.bindingStore.getActiveEvent(endpointKey) ?? + this.deps.deepchatAgentPresenter.getActiveGeneration(session.id)?.eventId ?? + null + + if (!activeEventId) { + return false + } + + const stopped = await this.deps.deepchatAgentPresenter.cancelGenerationByEventId( + session.id, + activeEventId + ) + if (stopped) { + this.bindingStore.clearActiveEvent(endpointKey) + } + return stopped + } + + async open(endpointKey: string): Promise { + const session = await this.getCurrentSession(endpointKey) + if (!session) { + return null + } + + const window = await this.resolveChatWindow() + if (!window || window.isDestroyed()) { + return null + } + + await this.deps.newAgentPresenter.activateSession(window.webContents.id, session.id) + this.deps.windowPresenter.show(window.id, true) + return session + } + + async getStatus(endpointKey: string): Promise { + const session = await this.getCurrentSession(endpointKey) + if (!session) { + return { + session: null, + activeEventId: null, + isGenerating: false + } + } + + const activeEventId = + this.bindingStore.getActiveEvent(endpointKey) ?? + this.deps.deepchatAgentPresenter.getActiveGeneration(session.id)?.eventId ?? + null + + return { + session, + activeEventId, + isGenerating: Boolean(activeEventId) || session.status === 'generating' + } + } + + private async getConversationSnapshot( + endpointKey: string, + sessionId: string, + tracking: { + afterOrderSeq: number + preferredMessageId: string | null + ignoreMessageId: string | null + } + ): Promise { + const session = await this.deps.newAgentPresenter.getSession(sessionId) + if (!session) { + this.bindingStore.clearBinding(endpointKey) + return { + messageId: null, + text: 'The bound session no longer exists.', + completed: true + } + } + + const activeGeneration = this.deps.deepchatAgentPresenter.getActiveGeneration(sessionId) + const trackedMessage = await this.resolveTrackedAssistantMessage( + sessionId, + tracking, + activeGeneration + ) + if (trackedMessage) { + this.bindingStore.rememberActiveEvent(endpointKey, trackedMessage.id) + } else if (activeGeneration?.eventId && activeGeneration.eventId !== tracking.ignoreMessageId) { + this.bindingStore.rememberActiveEvent(endpointKey, activeGeneration.eventId) + } + + if (!trackedMessage) { + const completed = !activeGeneration && session.status !== 'generating' + if (completed) { + this.bindingStore.clearActiveEvent(endpointKey) + } + return { + messageId: null, + text: completed ? 'No assistant response was produced.' : '', + completed + } + } + + const blocks = safeParseAssistantBlocks(trackedMessage.content) + const completed = + trackedMessage.status !== 'pending' && + (!activeGeneration || activeGeneration.eventId !== trackedMessage.id) + + if (completed) { + this.bindingStore.clearActiveEvent(endpointKey) + } + + return { + messageId: trackedMessage.id, + text: completed ? buildTelegramFinalText(blocks) : extractTelegramStreamText(blocks), + completed + } + } + + private async waitForAssistantMessage( + sessionId: string, + afterOrderSeq: number, + timeoutMs: number, + options?: { + ignoreMessageId?: string | null + } + ): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const activeGeneration = this.deps.deepchatAgentPresenter.getActiveGeneration(sessionId) + if (activeGeneration?.eventId && activeGeneration.eventId !== options?.ignoreMessageId) { + const message = await this.deps.newAgentPresenter.getMessage(activeGeneration.eventId) + if (message?.role === 'assistant') { + return message + } + } + + const fallback = await this.findLatestAssistantMessageAfter(sessionId, afterOrderSeq) + if (fallback) { + return fallback + } + + await sleep(Math.min(TELEGRAM_STREAM_POLL_INTERVAL_MS, 120)) + } + + return null + } + + private async resolveTrackedAssistantMessage( + sessionId: string, + tracking: { + afterOrderSeq: number + preferredMessageId: string | null + ignoreMessageId: string | null + }, + activeGeneration: { eventId: string; runId: string } | null + ): Promise { + const candidateIds = [activeGeneration?.eventId ?? null, tracking.preferredMessageId] + for (const messageId of candidateIds) { + if (!messageId || messageId === tracking.ignoreMessageId) { + continue + } + + const message = await this.deps.newAgentPresenter.getMessage(messageId) + if (message?.role === 'assistant') { + return message + } + } + + return await this.findLatestAssistantMessageAfter(sessionId, tracking.afterOrderSeq) + } + + private async findLatestAssistantMessageAfter( + sessionId: string, + afterOrderSeq: number + ): Promise { + const messages = await this.deps.newAgentPresenter.getMessages(sessionId) + const assistants = messages.filter( + (message) => message.role === 'assistant' && message.orderSeq > afterOrderSeq + ) + if (assistants.length === 0) { + return null + } + + return assistants.sort((left, right) => right.orderSeq - left.orderSeq)[0] + } + + private async resolveChatWindow(): Promise { + const tabPresenter = this.deps.tabPresenter as ChatWindowLookupPresenter + const chatWindows = this.deps.windowPresenter + .getAllWindows() + .filter((window) => !window.isDestroyed() && tabPresenter.getWindowType(window.id) === 'chat') + + const focusedWindow = this.deps.windowPresenter.getFocusedWindow() + if ( + focusedWindow && + !focusedWindow.isDestroyed() && + chatWindows.some((window) => window.id === focusedWindow.id) + ) { + return focusedWindow + } + + if (chatWindows.length > 0) { + return chatWindows[0] + } + + const createdWindowId = await this.deps.windowPresenter.createAppWindow({ + initialRoute: 'chat' + }) + if (!createdWindowId) { + return null + } + + return BrowserWindow.fromId(createdWindowId) + } +} diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts new file mode 100644 index 000000000..df91ad58a --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts @@ -0,0 +1,154 @@ +import type { TelegramTransportTarget } from '../types' + +type TelegramApiErrorParameters = { + retry_after?: number +} + +type TelegramApiResponse = { + ok: boolean + result?: T + description?: string + error_code?: number + parameters?: TelegramApiErrorParameters +} + +type TelegramChat = { + id: number + type: string +} + +type TelegramUser = { + id: number + username?: string +} + +export type TelegramRawMessage = { + message_id: number + message_thread_id?: number + chat: TelegramChat + from?: TelegramUser + text?: string +} + +export type TelegramRawUpdate = { + update_id: number + message?: TelegramRawMessage +} + +export type TelegramBotUser = { + id: number + username?: string +} + +export class TelegramApiRequestError extends Error { + constructor( + message: string, + readonly code?: number, + readonly retryAfter?: number + ) { + super(message) + this.name = 'TelegramApiRequestError' + } +} + +export class TelegramClient { + private readonly baseUrl: string + + constructor(botToken: string) { + this.baseUrl = `https://api.telegram.org/bot${botToken}` + } + + async getMe(): Promise { + return await this.request('getMe') + } + + async getUpdates(params: { + offset?: number + limit?: number + timeout?: number + allowedUpdates?: string[] + signal?: AbortSignal + }): Promise { + return await this.request( + 'getUpdates', + { + offset: params.offset, + limit: params.limit, + timeout: params.timeout, + allowed_updates: params.allowedUpdates + }, + { + signal: params.signal + } + ) + } + + async sendMessage(target: TelegramTransportTarget, text: string): Promise { + await this.request('sendMessage', { + chat_id: target.chatId, + message_thread_id: target.messageThreadId || undefined, + text + }) + } + + async sendMessageDraft( + target: TelegramTransportTarget, + draftId: number, + text: string + ): Promise { + await this.request('sendMessageDraft', { + chat_id: target.chatId, + message_thread_id: target.messageThreadId || undefined, + draft_id: draftId, + text + }) + } + + async sendChatAction( + target: TelegramTransportTarget, + action: 'typing' = 'typing' + ): Promise { + await this.request('sendChatAction', { + chat_id: target.chatId, + message_thread_id: target.messageThreadId || undefined, + action + }) + } + + private async request( + method: string, + body?: Record, + options?: { + timeoutMs?: number + signal?: AbortSignal + } + ): Promise { + const timeoutSignal = AbortSignal.timeout(options?.timeoutMs ?? 35_000) + const signal = options?.signal + ? AbortSignal.any([timeoutSignal, options.signal]) + : timeoutSignal + const response = await fetch(`${this.baseUrl}/${method}`, { + method: 'POST', + headers: { + 'content-type': 'application/json' + }, + body: body ? JSON.stringify(body) : undefined, + signal + }) + + const payload = (await response.json().catch(() => ({}))) as TelegramApiResponse + if (!response.ok || !payload.ok || payload.result === undefined) { + const description = payload.description?.trim() || `Telegram API request failed: ${method}` + const retryAfter = payload.parameters?.retry_after + const retrySuffix = + typeof retryAfter === 'number' && retryAfter > 0 ? ` (retry_after=${retryAfter})` : '' + throw new TelegramApiRequestError( + `${description}${retrySuffix}`, + payload.error_code, + retryAfter + ) + } + + return payload.result + } +} diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts new file mode 100644 index 000000000..a68da993d --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts @@ -0,0 +1,109 @@ +import type { AssistantMessageBlock } from '@shared/types/agent-interface' +import { TELEGRAM_OUTBOUND_TEXT_LIMIT } from '../types' + +const EMPTY_TELEGRAM_TEXT = '(No text output)' +const TELEGRAM_DESKTOP_CONFIRMATION_NOTICE = + 'Desktop confirmation is required to continue this action.' + +export const createTelegramDraftId = (): number => + Math.max(1, Math.trunc(Math.random() * 2_000_000_000)) + +export const safeParseAssistantBlocks = (content: string): AssistantMessageBlock[] => { + try { + const parsed = JSON.parse(content) as AssistantMessageBlock[] | string + if (typeof parsed === 'string') { + return [ + { + type: 'content', + content: parsed, + status: 'success', + timestamp: Date.now() + } + ] + } + return Array.isArray(parsed) ? parsed : [] + } catch { + return content.trim() + ? [ + { + type: 'content', + content: content.trim(), + status: 'success', + timestamp: Date.now() + } + ] + : [] + } +} + +export const blocksRequireDesktopConfirmation = (blocks: AssistantMessageBlock[]): boolean => + blocks.some( + (block) => + block.type === 'action' && + (block.action_type === 'tool_call_permission' || block.action_type === 'question_request') && + block.extra?.needsUserAction !== false + ) + +export const extractTelegramStreamText = (blocks: AssistantMessageBlock[]): string => { + const preferred = blocks + .filter((block) => block.type === 'content' && typeof block.content === 'string') + .map((block) => block.content?.trim() ?? '') + .filter(Boolean) + + if (preferred.length > 0) { + return preferred.join('\n\n').trim() + } + + return blocks + .filter((block) => block.type !== 'tool_call' && typeof block.content === 'string') + .map((block) => block.content?.trim() ?? '') + .filter(Boolean) + .join('\n\n') + .trim() +} + +export const buildTelegramFinalText = (blocks: AssistantMessageBlock[]): string => { + const text = extractTelegramStreamText(blocks) || EMPTY_TELEGRAM_TEXT + if (!blocksRequireDesktopConfirmation(blocks)) { + return text + } + + return `${text}\n\n${TELEGRAM_DESKTOP_CONFIRMATION_NOTICE}`.trim() +} + +export const chunkTelegramText = ( + text: string, + limit: number = TELEGRAM_OUTBOUND_TEXT_LIMIT +): string[] => { + const normalized = text?.trim() || EMPTY_TELEGRAM_TEXT + if (normalized.length <= limit) { + return [normalized] + } + + const chunks: string[] = [] + let remaining = normalized + + while (remaining.length > limit) { + const window = remaining.slice(0, limit) + const splitIndex = Math.max( + window.lastIndexOf('\n\n'), + window.lastIndexOf('\n'), + window.lastIndexOf(' ') + ) + const nextIndex = splitIndex > Math.floor(limit * 0.55) ? splitIndex : limit + const chunk = remaining.slice(0, nextIndex).trim() + if (!chunk) { + chunks.push(remaining.slice(0, limit)) + remaining = remaining.slice(limit).trim() + continue + } + chunks.push(chunk) + remaining = remaining.slice(nextIndex).trim() + } + + if (remaining) { + chunks.push(remaining) + } + + return chunks +} diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramParser.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramParser.ts new file mode 100644 index 000000000..b7ff3e8ea --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramParser.ts @@ -0,0 +1,35 @@ +import type { TelegramInboundMessage } from '../types' +import type { TelegramRawUpdate } from './telegramClient' + +const TELEGRAM_COMMAND_REGEX = /^\/([a-zA-Z0-9_]+)(?:@[a-zA-Z0-9_]+)?(?:\s+([\s\S]*))?$/ + +export class TelegramParser { + parseUpdate(update: TelegramRawUpdate): TelegramInboundMessage | null { + const message = update.message + if (!message || typeof message.text !== 'string') { + return null + } + + const text = message.text.trim() + if (!text) { + return null + } + + const commandMatch = TELEGRAM_COMMAND_REGEX.exec(text) + return { + updateId: update.update_id, + chatId: Number(message.chat.id), + messageThreadId: Number(message.message_thread_id ?? 0), + messageId: Number(message.message_id), + chatType: message.chat.type, + fromId: typeof message.from?.id === 'number' ? Number(message.from.id) : null, + text, + command: commandMatch + ? { + name: commandMatch[1].toLowerCase(), + args: commandMatch[2]?.trim() ?? '' + } + : null + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts new file mode 100644 index 000000000..4ab8fce2a --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts @@ -0,0 +1,278 @@ +import { + TELEGRAM_REMOTE_POLL_LIMIT, + TELEGRAM_REMOTE_POLL_TIMEOUT_SEC, + TELEGRAM_STREAM_POLL_INTERVAL_MS, + TELEGRAM_TYPING_DELAY_MS, + type TelegramPollerStatusSnapshot, + type TelegramTransportTarget +} from '../types' +import { RemoteBindingStore } from '../services/remoteBindingStore' +import { RemoteCommandRouter } from '../services/remoteCommandRouter' +import { chunkTelegramText, createTelegramDraftId } from './telegramOutbound' +import { TelegramApiRequestError, TelegramClient, type TelegramRawUpdate } from './telegramClient' +import { TelegramParser } from './telegramParser' + +const POLL_BACKOFF_MS = [1_000, 2_000, 5_000, 10_000, 30_000] as const + +const sleep = async (ms: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +type TelegramPollerDeps = { + client: TelegramClient + parser: TelegramParser + router: RemoteCommandRouter + bindingStore: RemoteBindingStore + onStatusChange?: (snapshot: TelegramPollerStatusSnapshot) => void +} + +export class TelegramPoller { + private stopRequested = false + private loopPromise: Promise | null = null + private activePollController: AbortController | null = null + private statusSnapshot: TelegramPollerStatusSnapshot = { + state: 'stopped', + lastError: null, + botUser: null + } + + constructor(private readonly deps: TelegramPollerDeps) {} + + async start(): Promise { + if (this.loopPromise) { + return + } + + this.stopRequested = false + this.loopPromise = this.runLoop().finally(() => { + this.loopPromise = null + if (!this.stopRequested && this.statusSnapshot.state !== 'error') { + this.setStatus({ + state: 'stopped' + }) + } + }) + } + + async stop(): Promise { + this.stopRequested = true + this.activePollController?.abort() + const loop = this.loopPromise + if (loop) { + await loop + } + this.setStatus({ + state: 'stopped' + }) + } + + getStatusSnapshot(): TelegramPollerStatusSnapshot { + return { ...this.statusSnapshot } + } + + private async runLoop(): Promise { + let backoffIndex = 0 + + while (!this.stopRequested) { + try { + await this.ensureBotIdentity() + this.setStatus({ + state: 'running', + lastError: null + }) + + const updates = await this.deps.client.getUpdates({ + offset: this.deps.bindingStore.getPollOffset(), + limit: TELEGRAM_REMOTE_POLL_LIMIT, + timeout: TELEGRAM_REMOTE_POLL_TIMEOUT_SEC, + allowedUpdates: ['message'], + signal: this.createPollSignal() + }) + + backoffIndex = 0 + + for (const update of updates) { + if (this.stopRequested) { + return + } + + await this.handleRawUpdate(update) + this.deps.bindingStore.setPollOffset(update.update_id + 1) + } + } catch (error) { + if (this.stopRequested) { + return + } + + const lastError = error instanceof Error ? error.message : String(error) + if (this.isTerminalConflictError(error)) { + this.setStatus({ + state: 'error', + lastError + }) + return + } + + const delay = POLL_BACKOFF_MS[Math.min(backoffIndex, POLL_BACKOFF_MS.length - 1)] + backoffIndex += 1 + this.setStatus({ + state: 'backoff', + lastError + }) + await sleep(delay) + } + } + } + + private createPollSignal(): AbortSignal { + this.activePollController?.abort() + this.activePollController = new AbortController() + return this.activePollController.signal + } + + private async ensureBotIdentity(): Promise { + if (this.statusSnapshot.botUser) { + return + } + + const botUser = await this.deps.client.getMe() + this.setStatus({ + botUser + }) + } + + private async handleRawUpdate(update: TelegramRawUpdate): Promise { + const parsed = this.deps.parser.parseUpdate(update) + if (!parsed) { + return + } + + const target: TelegramTransportTarget = { + chatId: parsed.chatId, + messageThreadId: parsed.messageThreadId + } + const routed = await this.deps.router.handleMessage(parsed) + + for (const reply of routed.replies) { + await this.sendChunkedMessage(target, reply) + } + + if (routed.conversation) { + await this.deliverConversation(target, routed.conversation) + } + } + + private async deliverConversation( + target: TelegramTransportTarget, + execution: NonNullable< + Awaited>['conversation'] + > + ): Promise { + const streamMode = this.deps.bindingStore.getTelegramConfig().streamMode + if (streamMode === 'final') { + await this.deliverFinalConversation(target, execution) + return + } + + try { + await this.deliverDraftConversation(target, execution) + } catch (error) { + console.warn('[TelegramPoller] Draft streaming failed, falling back to final mode:', error) + await this.deliverFinalConversation(target, execution) + } + } + + private async deliverDraftConversation( + target: TelegramTransportTarget, + execution: NonNullable< + Awaited>['conversation'] + > + ): Promise { + const draftId = createTelegramDraftId() + const startedAt = Date.now() + let typingSent = false + let lastDraftText = '' + + while (!this.stopRequested) { + const snapshot = await execution.getSnapshot() + if (snapshot.completed) { + await this.sendChunkedMessage(target, snapshot.text) + return + } + + const draftText = snapshot.text.trim() ? chunkTelegramText(snapshot.text)[0] : '' + if (draftText && draftText !== lastDraftText) { + await this.deps.client.sendMessageDraft(target, draftId, draftText) + lastDraftText = draftText + } else if (!typingSent && Date.now() - startedAt >= TELEGRAM_TYPING_DELAY_MS) { + typingSent = true + await this.sendTyping(target) + } + + await sleep(TELEGRAM_STREAM_POLL_INTERVAL_MS) + } + } + + private async deliverFinalConversation( + target: TelegramTransportTarget, + execution: NonNullable< + Awaited>['conversation'] + > + ): Promise { + const startedAt = Date.now() + let typingSent = false + + while (!this.stopRequested) { + const snapshot = await execution.getSnapshot() + if (snapshot.completed) { + await this.sendChunkedMessage(target, snapshot.text) + return + } + + if (!typingSent && Date.now() - startedAt >= TELEGRAM_TYPING_DELAY_MS) { + typingSent = true + await this.sendTyping(target) + } + + await sleep(TELEGRAM_STREAM_POLL_INTERVAL_MS) + } + } + + private async sendTyping(target: TelegramTransportTarget): Promise { + try { + await this.deps.client.sendChatAction(target, 'typing') + } catch (error) { + console.warn('[TelegramPoller] Failed to send typing action:', error) + } + } + + private async sendChunkedMessage(target: TelegramTransportTarget, text: string): Promise { + for (const chunk of chunkTelegramText(text)) { + await this.deps.client.sendMessage(target, chunk) + } + } + + private isTerminalConflictError(error: unknown): boolean { + if (error instanceof TelegramApiRequestError) { + return error.code === 409 + } + + if (!(error instanceof Error)) { + return false + } + + return error.message.includes('terminated by other getUpdates request') + } + + private setStatus( + patch: Partial & { + state?: TelegramPollerStatusSnapshot['state'] + } + ): void { + this.statusSnapshot = { + ...this.statusSnapshot, + ...patch + } + this.deps.onStatusChange?.(this.getStatusSnapshot()) + } +} diff --git a/src/main/presenter/remoteControlPresenter/types.ts b/src/main/presenter/remoteControlPresenter/types.ts new file mode 100644 index 000000000..145b2b250 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/types.ts @@ -0,0 +1,201 @@ +import { z } from 'zod' +import type { HookEventName } from '@shared/hooksNotifications' +import type { + TelegramRemoteRuntimeState, + TelegramRemoteSettings, + TelegramRemoteStatus, + TelegramStreamMode +} from '@shared/presenter' + +export const REMOTE_CONTROL_SETTING_KEY = 'remoteControl' +export const TELEGRAM_REMOTE_POLL_LIMIT = 20 +export const TELEGRAM_REMOTE_POLL_TIMEOUT_SEC = 30 +export const TELEGRAM_OUTBOUND_TEXT_LIMIT = 4096 +export const TELEGRAM_PAIR_CODE_TTL_MS = 10 * 60 * 1000 +export const TELEGRAM_TYPING_DELAY_MS = 800 +export const TELEGRAM_STREAM_POLL_INTERVAL_MS = 450 +export const TELEGRAM_STREAM_START_TIMEOUT_MS = 8_000 +export const TELEGRAM_PRIVATE_THREAD_DEFAULT = 0 +export const TELEGRAM_RECENT_SESSION_LIMIT = 10 + +export type TelegramEndpointBinding = { + sessionId: string + updatedAt: number +} + +export type TelegramPairingState = { + code: string | null + expiresAt: number | null +} + +export interface TelegramRemoteRuntimeConfig { + enabled: boolean + allowlist: number[] + streamMode: TelegramStreamMode + pollOffset: number + pairing: TelegramPairingState + bindings: Record +} + +export interface RemoteControlConfig { + telegram: TelegramRemoteRuntimeConfig +} + +export interface TelegramInboundMessage { + updateId: number + chatId: number + messageThreadId: number + messageId: number + chatType: string + fromId: number | null + text: string + command: { + name: string + args: string + } | null +} + +export interface TelegramPollerStatusSnapshot { + state: TelegramRemoteRuntimeState + lastError: string | null + botUser: TelegramRemoteStatus['botUser'] +} + +export interface TelegramTransportTarget { + chatId: number + messageThreadId: number +} + +export interface TelegramRemoteHookSettingsInput { + enabled: boolean + chatId: string + threadId?: string + events: HookEventName[] +} + +export const createDefaultRemoteControlConfig = (): RemoteControlConfig => ({ + telegram: { + enabled: false, + allowlist: [], + streamMode: 'draft', + pollOffset: 0, + pairing: { + code: null, + expiresAt: null + }, + bindings: {} + } +}) + +const TelegramEndpointBindingSchema = z + .object({ + sessionId: z.string().min(1), + updatedAt: z.number().int().nonnegative().optional() + }) + .strip() + +const TelegramPairingStateSchema = z + .object({ + code: z.string().nullable().optional(), + expiresAt: z.number().int().nonnegative().nullable().optional() + }) + .strip() + +const TelegramRemoteRuntimeConfigSchema = z + .object({ + enabled: z.boolean().optional(), + allowlist: z.array(z.union([z.number(), z.string()])).optional(), + streamMode: z.enum(['draft', 'final']).optional(), + pollOffset: z.number().int().nonnegative().optional(), + pairing: TelegramPairingStateSchema.optional(), + bindings: z.record(z.string(), TelegramEndpointBindingSchema).optional() + }) + .strip() + +const RemoteControlConfigSchema = z + .object({ + telegram: TelegramRemoteRuntimeConfigSchema.optional() + }) + .strip() + +export const normalizeTelegramUserIds = (input: Array | undefined): number[] => { + const normalized = new Set() + for (const value of input ?? []) { + const parsed = + typeof value === 'number' + ? value + : typeof value === 'string' && value.trim() + ? Number.parseInt(value.trim(), 10) + : Number.NaN + if (Number.isInteger(parsed) && parsed > 0) { + normalized.add(parsed) + } + } + return Array.from(normalized).sort((left, right) => left - right) +} + +export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfig => { + const defaults = createDefaultRemoteControlConfig() + const parsed = RemoteControlConfigSchema.safeParse(input) + if (!parsed.success) { + return defaults + } + + const telegram = parsed.data.telegram ?? {} + const bindings: Record = {} + for (const [endpointKey, binding] of Object.entries(telegram.bindings ?? {})) { + if (!binding?.sessionId?.trim()) { + continue + } + bindings[endpointKey] = { + sessionId: binding.sessionId.trim(), + updatedAt: binding.updatedAt ?? Date.now() + } + } + + return { + telegram: { + enabled: Boolean(telegram.enabled), + allowlist: normalizeTelegramUserIds(telegram.allowlist), + streamMode: telegram.streamMode ?? defaults.telegram.streamMode, + pollOffset: + typeof telegram.pollOffset === 'number' && telegram.pollOffset >= 0 + ? telegram.pollOffset + : defaults.telegram.pollOffset, + pairing: { + code: telegram.pairing?.code?.trim() || null, + expiresAt: + typeof telegram.pairing?.expiresAt === 'number' ? telegram.pairing.expiresAt : null + }, + bindings + } + } +} + +export const buildTelegramEndpointKey = (chatId: number, messageThreadId: number): string => + `telegram:${chatId}:${messageThreadId || TELEGRAM_PRIVATE_THREAD_DEFAULT}` + +export const createPairCode = (): { code: string; expiresAt: number } => { + const code = `${Math.floor(100000 + Math.random() * 900000)}` + return { + code, + expiresAt: Date.now() + TELEGRAM_PAIR_CODE_TTL_MS + } +} + +export const normalizeTelegramSettingsInput = ( + input: TelegramRemoteSettings +): TelegramRemoteSettings => ({ + botToken: input.botToken?.trim() ?? '', + remoteEnabled: Boolean(input.remoteEnabled), + allowedUserIds: normalizeTelegramUserIds(input.allowedUserIds), + streamMode: input.streamMode === 'final' ? 'final' : 'draft', + pairCode: input.pairCode?.trim() || null, + pairCodeExpiresAt: typeof input.pairCodeExpiresAt === 'number' ? input.pairCodeExpiresAt : null, + hookNotifications: { + enabled: Boolean(input.hookNotifications.enabled), + chatId: input.hookNotifications.chatId?.trim() ?? '', + threadId: input.hookNotifications.threadId?.trim() || undefined, + events: Array.from(new Set(input.hookNotifications.events ?? [])) + } +}) diff --git a/src/renderer/settings/components/NotificationsHooksSettings.vue b/src/renderer/settings/components/NotificationsHooksSettings.vue index 7e835c1a7..75536c750 100644 --- a/src/renderer/settings/components/NotificationsHooksSettings.vue +++ b/src/renderer/settings/components/NotificationsHooksSettings.vue @@ -20,171 +20,6 @@ -
-
-
-
- {{ t('settings.notificationsHooks.telegram.title') }} -
-

- {{ t('settings.notificationsHooks.telegram.description') }} -

-
-
- - -
-
- - -
-
- -
- - -
-
- -
-
- - -
-
- - -
-
- -
- -
- -
-
- -
- -
- -
-
- - {{ - telegramTestResult.success - ? t('settings.notificationsHooks.test.success') - : t('settings.notificationsHooks.test.failed') - }} - - - {{ - t('settings.notificationsHooks.test.duration', { - ms: telegramTestResult.durationMs - }) - }} - - - {{ - t('settings.notificationsHooks.test.statusCode', { - code: telegramTestResult.statusCode - }) - }} - - - {{ - t('settings.notificationsHooks.test.retryAfter', { - ms: telegramTestResult.retryAfterMs - }) - }} - -
-
- {{ telegramTestResult.error }} -
-
-
-
-
-
-
@@ -618,18 +453,14 @@ const isLoading = ref(false) const isSaving = ref(false) let pendingSave = false -const telegramOpen = ref(false) const discordOpen = ref(false) const confirmoOpen = ref(false) const commandsOpen = ref(false) -const showTelegramToken = ref(false) const showDiscordWebhook = ref(false) -const telegramTesting = ref(false) const discordTesting = ref(false) const confirmoTesting = ref(false) -const telegramTestResult = ref(null) const discordTestResult = ref(null) const confirmoTestResult = ref(null) const confirmoStatus = ref<{ available: boolean; path: string } | null>(null) @@ -697,16 +528,14 @@ const persistConfig = async () => { } } -const updateChannelEnabled = (channel: 'telegram' | 'discord' | 'confirmo', value: boolean) => { +const updateChannelEnabled = (channel: 'discord' | 'confirmo', value: boolean) => { if (!config.value) return if (channel === 'confirmo' && !confirmoAvailable.value) return const nextEnabled = Boolean(value) const wasEnabled = config.value[channel].enabled config.value[channel].enabled = nextEnabled if (!wasEnabled && nextEnabled) { - if (channel === 'telegram') { - telegramOpen.value = true - } else if (channel === 'discord') { + if (channel === 'discord') { discordOpen.value = true } else if (channel === 'confirmo') { confirmoOpen.value = true @@ -727,7 +556,7 @@ const updateCommandsEnabled = (value: boolean) => { } const updateChannelEvent = ( - channel: 'telegram' | 'discord' | 'confirmo', + channel: 'discord' | 'confirmo', eventName: HookEventName, checked: boolean ) => { @@ -749,25 +578,6 @@ const updateCommandEnabled = (eventName: HookEventName, value: boolean) => { persistConfig() } -const runTelegramTest = async () => { - if (telegramTesting.value) return - await persistConfig() - telegramTesting.value = true - telegramTestResult.value = null - try { - const result = await configPresenter.testTelegramNotification() - telegramTestResult.value = result - } catch (error) { - telegramTestResult.value = { - success: false, - durationMs: 0, - error: error instanceof Error ? error.message : String(error) - } - } finally { - telegramTesting.value = false - } -} - const runDiscordTest = async () => { if (discordTesting.value) return await persistConfig() diff --git a/src/renderer/settings/components/RemoteSettings.vue b/src/renderer/settings/components/RemoteSettings.vue new file mode 100644 index 000000000..88e13df77 --- /dev/null +++ b/src/renderer/settings/components/RemoteSettings.vue @@ -0,0 +1,553 @@ + + + diff --git a/src/renderer/settings/main.ts b/src/renderer/settings/main.ts index 3423cdef3..7418be142 100644 --- a/src/renderer/settings/main.ts +++ b/src/renderer/settings/main.ts @@ -92,6 +92,16 @@ const router = createRouter({ position: 5 } }, + { + path: '/remote', + name: 'settings-remote', + component: () => import('./components/RemoteSettings.vue'), + meta: { + titleKey: 'routes.settings-remote', + icon: 'lucide:smartphone', + position: 5.25 + } + }, { path: '/notifications-hooks', name: 'settings-notifications-hooks', diff --git a/src/renderer/src/i18n/da-DK/routes.json b/src/renderer/src/i18n/da-DK/routes.json index 265f700f6..8f9a40fdf 100644 --- a/src/renderer/src/i18n/da-DK/routes.json +++ b/src/renderer/src/i18n/da-DK/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", "settings-dashboard": "Dashboard", - "settings-environments": "Miljøer" + "settings-environments": "Miljøer", + "settings-remote": "Remote" } diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index b41254416..55a693280 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "Kunne ikke åbne mappe" } + }, + "remote": { + "title": "Remote", + "description": "Configure Telegram remote control and Telegram hook notifications.", + "telegram": { + "title": "Telegram", + "description": "Manage one Telegram bot for remote control and hook delivery.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token" + }, + "remoteControl": { + "title": "Remote Control", + "description": "Allow authorized Telegram users to continue DeepChat sessions remotely.", + "streamMode": "Stream Mode", + "streamModeDraft": "Draft streaming", + "streamModeFinal": "Final only", + "allowedUserIds": "Allowed User IDs", + "allowedUserIdsPlaceholder": "e.g. 123456789, 987654321", + "pairCode": "Pair Code", + "noPairCode": "No active pair code", + "generatePairCode": "Generate", + "clearPairCode": "Clear", + "pairCodeExpiresAt": "Expires at {time}", + "clearBindings": "Clear Bindings", + "clearBindingsResult": "{count} binding(s) cleared." + }, + "hooks": { + "title": "Telegram Hooks", + "description": "Reuse the same Telegram bot for hook notifications.", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "status": { + "title": "Runtime Status", + "botUser": "Bot user: {username} ({id})", + "bindings": "Bindings: {count}, poll offset: {pollOffset}", + "states": { + "disabled": "Disabled", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "backoff": "Retrying after failure", + "error": "Error" + } + } } } diff --git a/src/renderer/src/i18n/en-US/routes.json b/src/renderer/src/i18n/en-US/routes.json index 5fc3502cc..524ccc1be 100644 --- a/src/renderer/src/i18n/en-US/routes.json +++ b/src/renderer/src/i18n/en-US/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", "settings-dashboard": "Dashboard", - "settings-environments": "Environments" + "settings-environments": "Environments", + "settings-remote": "Remote" } diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 1ca5ba1aa..bdd9b843d 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "Failed to Open Directory" } + }, + "remote": { + "title": "Remote", + "description": "Configure Telegram remote control and Telegram hook notifications.", + "telegram": { + "title": "Telegram", + "description": "Manage one Telegram bot for remote control and hook delivery.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token" + }, + "remoteControl": { + "title": "Remote Control", + "description": "Allow authorized Telegram users to continue DeepChat sessions remotely.", + "streamMode": "Stream Mode", + "streamModeDraft": "Draft streaming", + "streamModeFinal": "Final only", + "allowedUserIds": "Allowed User IDs", + "allowedUserIdsPlaceholder": "e.g. 123456789, 987654321", + "pairCode": "Pair Code", + "noPairCode": "No active pair code", + "generatePairCode": "Generate", + "clearPairCode": "Clear", + "pairCodeExpiresAt": "Expires at {time}", + "clearBindings": "Clear Bindings", + "clearBindingsResult": "{count} binding(s) cleared." + }, + "hooks": { + "title": "Telegram Hooks", + "description": "Reuse the same Telegram bot for hook notifications.", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "status": { + "title": "Runtime Status", + "botUser": "Bot user: {username} ({id})", + "bindings": "Bindings: {count}, poll offset: {pollOffset}", + "states": { + "disabled": "Disabled", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "backoff": "Retrying after failure", + "error": "Error" + } + } } } diff --git a/src/renderer/src/i18n/fa-IR/routes.json b/src/renderer/src/i18n/fa-IR/routes.json index 04ac33823..82fc70467 100644 --- a/src/renderer/src/i18n/fa-IR/routes.json +++ b/src/renderer/src/i18n/fa-IR/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills", "settings-notifications-hooks": "هوک‌ها", "settings-dashboard": "داشبورد", - "settings-environments": "محیط‌ها" + "settings-environments": "محیط‌ها", + "settings-remote": "Remote" } diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 50724723a..be3faf2bd 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "باز کردن پوشه ناموفق بود" } + }, + "remote": { + "title": "Remote", + "description": "Configure Telegram remote control and Telegram hook notifications.", + "telegram": { + "title": "Telegram", + "description": "Manage one Telegram bot for remote control and hook delivery.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token" + }, + "remoteControl": { + "title": "Remote Control", + "description": "Allow authorized Telegram users to continue DeepChat sessions remotely.", + "streamMode": "Stream Mode", + "streamModeDraft": "Draft streaming", + "streamModeFinal": "Final only", + "allowedUserIds": "Allowed User IDs", + "allowedUserIdsPlaceholder": "e.g. 123456789, 987654321", + "pairCode": "Pair Code", + "noPairCode": "No active pair code", + "generatePairCode": "Generate", + "clearPairCode": "Clear", + "pairCodeExpiresAt": "Expires at {time}", + "clearBindings": "Clear Bindings", + "clearBindingsResult": "{count} binding(s) cleared." + }, + "hooks": { + "title": "Telegram Hooks", + "description": "Reuse the same Telegram bot for hook notifications.", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "status": { + "title": "Runtime Status", + "botUser": "Bot user: {username} ({id})", + "bindings": "Bindings: {count}, poll offset: {pollOffset}", + "states": { + "disabled": "Disabled", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "backoff": "Retrying after failure", + "error": "Error" + } + } } } diff --git a/src/renderer/src/i18n/fr-FR/routes.json b/src/renderer/src/i18n/fr-FR/routes.json index 7a33d911d..0e1f86ea2 100644 --- a/src/renderer/src/i18n/fr-FR/routes.json +++ b/src/renderer/src/i18n/fr-FR/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", "settings-dashboard": "Tableau de bord", - "settings-environments": "Environnements" + "settings-environments": "Environnements", + "settings-remote": "Remote" } diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 96adab2bd..1209c1e46 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "Échec de l'ouverture du dossier" } + }, + "remote": { + "title": "Remote", + "description": "Configure Telegram remote control and Telegram hook notifications.", + "telegram": { + "title": "Telegram", + "description": "Manage one Telegram bot for remote control and hook delivery.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token" + }, + "remoteControl": { + "title": "Remote Control", + "description": "Allow authorized Telegram users to continue DeepChat sessions remotely.", + "streamMode": "Stream Mode", + "streamModeDraft": "Draft streaming", + "streamModeFinal": "Final only", + "allowedUserIds": "Allowed User IDs", + "allowedUserIdsPlaceholder": "e.g. 123456789, 987654321", + "pairCode": "Pair Code", + "noPairCode": "No active pair code", + "generatePairCode": "Generate", + "clearPairCode": "Clear", + "pairCodeExpiresAt": "Expires at {time}", + "clearBindings": "Clear Bindings", + "clearBindingsResult": "{count} binding(s) cleared." + }, + "hooks": { + "title": "Telegram Hooks", + "description": "Reuse the same Telegram bot for hook notifications.", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "status": { + "title": "Runtime Status", + "botUser": "Bot user: {username} ({id})", + "bindings": "Bindings: {count}, poll offset: {pollOffset}", + "states": { + "disabled": "Disabled", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "backoff": "Retrying after failure", + "error": "Error" + } + } } } diff --git a/src/renderer/src/i18n/he-IL/routes.json b/src/renderer/src/i18n/he-IL/routes.json index 7932e17b7..a8f694aa5 100644 --- a/src/renderer/src/i18n/he-IL/routes.json +++ b/src/renderer/src/i18n/he-IL/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", "settings-dashboard": "Dashboard", - "settings-environments": "סביבות" + "settings-environments": "סביבות", + "settings-remote": "Remote" } diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 5a558087e..ad0207679 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "פתיחת התיקייה נכשלה" } + }, + "remote": { + "title": "Remote", + "description": "Configure Telegram remote control and Telegram hook notifications.", + "telegram": { + "title": "Telegram", + "description": "Manage one Telegram bot for remote control and hook delivery.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token" + }, + "remoteControl": { + "title": "Remote Control", + "description": "Allow authorized Telegram users to continue DeepChat sessions remotely.", + "streamMode": "Stream Mode", + "streamModeDraft": "Draft streaming", + "streamModeFinal": "Final only", + "allowedUserIds": "Allowed User IDs", + "allowedUserIdsPlaceholder": "e.g. 123456789, 987654321", + "pairCode": "Pair Code", + "noPairCode": "No active pair code", + "generatePairCode": "Generate", + "clearPairCode": "Clear", + "pairCodeExpiresAt": "Expires at {time}", + "clearBindings": "Clear Bindings", + "clearBindingsResult": "{count} binding(s) cleared." + }, + "hooks": { + "title": "Telegram Hooks", + "description": "Reuse the same Telegram bot for hook notifications.", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "status": { + "title": "Runtime Status", + "botUser": "Bot user: {username} ({id})", + "bindings": "Bindings: {count}, poll offset: {pollOffset}", + "states": { + "disabled": "Disabled", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "backoff": "Retrying after failure", + "error": "Error" + } + } } } diff --git a/src/renderer/src/i18n/ja-JP/routes.json b/src/renderer/src/i18n/ja-JP/routes.json index e3dfebf9f..aa919ecf0 100644 --- a/src/renderer/src/i18n/ja-JP/routes.json +++ b/src/renderer/src/i18n/ja-JP/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills", "settings-notifications-hooks": "フック", "settings-dashboard": "Dashboard", - "settings-environments": "環境" + "settings-environments": "環境", + "settings-remote": "Remote" } diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index be8127aed..7524fb692 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "ディレクトリを開けませんでした" } + }, + "remote": { + "title": "Remote", + "description": "Configure Telegram remote control and Telegram hook notifications.", + "telegram": { + "title": "Telegram", + "description": "Manage one Telegram bot for remote control and hook delivery.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token" + }, + "remoteControl": { + "title": "Remote Control", + "description": "Allow authorized Telegram users to continue DeepChat sessions remotely.", + "streamMode": "Stream Mode", + "streamModeDraft": "Draft streaming", + "streamModeFinal": "Final only", + "allowedUserIds": "Allowed User IDs", + "allowedUserIdsPlaceholder": "e.g. 123456789, 987654321", + "pairCode": "Pair Code", + "noPairCode": "No active pair code", + "generatePairCode": "Generate", + "clearPairCode": "Clear", + "pairCodeExpiresAt": "Expires at {time}", + "clearBindings": "Clear Bindings", + "clearBindingsResult": "{count} binding(s) cleared." + }, + "hooks": { + "title": "Telegram Hooks", + "description": "Reuse the same Telegram bot for hook notifications.", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "status": { + "title": "Runtime Status", + "botUser": "Bot user: {username} ({id})", + "bindings": "Bindings: {count}, poll offset: {pollOffset}", + "states": { + "disabled": "Disabled", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "backoff": "Retrying after failure", + "error": "Error" + } + } } } diff --git a/src/renderer/src/i18n/ko-KR/routes.json b/src/renderer/src/i18n/ko-KR/routes.json index 1f038823f..171dae3a0 100644 --- a/src/renderer/src/i18n/ko-KR/routes.json +++ b/src/renderer/src/i18n/ko-KR/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills", "settings-notifications-hooks": "훅", "settings-dashboard": "Dashboard", - "settings-environments": "환경" + "settings-environments": "환경", + "settings-remote": "Remote" } diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 01d2bcc21..612339cf4 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "디렉터리를 열지 못했습니다" } + }, + "remote": { + "title": "Remote", + "description": "Configure Telegram remote control and Telegram hook notifications.", + "telegram": { + "title": "Telegram", + "description": "Manage one Telegram bot for remote control and hook delivery.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token" + }, + "remoteControl": { + "title": "Remote Control", + "description": "Allow authorized Telegram users to continue DeepChat sessions remotely.", + "streamMode": "Stream Mode", + "streamModeDraft": "Draft streaming", + "streamModeFinal": "Final only", + "allowedUserIds": "Allowed User IDs", + "allowedUserIdsPlaceholder": "e.g. 123456789, 987654321", + "pairCode": "Pair Code", + "noPairCode": "No active pair code", + "generatePairCode": "Generate", + "clearPairCode": "Clear", + "pairCodeExpiresAt": "Expires at {time}", + "clearBindings": "Clear Bindings", + "clearBindingsResult": "{count} binding(s) cleared." + }, + "hooks": { + "title": "Telegram Hooks", + "description": "Reuse the same Telegram bot for hook notifications.", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "status": { + "title": "Runtime Status", + "botUser": "Bot user: {username} ({id})", + "bindings": "Bindings: {count}, poll offset: {pollOffset}", + "states": { + "disabled": "Disabled", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "backoff": "Retrying after failure", + "error": "Error" + } + } } } diff --git a/src/renderer/src/i18n/pt-BR/routes.json b/src/renderer/src/i18n/pt-BR/routes.json index 8647fa8a1..2ba961ec8 100644 --- a/src/renderer/src/i18n/pt-BR/routes.json +++ b/src/renderer/src/i18n/pt-BR/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills", "settings-notifications-hooks": "Hooks", "settings-dashboard": "Dashboard", - "settings-environments": "Ambientes" + "settings-environments": "Ambientes", + "settings-remote": "Remote" } diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 9063f71b1..ba3bba1a4 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "Falha ao abrir diretório" } + }, + "remote": { + "title": "Remote", + "description": "Configure Telegram remote control and Telegram hook notifications.", + "telegram": { + "title": "Telegram", + "description": "Manage one Telegram bot for remote control and hook delivery.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token" + }, + "remoteControl": { + "title": "Remote Control", + "description": "Allow authorized Telegram users to continue DeepChat sessions remotely.", + "streamMode": "Stream Mode", + "streamModeDraft": "Draft streaming", + "streamModeFinal": "Final only", + "allowedUserIds": "Allowed User IDs", + "allowedUserIdsPlaceholder": "e.g. 123456789, 987654321", + "pairCode": "Pair Code", + "noPairCode": "No active pair code", + "generatePairCode": "Generate", + "clearPairCode": "Clear", + "pairCodeExpiresAt": "Expires at {time}", + "clearBindings": "Clear Bindings", + "clearBindingsResult": "{count} binding(s) cleared." + }, + "hooks": { + "title": "Telegram Hooks", + "description": "Reuse the same Telegram bot for hook notifications.", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "status": { + "title": "Runtime Status", + "botUser": "Bot user: {username} ({id})", + "bindings": "Bindings: {count}, poll offset: {pollOffset}", + "states": { + "disabled": "Disabled", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "backoff": "Retrying after failure", + "error": "Error" + } + } } } diff --git a/src/renderer/src/i18n/ru-RU/routes.json b/src/renderer/src/i18n/ru-RU/routes.json index f6c842fa2..43dce1710 100644 --- a/src/renderer/src/i18n/ru-RU/routes.json +++ b/src/renderer/src/i18n/ru-RU/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills", "settings-notifications-hooks": "Хуки", "settings-dashboard": "Панель", - "settings-environments": "Окружения" + "settings-environments": "Окружения", + "settings-remote": "Remote" } diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index f5ef3f0b2..881a2b3f9 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "Не удалось открыть каталог" } + }, + "remote": { + "title": "Remote", + "description": "Configure Telegram remote control and Telegram hook notifications.", + "telegram": { + "title": "Telegram", + "description": "Manage one Telegram bot for remote control and hook delivery.", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram bot token" + }, + "remoteControl": { + "title": "Remote Control", + "description": "Allow authorized Telegram users to continue DeepChat sessions remotely.", + "streamMode": "Stream Mode", + "streamModeDraft": "Draft streaming", + "streamModeFinal": "Final only", + "allowedUserIds": "Allowed User IDs", + "allowedUserIdsPlaceholder": "e.g. 123456789, 987654321", + "pairCode": "Pair Code", + "noPairCode": "No active pair code", + "generatePairCode": "Generate", + "clearPairCode": "Clear", + "pairCodeExpiresAt": "Expires at {time}", + "clearBindings": "Clear Bindings", + "clearBindingsResult": "{count} binding(s) cleared." + }, + "hooks": { + "title": "Telegram Hooks", + "description": "Reuse the same Telegram bot for hook notifications.", + "chatId": "Chat ID", + "chatIdPlaceholder": "e.g. 123456789", + "threadId": "Thread ID (optional)", + "threadIdPlaceholder": "Optional thread ID" + }, + "status": { + "title": "Runtime Status", + "botUser": "Bot user: {username} ({id})", + "bindings": "Bindings: {count}, poll offset: {pollOffset}", + "states": { + "disabled": "Disabled", + "stopped": "Stopped", + "starting": "Starting", + "running": "Running", + "backoff": "Retrying after failure", + "error": "Error" + } + } } } diff --git a/src/renderer/src/i18n/zh-CN/routes.json b/src/renderer/src/i18n/zh-CN/routes.json index 4c79bd49c..4026b06f2 100644 --- a/src/renderer/src/i18n/zh-CN/routes.json +++ b/src/renderer/src/i18n/zh-CN/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills设置", "settings-notifications-hooks": "Hooks", "settings-dashboard": "数据看板", - "settings-environments": "目录环境" + "settings-environments": "目录环境", + "settings-remote": "远程" } diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index efa223409..f62a650ec 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "打开目录失败" } + }, + "remote": { + "title": "远程", + "description": "配置 Telegram 远程控制和 Telegram Hook 通知。", + "telegram": { + "title": "Telegram", + "description": "用同一个 Telegram Bot 管理远程控制和 Hook 投递。", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram Bot Token" + }, + "remoteControl": { + "title": "远程控制", + "description": "允许已授权的 Telegram 用户远程继续 DeepChat 会话。", + "streamMode": "流式模式", + "streamModeDraft": "草稿流式", + "streamModeFinal": "仅最终结果", + "allowedUserIds": "允许的用户 ID", + "allowedUserIdsPlaceholder": "例如 123456789, 987654321", + "pairCode": "配对码", + "noPairCode": "当前没有有效配对码", + "generatePairCode": "生成", + "clearPairCode": "清除", + "pairCodeExpiresAt": "过期时间:{time}", + "clearBindings": "清除绑定", + "clearBindingsResult": "已清除 {count} 个绑定。" + }, + "hooks": { + "title": "Telegram Hooks", + "description": "复用同一个 Telegram Bot 发送 Hook 通知。", + "chatId": "Chat ID", + "chatIdPlaceholder": "例如 123456789", + "threadId": "Thread ID(可选)", + "threadIdPlaceholder": "可选的 Thread ID" + }, + "status": { + "title": "运行状态", + "botUser": "Bot 用户:{username} ({id})", + "bindings": "绑定数:{count},轮询偏移:{pollOffset}", + "states": { + "disabled": "已禁用", + "stopped": "已停止", + "starting": "启动中", + "running": "运行中", + "backoff": "失败后重试中", + "error": "错误" + } + } } } diff --git a/src/renderer/src/i18n/zh-HK/routes.json b/src/renderer/src/i18n/zh-HK/routes.json index 307227055..ddc01e1b7 100644 --- a/src/renderer/src/i18n/zh-HK/routes.json +++ b/src/renderer/src/i18n/zh-HK/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills設置", "settings-notifications-hooks": "Hooks", "settings-dashboard": "資料看板", - "settings-environments": "目錄環境" + "settings-environments": "目錄環境", + "settings-remote": "遠端" } diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 9122edbef..978b135f8 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "打開目錄失敗" } + }, + "remote": { + "title": "遠端", + "description": "設定 Telegram 遠端控制與 Telegram Hook 通知。", + "telegram": { + "title": "Telegram", + "description": "使用同一個 Telegram Bot 管理遠端控制與 Hook 投遞。", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram Bot Token" + }, + "remoteControl": { + "title": "遠端控制", + "description": "允許已授權的 Telegram 使用者遠端繼續 DeepChat 會話。", + "streamMode": "串流模式", + "streamModeDraft": "草稿串流", + "streamModeFinal": "僅最終結果", + "allowedUserIds": "允許的使用者 ID", + "allowedUserIdsPlaceholder": "例如 123456789, 987654321", + "pairCode": "配對碼", + "noPairCode": "目前沒有有效配對碼", + "generatePairCode": "產生", + "clearPairCode": "清除", + "pairCodeExpiresAt": "到期時間:{time}", + "clearBindings": "清除綁定", + "clearBindingsResult": "已清除 {count} 個綁定。" + }, + "hooks": { + "title": "Telegram Hooks", + "description": "重用同一個 Telegram Bot 傳送 Hook 通知。", + "chatId": "Chat ID", + "chatIdPlaceholder": "例如 123456789", + "threadId": "Thread ID(可選)", + "threadIdPlaceholder": "可選的 Thread ID" + }, + "status": { + "title": "執行狀態", + "botUser": "Bot 使用者:{username} ({id})", + "bindings": "綁定數:{count},輪詢偏移:{pollOffset}", + "states": { + "disabled": "已停用", + "stopped": "已停止", + "starting": "啟動中", + "running": "執行中", + "backoff": "失敗後重試中", + "error": "錯誤" + } + } } } diff --git a/src/renderer/src/i18n/zh-TW/routes.json b/src/renderer/src/i18n/zh-TW/routes.json index 9815ef690..13fb254fe 100644 --- a/src/renderer/src/i18n/zh-TW/routes.json +++ b/src/renderer/src/i18n/zh-TW/routes.json @@ -17,5 +17,6 @@ "settings-skills": "Skills設定", "settings-notifications-hooks": "Hooks", "settings-dashboard": "資料看板", - "settings-environments": "目錄環境" + "settings-environments": "目錄環境", + "settings-remote": "遠端" } diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index c45d8ca78..a7a142d47 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -1468,5 +1468,52 @@ "errors": { "openTitle": "開啟目錄失敗" } + }, + "remote": { + "title": "遠端", + "description": "設定 Telegram 遠端控制與 Telegram Hook 通知。", + "telegram": { + "title": "Telegram", + "description": "使用同一個 Telegram Bot 管理遠端控制與 Hook 投遞。", + "botToken": "Bot Token", + "botTokenPlaceholder": "Telegram Bot Token" + }, + "remoteControl": { + "title": "遠端控制", + "description": "允許已授權的 Telegram 使用者遠端繼續 DeepChat 會話。", + "streamMode": "串流模式", + "streamModeDraft": "草稿串流", + "streamModeFinal": "僅最終結果", + "allowedUserIds": "允許的使用者 ID", + "allowedUserIdsPlaceholder": "例如 123456789, 987654321", + "pairCode": "配對碼", + "noPairCode": "目前沒有有效配對碼", + "generatePairCode": "產生", + "clearPairCode": "清除", + "pairCodeExpiresAt": "到期時間:{time}", + "clearBindings": "清除綁定", + "clearBindingsResult": "已清除 {count} 個綁定。" + }, + "hooks": { + "title": "Telegram Hooks", + "description": "重用同一個 Telegram Bot 傳送 Hook 通知。", + "chatId": "Chat ID", + "chatIdPlaceholder": "例如 123456789", + "threadId": "Thread ID(可選)", + "threadIdPlaceholder": "可選的 Thread ID" + }, + "status": { + "title": "執行狀態", + "botUser": "Bot 使用者:{username} ({id})", + "bindings": "綁定數:{count},輪詢偏移:{pollOffset}", + "states": { + "disabled": "已停用", + "stopped": "已停止", + "starting": "啟動中", + "running": "執行中", + "backoff": "失敗後重試中", + "error": "錯誤" + } + } } } diff --git a/src/shared/types/agent-interface.d.ts b/src/shared/types/agent-interface.d.ts index d6df22a65..c1e8c43e4 100644 --- a/src/shared/types/agent-interface.d.ts +++ b/src/shared/types/agent-interface.d.ts @@ -480,6 +480,18 @@ export interface CreateSessionInput { generationSettings?: Partial } +export interface CreateDetachedSessionInput { + agentId?: string + title?: string + projectDir?: string + providerId?: string + modelId?: string + permissionMode?: PermissionMode + activeSkills?: string[] + disabledAgentTools?: string[] + generationSettings?: Partial +} + // ---- Project Types ---- export interface Project { diff --git a/src/shared/types/presenters/index.d.ts b/src/shared/types/presenters/index.d.ts index 1edaca788..3168817f9 100644 --- a/src/shared/types/presenters/index.d.ts +++ b/src/shared/types/presenters/index.d.ts @@ -82,6 +82,14 @@ export type { IToolPresenter } from './tool.presenter' // New agent architecture types export type { INewAgentPresenter } from './new-agent.presenter' export type { IProjectPresenter } from './project.presenter' +export type { + IRemoteControlPresenter, + TelegramHookSettings, + TelegramRemoteRuntimeState, + TelegramRemoteSettings, + TelegramRemoteStatus, + TelegramStreamMode +} from './remote-control.presenter' // Re-export legacy types temporarily for compatibility export * from './legacy.presenters' diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index deb138e6d..c7ebd218b 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -21,6 +21,7 @@ import type { ISkillPresenter } from '../skill' import type { ISkillSyncPresenter } from '../skillSync' import type { INewAgentPresenter } from './new-agent.presenter' import type { IProjectPresenter } from './project.presenter' +import type { IRemoteControlPresenter } from './remote-control.presenter' import type { BrowserPageInfo, DownloadInfo, ScreenshotOptions, YoBrowserStatus } from '../browser' export type SQLITE_MESSAGE = { @@ -474,6 +475,7 @@ export interface IPresenter { skillSyncPresenter: ISkillSyncPresenter newAgentPresenter: INewAgentPresenter projectPresenter: IProjectPresenter + remoteControlPresenter: IRemoteControlPresenter init(): void destroy(): void } diff --git a/src/shared/types/presenters/new-agent.presenter.d.ts b/src/shared/types/presenters/new-agent.presenter.d.ts index 9e35bf0ba..a9d65dbde 100644 --- a/src/shared/types/presenters/new-agent.presenter.d.ts +++ b/src/shared/types/presenters/new-agent.presenter.d.ts @@ -1,6 +1,7 @@ import type { Agent, CreateSessionInput, + CreateDetachedSessionInput, SessionWithState, ChatMessageRecord, MessageTraceRecord, @@ -19,6 +20,7 @@ import type { SearchResult } from './thread.presenter' export interface INewAgentPresenter { createSession(input: CreateSessionInput, webContentsId: number): Promise + createDetachedSession(input: CreateDetachedSessionInput): Promise ensureAcpDraftSession(input: { agentId: string projectDir: string diff --git a/src/shared/types/presenters/remote-control.presenter.d.ts b/src/shared/types/presenters/remote-control.presenter.d.ts new file mode 100644 index 000000000..d10297e7c --- /dev/null +++ b/src/shared/types/presenters/remote-control.presenter.d.ts @@ -0,0 +1,49 @@ +import type { HookEventName, HookTestResult } from '../../hooksNotifications' + +export type TelegramStreamMode = 'draft' | 'final' +export type TelegramRemoteRuntimeState = + | 'disabled' + | 'stopped' + | 'starting' + | 'running' + | 'backoff' + | 'error' + +export interface TelegramHookSettings { + enabled: boolean + chatId: string + threadId?: string + events: HookEventName[] +} + +export interface TelegramRemoteSettings { + botToken: string + remoteEnabled: boolean + allowedUserIds: number[] + streamMode: TelegramStreamMode + pairCode: string | null + pairCodeExpiresAt: number | null + hookNotifications: TelegramHookSettings +} + +export interface TelegramRemoteStatus { + state: TelegramRemoteRuntimeState + pollOffset: number + bindingCount: number + allowedUserCount: number + lastError: string | null + botUser: { + id: number + username?: string + } | null +} + +export interface IRemoteControlPresenter { + getTelegramSettings(): Promise + saveTelegramSettings(input: TelegramRemoteSettings): Promise + getTelegramStatus(): Promise + createTelegramPairCode(): Promise<{ code: string; expiresAt: number }> + clearTelegramPairCode(): Promise + clearTelegramBindings(): Promise + testTelegramHookNotification(): Promise +} diff --git a/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts b/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts index 2249753c4..10b55624a 100644 --- a/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts +++ b/test/main/presenter/deepchatAgentPresenter/deepchatAgentPresenter.test.ts @@ -1843,6 +1843,22 @@ describe('DeepChatAgentPresenter', () => { }) ) }) + + it('cancels generation only when the event id matches the active assistant message', async () => { + await agent.initSession('s1', { providerId: 'openai', modelId: 'gpt-4' }) + const cancelSpy = vi.spyOn(agent, 'cancelGeneration').mockResolvedValue(undefined) + ;(agent as any).activeGenerations.set('s1', { + runId: 'run-1', + messageId: 'msg-active', + abortController: new AbortController() + }) + + await expect(agent.cancelGenerationByEventId('s1', 'msg-other')).resolves.toBe(false) + await expect(agent.cancelGenerationByEventId('s1', 'msg-active')).resolves.toBe(true) + + expect(cancelSpy).toHaveBeenCalledTimes(1) + expect(cancelSpy).toHaveBeenCalledWith('s1') + }) }) describe('queuePendingInput', () => { diff --git a/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts b/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts index 58c88ce0c..66cc8f581 100644 --- a/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts +++ b/test/main/presenter/newAgentPresenter/newAgentPresenter.test.ts @@ -574,6 +574,34 @@ describe('NewAgentPresenter', () => { }) }) + describe('createDetachedSession', () => { + it('creates a detached session without window activation', async () => { + const result = await presenter.createDetachedSession({ + title: 'Remote Session', + agentId: 'deepchat' + }) + + expect(result.id).toBe('mock-session-id') + expect(result.title).toBe('Remote Session') + expect(deepChatAgent.initSession).toHaveBeenCalledWith( + 'mock-session-id', + expect.objectContaining({ + agentId: 'deepchat', + providerId: 'openai', + modelId: 'gpt-4', + projectDir: null, + permissionMode: 'full_access' + }) + ) + expect(eventBus.sendToRenderer).toHaveBeenCalledWith('session:list-updated', 'all') + expect(eventBus.sendToRenderer).not.toHaveBeenCalledWith( + 'session:activated', + 'all', + expect.anything() + ) + }) + }) + describe('sendMessage', () => { it('promotes draft session before first message', async () => { configPresenter.getAcpAgents.mockResolvedValue([ diff --git a/test/main/presenter/remoteControlPresenter/remoteAuthGuard.test.ts b/test/main/presenter/remoteControlPresenter/remoteAuthGuard.test.ts new file mode 100644 index 000000000..c5101a4e7 --- /dev/null +++ b/test/main/presenter/remoteControlPresenter/remoteAuthGuard.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteAuthGuard } from '@/presenter/remoteControlPresenter/services/remoteAuthGuard' + +const createMessage = ( + overrides: Partial[0]> = {} +) => ({ + updateId: 1, + chatId: 100, + messageThreadId: 0, + messageId: 10, + chatType: 'private', + fromId: 123, + text: 'hello', + command: null, + ...overrides +}) + +describe('RemoteAuthGuard', () => { + it('authorizes allowed private users by numeric id', () => { + const store = { + isAllowedUser: vi.fn().mockReturnValue(true) + } as any + const guard = new RemoteAuthGuard(store) + + expect(guard.ensureAuthorized(createMessage())).toEqual({ + ok: true, + userId: 123 + }) + expect(store.isAllowedUser).toHaveBeenCalledWith(123) + }) + + it('rejects non-private chats', () => { + const guard = new RemoteAuthGuard({ + isAllowedUser: vi.fn().mockReturnValue(true) + } as any) + + const result = guard.ensureAuthorized(createMessage({ chatType: 'group' })) + + expect(result.ok).toBe(false) + expect(result).toEqual( + expect.objectContaining({ + message: 'Telegram remote control only supports private chats in v1.' + }) + ) + }) + + it('pairs a user with a valid one-time code', () => { + const store = { + getPairingState: vi.fn().mockReturnValue({ + code: '123456', + expiresAt: Date.now() + 60_000 + }), + addAllowedUser: vi.fn(), + clearPairCode: vi.fn() + } as any + const guard = new RemoteAuthGuard(store) + + const result = guard.pair(createMessage(), '123456') + + expect(result).toContain('Pairing complete') + expect(store.addAllowedUser).toHaveBeenCalledWith(123) + expect(store.clearPairCode).toHaveBeenCalled() + }) + + it('rejects expired pair codes and clears them', () => { + const store = { + getPairingState: vi.fn().mockReturnValue({ + code: '123456', + expiresAt: Date.now() - 1 + }), + addAllowedUser: vi.fn(), + clearPairCode: vi.fn() + } as any + const guard = new RemoteAuthGuard(store) + + const result = guard.pair(createMessage(), '123456') + + expect(result).toContain('missing or expired') + expect(store.addAllowedUser).not.toHaveBeenCalled() + expect(store.clearPairCode).toHaveBeenCalled() + }) +}) diff --git a/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts b/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts new file mode 100644 index 000000000..b156f2595 --- /dev/null +++ b/test/main/presenter/remoteControlPresenter/remoteBindingStore.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteBindingStore } from '@/presenter/remoteControlPresenter/services/remoteBindingStore' + +const createConfigPresenter = () => { + const store = new Map() + return { + getSetting: vi.fn((key: string) => store.get(key)), + setSetting: vi.fn((key: string, value: unknown) => { + store.set(key, value) + }) + } +} + +describe('RemoteBindingStore', () => { + it('persists endpoint bindings through config storage', () => { + const configPresenter = createConfigPresenter() + const firstStore = new RemoteBindingStore(configPresenter as any) + + firstStore.setBinding('telegram:100:0', 'session-1') + + const secondStore = new RemoteBindingStore(configPresenter as any) + expect(secondStore.getBinding('telegram:100:0')).toEqual({ + sessionId: 'session-1', + updatedAt: expect.any(Number) + }) + }) + + it('clears bindings and returns the cleared count', () => { + const configPresenter = createConfigPresenter() + const store = new RemoteBindingStore(configPresenter as any) + + store.setBinding('telegram:100:0', 'session-1') + store.setBinding('telegram:200:0', 'session-2') + + expect(store.clearBindings()).toBe(2) + expect(store.countBindings()).toBe(0) + }) + + it('stores and restores poll offset', () => { + const configPresenter = createConfigPresenter() + const store = new RemoteBindingStore(configPresenter as any) + + store.setPollOffset(42) + + const reloaded = new RemoteBindingStore(configPresenter as any) + expect(reloaded.getPollOffset()).toBe(42) + }) +}) diff --git a/test/main/presenter/remoteControlPresenter/remoteCommandRouter.test.ts b/test/main/presenter/remoteControlPresenter/remoteCommandRouter.test.ts new file mode 100644 index 000000000..fcb8ef499 --- /dev/null +++ b/test/main/presenter/remoteControlPresenter/remoteCommandRouter.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest' +import { RemoteCommandRouter } from '@/presenter/remoteControlPresenter/services/remoteCommandRouter' + +const createMessage = ( + overrides: Partial[0]> = {} +) => ({ + updateId: 1, + chatId: 100, + messageThreadId: 0, + messageId: 20, + chatType: 'private', + fromId: 123, + text: 'hello', + command: null, + ...overrides +}) + +describe('RemoteCommandRouter', () => { + it('returns pairing guidance for unauthorized plain text', async () => { + const router = new RemoteCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ + ok: false, + message: 'pair first' + }), + pair: vi.fn() + } as any, + runner: {} as any, + bindingStore: { + getEndpointKey: vi.fn().mockReturnValue('telegram:100:0'), + getTelegramConfig: vi.fn().mockReturnValue({ + allowlist: [], + bindings: {}, + streamMode: 'draft' + }) + } as any, + getPollerStatus: vi.fn().mockReturnValue({ + state: 'running', + lastError: null, + botUser: null + }) + }) + + const result = await router.handleMessage(createMessage()) + + expect(result).toEqual({ + replies: ['pair first'] + }) + }) + + it('routes plain text to the conversation runner when authorized', async () => { + const conversation = { + sessionId: 'session-1', + eventId: 'msg-1', + getSnapshot: vi.fn() + } + const runner = { + sendText: vi.fn().mockResolvedValue(conversation) + } + const router = new RemoteCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ + ok: true, + userId: 123 + }), + pair: vi.fn() + } as any, + runner: runner as any, + bindingStore: { + getEndpointKey: vi.fn().mockReturnValue('telegram:100:0'), + getTelegramConfig: vi.fn().mockReturnValue({ + allowlist: [123], + bindings: {}, + streamMode: 'draft' + }) + } as any, + getPollerStatus: vi.fn().mockReturnValue({ + state: 'running', + lastError: null, + botUser: null + }) + }) + + const result = await router.handleMessage(createMessage()) + + expect(runner.sendText).toHaveBeenCalledWith('telegram:100:0', 'hello') + expect(result).toEqual({ + replies: [], + conversation + }) + }) + + it('returns usage help for an invalid /use command', async () => { + const runner = { + useSessionByIndex: vi.fn() + } + const router = new RemoteCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ + ok: true, + userId: 123 + }), + pair: vi.fn() + } as any, + runner: runner as any, + bindingStore: { + getEndpointKey: vi.fn().mockReturnValue('telegram:100:0'), + getTelegramConfig: vi.fn().mockReturnValue({ + allowlist: [123], + bindings: {}, + streamMode: 'draft' + }) + } as any, + getPollerStatus: vi.fn().mockReturnValue({ + state: 'running', + lastError: null, + botUser: null + }) + }) + + const result = await router.handleMessage( + createMessage({ + text: '/use nope', + command: { + name: 'use', + args: 'nope' + } + }) + ) + + expect(result).toEqual({ + replies: ['Usage: /use '] + }) + expect(runner.useSessionByIndex).not.toHaveBeenCalled() + }) + + it('reports runtime state for /status', async () => { + const router = new RemoteCommandRouter({ + authGuard: { + ensureAuthorized: vi.fn().mockReturnValue({ + ok: true, + userId: 123 + }), + pair: vi.fn() + } as any, + runner: { + getStatus: vi.fn().mockResolvedValue({ + session: { + id: 'session-1', + title: 'Remote chat' + }, + activeEventId: 'msg-1', + isGenerating: true + }) + } as any, + bindingStore: { + getEndpointKey: vi.fn().mockReturnValue('telegram:100:0'), + getTelegramConfig: vi.fn().mockReturnValue({ + allowlist: [123], + bindings: { + 'telegram:100:0': { sessionId: 'session-1', updatedAt: 1 } + }, + streamMode: 'draft' + }) + } as any, + getPollerStatus: vi.fn().mockReturnValue({ + state: 'running', + lastError: null, + botUser: null + }) + }) + + const result = await router.handleMessage( + createMessage({ + text: '/status', + command: { + name: 'status', + args: '' + } + }) + ) + + expect(result.replies[0]).toContain('Runtime: running') + expect(result.replies[0]).toContain('Current session: Remote chat [session-1]') + }) +}) diff --git a/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts b/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts new file mode 100644 index 000000000..1bd76bfc8 --- /dev/null +++ b/test/main/presenter/remoteControlPresenter/remoteControlPresenter.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { HookEventName, HooksNotificationsSettings } from '@shared/hooksNotifications' + +const pollerInstances: Array<{ start: ReturnType; stop: ReturnType }> = + [] +let pollerStartImplementation: () => Promise = async () => {} + +vi.mock('@/presenter/remoteControlPresenter/telegram/telegramPoller', () => ({ + TelegramPoller: class MockTelegramPoller { + readonly start = vi.fn(() => pollerStartImplementation()) + readonly stop = vi.fn().mockResolvedValue(undefined) + + constructor() { + pollerInstances.push(this) + } + } +})) + +import { RemoteControlPresenter } from '@/presenter/remoteControlPresenter' + +const createHooksConfig = (): HooksNotificationsSettings => { + const commandEvents = Object.fromEntries( + [ + 'SessionStart', + 'UserPromptSubmit', + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'Stop', + 'SessionEnd' + ].map((eventName) => [eventName, { enabled: false, command: '' }]) + ) as Record + + return { + telegram: { + enabled: false, + botToken: 'test-bot-token', + chatId: '', + threadId: undefined, + events: [] + }, + discord: { + enabled: false, + webhookUrl: '', + events: [] + }, + confirmo: { + enabled: false, + events: [] + }, + commands: { + enabled: false, + events: commandEvents + } + } +} + +const createConfigPresenter = () => { + const store = new Map([ + [ + 'remoteControl', + { + telegram: { + enabled: true, + allowlist: [], + streamMode: 'draft', + pollOffset: 0, + pairing: { + code: null, + expiresAt: null + }, + bindings: {} + } + } + ] + ]) + + return { + getSetting: vi.fn((key: string) => store.get(key)), + setSetting: vi.fn((key: string, value: unknown) => { + store.set(key, value) + }) + } +} + +describe('RemoteControlPresenter', () => { + beforeEach(() => { + pollerInstances.length = 0 + pollerStartImplementation = async () => {} + }) + + it('serializes runtime rebuilds so only one poller starts per token', async () => { + const configPresenter = createConfigPresenter() + let hooksConfig = createHooksConfig() + + const presenter = new RemoteControlPresenter({ + configPresenter: configPresenter as any, + newAgentPresenter: {} as any, + deepchatAgentPresenter: {} as any, + windowPresenter: {} as any, + tabPresenter: {} as any, + getHooksNotificationsConfig: () => hooksConfig, + setHooksNotificationsConfig: (nextConfig) => { + hooksConfig = nextConfig + return nextConfig + }, + testTelegramHookNotification: vi.fn().mockResolvedValue({ + success: true, + durationMs: 0 + }) + }) + + await Promise.all([presenter.initialize(), presenter.initialize()]) + + expect(pollerInstances).toHaveLength(1) + expect(pollerInstances[0].start).toHaveBeenCalledTimes(1) + }) + + it('reports starting while the poller startup is still in flight', async () => { + const configPresenter = createConfigPresenter() + let hooksConfig = createHooksConfig() + let resolveStart: (() => void) | null = null + pollerStartImplementation = () => + new Promise((resolve) => { + resolveStart = resolve + }) + + const presenter = new RemoteControlPresenter({ + configPresenter: configPresenter as any, + newAgentPresenter: {} as any, + deepchatAgentPresenter: {} as any, + windowPresenter: {} as any, + tabPresenter: {} as any, + getHooksNotificationsConfig: () => hooksConfig, + setHooksNotificationsConfig: (nextConfig) => { + hooksConfig = nextConfig + return nextConfig + }, + testTelegramHookNotification: vi.fn().mockResolvedValue({ + success: true, + durationMs: 0 + }) + }) + + const initializePromise = presenter.initialize() + + await vi.waitFor(async () => { + await expect(presenter.getTelegramStatus()).resolves.toEqual( + expect.objectContaining({ + state: 'starting' + }) + ) + }) + + resolveStart?.() + await initializePromise + }) +}) diff --git a/test/main/presenter/remoteControlPresenter/telegramOutbound.test.ts b/test/main/presenter/remoteControlPresenter/telegramOutbound.test.ts new file mode 100644 index 000000000..864fb6988 --- /dev/null +++ b/test/main/presenter/remoteControlPresenter/telegramOutbound.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest' +import { + buildTelegramFinalText, + chunkTelegramText, + extractTelegramStreamText +} from '@/presenter/remoteControlPresenter/telegram/telegramOutbound' + +describe('telegramOutbound', () => { + it('extracts streaming text from content blocks', () => { + expect( + extractTelegramStreamText([ + { + type: 'content', + content: 'Hello', + status: 'success', + timestamp: 1 + }, + { + type: 'content', + content: 'World', + status: 'success', + timestamp: 2 + } + ]) + ).toBe('Hello\n\nWorld') + }) + + it('appends desktop confirmation notice for pending approval blocks', () => { + const text = buildTelegramFinalText([ + { + type: 'content', + content: 'Need your approval', + status: 'success', + timestamp: 1 + }, + { + type: 'action', + action_type: 'tool_call_permission', + content: 'Permission requested', + status: 'pending', + timestamp: 2, + extra: { + needsUserAction: true + } + } + ]) + + expect(text).toContain('Need your approval') + expect(text).toContain('Desktop confirmation is required') + }) + + it('chunks long text within the Telegram limit', () => { + const chunks = chunkTelegramText('A'.repeat(25), 10) + + expect(chunks).toHaveLength(3) + expect(chunks.every((chunk) => chunk.length <= 10)).toBe(true) + }) +}) diff --git a/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts b/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts new file mode 100644 index 000000000..f446fffb4 --- /dev/null +++ b/test/main/presenter/remoteControlPresenter/telegramPoller.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it, vi } from 'vitest' +import { TelegramApiRequestError } from '@/presenter/remoteControlPresenter/telegram/telegramClient' +import { TelegramPoller } from '@/presenter/remoteControlPresenter/telegram/telegramPoller' + +describe('TelegramPoller', () => { + it('reports running while waiting on long polling', async () => { + const client = { + getMe: vi.fn().mockResolvedValue({ + id: 123, + username: 'deepchat_bot' + }), + getUpdates: vi.fn().mockImplementation(({ signal }: { signal?: AbortSignal }) => { + return new Promise((_, reject) => { + signal?.addEventListener( + 'abort', + () => { + reject(new Error('aborted')) + }, + { once: true } + ) + }) + }), + sendMessage: vi.fn(), + sendMessageDraft: vi.fn(), + sendChatAction: vi.fn() + } + + const poller = new TelegramPoller({ + client: client as any, + parser: { + parseUpdate: vi.fn() + } as any, + router: {} as any, + bindingStore: { + getPollOffset: vi.fn().mockReturnValue(0), + setPollOffset: vi.fn(), + getTelegramConfig: vi.fn().mockReturnValue({ + streamMode: 'draft' + }) + } as any + }) + + await poller.start() + + await vi.waitFor(() => { + expect(poller.getStatusSnapshot().state).toBe('running') + }) + + await poller.stop() + }) + + it('stops retrying and reports error on Telegram 409 conflict', async () => { + const client = { + getMe: vi.fn().mockResolvedValue({ + id: 123, + username: 'deepchat_bot' + }), + getUpdates: vi + .fn() + .mockRejectedValue( + new TelegramApiRequestError( + 'Conflict: terminated by other getUpdates request; make sure that only one bot instance is running', + 409 + ) + ), + sendMessage: vi.fn(), + sendMessageDraft: vi.fn(), + sendChatAction: vi.fn() + } + + const poller = new TelegramPoller({ + client: client as any, + parser: { + parseUpdate: vi.fn() + } as any, + router: {} as any, + bindingStore: { + getPollOffset: vi.fn().mockReturnValue(0), + setPollOffset: vi.fn(), + getTelegramConfig: vi.fn().mockReturnValue({ + streamMode: 'draft' + }) + } as any + }) + + await poller.start() + + await vi.waitFor(() => { + expect(poller.getStatusSnapshot().state).toBe('error') + }) + + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(client.getUpdates).toHaveBeenCalledTimes(1) + expect(poller.getStatusSnapshot().lastError).toContain('terminated by other getUpdates request') + }) +}) From e1710545a2f8bc3a73ee27c8ba89543620f8fc3d Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 24 Mar 2026 00:37:39 +0800 Subject: [PATCH 2/7] fix(remote): harden polling and status --- .../presenter/remoteControlPresenter/index.ts | 56 ++++++++++- .../telegram/telegramPoller.ts | 10 +- .../presenter/remoteControlPresenter/types.ts | 4 + src/renderer/src/components/WindowSideBar.vue | 69 ++++++++++++- src/renderer/src/i18n/da-DK/chat.json | 8 ++ src/renderer/src/i18n/en-US/chat.json | 8 ++ src/renderer/src/i18n/fa-IR/chat.json | 8 ++ src/renderer/src/i18n/fr-FR/chat.json | 8 ++ src/renderer/src/i18n/he-IL/chat.json | 8 ++ src/renderer/src/i18n/ja-JP/chat.json | 8 ++ src/renderer/src/i18n/ko-KR/chat.json | 8 ++ src/renderer/src/i18n/pt-BR/chat.json | 8 ++ src/renderer/src/i18n/ru-RU/chat.json | 8 ++ src/renderer/src/i18n/zh-CN/chat.json | 8 ++ src/renderer/src/i18n/zh-HK/chat.json | 8 ++ src/renderer/src/i18n/zh-TW/chat.json | 8 ++ .../presenters/remote-control.presenter.d.ts | 1 + .../remoteControlPresenter.test.ts | 64 ++++++++++++- .../telegramPoller.test.ts | 70 +++++++++++++- .../renderer/components/WindowSideBar.test.ts | 96 +++++++++++++++++-- 20 files changed, 448 insertions(+), 18 deletions(-) diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts index 8a7748d72..96e8af87e 100644 --- a/src/main/presenter/remoteControlPresenter/index.ts +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -66,6 +66,11 @@ export class RemoteControlPresenter { async saveTelegramSettings(input: TelegramRemoteSettings): Promise { const normalized = normalizeTelegramSettingsInput(input) const currentHooksConfig = this.deps.getHooksNotificationsConfig() + const currentRemoteConfig = this.bindingStore.getTelegramConfig() + const currentBotToken = currentHooksConfig.telegram.botToken.trim() + const shouldClearFatalError = + currentRemoteConfig.enabled !== normalized.remoteEnabled || + currentBotToken !== normalized.botToken this.deps.setHooksNotificationsConfig({ ...currentHooksConfig, @@ -77,6 +82,7 @@ export class RemoteControlPresenter { enabled: normalized.remoteEnabled, allowlist: normalized.allowedUserIds, streamMode: normalized.streamMode, + lastFatalError: shouldClearFatalError ? null : config.lastFatalError, pairing: { code: normalized.pairCode, expiresAt: normalized.pairCodeExpiresAt @@ -92,9 +98,14 @@ export class RemoteControlPresenter { async getTelegramStatus(): Promise { const remoteConfig = this.bindingStore.getTelegramConfig() const hooksConfig = this.deps.getHooksNotificationsConfig().telegram - const runtimeStatus = this.getEffectivePollerStatus(hooksConfig.botToken, remoteConfig.enabled) + const runtimeStatus = this.getEffectivePollerStatus( + hooksConfig.botToken, + remoteConfig.enabled, + remoteConfig.lastFatalError + ) return { + enabled: remoteConfig.enabled, state: runtimeStatus.state, pollOffset: remoteConfig.pollOffset, bindingCount: Object.keys(remoteConfig.bindings).length, @@ -184,7 +195,7 @@ export class RemoteControlPresenter { authGuard, runner, bindingStore: this.bindingStore, - getPollerStatus: () => this.getEffectivePollerStatus(botToken, true) + getPollerStatus: () => this.getEffectivePollerStatus(botToken, true, null) }) this.telegramPoller = new TelegramPoller({ @@ -194,6 +205,11 @@ export class RemoteControlPresenter { bindingStore: this.bindingStore, onStatusChange: (snapshot) => { this.telegramPollerStatus = snapshot + }, + onFatalError: (message) => { + void this.enqueueRuntimeOperation(async () => { + await this.disableTelegramRuntimeForFatalError(botToken, message) + }) } }) @@ -223,9 +239,18 @@ export class RemoteControlPresenter { private getEffectivePollerStatus( botToken: string, - remoteEnabled: boolean + remoteEnabled: boolean, + lastFatalError: string | null ): TelegramPollerStatusSnapshot { if (!remoteEnabled) { + if (lastFatalError) { + return { + state: 'error', + lastError: lastFatalError, + botUser: null + } + } + return { state: 'disabled', lastError: null, @@ -244,6 +269,31 @@ export class RemoteControlPresenter { return { ...this.telegramPollerStatus } } + private async disableTelegramRuntimeForFatalError( + botToken: string, + errorMessage: string + ): Promise { + const currentHooksConfig = this.deps.getHooksNotificationsConfig().telegram + const currentRemoteConfig = this.bindingStore.getTelegramConfig() + + if (!currentRemoteConfig.enabled || currentHooksConfig.botToken.trim() !== botToken) { + return + } + + this.bindingStore.updateTelegramConfig((config) => ({ + ...config, + enabled: false, + lastFatalError: errorMessage + })) + + await this.stopTelegramRuntime() + this.telegramPollerStatus = { + state: 'error', + lastError: errorMessage, + botUser: null + } + } + private enqueueRuntimeOperation(operation: () => Promise): Promise { const nextOperation = this.runtimeOperation.then(operation, operation) this.runtimeOperation = nextOperation.catch(() => {}) diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts index 4ab8fce2a..5000a116f 100644 --- a/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts @@ -24,6 +24,7 @@ type TelegramPollerDeps = { router: RemoteCommandRouter bindingStore: RemoteBindingStore onStatusChange?: (snapshot: TelegramPollerStatusSnapshot) => void + onFatalError?: (message: string) => void } export class TelegramPoller { @@ -105,11 +106,12 @@ export class TelegramPoller { } const lastError = error instanceof Error ? error.message : String(error) - if (this.isTerminalConflictError(error)) { + if (this.isFatalPollError(error)) { this.setStatus({ state: 'error', lastError }) + this.deps.onFatalError?.(lastError) return } @@ -252,9 +254,11 @@ export class TelegramPoller { } } - private isTerminalConflictError(error: unknown): boolean { + private isFatalPollError(error: unknown): boolean { if (error instanceof TelegramApiRequestError) { - return error.code === 409 + return typeof error.code === 'number' && error.code >= 400 && error.code < 500 + ? error.code !== 429 + : false } if (!(error instanceof Error)) { diff --git a/src/main/presenter/remoteControlPresenter/types.ts b/src/main/presenter/remoteControlPresenter/types.ts index 145b2b250..25c49cfd3 100644 --- a/src/main/presenter/remoteControlPresenter/types.ts +++ b/src/main/presenter/remoteControlPresenter/types.ts @@ -33,6 +33,7 @@ export interface TelegramRemoteRuntimeConfig { allowlist: number[] streamMode: TelegramStreamMode pollOffset: number + lastFatalError: string | null pairing: TelegramPairingState bindings: Record } @@ -79,6 +80,7 @@ export const createDefaultRemoteControlConfig = (): RemoteControlConfig => ({ allowlist: [], streamMode: 'draft', pollOffset: 0, + lastFatalError: null, pairing: { code: null, expiresAt: null @@ -107,6 +109,7 @@ const TelegramRemoteRuntimeConfigSchema = z allowlist: z.array(z.union([z.number(), z.string()])).optional(), streamMode: z.enum(['draft', 'final']).optional(), pollOffset: z.number().int().nonnegative().optional(), + lastFatalError: z.string().nullable().optional(), pairing: TelegramPairingStateSchema.optional(), bindings: z.record(z.string(), TelegramEndpointBindingSchema).optional() }) @@ -162,6 +165,7 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi typeof telegram.pollOffset === 'number' && telegram.pollOffset >= 0 ? telegram.pollOffset : defaults.telegram.pollOffset, + lastFatalError: telegram.lastFatalError?.trim() || null, pairing: { code: telegram.pairing?.code?.trim() || null, expiresAt: diff --git a/src/renderer/src/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index 3a828c839..9c4313f83 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -51,6 +51,20 @@
+ + + + + {{ remoteControlTooltip }} + + @@ -226,7 +240,7 @@