Files
iistwin/server/routes/bots-crud.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

499 lines
20 KiB
TypeScript
Raw 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,
} 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-серверов' });
}
}
);
}