ваше сообщение коммита

This commit is contained in:
2025-11-14 12:34:24 +03:00
parent f37e9e1428
commit bbf1c6aa5a
7 changed files with 726 additions and 110 deletions

View File

@@ -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);

View File

@@ -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()

View File

@@ -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})`;

View File

@@ -319,6 +319,101 @@ class UniversalGuestService {
}
}
/**
* Извлечь имя гостя из текста сообщения через ИИ и сохранить в metadata
* @param {string} identifier - Идентификатор гостя
* @param {string} content - Текст сообщения
* @param {string} channel - Канал
* @returns {Promise<void>}
*/
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);

View File

@@ -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);

View File

@@ -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
});
});
});