feat(file/topic): добавлено меню в главном окне бота. UI обновлен в соответствии с новыми требованиями.
All checks were successful
Deploy / deploy (push) Successful in 46s

This commit is contained in:
Oscar
2026-05-29 12:51:04 +03:00
parent 810952a5c7
commit 6acc8d0adb
8 changed files with 701 additions and 590 deletions

View File

@@ -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<void>
type CallbackHandler = (chatId: number, messageId: number) => Promise<void>
const MESSAGE_HANDLERS: Partial<Record<string, MsgHandler>> = {
[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<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 CALLBACK_HANDLERS: Record<string, CallbackHandler> = {
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<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> {
async function clearAwaitingState(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 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\спользовать его или введи другой:`,
{
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<void> {
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Создайте резюме на <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' }],
],
},
})
}
}
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: 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,
})
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<void> {
@@ -268,9 +98,7 @@ export async function triggerHHStart(chatId: number): Promise<void> {
}
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,
`🔍 Текущий запрос: <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Введи новое количество откликов (150) или оставь текущее:`,
{
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
}
await MESSAGE_HANDLERS[msg.text]?.(chatId)
})
}

49
src/hh/handlers/apply.ts Normal file
View 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
View 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\спользовать его или введи другой:`,
{
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
View 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
View 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,
})
}

View 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Введи новое количество откликов (150) или оставь текущее:`,
{
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
View 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)!
}