feat: новая функция

This commit is contained in:
2025-10-09 16:48:20 +03:00
parent dd2c9988a5
commit 13fb51e447
60 changed files with 7694 additions and 1157 deletions

View File

@@ -193,22 +193,7 @@ async function initDbPool() {
console.log('Используем дефолтные настройки подключения к БД');
}
// Функция для сохранения гостевого сообщения в базе данных
async function saveGuestMessageToDatabase(message, language, guestId) {
try {
await query(
`
INSERT INTO guest_messages (guest_id, content, language, created_at)
VALUES ($1, $2, $3, NOW())
`,
[guestId, message, language]
);
// console.log('Гостевое сообщение успешно сохранено:', message); // Убрано избыточное логирование
} catch (error) {
console.error('Ошибка при сохранении гостевого сообщения:', error);
throw error; // Пробрасываем ошибку дальше
}
}
// Функция saveGuestMessageToDatabase удалена - используется UniversalGuestService
async function waitForOllamaModel(modelName) {
const ollamaConfig = require('./services/ollamaConfig');

View File

@@ -40,15 +40,15 @@ const requireAuth = async (req, res, next) => {
*/
async function requireAdmin(req, res, next) {
try {
// Убираем избыточное логирование
// logger.info(`[requireAdmin] Проверка доступа для ${req.method} ${req.url}`);
// logger.info(`[requireAdmin] Session:`, {
// exists: !!req.session,
// authenticated: req.session?.authenticated,
// isAdmin: req.session?.isAdmin,
// userId: req.session?.userId,
// address: req.session?.address
// });
// Временно включаем логирование для диагностики
logger.info(`[requireAdmin] Проверка доступа для ${req.method} ${req.url}`);
logger.info(`[requireAdmin] Session:`, {
exists: !!req.session,
authenticated: req.session?.authenticated,
isAdmin: req.session?.isAdmin,
userId: req.session?.userId,
address: req.session?.address
});
// Проверка аутентификации
if (!req.session || !req.session.authenticated) {

View File

@@ -981,4 +981,114 @@ router.get('/access-level/:address', async (req, res) => {
}
});
/**
* Подключение кошелька через токен связывания
* Используется для привязки Telegram/Email идентификаторов к кошельку
*/
router.post('/wallet-with-link', authLimiter, async (req, res) => {
try {
const { address, signature, message, token } = req.body;
if (!address || !signature || !message || !token) {
return res.status(400).json({
success: false,
error: 'Требуются: address, signature, message, token'
});
}
// 1. Проверяем подпись
let recoveredAddress;
try {
recoveredAddress = ethers.verifyMessage(message, signature);
} catch (err) {
logger.error('[Auth] Ошибка верификации подписи:', err);
return res.status(400).json({
success: false,
error: 'Неверная подпись'
});
}
if (recoveredAddress.toLowerCase() !== address.toLowerCase()) {
return res.status(400).json({
success: false,
error: 'Подпись не соответствует адресу'
});
}
// 2. Проверяем и используем токен
const identityLinkService = require('../services/IdentityLinkService');
const linkResult = await identityLinkService.useLinkToken(token, address);
if (!linkResult.success) {
return res.status(400).json({
success: false,
error: linkResult.error
});
}
const { userId, identifier, role } = linkResult;
// 3. Мигрируем историю гостя
const universalGuestService = require('../services/UniversalGuestService');
const migrationResult = await universalGuestService.migrateToUser(identifier, userId);
logger.info('[Auth] История мигрирована:', migrationResult);
// 4. Обновляем сессию
req.session.userId = userId;
req.session.address = address.toLowerCase();
req.session.authenticated = true;
req.session.authType = 'wallet';
req.session.isAdmin = (role === 'admin' || role === 'editor' || role === 'readonly');
await sessionService.saveSession(req.session, 'wallet-with-link');
logger.info(`[Auth] Кошелек подключен через токен: ${address} → user ${userId}`);
res.json({
success: true,
userId,
role,
conversationId: migrationResult.conversationId,
migratedMessages: migrationResult.migrated,
message: 'Кошелек успешно подключен, история перенесена'
});
} catch (error) {
logger.error('[Auth] Ошибка в wallet-with-link:', error);
res.status(500).json({
success: false,
error: 'Внутренняя ошибка сервера'
});
}
});
// ✨ НОВОЕ: Получение прав доступа пользователя
router.get('/permissions', requireAuth, async (req, res) => {
try {
const userId = req.session.userId;
// Получаем роль пользователя из БД
const users = await encryptedDb.getData('users', { id: userId }, 1);
const userRole = users && users.length > 0 ? users[0].role : 'user';
// Получаем настройки прав через adminLogicService
const adminLogicService = require('../services/adminLogicService');
const permissions = adminLogicService.getAdminSettings({ role: userRole });
res.json({
success: true,
userId: userId,
permissions: permissions
});
} catch (error) {
logger.error('[Auth] Ошибка получения permissions:', error);
res.status(500).json({
success: false,
error: 'Ошибка получения прав доступа'
});
}
});
module.exports = router;

View File

@@ -15,21 +15,44 @@ const router = express.Router();
const multer = require('multer');
const aiAssistant = require('../services/ai-assistant');
const db = require('../db');
const encryptedDb = require('../services/encryptedDatabaseService');
const logger = require('../utils/logger');
const { requireAuth, requireAdmin } = require('../middleware/auth');
const aiAssistantSettingsService = require('../services/aiAssistantSettingsService');
const aiAssistantRulesService = require('../services/aiAssistantRulesService');
const botManager = require('../services/botManager');
const universalMediaProcessor = require('../services/UniversalMediaProcessor');
// Настройка multer для обработки файлов в памяти
const storage = multer.memoryStorage();
const upload = multer({ storage: storage });
// Функция processGuestMessages перенесена в services/guestMessageService.js
// Функция processGuestMessages заменена на UniversalGuestService.migrateToUser()
// Обработчик для гостевых сообщений
// Обработчик для гостевых сообщений (НОВАЯ ВЕРСИЯ)
router.post('/guest-message', upload.array('attachments'), async (req, res) => {
try {
// Frontend отправляет FormData, поэтому читаем из req.body
const content = req.body.content || req.body.message;
const guestId = req.body.guestId;
const files = req.files || [];
logger.info('[Chat] Получен guest-message запрос:', {
content: content?.substring(0, 50),
guestId,
hasFiles: files.length > 0,
bodyKeys: Object.keys(req.body)
});
// Проверяем, что есть либо текст, либо файлы
if (!content && (!files || files.length === 0)) {
logger.warn('[Chat] Гостевое сообщение без content и файлов:', req.body);
return res.status(400).json({
success: false,
error: 'Текст сообщения или файлы обязательны'
});
}
// Проверяем готовность системы
if (!botManager.isReady()) {
return res.status(503).json({
@@ -38,18 +61,100 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => {
});
}
// Получаем WebBot
const webBot = botManager.getBot('web');
if (!webBot || !webBot.isInitialized) {
return res.status(503).json({
success: false,
error: 'Web Bot не инициализирован'
});
const universalGuestService = require('../services/UniversalGuestService');
const unifiedMessageProcessor = require('../services/unifiedMessageProcessor');
// Создаем или используем существующий гостевой ID
const webGuestId = guestId || universalGuestService.generateWebGuestId();
const identifier = universalGuestService.createIdentifier('web', webGuestId);
// Обработка вложений через медиа-процессор
let contentData = null;
if (files && files.length > 0) {
const mediaFiles = [];
for (const file of files) {
try {
const processedFile = await universalMediaProcessor.processFile(
file.buffer,
file.originalname,
{
webUpload: true,
originalSize: file.size,
mimeType: file.mimetype
}
);
mediaFiles.push(processedFile);
} catch (fileError) {
logger.error('[Chat] Ошибка обработки файла:', fileError);
// Fallback: сохраняем как есть
mediaFiles.push({
type: 'document',
content: `[Файл: ${file.originalname}]`,
processed: false,
error: fileError.message,
file: {
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
data: file.buffer
}
});
}
}
// Создаем contentData только если есть обработанные файлы
if (mediaFiles.length > 0) {
contentData = {
text: content,
files: mediaFiles.map(file => ({
data: file.file?.data || file.file?.buffer,
filename: file.file?.originalName || file.file?.filename,
metadata: {
type: file.type,
processed: file.processed,
webUpload: true,
mimeType: file.file?.mimetype,
originalSize: file.file?.size,
size: file.file?.size
}
}))
};
}
}
// Обрабатываем сообщение через новую архитектуру
await webBot.handleMessage(req, res, async (messageData) => {
return await botManager.processMessage(messageData);
// Обратная совместимость - старый формат attachments
const attachments = (files || []).map(file => ({
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
data: file.buffer
}));
const messageData = {
identifier: identifier,
content: content,
channel: 'web',
attachments: attachments,
contentData: contentData
};
// Обработка через unified processor
const result = await unifiedMessageProcessor.processMessage(messageData);
logger.info('[Chat] Результат обработки:', {
success: result.success,
hasAiResponse: !!result.aiResponse,
aiResponseType: typeof result.aiResponse?.response
});
res.json({
success: true,
guestId: webGuestId,
aiResponse: result.aiResponse ? {
response: result.aiResponse.response
} : null
});
} catch (error) {
@@ -63,9 +168,27 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => {
// Старая логика удалена - используется guestService.js);
// Обработчик для сообщений аутентифицированных пользователей
// Обработчик для сообщений аутентифицированных пользователей (НОВАЯ ВЕРСИЯ)
router.post('/message', requireAuth, upload.array('attachments'), async (req, res) => {
try {
const { content, conversationId, recipientId } = req.body;
const userId = req.session.userId;
const files = req.files || [];
if (!content) {
return res.status(400).json({
success: false,
error: 'Текст сообщения обязателен'
});
}
if (!userId) {
return res.status(401).json({
success: false,
error: 'Пользователь не авторизован'
});
}
// Проверяем готовность системы
if (!botManager.isReady()) {
return res.status(503).json({
@@ -74,18 +197,83 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
});
}
// Получаем WebBot
const webBot = botManager.getBot('web');
if (!webBot || !webBot.isInitialized) {
return res.status(503).json({
const encryptedDb = require('../services/encryptedDatabaseService');
const unifiedMessageProcessor = require('../services/unifiedMessageProcessor');
const identityService = require('../services/identity-service');
// Получаем информацию о пользователе
const users = await encryptedDb.getData('users', { id: userId }, 1);
// ✨ НОВОЕ: Валидация прав через adminLogicService
const adminLogicService = require('../services/adminLogicService');
const sessionUserId = req.session.userId;
const targetUserId = userId;
const isAdmin = req.session.isAdmin || false;
const canWrite = adminLogicService.canWriteToConversation({
isAdmin: isAdmin,
userId: sessionUserId,
conversationUserId: targetUserId
});
if (!canWrite) {
logger.warn(`[Chat] Пользователь ${sessionUserId} пытался писать в беседу ${targetUserId} без прав`);
return res.status(403).json({
success: false,
error: 'Нет прав для отправки сообщений в эту беседу'
});
}
if (!users || users.length === 0) {
return res.status(404).json({
success: false,
error: 'Web Bot не инициализирован'
error: 'Пользователь не найден'
});
}
// Обрабатываем сообщение через новую архитектуру
await webBot.handleMessage(req, res, async (messageData) => {
return await botManager.processMessage(messageData);
const user = users[0];
// Находим wallet идентификатор пользователя
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
if (!walletIdentity) {
return res.status(403).json({
success: false,
error: 'Требуется подключение кошелька'
});
}
// Создаем identifier для пользователя
const identifier = `wallet:${walletIdentity.provider_id}`;
// Обработка вложений
const attachments = files.map(file => ({
filename: file.originalname,
mimetype: file.mimetype,
size: file.size,
data: file.buffer
}));
const messageData = {
identifier: identifier,
content: content,
channel: 'web',
attachments: attachments,
conversationId: conversationId || null,
recipientId: recipientId || null,
userId: userId
};
// Обработка через unified processor
const result = await unifiedMessageProcessor.processMessage(messageData);
res.json({
success: true,
userMessageId: result.userMessageId,
conversationId: result.conversationId,
aiResponse: result.aiResponse ? {
response: result.aiResponse.response
} : null,
noAiResponse: result.noAiResponse
});
} catch (error) {
@@ -223,15 +411,21 @@ router.post('/process-guest', requireAuth, async (req, res) => {
return res.status(400).json({ success: false, error: 'guestId is required' });
}
try {
const guestMessageService = require('../services/guestMessageService');
const result = await guestMessageService.processGuestMessages(userId, guestId);
if (result && result.conversationId) {
return res.json({ success: true, conversationId: result.conversationId });
const universalGuestService = require('../services/UniversalGuestService');
const identifier = `web:${guestId}`; // Старые гости всегда из web
const result = await universalGuestService.migrateToUser(identifier, userId);
if (result && result.success) {
return res.json({
success: true,
conversationId: result.conversationId,
migratedMessages: result.migratedCount
});
} else {
return res.json({ success: false, error: result.error || 'No conversation created' });
return res.json({ success: false, error: result.error || 'Migration failed' });
}
} catch (error) {
logger.error('Error in /process-guest:', error);
logger.error('Error in /migrate-guest-messages:', error);
return res.status(500).json({ success: false, error: 'Internal error' });
}
});

View File

@@ -199,4 +199,45 @@ router.put('/db-settings', requireAuth, async (req, res, next) => {
}
});
/**
* Проверка статуса токена связывания
* Используется на странице /connect-wallet для валидации токена
*/
router.get('/link-status/:token', async (req, res) => {
try {
const { token } = req.params;
if (!token) {
return res.status(400).json({
success: false,
error: 'Токен не указан'
});
}
const identityLinkService = require('../services/IdentityLinkService');
const tokenData = await identityLinkService.verifyLinkToken(token);
if (!tokenData) {
return res.json({
valid: false,
error: 'Токен недействителен или истек'
});
}
res.json({
valid: true,
provider: tokenData.source_provider,
expiresAt: tokenData.expires_at,
isUsed: tokenData.is_used
});
} catch (error) {
logger.error('[Identity] Ошибка проверки статуса токена:', error);
res.status(500).json({
success: false,
error: 'Внутренняя ошибка сервера'
});
}
});
module.exports = router;

View File

@@ -27,6 +27,49 @@ router.get('/', async (req, res) => {
const encryptionKey = encryptionUtils.getEncryptionKey();
try {
// Проверяем, это гостевой идентификатор (формат: channel:rawId)
if (userId && userId.includes(':')) {
const guestResult = await db.getQuery()(
`SELECT
id,
decrypt_text(identifier_encrypted, $2) as user_id,
channel,
decrypt_text(content_encrypted, $2) as content,
content_type,
attachments,
media_metadata,
is_ai,
created_at
FROM unified_guest_messages
WHERE decrypt_text(identifier_encrypted, $2) = $1
ORDER BY created_at ASC`,
[userId, encryptionKey]
);
// Преобразуем формат для совместимости с фронтендом
const messages = guestResult.rows.map(msg => ({
id: msg.id,
user_id: msg.user_id,
sender_type: msg.is_ai ? 'bot' : 'user',
content: msg.content,
channel: msg.channel,
role: 'guest',
direction: msg.is_ai ? 'incoming' : 'outgoing',
created_at: msg.created_at,
attachment_filename: null,
attachment_mimetype: null,
attachment_size: null,
attachment_data: null,
// Дополнительные поля для медиа
content_type: msg.content_type,
attachments: msg.attachments,
media_metadata: msg.media_metadata
}));
return res.json(messages);
}
// Стандартная логика для зарегистрированных пользователей
let result;
if (conversationId) {
result = await db.getQuery()(
@@ -279,6 +322,24 @@ router.post('/broadcast', async (req, res) => {
return res.status(400).json({ error: 'user_id и content обязательны' });
}
// ✨ Проверка прав через adminLogicService (только editor может делать рассылку!)
const encryptedDb = require('../services/encryptedDatabaseService');
const users = await encryptedDb.getData('users', { id: req.session.userId }, 1);
const userRole = users && users.length > 0 ? users[0].role : 'user';
const adminLogicService = require('../services/adminLogicService');
const canBroadcast = adminLogicService.canPerformAdminAction({
role: userRole, // Передаем точную роль ('editor', 'readonly', 'user')
action: 'broadcast_message'
});
if (!canBroadcast) {
logger.warn(`[Messages] Пользователь ${req.session.userId} (роль: ${userRole}) пытался сделать broadcast без прав`);
return res.status(403).json({
error: 'Только редакторы (editor) могут делать массовую рассылку'
});
}
// Получаем ключ шифрования через унифицированную утилиту
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();

View File

@@ -19,8 +19,11 @@ const aiCache = require('../services/ai-cache');
const logger = require('../utils/logger');
const ollamaConfig = require('../services/ollamaConfig');
const TIMEOUTS = ollamaConfig.getTimeouts();
router.get('/', async (req, res) => {
const results = {};
// Backend
results.backend = { status: 'ok' };
@@ -28,7 +31,7 @@ router.get('/', async (req, res) => {
try {
const baseUrl = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001';
const healthUrl = baseUrl.replace(/\/$/, '') + '/health';
const vs = await axios.get(healthUrl, { timeout: 2000 });
const vs = await axios.get(healthUrl, { timeout: TIMEOUTS.vectorHealth });
results.vectorSearch = { status: 'ok', ...vs.data };
} catch (e) {
console.log('Vector Search error:', e.message, 'Status:', e.response?.status);
@@ -37,8 +40,7 @@ router.get('/', async (req, res) => {
// Ollama
try {
const ollamaConfig = require('../services/ollamaConfig');
const ollama = await axios.get(ollamaConfig.getApiUrl('tags'), { timeout: 2000 });
const ollama = await axios.get(ollamaConfig.getApiUrl('tags'), { timeout: TIMEOUTS.ollamaHealth });
results.ollama = { status: 'ok', models: ollama.data.models?.length || 0 };
} catch (e) {
results.ollama = { status: 'error', error: e.message };
@@ -52,6 +54,37 @@ router.get('/', async (req, res) => {
results.postgres = { status: 'error', error: e.message };
}
// ✨ НОВОЕ: AI Cache статистика
try {
const ragService = require('../services/ragService');
const cacheStats = ragService.getCacheStats();
results.aiCache = {
status: 'ok',
size: cacheStats.size,
maxSize: cacheStats.maxSize,
hitRate: `${(cacheStats.hitRate * 100).toFixed(2)}%`,
byType: cacheStats.byType
};
} catch (e) {
results.aiCache = { status: 'error', error: e.message };
}
// ✨ НОВОЕ: AI Queue статистика
try {
const ragService = require('../services/ragService');
const queueStats = ragService.getQueueStats();
results.aiQueue = {
status: 'ok',
currentSize: queueStats.currentQueueSize,
totalProcessed: queueStats.totalProcessed,
totalFailed: queueStats.totalFailed,
avgResponseTime: `${Math.round(queueStats.averageProcessingTime)}ms`,
uptime: `${Math.round(queueStats.uptime / 1000)}s`
};
} catch (e) {
results.aiQueue = { status: 'error', error: e.message };
}
res.json({ status: 'ok', services: results, timestamp: new Date().toISOString() });
});

View File

@@ -15,20 +15,23 @@ const router = express.Router();
const { exec } = require('child_process');
const util = require('util');
const execAsync = util.promisify(exec);
const axios = require('axios');
const logger = require('../utils/logger');
const { requireAuth } = require('../middleware/auth');
const ollamaConfig = require('../services/ollamaConfig');
// Инициализируем один раз
const TIMEOUTS = ollamaConfig.getTimeouts();
// Проверка статуса подключения к Ollama
router.get('/status', requireAuth, async (req, res) => {
try {
const axios = require('axios');
const ollamaConfig = require('../services/ollamaConfig');
const ollamaUrl = ollamaConfig.getBaseUrl();
// Проверяем API Ollama через HTTP запрос
try {
const response = await axios.get(`${ollamaUrl}/api/tags`, {
timeout: 5000 // 5 секунд таймаут
timeout: TIMEOUTS.ollamaTags // Централизованный таймаут
});
const models = response.data.models || [];
@@ -54,12 +57,10 @@ router.get('/status', requireAuth, async (req, res) => {
// Получение списка установленных моделей
router.get('/models', requireAuth, async (req, res) => {
try {
const axios = require('axios');
const ollamaConfig = require('../services/ollamaConfig');
const ollamaUrl = ollamaConfig.getBaseUrl();
const response = await axios.get(`${ollamaUrl}/api/tags`, {
timeout: 5000
timeout: TIMEOUTS.ollamaTags
});
const models = (response.data.models || []).map(model => ({

View File

@@ -17,6 +17,7 @@ const logger = require('../utils/logger');
const { ethers } = require('ethers');
const db = require('../db');
const rpcProviderService = require('../services/rpcProviderService');
const encryptedDb = require('../services/encryptedDatabaseService');
// Функция для получения информации о сети по chain_id
function getNetworkInfo(chainId) {
@@ -494,10 +495,13 @@ router.delete('/ai-assistant-rules/:id', requireAdmin, async (req, res, next) =>
// Получить текущие настройки Email (для страницы Email)
router.get('/email-settings', requireAdmin, async (req, res) => {
try {
const settings = await botsSettings.getEmailSettings();
logger.info('[Settings] Запрос getBotSettings(email)');
const settings = await botsSettings.getBotSettings('email');
logger.info('[Settings] getBotSettings(email) успешно:', settings);
res.json({ success: true, settings });
} catch (error) {
res.status(404).json({ success: false, error: error.message });
logger.error('[Settings] Ошибка getBotSettings(email):', error);
res.status(500).json({ success: false, error: error.message });
}
});
@@ -540,7 +544,7 @@ router.put('/email-settings', requireAdmin, async (req, res, next) => {
updated_at: new Date()
};
const result = await botsSettings.saveEmailSettings(settings);
const result = await botsSettings.saveBotSettings('email', settings);
res.json({ success: true, data: result });
} catch (error) {
logger.error('Ошибка при обновлении email настроек:', error);
@@ -599,20 +603,27 @@ router.post('/email-settings/test-smtp', requireAdmin, async (req, res, next) =>
// Получить список всех email (для ассистента)
router.get('/email-settings/list', requireAdmin, async (req, res) => {
try {
const emails = await botsSettings.getAllEmailSettings();
logger.info('[Settings] Запрос списка email');
const emails = await encryptedDb.getData('email_settings', {}, 1000, 'id ASC');
logger.info('[Settings] Получено email:', emails ? emails.length : 0);
res.json({ success: true, items: emails });
} catch (error) {
res.status(404).json({ success: false, error: error.message });
logger.error('[Settings] Ошибка получения списка email:', error);
logger.error('[Settings] Stack:', error.stack);
res.status(500).json({ success: false, error: error.message });
}
});
// Получить текущие настройки Telegram-бота (для страницы Telegram)
router.get('/telegram-settings', requireAdmin, async (req, res, next) => {
try {
const settings = await botsSettings.getTelegramSettings();
logger.info('[Settings] Запрос getBotSettings(telegram)');
const settings = await botsSettings.getBotSettings('telegram');
logger.info('[Settings] getBotSettings успешно:', settings);
res.json({ success: true, settings });
} catch (error) {
res.status(404).json({ success: false, error: error.message });
logger.error('[Settings] Ошибка getBotSettings(telegram):', error);
res.status(500).json({ success: false, error: error.message });
}
});
@@ -637,7 +648,7 @@ router.put('/telegram-settings', requireAdmin, async (req, res, next) => {
updated_at: new Date()
};
const result = await botsSettings.saveTelegramSettings(settings);
const result = await botsSettings.saveBotSettings('telegram', settings);
res.json({ success: true, data: result });
} catch (error) {
logger.error('Ошибка при обновлении настроек Telegram:', error);
@@ -648,10 +659,14 @@ router.put('/telegram-settings', requireAdmin, async (req, res, next) => {
// Получить список всех Telegram-ботов (для ассистента)
router.get('/telegram-settings/list', requireAdmin, async (req, res, next) => {
try {
const bots = await botsSettings.getAllTelegramBots();
logger.info('[Settings] Запрос списка telegram ботов');
const bots = await encryptedDb.getData('telegram_settings', {}, 1000, 'id ASC');
logger.info('[Settings] Получено telegram ботов:', bots ? bots.length : 0);
res.json({ success: true, items: bots });
} catch (error) {
res.status(404).json({ success: false, error: error.message });
logger.error('[Settings] Ошибка получения списка telegram:', error);
logger.error('[Settings] Stack:', error.stack);
res.status(500).json({ success: false, error: error.message });
}
});

View File

@@ -208,7 +208,7 @@ router.get('/', requireAuth, async (req, res, next) => {
});
}
// --- Формируем ответ ---
// --- Формируем ответ для зарегистрированных пользователей ---
const contacts = users.map(u => ({
id: u.id,
name: [u.first_name, u.last_name].filter(Boolean).join(' ').trim() || null,
@@ -222,7 +222,61 @@ router.get('/', requireAuth, async (req, res, next) => {
role: u.role || 'user'
}));
res.json({ success: true, contacts });
// --- Добавляем гостевые контакты ---
const guestContactsResult = await db.getQuery()(
`WITH decrypted_guests AS (
SELECT
decrypt_text(identifier_encrypted, $1) as guest_identifier,
channel,
created_at,
user_id
FROM unified_guest_messages
WHERE user_id IS NULL
)
SELECT
guest_identifier,
channel,
MIN(created_at) as created_at,
MAX(created_at) as last_message_at,
COUNT(*) as message_count
FROM decrypted_guests
GROUP BY guest_identifier, channel
ORDER BY MAX(created_at) DESC`,
[encryptionKey]
);
const guestContacts = guestContactsResult.rows.map(g => {
const channelMap = {
'web': '🌐',
'telegram': '📱',
'email': '✉️'
};
const icon = channelMap[g.channel] || '👤';
const rawId = g.guest_identifier.replace(`${g.channel}:`, '');
return {
id: g.guest_identifier, // Используем unified identifier как ID
name: `${icon} ${g.channel === 'web' ? 'Гость' : g.channel} (${rawId.substring(0, 8)}...)`,
email: g.channel === 'email' ? rawId : null,
telegram: g.channel === 'telegram' ? rawId : null,
wallet: null,
created_at: g.created_at,
preferred_language: [],
is_blocked: false,
contact_type: 'guest',
role: 'guest',
guest_info: {
channel: g.channel,
message_count: parseInt(g.message_count),
last_message_at: g.last_message_at
}
};
});
// Объединяем списки
const allContacts = [...contacts, ...guestContacts];
res.json({ success: true, contacts: allContacts });
} catch (error) {
logger.error('Error fetching contacts:', error);
next(error);
@@ -401,9 +455,64 @@ router.get('/:id', async (req, res, next) => {
const encryptionKey = encryptionUtils.getEncryptionKey();
try {
const query = db.getQuery();
// Получаем пользователя
// Проверяем, это гостевой идентификатор (формат: channel:rawId)
if (userId.includes(':')) {
const guestResult = await query(
`WITH decrypted_guest AS (
SELECT
decrypt_text(identifier_encrypted, $2) as guest_identifier,
channel,
created_at
FROM unified_guest_messages
WHERE decrypt_text(identifier_encrypted, $2) = $1
)
SELECT
guest_identifier,
channel,
MIN(created_at) as created_at,
MAX(created_at) as last_message_at,
COUNT(*) as message_count
FROM decrypted_guest
GROUP BY guest_identifier, channel`,
[userId, encryptionKey]
);
if (guestResult.rows.length === 0) {
return res.status(404).json({ error: 'Guest contact not found' });
}
const guest = guestResult.rows[0];
const rawId = userId.replace(`${guest.channel}:`, '');
const channelMap = {
'web': '🌐',
'telegram': '📱',
'email': '✉️'
};
const icon = channelMap[guest.channel] || '👤';
return res.json({
id: userId,
name: `${icon} ${guest.channel === 'web' ? 'Гость' : guest.channel} (${rawId.substring(0, 8)}...)`,
email: guest.channel === 'email' ? rawId : null,
telegram: guest.channel === 'telegram' ? rawId : null,
wallet: null,
created_at: guest.created_at,
preferred_language: [],
is_blocked: false,
contact_type: 'guest',
role: 'guest',
guest_info: {
channel: guest.channel,
message_count: parseInt(guest.message_count),
last_message_at: guest.last_message_at,
raw_identifier: rawId
}
});
}
// Получаем пользователя (зарегистрированный)
const userResult = await query('SELECT id, created_at, preferred_language, is_blocked FROM users WHERE id = $1', [userId]);
if (userResult.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });

View File

@@ -11,6 +11,9 @@
*/
const axios = require('axios');
const ollamaConfig = require('../services/ollamaConfig');
const TIMEOUTS = ollamaConfig.getTimeouts();
async function checkOllamaModels() {
try {
@@ -18,7 +21,7 @@ async function checkOllamaModels() {
const baseUrl = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
const response = await axios.get(`${baseUrl}/api/tags`, {
timeout: 5000, // 5 секунд таймаут
timeout: TIMEOUTS.ollamaTags, // Централизованный таймаут
});
if (response.status === 200 && response.data && response.data.models) {

View File

@@ -42,7 +42,16 @@ async function startServer() {
const botManager = require('./services/botManager');
console.log('[Server] ▶️ Вызываем botManager.initialize()...');
botManager.initialize()
.then(() => console.log('[Server] ✅ botManager.initialize() завершен'))
.then(() => {
console.log('[Server] ✅ botManager.initialize() завершен');
// ✨ НОВОЕ: Запускаем AI Queue Worker после инициализации ботов
if (process.env.USE_AI_QUEUE !== 'false') {
const ragService = require('./services/ragService');
ragService.startQueueWorker();
console.log('[Server] ✅ AI Queue Worker запущен');
}
})
.catch(error => {
console.error('[Server] ❌ Ошибка botManager.initialize():', error.message);
logger.error('[Server] Ошибка инициализации ботов:', error);
@@ -79,6 +88,13 @@ if (process.env.NODE_ENV === 'production') {
process.on('SIGINT', async () => {
console.log('[Server] Получен SIGINT, завершаем работу...');
try {
// ✨ Останавливаем AI Queue Worker
if (process.env.USE_AI_QUEUE !== 'false') {
const ragService = require('./services/ragService');
ragService.stopQueueWorker();
console.log('[Server] ✅ AI Queue Worker остановлен');
}
// Останавливаем боты
const botManager = require('./services/botManager');
if (botManager.isInitialized) {
@@ -96,6 +112,13 @@ process.on('SIGINT', async () => {
process.on('SIGTERM', async () => {
console.log('[Server] Получен SIGTERM, завершаем работу...');
try {
// ✨ Останавливаем AI Queue Worker
if (process.env.USE_AI_QUEUE !== 'false') {
const ragService = require('./services/ragService');
ragService.stopQueueWorker();
console.log('[Server] ✅ AI Queue Worker остановлен');
}
// Останавливаем боты
const botManager = require('./services/botManager');
if (botManager.isInitialized) {

View File

@@ -0,0 +1,297 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
const db = require('../db');
const logger = require('../utils/logger');
const encryptionUtils = require('../utils/encryptionUtils');
const crypto = require('crypto');
/**
* Сервис для создания и управления токенами связывания идентификаторов
* Используется для привязки Telegram/Email к Web3 кошелькам
*/
class IdentityLinkService {
constructor() {
this.DEFAULT_TTL_HOURS = 1; // Токен действителен 1 час
this.FRONTEND_URL = process.env.FRONTEND_URL || 'http://localhost:5173';
}
/**
* Сгенерировать токен для связывания
* @param {string} provider - 'telegram', 'email'
* @param {string} identifier - ID пользователя (Telegram ID или email)
* @param {Object} options - Дополнительные опции
* @returns {Promise<Object>} - {token, linkUrl, expiresAt}
*/
async generateLinkToken(provider, identifier, options = {}) {
try {
if (!provider || !identifier) {
throw new Error('Provider and identifier are required');
}
if (!['telegram', 'email'].includes(provider)) {
throw new Error(`Invalid provider: ${provider}. Must be 'telegram' or 'email'`);
}
// Генерируем уникальный токен
const token = crypto.randomBytes(32).toString('hex');
// Вычисляем время истечения
const ttlHours = options.ttlHours || this.DEFAULT_TTL_HOURS;
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + ttlHours);
const encryptionKey = encryptionUtils.getEncryptionKey();
// Сохраняем токен в БД
await db.getQuery()(
`INSERT INTO identity_link_tokens (
token,
source_provider,
source_identifier_encrypted,
user_id,
expires_at,
created_at
) VALUES (
$1, $2,
encrypt_text($3, $4),
$5,
$6,
NOW()
)`,
[
token,
provider,
identifier,
encryptionKey,
options.userId || null,
expiresAt
]
);
const linkUrl = `${this.FRONTEND_URL}/connect-wallet?token=${token}`;
logger.info(`[IdentityLinkService] Создан токен связывания для ${provider}:${identifier}`);
return {
success: true,
token,
linkUrl,
expiresAt: expiresAt.toISOString(),
provider,
identifier
};
} catch (error) {
logger.error('[IdentityLinkService] Ошибка генерации токена:', error);
throw error;
}
}
/**
* Проверить токен и получить данные
* @param {string} token
* @returns {Promise<Object|null>}
*/
async verifyLinkToken(token) {
try {
if (!token) {
return null;
}
const encryptionKey = encryptionUtils.getEncryptionKey();
const { rows } = await db.getQuery()(
`SELECT
id,
source_provider,
decrypt_text(source_identifier_encrypted, $2) as source_identifier,
user_id,
is_used,
used_at,
linked_wallet,
expires_at,
created_at
FROM identity_link_tokens
WHERE token = $1`,
[token, encryptionKey]
);
if (rows.length === 0) {
logger.warn(`[IdentityLinkService] Токен не найден: ${token}`);
return null;
}
const tokenData = rows[0];
// Проверяем срок действия
if (new Date() > new Date(tokenData.expires_at)) {
logger.warn(`[IdentityLinkService] Токен истек: ${token}`);
return null;
}
// Проверяем использование
if (tokenData.is_used) {
logger.warn(`[IdentityLinkService] Токен уже использован: ${token}`);
return null;
}
return tokenData;
} catch (error) {
logger.error('[IdentityLinkService] Ошибка проверки токена:', error);
throw error;
}
}
/**
* Использовать токен (связать с кошельком)
* @param {string} token
* @param {string} walletAddress
* @returns {Promise<Object>}
*/
async useLinkToken(token, walletAddress) {
try {
// 1. Проверяем токен
const tokenData = await this.verifyLinkToken(token);
if (!tokenData) {
return {
success: false,
error: 'Токен недействителен или истек'
};
}
const encryptionKey = encryptionUtils.getEncryptionKey();
// 2. Создаем пользователя если нужно
let userId = tokenData.user_id;
if (!userId) {
// Создаем нового пользователя
const { rows: userRows } = await db.getQuery()(
`INSERT INTO users (role) VALUES ($1) RETURNING id`,
['user']
);
userId = userRows[0].id;
logger.info(`[IdentityLinkService] Создан новый пользователь: ${userId}`);
}
// 3. Сохраняем wallet идентификатор
const identityService = require('./identity-service');
await identityService.saveIdentity(userId, 'wallet', walletAddress);
// 4. Сохраняем Telegram/Email идентификатор
await identityService.saveIdentity(
userId,
tokenData.source_provider,
tokenData.source_identifier
);
// 5. Помечаем токен как использованный
await db.getQuery()(
`UPDATE identity_link_tokens
SET is_used = true,
used_at = NOW(),
user_id = $2,
linked_wallet = $3
WHERE token = $1`,
[token, userId, walletAddress]
);
// 6. Проверяем админские права
const { checkAdminRole } = require('./admin-role');
const isAdmin = await checkAdminRole(walletAddress);
if (isAdmin) {
await db.getQuery()(
`UPDATE users SET role = $1 WHERE id = $2`,
['editor', userId]
);
logger.info(`[IdentityLinkService] Пользователь ${userId} получил роль admin`);
}
// 7. Создаем identifier для миграции
const universalGuestService = require('./UniversalGuestService');
const identifier = universalGuestService.createIdentifier(
tokenData.source_provider,
tokenData.source_identifier
);
logger.info(`[IdentityLinkService] Токен успешно использован. UserId: ${userId}`);
return {
success: true,
userId,
identifier,
provider: tokenData.source_provider,
role: isAdmin ? 'admin' : 'user'
};
} catch (error) {
logger.error('[IdentityLinkService] Ошибка использования токена:', error);
return {
success: false,
error: error.message
};
}
}
/**
* Очистить истекшие токены
* @returns {Promise<number>} - Количество удаленных
*/
async cleanupExpiredTokens() {
try {
const { rowCount } = await db.getQuery()(
`DELETE FROM identity_link_tokens
WHERE expires_at < NOW()
OR (is_used = true AND used_at < NOW() - INTERVAL '7 days')`
);
logger.info(`[IdentityLinkService] Очищено истекших токенов: ${rowCount}`);
return rowCount;
} catch (error) {
logger.error('[IdentityLinkService] Ошибка очистки токенов:', error);
throw error;
}
}
/**
* Получить статистику по токенам
* @returns {Promise<Object>}
*/
async getStats() {
try {
const { rows } = await db.getQuery()(
`SELECT
COUNT(*) as total_tokens,
COUNT(*) FILTER (WHERE is_used = true) as used_tokens,
COUNT(*) FILTER (WHERE is_used = false AND expires_at > NOW()) as active_tokens,
COUNT(*) FILTER (WHERE expires_at < NOW()) as expired_tokens
FROM identity_link_tokens`
);
return rows[0];
} catch (error) {
logger.error('[IdentityLinkService] Ошибка получения статистики:', error);
throw error;
}
}
}
module.exports = new IdentityLinkService();

View File

@@ -0,0 +1,570 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
const db = require('../db');
const logger = require('../utils/logger');
const encryptionUtils = require('../utils/encryptionUtils');
const crypto = require('crypto');
const universalMediaProcessor = require('./UniversalMediaProcessor');
/**
* Универсальный сервис для обработки гостевых сообщений
* Работает со всеми каналами: web, telegram, email
*/
class UniversalGuestService {
/**
* Создать унифицированный идентификатор
* @param {string} channel - 'web', 'telegram', 'email'
* @param {string} rawId - Исходный ID
* @returns {string} - "channel:rawId"
*/
createIdentifier(channel, rawId) {
if (!channel || !rawId) {
throw new Error('Channel and rawId are required');
}
return `${channel}:${rawId}`;
}
/**
* Сгенерировать гостевой ID для Web
* @returns {string} - "guest_abc123..."
*/
generateWebGuestId() {
return `guest_${crypto.randomBytes(16).toString('hex')}`;
}
/**
* Разобрать идентификатор на части
* @param {string} identifier - "channel:id"
* @returns {Object} - {channel, id}
*/
parseIdentifier(identifier) {
const parts = identifier.split(':');
if (parts.length < 2) {
throw new Error(`Invalid identifier format: ${identifier}`);
}
return {
channel: parts[0],
id: parts.slice(1).join(':') // На случай если в ID есть двоеточие (email)
};
}
/**
* Проверить, является ли идентификатор гостевым
* @param {string} identifier
* @returns {boolean}
*/
isGuest(identifier) {
if (!identifier || typeof identifier !== 'string') {
return true; // По умолчанию считаем гостем
}
// Если нет user_id в БД - это гость
// Для упрощения: любой identifier без wallet в user_identities = гость
return true; // Пока всегда true, позже добавим проверку через БД
}
/**
* Сохранить сообщение гостя
* @param {Object} messageData
* @returns {Promise<Object>}
*/
async saveMessage(messageData) {
try {
const {
identifier,
content,
channel,
metadata = {},
attachment_filename,
attachment_mimetype,
attachment_size,
attachment_data,
contentData = null // Новый параметр для структурированного контента
} = messageData;
const encryptionKey = encryptionUtils.getEncryptionKey();
// Обработка контента через UniversalMediaProcessor
let processedContent = null;
let finalContent = content;
let finalMetadata = { ...metadata };
if (contentData) {
processedContent = await universalMediaProcessor.processCombinedContent(contentData);
// Если есть и текст, и файлы - объединяем их
if (content && processedContent.summary) {
finalContent = `${content}\n\n[Прикрепленные файлы: ${processedContent.summary}]`;
} else if (processedContent.summary) {
// Только файлы без текста
finalContent = processedContent.summary;
}
finalMetadata.mediaSummary = processedContent.summary;
} else if (attachment_data) {
// Если есть только одно вложение без contentData, обрабатываем его
processedContent = await universalMediaProcessor.processFile(
attachment_data,
attachment_filename,
{
mimeType: attachment_mimetype,
originalSize: attachment_size
}
);
finalContent = content || processedContent.content;
finalMetadata.mediaSummary = processedContent.content;
}
const { rows } = await db.getQuery()(
`INSERT INTO unified_guest_messages (
identifier_encrypted,
channel,
content_encrypted,
is_ai,
metadata,
attachment_filename_encrypted,
attachment_mimetype_encrypted,
attachment_size,
attachment_data,
content_type,
attachments,
media_metadata,
created_at
) VALUES (
encrypt_text($1, $12),
$2,
encrypt_text($3, $12),
$4,
$5,
encrypt_text($6, $12),
encrypt_text($7, $12),
$8,
$9,
$10,
$11,
$13,
NOW()
) RETURNING id, created_at`,
[
identifier,
channel,
finalContent,
false, // is_ai = false (это сообщение от гостя)
JSON.stringify(finalMetadata),
attachment_filename || null,
attachment_mimetype || null,
attachment_size || null,
attachment_data || null,
processedContent ? processedContent.type : 'text',
processedContent ? JSON.stringify(processedContent.parts) : null,
encryptionKey,
JSON.stringify(finalMetadata)
]
);
const messageId = rows[0].id;
// Если есть медиа-файлы, сохраняем их метаданные
if (processedContent && processedContent.type === 'combined') {
await this.saveMediaFiles(messageId, processedContent.parts, identifier, channel);
}
logger.info(`[UniversalGuestService] Сохранено сообщение гостя: ${identifier}, id: ${messageId}, тип: ${processedContent ? processedContent.type : 'text'}`);
return {
success: true,
messageId: messageId,
identifier,
created_at: rows[0].created_at,
processedContent
};
} catch (error) {
logger.error('[UniversalGuestService] Ошибка сохранения сообщения гостя:', error);
throw error;
}
}
/**
* Сохраняет метаданные медиа-файлов
*/
async saveMediaFiles(messageId, contentParts, identifier, channel) {
try {
for (const part of contentParts) {
if (part.type !== 'text' && part.file) {
await db.getQuery()(
`INSERT INTO media_files
(message_id, file_name, original_name, file_path, file_size, file_type,
mime_type, identifier, channel, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
messageId,
part.file.savedName,
part.file.originalName,
part.file.path,
part.file.size,
part.type,
part.metadata?.mimeType || null, // Сохраняем реальный MIME-тип
identifier,
channel,
JSON.stringify(part.metadata)
]
);
}
}
logger.info(`[UniversalGuestService] Сохранены метаданные медиа-файлов для сообщения ${messageId}`);
} catch (error) {
logger.error(`[UniversalGuestService] Ошибка сохранения метаданных медиа:`, error);
}
}
/**
* Сохранить AI ответ гостю
* @param {Object} responseData
* @returns {Promise<Object>}
*/
async saveAiResponse(responseData) {
try {
const {
identifier,
content,
channel,
metadata = {}
} = responseData;
const encryptionKey = encryptionUtils.getEncryptionKey();
const { rows } = await db.getQuery()(
`INSERT INTO unified_guest_messages (
identifier_encrypted,
channel,
content_encrypted,
is_ai,
metadata,
created_at
) VALUES (
encrypt_text($1, $6),
$2,
encrypt_text($3, $6),
$4,
$5,
NOW()
) RETURNING id, created_at`,
[
identifier,
channel,
content,
true, // is_ai = true (это ответ AI)
JSON.stringify(metadata),
encryptionKey
]
);
logger.info(`[UniversalGuestService] Сохранен AI ответ для гостя: ${identifier}, id: ${rows[0].id}`);
return {
success: true,
messageId: rows[0].id,
identifier,
created_at: rows[0].created_at
};
} catch (error) {
logger.error('[UniversalGuestService] Ошибка сохранения AI ответа:', error);
throw error;
}
}
/**
* Получить историю сообщений гостя
* @param {string} identifier - "channel:id"
* @returns {Promise<Array>} - [{role: 'user'/'assistant', content}]
*/
async getHistory(identifier) {
try {
const encryptionKey = encryptionUtils.getEncryptionKey();
const { rows } = await db.getQuery()(
`SELECT
decrypt_text(content_encrypted, $2) as content,
is_ai,
created_at
FROM unified_guest_messages
WHERE decrypt_text(identifier_encrypted, $2) = $1
ORDER BY created_at ASC`,
[identifier, encryptionKey]
);
// Преобразуем в формат для AI
const history = rows.map(row => ({
role: row.is_ai ? 'assistant' : 'user',
content: row.content
}));
logger.info(`[UniversalGuestService] Загружена история для ${identifier}: ${history.length} сообщений`);
return history;
} catch (error) {
logger.error('[UniversalGuestService] Ошибка получения истории:', error);
throw error;
}
}
/**
* Обработать сообщение гостя (сохранить + получить AI ответ)
* @param {Object} messageData
* @returns {Promise<Object>}
*/
async processMessage(messageData) {
try {
const { identifier, content, channel, contentData } = messageData;
logger.info(`[UniversalGuestService] Обработка сообщения гостя: ${identifier}`);
// 1. Сохраняем сообщение гостя
const saveResult = await this.saveMessage(messageData);
const processedContent = saveResult.processedContent;
// 2. Загружаем историю для контекста
const conversationHistory = await this.getHistory(identifier);
// 3. Генерируем AI ответ
const aiAssistant = require('./ai-assistant');
// Формируем полное описание сообщения для AI
let fullMessageContent = content;
if (processedContent && processedContent.summary) {
// Если есть медиа, добавляем информацию о них
fullMessageContent = content ? `${content}\n\n[Прикрепленные файлы: ${processedContent.summary}]` : processedContent.summary;
}
const aiResponse = await aiAssistant.generateResponse({
channel: channel,
messageId: `guest_${identifier}_${Date.now()}`,
userId: identifier,
userQuestion: fullMessageContent,
conversationHistory: conversationHistory,
metadata: {
isGuest: true,
hasMedia: !!processedContent,
mediaSummary: processedContent?.summary
}
});
if (!aiResponse || !aiResponse.success) {
logger.warn(`[UniversalGuestService] AI не вернул ответ для ${identifier}`);
return {
success: false,
reason: aiResponse?.reason || 'no_ai_response'
};
}
// 4. Сохраняем AI ответ
await this.saveAiResponse({
identifier,
content: aiResponse.response,
channel,
metadata: messageData.metadata || {}
});
logger.info(`[UniversalGuestService] Сообщение гостя ${identifier} обработано успешно`);
return {
success: true,
identifier,
aiResponse: {
response: aiResponse.response,
ragData: aiResponse.ragData
}
};
} catch (error) {
logger.error('[UniversalGuestService] Ошибка обработки сообщения гостя:', error);
throw error;
}
}
/**
* Мигрировать историю гостя в user_id
* @param {string} identifier - "channel:id"
* @param {number} userId
* @returns {Promise<Object>}
*/
async migrateToUser(identifier, userId) {
try {
logger.info(`[UniversalGuestService] Миграция истории ${identifier} → user ${userId}`);
const encryptionKey = encryptionUtils.getEncryptionKey();
// 1. Получаем все сообщения гостя
const { rows: messages } = await db.getQuery()(
`SELECT
decrypt_text(identifier_encrypted, $2) as identifier,
channel,
decrypt_text(content_encrypted, $2) as content,
is_ai,
metadata,
decrypt_text(attachment_filename_encrypted, $2) as attachment_filename,
decrypt_text(attachment_mimetype_encrypted, $2) as attachment_mimetype,
attachment_size,
attachment_data,
created_at
FROM unified_guest_messages
WHERE decrypt_text(identifier_encrypted, $2) = $1
ORDER BY created_at ASC`,
[identifier, encryptionKey]
);
if (messages.length === 0) {
logger.info(`[UniversalGuestService] Нет сообщений для миграции`);
return { migrated: 0, skipped: 0, conversationId: null };
}
// 2. Создаем беседу для пользователя
const conversationService = require('./conversationService');
const conversation = await conversationService.getOrCreateConversation(
userId,
'Перенесенная беседа'
);
const conversationId = conversation.id;
let migrated = 0;
let skipped = 0;
// 3. Переносим каждое сообщение
for (const msg of messages) {
try {
const senderType = msg.is_ai ? 'assistant' : 'user';
const role = msg.is_ai ? 'assistant' : 'user';
const direction = msg.is_ai ? 'outgoing' : 'incoming';
await db.getQuery()(
`INSERT INTO messages (
user_id,
conversation_id,
sender_type_encrypted,
content_encrypted,
channel_encrypted,
role_encrypted,
direction_encrypted,
attachment_filename_encrypted,
attachment_mimetype_encrypted,
attachment_size,
attachment_data,
created_at
) VALUES (
$1, $2,
encrypt_text($3, $13),
encrypt_text($4, $13),
encrypt_text($5, $13),
encrypt_text($6, $13),
encrypt_text($7, $13),
encrypt_text($8, $13),
encrypt_text($9, $13),
$10, $11, $12
)`,
[
userId,
conversationId,
senderType,
msg.content,
msg.channel,
role,
direction,
msg.attachment_filename,
msg.attachment_mimetype,
msg.attachment_size,
msg.attachment_data,
msg.created_at,
encryptionKey
]
);
migrated++;
} catch (error) {
logger.error('[UniversalGuestService] Ошибка переноса сообщения:', error);
skipped++;
}
}
// 4. Удаляем гостевые сообщения после успешного переноса
if (migrated > 0) {
await db.getQuery()(
`DELETE FROM unified_guest_messages
WHERE decrypt_text(identifier_encrypted, $2) = $1`,
[identifier, encryptionKey]
);
// Сохраняем маппинг
const { channel } = this.parseIdentifier(identifier);
await db.getQuery()(
`INSERT INTO unified_guest_mapping (
user_id,
identifier_encrypted,
channel,
processed,
processed_at
) VALUES (
$1,
encrypt_text($2, $4),
$3,
true,
NOW()
)
ON CONFLICT (identifier_encrypted, channel) DO NOTHING`,
[userId, identifier, channel, encryptionKey]
);
}
logger.info(`[UniversalGuestService] Миграция завершена: ${migrated} перенесено, ${skipped} пропущено`);
return {
success: true,
migrated,
skipped,
total: messages.length,
conversationId
};
} catch (error) {
logger.error('[UniversalGuestService] Ошибка миграции истории:', error);
throw error;
}
}
/**
* Получить статистику по гостям
* @returns {Promise<Object>}
*/
async getStats() {
try {
const { rows } = await db.getQuery()(
`SELECT
COUNT(DISTINCT identifier_encrypted) as unique_guests,
COUNT(*) FILTER (WHERE is_ai = false) as user_messages,
COUNT(*) FILTER (WHERE is_ai = true) as ai_responses,
MAX(created_at) as last_activity
FROM unified_guest_messages`
);
return rows[0];
} catch (error) {
logger.error('[UniversalGuestService] Ошибка получения статистики:', error);
throw error;
}
}
}
module.exports = new UniversalGuestService();

View File

@@ -0,0 +1,504 @@
const logger = require('../utils/logger');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
/**
* Универсальный процессор медиа-контента для всех каналов связи
* Обрабатывает текст, аудио, видео, файлы и их комбинации
*/
class UniversalMediaProcessor {
constructor() {
// Реальные поддерживаемые форматы из frontend/src/components/ChatInterface.vue
this.supportedAudioFormats = ['.mp3', '.wav'];
this.supportedVideoFormats = ['.mp4', '.avi'];
this.supportedImageFormats = ['.jpg', '.jpeg', '.png', '.gif'];
this.supportedDocumentFormats = ['.txt', '.pdf', '.docx', '.xlsx', '.pptx', '.odt', '.ods', '.odp'];
this.supportedArchiveFormats = ['.zip', '.rar', '.7z'];
// Реальные ограничения размеров из кода:
// - uploads.js: 5MB для изображений
// - emailBot.js: 10MB для вложений
// - frontend: без ограничений (но браузер обычно ограничивает)
this.maxFileSize = 10 * 1024 * 1024; // 10MB (как в emailBot)
this.maxImageSize = 5 * 1024 * 1024; // 5MB (как в uploads.js)
this.uploadPath = path.join(__dirname, '../uploads');
this.ensureUploadDir();
}
async ensureUploadDir() {
try {
await fs.mkdir(this.uploadPath, { recursive: true });
await fs.mkdir(path.join(this.uploadPath, 'audio'), { recursive: true });
await fs.mkdir(path.join(this.uploadPath, 'video'), { recursive: true });
await fs.mkdir(path.join(this.uploadPath, 'images'), { recursive: true });
await fs.mkdir(path.join(this.uploadPath, 'documents'), { recursive: true });
await fs.mkdir(path.join(this.uploadPath, 'archives'), { recursive: true });
} catch (error) {
logger.error('[UniversalMediaProcessor] Ошибка создания директорий:', error);
}
}
/**
* Определяет тип медиа по расширению файла и MIME-типу
*/
getMediaType(filename, mimeType = null) {
const ext = path.extname(filename).toLowerCase();
// Сначала проверяем по расширению
if (this.supportedAudioFormats.includes(ext)) return 'audio';
if (this.supportedVideoFormats.includes(ext)) return 'video';
if (this.supportedImageFormats.includes(ext)) return 'image';
if (this.supportedDocumentFormats.includes(ext)) return 'document';
if (this.supportedArchiveFormats.includes(ext)) return 'archive';
// Если есть MIME-тип, проверяем по нему
if (mimeType) {
const mime = mimeType.toLowerCase();
if (mime.startsWith('audio/')) return 'audio';
if (mime.startsWith('video/')) return 'video';
if (mime.startsWith('image/')) return 'image';
if (mime.startsWith('application/')) {
// Документы и архивы
if (mime.includes('pdf') || mime.includes('document') || mime.includes('sheet') || mime.includes('presentation')) {
return 'document';
}
if (mime.includes('zip') || mime.includes('rar') || mime.includes('7z')) {
return 'archive';
}
}
}
return 'unknown';
}
/**
* Генерирует уникальное имя файла
*/
generateUniqueFilename(originalName, mediaType) {
const ext = path.extname(originalName);
const timestamp = Date.now();
const random = crypto.randomBytes(4).toString('hex');
return `${mediaType}_${timestamp}_${random}${ext}`;
}
/**
* Обрабатывает текстовое сообщение
*/
async processText(text, metadata = {}) {
return {
type: 'text',
content: text,
processed: true,
metadata: {
language: metadata.language || 'ru',
length: text.length,
...metadata
}
};
}
/**
* Обрабатывает аудио файл
*/
async processAudio(audioData, filename, metadata = {}) {
try {
const mediaType = 'audio';
const uniqueFilename = this.generateUniqueFilename(filename, mediaType);
const filePath = path.join(this.uploadPath, mediaType, uniqueFilename);
// Сохраняем файл
await fs.writeFile(filePath, audioData);
// Получаем информацию о файле
const stats = await fs.stat(filePath);
return {
type: 'audio',
content: `[Аудио сообщение: ${filename}]`,
processed: true,
file: {
originalName: filename,
savedName: uniqueFilename,
path: filePath,
size: stats.size,
url: `/uploads/audio/${uniqueFilename}`
},
metadata: {
duration: metadata.duration || null,
format: path.extname(filename).toLowerCase(),
...metadata
}
};
} catch (error) {
logger.error('[UniversalMediaProcessor] Ошибка обработки аудио:', error);
return {
type: 'audio',
content: `[Ошибка обработки аудио: ${filename}]`,
processed: false,
error: error.message
};
}
}
/**
* Обрабатывает видео файл
*/
async processVideo(videoData, filename, metadata = {}) {
try {
const mediaType = 'video';
const uniqueFilename = this.generateUniqueFilename(filename, mediaType);
const filePath = path.join(this.uploadPath, mediaType, uniqueFilename);
// Проверяем размер файла (видео до 10MB)
if (videoData.length > this.maxFileSize) {
throw new Error(`Видео файл слишком большой: ${(videoData.length / 1024 / 1024).toFixed(2)}MB. Максимум: ${this.maxFileSize / 1024 / 1024}MB`);
}
await fs.writeFile(filePath, videoData);
const stats = await fs.stat(filePath);
return {
type: 'video',
content: `[Видео сообщение: ${filename}]`,
processed: true,
file: {
originalName: filename,
savedName: uniqueFilename,
path: filePath,
size: stats.size,
url: `/uploads/video/${uniqueFilename}`
},
metadata: {
duration: metadata.duration || null,
format: path.extname(filename).toLowerCase(),
...metadata
}
};
} catch (error) {
logger.error('[UniversalMediaProcessor] Ошибка обработки видео:', error);
return {
type: 'video',
content: `[Ошибка обработки видео: ${filename}]`,
processed: false,
error: error.message
};
}
}
/**
* Обрабатывает изображение
*/
async processImage(imageData, filename, metadata = {}) {
try {
const mediaType = 'image';
const uniqueFilename = this.generateUniqueFilename(filename, mediaType);
const filePath = path.join(this.uploadPath, mediaType, uniqueFilename);
// Проверяем размер изображения (до 5MB)
if (imageData.length > this.maxImageSize) {
throw new Error(`Изображение слишком большое: ${(imageData.length / 1024 / 1024).toFixed(2)}MB. Максимум: ${this.maxImageSize / 1024 / 1024}MB`);
}
await fs.writeFile(filePath, imageData);
const stats = await fs.stat(filePath);
return {
type: 'image',
content: `[Изображение: ${filename}]`,
processed: true,
file: {
originalName: filename,
savedName: uniqueFilename,
path: filePath,
size: stats.size,
url: `/uploads/images/${uniqueFilename}`
},
metadata: {
width: metadata.width || null,
height: metadata.height || null,
format: path.extname(filename).toLowerCase(),
...metadata
}
};
} catch (error) {
logger.error('[UniversalMediaProcessor] Ошибка обработки изображения:', error);
return {
type: 'image',
content: `[Ошибка обработки изображения: ${filename}]`,
processed: false,
error: error.message
};
}
}
/**
* Обрабатывает документ
*/
async processDocument(docData, filename, metadata = {}) {
try {
const mediaType = 'document';
const uniqueFilename = this.generateUniqueFilename(filename, mediaType);
const filePath = path.join(this.uploadPath, mediaType, uniqueFilename);
await fs.writeFile(filePath, docData);
const stats = await fs.stat(filePath);
return {
type: 'document',
content: `[Документ: ${filename}]`,
processed: true,
file: {
originalName: filename,
savedName: uniqueFilename,
path: filePath,
size: stats.size,
url: `/uploads/documents/${uniqueFilename}`
},
metadata: {
format: path.extname(filename).toLowerCase(),
...metadata
}
};
} catch (error) {
logger.error('[UniversalMediaProcessor] Ошибка обработки документа:', error);
return {
type: 'document',
content: `[Ошибка обработки документа: ${filename}]`,
processed: false,
error: error.message
};
}
}
/**
* Обрабатывает файл (автоопределение типа)
*/
async processFile(fileData, filename, metadata = {}) {
const mediaType = this.getMediaType(filename, metadata.mimeType);
switch (mediaType) {
case 'audio':
return await this.processAudio(fileData, filename, metadata);
case 'video':
return await this.processVideo(fileData, filename, metadata);
case 'image':
return await this.processImage(fileData, filename, metadata);
case 'document':
return await this.processDocument(fileData, filename, metadata);
case 'archive':
return await this.processArchive(fileData, filename, metadata);
default:
return {
type: 'file',
content: `[Неизвестный файл: ${filename}]`,
processed: false,
error: 'Неподдерживаемый формат файла'
};
}
}
/**
* Обрабатывает архив
*/
async processArchive(archiveData, filename, metadata = {}) {
try {
const mediaType = 'archive';
const uniqueFilename = this.generateUniqueFilename(filename, mediaType);
const filePath = path.join(this.uploadPath, mediaType, uniqueFilename);
// Проверяем размер архива (до 10MB)
if (archiveData.length > this.maxFileSize) {
throw new Error(`Архив слишком большой: ${(archiveData.length / 1024 / 1024).toFixed(2)}MB. Максимум: ${this.maxFileSize / 1024 / 1024}MB`);
}
await fs.writeFile(filePath, archiveData);
const stats = await fs.stat(filePath);
return {
type: 'archive',
content: `[Архив: ${filename}]`,
processed: true,
file: {
originalName: filename,
savedName: uniqueFilename,
path: filePath,
size: stats.size,
url: `/uploads/archives/${uniqueFilename}`
},
metadata: {
format: path.extname(filename).toLowerCase(),
...metadata
}
};
} catch (error) {
logger.error('[UniversalMediaProcessor] Ошибка обработки архива:', error);
return {
type: 'archive',
content: `[Ошибка обработки архива: ${filename}]`,
processed: false,
error: error.message
};
}
}
/**
* Обрабатывает комбинированный контент (текст + медиа)
*/
async processCombinedContent(contentData) {
const results = [];
// Обрабатываем текст если есть
if (contentData.text && contentData.text.trim()) {
const textResult = await this.processText(contentData.text, contentData.textMetadata);
results.push(textResult);
}
// Обрабатываем файлы если есть
if (contentData.files && contentData.files.length > 0) {
for (const file of contentData.files) {
const fileResult = await this.processFile(file.data, file.filename, file.metadata);
results.push(fileResult);
}
}
// Обрабатываем аудио если есть
if (contentData.audio) {
const audioResult = await this.processAudio(
contentData.audio.data,
contentData.audio.filename,
contentData.audio.metadata
);
results.push(audioResult);
}
// Обрабатываем видео если есть
if (contentData.video) {
const videoResult = await this.processVideo(
contentData.video.data,
contentData.video.filename,
contentData.video.metadata
);
results.push(videoResult);
}
return {
type: 'combined',
parts: results,
processed: results.every(r => r.processed),
summary: this.generateContentSummary(results)
};
}
/**
* Генерирует краткое описание комбинированного контента
*/
generateContentSummary(parts) {
const summary = [];
parts.forEach(part => {
switch (part.type) {
case 'text':
summary.push(`Текст (${part.metadata.length} символов)`);
break;
case 'audio':
summary.push(`Аудио: ${part.file.originalName}`);
break;
case 'video':
summary.push(`Видео: ${part.file.originalName}`);
break;
case 'image':
summary.push(`Изображение: ${part.file.originalName}`);
break;
case 'document':
summary.push(`Документ: ${part.file.originalName}`);
break;
case 'archive':
summary.push(`Архив: ${part.file.originalName}`);
break;
}
});
return summary.join(', ');
}
/**
* Создает структуру для сохранения в БД
*/
createDatabaseRecord(processedContent, identifier, channel) {
const baseRecord = {
identifier,
channel,
timestamp: new Date(),
processed: processedContent.processed
};
if (processedContent.type === 'text') {
return {
...baseRecord,
content: processedContent.content,
content_type: 'text',
attachments: null,
metadata: processedContent.metadata
};
}
if (processedContent.type === 'combined') {
// Для комбинированного контента сохраняем как JSON
return {
...baseRecord,
content: processedContent.summary,
content_type: 'combined',
attachments: JSON.stringify(processedContent.parts),
metadata: {
partsCount: processedContent.parts.length,
hasText: processedContent.parts.some(p => p.type === 'text'),
hasMedia: processedContent.parts.some(p => p.type !== 'text')
}
};
}
// Для отдельных медиа файлов
return {
...baseRecord,
content: processedContent.content,
content_type: processedContent.type,
attachments: JSON.stringify(processedContent.file),
metadata: processedContent.metadata
};
}
/**
* Восстанавливает структуру из БД
*/
restoreFromDatabase(dbRecord) {
if (dbRecord.content_type === 'text') {
return {
type: 'text',
content: dbRecord.content,
metadata: dbRecord.metadata
};
}
if (dbRecord.content_type === 'combined') {
return {
type: 'combined',
parts: JSON.parse(dbRecord.attachments || '[]'),
summary: dbRecord.content,
metadata: dbRecord.metadata
};
}
// Для медиа файлов
return {
type: dbRecord.content_type,
content: dbRecord.content,
file: JSON.parse(dbRecord.attachments || '{}'),
metadata: dbRecord.metadata
};
}
}
module.exports = new UniversalMediaProcessor();

View File

@@ -17,23 +17,6 @@ const logger = require('../utils/logger');
* Определяет права доступа, приоритеты и логику работы с админами
*/
/**
* Определить тип отправителя на основе сессии
* @param {Object} session - Сессия пользователя
* @returns {Object} { senderType, role }
*/
function determineSenderType(session) {
if (!session) {
return { senderType: 'user', role: 'user' };
}
if (session.isAdmin === true) {
return { senderType: 'admin', role: 'admin' };
}
return { senderType: 'user', role: 'user' };
}
/**
* Определить, нужно ли генерировать AI ответ
* @param {Object} params - Параметры
@@ -81,142 +64,112 @@ function canWriteToConversation(params) {
return userId === conversationUserId;
}
/**
* Получить приоритет запроса для очереди AI
* @param {Object} params - Параметры
* @param {boolean} params.isAdmin - Является ли админом
* @param {string} params.message - Текст сообщения
* @param {Array} params.history - История сообщений
* @returns {number} Приоритет (чем выше, тем важнее)
*/
function getRequestPriority(params) {
const { isAdmin, message, history = [] } = params;
let priority = 10; // Базовый приоритет
// Админ получает повышенный приоритет
if (isAdmin) {
priority += 5;
}
// Срочные ключевые слова
const urgentKeywords = ['срочно', 'urgent', 'помогите', 'help', 'критично', 'critical'];
const messageLC = (message || '').toLowerCase();
if (urgentKeywords.some(keyword => messageLC.includes(keyword))) {
priority += 10;
}
// Короткие сообщения обрабатываются быстрее
if (message && message.length < 50) {
priority += 5;
}
// Первое сообщение в беседе
if (!history || history.length === 0) {
priority += 3;
}
return priority;
}
/**
* Проверить, может ли пользователь выполнить админское действие
* @param {Object} params - Параметры
* @param {boolean} params.isAdmin - Является ли админом
* @param {string} params.role - Роль пользователя ('editor', 'readonly', 'user')
* @param {string} params.action - Название действия
* @returns {boolean}
*/
function canPerformAdminAction(params) {
const { isAdmin, action } = params;
const { role, action } = params;
// Только админ может выполнять админские действия
if (!isAdmin) {
// Обычный пользователь не может выполнять админские действия
if (role === 'user') {
return false;
}
// Список разрешенных админских действий
const allowedActions = [
// Список действий только для editor (с правами редактирования)
const editorOnlyActions = [
'delete_message_history',
'view_all_conversations',
'manage_users',
'manage_ai_settings',
'broadcast_message',
'broadcast_message', // ← Массовая рассылка только для editor!
'delete_user',
'modify_user_settings'
];
return allowedActions.includes(action);
// Список действий для readonly (только просмотр и личные чаты)
const readonlyActions = [
'view_all_conversations', // Просмотр всех бесед
'create_admin_chat', // Создание приватных чатов между админами
'view_admin_chat' // Просмотр приватных чатов
];
// readonly может только просматривать и общаться
if (role === 'readonly') {
return readonlyActions.includes(action);
}
// editor может все (и свои действия, и readonly действия)
if (role === 'editor') {
return editorOnlyActions.includes(action) || readonlyActions.includes(action);
}
return false;
}
/**
* Получить настройки админа
* Получить настройки админа с учетом роли
* @param {Object} params - Параметры
* @param {boolean} params.isAdmin - Является ли админом
* @param {string} params.channel - Канал
* @returns {Object} Настройки
* @param {string} params.role - Роль пользователя ('editor', 'readonly', 'user')
* @returns {Object} Настройки прав доступа
*/
function getAdminSettings(params) {
const { isAdmin } = params;
const { role } = params;
if (!isAdmin) {
// Ограниченные права для обычного пользователя
// Editor - полные права
if (role === 'editor') {
return {
canWriteToAnyConversation: false,
canViewAllConversations: false,
canManageUsers: false,
canManageAISettings: false,
aiReplyPriority: 0
role: 'editor',
roleDisplay: 'Редактор',
canWriteToAnyConversation: true,
canViewAllConversations: true,
canManageUsers: true,
canManageAISettings: true,
canBroadcast: true,
canDeleteUsers: true,
canModifySettings: true,
canCreateAdminChat: true
};
}
// Полные права для админа
// Readonly - только просмотр и общение
if (role === 'readonly') {
return {
role: 'readonly',
roleDisplay: 'Только чтение',
canWriteToAnyConversation: false, // Только в свои беседы
canViewAllConversations: true, // Может просматривать все
canManageUsers: false,
canManageAISettings: false,
canBroadcast: false,
canDeleteUsers: false,
canModifySettings: false,
canCreateAdminChat: true // Может создавать приватные чаты с админами
};
}
// User - минимальные права
return {
canWriteToAnyConversation: true,
canViewAllConversations: true,
canManageUsers: true,
canManageAISettings: true,
aiReplyPriority: 15
role: 'user',
roleDisplay: 'Пользователь',
canWriteToAnyConversation: false,
canViewAllConversations: false,
canManageUsers: false,
canManageAISettings: false,
canBroadcast: false,
canDeleteUsers: false,
canModifySettings: false,
canCreateAdminChat: false
};
}
/**
* Логирование админского действия
* @param {Object} params - Параметры
* @param {number} params.adminId - ID админа
* @param {string} params.action - Действие
* @param {Object} params.details - Детали
*/
function logAdminAction(params) {
const { adminId, action, details } = params;
logger.info('[AdminLogic] Админское действие:', {
adminId,
action,
details,
timestamp: new Date().toISOString()
});
}
/**
* Проверить, является ли сообщение от админа личным
* @param {Object} params - Параметры
* @returns {boolean}
*/
function isPersonalAdminMessage(params) {
const { senderType, userId, recipientId } = params;
return senderType === 'admin' && userId !== recipientId;
}
module.exports = {
determineSenderType,
shouldGenerateAiReply,
canWriteToConversation,
getRequestPriority,
canPerformAdminAction,
getAdminSettings,
logAdminAction,
isPersonalAdminMessage
getAdminSettings
};

View File

@@ -75,19 +75,22 @@ class AIAssistant {
const aiAssistantRulesService = require('./aiAssistantRulesService');
const { ragAnswer } = require('./ragService');
// 1. Проверяем дедупликацию
const cleanMessageId = messageDeduplicationService.cleanMessageId(messageId, channel);
const isAlreadyProcessed = await messageDeduplicationService.isMessageAlreadyProcessed(
channel,
cleanMessageId,
userId,
'user'
);
// 1. Проверяем дедупликацию через хеш
const messageForDedup = {
userId,
content: userQuestion,
channel
};
const isDuplicate = messageDeduplicationService.isDuplicate(messageForDedup);
if (isAlreadyProcessed) {
logger.info(`[AIAssistant] Сообщение ${cleanMessageId} уже обработано - пропускаем`);
if (isDuplicate) {
logger.info(`[AIAssistant] Сообщение уже обработано - пропускаем`);
return { success: false, reason: 'duplicate' };
}
// Помечаем как обработанное
messageDeduplicationService.markAsProcessed(messageForDedup);
// 2. Получаем настройки AI ассистента
const aiSettings = await aiAssistantSettingsService.getSettings();
@@ -96,13 +99,32 @@ class AIAssistant {
rules = await aiAssistantRulesService.getRuleById(aiSettings.rules_id);
}
// 3. Генерируем AI ответ через RAG
const aiResponse = await ragAnswer({
// 3. Определяем tableId для RAG
let tableId = ragTableId;
if (!tableId && aiSettings && aiSettings.selected_rag_tables && aiSettings.selected_rag_tables.length > 0) {
tableId = aiSettings.selected_rag_tables[0];
}
// 4. Выполняем RAG поиск если есть tableId
let ragResult = null;
if (tableId) {
ragResult = await ragAnswer({
tableId,
userQuestion
// threshold использует дефолтное значение 300 из ragService
});
}
// 5. Генерируем LLM ответ
const { generateLLMResponse } = require('./ragService');
const aiResponse = await generateLLMResponse({
userQuestion,
conversationHistory,
context: ragResult?.context || '',
answer: ragResult?.answer || '',
systemPrompt: aiSettings ? aiSettings.system_prompt : '',
rules: rules ? rules.rules : null,
ragTableId
history: conversationHistory,
model: aiSettings ? aiSettings.model : undefined,
rules: rules ? rules.rules : null
});
if (!aiResponse) {
@@ -110,38 +132,13 @@ class AIAssistant {
return { success: false, reason: 'empty_response' };
}
// 4. Сохраняем ответ с дедупликацией
const aiResponseId = `ai_response_${cleanMessageId}_${Date.now()}`;
const saveResult = await messageDeduplicationService.saveMessageWithDeduplication(
{
user_id: userId,
conversation_id: conversationId,
sender_type: 'assistant',
content: aiResponse,
channel: channel,
role: 'assistant',
direction: 'out',
created_at: new Date(),
...metadata
},
channel,
aiResponseId,
userId,
'assistant',
'messages'
);
if (!saveResult.success) {
logger.error(`[AIAssistant] Ошибка сохранения AI ответа:`, saveResult.error);
return { success: false, reason: 'save_error' };
}
logger.info(`[AIAssistant] AI ответ успешно сгенерирован и сохранен для пользователя ${userId}`);
logger.info(`[AIAssistant] AI ответ успешно сгенерирован для пользователя ${userId}`);
return {
success: true,
response: aiResponse,
messageId: aiResponseId,
ragData: ragResult,
messageId: messageId,
conversationId: conversationId
};
@@ -153,18 +150,20 @@ class AIAssistant {
/**
* Простая генерация ответа (для гостевых сообщений)
* Используется в guestMessageService
* Используется в UniversalGuestService
*/
async getResponse(message, history = null, systemPrompt = '', rules = null) {
try {
const { ragAnswer } = require('./ragService');
const { generateLLMResponse } = require('./ragService');
const result = await ragAnswer({
const result = await generateLLMResponse({
userQuestion: message,
conversationHistory: history || [],
context: '',
answer: '',
systemPrompt: systemPrompt || '',
rules: rules || null,
ragTableId: null
history: history || [],
model: undefined,
rules: rules
});
return result;

View File

@@ -4,12 +4,16 @@
const crypto = require('crypto');
const logger = require('../utils/logger');
const ollamaConfig = require('./ollamaConfig');
class AICache {
constructor() {
const timeouts = ollamaConfig.getTimeouts();
this.cache = new Map();
this.maxSize = 1000; // Максимальное количество кэшированных ответов
this.ttl = 24 * 60 * 60 * 1000; // 24 часа в миллисекундах
this.maxSize = timeouts.cacheMax; // Из централизованных настроек
this.ttl = timeouts.cacheLLM; // 24 часа (для LLM)
this.ragTtl = timeouts.cacheRAG; // 5 минут (для RAG результатов)
}
// Генерация ключа кэша на основе запроса
@@ -22,6 +26,12 @@ class AICache {
return crypto.createHash('md5').update(content).digest('hex');
}
// ✨ НОВОЕ: Генерация ключа для RAG результатов
generateKeyForRAG(tableId, userQuestion, product = null) {
const content = JSON.stringify({ tableId, userQuestion, product });
return crypto.createHash('md5').update(content).digest('hex');
}
// Получение ответа из кэша
get(key) {
const cached = this.cache.get(key);
@@ -37,6 +47,24 @@ class AICache {
return cached.response;
}
// ✨ НОВОЕ: Получение с учетом типа кэша (RAG или LLM)
getWithTTL(key, type = 'llm') {
const cached = this.cache.get(key);
if (!cached) return null;
// Выбираем TTL в зависимости от типа
const ttl = type === 'rag' ? this.ragTtl : this.ttl;
// Проверяем TTL
if (Date.now() - cached.timestamp > ttl) {
this.cache.delete(key);
return null;
}
logger.info(`[AICache] Cache hit (${type}) for key: ${key.substring(0, 8)}...`);
return cached.response;
}
// Сохранение ответа в кэш
set(key, response) {
// Очищаем старые записи если кэш переполнен
@@ -53,6 +81,23 @@ class AICache {
logger.info(`[AICache] Cached response for key: ${key.substring(0, 8)}...`);
}
// ✨ НОВОЕ: Сохранение с указанием типа (rag или llm)
setWithType(key, response, type = 'llm') {
// Очищаем старые записи если кэш переполнен
if (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
this.cache.set(key, {
response,
timestamp: Date.now(),
type: type // Сохраняем тип для статистики
});
logger.info(`[AICache] Cached ${type} response for key: ${key.substring(0, 8)}...`);
}
// Очистка кэша
clear() {
this.cache.clear();
@@ -90,6 +135,31 @@ class AICache {
if (this.maxSize === 0) return 0;
return this.cache.size / this.maxSize;
}
// ✨ НОВОЕ: Статистика по типу кэша
getStatsByType() {
const stats = { rag: 0, llm: 0, other: 0 };
for (const [key, value] of this.cache.entries()) {
const type = value.type || 'other';
stats[type] = (stats[type] || 0) + 1;
}
return stats;
}
// ✨ НОВОЕ: Инвалидация по префиксу (для очистки RAG кэша при обновлении таблиц)
invalidateByPrefix(prefix) {
let deletedCount = 0;
for (const [key, value] of this.cache.entries()) {
if (key.startsWith(prefix)) {
this.cache.delete(key);
deletedCount++;
}
}
if (deletedCount > 0) {
logger.info(`[AICache] Инвалидировано ${deletedCount} записей с префиксом: ${prefix}`);
}
return deletedCount;
}
}
module.exports = new AICache();

View File

@@ -12,11 +12,20 @@
const EventEmitter = require('events');
const logger = require('../utils/logger');
const axios = require('axios');
const ollamaConfig = require('./ollamaConfig');
const aiCache = require('./ai-cache');
class AIQueue extends EventEmitter {
constructor() {
super();
const timeouts = ollamaConfig.getTimeouts();
this.queue = [];
this.isProcessing = false; // ✨ НОВОЕ: Флаг обработки
this.maxQueueSize = timeouts.queueMaxSize; // Из централизованных настроек
this.workerInterval = null; // ✨ НОВОЕ: Интервал worker
this.checkInterval = timeouts.queueInterval; // Интервал проверки очереди
this.stats = {
totalAdded: 0,
totalProcessed: 0,
@@ -135,6 +144,133 @@ class AIQueue extends EventEmitter {
isQueuePaused() {
return this.isPaused;
}
// ✨ НОВОЕ: Добавление задачи с Promise (для ожидания результата)
async addTask(taskData) {
// Проверяем лимит очереди
if (this.queue.length >= this.maxQueueSize) {
throw new Error('Очередь переполнена');
}
const taskId = Date.now() + Math.random();
const queueItem = {
id: taskId,
request: taskData,
priority: 0, // Все задачи с одинаковым приоритетом (FIFO)
status: 'queued',
timestamp: Date.now()
};
this.queue.push(queueItem);
// Не сортируем - FIFO (First In First Out)
this.stats.totalAdded++;
logger.info(`[AIQueue] Задача ${taskId} добавлена. Очередь: ${this.queue.length}`);
this.emit('requestAdded', queueItem);
// Возвращаем Promise для ожидания результата
return new Promise((resolve, reject) => {
const timeouts = ollamaConfig.getTimeouts();
const timeout = setTimeout(() => {
reject(new Error('Queue timeout'));
}, timeouts.queueTimeout); // Централизованный таймаут очереди
this.once(`task_${taskId}_completed`, (result) => {
clearTimeout(timeout);
resolve(result.response);
});
this.once(`task_${taskId}_failed`, (error) => {
clearTimeout(timeout);
reject(new Error(error.message));
});
});
}
// ✨ НОВОЕ: Запуск автоматического worker
startWorker() {
if (this.workerInterval) {
logger.warn('[AIQueue] Worker уже запущен');
return;
}
logger.info('[AIQueue] 🚀 Запуск worker для обработки очереди...');
this.workerInterval = setInterval(() => {
this.processNextTask();
}, this.checkInterval); // Интервал из централизованных настроек
}
// ✨ НОВОЕ: Остановка worker
stopWorker() {
if (this.workerInterval) {
clearInterval(this.workerInterval);
this.workerInterval = null;
logger.info('[AIQueue] ⏹️ Worker остановлен');
}
}
// ✨ НОВОЕ: Обработка следующей задачи из очереди
async processNextTask() {
if (this.isProcessing) return;
const task = this.getNextRequest();
if (!task) return;
this.isProcessing = true;
const startTime = Date.now();
try {
logger.info(`[AIQueue] Обработка задачи ${task.id}`);
// 1. Проверяем кэш
const cacheKey = aiCache.generateKey(task.request.messages);
const cached = aiCache.get(cacheKey);
if (cached) {
logger.info(`[AIQueue] Cache HIT для задачи ${task.id}`);
const responseTime = Date.now() - startTime;
this.updateRequestStatus(task.id, 'completed', cached, null, responseTime);
this.emit(`task_${task.id}_completed`, { response: cached, fromCache: true });
return;
}
// 2. Вызываем Ollama API
const ollamaUrl = ollamaConfig.getBaseUrl();
const timeouts = ollamaConfig.getTimeouts();
const response = await axios.post(`${ollamaUrl}/api/chat`, {
model: task.request.model || ollamaConfig.getDefaultModel(),
messages: task.request.messages,
stream: false
}, {
timeout: timeouts.ollamaChat
});
const result = response.data.message.content;
const responseTime = Date.now() - startTime;
// 3. Сохраняем в кэш
aiCache.set(cacheKey, result);
// 4. Обновляем статус
this.updateRequestStatus(task.id, 'completed', result, null, responseTime);
this.emit(`task_${task.id}_completed`, { response: result, fromCache: false });
logger.info(`[AIQueue] ✅ Задача ${task.id} выполнена за ${responseTime}ms`);
} catch (error) {
logger.error(`[AIQueue] ❌ Ошибка задачи ${task.id}:`, error.message);
this.updateRequestStatus(task.id, 'failed', null, error.message);
this.emit(`task_${task.id}_failed`, { message: error.message });
} finally {
this.isProcessing = false;
}
}
}
module.exports = AIQueue;

View File

@@ -13,8 +13,11 @@
const encryptedDb = require('./encryptedDatabaseService');
const OpenAI = require('openai');
const Anthropic = require('@anthropic-ai/sdk');
const axios = require('axios');
const ollamaConfig = require('./ollamaConfig');
const TABLE = 'ai_providers_settings';
const TIMEOUTS = ollamaConfig.getTimeouts();
async function getProviderSettings(provider) {
const settings = await encryptedDb.getData(TABLE, { provider: provider }, 1);
@@ -144,12 +147,10 @@ async function getAllLLMModels() {
// Для Ollama проверяем реально установленные модели через HTTP API
try {
const axios = require('axios');
const ollamaConfig = require('./ollamaConfig');
const ollamaUrl = ollamaConfig.getBaseUrl();
const response = await axios.get(`${ollamaUrl}/api/tags`, {
timeout: 5000
timeout: TIMEOUTS.ollamaTags
});
const models = response.data.models || [];
@@ -214,12 +215,10 @@ async function getAllEmbeddingModels() {
// Для Ollama проверяем реально установленные embedding модели через HTTP API
try {
const axios = require('axios');
const ollamaConfig = require('./ollamaConfig');
const ollamaUrl = ollamaConfig.getBaseUrl();
const response = await axios.get(`${ollamaUrl}/api/tags`, {
timeout: 5000
timeout: TIMEOUTS.ollamaTags
});
const models = response.data.models || [];

View File

@@ -261,9 +261,10 @@ class AuthService {
*/
async processAndCleanupGuestData(userId, guestId, session) {
try {
// Обрабатываем гостевые сообщения
const guestMessageService = require('./guestMessageService');
await guestMessageService.processGuestMessages(userId, guestId);
// Обрабатываем гостевые сообщения (используем новый UniversalGuestService)
const universalGuestService = require('./UniversalGuestService');
const identifier = `web:${guestId}`; // Старые гости всегда из web
await universalGuestService.migrateToUser(identifier, userId);
// Очищаем гостевой ID из сессии
delete session.guestId;
@@ -437,8 +438,8 @@ class AuthService {
const encryptionKey = encryptionUtils.getEncryptionKey();
await db.getQuery()(
'INSERT INTO guest_user_mapping (user_id, guest_id_encrypted) VALUES ($1, encrypt_text($2, $3)) ON CONFLICT (guest_id_encrypted) DO UPDATE SET user_id = $1',
[userId, session.guestId, encryptionKey]
'INSERT INTO unified_guest_mapping (user_id, identifier_encrypted, channel, created_at) VALUES ($1, encrypt_text($2, $4), $3, NOW()) ON CONFLICT (identifier_encrypted, channel) DO UPDATE SET user_id = $1',
[userId, `web:${session.guestId}`, 'web', encryptionKey]
);
logger.info(`[verifyTelegramAuth] Saved guest ID ${session.guestId} for user ${userId}`);
}

View File

@@ -34,17 +34,9 @@ class BotManager {
logger.info('[BotManager] 🚀 Инициализация BotManager...');
// Создаем экземпляры ботов
const webBot = {
name: 'WebBot',
channel: 'web',
isInitialized: true,
status: 'active',
initialize: async () => ({ success: true }),
processMessage: async (messageData) => {
return await unifiedMessageProcessor.processMessage(messageData);
}
};
const WebBot = require('./webBot');
const webBot = new WebBot();
const telegramBot = new TelegramBot();
const emailBot = new EmailBot();
@@ -53,6 +45,12 @@ class BotManager {
this.bots.set('telegram', telegramBot);
this.bots.set('email', emailBot);
// Инициализируем Web Bot
logger.info('[BotManager] Инициализация Web Bot...');
await webBot.initialize().catch(error => {
logger.warn('[BotManager] Web Bot не инициализирован:', error.message);
});
// Инициализируем Telegram Bot
logger.info('[BotManager] Инициализация Telegram Bot...');
await telegramBot.initialize().catch(error => {

View File

@@ -11,6 +11,7 @@
*/
const db = require('../db');
const encryptedDb = require('./encryptedDatabaseService');
const logger = require('../utils/logger');
/**
@@ -37,11 +38,10 @@ async function getBotSettings(botType) {
throw new Error(`Unknown bot type: ${botType}`);
}
const { rows } = await db.getQuery()(
`SELECT * FROM ${tableName} ORDER BY id LIMIT 1`
);
// Используем encryptedDb для автоматической расшифровки
const settings = await encryptedDb.getData(tableName, {}, 1);
return rows.length > 0 ? rows[0] : null;
return settings.length > 0 ? settings[0] : null;
} catch (error) {
logger.error(`[BotsSettings] Ошибка получения настроек ${botType}:`, error);

View File

@@ -16,6 +16,7 @@ const simpleParser = require('mailparser').simpleParser;
const logger = require('../utils/logger');
const encryptedDb = require('./encryptedDatabaseService');
const db = require('../db');
const universalMediaProcessor = require('./UniversalMediaProcessor');
/**
* EmailBot - обработчик Email сообщений
@@ -294,7 +295,7 @@ class EmailBot {
messageId = parsed.messageId;
}
const messageData = this.extractMessageData(parsed, messageId, uid);
const messageData = await this.extractMessageData(parsed, messageId, uid);
if (messageData && this.messageProcessor) {
await this.messageProcessor(messageData);
}
@@ -324,13 +325,13 @@ class EmailBot {
}
/**
* Извлечение данных из Email сообщения
* Извлечение данных из Email сообщения с поддержкой медиа
* @param {Object} parsed - Распарсенное письмо
* @param {string} messageId - ID сообщения
* @param {number} uid - UID сообщения
* @returns {Object|null} - Стандартизированные данные сообщения
*/
extractMessageData(parsed, messageId, uid) {
async extractMessageData(parsed, messageId, uid) {
try {
const fromEmail = parsed.from?.value?.[0]?.address;
const subject = parsed.subject || '';
@@ -355,33 +356,75 @@ class EmailBot {
return null;
}
const attachments = [];
let contentData = null;
const mediaFiles = [];
if (parsed.attachments && parsed.attachments.length > 0) {
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; // 10MB
for (const att of parsed.attachments) {
if (att.size <= MAX_ATTACHMENT_SIZE) {
attachments.push({
filename: att.filename,
mimetype: att.contentType,
size: att.size,
data: att.content
try {
// Обрабатываем вложение через медиа-процессор
const processedFile = await universalMediaProcessor.processFile(
att.content,
att.filename,
{
emailAttachment: true,
originalSize: att.size,
mimeType: att.contentType
}
);
mediaFiles.push(processedFile);
} catch (fileError) {
logger.error('[EmailBot] Ошибка обработки вложения:', fileError);
// Fallback: сохраняем как есть
mediaFiles.push({
type: 'document',
content: `[Вложение: ${att.filename}]`,
processed: false,
error: fileError.message,
file: {
filename: att.filename,
mimetype: att.contentType,
size: att.size,
data: att.content
}
});
}
}
}
// Создаем структурированные данные контента если есть медиа
if (mediaFiles.length > 0) {
contentData = {
text: text,
files: mediaFiles.map(file => ({
data: file.file?.data || file.file?.content,
filename: file.file?.originalName || file.file?.filename,
metadata: {
type: file.type,
processed: file.processed,
emailAttachment: true,
mimeType: file.file?.contentType || file.file?.mimetype,
originalSize: file.file?.size
}
}))
};
}
return {
channel: 'email',
identifier: fromEmail,
content: text,
attachments: attachments,
contentData: contentData,
attachments: mediaFiles, // Обратная совместимость
metadata: {
subject: subject,
messageId: messageId,
uid: uid,
fromEmail: fromEmail,
html: parsed.html || ''
html: parsed.html || '',
hasMedia: mediaFiles.length > 0,
mediaTypes: mediaFiles.map(f => f.type)
}
};
} catch (error) {
@@ -453,6 +496,39 @@ class EmailBot {
}
}
/**
* Отправка приветственного письма с ссылкой для подключения кошелька
* @param {string} email - Email получателя
* @param {string} linkUrl - Ссылка для подключения кошелька
*/
async sendWelcomeWithLink(email, linkUrl) {
try {
const mailOptions = {
from: this.settings.from_email,
to: email,
subject: 'Подключите Web3 кошелек',
text: `Добро пожаловать!\n\nДля полного доступа к системе подключите Web3 кошелек:\n${linkUrl}\n\nСсылка действительна 1 час.`,
html: `<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #333;">🔗 Подключите Web3 кошелек</h2>
<p style="font-size: 16px; color: #666;">Добро пожаловать! Для сохранения истории сообщений и полного доступа к системе подключите ваш кошелек:</p>
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 5px; text-align: center; margin: 20px 0;">
<a href="${linkUrl}" style="display: inline-block; background-color: #4CAF50; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; font-size: 16px;">
Подключить кошелек
</a>
</div>
<p style="font-size: 14px; color: #999;">⏱ Ссылка действительна 1 час</p>
<p style="font-size: 14px; color: #666;">Вы сможете продолжить переписку без подключения кошелька, но история будет временной.</p>
</div>`,
};
await this.transporter.sendMail(mailOptions);
logger.info('[EmailBot] Приветственное письмо с ссылкой отправлено');
} catch (error) {
logger.error('[EmailBot] Ошибка отправки приветственного письма:', error);
throw error;
}
}
/**
* Установка процессора сообщений
* @param {Function} processor - Функция обработки сообщений

View File

@@ -1,159 +0,0 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
const db = require('../db');
const logger = require('../utils/logger');
const encryptionUtils = require('../utils/encryptionUtils');
const guestService = require('./guestService');
/**
* Сервис для переноса гостевых сообщений в зарегистрированный аккаунт
* Используется при регистрации/входе пользователя, который был гостем
*/
/**
* Перенести гостевые сообщения в аккаунт пользователя
* @param {string} guestId - ID гостя
* @param {number} userId - ID зарегистрированного пользователя
* @returns {Promise<Object>}
*/
async function migrateGuestMessages(guestId, userId) {
try {
logger.info(`[GuestMessageService] Перенос сообщений с ${guestId} на user ${userId}`);
// Получаем гостевые сообщения
const guestMessages = await guestService.getGuestMessages(guestId);
if (guestMessages.length === 0) {
logger.info('[GuestMessageService] Нет сообщений для переноса');
return { migrated: 0, skipped: 0 };
}
const encryptionKey = encryptionUtils.getEncryptionKey();
let migrated = 0;
let skipped = 0;
// Переносим каждое сообщение
for (const msg of guestMessages) {
try {
// Вставляем в таблицу messages
await db.getQuery()(
`INSERT INTO messages (
user_id,
sender_type_encrypted,
content_encrypted,
channel_encrypted,
role_encrypted,
direction_encrypted,
created_at
) VALUES (
$1,
encrypt_text($2, $7),
encrypt_text($3, $7),
encrypt_text($4, $7),
encrypt_text($5, $7),
encrypt_text($6, $7),
$8
)`,
[
userId,
'user',
msg.content,
msg.channel || 'web',
'user',
'incoming',
encryptionKey,
msg.created_at
]
);
migrated++;
} catch (error) {
logger.error('[GuestMessageService] Ошибка переноса сообщения:', error);
skipped++;
}
}
// Удаляем гостевые сообщения после успешного переноса
if (migrated > 0) {
await guestService.deleteGuestMessages(guestId);
}
logger.info(`[GuestMessageService] Перенесено: ${migrated}, пропущено: ${skipped}`);
return { migrated, skipped, total: guestMessages.length };
} catch (error) {
logger.error('[GuestMessageService] Ошибка миграции сообщений:', error);
throw error;
}
}
/**
* Проверить, есть ли гостевые сообщения для переноса
* @param {string} guestId - ID гостя
* @returns {Promise<boolean>}
*/
async function hasGuestMessages(guestId) {
try {
const messages = await guestService.getGuestMessages(guestId);
return messages.length > 0;
} catch (error) {
logger.error('[GuestMessageService] Ошибка проверки гостевых сообщений:', error);
return false;
}
}
/**
* Получить количество гостевых сообщений
* @param {string} guestId - ID гостя
* @returns {Promise<number>}
*/
async function getGuestMessageCount(guestId) {
try {
const messages = await guestService.getGuestMessages(guestId);
return messages.length;
} catch (error) {
logger.error('[GuestMessageService] Ошибка подсчета гостевых сообщений:', error);
return 0;
}
}
/**
* Очистить старые гостевые сообщения (старше N дней)
* @param {number} daysOld - Возраст в днях
* @returns {Promise<number>}
*/
async function cleanupOldGuestMessages(daysOld = 30) {
try {
const { rowCount } = await db.getQuery()(
`DELETE FROM guest_messages
WHERE created_at < NOW() - INTERVAL '${daysOld} days'`
);
logger.info(`[GuestMessageService] Очищено ${rowCount} старых гостевых сообщений`);
return rowCount;
} catch (error) {
logger.error('[GuestMessageService] Ошибка очистки старых сообщений:', error);
throw error;
}
}
module.exports = {
migrateGuestMessages,
hasGuestMessages,
getGuestMessageCount,
cleanupOldGuestMessages
};

View File

@@ -1,160 +0,0 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
const db = require('../db');
const logger = require('../utils/logger');
const encryptionUtils = require('../utils/encryptionUtils');
const crypto = require('crypto');
/**
* Сервис для работы с гостевыми сообщениями
* Обрабатывает сообщения от незарегистрированных пользователей
*/
/**
* Создать гостевой идентификатор
* @returns {string}
*/
function createGuestId() {
return `guest_${crypto.randomBytes(16).toString('hex')}`;
}
/**
* Сохранить гостевое сообщение
* @param {Object} messageData - Данные сообщения
* @returns {Promise<Object>}
*/
async function saveGuestMessage(messageData) {
try {
const encryptionKey = encryptionUtils.getEncryptionKey();
const guestId = messageData.guestId || createGuestId();
const { rows } = await db.getQuery()(
`INSERT INTO guest_messages (
guest_id,
content_encrypted,
channel_encrypted,
created_at
) VALUES (
$1,
encrypt_text($2, $3),
encrypt_text($4, $3),
NOW()
) RETURNING id, guest_id, created_at`,
[guestId, messageData.content, encryptionKey, messageData.channel || 'web']
);
logger.info('[GuestService] Сохранено гостевое сообщение:', rows[0].id);
return {
...rows[0],
content: messageData.content,
channel: messageData.channel || 'web'
};
} catch (error) {
logger.error('[GuestService] Ошибка сохранения гостевого сообщения:', error);
throw error;
}
}
/**
* Получить гостевые сообщения по guest_id
* @param {string} guestId - ID гостя
* @returns {Promise<Array>}
*/
async function getGuestMessages(guestId) {
try {
const encryptionKey = encryptionUtils.getEncryptionKey();
const { rows } = await db.getQuery()(
`SELECT
id,
guest_id,
decrypt_text(content_encrypted, $2) as content,
decrypt_text(channel_encrypted, $2) as channel,
created_at
FROM guest_messages
WHERE guest_id = $1
ORDER BY created_at ASC`,
[guestId, encryptionKey]
);
return rows;
} catch (error) {
logger.error('[GuestService] Ошибка получения гостевых сообщений:', error);
throw error;
}
}
/**
* Удалить гостевые сообщения
* @param {string} guestId - ID гостя
* @returns {Promise<number>}
*/
async function deleteGuestMessages(guestId) {
try {
const { rowCount } = await db.getQuery()(
`DELETE FROM guest_messages WHERE guest_id = $1`,
[guestId]
);
logger.info(`[GuestService] Удалено ${rowCount} гостевых сообщений для ${guestId}`);
return rowCount;
} catch (error) {
logger.error('[GuestService] Ошибка удаления гостевых сообщений:', error);
throw error;
}
}
/**
* Проверить, является ли пользователь гостем
* @param {string} identifier - Идентификатор
* @returns {boolean}
*/
function isGuest(identifier) {
return typeof identifier === 'string' && identifier.startsWith('guest_');
}
/**
* Получить статистику гостевых сообщений
* @returns {Promise<Object>}
*/
async function getGuestStats() {
try {
const { rows } = await db.getQuery()(
`SELECT
COUNT(DISTINCT guest_id) as unique_guests,
COUNT(*) as total_messages,
MAX(created_at) as last_message_at
FROM guest_messages`
);
return rows[0];
} catch (error) {
logger.error('[GuestService] Ошибка получения статистики:', error);
throw error;
}
}
module.exports = {
createGuestId,
saveGuestMessage,
getGuestMessages,
deleteGuestMessages,
isGuest,
getGuestStats
};

View File

@@ -73,17 +73,23 @@ class IdentityService {
const { provider: normalizedProvider, providerId: normalizedProviderId } =
this.normalizeIdentity(provider, providerId);
// Проверяем тип провайдера и перенаправляем гостевые идентификаторы в guest_user_mapping
// Проверяем тип провайдера и перенаправляем гостевые идентификаторы в unified_guest_mapping
if (normalizedProvider === 'guest') {
logger.info(
`[IdentityService] Converting guest identity for user ${userId} to guest_user_mapping: ${normalizedProviderId}`
`[IdentityService] Converting guest identity for user ${userId} to unified_guest_mapping: ${normalizedProviderId}`
);
try {
await encryptedDb.saveData('guest_user_mapping', {
user_id: userId,
guest_id: normalizedProviderId
});
const db = require('../db');
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
await db.getQuery()(
`INSERT INTO unified_guest_mapping (user_id, identifier_encrypted, channel, created_at)
VALUES ($1, encrypt_text($2, $4), $3, NOW())
ON CONFLICT (identifier_encrypted, channel) DO NOTHING`,
[userId, `web:${normalizedProviderId}`, 'web', encryptionKey]
);
return { success: true };
} catch (guestError) {
logger.error(
@@ -285,13 +291,19 @@ class IdentityService {
results.push({ type: 'telegram', result: telegramResult });
}
// Сохраняем гостевые идентификаторы в guest_user_mapping
// Сохраняем гостевые идентификаторы в unified_guest_mapping
if (session.guestId) {
try {
await encryptedDb.saveData('guest_user_mapping', {
user_id: userId,
guest_id: session.guestId
});
const db = require('../db');
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
await db.getQuery()(
`INSERT INTO unified_guest_mapping (user_id, identifier_encrypted, channel, created_at)
VALUES ($1, encrypt_text($2, $4), $3, NOW())
ON CONFLICT (identifier_encrypted, channel) DO NOTHING`,
[userId, `web:${session.guestId}`, 'web', encryptionKey]
);
results.push({ type: 'guest', result: { success: true } });
} catch (error) {
logger.error(`[IdentityService] Error saving guest ID for user ${userId}:`, error);
@@ -301,10 +313,16 @@ class IdentityService {
if (session.previousGuestId && session.previousGuestId !== session.guestId) {
try {
await encryptedDb.saveData('guest_user_mapping', {
user_id: userId,
guest_id: session.previousGuestId
});
const db = require('../db');
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
await db.getQuery()(
`INSERT INTO unified_guest_mapping (user_id, identifier_encrypted, channel, created_at)
VALUES ($1, encrypt_text($2, $4), $3, NOW())
ON CONFLICT (identifier_encrypted, channel) DO NOTHING`,
[userId, `web:${session.previousGuestId}`, 'web', encryptionKey]
);
results.push({ type: 'previousGuest', result: { success: true } });
} catch (error) {
logger.error(
@@ -364,19 +382,24 @@ class IdentityService {
}
// Мигрируем гостевые идентификаторы
const guestMappings = await encryptedDb.getData('guest_user_mapping', { user_id: fromUserId });
const guestMappings = await encryptedDb.getData('unified_guest_mapping', { user_id: fromUserId });
// Переносим каждый гостевой идентификатор
for (const mapping of guestMappings) {
await encryptedDb.saveData('guest_user_mapping', {
user_id: toUserId,
guest_id: mapping.guest_id,
processed: mapping.processed
});
const db = require('../db');
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
await db.getQuery()(
`INSERT INTO unified_guest_mapping (user_id, identifier_encrypted, channel, processed, processed_at, created_at)
VALUES ($1, encrypt_text($2, $6), $3, $4, $5, NOW())
ON CONFLICT (identifier_encrypted, channel) DO UPDATE SET user_id = $1, processed = $4, processed_at = $5`,
[toUserId, mapping.identifier_encrypted, mapping.channel, mapping.processed, mapping.processed_at, encryptionKey]
);
}
// Удаляем старые гостевые маппинги
await encryptedDb.deleteData('guest_user_mapping', { user_id: fromUserId });
await encryptedDb.deleteData('unified_guest_mapping', { user_id: fromUserId });
// Переносим все сообщения
const messages = await encryptedDb.getData('messages', { user_id: fromUserId });

View File

@@ -1,41 +0,0 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
const botManager = require('./botManager');
const botsSettings = require('./botsSettings');
const aiAssistant = require('./ai-assistant');
const {
initializeVectorStore,
getVectorStore,
similaritySearch,
addDocument,
} = require('./vectorStore');
module.exports = {
// Bot Manager (новая архитектура)
botManager,
botsSettings,
// Vector Store
initializeVectorStore,
getVectorStore,
similaritySearch,
addDocument,
// AI Assistant
aiAssistant,
processMessage: aiAssistant.processMessage,
getUserInfo: aiAssistant.getUserInfo,
getConversationHistory: aiAssistant.getConversationHistory,
interfaceService: require('./interfaceService'),
};

View File

@@ -1,166 +0,0 @@
/**
* Copyright (c) 2024-2025 Тарабанов Александр Викторович
* All rights reserved.
*
* This software is proprietary and confidential.
* Unauthorized copying, modification, or distribution is prohibited.
*
* For licensing inquiries: info@hb3-accelerator.com
* Website: https://hb3-accelerator.com
* GitHub: https://github.com/HB3-ACCELERATOR
*/
const axios = require('axios');
const logger = require('../utils/logger');
/**
* Скрипт для уведомления Ollama о готовности
* Используется для проверки доступности Ollama и прогрева моделей
*/
const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://ollama:11434';
const MAX_RETRIES = 30;
const RETRY_DELAY = 2000; // 2 секунды
/**
* Проверить доступность Ollama
* @returns {Promise<boolean>}
*/
async function checkOllamaHealth() {
try {
const response = await axios.get(`${OLLAMA_HOST}/api/tags`, {
timeout: 5000
});
return response.status === 200;
} catch (error) {
return false;
}
}
/**
* Дождаться готовности Ollama с retry
* @returns {Promise<boolean>}
*/
async function waitForOllama() {
logger.info('[NotifyOllamaReady] Ожидание готовности Ollama...');
for (let i = 0; i < MAX_RETRIES; i++) {
const isReady = await checkOllamaHealth();
if (isReady) {
logger.info(`[NotifyOllamaReady] ✅ Ollama готов! (попытка ${i + 1}/${MAX_RETRIES})`);
return true;
}
logger.info(`[NotifyOllamaReady] Ollama не готов, повтор ${i + 1}/${MAX_RETRIES}...`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY));
}
logger.error('[NotifyOllamaReady] ❌ Ollama не стал доступен после всех попыток');
return false;
}
/**
* Получить список доступных моделей
* @returns {Promise<Array>}
*/
async function getAvailableModels() {
try {
const response = await axios.get(`${OLLAMA_HOST}/api/tags`, {
timeout: 5000
});
return response.data.models || [];
} catch (error) {
logger.error('[NotifyOllamaReady] Ошибка получения моделей:', error.message);
return [];
}
}
/**
* Прогреть модель (загрузить в память)
* @param {string} modelName - Название модели
* @returns {Promise<boolean>}
*/
async function warmupModel(modelName) {
try {
logger.info(`[NotifyOllamaReady] Прогрев модели: ${modelName}`);
const response = await axios.post(`${OLLAMA_HOST}/api/generate`, {
model: modelName,
prompt: 'Hello',
stream: false
}, {
timeout: 30000
});
if (response.status === 200) {
logger.info(`[NotifyOllamaReady] ✅ Модель ${modelName} прогрета`);
return true;
}
return false;
} catch (error) {
logger.error(`[NotifyOllamaReady] Ошибка прогрева модели ${modelName}:`, error.message);
return false;
}
}
/**
* Основная функция инициализации
*/
async function initialize() {
try {
logger.info('[NotifyOllamaReady] 🚀 Начало инициализации Ollama...');
// Ждем готовности Ollama
const isReady = await waitForOllama();
if (!isReady) {
logger.error('[NotifyOllamaReady] Не удалось дождаться готовности Ollama');
return false;
}
// Получаем список моделей
const models = await getAvailableModels();
logger.info(`[NotifyOllamaReady] Найдено моделей: ${models.length}`);
if (models.length > 0) {
logger.info('[NotifyOllamaReady] Доступные модели:', models.map(m => m.name).join(', '));
// Прогреваем первую модель (опционально)
if (process.env.WARMUP_MODEL === 'true' && models[0]) {
await warmupModel(models[0].name);
}
}
logger.info('[NotifyOllamaReady] ✅ Инициализация завершена');
return true;
} catch (error) {
logger.error('[NotifyOllamaReady] Ошибка инициализации:', error);
return false;
}
}
// Если запущен напрямую как скрипт
if (require.main === module) {
initialize()
.then(success => {
process.exit(success ? 0 : 1);
})
.catch(error => {
logger.error('[NotifyOllamaReady] Критическая ошибка:', error);
process.exit(1);
});
}
module.exports = {
initialize,
waitForOllama,
checkOllamaHealth,
getAvailableModels,
warmupModel
};

View File

@@ -11,8 +11,12 @@
*/
/**
* Конфигурационный сервис для Ollama
* Централизует все настройки и URL для Ollama API
* Конфигурационный сервис для Ollama и AI инфраструктуры
* Централизует все настройки, URL и таймауты для:
* - Ollama API
* - Vector Search
* - AI Cache
* - AI Queue
*
* ВАЖНО: Настройки берутся из таблицы ai_providers_settings (через aiProviderSettingsService)
*/
@@ -140,11 +144,43 @@ async function getEmbeddingModel() {
}
/**
* Получает timeout для запросов к Ollama
* Централизованные таймауты для Ollama и AI сервисов
* @returns {Object} Объект с различными таймаутами
*/
function getTimeouts() {
return {
// Ollama API - таймауты запросов
ollamaChat: 120000, // 120 сек (2 мин) - генерация ответов LLM
ollamaEmbedding: 60000, // 60 сек (1 мин) - генерация embeddings
ollamaHealth: 5000, // 5 сек - health check
ollamaTags: 10000, // 10 сек - список моделей
// Vector Search - таймауты запросов
vectorSearch: 30000, // 30 сек - поиск по векторам
vectorUpsert: 60000, // 60 сек - индексация данных
vectorHealth: 5000, // 5 сек - health check
// AI Cache - TTL (Time To Live) для кэширования
cacheLLM: 24 * 60 * 60 * 1000, // 24 часа - LLM ответы
cacheRAG: 5 * 60 * 1000, // 5 минут - RAG результаты
cacheMax: 1000, // Максимум записей в кэше
// AI Queue - параметры очереди
queueTimeout: 120000, // 120 сек - таймаут задачи в очереди
queueMaxSize: 100, // Максимум задач в очереди
queueInterval: 100, // 100 мс - интервал проверки очереди
// Default для совместимости
default: 120000 // 120 сек
};
}
/**
* Получает timeout для запросов к Ollama (обратная совместимость)
* @returns {number} Timeout в миллисекундах
*/
function getTimeout() {
return 30000; // 30 секунд
return getTimeouts().ollamaChat; // 120 секунд (2 минуты) - для генерации длинных ответов
}
/**
@@ -242,7 +278,8 @@ module.exports = {
getDefaultModel,
getDefaultModelAsync,
getEmbeddingModel,
getTimeout,
getTimeout, // Обратная совместимость (возвращает ollamaChat timeout)
getTimeouts, // ✨ НОВОЕ: Централизованные таймауты для всех сервисов
getConfig,
getConfigAsync,
loadSettingsFromDb,

View File

@@ -13,15 +13,24 @@
const encryptedDb = require('./encryptedDatabaseService');
const vectorSearch = require('./vectorSearchClient');
const { getProviderSettings } = require('./aiProviderSettingsService');
const axios = require('axios');
const ollamaConfig = require('./ollamaConfig');
const aiCache = require('./ai-cache');
const AIQueue = require('./ai-queue');
const logger = require('../utils/logger');
// console.log('[RAG] ragService.js loaded');
// Простой кэш для RAG результатов
const ragCache = new Map();
const RAG_CACHE_TTL = 5 * 60 * 1000; // 5 минут
// Управляет поведением: выполнять ли upsert всех строк на каждый запрос поиска
const UPSERT_ON_QUERY = process.env.RAG_UPSERT_ON_QUERY === 'true';
// Флаги для включения/выключения Queue и Cache
const USE_AI_CACHE = process.env.USE_AI_CACHE !== 'false'; // default: true
const USE_AI_QUEUE = process.env.USE_AI_QUEUE !== 'false'; // default: true
// Создаем экземпляр очереди
const aiQueue = new AIQueue();
async function getTableData(tableId) {
// console.log(`[RAG] getTableData called for tableId: ${tableId}`);
@@ -72,15 +81,17 @@ async function getTableData(tableId) {
return data;
}
async function ragAnswer({ tableId, userQuestion, product = null, threshold = 10, forceReindex = false }) {
async function ragAnswer({ tableId, userQuestion, product = null, threshold = 300, forceReindex = false }) {
// console.log(`[RAG] ragAnswer called: tableId=${tableId}, userQuestion="${userQuestion}"`);
// Проверяем кэш
const cacheKey = `${tableId}:${userQuestion}:${product}`;
const cached = ragCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp) < RAG_CACHE_TTL) {
// console.log(`[RAG] Returning cached result for: ${cacheKey}`);
return cached.result;
// Проверяем кэш (используем ai-cache вместо ragCache)
if (USE_AI_CACHE) {
const cacheKey = aiCache.generateKeyForRAG(tableId, userQuestion, product);
const cached = aiCache.getWithTTL(cacheKey, 'rag');
if (cached) {
console.log(`[RAG] Возврат RAG результата из кэша`);
return cached;
}
}
const data = await getTableData(tableId);
@@ -125,18 +136,18 @@ async function ragAnswer({ tableId, userQuestion, product = null, threshold = 10
let results = [];
if (rowsForUpsert.length > 0 && userQuestion && userQuestion.trim()) {
results = await vectorSearch.search(tableId, userQuestion, 3); // Увеличиваем до 3 результатов для лучшего поиска
// console.log(`[RAG] Search completed, got ${results.length} results`);
console.log(`[RAG] Search completed, got ${results.length} results`);
// Подробное логирование результатов поиска
results.forEach((result, index) => {
// console.log(`[RAG] Search result ${index}:`, {
// row_id: result.row_id,
// score: result.score,
// metadata: result.metadata
// });
console.log(`[RAG] Search result ${index}:`, {
row_id: result.row_id,
score: result.score,
metadata: result.metadata
});
});
} else {
// console.log(`[RAG] No data in table, skipping search`);
console.log(`[RAG] No data in table, skipping search`);
}
// Фильтрация по тегам/продукту
@@ -150,14 +161,14 @@ async function ragAnswer({ tableId, userQuestion, product = null, threshold = 10
}
// Берём ближайший результат с учётом порога (по модулю)
// console.log(`[RAG] Looking for best result with abs(threshold): ${threshold}`);
console.log(`[RAG] Looking for best result with abs(threshold): ${threshold}`);
const best = filtered.reduce((acc, row) => {
if (Math.abs(row.score) <= threshold && (acc === null || Math.abs(row.score) < Math.abs(acc.score))) {
return row;
}
return acc;
}, null);
// console.log(`[RAG] Best result:`, best);
console.log(`[RAG] Best result:`, best);
// Логируем все результаты с их score для диагностики
if (filtered.length > 0) {
@@ -176,11 +187,13 @@ async function ragAnswer({ tableId, userQuestion, product = null, threshold = 10
score: best?.score !== undefined && best?.score !== null ? Number(best.score) : null,
};
// Кэшируем результат
ragCache.set(cacheKey, {
result,
timestamp: Date.now()
});
console.log(`[RAG] Final result:`, result);
// Кэшируем результат (используем ai-cache вместо ragCache)
if (USE_AI_CACHE) {
const cacheKey = aiCache.generateKeyForRAG(tableId, userQuestion, product);
aiCache.setWithType(cacheKey, result, 'rag');
}
return result;
}
@@ -302,7 +315,8 @@ async function generateLLMResponse({
// Добавляем найденную информацию из RAG
if (answer) {
prompt += `\n\nНайденный ответ из базы знаний: ${answer}`;
// Формат: делаем RAG ответ главным, вопрос - контекстом
prompt = `База знаний содержит ответ:\n"${answer}"\n\nВопрос пользователя: ${userQuestion}\n\nДай пользователю этот ответ из базы знаний.`;
}
if (context) {
@@ -330,50 +344,80 @@ async function generateLLMResponse({
}
// --- КОНЕЦ ДОБАВЛЕНИЯ ---
// Используем системный промпт из настроек, если он есть
if (!finalSystemPrompt || !finalSystemPrompt.trim()) {
// Fallback инструкция, если системный промпт не настроен
prompt += `\n\nИнструкция: Используй найденную информацию из базы знаний для ответа. Если найденный ответ подходит к вопросу пользователя, используй его как основу. Если нужно дополнить или уточнить ответ, сделай это. Поддерживай естественную беседу, учитывая предыдущие сообщения. Отвечай на русском языке кратко и по делу. Если пользователь задает уточняющие вопросы, используй контекст предыдущих ответов.`;
}
// Системный промпт полностью настраивается пользователем в /settings/ai/assistant
// RAG ответ уже добавлен в prompt выше
console.log(`[RAG] Сформированный промпт:`, prompt.substring(0, 200) + '...');
// Получаем ответ от AI с учетом истории беседы
let llmResponse;
try {
// Прямое обращение к модели без очереди для снижения задержек при fallback
const messages = [];
if (finalSystemPrompt) {
messages.push({ role: 'system', content: finalSystemPrompt });
// Формируем сообщения для LLM
const messages = [];
if (finalSystemPrompt) {
messages.push({ role: 'system', content: finalSystemPrompt });
}
for (const h of (history || [])) {
if (h && h.content) {
const role = h.role === 'assistant' ? 'assistant' : 'user';
messages.push({ role, content: h.content });
}
for (const h of (history || [])) {
if (h && h.content) {
const role = h.role === 'assistant' ? 'assistant' : 'user';
messages.push({ role, content: h.content });
}
messages.push({ role: 'user', content: prompt });
try {
// ✨ НОВОЕ: Используем очередь (если включена)
if (USE_AI_QUEUE) {
try {
llmResponse = await aiQueue.addTask({
messages,
model
// Приоритет не используется - все запросы обрабатываются FIFO
});
console.log('[RAG] LLM response from queue:', llmResponse ? llmResponse.substring(0, 100) + '...' : 'null');
return llmResponse;
} catch (queueError) {
console.warn('[RAG] Queue error, fallback to direct call:', queueError.message);
// Fallback: если очередь переполнена и есть ответ из RAG - возвращаем его
if (queueError.message.includes('переполнена') && answer) {
console.log('[RAG] Возврат прямого ответа из RAG (очередь переполнена)');
return answer;
}
// Продолжаем к прямому вызову
}
}
messages.push({ role: 'user', content: prompt });
// Облегченные опции для снижения времени ответа на CPU
llmResponse = await aiAssistant.directRequest(messages, finalSystemPrompt, {
temperature: 0.2,
numPredict: 192,
numCtx: 1024,
numThread: 4
});
} catch (error) {
console.error(`[RAG] Error in getResponse:`, error.message);
// Прямой вызов Ollama (если очередь отключена или ошибка очереди)
const ollamaUrl = ollamaConfig.getBaseUrl();
const timeouts = ollamaConfig.getTimeouts();
// Fallback: если очередь перегружена, возвращаем найденный ответ напрямую
if (error.message.includes('очередь перегружена') && answer) {
console.log(`[RAG] Queue overloaded, returning direct answer from RAG`);
const response = await axios.post(`${ollamaUrl}/api/chat`, {
model: model || ollamaConfig.getDefaultModel(),
messages: messages,
stream: false
}, {
timeout: timeouts.ollamaChat
});
llmResponse = response.data.message.content;
} catch (error) {
console.error(`[RAG] Error in Ollama call:`, error.message);
// Финальный fallback - возврат ответа из RAG
if (answer) {
console.log('[RAG] Возврат прямого ответа из RAG (ошибка Ollama)');
return answer;
}
// Другой fallback для других ошибок
return 'Извините, произошла ошибка при генерации ответа.';
}
console.log(`[RAG] LLM response generated:`, llmResponse ? llmResponse.substring(0, 100) + '...' : 'null');
console.log(`[RAG] LLM response generated:`, llmResponse ? (typeof llmResponse === 'string' ? llmResponse.substring(0, 100) + '...' : JSON.stringify(llmResponse).substring(0, 100) + '...') : 'null');
return llmResponse;
} catch (error) {
console.error(`[RAG] Error generating LLM response:`, error);
@@ -423,7 +467,7 @@ async function ragAnswerWithConversation({
tableId,
userQuestion,
product = null,
threshold = 10,
threshold = 300,
history = [],
conversationId = null,
forceReindex = false
@@ -487,9 +531,43 @@ async function ragAnswerWithConversation({
};
}
// ✨ НОВОЕ: Функция для запуска AI Queue Worker
function startQueueWorker() {
if (USE_AI_QUEUE) {
aiQueue.startWorker();
logger.info('[RAG] ✅ AI Queue Worker запущен из ragService');
} else {
logger.info('[RAG] AI Queue отключена (USE_AI_QUEUE=false)');
}
}
// ✨ НОВОЕ: Функция для остановки AI Queue Worker
function stopQueueWorker() {
if (aiQueue && aiQueue.workerInterval) {
aiQueue.stopWorker();
logger.info('[RAG] ⏹️ AI Queue Worker остановлен');
}
}
// ✨ НОВОЕ: Получение статистики
function getQueueStats() {
return aiQueue.getStats();
}
function getCacheStats() {
return {
...aiCache.getStats(),
byType: aiCache.getStatsByType()
};
}
module.exports = {
ragAnswer,
getTableData,
generateLLMResponse,
ragAnswerWithConversation
ragAnswerWithConversation,
startQueueWorker, // ✨ НОВОЕ
stopQueueWorker, // ✨ НОВОЕ
getQueueStats, // ✨ НОВОЕ
getCacheStats // ✨ НОВОЕ
};

View File

@@ -12,7 +12,8 @@
const logger = require('../utils/logger');
const encryptedDb = require('./encryptedDatabaseService');
const guestMessageService = require('./guestMessageService');
const universalGuestService = require('./UniversalGuestService');
const db = require('../db');
/**
* Сервис для работы с сессиями пользователей
@@ -50,8 +51,8 @@ class SessionService {
async linkGuestMessages(session, userId) {
try {
// Получаем все гостевые ID для текущего пользователя из таблицы
const guestIdsResult = await encryptedDb.getData('guest_user_mapping', { user_id: userId });
const userGuestIds = guestIdsResult.map((row) => row.guest_id);
const guestIdsResult = await encryptedDb.getData('unified_guest_mapping', { user_id: userId });
const userGuestIds = guestIdsResult.map((row) => row.identifier_encrypted);
// Собираем все гостевые ID, которые нужно обработать
const guestIdsToProcess = new Set();
@@ -63,10 +64,15 @@ class SessionService {
guestIdsToProcess.add(session.guestId);
// Записываем связь с пользователем в новую таблицу
await encryptedDb.saveData('guest_user_mapping', {
user_id: userId,
guest_id: session.guestId
});
// НЕ используем encryptedDb.saveData, т.к. identifier_encrypted требует ручного шифрования
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
await db.getQuery()(
`INSERT INTO unified_guest_mapping (user_id, identifier_encrypted, channel, created_at)
VALUES ($1, encrypt_text($2, $4), $3, NOW())
ON CONFLICT (identifier_encrypted, channel) DO NOTHING`,
[userId, `web:${session.guestId}`, 'web', encryptionKey]
);
}
}
@@ -77,10 +83,15 @@ class SessionService {
guestIdsToProcess.add(session.previousGuestId);
// Записываем связь с пользователем в новую таблицу
await encryptedDb.saveData('guest_user_mapping', {
user_id: userId,
guest_id: session.previousGuestId
});
// НЕ используем encryptedDb.saveData, т.к. identifier_encrypted требует ручного шифрования
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
await db.getQuery()(
`INSERT INTO unified_guest_mapping (user_id, identifier_encrypted, channel, created_at)
VALUES ($1, encrypt_text($2, $4), $3, NOW())
ON CONFLICT (identifier_encrypted, channel) DO NOTHING`,
[userId, `web:${session.previousGuestId}`, 'web', encryptionKey]
);
}
}
@@ -98,9 +109,10 @@ class SessionService {
`[SessionService] Linking ${guestIdsToProcess.size} guest IDs to user ${userId}: ${Array.from(guestIdsToProcess).join(', ')}`
);
// Обрабатываем сообщения для каждого гостевого ID
// Обрабатываем сообщения для каждого гостевого ID (используем UniversalGuestService)
for (const guestId of guestIdsToProcess) {
await guestMessageService.processGuestMessages(userId, guestId);
const identifier = `web:${guestId}`; // Старые гости всегда из web
await universalGuestService.migrateToUser(identifier, userId);
}
}
@@ -118,7 +130,7 @@ class SessionService {
*/
async isGuestIdProcessed(guestId) {
try {
const result = await encryptedDb.getData('guest_user_mapping', { guest_id: guestId });
const result = await encryptedDb.getData('unified_guest_mapping', { identifier_encrypted: `web:${guestId}` });
return result.length > 0 && result[0].processed === true;
} catch (error) {
@@ -127,7 +139,7 @@ class SessionService {
}
}
// Обертка processGuestMessagesWrapper удалена - используется прямой вызов guestMessageService.processGuestMessages
// Обертка processGuestMessagesWrapper удалена - используется UniversalGuestService.migrateToUser
/**
* Получает сессию из хранилища по ID

View File

@@ -13,6 +13,7 @@
const { Telegraf } = require('telegraf');
const logger = require('../utils/logger');
const encryptedDb = require('./encryptedDatabaseService');
const universalMediaProcessor = require('./UniversalMediaProcessor');
/**
* TelegramBot - обработчик Telegram сообщений
@@ -139,6 +140,29 @@ class TelegramBot {
ctx.reply('Добро пожаловать! Отправьте код подтверждения для аутентификации.');
});
// Обработчик команды /connect - подключение кошелька
this.bot.command('connect', async (ctx) => {
try {
logger.info('[TelegramBot] 📨 Получена команда /connect');
const telegramId = ctx.from.id.toString();
const identityLinkService = require('./IdentityLinkService');
const linkData = await identityLinkService.generateLinkToken('telegram', telegramId);
await ctx.reply(
`🔗 *Подключите Web3 кошелек для полного доступа*\n\n` +
`Перейдите по ссылке:\n${linkData.linkUrl}\n\n` +
`⏱ Ссылка действительна 1 час`,
{ parse_mode: 'Markdown' }
);
logger.info('[TelegramBot] Отправлена ссылка для подключения кошелька');
} catch (error) {
logger.error('[TelegramBot] Ошибка команды /connect:', error);
ctx.reply('Произошла ошибка при создании ссылки. Попробуйте позже.');
}
});
// Обработчик текстовых сообщений
this.bot.on('text', async (ctx) => {
logger.info('[TelegramBot] 📨 Получено текстовое сообщение');
@@ -188,11 +212,11 @@ class TelegramBot {
* @param {Object} ctx - Telegraf context
* @returns {Object} - Стандартизированные данные сообщения
*/
extractMessageData(ctx) {
try {
const telegramId = ctx.from.id.toString();
async extractMessageData(ctx) {
try {
const telegramId = ctx.from.id.toString();
let content = '';
let attachments = [];
let contentData = null;
// Текст сообщения
if (ctx.message.text) {
@@ -201,8 +225,9 @@ class TelegramBot {
content = ctx.message.caption.trim();
}
// Обработка вложений
let fileId, fileName, mimeType, fileSize;
// Обработка медиа через UniversalMediaProcessor
const mediaFiles = [];
let fileId, fileName, mimeType, fileSize, fileData;
if (ctx.message.document) {
fileId = ctx.message.document.file_id;
@@ -227,28 +252,79 @@ class TelegramBot {
fileSize = ctx.message.video.file_size;
}
if (fileId) {
attachments.push({
type: 'telegram_file',
fileId: fileId,
filename: fileName,
mimetype: mimeType,
size: fileSize,
ctx: ctx // Сохраняем контекст для последующей загрузки
});
// Если есть файл, загружаем его и обрабатываем
if (fileId) {
try {
// Скачиваем файл из Telegram
const file = await ctx.telegram.getFile(fileId);
const fileUrl = `https://api.telegram.org/file/bot${this.settings.token}/${file.file_path}`;
// Загружаем данные файла
const response = await fetch(fileUrl);
fileData = Buffer.from(await response.arrayBuffer());
// Обрабатываем через медиа-процессор
const processedFile = await universalMediaProcessor.processFile(
fileData,
fileName,
{
telegramFileId: fileId,
mimeType: mimeType,
originalSize: fileSize
}
);
mediaFiles.push(processedFile);
} catch (fileError) {
logger.error('[TelegramBot] Ошибка загрузки файла:', fileError);
// Fallback: сохраняем как есть
mediaFiles.push({
type: 'telegram_file',
content: `[Файл: ${fileName}]`,
processed: false,
error: fileError.message,
file: {
fileId: fileId,
filename: fileName,
mimetype: mimeType,
size: fileSize
}
});
}
}
// Создаем структурированные данные контента
if (mediaFiles.length > 0) {
contentData = {
text: content,
files: mediaFiles.map(file => ({
data: file.file?.data || null,
filename: file.file?.originalName || file.file?.filename,
metadata: {
type: file.type,
processed: file.processed,
telegramFileId: file.file?.telegramFileId,
mimeType: file.file?.mimetype,
originalSize: file.file?.size
}
}))
};
}
return {
channel: 'telegram',
identifier: telegramId,
content: content,
attachments: attachments,
contentData: contentData,
attachments: mediaFiles, // Обратная совместимость
metadata: {
telegramUsername: ctx.from.username,
telegramFirstName: ctx.from.first_name,
telegramLastName: ctx.from.last_name,
messageId: ctx.message.message_id,
chatId: ctx.chat.id
chatId: ctx.chat.id,
hasMedia: mediaFiles.length > 0,
mediaTypes: mediaFiles.map(f => f.type)
}
};
} catch (error) {
@@ -283,7 +359,7 @@ class TelegramBot {
await ctx.replyWithChatAction('typing');
// Извлекаем данные из сообщения
const messageData = this.extractMessageData(ctx);
const messageData = await this.extractMessageData(ctx);
logger.info(`[TelegramBot] Обработка сообщения от пользователя: ${messageData.identifier}`);

View File

@@ -15,59 +15,104 @@ const logger = require('../utils/logger');
const encryptionUtils = require('../utils/encryptionUtils');
const aiAssistant = require('./ai-assistant');
const conversationService = require('./conversationService');
const adminLogicService = require('./adminLogicService');
const universalGuestService = require('./UniversalGuestService');
const identityService = require('./identity-service');
const { broadcastMessagesUpdate } = require('../wsHub');
/**
* Унифицированный процессор сообщений для всех каналов
* Обрабатывает сообщения из web, telegram, email
* НОВАЯ ВЕРСИЯ с поддержкой универсальной гостевой системы
*/
/**
* Обработать сообщение от пользователя
* Обработать сообщение (гость или пользователь)
* @param {Object} messageData - Данные сообщения
* @param {number} messageData.userId - ID пользователя
* @param {string} messageData.identifier - Универсальный идентификатор
* @param {string} messageData.content - Текст сообщения
* @param {string} messageData.channel - Канал (web/telegram/email)
* @param {Array} messageData.attachments - Вложения
* @param {number} messageData.conversationId - ID беседы (опционально)
* @param {number} messageData.recipientId - ID получателя (для админов)
* @returns {Promise<Object>}
*/
async function processMessage(messageData) {
try {
const {
userId,
identifier,
content,
channel = 'web',
attachments = [],
conversationId: inputConversationId,
guestId
recipientId,
metadata = {}
} = messageData;
logger.info('[UnifiedMessageProcessor] Обработка сообщения:', {
userId,
identifier,
channel,
contentLength: content?.length,
hasAttachments: attachments.length > 0
});
const encryptionKey = encryptionUtils.getEncryptionKey();
// 1. Определяем: гость или пользователь?
const isGuestIdentifier = await checkIfGuest(identifier);
// 1. Получаем или создаем беседу
if (isGuestIdentifier) {
// ГОСТЬ: обработка через UniversalGuestService
logger.info('[UnifiedMessageProcessor] Обработка гостевого сообщения');
return await universalGuestService.processMessage({
identifier,
content,
channel,
metadata,
...messageData
});
}
// 2. ПОЛЬЗОВАТЕЛЬ: ищем user_id
const [provider, providerId] = identifier.split(':');
const user = await identityService.findUserByIdentity(provider, providerId);
if (!user) {
throw new Error(`User not found for identifier: ${identifier}`);
}
const userId = user.id;
const userRole = user.role || 'user';
logger.info('[UnifiedMessageProcessor] Обработка сообщения пользователя:', {
userId,
role: userRole
});
// 3. Проверяем: админ или обычный пользователь?
const isAdmin = userRole === 'editor' || userRole === 'readonly';
// 4. Определяем нужно ли генерировать AI ответ
const shouldGenerateAi = adminLogicService.shouldGenerateAiReply({
senderType: isAdmin ? 'admin' : 'user',
userId: userId,
recipientId: recipientId || userId,
channel: channel
});
logger.info('[UnifiedMessageProcessor] Генерация AI:', { shouldGenerateAi, isAdmin });
// 5. Получаем или создаем беседу
let conversation;
if (inputConversationId) {
conversation = await conversationService.getConversationById(inputConversationId);
}
if (!conversation && userId) {
if (!conversation) {
conversation = await conversationService.getOrCreateConversation(userId, 'Беседа');
}
const conversationId = conversation?.id || null;
const conversationId = conversation.id;
// 2. Сохраняем входящее сообщение пользователя
let userMessage;
// Обработка вложений
// 6. Обработка вложений
let attachment_filename = null;
let attachment_mimetype = null;
let attachment_size = null;
@@ -81,57 +126,62 @@ async function processMessage(messageData) {
attachment_data = firstAttachment.data;
}
if (userId) {
const { rows } = await db.getQuery()(
`INSERT INTO messages (
user_id,
conversation_id,
sender_type_encrypted,
content_encrypted,
channel_encrypted,
role_encrypted,
direction_encrypted,
attachment_filename_encrypted,
attachment_mimetype_encrypted,
attachment_size,
attachment_data,
created_at
) VALUES (
$1, $2,
encrypt_text($3, $12),
encrypt_text($4, $12),
encrypt_text($5, $12),
encrypt_text($6, $12),
encrypt_text($7, $12),
encrypt_text($8, $12),
encrypt_text($9, $12),
$10, $11,
NOW()
) RETURNING id`,
[
userId,
conversationId,
'user',
content,
channel,
'user',
'incoming',
attachment_filename,
attachment_mimetype,
attachment_size,
attachment_data,
encryptionKey
]
);
userMessage = rows[0];
logger.info('[UnifiedMessageProcessor] Сообщение пользователя сохранено:', userMessage.id);
}
// 7. Сохраняем входящее сообщение пользователя
const encryptionKey = encryptionUtils.getEncryptionKey();
// 3. Получаем историю беседы для контекста
let conversationHistory = [];
if (conversationId && userId) {
const { rows } = await db.getQuery()(
const { rows } = await db.getQuery()(
`INSERT INTO messages (
user_id,
conversation_id,
sender_type_encrypted,
content_encrypted,
channel_encrypted,
role_encrypted,
direction_encrypted,
attachment_filename_encrypted,
attachment_mimetype_encrypted,
attachment_size,
attachment_data,
message_type,
created_at
) VALUES (
$1, $2,
encrypt_text($3, $13),
encrypt_text($4, $13),
encrypt_text($5, $13),
encrypt_text($6, $13),
encrypt_text($7, $13),
encrypt_text($8, $13),
encrypt_text($9, $13),
$10, $11, $12,
NOW()
) RETURNING id`,
[
userId,
conversationId,
isAdmin ? 'admin' : 'user',
content,
channel,
'user',
'incoming',
attachment_filename,
attachment_mimetype,
attachment_size,
attachment_data,
'user_chat', // message_type
encryptionKey
]
);
const userMessageId = rows[0].id;
logger.info('[UnifiedMessageProcessor] Сообщение пользователя сохранено:', userMessageId);
// 8. Генерируем AI ответ (если нужно)
let aiResponse = null;
if (shouldGenerateAi) {
// Загружаем историю беседы
const { rows: historyRows } = await db.getQuery()(
`SELECT
decrypt_text(role_encrypted, $2) as role,
decrypt_text(content_encrypted, $2) as content,
@@ -143,98 +193,91 @@ async function processMessage(messageData) {
[conversationId, encryptionKey, userId]
);
conversationHistory = rows.map(row => ({
const conversationHistory = historyRows.map(row => ({
role: row.role,
content: row.content
}));
}
// 4. Генерируем AI ответ
logger.info('[UnifiedMessageProcessor] Генерация AI ответа...');
const aiResponse = await aiAssistant.generateResponse({
channel,
messageId: userMessage?.id || `guest_${Date.now()}`,
userId: userId || guestId,
userQuestion: content,
conversationHistory,
conversationId,
metadata: {
hasAttachments: attachments.length > 0,
channel
}
});
if (!aiResponse || !aiResponse.success) {
logger.warn('[UnifiedMessageProcessor] AI не вернул ответ или ошибка:', aiResponse?.reason);
logger.info('[UnifiedMessageProcessor] Генерация AI ответа...');
// Возвращаем результат без AI ответа
return {
success: true,
userMessageId: userMessage?.id,
aiResponse = await aiAssistant.generateResponse({
channel,
messageId: userMessageId,
userId: userId,
userQuestion: content,
conversationHistory,
conversationId,
noAiResponse: true,
reason: aiResponse?.reason
};
}
// 5. Сохраняем ответ AI
if (userId && aiResponse.response) {
const { rows: aiMessageRows } = await db.getQuery()(
`INSERT INTO messages (
user_id,
conversation_id,
sender_type_encrypted,
content_encrypted,
channel_encrypted,
role_encrypted,
direction_encrypted,
created_at
) VALUES (
$1, $2,
encrypt_text($3, $8),
encrypt_text($4, $8),
encrypt_text($5, $8),
encrypt_text($6, $8),
encrypt_text($7, $8),
NOW()
) RETURNING id`,
[
userId,
conversationId,
'assistant',
aiResponse.response,
metadata: {
hasAttachments: attachments.length > 0,
channel,
'assistant',
'outgoing',
encryptionKey
]
);
isAdmin
}
});
logger.info('[UnifiedMessageProcessor] Ответ AI сохранен:', aiMessageRows[0].id);
if (aiResponse && aiResponse.success && aiResponse.response) {
// Сохраняем ответ AI
const { rows: aiMessageRows } = await db.getQuery()(
`INSERT INTO messages (
user_id,
conversation_id,
sender_type_encrypted,
content_encrypted,
channel_encrypted,
role_encrypted,
direction_encrypted,
message_type,
created_at
) VALUES (
$1, $2,
encrypt_text($3, $9),
encrypt_text($4, $9),
encrypt_text($5, $9),
encrypt_text($6, $9),
encrypt_text($7, $9),
$8,
NOW()
) RETURNING id`,
[
userId,
conversationId,
'assistant',
aiResponse.response,
channel,
'assistant',
'outgoing',
'user_chat',
encryptionKey
]
);
// 6. Обновляем время беседы
if (conversationId) {
await conversationService.touchConversation(conversationId);
}
// 7. Отправляем уведомление через WebSocket
try {
broadcastMessagesUpdate(userId);
} catch (wsError) {
logger.warn('[UnifiedMessageProcessor] Ошибка отправки WebSocket:', wsError.message);
logger.info('[UnifiedMessageProcessor] Ответ AI сохранен:', aiMessageRows[0].id);
} else {
logger.warn('[UnifiedMessageProcessor] AI не вернул ответ:', aiResponse?.reason);
}
} else {
logger.info('[UnifiedMessageProcessor] AI ответ не требуется (админ → пользователь)');
}
// 8. Возвращаем результат
// 9. Обновляем время беседы
await conversationService.touchConversation(conversationId);
// 10. Отправляем уведомление через WebSocket
try {
broadcastMessagesUpdate(userId);
} catch (wsError) {
logger.warn('[UnifiedMessageProcessor] Ошибка отправки WebSocket:', wsError.message);
}
// 11. Возвращаем результат
return {
success: true,
userMessageId: userMessage?.id,
userMessageId,
conversationId,
aiResponse: {
aiResponse: aiResponse && aiResponse.success ? {
response: aiResponse.response,
ragData: aiResponse.ragData
}
} : null,
noAiResponse: !shouldGenerateAi
};
} catch (error) {
@@ -244,50 +287,71 @@ async function processMessage(messageData) {
}
/**
* Проверить, является ли идентификатор гостевым
* @param {string} identifier
* @returns {Promise<boolean>}
*/
async function checkIfGuest(identifier) {
try {
if (!identifier || typeof identifier !== 'string') {
return true; // По умолчанию гость
}
// Разбираем идентификатор
const [provider, providerId] = identifier.split(':');
// Проверяем что это не web:guest_*
if (provider === 'web' && providerId.startsWith('guest_')) {
return true; // Это web гость
}
// Проверяем есть ли пользователь с wallet
const user = await identityService.findUserByIdentity(provider, providerId);
if (!user) {
return true; // Пользователь не найден - это гость
}
// Проверяем есть ли у пользователя wallet
const walletIdentity = await identityService.findIdentity(user.id, 'wallet');
if (!walletIdentity) {
// Нет кошелька - это временный пользователь, считаем гостем
return true;
}
// Есть кошелек - полноценный пользователь
return false;
} catch (error) {
logger.error('[UnifiedMessageProcessor] Ошибка проверки гостя:', error);
return true; // В случае ошибки считаем гостем для безопасности
}
}
/**
* DEPRECATED: Используйте processMessage()
* Обработать сообщение от гостя
* @param {Object} messageData - Данные сообщения
* @returns {Promise<Object>}
*/
async function processGuestMessage(messageData) {
try {
const guestService = require('./guestService');
// Создаем guest ID если нет
const guestId = messageData.guestId || guestService.createGuestId();
// Сохраняем гостевое сообщение
await guestService.saveGuestMessage({
guestId,
content: messageData.content,
channel: messageData.channel || 'web'
});
// Генерируем AI ответ для гостя (без сохранения в messages)
const aiResponse = await aiAssistant.generateResponse({
channel: messageData.channel || 'web',
messageId: `guest_${guestId}_${Date.now()}`,
userId: guestId,
userQuestion: messageData.content,
conversationHistory: [],
metadata: { isGuest: true }
});
return {
success: true,
guestId,
aiResponse: aiResponse?.success ? {
response: aiResponse.response
} : null
};
} catch (error) {
logger.error('[UnifiedMessageProcessor] Ошибка обработки гостевого сообщения:', error);
throw error;
}
logger.warn('[UnifiedMessageProcessor] processGuestMessage() устарел, используйте processMessage()');
// Для обратной совместимости
const { guestId, content, channel } = messageData;
const identifier = universalGuestService.createIdentifier(channel || 'web', guestId);
return processMessage({
identifier,
content,
channel: channel || 'web',
...messageData
});
}
module.exports = {
processMessage,
processGuestMessage
processGuestMessage, // deprecated
checkIfGuest
};

View File

@@ -73,13 +73,13 @@ async function deleteUserById(userId) {
);
console.log('[DELETE] Удалено verification_codes:', resCodes.rows.length);
// 7. Удаляем guest_user_mapping
console.log('[DELETE] Начинаем удаление guest_user_mapping для userId:', userId);
// 7. Удаляем unified_guest_mapping
console.log('[DELETE] Начинаем удаление unified_guest_mapping для userId:', userId);
const resGuestMapping = await db.getQuery()(
'DELETE FROM guest_user_mapping WHERE user_id = $1 RETURNING id',
'DELETE FROM unified_guest_mapping WHERE user_id = $1 RETURNING id',
[userId]
);
console.log('[DELETE] Удалено guest_user_mapping:', resGuestMapping.rows.length);
console.log('[DELETE] Удалено unified_guest_mapping:', resGuestMapping.rows.length);
// 8. Удаляем user_tag_links
console.log('[DELETE] Начинаем удаление user_tag_links для userId:', userId);

View File

@@ -12,8 +12,10 @@
const axios = require('axios');
const logger = require('../utils/logger');
const ollamaConfig = require('./ollamaConfig');
const VECTOR_SEARCH_URL = process.env.VECTOR_SEARCH_URL || 'http://vector-search:8001';
const TIMEOUTS = ollamaConfig.getTimeouts();
async function upsert(tableId, rows) {
logger.info(`[VectorSearch] upsert: tableId=${tableId}, rows=${rows.length}`);
@@ -25,6 +27,8 @@ async function upsert(tableId, rows) {
text: r.text,
metadata: r.metadata || {}
}))
}, {
timeout: TIMEOUTS.vectorUpsert // Централизованный таймаут для индексации
});
logger.info(`[VectorSearch] upsert result:`, res.data);
return res.data;
@@ -41,6 +45,8 @@ async function search(tableId, query, topK = 3) {
table_id: String(tableId),
query,
top_k: topK
}, {
timeout: TIMEOUTS.vectorSearch // Централизованный таймаут для поиска
});
logger.info(`[VectorSearch] search result:`, res.data.results);
return res.data.results;
@@ -87,7 +93,7 @@ async function rebuild(tableId, rows) {
async function health() {
logger.info(`[VectorSearch] health check`);
try {
const res = await axios.get(`${VECTOR_SEARCH_URL}/health`, { timeout: 5000 });
const res = await axios.get(`${VECTOR_SEARCH_URL}/health`, { timeout: TIMEOUTS.vectorHealth });
logger.info(`[VectorSearch] health result:`, res.data);
return {
status: 'ok',

View File

@@ -12,6 +12,7 @@
const logger = require('../utils/logger');
const unifiedMessageProcessor = require('./unifiedMessageProcessor');
const universalMediaProcessor = require('./UniversalMediaProcessor');
/**
* WebBot - обработчик веб-чата
@@ -47,7 +48,7 @@ class WebBot {
}
/**
* Обработка сообщения из веб-чата
* Обработка сообщения из веб-чата с поддержкой медиа
* @param {Object} messageData - Данные сообщения
* @returns {Promise<Object>}
*/
@@ -60,6 +61,59 @@ class WebBot {
// Устанавливаем канал
messageData.channel = 'web';
// Если есть вложения, обрабатываем их через медиа-процессор
if (messageData.attachments && messageData.attachments.length > 0) {
const processedFiles = [];
for (const attachment of messageData.attachments) {
try {
const processedFile = await universalMediaProcessor.processFile(
attachment.data,
attachment.filename,
{
webUpload: true,
originalSize: attachment.size,
mimeType: attachment.mimetype
}
);
processedFiles.push(processedFile);
} catch (fileError) {
logger.error('[WebBot] Ошибка обработки файла:', fileError);
// Fallback: сохраняем как есть
processedFiles.push({
type: 'document',
content: `[Файл: ${attachment.filename}]`,
processed: false,
error: fileError.message,
file: attachment
});
}
}
// Создаем структурированные данные контента
messageData.contentData = {
text: messageData.content,
files: processedFiles.map(file => ({
data: file.file?.data || file.file?.buffer,
filename: file.file?.originalName || file.file?.filename,
metadata: {
type: file.type,
processed: file.processed,
webUpload: true
}
}))
};
// Добавляем информацию о медиа в метаданные
messageData.metadata = {
...messageData.metadata,
hasMedia: processedFiles.length > 0,
mediaTypes: processedFiles.map(f => f.type),
processedFiles: processedFiles
};
}
// Обрабатываем через unified processor
return await unifiedMessageProcessor.processMessage(messageData);