diff --git a/README.md b/README.md index 1d603fb..06084e4 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,246 @@ -# 🤖 HH Auto-Apply Bot +# HH Auto-Apply Bot -Telegram-бот для автоматизации откликов на вакансии с hh.ru. Бот логинится на hh.ru от твоего имени, парсит вакансии по запросу и с помощью LLM генерирует персонализированное сопроводительное письмо под каждую вакансию — с учётом твоего резюме и описания позиции. +Telegram-бот для автоматизации откликов на вакансии с **hh.ru**. Авторизуется на сайте от имени пользователя, парсит вакансии по заданному запросу и генерирует персонализированное сопроводительное письмо под каждую позицию — с учётом резюме и описания вакансии. + +> **Статус:** активная разработка. Генерация писем работает; финальный клик «Откликнуться» реализован, логика отклика отлажена на реальных вакансиях. + +--- + +## Что умеет бот + +- **Авторизация через OTP** — входит на hh.ru по email + одноразовый код, сессия (cookies) сохраняется в БД и переиспользуется +- **Парсинг резюме** — скачивает резюме с hh.ru после логина, сохраняет текстом в БД +- **Поиск вакансий** — по настраиваемому запросу, с лимитом откликов за сессию +- **AI-письма** — уникальное сопроводительное письмо под каждую вакансию через LLM (Llama 3.3 70B / Groq) +- **Пропуск анкет** — вакансии с опросником автоматически пропускаются и логируются +- **Авто-режим** — cron-задача (пн–пт, 10:00) для ежедневного автозапуска +- **Полное управление через Telegram** — настройки, промпт, резюме, статус — всё в чате + +--- ## Стек | Слой | Технология | |---|---| -| Runtime | Node.js 20+ (ESM), TypeScript | +| Runtime | Node.js 22+ · TypeScript · ESM | | Telegram | node-telegram-bot-api (long polling) | | Браузерная автоматизация | Playwright (headless Chromium) | -| LLM / AI | Groq API — `llama-3.3-70b-versatile` | -| ORM | Prisma 6 + SQLite | +| LLM | DeepSeek V4 Flash · OpenRouter · через `@opencode-ai/sdk` | +| ORM | Prisma 6 · SQLite | | Планировщик | node-cron | -| Пакетный менеджер | Yarn 4 | +| Контейнеризация | Docker | +| Пакетный менеджер | Yarn 4 (PnP off) | + +--- ## Как это работает ``` -Пользователь → Telegram Bot - │ - ├─ Авторизация на hh.ru (Playwright + OTP через бот) - │ └─ Сохранение сессии (cookies) в БД - │ - ├─ Парсинг резюме с hh.ru → сохранение в БД - │ - └─ Поиск вакансий по запросу - │ - └─ Для каждой вакансии: - ├─ Playwright парсит описание - └─ LLM (Llama 3.3 70B) генерирует - сопроводительное письмо на основе - резюме + описания вакансии +Пользователь в Telegram + │ + ├─ /start → главное меню + │ + ├─ Войти на hh.ru + │ └─ Playwright открывает браузер, вводит email + │ └─ OTP-код пользователь присылает в чат + │ └─ Сессия (cookies) сохраняется в БД + │ + ├─ Выбрать резюме → Playwright парсит список резюме с hh.ru + │ + └─ Откликнуться + └─ Playwright ищет вакансии по запросу + └─ Для каждой вакансии: + ├─ Парсит описание (название, требования, компания) + ├─ Groq API генерирует письмо (резюме + описание → письмо) + ├─ Playwright вставляет письмо и кликает «Откликнуться» + └─ Отчёт в Telegram: применено / пропущено / ошибки ``` -## AI / LLM +--- -Сопроводительные письма генерирует **Llama 3.3 70B** через Groq API (OpenAI-совместимый интерфейс). Модель получает: +## AI: как генерируются письма -- Системный промпт с инструкциями по стилю (настраивается пользователем) -- Текст резюме пользователя (парсится с hh.ru) -- Описание конкретной вакансии (парсится Playwright'ом в реальном времени) +Используется **DeepSeek V4 Flash** через [OpenRouter](https://openrouter.ai). Интеграция реализована через `@opencode-ai/sdk` — при первом запросе бот поднимает локальный OpenCode-сервер (`localhost:4096`), при последующих переиспользует его. -На выходе — живое, не шаблонное письмо, адаптированное под конкретную позицию. +Каждая сессия генерации состоит из двух шагов: -В коде также подключён **Anthropic SDK** (`@anthropic-ai/sdk`) для возможности использования Claude. +1. **Первый промпт (без ответа)** — системная инструкция + полный текст резюме +2. **Второй промпт** — описание вакансии, модель возвращает готовое письмо -## Возможности +Пользователь настраивает системный промпт прямо в боте через кнопку `📝 Промт`. По умолчанию модель пишет коротко, опирается только на факты из резюме и добавляет контакты в конце. -- **Авторизация** — вход на hh.ru через email + OTP-код, сессия сохраняется в БД и переиспользуется -- **Парсинг резюме** — автоматически скачивает твоё резюме с hh.ru после логина -- **Поиск вакансий** — поиск по настраиваемому запросу с лимитом откликов -- **AI-сопроводительные письма** — уникальное письмо под каждую вакансию через LLM -- **Авто-режим** — крон-задача (пн–пт, 10:00) для ежедневного автозапуска -- **Настройки через бот** — поисковый запрос, макс. кол-во откликов — всё меняется прямо в чате - -## Команды бота - -| Кнопка / команда | Действие | -|---|---| -| `/start` | Регистрация, главное меню | -| `💼 Меню` | Панель управления HH Auto-Apply | -| `🚀 Откликнуться сейчас` | Запустить поиск и генерацию писем | -| `🔍 Изменить запрос` | Сменить поисковый запрос | -| `🔢 Макс откликов` | Установить лимит вакансий за сессию | -| `⏰ Авто вкл / Авто выкл` | Включить/выключить ежедневный крон | -| `🔑 Логин` | Авторизоваться на hh.ru | -| `⚙️ Статус` | Текущие настройки и статус авторизации | - -## База данных - -``` -User — telegramId, hhEmail, session (cookies), AI-промпт -Resume — текст резюме, привязан к пользователю -Settings — searchQuery, maxApplies -``` - -## Docker - -### Сборка образа - -```bash -docker build -t oscr-test-bot . -``` - -### Запуск контейнера - -Создай файл `.env.docker`: - -``` -DATABASE_URL=file:/data/dev.db -TG_BOT_TOKEN=your_token -GROQ_API_KEY=your_key -OPENROUTER_API_KEY=your_key -``` - -Запуск (Linux/Mac): - -```bash -docker run --rm --env-file .env.docker -v $(pwd)/data:/data oscr-test-bot -``` - -Запуск (Windows PowerShell): - -```powershell -docker run --rm --env-file .env.docker -v //c/MyApps/oscr-test-bot/data:/data oscr-test-bot -``` - -SQLite база данных сохраняется в папку `data/` и переживает перезапуски контейнера. - -## Запуск локально - -```bash -# 1. Установить зависимости -yarn - -# 2. Создать .env -echo 'DATABASE_URL="file:./dev.db"' > .env - -# 3. Применить миграции -yarn db-migrate - -# 4. Запустить в dev-режиме -yarn dev -``` - -## Команды разработки - -```bash -yarn dev # запуск с hot reload (tsx watch) -yarn build # компиляция TypeScript → dist/ -yarn start # запуск скомпилированного кода -yarn lint # проверка ESLint -yarn lint:fix # автофикс ESLint -yarn db-view # Prisma Studio (GUI для БД) -yarn db-migrate # создать и применить миграцию -yarn db-deploy # применить миграции на проде -``` +--- ## Архитектура -Одиночный Node.js процесс без HTTP-сервера. Весь UI — Telegram inline-клавиатуры и сообщения. Два синглтона (`TelegramBot`, `PrismaClient`) шарятся через path-алиасы `@bot` и `@prisma`. +Одиночный Node.js процесс без HTTP-сервера. Весь UI — Telegram reply-клавиатуры и inline-кнопки. -Playwright запускается headless для каждой операции с hh.ru и закрывается после — никаких persistent browser-процессов. Для имитации человеческого поведения используются случайные задержки и скроллы. +``` +src/ +├── index.ts # точка входа, регистрация хендлеров +├── bot-singleton.ts # singleton TelegramBot (@bot) +├── prisma.ts # singleton PrismaClient (@prisma) +├── openai.ts # Groq API через openai-sdk +└── hh/ + ├── bot-commands.ts # маршрутизация: handler maps вместо switch + ├── state.ts # UserState (awaiting-флаги, cron, pending resumes) + ├── scraper.ts # Playwright-автоматизация hh.ru + ├── browser.ts # stealth-контекст, сессии, утилиты + ├── ui.ts # клавиатуры, escapeHtml, StatusReporter + ├── types.ts # общие типы + └── handlers/ + ├── apply.ts # запуск поиска и откликов + ├── auth.ts # логин, OTP-флоу + ├── info.ts # статус, проблемные вакансии + ├── resume.ts # список резюме, просмотр, выбор + └── settings.ts # запрос, лимит, промпт, авто-режим +``` + +**Path-алиасы** (tsconfig.json): `@bot`, `@prisma`, `@/*` → `src/*` + +Состояние диалога (`awaitingEmail`, `awaitingQuery` и т.д.) хранится in-memory в `Map` — сбрасывается при рестарте процесса. Персистентные данные (сессия, резюме, настройки) — только в БД. + +--- + +## База данных + +```prisma +model User { + telegramId BigInt @unique + hhEmail String? + session String? // cookies hh.ru (JSON) + prompt String // системный промпт для AI + resumes Resume[] + Settings Settings? +} + +model Resume { + id String @id // хэш от URL резюме на hh.ru + title String + data String // полный текст резюме + telegramId BigInt +} + +model Settings { + telegramId BigInt @id + searchQuery String @default("Vue") + maxApplies Int @default(1) + selectedResumeId String? +} + +model SkippedVacancy { + telegramId BigInt + href String + title String + createdAt DateTime + // @@unique([telegramId, href]) +} +``` + +--- + +## Запуск локально + +### 1. Зависимости + +```bash +yarn +``` + +### 2. Переменные окружения + +Создай `.env` в корне проекта: + +```env +# База данных (обязательно) +DATABASE_URL="file:./prisma/dev.db" + +# Telegram Bot Token — получить у @BotFather +TG_BOT_TOKEN=1234567890:AAFxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# OpenRouter API Key — нужен для DeepSeek V4 Flash (openrouter.ai) +OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# Groq API Key — legacy, в текущей версии не используется +# GROQ_API_KEY=gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +### 3. Миграции и запуск + +```bash +yarn db-migrate # создать БД и применить миграции +yarn dev # запуск с hot reload +``` + +--- + +## Команды + +```bash +yarn dev # запуск с hot reload (tsx watch + .env) +yarn build # компиляция TypeScript → dist/ +yarn start # запуск скомпилированного dist/index.js +yarn lint # ESLint проверка +yarn lint:fix # ESLint автофикс +yarn db-view # Prisma Studio — GUI для БД +yarn db-migrate # создать и применить миграцию (dev) +yarn db:deploy # применить миграции без создания новых (prod) +``` + +--- + +## Docker + +### Сборка + +```bash +docker build -t hh-auto-apply-bot . +``` + +### Запуск + +Создай `.env.docker`: + +```env +DATABASE_URL=file:/data/dev.db +TG_BOT_TOKEN=... +OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +```bash +# Linux / macOS +docker run --rm --env-file .env.docker -v $(pwd)/data:/data hh-auto-apply-bot + +# Windows PowerShell +docker run --rm --env-file .env.docker -v ${PWD}/data:/data hh-auto-apply-bot +``` + +SQLite-база сохраняется в `./data/` и переживает перезапуски контейнера. + +--- + +## UI бота + +**Главное меню** +| Кнопка | Действие | +|---|---| +| `🚀 Откликнуться` | Запустить поиск и отклики | +| `⚙️ Настройки` | Открыть меню настроек | +| `ℹ️ Информация` | Открыть меню информации | + +**Настройки** +| Кнопка | Действие | +|---|---| +| `🔢 Макс. откликов` | Лимит откликов за сессию (1–50) | +| `🔍 Изменить запрос` | Поисковый запрос на hh.ru | +| `⏰ Авто` | Вкл/выкл ежедневный cron (пн–пт 10:00) | +| `📄 Выбрать резюме` | Загрузить список резюме с hh.ru | +| `📝 Промт` | Настроить системный промпт для AI | +| `🔑 Войти на hh.ru` | Авторизация (email + OTP) | + +**Информация** +| Кнопка | Действие | +|---|---| +| `⚙️ Статус` | Текущие настройки и статус авторизации | +| `📋 Моё резюме` | Просмотр сохранённого резюме | +| `🚫 Проблемные вакансии` | Вакансии с анкетой (бот не может откликнуться) | diff --git a/src/hh/bot-commands.ts b/src/hh/bot-commands.ts index 5f7a1d5..6607bbe 100644 --- a/src/hh/bot-commands.ts +++ b/src/hh/bot-commands.ts @@ -1,264 +1,94 @@ -import type { ResumeListItem } from './types.js' import bot from '@bot' import prisma from '@prisma' -import cron, { type ScheduledTask } from 'node-cron' -import { applyToJobs, checkIsAuth, listResumes, login, NoResumeError, saveResume } from './scraper.js' -import { BACK_MARKUP, BTN, createStatusReporter, escapeHtml, INFO_REPLY_KEYBOARD, LOGIN_REPLY_KEYBOARD, MAIN_REPLY_KEYBOARD, NO_RESUME_MARKUP, safeEdit, SETTINGS_REPLY_KEYBOARD } from './ui.js' +import { BTN, INFO_REPLY_KEYBOARD, LOGIN_REPLY_KEYBOARD, MAIN_REPLY_KEYBOARD, SETTINGS_REPLY_KEYBOARD } from './ui.js' +import { getState } from './state.js' +import { doLogin, handleLogin } from './handlers/auth.js' +import { handleApply } from './handlers/apply.js' +import { handleStatus, handleSkipped } from './handlers/info.js' +import { handleMyResume, handleResumeList, handleResumePick } from './handlers/resume.js' +import { handleAutoToggle, handleMax, handlePrompt, handleQuery } from './handlers/settings.js' -interface UserState { - autoCron: ScheduledTask | null - awaitingEmail: boolean - awaitingQuery: boolean - awaitingMax: boolean - awaitingPrompt: boolean - pendingResumes: ResumeListItem[] - loginPromptMessageId: number | null - queryPromptMessageId: number | null - maxPromptMessageId: number | null - promptPromptMessageId: number | null +type MsgHandler = (chatId: number) => Promise +type CallbackHandler = (chatId: number, messageId: number) => Promise + +const MESSAGE_HANDLERS: Partial> = { + [BTN.APPLY]: handleApply, + [BTN.STATUS]: handleStatus, + [BTN.QUERY]: handleQuery, + [BTN.MAX]: handleMax, + [BTN.AUTO_TOGGLE]: handleAutoToggle, + [BTN.PROMPT]: handlePrompt, + [BTN.LOGIN]: handleLogin, + [BTN.RESUME_LIST]: handleResumeList, + [BTN.MY_RESUME]: handleMyResume, + [BTN.SKIPPED]: handleSkipped, + [BTN.SETTINGS]: async chatId => { await bot.sendMessage(chatId, '⚙️ Настройки:', { reply_markup: SETTINGS_REPLY_KEYBOARD }) }, + [BTN.INFO]: async chatId => { await bot.sendMessage(chatId, 'ℹ️ Информация:', { reply_markup: INFO_REPLY_KEYBOARD }) }, + [BTN.BACK]: async chatId => { await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: MAIN_REPLY_KEYBOARD }) }, } -function makeUserState(): UserState { - return { - autoCron: null, - awaitingEmail: false, - awaitingQuery: false, - awaitingMax: false, - awaitingPrompt: false, - pendingResumes: [], - loginPromptMessageId: null, - queryPromptMessageId: null, - maxPromptMessageId: null, - promptPromptMessageId: null, - } -} - -const states = new Map() - -function getState(chatId: number): UserState { - if (!states.has(chatId)) - states.set(chatId, makeUserState()) - return states.get(chatId)! -} - -async function doLogin(chatId: number, email: string): Promise { - await bot.sendMessage(chatId, '🔄 Логинюсь...') - try { - await login(email, chatId) - await prisma.user.upsert({ - where: { telegramId: chatId }, - update: { hhEmail: email }, - create: { telegramId: chatId, hhEmail: email, Settings: { create: {} } }, - }) - +const CALLBACK_HANDLERS: Record = { + hh_back: async (chatId, messageId) => { + await bot.deleteMessage(chatId, messageId).catch(() => {}) + }, + hh_login: async (chatId, messageId) => { + await bot.deleteMessage(chatId, messageId).catch(() => {}) + await handleLogin(chatId) + }, + hh_login_use_current: async (chatId, messageId) => { const state = getState(chatId) - - let resumes: ResumeListItem[] | null = null - try { - resumes = await listResumes(chatId) + state.awaitingEmail = false + state.loginPromptMessageId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) + if (!user?.hhEmail) { + await bot.sendMessage(chatId, '❌ Email не найден, введи вручную') + state.awaitingEmail = true + return } - catch { - await bot.sendMessage(chatId, '⚠️ Не удалось загрузить резюме — выбери вручную через меню') - } - - await bot.sendMessage(chatId, '✅ Вход выполнен!', { reply_markup: MAIN_REPLY_KEYBOARD }) - - if (resumes === null) { - // таймаут при загрузке резюме - } - else if (resumes.length === 0) { - await bot.sendMessage(chatId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru') - } - else if (resumes.length === 1) { - await saveResume(chatId, resumes[0]) - await bot.sendMessage(chatId, `✅ Резюме сохранено: ${resumes[0].title}`) - } - else { - state.pendingResumes = resumes - 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' }], - ], - }, - }) - } - } - catch (e) { - await bot.sendMessage(chatId, `❌ Ошибка: ${(e as Error).message}`) - } + await doLogin(chatId, user.hhEmail) + }, + hh_keep_query: async (chatId, messageId) => { + const state = getState(chatId) + state.awaitingQuery = false + state.queryPromptMessageId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + }, + hh_keep_max: async (chatId, messageId) => { + const state = getState(chatId) + state.awaitingMax = false + state.maxPromptMessageId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + }, + hh_keep_prompt: async (chatId, messageId) => { + const state = getState(chatId) + state.awaitingPrompt = false + state.promptPromptMessageId = null + await bot.deleteMessage(chatId, messageId).catch(() => {}) + }, + hh_resume_list: async (chatId, messageId) => { + await bot.deleteMessage(chatId, messageId).catch(() => {}) + await handleResumeList(chatId) + }, } -async function handleApply(chatId: number): Promise { - const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) - if (!settings) - return - - const reporter = createStatusReporter(chatId) - await reporter.keep(`🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`) - - applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, { chatId, reporter }) - .then(async (result) => { - if (result.error) { - await bot.sendMessage(chatId, `❌ ${result.error}`) - return - } - - const lines: string[] = [] - lines.push(`📊 Итого по запросу «${settings.searchQuery}»`) - lines.push(`✅ Откликнулся: ${result.applied.length}`) - lines.push(`⏭ Пропущено: ${result.skipped.length}`) - if (result.errors.length) - lines.push(`❌ Ошибок: ${result.errors.length}`) - - if (result.skipped.length) { - lines.push('') - lines.push('⏭ Пропущенные:') - result.skipped.forEach(v => lines.push(`• ${v.title}`)) - } - - if (result.errors.length) { - lines.push('') - lines.push('❌ Ошибки:') - result.errors.forEach(v => lines.push(`• ${escapeHtml(v.title)} — ${escapeHtml(v.message ?? '')}`)) - } - - const fullText = lines.join('\n') - const LIMIT = 4000 - for (let i = 0; i < fullText.length; i += LIMIT) { - await bot.sendMessage(chatId, fullText.slice(i, i + LIMIT), { - parse_mode: 'HTML', - disable_web_page_preview: true, - }) - } - }) -} - -async function handleStatus(chatId: number): Promise { +async function clearAwaitingState(chatId: number): Promise { const state = getState(chatId) - const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) - const isAuth = await checkIsAuth(chatId) - await bot.sendMessage( - chatId, - `⚙️ Настройки:\n\nЗапрос: ${settings?.searchQuery ?? '--'}\nМакс откликов: ${settings?.maxApplies ?? '--'}\nАвто: ${state.autoCron ? '✅ включено' : '❌ выключено'}\nАвторизован: ${isAuth ? '✅' : '❌'}`, - { reply_markup: BACK_MARKUP }, - ) -} - -async function handleLogin(chatId: number): Promise { - const state = getState(chatId) - const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) - state.awaitingEmail = true - - if (!user?.hhEmail) { - await bot.sendMessage(chatId, '📧 Введи email от hh.ru:') - } - else { - const prompt = await bot.sendMessage( - chatId, - `📧 Текущий email: ${user.hhEmail}\n\nИспользовать его или введи другой:`, - { - parse_mode: 'HTML', - reply_markup: { - inline_keyboard: [[ - { text: `✅ Войти как ${user.hhEmail}`, callback_data: 'hh_login_use_current' }, - ]], - }, - }, - ) - state.loginPromptMessageId = prompt.message_id - } -} - -async function handleResumeList(chatId: number): Promise { - const state = getState(chatId) - const loadingMsg = await bot.sendMessage(chatId, '🔄 Загружаю список резюме...') - - let resumes: ResumeListItem[] - try { - resumes = await listResumes(chatId) - console.log(`[handleResumeList ${chatId}]: ${resumes}`) - } - catch (e) { - await bot.deleteMessage(chatId, loadingMsg.message_id).catch(() => {}) - if (e instanceof NoResumeError) { - await bot.sendMessage( - chatId, - '📝 Резюме не найдено.\n\nСоздайте резюме на hh.ru, затем нажмите Повторить.', - { parse_mode: 'HTML', reply_markup: NO_RESUME_MARKUP }, - ) - } - else { - await bot.sendMessage(chatId, '❌ Не удалось загрузить резюме. Попробуйте войти заново через «Войти на hh.ru».') - } - return - } - - await bot.deleteMessage(chatId, loadingMsg.message_id).catch(() => {}) - - if (resumes.length === 0) { - await bot.sendMessage(chatId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru') - } - else if (resumes.length === 1) { - await saveResume(chatId, resumes[0]) - await bot.sendMessage(chatId, `✅ Резюме сохранено: ${resumes[0].title}`) - } - else { - state.pendingResumes = resumes - 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' }], - ], - }, - }) - } -} - -async function handleMyResume(chatId: number): Promise { - const settings = await prisma.settings.findUnique({ 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Выбери резюме через кнопку 📄 Выбрать резюме.') - return - } - - const MAX = 3500 - const text = resume.data.length > MAX - ? `${resume.data.slice(0, MAX)}\n\n… (текст обрезан)` - : resume.data - - await bot.sendMessage( - chatId, - `📋 Твоё резюме:\n${resume.title}\n
${escapeHtml(text)}
`, - { parse_mode: 'HTML', reply_markup: BACK_MARKUP }, - ) -} - -async function handleSkipped(chatId: number): Promise { - const skipped = await prisma.skippedVacancy.findMany({ - where: { telegramId: chatId }, - orderBy: { createdAt: 'desc' }, - take: 50, - }) - - if (!skipped.length) { - await bot.sendMessage(chatId, '✅ Проблемных вакансий нет') - return - } - - const lines = ['🚫 Вакансии с опросником (бот не может откликнуться):', ''] - skipped.forEach(v => lines.push(`• ${escapeHtml(v.title)}`)) - await bot.sendMessage(chatId, lines.join('\n'), { - parse_mode: 'HTML', - disable_web_page_preview: true, - reply_markup: BACK_MARKUP, - }) + const msgIds = [ + state.loginPromptMessageId, + state.queryPromptMessageId, + state.maxPromptMessageId, + state.promptPromptMessageId, + ] + state.awaitingEmail = false + state.awaitingQuery = false + state.awaitingMax = false + state.awaitingPrompt = false + state.loginPromptMessageId = null + state.queryPromptMessageId = null + state.maxPromptMessageId = null + state.promptPromptMessageId = null + await Promise.all(msgIds.filter(Boolean).map(id => bot.deleteMessage(chatId, id!).catch(() => {}))) } export async function triggerHHStart(chatId: number): Promise { @@ -268,9 +98,7 @@ export async function triggerHHStart(chatId: number): Promise { } export function registerHHCommands() { - bot.onText(/\/hhstart/, (msg) => { - triggerHHStart(msg.chat.id) - }) + bot.onText(/\/hhstart/, msg => triggerHHStart(msg.chat.id)) bot.on('callback_query', async (query) => { if (!query.message) @@ -278,117 +106,32 @@ export function registerHHCommands() { const chatId = query.message.chat.id const messageId = query.message.message_id - const state = getState(chatId) await bot.answerCallbackQuery(query.id).catch(() => {}) - switch (query.data) { - case 'hh_back': - await bot.deleteMessage(chatId, messageId).catch(() => {}) - break + const exactHandler = query.data ? CALLBACK_HANDLERS[query.data] : undefined + if (exactHandler) { + await exactHandler(chatId, messageId) + return + } - case 'hh_login': - await bot.deleteMessage(chatId, messageId).catch(() => {}) - await handleLogin(chatId) - break - - case 'hh_login_use_current': { - state.awaitingEmail = false - await bot.deleteMessage(chatId, messageId).catch(() => {}) - state.loginPromptMessageId = null - const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) - if (!user?.hhEmail) { - await bot.sendMessage(chatId, '❌ Email не найден, введи вручную') - state.awaitingEmail = true - break - } - await doLogin(chatId, user.hhEmail) - break - } - - case 'hh_keep_query': - state.awaitingQuery = false - state.queryPromptMessageId = null - await bot.deleteMessage(chatId, messageId).catch(() => {}) - break - - case 'hh_keep_max': - state.awaitingMax = false - state.maxPromptMessageId = null - await bot.deleteMessage(chatId, messageId).catch(() => {}) - break - - case 'hh_keep_prompt': - state.awaitingPrompt = false - state.promptPromptMessageId = null - await bot.deleteMessage(chatId, messageId).catch(() => {}) - break - - case 'hh_resume_list': - await bot.deleteMessage(chatId, messageId).catch(() => {}) - await handleResumeList(chatId) - break - - default: { - if (query.data?.startsWith('hh_resume_pick_')) { - const idx = Number(query.data.replace('hh_resume_pick_', '')) - const resume = state.pendingResumes[idx] - if (!resume) { - await safeEdit('❌ Резюме не найдено, попробуйте снова', { - chat_id: chatId, - message_id: messageId, - reply_markup: { inline_keyboard: [] }, - }) - break - } - await safeEdit('🔄 Сохраняю резюме...', { - chat_id: chatId, - message_id: messageId, - reply_markup: { inline_keyboard: [] }, - }) - await saveResume(chatId, resume) - state.pendingResumes = [] - await safeEdit(`✅ Резюме выбрано: ${resume.title}`, { - chat_id: chatId, - message_id: messageId, - reply_markup: BACK_MARKUP, - }) - } - break - } + if (query.data?.startsWith('hh_resume_pick_')) { + const idx = Number(query.data.replace('hh_resume_pick_', '')) + await handleResumePick(chatId, messageId, idx) } }) bot.on('message', async (msg) => { - const chatId = msg.chat.id - if (!msg.text || msg.text.startsWith('/')) return + const chatId = msg.chat.id const state = getState(chatId) - + const isAwaiting = state.awaitingEmail || state.awaitingQuery || state.awaitingMax || state.awaitingPrompt const isMenuButton = Object.values(BTN).includes(msg.text as typeof BTN[keyof typeof BTN]) - if (isMenuButton && (state.awaitingEmail || state.awaitingQuery || state.awaitingMax || state.awaitingPrompt)) { - state.awaitingEmail = false - state.awaitingQuery = false - state.awaitingMax = false - state.awaitingPrompt = false - if (state.loginPromptMessageId) { - await bot.deleteMessage(chatId, state.loginPromptMessageId).catch(() => {}) - state.loginPromptMessageId = null - } - if (state.queryPromptMessageId) { - await bot.deleteMessage(chatId, state.queryPromptMessageId).catch(() => {}) - state.queryPromptMessageId = null - } - if (state.maxPromptMessageId) { - await bot.deleteMessage(chatId, state.maxPromptMessageId).catch(() => {}) - state.maxPromptMessageId = null - } - if (state.promptPromptMessageId) { - await bot.deleteMessage(chatId, state.promptPromptMessageId).catch(() => {}) - state.promptPromptMessageId = null - } + + if (isMenuButton && isAwaiting) { + await clearAwaitingState(chatId) } if (state.awaitingEmail) { @@ -409,10 +152,7 @@ export function registerHHCommands() { await bot.deleteMessage(chatId, state.queryPromptMessageId).catch(() => {}) state.queryPromptMessageId = null } - const updated = await prisma.settings.update({ - where: { telegramId: chatId }, - data: { searchQuery: msg.text }, - }) + const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { searchQuery: msg.text } }) await bot.sendMessage(chatId, `✅ Запрос: "${updated.searchQuery}"`) return } @@ -429,10 +169,7 @@ export function registerHHCommands() { await bot.deleteMessage(chatId, state.maxPromptMessageId).catch(() => {}) state.maxPromptMessageId = null } - const updated = await prisma.settings.update({ - where: { telegramId: chatId }, - data: { maxApplies: num }, - }) + const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { maxApplies: num } }) await bot.sendMessage(chatId, `✅ Макс откликов: ${updated.maxApplies}`) return } @@ -453,116 +190,6 @@ export function registerHHCommands() { return } - switch (msg.text) { - case BTN.APPLY: - await handleApply(chatId) - break - - case BTN.STATUS: - await handleStatus(chatId) - break - - case BTN.QUERY: { - state.awaitingQuery = true - const q = await prisma.settings.findFirst({ where: { telegramId: chatId } }) - const currentQuery = q?.searchQuery || '--' - const queryMsg = await bot.sendMessage( - chatId, - `🔍 Текущий запрос: ${escapeHtml(currentQuery)}\n\nВведи новый или оставь текущий:`, - { - parse_mode: 'HTML', - reply_markup: { - inline_keyboard: [[ - { text: `✅ Оставить «${currentQuery}»`, callback_data: 'hh_keep_query' }, - ]], - }, - }, - ) - state.queryPromptMessageId = queryMsg.message_id - break - } - - case BTN.MAX: { - state.awaitingMax = true - const s = await prisma.settings.findFirst({ where: { telegramId: chatId } }) - const currentMax = s?.maxApplies ?? '--' - const maxMsg = await bot.sendMessage( - chatId, - `🔢 Текущее значение: ${currentMax}\n\nВведи новое количество откликов (1–50) или оставь текущее:`, - { - parse_mode: 'HTML', - reply_markup: { - inline_keyboard: [[ - { text: `✅ Оставить ${currentMax}`, callback_data: 'hh_keep_max' }, - ]], - }, - }, - ) - state.maxPromptMessageId = maxMsg.message_id - break - } - - case BTN.AUTO_TOGGLE: { - const s = getState(chatId) - if (s.autoCron) { - s.autoCron.stop() - s.autoCron = null - await bot.sendMessage(chatId, '⛔ Авто остановлен', { reply_markup: SETTINGS_REPLY_KEYBOARD }) - } - else { - s.autoCron = cron.schedule('0 10 * * 1-5', async () => { - await bot.sendMessage(chatId, '⏰ Авто-отклик...') - }) - await bot.sendMessage(chatId, '✅ Авто включён (пн-пт, 10:00)', { reply_markup: SETTINGS_REPLY_KEYBOARD }) - } - break - } - - case BTN.SETTINGS: - await bot.sendMessage(chatId, '⚙️ Настройки:', { reply_markup: SETTINGS_REPLY_KEYBOARD }) - break - - case BTN.INFO: - await bot.sendMessage(chatId, 'ℹ️ Информация:', { reply_markup: INFO_REPLY_KEYBOARD }) - break - - case BTN.BACK: - await bot.sendMessage(chatId, '🤖 HH Auto-Apply', { reply_markup: MAIN_REPLY_KEYBOARD }) - break - - case BTN.PROMPT: { - state.awaitingPrompt = true - const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) - const currentPrompt = user?.prompt - const promptText = currentPrompt - ? `📝 Текущий промт:\n
${escapeHtml(currentPrompt)}
\n\nВведи новый или оставь текущий:` - : '📝 Введи промт для AI (пока не задан):' - const keepButton = currentPrompt - ? [[{ text: '✅ Оставить текущий промт', callback_data: 'hh_keep_prompt' }]] - : [] - const promptMsg = await bot.sendMessage(chatId, promptText, { - parse_mode: 'HTML', - reply_markup: { inline_keyboard: keepButton }, - }) - state.promptPromptMessageId = promptMsg.message_id - break - } - - case BTN.LOGIN: - await handleLogin(chatId) - break - - case BTN.RESUME_LIST: - await handleResumeList(chatId) - break - - case BTN.MY_RESUME: - await handleMyResume(chatId) - break - - case BTN.SKIPPED: - await handleSkipped(chatId) - break - } + await MESSAGE_HANDLERS[msg.text]?.(chatId) }) } diff --git a/src/hh/handlers/apply.ts b/src/hh/handlers/apply.ts new file mode 100644 index 0000000..15ad666 --- /dev/null +++ b/src/hh/handlers/apply.ts @@ -0,0 +1,49 @@ +import bot from '@bot' +import prisma from '@prisma' +import { applyToJobs } from '../scraper.js' +import { createStatusReporter, escapeHtml } from '../ui.js' + +export async function handleApply(chatId: number): Promise { + const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) + if (!settings) + return + + const reporter = createStatusReporter(chatId) + await reporter.keep(`🔄 Ищу вакансии по запросу "${settings.searchQuery}"...`) + + applyToJobs({ query: settings.searchQuery, maxApplies: settings.maxApplies }, { chatId, reporter }) + .then(async (result) => { + if (result.error) { + await bot.sendMessage(chatId, `❌ ${result.error}`) + return + } + + const lines: string[] = [] + lines.push(`📊 Итого по запросу «${settings.searchQuery}»`) + lines.push(`✅ Откликнулся: ${result.applied.length}`) + lines.push(`⏭ Пропущено: ${result.skipped.length}`) + if (result.errors.length) + lines.push(`❌ Ошибок: ${result.errors.length}`) + + if (result.skipped.length) { + lines.push('') + lines.push('⏭ Пропущенные:') + result.skipped.forEach(v => lines.push(`• ${v.title}`)) + } + + if (result.errors.length) { + lines.push('') + lines.push('❌ Ошибки:') + result.errors.forEach(v => lines.push(`• ${escapeHtml(v.title)} — ${escapeHtml(v.message ?? '')}`)) + } + + const fullText = lines.join('\n') + const LIMIT = 4000 + for (let i = 0; i < fullText.length; i += LIMIT) { + await bot.sendMessage(chatId, fullText.slice(i, i + LIMIT), { + parse_mode: 'HTML', + disable_web_page_preview: true, + }) + } + }) +} diff --git a/src/hh/handlers/auth.ts b/src/hh/handlers/auth.ts new file mode 100644 index 0000000..0fe6254 --- /dev/null +++ b/src/hh/handlers/auth.ts @@ -0,0 +1,80 @@ +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 type { ResumeListItem } from '../types.js' + +export async function doLogin(chatId: number, email: string): Promise { + await bot.sendMessage(chatId, '🔄 Логинюсь...') + try { + await login(email, chatId) + await prisma.user.upsert({ + where: { telegramId: chatId }, + update: { hhEmail: email }, + create: { telegramId: chatId, hhEmail: email, Settings: { create: {} } }, + }) + + const state = getState(chatId) + + let resumes: ResumeListItem[] | null = null + try { + resumes = await listResumes(chatId) + } + catch { + await bot.sendMessage(chatId, '⚠️ Не удалось загрузить резюме — выбери вручную через меню') + } + + await bot.sendMessage(chatId, '✅ Вход выполнен!', { reply_markup: MAIN_REPLY_KEYBOARD }) + + if (resumes === null) { + // таймаут при загрузке резюме + } + else if (resumes.length === 0) { + await bot.sendMessage(chatId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru') + } + else if (resumes.length === 1) { + await saveResume(chatId, resumes[0]) + await bot.sendMessage(chatId, `✅ Резюме сохранено: ${resumes[0].title}`) + } + else { + state.pendingResumes = resumes + 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' }], + ], + }, + }) + } + } + catch (e) { + await bot.sendMessage(chatId, `❌ Ошибка: ${(e as Error).message}`) + } +} + +export async function handleLogin(chatId: number): Promise { + const state = getState(chatId) + const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) + state.awaitingEmail = true + + if (!user?.hhEmail) { + await bot.sendMessage(chatId, '📧 Введи email от hh.ru:') + } + else { + const prompt = await bot.sendMessage( + chatId, + `📧 Текущий email: ${user.hhEmail}\n\nИспользовать его или введи другой:`, + { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[ + { text: `✅ Войти как ${user.hhEmail}`, callback_data: 'hh_login_use_current' }, + ]], + }, + }, + ) + state.loginPromptMessageId = prompt.message_id + } +} diff --git a/src/hh/handlers/info.ts b/src/hh/handlers/info.ts new file mode 100644 index 0000000..0ea55c6 --- /dev/null +++ b/src/hh/handlers/info.ts @@ -0,0 +1,37 @@ +import bot from '@bot' +import prisma from '@prisma' +import { checkIsAuth } from '../scraper.js' +import { getState } from '../state.js' +import { escapeHtml } from '../ui.js' + +export async function handleStatus(chatId: number): Promise { + const state = getState(chatId) + const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } }) + const isAuth = await checkIsAuth(chatId) + await bot.sendMessage( + chatId, + `⚙️ Настройки:\n\nЗапрос: ${settings?.searchQuery ?? '--'}\nМакс откликов: ${settings?.maxApplies ?? '--'}\nАвто: ${state.autoCron ? '✅ включено' : '❌ выключено'}\nАвторизован: ${isAuth ? '✅' : '❌'}`, + { reply_markup: { inline_keyboard: [] } }, + ) +} + +export async function handleSkipped(chatId: number): Promise { + const skipped = await prisma.skippedVacancy.findMany({ + where: { telegramId: chatId }, + orderBy: { createdAt: 'desc' }, + take: 50, + }) + + if (!skipped.length) { + await bot.sendMessage(chatId, '✅ Проблемных вакансий нет') + return + } + + const lines = ['🚫 Вакансии с опросником (бот не может откликнуться):', ''] + skipped.forEach(v => lines.push(`• ${escapeHtml(v.title)}`)) + await bot.sendMessage(chatId, lines.join('\n'), { + parse_mode: 'HTML', + disable_web_page_preview: true, + reply_markup: { inline_keyboard: [] }, + }) +} diff --git a/src/hh/handlers/resume.ts b/src/hh/handlers/resume.ts new file mode 100644 index 0000000..6c72153 --- /dev/null +++ b/src/hh/handlers/resume.ts @@ -0,0 +1,100 @@ +import bot from '@bot' +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' + +export async function handleResumeList(chatId: number): Promise { + const state = getState(chatId) + const loadingMsg = await bot.sendMessage(chatId, '🔄 Загружаю список резюме...') + + let resumes + try { + resumes = await listResumes(chatId) + console.log(`[handleResumeList ${chatId}]: ${resumes}`) + } + catch (e) { + await bot.deleteMessage(chatId, loadingMsg.message_id).catch(() => {}) + if (e instanceof NoResumeError) { + await bot.sendMessage( + chatId, + '📝 Резюме не найдено.\n\nСоздайте резюме на hh.ru, затем нажмите Повторить.', + { parse_mode: 'HTML', reply_markup: NO_RESUME_MARKUP }, + ) + } + else { + await bot.sendMessage(chatId, '❌ Не удалось загрузить резюме. Попробуйте войти заново через «Войти на hh.ru».') + } + return + } + + await bot.deleteMessage(chatId, loadingMsg.message_id).catch(() => {}) + + if (resumes.length === 0) { + await bot.sendMessage(chatId, '⚠️ Резюме не найдены. Создайте резюме на hh.ru') + } + else if (resumes.length === 1) { + await saveResume(chatId, resumes[0]) + await bot.sendMessage(chatId, `✅ Резюме сохранено: ${resumes[0].title}`) + } + else { + state.pendingResumes = resumes + 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 handleMyResume(chatId: number): Promise { + const settings = await prisma.settings.findUnique({ 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Выбери резюме через кнопку 📄 Выбрать резюме.') + return + } + + const MAX = 3500 + const text = resume.data.length > MAX + ? `${resume.data.slice(0, MAX)}\n\n… (текст обрезан)` + : resume.data + + await bot.sendMessage( + chatId, + `📋 Твоё резюме:\n${resume.title}\n
${escapeHtml(text)}
`, + { parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } }, + ) +} + +export async function handleResumePick(chatId: number, messageId: number, idx: number): Promise { + const state = getState(chatId) + const resume = state.pendingResumes[idx] + if (!resume) { + await safeEdit('❌ Резюме не найдено, попробуйте снова', { + chat_id: chatId, + message_id: messageId, + reply_markup: { inline_keyboard: [] }, + }) + return + } + await safeEdit('🔄 Сохраняю резюме...', { + chat_id: chatId, + message_id: messageId, + reply_markup: { inline_keyboard: [] }, + }) + await saveResume(chatId, resume) + state.pendingResumes = [] + await safeEdit(`✅ Резюме выбрано: ${resume.title}`, { + chat_id: chatId, + message_id: messageId, + reply_markup: { inline_keyboard: [] }, + // reply_markup: BACK_MARKUP, + }) +} diff --git a/src/hh/handlers/settings.ts b/src/hh/handlers/settings.ts new file mode 100644 index 0000000..ef46c26 --- /dev/null +++ b/src/hh/handlers/settings.ts @@ -0,0 +1,78 @@ +import bot from '@bot' +import prisma from '@prisma' +import cron from 'node-cron' +import { SETTINGS_REPLY_KEYBOARD, escapeHtml } from '../ui.js' +import { getState } from '../state.js' + +export async function handleQuery(chatId: number): Promise { + const state = getState(chatId) + state.awaitingQuery = true + const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } }) + const currentQuery = settings?.searchQuery || '--' + const msg = await bot.sendMessage( + chatId, + `🔍 Текущий запрос: ${escapeHtml(currentQuery)}\n\nВведи новый или оставь текущий:`, + { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[ + { text: `✅ Оставить «${currentQuery}»`, callback_data: 'hh_keep_query' }, + ]], + }, + }, + ) + state.queryPromptMessageId = msg.message_id +} + +export async function handleMax(chatId: number): Promise { + const state = getState(chatId) + state.awaitingMax = true + const settings = await prisma.settings.findFirst({ where: { telegramId: chatId } }) + const currentMax = settings?.maxApplies ?? '--' + const msg = await bot.sendMessage( + chatId, + `🔢 Текущее значение: ${currentMax}\n\nВведи новое количество откликов (1–50) или оставь текущее:`, + { + parse_mode: 'HTML', + reply_markup: { + inline_keyboard: [[ + { text: `✅ Оставить ${currentMax}`, callback_data: 'hh_keep_max' }, + ]], + }, + }, + ) + state.maxPromptMessageId = msg.message_id +} + +export async function handlePrompt(chatId: number): Promise { + const state = getState(chatId) + state.awaitingPrompt = true + const user = await prisma.user.findUnique({ where: { telegramId: chatId } }) + const currentPrompt = user?.prompt + const text = currentPrompt + ? `📝 Текущий промт:\n
${escapeHtml(currentPrompt)}
\n\nВведи новый или оставь текущий:` + : '📝 Введи промт для AI (пока не задан):' + const keepButton = currentPrompt + ? [[{ text: '✅ Оставить текущий промт', callback_data: 'hh_keep_prompt' }]] + : [] + const msg = await bot.sendMessage(chatId, text, { + parse_mode: 'HTML', + reply_markup: { inline_keyboard: keepButton }, + }) + state.promptPromptMessageId = msg.message_id +} + +export async function handleAutoToggle(chatId: number): Promise { + const state = getState(chatId) + if (state.autoCron) { + state.autoCron.stop() + state.autoCron = null + await bot.sendMessage(chatId, '⛔ Авто остановлен', { reply_markup: SETTINGS_REPLY_KEYBOARD }) + } + else { + state.autoCron = cron.schedule('0 10 * * 1-5', async () => { + await bot.sendMessage(chatId, '⏰ Авто-отклик...') + }) + await bot.sendMessage(chatId, '✅ Авто включён (пн-пт, 10:00)', { reply_markup: SETTINGS_REPLY_KEYBOARD }) + } +} diff --git a/src/hh/state.ts b/src/hh/state.ts new file mode 100644 index 0000000..8d966e8 --- /dev/null +++ b/src/hh/state.ts @@ -0,0 +1,38 @@ +import type { ResumeListItem } from './types.js' +import type { ScheduledTask } from 'node-cron' + +export interface UserState { + autoCron: ScheduledTask | null + awaitingEmail: boolean + awaitingQuery: boolean + awaitingMax: boolean + awaitingPrompt: boolean + pendingResumes: ResumeListItem[] + loginPromptMessageId: number | null + queryPromptMessageId: number | null + maxPromptMessageId: number | null + promptPromptMessageId: number | null +} + +function makeUserState(): UserState { + return { + autoCron: null, + awaitingEmail: false, + awaitingQuery: false, + awaitingMax: false, + awaitingPrompt: false, + pendingResumes: [], + loginPromptMessageId: null, + queryPromptMessageId: null, + maxPromptMessageId: null, + promptPromptMessageId: null, + } +} + +const states = new Map() + +export function getState(chatId: number): UserState { + if (!states.has(chatId)) + states.set(chatId, makeUserState()) + return states.get(chatId)! +}