Files
DLE/backend/services/unifiedMessageProcessor.js
2025-11-06 16:24:50 +03:00

567 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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/VC-HB3-Accelerator
*/
const db = require('../db');
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');
/**
* Определить тип сообщения по контексту
* @param {number|null} recipientId - ID получателя
* @param {number} userId - ID отправителя
* @param {boolean} isAdminSender - Является ли отправитель админом
* @returns {string} - Тип сообщения: 'user_chat', 'admin_chat', 'public'
*/
function determineMessageType(recipientId, userId, isAdminSender) {
// 1. Личный чат с ИИ (recipientId не указан или равен userId)
if (!recipientId || recipientId === userId) {
return 'user_chat';
}
// 2. Приватное сообщение к редактору (recipientId = 1)
if (recipientId === 1) {
return 'admin_chat';
}
// 3. Публичное сообщение между пользователями
return 'public';
}
/**
* Определить тип беседы
* @param {string} messageType - Тип сообщения
* @param {number|null} recipientId - ID получателя
* @param {number} userId - ID отправителя
* @returns {string} - Тип беседы: 'user_chat', 'private', 'public'
*/
function determineConversationType(messageType, recipientId, userId) {
switch (messageType) {
case 'user_chat':
return 'user_chat'; // Личная беседа с ИИ
case 'admin_chat':
return 'private'; // Приватная беседа с редактором
case 'public':
return 'public_chat'; // Публичная беседа между пользователями
default:
return 'user_chat';
}
}
/**
* Определить, нужно ли генерировать AI ответ
* @param {string} messageType - Тип сообщения
* @param {number|null} recipientId - ID получателя
* @param {number} userId - ID отправителя
* @returns {boolean}
*/
function shouldGenerateAiReply(messageType, recipientId, userId) {
// ИИ отвечает только в личных чатах
return messageType === 'user_chat';
}
const { broadcastMessagesUpdate } = require('../wsHub');
// НОВАЯ СИСТЕМА РОЛЕЙ: используем shared/permissions.js
const { hasPermission, ROLES, PERMISSIONS } = require('/app/shared/permissions');
/**
* Унифицированный процессор сообщений для всех каналов
* Обрабатывает сообщения из web, telegram, email
* НОВАЯ ВЕРСИЯ с поддержкой универсальной гостевой системы
*/
/**
* Обработать сообщение (гость или пользователь)
* @param {Object} messageData - Данные сообщения
* @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 {
identifier,
content,
channel = 'web',
attachments = [],
conversationId: inputConversationId,
recipientId,
metadata = {}
} = messageData;
logger.info('[UnifiedMessageProcessor] Обработка сообщения:', {
identifier,
channel,
contentLength: content?.length,
hasAttachments: attachments.length > 0
});
// 1. Определяем: гость или пользователь?
const isGuestIdentifier = await checkIfGuest(identifier);
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
});
// НОВАЯ СИСТЕМА РОЛЕЙ: определяем права через новую систему
const isAdmin = userRole === ROLES.EDITOR || userRole === ROLES.READONLY;
// 4. Определяем тип сообщения по контексту
const messageType = determineMessageType(recipientId, userId, isAdmin);
// 5. Определяем нужно ли генерировать AI ответ
let shouldGenerateAi = shouldGenerateAiReply(messageType, recipientId, userId);
logger.info('[UnifiedMessageProcessor] Генерация AI:', { shouldGenerateAi, userRole, isAdmin });
// 6. Получаем или создаем беседу с правильным типом
let conversation;
const conversationType = determineConversationType(messageType, recipientId, userId);
if (inputConversationId) {
conversation = await conversationService.getConversationById(inputConversationId);
}
if (!conversation) {
// Для публичных сообщений создаем беседу между пользователями
if (messageType === 'public') {
conversation = await conversationService.getOrCreatePublicConversation(userId, recipientId);
} else {
// Для личных и админских чатов используем стандартную логику
conversation = await conversationService.getOrCreateConversation(userId, 'Беседа');
}
// Обновляем тип беседы в БД, если он не соответствует
if (conversation.conversation_type !== conversationType) {
await db.getQuery()(
'UPDATE conversations SET conversation_type = $1 WHERE id = $2',
[conversationType, conversation.id]
);
conversation.conversation_type = conversationType;
}
}
const conversationId = conversation.id;
// Получаем ключ шифрования (будет использоваться далее)
const encryptionKey = encryptionUtils.getEncryptionKey();
// 5.5. Проверяем, нужно ли автоматически подписать согласие при ответе
// Ищем последнее сообщение от ассистента или системное сообщение с согласием
const consentService = require('./consentService');
const { rows: lastMessages } = await db.getQuery()(
`SELECT
decrypt_text(role_encrypted, $2) as role,
decrypt_text(content_encrypted, $2) as content,
message_type
FROM messages
WHERE conversation_id = $1
AND user_id = $3
AND (
decrypt_text(role_encrypted, $2) = 'assistant'
OR message_type = 'system_consent'
)
ORDER BY created_at DESC
LIMIT 1`,
[conversationId, encryptionKey, userId]
);
// Если последнее сообщение было от ассистента, проверяем наличие системного сообщения о согласиях
if (lastMessages.length > 0) {
// Проверяем согласия пользователя
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
const consentCheck = await consentService.checkConsents({
userId,
walletAddress: walletIdentity?.provider_id || null
});
// Если согласия нужны, но пользователь отвечает на сообщение, автоматически подписываем
if (consentCheck.needsConsent) {
logger.info(`[UnifiedMessageProcessor] Автоматическое подписание согласий при ответе пользователя ${userId}`);
// Получаем документы для подписания
const consentDocuments = await consentService.getConsentDocuments(consentCheck.missingConsents);
const documentIds = consentDocuments.map(doc => doc.id);
const consentTypes = consentDocuments.map(doc => doc.consentType).filter(type => type);
// Автоматически подписываем согласие
if (documentIds.length > 0 && consentTypes.length > 0) {
try {
// Используем проверку существования вместо ON CONFLICT (т.к. может не быть уникального ограничения)
for (let i = 0; i < documentIds.length; i++) {
const docId = documentIds[i];
const docTitle = consentDocuments.find(d => d.id === docId)?.title || '';
const consentType = consentTypes[i];
// Проверяем, есть ли уже согласие
const existing = await db.getQuery()(
`SELECT id FROM consent_logs
WHERE user_id = $1 AND consent_type = $2 AND document_id = $3 AND status = 'granted'`,
[userId, consentType, docId]
);
if (existing.rows.length > 0) {
// Обновляем существующее
await db.getQuery()(
`UPDATE consent_logs
SET signed_at = NOW(), revoked_at = NULL, updated_at = NOW()
WHERE id = $1`,
[existing.rows[0].id]
);
} else {
// Создаем новое
await db.getQuery()(
`INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, 'granted', NOW(), 'web', NOW(), NOW())`,
[userId, walletIdentity?.provider_id || null, docId, docTitle, consentType]
);
}
}
logger.info(`[UnifiedMessageProcessor] Согласия автоматически подписаны для пользователя ${userId}`);
} catch (consentError) {
logger.error(`[UnifiedMessageProcessor] Ошибка автоматического подписания согласий:`, consentError);
// Не блокируем обработку сообщения при ошибке подписания
}
}
}
}
// 6. Обработка вложений
let attachment_filename = null;
let attachment_mimetype = null;
let attachment_size = null;
let attachment_data = null;
if (attachments && attachments.length > 0) {
const firstAttachment = attachments[0];
attachment_filename = firstAttachment.filename;
attachment_mimetype = firstAttachment.mimetype;
attachment_size = firstAttachment.size;
attachment_data = firstAttachment.data;
}
// 7. Сохраняем входящее сообщение пользователя
// encryptionKey уже объявлен выше
const { rows } = await db.getQuery()(
`INSERT INTO messages (
conversation_id,
sender_id,
sender_type_encrypted,
content_encrypted,
channel_encrypted,
role_encrypted,
direction_encrypted,
attachment_filename,
attachment_mimetype,
attachment_size,
attachment_data,
message_type,
user_id,
role,
direction,
created_at
) VALUES (
$1, $2,
encrypt_text($3, $16),
encrypt_text($4, $16),
encrypt_text($5, $16),
encrypt_text($6, $16),
encrypt_text($7, $16),
$8,
$9,
$10, $11, $12,
$13, $14, $15,
NOW()
) RETURNING id`,
[
conversationId,
userId, // sender_id
isAdmin ? 'editor' : 'user',
content,
channel,
'user',
'incoming',
attachment_filename,
attachment_mimetype,
attachment_size,
attachment_data,
messageType, // message_type
recipientId || userId, // user_id (получатель для публичных сообщений)
'user', // role (незашифрованное)
'incoming', // direction (незашифрованное)
encryptionKey
]
);
const userMessageId = rows[0].id;
logger.info('[UnifiedMessageProcessor] Сообщение пользователя сохранено:', userMessageId);
// 8. Генерируем AI ответ (если нужно)
let aiResponse = null;
// Инициализируем finalAiResponse для использования в результатах (должен быть доступен везде)
let finalAiResponse = null;
let aiResponseDisabled = false;
if (shouldGenerateAi) {
// Загружаем историю беседы
const { rows: historyRows } = await db.getQuery()(
`SELECT
decrypt_text(role_encrypted, $2) as role,
decrypt_text(content_encrypted, $2) as content,
created_at
FROM messages
WHERE conversation_id = $1 AND user_id = $3
ORDER BY created_at ASC
LIMIT 20`,
[conversationId, encryptionKey, userId]
);
const conversationHistory = historyRows.map(row => ({
role: row.role,
content: row.content
}));
logger.info('[UnifiedMessageProcessor] Генерация AI ответа...');
aiResponse = await aiAssistant.generateResponse({
channel,
messageId: userMessageId,
userId: userId,
userQuestion: content,
conversationHistory,
conversationId,
metadata: {
hasAttachments: attachments.length > 0,
channel,
isAdmin
}
});
if (aiResponse && aiResponse.success && aiResponse.response) {
// Проверяем согласия и добавляем системное сообщение к ответу ИИ
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
const consentSystemMessage = await consentService.getConsentSystemMessage({
userId,
walletAddress: walletIdentity?.provider_id || null,
channel: channel === 'web' ? 'web' : channel,
baseUrl: process.env.BASE_URL || 'http://localhost:9000'
});
// Формируем финальный ответ ИИ с системным сообщением, если нужно
finalAiResponse = aiResponse.response;
if (consentSystemMessage && consentSystemMessage.consentRequired) {
// Добавляем системное сообщение к ответу ИИ
finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`;
// Сохраняем информацию о согласиях в метаданные ответа
aiResponse.consentInfo = {
consentRequired: true,
missingConsents: consentSystemMessage.missingConsents,
consentDocuments: consentSystemMessage.consentDocuments,
autoConsentOnReply: consentSystemMessage.autoConsentOnReply
};
}
// Сохраняем ответ AI с добавленным системным сообщением
const { rows: aiMessageRows } = await db.getQuery()(
`INSERT INTO messages (
conversation_id,
sender_id,
sender_type_encrypted,
content_encrypted,
channel_encrypted,
role_encrypted,
direction_encrypted,
message_type,
user_id,
role,
direction,
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),
$8, $9, $10, $11,
NOW()
) RETURNING id`,
[
conversationId,
userId, // sender_id
'assistant',
finalAiResponse,
channel,
'assistant',
'outgoing',
messageType,
userId, // user_id
'assistant', // role (незашифрованное)
'outgoing', // direction (незашифрованное)
encryptionKey
]
);
logger.info('[UnifiedMessageProcessor] Ответ AI сохранен:', aiMessageRows[0].id);
} else if (aiResponse && aiResponse.disabled) {
aiResponseDisabled = true;
logger.info('[UnifiedMessageProcessor] AI ассистент отключен для текущего канала — ответ не генерируется.');
} else {
logger.warn('[UnifiedMessageProcessor] AI не вернул ответ:', aiResponse?.reason);
}
} else {
logger.info('[UnifiedMessageProcessor] AI ответ не требуется (админ → пользователь)');
}
// 9. Обновляем время беседы
await conversationService.touchConversation(conversationId);
// 10. Отправляем уведомление через WebSocket
try {
broadcastMessagesUpdate(userId);
} catch (wsError) {
logger.warn('[UnifiedMessageProcessor] Ошибка отправки WebSocket:', wsError.message);
}
// 11. Возвращаем результат
const result = {
success: true,
userMessageId,
conversationId,
aiResponse: aiResponse && aiResponse.success ? {
response: finalAiResponse || (aiResponse?.response || ''),
ragData: aiResponse.ragData
} : null,
noAiResponse: !shouldGenerateAi || aiResponseDisabled,
assistantDisabled: aiResponseDisabled
};
// Если есть информация о согласиях, добавляем её в результат
if (aiResponse && aiResponse.success && aiResponse.consentInfo) {
result.consentRequired = aiResponse.consentInfo.consentRequired;
result.missingConsents = aiResponse.consentInfo.missingConsents;
result.consentDocuments = aiResponse.consentInfo.consentDocuments;
result.autoConsentOnReply = aiResponse.consentInfo.autoConsentOnReply;
}
return result;
} catch (error) {
logger.error('[UnifiedMessageProcessor] Ошибка обработки сообщения:', error);
throw error;
}
}
/**
* Проверить, является ли идентификатор гостевым
* @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) {
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, // deprecated
checkIfGuest
};