diff --git a/src/hh/bot-commands.ts b/src/hh/bot-commands.ts index d2b31cc..5433167 100644 --- a/src/hh/bot-commands.ts +++ b/src/hh/bot-commands.ts @@ -7,6 +7,7 @@ import { handleApply } from './handlers/apply.js' import { handleStatus, handleSkipped } from './handlers/info.js' import { handleMyResume, handleResumeList, handleResumePick } from './handlers/resume.js' import { DEFAULT_PROMPT, handleAutoToggle, handleMax, handlePrompt, handleQuery } from './handlers/settings.js' +import { finishOnboarding, showPromptStep, showQueryStep, showResumeInfo } from './handlers/onboarding.js' type MsgHandler = (chatId: number) => Promise type CallbackHandler = (chatId: number, messageId: number) => Promise @@ -75,6 +76,28 @@ const CALLBACK_HANDLERS: Record = { await bot.deleteMessage(chatId, messageId).catch(() => {}) await bot.sendMessage(chatId, '✅ Промт сброшен на дефолтный') }, + ob_skip_max: async (chatId, messageId) => { + const state = getState(chatId) + state.onboardingStep = null + state.onboardingMsgId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + await showQueryStep(chatId) + }, + ob_skip_query: async (chatId, messageId) => { + const state = getState(chatId) + state.onboardingStep = null + state.onboardingMsgId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + await showResumeInfo(chatId) + await showPromptStep(chatId) + }, + ob_skip_prompt: async (chatId, messageId) => { + const state = getState(chatId) + state.onboardingStep = null + state.onboardingMsgId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + await finishOnboarding(chatId) + }, hh_resume_list: async (chatId, messageId) => { await bot.deleteMessage(chatId, messageId).catch(() => {}) await handleResumeList(chatId) @@ -88,11 +111,14 @@ async function clearAwaitingState(chatId: number): Promise { state.queryPromptMessageId, state.maxPromptMessageId, state.promptPromptMessageId, + state.onboardingMsgId, ] state.awaitingEmail = false state.awaitingQuery = false state.awaitingMax = false state.awaitingPrompt = false + state.onboardingStep = null + state.onboardingMsgId = null state.loginPromptMessageId = null state.queryPromptMessageId = null state.maxPromptMessageId = null @@ -142,13 +168,62 @@ export function registerHHCommands() { return } - const isAwaiting = state.awaitingEmail || state.awaitingQuery || state.awaitingMax || state.awaitingPrompt + const isAwaiting = state.awaitingEmail || state.awaitingQuery || state.awaitingMax || state.awaitingPrompt || state.onboardingStep !== null const isMenuButton = Object.values(BTN).includes(msg.text as typeof BTN[keyof typeof BTN]) if (isMenuButton && isAwaiting) { await clearAwaitingState(chatId) } + if (state.onboardingStep === 'max') { + const num = Number(msg.text) + if (Number.isNaN(num) || num < 1 || num > 50) { + await bot.sendMessage(chatId, '❌ Введи число от 1 до 50:') + return + } + state.onboardingStep = null + if (state.onboardingMsgId) { + await bot.deleteMessage(chatId, state.onboardingMsgId).catch(() => {}) + state.onboardingMsgId = null + } + await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) + await prisma.settings.update({ where: { telegramId: chatId }, data: { maxApplies: num } }) + await bot.sendMessage(chatId, `✅ Макс откликов: ${num}`) + await showQueryStep(chatId) + return + } + + if (state.onboardingStep === 'query') { + state.onboardingStep = null + if (state.onboardingMsgId) { + await bot.deleteMessage(chatId, state.onboardingMsgId).catch(() => {}) + state.onboardingMsgId = null + } + await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) + const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { searchQuery: msg.text } }) + await bot.sendMessage(chatId, `✅ Запрос: «${updated.searchQuery}»`) + await showResumeInfo(chatId) + await showPromptStep(chatId) + return + } + + if (state.onboardingStep === 'prompt') { + state.onboardingStep = null + if (state.onboardingMsgId) { + await bot.deleteMessage(chatId, state.onboardingMsgId).catch(() => {}) + state.onboardingMsgId = null + } + await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) + await prisma.user.upsert({ + where: { telegramId: chatId }, + update: { prompt: msg.text }, + create: { telegramId: chatId, prompt: msg.text, Settings: { create: {} } }, + }) + await bot.sendMessage(chatId, '✅ Промт сохранён') + await finishOnboarding(chatId) + return + } + if (state.awaitingEmail) { state.awaitingEmail = false await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) diff --git a/src/hh/handlers/auth.ts b/src/hh/handlers/auth.ts index 0fe6254..c372ad1 100644 --- a/src/hh/handlers/auth.ts +++ b/src/hh/handlers/auth.ts @@ -1,8 +1,8 @@ import bot from '@bot' import prisma from '@prisma' import { listResumes, login, saveResume } from '../scraper.js' -import { MAIN_REPLY_KEYBOARD } from '../ui.js' import { getState } from '../state.js' +import { startOnboarding } from './onboarding.js' import type { ResumeListItem } from '../types.js' export async function doLogin(chatId: number, email: string): Promise { @@ -25,20 +25,21 @@ export async function doLogin(chatId: number, email: string): Promise { await bot.sendMessage(chatId, '⚠️ Не удалось загрузить резюме — выбери вручную через меню') } - await bot.sendMessage(chatId, '✅ Вход выполнен!', { reply_markup: MAIN_REPLY_KEYBOARD }) + await bot.sendMessage(chatId, '✅ Вход выполнен!') - if (resumes === null) { - // таймаут при загрузке резюме - } - else if (resumes.length === 0) { - await bot.sendMessage(chatId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru') + if (resumes === null || resumes.length === 0) { + if (resumes?.length === 0) + await bot.sendMessage(chatId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru') + await startOnboarding(chatId) } else if (resumes.length === 1) { await saveResume(chatId, resumes[0]) await bot.sendMessage(chatId, `✅ Резюме сохранено: ${resumes[0].title}`) + await startOnboarding(chatId) } else { state.pendingResumes = resumes + state.onboardingAfterResume = true await bot.sendMessage(chatId, '📄 Выбери резюме:', { reply_markup: { inline_keyboard: [ diff --git a/src/hh/handlers/onboarding.ts b/src/hh/handlers/onboarding.ts new file mode 100644 index 0000000..98447bb --- /dev/null +++ b/src/hh/handlers/onboarding.ts @@ -0,0 +1,120 @@ +import bot from '@bot' +import prisma from '@prisma' +import { getState } from '../state.js' +import { escapeHtml, MAIN_REPLY_KEYBOARD } from '../ui.js' +import { DEFAULT_PROMPT } from './settings.js' + +export async function startOnboarding(chatId: number): Promise { + await bot.sendMessage( + chatId, + `👋 Давай настроим бота — займёт меньше минуты.\n\nПройдём по двум ключевым параметрам.`, + { parse_mode: 'HTML' }, + ) + await showMaxStep(chatId) +} + +export async function showMaxStep(chatId: number): Promise { + const state = getState(chatId) + state.onboardingStep = 'max' + const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } }) + const current = settings?.maxApplies ?? 1 + + const msg = await bot.sendMessage( + chatId, + `🔢 Шаг 1 из 3 — Максимум откликов\n\n` + + `Сколько вакансий бот обработает за один запуск. Рекомендуем начать с небольшого числа, чтобы проверить письма.\n\n` + + `Текущее значение: ${current}\n\n` + + `Введи число от 1 до 50:`, + { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[ + { text: `Оставить ${current} ✓`, callback_data: 'ob_skip_max' }, + ]], + }, + }, + ) + state.onboardingMsgId = msg.message_id +} + +export async function showQueryStep(chatId: number): Promise { + const state = getState(chatId) + state.onboardingStep = 'query' + const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } }) + const current = settings?.searchQuery || 'Vue' + + const msg = await bot.sendMessage( + chatId, + `🔍 Шаг 2 из 3 — Поисковый запрос\n\n` + + `По этому запросу бот ищет вакансии на hh.ru. Используй профессию или ключевые навыки.\n\n` + + `Текущий запрос: ${current}\n\n` + + `Введи новый или оставь текущий:`, + { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[ + { text: `Оставить «${current}» ✓`, callback_data: 'ob_skip_query' }, + ]], + }, + }, + ) + state.onboardingMsgId = msg.message_id +} + +export async function showResumeInfo(chatId: number): Promise { + const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } }) + const resume = settings?.selectedResumeId + ? await prisma.resume.findUnique({ where: { id: settings.selectedResumeId } }) + : await prisma.resume.findFirst({ where: { telegramId: chatId } }) + + if (resume) { + await bot.sendMessage( + chatId, + `📄 Резюме\n\nАктивное резюме: ${resume.title}\n\nЕсли у тебя несколько резюме на hh.ru — можно выбрать нужное через Настройки → Выбрать резюме.`, + { parse_mode: 'HTML' }, + ) + } + else { + await bot.sendMessage( + chatId, + `📄 Резюме\n\nРезюме пока не выбрано. Создай его на hh.ru, затем выбери через Настройки → Выбрать резюме.`, + { parse_mode: 'HTML' }, + ) + } +} + +export async function showPromptStep(chatId: number): Promise { + const state = getState(chatId) + state.onboardingStep = 'prompt' + const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) + const currentPrompt = user?.prompt || DEFAULT_PROMPT + + const msg = await bot.sendMessage( + chatId, + `📝 Шаг 3 из 3 — Промт для AI\n\n` + + `Инструкция, которую AI получает при написании сопроводительного письма. ` + + `Задаёт стиль, тон и то, что важно упомянуть.\n\n` + + `Текущий промт:\n
${escapeHtml(currentPrompt)}
\n\n` + + `Введи свой или оставь дефолтный:`, + { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[ + { text: 'Оставить дефолтный ✓', callback_data: 'ob_skip_prompt' }, + ]], + }, + }, + ) + state.onboardingMsgId = msg.message_id +} + +export async function finishOnboarding(chatId: number): Promise { + const state = getState(chatId) + state.onboardingStep = null + state.onboardingMsgId = null + await bot.sendMessage( + chatId, + `🎉 Настройка завершена!\n\nВсё готово — нажми 🚀 Откликнуться, чтобы запустить бота.\n\nДополнительные параметры (запрос, регион, слова-исключения) доступны в разделе Фильтры.`, + { parse_mode: 'HTML', reply_markup: MAIN_REPLY_KEYBOARD }, + ) +} diff --git a/src/hh/handlers/resume.ts b/src/hh/handlers/resume.ts index e89980f..5007abe 100644 --- a/src/hh/handlers/resume.ts +++ b/src/hh/handlers/resume.ts @@ -3,6 +3,7 @@ import prisma from '@prisma' import { listResumes, NoResumeError, saveResume } from '../scraper.js' import { getState } from '../state.js' import { escapeHtml, NO_RESUME_MARKUP, safeEdit } from '../ui.js' +import { startOnboarding } from './onboarding.js' const log = createLogger('resume') @@ -97,6 +98,10 @@ export async function handleResumePick(chatId: number, messageId: number, idx: n chat_id: chatId, message_id: messageId, reply_markup: { inline_keyboard: [] }, - // reply_markup: BACK_MARKUP, }) + + if (state.onboardingAfterResume) { + state.onboardingAfterResume = false + await startOnboarding(chatId) + } } diff --git a/src/hh/state.ts b/src/hh/state.ts index 5699e9c..ee08c51 100644 --- a/src/hh/state.ts +++ b/src/hh/state.ts @@ -13,6 +13,9 @@ export interface UserState { queryPromptMessageId: number | null maxPromptMessageId: number | null promptPromptMessageId: number | null + onboardingStep: 'max' | 'query' | 'prompt' | null + onboardingMsgId: number | null + onboardingAfterResume: boolean } function makeUserState(): UserState { @@ -28,6 +31,9 @@ function makeUserState(): UserState { queryPromptMessageId: null, maxPromptMessageId: null, promptPromptMessageId: null, + onboardingStep: null, + onboardingMsgId: null, + onboardingAfterResume: false, } }