🚀 feature(file): Реализована функциональность "onboarding" для нового пользователя.
All checks were successful
Deploy / deploy (push) Successful in 50s

This commit is contained in:
Oscar
2026-06-01 11:18:18 +03:00
parent 0c18846cbb
commit d883b17bd1
5 changed files with 216 additions and 9 deletions

View File

@@ -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<void>
type CallbackHandler = (chatId: number, messageId: number) => Promise<void>
@@ -75,6 +76,28 @@ const CALLBACK_HANDLERS: Record<string, CallbackHandler> = {
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<void> {
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(() => {})

View File

@@ -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<void> {
@@ -25,20 +25,21 @@ export async function doLogin(chatId: number, email: string): Promise<void> {
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: [

View File

@@ -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<void> {
await bot.sendMessage(
chatId,
`👋 <b>Давай настроим бота</b> — займёт меньше минуты.\n\ройдём по двум ключевым параметрам.`,
{ parse_mode: 'HTML' },
)
await showMaxStep(chatId)
}
export async function showMaxStep(chatId: number): Promise<void> {
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,
`🔢 <b>Шаг 1 из 3 — Максимум откликов</b>\n\n`
+ `Сколько вакансий бот обработает за один запуск. Рекомендуем начать с небольшого числа, чтобы проверить письма.\n\n`
+ `Текущее значение: <b>${current}</b>\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<void> {
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,
`🔍 <b>Шаг 2 из 3 — Поисковый запрос</b>\n\n`
+ `По этому запросу бот ищет вакансии на hh.ru. Используй профессию или ключевые навыки.\n\n`
+ `Текущий запрос: <b>${current}</b>\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<void> {
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,
`📄 <b>Резюме</b>\n\nАктивное резюме: <b>${resume.title}</b>\n\nЕсли у тебя несколько резюме на hh.ru — можно выбрать нужное через <i>Настройки → Выбрать резюме</i>.`,
{ parse_mode: 'HTML' },
)
}
else {
await bot.sendMessage(
chatId,
`📄 <b>Резюме</b>\n\nРезюме пока не выбрано. Создай его на hh.ru, затем выбери через <i>Настройки → Выбрать резюме</i>.`,
{ parse_mode: 'HTML' },
)
}
}
export async function showPromptStep(chatId: number): Promise<void> {
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,
`📝 <b>Шаг 3 из 3 — Промт для AI</b>\n\n`
+ `Инструкция, которую AI получает при написании сопроводительного письма. `
+ `Задаёт стиль, тон и то, что важно упомянуть.\n\n`
+ `<i>Текущий промт:</i>\n<pre>${escapeHtml(currentPrompt)}</pre>\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<void> {
const state = getState(chatId)
state.onboardingStep = null
state.onboardingMsgId = null
await bot.sendMessage(
chatId,
`🎉 <b>Настройка завершена!</b>\n\nВсё готово — нажми <b>🚀 Откликнуться</b>, чтобы запустить бота.\n\ополнительные параметры (запрос, регион, слова-исключения) доступны в разделе <b>Фильтры</b>.`,
{ parse_mode: 'HTML', reply_markup: MAIN_REPLY_KEYBOARD },
)
}

View File

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

View File

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