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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -15,6 +15,8 @@ templates/smartadmin/
|
|||||||
db/*.db
|
db/*.db
|
||||||
db/*.db-wal
|
db/*.db-wal
|
||||||
db/*.db-shm
|
db/*.db-shm
|
||||||
|
db/*.backup-*
|
||||||
|
db/*.pre-merge-*
|
||||||
|
|
||||||
# Root-level throwaway scripts
|
# Root-level throwaway scripts
|
||||||
/*.mjs
|
/*.mjs
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import WalletService from "../../services/walletService.js";
|
|||||||
import logger from "../../utils/logger.js";
|
import logger from "../../utils/logger.js";
|
||||||
import { resetUserContext } from "../../utils/messageUtils.js";
|
import { resetUserContext } from "../../utils/messageUtils.js";
|
||||||
import userStates from "../../context/userStates.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';
|
import { tForUser, LANGUAGE_NAMES, AVAILABLE_LANGUAGES } from '../../i18n/index.js';
|
||||||
|
|
||||||
export default class UserHandler {
|
export default class UserHandler {
|
||||||
@@ -109,6 +111,41 @@ ${t('profile.member_since')}: ${new Date(userStats.created_at).toLocaleDateStrin
|
|||||||
username: username
|
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 = {
|
const keyboard = {
|
||||||
inline_keyboard: AVAILABLE_LANGUAGES.map(code => [{
|
inline_keyboard: AVAILABLE_LANGUAGES.map(code => [{
|
||||||
text: LANGUAGE_NAMES[code],
|
text: LANGUAGE_NAMES[code],
|
||||||
|
|||||||
42
src/index.js
42
src/index.js
@@ -10,6 +10,9 @@ import callbackRouter from './router/callbackRouter.js';
|
|||||||
import messageRouter from './router/messageRouter.js';
|
import messageRouter from './router/messageRouter.js';
|
||||||
|
|
||||||
import { initStates } from './services/stateService.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 { resetUserContext } from './utils/messageUtils.js';
|
||||||
import { tryAcquireLock, pruneLocks } from './utils/callbackLock.js';
|
import { tryAcquireLock, pruneLocks } from './utils/callbackLock.js';
|
||||||
|
|
||||||
@@ -55,7 +58,44 @@ if (bot && botAvailable) {
|
|||||||
const canUse = await userHandler.canUseBot(msg);
|
const canUse = await userHandler.canUseBot(msg);
|
||||||
if (!canUse) return;
|
if (!canUse) return;
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
await ErrorHandler.handleError(bot, msg.chat.id, error, 'message handler');
|
await ErrorHandler.handleError(bot, msg.chat.id, error, 'message handler');
|
||||||
}
|
}
|
||||||
|
|||||||
66
src/migrations/013_ai_features.js
Normal file
66
src/migrations/013_ai_features.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ export async function runMigrations() {
|
|||||||
(await import('./010_is_mono_product.js')).default,
|
(await import('./010_is_mono_product.js')).default,
|
||||||
(await import('./011_active_flags.js')).default,
|
(await import('./011_active_flags.js')).default,
|
||||||
(await import('./012_fix_typos.js')).default,
|
(await import('./012_fix_typos.js')).default,
|
||||||
|
(await import('./013_ai_features.js')).default,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = currentVersion; i < migrations.length; i++) {
|
for (let i = currentVersion; i < migrations.length; i++) {
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ class MessageRouter {
|
|||||||
|
|
||||||
async dispatch(msg) {
|
async dispatch(msg) {
|
||||||
for (const handler of this.inputHandlers) {
|
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 user = msg.__user || await UserService.getUserByTelegramId(msg.from.id);
|
||||||
const lang = user?.language || 'en';
|
const lang = user?.language || 'en';
|
||||||
@@ -35,7 +35,7 @@ class MessageRouter {
|
|||||||
|
|
||||||
if (localeKey && this.textHandlers.has(localeKey)) {
|
if (localeKey && this.textHandlers.has(localeKey)) {
|
||||||
await this.textHandlers.get(localeKey)(msg);
|
await this.textHandlers.get(localeKey)(msg);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const l of AVAILABLE_LANGUAGES) {
|
for (const l of AVAILABLE_LANGUAGES) {
|
||||||
@@ -43,9 +43,11 @@ class MessageRouter {
|
|||||||
const lk = this.localeKeyMap.get(key);
|
const lk = this.localeKeyMap.get(key);
|
||||||
if (lk && this.textHandlers.has(lk)) {
|
if (lk && this.textHandlers.has(lk)) {
|
||||||
await this.textHandlers.get(lk)(msg);
|
await this.textHandlers.get(lk)(msg);
|
||||||
return;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import callbackRouter from './callbackRouter.js';
|
import callbackRouter from './callbackRouter.js';
|
||||||
import messageRouter from './messageRouter.js';
|
import messageRouter from './messageRouter.js';
|
||||||
|
import bot from '../context/bot.js';
|
||||||
import { isAdmin } from '../middleware/auth.js';
|
import { isAdmin } from '../middleware/auth.js';
|
||||||
import logger from '../utils/logger.js';
|
import logger from '../utils/logger.js';
|
||||||
import { resetUserContext } from '../utils/messageUtils.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 userHandler from '../handlers/userHandlers/userHandler.js';
|
||||||
import userPurchaseHandler from '../handlers/userHandlers/userPurchaseHandler.js';
|
import userPurchaseHandler from '../handlers/userHandlers/userPurchaseHandler.js';
|
||||||
@@ -68,6 +72,38 @@ export function registerRoutes() {
|
|||||||
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.products')) return;
|
if (shouldDebounceMainMenu(msg.chat.id, 'keyboard.products')) return;
|
||||||
await resetUserContext(msg.chat.id);
|
await resetUserContext(msg.chat.id);
|
||||||
logDebug(msg.text, 'showProducts');
|
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);
|
await userProductHandler.showProducts(msg);
|
||||||
});
|
});
|
||||||
messageRouter.registerText('keyboard.profile', async (msg) => {
|
messageRouter.registerText('keyboard.profile', async (msg) => {
|
||||||
|
|||||||
113
src/services/chatbotService.js
Normal file
113
src/services/chatbotService.js
Normal 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
124
src/services/leadService.js
Normal 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,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user