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

This commit is contained in:
2025-11-01 17:25:49 +03:00
parent 772d4cff54
commit e28848146d
19 changed files with 1680 additions and 67 deletions

View File

@@ -108,6 +108,7 @@ const dleAnalyticsRoutes = require('./routes/dleAnalytics'); // Аналитик
const compileRoutes = require('./routes/compile'); // Компиляция контрактов const compileRoutes = require('./routes/compile'); // Компиляция контрактов
const { router: dleHistoryRoutes } = require('./routes/dleHistory'); // Расширенная история const { router: dleHistoryRoutes } = require('./routes/dleHistory'); // Расширенная история
const systemRoutes = require('./routes/system'); // Добавляем импорт маршрутов системного мониторинга const systemRoutes = require('./routes/system'); // Добавляем импорт маршрутов системного мониторинга
const consentRoutes = require('./routes/consent'); // Добавляем импорт маршрутов согласий
const app = express(); const app = express();
@@ -283,6 +284,7 @@ app.use('/api/identities', identitiesRoutes);
app.use('/api/rag', ragRoutes); // Подключаем роут app.use('/api/rag', ragRoutes); // Подключаем роут
app.use('/api/monitoring', monitoringRoutes); app.use('/api/monitoring', monitoringRoutes);
app.use('/api/pages', pagesRoutes); // Подключаем роутер страниц app.use('/api/pages', pagesRoutes); // Подключаем роутер страниц
app.use('/api/consent', consentRoutes); // Добавляем маршрут согласий
app.use('/api/system', systemRoutes); // Добавляем маршрут системного мониторинга app.use('/api/system', systemRoutes); // Добавляем маршрут системного мониторинга
app.use('/api/uploads', uploadsRoutes); // Загрузка файлов (логотипы) app.use('/api/uploads', uploadsRoutes); // Загрузка файлов (логотипы)
app.use('/api/ens', ensRoutes); // ENS utilities app.use('/api/ens', ensRoutes); // ENS utilities

View File

