init
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { DateTime } from 'luxon'
|
||||
import Auction from '#models/auction'
|
||||
import Keyword from '#models/keyword'
|
||||
import Notification from '#models/notification'
|
||||
import User from '#models/user'
|
||||
import { TelegramService } from '#services/telegram_service'
|
||||
|
||||
/**
|
||||
* NotificationService - Handles keyword matching and notification delivery
|
||||
*
|
||||
* Features:
|
||||
* - Matches keywords against auction titles and descriptions
|
||||
* - Case-sensitive and case-insensitive matching support
|
||||
* - Prevents duplicate notifications (checks existing records)
|
||||
* - Tracks notification status (pending/sent/failed)
|
||||
* - Integrates with TelegramService for message delivery
|
||||
* - Validates user active status before sending
|
||||
*/
|
||||
export class NotificationService {
|
||||
/**
|
||||
* Check auction against all active keywords and send notifications for matches
|
||||
*
|
||||
* @param auction - The auction to check for keyword matches
|
||||
*/
|
||||
async checkAndNotify(auction: Auction): Promise<void> {
|
||||
try {
|
||||
logger.info(`Checking auction ${auction.auctionNum} for keyword matches`)
|
||||
|
||||
// Get all active keywords
|
||||
const keywords = await Keyword.query().where('isActive', true).preload('user')
|
||||
|
||||
if (keywords.length === 0) {
|
||||
logger.debug('No active keywords found, skipping notification check')
|
||||
return
|
||||
}
|
||||
|
||||
logger.debug(`Found ${keywords.length} active keyword(s) to check`)
|
||||
|
||||
let matchCount = 0
|
||||
|
||||
for (const keyword of keywords) {
|
||||
try {
|
||||
// Check if keyword matches auction title or description
|
||||
const titleMatch = this.matchKeyword(
|
||||
auction.title,
|
||||
keyword.keyword,
|
||||
keyword.caseSensitive
|
||||
)
|
||||
|
||||
const descMatch = auction.description
|
||||
? this.matchKeyword(auction.description, keyword.keyword, keyword.caseSensitive)
|
||||
: false
|
||||
|
||||
if (!titleMatch && !descMatch) {
|
||||
continue // No match, skip this keyword
|
||||
}
|
||||
|
||||
matchCount++
|
||||
logger.info(
|
||||
`Keyword match found: "${keyword.keyword}" in auction ${auction.auctionNum}`,
|
||||
{
|
||||
keywordId: keyword.id,
|
||||
auctionId: auction.id,
|
||||
matchedIn: titleMatch ? 'title' : 'description',
|
||||
}
|
||||
)
|
||||
|
||||
// Check if notification already exists (prevent duplicates)
|
||||
const existingNotification = await Notification.query()
|
||||
.where('auctionId', auction.id)
|
||||
.where('keywordId', keyword.id)
|
||||
.first()
|
||||
|
||||
if (existingNotification) {
|
||||
logger.debug(
|
||||
`Notification already exists for auction ${auction.id} and keyword ${keyword.id}, skipping`,
|
||||
{
|
||||
notificationId: existingNotification.id,
|
||||
status: existingNotification.status,
|
||||
}
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Create notification record with 'pending' status
|
||||
const notification = await Notification.create({
|
||||
auctionId: auction.id,
|
||||
keywordId: keyword.id,
|
||||
status: 'pending',
|
||||
errorMessage: null,
|
||||
sentAt: null,
|
||||
})
|
||||
|
||||
logger.debug(`Created notification record ${notification.id}`)
|
||||
|
||||
// Get user and validate active status
|
||||
const user = await User.find(keyword.userId)
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`User ${keyword.userId} not found for keyword ${keyword.id}`)
|
||||
await notification
|
||||
.merge({
|
||||
status: 'failed',
|
||||
errorMessage: 'User not found',
|
||||
})
|
||||
.save()
|
||||
continue
|
||||
}
|
||||
|
||||
if (!user.telegramChatId) {
|
||||
logger.warn(`User ${user.id} has no Telegram chat ID`)
|
||||
await notification
|
||||
.merge({
|
||||
status: 'failed',
|
||||
errorMessage: 'User has no Telegram chat ID',
|
||||
})
|
||||
.save()
|
||||
continue
|
||||
}
|
||||
|
||||
// Send notification via Telegram
|
||||
try {
|
||||
const telegramService = TelegramService.getInstance()
|
||||
const auctionUrl = auction.url || `https://icetrade.by/auction/${auction.auctionNum}`
|
||||
|
||||
const success = await telegramService.sendNotification(
|
||||
user.telegramChatId,
|
||||
auction.title,
|
||||
auctionUrl,
|
||||
keyword.keyword
|
||||
)
|
||||
|
||||
if (success) {
|
||||
// Update notification status to 'sent'
|
||||
await notification
|
||||
.merge({
|
||||
status: 'sent',
|
||||
sentAt: DateTime.now(),
|
||||
})
|
||||
.save()
|
||||
|
||||
logger.info(`Notification sent successfully`, {
|
||||
notificationId: notification.id,
|
||||
userId: user.id,
|
||||
chatId: user.telegramChatId,
|
||||
})
|
||||
} else {
|
||||
// Update notification status to 'failed'
|
||||
await notification
|
||||
.merge({
|
||||
status: 'failed',
|
||||
errorMessage: 'Failed to send Telegram message (unknown error)',
|
||||
})
|
||||
.save()
|
||||
|
||||
logger.error(`Failed to send notification`, {
|
||||
notificationId: notification.id,
|
||||
userId: user.id,
|
||||
chatId: user.telegramChatId,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error during notification send'
|
||||
|
||||
logger.error(`Error sending notification`, {
|
||||
notificationId: notification.id,
|
||||
error: errorMessage,
|
||||
userId: user.id,
|
||||
})
|
||||
|
||||
// Update notification status to 'failed' with error details
|
||||
await notification
|
||||
.merge({
|
||||
status: 'failed',
|
||||
errorMessage,
|
||||
})
|
||||
.save()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error processing keyword ${keyword.id} for auction ${auction.id}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
keywordId: keyword.id,
|
||||
auctionId: auction.id,
|
||||
})
|
||||
// Continue processing other keywords even if one fails
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Completed notification check for auction ${auction.auctionNum}: ${matchCount} match(es) found`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error in checkAndNotify for auction ${auction.id}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
auctionId: auction.id,
|
||||
})
|
||||
// Don't throw - allow scraper to continue processing other auctions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a keyword matches text with case-sensitive or case-insensitive matching
|
||||
*
|
||||
* @param text - The text to search in
|
||||
* @param keyword - The keyword to search for
|
||||
* @param caseSensitive - Whether to perform case-sensitive matching
|
||||
* @returns true if keyword is found in text, false otherwise
|
||||
*/
|
||||
private matchKeyword(text: string, keyword: string, caseSensitive: boolean): boolean {
|
||||
if (!text || !keyword) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (caseSensitive) {
|
||||
return text.includes(keyword)
|
||||
}
|
||||
|
||||
return text.toLowerCase().includes(keyword.toLowerCase())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import * as cheerio from 'cheerio'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import { AuctionSchema, type AuctionData } from '../schemas/auction_schema.js'
|
||||
|
||||
/**
|
||||
* Configuration for the scraper
|
||||
*/
|
||||
const SCRAPER_CONFIG = {
|
||||
baseUrl: 'https://icetrade.by',
|
||||
requestDelay: 1000, // 1 second between requests
|
||||
timeout: 30000, // 30 seconds timeout
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
maxRetries: 3,
|
||||
retryDelay: 2000,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Error thrown when scraping fails
|
||||
*/
|
||||
export class ScraperError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly cause?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ScraperError'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ScraperService - Handles fetching and parsing auction data from icetrade.by
|
||||
*
|
||||
* Features:
|
||||
* - Rate limiting (1s delay between requests)
|
||||
* - Retry logic with exponential backoff
|
||||
* - Comprehensive error handling
|
||||
* - Data validation with Zod
|
||||
* - Structured logging
|
||||
*/
|
||||
export class ScraperService {
|
||||
/**
|
||||
* Builds the URL for fetching auctions with all required parameters
|
||||
*/
|
||||
private buildUrl(pageNumber: number): string {
|
||||
const params = new URLSearchParams({
|
||||
search_text: '',
|
||||
'zakup_type[1]': '1',
|
||||
'zakup_type[2]': '1',
|
||||
onPage: '100',
|
||||
sort: 'num:desc',
|
||||
p: pageNumber.toString(),
|
||||
// Status flags
|
||||
'r[1]': '1',
|
||||
'r[2]': '1',
|
||||
'r[3]': '1',
|
||||
'r[4]': '1',
|
||||
'r[5]': '1',
|
||||
'r[6]': '1',
|
||||
'r[7]': '1',
|
||||
// Trade type flags
|
||||
't[Trade]': '1',
|
||||
't[contest]': '1',
|
||||
't[request]': '1',
|
||||
't[qualification]': '1',
|
||||
't[negotiations]': '1',
|
||||
})
|
||||
|
||||
return `${SCRAPER_CONFIG.baseUrl}/trades/index?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches HTML content from the specified page with retry logic
|
||||
*/
|
||||
async fetchPage(pageNumber: number): Promise<string> {
|
||||
const url = this.buildUrl(pageNumber)
|
||||
let lastError: Error | undefined
|
||||
|
||||
logger.info(`Preparing to fetch URL: ${url}`)
|
||||
|
||||
for (let attempt = 1; attempt <= SCRAPER_CONFIG.maxRetries; attempt++) {
|
||||
try {
|
||||
logger.info(
|
||||
`Fetching page ${pageNumber} (attempt ${attempt}/${SCRAPER_CONFIG.maxRetries})`,
|
||||
{ url }
|
||||
)
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), SCRAPER_CONFIG.timeout)
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': SCRAPER_CONFIG.userAgent,
|
||||
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
logger.info(`Received response: status=${response.status} ${response.statusText}`)
|
||||
|
||||
if (!response.ok) {
|
||||
const bodyText = await response.text().catch(() => 'Unable to read response body')
|
||||
throw new Error(
|
||||
`HTTP ${response.status}: ${response.statusText}. Body: ${bodyText.substring(0, 200)}`
|
||||
)
|
||||
}
|
||||
|
||||
const html = await response.text()
|
||||
|
||||
if (!html || html.trim().length === 0) {
|
||||
throw new Error('Received empty response')
|
||||
}
|
||||
|
||||
logger.info(`Successfully fetched page ${pageNumber}: ${html.length} bytes`)
|
||||
return html
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
// Log detailed error information
|
||||
const errorDetails: Record<string, any> = {
|
||||
message: lastError.message,
|
||||
name: lastError.name,
|
||||
url,
|
||||
}
|
||||
|
||||
// Add stack trace for non-HTTP errors
|
||||
if (!(lastError.message.startsWith('HTTP '))) {
|
||||
errorDetails.stack = lastError.stack
|
||||
}
|
||||
|
||||
// Check for specific error types
|
||||
if (lastError.name === 'AbortError') {
|
||||
errorDetails.reason = 'Request timeout after 30s'
|
||||
} else if (lastError.message.includes('fetch failed')) {
|
||||
errorDetails.reason = 'Network error - check DNS, firewall, or connectivity'
|
||||
} else if (lastError.message.includes('ENOTFOUND')) {
|
||||
errorDetails.reason = 'DNS resolution failed - domain not found'
|
||||
} else if (lastError.message.includes('ECONNREFUSED')) {
|
||||
errorDetails.reason = 'Connection refused - server not reachable'
|
||||
} else if (lastError.message.includes('ETIMEDOUT')) {
|
||||
errorDetails.reason = 'Connection timeout - server too slow or unreachable'
|
||||
}
|
||||
|
||||
logger.warn(errorDetails, `Failed to fetch page ${pageNumber} (attempt ${attempt}/${SCRAPER_CONFIG.maxRetries})`)
|
||||
|
||||
if (attempt < SCRAPER_CONFIG.maxRetries) {
|
||||
const delay = SCRAPER_CONFIG.retryDelay * attempt
|
||||
logger.info(`Retrying in ${delay}ms...`)
|
||||
await this.delay(delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalError = new ScraperError(
|
||||
`Failed to fetch page ${pageNumber} after ${SCRAPER_CONFIG.maxRetries} attempts: ${lastError?.message}`,
|
||||
lastError
|
||||
)
|
||||
|
||||
logger.error('All fetch attempts failed', {
|
||||
pageNumber,
|
||||
url,
|
||||
lastErrorMessage: lastError?.message,
|
||||
lastErrorName: lastError?.name,
|
||||
})
|
||||
|
||||
throw finalError
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses HTML content and extracts auction data
|
||||
*/
|
||||
parsePage(html: string): AuctionData[] {
|
||||
try {
|
||||
const $ = cheerio.load(html)
|
||||
const auctions: AuctionData[] = []
|
||||
|
||||
// Find the auctions table
|
||||
const auctionsTable = $('table.auctions.w100')
|
||||
|
||||
if (auctionsTable.length === 0) {
|
||||
logger.warn('No auctions table found in HTML')
|
||||
return []
|
||||
}
|
||||
|
||||
// Parse each auction row
|
||||
const rows = auctionsTable.find('tbody tr')
|
||||
logger.info(`Found ${rows.length} auction rows to parse`)
|
||||
|
||||
rows.each((index, element) => {
|
||||
try {
|
||||
const row = $(element)
|
||||
|
||||
// Extract auction data from table cells
|
||||
const cells = row.find('td')
|
||||
|
||||
if (cells.length < 4) {
|
||||
logger.warn(`Row ${index} has insufficient cells, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract auction number (typically in first cell)
|
||||
const auctionNumCell = $(cells[0])
|
||||
const auctionNum = auctionNumCell.text().trim()
|
||||
|
||||
// Extract title and link (typically in second cell with <a> tag)
|
||||
const titleCell = $(cells[1])
|
||||
const titleLink = titleCell.find('a').first()
|
||||
const title = titleLink.text().trim()
|
||||
const link = titleLink.attr('href')?.trim() || ''
|
||||
|
||||
// Skip if link is empty or missing
|
||||
if (!link) {
|
||||
logger.warn(`Row ${index} has missing or empty link, skipping`)
|
||||
return
|
||||
}
|
||||
|
||||
// Make link absolute if it's relative
|
||||
const absoluteLink = link.startsWith('http')
|
||||
? link
|
||||
: `${SCRAPER_CONFIG.baseUrl}${link.startsWith('/') ? link : `/${link}`}`
|
||||
|
||||
// Extract organization (typically in third cell)
|
||||
const organizationCell = $(cells[2])
|
||||
const organization = organizationCell.text().trim()
|
||||
|
||||
// Extract status (typically in fourth cell)
|
||||
const statusCell = $(cells[3])
|
||||
const status = statusCell.text().trim()
|
||||
|
||||
// Extract deadline if available (typically in fifth cell)
|
||||
const deadlineCell = $(cells[4])
|
||||
const deadline = deadlineCell.text().trim() || null
|
||||
|
||||
// Description can be extracted from title cell's additional text or separate element
|
||||
const description = titleCell.find('.description').text().trim() || null
|
||||
|
||||
// Validate with Zod schema
|
||||
const result = AuctionSchema.safeParse({
|
||||
auctionNum,
|
||||
title,
|
||||
organization,
|
||||
status,
|
||||
deadline,
|
||||
link: absoluteLink,
|
||||
description,
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
logger.warn(`Validation failed for auction at row ${index}`, {
|
||||
errors: result.error.issues,
|
||||
data: { auctionNum, title },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
auctions.push(result.data)
|
||||
} catch (error) {
|
||||
logger.error(`Error parsing auction row ${index}`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
logger.info(`Successfully parsed ${auctions.length} valid auctions`)
|
||||
return auctions
|
||||
} catch (error) {
|
||||
throw new ScraperError(
|
||||
'Failed to parse HTML content',
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrapes multiple pages of auctions with rate limiting
|
||||
*
|
||||
* @param maxPages - Maximum number of pages to scrape (default: 1)
|
||||
* @returns Array of all parsed auction data
|
||||
*/
|
||||
async scrapeAuctions(maxPages: number = 1): Promise<AuctionData[]> {
|
||||
if (maxPages < 1) {
|
||||
throw new Error('maxPages must be at least 1')
|
||||
}
|
||||
|
||||
logger.info(`Starting scrape of ${maxPages} page(s)`)
|
||||
const allAuctions: AuctionData[] = []
|
||||
|
||||
for (let page = 1; page <= maxPages; page++) {
|
||||
try {
|
||||
// Fetch page HTML
|
||||
const html = await this.fetchPage(page)
|
||||
|
||||
// Parse auctions from HTML
|
||||
const auctions = this.parsePage(html)
|
||||
|
||||
allAuctions.push(...auctions)
|
||||
|
||||
logger.info(`Page ${page}/${maxPages}: Found ${auctions.length} auctions`)
|
||||
|
||||
// Rate limiting: wait before next request (except for last page)
|
||||
if (page < maxPages) {
|
||||
logger.debug(`Waiting ${SCRAPER_CONFIG.requestDelay}ms before next request`)
|
||||
await this.delay(SCRAPER_CONFIG.requestDelay)
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
logger.error(`Error scraping page ${page}`, {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
stack: err.stack,
|
||||
cause: err.cause,
|
||||
})
|
||||
|
||||
// Continue to next page instead of failing completely
|
||||
// This ensures partial data is still returned
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Scraping completed: ${allAuctions.length} total auctions from ${maxPages} page(s)`
|
||||
)
|
||||
|
||||
return allAuctions
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method for delays (rate limiting, retries)
|
||||
*/
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { Bot, Context } from 'grammy'
|
||||
import env from '#start/env'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import User from '#models/user'
|
||||
import Keyword from '#models/keyword'
|
||||
|
||||
/**
|
||||
* TelegramService - Handles Telegram bot operations
|
||||
*
|
||||
* Singleton service for managing Grammy bot instance and commands.
|
||||
* Provides keyword management and notification delivery.
|
||||
*/
|
||||
export class TelegramService {
|
||||
private static instance: TelegramService | null = null
|
||||
private bot: Bot
|
||||
private isRunning: boolean = false
|
||||
|
||||
private constructor() {
|
||||
const token = env.get('TELEGRAM_BOT_TOKEN')
|
||||
this.bot = new Bot(token)
|
||||
this.setupCommands()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance of TelegramService
|
||||
*/
|
||||
static getInstance(): TelegramService {
|
||||
if (!TelegramService.instance) {
|
||||
TelegramService.instance = new TelegramService()
|
||||
}
|
||||
return TelegramService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup bot commands and handlers
|
||||
*/
|
||||
private setupCommands(): void {
|
||||
// /start - Register user
|
||||
this.bot.command('start', async (ctx) => {
|
||||
try {
|
||||
await this.handleStart(ctx)
|
||||
} catch (error) {
|
||||
logger.error('Error handling /start command', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
chatId: ctx.chat?.id,
|
||||
})
|
||||
await ctx.reply('Произошла ошибка при регистрации. Попробуйте позже.')
|
||||
}
|
||||
})
|
||||
|
||||
// /addkeyword - Add keyword
|
||||
this.bot.command('addkeyword', async (ctx) => {
|
||||
try {
|
||||
await this.handleAddKeyword(ctx)
|
||||
} catch (error) {
|
||||
logger.error('Error handling /addkeyword command', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
chatId: ctx.chat?.id,
|
||||
})
|
||||
await ctx.reply('Произошла ошибка при добавлении ключевого слова. Попробуйте позже.')
|
||||
}
|
||||
})
|
||||
|
||||
// /listkeywords - List keywords
|
||||
this.bot.command('listkeywords', async (ctx) => {
|
||||
try {
|
||||
await this.handleListKeywords(ctx)
|
||||
} catch (error) {
|
||||
logger.error('Error handling /listkeywords command', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
chatId: ctx.chat?.id,
|
||||
})
|
||||
await ctx.reply('Произошла ошибка при получении списка ключевых слов. Попробуйте позже.')
|
||||
}
|
||||
})
|
||||
|
||||
// /deletekeyword - Delete keyword
|
||||
this.bot.command('deletekeyword', async (ctx) => {
|
||||
try {
|
||||
await this.handleDeleteKeyword(ctx)
|
||||
} catch (error) {
|
||||
logger.error('Error handling /deletekeyword command', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
chatId: ctx.chat?.id,
|
||||
})
|
||||
await ctx.reply('Произошла ошибка при удалении ключевого слова. Попробуйте позже.')
|
||||
}
|
||||
})
|
||||
|
||||
// /help - Show help
|
||||
this.bot.command('help', async (ctx) => {
|
||||
await this.handleHelp(ctx)
|
||||
})
|
||||
|
||||
// Error handler
|
||||
this.bot.catch((err) => {
|
||||
logger.error('Grammy bot error', {
|
||||
error: err.error instanceof Error ? err.error.message : String(err.error),
|
||||
ctx: err.ctx,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /start command - Register user
|
||||
*/
|
||||
private async handleStart(ctx: Context): Promise<void> {
|
||||
if (!ctx.chat?.id) {
|
||||
await ctx.reply('Не удалось определить chat ID.')
|
||||
return
|
||||
}
|
||||
|
||||
const chatId = String(ctx.chat.id)
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await User.findBy('telegramChatId', chatId)
|
||||
|
||||
if (existingUser) {
|
||||
await ctx.reply(
|
||||
'Вы уже зарегистрированы!\n\n' +
|
||||
'Используйте /addkeyword для добавления ключевых слов.\n' +
|
||||
'Используйте /help для получения справки.'
|
||||
)
|
||||
logger.info(`User already registered: ${chatId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const user = await User.create({
|
||||
email: `telegram_${chatId}@temp.local`,
|
||||
password: Math.random().toString(36).substring(2, 15),
|
||||
telegramChatId: chatId,
|
||||
})
|
||||
|
||||
await ctx.reply(
|
||||
'Добро пожаловать! Вы успешно зарегистрированы.\n\n' +
|
||||
'Теперь вы можете добавлять ключевые слова для отслеживания аукционов:\n' +
|
||||
'/addkeyword <слово> - добавить ключевое слово\n' +
|
||||
'/listkeywords - список ваших ключевых слов\n' +
|
||||
'/deletekeyword <id> - удалить ключевое слово\n' +
|
||||
'/help - справка'
|
||||
)
|
||||
|
||||
logger.info(`New user registered: ${chatId} (user_id: ${user.id})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /addkeyword command - Add keyword for user
|
||||
*/
|
||||
private async handleAddKeyword(ctx: Context): Promise<void> {
|
||||
if (!ctx.chat?.id) {
|
||||
await ctx.reply('Не удалось определить chat ID.')
|
||||
return
|
||||
}
|
||||
|
||||
const chatId = String(ctx.chat.id)
|
||||
const user = await User.findBy('telegramChatId', chatId)
|
||||
|
||||
if (!user) {
|
||||
await ctx.reply('Вы не зарегистрированы. Используйте /start для регистрации.')
|
||||
return
|
||||
}
|
||||
|
||||
// Extract keyword from message
|
||||
const messageText = ctx.message?.text || ''
|
||||
const parts = messageText.split(' ')
|
||||
|
||||
if (parts.length < 2) {
|
||||
await ctx.reply(
|
||||
'Использование: /addkeyword <слово>\n\n' + 'Пример: /addkeyword строительство'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const keyword = parts.slice(1).join(' ').trim()
|
||||
|
||||
if (keyword.length === 0) {
|
||||
await ctx.reply('Ключевое слово не может быть пустым.')
|
||||
return
|
||||
}
|
||||
|
||||
if (keyword.length > 255) {
|
||||
await ctx.reply('Ключевое слово слишком длинное (максимум 255 символов).')
|
||||
return
|
||||
}
|
||||
|
||||
// Check if keyword already exists for this user
|
||||
const existingKeyword = await Keyword.query()
|
||||
.where('userId', user.id)
|
||||
.where('keyword', keyword)
|
||||
.first()
|
||||
|
||||
if (existingKeyword) {
|
||||
await ctx.reply(`Ключевое слово "${keyword}" уже добавлено.`)
|
||||
return
|
||||
}
|
||||
|
||||
// Create keyword
|
||||
const newKeyword = await Keyword.create({
|
||||
userId: user.id,
|
||||
keyword: keyword,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
await ctx.reply(`Ключевое слово "${keyword}" успешно добавлено (ID: ${newKeyword.id}).`)
|
||||
|
||||
logger.info(`Keyword added: "${keyword}" for user ${user.id} (chat_id: ${chatId})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /listkeywords command - List user's keywords
|
||||
*/
|
||||
private async handleListKeywords(ctx: Context): Promise<void> {
|
||||
if (!ctx.chat?.id) {
|
||||
await ctx.reply('Не удалось определить chat ID.')
|
||||
return
|
||||
}
|
||||
|
||||
const chatId = String(ctx.chat.id)
|
||||
const user = await User.findBy('telegramChatId', chatId)
|
||||
|
||||
if (!user) {
|
||||
await ctx.reply('Вы не зарегистрированы. Используйте /start для регистрации.')
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch user's keywords
|
||||
const keywords = await Keyword.query().where('userId', user.id).where('isActive', true)
|
||||
|
||||
if (keywords.length === 0) {
|
||||
await ctx.reply(
|
||||
'У вас нет активных ключевых слов.\n\n' +
|
||||
'Используйте /addkeyword для добавления ключевых слов.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Format keywords list
|
||||
const keywordsList = keywords
|
||||
.map((kw) => `${kw.id}. ${kw.keyword}`)
|
||||
.join('\n')
|
||||
|
||||
await ctx.reply(`Ваши ключевые слова:\n\n${keywordsList}\n\nДля удаления используйте: /deletekeyword <id>`)
|
||||
|
||||
logger.info(`Listed ${keywords.length} keyword(s) for user ${user.id} (chat_id: ${chatId})`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /deletekeyword command - Delete keyword by ID
|
||||
*/
|
||||
private async handleDeleteKeyword(ctx: Context): Promise<void> {
|
||||
if (!ctx.chat?.id) {
|
||||
await ctx.reply('Не удалось определить chat ID.')
|
||||
return
|
||||
}
|
||||
|
||||
const chatId = String(ctx.chat.id)
|
||||
const user = await User.findBy('telegramChatId', chatId)
|
||||
|
||||
if (!user) {
|
||||
await ctx.reply('Вы не зарегистрированы. Используйте /start для регистрации.')
|
||||
return
|
||||
}
|
||||
|
||||
// Extract keyword ID from message
|
||||
const messageText = ctx.message?.text || ''
|
||||
const parts = messageText.split(' ')
|
||||
|
||||
if (parts.length < 2) {
|
||||
await ctx.reply('Использование: /deletekeyword <id>\n\n' + 'Пример: /deletekeyword 5')
|
||||
return
|
||||
}
|
||||
|
||||
const keywordId = Number.parseInt(parts[1], 10)
|
||||
|
||||
if (Number.isNaN(keywordId)) {
|
||||
await ctx.reply('Некорректный ID. Используйте числовое значение.')
|
||||
return
|
||||
}
|
||||
|
||||
// Find keyword
|
||||
const keyword = await Keyword.query()
|
||||
.where('id', keywordId)
|
||||
.where('userId', user.id)
|
||||
.first()
|
||||
|
||||
if (!keyword) {
|
||||
await ctx.reply(`Ключевое слово с ID ${keywordId} не найдено.`)
|
||||
return
|
||||
}
|
||||
|
||||
// Soft delete - set isActive to false
|
||||
keyword.isActive = false
|
||||
await keyword.save()
|
||||
|
||||
await ctx.reply(`Ключевое слово "${keyword.keyword}" (ID: ${keywordId}) успешно удалено.`)
|
||||
|
||||
logger.info(
|
||||
`Keyword deleted: "${keyword.keyword}" (ID: ${keywordId}) for user ${user.id} (chat_id: ${chatId})`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle /help command - Show help message
|
||||
*/
|
||||
private async handleHelp(ctx: Context): Promise<void> {
|
||||
const helpMessage =
|
||||
'🤖 Помощь по использованию бота\n\n' +
|
||||
'Доступные команды:\n\n' +
|
||||
'/start - Регистрация в системе\n' +
|
||||
'/addkeyword <слово> - Добавить ключевое слово для отслеживания\n' +
|
||||
'/listkeywords - Список ваших ключевых слов\n' +
|
||||
'/deletekeyword <id> - Удалить ключевое слово\n' +
|
||||
'/help - Показать эту справку\n\n' +
|
||||
'Примеры использования:\n' +
|
||||
'/addkeyword строительство\n' +
|
||||
'/deletekeyword 5'
|
||||
|
||||
await ctx.reply(helpMessage)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification to user about matched auction
|
||||
*/
|
||||
async sendNotification(
|
||||
chatId: string,
|
||||
auctionTitle: string,
|
||||
auctionUrl: string,
|
||||
keyword: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const message =
|
||||
`🔔 Новый аукцион по ключевому слову "${keyword}"\n\n` +
|
||||
`📋 ${auctionTitle}\n\n` +
|
||||
`🔗 ${auctionUrl}`
|
||||
|
||||
await this.bot.api.sendMessage(chatId, message, {
|
||||
parse_mode: 'HTML',
|
||||
})
|
||||
|
||||
logger.info(`Notification sent to chat ${chatId} for keyword "${keyword}"`)
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Failed to send notification', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
chatId,
|
||||
keyword,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the bot (long polling)
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.isRunning) {
|
||||
logger.warn('Bot is already running')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Starting Telegram bot...')
|
||||
this.isRunning = true
|
||||
await this.bot.start()
|
||||
logger.info('Telegram bot started successfully')
|
||||
} catch (error) {
|
||||
this.isRunning = false
|
||||
logger.error('Failed to start Telegram bot', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the bot gracefully
|
||||
*/
|
||||
async stop(): Promise<void> {
|
||||
if (!this.isRunning) {
|
||||
logger.warn('Bot is not running')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
logger.info('Stopping Telegram bot...')
|
||||
await this.bot.stop()
|
||||
this.isRunning = false
|
||||
logger.info('Telegram bot stopped successfully')
|
||||
} catch (error) {
|
||||
logger.error('Failed to stop Telegram bot', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if bot is running
|
||||
*/
|
||||
getIsRunning(): boolean {
|
||||
return this.isRunning
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user