import express from 'express'; import { z } from 'zod'; import crypto from 'crypto'; import { storage } from "../storage"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { validateRequest } from "../middleware/validation.middleware"; import { createBotSchema, updateBotSchema, botLoginSchema, mcpServerSchema, } from "@shared/schema"; import { generateTokens } from "../utils/jwt"; import { verifyPassword } from "../utils/password"; import { authLimiter } from "./shared"; import { encrypt, decrypt } from "../crypto"; const CYRILLIC_TO_LATIN: Record = { а:'a',б:'b',в:'v',г:'g',д:'d',е:'e',ё:'yo',ж:'zh',з:'z',и:'i',й:'y',к:'k',л:'l',м:'m',н:'n', о:'o',п:'p',р:'r',с:'s',т:'t',у:'u',ф:'f',х:'kh',ц:'ts',ч:'ch',ш:'sh',щ:'shch',ъ:'',ы:'y', ь:'',э:'e',ю:'yu',я:'ya', }; function transliterate(str: string): string { return str .toLowerCase() .split('') .map(c => CYRILLIC_TO_LATIN[c] ?? c) .join('') .replace(/[^a-z0-9]+/g, '_') .replace(/^_+|_+$/g, '') .slice(0, 50); } async function generateBotLogin(name: string, organizationId: number): Promise { const base = transliterate(name) || 'bot'; let login = base; let counter = 1; while (await storage.getBotByLogin(login, organizationId)) { const suffix = `_${counter}`; login = base.slice(0, 50 - suffix.length) + suffix; counter++; if (counter > 9999) { login = `${base}_${Date.now()}`; break; } } return login; } export function registerBotCrudRoutes(app: import("express").Express): void { // Bot authentication app.post('/api/bot/auth/login', authLimiter, validateRequest(botLoginSchema), async (req, res) => { try { const { login, password, organizationSlug } = req.body; const organization = await storage.getOrganizationBySlug(organizationSlug); if (!organization) { return res.status(401).json({ success: false, error: 'Организация не найдена' }); } const bot = await storage.getBotByLogin(login, organization.id); if (!bot) { return res.status(401).json({ success: false, error: 'Неверный логин или пароль' }); } if (!bot.isActive) { return res.status(401).json({ success: false, error: 'Бот деактивирован' }); } const isValidPassword = await verifyPassword(password, bot.passwordHash); if (!isValidPassword) { return res.status(401).json({ success: false, error: 'Неверный логин или пароль' }); } const tokens = generateTokens({ userId: bot.id, organizationId: organization.id, appRole: 'bot' }); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); await storage.createBotSession({ botId: bot.id, refreshToken: tokens.refreshToken, expiresAt, ipAddress: req.ip || null, userAgent: req.headers['user-agent'] || null }); res.json({ success: true, bot: { id: bot.id, name: bot.name, login: bot.login }, organization: { id: organization.id, name: organization.name, slug: organization.slug }, tokens }); } catch (error) { console.error('Bot login error:', error); res.status(500).json({ success: false, error: 'Ошибка авторизации бота' }); } } ); // Get all bots for organization (admin only) app.get('/api/bots', authenticateToken, requirePermission('bots.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const bots = await storage.getBotsByOrganization(req.organizationId!); const safeBots = bots.map(bot => ({ id: bot.id, name: bot.name, description: bot.description, avatarUrl: bot.avatarUrl, login: bot.login, webhookUrl: bot.webhookUrl, webhookEnabled: bot.webhookEnabled, isActive: bot.isActive, createdAt: bot.createdAt, type: bot.type ?? 'webhook', ragEnabled: bot.ragEnabled ?? false, mcpServers: bot.mcpServers ? bot.mcpServers.map(s => ({ url: s.url, name: s.name })) : null, })); res.json({ success: true, bots: safeBots }); } catch (error) { console.error('Get bots error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении ботов' }); } } ); // Get bot by id (admin only) app.get('/api/bots/:id', authenticateToken, requirePermission('bots.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const botId = parseInt(req.params.id); if (isNaN(botId)) { return res.status(400).json({ success: false, error: 'Некорректный ID бота' }); } const bot = await storage.getBotWithSubscriptions(botId, req.organizationId!); if (!bot) { return res.status(404).json({ success: false, error: 'Бот не найден' }); } res.json({ success: true, bot: { id: bot.id, name: bot.name, description: bot.description, avatarUrl: bot.avatarUrl, login: bot.login, webhookUrl: bot.webhookUrl, webhookSecret: bot.webhookSecret ? '***' : null, webhookEnabled: bot.webhookEnabled, isActive: bot.isActive, createdAt: bot.createdAt, subscriptions: bot.subscriptions, type: bot.type ?? 'webhook', systemPrompt: bot.systemPrompt ?? null, ragEnabled: bot.ragEnabled ?? false, ragTopK: bot.ragTopK ?? 5, mcpServers: bot.mcpServers ? bot.mcpServers.map(s => ({ url: s.url, name: s.name, apiKey: s.apiKey ? '***' : undefined })) : null, accessPolicy: bot.accessPolicy ?? null, llmProviderId: bot.llmProviderId ?? null, llmModel: bot.llmModel ?? null, sendSystemPrompt: bot.sendSystemPrompt ?? true, sendCardInfo: bot.sendCardInfo ?? true, sendChatHistory: bot.sendChatHistory ?? true, } }); } catch (error) { console.error('Get bot error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении бота' }); } } ); // Create bot (admin only) app.post('/api/bots', authenticateToken, requirePermission('bots.manage'), tenantIsolation, validateRequest(createBotSchema), async (req: AuthenticatedRequest, res) => { try { const { name, description, avatarUrl, webhookUrl, webhookSecret, type, systemPrompt, ragEnabled, ragTopK, mcpServers, accessPolicy, llmProviderId, llmModel } = req.body; const isAiBot = type === 'ai_assistant'; const login: string = req.body.login || await generateBotLogin(name, req.organizationId!); const password: string = req.body.password || crypto.randomBytes(16).toString('hex'); const existingBot = await storage.getBotByLogin(login, req.organizationId!); if (existingBot) { return res.status(400).json({ success: false, error: 'Бот с таким логином уже существует' }); } if (llmProviderId) { const numProv = Number(llmProviderId); if (!Number.isInteger(numProv) || numProv <= 0) { return res.status(400).json({ success: false, error: "Некорректный llmProviderId" }); } const prov = await storage.getLlmProvider(numProv, req.organizationId!); if (!prov || !prov.isActive) { return res.status(400).json({ success: false, error: "Провайдер LLM не найден или неактивен" }); } if (llmModel && prov.enabledModels && prov.enabledModels.length > 0 && !prov.enabledModels.includes(llmModel)) { return res.status(400).json({ success: false, error: `Модель "${llmModel}" не входит в список разрешённых моделей провайдера` }); } } const bcrypt = await import('bcrypt'); const passwordHash = await bcrypt.hash(password, 12); const finalWebhookSecret = webhookSecret || crypto.randomBytes(32).toString('hex'); const encryptedWebhookSecret = encrypt(finalWebhookSecret); const encryptedMcpServers = mcpServers != null ? mcpServers.map((s: { url: string; apiKey?: string; name?: string }) => ({ ...s, ...(s.apiKey ? { apiKey: encrypt(s.apiKey) } : {}), })) : undefined; const bot = await storage.createBot({ organizationId: req.organizationId!, name, description: description || null, avatarUrl: avatarUrl || null, login, passwordHash, webhookUrl: webhookUrl || null, webhookSecret: encryptedWebhookSecret, webhookEnabled: true, isActive: true, createdBy: req.user!.id, type: type ?? 'webhook', ...(systemPrompt != null && { systemPrompt }), ...(ragEnabled !== undefined && { ragEnabled }), ...(ragTopK !== undefined && { ragTopK }), ...(encryptedMcpServers != null && { mcpServers: encryptedMcpServers }), ...(accessPolicy != null && { accessPolicy }), ...(llmProviderId != null && { llmProviderId: Number(llmProviderId) }), llmModel: llmProviderId != null ? (llmModel || null) : null, }); res.status(201).json({ success: true, message: 'Бот создан', bot: { id: bot.id, name: bot.name, login: bot.login, password, webhookSecret: finalWebhookSecret, type: bot.type ?? 'webhook', createdAt: bot.createdAt } }); } catch (error) { console.error('Create bot error:', error); res.status(500).json({ success: false, error: 'Ошибка при создании бота' }); } } ); // Update bot (admin only) app.put('/api/bots/:id', authenticateToken, requirePermission('bots.manage'), tenantIsolation, validateRequest(updateBotSchema), async (req: AuthenticatedRequest, res) => { try { const botId = parseInt(req.params.id); if (isNaN(botId)) { return res.status(400).json({ success: false, error: 'Некорректный ID бота' }); } const existingBot = await storage.getBot(botId, req.organizationId!); if (!existingBot) { return res.status(404).json({ success: false, error: 'Бот не найден' }); } const updates: Record = {}; if (req.body.name !== undefined) updates.name = req.body.name; if (req.body.description !== undefined) updates.description = req.body.description; if (req.body.avatarUrl !== undefined) updates.avatarUrl = req.body.avatarUrl; if (req.body.webhookUrl !== undefined) updates.webhookUrl = req.body.webhookUrl; if (req.body.webhookSecret !== undefined) updates.webhookSecret = encrypt(req.body.webhookSecret); if (req.body.webhookEnabled !== undefined) updates.webhookEnabled = req.body.webhookEnabled; if (req.body.isActive !== undefined) updates.isActive = req.body.isActive; if (req.body.type !== undefined) updates.type = req.body.type; if (req.body.systemPrompt !== undefined) updates.systemPrompt = req.body.systemPrompt; if (req.body.ragEnabled !== undefined) updates.ragEnabled = req.body.ragEnabled; if (req.body.ragTopK !== undefined) updates.ragTopK = req.body.ragTopK; if (req.body.mcpServers !== undefined) { const incoming: Array<{ url: string; apiKey?: string; name?: string }> = req.body.mcpServers ?? []; const existing: Array<{ url: string; apiKey?: string; name?: string }> = existingBot.mcpServers ?? []; updates.mcpServers = incoming.map(srv => { if (!srv.apiKey || srv.apiKey === '***') { const prev = existing.find(e => e.url === srv.url); return { url: srv.url, name: srv.name, ...(prev?.apiKey ? { apiKey: prev.apiKey } : {}) }; } return { url: srv.url, name: srv.name, apiKey: encrypt(srv.apiKey) }; }); } if (req.body.accessPolicy !== undefined) updates.accessPolicy = req.body.accessPolicy; if (req.body.llmProviderId !== undefined) { if (req.body.llmProviderId) { const n = Number(req.body.llmProviderId); if (!Number.isInteger(n) || n <= 0) return res.status(400).json({ success: false, error: "Некорректный llmProviderId" }); updates.llmProviderId = n; } else { updates.llmProviderId = null; } } if (req.body.sendSystemPrompt !== undefined) updates.sendSystemPrompt = req.body.sendSystemPrompt; if (req.body.sendCardInfo !== undefined) updates.sendCardInfo = req.body.sendCardInfo; if (req.body.sendChatHistory !== undefined) updates.sendChatHistory = req.body.sendChatHistory; if (req.body.llmModel !== undefined) updates.llmModel = req.body.llmModel || null; if (updates.llmProviderId === null) updates.llmModel = null; const effectiveProviderId = updates.llmProviderId !== undefined ? (updates.llmProviderId as number | null) : existingBot.llmProviderId; if (!effectiveProviderId && updates.llmModel !== null) updates.llmModel = null; const effectiveModel = updates.llmModel !== undefined ? (updates.llmModel as string | null) : existingBot.llmModel; if (effectiveProviderId) { const prov = await storage.getLlmProvider(effectiveProviderId, req.organizationId!); if (!prov || !prov.isActive) { return res.status(400).json({ success: false, error: "Провайдер LLM не найден или неактивен" }); } if (effectiveModel && prov.enabledModels && prov.enabledModels.length > 0 && !prov.enabledModels.includes(effectiveModel)) { return res.status(400).json({ success: false, error: `Модель "${effectiveModel}" не входит в список разрешённых моделей провайдера` }); } } const updatedBot = await storage.updateBot(botId, req.organizationId!, updates as Parameters[2]); res.json({ success: true, message: 'Бот обновлен', bot: { id: updatedBot.id, name: updatedBot.name, description: updatedBot.description, avatarUrl: updatedBot.avatarUrl, login: updatedBot.login, webhookUrl: updatedBot.webhookUrl, webhookEnabled: updatedBot.webhookEnabled, isActive: updatedBot.isActive } }); } catch (error) { console.error('Update bot error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении бота' }); } } ); // Regenerate bot password (admin only) app.post('/api/bots/:id/regenerate-password', authenticateToken, requirePermission('bots.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const botId = parseInt(req.params.id); if (isNaN(botId)) { return res.status(400).json({ success: false, error: 'Некорректный ID бота' }); } const bot = await storage.getBot(botId, req.organizationId!); if (!bot) { return res.status(404).json({ success: false, error: 'Бот не найден' }); } const newPassword = crypto.randomBytes(16).toString('base64'); const bcrypt = await import('bcrypt'); const passwordHash = await bcrypt.hash(newPassword, 12); await storage.updateBot(botId, req.organizationId!, { passwordHash }); await storage.deleteBotSessions(botId); res.json({ success: true, message: 'Пароль бота обновлен', newPassword }); } catch (error) { console.error('Regenerate bot password error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении пароля бота' }); } } ); // Regenerate bot webhook secret (admin only) app.post('/api/bots/:id/regenerate-webhook-secret', authenticateToken, requirePermission('bots.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const botId = parseInt(req.params.id); if (isNaN(botId)) { return res.status(400).json({ success: false, error: 'Некорректный ID бота' }); } const bot = await storage.getBot(botId, req.organizationId!); if (!bot) { return res.status(404).json({ success: false, error: 'Бот не найден' }); } const newWebhookSecret = crypto.randomBytes(32).toString('hex'); await storage.updateBot(botId, req.organizationId!, { webhookSecret: encrypt(newWebhookSecret) }); res.json({ success: true, message: 'Webhook secret обновлен', webhookSecret: newWebhookSecret }); } catch (error) { console.error('Regenerate webhook secret error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении webhook secret' }); } } ); // Test MCP servers connectivity for a bot (admin only) app.post('/api/bots/:id/mcp/test', authenticateToken, requirePermission('bots.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const botId = parseInt(req.params.id); if (isNaN(botId)) { return res.status(400).json({ success: false, error: 'Некорректный ID бота' }); } const bot = await storage.getBot(botId, req.organizationId!); if (!bot) { return res.status(404).json({ success: false, error: 'Бот не найден' }); } let mcpServers: Array<{ url: string; apiKey?: string; name?: string }> | null = null; if (req.body && Array.isArray(req.body.servers) && req.body.servers.length > 0) { const parsed = z.array(mcpServerSchema).safeParse(req.body.servers); if (!parsed.success) { return res.status(400).json({ success: false, error: 'Некорректные данные серверов MCP', details: parsed.error.flatten() }); } const existing: Array<{ url: string; apiKey?: string; name?: string }> = bot.mcpServers ?? []; mcpServers = parsed.data.map(srv => { if (!srv.apiKey || srv.apiKey === '***') { const prev = existing.find(e => e.url === srv.url); const decryptedApiKey = prev?.apiKey ? decrypt(prev.apiKey) : undefined; return { url: srv.url, name: srv.name, ...(decryptedApiKey ? { apiKey: decryptedApiKey } : {}) }; } return srv; }); } else { mcpServers = (bot.mcpServers ?? []).map(srv => ({ ...srv, apiKey: srv.apiKey ? decrypt(srv.apiKey) : undefined, })); } if (!mcpServers || mcpServers.length === 0) { return res.json({ success: true, results: [] }); } const { McpClient } = await import('../utils/mcp-client'); const results = await Promise.all( mcpServers.map(async (srv) => { const client = new McpClient(srv.url, srv.apiKey, 8000); const ping = await client.ping(); let toolCount = 0; if (ping.ok) { try { const tools = await client.listTools(); toolCount = tools.length; } catch {} } return { url: srv.url, name: srv.name, ok: ping.ok, error: ping.error, toolCount }; }) ); res.json({ success: true, results }); } catch (error) { console.error('MCP test error:', error); res.status(500).json({ success: false, error: 'Ошибка при проверке MCP-серверов' }); } } ); }