mirror of
https://github.com/hempyhemp/hh-auto-reply.git
synced 2026-06-08 18:04:57 +00:00
✨ feat(file/topic): добавлено меню в главном окне бота. UI обновлен в соответствии с новыми требованиями.
All checks were successful
Deploy / deploy (push) Successful in 46s
All checks were successful
Deploy / deploy (push) Successful in 46s
This commit is contained in:
330
README.md
330
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) |
|
| Telegram | node-telegram-bot-api (long polling) |
|
||||||
| Браузерная автоматизация | Playwright (headless Chromium) |
|
| Браузерная автоматизация | Playwright (headless Chromium) |
|
||||||
| LLM / AI | Groq API — `llama-3.3-70b-versatile` |
|
| LLM | DeepSeek V4 Flash · OpenRouter · через `@opencode-ai/sdk` |
|
||||||
| ORM | Prisma 6 + SQLite |
|
| ORM | Prisma 6 · SQLite |
|
||||||
| Планировщик | node-cron |
|
| Планировщик | node-cron |
|
||||||
| Пакетный менеджер | Yarn 4 |
|
| Контейнеризация | Docker |
|
||||||
|
| Пакетный менеджер | Yarn 4 (PnP off) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Как это работает
|
## Как это работает
|
||||||
|
|
||||||
```
|
```
|
||||||
Пользователь → Telegram Bot
|
Пользователь в Telegram
|
||||||
│
|
│
|
||||||
├─ Авторизация на hh.ru (Playwright + OTP через бот)
|
├─ /start → главное меню
|
||||||
│ └─ Сохранение сессии (cookies) в БД
|
|
||||||
│
|
│
|
||||||
├─ Парсинг резюме с hh.ru → сохранение в БД
|
├─ Войти на hh.ru
|
||||||
|
│ └─ Playwright открывает браузер, вводит email
|
||||||
|
│ └─ OTP-код пользователь присылает в чат
|
||||||
|
│ └─ Сессия (cookies) сохраняется в БД
|
||||||
│
|
│
|
||||||
└─ Поиск вакансий по запросу
|
├─ Выбрать резюме → Playwright парсит список резюме с hh.ru
|
||||||
│
|
│
|
||||||
|
└─ Откликнуться
|
||||||
|
└─ Playwright ищет вакансии по запросу
|
||||||
└─ Для каждой вакансии:
|
└─ Для каждой вакансии:
|
||||||
├─ Playwright парсит описание
|
├─ Парсит описание (название, требования, компания)
|
||||||
└─ LLM (Llama 3.3 70B) генерирует
|
├─ Groq API генерирует письмо (резюме + описание → письмо)
|
||||||
сопроводительное письмо на основе
|
├─ Playwright вставляет письмо и кликает «Откликнуться»
|
||||||
резюме + описания вакансии
|
└─ Отчёт в Telegram: применено / пропущено / ошибки
|
||||||
```
|
```
|
||||||
|
|
||||||
## AI / LLM
|
---
|
||||||
|
|
||||||
Сопроводительные письма генерирует **Llama 3.3 70B** через Groq API (OpenAI-совместимый интерфейс). Модель получает:
|
## AI: как генерируются письма
|
||||||
|
|
||||||
- Системный промпт с инструкциями по стилю (настраивается пользователем)
|
Используется **DeepSeek V4 Flash** через [OpenRouter](https://openrouter.ai). Интеграция реализована через `@opencode-ai/sdk` — при первом запросе бот поднимает локальный OpenCode-сервер (`localhost:4096`), при последующих переиспользует его.
|
||||||
- Текст резюме пользователя (парсится с hh.ru)
|
|
||||||
- Описание конкретной вакансии (парсится Playwright'ом в реальном времени)
|
|
||||||
|
|
||||||
На выходе — живое, не шаблонное письмо, адаптированное под конкретную позицию.
|
Каждая сессия генерации состоит из двух шагов:
|
||||||
|
|
||||||
В коде также подключён **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<chatId, UserState>` — сбрасывается при рестарте процесса. Персистентные данные (сессия, резюме, настройки) — только в БД.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## База данных
|
||||||
|
|
||||||
|
```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) |
|
||||||
|
|
||||||
|
**Информация**
|
||||||
|
| Кнопка | Действие |
|
||||||
|
|---|---|
|
||||||
|
| `⚙️ Статус` | Текущие настройки и статус авторизации |
|
||||||
|
| `📋 Моё резюме` | Просмотр сохранённого резюме |
|
||||||
|
| `🚫 Проблемные вакансии` | Вакансии с анкетой (бот не может откликнуться) |
|
||||||
|
|||||||
@@ -1,264 +1,94 @@
|
|||||||
import type { ResumeListItem } from './types.js'
|
|
||||||
import bot from '@bot'
|
import bot from '@bot'
|
||||||
import prisma from '@prisma'
|
import prisma from '@prisma'
|
||||||
import cron, { type ScheduledTask } from 'node-cron'
|
import { BTN, INFO_REPLY_KEYBOARD, LOGIN_REPLY_KEYBOARD, MAIN_REPLY_KEYBOARD, SETTINGS_REPLY_KEYBOARD } from './ui.js'
|
||||||
import { applyToJobs, checkIsAuth, listResumes, login, NoResumeError, saveResume } from './scraper.js'
|
import { getState } from './state.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 { 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 {
|
type MsgHandler = (chatId: number) => Promise<void>
|
||||||
autoCron: ScheduledTask | null
|
type CallbackHandler = (chatId: number, messageId: number) => Promise<void>
|
||||||
awaitingEmail: boolean
|
|
||||||
awaitingQuery: boolean
|
const MESSAGE_HANDLERS: Partial<Record<string, MsgHandler>> = {
|
||||||
awaitingMax: boolean
|
[BTN.APPLY]: handleApply,
|
||||||
awaitingPrompt: boolean
|
[BTN.STATUS]: handleStatus,
|
||||||
pendingResumes: ResumeListItem[]
|
[BTN.QUERY]: handleQuery,
|
||||||
loginPromptMessageId: number | null
|
[BTN.MAX]: handleMax,
|
||||||
queryPromptMessageId: number | null
|
[BTN.AUTO_TOGGLE]: handleAutoToggle,
|
||||||
maxPromptMessageId: number | null
|
[BTN.PROMPT]: handlePrompt,
|
||||||
promptPromptMessageId: number | null
|
[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 {
|
const CALLBACK_HANDLERS: Record<string, CallbackHandler> = {
|
||||||
return {
|
hh_back: async (chatId, messageId) => {
|
||||||
autoCron: null,
|
await bot.deleteMessage(chatId, messageId).catch(() => {})
|
||||||
awaitingEmail: false,
|
|
||||||
awaitingQuery: false,
|
|
||||||
awaitingMax: false,
|
|
||||||
awaitingPrompt: false,
|
|
||||||
pendingResumes: [],
|
|
||||||
loginPromptMessageId: null,
|
|
||||||
queryPromptMessageId: null,
|
|
||||||
maxPromptMessageId: null,
|
|
||||||
promptPromptMessageId: null,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const states = new Map<number, UserState>()
|
|
||||||
|
|
||||||
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<void> {
|
|
||||||
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' }],
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
})
|
hh_login: async (chatId, messageId) => {
|
||||||
}
|
await bot.deleteMessage(chatId, messageId).catch(() => {})
|
||||||
}
|
await handleLogin(chatId)
|
||||||
catch (e) {
|
},
|
||||||
await bot.sendMessage(chatId, `❌ Ошибка: ${(e as Error).message}`)
|
hh_login_use_current: async (chatId, messageId) => {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleApply(chatId: number): Promise<void> {
|
|
||||||
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(`📊 <b>Итого по запросу «${settings.searchQuery}»</b>`)
|
|
||||||
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('⏭ <b>Пропущенные:</b>')
|
|
||||||
result.skipped.forEach(v => lines.push(`• <a href="${v.href}">${v.title}</a>`))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.errors.length) {
|
|
||||||
lines.push('')
|
|
||||||
lines.push('❌ <b>Ошибки:</b>')
|
|
||||||
result.errors.forEach(v => lines.push(`• <a href="${v.href}">${escapeHtml(v.title)}</a> — ${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<void> {
|
|
||||||
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<void> {
|
|
||||||
const state = getState(chatId)
|
const state = getState(chatId)
|
||||||
|
state.awaitingEmail = false
|
||||||
|
state.loginPromptMessageId = null
|
||||||
|
await bot.deleteMessage(chatId, messageId).catch(() => {})
|
||||||
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
|
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
|
||||||
state.awaitingEmail = true
|
|
||||||
|
|
||||||
if (!user?.hhEmail) {
|
if (!user?.hhEmail) {
|
||||||
await bot.sendMessage(chatId, '📧 Введи email от hh.ru:')
|
await bot.sendMessage(chatId, '❌ Email не найден, введи вручную')
|
||||||
|
state.awaitingEmail = true
|
||||||
|
return
|
||||||
}
|
}
|
||||||
else {
|
await doLogin(chatId, user.hhEmail)
|
||||||
const prompt = await bot.sendMessage(
|
|
||||||
chatId,
|
|
||||||
`📧 Текущий email: <b>${user.hhEmail}</b>\n\nИспользовать его или введи другой:`,
|
|
||||||
{
|
|
||||||
parse_mode: 'HTML',
|
|
||||||
reply_markup: {
|
|
||||||
inline_keyboard: [[
|
|
||||||
{ text: `✅ Войти как ${user.hhEmail}`, callback_data: 'hh_login_use_current' },
|
|
||||||
]],
|
|
||||||
},
|
},
|
||||||
},
|
hh_keep_query: async (chatId, messageId) => {
|
||||||
)
|
|
||||||
state.loginPromptMessageId = prompt.message_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResumeList(chatId: number): Promise<void> {
|
|
||||||
const state = getState(chatId)
|
const state = getState(chatId)
|
||||||
const loadingMsg = await bot.sendMessage(chatId, '🔄 Загружаю список резюме...')
|
state.awaitingQuery = false
|
||||||
|
state.queryPromptMessageId = null
|
||||||
let resumes: ResumeListItem[]
|
await bot.deleteMessage(chatId, messageId).catch(() => {})
|
||||||
try {
|
},
|
||||||
resumes = await listResumes(chatId)
|
hh_keep_max: async (chatId, messageId) => {
|
||||||
console.log(`[handleResumeList ${chatId}]: ${resumes}`)
|
const state = getState(chatId)
|
||||||
}
|
state.awaitingMax = false
|
||||||
catch (e) {
|
state.maxPromptMessageId = null
|
||||||
await bot.deleteMessage(chatId, loadingMsg.message_id).catch(() => {})
|
await bot.deleteMessage(chatId, messageId).catch(() => {})
|
||||||
if (e instanceof NoResumeError) {
|
},
|
||||||
await bot.sendMessage(
|
hh_keep_prompt: async (chatId, messageId) => {
|
||||||
chatId,
|
const state = getState(chatId)
|
||||||
'📝 Резюме не найдено.\n\nСоздайте резюме на <a href="https://hh.ru/applicant/resumes/new">hh.ru</a>, затем нажмите <b>Повторить</b>.',
|
state.awaitingPrompt = false
|
||||||
{ parse_mode: 'HTML', reply_markup: NO_RESUME_MARKUP },
|
state.promptPromptMessageId = null
|
||||||
)
|
await bot.deleteMessage(chatId, messageId).catch(() => {})
|
||||||
}
|
},
|
||||||
else {
|
hh_resume_list: async (chatId, messageId) => {
|
||||||
await bot.sendMessage(chatId, '❌ Не удалось загрузить резюме. Попробуйте войти заново через «Войти на hh.ru».')
|
await bot.deleteMessage(chatId, messageId).catch(() => {})
|
||||||
}
|
await handleResumeList(chatId)
|
||||||
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<void> {
|
async function clearAwaitingState(chatId: number): Promise<void> {
|
||||||
const settings = await prisma.settings.findUnique({ where: { telegramId: chatId } })
|
const state = getState(chatId)
|
||||||
const resume = settings?.selectedResumeId
|
const msgIds = [
|
||||||
? await prisma.resume.findUnique({ where: { id: settings.selectedResumeId } })
|
state.loginPromptMessageId,
|
||||||
: await prisma.resume.findFirst({ where: { telegramId: chatId } })
|
state.queryPromptMessageId,
|
||||||
|
state.maxPromptMessageId,
|
||||||
if (!resume) {
|
state.promptPromptMessageId,
|
||||||
await bot.sendMessage(chatId, '📋 Резюме не найдено.\n\nВыбери резюме через кнопку 📄 Выбрать резюме.')
|
]
|
||||||
return
|
state.awaitingEmail = false
|
||||||
}
|
state.awaitingQuery = false
|
||||||
|
state.awaitingMax = false
|
||||||
const MAX = 3500
|
state.awaitingPrompt = false
|
||||||
const text = resume.data.length > MAX
|
state.loginPromptMessageId = null
|
||||||
? `${resume.data.slice(0, MAX)}\n\n… (текст обрезан)`
|
state.queryPromptMessageId = null
|
||||||
: resume.data
|
state.maxPromptMessageId = null
|
||||||
|
state.promptPromptMessageId = null
|
||||||
await bot.sendMessage(
|
await Promise.all(msgIds.filter(Boolean).map(id => bot.deleteMessage(chatId, id!).catch(() => {})))
|
||||||
chatId,
|
|
||||||
`📋 <b>Твоё резюме:</b>\n<b>${resume.title}</b>\n<pre>${escapeHtml(text)}</pre>`,
|
|
||||||
{ parse_mode: 'HTML', reply_markup: BACK_MARKUP },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSkipped(chatId: number): Promise<void> {
|
|
||||||
const skipped = await prisma.skippedVacancy.findMany({
|
|
||||||
where: { telegramId: chatId },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: 50,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!skipped.length) {
|
|
||||||
await bot.sendMessage(chatId, '✅ Проблемных вакансий нет')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = ['🚫 <b>Вакансии с опросником (бот не может откликнуться):</b>', '']
|
|
||||||
skipped.forEach(v => lines.push(`• <a href="${escapeHtml(v.href)}">${escapeHtml(v.title)}</a>`))
|
|
||||||
await bot.sendMessage(chatId, lines.join('\n'), {
|
|
||||||
parse_mode: 'HTML',
|
|
||||||
disable_web_page_preview: true,
|
|
||||||
reply_markup: BACK_MARKUP,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function triggerHHStart(chatId: number): Promise<void> {
|
export async function triggerHHStart(chatId: number): Promise<void> {
|
||||||
@@ -268,9 +98,7 @@ export async function triggerHHStart(chatId: number): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function registerHHCommands() {
|
export function registerHHCommands() {
|
||||||
bot.onText(/\/hhstart/, (msg) => {
|
bot.onText(/\/hhstart/, msg => triggerHHStart(msg.chat.id))
|
||||||
triggerHHStart(msg.chat.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
bot.on('callback_query', async (query) => {
|
bot.on('callback_query', async (query) => {
|
||||||
if (!query.message)
|
if (!query.message)
|
||||||
@@ -278,117 +106,32 @@ export function registerHHCommands() {
|
|||||||
|
|
||||||
const chatId = query.message.chat.id
|
const chatId = query.message.chat.id
|
||||||
const messageId = query.message.message_id
|
const messageId = query.message.message_id
|
||||||
const state = getState(chatId)
|
|
||||||
|
|
||||||
await bot.answerCallbackQuery(query.id).catch(() => {})
|
await bot.answerCallbackQuery(query.id).catch(() => {})
|
||||||
|
|
||||||
switch (query.data) {
|
const exactHandler = query.data ? CALLBACK_HANDLERS[query.data] : undefined
|
||||||
case 'hh_back':
|
if (exactHandler) {
|
||||||
await bot.deleteMessage(chatId, messageId).catch(() => {})
|
await exactHandler(chatId, messageId)
|
||||||
break
|
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_')) {
|
if (query.data?.startsWith('hh_resume_pick_')) {
|
||||||
const idx = Number(query.data.replace('hh_resume_pick_', ''))
|
const idx = Number(query.data.replace('hh_resume_pick_', ''))
|
||||||
const resume = state.pendingResumes[idx]
|
await handleResumePick(chatId, messageId, 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
bot.on('message', async (msg) => {
|
bot.on('message', async (msg) => {
|
||||||
const chatId = msg.chat.id
|
|
||||||
|
|
||||||
if (!msg.text || msg.text.startsWith('/'))
|
if (!msg.text || msg.text.startsWith('/'))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
const chatId = msg.chat.id
|
||||||
const state = getState(chatId)
|
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])
|
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
|
if (isMenuButton && isAwaiting) {
|
||||||
state.awaitingQuery = false
|
await clearAwaitingState(chatId)
|
||||||
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 (state.awaitingEmail) {
|
if (state.awaitingEmail) {
|
||||||
@@ -409,10 +152,7 @@ export function registerHHCommands() {
|
|||||||
await bot.deleteMessage(chatId, state.queryPromptMessageId).catch(() => {})
|
await bot.deleteMessage(chatId, state.queryPromptMessageId).catch(() => {})
|
||||||
state.queryPromptMessageId = null
|
state.queryPromptMessageId = null
|
||||||
}
|
}
|
||||||
const updated = await prisma.settings.update({
|
const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { searchQuery: msg.text } })
|
||||||
where: { telegramId: chatId },
|
|
||||||
data: { searchQuery: msg.text },
|
|
||||||
})
|
|
||||||
await bot.sendMessage(chatId, `✅ Запрос: "${updated.searchQuery}"`)
|
await bot.sendMessage(chatId, `✅ Запрос: "${updated.searchQuery}"`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -429,10 +169,7 @@ export function registerHHCommands() {
|
|||||||
await bot.deleteMessage(chatId, state.maxPromptMessageId).catch(() => {})
|
await bot.deleteMessage(chatId, state.maxPromptMessageId).catch(() => {})
|
||||||
state.maxPromptMessageId = null
|
state.maxPromptMessageId = null
|
||||||
}
|
}
|
||||||
const updated = await prisma.settings.update({
|
const updated = await prisma.settings.update({ where: { telegramId: chatId }, data: { maxApplies: num } })
|
||||||
where: { telegramId: chatId },
|
|
||||||
data: { maxApplies: num },
|
|
||||||
})
|
|
||||||
await bot.sendMessage(chatId, `✅ Макс откликов: ${updated.maxApplies}`)
|
await bot.sendMessage(chatId, `✅ Макс откликов: ${updated.maxApplies}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -453,116 +190,6 @@ export function registerHHCommands() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (msg.text) {
|
await MESSAGE_HANDLERS[msg.text]?.(chatId)
|
||||||
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,
|
|
||||||
`🔍 Текущий запрос: <b>${escapeHtml(currentQuery)}</b>\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,
|
|
||||||
`🔢 Текущее значение: <b>${currentMax}</b>\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<pre>${escapeHtml(currentPrompt)}</pre>\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
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/hh/handlers/apply.ts
Normal file
49
src/hh/handlers/apply.ts
Normal file
@@ -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<void> {
|
||||||
|
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(`📊 <b>Итого по запросу «${settings.searchQuery}»</b>`)
|
||||||
|
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('⏭ <b>Пропущенные:</b>')
|
||||||
|
result.skipped.forEach(v => lines.push(`• <a href="${v.href}">${v.title}</a>`))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errors.length) {
|
||||||
|
lines.push('')
|
||||||
|
lines.push('❌ <b>Ошибки:</b>')
|
||||||
|
result.errors.forEach(v => lines.push(`• <a href="${v.href}">${escapeHtml(v.title)}</a> — ${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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
80
src/hh/handlers/auth.ts
Normal file
80
src/hh/handlers/auth.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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: <b>${user.hhEmail}</b>\n\nИспользовать его или введи другой:`,
|
||||||
|
{
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
reply_markup: {
|
||||||
|
inline_keyboard: [[
|
||||||
|
{ text: `✅ Войти как ${user.hhEmail}`, callback_data: 'hh_login_use_current' },
|
||||||
|
]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
state.loginPromptMessageId = prompt.message_id
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/hh/handlers/info.ts
Normal file
37
src/hh/handlers/info.ts
Normal file
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
const skipped = await prisma.skippedVacancy.findMany({
|
||||||
|
where: { telegramId: chatId },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!skipped.length) {
|
||||||
|
await bot.sendMessage(chatId, '✅ Проблемных вакансий нет')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = ['🚫 <b>Вакансии с опросником (бот не может откликнуться):</b>', '']
|
||||||
|
skipped.forEach(v => lines.push(`• <a href="${escapeHtml(v.href)}">${escapeHtml(v.title)}</a>`))
|
||||||
|
await bot.sendMessage(chatId, lines.join('\n'), {
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
reply_markup: { inline_keyboard: [] },
|
||||||
|
})
|
||||||
|
}
|
||||||
100
src/hh/handlers/resume.ts
Normal file
100
src/hh/handlers/resume.ts
Normal file
@@ -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<void> {
|
||||||
|
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Создайте резюме на <a href="https://hh.ru/applicant/resumes/new">hh.ru</a>, затем нажмите <b>Повторить</b>.',
|
||||||
|
{ 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<void> {
|
||||||
|
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,
|
||||||
|
`📋 <b>Твоё резюме:</b>\n<b>${resume.title}</b>\n<pre>${escapeHtml(text)}</pre>`,
|
||||||
|
{ parse_mode: 'HTML', reply_markup: { inline_keyboard: [] } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleResumePick(chatId: number, messageId: number, idx: number): Promise<void> {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
78
src/hh/handlers/settings.ts
Normal file
78
src/hh/handlers/settings.ts
Normal file
@@ -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<void> {
|
||||||
|
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,
|
||||||
|
`🔍 Текущий запрос: <b>${escapeHtml(currentQuery)}</b>\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<void> {
|
||||||
|
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,
|
||||||
|
`🔢 Текущее значение: <b>${currentMax}</b>\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<void> {
|
||||||
|
const state = getState(chatId)
|
||||||
|
state.awaitingPrompt = true
|
||||||
|
const user = await prisma.user.findUnique({ where: { telegramId: chatId } })
|
||||||
|
const currentPrompt = user?.prompt
|
||||||
|
const text = currentPrompt
|
||||||
|
? `📝 Текущий промт:\n<pre>${escapeHtml(currentPrompt)}</pre>\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<void> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/hh/state.ts
Normal file
38
src/hh/state.ts
Normal file
@@ -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<number, UserState>()
|
||||||
|
|
||||||
|
export function getState(chatId: number): UserState {
|
||||||
|
if (!states.has(chatId))
|
||||||
|
states.set(chatId, makeUserState())
|
||||||
|
return states.get(chatId)!
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user