571 lines
18 KiB
JavaScript
571 lines
18 KiB
JavaScript
/**
|
||
* 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();
|
||
|