diff --git a/docs/specs/telegram-remote-control/plan.md b/docs/specs/telegram-remote-control/plan.md new file mode 100644 index 000000000..0648ebacc --- /dev/null +++ b/docs/specs/telegram-remote-control/plan.md @@ -0,0 +1,101 @@ +# 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, default agent id, pair code, internal stream mode, and endpoint bindings. + - Keeps active event IDs, `/sessions` snapshots, and `/model` inline-menu state 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. + - Resolves a valid enabled DeepChat default agent before creating unbound Telegram sessions. + - Lists recent sessions by the currently bound session's agent when a valid binding exists; otherwise falls back to the default DeepChat agent. + - Exposes current-session lookup and bound-session model switching through `newAgentPresenter.setSessionModel()`. + - Reuses `newAgentPresenter.sendMessage()` for plain-text Telegram input. + - Tracks the active assistant message/event for `/stop`. +- `remoteCommandRouter` + - Handles `/start`, `/help`, `/pair`, `/new`, `/sessions`, `/use`, `/stop`, `/status`, `/model`, plain text, and `/model` callback actions. +- `telegramClient` + - Calls `getMe`, `getUpdates`, `sendMessageDraft`, `sendMessage`, `sendChatAction`, `setMyCommands`, `setMessageReaction`, `editMessageText`, `editMessageReplyMarkup`, and `answerCallbackQuery`. +- `telegramParser` + - Parses private text updates, bot commands, and callback queries into one internal event shape. +- `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. + - Only adds reactions for plain-text conversation messages and clears them after the conversation completes or fails. + +## Shared / IPC Contract + +- Add `src/shared/types/presenters/remote-control.presenter.d.ts`. +- Expose methods for reading/saving Telegram settings, reading runtime status, listing/removing bindings, reading pairing snapshot, 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. +- Simplify the first-layer Telegram remote UI to allowed user IDs, default agent selection, pairing, and binding management. +- Show pairing and binding management inside dialogs; hide remote/hook detail forms when their toggle is off. +- 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` + - `defaultAgentId` + - `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 message and callback payloads. +5. Router applies auth, command handling, and `/model` inline-menu transitions. +6. Plain text enters `newAgentPresenter.sendMessage()` using the bound or newly created detached session. +7. `/model` callback actions edit a single bot menu message in place and answer the callback query. +8. Poller watches assistant message state and sends draft/final Telegram output. +9. 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 `remoteConversationRunner`. +- Unit tests for `telegramParser`. +- Unit tests for `telegramClient` request payloads. +- Unit tests for `telegramOutbound` chunking/final-text behavior. +- Unit tests for Telegram command registration, callback handling, and message reaction lifecycle 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..a6d1188d4 --- /dev/null +++ b/docs/specs/telegram-remote-control/spec.md @@ -0,0 +1,59 @@ +# 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. + +This increment simplifies the Remote settings UX, removes the user-facing stream mode switch, adds a selectable default DeepChat agent for new remote sessions, and keeps remote session/model control intentionally lightweight for v1. + +## 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`, list recent sessions with `/sessions`, and rebind to one with `/use`. +- As a user configuring integrations, I can manage Telegram remote control and Telegram hook notifications from a single `Remote` settings page. +- As a user configuring Telegram pairing, I only see a simple “Pair” entry point in the first layer and complete the flow from a small modal. +- As a user using multiple DeepChat agents, I can choose which enabled DeepChat agent new Telegram sessions should use by default. +- As a paired user, I can change the current bound session's provider/model through a two-step Telegram inline keyboard opened from `/model`. + +## 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. +- `/sessions` lists recent sessions for the currently bound session's agent; if no valid binding exists, it falls back to the configured default DeepChat agent. +- `/use ` binds the endpoint to the corresponding session from the latest `/sessions` list. +- `/model` opens a Telegram inline keyboard, first for enabled providers and then for enabled models, and only changes the current 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`. +- The Remote settings page hides the remote-control detail area when remote control is disabled, and hides the Telegram hook detail area when hooks are disabled. +- The first-layer Telegram remote UI shows allowed user IDs, a default DeepChat agent selector, a pairing button, and a binding-management button; pair code display moves into a modal. +- Telegram remote no longer exposes a stream mode selector; draft streaming remains the internal default. +- Telegram runtime registers its default command list when it starts. +- Only plain-text conversation messages get a temporary bot reaction; slash commands and inline-button callbacks do not, and the reaction is cleared after the reply finishes or fails. +- New Telegram sessions use the selected default DeepChat agent, inheriting that agent's default model/project/permission defaults; existing bound sessions remain bound even if the default agent later changes. +- 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, arbitrary bot button workflows outside `/model`, 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. +- `remoteControl.telegram.defaultAgentId` stores the default DeepChat agent for new Telegram sessions. +- 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..a1beaf5cd --- /dev/null +++ b/docs/specs/telegram-remote-control/tasks.md @@ -0,0 +1,36 @@ +# 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. + - Route new Telegram sessions through a validated default DeepChat agent. + - Make `/sessions` prefer the currently bound agent and add bound-session `/model` switching. + +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. + - Register default Telegram bot commands, support inline-keyboard callback queries, and keep reactions scoped to plain-text conversations. + +5. Renderer + - Add `RemoteSettings.vue`. + - Add `settings-remote` route. + - Remove Telegram UI from `NotificationsHooksSettings.vue`. + - Simplify the Telegram remote first layer and move pairing / binding management into dialogs. + - Hide remote and hook detail sections when their toggle is off. + - Add i18n keys for `Remote`. + +6. Tests + - Add main tests for auth guard, bindings, command routing, and chunking. + - Add parser/client tests for callback query and inline-keyboard payloads. + - Extend existing presenter tests for detached session creation, session model switching, 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/configPresenter/systemPromptHelper.ts b/src/main/presenter/configPresenter/systemPromptHelper.ts index 18c2640f3..0d4a40ae1 100644 --- a/src/main/presenter/configPresenter/systemPromptHelper.ts +++ b/src/main/presenter/configPresenter/systemPromptHelper.ts @@ -7,8 +7,7 @@ type SetSetting = (key: string, value: T) => void export const DEFAULT_SYSTEM_PROMPT = `You are DeepChat, a highly capable AI assistant. Your goal is to fully complete the user’s requested task before handing the conversation back to them. Keep working autonomously until the task is fully resolved. Be thorough in gathering information. Before replying, make sure you have all the details necessary to provide a complete solution. Use additional tools or ask clarifying questions when needed, but if you can find the answer on your own, avoid asking the user for help. -When using tools, briefly describe your intended steps first—for example, which tool you’ll use and for what purpose. -Adhere to this in all languages.Respond in the same language as the user's query.` +When using tools, briefly describe your intended steps first—for example, which tool you’ll use and for what purpose.` type GetSetting = (key: string) => T | undefined diff --git a/src/main/presenter/deepchatAgentPresenter/index.ts b/src/main/presenter/deepchatAgentPresenter/index.ts index 4775c67a4..6971a592e 100644 --- a/src/main/presenter/deepchatAgentPresenter/index.ts +++ b/src/main/presenter/deepchatAgentPresenter/index.ts @@ -888,6 +888,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 88236e504..c1075734b 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -28,7 +28,8 @@ import { ISkillPresenter, ISkillSyncPresenter, INewAgentPresenter, - IProjectPresenter + IProjectPresenter, + IRemoteControlPresenter } from '@shared/presenter' import { eventBus } from '@/eventbus' import { LLMProviderPresenter } from './llmProviderPresenter' @@ -66,6 +67,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' import { AgentRepository } from './agentRepository' import type { SQLitePresenter } from './sqlitePresenter' @@ -85,6 +88,45 @@ interface IPCCallContext { export class Presenter implements IPresenter { // 私有静态实例 private static instance: Presenter + static readonly DISPATCHABLE_PRESENTERS = new Set([ + 'windowPresenter', + 'sqlitePresenter', + 'llmproviderPresenter', + 'configPresenter', + 'exporter', + 'devicePresenter', + 'upgradePresenter', + 'shortcutPresenter', + 'filePresenter', + 'mcpPresenter', + 'syncPresenter', + 'deeplinkPresenter', + 'notificationPresenter', + 'tabPresenter', + 'yoBrowserPresenter', + 'oauthPresenter', + 'dialogPresenter', + 'knowledgePresenter', + 'workspacePresenter', + 'toolPresenter', + 'skillPresenter', + 'skillSyncPresenter', + 'newAgentPresenter', + 'projectPresenter' + ]) + + static readonly REMOTE_CONTROL_METHODS = new Set([ + 'getTelegramSettings', + 'saveTelegramSettings', + 'getTelegramStatus', + 'getTelegramBindings', + 'removeTelegramBinding', + 'getTelegramPairingSnapshot', + 'createTelegramPairCode', + 'clearTelegramPairCode', + 'clearTelegramBindings', + 'testTelegramHookNotification' + ]) windowPresenter: IWindowPresenter sqlitePresenter: ISQLitePresenter @@ -120,6 +162,8 @@ export class Presenter implements IPresenter { settingsPermissionService: SettingsPermissionService private sessionMessageManager: MessageManager private sessionPresenterInternal?: SessionPresenter + #remoteControlPresenter: RemoteControlPresenterLike + readonly #remoteControlBridge: IRemoteControlPresenter private constructor(lifecycleManager: ILifecycleManager) { // Store lifecycle manager reference for component access @@ -298,6 +342,31 @@ 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() + }) + this.#remoteControlBridge = { + getTelegramSettings: () => this.#remoteControlPresenter.getTelegramSettings(), + saveTelegramSettings: (input) => this.#remoteControlPresenter.saveTelegramSettings(input), + getTelegramStatus: () => this.#remoteControlPresenter.getTelegramStatus(), + getTelegramBindings: () => this.#remoteControlPresenter.getTelegramBindings(), + removeTelegramBinding: (endpointKey) => + this.#remoteControlPresenter.removeTelegramBinding(endpointKey), + getTelegramPairingSnapshot: () => this.#remoteControlPresenter.getTelegramPairingSnapshot(), + createTelegramPairCode: () => this.#remoteControlPresenter.createTelegramPairCode(), + clearTelegramPairCode: () => this.#remoteControlPresenter.clearTelegramPairCode(), + clearTelegramBindings: () => this.#remoteControlPresenter.clearTelegramBindings(), + testTelegramHookNotification: () => + this.#remoteControlPresenter.testTelegramHookNotification() + } // Update hooksNotifications with actual dependencies now that newAgentPresenter is ready this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { @@ -400,6 +469,9 @@ export class Presenter implements IPresenter { // 初始化 Skills 系统 this.initializeSkills() + + // Initialize remote control runtime + void this.initializeRemoteControl() } // 初始化悬浮按钮 @@ -439,6 +511,26 @@ export class Presenter implements IPresenter { } } + private async initializeRemoteControl() { + try { + await this.#remoteControlPresenter.initialize() + } catch (error) { + console.error('RemoteControlPresenter.initialize failed:', error) + } + } + + async callRemoteControl( + method: keyof IRemoteControlPresenter, + ...payloads: unknown[] + ): Promise { + if (!Presenter.REMOTE_CONTROL_METHODS.has(method)) { + throw new Error(`Method "${String(method)}" is not allowed on "remoteControlPresenter"`) + } + + const handler = this.#remoteControlBridge[method] as (...args: unknown[]) => unknown + return await handler(...payloads) + } + // 从配置中同步自定义模型到 LLMProviderPresenter private async syncCustomModels() { const providers = this.configPresenter.getProviders() @@ -460,7 +552,8 @@ export class Presenter implements IPresenter { } // 在应用退出时进行清理,关闭数据库连接 - destroy() { + async destroy(): Promise { + await this.destroyRemoteControl() this.floatingButtonPresenter.destroy() // 销毁悬浮按钮 this.tabPresenter.destroy() this.sqlitePresenter.close() // 关闭数据库连接 @@ -474,6 +567,14 @@ export class Presenter implements IPresenter { // 注意: trayPresenter.destroy() 在 main/index.ts 的 will-quit 事件中处理 // 此处不销毁 trayPresenter,其生命周期由 main/index.ts 管理 } + + private async destroyRemoteControl() { + try { + await this.#remoteControlPresenter.destroy() + } catch (error) { + console.error('RemoteControlPresenter.destroy failed:', error) + } + } } // Export presenter instance - will be initialized with database during lifecycle @@ -516,6 +617,13 @@ ipcMain.handle( ) } + if (!Presenter.DISPATCHABLE_PRESENTERS.has(name as keyof IPresenter)) { + console.warn( + `[IPC Warning] WebContents:${context.webContentsId} blocked presenter access: ${name}` + ) + return { error: `Presenter "${name}" is not accessible via generic dispatcher` } + } + // 通过名称获取对应的 Presenter 实例 // eslint-disable-next-line @typescript-eslint/no-explicit-any let calledPresenter: any = presenter[name as keyof Presenter] @@ -551,3 +659,35 @@ ipcMain.handle( } } ) + +ipcMain.handle( + 'remoteControlPresenter:call', + async (event: IpcMainInvokeEvent, method: string, ...payloads: unknown[]) => { + try { + const webContentsId = event.sender.id + const windowId = BrowserWindow.fromWebContents(event.sender)?.id + + if (import.meta.env.VITE_LOG_IPC_CALL === '1') { + console.log( + `[IPC Call] WebContents:${webContentsId} Window:${windowId || 'unknown'} -> remoteControlPresenter.${method}` + ) + } + + if (!Presenter.REMOTE_CONTROL_METHODS.has(method as keyof IRemoteControlPresenter)) { + console.warn( + `[IPC Warning] WebContents:${webContentsId} blocked remote control method: ${method}` + ) + return { error: `Method "${method}" is not allowed on "remoteControlPresenter"` } + } + + return await presenter.callRemoteControl(method as keyof IRemoteControlPresenter, ...payloads) + } catch ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + e: any + ) { + const webContentsId = event.sender.id + console.error(`[IPC Error] WebContents:${webContentsId} remoteControlPresenter.${method}:`, e) + return { error: e.message || String(e) } + } + } +) diff --git a/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/presenterDestroyHook.ts b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/presenterDestroyHook.ts index 70d8aff8d..944ba1c90 100644 --- a/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/presenterDestroyHook.ts +++ b/src/main/presenter/lifecyclePresenter/hooks/beforeQuit/presenterDestroyHook.ts @@ -19,7 +19,7 @@ export const presenterDestroyHook: LifecycleHook = { throw new Error('presenterDestroyHook: Presenter has been destroyed') } - presenter.destroy() + await presenter.destroy() console.log('presenterDestroyHook: System presenter destroyed successfully') } diff --git a/src/main/presenter/newAgentPresenter/index.ts b/src/main/presenter/newAgentPresenter/index.ts index 5920a302c..5eab1dd43 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, @@ -235,6 +236,97 @@ export class NewAgentPresenter { return sessionResult } + async createDetachedSession(input: CreateDetachedSessionInput): Promise { + const agentId = input.agentId?.trim() || 'deepchat' + const title = input.title?.trim() || 'New Chat' + const agentType = await this.getAgentType(agentId) + const deepChatAgentConfig = + agentType === 'deepchat' + ? await this.configPresenter.resolveDeepChatAgentConfig(agentId) + : null + const projectDir = + input.projectDir?.trim() || + deepChatAgentConfig?.defaultProjectPath?.trim() || + this.configPresenter.getDefaultProjectPath() || + null + const disabledAgentTools = + agentType === 'deepchat' + ? this.normalizeDisabledAgentTools( + input.disabledAgentTools ?? deepChatAgentConfig?.disabledAgentTools + ) + : [] + const agent = await this.resolveAgentImplementation(agentId) + + const defaultModel = this.configPresenter.getDefaultModel() + const providerId = + input.providerId ?? + deepChatAgentConfig?.defaultModelPreset?.providerId ?? + defaultModel?.providerId ?? + '' + const modelId = + input.modelId ?? + deepChatAgentConfig?.defaultModelPreset?.modelId ?? + defaultModel?.modelId ?? + '' + const permissionMode: PermissionMode = + input.permissionMode !== undefined + ? input.permissionMode === 'default' + ? 'default' + : 'full_access' + : deepChatAgentConfig?.permissionMode === 'default' + ? 'default' + : 'full_access' + const generationSettings = this.mergeDeepChatDefaultGenerationSettings( + deepChatAgentConfig, + input.generationSettings + ) + + 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 + }) + } 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 @@ -301,6 +393,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) { @@ -313,6 +406,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) { if ((await this.getAgentType(session.agentId)) === 'acp') { @@ -328,11 +422,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..f9cea6de0 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -0,0 +1,381 @@ +import type { HookTestResult, TelegramNotificationsConfig } from '@shared/hooksNotifications' +import type { + TelegramPairingSnapshot, + TelegramRemoteBindingSummary, + TelegramRemoteSettings, + TelegramRemoteStatus +} from '@shared/presenter' +import { + TELEGRAM_REMOTE_COMMANDS, + TELEGRAM_REMOTE_DEFAULT_AGENT_ID, + normalizeTelegramSettingsInput, + parseTelegramEndpointKey, + 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, + defaultAgentId: remoteConfig.defaultAgentId, + hookNotifications: { + enabled: hooksConfig.enabled, + chatId: hooksConfig.chatId, + threadId: hooksConfig.threadId, + events: hooksConfig.events + } + } + } + + async getTelegramSettings(): Promise { + const snapshot = this.buildTelegramSettingsSnapshot() + const defaultAgentId = await this.sanitizeDefaultAgentId(snapshot.defaultAgentId) + return { + ...snapshot, + defaultAgentId + } + } + + async saveTelegramSettings(input: TelegramRemoteSettings): Promise { + const normalized = normalizeTelegramSettingsInput(input) + const defaultAgentId = await this.sanitizeDefaultAgentId(normalized.defaultAgentId) + 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, + telegram: this.buildTelegramHookConfig(normalized, currentHooksConfig.telegram) + }) + + this.bindingStore.updateTelegramConfig((config) => ({ + ...config, + enabled: normalized.remoteEnabled, + allowlist: normalized.allowedUserIds, + defaultAgentId, + streamMode: 'draft', + lastFatalError: shouldClearFatalError ? null : config.lastFatalError, + pairing: config.pairing + })) + + await this.enqueueRuntimeOperation(async () => { + await this.rebuildTelegramRuntime() + }) + return await this.getTelegramSettings() + } + + async getTelegramStatus(): Promise { + const remoteConfig = this.bindingStore.getTelegramConfig() + const hooksConfig = this.deps.getHooksNotificationsConfig().telegram + 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, + allowedUserCount: remoteConfig.allowlist.length, + lastError: runtimeStatus.lastError, + botUser: runtimeStatus.botUser + } + } + + async getTelegramBindings(): Promise { + return this.bindingStore + .listBindings() + .map(({ endpointKey, binding }) => { + const endpoint = parseTelegramEndpointKey(endpointKey) + if (!endpoint) { + return null + } + + return { + endpointKey, + sessionId: binding.sessionId, + chatId: endpoint.chatId, + messageThreadId: endpoint.messageThreadId, + updatedAt: binding.updatedAt + } + }) + .filter((binding): binding is TelegramRemoteBindingSummary => binding !== null) + .sort((left, right) => right.updatedAt - left.updatedAt) + } + + async removeTelegramBinding(endpointKey: string): Promise { + this.bindingStore.clearBinding(endpointKey) + } + + async getTelegramPairingSnapshot(): Promise { + return this.bindingStore.getPairingSnapshot() + } + + 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 client = new TelegramClient(botToken) + await this.registerTelegramCommands(client) + + const authGuard = new RemoteAuthGuard(this.bindingStore) + const runner = new RemoteConversationRunner( + { + configPresenter: this.deps.configPresenter, + newAgentPresenter: this.deps.newAgentPresenter, + deepchatAgentPresenter: this.deps.deepchatAgentPresenter, + windowPresenter: this.deps.windowPresenter, + tabPresenter: this.deps.tabPresenter, + resolveDefaultAgentId: async () => + await this.sanitizeDefaultAgentId(this.bindingStore.getDefaultAgentId()) + }, + this.bindingStore + ) + const router = new RemoteCommandRouter({ + authGuard, + runner, + bindingStore: this.bindingStore, + getPollerStatus: () => this.getEffectivePollerStatus(botToken, true, null) + }) + + this.telegramPoller = new TelegramPoller({ + client, + parser: new TelegramParser(), + router, + bindingStore: this.bindingStore, + onStatusChange: (snapshot) => { + this.telegramPollerStatus = snapshot + }, + onFatalError: (message) => { + void this.enqueueRuntimeOperation(async () => { + await this.disableTelegramRuntimeForFatalError(botToken, message) + }) + } + }) + + 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, + lastFatalError: string | null + ): TelegramPollerStatusSnapshot { + if (!remoteEnabled) { + if (lastFatalError) { + return { + state: 'error', + lastError: lastFatalError, + botUser: null + } + } + + return { + state: 'disabled', + lastError: null, + botUser: null + } + } + + if (!botToken.trim()) { + return { + state: 'error', + lastError: 'Bot token is required.', + botUser: null + } + } + + 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(() => {}) + return nextOperation + } + + private async sanitizeDefaultAgentId(candidate: string | null | undefined): Promise { + const normalizedCandidate = candidate?.trim() || TELEGRAM_REMOTE_DEFAULT_AGENT_ID + const agents = await this.deps.configPresenter.listAgents() + const enabledDeepChatAgents = agents.filter( + (agent) => agent.type === 'deepchat' && agent.enabled !== false + ) + const enabledAgentIds = new Set(enabledDeepChatAgents.map((agent) => agent.id)) + const nextDefaultAgentId = enabledAgentIds.has(normalizedCandidate) + ? normalizedCandidate + : enabledAgentIds.has(TELEGRAM_REMOTE_DEFAULT_AGENT_ID) + ? TELEGRAM_REMOTE_DEFAULT_AGENT_ID + : enabledDeepChatAgents[0]?.id || TELEGRAM_REMOTE_DEFAULT_AGENT_ID + + if (this.bindingStore.getDefaultAgentId() !== nextDefaultAgentId) { + this.bindingStore.updateTelegramConfig((config) => ({ + ...config, + defaultAgentId: nextDefaultAgentId + })) + } + + return nextDefaultAgentId + } + + private async registerTelegramCommands(client: TelegramClient): Promise { + try { + await client.setMyCommands([...TELEGRAM_REMOTE_COMMANDS]) + } catch (error) { + console.warn('[RemoteControlPresenter] Failed to register Telegram commands:', error) + } + } +} 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..428e6fdc9 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts @@ -0,0 +1,84 @@ +import type { TelegramInboundEvent, 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(event: TelegramInboundEvent): RemoteAuthResult { + if (event.chatType !== 'private') { + return { + ok: false, + message: 'Telegram remote control only supports private chats in v1.' + } + } + + if (!event.fromId || !Number.isInteger(event.fromId) || event.fromId <= 0) { + return { + ok: false, + message: 'Unable to verify your Telegram user ID.' + } + } + + if (this.bindingStore.isAllowedUser(event.fromId)) { + return { + ok: true, + userId: event.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..d22f5678d --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts @@ -0,0 +1,255 @@ +import type { IConfigPresenter } from '@shared/presenter' +import { + REMOTE_CONTROL_SETTING_KEY, + TELEGRAM_MODEL_MENU_TTL_MS, + buildTelegramEndpointKey, + normalizeRemoteControlConfig, + createPairCode, + createTelegramCallbackToken, + buildTelegramPairingSnapshot, + type RemoteControlConfig, + type TelegramEndpointBinding, + type TelegramInboundEvent, + type TelegramModelMenuState, + type TelegramPairingState, + type TelegramRemoteRuntimeConfig +} from '../types' + +export class RemoteBindingStore { + private readonly activeEvents = new Map() + private readonly sessionSnapshots = new Map() + private readonly modelMenuStates = 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 } | TelegramInboundEvent + ): 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) + this.clearModelMenuStatesForEndpoint(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) + this.clearModelMenuStatesForEndpoint(endpointKey) + } + + listBindings(): Array<{ + endpointKey: string + binding: TelegramEndpointBinding + }> { + return Object.entries(this.getTelegramConfig().bindings).map(([endpointKey, binding]) => ({ + endpointKey, + binding + })) + } + + clearBindings(): number { + const count = Object.keys(this.getTelegramConfig().bindings).length + this.updateTelegramConfig((config) => ({ + ...config, + bindings: {} + })) + this.activeEvents.clear() + this.sessionSnapshots.clear() + this.modelMenuStates.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 + } + + getDefaultAgentId(): string { + return this.getTelegramConfig().defaultAgentId + } + + 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(): TelegramPairingState { + return this.getTelegramConfig().pairing + } + + getPairingSnapshot() { + return buildTelegramPairingSnapshot(this.getTelegramConfig()) + } + + 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) ?? [] + } + + createModelMenuState( + endpointKey: string, + sessionId: string, + providers: TelegramModelMenuState['providers'] + ): string { + this.clearExpiredModelMenuStates() + this.clearModelMenuStatesForEndpoint(endpointKey) + const token = createTelegramCallbackToken() + this.modelMenuStates.set(token, { + endpointKey, + sessionId, + createdAt: Date.now(), + providers: providers.map((provider) => ({ + ...provider, + models: provider.models.map((model) => ({ ...model })) + })) + }) + return token + } + + getModelMenuState(token: string, ttlMs: number): TelegramModelMenuState | null { + this.clearExpiredModelMenuStates() + const state = this.modelMenuStates.get(token) + if (!state) { + return null + } + + if (Date.now() - state.createdAt > ttlMs) { + this.modelMenuStates.delete(token) + return null + } + + return { + ...state, + providers: state.providers.map((provider) => ({ + ...provider, + models: provider.models.map((model) => ({ ...model })) + })) + } + } + + clearModelMenuState(token: string): void { + this.modelMenuStates.delete(token) + } + + private clearExpiredModelMenuStates(): void { + const now = Date.now() + for (const [token, state] of this.modelMenuStates.entries()) { + if (now - state.createdAt > TELEGRAM_MODEL_MENU_TTL_MS) { + this.modelMenuStates.delete(token) + } + } + } + + private clearModelMenuStatesForEndpoint(endpointKey: string): void { + for (const [token, state] of this.modelMenuStates.entries()) { + if (state.endpointKey === endpointKey) { + this.modelMenuStates.delete(token) + } + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts new file mode 100644 index 000000000..1f17ac06b --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts @@ -0,0 +1,478 @@ +import type { + TelegramCallbackAnswer, + TelegramInboundCallbackQuery, + TelegramInboundEvent, + TelegramInboundMessage, + TelegramInlineKeyboardMarkup, + TelegramModelProviderOption, + TelegramOutboundAction, + TelegramPollerStatusSnapshot +} from '../types' +import { + TELEGRAM_MODEL_MENU_TTL_MS, + buildModelMenuBackCallbackData, + buildModelMenuCancelCallbackData, + buildModelMenuChoiceCallbackData, + buildModelMenuProviderCallbackData, + parseModelMenuCallbackData +} from '../types' +import type { RemoteConversationExecution } from './remoteConversationRunner' +import { RemoteAuthGuard } from './remoteAuthGuard' +import { RemoteBindingStore } from './remoteBindingStore' +import { RemoteConversationRunner } from './remoteConversationRunner' + +export interface RemoteCommandRouteResult { + replies: string[] + outboundActions?: TelegramOutboundAction[] + conversation?: RemoteConversationExecution + callbackAnswer?: TelegramCallbackAnswer +} + +type RemoteCommandRouterDeps = { + authGuard: RemoteAuthGuard + runner: RemoteConversationRunner + bindingStore: RemoteBindingStore + getPollerStatus: () => TelegramPollerStatusSnapshot +} + +export class RemoteCommandRouter { + constructor(private readonly deps: RemoteCommandRouterDeps) {} + + async handleMessage(event: TelegramInboundEvent): Promise { + if (event.kind === 'callback_query') { + return await this.handleCallbackQuery(event) + } + + return await this.handleTextMessage(event) + } + + private async handleTextMessage( + 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 'model': { + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session) { + return { + replies: ['No bound session. Send a message, /new, or /use first.'] + } + } + + const providers = await this.deps.runner.listAvailableModelProviders() + if (providers.length === 0) { + return { + replies: ['No enabled providers or models are available.'] + } + } + + const token = this.deps.bindingStore.createModelMenuState( + endpointKey, + session.id, + providers + ) + + return { + replies: [], + outboundActions: [ + { + type: 'sendMessage', + text: this.formatProviderMenuText(session), + replyMarkup: this.buildProviderMenuKeyboard(token, providers) + } + ] + } + } + + case 'status': { + const runtime = this.deps.getPollerStatus() + const status = await this.deps.runner.getStatus(endpointKey) + const defaultAgentId = await this.deps.runner.getDefaultAgentId() + const telegramConfig = this.deps.bindingStore.getTelegramConfig() + return { + replies: [ + [ + 'DeepChat Telegram Remote', + `Runtime: ${runtime.state}`, + `Stream mode: ${telegramConfig.streamMode}`, + `Default agent: ${defaultAgentId}`, + `Current session: ${status.session ? this.formatSessionLabel(status.session) : 'none'}`, + `Current agent: ${status.session?.agentId ?? 'none'}`, + `Current model: ${status.session?.modelId ?? '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 async handleCallbackQuery( + event: TelegramInboundCallbackQuery + ): Promise { + const endpointKey = this.deps.bindingStore.getEndpointKey(event) + const auth = this.deps.authGuard.ensureAuthorized(event) + if (!auth.ok) { + return { + replies: [], + callbackAnswer: { + text: auth.message, + showAlert: true + } + } + } + + const callback = parseModelMenuCallbackData(event.data) + if (!callback) { + return { + replies: [], + callbackAnswer: { + text: 'Unsupported Telegram remote action.', + showAlert: false + } + } + } + + const state = this.deps.bindingStore.getModelMenuState( + callback.token, + TELEGRAM_MODEL_MENU_TTL_MS + ) + const expiredResult = this.buildExpiredMenuResult(event.messageId) + if (!state || state.endpointKey !== endpointKey) { + return expiredResult + } + + const session = await this.deps.runner.getCurrentSession(endpointKey) + if (!session || session.id !== state.sessionId) { + this.deps.bindingStore.clearModelMenuState(callback.token) + return expiredResult + } + + try { + switch (callback.action) { + case 'provider': { + const provider = state.providers[callback.providerIndex] + if (!provider) { + return expiredResult + } + + return { + replies: [], + outboundActions: [ + { + type: 'editMessageText', + messageId: event.messageId, + text: this.formatModelMenuText(session, provider), + replyMarkup: this.buildModelMenuKeyboard( + callback.token, + callback.providerIndex, + provider + ) + } + ] + } + } + + case 'model': { + const provider = state.providers[callback.providerIndex] + const model = provider?.models[callback.modelIndex] + if (!provider || !model) { + return expiredResult + } + + const updatedSession = await this.deps.runner.setSessionModel( + endpointKey, + provider.providerId, + model.modelId + ) + this.deps.bindingStore.clearModelMenuState(callback.token) + + return { + replies: [], + outboundActions: [ + { + type: 'editMessageText', + messageId: event.messageId, + text: [ + 'Model updated.', + `Session: ${this.formatSessionLabel(updatedSession)}`, + `Provider: ${provider.providerName}`, + `Model: ${model.modelName}` + ].join('\n'), + replyMarkup: null + } + ], + callbackAnswer: { + text: 'Model switched.' + } + } + } + + case 'back': + return { + replies: [], + outboundActions: [ + { + type: 'editMessageText', + messageId: event.messageId, + text: this.formatProviderMenuText(session), + replyMarkup: this.buildProviderMenuKeyboard(callback.token, state.providers) + } + ] + } + + case 'cancel': + this.deps.bindingStore.clearModelMenuState(callback.token) + return { + replies: [], + outboundActions: [ + { + type: 'editMessageText', + messageId: event.messageId, + text: 'Model selection cancelled.', + replyMarkup: null + } + ], + callbackAnswer: { + text: 'Cancelled.' + } + } + } + } catch (error) { + return { + replies: [], + callbackAnswer: { + text: error instanceof Error ? error.message : String(error), + showAlert: true + } + } + } + } + + private buildExpiredMenuResult(messageId: number): RemoteCommandRouteResult { + return { + replies: [], + outboundActions: [ + { + type: 'editMessageText', + messageId, + text: 'Model menu expired. Run /model again.', + replyMarkup: null + } + ], + callbackAnswer: { + text: 'Model menu expired. Run /model again.', + showAlert: true + } + } + } + + private buildProviderMenuKeyboard( + token: string, + providers: TelegramModelProviderOption[] + ): TelegramInlineKeyboardMarkup { + return { + inline_keyboard: [ + ...providers.map((provider, index) => [ + { + text: provider.providerName, + callback_data: buildModelMenuProviderCallbackData(token, index) + } + ]), + [ + { + text: 'Cancel', + callback_data: buildModelMenuCancelCallbackData(token) + } + ] + ] + } + } + + private buildModelMenuKeyboard( + token: string, + providerIndex: number, + provider: TelegramModelProviderOption + ): TelegramInlineKeyboardMarkup { + return { + inline_keyboard: [ + ...provider.models.map((model, modelIndex) => [ + { + text: model.modelName, + callback_data: buildModelMenuChoiceCallbackData(token, providerIndex, modelIndex) + } + ]), + [ + { + text: 'Back', + callback_data: buildModelMenuBackCallbackData(token) + }, + { + text: 'Cancel', + callback_data: buildModelMenuCancelCallbackData(token) + } + ] + ] + } + } + + 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', + '/status', + '/model', + 'Plain text sends to the current bound session.' + ].join('\n') + } + + private formatProviderMenuText(session: { + title: string + id: string + providerId: string + modelId: string + }): string { + return [ + `Session: ${this.formatSessionLabel(session)}`, + `Current: ${session.providerId || 'none'} / ${session.modelId || 'none'}`, + 'Choose a provider:' + ].join('\n') + } + + private formatModelMenuText( + session: { title: string; id: string; providerId: string; modelId: string }, + provider: TelegramModelProviderOption + ): string { + return [ + `Session: ${this.formatSessionLabel(session)}`, + `Current: ${session.providerId || 'none'} / ${session.modelId || 'none'}`, + `Provider: ${provider.providerName}`, + 'Choose a model:' + ].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..c5e23b94a --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts @@ -0,0 +1,431 @@ +import { BrowserWindow } from 'electron' +import type { ChatMessageRecord, SessionWithState } from '@shared/types/agent-interface' +import type { + IConfigPresenter, + INewAgentPresenter, + ITabPresenter, + IWindowPresenter +} from '@shared/presenter' +import type { DeepChatAgentPresenter } from '../../deepchatAgentPresenter' +import { + TELEGRAM_RECENT_SESSION_LIMIT, + TELEGRAM_STREAM_POLL_INTERVAL_MS, + type TelegramModelProviderOption +} 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 = { + configPresenter: IConfigPresenter + newAgentPresenter: INewAgentPresenter + deepchatAgentPresenter: DeepChatAgentPresenter + windowPresenter: IWindowPresenter + tabPresenter: ITabPresenter + resolveDefaultAgentId: () => Promise +} + +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 agentId = await this.deps.resolveDefaultAgentId() + const session = await this.deps.newAgentPresenter.createDetachedSession({ + title: title?.trim() || 'New Chat', + agentId + }) + 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 agentId = await this.resolveSessionListAgentId(endpointKey) + const sessions = await this.deps.newAgentPresenter.getSessionList({ + agentId + }) + 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 listAvailableModelProviders(): Promise { + const enabledProviders = this.deps.configPresenter.getEnabledProviders() + const enabledModelGroups = await this.deps.configPresenter.getAllEnabledModels() + const providerNameById = new Map( + enabledProviders.map((provider) => [provider.id, provider.name]) + ) + + return enabledModelGroups + .filter((group) => providerNameById.has(group.providerId) && group.models.length > 0) + .map((group) => ({ + providerId: group.providerId, + providerName: providerNameById.get(group.providerId) ?? group.providerId, + models: group.models.map((model) => ({ + modelId: model.id, + modelName: model.name || model.id + })) + })) + } + + async setSessionModel( + endpointKey: string, + providerId: string, + modelId: string + ): Promise { + const session = await this.getCurrentSession(endpointKey) + if (!session) { + throw new Error('No bound session. Send a message, /new, or /use first.') + } + + return await this.deps.newAgentPresenter.setSessionModel(session.id, providerId, modelId) + } + + 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' + } + } + + async getDefaultAgentId(): Promise { + return await this.deps.resolveDefaultAgentId() + } + + private async resolveSessionListAgentId(endpointKey: string): Promise { + const currentSession = await this.getCurrentSession(endpointKey) + return currentSession?.agentId ?? (await this.deps.resolveDefaultAgentId()) + } + + 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, + options?.ignoreMessageId + ) + 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, + tracking.ignoreMessageId + ) + } + + private async findLatestAssistantMessageAfter( + sessionId: string, + afterOrderSeq: number, + ignoreMessageId?: string | null + ): Promise { + const messages = await this.deps.newAgentPresenter.getMessages(sessionId) + const assistants = messages.filter( + (message) => + message.role === 'assistant' && + message.orderSeq > afterOrderSeq && + message.id !== ignoreMessageId + ) + 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..2b402782a --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramClient.ts @@ -0,0 +1,240 @@ +import type { TelegramInlineKeyboardMarkup, 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 TelegramRawCallbackQuery = { + id: string + from?: TelegramUser + message?: TelegramRawMessage + data?: string +} + +export type TelegramRawUpdate = { + update_id: number + message?: TelegramRawMessage + callback_query?: TelegramRawCallbackQuery +} + +export type TelegramBotUser = { + id: number + username?: string +} + +export type TelegramBotCommand = { + command: string + description: string +} + +const buildReplyMarkup = ( + replyMarkup?: TelegramInlineKeyboardMarkup | null +): TelegramInlineKeyboardMarkup | undefined => + replyMarkup === null ? { inline_keyboard: [] } : replyMarkup + +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, + replyMarkup?: TelegramInlineKeyboardMarkup + ): Promise { + await this.request('sendMessage', { + chat_id: target.chatId, + message_thread_id: target.messageThreadId || undefined, + text, + reply_markup: buildReplyMarkup(replyMarkup) + }) + } + + 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 + }) + } + + async setMyCommands(commands: TelegramBotCommand[]): Promise { + await this.request('setMyCommands', { + commands + }) + } + + async editMessageText(params: { + target: TelegramTransportTarget + messageId: number + text: string + replyMarkup?: TelegramInlineKeyboardMarkup | null + }): Promise { + await this.request('editMessageText', { + chat_id: params.target.chatId, + message_id: params.messageId, + text: params.text, + reply_markup: buildReplyMarkup(params.replyMarkup) + }) + } + + async editMessageReplyMarkup(params: { + target: TelegramTransportTarget + messageId: number + replyMarkup?: TelegramInlineKeyboardMarkup | null + }): Promise { + await this.request('editMessageReplyMarkup', { + chat_id: params.target.chatId, + message_id: params.messageId, + reply_markup: buildReplyMarkup(params.replyMarkup) + }) + } + + async answerCallbackQuery(params: { + callbackQueryId: string + text?: string + showAlert?: boolean + }): Promise { + await this.request('answerCallbackQuery', { + callback_query_id: params.callbackQueryId, + text: params.text, + show_alert: params.showAlert + }) + } + + async setMessageReaction(params: { + chatId: number + messageId: number + emoji?: string | null + }): Promise { + await this.request('setMessageReaction', { + chat_id: params.chatId, + message_id: params.messageId, + reaction: params.emoji + ? [ + { + type: 'emoji', + emoji: params.emoji + } + ] + : [] + }) + } + + 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..052441282 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramParser.ts @@ -0,0 +1,51 @@ +import type { TelegramInboundEvent } 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): TelegramInboundEvent | null { + const callbackQuery = update.callback_query + if (callbackQuery?.message && typeof callbackQuery.data === 'string' && callbackQuery.data) { + return { + kind: 'callback_query', + updateId: update.update_id, + chatId: Number(callbackQuery.message.chat.id), + messageThreadId: Number(callbackQuery.message.message_thread_id ?? 0), + messageId: Number(callbackQuery.message.message_id), + chatType: callbackQuery.message.chat.type, + fromId: typeof callbackQuery.from?.id === 'number' ? Number(callbackQuery.from.id) : null, + callbackQueryId: callbackQuery.id, + data: callbackQuery.data.trim() + } + } + + 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 { + kind: 'message', + 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..cfac17c8f --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramPoller.ts @@ -0,0 +1,480 @@ +import { + TELEGRAM_REMOTE_REACTION_EMOJI, + TELEGRAM_REMOTE_POLL_LIMIT, + TELEGRAM_REMOTE_POLL_TIMEOUT_SEC, + TELEGRAM_STREAM_POLL_INTERVAL_MS, + TELEGRAM_TYPING_DELAY_MS, + type TelegramOutboundAction, + 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 CALLBACK_QUERY_ACK_TIMEOUT_MS = 500 + +const sleep = (ms: number, signal?: AbortSignal): Promise => { + if (signal?.aborted) { + return Promise.resolve() + } + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', handleAbort) + resolve() + }, ms) + + const handleAbort = () => { + clearTimeout(timeout) + signal?.removeEventListener('abort', handleAbort) + resolve() + } + + signal?.addEventListener('abort', handleAbort, { once: true }) + }) +} + +type TelegramPollerDeps = { + client: TelegramClient + parser: TelegramParser + router: RemoteCommandRouter + bindingStore: RemoteBindingStore + onStatusChange?: (snapshot: TelegramPollerStatusSnapshot) => void + onFatalError?: (message: string) => 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) { + const pollSignal = this.createPollSignal() + let updates: TelegramRawUpdate[] + + try { + await this.ensureBotIdentity() + this.setStatus({ + state: 'running', + lastError: null + }) + + updates = await this.deps.client.getUpdates({ + offset: this.deps.bindingStore.getPollOffset(), + limit: TELEGRAM_REMOTE_POLL_LIMIT, + timeout: TELEGRAM_REMOTE_POLL_TIMEOUT_SEC, + allowedUpdates: ['message', 'callback_query'], + signal: pollSignal + }) + + backoffIndex = 0 + } catch (error) { + if (this.stopRequested) { + return + } + + const lastError = error instanceof Error ? error.message : String(error) + if (this.isFatalPollError(error)) { + this.setStatus({ + state: 'error', + lastError + }) + this.deps.onFatalError?.(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, pollSignal) + continue + } + + for (const update of updates) { + if (this.stopRequested) { + return + } + + // Persist the next offset before processing to avoid replaying + // partially-delivered Telegram side effects after restart. + this.deps.bindingStore.setPollOffset(update.update_id + 1) + + try { + await this.handleRawUpdate(update) + } catch (error) { + if (this.stopRequested) { + return + } + + console.warn('[TelegramPoller] Failed to handle update:', { + updateId: update.update_id, + error + }) + } + } + } + } + + 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 callbackAcknowledger = + parsed.kind === 'callback_query' + ? this.createCallbackQueryAcknowledger(parsed.callbackQueryId) + : null + + let routed: Awaited> + try { + routed = await this.deps.router.handleMessage(parsed) + } catch (error) { + if (callbackAcknowledger) { + await callbackAcknowledger.answer() + } + throw error + } + + if (callbackAcknowledger) { + await callbackAcknowledger.answer(routed.callbackAnswer) + } + + for (const reply of routed.replies) { + await this.sendChunkedMessage(target, reply) + } + + if (routed.outboundActions?.length) { + await this.dispatchOutboundActions(target, routed.outboundActions) + } + + if (routed.conversation) { + const reactionMessage = parsed.kind === 'message' && !parsed.command ? parsed : null + + if (reactionMessage) { + await this.setIncomingReaction(reactionMessage.chatId, reactionMessage.messageId) + } + + try { + await this.deliverConversation(target, routed.conversation) + } finally { + if (reactionMessage) { + await this.clearIncomingReaction(reactionMessage.chatId, reactionMessage.messageId) + } + } + } + } + + 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 async dispatchOutboundActions( + target: TelegramTransportTarget, + actions: TelegramOutboundAction[] + ): Promise { + for (const action of actions) { + if (action.type === 'sendMessage') { + if (action.replyMarkup) { + await this.deps.client.sendMessage(target, action.text, action.replyMarkup) + continue + } + + await this.sendChunkedMessage(target, action.text) + continue + } + + await this.editMessageText(target, action) + } + } + + private async editMessageText( + target: TelegramTransportTarget, + action: Extract + ): Promise { + try { + await this.deps.client.editMessageText({ + target, + messageId: action.messageId, + text: action.text, + replyMarkup: action.replyMarkup ?? undefined + }) + } catch (error) { + if (this.isMessageNotModifiedError(error)) { + return + } + + throw error + } + } + + private async setIncomingReaction(chatId: number, messageId: number): Promise { + try { + await this.deps.client.setMessageReaction({ + chatId, + messageId, + emoji: TELEGRAM_REMOTE_REACTION_EMOJI + }) + } catch (error) { + console.warn('[TelegramPoller] Failed to set message reaction:', error) + } + } + + private async clearIncomingReaction(chatId: number, messageId: number): Promise { + try { + await this.deps.client.setMessageReaction({ + chatId, + messageId, + emoji: null + }) + } catch (error) { + console.warn('[TelegramPoller] Failed to clear message reaction:', error) + } + } + + private async answerCallbackQuery( + callbackQueryId: string, + answer?: { + text?: string + showAlert?: boolean + } + ): Promise { + try { + await this.deps.client.answerCallbackQuery({ + callbackQueryId, + text: answer?.text, + showAlert: answer?.showAlert + }) + } catch (error) { + if (this.isExpiredCallbackQueryError(error)) { + return + } + + console.warn('[TelegramPoller] Failed to answer callback query:', error) + } + } + + private createCallbackQueryAcknowledger(callbackQueryId: string): { + answer: (answer?: { text?: string; showAlert?: boolean }) => Promise + } { + let answered = false + const timer = setTimeout(() => { + if (answered) { + return + } + + answered = true + void this.answerCallbackQuery(callbackQueryId) + }, CALLBACK_QUERY_ACK_TIMEOUT_MS) + + return { + answer: async (answer) => { + clearTimeout(timer) + if (answered) { + return + } + + answered = true + await this.answerCallbackQuery(callbackQueryId, answer) + } + } + } + + private isExpiredCallbackQueryError(error: unknown): boolean { + return ( + error instanceof TelegramApiRequestError && + error.code === 400 && + /query is too old|query id is invalid|response timeout expired/i.test(error.message) + ) + } + + private isMessageNotModifiedError(error: unknown): boolean { + return ( + error instanceof TelegramApiRequestError && + error.code === 400 && + /message is not modified/i.test(error.message) + ) + } + + private isFatalPollError(error: unknown): boolean { + if (error instanceof TelegramApiRequestError) { + return typeof error.code === 'number' && error.code >= 400 && error.code < 500 + ? error.code !== 429 + : false + } + + 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..0ed146f28 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/types.ts @@ -0,0 +1,429 @@ +import { z } from 'zod' +import type { HookEventName } from '@shared/hooksNotifications' +import type { + TelegramPairingSnapshot, + TelegramRemoteBindingSummary, + 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 const TELEGRAM_MODEL_MENU_TTL_MS = 10 * 60 * 1000 +export const TELEGRAM_REMOTE_DEFAULT_AGENT_ID = 'deepchat' +export const TELEGRAM_REMOTE_REACTION_EMOJI = '🤯' +export const TELEGRAM_REMOTE_COMMANDS = [ + { + command: 'start', + description: 'Show remote control status' + }, + { + command: 'help', + description: 'Show available commands' + }, + { + command: 'pair', + description: 'Authorize this Telegram account' + }, + { + command: 'new', + description: 'Start a new DeepChat session' + }, + { + command: 'sessions', + description: 'List recent sessions' + }, + { + command: 'use', + description: 'Bind a listed session' + }, + { + command: 'stop', + description: 'Stop the active generation' + }, + { + command: 'model', + description: 'Switch provider and model' + }, + { + command: 'status', + description: 'Show runtime and session status' + } +] as const + +export type TelegramEndpointBinding = { + sessionId: string + updatedAt: number +} + +export type TelegramPairingState = { + code: string | null + expiresAt: number | null +} + +export type TelegramCommandPayload = { + name: string + args: string +} + +export interface TelegramRemoteRuntimeConfig { + enabled: boolean + allowlist: number[] + streamMode: TelegramStreamMode + defaultAgentId: string + pollOffset: number + lastFatalError: string | null + pairing: TelegramPairingState + bindings: Record +} + +export interface RemoteControlConfig { + telegram: TelegramRemoteRuntimeConfig +} + +interface TelegramInboundBase { + updateId: number + chatId: number + messageThreadId: number + messageId: number + chatType: string + fromId: number | null +} + +export interface TelegramInboundMessage extends TelegramInboundBase { + kind: 'message' + text: string + command: TelegramCommandPayload | null +} + +export interface TelegramInboundCallbackQuery extends TelegramInboundBase { + kind: 'callback_query' + callbackQueryId: string + data: string +} + +export type TelegramInboundEvent = TelegramInboundMessage | TelegramInboundCallbackQuery + +export interface TelegramInlineKeyboardButton { + text: string + callback_data: string +} + +export interface TelegramInlineKeyboardMarkup { + inline_keyboard: TelegramInlineKeyboardButton[][] +} + +export type TelegramOutboundAction = + | { + type: 'sendMessage' + text: string + replyMarkup?: TelegramInlineKeyboardMarkup + } + | { + type: 'editMessageText' + messageId: number + text: string + replyMarkup?: TelegramInlineKeyboardMarkup | null + } + +export interface TelegramCallbackAnswer { + text?: string + showAlert?: boolean +} + +export interface TelegramModelOption { + modelId: string + modelName: string +} + +export interface TelegramModelProviderOption { + providerId: string + providerName: string + models: TelegramModelOption[] +} + +export interface TelegramModelMenuState { + endpointKey: string + sessionId: string + createdAt: number + providers: TelegramModelProviderOption[] +} + +export type TelegramModelMenuCallback = + | { + action: 'provider' + token: string + providerIndex: number + } + | { + action: 'model' + token: string + providerIndex: number + modelIndex: number + } + | { + action: 'back' | 'cancel' + token: string + } + +const TELEGRAM_MODEL_MENU_CALLBACK_PREFIX = 'model' + +export const createTelegramCallbackToken = (): string => + `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` + +export const buildModelMenuProviderCallbackData = (token: string, providerIndex: number): string => + `${TELEGRAM_MODEL_MENU_CALLBACK_PREFIX}:${token}:p:${providerIndex}` + +export const buildModelMenuChoiceCallbackData = ( + token: string, + providerIndex: number, + modelIndex: number +): string => `${TELEGRAM_MODEL_MENU_CALLBACK_PREFIX}:${token}:m:${providerIndex}:${modelIndex}` + +export const buildModelMenuBackCallbackData = (token: string): string => + `${TELEGRAM_MODEL_MENU_CALLBACK_PREFIX}:${token}:b` + +export const buildModelMenuCancelCallbackData = (token: string): string => + `${TELEGRAM_MODEL_MENU_CALLBACK_PREFIX}:${token}:c` + +export const parseModelMenuCallbackData = (data: string): TelegramModelMenuCallback | null => { + const parts = data.trim().split(':') + if (parts[0] !== TELEGRAM_MODEL_MENU_CALLBACK_PREFIX || !parts[1]) { + return null + } + + const token = parts[1] + const action = parts[2] + if (action === 'p' && parts[3] !== undefined) { + const providerIndex = Number.parseInt(parts[3], 10) + if (Number.isInteger(providerIndex) && providerIndex >= 0) { + return { + action: 'provider', + token, + providerIndex + } + } + } + + if (action === 'm' && parts[3] !== undefined && parts[4] !== undefined) { + const providerIndex = Number.parseInt(parts[3], 10) + const modelIndex = Number.parseInt(parts[4], 10) + if ( + Number.isInteger(providerIndex) && + providerIndex >= 0 && + Number.isInteger(modelIndex) && + modelIndex >= 0 + ) { + return { + action: 'model', + token, + providerIndex, + modelIndex + } + } + } + + if (action === 'b') { + return { + action: 'back', + token + } + } + + if (action === 'c') { + return { + action: 'cancel', + token + } + } + + return 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', + defaultAgentId: TELEGRAM_REMOTE_DEFAULT_AGENT_ID, + pollOffset: 0, + lastFatalError: null, + 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(), + defaultAgentId: 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(), z.unknown()).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 ?? {})) { + const parsedBinding = TelegramEndpointBindingSchema.safeParse(binding) + if (!parsedBinding.success) { + continue + } + + const normalizedSessionId = parsedBinding.data.sessionId.trim() + if (!normalizedSessionId) { + continue + } + + bindings[endpointKey] = { + sessionId: normalizedSessionId, + updatedAt: parsedBinding.data.updatedAt ?? Date.now() + } + } + + return { + telegram: { + enabled: Boolean(telegram.enabled), + allowlist: normalizeTelegramUserIds(telegram.allowlist), + streamMode: 'draft', + defaultAgentId: telegram.defaultAgentId?.trim() || defaults.telegram.defaultAgentId, + pollOffset: + 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: + 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 parseTelegramEndpointKey = ( + endpointKey: string +): Pick | null => { + const match = /^telegram:(-?\d+):(-?\d+)$/.exec(endpointKey.trim()) + if (!match) { + return null + } + + return { + chatId: Number.parseInt(match[1], 10), + messageThreadId: Number.parseInt(match[2], 10) + } +} + +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), + defaultAgentId: input.defaultAgentId?.trim() || TELEGRAM_REMOTE_DEFAULT_AGENT_ID, + 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 ?? [])) + } +}) + +export const buildTelegramPairingSnapshot = ( + settings: TelegramRemoteRuntimeConfig +): TelegramPairingSnapshot => ({ + pairCode: settings.pairing.code, + pairCodeExpiresAt: settings.pairing.expiresAt, + allowedUserIds: [...settings.allowlist] +}) 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..73d606dd6 --- /dev/null +++ b/src/renderer/settings/components/RemoteSettings.vue @@ -0,0 +1,832 @@ + + + diff --git a/src/renderer/settings/components/prompt/SystemPromptSettingsSection.vue b/src/renderer/settings/components/prompt/SystemPromptSettingsSection.vue index 0130983fc..87c10e9fe 100644 --- a/src/renderer/settings/components/prompt/SystemPromptSettingsSection.vue +++ b/src/renderer/settings/components/prompt/SystemPromptSettingsSection.vue @@ -242,8 +242,7 @@ const resetDefaultSystemPrompt = async () => { try { const originalContent = `You are DeepChat, a highly capable AI assistant. Your goal is to fully complete the user's requested task before handing the conversation back to them. Keep working autonomously until the task is fully resolved. Be thorough in gathering information. Before replying, make sure you have all the details necessary to provide a complete solution. Use additional tools or ask clarifying questions when needed, but if you can find the answer on your own, avoid asking the user for help. -When using tools, briefly describe your intended steps first—for example, which tool you'll use and for what purpose. -Adhere to this in all languages.respond in the same language as the user's query.` +When using tools, briefly describe your intended steps first—for example, which tool you'll use and for what purpose.` await systemPromptStore.updateSystemPrompt('default', { content: originalContent, diff --git a/src/renderer/settings/main.ts b/src/renderer/settings/main.ts index 01e61c875..17805411a 100644 --- a/src/renderer/settings/main.ts +++ b/src/renderer/settings/main.ts @@ -102,6 +102,16 @@ const router = createRouter({ position: 4 } }, + { + 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/components/WindowSideBar.vue b/src/renderer/src/components/WindowSideBar.vue index 17238e81f..984cd42c6 100644 --- a/src/renderer/src/components/WindowSideBar.vue +++ b/src/renderer/src/components/WindowSideBar.vue @@ -51,6 +51,21 @@
+ + + + + {{ remoteControlTooltip }} + + @@ -226,7 +241,7 @@