// src/index.js — Nutrition Bot (Phase 1 MVP) // Cloudflare Workers + Neon PostgreSQL + Telegram Bot API import * as db from './db.js'; import { handleDashboardCallback } from './dashboard/index.js'; import { SURVEY_QUESTIONS, SURVEY_QUESTIONS_COUNT } from './config/questions.js'; import { texts, getStartText, getStateText, FSM_ORDER, paymentConfirm } from './config/texts.js'; import { sendMessage, answerCallbackQuery, setWebhook, createReplyKeyboard } from './utils/telegram.js'; function addCorsHeaders(response) { const headers = new Headers(response.headers); headers.set('Access-Control-Allow-Origin', '*'); headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization'); return new Response(response.body, { status: response.status, statusText: response.statusText, headers, }); } export default { async fetch(request, env, ctx) { const url = new URL(request.url); // CORS preflight handler if (request.method === 'OPTIONS') { return new Response(null, { headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400', }, }); } // Установка webhook if (url.pathname === '/set-webhook') { const webhookUrl = `${url.origin}/`; const result = await setWebhook(env.telegram_bot_token, webhookUrl); return new Response(JSON.stringify(result), { headers: { 'Content-Type': 'application/json' } }); } // Health check if (url.pathname === '/health') { try { const stats = await db.getStats(env); return new Response(JSON.stringify({ status: 'ok', timestamp: new Date().toISOString(), database: 'connected', stats }), { headers: { 'Content-Type': 'application/json' } }); } catch (error) { return new Response(JSON.stringify({ status: 'error', database: 'disconnected', error: error.message }), { status: 500, headers: { 'Content-Type': 'application/json' } }); } } // ============================================ // ADMIN PANEL API (Phase 2.1) // ============================================ // Простая авторизация: проверяем Bearer-токен function verifyAdminAuth(request, env) { if (!env.admin_login || !env.admin_password) { return false; } const authHeader = request.headers.get('Authorization') || ''; if (!authHeader.startsWith('Bearer ')) { return false; } const token = authHeader.slice(7); const expected = btoa(`${env.admin_login}:${env.admin_password}`); return token === expected; } function jsonResponse(data, status = 200) { return new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } }); } // POST /admin/api/login if (url.pathname === '/admin/api/login' && request.method === 'POST') { try { const body = await request.json(); const { login, password } = body; if (login === env.admin_login && password === env.admin_password) { const token = btoa(`${login}:${password}`); return addCorsHeaders(jsonResponse({ success: true, token })); } return addCorsHeaders(jsonResponse({ success: false, error: 'Invalid credentials' }, 401)); } catch (error) { return addCorsHeaders(jsonResponse({ error: error.message }, 400)); } } // GET /admin/api/clients if (url.pathname === '/admin/api/clients' && request.method === 'GET') { if (!verifyAdminAuth(request, env)) { return addCorsHeaders(jsonResponse({ error: 'Unauthorized' }, 401)); } try { const users = await db.getAllUsers(env); return addCorsHeaders(jsonResponse(users)); } catch (error) { return addCorsHeaders(jsonResponse({ error: error.message }, 500)); } } // GET /admin/api/client/:userId if (url.pathname.match(/^\/admin\/api\/client\/\d+$/) && request.method === 'GET') { if (!verifyAdminAuth(request, env)) { return addCorsHeaders(jsonResponse({ error: 'Unauthorized' }, 401)); } try { const userId = parseInt(url.pathname.split('/').pop()); const user = await db.getUser(env, userId); if (!user) { return addCorsHeaders(jsonResponse({ error: 'User not found' }, 404)); } const surveyAnswers = await db.getSurveyAnswers(env, userId); const recommendations = await db.getRecommendations(env, userId); const experimentProgress = await db.getExperimentProgress(env, userId); const experimentDays = await db.getExperimentDays(env, userId); return addCorsHeaders(jsonResponse({ user, surveyAnswers, recommendations, experimentProgress, experimentDays })); } catch (error) { return addCorsHeaders(jsonResponse({ error: error.message }, 500)); } } // POST /admin/api/client/:userId/payment if (url.pathname.match(/^\/admin\/api\/client\/\d+\/payment$/) && request.method === 'POST') { if (!verifyAdminAuth(request, env)) { return addCorsHeaders(jsonResponse({ error: 'Unauthorized' }, 401)); } try { const userId = parseInt(url.pathname.split('/')[4]); const user = await db.getUser(env, userId); if (!user) { return addCorsHeaders(jsonResponse({ error: 'User not found' }, 404)); } const currentIndex = FSM_ORDER.indexOf(user.state); const targetIndex = FSM_ORDER.indexOf('payment_confirmed'); if (currentIndex < targetIndex) { await db.updateUserState(env, userId, 'payment_confirmed'); // Отправляем уведомление пользователю try { await sendMessage(env.telegram_bot_token, userId, paymentConfirm.surveyNext, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[{ text: '📋 Начать анкетирование', callback_data: 'start_survey' }]] } } ); } catch (e) { console.log('⚠️ Could not notify user about payment confirmation:', e.message); } } return addCorsHeaders(jsonResponse({ success: true })); } catch (error) { return addCorsHeaders(jsonResponse({ error: error.message }, 500)); } } // POST /admin/api/client/:userId/recommendation if (url.pathname.match(/^\/admin\/api\/client\/\d+\/recommendation$/) && request.method === 'POST') { if (!verifyAdminAuth(request, env)) { return addCorsHeaders(jsonResponse({ error: 'Unauthorized' }, 401)); } try { const body = await request.json(); const { content } = body; if (!content) { return addCorsHeaders(jsonResponse({ error: 'Content is required' }, 400)); } const userId = parseInt(url.pathname.split('/')[4]); const user = await db.getUser(env, userId); if (!user) { return addCorsHeaders(jsonResponse({ error: 'User not found' }, 404)); } await db.saveRecommendation(env, userId, content); // Обновляем состояние только если пользователь ещё не дальше const recIndex = FSM_ORDER.indexOf('recommendations_received'); const currentIndex = FSM_ORDER.indexOf(user.state); if (currentIndex < recIndex) { await db.updateUserState(env, userId, 'recommendations_received'); // Уведомляем пользователя try { const firstName = user.first_name ? `, ${user.first_name}` : ''; await sendMessage(env.telegram_bot_token, userId, `🎉 *Отличные новости, ${firstName.trim()}!*\n\n` + `Ваши персональные рекомендации готовы! ✅\n\n` + `Используйте /recommendations или /dashboard чтобы их просмотреть.`, { parse_mode: 'Markdown' } ); } catch (e) { console.log('⚠️ Could not notify user about recommendations:', e.message); } } return addCorsHeaders(jsonResponse({ success: true })); } catch (error) { return addCorsHeaders(jsonResponse({ error: error.message }, 500)); } } // Админская команда подтверждения оплаты через URL if (url.pathname.startsWith('/admin/confirm/')) { const userId = parseInt(url.pathname.split('/').pop()); const user = await db.getUser(env, userId); const currentState = user?.state || 'welcome'; const currentIndex = FSM_ORDER.indexOf(currentState); const targetIndex = FSM_ORDER.indexOf('payment_confirmed'); const newState = currentIndex < targetIndex ? 'payment_confirmed' : currentState; if (newState !== currentState) { await db.updateUserState(env, userId, newState); } const finalUser = await db.getUser(env, userId); const finalState = finalUser?.state || currentState; let confirmText, replyMarkup; if (finalState === 'payment_confirmed') { confirmText = paymentConfirm.surveyNext; replyMarkup = { inline_keyboard: [[{ text: '📋 Начать анкетирование', callback_data: 'start_survey' }]] }; } else if (finalState === 'survey_in_progress') { confirmText = paymentConfirm.surveyContinue; replyMarkup = { inline_keyboard: [[{ text: '📝 Продолжить анкету', callback_data: 'continue_survey' }]] }; } else if (finalState === 'survey_completed' || finalState === 'waiting_recommendations') { confirmText = paymentConfirm.waitingRecs; replyMarkup = undefined; } else { confirmText = paymentConfirm.alreadyAhead; replyMarkup = undefined; } await sendMessage(env.telegram_bot_token, userId, confirmText, { parse_mode: 'Markdown', reply_markup: replyMarkup }); return new Response(`User ${userId} payment confirmed. State: ${finalState}. Message sent.`); } // Telegram webhook if (request.method === 'POST') { try { const update = await request.json(); console.log('📥 Update:', JSON.stringify(update)); const message = update.message; const callbackQuery = update.callback_query; const chatId = message?.chat?.id || callbackQuery?.message?.chat?.id; const userId = message?.from?.id || callbackQuery?.from?.id; const text = message?.text || ''; const userData = { firstName: message?.from?.first_name || callbackQuery?.from?.first_name, lastName: message?.from?.last_name || callbackQuery?.from?.last_name, username: message?.from?.username || callbackQuery?.from?.username }; let user = await db.getUser(env, userId); if (!user) { user = await db.createUser(env, userId, userData); if (env.admin_chat_id) { await sendMessage(env.telegram_bot_token, env.admin_chat_id, texts.admin.new_user(userId, userData.firstName, userData.username) ); } } if (message && text.startsWith('/')) { await handleCommand(text, chatId, userId, user, env); } if (callbackQuery) { await handleCallback(callbackQuery, chatId, userId, user, env); } if (message && !text.startsWith('/')) { await handleMessage(text, chatId, userId, user, env); } return new Response('OK'); } catch (error) { console.error('❌ Error:', error); return new Response('Error', { status: 500 }); } } return new Response('🤖 Nutrition Bot with PostgreSQL'); } }; // ============================================= // Reply-клавиатура — кнопки внизу чата // ============================================= function universalKeyboard(userState) { const kb = []; if (userState === 'welcome') { return null; } if (userState === 'presentation_viewed') { kb.push(['💳 Перейти к оплате']); } if (['payment_pending', 'payment_confirmed'].includes(userState)) { kb.push(['📋 Начать анкетирование']); } if (userState === 'survey_in_progress') { kb.push(['📝 Продолжить анкету']); } if (['survey_completed', 'waiting_recommendations', 'recommendations_received', 'experiment_active', 'experiment_completed'].includes(userState)) { kb.push(['🏠 Личный кабинет']); } if (['recommendations_received', 'experiment_active'].includes(userState)) { kb.push(['💡 Рекомендации', '📊 Эксперимент']); } if (kb.length === 0) return null; return createReplyKeyboard(kb); } // ============================================= // ОБРАБОТЧИКИ КОМАНД // ============================================= async function handleStartCommand(chatId, userId, user, env) { const firstName = user.first_name ? `, ${user.first_name}` : ''; if (user.state === 'survey_in_progress') { const isPaused = await db.getUserData(env, userId, 'survey_paused'); if (isPaused === 'true') { await continueSurvey(chatId, userId, env); return; } } const text = getStartText(user.state, firstName, null); const inlineKeyboard = getStartKeyboard(user.state, env, userId); const replyKb = universalKeyboard(user.state); const options = { parse_mode: 'Markdown' }; if (inlineKeyboard) { options.reply_markup = inlineKeyboard; } await sendMessage(env.telegram_bot_token, chatId, text, options); if (replyKb) { await sendMessage(env.telegram_bot_token, chatId, '📌 Навигация:', { reply_markup: replyKb }); } } function getStartKeyboard(state, env, userId) { switch (state) { case 'welcome': return { inline_keyboard: [[{ text: texts.presentation.button, callback_data: 'show_presentation' }]] }; case 'presentation_viewed': return { inline_keyboard: [[{ text: texts.payment.button, callback_data: 'accept_terms' }]] }; case 'payment_pending': return { inline_keyboard: [[{ text: texts.start.payment_pending.button, callback_data: 'start_survey' }]] }; case 'payment_confirmed': return { inline_keyboard: [[{ text: texts.start.payment_confirmed.button, callback_data: 'start_survey' }]] }; case 'survey_in_progress': return { inline_keyboard: [[{ text: texts.start.survey_in_progress.button, callback_data: 'continue_survey' }]] }; case 'recommendations_received': return { inline_keyboard: [ [{ text: texts.start.recommendations_received.button_recommendations, callback_data: 'view_recommendations' }], [{ text: texts.start.recommendations_received.button_experiment, callback_data: 'start_experiment' }] ] }; case 'experiment_active': return { inline_keyboard: [ [{ text: texts.start.experiment_active.button_progress, callback_data: 'view_progress' }], [{ text: texts.start.experiment_active.button_recommendations, callback_data: 'view_recommendations' }] ] }; case 'experiment_completed': return { inline_keyboard: [[{ text: texts.start.experiment_completed.button, callback_data: 'view_profile' }]] }; default: return null; } } function getReplyKb(options = {}) { if (options.userState) { return { reply_markup: { ...universalKeyboard(options.userState) } }; } return {}; } async function handleCommand(command, chatId, userId, user, env) { if (command.startsWith('/confirm_')) { if (!env.admin_chat_id) return; if (String(chatId) === String(env.admin_chat_id)) { const targetUserId = parseInt(command.replace('/confirm_', '')); const targetUser = await db.getUser(env, targetUserId); const currentState = targetUser?.state || 'welcome'; const currentIndex = FSM_ORDER.indexOf(currentState); const targetIndex = FSM_ORDER.indexOf('payment_confirmed'); const newState = currentIndex < targetIndex ? 'payment_confirmed' : currentState; if (newState !== currentState) { await db.updateUserState(env, targetUserId, newState); } await sendMessage(env.telegram_bot_token, chatId, texts.admin.payment_confirmed); const finalUser = await db.getUser(env, targetUserId); const finalState = finalUser?.state || currentState; let confirmText, replyMarkup; if (finalState === 'payment_confirmed') { confirmText = paymentConfirm.surveyNext; replyMarkup = { inline_keyboard: [[{ text: '📋 Начать анкетирование', callback_data: 'start_survey' }]] }; } else if (finalState === 'survey_in_progress') { confirmText = paymentConfirm.surveyContinue; replyMarkup = { inline_keyboard: [[{ text: '📝 Продолжить анкету', callback_data: 'continue_survey' }]] }; } else if (finalState === 'survey_completed' || finalState === 'waiting_recommendations') { confirmText = paymentConfirm.waitingRecs; replyMarkup = undefined; } else { confirmText = paymentConfirm.alreadyAhead; replyMarkup = undefined; } await sendMessage(env.telegram_bot_token, targetUserId, confirmText, { parse_mode: 'Markdown', reply_markup: replyMarkup }); return; } } switch (command) { case '/start': await handleStartCommand(chatId, userId, user, env); break; case '/dashboard': if (['survey_completed', 'waiting_recommendations', 'recommendations_received', 'experiment_active', 'experiment_completed'].includes(user.state)) { const { showDashboard } = await import('./dashboard/components.js'); await showDashboard(chatId, userId, env, user); } else { await sendMessage(env.telegram_bot_token, chatId, '🏠 Личный кабинет будет доступен после заполнения анкеты.', { reply_markup: universalKeyboard(user.state) }); } break; case '/payment': if (user.state === 'payment_confirmed') { await sendMessage(env.telegram_bot_token, chatId, texts.payment.already_confirmed, { reply_markup: universalKeyboard(user.state) }); } else if (user.state !== 'welcome') { await showPaymentInfo(chatId, userId, env); } else { await sendMessage(env.telegram_bot_token, chatId, texts.payment.need_presentation_first, { reply_markup: universalKeyboard(user.state) }); } break; case '/survey': if (['payment_pending', 'payment_confirmed'].includes(user.state)) { await startSurvey(chatId, userId, env); } else if (user.state === 'survey_in_progress') { await continueSurvey(chatId, userId, env); } else if (user.state === 'survey_completed' || user.state === 'waiting_recommendations') { await sendMessage(env.telegram_bot_token, chatId, texts.survey.already_completed, { reply_markup: universalKeyboard(user.state) }); } else { await sendMessage(env.telegram_bot_token, chatId, texts.survey.not_available, { reply_markup: universalKeyboard(user.state) }); } break; case '/recommendations': if (['waiting_recommendations', 'recommendations_received', 'experiment_active'].includes(user.state)) { const recommendations = await db.getRecommendations(env, userId); if (recommendations.length > 0) { await sendMessage(env.telegram_bot_token, chatId, texts.recommendations.found(recommendations[0].content), { parse_mode: 'Markdown', reply_markup: universalKeyboard(user.state) } ); if (!recommendations[0].is_read) { await db.markRecommendationRead(env, recommendations[0].id); } } else { await sendMessage(env.telegram_bot_token, chatId, texts.recommendations.not_ready, { reply_markup: universalKeyboard(user.state) }); } } else { await sendMessage(env.telegram_bot_token, chatId, texts.recommendations.need_survey_first, { reply_markup: universalKeyboard(user.state) }); } break; case '/experiment': if (user.state === 'recommendations_received' || user.state === 'experiment_active') { await showExperimentProgress(chatId, userId, user, env); } else { await sendMessage(env.telegram_bot_token, chatId, texts.experiment.not_available, { reply_markup: universalKeyboard(user.state) }); } break; case '/profile': await showProfile(chatId, userId, user, env); break; case '/help': await sendMessage(env.telegram_bot_token, chatId, texts.help.text, { parse_mode: 'Markdown', reply_markup: universalKeyboard(user.state) }); break; } } // ============================================= // ОБРАБОТЧИКИ CALLBACK // ============================================= async function handleCallback(callbackQuery, chatId, userId, user, env) { const data = callbackQuery.data; const queryId = callbackQuery.id; console.log('handleCallback: start', { data }); await answerCallbackQuery(env.telegram_bot_token, queryId); switch (data) { case 'show_presentation': await sendMessage(env.telegram_bot_token, chatId, texts.presentation.text, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[{ text: texts.presentation.button, callback_data: 'accept_terms' }]] } }); await db.updateUserState(env, userId, 'presentation_viewed'); break; case 'accept_terms': await showPaymentInfo(chatId, userId, env); break; case 'payment_done': console.log('payment_done: start', { state: user.state, hasAdmin: !!env.admin_chat_id }); if (user.state === 'payment_pending' || user.state === 'payment_confirmed') { console.log('payment_done: early-exit', { state: user.state }); await sendMessage(env.telegram_bot_token, chatId, texts.payment.already_pending, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[{ text: texts.payment.button_pending, callback_data: 'start_survey' }]] } }); break; } console.log('payment_done: setState->payment_pending'); await db.updateUserState(env, userId, 'payment_pending'); await db.saveUserData(env, userId, 'payment_reported_at', new Date().toISOString()); console.log('payment_done: reply-user'); await sendMessage(env.telegram_bot_token, chatId, texts.payment.after_payment, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[{ text: texts.payment.button_pending, callback_data: 'start_survey' }]] } }); if (env.admin_chat_id) { console.log('payment_done: notify-admin'); try { await sendMessage(env.telegram_bot_token, env.admin_chat_id, texts.admin.new_payment(userId, user.first_name, `/confirm_${userId}`) ); console.log('✅ Уведомление админу отправлено успешно'); } catch (error) { console.error('payment_done: notify-error', error); } } else { console.log('⚠️ admin_chat_id не установлен'); } break; case 'start_survey': if (['payment_pending', 'payment_confirmed', 'survey_in_progress'].includes(user.state)) { if (user.state === 'survey_in_progress') { await continueSurvey(chatId, userId, env); } else { await startSurvey(chatId, userId, env); } } else { await sendMessage(env.telegram_bot_token, chatId, texts.survey.not_available); } break; case 'start_experiment': if (user.state === 'recommendations_received') { const tomorrow = new Date(); tomorrow.setHours(0, 0, 0, 0); tomorrow.setTime(tomorrow.getTime() + 86400000); await db.updateUserState(env, userId, 'experiment_active'); await db.saveUserData(env, userId, 'experiment_start_date', tomorrow.toISOString()); await sendMessage(env.telegram_bot_token, chatId, texts.experiment.start_text, { parse_mode: 'Markdown', reply_markup: universalKeyboard('experiment_active') }); } break; case 'continue_survey': if (user.state === 'survey_in_progress') { await continueSurvey(chatId, userId, env); } break; case 'view_recommendations': await handleViewRecommendations(chatId, userId, env); break; case 'view_progress': await showExperimentProgress(chatId, userId, user, env); break; case 'view_profile': await showProfile(chatId, userId, user, env); break; case 'pause_survey': const currentPhase = await db.getSurveyPhase(env, userId); await db.saveUserData(env, userId, 'survey_paused', 'true'); await db.saveUserData(env, userId, 'survey_paused_phase', currentPhase); console.log('⏸ Пауза: сохранена фаза:', currentPhase); await sendMessage(env.telegram_bot_token, chatId, texts.pause.text, { parse_mode: 'Markdown', reply_markup: universalKeyboard(user.state) }); break; case 'skip_q': { const currentQ = parseInt(await db.getUserData(env, userId, 'current_question') || '1'); const maxReachedQ = parseInt(await db.getUserData(env, userId, 'max_question_reached') || '0'); await db.addSkippedQuestion(env, userId, currentQ); if (currentQ < SURVEY_QUESTIONS_COUNT) { const nextQ = currentQ + 1; await db.saveUserData(env, userId, 'current_question', nextQ.toString()); if (nextQ > maxReachedQ) { await db.saveUserData(env, userId, 'max_question_reached', nextQ.toString()); } await sendMessage(env.telegram_bot_token, chatId, texts.skip.text(nextQ, SURVEY_QUESTIONS_COUNT, SURVEY_QUESTIONS[nextQ - 1]), { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[ { text: texts.survey.button_pause, callback_data: 'pause_survey' }, { text: texts.survey.button_skip, callback_data: 'skip_q' } ]] } } ); } else { await decideNextStep(chatId, userId, env); } break; } case 'final_submit': await db.saveUserData(env, userId, 'review_mode', 'false'); await db.clearSkippedQuestions(env, userId); await completeSurvey(chatId, userId, env, user); break; case 'review_survey': await showReviewSurvey(chatId, userId, env); break; case 'edit_answer_prompt': await db.saveUserData(env, userId, 'review_mode', 'awaiting_question_number'); await sendMessage(env.telegram_bot_token, chatId, texts.review.edit_prompt(SURVEY_QUESTIONS_COUNT), { parse_mode: 'Markdown' } ); break; default: console.log('handleCallback: unknown-callback', { data }); if (data.startsWith('mark_day_')) { const day = parseInt(data.replace('mark_day_', '')); await db.markDayComplete(env, userId, day); const progress = await db.getExperimentProgress(env, userId); const remaining = 30 - progress.completed_days; await sendMessage(env.telegram_bot_token, chatId, texts.experiment.day_marked(day, progress.completed_days, 30, remaining), { reply_markup: universalKeyboard(user.state) } ); if (progress.completed_days >= 30) { await db.updateUserState(env, userId, 'experiment_completed'); } break; } if (['view_survey', 'my_progress', 'my_recommendations', 'back_to_main', 'dashboard_menu', 'mark_all_read'].includes(data)) { await handleDashboardQuery(queryId, chatId, userId, user, env, data); break; } await sendMessage(env.telegram_bot_token, chatId, texts.errors.generic, { reply_markup: universalKeyboard(user.state) }); } } // ============================================= // АНКЕТА // ============================================= async function startSurvey(chatId, userId, env) { await db.updateUserState(env, userId, 'survey_in_progress'); await db.setSurveyPhase(env, userId, 'main'); await db.setCurrentQuestion(env, userId, 1); await db.setMaxQuestionReached(env, userId, 1); await db.clearSkippedQuestions(env, userId); await db.setSkippedQueue(env, userId, []); await db.setSkippedIdx(env, userId, 0); await db.saveUserData(env, userId, 'survey_paused', 'false'); console.log(`🎬 startSurvey: userId=${userId}, phase='main', q=1`); const N = SURVEY_QUESTIONS_COUNT; await sendMessage(env.telegram_bot_token, chatId, `${texts.survey.start(N)}\n${SURVEY_QUESTIONS[0]}`, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[ { text: texts.survey.button_pause, callback_data: 'pause_survey' }, { text: texts.survey.button_skip, callback_data: 'skip_q' } ]] } } ); } async function continueSurvey(chatId, userId, env) { const phase = await db.getSurveyPhase(env, userId); const pausedPhase = await db.getUserData(env, userId, 'survey_paused_phase'); console.log('🔄 continueSurvey:', { phase, pausedPhase }); await db.saveUserData(env, userId, 'survey_paused', 'false'); if (pausedPhase && phase !== pausedPhase) { await db.setSurveyPhase(env, userId, pausedPhase); console.log(`✅ Восстановлена фаза из паузы: ${pausedPhase}`); } if (!phase) { console.log('⚠️ continueSurvey: фаза не определена, инициализируем FSM'); await db.initSurveyFSM(env, userId); } const N = SURVEY_QUESTIONS_COUNT; switch (phase) { case 'main': { const currentQuestion = await db.getCurrentQuestion(env, userId); console.log(`➡️ [FSM continue→main] Показываем вопрос ${currentQuestion}`); if (currentQuestion <= N) { await sendMessage(env.telegram_bot_token, chatId, `${texts.survey.continue(currentQuestion, N)}\n${SURVEY_QUESTIONS[currentQuestion - 1]}`, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[ { text: texts.survey.button_pause, callback_data: 'pause_survey' }, { text: texts.survey.button_skip, callback_data: 'skip_q' } ]] } } ); } else { await decideNextStep(chatId, userId, env); } break; } case 'skipped': console.log('🔄 [FSM continue→skipped] Показываем пропущенный вопрос'); await askNextSkippedQuestion(chatId, userId, env); break; case 'review': console.log('📋 [FSM continue→review] Показываем экран проверки'); await askForReview(chatId, userId, env); break; default: console.log('⚠️ continueSurvey: неизвестная фаза, используем decideNextStep'); await decideNextStep(chatId, userId, env); break; } } // ============================================= // ОБРАБОТКА СООБЩЕНИЙ // ============================================= const REPLY_BUTTON_COMMANDS = { '❓ Помощь': '/help', '🏠 Личный кабинет': '/dashboard', '📋 Анкета': '/survey', '📋 Начать анкетирование': '/survey', '📝 Продолжить анкету': '/survey', '💳 Перейти к оплате': '/payment', '💡 Рекомендации': '/recommendations', '📊 Эксперимент': '/experiment' }; async function handleMessage(text, chatId, userId, user, env) { if (REPLY_BUTTON_COMMANDS[text]) { await handleCommand(REPLY_BUTTON_COMMANDS[text], chatId, userId, user, env); return; } if (user.state === 'welcome' || user.state === 'presentation_viewed') { await handleStartCommand(chatId, userId, user, env); return; } if (user.state === 'survey_in_progress') { const isPaused = await db.getUserData(env, userId, 'survey_paused'); if (isPaused === 'true') { await sendMessage(env.telegram_bot_token, chatId, texts.pause.reminder, { parse_mode: 'Markdown', reply_markup: universalKeyboard(user.state) }); return; } const reviewMode = await db.getUserData(env, userId, 'review_mode'); const phase = await db.getSurveyPhase(env, userId); if (phase === 'review' && reviewMode !== 'awaiting_question_number' && reviewMode !== 'awaiting_new_answer') { console.log('⚠️ handleMessage: в фазе review без режима редактирования'); await sendMessage(env.telegram_bot_token, chatId, texts.review.review_no_text, { parse_mode: 'Markdown' }); return; } if (reviewMode === 'awaiting_question_number') { const questionNumber = parseInt(text); if (isNaN(questionNumber) || questionNumber < 1 || questionNumber > SURVEY_QUESTIONS_COUNT) { await sendMessage(env.telegram_bot_token, chatId, texts.review.edit_invalid(SURVEY_QUESTIONS_COUNT)); return; } const answers = await db.getSurveyAnswers(env, userId); const currentAnswer = answers.find(a => a.question_id === questionNumber); await db.saveUserData(env, userId, 'review_mode', 'awaiting_new_answer'); await db.saveUserData(env, userId, 'editing_question', questionNumber.toString()); await sendMessage(env.telegram_bot_token, chatId, texts.review.edit_question(questionNumber, SURVEY_QUESTIONS[questionNumber - 1], currentAnswer?.answer), { parse_mode: 'Markdown' } ); return; } if (reviewMode === 'awaiting_new_answer') { const editingQuestion = parseInt(await db.getUserData(env, userId, 'editing_question')); await db.updateSurveyAnswer(env, userId, editingQuestion, text); await db.saveUserData(env, userId, 'review_mode', 'false'); await db.saveUserData(env, userId, 'editing_question', '0'); await sendMessage(env.telegram_bot_token, chatId, texts.review.edit_success(editingQuestion)); await askForReview(chatId, userId, env); return; } const currentQuestion = parseInt(await db.getUserData(env, userId, 'current_question') || '1'); console.log(`💬 Получен ответ на вопрос ${currentQuestion}`); await db.saveSurveyAnswer(env, userId, currentQuestion, text); const skipped = await db.getSkippedQuestions(env, userId); if (skipped.includes(currentQuestion)) { const newSkipped = skipped.filter(id => id !== currentQuestion); await db.saveUserData(env, userId, 'skipped_questions', JSON.stringify(newSkipped)); console.log(`✅ Вопрос ${currentQuestion} удалён из списка пропущенных`); } const currentPhase = await db.getSurveyPhase(env, userId); if (currentPhase === 'skipped') { const idx = await db.getSkippedIdx(env, userId); await db.setSkippedIdx(env, userId, idx + 1); console.log(`✅ [FSM skipped] Инкремент idx: ${idx} → ${idx + 1}`); } await decideNextStep(chatId, userId, env); } } // ============================================= // ЗАВЕРШЕНИЕ АНКЕТЫ // ============================================= async function completeSurvey(chatId, userId, env, user) { if (user.state === 'survey_completed' || user.state === 'waiting_recommendations') { console.log('⚠️ completeSurvey: анкета уже завершена'); await sendMessage(env.telegram_bot_token, chatId, texts.complete.already_done, { parse_mode: 'Markdown', reply_markup: universalKeyboard(user.state) }); return; } const phase = await db.getSurveyPhase(env, userId); if (phase !== 'review') { console.log(`⚠️ [FSM] Попытка завершить анкету вне фазы review (текущая: ${phase})`); await decideNextStep(chatId, userId, env); return; } const allAnswers = await db.getSurveyAnswers(env, userId); const answeredIds = allAnswers.map(a => a.question_id); const skipped = await db.getSkippedQuestions(env, userId); const skippedUnanswered = skipped.filter(qId => !answeredIds.includes(qId)); console.log('✅ completeSurvey:', { phase, answeredCount: allAnswers.length, skippedUnanswered: skippedUnanswered.length }); if (allAnswers.length < SURVEY_QUESTIONS_COUNT || skippedUnanswered.length > 0) { console.log('⚠️ completeSurvey: анкета не завершена'); await decideNextStep(chatId, userId, env); return; } await db.updateUserState(env, userId, 'survey_completed'); await db.saveUserData(env, userId, 'survey_completed_at', new Date().toISOString()); await sendMessage(env.telegram_bot_token, chatId, texts.complete.success, { parse_mode: 'Markdown', reply_markup: universalKeyboard('survey_completed') }); await db.updateUserState(env, userId, 'waiting_recommendations'); if (env.admin_chat_id) { const userName = user.first_name || 'Пользователь'; const username = user.username ? `@${user.username}` : 'не указан'; const dateStr = new Date().toLocaleString('ru-RU'); try { await sendMessage(env.telegram_bot_token, env.admin_chat_id, texts.admin.new_survey(userName, userId, username, `${allAnswers.length}/${SURVEY_QUESTIONS_COUNT}`, dateStr) ); console.log('✅ Уведомление админу отправлено'); } catch (error) { console.error('❌ Ошибка отправки уведомления админу:', error); } } else { console.log('⚠️ admin_chat_id не установлен'); } } // ============================================= // ЭКРАН ПРОВЕРКИ (review) // ============================================= async function askForReview(chatId, userId, env) { await sendMessage(env.telegram_bot_token, chatId, texts.review.ask_title, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [{ text: texts.review.button_confirm, callback_data: 'final_submit' }], [{ text: texts.review.button_review, callback_data: 'review_survey' }], [{ text: texts.review.button_edit, callback_data: 'edit_answer_prompt' }] ] } }); } async function showReviewSurvey(chatId, userId, env) { const answers = await db.getSurveyAnswers(env, userId); let reviewText = texts.review.survey_title; answers.forEach((answer) => { const questionText = SURVEY_QUESTIONS[answer.question_id - 1]; reviewText += `*${answer.question_id}.* ${questionText}\n`; reviewText += `Ответ: _${answer.answer}_\n\n`; }); reviewText += texts.review.survey_footer; await sendMessage(env.telegram_bot_token, chatId, reviewText, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [ [{ text: texts.review.button_submit, callback_data: 'final_submit' }], [{ text: texts.review.button_edit_from_review, callback_data: 'edit_answer_prompt' }] ] } }); } // ============================================= // FSM: decideNextStep // ============================================= async function decideNextStep(chatId, userId, env) { const N = SURVEY_QUESTIONS_COUNT; const phase = await db.getSurveyPhase(env, userId); const currentQuestion = await db.getCurrentQuestion(env, userId); const maxReached = await db.getMaxQuestionReached(env, userId); const allAnswers = await db.getSurveyAnswers(env, userId); const answeredCount = allAnswers.length; const answeredIds = allAnswers.map(a => a.question_id); const skipped = await db.getSkippedQuestions(env, userId); const skippedUnanswered = skipped.filter(qId => !answeredIds.includes(qId)); console.log('🔍 [FSM] decideNextStep:', { phase, currentQuestion, maxReached, answeredCount, unanswered: N - answeredCount, skippedUnanswered: skippedUnanswered.length }); if (answeredCount >= N && skippedUnanswered.length === 0) { console.log('✅ [FSM] Все вопросы отвечены → review'); await db.setSurveyPhase(env, userId, 'review'); await askForReview(chatId, userId, env); return; } switch (phase) { case 'main': await handleMainPhase(chatId, userId, env, currentQuestion, maxReached, skippedUnanswered, N); break; case 'skipped': await handleSkippedPhase(chatId, userId, env); break; case 'review': console.log('📋 [FSM] review → показываем экран проверки'); await askForReview(chatId, userId, env); break; default: console.log('⚠️ [FSM] Фаза не определена, инициализируем main'); await db.initSurveyFSM(env, userId); await handleMainPhase(chatId, userId, env, currentQuestion, maxReached, skippedUnanswered, N); break; } } async function handleMainPhase(chatId, userId, env, currentQuestion, maxReached, skippedUnanswered, N) { console.log(`➡️ [FSM main] currentQ=${currentQuestion}, maxReached=${maxReached}`); if (currentQuestion < N) { const nextQuestion = currentQuestion + 1; await db.setCurrentQuestion(env, userId, nextQuestion); if (nextQuestion > maxReached) { await db.setMaxQuestionReached(env, userId, nextQuestion); } console.log(`→ [FSM main→main] Вопрос ${nextQuestion}/${N}`); await sendMessage(env.telegram_bot_token, chatId, `*Вопрос ${nextQuestion}/${N}:*\n${SURVEY_QUESTIONS[nextQuestion - 1]}`, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[ { text: texts.survey.button_pause, callback_data: 'pause_survey' }, { text: texts.survey.button_skip, callback_data: 'skip_q' } ]] } } ); return; } if (currentQuestion === N && skippedUnanswered.length > 0) { console.log(`🔄 [FSM main→skipped] Переход к пропущенным (${skippedUnanswered.length} шт.)`); await db.setSkippedQueue(env, userId, skippedUnanswered); await db.setSkippedIdx(env, userId, 0); await db.setSurveyPhase(env, userId, 'skipped'); await askNextSkippedQuestion(chatId, userId, env); return; } if (currentQuestion === N && skippedUnanswered.length === 0) { console.log('✅ [FSM main→review] Все вопросы отвечены'); await db.setSurveyPhase(env, userId, 'review'); await askForReview(chatId, userId, env); return; } console.log('⚠️ [FSM main] Неожиданное состояние'); await askForReview(chatId, userId, env); } async function handleSkippedPhase(chatId, userId, env) { const skippedQueue = await db.getSkippedQueue(env, userId); const skippedIdx = await db.getSkippedIdx(env, userId); console.log(`🔄 [FSM skipped] idx=${skippedIdx}, queue.length=${skippedQueue.length}`); if (skippedIdx < skippedQueue.length) { console.log('→ [FSM skipped→skipped] Показываем пропущенный вопрос'); await askNextSkippedQuestion(chatId, userId, env); return; } console.log('✅ [FSM skipped→review] Все пропущенные отвечены'); await db.setSurveyPhase(env, userId, 'review'); await askForReview(chatId, userId, env); } async function askNextSkippedQuestion(chatId, userId, env) { const skippedQueue = await db.getSkippedQueue(env, userId); const skippedIdx = await db.getSkippedIdx(env, userId); console.log(`🔄 [FSM] askNextSkippedQuestion: idx=${skippedIdx}, queue=${JSON.stringify(skippedQueue)}`); if (skippedQueue.length === 0 || skippedIdx >= skippedQueue.length) { console.log('✅ [FSM] Очередь пропущенных пуста → review'); await db.setSurveyPhase(env, userId, 'review'); await askForReview(chatId, userId, env); return; } const questionId = skippedQueue[skippedIdx]; await db.setCurrentQuestion(env, userId, questionId); console.log(`→ [FSM skipped] Вопрос ${questionId} (idx=${skippedIdx}/${skippedQueue.length})`); await sendMessage(env.telegram_bot_token, chatId, texts.skipped.text(questionId, SURVEY_QUESTIONS_COUNT, SURVEY_QUESTIONS[questionId - 1]), { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[{ text: texts.skipped.button_pause, callback_data: 'pause_survey' }]] } } ); } // ============================================= // ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ // ============================================= async function handleViewRecommendations(chatId, userId, env) { const recommendations = await db.getRecommendations(env, userId); if (recommendations.length > 0) { await sendMessage(env.telegram_bot_token, chatId, texts.recommendations.found(recommendations[0].content), { parse_mode: 'Markdown' } ); if (!recommendations[0].is_read) { await db.markRecommendationRead(env, recommendations[0].id); } } else { await sendMessage(env.telegram_bot_token, chatId, texts.recommendations.not_ready); } } async function showPaymentInfo(chatId, userId, env) { await sendMessage(env.telegram_bot_token, chatId, texts.payment.info(userId), { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[{ text: texts.payment.button, callback_data: 'payment_done' }]] } } ); } async function showProfile(chatId, userId, user, env) { const progress = await db.getExperimentProgress(env, userId); let profileText = texts.profile.title + texts.profile.id_label(userId) + '\n' + texts.profile.name_label(user.first_name) + '\n' + texts.profile.username_label(user.username) + '\n' + texts.profile.state_label(getStateText(user.state)); if (user.state === 'experiment_active') { profileText += texts.profile.experiment_label(progress.completed_days); } await sendMessage(env.telegram_bot_token, chatId, profileText, { parse_mode: 'Markdown' }); } async function showExperimentProgress(chatId, userId, user, env) { const progress = await db.getExperimentProgress(env, userId); const startDate = await db.getUserData(env, userId, 'experiment_start_date'); if (!startDate && user.state === 'recommendations_received') { await sendMessage(env.telegram_bot_token, chatId, texts.experiment.invite_start, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[{ text: texts.experiment.button_start, callback_data: 'start_experiment' }]] } }); return; } if (!startDate) { await sendMessage(env.telegram_bot_token, chatId, texts.experiment.not_available, { reply_markup: universalKeyboard(user.state) }); return; } const startMs = new Date(startDate).getTime(); const nowMs = Date.now(); const daysSinceStart = Math.floor((nowMs - startMs) / 86400000) + 1; const currentDay = Math.max(1, Math.min(daysSinceStart, 30)); const completedDays = progress.completed_days; const remaining = Math.max(0, 30 - completedDays); const filled = Math.floor(completedDays / 3); const progressBar = '█'.repeat(filled) + '░'.repeat(10 - filled); const percent = Math.round(completedDays / 30 * 100); let text = texts.experiment.progress_title + texts.experiment.day_label(currentDay, completedDays, remaining) + '\n' + texts.experiment.completed_label(completedDays) + '\n\n' + `${progressBar} ${percent}%\n\n` + `Осталось: ${remaining} дней\n\n`; if (completedDays >= 30) { text += texts.experiment.completed; await db.updateUserState(env, userId, 'experiment_completed'); await sendMessage(env.telegram_bot_token, chatId, text, { parse_mode: 'Markdown', reply_markup: universalKeyboard('experiment_completed') }); return; } const dayToMark = completedDays + 1; const clampedDay = Math.min(dayToMark, 30); text += texts.experiment.mark_day_prompt(clampedDay); await sendMessage(env.telegram_bot_token, chatId, text, { parse_mode: 'Markdown', reply_markup: { inline_keyboard: [[{ text: texts.experiment.button_mark_day(clampedDay), callback_data: `mark_day_${clampedDay}` }]] } }); } async function handleDashboardQuery(queryId, chatId, userId, user, env, data) { await answerCallbackQuery(env.telegram_bot_token, queryId); try { await handleDashboardCallback({ data: data, id: queryId }, chatId, userId, user, env); } catch (error) { console.error('❌ Error in handleDashboardQuery:', error); await sendMessage(env.telegram_bot_token, chatId, texts.errors.generic); } }