diff --git a/apps/desktop/electron/electron-env.d.ts b/apps/desktop/electron/electron-env.d.ts index b53664a4..98b087ac 100644 --- a/apps/desktop/electron/electron-env.d.ts +++ b/apps/desktop/electron/electron-env.d.ts @@ -202,7 +202,7 @@ interface ElectronAPI { getConfig: () => Promise> | undefined>> saveToken: (channelId: string, accountId: string, token: string) => Promise<{ ok: boolean; error?: string }> removeToken: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> - stop: (channelId: string, accountId: string) => Promise<{ ok: boolean }> + stop: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> start: (channelId: string, accountId: string) => Promise<{ ok: boolean; error?: string }> } localChat: { diff --git a/apps/desktop/electron/ipc/channels.ts b/apps/desktop/electron/ipc/channels.ts index dd85ef74..49662e94 100644 --- a/apps/desktop/electron/ipc/channels.ts +++ b/apps/desktop/electron/ipc/channels.ts @@ -10,6 +10,21 @@ import { getCurrentHub } from './hub.js' import { credentialManager } from '../../../../src/agent/credentials.js' import { listChannels } from '../../../../src/channels/registry.js' +/** Validate that a string is a safe identifier (alphanumeric, dashes, underscores) */ +function isValidId(value: unknown): value is string { + return typeof value === 'string' && /^[a-zA-Z0-9_-]+$/.test(value) && value.length <= 64 +} + +/** + * Mask a token string for safe display: show first 5 and last 5 chars. + * Returns undefined if the input is not a string. + */ +function maskToken(token: unknown): string | undefined { + if (typeof token !== 'string' || token.length === 0) return undefined + if (token.length <= 12) return '*'.repeat(token.length) + return `${token.slice(0, 5)}${'*'.repeat(10)}${token.slice(-5)}` +} + /** * Register all Channel-related IPC handlers. */ @@ -25,20 +40,42 @@ export function registerChannelsIpcHandlers(): void { /** * Get the channels config from credentials.json5. - * Returns the raw `channels` section: { telegram: { default: { botToken: "..." } } } + * Returns a sanitized version with tokens masked (not the raw secret values). */ ipcMain.handle('channels:getConfig', async () => { - return credentialManager.getChannelsConfig() + const raw = credentialManager.getChannelsConfig() + // Mask secret values before sending to renderer + const masked: Record> | undefined> = {} + for (const [channelId, accounts] of Object.entries(raw)) { + if (!accounts) continue + const maskedAccounts: Record> = {} + for (const [accountId, accountConfig] of Object.entries(accounts)) { + const maskedConfig = { ...accountConfig } + if ('botToken' in maskedConfig) { + maskedConfig.botToken = maskToken(maskedConfig.botToken) + } + maskedAccounts[accountId] = maskedConfig + } + masked[channelId] = maskedAccounts + } + return masked }) /** * Save a channel account token and start the bot immediately. - * Flow: write to credentials.json5 → start the channel account. + * Flow: validate → write to credentials.json5 → start the channel account. */ ipcMain.handle( 'channels:saveToken', async (_event, channelId: string, accountId: string, token: string): Promise<{ ok: boolean; error?: string }> => { try { + // Validate inputs + if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' } + if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' } + if (typeof token !== 'string' || token.trim().length === 0 || token.length > 256) { + return { ok: false, error: 'Invalid token' } + } + const hub = getCurrentHub() if (!hub) return { ok: false, error: 'Hub not initialized' } @@ -73,6 +110,9 @@ export function registerChannelsIpcHandlers(): void { 'channels:removeToken', async (_event, channelId: string, accountId: string): Promise<{ ok: boolean; error?: string }> => { try { + if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' } + if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' } + const hub = getCurrentHub() if (!hub) return { ok: false, error: 'Hub not initialized' } @@ -97,9 +137,11 @@ export function registerChannelsIpcHandlers(): void { */ ipcMain.handle( 'channels:stop', - async (_event, channelId: string, accountId: string): Promise<{ ok: boolean }> => { + async (_event, channelId: string, accountId: string): Promise<{ ok: boolean; error?: string }> => { + if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' } + if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' } const hub = getCurrentHub() - if (!hub) return { ok: false } + if (!hub) return { ok: false, error: 'Hub not initialized' } hub.channelManager.stopAccount(channelId, accountId) return { ok: true } } @@ -112,6 +154,9 @@ export function registerChannelsIpcHandlers(): void { 'channels:start', async (_event, channelId: string, accountId: string): Promise<{ ok: boolean; error?: string }> => { try { + if (!isValidId(channelId)) return { ok: false, error: 'Invalid channel ID' } + if (!isValidId(accountId)) return { ok: false, error: 'Invalid account ID' } + const hub = getCurrentHub() if (!hub) return { ok: false, error: 'Hub not initialized' } diff --git a/apps/desktop/src/hooks/use-channels.ts b/apps/desktop/src/hooks/use-channels.ts index 29f52100..ba8ae386 100644 --- a/apps/desktop/src/hooks/use-channels.ts +++ b/apps/desktop/src/hooks/use-channels.ts @@ -8,7 +8,7 @@ */ import { useState, useEffect, useCallback } from 'react' -interface UseChannelsReturn { +export interface UseChannelsReturn { /** Runtime states of all channel accounts */ states: ChannelAccountStateInfo[] /** Raw channel config from credentials.json5 */ @@ -94,17 +94,31 @@ export function useChannels(): UseChannelsReturn { }, [refresh]) const stopChannel = useCallback(async (channelId: string, accountId: string) => { - await window.electronAPI.channels.stop(channelId, accountId) - await refresh() + setError(null) + try { + const result = await window.electronAPI.channels.stop(channelId, accountId) + if (!result.ok) { + setError(result.error ?? 'Failed to stop channel') + } + await refresh() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) + } }, [refresh]) const startChannel = useCallback(async (channelId: string, accountId: string) => { setError(null) - const result = await window.electronAPI.channels.start(channelId, accountId) - if (!result.ok) { - setError(result.error ?? 'Failed to start channel') + try { + const result = await window.electronAPI.channels.start(channelId, accountId) + if (!result.ok) { + setError(result.error ?? 'Failed to start channel') + } + await refresh() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setError(message) } - await refresh() }, [refresh]) return { diff --git a/apps/desktop/src/pages/channels.tsx b/apps/desktop/src/pages/channels.tsx index 1f7b38cd..81bc8926 100644 --- a/apps/desktop/src/pages/channels.tsx +++ b/apps/desktop/src/pages/channels.tsx @@ -9,7 +9,7 @@ import { import { Button } from '@multica/ui/components/ui/button' import { Input } from '@multica/ui/components/ui/input' import { Badge } from '@multica/ui/components/ui/badge' -import { useChannels } from '../hooks/use-channels' +import { useChannels, type UseChannelsReturn } from '../hooks/use-channels' /** Status badge color mapping */ function statusVariant(status: string): 'default' | 'secondary' | 'destructive' | 'outline' { @@ -21,8 +21,8 @@ function statusVariant(status: string): 'default' | 'secondary' | 'destructive' } } -function TelegramCard() { - const { states, config, saveToken, removeToken, startChannel, stopChannel } = useChannels() +function TelegramCard({ channels }: { channels: UseChannelsReturn }) { + const { states, config, saveToken, removeToken, startChannel, stopChannel } = channels const [token, setToken] = useState('') const [saving, setSaving] = useState(false) const [localError, setLocalError] = useState(null) @@ -118,6 +118,7 @@ function TelegramCard() { size="sm" onClick={handleRemove} disabled={saving || isRunning} + title={isRunning ? 'Stop the bot before removing' : undefined} > Remove @@ -152,7 +153,8 @@ function TelegramCard() { } export default function ChannelsPage() { - const { loading, error } = useChannels() + const channels = useChannels() + const { loading, error } = channels return (
@@ -168,7 +170,7 @@ export default function ChannelsPage() { ) : error ? (

{error}

) : ( - + )}
) diff --git a/src/channels/manager.ts b/src/channels/manager.ts index fb094065..8762d674 100644 --- a/src/channels/manager.ts +++ b/src/channels/manager.ts @@ -439,15 +439,31 @@ export class ChannelManager { /** * Stop a specific channel account. * Public so the desktop IPC layer can call it when removing config. + * Cleans up typing timer, debouncer, aggregator, and lastRoute if they + * belong to this account. */ stopAccount(channelId: string, accountId: string): void { const key = `${channelId}:${accountId}`; const handle = this.accounts.get(key); if (!handle) return; + // Clean up shared resources if they target this account + if (this.lastRoute && this.lastRoute.plugin.id === channelId && this.lastRoute.deliveryCtx.accountId === accountId) { + this.stopTyping(); + this.lastRoute = null; + this.aggregator = null; + } + handle.abortController.abort(); handle.state = { ...handle.state, status: "stopped" }; this.accounts.delete(key); + + // Dispose debouncer if no accounts remain + if (this.accounts.size === 0 && this.debouncer) { + this.debouncer.dispose(); + this.debouncer = null; + } + console.log(`[Channels] Stopped ${key}`); }