/
pyadminru
/
support-bot
Обзор
Документация
Войти
/
pyadminru
/
support-bot
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
main.py
492 строки
18 KB
pyadminru
Рефакторинг кода
23 янв 2026, 19:18
23 янв 2026, 19:18
63a7765
Код
Авторство
О чём код?
""" Модуль основной логики бота-поддержки. Создает и управляет Telegram-ботом для обработки пользовательских обращений через форумные темы в группе поддержки. Реализует систему тикетов с возможностью принятия, отклонения и закрытия заявок менеджерами. """ import asyncio import logging import peewee from aiogram import Bot, Dispatcher, Router, F from aiogram.filters import CommandStart from aiogram.fsm.context import FSMContext from aiogram.fsm.state import StatesGroup, State from aiogram.fsm.storage.memory import MemoryStorage from aiogram.types import ( Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton, ) from loguru import logger from peewee import Model, IntegerField, CharField, SqliteDatabase from config import BOT_TOKEN, SUPPORT_CHAT_ID, ADMIN_IDS logger.add("logs/log.log", rotation="1 day") # ------------------ База данных ------------------ db = SqliteDatabase('tickets.db') """ Модель для хранения информации о заявке в поддержку. Атрибуты: topic_id (int): Идентификатор темы в чате поддержки (первичный ключ) user_id (int): Идентификатор пользователя, создавшего заявку user_name (str): Полное имя пользователя username (str | None): Username пользователя в Telegram status (str): Статус заявки (waiting, accepted, rejected, closed) manager_id (int | None): Идентификатор менеджера, принявшего заявку manager_name (str | None): Имя менеджера, обрабатывающего заявку """ class Ticket(Model): topic_id = IntegerField(primary_key=True) user_id = IntegerField() user_name = CharField() username = CharField(null=True) status = CharField() manager_id = IntegerField(null=True) manager_name = CharField(null=True) class Meta: database = db # ------------------ FSM для диалога ------------------ """ Группа состояний для управления диалогом поддержки. Состояния: waiting_for_accept: Ожидание принятия заявки менеджером in_dialog: Диалог с менеджером активен """ class SupportDialog(StatesGroup): waiting_for_accept = State() in_dialog = State() # ------------------ Инициализация ------------------ logging.basicConfig(level=logging.INFO) bot = Bot(token=BOT_TOKEN) storage = MemoryStorage() dp = Dispatcher(storage=storage) router = Router() dp.include_router(router) def get_ticket_keyboard(topic_id: int) -> InlineKeyboardMarkup: """ Создает клавиатуру для управления заявкой. Аргументы: topic_id (int): Идентификатор темы заявки Возвращает: InlineKeyboardMarkup: Клавиатура с кнопками принятия и отклонения заявки """ return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( text="✅ Принять", callback_data=f"accept_{topic_id}" ), InlineKeyboardButton( text="❌ Отклонить", callback_data=f"reject_{topic_id}" ), ] ]) def get_close_keyboard(topic_id: int) -> InlineKeyboardMarkup: """ Создает клавиатуру для закрытия заявки. Аргументы: topic_id (int): Идентификатор темы заявки Возвращает: InlineKeyboardMarkup: Клавиатура с кнопкой закрытия заявки """ return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton( text="✖️ Закрыть заявку", callback_data=f"close_{topic_id}" ) ] ]) """ Группа состояний для управления диалогом с пользователем. Состояния: message_text: Ожидание текста сообщения от пользователя """ class User(StatesGroup): message_text = State() # Вопрос пользователя """ Обработчик команды /start. Проверяет наличие активных заявок у пользователя и разрешает создание новой, если активных заявок нет. Аргументы: message (Message): Объект входящего сообщения state (FSMContext): Контекст состояния конечного автомата """ @router.message(CommandStart()) async def cmd_start(message: Message, state: FSMContext): await state.clear() user_id = message.from_user.id # Получаем все статусы заявок пользователя user_tickets = Ticket.select(Ticket.status).where(Ticket.user_id == user_id) # Преобразуем в множество для удобства проверки statuses = {ticket.status for ticket in user_tickets} # Если есть хотя бы одна активная заявка (waiting или accepted) if "waiting" in statuses or "accepted" in statuses: await message.answer( "❌ У вас уже есть активная заявка в поддержке. " "Ожидайте ответа менеджера." ) return # В противном случае — разрешаем создать новую await message.answer( "Привет! Напиши мне любое сообщение — я передам его нашей команде поддержки." ) await state.set_state(User.message_text) """ Обработчик сообщений от пользователя при создании новой заявки. Создает новую тему в чате поддержки, отправляет информацию о заявке и сохраняет тикет в базу данных. Аргументы: message (Message): Объект входящего сообщения с текстом заявки state (FSMContext): Контекст состояния конечного автомата """ @router.message(User.message_text) async def get_message(message: Message, state: FSMContext): user_id = message.from_user.id user_name = message.from_user.full_name username = f"@{message.from_user.username}" if message.from_user.username else "" # Создаём тему в чате поддержки topic = await bot.create_forum_topic( chat_id=SUPPORT_CHAT_ID, name=f"Заявка от {user_name[:20]}", ) topic_id = topic.message_thread_id logger.info(f"Создана новая заявка в теме #{topic_id}") # Отправляем информацию о заявке в тему await bot.send_message( chat_id=SUPPORT_CHAT_ID, text=( f"🆕 Новая заявка #{topic_id}\n" f"👤 От: {user_name} {username}\n" f"🆔 ID: <code>{user_id}</code>\n" f"💬 Сообщение: {message.text}" ), message_thread_id=topic_id, reply_markup=get_ticket_keyboard(topic_id), parse_mode="HTML", ) # Сохраняем тикет в БД Ticket.create( topic_id=topic_id, user_id=user_id, user_name=user_name, username=username, status="waiting", manager_id=None, manager_name=None, ) await message.answer( "✅ Ваше обращение отправлено в поддержку!\n" "Ожидайте, скоро с вами свяжутся. Можете продолжать писать." ) await state.clear() """ Обработчик всех остальных сообщений. Обрабатывает сообщения от пользователей и менеджеров, направляя их в соответствующие темы чата поддержки или пользователю. Управляет статусами заявок и логирует всю активность. Аргументы: message (Message): Объект входящего сообщения """ @router.message() async def handle_message(message: Message): user_id = message.from_user.id chat_id = message.chat.id text = message.text or "" user_name = message.from_user.full_name topic_id = message.message_thread_id # Правильный доступ к ID темы logger.info( f"Получено сообщение от {user_name} ({user_id}) " f"в чат {chat_id}, тема {topic_id}: {text}" ) if topic_id is None: # Ищем одну активную заявку (например, со статусом "waiting" или "accepted") active_ticket = ( Ticket .select() .where( (Ticket.user_id == user_id) & (Ticket.status.in_(["waiting", "accepted"])) ) .first() # Получаем первую (или единственную) активную заявку ) if active_ticket: topic_id = active_ticket.topic_id await bot.send_message( chat_id=SUPPORT_CHAT_ID, text=f"💬 Новое сообщение от {user_name}:\n\n{text}", message_thread_id=topic_id ) else: # Нет активной заявки — возможно, нужно создать новую logger.warning(f"Пользователь {user_id} прислал сообщение, но нет активной заявки") else: try: ticket = Ticket.get(Ticket.topic_id == topic_id) except peewee.DoesNotExist: logger.warning(f"Тикет для темы {topic_id} не найден. Игнорируем сообщение.") return # ← ВАЖНО: не продолжать обработку user_id = ticket.user_id logger.info(f"Найден пользователь с ID: {user_id} для топика {topic_id}") status = ticket.status logger.info(f"Статус заявки: {status}") if status == "accepted": if message.from_user.is_bot: logger.warning('Бот пытается отправить сообщение пользователю') return else: await bot.send_message( chat_id=user_id, text=f"📩 Ответ от поддержки:\n\n{text}" ) logger.info(f"Сообщение от менеджера отправлено пользователю {user_id}") elif status == "waiting": await bot.send_message( chat_id=SUPPORT_CHAT_ID, text="❌ Заявка не принята менеджером.\n", message_thread_id=topic_id ) elif status == "closed": await bot.send_message( chat_id=SUPPORT_CHAT_ID, text="❌ Заявка закрыта.\n", message_thread_id=topic_id ) """ Обработчик нажатия кнопки принятия заявки. Позволяет менеджеру принять заявку в работу, обновляет статус тикета, уведомляет пользователя и изменяет клавиатуру в теме. Аргументы: call (CallbackQuery): Объект callback-запроса от нажатия кнопки """ @router.callback_query(F.data.startswith("accept_")) async def cb_accept(call: CallbackQuery): if call.from_user.id not in ADMIN_IDS: await call.answer("У вас нет прав", show_alert=True) return topic_id = int(call.data.split("_")[1]) try: ticket = Ticket.get(Ticket.topic_id == topic_id) except peewee.DoesNotExist: await call.answer("Заявка не найдена", show_alert=True) return if ticket.status != "waiting": await call.answer("Заявка уже обработана", show_alert=True) return ticket.status = "accepted" ticket.manager_id = call.from_user.id ticket.manager_name = call.from_user.full_name ticket.save() # Уведомляем пользователя await bot.send_message( ticket.user_id, f"✅ Ваша заявка принята менеджером ожидайте ответа от {call.from_user.first_name}!\n" # "Можете продолжать писать сюда." ) # Обновляем клавиатуру await call.message.edit_reply_markup(reply_markup=get_close_keyboard(topic_id)) # Отправляем уведомление в тему await bot.send_message( chat_id=SUPPORT_CHAT_ID, text=f"✅ Заявка принята менеджером {call.from_user.full_name}", message_thread_id=topic_id, ) """ Обработчик нажатия кнопки отклонения заявки. Позволяет менеджеру отклонить заявку, уведомляет пользователя, закрывает тему в чате поддержки и обновляет статус тикета. Аргументы: call (CallbackQuery): Объект callback-запроса от нажатия кнопки """ @router.callback_query(F.data.startswith("reject_")) async def cb_reject(call: CallbackQuery): if call.from_user.id not in ADMIN_IDS: await call.answer("Нет прав", show_alert=True) return topic_id = int(call.data.split("_")[1]) try: ticket = Ticket.get(Ticket.topic_id == topic_id) except peewee.DoesNotExist: await call.answer("Заявка не найдена", show_alert=True) return if ticket.status != "waiting": await call.answer("Заявка уже обработана", show_alert=True) return user_id = ticket.user_id # Уведомляем пользователя await bot.send_message( user_id, "❌ Ваша заявка была отклонена.\nПриносим извинения." ) # Закрываем тему await bot.close_forum_topic( chat_id=SUPPORT_CHAT_ID, message_thread_id=topic_id ) # Отправляем уведомление в тему await bot.send_message( chat_id=SUPPORT_CHAT_ID, text=f"❌ Заявка отклонена менеджером {call.from_user.full_name}", message_thread_id=topic_id, ) # Обновляем статус в БД ticket.status = "rejected" ticket.save() await call.message.edit_reply_markup(reply_markup=None) await call.answer("Заявка отклонена") """ Обработчик нажатия кнопки закрытия заявки. Позволяет менеджеру закрыть заявку, уведомляет пользователя, закрывает и переименовывает тему в чате поддержки, обновляет статус тикета. Аргументы: call (CallbackQuery): Объект callback-запроса от нажатия кнопки """ @router.callback_query(F.data.startswith("close_")) async def cb_close(call: CallbackQuery): if call.from_user.id not in ADMIN_IDS: await call.answer("Нет прав", show_alert=True) return topic_id = int(call.data.split("_")[1]) try: ticket = Ticket.get(Ticket.topic_id == topic_id) except peewee.DoesNotExist: await call.answer("Заявка не найдена", show_alert=True) return user_id = ticket.user_id # Уведомляем пользователя await bot.send_message( user_id, "✅ Диалог с поддержкой завершён.\nЕсли понадобится помощь — пишите заново!" ) # Закрываем тему await bot.close_forum_topic( chat_id=SUPPORT_CHAT_ID, message_thread_id=topic_id ) # Переименовываем тему await bot.edit_forum_topic( chat_id=SUPPORT_CHAT_ID, message_thread_id=topic_id, name=f"✅ Закрыто: {ticket.user_name[:15]}" ) # Отправляем уведомление в тему await bot.send_message( chat_id=SUPPORT_CHAT_ID, text=f"🛑 Заявка закрыта менеджером {call.from_user.full_name}", message_thread_id=topic_id, ) # Обновляем статус в БД ticket.status = "closed" ticket.save() await call.message.edit_reply_markup(reply_markup=None) await call.answer("Заявка закрыта") async def main(): """ Основная функция запуска бота. Создает необходимые таблицы в базе данных и запускает поллинг обновлений. Использует безопасное создание таблиц (не выдает ошибку, если таблицы уже существуют). """ db.create_tables([Ticket], safe=True) await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) if __name__ == "__main__": asyncio.run(main())