diff --git a/prisma/migrations/20260601084647_add_hh_phone/migration.sql b/prisma/migrations/20260601084647_add_hh_phone/migration.sql new file mode 100644 index 0000000..3c0733a --- /dev/null +++ b/prisma/migrations/20260601084647_add_hh_phone/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "hhPhone" TEXT; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 306ab85..cb279c9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -23,6 +23,7 @@ model User { createdAt DateTime @default(now()) session String? hhEmail String? + hhPhone String? resumes Resume[] prompt String @default("Ты — помощник по написанию сопроводительных писем. Отвечай только текстом самого письма, без вступлений, ремарок и пояснений. Опирайся на резюме и ничего не выдумывай, чего недостаточно в резюме лучше умолчать. Пиши по короче и простыми словами. В конце письма оставляй все контакты для связи.") Settings Settings? diff --git a/src/hh/bot-commands.ts b/src/hh/bot-commands.ts index 3c4052c..d65048d 100644 --- a/src/hh/bot-commands.ts +++ b/src/hh/bot-commands.ts @@ -2,7 +2,7 @@ import bot from '@bot' import prisma from '@prisma' import { debugFunc } from '@/hh/handlers/debug' import { handleApply } from './handlers/apply.js' -import { doLogin, handleLogin, handleLoginByEmail, handleLoginByPhone } from './handlers/auth.js' +import { doLogin, doLoginByPhone, handleLogin, handleLoginByEmail, handleLoginByPhone } from './handlers/auth.js' import { handleSkipped, handleStatus } from './handlers/info.js' import { finishOnboarding, showPromptStep, showQueryStep, showResumeInfo } from './handlers/onboarding.js' import { handleMyResume, handleResumeList, handleResumePick } from './handlers/resume.js' @@ -64,6 +64,19 @@ const CALLBACK_HANDLERS: Record = { } await doLogin(chatId, user.hhEmail) }, + hh_login_use_current_phone: async (chatId, messageId) => { + const state = getState(chatId) + state.awaitingPhone = false + state.loginPromptMessageId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) + if (!user?.hhPhone) { + await bot.sendMessage(chatId, '❌ Телефон не найден, введи вручную') + state.awaitingPhone = true + return + } + await doLoginByPhone(chatId, user.hhPhone) + }, hh_keep_query: async (chatId, messageId) => { const state = getState(chatId) state.awaitingQuery = false @@ -252,6 +265,17 @@ export function registerHHCommands() { return } + if (state.awaitingPhone) { + state.awaitingPhone = false + await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) + if (state.loginPromptMessageId) { + await bot.deleteMessage(chatId, state.loginPromptMessageId).catch(() => {}) + state.loginPromptMessageId = null + } + await doLoginByPhone(chatId, msg.text) + return + } + if (state.awaitingQuery) { state.awaitingQuery = false await bot.deleteMessage(chatId, msg.message_id).catch(() => {}) diff --git a/src/hh/handlers/auth.ts b/src/hh/handlers/auth.ts index f0f7c54..de51171 100644 --- a/src/hh/handlers/auth.ts +++ b/src/hh/handlers/auth.ts @@ -1,10 +1,47 @@ import bot from '@bot' import prisma from '@prisma' -import { listResumes, login, saveResume } from '../scraper.js' +import { listResumes, login, loginByPhone, saveResume } from '../scraper.js' import { getState } from '../state.js' import { startOnboarding } from './onboarding.js' import type { ResumeListItem } from '../types.js' +async function handlePostLogin(chatId: number): Promise { + const state = getState(chatId) + + let resumes: ResumeListItem[] | null = null + try { + resumes = await listResumes(chatId) + } + catch { + await bot.sendMessage(chatId, '⚠️ Не удалось загрузить резюме — выбери вручную через меню') + } + + await bot.sendMessage(chatId, '✅ Вход выполнен!') + + 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: [ + ...resumes.map((r, i) => [{ text: r.title, callback_data: `hh_resume_pick_${i}` }]), + [{ text: '◀️ Закрыть', callback_data: 'hh_back' }], + ], + }, + }) + } +} + export async function doLogin(chatId: number, email: string): Promise { await bot.sendMessage(chatId, '🔄 Логинюсь...') try { @@ -14,41 +51,23 @@ export async function doLogin(chatId: number, email: string): Promise { update: { hhEmail: email }, create: { telegramId: chatId, hhEmail: email, Settings: { create: {} } }, }) + await handlePostLogin(chatId) + } + catch (e) { + await bot.sendMessage(chatId, `❌ Ошибка: ${(e as Error).message}`) + } +} - const state = getState(chatId) - - let resumes: ResumeListItem[] | null = null - try { - resumes = await listResumes(chatId) - } - catch { - await bot.sendMessage(chatId, '⚠️ Не удалось загрузить резюме — выбери вручную через меню') - } - - await bot.sendMessage(chatId, '✅ Вход выполнен!') - - 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: [ - ...resumes.map((r, i) => [{ text: r.title, callback_data: `hh_resume_pick_${i}` }]), - [{ text: '◀️ Закрыть', callback_data: 'hh_back' }], - ], - }, - }) - } +export async function doLoginByPhone(chatId: number, phone: string): Promise { + await bot.sendMessage(chatId, '🔄 Логинюсь...') + try { + await loginByPhone(phone, chatId) + await prisma.user.upsert({ + where: { telegramId: chatId }, + update: { hhPhone: phone }, + create: { telegramId: chatId, hhPhone: phone, Settings: { create: {} } }, + }) + await handlePostLogin(chatId) } catch (e) { await bot.sendMessage(chatId, `❌ Ошибка: ${(e as Error).message}`) @@ -94,5 +113,26 @@ export async function handleLoginByEmail(chatId: number): Promise { } export async function handleLoginByPhone(chatId: number): Promise { - await bot.sendMessage(chatId, '📱 Авторизация по телефону — скоро будет доступна') -} + const state = getState(chatId) + const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) + state.awaitingPhone = true + + if (!user?.hhPhone) { + await bot.sendMessage(chatId, '📱 Введи номер телефона (например: +79001234567):') + } + else { + const prompt = await bot.sendMessage( + chatId, + `📱 Текущий телефон: ${user.hhPhone}\n\nИспользовать его или введи другой:`, + { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[ + { text: `✅ Войти как ${user.hhPhone}`, callback_data: 'hh_login_use_current_phone' }, + ]], + }, + }, + ) + state.loginPromptMessageId = prompt.message_id + } +} \ No newline at end of file diff --git a/src/hh/scraper.ts b/src/hh/scraper.ts index 48b49b0..34d4638 100644 --- a/src/hh/scraper.ts +++ b/src/hh/scraper.ts @@ -4,9 +4,9 @@ import type { ApplyOptions, ApplyResult, ResumeListItem, VacancyRef } from './ty import type { StatusReporter } from './ui.js' import bot from '@bot' import prisma from '@prisma' +import { createLogger } from '@/logger' import { createMessage } from '@/openai' import { loadSession, newStealthContext, randomDelay, randomScroll, withBrowser } from './browser.js' -import { createLogger } from '@/logger' import { createStatusReporter, escapeHtml } from './ui.js' const log = createLogger('scraper') @@ -64,6 +64,48 @@ async function skipIfQuestionnaire( return true } +export async function loginByPhone(phone: string, chatId: number): Promise { + await withBrowser(async (browser) => { + if (!browser.version()) { + log.error('browser error') + return + } + + const context = await newStealthContext(browser) + const page = await context.newPage() + + await page.goto('https://hh.ru/account/login', { waitUntil: 'domcontentloaded' }) + + await page.click('[data-qa="submit-button"]') + await page.waitForTimeout(randomDelay()) + + await page.fill('[data-qa="magritte-phone-input-national-number-input"]', phone) + await page.waitForTimeout(randomDelay()) + + await page.click('[data-qa="submit-button"]') + await page.waitForTimeout(randomDelay()) + + await bot.sendMessage(chatId, '🔑 Введи код из SMS') + await page.waitForTimeout(randomDelay()) + + await page.click('[data-qa="applicant-login-input-otp"]') + const otp = await waitForOtp(chatId) + await page.fill('[data-qa="applicant-login-input-otp"] input', otp) + + await page.waitForTimeout(randomDelay()) + await page.waitForSelector('[data-qa="profileAndResumes-button"]', { timeout: 15000 }) + + const cookies = await context.cookies() + await prisma.user.upsert({ + where: { telegramId: chatId }, + update: { session: JSON.stringify(cookies, null, 2) }, + create: { telegramId: chatId, session: JSON.stringify(cookies, null, 2), Settings: { create: {} } }, + }) + + await bot.sendMessage(chatId, cookies.length > 0 ? '✅ Авторизация выполнена' : '❌ Произошла ошибка') + }) +} + export async function login(email: string, chatId: number): Promise { await withBrowser(async (browser) => { if (!browser.version()) {