fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
498
server/routes/bots-crud.routes.ts
Normal file
498
server/routes/bots-crud.routes.ts
Normal file
@@ -0,0 +1,498 @@
|
||||
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<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: 'Неверный логин или пароль' });
|
||||
}
|
||||
|
||||
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<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]);
|
||||
|
||||
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-серверов' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user