feat(bot): AI-chat integration — leads, chatbot service, sleep mode + migration 013

- chatbotService: bridge to Next.js admin chat API (localhost:3100) — sleep mode, welcome, LLM replies
- leadService: lead sync from every user message, interaction logging to audit_log
- index.js: unhandled messages route to AI when chatbot_enabled; lead sync on every message
- userHandler/routes: lead capture on /start and catalog; sleep mode redirects to AI dialog
- Migration 013: leads, site_settings, chat_sessions tables (idempotent, chatbot_enabled/sleep_mode default 0)
- messageRouter.dispatch returns boolean (handled/not) for AI fallback
- gitignore db backups
This commit is contained in:
NW
2026-08-08 01:11:14 +01:00
parent a5ea99b39b
commit 62534dbe85
9 changed files with 426 additions and 5 deletions

2
.gitignore vendored
View File

@@ -15,6 +15,8 @@ templates/smartadmin/
db/*.db
db/*.db-wal
db/*.db-shm
db/*.backup-*
db/*.pre-merge-*
# Root-level throwaway scripts
/*.mjs

View File

@@ -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],

View File

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

View File

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

View File

@@ -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++) {

View File

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

View File

@@ -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) => {

View File

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

124
src/services/leadService.js Normal file
View File

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