@@ -25,6 +25,9 @@ const emailAuth = require('../services/emailAuth');
const verificationService = require('../services/verification-service'); const verificationService = require('../services/verification-service');
const identityService = require('../services/identity-service'); const identityService = require('../services/identity-service');
const sessionService = require('../services/session-service'); const sessionService = require('../services/session-service');
// Используем централизованный сервис для работы с согласиями
const consentService = require('../services/consentService');
const { DOCUMENT_CONSENT_MAP } = consentService;
// Создаем лимитер для попыток аутентификации // Создаем лимитер для попыток аутентификации
const authLimiter = rateLimit({ const authLimiter = rateLimit({
@@ -173,30 +176,60 @@ router.post('/verify', async (req, res) => {
const origin = req.get('origin') || 'http://localhost:5173'; const origin = req.get('origin') || 'http://localhost:5173';
const domain = new URL(origin).host; // Извлекаем домен из origin const domain = new URL(origin).host; // Извлекаем домен из origin
// Получаем список документов для подписания и добавляем их в resources
const documentTitles = Object.keys(DOCUMENT_CONSENT_MAP);
const tableName = 'admin_pages_simple';
const tableExistsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`, [tableName]
);
let resources = [`${origin}/api/auth/verify`];
if (tableExistsRes.rows[0].exists) {
const { rows: documents } = await db.getQuery()(`
SELECT id FROM ${tableName}
WHERE status = 'published'
AND visibility = 'public'
AND title = ANY($1)
`, [documentTitles]);
// Добавляем ссылки на документы в resources
documents.forEach(doc => {
resources.push(`${origin}/content/published/${doc.id}`);
});
}
// Сортируем resources для консистентности (должно совпадать с фронтендом)
resources = resources.sort();
// Используем issuedAt из запроса, если он есть, иначе создаем новый
const messageIssuedAt = issuedAt || new Date().toISOString();
const { SiweMessage } = require('siwe'); const { SiweMessage } = require('siwe');
const message = new SiweMessage({ const message = new SiweMessage({
domain, domain,
address: normalizedAddress, address: normalizedAddress,
statement: 'Sign in with Ethereum to the app.', statement: 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.',
uri: origin, uri: origin,
version: '1', version: '1',
chainId: 1, chainId: 1,
nonce: nonce, nonce: nonce,
issuedAt: issuedAt || new Date().toISOString(), issuedAt: messageIssuedAt,
resources: [`${origin}/api/auth/verify`], resources: resources,
}); });
const messageToSign = message.prepareMessage(); const messageToSign = message.prepareMessage();
logger.info(`[verify] SIWE message for verification: ${messageToSign}`); logger.info(`[verify] SIWE message for verification: ${messageToSign}`);
logger.info(`[verify] Resources: ${JSON.stringify(resources)}`);
logger.info(`[verify] IssuedAt: ${messageIssuedAt}`);
logger.info(`[verify] Domain: ${domain}, Origin: ${origin}`); logger.info(`[verify] Domain: ${domain}, Origin: ${origin}`);
logger.info(`[verify] Normalized address: ${normalizedAddress}`); logger.info(`[verify] Normalized address: ${normalizedAddress}`);
logger.info(`[verify] Request headers origin: ${req.get('origin')}`); logger.info(`[verify] Request headers origin: ${req.get('origin')}`);
logger.info(`[verify] Request headers host: ${req.get('host')}`); logger.info(`[verify] Request headers host: ${req.get('host')}`);
logger.info(`[verify] Request headers referer: ${req.get('referer')}`); logger.info(`[verify] Request headers referer: ${req.get('referer')}`);
// Проверяем подпись // Проверяем подпись через SiweMessage.verify() (передаем объект сообщения, а не строку)
const isValid = await authService.verifySignature(messageToSign, signature, normalizedAddress); const isValid = await authService.verifySignature(message, signature, normalizedAddress);
if (!isValid) { if (!isValid) {
logger.error(`[verify] Invalid signature for address: ${normalizedAddress}`); logger.error(`[verify] Invalid signature for address: ${normalizedAddress}`);
return res.status(401).json({ success: false, error: 'Invalid signature' }); return res.status(401).json({ success: false, error: 'Invalid signature' });
@@ -257,6 +290,15 @@ router.post('/verify', async (req, res) => {
// Связываем гостевые сообщения с пользователем // Связываем гостевые сообщения с пользователем
await sessionService.linkGuestMessages(req.session, userId); await sessionService.linkGuestMessages(req.session, userId);
// Проверяем согласия пользователя через централизованный сервис
const consentCheck = await consentService.checkConsents({
userId,
walletAddress: normalizedAddress
});
const needsConsent = consentCheck.needsConsent;
const missingConsents = consentCheck.missingConsents;
// Возвращаем успешный ответ // Возвращаем успешный ответ
userAccessLevel = await authService.getUserAccessLevel(normalizedAddress); userAccessLevel = await authService.getUserAccessLevel(normalizedAddress);
return res.json({ return res.json({
@@ -265,6 +307,8 @@ router.post('/verify', async (req, res) => {
address: normalizedAddress, // Возвращаем нормализованный адрес address: normalizedAddress, // Возвращаем нормализованный адрес
userAccessLevel: userAccessLevel, userAccessLevel: userAccessLevel,
authenticated: true, authenticated: true,
needsConsent: needsConsent,
missingConsents: missingConsents,
}); });
} catch (error) { } catch (error) {
logger.error('[verify] Error:', error); logger.error('[verify] Error:', error);

View File

@@ -25,6 +25,14 @@ const aiAssistantRulesService = require('../services/aiAssistantRulesService');
const botManager = require('../services/botManager'); const botManager = require('../services/botManager');
const universalMediaProcessor = require('../services/UniversalMediaProcessor'); const universalMediaProcessor = require('../services/UniversalMediaProcessor');
// Маппинг названий документов на типы согласий
const DOCUMENT_CONSENT_MAP = {
'Политика конфиденциальности': 'privacy_policy',
'Права субъектов персональных данных и отзыв согласия': 'personal_data',
'Согласие на использование файлов cookie': 'cookies',
'Согласие на обработку персональных данных': 'personal_data_processing',
};
// Настройка multer для обработки файлов в памяти // Настройка multer для обработки файлов в памяти
const storage = multer.memoryStorage(); const storage = multer.memoryStorage();
const upload = multer({ storage: storage }); const upload = multer({ storage: storage });
@@ -143,6 +151,7 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => {
}; };
// Обработка через unified processor // Обработка через unified processor
// Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора
const result = await unifiedMessageProcessor.processMessage(messageData); const result = await unifiedMessageProcessor.processMessage(messageData);
logger.info('[Chat] Результат обработки:', { logger.info('[Chat] Результат обработки:', {
@@ -151,13 +160,25 @@ router.post('/guest-message', upload.array('attachments'), async (req, res) => {
aiResponseType: typeof result.aiResponse?.response aiResponseType: typeof result.aiResponse?.response
}); });
res.json({ // Формируем ответ
// Системное сообщение уже включено в ответ ИИ (если нужно)
const response = {
success: true, success: true,
guestId: webGuestId, guestId: webGuestId,
aiResponse: result.aiResponse ? { aiResponse: result.aiResponse ? {
response: result.aiResponse.response response: result.aiResponse.response
} : null } : null
}); };
// Добавляем информацию о согласиях из результата (если есть)
if (result.consentRequired) {
response.consentRequired = result.consentRequired;
response.missingConsents = result.missingConsents;
response.consentDocuments = result.consentDocuments;
response.autoConsentOnReply = result.autoConsentOnReply;
}
res.json(response);
} catch (error) { } catch (error) {
logger.error('[Chat] Ошибка обработки гостевого сообщения:', error); logger.error('[Chat] Ошибка обработки гостевого сообщения:', error);
@@ -268,9 +289,11 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
}; };
// Обработка через unified processor // Обработка через unified processor
// Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора
const result = await unifiedMessageProcessor.processMessage(messageData); const result = await unifiedMessageProcessor.processMessage(messageData);
res.json({ // Формируем ответ с информацией о согласиях
const response = {
success: true, success: true,
userMessageId: result.userMessageId, userMessageId: result.userMessageId,
conversationId: result.conversationId, conversationId: result.conversationId,
@@ -278,7 +301,17 @@ router.post('/message', requireAuth, upload.array('attachments'), async (req, re
response: result.aiResponse.response response: result.aiResponse.response
} : null, } : null,
noAiResponse: result.noAiResponse noAiResponse: result.noAiResponse
}); };
// Добавляем информацию о согласиях из результата (если есть)
if (result.consentRequired) {
response.consentRequired = result.consentRequired;
response.missingConsents = result.missingConsents;
response.consentDocuments = result.consentDocuments;
response.autoConsentOnReply = result.autoConsentOnReply;
}
res.json(response);
} catch (error) { } catch (error) {
logger.error('[Chat] Ошибка обработки сообщения:', error); logger.error('[Chat] Ошибка обработки сообщения:', error);

289
backend/routes/consent.js Normal file
View File

@@ -0,0 +1,289 @@
/**
* 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 express = require('express');
const router = express.Router();
const db = require('../db');
const logger = require('../utils/logger');
const { requireAuth } = require('../middleware/auth');
const consentService = require('../services/consentService');
const { DOCUMENT_CONSENT_MAP } = consentService;
// Получить список документов для подписания
router.get('/documents', async (req, res) => {
try {
const tableName = 'admin_pages_simple';
// Проверяем, есть ли таблица
const existsRes = await db.getQuery()(
`SELECT to_regclass($1) as exists`, [tableName]
);
if (!existsRes.rows[0].exists) {
return res.json([]);
}
// Получаем документы для подписания по названиям
const documentTitles = Object.keys(DOCUMENT_CONSENT_MAP);
const { rows } = await db.getQuery()(`
SELECT id, title, summary, content, created_at, updated_at
FROM ${tableName}
WHERE status = 'published'
AND visibility = 'public'
AND title = ANY($1)
ORDER BY created_at DESC
`, [documentTitles]);
// Добавляем тип согласия к каждому документу
const documents = rows.map(doc => ({
...doc,
consentType: DOCUMENT_CONSENT_MAP[doc.title] || null,
}));
res.json(documents);
} catch (error) {
logger.error('Error fetching consent documents:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Проверить, подписал ли пользователь необходимые документы
router.get('/status', requireAuth, async (req, res) => {
try {
const userId = req.session.userId;
const walletAddress = req.session.address;
if (!userId && !walletAddress) {
return res.status(400).json({ error: 'Требуется авторизация' });
}
// Получаем все необходимые типы согласий
const requiredConsentTypes = Object.values(DOCUMENT_CONSENT_MAP);
// Получаем активные согласия пользователя
let query = `
SELECT consent_type, status, signed_at, document_id, document_title
FROM consent_logs
WHERE status = 'granted'
AND (
`;
const params = [];
if (userId) {
query += `user_id = $${params.length + 1}`;
params.push(userId);
}
if (walletAddress) {
if (params.length > 0) query += ' OR ';
query += `wallet_address = $${params.length + 1}`;
params.push(walletAddress);
}
query += `)
AND consent_type = ANY($${params.length + 1})
ORDER BY signed_at DESC
`;
params.push(requiredConsentTypes);
const { rows } = await db.getQuery()(query, params);
// Формируем статус для каждого типа согласия
const status = {};
requiredConsentTypes.forEach(type => {
const consent = rows.find(r => r.consent_type === type);
status[type] = consent ? {
granted: true,
signedAt: consent.signed_at,
documentId: consent.document_id,
documentTitle: consent.document_title,
} : {
granted: false,
};
});
// Проверяем, все ли согласия предоставлены
const allGranted = requiredConsentTypes.every(type => status[type].granted);
res.json({
allGranted,
status,
});
} catch (error) {
logger.error('Error checking consent status:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Сохранить согласие пользователя
router.post('/grant', async (req, res) => {
try {
// Разрешаем подпись как для авторизованных, так и для гостей
const userId = req.session?.userId || null;
const walletAddress = req.session?.address || null;
const guestId = req.session?.guestId || null;
const { documentIds, consentTypes } = req.body; // Массивы ID документов и типов согласий
// Если нет ни userId, ни walletAddress, используем guestId для идентификации
if (!userId && !walletAddress && !guestId) {
return res.status(400).json({ error: 'Требуется идентификация (авторизация или гостевая сессия)' });
}
if (!documentIds || !Array.isArray(documentIds) || documentIds.length === 0) {
return res.status(400).json({ error: 'Требуется указать документы для подписания' });
}
// Получаем информацию о документах
const { rows: documents } = await db.getQuery()(`
SELECT id, title
FROM admin_pages_simple
WHERE id = ANY($1) AND status = 'published'
`, [documentIds]);
if (documents.length !== documentIds.length) {
return res.status(400).json({ error: 'Некоторые документы не найдены' });
}
const ipAddress = req.ip || req.connection.remoteAddress;
const userAgent = req.get('user-agent');
// Сохраняем согласия для каждого документа
const results = [];
for (let i = 0; i < documents.length; i++) {
const doc = documents[i];
const consentType = consentTypes && consentTypes[i]
? consentTypes[i]
: DOCUMENT_CONSENT_MAP[doc.title];
if (!consentType) {
logger.warn(`Unknown consent type for document: ${doc.title}`);
continue;
}
// Проверяем, есть ли уже активное согласие
let checkQuery = `
SELECT id FROM consent_logs
WHERE status = 'granted' AND consent_type = $1 AND (
`;
const checkParams = [consentType];
if (userId) {
checkQuery += `user_id = $${checkParams.length + 1}`;
checkParams.push(userId);
}
if (walletAddress) {
if (checkParams.length > 1) checkQuery += ' OR ';
checkQuery += `wallet_address = $${checkParams.length + 1}`;
checkParams.push(walletAddress);
}
// Для гостей проверяем по формату guest_${guestId}
if (guestId && !walletAddress) {
if (checkParams.length > 1) checkQuery += ' OR ';
checkQuery += `wallet_address = $${checkParams.length + 1}`;
checkParams.push(`guest_${guestId}`);
}
checkQuery += ')';
const existing = await db.getQuery()(checkQuery, checkParams);
if (existing.rows.length > 0) {
// Обновляем существующее согласие
await db.getQuery()(`
UPDATE consent_logs
SET document_id = $1,
document_title = $2,
signed_at = NOW(),
revoked_at = NULL,
ip_address = $3,
user_agent = $4,
updated_at = NOW()
WHERE id = $5
`, [doc.id, doc.title, ipAddress, userAgent, existing.rows[0].id]);
results.push({ documentId: doc.id, consentType, action: 'updated' });
} else {
// Для гостей используем guestId как wallet_address для последующей миграции
const consentWalletAddress = walletAddress || (guestId ? `guest_${guestId}` : null);
// Создаем новое согласие
await db.getQuery()(`
INSERT INTO consent_logs (
user_id, wallet_address, document_id, document_title,
consent_type, status, ip_address, user_agent, channel
) VALUES ($1, $2, $3, $4, $5, 'granted', $6, $7, 'web')
`, [userId, consentWalletAddress, doc.id, doc.title, consentType, ipAddress, userAgent]);
results.push({ documentId: doc.id, consentType, action: 'created' });
}
}
logger.info(`Consent granted: userId=${userId}, walletAddress=${walletAddress}, documents=${documentIds.join(',')}`);
res.json({
success: true,
results,
});
} catch (error) {
logger.error('Error granting consent:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Отозвать согласие
router.post('/revoke', requireAuth, async (req, res) => {
try {
const userId = req.session.userId;
const walletAddress = req.session.address;
const { consentTypes } = req.body; // Массив типов согласий для отзыва
if (!userId && !walletAddress) {
return res.status(400).json({ error: 'Требуется авторизация' });
}
if (!consentTypes || !Array.isArray(consentTypes) || consentTypes.length === 0) {
return res.status(400).json({ error: 'Требуется указать типы согласий для отзыва' });
}
let query = `
UPDATE consent_logs
SET status = 'revoked',
revoked_at = NOW(),
updated_at = NOW()
WHERE consent_type = ANY($1) AND status = 'granted' AND (
`;
const params = [consentTypes];
if (userId) {
query += `user_id = $${params.length + 1}`;
params.push(userId);
}
if (walletAddress) {
if (params.length > 1) query += ' OR ';
query += `wallet_address = $${params.length + 1}`;
params.push(walletAddress);
}
query += ')';
const { rowCount } = await db.getQuery()(query, params);
logger.info(`Consent revoked: userId=${userId}, walletAddress=${walletAddress}, types=${consentTypes.join(',')}`);
res.json({
success: true,
revokedCount: rowCount,
});
} catch (error) {
logger.error('Error revoking consent:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
module.exports = router;

View File

@@ -330,11 +330,75 @@ class UniversalGuestService {
logger.info(`[UniversalGuestService] Обработка сообщения гостя: ${identifier}`); logger.info(`[UniversalGuestService] Обработка сообщения гостя: ${identifier}`);
// 0.5. Проверяем, нужно ли автоматически подписать согласие при ответе
// Загружаем историю для проверки последнего сообщения
const previousHistory = await this.getHistory(identifier);
// Если в истории есть системное сообщение о согласиях, автоматически подписываем при ответе
if (previousHistory.length > 0) {
const consentService = require('./consentService');
const [provider, providerId] = identifier?.split(':') || [];
let walletAddress = null;
if (provider === 'web' && providerId?.startsWith('guest_')) {
walletAddress = providerId;
}
// Проверяем, было ли последнее сообщение системным с согласием
const lastMessage = previousHistory[previousHistory.length - 1];
const hasConsentSystemMessage = lastMessage &&
(lastMessage.role === 'system' || lastMessage.consentRequired);
if (hasConsentSystemMessage) {
// Проверяем текущие согласия
const consentCheck = await consentService.checkConsents({
userId: null,
walletAddress
});
// Если согласия нужны, автоматически подписываем
if (consentCheck.needsConsent) {
logger.info(`[UniversalGuestService] Автоматическое подписание согласий при ответе гостя ${identifier}`);
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) {
const db = require('../db');
try {
// Для гостей используем wallet_address в формате guest_ID
await db.getQuery()(
`INSERT INTO consent_logs (wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at)
SELECT $1, unnest($2::int[]), unnest($3::text[]), unnest($4::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW()
ON CONFLICT (wallet_address, consent_type, document_id)
DO UPDATE SET
status = 'granted',
signed_at = NOW(),
revoked_at = NULL,
updated_at = NOW()
WHERE consent_logs.wallet_address = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`,
[
walletAddress,
documentIds,
consentDocuments.map(doc => doc.title),
consentTypes
]
);
logger.info(`[UniversalGuestService] Согласия автоматически подписаны для гостя ${identifier}`);
} catch (consentError) {
logger.error(`[UniversalGuestService] Ошибка автоматического подписания согласий:`, consentError);
}
}
}
}
}
// 1. Сохраняем сообщение гостя // 1. Сохраняем сообщение гостя
const saveResult = await this.saveMessage(messageData); const saveResult = await this.saveMessage(messageData);
const processedContent = saveResult.processedContent; const processedContent = saveResult.processedContent;
// 2. Загружаем историю для контекста // 2. Загружаем историю для контекста (заново, так как могли добавиться сообщения)
const conversationHistory = await this.getHistory(identifier); const conversationHistory = await this.getHistory(identifier);
// 3. Генерируем AI ответ // 3. Генерируем AI ответ
@@ -368,25 +432,74 @@ class UniversalGuestService {
}; };
} }
// 4. Сохраняем AI ответ // Проверяем согласия для добавления системного сообщения к ответу ИИ
const consentService = require('./consentService');
const [provider, providerId] = identifier?.split(':') || [];
let walletAddress = null;
if (provider === 'web' && providerId?.startsWith('guest_')) {
walletAddress = providerId; // Для веб-гостей используем guest_ID
}
const consentCheck = await consentService.checkConsents({
userId: null,
walletAddress
});
// Формируем финальный ответ ИИ с системным сообщением, если нужно
let finalAiResponse = aiResponse.response;
let consentInfo = null;
if (consentCheck.needsConsent) {
const consentSystemMessage = await consentService.getConsentSystemMessage({
userId: null,
walletAddress,
channel: channel === 'web' ? 'web' : channel,
baseUrl: process.env.BASE_URL || 'http://localhost:9000'
});
if (consentSystemMessage && consentSystemMessage.consentRequired) {
// Добавляем системное сообщение к ответу ИИ
finalAiResponse = `${aiResponse.response}\n\n---\n\n${consentSystemMessage.content}`;
consentInfo = {
consentRequired: true,
missingConsents: consentSystemMessage.missingConsents,
consentDocuments: consentSystemMessage.consentDocuments,
autoConsentOnReply: consentSystemMessage.autoConsentOnReply
};
}
}
// 4. Сохраняем AI ответ с добавленным системным сообщением
await this.saveAiResponse({ await this.saveAiResponse({
identifier, identifier,
content: aiResponse.response, content: finalAiResponse,
channel, channel,
metadata: messageData.metadata || {} metadata: messageData.metadata || {}
}); });
logger.info(`[UniversalGuestService] Сообщение гостя ${identifier} обработано успешно`); logger.info(`[UniversalGuestService] Сообщение гостя ${identifier} обработано успешно`);
return { const result = {
success: true, success: true,
identifier, identifier,
aiResponse: { aiResponse: {
response: aiResponse.response, response: finalAiResponse,
ragData: aiResponse.ragData ragData: aiResponse.ragData
} }
}; };
// Добавляем информацию о согласиях, если они нужны
if (consentInfo) {
result.consentRequired = consentInfo.consentRequired;
result.missingConsents = consentInfo.missingConsents;
result.consentDocuments = consentInfo.consentDocuments;
result.autoConsentOnReply = consentInfo.autoConsentOnReply;
}
return result;
} catch (error) { } catch (error) {
logger.error('[UniversalGuestService] Ошибка обработки сообщения гостя:', error); logger.error('[UniversalGuestService] Ошибка обработки сообщения гостя:', error);
throw error; throw error;
@@ -534,6 +647,48 @@ class UniversalGuestService {
); );
} }
// 5. Переносим согласия гостя на пользователя, если они есть
// Согласия могут быть связаны с гостевой сессией через wallet_address = "guest_${guestId}"
try {
const [channel, guestId] = identifier.split(':');
// Ищем согласия по гостевому идентификатору в формате "guest_${guestId}"
const guestWalletAddress = `guest_${guestId}`;
const { rows: guestConsents } = await db.getQuery()(`
SELECT id, consent_type, document_id, document_title, status, signed_at, ip_address, user_agent, channel as consent_channel
FROM consent_logs
WHERE wallet_address = $1
AND status = 'granted'
AND (user_id IS NULL OR user_id = $2)
`, [guestWalletAddress, userId]);
if (guestConsents.length > 0) {
logger.info(`[UniversalGuestService] Найдено ${guestConsents.length} согласий для переноса`);
// Переносим согласия на пользователя
// Обновляем wallet_address на нормализованный адрес кошелька пользователя, если он есть
const identityService = require('./identity-service');
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
const normalizedWalletAddress = walletIdentity?.provider_id || null;
for (const consent of guestConsents) {
await db.getQuery()(`
UPDATE consent_logs
SET user_id = $1,
wallet_address = COALESCE($2, wallet_address),
updated_at = NOW()
WHERE id = $3
`, [userId, normalizedWalletAddress, consent.id]);
}
logger.info(`[UniversalGuestService] Перенесено ${guestConsents.length} согласий на user ${userId}`);
}
} catch (consentError) {
// Не критично, если не удалось перенести согласия - просто логируем
logger.warn(`[UniversalGuestService] Ошибка переноса согласий (не критично):`, consentError);
}
logger.info(`[UniversalGuestService] Миграция завершена: ${migrated} перенесено, ${skipped} пропущено`); logger.info(`[UniversalGuestService] Миграция завершена: ${migrated} перенесено, ${skipped} пропущено`);
return { return {

View File

@@ -33,28 +33,47 @@ const ERC20_ABI = ['function balanceOf(address owner) view returns (uint256)'];
class AuthService { class AuthService {
constructor() {} constructor() {}
// Проверка подписи // Проверка подписи SIWE
async verifySignature(message, signature, address) { // Используем SiweMessage для правильной проверки SIWE подписей
async verifySignature(siweMessage, signature, address) {
try { try {
if (!message || !signature || !address) return false; if (!siweMessage || !signature || !address) return false;
// Нормализуем входящий адрес // Нормализуем входящий адрес
const normalizedAddress = ethers.getAddress(address); const normalizedAddress = ethers.getAddress(address);
// Восстанавливаем адрес из подписи // Используем SiweMessage.verify() для правильной проверки SIWE подписи
const recoveredAddress = ethers.verifyMessage(message, signature); const { SiweMessage } = require('siwe');
// Если siweMessage уже является объектом SiweMessage, используем его напрямую
// Если это строка, парсим её
let message;
if (typeof siweMessage === 'string') {
message = new SiweMessage(siweMessage);
} else {
message = siweMessage;
}
// Проверяем подпись через SiweMessage.verify()
const { success, data } = await message.verify({ signature });
// Логируем для отладки // Логируем для отладки
logger.info(`[verifySignature] Message: ${message}`); logger.info(`[verifySignature] SIWE verification result: ${success}`);
if (data) {
logger.info(`[verifySignature] Verified address: ${data.address}`);
logger.info(`[verifySignature] Expected address: ${normalizedAddress}`);
logger.info(`[verifySignature] Addresses match: ${ethers.getAddress(data.address) === normalizedAddress}`);
}
logger.info(`[verifySignature] Signature: ${signature}`); logger.info(`[verifySignature] Signature: ${signature}`);
logger.info(`[verifySignature] Expected address: ${normalizedAddress}`);
logger.info(`[verifySignature] Recovered address: ${recoveredAddress}`); if (!success) {
logger.info(`[verifySignature] Addresses match: ${ethers.getAddress(recoveredAddress) === normalizedAddress}`); return false;
}
// Сравниваем нормализованные адреса // Сравниваем нормализованные адреса
return ethers.getAddress(recoveredAddress) === normalizedAddress; return data && ethers.getAddress(data.address) === normalizedAddress;
} catch (error) { } catch (error) {
logger.error('Error in signature verification:', error); logger.error('Error in SIWE signature verification:', error);
return false; return false;
} }
} }

View File

@@ -0,0 +1,263 @@
/**
* 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 DOCUMENT_CONSENT_MAP = {
'Политика конфиденциальности': 'privacy_policy',
'Права субъектов персональных данных и отзыв согласия': 'personal_data',
'Согласие на использование файлов cookie': 'cookies',
'Согласие на обработку персональных данных': 'personal_data_processing'
};
/**
* Проверить согласия пользователя или гостя
* @param {Object} params - Параметры проверки
* @param {number|null} params.userId - ID пользователя (если авторизован)
* @param {string|null} params.walletAddress - Адрес кошелька или guest_ID
* @returns {Promise<Object>} - Результат проверки с информацией о недостающих согласиях
*/
async function checkConsents({ userId = null, walletAddress = null }) {
try {
const requiredConsentTypes = Object.values(DOCUMENT_CONSENT_MAP);
// Строим запрос в зависимости от наличия userId
let consentCheckQuery;
let queryParams;
if (userId) {
// Для авторизованного пользователя
consentCheckQuery = `
SELECT consent_type, COUNT(*) as count
FROM consent_logs
WHERE status = 'granted'
AND (user_id = $1 OR wallet_address = $2)
AND consent_type = ANY($3)
GROUP BY consent_type
`;
queryParams = [userId, walletAddress, requiredConsentTypes];
} else if (walletAddress) {
// Для гостя
consentCheckQuery = `
SELECT consent_type, COUNT(*) as count
FROM consent_logs
WHERE wallet_address = $1
AND status = 'granted'
AND consent_type = ANY($2)
GROUP BY consent_type
`;
queryParams = [walletAddress, requiredConsentTypes];
} else {
// Если нет ни userId, ни walletAddress - все согласия отсутствуют
return {
needsConsent: true,
missingConsents: requiredConsentTypes,
grantedConsents: []
};
}
const consentResult = await db.getQuery()(consentCheckQuery, queryParams);
const grantedConsentTypes = consentResult.rows.map(r => r.consent_type);
const missingConsents = requiredConsentTypes.filter(type => !grantedConsentTypes.includes(type));
const needsConsent = missingConsents.length > 0;
return {
needsConsent,
missingConsents,
grantedConsents: grantedConsentTypes
};
} catch (error) {
logger.error('[ConsentService] Ошибка проверки согласий:', error);
// В случае ошибки считаем, что согласия нужны для безопасности
return {
needsConsent: true,
missingConsents: Object.values(DOCUMENT_CONSENT_MAP),
grantedConsents: []
};
}
}
/**
* Получить список документов для подписания
* @param {Array<string>} missingConsents - Типы недостающих согласий
* @returns {Promise<Array>} - Массив документов с информацией
*/
async function getConsentDocuments(missingConsents = []) {
try {
// Определяем, какие документы нужно подписать (по недостающим типам согласий)
const documentsToShow = Object.entries(DOCUMENT_CONSENT_MAP)
.filter(([title, consentType]) => missingConsents.includes(consentType))
.map(([title]) => title);
if (documentsToShow.length === 0) {
return [];
}
// Получаем список документов для подписания
const { rows: documents } = await db.getQuery()(`
SELECT id, title, summary
FROM admin_pages_simple
WHERE status = 'published'
AND visibility = 'public'
AND title = ANY($1)
ORDER BY created_at DESC
`, [documentsToShow]);
return documents.map(doc => ({
id: doc.id,
title: doc.title,
summary: doc.summary,
consentType: DOCUMENT_CONSENT_MAP[doc.title],
url: `/content/published/${doc.id}`
}));
} catch (error) {
logger.error('[ConsentService] Ошибка получения документов:', error);
return [];
}
}
/**
* Сформировать системное сообщение о необходимости согласия
* @param {Object} params - Параметры
* @param {string} params.channel - Канал (web/telegram/email)
* @param {Array} params.missingConsents - Типы недостающих согласий
* @param {Array} params.consentDocuments - Документы для подписания
* @param {string} params.baseUrl - Базовый URL сайта (для формирования ссылок)
* @returns {Object} - Системное сообщение, отформатированное для канала
*/
function formatConsentMessage({ channel = 'web', missingConsents = [], consentDocuments = [], baseUrl = 'http://localhost:9000' }) {
const baseMessage = 'Для полноценного использования сервиса необходимо предоставить согласие на обработку персональных данных.';
const autoConsentMessage = '\n\n⚠ Внимание: При ответе на это сообщение вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.';
switch (channel) {
case 'web':
// Для веб-чата возвращаем структурированные данные
return {
content: baseMessage + autoConsentMessage,
consentRequired: true,
missingConsents,
consentDocuments,
autoConsentOnReply: true, // Флаг для автоматического подписания при ответе
format: 'structured'
};
case 'telegram':
// Для Telegram форматируем как текст с ссылками
let telegramMessage = `${baseMessage}\n\nДля продолжения работы ознакомьтесь со следующими документами:\n\n`;
consentDocuments.forEach((doc, index) => {
telegramMessage += `${index + 1}. ${doc.title}\n`;
if (doc.summary) {
telegramMessage += ` ${doc.summary}\n`;
}
telegramMessage += ` ${baseUrl}${doc.url}\n\n`;
});
telegramMessage += `⚠️ Внимание: При ответе на это сообщение вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.`;
return {
content: telegramMessage,
consentRequired: true,
missingConsents,
consentDocuments,
autoConsentOnReply: true,
format: 'text'
};
case 'email':
// Для email форматируем как HTML
let emailHtml = `<p>${baseMessage}</p>`;
emailHtml += '<p>Для продолжения работы ознакомьтесь со следующими документами:</p>';
emailHtml += '<ul>';
consentDocuments.forEach((doc) => {
emailHtml += `<li><strong>${doc.title}</strong><br>`;
if (doc.summary) {
emailHtml += `${doc.summary}<br>`;
}
emailHtml += `<a href="${baseUrl}${doc.url}">Открыть документ</a></li>`;
});
emailHtml += '</ul>';
emailHtml += `<p><strong>⚠️ Внимание:</strong> При ответе на это письмо вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.</p>`;
emailHtml += `<p><a href="${baseUrl}/consent">Также вы можете подписать документы на сайте</a></p>`;
const textContent = baseMessage + '\n\n' +
consentDocuments.map(doc => `${doc.title}: ${baseUrl}${doc.url}`).join('\n') +
'\n\n⚠ Внимание: При ответе на это письмо вы автоматически подтверждаете ознакомление с документами и даете согласие на обработку персональных данных.';
return {
content: emailHtml,
textContent: textContent,
consentRequired: true,
missingConsents,
consentDocuments,
autoConsentOnReply: true,
format: 'html'
};
default:
return {
content: baseMessage + autoConsentMessage,
consentRequired: true,
missingConsents,
consentDocuments,
autoConsentOnReply: true,
format: 'text'
};
}
}
/**
* Получить полную информацию о согласиях и сформировать системное сообщение
* @param {Object} params - Параметры
* @param {number|null} params.userId - ID пользователя
* @param {string|null} params.walletAddress - Адрес кошелька или guest_ID
* @param {string} params.channel - Канал (web/telegram/email)
* @param {string} params.baseUrl - Базовый URL сайта
* @returns {Promise<Object|null>} - Системное сообщение или null, если согласия есть
*/
async function getConsentSystemMessage({ userId = null, walletAddress = null, channel = 'web', baseUrl = 'http://localhost:9000' }) {
try {
// Проверяем согласия
const consentCheck = await checkConsents({ userId, walletAddress });
if (!consentCheck.needsConsent) {
return null; // Все согласия есть
}
// Получаем документы для подписания
const consentDocuments = await getConsentDocuments(consentCheck.missingConsents);
// Формируем системное сообщение для канала
return formatConsentMessage({
channel,
missingConsents: consentCheck.missingConsents,
consentDocuments,
baseUrl
});
} catch (error) {
logger.error('[ConsentService] Ошибка формирования системного сообщения:', error);
return null;
}
}
module.exports = {
checkConsents,
getConsentDocuments,
formatConsentMessage,
getConsentSystemMessage,
DOCUMENT_CONSENT_MAP
};

View File

@@ -297,7 +297,22 @@ class EmailBot {
const messageData = await this.extractMessageData(parsed, messageId, uid); const messageData = await this.extractMessageData(parsed, messageId, uid);
if (messageData && this.messageProcessor) { if (messageData && this.messageProcessor) {
await this.messageProcessor(messageData); // Обрабатываем сообщение через унифицированный процессор
// Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора
const result = await this.messageProcessor(messageData);
// Если есть ответ ИИ с информацией о согласиях, отправляем email
if (result && result.success && result.aiResponse) {
const fromEmail = parsed.from?.value?.[0]?.address;
if (fromEmail) {
// Ответ ИИ уже содержит системное сообщение о согласиях (если нужно)
await this.sendEmail(
fromEmail,
'Ответ на ваше сообщение',
result.aiResponse.response
);
}
}
} }
processedCount++; processedCount++;
@@ -466,6 +481,40 @@ class EmailBot {
} }
} }
/**
* Отправка email с HTML содержимым
* @param {string} to - Email получателя
* @param {string} subject - Тема письма
* @param {string} text - Текстовая версия
* @param {string} html - HTML версия
*/
async sendEmailWithHtml(to, subject, text, html) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(to)) {
throw new Error(`Неверный формат email адреса: ${to}`);
}
try {
const mailOptions = {
from: this.settings.from_email,
to,
subject,
text,
html
};
await this.transporter.sendMail(mailOptions);
this.transporter.close();
logger.info(`[EmailBot] Email с HTML отправлен успешно: ${to}`);
return true;
} catch (error) {
logger.error('[EmailBot] Ошибка отправки email с HTML:', error);
throw error;
}
}
/** /**
* Отправка кода верификации * Отправка кода верификации
* @param {string} email - Email получателя * @param {string} email - Email получателя

View File

@@ -300,27 +300,49 @@ class EncryptedDataService {
const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) RETURNING *`; const query = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) RETURNING *`;
// Собираем параметры в правильном порядке: сначала для encrypted, потом для unencrypted // Собираем параметры в правильном порядке по номерам из плейсхолдеров
const paramsArray = []; const paramMap = new Map(); // номер параметра -> значение
if (hasEncryptedFields) paramsArray.push(this.encryptionKey);
// Добавляем параметры для encrypted колонок if (hasEncryptedFields) {
for (const key of Object.keys(encryptedData)) { paramMap.set(1, this.encryptionKey); // $1 - ключ шифрования
const originalKey = key.replace('_encrypted', ''); }
if (filteredData[originalKey] !== undefined) {
paramsArray.push(filteredData[originalKey]); // Проходим по колонкам в порядке allData и добавляем соответствующие значения
} else if (filteredData[originalKey + '_unencrypted'] !== undefined) { for (const key of Object.keys(allData)) {
paramsArray.push(filteredData[originalKey + '_unencrypted']); const placeholder = allData[key].toString();
// Извлекаем все номера параметров из плейсхолдера (может быть $1 в encrypt_text)
const paramMatches = placeholder.match(/\$(\d+)/g);
if (paramMatches) {
// Для зашифрованных колонок нас интересует второй параметр ($3, $4 и т.д.)
// Для незашифрованных - первый параметр ($2, $3 и т.д.)
if (encryptedData[key]) {
// Это зашифрованная колонка - берем второй параметр (первый это $1 - ключ шифрования)
const originalKey = key.replace('_encrypted', '');
if (filteredData[originalKey] !== undefined && paramMatches.length > 0) {
// Последний параметр это значение для шифрования
const valueParam = paramMatches[paramMatches.length - 1];
const paramNum = parseInt(valueParam.substring(1));
paramMap.set(paramNum, filteredData[originalKey]);
}
} else if (unencryptedData[key]) {
// Это незашифрованная колонка - берем параметр из плейсхолдера
const valueParam = paramMatches[0];
const paramNum = parseInt(valueParam.substring(1));
paramMap.set(paramNum, filteredData[key]);
}
} }
} }
// Добавляем параметры для unencrypted колонок // Создаем массив параметров в правильном порядке (от $1 до максимального номера)
for (const key of Object.keys(unencryptedData)) { const maxParamNum = Math.max(...Array.from(paramMap.keys()));
paramsArray.push(filteredData[key + '_unencrypted'] || filteredData[key]); const params = [];
for (let i = 1; i <= maxParamNum; i++) {
if (!paramMap.has(i)) {
throw new Error(`Отсутствует параметр $${i} для запроса`);
}
params.push(paramMap.get(i));
} }
const params = paramsArray;
console.log(`🔍 Выполняем INSERT запрос:`, query); console.log(`🔍 Выполняем INSERT запрос:`, query);
console.log(`🔍 Параметры:`, params); console.log(`🔍 Параметры:`, params);
console.log(`🔍 Ключ шифрования:`, this.encryptionKey ? 'установлен' : 'не установлен'); console.log(`🔍 Ключ шифрования:`, this.encryptionKey ? 'установлен' : 'не установлен');

View File

@@ -114,6 +114,46 @@ class SessionService {
const identifier = `web:${guestId}`; // Старые гости всегда из web const identifier = `web:${guestId}`; // Старые гости всегда из web
await universalGuestService.migrateToUser(identifier, userId); await universalGuestService.migrateToUser(identifier, userId);
} }
// После миграции сообщений переносим согласия по всем гостевых идентификаторам
// Это нужно на случай, если согласия были связаны с гостевой сессией
try {
const encryptionUtils = require('../utils/encryptionUtils');
const encryptionKey = encryptionUtils.getEncryptionKey();
// Получаем wallet адрес пользователя для обновления согласий
const identityService = require('./identity-service');
const walletIdentity = await identityService.findIdentity(userId, 'wallet');
const normalizedWalletAddress = walletIdentity?.provider_id || null;
for (const guestId of guestIdsToProcess) {
// Ищем согласия по гостевому идентификатору в формате "guest_${guestId}"
const guestWalletAddress = `guest_${guestId}`;
const { rows: guestConsents } = await db.getQuery()(`
SELECT id FROM consent_logs
WHERE wallet_address = $1
AND status = 'granted'
AND (user_id IS NULL OR user_id = $2)
`, [guestWalletAddress, userId]);
if (guestConsents.length > 0) {
// Переносим согласия на пользователя и обновляем wallet_address
await db.getQuery()(`
UPDATE consent_logs
SET user_id = $1,
wallet_address = COALESCE($2, wallet_address),
updated_at = NOW()
WHERE id = ANY($3)
`, [userId, normalizedWalletAddress, guestConsents.map(c => c.id)]);
logger.info(`[SessionService] Перенесено ${guestConsents.length} согласий для guest ${guestId} → user ${userId}`);
}
}
} catch (consentError) {
// Не критично, если не удалось перенести согласия - просто логируем
logger.warn(`[SessionService] Ошибка переноса согласий (не критично):`, consentError);
}
} }
return { success: true, processedCount: guestIdsToProcess.size }; return { success: true, processedCount: guestIdsToProcess.size };

View File

@@ -382,9 +382,11 @@ class TelegramBot {
} }
// Обрабатываем сообщение через унифицированный процессор // Обрабатываем сообщение через унифицированный процессор
// Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора
const result = await messageProcessor(messageData); const result = await messageProcessor(messageData);
// Отправляем ответ пользователю // Отправляем ответ пользователю
// Системное сообщение о согласиях уже включено в ответ ИИ (если нужно)
if (result.success && result.aiResponse) { if (result.success && result.aiResponse) {
await ctx.reply(result.aiResponse.response); await ctx.reply(result.aiResponse.response);
} else if (result.success) { } else if (result.success) {

View File

@@ -183,6 +183,80 @@ async function processMessage(messageData) {
const conversationId = conversation.id; 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) {
const consentRoutes = require('../routes/consent');
// Вызываем логику подписания напрямую через сервис или API
try {
await db.getQuery()(
`INSERT INTO consent_logs (user_id, wallet_address, document_id, document_title, consent_type, status, signed_at, channel, ip_address, created_at, updated_at)
SELECT $1, $2, unnest($3::int[]), unnest($4::text[]), unnest($5::text[]), 'granted', NOW(), 'web', NULL, NOW(), NOW()
ON CONFLICT (user_id, consent_type, document_id)
DO UPDATE SET
status = 'granted',
signed_at = NOW(),
revoked_at = NULL,
updated_at = NOW()
WHERE consent_logs.user_id = $1 AND consent_logs.consent_type = EXCLUDED.consent_type`,
[
userId,
walletIdentity?.provider_id || null,
documentIds,
consentDocuments.map(doc => doc.title),
consentTypes
]
);
logger.info(`[UnifiedMessageProcessor] Согласия автоматически подписаны для пользователя ${userId}`);
} catch (consentError) {
logger.error(`[UnifiedMessageProcessor] Ошибка автоматического подписания согласий:`, consentError);
// Не блокируем обработку сообщения при ошибке подписания
}
}
}
}
// 6. Обработка вложений // 6. Обработка вложений
let attachment_filename = null; let attachment_filename = null;
let attachment_mimetype = null; let attachment_mimetype = null;
@@ -198,7 +272,7 @@ async function processMessage(messageData) {
} }
// 7. Сохраняем входящее сообщение пользователя // 7. Сохраняем входящее сообщение пользователя
const encryptionKey = encryptionUtils.getEncryptionKey(); // encryptionKey уже объявлен выше
const { rows } = await db.getQuery()( const { rows } = await db.getQuery()(
`INSERT INTO messages ( `INSERT INTO messages (
@@ -293,7 +367,31 @@ async function processMessage(messageData) {
}); });
if (aiResponse && aiResponse.success && aiResponse.response) { if (aiResponse && aiResponse.success && aiResponse.response) {
// Сохраняем ответ AI // Проверяем согласия и добавляем системное сообщение к ответу ИИ
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'
});
// Формируем финальный ответ ИИ с системным сообщением, если нужно
let 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()( const { rows: aiMessageRows } = await db.getQuery()(
`INSERT INTO messages ( `INSERT INTO messages (
conversation_id, conversation_id,
@@ -322,7 +420,7 @@ async function processMessage(messageData) {
conversationId, conversationId,
userId, // sender_id userId, // sender_id
'assistant', 'assistant',
aiResponse.response, finalAiResponse,
channel, channel,
'assistant', 'assistant',
'outgoing', 'outgoing',
@@ -353,17 +451,27 @@ async function processMessage(messageData) {
} }
// 11. Возвращаем результат // 11. Возвращаем результат
return { const result = {
success: true, success: true,
userMessageId, userMessageId,
conversationId, conversationId,
aiResponse: aiResponse && aiResponse.success ? { aiResponse: aiResponse && aiResponse.success ? {
response: aiResponse.response, response: finalAiResponse || aiResponse.response,
ragData: aiResponse.ragData ragData: aiResponse.ragData
} : null, } : null,
noAiResponse: !shouldGenerateAi noAiResponse: !shouldGenerateAi
}; };
// Если есть информация о согласиях, добавляем её в результат
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) { } catch (error) {
logger.error('[UnifiedMessageProcessor] Ошибка обработки сообщения:', error); logger.error('[UnifiedMessageProcessor] Ошибка обработки сообщения:', error);
throw error; throw error;

View File

@@ -21,6 +21,7 @@
:message="message" :message="message"
:isPrivateChat="isPrivateChat" :isPrivateChat="isPrivateChat"
:currentUserId="currentUserId" :currentUserId="currentUserId"
@consent-granted="handleConsentGranted"
/> />
</div> </div>
</div> </div>
@@ -113,6 +114,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@@ -148,6 +150,7 @@ const emit = defineEmits([
'send-message', 'send-message',
'load-more', // Событие для загрузки старых сообщений 'load-more', // Событие для загрузки старых сообщений
'ai-reply', 'ai-reply',
'remove-consent-messages', // Событие для удаления системных сообщений о согласиях
]); ]);
const messagesContainer = ref(null); const messagesContainer = ref(null);
@@ -155,6 +158,11 @@ const messageInputRef = ref(null);
const chatInputRef = ref(null); // Ref для chat-input const chatInputRef = ref(null); // Ref для chat-input
const chatInputHeight = ref(80); // Начальная высота (можно подобрать точнее) const chatInputHeight = ref(80); // Начальная высота (можно подобрать точнее)
function handleConsentGranted(messageId) {
// После подписания удаляем системное сообщение о необходимости согласия
emit('remove-consent-messages', [messageId]);
}
// Локальное состояние для предпросмотра, синхронизированное с props.attachments // Локальное состояние для предпросмотра, синхронизированное с props.attachments
const localAttachments = ref([...props.attachments]); const localAttachments = ref([...props.attachments]);
watch(() => props.attachments, (newVal) => { watch(() => props.attachments, (newVal) => {

View File

@@ -0,0 +1,341 @@
<!--
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
-->
<template>
<div v-if="isOpen" class="consent-modal-overlay" @click.self="close">
<div class="consent-modal">
<div class="consent-modal-header">
<h2>Подписание документов</h2>
<button class="close-btn" @click="close">×</button>
</div>
<div class="consent-modal-content">
<p class="consent-description">
Для полноценного использования сервиса необходимо ознакомиться и подписать следующие документы:
</p>
<div v-if="loading" class="loading-state">
<p>Загрузка документов...</p>
</div>
<div v-else-if="documents.length === 0" class="empty-state">
<p>Документы не найдены</p>
</div>
<div v-else class="documents-list">
<div v-for="doc in documents" :key="doc.id" class="document-item">
<label class="document-checkbox">
<input
type="checkbox"
:value="doc.id"
v-model="selectedDocuments"
class="checkbox-input"
/>
<div class="document-info">
<h3 class="document-title">{{ doc.title }}</h3>
<p v-if="doc.summary" class="document-summary">{{ doc.summary }}</p>
<a
:href="`/content/published/${doc.id}`"
target="_blank"
class="document-link"
@click.stop
>
Открыть документ
</a>
</div>
</label>
</div>
</div>
</div>
<div class="consent-modal-footer">
<button class="btn-secondary" @click="close">Отмена</button>
<button
class="btn-primary"
@click="submitConsent"
:disabled="selectedDocuments.length === 0 || submitting"
>
{{ submitting ? 'Подписание...' : 'Подписать' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import api from '../api/axios';
const props = defineProps({
isOpen: {
type: Boolean,
default: false,
},
missingConsents: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'consent-granted']);
const documents = ref([]);
const selectedDocuments = ref([]);
const loading = ref(false);
const submitting = ref(false);
// Загружаем документы для подписания
async function loadDocuments() {
loading.value = true;
try {
const response = await api.get('/consent/documents');
documents.value = response.data || [];
// Автоматически выбираем все документы
selectedDocuments.value = documents.value.map(doc => doc.id);
} catch (error) {
console.error('Ошибка загрузки документов:', error);
documents.value = [];
} finally {
loading.value = false;
}
}
// Отправляем согласие
async function submitConsent() {
if (selectedDocuments.value.length === 0) return;
submitting.value = true;
try {
// Получаем типы согласий для выбранных документов
const consentTypes = documents.value
.filter(doc => selectedDocuments.value.includes(doc.id))
.map(doc => doc.consentType)
.filter(type => type);
await api.post('/consent/grant', {
documentIds: selectedDocuments.value,
consentTypes: consentTypes,
});
emit('consent-granted');
close();
} catch (error) {
console.error('Ошибка подписания документов:', error);
alert('Ошибка при подписании документов. Попробуйте еще раз.');
} finally {
submitting.value = false;
}
}
function close() {
emit('close');
selectedDocuments.value = [];
}
// Загружаем документы при открытии модалки
watch(() => props.isOpen, (newValue) => {
if (newValue) {
loadDocuments();
}
});
onMounted(() => {
if (props.isOpen) {
loadDocuments();
}
});
</script>
<style scoped>
.consent-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.consent-modal {
background: white;
border-radius: 12px;
max-width: 600px;
width: 90%;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.consent-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e9ecef;
}
.consent-modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--color-primary, #333);
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #888;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.close-btn:hover {
background: #f0f0f0;
color: #333;
}
.consent-modal-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.consent-description {
margin: 0 0 20px 0;
color: #666;
line-height: 1.6;
}
.loading-state,
.empty-state {
text-align: center;
padding: 40px 20px;
color: #888;
}
.documents-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.document-item {
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 16px;
transition: all 0.2s;
}
.document-item:hover {
border-color: var(--color-primary, #007bff);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.document-checkbox {
display: flex;
gap: 12px;
cursor: pointer;
align-items: flex-start;
}
.checkbox-input {
margin-top: 4px;
width: 18px;
height: 18px;
cursor: pointer;
}
.document-info {
flex: 1;
}
.document-title {
margin: 0 0 8px 0;
font-size: 1.1rem;
color: var(--color-primary, #333);
font-weight: 600;
}
.document-summary {
margin: 0 0 8px 0;
color: #666;
font-size: 0.9rem;
line-height: 1.5;
}
.document-link {
color: var(--color-primary, #007bff);
text-decoration: none;
font-size: 0.9rem;
display: inline-block;
margin-top: 8px;
}
.document-link:hover {
text-decoration: underline;
}
.consent-modal-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 24px;
border-top: 1px solid #e9ecef;
}
.btn-secondary,
.btn-primary {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
transition: all 0.2s;
}
.btn-secondary {
background: #f0f0f0;
color: #333;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.btn-primary {
background: var(--color-primary, #007bff);
color: white;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-dark, #0056b3);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>

View File

@@ -46,8 +46,43 @@
<a :href="replyLink" class="reply-link">Ответить</a> <a :href="replyLink" class="reply-link">Ответить</a>
</div> </div>
<!-- Блок с документами для подписания -->
<div v-if="message.consentRequired && message.consentDocuments" class="consent-documents-block">
<div v-for="doc in message.consentDocuments" :key="doc.id" class="consent-document-item">
<label class="consent-document-label">
<input
type="checkbox"
:value="doc.id"
v-model="selectedConsentDocuments"
class="consent-checkbox"
/>
<div class="consent-document-info">
<h4 class="consent-document-title">{{ doc.title }}</h4>
<p v-if="doc.summary" class="consent-document-summary">{{ doc.summary }}</p>
<a
:href="`/content/published/${doc.id}`"
target="_blank"
class="consent-document-link"
@click.stop
>
Открыть документ
</a>
</div>
</label>
</div>
<div class="consent-actions">
<button
@click="submitConsent"
class="system-btn primary"
:disabled="selectedConsentDocuments.length === 0 || isSubmittingConsent"
>
{{ isSubmittingConsent ? 'Подписание...' : 'Подписать' }}
</button>
</div>
</div>
<!-- Кнопки для системного сообщения --> <!-- Кнопки для системного сообщения -->
<div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail)" class="system-actions"> <div v-if="message.sender_type === 'system' && (message.telegramBotUrl || message.supportEmail) && !message.consentRequired" class="system-actions">
<button v-if="message.telegramBotUrl" @click="openTelegram(message.telegramBotUrl)" class="system-btn">Перейти в Telegram-бот</button> <button v-if="message.telegramBotUrl" @click="openTelegram(message.telegramBotUrl)" class="system-btn">Перейти в Telegram-бот</button>
<button v-if="message.supportEmail" @click="copyEmail(message.supportEmail)" class="system-btn">Скопировать email</button> <button v-if="message.supportEmail" @click="copyEmail(message.supportEmail)" class="system-btn">Скопировать email</button>
</div> </div>
@@ -117,6 +152,48 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['consent-granted']);
// Состояние для выбранных документов и отправки согласия
const selectedConsentDocuments = ref([]);
const isSubmittingConsent = ref(false);
// Инициализируем выбранные документы при монтировании, если есть документы
watch(() => props.message.consentDocuments, (docs) => {
if (docs && Array.isArray(docs) && docs.length > 0) {
// Автоматически выбираем все документы
selectedConsentDocuments.value = docs.map(doc => doc.id);
}
}, { immediate: true });
// Функция подписания документов
async function submitConsent() {
if (selectedConsentDocuments.value.length === 0 || isSubmittingConsent.value) return;
isSubmittingConsent.value = true;
try {
const api = (await import('../api/axios')).default;
const documents = props.message.consentDocuments || [];
const consentTypes = documents
.filter(doc => selectedConsentDocuments.value.includes(doc.id))
.map(doc => doc.consentType)
.filter(type => type);
await api.post('/consent/grant', {
documentIds: selectedConsentDocuments.value,
consentTypes: consentTypes,
});
// Уведомляем родительский компонент об успешном подписании
emit('consent-granted', props.message.id);
} catch (error) {
console.error('Ошибка подписания документов:', error);
alert('Ошибка при подписании документов. Попробуйте еще раз.');
} finally {
isSubmittingConsent.value = false;
}
}
// Простая функция для определения, является ли сообщение отправленным текущим пользователем // Простая функция для определения, является ли сообщение отправленным текущим пользователем
// Используем данные из самого сообщения для определения направления // Используем данные из самого сообщения для определения направления
const isCurrentUserMessage = computed(() => { const isCurrentUserMessage = computed(() => {
@@ -494,6 +571,97 @@ function copyEmail(email) {
.system-btn:hover { .system-btn:hover {
background: var(--color-primary-dark, #2563eb); background: var(--color-primary-dark, #2563eb);
} }
.system-btn.primary {
background: var(--color-primary, #007bff);
font-weight: 600;
}
.system-btn.primary:hover {
background: var(--color-primary-dark, #0056b3);
}
.system-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Стили для блока с документами для подписания */
.consent-documents-block {
margin-top: 16px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.consent-document-item {
margin-bottom: 12px;
padding: 12px;
background: white;
border-radius: 6px;
border: 1px solid #e9ecef;
transition: all 0.2s;
}
.consent-document-item:hover {
border-color: var(--color-primary, #007bff);
box-shadow: 0 2px 8px rgba(0, 123, 255, 0.1);
}
.consent-document-item:last-child {
margin-bottom: 0;
}
.consent-document-label {
display: flex;
gap: 12px;
cursor: pointer;
align-items: flex-start;
}
.consent-checkbox {
margin-top: 4px;
width: 18px;
height: 18px;
cursor: pointer;
flex-shrink: 0;
}
.consent-document-info {
flex: 1;
}
.consent-document-title {
margin: 0 0 6px 0;
font-size: 1rem;
color: var(--color-primary, #333);
font-weight: 600;
}
.consent-document-summary {
margin: 0 0 8px 0;
color: #666;
font-size: 0.9rem;
line-height: 1.4;
}
.consent-document-link {
color: var(--color-primary, #007bff);
text-decoration: none;
font-size: 0.9rem;
display: inline-block;
margin-top: 4px;
}
.consent-document-link:hover {
text-decoration: underline;
}
.consent-actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
padding-top: 12px;
border-top: 1px solid #e9ecef;
}
/* Стили для информации об отправителе в приватном чате */ /* Стили для информации об отправителе в приватном чате */
.message-sender-info { .message-sender-info {

View File

@@ -316,6 +316,7 @@ export function useChat(auth) {
} }
// Добавляем ответ ИИ, если есть // Добавляем ответ ИИ, если есть
// Системное сообщение о согласиях уже включено в ответ ИИ
if (response.data.aiResponse) { if (response.data.aiResponse) {
messages.value.push({ messages.value.push({
id: `ai_${Date.now()}`, id: `ai_${Date.now()}`,
@@ -323,12 +324,17 @@ export function useChat(auth) {
sender_type: 'assistant', sender_type: 'assistant',
role: 'assistant', role: 'assistant',
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
isLocal: false isLocal: false,
// Добавляем информацию о согласиях, если есть
consentRequired: response.data.consentRequired || false,
missingConsents: response.data.missingConsents || [],
consentDocuments: response.data.consentDocuments || [],
autoConsentOnReply: response.data.autoConsentOnReply || false
}); });
} }
// Добавляем системное сообщение для гостя (только на клиенте, не сохраняется в истории) // Добавляем системное сообщение для гостя (только если нет согласия, чтобы не дублировать)
if (isGuestMessage && response.data.systemMessage) { if (isGuestMessage && response.data.systemMessage && !response.data.consentRequired) {
messages.value.push({ messages.value.push({
id: `system-${Date.now()}`, id: `system-${Date.now()}`,
content: response.data.systemMessage, content: response.data.systemMessage,

View File

@@ -48,12 +48,30 @@ export async function connectWithWallet() {
throw new Error('Не удалось получить nonce с сервера'); throw new Error('Не удалось получить nonce с сервера');
} }
// Получаем список документов для подписания
let resources = [`${window.location.origin}/api/auth/verify`];
try {
const docsResponse = await axios.get('/consent/documents');
if (docsResponse.data && docsResponse.data.length > 0) {
docsResponse.data.forEach(doc => {
resources.push(`${window.location.origin}/content/published/${doc.id}`);
});
}
} catch (error) {
// Если не удалось получить документы, продолжаем без них
console.warn('Не удалось получить список документов для подписания:', error);
}
// Создаем сообщение для подписи // Создаем сообщение для подписи
const domain = window.location.host; const domain = window.location.host;
const origin = window.location.origin; const origin = window.location.origin;
const statement = 'Sign in with Ethereum to the app.'; const statement = 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.';
const issuedAt = new Date().toISOString(); const issuedAt = new Date().toISOString();
// Создаем копию resources и сортируем (не мутируем исходный массив)
const sortedResources = [...resources].sort();
const siweMessage = new SiweMessage({ const siweMessage = new SiweMessage({
domain, domain,
address, address,
@@ -63,7 +81,7 @@ export async function connectWithWallet() {
chainId: 1, chainId: 1,
nonce, nonce,
issuedAt, issuedAt,
resources: [`${origin}/api/auth/verify`], resources: sortedResources,
}); });
const message = siweMessage.prepareMessage(); const message = siweMessage.prepareMessage();

View File

@@ -43,8 +43,9 @@ export const connectWallet = async () => {
// Берем первый аккаунт в списке // Берем первый аккаунт в списке
const address = accounts[0]; const address = accounts[0];
// Нормализуем адрес (приводим к нижнему регистру для последующих сравнений) // Нормализуем адрес (используем getAddress для совместимости)
const normalizedAddress = ethers.utils.getAddress(address); // Проверяем версию ethers - если v6, используем ethers.getAddress, иначе ethers.utils.getAddress
const normalizedAddress = ethers.getAddress ? ethers.getAddress(address) : ethers.utils.getAddress(address);
// console.log('Normalized address:', normalizedAddress); // console.log('Normalized address:', normalizedAddress);
// Запрашиваем nonce с сервера // Запрашиваем nonce с сервера
@@ -60,34 +61,70 @@ export const connectWallet = async () => {
}; };
} }
// Создаем провайдер Ethers // Для SIWE используем personal_sign напрямую через window.ethereum
const provider = new ethers.providers.Web3Provider(window.ethereum); // Не используем ethers signer, так как он добавляет префикс, который нарушает SIWE формат
const signer = provider.getSigner();
// Получаем список документов для подписания
let resources = [`${window.location.origin}/api/auth/verify`];
try {
const docsResponse = await axios.get('/consent/documents');
if (docsResponse.data && docsResponse.data.length > 0) {
docsResponse.data.forEach(doc => {
resources.push(`${window.location.origin}/content/published/${doc.id}`);
});
}
} catch (error) {
// Если не удалось получить документы, продолжаем без них
console.warn('Не удалось получить список документов для подписания:', error);
}
// Создаем сообщение для подписи // Создаем сообщение для подписи
const domain = window.location.host; // Важно: domain должен быть hostname без протокола и порта (если порт стандартный)
const domain = window.location.hostname === 'localhost' ?
`localhost:${window.location.port}` :
window.location.hostname;
const origin = window.location.origin; const origin = window.location.origin;
// Создаем SIWE сообщение // Создаем issuedAt один раз, чтобы использовать одинаковый в сообщении и запросе
const issuedAt = new Date().toISOString();
// Создаем копию resources и сортируем (не мутируем исходный массив)
const sortedResources = [...resources].sort();
// Создаем SIWE сообщение с документами в resources
const message = new SiweMessage({ const message = new SiweMessage({
domain, domain,
address: normalizedAddress, address: normalizedAddress,
statement: 'Sign in with Ethereum to the app.', statement: 'Sign in with Ethereum to the app.\n\nПодписывая это сообщение, вы подтверждаете ознакомление с документами, указанными в Resources, и согласие на обработку персональных данных.',
uri: origin, uri: origin,
version: '1', version: '1',
chainId: 1, // Ethereum mainnet chainId: 1, // Ethereum mainnet
nonce: nonce, nonce: nonce,
issuedAt: new Date().toISOString(), issuedAt: issuedAt,
resources: [`${origin}/api/auth/verify`], resources: sortedResources,
}); });
// Получаем строку сообщения для подписи // Получаем строку сообщения для подписи
const messageToSign = message.prepareMessage(); const messageToSign = message.prepareMessage();
// console.log('SIWE message:', messageToSign);
// Запрашиваем подпись // Логируем для отладки
// console.log('Requesting signature...'); console.log('🔐 [Frontend] Domain:', domain);
const signature = await signer.signMessage(messageToSign); console.log('🔐 [Frontend] Origin:', origin);
console.log('🔐 [Frontend] Address:', normalizedAddress);
console.log('🔐 [Frontend] Nonce:', nonce);
console.log('🔐 [Frontend] IssuedAt:', issuedAt);
console.log('🔐 [Frontend] Resources:', JSON.stringify(sortedResources));
console.log('🔐 [Frontend] SIWE message to sign:', messageToSign);
console.log('🔐 [Frontend] Message length:', messageToSign.length);
// Запрашиваем подпись через personal_sign (правильный способ для SIWE)
// personal_sign подписывает сообщение С префиксом "\x19Ethereum Signed Message:\n"
// ethers.verifyMessage() также добавляет этот префикс, поэтому они совместимы
// Параметры: [message, address] - MetaMask принимает строку напрямую
const signature = await window.ethereum.request({
method: 'personal_sign',
params: [messageToSign, normalizedAddress.toLowerCase()],
});
if (!signature) { if (!signature) {
return { return {
@@ -104,7 +141,7 @@ export const connectWallet = async () => {
address: normalizedAddress, address: normalizedAddress,
signature, signature,
nonce, nonce,
issuedAt: new Date().toISOString(), issuedAt: issuedAt, // Используем тот же issuedAt, что и в сообщении
}; };
// console.log('Request data:', requestData); // console.log('Request data:', requestData);

View File

@@ -38,6 +38,7 @@
v-model:attachments="attachments" v-model:attachments="attachments"
@send-message="handleSendMessage" @send-message="handleSendMessage"
@load-more="loadMessages" @load-more="loadMessages"
@remove-consent-messages="handleRemoveConsentMessages"
/> />
</template> </template>
<template v-else> <template v-else>
@@ -50,6 +51,7 @@
v-model:attachments="attachments" v-model:attachments="attachments"
@send-message="handleSendMessage" @send-message="handleSendMessage"
@load-more="loadMessages" @load-more="loadMessages"
@remove-consent-messages="handleRemoveConsentMessages"
/> />
</template> </template>
</div> </div>
@@ -155,6 +157,13 @@
loadMessages({ initial: true }); loadMessages({ initial: true });
} }
}; };
// Функция удаления системных сообщений о согласиях после подписания
const handleRemoveConsentMessages = (messageIds) => {
if (messageIds && Array.isArray(messageIds)) {
messages.value = messages.value.filter(msg => !messageIds.includes(msg.id));
}
};
</script> </script>
<style scoped> <style scoped>