ваше сообщение коммита
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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})`;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, '), сообщение не отправлено');
|
||||
}
|
||||
};
|
||||
|
||||
// Обработчик ответа с балансами
|
||||
|
||||
Reference in New Issue
Block a user