Телеграм-бот технической поддержки Со стороны пользователя: Запрашивает тему обращения Категорию обращения Опсание обращения Приоритет Медиа-файл(опционально) Действия бота: Создает топик в супер-группе(вкл функция "Темы" в настройках чата) Устанавливает название с UUID обращения Отправляет краткую сводку обращения(Тема, опсание, приоритет, медиа) Со стороны администратора: Пишет в топик - бот передает пользователю Пользователь пишет в бота - бот передает в топик Команды: /closed_yes - Закрыть обращение со статусом "Решено" /closed_no - Закрыть обращение со статусом "Не решено" /reassign renameduser_7185940 - Переназначить обращение другому администратору /remind - Отправить напоминание пользователю /search текст - Поиск обращений Скриншотики: Медиа типа Код import asyncio import json import os import logging from enum import Enum from dataclasses import dataclass from typing import Optional, List, Dict, Any from aiogram import Bot, Dispatcher, Router from aiogram.filters import Command, CommandStart from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, ContentType from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.storage.memory import MemoryStorage from aiogram import F from aiogram.types import Update import uuid from dotenv import load_dotenv from datetime import datetime logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN") SUPPORT_CHAT_ID = int(os.getenv("SUPPORT_CHAT_ID")) ADMIN_IDS = [int(admin_id) for admin_id in os.getenv("ADMIN_IDS", "").split(",") if admin_id] if not all([BOT_TOKEN, SUPPORT_CHAT_ID, ADMIN_IDS]): raise ValueError("Missing required environment variables") class TicketStatus(Enum): PENDING = " В обработке" RESOLVED = " Решено" UNRESOLVED = " Не решено" class TicketPriority(Enum): LOW = " Низкий" MEDIUM = " Средний" HIGH = " Высокий" class TicketCategory(Enum): GENERAL = " Общий вопрос" TECHNICAL = " Техническая проблема" BILLING = " Оплата" FEATURE = " Предложение" OTHER = " Другое" @dataclass class Ticket: id: str user_id: int username: str first_name: str topic: str description: str media: List[Dict[str, str]] = None status: TicketStatus = TicketStatus.PENDING priority: TicketPriority = TicketPriority.MEDIUM category: TicketCategory = TicketCategory.GENERAL thread_id: Optional[int] = None created_at: str = None class TicketStorage: def __init__(self, file_path: str = "tickets.json"): self.file_path = file_path self.tickets: List[Ticket] = self.load_tickets() def load_tickets(self) -> List[Ticket]: try: if os.path.exists(self.file_path): with open(self.file_path, 'r', encoding='utf-8') as f: content = f.read().strip() if not content: return [] data = json.loads(content) return [Ticket( id=ticket['id'], user_id=ticket['user_id'], username=ticket['username'], first_name=ticket['first_name'], topic=ticket['topic'], description=ticket['description'], media=ticket.get('media', []), status=TicketStatus(ticket['status']), priority=TicketPriority(ticket['priority']), category=TicketCategory(ticket['category']), thread_id=ticket.get('thread_id'), created_at=ticket.get('created_at') ) for ticket in data] except (json.JSONDecodeError, ValueError) as e: logger.error(f"Error loading tickets: {e}") return [] except Exception as e: logger.error(f"Unexpected error loading tickets: {e}") return [] return [] def save_tickets(self): try: with open(self.file_path, 'w', encoding='utf-8') as f: json.dump([{ 'id': ticket.id, 'user_id': ticket.user_id, 'username': ticket.username, 'first_name': ticket.first_name, 'topic': ticket.topic, 'description': ticket.description, 'media': ticket.media, 'status': ticket.status.value, 'priority': ticket.priority.value, 'category': ticket.category.value, 'thread_id': ticket.thread_id, 'created_at': ticket.created_at } for ticket in self.tickets], f, ensure_ascii=False, indent=2) except Exception as e: logger.error(f"Error saving tickets: {e}") raise def add_ticket(self, ticket: Ticket): self.tickets.append(ticket) self.save_tickets() def get_user_tickets(self, user_id: int) -> List[Ticket]: return [ticket for ticket in self.tickets if ticket.user_id == user_id] def get_ticket_by_id(self, ticket_id: str) -> Optional[Ticket]: return next((ticket for ticket in self.tickets if ticket.id == ticket_id), None) def get_ticket_by_thread_id(self, thread_id: int) -> Optional[Ticket]: return next((ticket for ticket in self.tickets if ticket.thread_id == thread_id), None) def get_latest_pending_ticket(self, user_id: int) -> Optional[Ticket]: user_tickets = [ticket for ticket in self.tickets if ticket.user_id == user_id and ticket.status == TicketStatus.PENDING] return max(user_tickets, key=lambda t: t.id, default=None) def update_ticket(self, ticket_id: str, **kwargs): ticket = self.get_ticket_by_id(ticket_id) if ticket: for key, value in kwargs.items(): setattr(ticket, key, value) self.save_tickets() class TicketService: def __init__(self, bot: Bot, storage: TicketStorage, support_chat_id: int): self.bot = bot self.storage = storage self.support_chat_id = support_chat_id async def create_ticket(self, user_id: int, username: str, first_name: str, topic: str, description: str, media: List[Dict[str, str]] = None, priority: TicketPriority = TicketPriority.MEDIUM): try: ticket_id = str(uuid.uuid4()) ticket = Ticket( id=ticket_id, user_id=user_id, username=username, first_name=first_name, topic=topic, description=description, media=media or [], status=TicketStatus.PENDING, priority=priority, created_at=datetime.now().isoformat() ) topic_obj = await self.bot.create_forum_topic( chat_id=self.support_chat_id, name=f"Обращение #{ticket_id} @{username} {priority.value}" ) ticket.thread_id = topic_obj.message_thread_id await self.bot.send_message( chat_id=self.support_chat_id, message_thread_id=ticket.thread_id, text=f"<b> Обращение</b> #<code>{ticket_id}</code> @{username}\n" f"<b> Имя:</b> {first_name}\n" f"<b> Тема:</b> {ticket.topic}\n" f"<b> Описание:</b> {description}\n" f"<b> Приоритет:</b> {priority.value}", parse_mode="HTML" ) for media_item in ticket.media: try: media_type = media_item['type'] file_id = media_item['file_id'] if media_type == "photo": await self.bot.send_photo( chat_id=self.support_chat_id, photo=file_id, message_thread_id=ticket.thread_id ) elif media_type == "video": await self.bot.send_video( chat_id=self.support_chat_id, video=file_id, message_thread_id=ticket.thread_id ) elif media_type == "document": await self.bot.send_document( chat_id=self.support_chat_id, document=file_id, message_thread_id=ticket.thread_id ) except Exception as e: logger.error(f"Error sending media for ticket {ticket_id}: {e}") self.storage.add_ticket(ticket) logger.info(f"Created new ticket {ticket_id} for user {username} with priority {priority.value}") return ticket except Exception as e: logger.error(f"Error creating ticket: {e}") raise async def close_ticket(self, ticket_id: str, status: TicketStatus, admin_message: Message): ticket = self.storage.get_ticket_by_id(ticket_id) if ticket: self.storage.update_ticket(ticket_id, status=status) await self.bot.send_message( chat_id=ticket.user_id, text=f"Обращение #{ticket_id} закрыто со статусом: {status.value}" ) await self.bot.send_message( chat_id=self.support_chat_id, message_thread_id=ticket.thread_id, text=f"Обращение #{ticket_id} закрыто: {status.value}" ) await self.bot.close_forum_topic( chat_id=self.support_chat_id, message_thread_id=ticket.thread_id ) async def reassign_ticket(self, ticket_id: str, new_admin: str): ticket = self.storage.get_ticket_by_id(ticket_id) if ticket: await self.bot.send_message( chat_id=self.support_chat_id, message_thread_id=ticket.thread_id, text=f"Обращение переназначено на {new_admin}" ) await self.bot.send_message( chat_id=ticket.user_id, text=f"Ваше обращение #{ticket_id} переназначено на другого сотрудника" ) async def remind_user(self, ticket_id: str): ticket = self.storage.get_ticket_by_id(ticket_id) if ticket: await self.bot.send_message( chat_id=ticket.user_id, text=f"Напоминание по обращению #{ticket_id}: Пожалуйста, предоставьте дополнительную информацию или уточните статус." ) class CreateTicket(StatesGroup): TOPIC = State() CATEGORY = State() DESCRIPTION = State() PRIORITY = State() MEDIA = State() class TicketServiceMiddleware: def __init__(self, ticket_service: TicketService): self.ticket_service = ticket_service async def __call__(self, handler, event: Update, data: Dict[str, Any]): data['ticket_service'] = self.ticket_service return await handler(event, data) router = Router() def get_main_menu(): return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=" Создать обращение", callback_data="create_ticket", parse_mode="HTML")], [InlineKeyboardButton(text=" История обращений", callback_data="history", parse_mode="HTML")] ]) def get_priority_keyboard(): return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=" Низкий", callback_data="priority_low"), InlineKeyboardButton(text=" Средний", callback_data="priority_medium"), InlineKeyboardButton(text=" Высокий", callback_data="priority_high") ] ]) def get_category_keyboard(): return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=" Общий вопрос", callback_data="category_general"), InlineKeyboardButton(text=" Техническая проблема", callback_data="category_technical") ], [ InlineKeyboardButton(text=" Оплата", callback_data="category_billing"), InlineKeyboardButton(text=" Предложение", callback_data="category_feature") ], [ InlineKeyboardButton(text=" Другое", callback_data="category_other") ] ]) @router.message(CommandStart()) async def start_command(message: Message): await message.answer( " Добро пожаловать в бот поддержки! Выберите действие:", reply_markup=get_main_menu(), parse_mode="HTML" ) @router.callback_query(F.data == "create_ticket") async def create_ticket_start(callback, state: FSMContext): await callback.message.answer(" Введите тему обращения:", parse_mode="HTML") await state.set_state(CreateTicket.TOPIC) await callback.answer() @router.message(CreateTicket.TOPIC) async def process_topic(message: Message, state: FSMContext): await state.update_data(topic=message.text) await message.answer( " Выберите категорию обращения:", reply_markup=get_category_keyboard(), parse_mode="HTML" ) await state.set_state(CreateTicket.CATEGORY) @router.callback_query(F.data.startswith("category_")) async def process_category(callback, state: FSMContext): category_map = { "category_general": TicketCategory.GENERAL, "category_technical": TicketCategory.TECHNICAL, "category_billing": TicketCategory.BILLING, "category_feature": TicketCategory.FEATURE, "category_other": TicketCategory.OTHER } category = category_map[callback.data] await state.update_data(category=category) await callback.message.answer(" Опишите проблему:", parse_mode="HTML") await state.set_state(CreateTicket.DESCRIPTION) await callback.answer() @router.message(CreateTicket.DESCRIPTION) async def process_description(message: Message, state: FSMContext): await state.update_data(description=message.text) await message.answer( " Выберите приоритет обращения:", reply_markup=get_priority_keyboard(), parse_mode="HTML" ) await state.set_state(CreateTicket.PRIORITY) @router.callback_query(F.data.startswith("priority_")) async def process_priority(callback, state: FSMContext): priority_map = { "priority_low": TicketPriority.LOW, "priority_medium": TicketPriority.MEDIUM, "priority_high": TicketPriority.HIGH } priority = priority_map[callback.data] await state.update_data(priority=priority) await callback.message.answer( " Прикрепите медиа (фото/видео/файл) или отправьте 'Готово' для завершения:", parse_mode="HTML" ) await state.set_state(CreateTicket.MEDIA) await state.update_data(media=[]) await callback.answer() @router.message(CreateTicket.MEDIA, F.content_type.in_({ ContentType.PHOTO, ContentType.VIDEO, ContentType.DOCUMENT })) async def process_media(message: Message, state: FSMContext): data = await state.get_data() media = data.get('media', []) if message.photo: media.append({"type": "photo", "file_id": message.photo[-1].file_id}) elif message.video: media.append({"type": "video", "file_id": message.video.file_id}) elif message.document: media.append({"type": "document", "file_id": message.document.file_id}) await state.update_data(media=media) await message.answer(" Медиа добавлено. Отправьте ещё или напишите '<code>Готово</code>'.", parse_mode="HTML") @router.message(CreateTicket.MEDIA, F.text.lower() == "готово") async def finish_ticket(message: Message, state: FSMContext, ticket_service: TicketService): try: data = await state.get_data() ticket = await ticket_service.create_ticket( user_id=message.from_user.id, username=message.from_user.username or "Unknown", first_name=message.from_user.first_name, topic=data['topic'], description=data['description'], media=data.get('media', []), priority=data.get('priority', TicketPriority.MEDIUM) ) await message.answer( f" Обращение #{ticket.id} создано!\n" f"Приоритет: {ticket.priority.value}\n" f"Статус: {ticket.status.value}", reply_markup=get_main_menu(), parse_mode="HTML" ) await state.clear() except Exception as e: logger.error(f"Error creating ticket: {e}") await message.answer( " Произошла ошибка при создании обращения. Пожалуйста, попробуйте позже.", reply_markup=get_main_menu(), parse_mode="HTML" ) await state.clear() @router.callback_query(F.data == "history") async def show_history(callback, ticket_service: TicketService): tickets = ticket_service.storage.get_user_tickets(callback.from_user.id) if not tickets: await callback.message.answer( " У вас пока нет обращений", reply_markup=get_main_menu(), parse_mode="HTML" ) return tickets.sort(key=lambda x: x.created_at, reverse=True) keyboard = [] for ticket in tickets[:5]: keyboard.append([ InlineKeyboardButton( text=f"#{ticket.id} - {ticket.topic} ({ticket.status.value})", callback_data=f"details_{ticket.id}" ) ]) await callback.message.answer( " Ваши последние обращения:", reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) await callback.answer() @router.callback_query(F.data.startswith("details_")) async def show_ticket_details(callback, ticket_service: TicketService): ticket_id = callback.data.split("_")[1] ticket = ticket_service.storage.get_ticket_by_id(ticket_id) if not ticket: await callback.message.answer( " Обращение не найдено", reply_markup=get_main_menu(), parse_mode="HTML" ) return keyboard = [ [InlineKeyboardButton(text=" Создать похожее", callback_data=f"recreate_{ticket_id}")], [InlineKeyboardButton(text=" Назад", callback_data="history")] ] await callback.message.answer( get_ticket_info(ticket), reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) await callback.answer() @router.callback_query(F.data.startswith("recreate_")) async def recreate_ticket(callback, state: FSMContext, ticket_service: TicketService): ticket_id = callback.data.split("_")[1] ticket = ticket_service.storage.get_ticket_by_id(ticket_id) if ticket: await state.update_data(topic=ticket.topic, description=ticket.description) await callback.message.answer( f" Создаём новое обращение на основе #{ticket.id}\n" f" Тема: {ticket.topic}\n" f" Описание: {ticket.description}\n" "Введите новую тему или подтвердите текущую:", parse_mode="HTML" ) await state.set_state(CreateTicket.TOPIC) await callback.answer() @router.message(Command("closed_yes"), F.chat.type == "supergroup", lambda message: message.from_user.id in ADMIN_IDS) async def close_yes(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: await ticket_service.close_ticket(ticket.id, TicketStatus.RESOLVED, message) await message.answer(" Обращение успешно закрыто.", parse_mode="HTML") else: await message.answer(" Обращение не найдено.", parse_mode="HTML") @router.message(Command("closed_no"), F.chat.type == "supergroup", lambda message: message.from_user.id in ADMIN_IDS) async def close_no(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: await ticket_service.close_ticket(ticket.id, TicketStatus.UNRESOLVED, message) await message.answer(" Обращение закрыто.", parse_mode="HTML") else: await message.answer(" Обращение не найдено.", parse_mode="HTML") @router.message(Command("reassign"), F.chat.type == "supergroup", lambda message: message.from_user.id in ADMIN_IDS) async def reassign(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: args = message.text.split() if len(args) > 1: new_admin = args[1] await ticket_service.reassign_ticket(ticket.id, new_admin) await message.answer(f" Обращение переназначено на {new_admin}", parse_mode="HTML") else: await message.answer(" Укажите @username для переназначения", parse_mode="HTML") else: await message.answer(" Обращение не найдено.", parse_mode="HTML") @router.message(Command("remind"), F.chat.type == "supergroup", lambda message: message.from_user.id in ADMIN_IDS) async def remind(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: await ticket_service.remind_user(ticket.id) await message.answer(" Напоминание отправлено пользователю.", parse_mode="HTML") else: await message.answer(" Обращение не найдено.", parse_mode="HTML") @router.message(F.chat.type == "supergroup", F.message_thread_id, lambda message: message.from_user.id in ADMIN_IDS) async def forward_to_user(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: await ticket_service.bot.copy_message( chat_id=ticket.user_id, from_chat_id=message.chat.id, message_id=message.message_id ) @router.message(F.chat.type == "private", ~F.text.startswith('/'), ~F.state) async def forward_user_message(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_latest_pending_ticket(message.from_user.id) if ticket: if message.text: await ticket_service.bot.send_message( chat_id=ticket_service.support_chat_id, message_thread_id=ticket.thread_id, text=f"<b> Сообщение от пользователя:</b>\n{message.text}", parse_mode="HTML" ) elif message.photo or message.video or message.document: caption = f"<b> Сообщение от пользователя:</b>\n[Медиа]" await ticket_service.bot.copy_message( chat_id=ticket_service.support_chat_id, from_chat_id=message.from_user.id, message_id=message.message_id, message_thread_id=ticket.thread_id, caption=caption, parse_mode="HTML" ) def get_ticket_info(ticket: Ticket) -> str: return ( f"<b> Обращение</b> #<code>{ticket.id}</code>\n" f"<b> Категория:</b> {ticket.category.value}\n" f"<b> Приоритет:</b> {ticket.priority.value}\n" f"<b> Создано:</b> {ticket.created_at}\n" f"<b> Тема:</b> {ticket.topic}\n" f"<b> Описание:</b> {ticket.description}\n" f"<b> Статус:</b> {ticket.status.value}" ) @router.message(Command("search")) async def search_tickets(message: Message, ticket_service: TicketService): if message.from_user.id not in ADMIN_IDS: return query = message.text.split(maxsplit=1)[1] if len(message.text.split()) > 1 else "" if not query: await message.answer(" Укажите поисковый запрос") return tickets = ticket_service.storage.tickets results = [] for ticket in tickets: if (query.lower() in ticket.topic.lower() or query.lower() in ticket.description.lower() or query.lower() in ticket.username.lower()): results.append(ticket) if not results: await message.answer(" Ничего не найдено") return keyboard = [] for ticket in results[:5]: keyboard.append([ InlineKeyboardButton( text=f"#{ticket.id} - {ticket.topic} ({ticket.status.value})", callback_data=f"details_{ticket.id}" ) ]) await message.answer( f" Найдено обращений: {len(results)}\nПоказаны первые 5:", reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) async def main(): try: bot = Bot(token=BOT_TOKEN) storage = TicketStorage() ticket_service = TicketService(bot, storage, SUPPORT_CHAT_ID) dp = Dispatcher(storage=MemoryStorage()) dp.update.middleware(TicketServiceMiddleware(ticket_service)) dp.include_router(router) logger.info("Bot started") await dp.start_polling(bot) except Exception as e: logger.error(f"Error in main: {e}") raise finally: await bot.session.close() if __name__ == "__main__": asyncio.run(main()) Python import asyncio import json import os import logging from enum import Enum from dataclasses import dataclass from typing import Optional, List, Dict, Any from aiogram import Bot, Dispatcher, Router from aiogram.filters import Command, CommandStart from aiogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, ContentType from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.storage.memory import MemoryStorage from aiogram import F from aiogram.types import Update import uuid from dotenv import load_dotenv from datetime import datetime logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) load_dotenv() BOT_TOKEN = os.getenv("BOT_TOKEN") SUPPORT_CHAT_ID = int(os.getenv("SUPPORT_CHAT_ID")) ADMIN_IDS = [int(admin_id) for admin_id in os.getenv("ADMIN_IDS", "").split(",") if admin_id] if not all([BOT_TOKEN, SUPPORT_CHAT_ID, ADMIN_IDS]): raise ValueError("Missing required environment variables") class TicketStatus(Enum): PENDING = " В обработке" RESOLVED = " Решено" UNRESOLVED = " Не решено" class TicketPriority(Enum): LOW = " Низкий" MEDIUM = " Средний" HIGH = " Высокий" class TicketCategory(Enum): GENERAL = " Общий вопрос" TECHNICAL = " Техническая проблема" BILLING = " Оплата" FEATURE = " Предложение" OTHER = " Другое" @dataclass class Ticket: id: str user_id: int username: str first_name: str topic: str description: str media: List[Dict[str, str]] = None status: TicketStatus = TicketStatus.PENDING priority: TicketPriority = TicketPriority.MEDIUM category: TicketCategory = TicketCategory.GENERAL thread_id: Optional[int] = None created_at: str = None class TicketStorage: def __init__(self, file_path: str = "tickets.json"): self.file_path = file_path self.tickets: List[Ticket] = self.load_tickets() def load_tickets(self) -> List[Ticket]: try: if os.path.exists(self.file_path): with open(self.file_path, 'r', encoding='utf-8') as f: content = f.read().strip() if not content: return [] data = json.loads(content) return [Ticket( id=ticket['id'], user_id=ticket['user_id'], username=ticket['username'], first_name=ticket['first_name'], topic=ticket['topic'], description=ticket['description'], media=ticket.get('media', []), status=TicketStatus(ticket['status']), priority=TicketPriority(ticket['priority']), category=TicketCategory(ticket['category']), thread_id=ticket.get('thread_id'), created_at=ticket.get('created_at') ) for ticket in data] except (json.JSONDecodeError, ValueError) as e: logger.error(f"Error loading tickets: {e}") return [] except Exception as e: logger.error(f"Unexpected error loading tickets: {e}") return [] return [] def save_tickets(self): try: with open(self.file_path, 'w', encoding='utf-8') as f: json.dump([{ 'id': ticket.id, 'user_id': ticket.user_id, 'username': ticket.username, 'first_name': ticket.first_name, 'topic': ticket.topic, 'description': ticket.description, 'media': ticket.media, 'status': ticket.status.value, 'priority': ticket.priority.value, 'category': ticket.category.value, 'thread_id': ticket.thread_id, 'created_at': ticket.created_at } for ticket in self.tickets], f, ensure_ascii=False, indent=2) except Exception as e: logger.error(f"Error saving tickets: {e}") raise def add_ticket(self, ticket: Ticket): self.tickets.append(ticket) self.save_tickets() def get_user_tickets(self, user_id: int) -> List[Ticket]: return [ticket for ticket in self.tickets if ticket.user_id == user_id] def get_ticket_by_id(self, ticket_id: str) -> Optional[Ticket]: return next((ticket for ticket in self.tickets if ticket.id == ticket_id), None) def get_ticket_by_thread_id(self, thread_id: int) -> Optional[Ticket]: return next((ticket for ticket in self.tickets if ticket.thread_id == thread_id), None) def get_latest_pending_ticket(self, user_id: int) -> Optional[Ticket]: user_tickets = [ticket for ticket in self.tickets if ticket.user_id == user_id and ticket.status == TicketStatus.PENDING] return max(user_tickets, key=lambda t: t.id, default=None) def update_ticket(self, ticket_id: str, **kwargs): ticket = self.get_ticket_by_id(ticket_id) if ticket: for key, value in kwargs.items(): setattr(ticket, key, value) self.save_tickets() class TicketService: def __init__(self, bot: Bot, storage: TicketStorage, support_chat_id: int): self.bot = bot self.storage = storage self.support_chat_id = support_chat_id async def create_ticket(self, user_id: int, username: str, first_name: str, topic: str, description: str, media: List[Dict[str, str]] = None, priority: TicketPriority = TicketPriority.MEDIUM): try: ticket_id = str(uuid.uuid4()) ticket = Ticket( id=ticket_id, user_id=user_id, username=username, first_name=first_name, topic=topic, description=description, media=media or [], status=TicketStatus.PENDING, priority=priority, created_at=datetime.now().isoformat() ) topic_obj = await self.bot.create_forum_topic( chat_id=self.support_chat_id, name=f"Обращение #{ticket_id} @{username} {priority.value}" ) ticket.thread_id = topic_obj.message_thread_id await self.bot.send_message( chat_id=self.support_chat_id, message_thread_id=ticket.thread_id, text=f"<b> Обращение</b> #<code>{ticket_id}</code> @{username}\n" f"<b> Имя:</b> {first_name}\n" f"<b> Тема:</b> {ticket.topic}\n" f"<b> Описание:</b> {description}\n" f"<b> Приоритет:</b> {priority.value}", parse_mode="HTML" ) for media_item in ticket.media: try: media_type = media_item['type'] file_id = media_item['file_id'] if media_type == "photo": await self.bot.send_photo( chat_id=self.support_chat_id, photo=file_id, message_thread_id=ticket.thread_id ) elif media_type == "video": await self.bot.send_video( chat_id=self.support_chat_id, video=file_id, message_thread_id=ticket.thread_id ) elif media_type == "document": await self.bot.send_document( chat_id=self.support_chat_id, document=file_id, message_thread_id=ticket.thread_id ) except Exception as e: logger.error(f"Error sending media for ticket {ticket_id}: {e}") self.storage.add_ticket(ticket) logger.info(f"Created new ticket {ticket_id} for user {username} with priority {priority.value}") return ticket except Exception as e: logger.error(f"Error creating ticket: {e}") raise async def close_ticket(self, ticket_id: str, status: TicketStatus, admin_message: Message): ticket = self.storage.get_ticket_by_id(ticket_id) if ticket: self.storage.update_ticket(ticket_id, status=status) await self.bot.send_message( chat_id=ticket.user_id, text=f"Обращение #{ticket_id} закрыто со статусом: {status.value}" ) await self.bot.send_message( chat_id=self.support_chat_id, message_thread_id=ticket.thread_id, text=f"Обращение #{ticket_id} закрыто: {status.value}" ) await self.bot.close_forum_topic( chat_id=self.support_chat_id, message_thread_id=ticket.thread_id ) async def reassign_ticket(self, ticket_id: str, new_admin: str): ticket = self.storage.get_ticket_by_id(ticket_id) if ticket: await self.bot.send_message( chat_id=self.support_chat_id, message_thread_id=ticket.thread_id, text=f"Обращение переназначено на {new_admin}" ) await self.bot.send_message( chat_id=ticket.user_id, text=f"Ваше обращение #{ticket_id} переназначено на другого сотрудника" ) async def remind_user(self, ticket_id: str): ticket = self.storage.get_ticket_by_id(ticket_id) if ticket: await self.bot.send_message( chat_id=ticket.user_id, text=f"Напоминание по обращению #{ticket_id}: Пожалуйста, предоставьте дополнительную информацию или уточните статус." ) class CreateTicket(StatesGroup): TOPIC = State() CATEGORY = State() DESCRIPTION = State() PRIORITY = State() MEDIA = State() class TicketServiceMiddleware: def __init__(self, ticket_service: TicketService): self.ticket_service = ticket_service async def __call__(self, handler, event: Update, data: Dict[str, Any]): data['ticket_service'] = self.ticket_service return await handler(event, data) router = Router() def get_main_menu(): return InlineKeyboardMarkup(inline_keyboard=[ [InlineKeyboardButton(text=" Создать обращение", callback_data="create_ticket", parse_mode="HTML")], [InlineKeyboardButton(text=" История обращений", callback_data="history", parse_mode="HTML")] ]) def get_priority_keyboard(): return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=" Низкий", callback_data="priority_low"), InlineKeyboardButton(text=" Средний", callback_data="priority_medium"), InlineKeyboardButton(text=" Высокий", callback_data="priority_high") ] ]) def get_category_keyboard(): return InlineKeyboardMarkup(inline_keyboard=[ [ InlineKeyboardButton(text=" Общий вопрос", callback_data="category_general"), InlineKeyboardButton(text=" Техническая проблема", callback_data="category_technical") ], [ InlineKeyboardButton(text=" Оплата", callback_data="category_billing"), InlineKeyboardButton(text=" Предложение", callback_data="category_feature") ], [ InlineKeyboardButton(text=" Другое", callback_data="category_other") ] ]) @router.message(CommandStart()) async def start_command(message: Message): await message.answer( " Добро пожаловать в бот поддержки! Выберите действие:", reply_markup=get_main_menu(), parse_mode="HTML" ) @router.callback_query(F.data == "create_ticket") async def create_ticket_start(callback, state: FSMContext): await callback.message.answer(" Введите тему обращения:", parse_mode="HTML") await state.set_state(CreateTicket.TOPIC) await callback.answer() @router.message(CreateTicket.TOPIC) async def process_topic(message: Message, state: FSMContext): await state.update_data(topic=message.text) await message.answer( " Выберите категорию обращения:", reply_markup=get_category_keyboard(), parse_mode="HTML" ) await state.set_state(CreateTicket.CATEGORY) @router.callback_query(F.data.startswith("category_")) async def process_category(callback, state: FSMContext): category_map = { "category_general": TicketCategory.GENERAL, "category_technical": TicketCategory.TECHNICAL, "category_billing": TicketCategory.BILLING, "category_feature": TicketCategory.FEATURE, "category_other": TicketCategory.OTHER } category = category_map[callback.data] await state.update_data(category=category) await callback.message.answer(" Опишите проблему:", parse_mode="HTML") await state.set_state(CreateTicket.DESCRIPTION) await callback.answer() @router.message(CreateTicket.DESCRIPTION) async def process_description(message: Message, state: FSMContext): await state.update_data(description=message.text) await message.answer( " Выберите приоритет обращения:", reply_markup=get_priority_keyboard(), parse_mode="HTML" ) await state.set_state(CreateTicket.PRIORITY) @router.callback_query(F.data.startswith("priority_")) async def process_priority(callback, state: FSMContext): priority_map = { "priority_low": TicketPriority.LOW, "priority_medium": TicketPriority.MEDIUM, "priority_high": TicketPriority.HIGH } priority = priority_map[callback.data] await state.update_data(priority=priority) await callback.message.answer( " Прикрепите медиа (фото/видео/файл) или отправьте 'Готово' для завершения:", parse_mode="HTML" ) await state.set_state(CreateTicket.MEDIA) await state.update_data(media=[]) await callback.answer() @router.message(CreateTicket.MEDIA, F.content_type.in_({ ContentType.PHOTO, ContentType.VIDEO, ContentType.DOCUMENT })) async def process_media(message: Message, state: FSMContext): data = await state.get_data() media = data.get('media', []) if message.photo: media.append({"type": "photo", "file_id": message.photo[-1].file_id}) elif message.video: media.append({"type": "video", "file_id": message.video.file_id}) elif message.document: media.append({"type": "document", "file_id": message.document.file_id}) await state.update_data(media=media) await message.answer(" Медиа добавлено. Отправьте ещё или напишите '<code>Готово</code>'.", parse_mode="HTML") @router.message(CreateTicket.MEDIA, F.text.lower() == "готово") async def finish_ticket(message: Message, state: FSMContext, ticket_service: TicketService): try: data = await state.get_data() ticket = await ticket_service.create_ticket( user_id=message.from_user.id, username=message.from_user.username or "Unknown", first_name=message.from_user.first_name, topic=data['topic'], description=data['description'], media=data.get('media', []), priority=data.get('priority', TicketPriority.MEDIUM) ) await message.answer( f" Обращение #{ticket.id} создано!\n" f"Приоритет: {ticket.priority.value}\n" f"Статус: {ticket.status.value}", reply_markup=get_main_menu(), parse_mode="HTML" ) await state.clear() except Exception as e: logger.error(f"Error creating ticket: {e}") await message.answer( " Произошла ошибка при создании обращения. Пожалуйста, попробуйте позже.", reply_markup=get_main_menu(), parse_mode="HTML" ) await state.clear() @router.callback_query(F.data == "history") async def show_history(callback, ticket_service: TicketService): tickets = ticket_service.storage.get_user_tickets(callback.from_user.id) if not tickets: await callback.message.answer( " У вас пока нет обращений", reply_markup=get_main_menu(), parse_mode="HTML" ) return tickets.sort(key=lambda x: x.created_at, reverse=True) keyboard = [] for ticket in tickets[:5]: keyboard.append([ InlineKeyboardButton( text=f"#{ticket.id} - {ticket.topic} ({ticket.status.value})", callback_data=f"details_{ticket.id}" ) ]) await callback.message.answer( " Ваши последние обращения:", reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) await callback.answer() @router.callback_query(F.data.startswith("details_")) async def show_ticket_details(callback, ticket_service: TicketService): ticket_id = callback.data.split("_")[1] ticket = ticket_service.storage.get_ticket_by_id(ticket_id) if not ticket: await callback.message.answer( " Обращение не найдено", reply_markup=get_main_menu(), parse_mode="HTML" ) return keyboard = [ [InlineKeyboardButton(text=" Создать похожее", callback_data=f"recreate_{ticket_id}")], [InlineKeyboardButton(text=" Назад", callback_data="history")] ] await callback.message.answer( get_ticket_info(ticket), reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) await callback.answer() @router.callback_query(F.data.startswith("recreate_")) async def recreate_ticket(callback, state: FSMContext, ticket_service: TicketService): ticket_id = callback.data.split("_")[1] ticket = ticket_service.storage.get_ticket_by_id(ticket_id) if ticket: await state.update_data(topic=ticket.topic, description=ticket.description) await callback.message.answer( f" Создаём новое обращение на основе #{ticket.id}\n" f" Тема: {ticket.topic}\n" f" Описание: {ticket.description}\n" "Введите новую тему или подтвердите текущую:", parse_mode="HTML" ) await state.set_state(CreateTicket.TOPIC) await callback.answer() @router.message(Command("closed_yes"), F.chat.type == "supergroup", lambda message: message.from_user.id in ADMIN_IDS) async def close_yes(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: await ticket_service.close_ticket(ticket.id, TicketStatus.RESOLVED, message) await message.answer(" Обращение успешно закрыто.", parse_mode="HTML") else: await message.answer(" Обращение не найдено.", parse_mode="HTML") @router.message(Command("closed_no"), F.chat.type == "supergroup", lambda message: message.from_user.id in ADMIN_IDS) async def close_no(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: await ticket_service.close_ticket(ticket.id, TicketStatus.UNRESOLVED, message) await message.answer(" Обращение закрыто.", parse_mode="HTML") else: await message.answer(" Обращение не найдено.", parse_mode="HTML") @router.message(Command("reassign"), F.chat.type == "supergroup", lambda message: message.from_user.id in ADMIN_IDS) async def reassign(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: args = message.text.split() if len(args) > 1: new_admin = args[1] await ticket_service.reassign_ticket(ticket.id, new_admin) await message.answer(f" Обращение переназначено на {new_admin}", parse_mode="HTML") else: await message.answer(" Укажите @username для переназначения", parse_mode="HTML") else: await message.answer(" Обращение не найдено.", parse_mode="HTML") @router.message(Command("remind"), F.chat.type == "supergroup", lambda message: message.from_user.id in ADMIN_IDS) async def remind(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: await ticket_service.remind_user(ticket.id) await message.answer(" Напоминание отправлено пользователю.", parse_mode="HTML") else: await message.answer(" Обращение не найдено.", parse_mode="HTML") @router.message(F.chat.type == "supergroup", F.message_thread_id, lambda message: message.from_user.id in ADMIN_IDS) async def forward_to_user(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_ticket_by_thread_id(message.message_thread_id) if ticket: await ticket_service.bot.copy_message( chat_id=ticket.user_id, from_chat_id=message.chat.id, message_id=message.message_id ) @router.message(F.chat.type == "private", ~F.text.startswith('/'), ~F.state) async def forward_user_message(message: Message, ticket_service: TicketService): ticket = ticket_service.storage.get_latest_pending_ticket(message.from_user.id) if ticket: if message.text: await ticket_service.bot.send_message( chat_id=ticket_service.support_chat_id, message_thread_id=ticket.thread_id, text=f"<b> Сообщение от пользователя:</b>\n{message.text}", parse_mode="HTML" ) elif message.photo or message.video or message.document: caption = f"<b> Сообщение от пользователя:</b>\n[Медиа]" await ticket_service.bot.copy_message( chat_id=ticket_service.support_chat_id, from_chat_id=message.from_user.id, message_id=message.message_id, message_thread_id=ticket.thread_id, caption=caption, parse_mode="HTML" ) def get_ticket_info(ticket: Ticket) -> str: return ( f"<b> Обращение</b> #<code>{ticket.id}</code>\n" f"<b> Категория:</b> {ticket.category.value}\n" f"<b> Приоритет:</b> {ticket.priority.value}\n" f"<b> Создано:</b> {ticket.created_at}\n" f"<b> Тема:</b> {ticket.topic}\n" f"<b> Описание:</b> {ticket.description}\n" f"<b> Статус:</b> {ticket.status.value}" ) @router.message(Command("search")) async def search_tickets(message: Message, ticket_service: TicketService): if message.from_user.id not in ADMIN_IDS: return query = message.text.split(maxsplit=1)[1] if len(message.text.split()) > 1 else "" if not query: await message.answer(" Укажите поисковый запрос") return tickets = ticket_service.storage.tickets results = [] for ticket in tickets: if (query.lower() in ticket.topic.lower() or query.lower() in ticket.description.lower() or query.lower() in ticket.username.lower()): results.append(ticket) if not results: await message.answer(" Ничего не найдено") return keyboard = [] for ticket in results[:5]: keyboard.append([ InlineKeyboardButton( text=f"#{ticket.id} - {ticket.topic} ({ticket.status.value})", callback_data=f"details_{ticket.id}" ) ]) await message.answer( f" Найдено обращений: {len(results)}\nПоказаны первые 5:", reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard), parse_mode="HTML" ) async def main(): try: bot = Bot(token=BOT_TOKEN) storage = TicketStorage() ticket_service = TicketService(bot, storage, SUPPORT_CHAT_ID) dp = Dispatcher(storage=MemoryStorage()) dp.update.middleware(TicketServiceMiddleware(ticket_service)) dp.include_router(router) logger.info("Bot started") await dp.start_polling(bot) except Exception as e: logger.error(f"Error in main: {e}") raise finally: await bot.session.close() if __name__ == "__main__": asyncio.run(main()) Дополнительно: 1. Создайте .env по следующему шаблону: BOT_TOKEN=tokem_from_@botfather SUPPORT_CHAT_ID=-1001234567890 ADMIN_IDS=1234567890 Code BOT_TOKEN=tokem_from_@botfather SUPPORT_CHAT_ID=-1001234567890 ADMIN_IDS=1234567890 Это один из первых петников, код прогонял несколько раз через gpt что бы было как минимум не стыдно выложить неструктруированное говнище, а выложить хочеться что бы увидеть какой-либо фитбек, эмодзи и парсмод так же устанавливалось через гпт ибо мне в падлу( P.s. где то видел подобное на форуме, но код и самого бота не смотрел, если что-то похожее, сорян... p.s.2 все обращения еще в json сохраняются