/** * 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} */ 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} */ 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} - [{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} */ 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} */ 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} */ 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();