From bbf1c6aa5a91d370e83a2986267271e1329370bd Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 14 Nov 2025 12:34:24 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B2=D0=B0=D1=88=D0=B5=20=D1=81=D0=BE=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B8=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routes/messages.js | 342 +++++++++++++++--- backend/routes/settings.js | 23 +- backend/routes/users.js | 202 +++++++++-- backend/services/UniversalGuestService.js | 101 ++++++ backend/services/botsSettings.js | 26 +- backend/services/emailBot.js | 111 ++++-- .../composables/useTokenBalancesWebSocket.js | 31 +- 7 files changed, 726 insertions(+), 110 deletions(-) diff --git a/backend/routes/messages.js b/backend/routes/messages.js index 6aacfd3..912fe1e 100644 --- a/backend/routes/messages.js +++ b/backend/routes/messages.js @@ -13,8 +13,10 @@ const express = require('express'); const router = express.Router(); const db = require('../db'); +const logger = require('../utils/logger'); const { broadcastMessagesUpdate } = require('../wsHub'); const botManager = require('../services/botManager'); +const universalGuestService = require('../services/UniversalGuestService'); const { isUserBlocked } = require('../utils/userUtils'); const { requireAuth } = require('../middleware/auth'); const { requirePermission } = require('../middleware/permissions'); @@ -35,11 +37,166 @@ router.get('/public', requireAuth, async (req, res) => { const encryptionUtils = require('../utils/encryptionUtils'); const encryptionKey = encryptionUtils.getEncryptionKey(); + const parseMetadata = (rawMetadata) => { + if (!rawMetadata) { + return {}; + } + + if (typeof rawMetadata === 'object') { + return rawMetadata; + } + + try { + return JSON.parse(rawMetadata); + } catch (error) { + logger.warn('[messages/public] Не удалось распарсить metadata гостевого сообщения:', error?.message); + return {}; + } + }; + try { // Публичные сообщения видны на главной странице пользователя const targetUserId = userId || currentUserId; - - + const isGuestContact = typeof targetUserId === 'string' && targetUserId.startsWith('guest_'); + + if (isGuestContact) { + const guestId = parseInt(targetUserId.replace('guest_', ''), 10); + + if (Number.isNaN(guestId)) { + return res.status(400).json({ error: 'Invalid guest ID format' }); + } + + const guestIdentifierResult = await db.getQuery()( + `WITH decrypted_guest AS ( + SELECT + id, + decrypt_text(identifier_encrypted, $2) AS guest_identifier, + channel + FROM unified_guest_messages + WHERE user_id IS NULL + ) + SELECT guest_identifier, channel + FROM decrypted_guest + GROUP BY guest_identifier, channel + HAVING MIN(id) = $1 + LIMIT 1`, + [guestId, encryptionKey] + ); + + if (guestIdentifierResult.rows.length === 0) { + return res.json({ + success: true, + messages: [], + total: 0, + limit, + offset, + hasMore: false + }); + } + + const guestIdentifier = guestIdentifierResult.rows[0].guest_identifier; + const guestChannel = guestIdentifierResult.rows[0].channel; + + if (countOnly) { + const countResult = await db.getQuery()( + `SELECT COUNT(*) + FROM unified_guest_messages + WHERE decrypt_text(identifier_encrypted, $2) = $1`, + [guestIdentifier, encryptionKey] + ); + const totalCount = parseInt(countResult.rows[0].count, 10); + return res.json({ success: true, count: totalCount, total: totalCount }); + } + + const messagesResult = await db.getQuery()( + `SELECT + id, + decrypt_text(content_encrypted, $3) AS content, + is_ai, + metadata, + channel, + created_at, + decrypt_text(attachment_filename_encrypted, $3) AS attachment_filename, + decrypt_text(attachment_mimetype_encrypted, $3) AS attachment_mimetype, + attachment_size + FROM unified_guest_messages + WHERE decrypt_text(identifier_encrypted, $3) = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $4`, + [guestIdentifier, limit, encryptionKey, offset] + ); + + const countResult = await db.getQuery()( + `SELECT COUNT(*) + FROM unified_guest_messages + WHERE decrypt_text(identifier_encrypted, $2) = $1`, + [guestIdentifier, encryptionKey] + ); + const totalCount = parseInt(countResult.rows[0].count, 10); + + const mappedMessages = messagesResult.rows.map((row) => { + const metadata = parseMetadata(row.metadata); + const baseMessage = { + id: row.id, + user_id: targetUserId, + sender_id: row.is_ai ? null : targetUserId, + sender_type: row.is_ai ? 'assistant' : 'user', + content: row.content, + channel: row.channel || guestChannel, + role: row.is_ai ? 'assistant' : 'user', + direction: row.is_ai ? 'out' : 'in', + created_at: row.created_at, + message_type: 'public', + is_ai: row.is_ai, + metadata, + last_read_at: null + }; + + if (row.attachment_filename || row.attachment_mimetype || row.attachment_size) { + baseMessage.attachments = [ + { + filename: row.attachment_filename, + mimetype: row.attachment_mimetype, + size: row.attachment_size + } + ]; + } + + if (metadata.consentRequired !== undefined) { + baseMessage.consentRequired = metadata.consentRequired; + } + if (metadata.consentDocuments) { + baseMessage.consentDocuments = metadata.consentDocuments; + } + if (metadata.autoConsentOnReply !== undefined) { + baseMessage.autoConsentOnReply = metadata.autoConsentOnReply; + } + if (metadata.telegramBotUrl) { + baseMessage.telegramBotUrl = metadata.telegramBotUrl; + } + if (metadata.supportEmail) { + baseMessage.supportEmail = metadata.supportEmail; + } + + return baseMessage; + }); + + const orderedMessages = mappedMessages.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)); + + return res.json({ + success: true, + messages: orderedMessages, + total: totalCount, + limit, + offset, + hasMore: offset + limit < totalCount, + guest: { + identifier: guestIdentifier, + channel: guestChannel + } + }); + } + // Если нужен только подсчет if (countOnly) { const countResult = await db.getQuery()( @@ -348,81 +505,177 @@ router.post('/send', requireAuth, async (req, res) => { if (!['public', 'private'].includes(messageType)) { return res.status(400).json({ error: 'messageType должен быть "public" или "private"' }); } - - // Определяем recipientId в зависимости от типа сообщения - let recipientIdNum; - if (messageType === 'private') { - // Приватные сообщения всегда идут к редактору (ID = 1) - recipientIdNum = 1; - } else { - // Конвертируем recipientId в число для публичных сообщений - recipientIdNum = parseInt(recipientId); - if (isNaN(recipientIdNum)) { - return res.status(400).json({ error: 'recipientId должен быть числом' }); - } - } - + + const senderId = req.user.id; + const senderRole = req.user.role || req.user.userAccessLevel?.level || 'user'; + const isGuestRecipient = typeof recipientId === 'string' && recipientId.startsWith('guest_'); + try { - const senderId = req.user.id; - const senderRole = req.user.role || req.user.userAccessLevel?.level || 'user'; - + if (isGuestRecipient) { + if (!hasPermission(senderRole, PERMISSIONS.SEND_TO_USERS)) { + return res.status(403).json({ error: 'Недостаточно прав для отправки сообщений гостям' }); + } + + if (messageType !== 'public') { + return res.status(400).json({ error: 'Гостям можно отправлять только публичные сообщения' }); + } + + const guestInternalId = parseInt(recipientId.replace('guest_', ''), 10); + if (Number.isNaN(guestInternalId)) { + return res.status(400).json({ error: 'Некорректный формат гостевого идентификатора' }); + } + + const encryptionUtils = require('../utils/encryptionUtils'); + const encryptionKey = encryptionUtils.getEncryptionKey(); + + const guestIdentifierResult = await db.getQuery()( + `WITH decrypted_guest AS ( + SELECT + id, + decrypt_text(identifier_encrypted, $2) AS guest_identifier, + channel + FROM unified_guest_messages + WHERE user_id IS NULL + ) + SELECT guest_identifier, channel + FROM decrypted_guest + GROUP BY guest_identifier, channel + HAVING MIN(id) = $1 + LIMIT 1`, + [guestInternalId, encryptionKey] + ); + + if (guestIdentifierResult.rows.length === 0) { + return res.status(404).json({ error: 'Гостевой контакт не найден' }); + } + + const guestIdentifier = guestIdentifierResult.rows[0].guest_identifier; + const guestChannel = guestIdentifierResult.rows[0].channel; + const deliveryMeta = { + sentBy: 'admin_panel', + senderId, + senderRole, + originalRecipientId: recipientId, + messageType + }; + + let deliveryStatus = { success: true }; + + try { + if (guestChannel === 'telegram') { + const telegramBot = botManager.getBot('telegram'); + if (telegramBot && telegramBot.isInitialized) { + await telegramBot.getBot().telegram.sendMessage(guestIdentifier, content); + } else { + logger.warn('[messages/send] Telegram Bot не инициализирован, сообщение сохранено только в истории'); + deliveryStatus = { success: false, error: 'Telegram bot inactive' }; + } + } else if (guestChannel === 'email') { + const emailBot = botManager.getBot('email'); + if (emailBot && emailBot.isInitialized) { + await emailBot.sendEmail(guestIdentifier, 'Ответ от администратора', content); + } else { + logger.warn('[messages/send] Email Bot не инициализирован, сообщение сохранено только в истории'); + deliveryStatus = { success: false, error: 'Email bot inactive' }; + } + } else { + logger.info(`[messages/send] Гость ${guestIdentifier} имеет канал ${guestChannel}, внешняя доставка не требуется`); + } + } catch (deliveryError) { + logger.error('[messages/send] Ошибка отправки гостю через внешний канал:', deliveryError); + deliveryStatus = { success: false, error: deliveryError.message }; + } + + const saveResult = await universalGuestService.saveAiResponse({ + identifier: guestIdentifier, + channel: guestChannel, + content, + metadata: deliveryMeta + }); + + broadcastMessagesUpdate(); + + return res.json({ + success: true, + message: { + id: saveResult.messageId, + user_id: recipientId, + sender_id: null, + sender_type: 'assistant', + content, + channel: guestChannel, + role: 'assistant', + direction: 'out', + created_at: saveResult.created_at, + message_type: 'public', + metadata: deliveryMeta + }, + delivery: deliveryStatus + }); + } + + // Работа с зарегистрированными пользователями + let recipientIdNum; + if (messageType === 'private') { + recipientIdNum = 1; + } else { + recipientIdNum = parseInt(recipientId, 10); + if (Number.isNaN(recipientIdNum)) { + return res.status(400).json({ error: 'recipientId должен быть числом' }); + } + } + console.log('[DEBUG] /messages/send: senderId:', senderId, 'senderRole:', senderRole); - - // Получаем информацию о получателе + const recipientResult = await db.getQuery()( 'SELECT id, role FROM users WHERE id = $1', [recipientIdNum] ); - + if (recipientResult.rows.length === 0) { return res.status(404).json({ error: 'Получатель не найден' }); } - + const recipientRole = recipientResult.rows[0].role; console.log('[DEBUG] /messages/send: recipientId:', recipientIdNum, 'recipientRole:', recipientRole); - - // Используем централизованную проверку прав + const { canSendMessage } = require('/app/shared/permissions'); const permissionCheck = canSendMessage(senderRole, recipientRole, senderId, recipientIdNum); - + console.log('[DEBUG] /messages/send: canSend:', permissionCheck.canSend, 'senderRole:', senderRole, 'recipientRole:', recipientRole, 'error:', permissionCheck.errorMessage); - + if (!permissionCheck.canSend) { - return res.status(403).json({ - error: permissionCheck.errorMessage || 'Недостаточно прав для отправки сообщения этому получателю' + return res.status(403).json({ + error: permissionCheck.errorMessage || 'Недостаточно прав для отправки сообщения этому получателю' }); } - - // ✨ Используем unifiedMessageProcessor для унификации + const unifiedMessageProcessor = require('../services/unifiedMessageProcessor'); const identityService = require('../services/identity-service'); - - // Получаем wallet идентификатор отправителя + const walletIdentity = await identityService.findIdentity(senderId, 'wallet'); if (!walletIdentity) { return res.status(403).json({ error: 'Требуется подключение кошелька' }); } - + const identifier = `wallet:${walletIdentity.provider_id}`; - - // Обрабатываем через unifiedMessageProcessor + const result = await unifiedMessageProcessor.processMessage({ - identifier: identifier, - content: content, + identifier, + content, channel: 'web', attachments: [], - conversationId: null, // unifiedMessageProcessor сам найдет/создаст беседу + conversationId: null, recipientId: recipientIdNum, userId: senderId, metadata: { - messageType: messageType, - markAsRead: markAsRead + messageType, + markAsRead } }); - - // Если нужно отметить как прочитанное + if (markAsRead) { try { const lastReadAt = new Date().toISOString(); @@ -434,10 +687,9 @@ router.post('/send', requireAuth, async (req, res) => { ); } catch (markError) { console.warn('[WARNING] /send mark-read error:', markError); - // Не прерываем выполнение, если mark-read не удался } } - + res.json({ success: true, message: result }); } catch (e) { console.error('[ERROR] /send:', e); diff --git a/backend/routes/settings.js b/backend/routes/settings.js index 7679e07..b881a06 100644 --- a/backend/routes/settings.js +++ b/backend/routes/settings.js @@ -622,24 +622,37 @@ router.put('/email-settings', requireAdmin, async (req, res, next) => { is_active } = req.body; - // Валидация обязательных полей - if (!imap_host || !imap_port || !imap_user || !imap_password || - !smtp_host || !smtp_port || !smtp_user || !smtp_password || !from_email) { + // Загрузка текущих настроек (нужна для сохранения старых паролей) + const currentSettings = await botsSettings.getBotSettings('email'); + + // Валидация обязательных полей (пароли могут быть опущены, если уже сохранены) + if (!imap_host || !imap_port || !imap_user || + !smtp_host || !smtp_port || !smtp_user || !from_email) { return res.status(400).json({ success: false, error: 'Все поля обязательны для заполнения' }); } + + const finalImapPassword = imap_password || currentSettings?.imap_password; + const finalSmtpPassword = smtp_password || currentSettings?.smtp_password; + + if (!finalImapPassword || !finalSmtpPassword) { + return res.status(400).json({ + success: false, + error: 'Необходимо указать IMAP и SMTP пароли' + }); + } const settings = { imap_host, imap_port: parseInt(imap_port), imap_user, - imap_password, + imap_password: finalImapPassword, smtp_host, smtp_port: parseInt(smtp_port), smtp_user, - smtp_password, + smtp_password: finalSmtpPassword, from_email, is_active: is_active !== undefined ? is_active : true, updated_at: new Date() diff --git a/backend/routes/users.js b/backend/routes/users.js index e53de40..96213f2 100644 --- a/backend/routes/users.js +++ b/backend/routes/users.js @@ -239,20 +239,33 @@ router.get('/', requireAuth, async (req, res, next) => { decrypt_text(identifier_encrypted, $1) as guest_identifier, channel, created_at, - user_id + user_id, + metadata FROM unified_guest_messages WHERE user_id IS NULL ), - guest_groups AS ( - SELECT - MIN(id) as guest_id, + first_messages AS ( + SELECT DISTINCT ON (guest_identifier, channel) + id as guest_id, guest_identifier, channel, - MIN(created_at) as created_at, - MAX(created_at) as last_message_at, - COUNT(*) as message_count + metadata, + created_at FROM decrypted_guests - GROUP BY guest_identifier, channel + ORDER BY guest_identifier, channel, id ASC + ), + guest_groups AS ( + SELECT + fm.guest_id, + fm.guest_identifier, + fm.channel, + fm.metadata, + fm.created_at, + MAX(dg.created_at) as last_message_at, + COUNT(*) as message_count + FROM first_messages fm + JOIN decrypted_guests dg ON dg.guest_identifier = fm.guest_identifier AND dg.channel = fm.channel + GROUP BY fm.guest_id, fm.guest_identifier, fm.channel, fm.metadata, fm.created_at ) SELECT ROW_NUMBER() OVER (ORDER BY guest_id ASC) as guest_number, @@ -261,7 +274,8 @@ router.get('/', requireAuth, async (req, res, next) => { channel, created_at, last_message_at, - message_count + message_count, + metadata FROM guest_groups ORDER BY guest_id ASC`, [encryptionKey] @@ -276,9 +290,21 @@ router.get('/', requireAuth, async (req, res, next) => { const icon = channelMap[g.channel] || '👤'; const rawId = g.guest_identifier.replace(`${g.channel}:`, ''); - // Формируем имя в зависимости от канала + // Проверяем, есть ли кастомное имя в metadata + let metadata = g.metadata || {}; + if (typeof metadata === 'string') { + try { + metadata = JSON.parse(metadata); + } catch (e) { + metadata = {}; + } + } + + // Формируем имя: сначала проверяем кастомное имя, затем генерируем автоматически let displayName; - if (g.channel === 'email') { + if (metadata.custom_name) { + displayName = metadata.custom_name; + } else if (g.channel === 'email') { displayName = `${icon} ${rawId}`; } else if (g.channel === 'telegram') { displayName = `${icon} Telegram (${rawId})`; @@ -456,14 +482,121 @@ router.patch('/:id', requireAuth, requirePermission(PERMISSIONS.EDIT_CONTACTS), try { const userId = req.params.id; const { first_name, last_name, name, preferred_language, language, is_blocked } = req.body; - const fields = []; - const values = []; - let idx = 1; // Получаем ключ шифрования один раз const encryptionUtils = require('../utils/encryptionUtils'); const encryptionKey = encryptionUtils.getEncryptionKey(); + // Обработка гостевых контактов (guest_123) + if (userId.startsWith('guest_')) { + const guestId = parseInt(userId.replace('guest_', '')); + + if (isNaN(guestId)) { + return res.status(400).json({ success: false, error: 'Invalid guest ID format' }); + } + + // Проверяем, существует ли гость и получаем его идентификатор + const guestResult = await db.getQuery()( + `WITH decrypted_guest AS ( + SELECT + id, + decrypt_text(identifier_encrypted, $2) as guest_identifier, + channel, + metadata + FROM unified_guest_messages + WHERE user_id IS NULL + ) + SELECT + id as first_message_id, + guest_identifier, + channel, + metadata + FROM decrypted_guest + WHERE id = $1 + LIMIT 1`, + [guestId, encryptionKey] + ); + + if (guestResult.rows.length === 0) { + return res.status(404).json({ success: false, error: 'Guest contact not found' }); + } + + const guest = guestResult.rows[0]; + const firstMessageId = guest.first_message_id; + let metadata = guest.metadata || {}; + + // Если metadata - строка, парсим её + if (typeof metadata === 'string') { + try { + metadata = JSON.parse(metadata); + } catch (e) { + metadata = {}; + } + } + + // Обработка имени гостя + let hasUpdates = false; + if (name !== undefined) { + const nameParts = name.trim().split(' '); + metadata.custom_name = name.trim(); + metadata.custom_first_name = nameParts[0] || ''; + metadata.custom_last_name = nameParts.slice(1).join(' ') || ''; + hasUpdates = true; + } else { + if (first_name !== undefined) { + metadata.custom_first_name = first_name; + // Обновляем полное имя, если есть + if (metadata.custom_last_name) { + metadata.custom_name = `${first_name} ${metadata.custom_last_name}`.trim(); + } else { + metadata.custom_name = first_name; + } + hasUpdates = true; + } + if (last_name !== undefined) { + metadata.custom_last_name = last_name; + // Обновляем полное имя, если есть + if (metadata.custom_first_name) { + metadata.custom_name = `${metadata.custom_first_name} ${last_name}`.trim(); + } else { + metadata.custom_name = last_name; + } + hasUpdates = true; + } + } + + // Если имя пустое, удаляем кастомное имя + if (name === '' || (first_name === '' && last_name === '')) { + delete metadata.custom_name; + delete metadata.custom_first_name; + delete metadata.custom_last_name; + hasUpdates = true; + } + + if (!hasUpdates) { + return res.status(400).json({ success: false, error: 'Нет данных для обновления' }); + } + + // Обновляем metadata первого сообщения гостя + await db.getQuery()( + `UPDATE unified_guest_messages + SET metadata = $1 + WHERE id = $2`, + [JSON.stringify(metadata), firstMessageId] + ); + + broadcastContactsUpdate(); + return res.json({ + success: true, + message: 'Имя гостя обновлено' + }); + } + + // Обработка обычных пользователей + const fields = []; + const values = []; + let idx = 1; + // Обработка поля name - разбиваем на first_name и last_name if (name !== undefined) { const nameParts = name.trim().split(' '); @@ -504,8 +637,15 @@ router.patch('/:id', requireAuth, requirePermission(PERMISSIONS.EDIT_CONTACTS), } } if (!fields.length) return res.status(400).json({ success: false, error: 'Нет данных для обновления' }); + + // Проверяем, что userId - это число + const userIdNum = Number(userId); + if (isNaN(userIdNum)) { + return res.status(400).json({ success: false, error: 'Invalid user ID format' }); + } + const sql = `UPDATE users SET ${fields.join(', ')} WHERE id = $${idx}`; - values.push(userId); + values.push(userIdNum); await db.query(sql, values); broadcastContactsUpdate(); res.json({ success: true, message: 'Пользователь обновлен' }); @@ -652,20 +792,22 @@ router.get('/:id', requireAuth, requirePermission(PERMISSIONS.VIEW_CONTACTS), as decrypt_text(identifier_encrypted, $2) as guest_identifier, channel, created_at, - user_id + user_id, + metadata FROM unified_guest_messages WHERE user_id IS NULL ) SELECT - MIN(id) as guest_id, + id as guest_id, guest_identifier, channel, - MIN(created_at) as created_at, - MAX(created_at) as last_message_at, - COUNT(*) as message_count + created_at, + (SELECT MAX(created_at) FROM decrypted_guest dg2 WHERE dg2.guest_identifier = decrypted_guest.guest_identifier AND dg2.channel = decrypted_guest.channel) as last_message_at, + (SELECT COUNT(*) FROM decrypted_guest dg2 WHERE dg2.guest_identifier = decrypted_guest.guest_identifier AND dg2.channel = decrypted_guest.channel) as message_count, + metadata FROM decrypted_guest - GROUP BY guest_identifier, channel - HAVING MIN(id) = $1`, + WHERE id = $1 + LIMIT 1`, [guestId, encryptionKey] ); @@ -682,9 +824,21 @@ router.get('/:id', requireAuth, requirePermission(PERMISSIONS.VIEW_CONTACTS), as }; const icon = channelMap[guest.channel] || '👤'; - // Формируем имя в зависимости от канала + // Проверяем, есть ли кастомное имя в metadata + let metadata = guest.metadata || {}; + if (typeof metadata === 'string') { + try { + metadata = JSON.parse(metadata); + } catch (e) { + metadata = {}; + } + } + + // Формируем имя: сначала проверяем кастомное имя, затем генерируем автоматически let displayName; - if (guest.channel === 'email') { + if (metadata.custom_name) { + displayName = metadata.custom_name; + } else if (guest.channel === 'email') { displayName = `${icon} ${rawId}`; } else if (guest.channel === 'telegram') { displayName = `${icon} Telegram (${rawId})`; diff --git a/backend/services/UniversalGuestService.js b/backend/services/UniversalGuestService.js index fb9746d..e8975f2 100644 --- a/backend/services/UniversalGuestService.js +++ b/backend/services/UniversalGuestService.js @@ -319,6 +319,101 @@ class UniversalGuestService { } } + /** + * Извлечь имя гостя из текста сообщения через ИИ и сохранить в metadata + * @param {string} identifier - Идентификатор гостя + * @param {string} content - Текст сообщения + * @param {string} channel - Канал + * @returns {Promise} + */ + async extractAndSaveGuestName(identifier, content, channel) { + try { + if (!content || !content.trim()) { + return; // Нет текста для анализа + } + + const encryptionKey = encryptionUtils.getEncryptionKey(); + + // Находим первое сообщение гостя + const firstMessageResult = await db.getQuery()( + `WITH decrypted_guest AS ( + SELECT + id, + decrypt_text(identifier_encrypted, $2) as guest_identifier, + channel, + metadata + FROM unified_guest_messages + WHERE user_id IS NULL + ) + SELECT + MIN(id) as first_message_id, + MIN(metadata) as metadata + FROM decrypted_guest + WHERE guest_identifier = $1 AND channel = $3 + GROUP BY guest_identifier, channel`, + [identifier, encryptionKey, channel] + ); + + if (firstMessageResult.rows.length === 0) { + return; // Гость не найден + } + + const firstMessage = firstMessageResult.rows[0]; + let metadata = firstMessage.metadata || {}; + + // Если metadata - строка, парсим её + if (typeof metadata === 'string') { + try { + metadata = JSON.parse(metadata); + } catch (e) { + metadata = {}; + } + } + + // Если уже есть кастомное имя, не извлекаем заново + if (metadata.custom_name) { + logger.info(`[UniversalGuestService] У гостя ${identifier} уже есть кастомное имя: ${metadata.custom_name}`); + return; + } + + // Используем существующий сервис для извлечения имени + const profileAnalysisService = require('./profileAnalysisService'); + const nameResult = await profileAnalysisService.extractName(content); + + // Проверяем результат извлечения имени + if (!nameResult || !nameResult.name || !nameResult.should_update_name) { + logger.info(`[UniversalGuestService] Имя не найдено в сообщении гостя ${identifier} (confidence: ${nameResult?.confidence || 0})`); + return; + } + + const extractedName = nameResult.name; + + // Разбиваем имя на части + const nameParts = extractedName.split(' '); + const firstName = nameParts[0] || ''; + const lastName = nameParts.slice(1).join(' ') || ''; + + // Сохраняем имя в metadata + metadata.custom_name = extractedName; + metadata.custom_first_name = firstName; + metadata.custom_last_name = lastName; + + // Обновляем metadata первого сообщения гостя + await db.getQuery()( + `UPDATE unified_guest_messages + SET metadata = $1 + WHERE id = $2`, + [JSON.stringify(metadata), firstMessage.first_message_id] + ); + + logger.info(`[UniversalGuestService] Имя гостя ${identifier} извлечено и сохранено: ${extractedName}`); + + } catch (error) { + logger.error('[UniversalGuestService] Ошибка извлечения имени гостя:', error); + throw error; + } + } + /** * Обработать сообщение гостя (сохранить + получить AI ответ) * @param {Object} messageData @@ -398,6 +493,12 @@ class UniversalGuestService { const saveResult = await this.saveMessage(messageData); const processedContent = saveResult.processedContent; + // 1.5. Извлекаем имя из текста сообщения через ИИ (если это первое сообщение гостя) + await this.extractAndSaveGuestName(identifier, content, channel).catch(error => { + // Не критично, если не удалось извлечь имя - просто логируем + logger.warn(`[UniversalGuestService] Ошибка извлечения имени гостя:`, error); + }); + // 2. Загружаем историю для контекста (заново, так как могли добавиться сообщения) const conversationHistory = await this.getHistory(identifier); diff --git a/backend/services/botsSettings.js b/backend/services/botsSettings.js index 7c852cb..23a5bce 100644 --- a/backend/services/botsSettings.js +++ b/backend/services/botsSettings.js @@ -70,16 +70,24 @@ async function saveBotSettings(botType, settings) { throw new Error(`Unknown bot type: ${botType}`); } - // Простое сохранение - детали зависят от структуры таблицы - const { rows } = await db.getQuery()( - `INSERT INTO ${tableName} (settings, updated_at) - VALUES ($1, NOW()) - ON CONFLICT (id) DO UPDATE SET settings = $1, updated_at = NOW() - RETURNING *`, - [JSON.stringify(settings)] - ); + const dataToSave = { + ...settings, + updated_at: new Date() + }; - return rows[0]; + // Проверяем, существуют ли записи в таблице + const existing = await encryptedDb.getData(tableName, {}, 1); + + if (existing.length > 0) { + // Обновляем первую запись (ожидаем, что таблица хранит единственную конфигурацию) + return await encryptedDb.saveData(tableName, dataToSave, { id: existing[0].id }); + } + + // Если записей нет, создаем новую + return await encryptedDb.saveData(tableName, { + ...dataToSave, + created_at: new Date() + }); } catch (error) { logger.error(`[BotsSettings] Ошибка сохранения настроек ${botType}:`, error); diff --git a/backend/services/emailBot.js b/backend/services/emailBot.js index 69eda86..81a85f7 100644 --- a/backend/services/emailBot.js +++ b/backend/services/emailBot.js @@ -33,6 +33,7 @@ class EmailBot { this.status = 'inactive'; this.reconnectAttempts = 0; this.maxReconnectAttempts = 3; + this.periodicCheckInterval = null; } /** @@ -154,7 +155,9 @@ class EmailBot { authTimeout: 60000, greetingTimeout: 30000, socketTimeout: 60000, - debug: false + debug: (info) => { + logger.debug(`[EmailBot IMAP] ${info}`); + } }); // Настраиваем обработчики событий @@ -177,6 +180,8 @@ class EmailBot { logger.info('[EmailBot] IMAP соединение установлено'); this.reconnectAttempts = 0; this.checkEmails(); + // Запускаем периодическую проверку новых писем каждые 5 минут + this.startPeriodicCheck(); }); this.imap.once('end', () => { @@ -200,6 +205,9 @@ class EmailBot { * Очистка IMAP соединения */ cleanupImap() { + // Останавливаем периодическую проверку + this.stopPeriodicCheck(); + if (this.imap) { try { this.imap.removeAllListeners('error'); @@ -244,22 +252,66 @@ class EmailBot { setTimeout(() => this.initializeImap(), reconnectDelay); } + /** + * Запуск периодической проверки новых писем + */ + startPeriodicCheck() { + // Останавливаем предыдущий интервал, если он есть + this.stopPeriodicCheck(); + + // Проверяем новые письма каждые 5 минут + this.periodicCheckInterval = setInterval(() => { + if (this.imap && this.imap.state === 'authenticated') { + logger.info('[EmailBot] Периодическая проверка новых писем...'); + this.checkEmails(); + } else { + logger.warn('[EmailBot] IMAP соединение не активно, пропускаем периодическую проверку'); + } + }, 5 * 60 * 1000); // 5 минут + + logger.info('[EmailBot] Периодическая проверка новых писем запущена (каждые 5 минут)'); + } + + /** + * Остановка периодической проверки + */ + stopPeriodicCheck() { + if (this.periodicCheckInterval) { + clearInterval(this.periodicCheckInterval); + this.periodicCheckInterval = null; + logger.info('[EmailBot] Периодическая проверка остановлена'); + } + } + /** * Проверка входящих писем */ checkEmails() { try { + logger.info('[EmailBot] Проверка входящих писем...'); this.imap.openBox('INBOX', false, (err, box) => { if (err) { logger.error('[EmailBot] Ошибка открытия INBOX:', err); return; } - this.imap.search(['ALL'], (err, results) => { - if (err || !results || results.length === 0) { - this.imap.end(); + logger.info(`[EmailBot] INBOX открыт. Всего сообщений: ${box.messages.total}`); + + // Ищем только непрочитанные сообщения (UNSEEN) + this.imap.search(['UNSEEN'], (err, results) => { + if (err) { + logger.error('[EmailBot] Ошибка поиска писем:', err); + // Не закрываем соединение при ошибке поиска, оставляем его открытым для keepalive return; } + + if (!results || results.length === 0) { + logger.info('[EmailBot] Новых непрочитанных писем нет'); + // Не закрываем соединение, оставляем его открытым для keepalive + return; + } + + logger.info(`[EmailBot] Найдено ${results.length} непрочитанных писем`); const f = this.imap.fetch(results, { bodies: '', @@ -284,9 +336,11 @@ class EmailBot { msg.on('body', (stream, info) => { simpleParser(stream, async (err, parsed) => { if (err) { + logger.error(`[EmailBot] Ошибка парсинга письма ${seqno}:`, err); processedCount++; if (processedCount >= totalMessages) { - this.imap.end(); + logger.info('[EmailBot] Обработка всех писем завершена'); + // Не закрываем соединение, оставляем его открытым для keepalive } return; } @@ -295,29 +349,44 @@ class EmailBot { messageId = parsed.messageId; } + const fromEmail = parsed.from?.value?.[0]?.address; + logger.info(`[EmailBot] Обработка письма ${seqno} от ${fromEmail || 'неизвестного отправителя'}`); + const messageData = await this.extractMessageData(parsed, messageId, uid); if (messageData && this.messageProcessor) { - // Обрабатываем сообщение через унифицированный процессор - // Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора - 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 - ); + try { + // Обрабатываем сообщение через унифицированный процессор + // Системное сообщение о согласиях будет добавлено к ответу ИИ внутри процессора + const result = await this.messageProcessor(messageData); + logger.info(`[EmailBot] Письмо ${seqno} обработано успешно`); + + // Если есть ответ ИИ с информацией о согласиях, отправляем email + if (result && result.success && result.aiResponse) { + if (fromEmail) { + logger.info(`[EmailBot] Отправка ответа ИИ на ${fromEmail}`); + // Ответ ИИ уже содержит системное сообщение о согласиях (если нужно) + await this.sendEmail( + fromEmail, + 'Ответ на ваше сообщение', + result.aiResponse.response + ); + } } + } catch (processError) { + logger.error(`[EmailBot] Ошибка обработки письма ${seqno}:`, processError); + } + } else { + if (!messageData) { + logger.warn(`[EmailBot] Письмо ${seqno} отфильтровано (системное или некорректное)`); + } else if (!this.messageProcessor) { + logger.warn('[EmailBot] messageProcessor не установлен, письмо не обработано'); } } processedCount++; if (processedCount >= totalMessages) { - this.imap.end(); + logger.info('[EmailBot] Обработка всех писем завершена'); + // Не закрываем соединение, оставляем его открытым для keepalive } }); }); @@ -325,7 +394,7 @@ class EmailBot { f.once('error', (err) => { logger.error('[EmailBot] Ошибка получения писем:', err); - this.imap.end(); + // Не закрываем соединение при ошибке fetch, оставляем его открытым для keepalive }); }); }); diff --git a/frontend/src/composables/useTokenBalancesWebSocket.js b/frontend/src/composables/useTokenBalancesWebSocket.js index ff69852..392ca69 100644 --- a/frontend/src/composables/useTokenBalancesWebSocket.js +++ b/frontend/src/composables/useTokenBalancesWebSocket.js @@ -29,14 +29,33 @@ export function useTokenBalancesWebSocket() { console.log('[useTokenBalancesWebSocket] Запрашиваем балансы для:', address, 'userId:', userId); isLoadingTokens.value = true; - const message = { - type: 'request_token_balances', - address: address, - userId: userId + const sendMessage = () => { + const message = { + type: 'request_token_balances', + address: address, + userId: userId + }; + console.log('[useTokenBalancesWebSocket] Отправляем WebSocket сообщение:', message); + wsClient.ws.send(JSON.stringify(message)); }; - console.log('[useTokenBalancesWebSocket] Отправляем WebSocket сообщение:', message); - wsClient.ws.send(JSON.stringify(message)); + if (!wsClient.ws || wsClient.ws.readyState === WebSocket.CLOSED) { + console.log('[useTokenBalancesWebSocket] WS закрыт, переподключаемся'); + wsClient.connect(); + } + + if (wsClient.ws.readyState === WebSocket.OPEN) { + sendMessage(); + } else if (wsClient.ws.readyState === WebSocket.CONNECTING) { + console.log('[useTokenBalancesWebSocket] WS в CONNECTING, откладываем отправку'); + const onConnected = () => { + wsClient.off('connected', onConnected); + sendMessage(); + }; + wsClient.on('connected', onConnected); + } else { + console.warn('[useTokenBalancesWebSocket] WS не готов (state:', wsClient.ws.readyState, '), сообщение не отправлено'); + } }; // Обработчик ответа с балансами