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, type ApiKeyScopes, } from "@shared/schema"; import { generateBotLoginTokens } from "../utils/jwt"; import { verifyPassword } from "../utils/password"; import { authLimiter } from "./shared"; import { encrypt, decrypt } from "../crypto"; import { parseApiKeyScopesInput, normalizeApiKeyScopes } from "../utils/api-key"; // Валидация apiAccess из тела запроса → ApiKeyScopes (дефолт mode='read'). // Возвращает { ok: true, scopes } либо { ok: false, error } с русским сообщением. function parseBotApiAccess(apiAccess: { enabled: boolean; mode?: 'read' | 'write' | 'full'; formIds?: number[] | null; tableIds?: number[] | null }): { ok: true; scopes: ApiKeyScopes } | { ok: false; error: string } { return parseApiKeyScopesInput({ mode: apiAccess.mode ?? 'read', formIds: apiAccess.formIds ?? null, tableIds: apiAccess.tableIds ?? null, }); } 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: 'Неверный логин или пароль' }); } // Отдельный тип токена bot_login: НЕ пользовательский JWT — бот не может // войти как пользователь с совпадающим числовым id (закрыта коллизия id). const tokens = generateBotLoginTokens(bot.id, organization.id); 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!); // Ключи организации одним запросом; по bot_id находим ключ каждого бота const apiKeys = await storage.listApiKeys(req.organizationId!); const keyByBotId = new Map( apiKeys.filter((k) => k.botId != null).map((k) => [k.botId as number, k]) ); 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, // Сырой ключ и keyHash никогда не отдаём apiKey: (() => { const k = keyByBotId.get(bot.id); return k ? { id: k.id, keyPrefix: k.keyPrefix, scopes: k.scopes, isActive: k.isActive, lastUsedAt: k.lastUsedAt } : 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: 'Бот не найден' }); } // Привязанный API-ключ бота (без сырого ключа и keyHash) const botApiKey = await storage.getApiKeyByBotId(botId, req.organizationId!); 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, apiKey: botApiKey ? { id: botApiKey.id, keyPrefix: botApiKey.keyPrefix, scopes: botApiKey.scopes, isActive: botApiKey.isActive, lastUsedAt: botApiKey.lastUsedAt } : null, } }); } 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: 'Бот с таким логином уже существует' }); } // apiAccess валидируем ДО создания бота, чтобы не оставлять бота без ключа при ошибке let apiKeyScopes: ApiKeyScopes | null = null; if (req.body.apiAccess?.enabled) { const parsed = parseBotApiAccess(req.body.apiAccess); if (!parsed.ok) { return res.status(400).json({ success: false, error: parsed.error }); } apiKeyScopes = parsed.scopes; } 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, }); // API-ключ бота (1:1): создаём только при apiAccess.enabled. // СЫРОЙ КЛЮЧ возвращается один раз — только в этом ответе. let apiKeyResponse: { key: string; keyPrefix: string } | null = null; if (apiKeyScopes) { const { key, record } = await storage.createApiKey( req.organizationId!, req.user!.id, bot.name, apiKeyScopes, bot.id ); apiKeyResponse = { key, keyPrefix: record.keyPrefix }; } 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 }, apiKey: apiKeyResponse }); } 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]); // Управление API-ключом бота (1:1) через apiAccess: // enabled без ключа → создать (сырой ключ — только в этом ответе); // enabled с ключом → обновить scopes (+реактивировать, если был выключен); // disabled с активным ключом → деактивировать (запись сохраняется). let apiKeyResponse: { key: string; keyPrefix: string } | null = null; if (req.body.apiAccess !== undefined) { const apiAccess = req.body.apiAccess; const existingKey = await storage.getApiKeyByBotId(botId, req.organizationId!); if (apiAccess.enabled) { const parsed = parseBotApiAccess(apiAccess); if (!parsed.ok) { return res.status(400).json({ success: false, error: parsed.error }); } if (existingKey) { await storage.updateApiKey(existingKey.id, req.organizationId!, { scopes: parsed.scopes }); if (!existingKey.isActive) { await storage.setApiKeyActive(existingKey.id, req.organizationId!, true); } } else { const { key, record } = await storage.createApiKey( req.organizationId!, req.user!.id, updatedBot.name, parsed.scopes, botId ); apiKeyResponse = { key, keyPrefix: record.keyPrefix }; } } else if (existingKey?.isActive) { await storage.setApiKeyActive(existingKey.id, req.organizationId!, false); } } 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 }, apiKey: apiKeyResponse }); } catch (error) { console.error('Update bot error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении бота' }); } } ); // Regenerate bot API key (admin only) app.post('/api/bots/:id/api-key/regenerate', 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 existingKey = await storage.getApiKeyByBotId(botId, req.organizationId!); // Скоупы нового ключа = скоупы старого; без старого ключа — безопасный дефолт 'read' const scopes: ApiKeyScopes = existingKey ? normalizeApiKeyScopes(existingKey.scopes) : { mode: 'read', formIds: null, tableIds: null }; if (existingKey) { // Деактивируем и отвязываем от бота: частичный уникальный индекс по bot_id // не позволит создать новый ключ, пока старый хранит привязку. await storage.setApiKeyActive(existingKey.id, req.organizationId!, false); await storage.detachApiKeyFromBot(existingKey.id, req.organizationId!); } const { key, record } = await storage.createApiKey( req.organizationId!, req.user!.id, bot.name, scopes, botId ); // СЫРОЙ КЛЮЧ возвращается один раз — только в этом ответе res.json({ success: true, message: 'API-ключ бота перевыпущен', apiKey: { key, keyPrefix: record.keyPrefix } }); } catch (error) { console.error('Regenerate bot API key error:', error); res.status(500).json({ success: false, error: 'Ошибка при перевыпуске API-ключа' }); } } ); // 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-серверов' }); } } ); }