diff --git a/docs/specs/remote-multi-channel/plan.md b/docs/specs/remote-multi-channel/plan.md new file mode 100644 index 000000000..560df832a --- /dev/null +++ b/docs/specs/remote-multi-channel/plan.md @@ -0,0 +1,51 @@ +# Remote Multi-Channel Plan + +## Main Process + +- Expand `remoteControl` config normalization to include `telegram` and `feishu`. +- Keep the existing Telegram runtime, but add Feishu runtime management beside it. +- Reuse `RemoteConversationRunner` for both channels by passing endpoint binding metadata. +- Add a Feishu WebSocket runtime with: + - bot identity probe + - inbound message parsing + - endpoint-scoped serial queue + - text command routing + - final-text response delivery + +## Shared Contracts + +- Add `RemoteChannel = 'telegram' | 'feishu'`. +- Add channel-aware presenter methods: + - `getChannelSettings` + - `saveChannelSettings` + - `getChannelStatus` + - `getChannelBindings` + - `removeChannelBinding` + - `getChannelPairingSnapshot` + - `createChannelPairCode` + - `clearChannelPairCode` + - `clearChannelBindings` + - `getRemoteOverview` +- Keep Telegram hook test API separate. + +## Renderer + +- Rebuild `RemoteSettings.vue` into: + - shared overview header + - Telegram tab + - Feishu tab +- Telegram tab keeps hooks UI. +- Feishu tab only shows remote-control related sections. +- Binding rows display endpoint badges (`DM`, `Group`, `Topic`) instead of raw endpoint keys only. + +## Telegram Fix + +- Restrict draft-stream text extraction to stable visible content blocks. +- When no visible draft-safe text exists, do not send draft updates; keep typing/final-message behavior only. + +## Testing + +- Extend config normalization tests for legacy Telegram-only data plus new Feishu config. +- Add presenter/runtime tests for Feishu settings, bindings, pairing, and runtime enable/disable. +- Add Telegram regression tests proving reasoning/tool-call/pending-action states do not call `sendMessageDraft`. +- Update renderer tests for tab layout, overview, and per-channel dialogs. diff --git a/docs/specs/remote-multi-channel/spec.md b/docs/specs/remote-multi-channel/spec.md new file mode 100644 index 000000000..66e6d6f8b --- /dev/null +++ b/docs/specs/remote-multi-channel/spec.md @@ -0,0 +1,49 @@ +# Remote Multi-Channel + +## Summary + +Extend the existing Remote settings and runtime from Telegram-only to a fixed two-channel model: Telegram and Feishu. Telegram keeps hook notifications, while Feishu adds remote control only. Both channels continue to bind one remote endpoint to one DeepChat session and reuse the existing detached-session flow in Electron main. + +This iteration also fixes the Telegram "deleted message" draft issue by preventing draft streaming from using reasoning-only, tool-call-only, or permission/question-request blocks as visible text. + +## User Stories + +- As a desktop user, I can configure Telegram and Feishu remote control from one Remote page without mixing their credentials and rules together. +- As a Telegram user, I can continue using the existing private-chat pairing flow and hook notifications. +- As a Feishu user, I can pair in a bot DM, then continue the same DeepChat session from DM, group chat, or topic thread. +- As an admin of the desktop app, I can see per-channel runtime health and binding counts from a shared overview area. +- As a paired Feishu user, I can trigger a remote session in group/topic only when I explicitly `@bot`. +- As a Telegram user, I no longer see bot replies visually attached to a deleted draft/reference while the assistant is only reasoning or issuing tool calls. + +## Acceptance Criteria + +- The Remote page renders a shared overview plus separate Telegram and Feishu tabs. +- Telegram settings continue to support bot token, remote pairing, allowlist, default agent, hook settings, and hook test actions. +- Feishu settings support app credentials, pairing, paired user management, default agent selection, and binding management. +- Feishu runtime runs in Electron main via WebSocket event subscription and does not require a renderer window. +- Feishu endpoints are keyed by `chatId + optional threadId`, with topic/thread replies isolated from the group root conversation. +- Feishu authorization requires DM pairing first; in groups/topics, only paired users who `@bot` may send commands or plain text to the bound session. +- `/pair`, `/new`, `/sessions`, `/use`, `/stop`, `/status`, `/open`, and `/model` work for Feishu remote control. +- Telegram `/model` continues to use inline keyboard menus; Feishu `/model` uses text commands only. +- Telegram draft streaming only uses stable visible content blocks. Reasoning-only, tool-call-only, and pending action-only assistant states never call `sendMessageDraft`. +- Existing local desktop chat behavior remains unchanged. + +## Constraints + +- Keep a fixed two-channel architecture. Do not introduce a generic plugin registry for remote channels. +- Telegram hook notifications remain under the shared Remote page; Feishu hook notifications are out of scope. +- Remote sessions continue to use the existing `RemoteConversationRunner` and detached session creation path. +- Feishu v1 supports DM, group, and topic/thread input; media upload, cards, approvals, and user-OAuth automation remain out of scope. + +## Non-Goals + +- A general remote channel SDK or third-party channel plugin system. +- Feishu user-OAuth flows, approval cards, or hook notifications. +- Rich Feishu card-based model switching. +- Telegram group chat support. + +## Compatibility + +- Existing `remoteControl.telegram` store data stays valid and is normalized into the new dual-channel config. +- Existing Telegram hook settings remain valid and continue to be saved through the Remote page. +- New Feishu-specific state is additive under `remoteControl.feishu`. diff --git a/docs/specs/remote-multi-channel/tasks.md b/docs/specs/remote-multi-channel/tasks.md new file mode 100644 index 000000000..5b3036eb2 --- /dev/null +++ b/docs/specs/remote-multi-channel/tasks.md @@ -0,0 +1,11 @@ +# Remote Multi-Channel Tasks + +1. Expand shared remote-control presenter types for channel-aware APIs and overview snapshots. +2. Extend `remoteControl` config normalization for Telegram + Feishu runtime state. +3. Update `RemoteBindingStore` to read/write both channels and persist endpoint metadata. +4. Add Feishu client/runtime/parser/auth/router files under `src/main/presenter/remoteControlPresenter/feishu/`. +5. Update `RemoteControlPresenter` to manage both runtimes and expose channel-aware IPC methods. +6. Update Telegram runtime draft gating so reasoning/tool-call/pending-action states never stream drafts. +7. Rebuild `RemoteSettings.vue` into overview + tabs and refresh the sidebar remote indicator. +8. Update main/renderer tests and add Feishu runtime coverage. +9. Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, and targeted Vitest suites. diff --git a/package.json b/package.json index 1a98750ba..17d53b24c 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@electron-toolkit/utils": "^4.0.0", "@google/genai": "^1.34.0", "@jxa/run": "^1.4.0", + "@larksuiteoapi/node-sdk": "^1.59.0", "@modelcontextprotocol/sdk": "^1.25.1", "axios": "^1.13.2", "better-sqlite3-multiple-ciphers": "12.4.1", diff --git a/src/main/presenter/browser/BrowserTab.ts b/src/main/presenter/browser/BrowserTab.ts index 7e434cf23..4ef88a215 100644 --- a/src/main/presenter/browser/BrowserTab.ts +++ b/src/main/presenter/browser/BrowserTab.ts @@ -8,6 +8,10 @@ import { import { CDPManager } from './CDPManager' import { ScreenshotManager } from './ScreenshotManager' +const INTERACTIVE_READY_WAIT_TIMEOUT_MS = 2000 +const INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX = 'Timed out waiting for dom-ready:' +const NAVIGATION_CDP_METHODS = new Set(['Page.navigate', 'Page.reload']) + export class BrowserTab { readonly pageId: string readonly createdAt: number @@ -20,7 +24,10 @@ export class BrowserTab { private readonly cdpManager: CDPManager private readonly screenshotManager: ScreenshotManager private isAttached = false + private awaitingMainFrameInteractive = false private interactiveReady = false + private lastInteractiveAt: number | null = null + private loadingStartedAt: number | null = null private fullReady = false constructor( @@ -48,7 +55,7 @@ export class BrowserTab { } async navigate(url: string, timeoutMs?: number): Promise { - this.prepareForNavigation(url) + this.beginMainFrameNavigation(url) try { await this.withTimeout(this.webContents.loadURL(url), timeoutMs ?? 30000) this.title = this.webContents.getTitle() || url @@ -62,7 +69,7 @@ export class BrowserTab { } async navigateUntilDomReady(url: string, timeoutMs: number = 30000): Promise { - this.prepareForNavigation(url) + this.beginMainFrameNavigation(url) const loadPromise = this.webContents.loadURL(url) void loadPromise.catch((error) => { @@ -88,25 +95,51 @@ export class BrowserTab { } async extractDOM(selector?: string): Promise { - this.ensureInteractiveReady('extract DOM') + await this.ensureInteractiveReadyOrWait('extract DOM') const session = await this.ensureSession() return await this.cdpManager.getDOM(session, selector) } async evaluateScript(script: string): Promise { - this.ensureInteractiveReady('evaluate script') + await this.ensureInteractiveReadyOrWait('evaluate script') const session = await this.ensureSession() return await this.cdpManager.evaluateScript(session, script) } async sendCdpCommand(method: string, params?: Record): Promise { - this.ensureInteractiveReady(`send CDP command ${method}`) + if (NAVIGATION_CDP_METHODS.has(method)) { + this.ensureAvailable() + } else { + await this.ensureInteractiveReadyOrWait(`send CDP command ${method}`) + } + const session = await this.ensureSession() - return await session.sendCommand(method, params ?? {}) + const response = await session.sendCommand(method, params ?? {}) + + if (method === 'Page.navigate') { + const navigationResponse = response as { + loaderId?: string + errorText?: string + } + const hasCommittedCrossDocumentNavigation = + typeof navigationResponse.loaderId === 'string' && + navigationResponse.loaderId.trim() !== '' && + !navigationResponse.errorText + + if (hasCommittedCrossDocumentNavigation) { + this.beginMainFrameNavigation( + typeof params?.url === 'string' && params.url.trim() ? params.url : this.url + ) + } + } else if (method === 'Page.reload') { + this.beginMainFrameNavigation(this.url) + } + + return response } async takeScreenshot(options?: ScreenshotOptions): Promise { - this.ensureInteractiveReady('capture screenshot') + await this.ensureInteractiveReadyOrWait('capture screenshot') await this.ensureSession() this.ensureAvailable() @@ -491,7 +524,7 @@ export class BrowserTab { private async evaluate(fn: (...args: any[]) => T, ...args: any[]): Promise { this.ensureAvailable() - this.ensureInteractiveReady('evaluate script') + await this.ensureInteractiveReadyOrWait('evaluate script') const session = await this.ensureSession() const serializedArgs = JSON.stringify(args, (_key, value) => value === undefined ? null : value @@ -507,23 +540,35 @@ export class BrowserTab { } } - private ensureInteractiveReady(action: string): void { + private async ensureInteractiveReadyOrWait( + action: string, + timeoutMs: number = INTERACTIVE_READY_WAIT_TIMEOUT_MS + ): Promise { this.ensureAvailable() if (this.interactiveReady) { return } - const error = new Error( - `YoBrowser page is not ready to ${action}. Retry this request. url=${this.url} status=${this.status}` - ) - error.name = 'YoBrowserNotReadyError' - Object.assign(error, { - retryable: true, - url: this.url, - status: this.status - }) - throw error + if (this.awaitingMainFrameInteractive || this.status === BrowserPageStatus.Loading) { + try { + await this.waitForInteractiveReady(timeoutMs) + } catch (error) { + if (!this.isInteractiveReadyTimeoutError(error)) { + throw error + } + } + + if (this.interactiveReady) { + return + } + + if (await this.probeInteractiveReadiness()) { + return + } + } + + throw this.buildNotReadyError(action) } private validateKeyInput(key: string, count: number): string { @@ -693,17 +738,22 @@ export class BrowserTab { return this.webContents.debugger } - private prepareForNavigation(url: string): void { + private beginMainFrameNavigation(url: string): void { + const now = Date.now() this.url = url + this.awaitingMainFrameInteractive = true this.interactiveReady = false this.fullReady = false + this.loadingStartedAt = now this.status = BrowserPageStatus.Loading - this.updatedAt = Date.now() + this.updatedAt = now } private markNavigationError(error: unknown): void { + this.awaitingMainFrameInteractive = false this.interactiveReady = false this.fullReady = false + this.loadingStartedAt = null this.status = BrowserPageStatus.Error this.updatedAt = Date.now() console.error(`[YoBrowser][${this.pageId}] navigation failed`, { @@ -759,7 +809,7 @@ export class BrowserTab { timeoutId = setTimeout(() => { cleanup() - reject(new Error(`Timed out waiting for dom-ready: ${this.url}`)) + reject(new Error(`${INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX} ${this.url}`)) }, timeoutMs) this.webContents.once('dom-ready', onDomReady) @@ -768,6 +818,68 @@ export class BrowserTab { }) } + private isInteractiveReadyTimeoutError(error: unknown): error is Error { + return ( + error instanceof Error && error.message.startsWith(INTERACTIVE_READY_TIMEOUT_MESSAGE_PREFIX) + ) + } + + private async probeInteractiveReadiness(): Promise { + try { + const session = await this.ensureSession() + const probe = (await this.cdpManager.evaluateScript( + session, + `(() => { + try { + return { + readyState: document.readyState, + hasBody: Boolean(document.body), + href: location.href + } + } catch { + return null + } + })()` + )) as { readyState?: unknown; hasBody?: unknown; href?: unknown } | null + + const readyState = typeof probe?.readyState === 'string' ? probe.readyState : '' + const hasBody = probe?.hasBody === true + if (readyState !== 'interactive' && readyState !== 'complete') { + return false + } + + if (!hasBody) { + return false + } + + this.awaitingMainFrameInteractive = false + this.interactiveReady = true + this.lastInteractiveAt = Date.now() + this.updatedAt = this.lastInteractiveAt + if (typeof probe?.href === 'string' && probe.href) { + this.url = probe.href + } + return true + } catch { + return false + } + } + + private buildNotReadyError(action: string): Error { + const error = new Error( + `YoBrowser page is not ready to ${action}. Retry this request. url=${this.url} status=${this.status}` + ) + error.name = 'YoBrowserNotReadyError' + Object.assign(error, { + retryable: true, + url: this.url, + status: this.status, + lastInteractiveAt: this.lastInteractiveAt, + loadingStartedAt: this.loadingStartedAt + }) + return error + } + private async withTimeout(promise: Promise, timeoutMs: number): Promise { return await new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { @@ -788,28 +900,59 @@ export class BrowserTab { } private bindLifecycleEvents(): void { + this.webContents.on('did-start-navigation', (details) => { + if (!details.isMainFrame || details.isSameDocument) { + return + } + + this.beginMainFrameNavigation(details.url || this.url) + }) + + this.webContents.on('did-navigate-in-page', (_event, url: string, isMainFrame: boolean) => { + if (!isMainFrame) { + return + } + + this.url = url || this.url + this.updatedAt = Date.now() + }) + this.webContents.on('did-start-loading', () => { - this.interactiveReady = false - this.fullReady = false + this.loadingStartedAt = Date.now() this.status = BrowserPageStatus.Loading - this.updatedAt = Date.now() + this.updatedAt = this.loadingStartedAt }) this.webContents.on('dom-ready', () => { + const now = Date.now() + this.awaitingMainFrameInteractive = false this.interactiveReady = true - this.updatedAt = Date.now() + this.lastInteractiveAt = now + this.updatedAt = now console.info(`[YoBrowser][${this.pageId}] page dom-ready`, { url: this.url, status: this.status }) }) + this.webContents.on('did-stop-loading', () => { + this.loadingStartedAt = null + if (this.interactiveReady) { + this.fullReady = true + this.status = BrowserPageStatus.Ready + } + this.updatedAt = Date.now() + }) + this.webContents.on('did-finish-load', () => { + const now = Date.now() + this.awaitingMainFrameInteractive = false this.interactiveReady = true + this.lastInteractiveAt = now this.fullReady = true this.status = BrowserPageStatus.Ready this.title = this.webContents.getTitle() || this.url - this.updatedAt = Date.now() + this.updatedAt = now console.info(`[YoBrowser][${this.pageId}] page did-finish-load`, { url: this.url, status: this.status @@ -830,8 +973,10 @@ export class BrowserTab { } this.url = validatedURL || this.url + this.awaitingMainFrameInteractive = false this.interactiveReady = false this.fullReady = false + this.loadingStartedAt = null this.status = BrowserPageStatus.Error this.updatedAt = Date.now() console.error(`[YoBrowser][${this.pageId}] navigation failed`, { diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index 325878d69..3cb3b884e 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -735,7 +735,7 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { canGoBack: state.page.contents.navigationHistory.canGoBack(), canGoForward: state.page.contents.navigationHistory.canGoForward(), visible: state.visible, - loading: state.page.status === BrowserPageStatus.Loading + loading: state.page.contents.isLoading() || state.page.status === BrowserPageStatus.Loading } } diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index c1075734b..6d513c108 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -116,6 +116,15 @@ export class Presenter implements IPresenter { ]) static readonly REMOTE_CONTROL_METHODS = new Set([ + 'getChannelSettings', + 'saveChannelSettings', + 'getChannelStatus', + 'getChannelBindings', + 'removeChannelBinding', + 'getChannelPairingSnapshot', + 'createChannelPairCode', + 'clearChannelPairCode', + 'clearChannelBindings', 'getTelegramSettings', 'saveTelegramSettings', 'getTelegramStatus', @@ -353,20 +362,7 @@ export class Presenter implements IPresenter { 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() - } + this.#remoteControlBridge = this.#remoteControlPresenter // Update hooksNotifications with actual dependencies now that newAgentPresenter is ready this.hooksNotifications = new HooksNotificationsService(this.configPresenter, { @@ -528,7 +524,7 @@ export class Presenter implements IPresenter { } const handler = this.#remoteControlBridge[method] as (...args: unknown[]) => unknown - return await handler(...payloads) + return await Reflect.apply(handler, this.#remoteControlBridge, payloads) } // 从配置中同步自定义模型到 LLMProviderPresenter diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts new file mode 100644 index 000000000..44c7476e3 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuClient.ts @@ -0,0 +1,160 @@ +import * as Lark from '@larksuiteoapi/node-sdk' +import type { EventHandles } from '@larksuiteoapi/node-sdk' +import type { FeishuTransportTarget } from '../types' + +const FEISHU_OUTBOUND_TEXT_LIMIT = 8_000 + +export type FeishuRawMessageEvent = Parameters< + NonNullable +>[0] + +export interface FeishuBotIdentity { + openId: string + name?: string +} + +const createTextPayload = (text: string): string => + JSON.stringify({ + text + }) + +const chunkFeishuText = (text: string): string[] => { + const normalized = text.trim() || '(No text output)' + if (normalized.length <= FEISHU_OUTBOUND_TEXT_LIMIT) { + return [normalized] + } + + const chunks: string[] = [] + let remaining = normalized + + while (remaining.length > FEISHU_OUTBOUND_TEXT_LIMIT) { + const window = remaining.slice(0, FEISHU_OUTBOUND_TEXT_LIMIT) + const splitIndex = Math.max(window.lastIndexOf('\n\n'), window.lastIndexOf('\n')) + const nextIndex = + splitIndex > Math.floor(FEISHU_OUTBOUND_TEXT_LIMIT * 0.55) + ? splitIndex + : FEISHU_OUTBOUND_TEXT_LIMIT + chunks.push(remaining.slice(0, nextIndex).trim()) + remaining = remaining.slice(nextIndex).trim() + } + + if (remaining) { + chunks.push(remaining) + } + + return chunks +} + +export class FeishuClient { + private readonly sdk: Lark.Client + private wsClient: Lark.WSClient | null = null + + constructor( + private readonly credentials: { + appId: string + appSecret: string + verificationToken: string + encryptKey: string + } + ) { + this.sdk = new Lark.Client({ + appId: credentials.appId, + appSecret: credentials.appSecret, + appType: Lark.AppType.SelfBuild + }) + } + + async probeBot(): Promise { + const response = await (this.sdk as any).request({ + method: 'GET', + url: '/open-apis/bot/v3/info', + data: {} + }) + + if (response?.code !== 0) { + throw new Error(response?.msg?.trim() || 'Failed to fetch Feishu bot info.') + } + + const bot = response?.bot || response?.data?.bot + const openId = bot?.open_id?.trim() + if (!openId) { + throw new Error('Feishu bot open_id is missing from bot/v3/info response.') + } + + return { + openId, + name: bot?.bot_name?.trim() || undefined + } + } + + async startMessageStream(params: { + onMessage: (event: FeishuRawMessageEvent) => Promise + }): Promise { + this.stop() + + const dispatcherOptions: { + encryptKey?: string + verificationToken?: string + } = {} + + if (this.credentials.encryptKey.trim()) { + dispatcherOptions.encryptKey = this.credentials.encryptKey + } + + if (this.credentials.verificationToken.trim()) { + dispatcherOptions.verificationToken = this.credentials.verificationToken + } + + const dispatcher = new Lark.EventDispatcher(dispatcherOptions) + + dispatcher.register({ + 'im.message.receive_v1': async (event: FeishuRawMessageEvent) => { + await params.onMessage(event) + } + }) + + this.wsClient = new Lark.WSClient({ + appId: this.credentials.appId, + appSecret: this.credentials.appSecret, + loggerLevel: Lark.LoggerLevel.info + }) + + await this.wsClient.start({ + eventDispatcher: dispatcher + }) + } + + stop(): void { + this.wsClient?.close({ force: true }) + this.wsClient = null + } + + async sendText(target: FeishuTransportTarget, text: string): Promise { + for (const chunk of chunkFeishuText(text)) { + if (target.replyToMessageId) { + await this.sdk.im.message.reply({ + path: { + message_id: target.replyToMessageId + }, + data: { + content: createTextPayload(chunk), + msg_type: 'text', + reply_in_thread: Boolean(target.threadId) + } + }) + continue + } + + await this.sdk.im.message.create({ + params: { + receive_id_type: 'chat_id' + }, + data: { + receive_id: target.chatId, + msg_type: 'text', + content: createTextPayload(chunk) + } + }) + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts new file mode 100644 index 000000000..fb05da665 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuParser.ts @@ -0,0 +1,69 @@ +import type { TelegramCommandPayload } from '../types' +import type { FeishuRawMessageEvent } from './feishuClient' +import type { FeishuInboundMessage } from '../types' + +const FEISHU_COMMAND_REGEX = /^\/([a-zA-Z0-9_]+)(?:\s+([\s\S]*))?$/ +const FEISHU_LEADING_AT_TAG_REGEX = /^(?:\s*]*>.*?<\/at>\s*)+/i +const FEISHU_LEADING_AT_TEXT_REGEX = /^(?:\s*@[\w.-]+\s*)+/i + +const parseTextContent = (content: string): string => { + try { + const parsed = JSON.parse(content) as { text?: string } + if (typeof parsed?.text === 'string') { + return parsed.text.trim() + } + } catch { + // Fall through to raw content. + } + + return content.trim() +} + +const stripLeadingMentions = (text: string): string => + text.replace(FEISHU_LEADING_AT_TAG_REGEX, '').replace(FEISHU_LEADING_AT_TEXT_REGEX, '').trim() + +const parseCommand = (text: string): TelegramCommandPayload | null => { + const match = FEISHU_COMMAND_REGEX.exec(text) + if (!match) { + return null + } + + return { + name: match[1].toLowerCase(), + args: match[2]?.trim() ?? '' + } +} + +export class FeishuParser { + parseEvent(event: FeishuRawMessageEvent, botOpenId?: string): FeishuInboundMessage | null { + const rawText = parseTextContent(event.message?.content ?? '') + if (!rawText) { + return null + } + + const mentions = event.message?.mentions ?? [] + const mentionedBot = Boolean( + botOpenId && + mentions.some((mention) => mention.id?.open_id && mention.id.open_id === botOpenId) + ) + + const normalizedText = stripLeadingMentions(rawText) + if (!normalizedText) { + return null + } + + return { + kind: 'message', + eventId: event.event_id?.trim() || event.uuid?.trim() || event.message.message_id, + chatId: event.message.chat_id, + threadId: event.message.thread_id || event.message.root_id || null, + messageId: event.message.message_id, + chatType: event.message.chat_type === 'p2p' ? 'p2p' : 'group', + senderOpenId: event.sender?.sender_id?.open_id?.trim() || null, + text: normalizedText, + command: parseCommand(normalizedText), + mentionedBot, + mentions + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts b/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts new file mode 100644 index 000000000..1f01d2362 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/feishu/feishuRuntime.ts @@ -0,0 +1,368 @@ +import { + FEISHU_CONVERSATION_POLL_TIMEOUT_MS, + FEISHU_INBOUND_DEDUP_LIMIT, + FEISHU_INBOUND_DEDUP_TTL_MS, + TELEGRAM_STREAM_POLL_INTERVAL_MS, + buildFeishuEndpointKey, + type FeishuInboundMessage, + type FeishuRuntimeStatusSnapshot, + type FeishuTransportTarget +} from '../types' +import { FeishuCommandRouter } from '../services/feishuCommandRouter' +import type { RemoteConversationExecution } from '../services/remoteConversationRunner' +import { FeishuClient, type FeishuBotIdentity } from './feishuClient' +import { FeishuParser } from './feishuParser' + +const sleep = async (ms: number): Promise => { + await new Promise((resolve) => setTimeout(resolve, ms)) +} + +const FEISHU_INTERNAL_ERROR_REPLY = 'An internal error occurred while processing your request.' + +type FeishuRuntimeDeps = { + client: FeishuClient + parser: FeishuParser + router: FeishuCommandRouter + logger?: { + error: (...params: unknown[]) => void + } + onStatusChange?: (snapshot: FeishuRuntimeStatusSnapshot) => void + onFatalError?: (message: string) => void +} + +type FeishuProcessedInboundEntry = { + receivedAt: number + eventId: string | null +} + +export class FeishuRuntime { + private runId = 0 + private started = false + private stopRequested = false + private statusSnapshot: FeishuRuntimeStatusSnapshot = { + state: 'stopped', + lastError: null, + botUser: null + } + private readonly processedInboundByMessage = new Map() + private readonly processedEventToMessage = new Map() + private readonly endpointOperations = new Map>() + + constructor(private readonly deps: FeishuRuntimeDeps) {} + + async start(): Promise { + if (this.started) { + return + } + + const runId = ++this.runId + this.started = true + this.stopRequested = false + this.setStatus({ + state: 'starting', + lastError: null + }) + + try { + const botUser = await this.deps.client.probeBot() + if (!this.isCurrentRun(runId)) { + return + } + + this.setBotUser(botUser) + await this.deps.client.startMessageStream({ + onMessage: async (event) => { + try { + this.acceptRawMessage(event, runId) + } catch (error) { + console.warn('[FeishuRuntime] Failed to enqueue event:', error) + } + } + }) + if (!this.isCurrentRun(runId)) { + return + } + + this.setStatus({ + state: 'running', + lastError: null + }) + } catch (error) { + if (!this.isCurrentRun(runId)) { + return + } + + this.started = false + this.setStatus({ + state: 'error', + lastError: error instanceof Error ? error.message : String(error) + }) + throw error + } + } + + async stop(): Promise { + this.stopRequested = true + this.started = false + this.runId += 1 + this.deps.client.stop() + this.endpointOperations.clear() + this.processedInboundByMessage.clear() + this.processedEventToMessage.clear() + this.setStatus({ + state: 'stopped' + }) + } + + getStatusSnapshot(): FeishuRuntimeStatusSnapshot { + return { ...this.statusSnapshot } + } + + private isCurrentRun(runId: number): boolean { + return this.runId === runId && this.started && !this.stopRequested + } + + private acceptRawMessage(event: Parameters[0], runId: number): void { + if (!this.isCurrentRun(runId)) { + return + } + + const parsed = this.deps.parser.parseEvent(event, this.statusSnapshot.botUser?.openId) + if (!parsed) { + return + } + + const duplicateReason = this.rememberInboundMessage(parsed) + if (duplicateReason) { + console.info('[FeishuRuntime] Dropped duplicate inbound message.', { + reason: duplicateReason, + chatId: parsed.chatId, + threadId: parsed.threadId, + messageId: parsed.messageId, + eventId: parsed.eventId + }) + return + } + + const endpointKey = buildFeishuEndpointKey(parsed.chatId, parsed.threadId) + if (parsed.command?.name === 'stop') { + void this.processInboundMessage(parsed, runId) + return + } + + this.enqueueEndpointOperation(endpointKey, runId, async () => { + await this.processInboundMessage(parsed, runId) + }) + } + + private rememberInboundMessage(message: FeishuInboundMessage): 'eventId' | 'messageId' | null { + const now = Date.now() + this.pruneProcessedInbound(now) + + const messageKey = this.buildMessageDedupKey(message) + if (this.processedInboundByMessage.has(messageKey)) { + return 'messageId' + } + + const normalizedEventId = message.eventId.trim() + if (normalizedEventId && this.processedEventToMessage.has(normalizedEventId)) { + return 'eventId' + } + + this.processedInboundByMessage.set(messageKey, { + receivedAt: now, + eventId: normalizedEventId || null + }) + if (normalizedEventId) { + this.processedEventToMessage.set(normalizedEventId, messageKey) + } + + while (this.processedInboundByMessage.size > FEISHU_INBOUND_DEDUP_LIMIT) { + const oldestKey = this.processedInboundByMessage.keys().next().value + if (!oldestKey) { + break + } + this.deleteProcessedInbound(oldestKey) + } + + return null + } + + private buildMessageDedupKey(message: FeishuInboundMessage): string { + return `${message.chatId}:${message.messageId}` + } + + private pruneProcessedInbound(now: number): void { + for (const [messageKey, entry] of this.processedInboundByMessage.entries()) { + if (now - entry.receivedAt <= FEISHU_INBOUND_DEDUP_TTL_MS) { + break + } + this.deleteProcessedInbound(messageKey) + } + } + + private deleteProcessedInbound(messageKey: string): void { + const entry = this.processedInboundByMessage.get(messageKey) + if (!entry) { + return + } + + this.processedInboundByMessage.delete(messageKey) + if (entry.eventId) { + this.processedEventToMessage.delete(entry.eventId) + } + } + + private enqueueEndpointOperation( + endpointKey: string, + runId: number, + operation: () => Promise + ): void { + const previous = this.endpointOperations.get(endpointKey) ?? Promise.resolve() + const next = previous + .catch(() => undefined) + .then(async () => { + if (!this.isCurrentRun(runId)) { + return + } + + await operation() + }) + .finally(() => { + if (this.endpointOperations.get(endpointKey) === next) { + this.endpointOperations.delete(endpointKey) + } + }) + + this.endpointOperations.set(endpointKey, next) + } + + private async processInboundMessage(parsed: FeishuInboundMessage, runId: number): Promise { + if (!this.isCurrentRun(runId)) { + return + } + + const target: FeishuTransportTarget = { + chatId: parsed.chatId, + threadId: parsed.threadId, + replyToMessageId: parsed.messageId + } + + try { + const routed = await this.deps.router.handleMessage(parsed) + if (!this.isCurrentRun(runId)) { + return + } + + for (const reply of routed.replies) { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText(target, reply) + } + + if (routed.conversation) { + await this.deliverConversation(target, routed.conversation, runId) + } + } catch (error) { + const diagnostics = { + runId, + target, + chatId: parsed.chatId, + threadId: parsed.threadId, + messageId: parsed.messageId, + eventId: parsed.eventId + } + + console.warn('[FeishuRuntime] Failed to handle event:', { + ...diagnostics, + error + }) + if (this.deps.logger?.error) { + this.deps.logger.error(error, diagnostics) + } else { + console.error('[FeishuRuntime] Failed to handle event:', error, diagnostics) + } + + if (!this.isCurrentRun(runId)) { + return + } + + try { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText(target, FEISHU_INTERNAL_ERROR_REPLY) + } catch (sendError) { + console.warn('[FeishuRuntime] Failed to send error reply:', { + chatId: parsed.chatId, + threadId: parsed.threadId, + messageId: parsed.messageId, + eventId: parsed.eventId, + error: sendError + }) + } + } + } + + private async deliverConversation( + target: FeishuTransportTarget, + execution: RemoteConversationExecution, + runId: number + ): Promise { + const startedAt = Date.now() + + while (this.isCurrentRun(runId)) { + const snapshot = await execution.getSnapshot() + if (!this.isCurrentRun(runId)) { + return + } + + if (snapshot.completed) { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText(target, snapshot.text) + return + } + + if (Date.now() - startedAt >= FEISHU_CONVERSATION_POLL_TIMEOUT_MS) { + if (!this.isCurrentRun(runId)) { + return + } + await this.deps.client.sendText( + target, + 'The current conversation timed out before finishing. Please try again.' + ) + return + } + + await sleep(TELEGRAM_STREAM_POLL_INTERVAL_MS) + } + } + + private setBotUser(botUser: FeishuBotIdentity): void { + this.setStatus({ + botUser: { + openId: botUser.openId, + name: botUser.name + } + }) + } + + private setStatus( + patch: Partial & { + state?: FeishuRuntimeStatusSnapshot['state'] + } + ): void { + this.statusSnapshot = { + ...this.statusSnapshot, + ...patch + } + this.deps.onStatusChange?.(this.getStatusSnapshot()) + + if (patch.state === 'error' && patch.lastError) { + this.deps.onFatalError?.(patch.lastError) + } + } +} diff --git a/src/main/presenter/remoteControlPresenter/index.ts b/src/main/presenter/remoteControlPresenter/index.ts index f9cea6de0..9512d6cee 100644 --- a/src/main/presenter/remoteControlPresenter/index.ts +++ b/src/main/presenter/remoteControlPresenter/index.ts @@ -1,5 +1,12 @@ import type { HookTestResult, TelegramNotificationsConfig } from '@shared/hooksNotifications' import type { + FeishuPairingSnapshot, + FeishuRemoteSettings, + FeishuRemoteStatus, + RemoteBindingSummary, + RemoteChannel, + RemoteChannelSettings, + RemoteChannelStatus, TelegramPairingSnapshot, TelegramRemoteBindingSummary, TelegramRemoteSettings, @@ -8,20 +15,35 @@ import type { import { TELEGRAM_REMOTE_COMMANDS, TELEGRAM_REMOTE_DEFAULT_AGENT_ID, + buildBindingSummary, + normalizeFeishuSettingsInput, normalizeTelegramSettingsInput, parseTelegramEndpointKey, + type FeishuRuntimeStatusSnapshot, type TelegramPollerStatusSnapshot } from './types' import type { RemoteControlPresenterDeps } from './interface' +import logger from '@shared/logger' import { RemoteBindingStore } from './services/remoteBindingStore' +import { FeishuAuthGuard } from './services/feishuAuthGuard' +import { FeishuCommandRouter } from './services/feishuCommandRouter' import { RemoteAuthGuard } from './services/remoteAuthGuard' import { RemoteConversationRunner } from './services/remoteConversationRunner' import { RemoteCommandRouter } from './services/remoteCommandRouter' +import { FeishuClient } from './feishu/feishuClient' +import { FeishuParser } from './feishu/feishuParser' +import { FeishuRuntime } from './feishu/feishuRuntime' import { TelegramClient } from './telegram/telegramClient' import { TelegramParser } from './telegram/telegramParser' import { TelegramPoller } from './telegram/telegramPoller' -const DEFAULT_POLLER_STATUS: TelegramPollerStatusSnapshot = { +const DEFAULT_TELEGRAM_POLLER_STATUS: TelegramPollerStatusSnapshot = { + state: 'stopped', + lastError: null, + botUser: null +} + +const DEFAULT_FEISHU_RUNTIME_STATUS: FeishuRuntimeStatusSnapshot = { state: 'stopped', lastError: null, botUser: null @@ -30,8 +52,11 @@ const DEFAULT_POLLER_STATUS: TelegramPollerStatusSnapshot = { export class RemoteControlPresenter { private readonly bindingStore: RemoteBindingStore private telegramPoller: TelegramPoller | null = null - private telegramPollerStatus: TelegramPollerStatusSnapshot = { ...DEFAULT_POLLER_STATUS } + private telegramPollerStatus: TelegramPollerStatusSnapshot = { ...DEFAULT_TELEGRAM_POLLER_STATUS } private activeBotToken: string | null = null + private feishuRuntime: FeishuRuntime | null = null + private feishuRuntimeStatus: FeishuRuntimeStatusSnapshot = { ...DEFAULT_FEISHU_RUNTIME_STATUS } + private activeFeishuRuntimeKey: string | null = null private runtimeOperation: Promise = Promise.resolve() constructor(private readonly deps: RemoteControlPresenterDeps) { @@ -40,13 +65,13 @@ export class RemoteControlPresenter { async initialize(): Promise { await this.enqueueRuntimeOperation(async () => { - await this.rebuildTelegramRuntime() + await Promise.all([this.rebuildTelegramRuntime(), this.rebuildFeishuRuntime()]) }) } async destroy(): Promise { await this.enqueueRuntimeOperation(async () => { - await this.stopTelegramRuntime() + await Promise.all([this.stopTelegramRuntime(), this.stopFeishuRuntime()]) }) } @@ -68,9 +93,112 @@ export class RemoteControlPresenter { } } + buildFeishuSettingsSnapshot(): FeishuRemoteSettings { + const remoteConfig = this.bindingStore.getFeishuConfig() + return { + appId: remoteConfig.appId, + appSecret: remoteConfig.appSecret, + verificationToken: remoteConfig.verificationToken, + encryptKey: remoteConfig.encryptKey, + remoteEnabled: remoteConfig.enabled, + defaultAgentId: remoteConfig.defaultAgentId, + pairedUserOpenIds: [...remoteConfig.pairedUserOpenIds] + } + } + + async getChannelSettings(channel: 'telegram'): Promise + async getChannelSettings(channel: 'feishu'): Promise + async getChannelSettings(channel: RemoteChannel): Promise + async getChannelSettings(channel: RemoteChannel): Promise { + if (channel === 'telegram') { + return await this.getTelegramSettings() + } + + return await this.getFeishuSettings() + } + + async saveChannelSettings( + channel: 'telegram', + input: TelegramRemoteSettings + ): Promise + async saveChannelSettings( + channel: 'feishu', + input: FeishuRemoteSettings + ): Promise + async saveChannelSettings( + channel: RemoteChannel, + input: RemoteChannelSettings + ): Promise + async saveChannelSettings( + channel: RemoteChannel, + input: RemoteChannelSettings + ): Promise { + if (channel === 'telegram') { + return await this.saveTelegramSettings(input as TelegramRemoteSettings) + } + + return await this.saveFeishuSettings(input as FeishuRemoteSettings) + } + + async getChannelStatus(channel: 'telegram'): Promise + async getChannelStatus(channel: 'feishu'): Promise + async getChannelStatus(channel: RemoteChannel): Promise + async getChannelStatus(channel: RemoteChannel): Promise { + if (channel === 'telegram') { + return await this.getTelegramStatus() + } + + return await this.getFeishuStatus() + } + + async getChannelBindings(channel: RemoteChannel): Promise { + return this.bindingStore + .listBindings(channel) + .map(({ endpointKey, binding }) => buildBindingSummary(endpointKey, binding)) + .filter((binding): binding is RemoteBindingSummary => binding !== null) + .sort((left, right) => right.updatedAt - left.updatedAt) + } + + async removeChannelBinding(channel: RemoteChannel, endpointKey: string): Promise { + if (!endpointKey.startsWith(`${channel}:`)) { + return + } + + this.bindingStore.clearBinding(endpointKey) + } + + async getChannelPairingSnapshot(channel: 'telegram'): Promise + async getChannelPairingSnapshot(channel: 'feishu'): Promise + async getChannelPairingSnapshot( + channel: RemoteChannel + ): Promise + async getChannelPairingSnapshot( + channel: RemoteChannel + ): Promise { + if (channel === 'telegram') { + return this.bindingStore.getTelegramPairingSnapshot() + } + + return this.bindingStore.getFeishuPairingSnapshot() + } + + async createChannelPairCode( + channel: RemoteChannel + ): Promise<{ code: string; expiresAt: number }> { + return this.bindingStore.createPairCode(channel) + } + + async clearChannelPairCode(channel: RemoteChannel): Promise { + this.bindingStore.clearPairCode(channel) + } + + async clearChannelBindings(channel: RemoteChannel): Promise { + return this.bindingStore.clearBindings(channel) + } + async getTelegramSettings(): Promise { const snapshot = this.buildTelegramSettingsSnapshot() - const defaultAgentId = await this.sanitizeDefaultAgentId(snapshot.defaultAgentId) + const defaultAgentId = await this.sanitizeDefaultAgentId('telegram', snapshot.defaultAgentId) return { ...snapshot, defaultAgentId @@ -79,7 +207,7 @@ export class RemoteControlPresenter { async saveTelegramSettings(input: TelegramRemoteSettings): Promise { const normalized = normalizeTelegramSettingsInput(input) - const defaultAgentId = await this.sanitizeDefaultAgentId(normalized.defaultAgentId) + const defaultAgentId = await this.sanitizeDefaultAgentId('telegram', normalized.defaultAgentId) const currentHooksConfig = this.deps.getHooksNotificationsConfig() const currentRemoteConfig = this.bindingStore.getTelegramConfig() const currentBotToken = currentHooksConfig.telegram.botToken.trim() @@ -97,7 +225,7 @@ export class RemoteControlPresenter { enabled: normalized.remoteEnabled, allowlist: normalized.allowedUserIds, defaultAgentId, - streamMode: 'draft', + streamMode: currentRemoteConfig.streamMode, lastFatalError: shouldClearFatalError ? null : config.lastFatalError, pairing: config.pairing })) @@ -111,13 +239,14 @@ export class RemoteControlPresenter { async getTelegramStatus(): Promise { const remoteConfig = this.bindingStore.getTelegramConfig() const hooksConfig = this.deps.getHooksNotificationsConfig().telegram - const runtimeStatus = this.getEffectivePollerStatus( + const runtimeStatus = this.getEffectiveTelegramStatus( hooksConfig.botToken, remoteConfig.enabled, remoteConfig.lastFatalError ) return { + channel: 'telegram', enabled: remoteConfig.enabled, state: runtimeStatus.state, pollOffset: remoteConfig.pollOffset, @@ -130,7 +259,7 @@ export class RemoteControlPresenter { async getTelegramBindings(): Promise { return this.bindingStore - .listBindings() + .listBindings('telegram') .map(({ endpointKey, binding }) => { const endpoint = parseTelegramEndpointKey(endpointKey) if (!endpoint) { @@ -150,23 +279,82 @@ export class RemoteControlPresenter { } async removeTelegramBinding(endpointKey: string): Promise { - this.bindingStore.clearBinding(endpointKey) + await this.removeChannelBinding('telegram', endpointKey) } async getTelegramPairingSnapshot(): Promise { - return this.bindingStore.getPairingSnapshot() + return this.bindingStore.getTelegramPairingSnapshot() } async createTelegramPairCode(): Promise<{ code: string; expiresAt: number }> { - return this.bindingStore.createPairCode() + return await this.createChannelPairCode('telegram') } async clearTelegramPairCode(): Promise { - this.bindingStore.clearPairCode() + await this.clearChannelPairCode('telegram') } async clearTelegramBindings(): Promise { - return this.bindingStore.clearBindings() + return await this.clearChannelBindings('telegram') + } + + async getFeishuSettings(): Promise { + const snapshot = this.buildFeishuSettingsSnapshot() + const defaultAgentId = await this.sanitizeDefaultAgentId('feishu', snapshot.defaultAgentId) + return { + ...snapshot, + defaultAgentId + } + } + + async saveFeishuSettings(input: FeishuRemoteSettings): Promise { + const normalized = normalizeFeishuSettingsInput(input) + const defaultAgentId = await this.sanitizeDefaultAgentId('feishu', normalized.defaultAgentId) + const currentRemoteConfig = this.bindingStore.getFeishuConfig() + const shouldClearFatalError = + currentRemoteConfig.enabled !== normalized.remoteEnabled || + currentRemoteConfig.appId !== normalized.appId || + currentRemoteConfig.appSecret !== normalized.appSecret || + currentRemoteConfig.verificationToken !== normalized.verificationToken || + currentRemoteConfig.encryptKey !== normalized.encryptKey + + this.bindingStore.updateFeishuConfig((config) => ({ + ...config, + appId: normalized.appId, + appSecret: normalized.appSecret, + verificationToken: normalized.verificationToken, + encryptKey: normalized.encryptKey, + enabled: normalized.remoteEnabled, + defaultAgentId, + pairedUserOpenIds: normalized.pairedUserOpenIds, + lastFatalError: shouldClearFatalError ? null : config.lastFatalError, + pairing: config.pairing + })) + + await this.enqueueRuntimeOperation(async () => { + await this.rebuildFeishuRuntime() + }) + return await this.getFeishuSettings() + } + + async getFeishuStatus(): Promise { + const remoteConfig = this.bindingStore.getFeishuConfig() + const runtimeStatus = this.getEffectiveFeishuStatus( + remoteConfig.enabled, + remoteConfig.lastFatalError, + remoteConfig.appId, + remoteConfig.appSecret + ) + + return { + channel: 'feishu', + enabled: remoteConfig.enabled, + state: runtimeStatus.state, + bindingCount: Object.keys(remoteConfig.bindings).length, + pairedUserCount: remoteConfig.pairedUserOpenIds.length, + lastError: runtimeStatus.lastError, + botUser: runtimeStatus.botUser + } } async testTelegramHookNotification(): Promise { @@ -227,23 +415,12 @@ export class RemoteControlPresenter { 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 runner = this.createConversationRunner('telegram') const router = new RemoteCommandRouter({ authGuard, runner, bindingStore: this.bindingStore, - getPollerStatus: () => this.getEffectivePollerStatus(botToken, true, null) + getPollerStatus: () => this.getEffectiveTelegramStatus(botToken, true, null) }) this.telegramPoller = new TelegramPoller({ @@ -273,6 +450,84 @@ export class RemoteControlPresenter { } } + private async rebuildFeishuRuntime(): Promise { + const settings = this.buildFeishuSettingsSnapshot() + const runtimeKey = this.buildFeishuRuntimeKey(settings) + + if (!settings.remoteEnabled) { + await this.stopFeishuRuntime() + this.feishuRuntimeStatus = { + state: 'disabled', + lastError: null, + botUser: null + } + return + } + + if (!settings.appId.trim() || !settings.appSecret.trim()) { + await this.stopFeishuRuntime() + this.feishuRuntimeStatus = { + state: 'error', + lastError: 'App ID and App Secret are required.', + botUser: null + } + return + } + + if (this.feishuRuntime && this.activeFeishuRuntimeKey === runtimeKey) { + return + } + + await this.stopFeishuRuntime() + this.activeFeishuRuntimeKey = runtimeKey + this.feishuRuntimeStatus = { + state: 'starting', + lastError: null, + botUser: null + } + + const client = new FeishuClient({ + appId: settings.appId, + appSecret: settings.appSecret, + verificationToken: settings.verificationToken, + encryptKey: settings.encryptKey + }) + const runner = this.createConversationRunner('feishu') + const router = new FeishuCommandRouter({ + authGuard: new FeishuAuthGuard(this.bindingStore), + runner, + bindingStore: this.bindingStore, + getRuntimeStatus: () => + this.getEffectiveFeishuStatus(true, null, settings.appId, settings.appSecret) + }) + + this.feishuRuntime = new FeishuRuntime({ + client, + parser: new FeishuParser(), + router, + logger, + onStatusChange: (snapshot) => { + this.feishuRuntimeStatus = snapshot + }, + onFatalError: (message) => { + void this.enqueueRuntimeOperation(async () => { + await this.disableFeishuRuntimeForFatalError(runtimeKey, message) + }) + } + }) + + try { + await this.feishuRuntime.start() + } catch (error) { + this.feishuRuntimeStatus = { + state: 'error', + lastError: error instanceof Error ? error.message : String(error), + botUser: null + } + await this.stopFeishuRuntime() + } + } + private async stopTelegramRuntime(): Promise { const poller = this.telegramPoller this.telegramPoller = null @@ -285,7 +540,19 @@ export class RemoteControlPresenter { await poller.stop() } - private getEffectivePollerStatus( + private async stopFeishuRuntime(): Promise { + const runtime = this.feishuRuntime + this.feishuRuntime = null + this.activeFeishuRuntimeKey = null + + if (!runtime) { + return + } + + await runtime.stop() + } + + private getEffectiveTelegramStatus( botToken: string, remoteEnabled: boolean, lastFatalError: string | null @@ -317,6 +584,39 @@ export class RemoteControlPresenter { return { ...this.telegramPollerStatus } } + private getEffectiveFeishuStatus( + remoteEnabled: boolean, + lastFatalError: string | null, + appId: string, + appSecret: string + ): FeishuRuntimeStatusSnapshot { + if (!remoteEnabled) { + if (lastFatalError) { + return { + state: 'error', + lastError: lastFatalError, + botUser: null + } + } + + return { + state: 'disabled', + lastError: null, + botUser: null + } + } + + if (!appId.trim() || !appSecret.trim()) { + return { + state: 'error', + lastError: 'App ID and App Secret are required.', + botUser: null + } + } + + return { ...this.feishuRuntimeStatus } + } + private async disableTelegramRuntimeForFatalError( botToken: string, errorMessage: string @@ -342,13 +642,82 @@ export class RemoteControlPresenter { } } + private async disableFeishuRuntimeForFatalError( + runtimeKey: string, + errorMessage: string + ): Promise { + const currentRemoteConfig = this.bindingStore.getFeishuConfig() + if ( + !currentRemoteConfig.enabled || + this.buildFeishuRuntimeKey({ + appId: currentRemoteConfig.appId, + appSecret: currentRemoteConfig.appSecret, + verificationToken: currentRemoteConfig.verificationToken, + encryptKey: currentRemoteConfig.encryptKey + }) !== runtimeKey + ) { + return + } + + this.bindingStore.updateFeishuConfig((config) => ({ + ...config, + enabled: false, + lastFatalError: errorMessage + })) + + await this.stopFeishuRuntime() + this.feishuRuntimeStatus = { + state: 'error', + lastError: errorMessage, + botUser: null + } + } + + private createConversationRunner(channel: RemoteChannel): RemoteConversationRunner { + return 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(channel, this.getDefaultAgentId(channel)) + }, + this.bindingStore + ) + } + + private getDefaultAgentId(channel: RemoteChannel): string { + return channel === 'telegram' + ? this.bindingStore.getTelegramDefaultAgentId() + : this.bindingStore.getFeishuDefaultAgentId() + } + + private buildFeishuRuntimeKey(settings: { + appId: string + appSecret: string + verificationToken: string + encryptKey: string + }): string { + return [ + settings.appId.trim(), + settings.appSecret.trim(), + settings.verificationToken.trim(), + settings.encryptKey.trim() + ].join('::') + } + 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 { + private async sanitizeDefaultAgentId( + channel: RemoteChannel, + 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( @@ -361,8 +730,15 @@ export class RemoteControlPresenter { ? TELEGRAM_REMOTE_DEFAULT_AGENT_ID : enabledDeepChatAgents[0]?.id || TELEGRAM_REMOTE_DEFAULT_AGENT_ID - if (this.bindingStore.getDefaultAgentId() !== nextDefaultAgentId) { - this.bindingStore.updateTelegramConfig((config) => ({ + if (channel === 'telegram') { + if (this.bindingStore.getTelegramDefaultAgentId() !== nextDefaultAgentId) { + this.bindingStore.updateTelegramConfig((config) => ({ + ...config, + defaultAgentId: nextDefaultAgentId + })) + } + } else if (this.bindingStore.getFeishuDefaultAgentId() !== nextDefaultAgentId) { + this.bindingStore.updateFeishuConfig((config) => ({ ...config, defaultAgentId: nextDefaultAgentId })) diff --git a/src/main/presenter/remoteControlPresenter/interface.ts b/src/main/presenter/remoteControlPresenter/interface.ts index b955446c6..cdcd74b01 100644 --- a/src/main/presenter/remoteControlPresenter/interface.ts +++ b/src/main/presenter/remoteControlPresenter/interface.ts @@ -1,5 +1,6 @@ import type { HookTestResult, HooksNotificationsSettings } from '@shared/hooksNotifications' import type { + FeishuRemoteSettings, IConfigPresenter, INewAgentPresenter, IRemoteControlPresenter, @@ -28,4 +29,5 @@ export interface RemoteRuntimeLifecycle { export interface RemoteControlPresenterLike extends IRemoteControlPresenter, RemoteRuntimeLifecycle { buildTelegramSettingsSnapshot(): TelegramRemoteSettings + buildFeishuSettingsSnapshot(): FeishuRemoteSettings } diff --git a/src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts b/src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts new file mode 100644 index 000000000..66f523c07 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/feishuAuthGuard.ts @@ -0,0 +1,83 @@ +import { REMOTE_PAIR_CODE_MAX_FAILURES, type FeishuInboundMessage } from '../types' +import { RemoteBindingStore } from './remoteBindingStore' + +export type FeishuAuthResult = + | { + ok: true + userOpenId: string + } + | { + ok: false + message: string + silent?: boolean + } + +export class FeishuAuthGuard { + constructor(private readonly bindingStore: RemoteBindingStore) {} + + ensureAuthorized(message: FeishuInboundMessage): FeishuAuthResult { + if (message.chatType !== 'p2p' && !message.mentionedBot) { + return { + ok: false, + message: '', + silent: true + } + } + + if (!message.senderOpenId) { + return { + ok: false, + message: 'Unable to verify your Feishu account.' + } + } + + if (this.bindingStore.isFeishuPairedUser(message.senderOpenId)) { + return { + ok: true, + userOpenId: message.senderOpenId + } + } + + return { + ok: false, + message: + 'This Feishu account is not paired. Open Remote settings and use the current /pair code.' + } + } + + pair(message: FeishuInboundMessage, rawCode: string): string { + if (message.chatType !== 'p2p') { + return 'Pairing is only available in a private chat with the Feishu bot.' + } + + if (!message.senderOpenId) { + return 'Unable to verify your Feishu account for pairing.' + } + + const normalizedCode = rawCode.trim() + if (!/^\d{6}$/.test(normalizedCode)) { + return 'Usage: /pair <6-digit-code>' + } + + const pairing = this.bindingStore.getFeishuPairingState() + if (!pairing.code || !pairing.expiresAt || pairing.expiresAt <= Date.now()) { + this.bindingStore.clearPairCode('feishu') + return 'Pairing code is missing or expired. Generate a new code from DeepChat Remote settings.' + } + + if (pairing.code !== normalizedCode) { + const result = this.bindingStore.recordPairCodeFailure( + 'feishu', + REMOTE_PAIR_CODE_MAX_FAILURES + ) + if (result.exhausted) { + return 'Too many invalid pairing attempts. The current pairing code has expired. Generate a new code from DeepChat Remote settings.' + } + return 'Pairing code is invalid.' + } + + this.bindingStore.addFeishuPairedUser(message.senderOpenId) + this.bindingStore.clearPairCode('feishu') + return `Pairing complete. Feishu user ${message.senderOpenId} is now authorized.` + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts new file mode 100644 index 000000000..614d983f9 --- /dev/null +++ b/src/main/presenter/remoteControlPresenter/services/feishuCommandRouter.ts @@ -0,0 +1,295 @@ +import type { SessionWithState } from '@shared/types/agent-interface' +import { + FEISHU_REMOTE_COMMANDS, + buildFeishuBindingMeta, + buildFeishuEndpointKey, + type FeishuInboundMessage, + type FeishuRuntimeStatusSnapshot, + type TelegramModelProviderOption +} from '../types' +import type { RemoteConversationExecution } from './remoteConversationRunner' +import { FeishuAuthGuard } from './feishuAuthGuard' +import { RemoteBindingStore } from './remoteBindingStore' +import { RemoteConversationRunner } from './remoteConversationRunner' + +export interface FeishuCommandRouteResult { + replies: string[] + conversation?: RemoteConversationExecution +} + +type FeishuCommandRouterDeps = { + authGuard: FeishuAuthGuard + runner: RemoteConversationRunner + bindingStore: RemoteBindingStore + getRuntimeStatus: () => FeishuRuntimeStatusSnapshot +} + +export class FeishuCommandRouter { + constructor(private readonly deps: FeishuCommandRouterDeps) {} + + async handleMessage(message: FeishuInboundMessage): Promise { + const endpointKey = buildFeishuEndpointKey(message.chatId, message.threadId) + const bindingMeta = buildFeishuBindingMeta({ + chatId: message.chatId, + threadId: message.threadId, + chatType: message.chatType + }) + const command = message.command?.name + + if (command === 'start') { + const auth = this.deps.authGuard.ensureAuthorized(message) + if (!auth.ok && auth.silent) { + return { + replies: [] + } + } + + return { + replies: [this.formatStartMessage(auth.ok)] + } + } + + if (command === 'help') { + if (message.chatType !== 'p2p' && !message.mentionedBot) { + return { + replies: [] + } + } + + 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.silent ? [] : [auth.message] + } + } + + try { + switch (command) { + case 'new': { + const title = message.command?.args?.trim() + const session = await this.deps.runner.createNewSession(endpointKey, title, bindingMeta) + 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, + bindingMeta + ) + return { + replies: [`Now using: ${this.formatSessionLabel(session)}`] + } + } + + case 'stop': { + const stopped = await this.deps.runner.stop(endpointKey) + return { + replies: [ + stopped ? 'Stopped the active generation.' : 'There is no active generation to stop.' + ] + } + } + + case 'open': { + const openResult = await this.deps.runner.open(endpointKey) + return { + replies: [ + openResult.status === 'ok' + ? `Opened on desktop: ${this.formatSessionLabel(openResult.session)}` + : openResult.status === 'windowNotFound' + ? 'Could not find a DeepChat desktop window. Open DeepChat and try /open again.' + : 'No bound session. Send a message, /new, or /use first.' + ] + } + } + + case 'model': + return await this.handleModelCommand(message, endpointKey) + + case 'status': { + const runtime = this.deps.getRuntimeStatus() + const status = await this.deps.runner.getStatus(endpointKey) + const defaultAgentId = await this.deps.runner.getDefaultAgentId() + const feishuConfig = this.deps.bindingStore.getFeishuConfig() + return { + replies: [ + [ + 'DeepChat Feishu Remote', + `Runtime: ${runtime.state}`, + `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'}`, + `Paired users: ${feishuConfig.pairedUserOpenIds.length}`, + `Bindings: ${Object.keys(feishuConfig.bindings).length}`, + `Last error: ${runtime.lastError ?? 'none'}` + ].join('\n') + ] + } + } + + default: + break + } + + return { + replies: [], + conversation: await this.deps.runner.sendText(endpointKey, message.text, bindingMeta) + } + } catch (error) { + return { + replies: [error instanceof Error ? error.message : String(error)] + } + } + } + + private async handleModelCommand( + message: FeishuInboundMessage, + endpointKey: string + ): Promise { + 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 rawArgs = message.command?.args?.trim() ?? '' + if (!rawArgs) { + return { + replies: [this.formatModelOverview(session, providers)] + } + } + + const [providerId, ...modelParts] = rawArgs.split(/\s+/) + const modelId = modelParts.join(' ').trim() + if (!providerId || !modelId) { + return { + replies: ['Usage: /model '] + } + } + + const provider = providers.find((item) => item.providerId === providerId) + const model = provider?.models.find((item) => item.modelId === modelId) + if (!provider || !model) { + return { + replies: [ + `Model "${providerId} ${modelId}" is not enabled.\n\n${this.formatModelOverview(session, providers)}` + ] + } + } + + const updatedSession = await this.deps.runner.setSessionModel( + endpointKey, + provider.providerId, + model.modelId + ) + + return { + replies: [ + [ + 'Model updated.', + `Session: ${this.formatSessionLabel(updatedSession)}`, + `Provider: ${provider.providerName}`, + `Model: ${model.modelName}` + ].join('\n') + ] + } + } + + private formatModelOverview( + session: SessionWithState, + providers: TelegramModelProviderOption[] + ): string { + return [ + `Session: ${this.formatSessionLabel(session)}`, + `Current model: ${session.modelId ?? 'none'}`, + 'Usage: /model ', + '', + 'Available models:', + ...providers.flatMap((provider) => [ + `${provider.providerName} (${provider.providerId})`, + ...provider.models.map( + (model) => `- ${model.modelName} (${provider.providerId} ${model.modelId})` + ) + ]) + ].join('\n') + } + + private formatStartMessage(isAuthorized: boolean): string { + if (isAuthorized) { + return [ + 'DeepChat Feishu Remote is ready.', + 'Send any message to continue the bound session, or /help for commands.' + ].join('\n') + } + + return [ + 'DeepChat Feishu Remote is online.', + 'Pair first from a direct message with /pair before using group control.' + ].join('\n') + } + + private formatHelpMessage(): string { + return [ + 'DeepChat Feishu Remote commands:', + ...FEISHU_REMOTE_COMMANDS.map((item) => `/${item.command} - ${item.description}`) + ].join('\n') + } + + private formatSessionLabel(session: Pick): string { + return `${session.title} [${session.id}]` + } + + private formatSessionLine(session: SessionWithState, index: number): string { + return `${index}. ${session.title} [${session.id}]` + } +} diff --git a/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts b/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts index 428e6fdc9..e7c171344 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteAuthGuard.ts @@ -1,4 +1,8 @@ -import type { TelegramInboundEvent, TelegramInboundMessage } from '../types' +import { + REMOTE_PAIR_CODE_MAX_FAILURES, + type TelegramInboundEvent, + type TelegramInboundMessage +} from '../types' import { RemoteBindingStore } from './remoteBindingStore' export type RemoteAuthResult = @@ -54,19 +58,26 @@ export class RemoteAuthGuard { return 'Usage: /pair <6-digit-code>' } - const pairing = this.bindingStore.getPairingState() + const pairing = this.bindingStore.getTelegramPairingState() if (!pairing.code || !pairing.expiresAt || pairing.expiresAt <= Date.now()) { - this.bindingStore.clearPairCode() + this.bindingStore.clearPairCode('telegram') return 'Pairing code is missing or expired. Generate a new code from DeepChat Remote settings.' } if (pairing.code !== normalizedCode) { + const result = this.bindingStore.recordPairCodeFailure( + 'telegram', + REMOTE_PAIR_CODE_MAX_FAILURES + ) + if (result.exhausted) { + return 'Too many invalid pairing attempts. The current pairing code has expired. Generate a new code from DeepChat Remote settings.' + } return 'Pairing code is invalid.' } const userId = message.fromId as number this.bindingStore.addAllowedUser(userId) - this.bindingStore.clearPairCode() + this.bindingStore.clearPairCode('telegram') return `Pairing complete. Telegram user ${userId} is now authorized.` } diff --git a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts index d22f5678d..d4c8ada4d 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteBindingStore.ts @@ -1,14 +1,18 @@ -import type { IConfigPresenter } from '@shared/presenter' +import type { IConfigPresenter, RemoteChannel } from '@shared/presenter' import { REMOTE_CONTROL_SETTING_KEY, TELEGRAM_MODEL_MENU_TTL_MS, - buildTelegramEndpointKey, normalizeRemoteControlConfig, createPairCode, createTelegramCallbackToken, + buildFeishuPairingSnapshot, + buildTelegramEndpointKey, buildTelegramPairingSnapshot, + type FeishuPairingState, + type FeishuRemoteRuntimeConfig, type RemoteControlConfig, - type TelegramEndpointBinding, + type RemoteEndpointBinding, + type RemoteEndpointBindingMeta, type TelegramInboundEvent, type TelegramModelMenuState, type TelegramPairingState, @@ -28,8 +32,20 @@ export class RemoteBindingStore { ) } + getChannelConfig(channel: 'telegram'): TelegramRemoteRuntimeConfig + getChannelConfig(channel: 'feishu'): FeishuRemoteRuntimeConfig + getChannelConfig(channel: RemoteChannel): TelegramRemoteRuntimeConfig | FeishuRemoteRuntimeConfig + getChannelConfig(channel: RemoteChannel) { + const config = this.getConfig() + return config[channel] + } + getTelegramConfig(): TelegramRemoteRuntimeConfig { - return this.getConfig().telegram + return this.getChannelConfig('telegram') + } + + getFeishuConfig(): FeishuRemoteRuntimeConfig { + return this.getChannelConfig('feishu') } updateTelegramConfig( @@ -44,25 +60,55 @@ export class RemoteBindingStore { return next.telegram } + updateFeishuConfig( + updater: (config: FeishuRemoteRuntimeConfig) => FeishuRemoteRuntimeConfig + ): FeishuRemoteRuntimeConfig { + const current = this.getConfig() + const next = normalizeRemoteControlConfig({ + ...current, + feishu: updater(current.feishu) + }) + this.configPresenter.setSetting(REMOTE_CONTROL_SETTING_KEY, next) + return next.feishu + } + 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 + getBinding(endpointKey: string): RemoteEndpointBinding | null { + const channel = this.resolveChannelFromEndpointKey(endpointKey) + if (!channel) { + return null + } + + return this.getChannelBindings(channel)[endpointKey] ?? null } - setBinding(endpointKey: string, sessionId: string): void { - this.updateTelegramConfig((config) => ({ - ...config, - bindings: { - ...config.bindings, - [endpointKey]: { - sessionId, - updatedAt: Date.now() - } + setBinding(endpointKey: string, sessionId: string, meta?: RemoteEndpointBindingMeta): void { + const resolvedChannel = this.resolveChannelFromEndpointKey(endpointKey) + if (!resolvedChannel) { + return + } + + this.updateBindings(resolvedChannel, (bindings) => ({ + ...bindings, + [endpointKey]: { + sessionId, + updatedAt: Date.now(), + meta: meta + ? { + ...meta, + channel: resolvedChannel + } + : bindings[endpointKey]?.meta + ? { + ...bindings[endpointKey].meta, + channel: resolvedChannel + } + : undefined } })) this.activeEvents.delete(endpointKey) @@ -70,43 +116,75 @@ export class RemoteBindingStore { } clearBinding(endpointKey: string): void { - this.updateTelegramConfig((config) => { - const bindings = { ...config.bindings } - delete bindings[endpointKey] - return { - ...config, - bindings - } + const channel = this.resolveChannelFromEndpointKey(endpointKey) + if (!channel) { + return + } + + this.updateBindings(channel, (bindings) => { + const nextBindings = { ...bindings } + delete nextBindings[endpointKey] + return nextBindings }) - this.activeEvents.delete(endpointKey) - this.sessionSnapshots.delete(endpointKey) - this.clearModelMenuStatesForEndpoint(endpointKey) + this.clearTransientStateForEndpoint(endpointKey) } - listBindings(): Array<{ + listBindings(channel?: RemoteChannel): Array<{ endpointKey: string - binding: TelegramEndpointBinding + binding: RemoteEndpointBinding }> { - return Object.entries(this.getTelegramConfig().bindings).map(([endpointKey, binding]) => ({ - endpointKey, - binding - })) + const configs = + channel === undefined + ? (['telegram', 'feishu'] as const).map( + (key) => [key, this.getChannelBindings(key)] as const + ) + : ([[channel, this.getChannelBindings(channel)]] as const) + + return configs.flatMap((entry) => { + const bindings = entry[1] + return Object.entries(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 + clearBindings(channel?: RemoteChannel): number { + const entries = this.listBindings(channel) + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: {} + })) + } else if (channel === 'feishu') { + this.updateFeishuConfig((config) => ({ + ...config, + bindings: {} + })) + } else { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: {} + })) + this.updateFeishuConfig((config) => ({ + ...config, + bindings: {} + })) + } + + for (const { endpointKey } of entries) { + this.clearTransientStateForEndpoint(endpointKey) + } + + if (channel === undefined) { + this.modelMenuStates.clear() + } + + return entries.length } - countBindings(): number { - return Object.keys(this.getTelegramConfig().bindings).length + countBindings(channel?: RemoteChannel): number { + return this.listBindings(channel).length } getPollOffset(): number { @@ -124,10 +202,18 @@ export class RemoteBindingStore { return this.getTelegramConfig().allowlist } - getDefaultAgentId(): string { + getTelegramDefaultAgentId(): string { return this.getTelegramConfig().defaultAgentId } + getDefaultAgentId(): string { + return this.getTelegramDefaultAgentId() + } + + getFeishuDefaultAgentId(): string { + return this.getFeishuConfig().defaultAgentId + } + isAllowedUser(userId: number | null | undefined): boolean { if (!userId) { return false @@ -144,33 +230,153 @@ export class RemoteBindingStore { })) } - getPairingState(): TelegramPairingState { + getFeishuPairedUserOpenIds(): string[] { + return this.getFeishuConfig().pairedUserOpenIds + } + + isFeishuPairedUser(openId: string | null | undefined): boolean { + if (!openId) { + return false + } + return this.getFeishuPairedUserOpenIds().includes(openId.trim()) + } + + addFeishuPairedUser(openId: string): void { + const normalized = openId.trim() + if (!normalized) { + return + } + + this.updateFeishuConfig((config) => ({ + ...config, + pairedUserOpenIds: Array.from(new Set([...config.pairedUserOpenIds, normalized])).sort( + (left, right) => left.localeCompare(right) + ) + })) + } + + getTelegramPairingState(): TelegramPairingState { return this.getTelegramConfig().pairing } - getPairingSnapshot() { + getPairingState(): TelegramPairingState { + return this.getTelegramPairingState() + } + + getFeishuPairingState(): FeishuPairingState { + return this.getFeishuConfig().pairing + } + + getTelegramPairingSnapshot() { return buildTelegramPairingSnapshot(this.getTelegramConfig()) } - createPairCode(): { code: string; expiresAt: number } { + getFeishuPairingSnapshot() { + return buildFeishuPairingSnapshot(this.getFeishuConfig()) + } + + createPairCode(channel: RemoteChannel = 'telegram'): { code: string; expiresAt: number } { const pairing = createPairCode() - this.updateTelegramConfig((config) => ({ - ...config, - pairing - })) - return pairing + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + pairing + })) + } else { + this.updateFeishuConfig((config) => ({ + ...config, + pairing + })) + } + return { + code: pairing.code!, + expiresAt: pairing.expiresAt! + } } - clearPairCode(): void { - this.updateTelegramConfig((config) => ({ + clearPairCode(channel: RemoteChannel = 'telegram'): void { + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + pairing: { + code: null, + expiresAt: null, + failedAttempts: 0 + } + })) + return + } + + this.updateFeishuConfig((config) => ({ ...config, pairing: { code: null, - expiresAt: null + expiresAt: null, + failedAttempts: 0 } })) } + recordPairCodeFailure( + channel: RemoteChannel, + maxAttempts: number + ): { attempts: number; exhausted: boolean } { + let result = { + attempts: 0, + exhausted: false + } + + if (channel === 'telegram') { + this.updateTelegramConfig((config) => { + const attempts = config.pairing.failedAttempts + 1 + const exhausted = attempts >= maxAttempts + result = { + attempts, + exhausted + } + + return { + ...config, + pairing: exhausted + ? { + code: null, + expiresAt: null, + failedAttempts: 0 + } + : { + ...config.pairing, + failedAttempts: attempts + } + } + }) + } else { + this.updateFeishuConfig((config) => { + const attempts = config.pairing.failedAttempts + 1 + const exhausted = attempts >= maxAttempts + result = { + attempts, + exhausted + } + + return { + ...config, + pairing: exhausted + ? { + code: null, + expiresAt: null, + failedAttempts: 0 + } + : { + ...config.pairing, + failedAttempts: attempts + } + } + }) + } + + return result + } + rememberActiveEvent(endpointKey: string, eventId: string): void { this.activeEvents.set(endpointKey, eventId) } @@ -236,6 +442,47 @@ export class RemoteBindingStore { this.modelMenuStates.delete(token) } + private getChannelBindings(channel: RemoteChannel): Record { + const config = this.getChannelConfig(channel) + return config.bindings + } + + private updateBindings( + channel: RemoteChannel, + updater: ( + bindings: Record + ) => Record + ): void { + if (channel === 'telegram') { + this.updateTelegramConfig((config) => ({ + ...config, + bindings: updater(config.bindings) + })) + return + } + + this.updateFeishuConfig((config) => ({ + ...config, + bindings: updater(config.bindings) + })) + } + + private resolveChannelFromEndpointKey(endpointKey: string): RemoteChannel | null { + if (endpointKey.startsWith('telegram:')) { + return 'telegram' + } + if (endpointKey.startsWith('feishu:')) { + return 'feishu' + } + return null + } + + private clearTransientStateForEndpoint(endpointKey: string): void { + this.activeEvents.delete(endpointKey) + this.sessionSnapshots.delete(endpointKey) + this.clearModelMenuStatesForEndpoint(endpointKey) + } + private clearExpiredModelMenuStates(): void { const now = Date.now() for (const [token, state] of this.modelMenuStates.entries()) { diff --git a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts index 1f17ac06b..1d424c51e 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteCommandRouter.ts @@ -10,6 +10,7 @@ import type { } from '../types' import { TELEGRAM_MODEL_MENU_TTL_MS, + TELEGRAM_REMOTE_COMMANDS, buildModelMenuBackCallbackData, buildModelMenuCancelCallbackData, buildModelMenuChoiceCallbackData, @@ -130,6 +131,19 @@ export class RemoteCommandRouter { } } + case 'open': { + const openResult = await this.deps.runner.open(endpointKey) + return { + replies: [ + openResult.status === 'ok' + ? `Opened on desktop: ${this.formatSessionLabel(openResult.session)}` + : openResult.status === 'windowNotFound' + ? 'Could not find a DeepChat desktop window. Open DeepChat and try /open again.' + : 'No bound session. Send a message, /new, or /use first.' + ] + } + } + case 'model': { const session = await this.deps.runner.getCurrentSession(endpointKey) if (!session) { @@ -426,15 +440,15 @@ export class RemoteCommandRouter { private formatHelpMessage(): string { return [ 'Commands:', - '/start', - '/help', - '/pair ', - '/new [title]', - '/sessions', - '/use ', - '/stop', - '/status', - '/model', + ...TELEGRAM_REMOTE_COMMANDS.map((item) => + item.command === 'pair' + ? '/pair - Authorize this Telegram account' + : item.command === 'new' + ? '/new [title] - Start a new DeepChat session' + : item.command === 'use' + ? '/use - Bind a listed session' + : `/${item.command} - ${item.description}` + ), 'Plain text sends to the current bound session.' ].join('\n') } diff --git a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts index c5e23b94a..1a1ae6b8a 100644 --- a/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts +++ b/src/main/presenter/remoteControlPresenter/services/remoteConversationRunner.ts @@ -10,11 +10,12 @@ import type { DeepChatAgentPresenter } from '../../deepchatAgentPresenter' import { TELEGRAM_RECENT_SESSION_LIMIT, TELEGRAM_STREAM_POLL_INTERVAL_MS, + type RemoteEndpointBindingMeta, type TelegramModelProviderOption } from '../types' import { buildTelegramFinalText, - extractTelegramStreamText, + extractTelegramDraftText, safeParseAssistantBlocks } from '../telegram/telegramOutbound' import { RemoteBindingStore } from './remoteBindingStore' @@ -41,6 +42,18 @@ export interface RemoteRunnerStatus { isGenerating: boolean } +export type RemoteOpenSessionResult = + | { + status: 'noSession' + } + | { + status: 'windowNotFound' + } + | { + status: 'ok' + session: SessionWithState + } + type RemoteConversationRunnerDeps = { configPresenter: IConfigPresenter newAgentPresenter: INewAgentPresenter @@ -60,13 +73,21 @@ export class RemoteConversationRunner { private readonly bindingStore: RemoteBindingStore ) {} - async createNewSession(endpointKey: string, title?: string): Promise { + async createNewSession( + endpointKey: string, + title?: string, + bindingMeta?: RemoteEndpointBindingMeta + ): 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) + if (bindingMeta) { + this.bindingStore.setBinding(endpointKey, session.id, bindingMeta) + } else { + this.bindingStore.setBinding(endpointKey, session.id) + } return session } @@ -85,13 +106,16 @@ export class RemoteConversationRunner { return session } - async ensureBoundSession(endpointKey: string): Promise { + async ensureBoundSession( + endpointKey: string, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { const existing = await this.getCurrentSession(endpointKey) if (existing) { return existing } - return await this.createNewSession(endpointKey) + return await this.createNewSession(endpointKey, undefined, bindingMeta) } async listSessions(endpointKey: string): Promise { @@ -109,7 +133,11 @@ export class RemoteConversationRunner { return sorted } - async useSessionByIndex(endpointKey: string, index: number): Promise { + async useSessionByIndex( + endpointKey: string, + index: number, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { const snapshot = this.bindingStore.getSessionSnapshot(endpointKey) if (snapshot.length === 0) { throw new Error('Run /sessions first before using /use.') @@ -125,7 +153,11 @@ export class RemoteConversationRunner { throw new Error('Selected session no longer exists.') } - this.bindingStore.setBinding(endpointKey, session.id) + if (bindingMeta) { + this.bindingStore.setBinding(endpointKey, session.id, bindingMeta) + } else { + this.bindingStore.setBinding(endpointKey, session.id) + } return session } @@ -161,8 +193,12 @@ export class RemoteConversationRunner { return await this.deps.newAgentPresenter.setSessionModel(session.id, providerId, modelId) } - async sendText(endpointKey: string, text: string): Promise { - const session = await this.ensureBoundSession(endpointKey) + async sendText( + endpointKey: string, + text: string, + bindingMeta?: RemoteEndpointBindingMeta + ): Promise { + const session = await this.ensureBoundSession(endpointKey, bindingMeta) const beforeMessages = await this.deps.newAgentPresenter.getMessages(session.id) const lastOrderSeq = beforeMessages.at(-1)?.orderSeq ?? 0 const previousActiveEventId = @@ -214,20 +250,27 @@ export class RemoteConversationRunner { return stopped } - async open(endpointKey: string): Promise { + async open(endpointKey: string): Promise { const session = await this.getCurrentSession(endpointKey) if (!session) { - return null + return { + status: 'noSession' + } } const window = await this.resolveChatWindow() if (!window || window.isDestroyed()) { - return null + return { + status: 'windowNotFound' + } } await this.deps.newAgentPresenter.activateSession(window.webContents.id, session.id) this.deps.windowPresenter.show(window.id, true) - return session + return { + status: 'ok', + session + } } async getStatus(endpointKey: string): Promise { @@ -315,7 +358,7 @@ export class RemoteConversationRunner { return { messageId: trackedMessage.id, - text: completed ? buildTelegramFinalText(blocks) : extractTelegramStreamText(blocks), + text: completed ? buildTelegramFinalText(blocks) : extractTelegramDraftText(blocks), completed } } diff --git a/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts index a68da993d..3ef44ed3d 100644 --- a/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts +++ b/src/main/presenter/remoteControlPresenter/telegram/telegramOutbound.ts @@ -44,22 +44,39 @@ export const blocksRequireDesktopConfirmation = (blocks: AssistantMessageBlock[] block.extra?.needsUserAction !== false ) -export const extractTelegramStreamText = (blocks: AssistantMessageBlock[]): string => { - const preferred = blocks - .filter((block) => block.type === 'content' && typeof block.content === 'string') +const collectText = ( + blocks: AssistantMessageBlock[], + predicate: (block: AssistantMessageBlock) => boolean +): string => + blocks + .filter(predicate) .map((block) => block.content?.trim() ?? '') .filter(Boolean) + .join('\n\n') + .trim() + +export const extractTelegramDraftText = (blocks: AssistantMessageBlock[]): string => + collectText(blocks, (block) => block.type === 'content' && typeof block.content === 'string') + +export const shouldSendTelegramDraft = (blocks: AssistantMessageBlock[]): boolean => + Boolean(extractTelegramDraftText(blocks)) + +export const extractTelegramStreamText = (blocks: AssistantMessageBlock[]): string => { + const preferred = extractTelegramDraftText(blocks) - if (preferred.length > 0) { - return preferred.join('\n\n').trim() + if (preferred) { + return preferred } - return blocks - .filter((block) => block.type !== 'tool_call' && typeof block.content === 'string') - .map((block) => block.content?.trim() ?? '') - .filter(Boolean) - .join('\n\n') - .trim() + return collectText( + blocks, + (block) => + typeof block.content === 'string' && + (block.type === 'content' || + (block.type === 'action' && + (block.action_type === 'tool_call_permission' || + block.action_type === 'question_request'))) + ) } export const buildTelegramFinalText = (blocks: AssistantMessageBlock[]): string => { diff --git a/src/main/presenter/remoteControlPresenter/types.ts b/src/main/presenter/remoteControlPresenter/types.ts index 0ed146f28..3d2046211 100644 --- a/src/main/presenter/remoteControlPresenter/types.ts +++ b/src/main/presenter/remoteControlPresenter/types.ts @@ -1,9 +1,15 @@ import { z } from 'zod' import type { HookEventName } from '@shared/hooksNotifications' import type { + FeishuPairingSnapshot, + FeishuRemoteSettings, + FeishuRemoteStatus, + RemoteBindingKind, + RemoteBindingSummary, + RemoteChannel, + RemoteRuntimeState, TelegramPairingSnapshot, TelegramRemoteBindingSummary, - TelegramRemoteRuntimeState, TelegramRemoteSettings, TelegramRemoteStatus, TelegramStreamMode @@ -14,6 +20,11 @@ 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 FEISHU_PAIR_CODE_TTL_MS = TELEGRAM_PAIR_CODE_TTL_MS +export const REMOTE_PAIR_CODE_MAX_FAILURES = 5 +export const FEISHU_INBOUND_DEDUP_TTL_MS = 30 * 60 * 1000 +export const FEISHU_INBOUND_DEDUP_LIMIT = 2048 +export const FEISHU_CONVERSATION_POLL_TIMEOUT_MS = 5 * 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 @@ -21,6 +32,7 @@ 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 FEISHU_REMOTE_DEFAULT_AGENT_ID = TELEGRAM_REMOTE_DEFAULT_AGENT_ID export const TELEGRAM_REMOTE_REACTION_EMOJI = '🤯' export const TELEGRAM_REMOTE_COMMANDS = [ { @@ -51,6 +63,10 @@ export const TELEGRAM_REMOTE_COMMANDS = [ command: 'stop', description: 'Stop the active generation' }, + { + command: 'open', + description: 'Open the current session on desktop' + }, { command: 'model', description: 'Switch provider and model' @@ -61,16 +77,72 @@ export const TELEGRAM_REMOTE_COMMANDS = [ } ] as const -export type TelegramEndpointBinding = { +export const FEISHU_REMOTE_COMMANDS = [ + { + command: 'start', + description: 'Show remote control status' + }, + { + command: 'help', + description: 'Show available commands' + }, + { + command: 'pair', + description: 'Authorize this Feishu 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: 'open', + description: 'Open the current session on desktop' + }, + { + command: 'model', + description: 'View or switch the current model' + }, + { + command: 'status', + description: 'Show runtime and session status' + } +] as const + +export interface RemoteEndpointBindingMeta { + channel: RemoteChannel + kind: RemoteBindingKind + chatId: string + threadId: string | null +} + +export type RemoteEndpointBinding = { sessionId: string updatedAt: number + meta?: RemoteEndpointBindingMeta } +export type TelegramEndpointBinding = RemoteEndpointBinding + export type TelegramPairingState = { code: string | null expiresAt: number | null + failedAttempts: number } +export type FeishuPairingState = TelegramPairingState + export type TelegramCommandPayload = { name: string args: string @@ -87,8 +159,22 @@ export interface TelegramRemoteRuntimeConfig { bindings: Record } +export interface FeishuRemoteRuntimeConfig { + appId: string + appSecret: string + verificationToken: string + encryptKey: string + enabled: boolean + defaultAgentId: string + pairedUserOpenIds: string[] + lastFatalError: string | null + pairing: FeishuPairingState + bindings: Record +} + export interface RemoteControlConfig { telegram: TelegramRemoteRuntimeConfig + feishu: FeishuRemoteRuntimeConfig } interface TelegramInboundBase { @@ -114,6 +200,28 @@ export interface TelegramInboundCallbackQuery extends TelegramInboundBase { export type TelegramInboundEvent = TelegramInboundMessage | TelegramInboundCallbackQuery +export interface FeishuRawMention { + key: string + id?: { + open_id?: string + } + name?: string +} + +export interface FeishuInboundMessage { + kind: 'message' + eventId: string + chatId: string + threadId: string | null + messageId: string + chatType: 'p2p' | 'group' + senderOpenId: string | null + text: string + command: TelegramCommandPayload | null + mentionedBot: boolean + mentions: FeishuRawMention[] +} + export interface TelegramInlineKeyboardButton { text: string callback_data: string @@ -177,6 +285,8 @@ export type TelegramModelMenuCallback = } const TELEGRAM_MODEL_MENU_CALLBACK_PREFIX = 'model' +const TELEGRAM_ENDPOINT_KEY_REGEX = /^telegram:(-?\d+):(-?\d+)$/ +const FEISHU_ENDPOINT_KEY_REGEX = /^feishu:([^:]+):([^:]+)$/ export const createTelegramCallbackToken = (): string => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` @@ -251,16 +361,28 @@ export const parseModelMenuCallbackData = (data: string): TelegramModelMenuCallb } export interface TelegramPollerStatusSnapshot { - state: TelegramRemoteRuntimeState + state: RemoteRuntimeState lastError: string | null botUser: TelegramRemoteStatus['botUser'] } +export interface FeishuRuntimeStatusSnapshot { + state: RemoteRuntimeState + lastError: string | null + botUser: FeishuRemoteStatus['botUser'] +} + export interface TelegramTransportTarget { chatId: number messageThreadId: number } +export interface FeishuTransportTarget { + chatId: string + threadId: string | null + replyToMessageId?: string | null +} + export interface TelegramRemoteHookSettingsInput { enabled: boolean chatId: string @@ -278,23 +400,51 @@ export const createDefaultRemoteControlConfig = (): RemoteControlConfig => ({ lastFatalError: null, pairing: { code: null, - expiresAt: null + expiresAt: null, + failedAttempts: 0 + }, + bindings: {} + }, + feishu: { + appId: '', + appSecret: '', + verificationToken: '', + encryptKey: '', + enabled: false, + defaultAgentId: FEISHU_REMOTE_DEFAULT_AGENT_ID, + pairedUserOpenIds: [], + lastFatalError: null, + pairing: { + code: null, + expiresAt: null, + failedAttempts: 0 }, bindings: {} } }) -const TelegramEndpointBindingSchema = z +const RemoteEndpointBindingMetaSchema = z + .object({ + channel: z.enum(['telegram', 'feishu']).optional(), + kind: z.enum(['dm', 'group', 'topic']).optional(), + chatId: z.string().optional(), + threadId: z.string().nullable().optional() + }) + .strip() + +const RemoteEndpointBindingSchema = z .object({ sessionId: z.string().min(1), - updatedAt: z.number().int().nonnegative().optional() + updatedAt: z.number().int().nonnegative().optional(), + meta: RemoteEndpointBindingMetaSchema.optional() }) .strip() -const TelegramPairingStateSchema = z +const PairingStateSchema = z .object({ code: z.string().nullable().optional(), - expiresAt: z.number().int().nonnegative().nullable().optional() + expiresAt: z.number().int().nonnegative().nullable().optional(), + failedAttempts: z.number().int().nonnegative().optional() }) .strip() @@ -306,17 +456,97 @@ const TelegramRemoteRuntimeConfigSchema = z streamMode: z.enum(['draft', 'final']).optional(), pollOffset: z.number().int().nonnegative().optional(), lastFatalError: z.string().nullable().optional(), - pairing: TelegramPairingStateSchema.optional(), + pairing: PairingStateSchema.optional(), + bindings: z.record(z.string(), z.unknown()).optional() + }) + .strip() + +const FeishuRemoteRuntimeConfigSchema = z + .object({ + appId: z.string().optional(), + appSecret: z.string().optional(), + verificationToken: z.string().optional(), + encryptKey: z.string().optional(), + enabled: z.boolean().optional(), + defaultAgentId: z.string().optional(), + pairedUserOpenIds: z.array(z.string()).optional(), + lastFatalError: z.string().nullable().optional(), + pairing: PairingStateSchema.optional(), bindings: z.record(z.string(), z.unknown()).optional() }) .strip() const RemoteControlConfigSchema = z .object({ - telegram: TelegramRemoteRuntimeConfigSchema.optional() + telegram: TelegramRemoteRuntimeConfigSchema.optional(), + feishu: FeishuRemoteRuntimeConfigSchema.optional() }) .strip() +type LegacyTelegramRemoteConfig = z.infer +type LegacyFeishuRemoteConfig = z.infer + +const hasOwn = (value: Record, key: string): boolean => + Object.prototype.hasOwnProperty.call(value, key) + +const hasAnyOwn = (value: Record, keys: string[]): boolean => + keys.some((key) => hasOwn(value, key)) + +const hasBindingPrefix = (value: Record, prefix: string): boolean => { + const bindings = value.bindings + if (!bindings || typeof bindings !== 'object' || Array.isArray(bindings)) { + return false + } + + return Object.keys(bindings as Record).some((key) => key.startsWith(prefix)) +} + +const extractLegacyTelegramConfig = (input: unknown): LegacyTelegramRemoteConfig | null => { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null + } + + const record = input as Record + if ( + !hasAnyOwn(record, ['allowlist', 'streamMode', 'pollOffset', 'lastFatalError']) && + !hasBindingPrefix(record, 'telegram:') + ) { + return null + } + + const parsed = TelegramRemoteRuntimeConfigSchema.safeParse(record) + return parsed.success ? parsed.data : null +} + +const extractLegacyFeishuConfig = (input: unknown): LegacyFeishuRemoteConfig | null => { + if (!input || typeof input !== 'object' || Array.isArray(input)) { + return null + } + + const record = input as Record + if ( + !hasAnyOwn(record, [ + 'appId', + 'appSecret', + 'verificationToken', + 'encryptKey', + 'pairedUserOpenIds', + 'lastFatalError' + ]) && + !hasBindingPrefix(record, 'feishu:') + ) { + return null + } + + const parsed = FeishuRemoteRuntimeConfigSchema.safeParse(record) + return parsed.success ? parsed.data : null +} + +const normalizeStringList = (input: Array | undefined): string[] => + Array.from( + new Set((input ?? []).map((value) => String(value ?? '').trim()).filter(Boolean)) + ).sort((left, right) => left.localeCompare(right)) + export const normalizeTelegramUserIds = (input: Array | undefined): number[] => { const normalized = new Set() for (const value of input ?? []) { @@ -333,17 +563,38 @@ export const normalizeTelegramUserIds = (input: Array | undefin 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 +export const normalizeFeishuOpenIds = (input: Array | undefined): string[] => + normalizeStringList(input) + +const normalizeBindingMeta = ( + endpointKey: string, + meta: unknown, + fallbackChannel: RemoteChannel +): RemoteEndpointBindingMeta | undefined => { + const parsed = RemoteEndpointBindingMetaSchema.safeParse(meta) + if (parsed.success && parsed.data.channel && parsed.data.kind && parsed.data.chatId) { + return { + channel: parsed.data.channel, + kind: parsed.data.kind, + chatId: parsed.data.chatId, + threadId: parsed.data.threadId ?? null + } + } + + if (fallbackChannel === 'telegram') { + return deriveTelegramBindingMeta(endpointKey) ?? undefined } - const telegram = parsed.data.telegram ?? {} - const bindings: Record = {} - for (const [endpointKey, binding] of Object.entries(telegram.bindings ?? {})) { - const parsedBinding = TelegramEndpointBindingSchema.safeParse(binding) + return deriveFeishuBindingMeta(endpointKey) ?? undefined +} + +const normalizeBindings = ( + rawBindings: Record | undefined, + channel: RemoteChannel +): Record => { + const bindings: Record = {} + for (const [endpointKey, binding] of Object.entries(rawBindings ?? {})) { + const parsedBinding = RemoteEndpointBindingSchema.safeParse(binding) if (!parsedBinding.success) { continue } @@ -355,15 +606,28 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi bindings[endpointKey] = { sessionId: normalizedSessionId, - updatedAt: parsedBinding.data.updatedAt ?? Date.now() + updatedAt: parsedBinding.data.updatedAt ?? Date.now(), + meta: normalizeBindingMeta(endpointKey, parsedBinding.data.meta, channel) } } + return bindings +} + +export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfig => { + const defaults = createDefaultRemoteControlConfig() + const parsed = RemoteControlConfigSchema.safeParse(input) + if (!parsed.success) { + return defaults + } + + const telegram = parsed.data.telegram ?? extractLegacyTelegramConfig(input) ?? {} + const feishu = parsed.data.feishu ?? extractLegacyFeishuConfig(input) ?? {} return { telegram: { enabled: Boolean(telegram.enabled), allowlist: normalizeTelegramUserIds(telegram.allowlist), - streamMode: 'draft', + streamMode: telegram.streamMode === 'final' ? 'final' : defaults.telegram.streamMode, defaultAgentId: telegram.defaultAgentId?.trim() || defaults.telegram.defaultAgentId, pollOffset: typeof telegram.pollOffset === 'number' && telegram.pollOffset >= 0 @@ -373,9 +637,33 @@ export const normalizeRemoteControlConfig = (input: unknown): RemoteControlConfi pairing: { code: telegram.pairing?.code?.trim() || null, expiresAt: - typeof telegram.pairing?.expiresAt === 'number' ? telegram.pairing.expiresAt : null + typeof telegram.pairing?.expiresAt === 'number' ? telegram.pairing.expiresAt : null, + failedAttempts: + typeof telegram.pairing?.failedAttempts === 'number' && + telegram.pairing.failedAttempts >= 0 + ? Math.trunc(telegram.pairing.failedAttempts) + : 0 }, - bindings + bindings: normalizeBindings(telegram.bindings, 'telegram') + }, + feishu: { + appId: feishu.appId?.trim() || '', + appSecret: feishu.appSecret?.trim() || '', + verificationToken: feishu.verificationToken?.trim() || '', + encryptKey: feishu.encryptKey?.trim() || '', + enabled: Boolean(feishu.enabled), + defaultAgentId: feishu.defaultAgentId?.trim() || defaults.feishu.defaultAgentId, + pairedUserOpenIds: normalizeFeishuOpenIds(feishu.pairedUserOpenIds), + lastFatalError: feishu.lastFatalError?.trim() || null, + pairing: { + code: feishu.pairing?.code?.trim() || null, + expiresAt: typeof feishu.pairing?.expiresAt === 'number' ? feishu.pairing.expiresAt : null, + failedAttempts: + typeof feishu.pairing?.failedAttempts === 'number' && feishu.pairing.failedAttempts >= 0 + ? Math.trunc(feishu.pairing.failedAttempts) + : 0 + }, + bindings: normalizeBindings(feishu.bindings, 'feishu') } } } @@ -386,7 +674,7 @@ export const buildTelegramEndpointKey = (chatId: number, messageThreadId: number export const parseTelegramEndpointKey = ( endpointKey: string ): Pick | null => { - const match = /^telegram:(-?\d+):(-?\d+)$/.exec(endpointKey.trim()) + const match = TELEGRAM_ENDPOINT_KEY_REGEX.exec(endpointKey.trim()) if (!match) { return null } @@ -397,11 +685,101 @@ export const parseTelegramEndpointKey = ( } } -export const createPairCode = (): { code: string; expiresAt: number } => { - const code = `${Math.floor(100000 + Math.random() * 900000)}` +export const buildTelegramBindingMeta = ( + chatId: number, + messageThreadId: number +): RemoteEndpointBindingMeta => { + const normalizedThreadId = messageThreadId || TELEGRAM_PRIVATE_THREAD_DEFAULT + const isTopic = normalizedThreadId > 0 + const isGroup = chatId < 0 return { - code, - expiresAt: Date.now() + TELEGRAM_PAIR_CODE_TTL_MS + channel: 'telegram', + kind: isTopic ? 'topic' : isGroup ? 'group' : 'dm', + chatId: String(chatId), + threadId: isTopic ? String(normalizedThreadId) : null + } +} + +export const deriveTelegramBindingMeta = ( + endpointKey: string +): RemoteEndpointBindingMeta | null => { + const endpoint = parseTelegramEndpointKey(endpointKey) + if (!endpoint) { + return null + } + + return buildTelegramBindingMeta(endpoint.chatId, endpoint.messageThreadId) +} + +export const buildFeishuEndpointKey = (chatId: string, threadId?: string | null): string => + `feishu:${chatId}:${threadId?.trim() || 'root'}` + +export const parseFeishuEndpointKey = ( + endpointKey: string +): Pick | null => { + const match = FEISHU_ENDPOINT_KEY_REGEX.exec(endpointKey.trim()) + if (!match) { + return null + } + + return { + chatId: match[1], + threadId: match[2] === 'root' ? null : match[2] + } +} + +export const buildFeishuBindingMeta = (params: { + chatId: string + threadId?: string | null + chatType: 'p2p' | 'group' +}): RemoteEndpointBindingMeta => ({ + channel: 'feishu', + kind: params.chatType === 'p2p' ? 'dm' : params.threadId ? 'topic' : 'group', + chatId: params.chatId.trim(), + threadId: params.threadId?.trim() || null +}) + +export const deriveFeishuBindingMeta = (endpointKey: string): RemoteEndpointBindingMeta | null => { + const endpoint = parseFeishuEndpointKey(endpointKey) + if (!endpoint) { + return null + } + + return { + channel: 'feishu', + kind: endpoint.threadId ? 'topic' : 'group', + chatId: endpoint.chatId, + threadId: endpoint.threadId + } +} + +export const buildBindingSummary = ( + endpointKey: string, + binding: RemoteEndpointBinding +): RemoteBindingSummary | null => { + const meta = + binding.meta ?? deriveTelegramBindingMeta(endpointKey) ?? deriveFeishuBindingMeta(endpointKey) + + if (!meta) { + return null + } + + return { + channel: meta.channel, + endpointKey, + sessionId: binding.sessionId, + chatId: meta.chatId, + threadId: meta.threadId, + kind: meta.kind, + updatedAt: binding.updatedAt + } +} + +export const createPairCode = (ttlMs: number = TELEGRAM_PAIR_CODE_TTL_MS): TelegramPairingState => { + return { + code: `${Math.floor(100000 + Math.random() * 900000)}`, + failedAttempts: 0, + expiresAt: Date.now() + ttlMs } } @@ -420,6 +798,18 @@ export const normalizeTelegramSettingsInput = ( } }) +export const normalizeFeishuSettingsInput = ( + input: FeishuRemoteSettings +): FeishuRemoteSettings => ({ + appId: input.appId?.trim() ?? '', + appSecret: input.appSecret?.trim() ?? '', + verificationToken: input.verificationToken?.trim() ?? '', + encryptKey: input.encryptKey?.trim() ?? '', + remoteEnabled: Boolean(input.remoteEnabled), + defaultAgentId: input.defaultAgentId?.trim() || FEISHU_REMOTE_DEFAULT_AGENT_ID, + pairedUserOpenIds: normalizeFeishuOpenIds(input.pairedUserOpenIds) +}) + export const buildTelegramPairingSnapshot = ( settings: TelegramRemoteRuntimeConfig ): TelegramPairingSnapshot => ({ @@ -427,3 +817,11 @@ export const buildTelegramPairingSnapshot = ( pairCodeExpiresAt: settings.pairing.expiresAt, allowedUserIds: [...settings.allowlist] }) + +export const buildFeishuPairingSnapshot = ( + settings: FeishuRemoteRuntimeConfig +): FeishuPairingSnapshot => ({ + pairCode: settings.pairing.code, + pairCodeExpiresAt: settings.pairing.expiresAt, + pairedUserOpenIds: [...settings.pairedUserOpenIds] +}) diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index 9d29d63eb..71dcf250c 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -461,10 +461,13 @@ export class ToolPresenter implements IToolPresenter { lines.push('- Use `get_browser_status` to inspect the current session browser state.') } if (toolNames.has('load_url')) { - lines.push('- Use `load_url` to lazily create the session browser and navigate to a page.') + lines.push('- Prefer `load_url` to create the session browser and handle navigation.') } if (toolNames.has('cdp_send')) { - lines.push('- Use `cdp_send` for DOM inspection, scripted interaction, and screenshots.') + lines.push( + '- Use `cdp_send` for DOM inspection, scripted interaction, screenshots, and low-level CDP commands.' + ) + lines.push('- Avoid using `cdp_send` `Page.navigate` for normal navigation unless needed.') } return lines.join('\n') diff --git a/src/renderer/settings/components/RemoteSettings.vue b/src/renderer/settings/components/RemoteSettings.vue index 73d606dd6..62ac6c261 100644 --- a/src/renderer/settings/components/RemoteSettings.vue +++ b/src/renderer/settings/components/RemoteSettings.vue @@ -4,14 +4,17 @@
{{ t('common.loading') }}
-
+
{{ t('common.error.requestFailed') }}
@@ -296,9 +546,19 @@
- {{ t('settings.remote.remoteControl.pairDialogTitle') }} + + {{ + t('settings.remote.remoteControl.pairDialogTitle', { + channel: pairDialogChannel ? t(`settings.remote.${pairDialogChannel}.title`) : '' + }) + }} + - {{ t('settings.remote.remoteControl.pairDialogDescription') }} + {{ + t('settings.remote.remoteControl.pairDialogDescription', { + channel: pairDialogChannel ? t(`settings.remote.${pairDialogChannel}.title`) : '' + }) + }} @@ -321,7 +581,11 @@
- {{ t('settings.remote.remoteControl.pairDialogInstruction') }} + {{ + pairDialogChannel === 'feishu' + ? t('settings.remote.remoteControl.pairDialogInstructionFeishu') + : t('settings.remote.remoteControl.pairDialogInstructionTelegram') + }}
/pair {{ pairDialogCode || '------' }} @@ -342,9 +606,23 @@
- {{ t('settings.remote.remoteControl.bindingsDialogTitle') }} + + {{ + t('settings.remote.remoteControl.bindingsDialogTitle', { + channel: bindingsDialogChannel + ? t(`settings.remote.${bindingsDialogChannel}.title`) + : '' + }) + }} + - {{ t('settings.remote.remoteControl.bindingsDialogDescription') }} + {{ + t('settings.remote.remoteControl.bindingsDialogDescription', { + channel: bindingsDialogChannel + ? t(`settings.remote.${bindingsDialogChannel}.title`) + : '' + }) + }} @@ -367,9 +645,20 @@ class="flex items-center justify-between gap-3 rounded-lg border p-3" >
-
{{ binding.sessionId }}
+
+
{{ binding.sessionId }}
+ + {{ t(`settings.remote.bindingKinds.${binding.kind}`) }} + +
- telegram:{{ binding.chatId }}:{{ binding.messageThreadId }} + {{ binding.channel }}:{{ binding.chatId + }}{{ binding.threadId ? `:${binding.threadId}` : '' }}