405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
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
|
||
}
|
||
}
|