Files
iistwin/server/routes/bots-crud.routes.ts
Ильяс Султанов b3818852df UI: API/MCP-доступ в карточке бота, legacy-ключи в Settings, бот и канал в истории (этап 3)
- ApiScopesEditor — общий редактор скоупов (режим + формы/справочники)
- Bots.tsx: секция «Доступ к API и MCP» при создании и в настройках бота, показ ключа один раз, перевыпуск с подтверждением
- Settings.tsx: управление ключами убрано, read-only список legacy-ключей
- TaskHistory/TaskDetail: иконка бота + бейдж канала MCP/API у записей аудита
- GET /api/bots/:id возвращает apiKey
2026-07-22 15:35:34 +03:00

624 lines
27 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, string> = {
а:'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<string> {
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<string, unknown> = {};
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<typeof storage.updateBot>[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-серверов' });
}
}
);
}