diff --git a/.gitignore b/.gitignore index 196386f..c47e9af 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ templates/smartadmin/ db/*.db db/*.db-wal db/*.db-shm +db/*.backup-* +db/*.pre-merge-* # Root-level throwaway scripts /*.mjs diff --git a/src/handlers/userHandlers/userHandler.js b/src/handlers/userHandlers/userHandler.js index 97bd7f2..f114ba9 100644 --- a/src/handlers/userHandlers/userHandler.js +++ b/src/handlers/userHandlers/userHandler.js @@ -7,6 +7,8 @@ import WalletService from "../../services/walletService.js"; import logger from "../../utils/logger.js"; import { resetUserContext } from "../../utils/messageUtils.js"; import userStates from "../../context/userStates.js"; +import chatbotService from "../../services/chatbotService.js"; +import leadService from "../../services/leadService.js"; import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js'; export default class UserHandler { @@ -109,6 +111,41 @@ ${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateStrin username: username }); + // Фиксация лида при активации чата (/start) + const lead = await leadService.getOrCreateLead({ + telegramId, + username, + name: msg.from?.first_name || null, + }); + if (lead?.id) { + await leadService.logInteraction({ + telegramId, + leadId: lead.id, + action: 'bot_start', + details: { username: username || null, chatType: msg.chat?.type || 'private' }, + }); + } + + // Sleep mode: если магазин на паузе — живой ИИ-диалог вместо каталога + if (await chatbotService.isSleepMode()) { + const lang = (await UserService.getUserByTelegramId(telegramId))?.language || 'en'; + // Приветствие от ИИ-бота (живой диалог, не заглушка) + const welcome = await chatbotService.sendToChatbot({ + sessionId: telegramId, + message: '/start', + telegramId, + language: lang, + username, + name: msg.from?.first_name, + }); + if (welcome.reply) { + await bot.sendMessage(chatId, welcome.reply); + } else { + await bot.sendMessage(chatId, await chatbotService.getWelcomeMessage()); + } + return; + } + const keyboard = { inline_keyboard: AVAILABLE_LANGUAGES.map(code => [{ text: LANGUAGE_NAMES[code], diff --git a/src/index.js b/src/index.js index adff053..b5a190e 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,9 @@ import callbackRouter from './router/callbackRouter.js'; import messageRouter from './router/messageRouter.js'; import { initStates } from './services/stateService.js'; +import chatbotService from './services/chatbotService.js'; +import leadService from './services/leadService.js'; +import UserService from './services/userService.js'; import { resetUserContext } from './utils/messageUtils.js'; import { tryAcquireLock, pruneLocks } from './utils/callbackLock.js'; @@ -55,7 +58,44 @@ if (bot && botAvailable) { const canUse = await userHandler.canUseBot(msg); if (!canUse) return; try { - await messageRouter.dispatch(msg); + // Синхронизация лида при каждом сообщении (фиксация telegram_id в админке) + const leadSync = await leadService.syncLeadFromMessage(msg); + + const dispatched = await messageRouter.dispatch(msg); + // Если сообщение не обработано роутером и чатбот включён — отправить в ИИ + if (!dispatched && msg.text && await chatbotService.isChatbotEnabled()) { + const user = msg.__user || await UserService.getUserByTelegramId(msg.from.id); + const lang = user?.language || 'en'; + const result = await chatbotService.sendToChatbot({ + sessionId: msg.from.id, + message: msg.text, + telegramId: msg.from.id, + language: lang, + username: msg.chat?.username || msg.from?.username, + name: msg.from?.first_name, + fingerprint: { + device: msg.from?.is_bot ? 'bot' : 'telegram', + ip: null, // Telegram API не отдаёт IP + country: null, + geoAddress: null, + }, + }); + if (leadSync?.leadId) { + await leadService.logInteraction({ + telegramId: msg.from.id, + leadId: leadSync.leadId, + action: 'bot_ai_reply', + details: { + replied: !!result.reply, + replyLength: result.reply ? result.reply.length : 0, + error: result.error || null, + }, + }); + } + if (result.reply) { + await bot.sendMessage(msg.chat.id, result.reply); + } + } } catch (error) { await ErrorHandler.handleError(bot, msg.chat.id, error, 'message handler'); } diff --git a/src/migrations/013_ai_features.js b/src/migrations/013_ai_features.js new file mode 100644 index 0000000..a90a6d3 --- /dev/null +++ b/src/migrations/013_ai_features.js @@ -0,0 +1,66 @@ +import logger from '../utils/logger.js'; + +// Migration 013: AI-feature tables (leads, site_settings, chat_sessions) +// Создаются таблицы для ИИ-диалога и лидов. Идемпотентно — CREATE IF NOT EXISTS. +export default async function migration013(db) { + await db.runAsync('BEGIN TRANSACTION'); + try { + await db.runAsync(`CREATE TABLE IF NOT EXISTS leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id TEXT UNIQUE, + name TEXT, + phone TEXT, + email TEXT, + telegram TEXT, + status TEXT NOT NULL DEFAULT 'new', + verification TEXT NOT NULL DEFAULT 'pending', + notes TEXT, + custom_fields TEXT NOT NULL DEFAULT '{}', + geo_address TEXT, + ai_lead_score REAL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); + + await db.runAsync(`CREATE TABLE IF NOT EXISTS site_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key TEXT UNIQUE NOT NULL, + value TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); + + await db.runAsync(`CREATE TABLE IF NOT EXISTS chat_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT UNIQUE NOT NULL, + telegram_id TEXT, + lead_id INTEGER, + messages TEXT NOT NULL, + device TEXT, + ip TEXT, + country TEXT, + customer_profile TEXT, + is_active INTEGER NOT NULL DEFAULT 1, + operator_name TEXT, + auto_reply_disabled INTEGER NOT NULL DEFAULT 0, + operator_connected_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + language TEXT NOT NULL DEFAULT 'en' + )`); + + // Базовые настройки ИИ-режима (по умолчанию выключен — не ломает текущее поведение) + await db.runAsync( + "INSERT OR IGNORE INTO site_settings (key, value) VALUES ('chatbot_enabled', '0')" + ); + await db.runAsync( + "INSERT OR IGNORE INTO site_settings (key, value) VALUES ('sleep_mode', '0')" + ); + + await db.runAsync('COMMIT'); + logger.info('Migration 013: AI-feature tables created'); + } catch (e) { + await db.runAsync('ROLLBACK'); + throw e; + } +} diff --git a/src/migrations/runner.js b/src/migrations/runner.js index 3e4d940..39df6e0 100644 --- a/src/migrations/runner.js +++ b/src/migrations/runner.js @@ -47,6 +47,7 @@ export async function runMigrations() { (await import('./010_is_mono_product.js')).default, (await import('./011_active_flags.js')).default, (await import('./012_fix_typos.js')).default, + (await import('./013_ai_features.js')).default, ]; for (let i = currentVersion; i < migrations.length; i++) { diff --git a/src/router/messageRouter.js b/src/router/messageRouter.js index 1d0664b..60b5518 100644 --- a/src/router/messageRouter.js +++ b/src/router/messageRouter.js @@ -22,10 +22,10 @@ class MessageRouter { async dispatch(msg) { for (const handler of this.inputHandlers) { - if (await handler(msg)) return; + if (await handler(msg)) return true; } - if (!msg.text) return; + if (!msg.text) return false; const user = msg.__user || await UserService.getUserByTelegramId(msg.from.id); const lang = user?.language || 'en'; @@ -35,7 +35,7 @@ class MessageRouter { if (localeKey && this.textHandlers.has(localeKey)) { await this.textHandlers.get(localeKey)(msg); - return; + return true; } for (const l of AVAILABLE_LANGUAGES) { @@ -43,9 +43,11 @@ class MessageRouter { const lk = this.localeKeyMap.get(key); if (lk && this.textHandlers.has(lk)) { await this.textHandlers.get(lk)(msg); - return; + return true; } } + + return false; } } diff --git a/src/router/routes.js b/src/router/routes.js index 2b78246..87a1e3c 100644 --- a/src/router/routes.js +++ b/src/router/routes.js @@ -1,8 +1,12 @@ import callbackRouter from './callbackRouter.js'; import messageRouter from './messageRouter.js'; +import bot from '../context/bot.js'; import { isAdmin } from '../middleware/auth.js'; import logger from '../utils/logger.js'; import { resetUserContext } from '../utils/messageUtils.js'; +import chatbotService from '../services/chatbotService.js'; +import leadService from '../services/leadService.js'; +import UserService from '../services/userService.js'; import userHandler from '../handlers/userHandlers/userHandler.js'; import userPurchaseHandler from '../handlers/userHandlers/userPurchaseHandler.js'; @@ -68,6 +72,38 @@ export function registerRoutes() { if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.products')) return; await resetUserContext(msg.chat.id); logDebug(msg.text, 'showProducts'); + // Фиксация лида при активации каталога + const lead = await leadService.getOrCreateLead({ + telegramId: msg.from.id, + username: msg.chat?.username || msg.from?.username, + name: msg.from?.first_name || null, + }); + if (lead?.id) { + await leadService.logInteraction({ + telegramId: msg.from.id, + leadId: lead.id, + action: 'bot_catalog', + details: { button: 'keyboard.products' }, + }); + } + // Магазин на паузе — сразу переключаем на ИИ-диалог, убираем меню + if (await chatbotService.isSleepMode()) { + const user = msg.__user || await UserService.getUserByTelegramId(msg.from.id); + const lang = user?.language || 'en'; + const result = await chatbotService.sendToChatbot({ + sessionId: msg.from.id, + message: msg.text, + telegramId: msg.from.id, + language: lang, + username: msg.chat?.username || msg.from?.username, + name: msg.from?.first_name, + }); + const reply = result.reply || await chatbotService.getWelcomeMessage(); + await bot.sendMessage(msg.chat.id, reply, { + reply_markup: { remove_keyboard: true }, + }); + return; + } await userProductHandler.showProducts(msg); }); messageRouter.registerText('keyboard.profile', async (msg) => { diff --git a/src/services/chatbotService.js b/src/services/chatbotService.js new file mode 100644 index 0000000..549e760 --- /dev/null +++ b/src/services/chatbotService.js @@ -0,0 +1,113 @@ +// chatbotService.js — мост между Telegram-ботом и AI-чатом Next.js админки +// Бот вызывает POST /api/chat админки (localhost:3100), которая: +// - управляет сессиями (chat_sessions) +// - обрабатывает sleep mode (пауза магазина) +// - вызывает LLM (Ollama/OpenAI/DeepSeek) +// - извлекает лидов и профили клиентов + +import db from '../config/database.js'; +import logger from '../utils/logger.js'; + +const ADMIN_CHAT_URL = process.env.ADMIN_CHAT_URL || 'http://localhost:3100/api/chat'; +const CONFIG_CACHE_TTL_MS = 30 * 1000; // 30 сек кэш настроек + +let configCache = null; +let configCachedAt = 0; + +async function getChatbotConfig() { + const now = Date.now(); + if (configCache && now - configCachedAt < CONFIG_CACHE_TTL_MS) { + return configCache; + } + try { + const rows = await db.allAsync( + `SELECT key, value FROM site_settings WHERE key LIKE 'chatbot_%'` + ); + const config = {}; + for (const row of rows) config[row.key] = row.value; + configCache = config; + configCachedAt = now; + return config; + } catch (err) { + logger.error({ err }, 'Failed to load chatbot config'); + return {}; + } +} + +export async function isChatbotEnabled() { + const config = await getChatbotConfig(); + return config.chatbot_enabled === 'true'; +} + +export async function isSleepMode() { + const config = await getChatbotConfig(); + return config.chatbot_sleep_mode === 'true'; +} + +export async function getSleepMessage() { + const config = await getChatbotConfig(); + return config.chatbot_sleep_message || 'Извините, мы сейчас не доступны. Напишите позже, пожалуйста.'; +} + +export async function getWelcomeMessage() { + const config = await getChatbotConfig(); + return config.chatbot_welcome_message || 'Здравствуйте! Чем могу помочь?'; +} + +/** + * Отправить сообщение в AI-чат админки. + * @param {object} params + * @param {string} params.sessionId — стабильный ID сессии (telegramId) + * @param {string} params.message — текст сообщения пользователя + * @param {string|number} params.telegramId — ID пользователя в Telegram + * @param {string} params.language — язык пользователя (ru/en/es/de/...) + * @param {string} [params.username] — @username пользователя + * @param {string} [params.name] — имя пользователя + * @param {object} [params.fingerprint] — device, ip, country, geoAddress + * @returns {Promise<{reply: string, sessionId: string, leadId?: number|null, operatorConnected?: boolean}>} + */ +export async function sendToChatbot({ + sessionId, + message, + telegramId, + language = 'en', + username, + name, + fingerprint, +}) { + try { + const res = await fetch(ADMIN_CHAT_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + sessionId: String(sessionId), + message, + telegramId: telegramId != null ? String(telegramId) : undefined, + language, + username: username || undefined, + name: name || undefined, + fingerprint: fingerprint || undefined, + }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + logger.warn({ status: res.status, body: text.slice(0, 200) }, 'Chatbot API error'); + return { reply: '', error: `Chatbot API ${res.status}` }; + } + + const data = await res.json(); + return data; + } catch (err) { + logger.error({ err }, 'Chatbot API request failed'); + return { reply: '', error: 'Chatbot API unreachable' }; + } +} + +export default { + isChatbotEnabled, + isSleepMode, + getSleepMessage, + getWelcomeMessage, + sendToChatbot, +}; diff --git a/src/services/leadService.js b/src/services/leadService.js new file mode 100644 index 0000000..4f63896 --- /dev/null +++ b/src/services/leadService.js @@ -0,0 +1,124 @@ +// leadService.js — синхронизация лидов и логов взаимодействия между ботом и админкой +// При каждом сообщении пользователя: +// 1. Создаёт/обновляет лида (telegram_id, telegram-username) +// 2. Пишет лог взаимодействия в audit_log +// 3. Передаёт фингерпринты (device, ip, country) в /api/chat админки + +import db from '../config/database.js'; +import logger from '../utils/logger.js'; + +/** + * Получить или создать лида по telegram_id. + * @param {object} params + * @param {string|number} params.telegramId + * @param {string} [params.username] — username из Telegram (@handle) + * @param {string} [params.name] — имя если известно + * @returns {Promise<{id: number, isNew: boolean}>} + */ +export async function getOrCreateLead({ telegramId, username, name }) { + if (telegramId == null) return null; + const tid = String(telegramId); + + try { + const existing = await db.getAsync( + 'SELECT * FROM leads WHERE telegram_id = ?', + [tid] + ); + if (existing) { + // Обновляем telegram-username, если появился + const updates = []; + const params = []; + if (username && !existing.telegram) { + updates.push('telegram = ?'); + params.push(username.startsWith('@') ? username : `@${username}`); + } + if (name && !existing.name) { + updates.push('name = ?'); + params.push(name); + } + if (updates.length > 0) { + params.push(existing.id); + await db.runAsync( + `UPDATE leads SET ${updates.join(', ')}, updated_at = datetime('now') WHERE id = ?`, + params + ); + } + return { id: existing.id, isNew: false }; + } + + const result = await db.runAsync( + `INSERT INTO leads (telegram_id, telegram, name, status, verification, custom_fields, created_at, updated_at) + VALUES (?, ?, ?, 'new', 'pending', '{}', datetime('now'), datetime('now'))`, + [ + tid, + username ? (username.startsWith('@') ? username : `@${username}`) : null, + name || null, + ] + ); + logger.info({ telegramId: tid, leadId: result.lastInsertRowid }, 'Lead created from bot interaction'); + return { id: Number(result.lastInsertRowid), isNew: true }; + } catch (err) { + logger.error({ err }, 'Failed to getOrCreateLead'); + return null; + } +} + +/** + * Записать лог взаимодействия пользователя с ботом в audit_log. + * @param {object} params + * @param {string|number} params.telegramId + * @param {number} params.leadId + * @param {string} params.action — bot_message / bot_start / bot_catalog / bot_ai_reply + * @param {object} params.details — детали (текст, обработка, reply-статус) + */ +export async function logInteraction({ telegramId, leadId, action, details }) { + try { + await db.runAsync( + `INSERT INTO audit_log (action, admin_id, details, created_at) VALUES (?, ?, ?, datetime('now'))`, + [ + action, + String(telegramId), + JSON.stringify({ leadId, ...details }), + ] + ); + } catch (err) { + logger.error({ err }, 'Failed to log interaction'); + } +} + +/** + * Синхронизировать лида при каждом сообщении от пользователя. + * Вызывается в message handler — гарантирует появление лида в админке. + * @param {object} msg — сообщение Telegram + * @returns {Promise<{leadId: number|null, telegramId: string, isNew: boolean}|null>} + */ +export async function syncLeadFromMessage(msg) { + if (!msg?.from?.id) return null; + const telegramId = String(msg.from.id); + const username = msg.chat?.username || msg.from?.username || null; + const firstName = msg.from?.first_name || null; + + const lead = await getOrCreateLead({ telegramId, username, name: firstName }); + if (!lead) return null; + + // Лог входящего сообщения (без текста для приватности — только факт + длину) + await logInteraction({ + telegramId, + leadId: lead.id, + action: msg.text ? 'bot_message' : 'bot_media', + details: { + messageType: msg.text ? 'text' : (msg.photo ? 'photo' : 'other'), + textLength: msg.text ? msg.text.length : 0, + chatType: msg.chat?.type || 'private', + username: username || null, + }, + }); + + return { leadId: lead.id, telegramId, isNew: lead.isNew }; +} + +export default { + getOrCreateLead, + logInteraction, + syncLeadFromMessage, +};