В этой инструкции описан полный процесс установки бота в папку /opt/remnasupport на сервере с использованием Docker.
Подключитесь к серверу и создайте директорию для бота:
Внутри папки /opt/remnasupport создайте следующие файлы с указанным содержанием:
FROM python:3.11-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "bot.py"]
services:
support-bot:
build: .
restart: unless-stopped
container_name: remnasupport
env_file:
- .env
volumes:
- ./data:/app/data
aiogram>=3.4.1 python-dotenv>=1.0.0
Создайте файл .env и впишите ваши данные.
BOT_TOKEN=ВАШ_ТОКЕН_ОТ_BOTFATHER ADMIN_ID=ВАШ_ID_ЦИФРАМИ
Создайте файл bot.py и вставьте туда этот код (версия 0.0.5):
import asyncio
import json
import logging
import os
import uuid
from datetime import datetime
from pathlib import Path
from typing import Dict, Any
from aiogram import Bot, Dispatcher, F, Router, types
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.exceptions import TelegramBadRequest
from dotenv import load_dotenv
# --- КОНФИГУРАЦИЯ И ЛОГИРОВАНИЕ ---
load_dotenv()
BOT_TOKEN = os.getenv("BOT_TOKEN")
try:
ADMIN_ID = int(os.getenv("ADMIN_ID"))
except (ValueError, TypeError):
logging.error("❌ Ошибка: ADMIN_ID в .env файле не является числом или отсутствует!")
exit()
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)
FILES = {
"tickets": DATA_DIR / "tickets.json",
"buttons": DATA_DIR / "buttons.json",
"content": DATA_DIR / "content.json"
}
BOT_VERSION = "v 0.0.5 (Smooth Navigation)"
AUTHOR_LINK = "https://m.gmhost.ru"
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
bot = Bot(token=BOT_TOKEN)
storage = MemoryStorage()
dp = Dispatcher(storage=storage)
router = Router()
# --- FSM СОСТОЯНИЯ ---
class TicketStates(StatesGroup):
creating_ticket = State()
admin_reply = State()
user_reply = State()
# --- ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ---
def escape_html(text: str) -> str:
"""Экранирует спецсимволы для HTML"""
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
def load_json(file_path: Path, default: Any = None) -> Any:
"""Безопасная загрузка JSON"""
if default is None:
default = {}
try:
if file_path.exists():
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
else:
save_json(file_path, default)
return default
except Exception as e:
logger.error(f"Ошибка чтения {file_path}: {e}")
save_json(file_path, default)
return default
def save_json(file_path: Path, data: Any):
"""Сохранение JSON"""
with open(file_path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=4)
def load_tickets() -> Dict:
data = load_json(FILES["tickets"], {})
if "last_ticket_id" not in data:
data["last_ticket_id"] = -1
save_json(FILES["tickets"], data)
return data
def save_tickets(data: Dict):
save_json(FILES["tickets"], data)
def load_content():
default_content = {
"payment_issues": (
"💳 Если у вас возникли проблемы с оплатой:\n\n"
"• Убедитесь, что вы правильно вводите данные карты.\n"
"• Попробуйте использовать другой способ оплаты (если доступно).\n"
"• Если вы уверены, что делали всё правильно, но оплата не проходит — создайте тикет."
),
"get_key": (
"🔑 Чтобы получить ключ:\n\n"
"1. Зайдите в бот для покупки ключа VPN — @DVIJ_VPN_BOT\n"
"2. Нажмите Подписка -> Купить подписку.\n"
"3. Просмотрите все тарифы и выберите подходящий.\n"
"4. Выберите срок покупки ключа.\n"
"5. Выберите платёжную систему и нажмите Оплатить.\n"
"6. После успешной оплаты ключ будет доступен в вашем аккаунте."
),
"connect_guide": (
"🚀 Инструкция по подключению:\n\n"
"1. Нажмите на кнопку 🚀 Подключиться, откройте ссылку.\n"
"2. В карточке аккаунта перейдите в раздел Установка.\n"
"3. Выберите приложение, через которое будет запускаться VPN.\n"
"4. Скачайте и установите выбранное приложение.\n"
"5. Вернитесь на страницу своего аккаунта.\n"
"6. Нажмите + Добавить подписку.\n"
"7. Выберите сервер и подключайтесь к нему! 🎉"
),
"exchange_info": (
"💎 Обмен Баллов на счёт в боте:\n\n"
"Выберите количество баллов для обмена."
)
}
return load_json(FILES["content"], default_content)
def save_content(data): save_json(FILES["content"], data)
# --- КЛАВИАТУРЫ ---
def get_main_keyboard(user_id: int):
kb_buttons = []
# Кнопка админа
if user_id == ADMIN_ID:
kb_buttons.append([InlineKeyboardButton(text="⚙️ Функции администратора", callback_data="admin_tools_menu")])
# Основные кнопки
main_buttons = [
{"text": "💳 Проблемы с оплатой", "callback_data": "payment_issues"},
{"text": "🔑 Как получить ключ", "callback_data": "get_key"},
{"text": "🚀 Как Подключиться", "callback_data": "connect_guide"},
{"text": "💎 Обмен баллов", "callback_data": "exchange_points"},
{"text": "📝 Тикеты", "callback_data": "tickets_menu"},
{"text": "📜 История заявок", "callback_data": "user_history"}
]
for btn in main_buttons:
# Скрываем ВСЕ пользовательские кнопки от админа
if user_id == ADMIN_ID and btn["callback_data"] in ["exchange_points", "tickets_menu", "user_history", "payment_issues", "get_key", "connect_guide"]:
continue
kb_buttons.append([InlineKeyboardButton(text=btn["text"], callback_data=btn["callback_data"])])
return InlineKeyboardMarkup(inline_keyboard=kb_buttons)
def get_back_keyboard(callback="back_to_main"):
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔙 Назад", callback_data=callback)]
])
def get_exchange_keyboard():
row1 = [InlineKeyboardButton(text=str(i), callback_data=f"exch_{i}") for i in range(10, 60, 10)]
row2 = [InlineKeyboardButton(text=str(i), callback_data=f"exch_{i}") for i in range(60, 110, 10)]
back_row = [
InlineKeyboardButton(text="📜 Моя история", callback_data="user_exchange_history"),
InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")
]
return InlineKeyboardMarkup(inline_keyboard=[row1, row2, back_row])
def get_tickets_menu_keyboard():
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📝 Создать тикет", callback_data="create_ticket")],
[InlineKeyboardButton(text="📂 Открытые тикеты", callback_data="list_open_tickets")],
[InlineKeyboardButton(text="🗂 Завершённые тикеты", callback_data="list_closed_tickets")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]
])
def get_admin_menu_keyboard():
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔀 Заявки на вывод", callback_data="admin_exchange_list")],
[InlineKeyboardButton(text="📋 Управление тикетами", callback_data="admin_ticket_list_menu")],
[InlineKeyboardButton(text="🗑 Архив", callback_data="admin_archive_menu")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]
])
def get_admin_ticket_control_keyboard():
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="📂 Открытые тикеты", callback_data="admin_view_open")],
[InlineKeyboardButton(text="🗂 Завершённые тикеты", callback_data="admin_view_closed")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_tools_menu")]
])
def get_admin_archive_keyboard():
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🗂 Завершённые тикеты", callback_data="admin_archive_tickets")],
[InlineKeyboardButton(text="🔀 Завершённые заявки на обмен", callback_data="admin_archive_exchanges")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="admin_tools_menu")]
])
# --- ХЕНДЛЕРЫ ГЛАВНОГО МЕНЮ ---
@router.message(Command("start"))
async def cmd_start(message: types.Message, state: FSMContext):
await state.clear()
text = "👋 Добро пожаловать! Выберите нужный раздел:"
await message.answer(text, reply_markup=get_main_keyboard(message.from_user.id))
@router.callback_query(F.data == "back_to_main")
async def back_main(callback: types.CallbackQuery, state: FSMContext):
await state.clear()
try:
await callback.message.edit_text("👋 Выберите нужный раздел:", reply_markup=get_main_keyboard(callback.from_user.id))
except TelegramBadRequest:
pass # Игнорируем ошибку "не изменено"
await callback.answer()
# --- СТАТИЧНЫЕ СТРАНИЦЫ ---
@router.callback_query(F.data.regexp(r'^(payment_issues|get_key|connect_guide)$'))
async def show_info_page(callback: types.CallbackQuery):
key = callback.data
text = load_content().get(key, "Информация не найдена")
kb = None
if key == "payment_issues":
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="🔗 ОТКРЫТЬ ТИКЕТ", callback_data="create_ticket")],
[InlineKeyboardButton(text="🔙 Назад", callback_data="back_to_main")]
])
else:
kb = get_back_keyboard()
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=kb)
except TelegramBadRequest:
pass
await callback.answer()
# --- ОБМЕН БАЛЛОВ ---
@router.callback_query(F.data == "exchange_points")
async def exchange_points_start(callback: types.CallbackQuery, state: FSMContext):
text = load_content().get("exchange_info")
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=get_exchange_keyboard())
except TelegramBadRequest:
pass
await callback.answer()
@router.callback_query(F.data.startswith("exch_"))
async def exchange_points_select(callback: types.CallbackQuery, state: FSMContext):
parts = callback.data.split("_")
amount = parts[1]
if not amount.isdigit():
logger.warning(f"Попытка создания заявки с нечисловым amount: {amount}")
return
tickets_data = load_tickets()
current_id = tickets_data.get("last_ticket_id", -1) + 1
ticket_uuid = str(uuid.uuid4())
tickets_data["last_ticket_id"] = current_id
tickets_data[ticket_uuid] = {
"ticket_number": current_id,
"user_id": callback.from_user.id,
"status": "open",
"exchange_status": "pending",
"type": "exchange_points",
"amount": amount,
"messages": [
{"sender": "user", "text": f"Запрос на обмен {amount} баллов", "time": datetime.now().isoformat()}
]
}
save_tickets(tickets_data)
await callback.message.edit_text(
f"Номер заявки {current_id}\n\n"
f"Ваша заявка на перевод Баллов сформирована\n"
f"Ваш ID будет проверен на наличие Баллов для перевода.",
parse_mode="HTML",
reply_markup=get_back_keyboard("exchange_points")
)
await callback.answer()
admin_text = (
f"🔔 Новая заявка на обмен!\n"
f"🆔 Номер: {current_id}\n"
f"👤 Пользователь: {callback.from_user.full_name}\n"
f"💰 Сумма: {amount}\n\n"
f"Необходимо подтвердить или отказать в заявке."
)
try:
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="ℹ️ Подробно", callback_data=f"adm_exch_detail_{ticket_uuid}")],
[InlineKeyboardButton(text="✅ Готово", callback_data=f"exch_done_{ticket_uuid}")],
[InlineKeyboardButton(text="❌ Отказать", callback_data=f"exch_rej_{ticket_uuid}")]
])
await bot.send_message(ADMIN_ID, admin_text, parse_mode="HTML", reply_markup=kb)
logger.info(f"Админ уведомлен о заявке #{current_id}")
except Exception as e:
logger.error(f"❌ КРИТИЧНО: Не удалось отправить уведомление админу! {e}")
# --- АДМИН УДАЛЕНИЕ ЗАЯВОК ---
@router.callback_query(F.data.startswith("delete_exchange_"))
async def admin_delete_exchange(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
tid = callback.data.split("_")[-1]
tickets_data = load_tickets()
if tid in tickets_data:
tickets_data.pop(tid)
save_tickets(tickets_data)
await callback.answer("Заявка удалена")
await admin_archive_exchanges_list(callback) # Обновить список
else:
await callback.answer("Ошибка удаления")
@router.callback_query(F.data.startswith("delete_ticket_"))
async def admin_delete_ticket(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
tid = callback.data.split("_")[-1]
tickets_data = load_tickets()
if tid in tickets_data:
tickets_data.pop(tid)
save_tickets(tickets_data)
await callback.answer("Тикет удален")
await admin_archive_tickets_list(callback) # Обновить список
else:
await callback.answer("Ошибка удаления")
# --- АДМИН ПОДРОБНЫЙ ПРОСМОТ ЗАЯВОК ---
@router.callback_query(F.data.startswith("adm_exch_detail_"))
async def admin_view_exchange_detail(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
tid = callback.data.split("_")[-1]
tickets_data = load_tickets()
if tid not in tickets_data or not isinstance(tickets_data[tid], dict): return
t = tickets_data[tid]
status_map = {"pending": "🟢 Ожидает", "done": "✅ Выполнено", "rejected": "❌ Отказано"}
exch_status = status_map.get(t.get("exchange_status"), "Неизвестно")
text = (
f"🔔 Подробности заявки №{t['ticket_number']}\n\n"
f"👤 Пользователь ID: {t['user_id']}\n"
f"💰 Сумма: {t.get('amount', '?')} баллов\n"
f"📊 Статус: {exch_status}\n"
f"🕒 Дата создания: {t['messages'][0]['time'][:10] if t['messages'] else 'Неизвестно'}\n\n"
)
kb = []
if t["exchange_status"] == "pending":
kb.append([InlineKeyboardButton(text="✅ Готово", callback_data=f"exch_done_{tid}")])
kb.append([InlineKeyboardButton(text="❌ Отказать", callback_data=f"exch_rej_{tid}")])
kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_exchange_list")])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(inline_keyboard=kb))
except TelegramBadRequest:
pass
await callback.answer()
# --- АДМИН ОБРАБОТКА ОБМЕНОВ ---
@router.callback_query(F.data.startswith("exch_done_"))
async def admin_exchange_approve(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
tid = callback.data.split("_")[-1]
tickets_data = load_tickets()
if tid not in tickets_data or not isinstance(tickets_data[tid], dict): return
ticket = tickets_data[tid]
if ticket["exchange_status"] != "pending":
await callback.answer("Заявка уже обработана", show_alert=True)
return
tickets_data[tid]["exchange_status"] = "done"
tickets_data[tid]["status"] = "closed"
save_tickets(tickets_data)
await callback.answer("Заявка подтверждена")
try:
await callback.message.edit_reply_markup(reply_markup=None)
except: pass
try:
user_id = ticket["user_id"]
await bot.send_message(
user_id,
"✅ Успешно!\n\n"
"Ваша заявка была успешно обработана и запрашиваемы баланс баллов был переведён на аккаунт пользователя.",
parse_mode="HTML"
)
logger.info(f"Сообщение о успешном обмене отправлено юзеру {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка отправки сообщения юзеру {ticket['user_id']}: {e}")
@router.callback_query(F.data.startswith("exch_rej_"))
async def admin_exchange_reject(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
tid = callback.data.split("_")[-1]
tickets_data = load_tickets()
if tid not in tickets_data or not isinstance(tickets_data[tid], dict): return
ticket = tickets_data[tid]
if ticket["exchange_status"] != "pending":
await callback.answer("Заявка уже обработана", show_alert=True)
return
tickets_data[tid]["exchange_status"] = "rejected"
tickets_data[tid]["status"] = "closed"
save_tickets(tickets_data)
await callback.answer("Заявка отклонена")
try:
await callback.message.edit_reply_markup(reply_markup=None)
except: pass
try:
user_id = ticket["user_id"]
await bot.send_message(
user_id,
"❌ Отказано\n\n"
"Обмен выбранного количества вами балов невозможен так как он не был подтверждён, пожалуйста проверьте свой баланс баллов и повторите попытку.",
parse_mode="HTML"
)
logger.info(f"Сообщение об отказе отправлено юзеру {user_id}")
except Exception as e:
logger.error(f"❌ Ошибка отправки сообщения юзеру {ticket['user_id']}: {e}")
# --- АДМИН СПИСОК ЗАЯВОК ---
@router.callback_query(F.data == "admin_exchange_list")
async def admin_exchange_list(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
tickets_data = load_tickets()
pending_exchanges = [
(tid, t) for tid, t in tickets_data.items()
if isinstance(t, dict)
and t.get("type") == "exchange_points"
and t.get("exchange_status") == "pending"
]
if not pending_exchanges:
try:
await callback.message.edit_text("🔀 Заявки на вывод\n\nНет ожидающих заявок.", parse_mode="HTML", reply_markup=get_admin_menu_keyboard())
except TelegramBadRequest:
await callback.answer()
return
text = "🔀 Заявки на вывод:\n\n"
kb = []
for tid, t in pending_exchanges:
amount = t.get('amount', 'Неизвестно')
text += f"🆔 #{t['ticket_number']} | {amount} баллов\n"
text += f"👤 ID: {t['user_id']}\n\n"
# Добавляем кнопку "Подробно"
kb.append([
InlineKeyboardButton(text="ℹ️ Подробно", callback_data=f"adm_exch_detail_{tid}"),
InlineKeyboardButton(text=f"✅ Готово #{t['ticket_number']}", callback_data=f"exch_done_{tid}"),
InlineKeyboardButton(text=f"❌ Отказать #{t['ticket_number']}", callback_data=f"exch_rej_{tid}")
])
kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_tools_menu")])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(inline_keyboard=kb))
except TelegramBadRequest:
pass
await callback.answer()
# --- ИСТОРИЯ ЗАЯВОК ---
@router.callback_query(F.data == "user_history")
@router.callback_query(F.data == "user_exchange_history")
async def show_user_exchange_history(callback: types.CallbackQuery):
tickets_data = load_tickets()
user_exchanges = [
t for t in tickets_data.values()
if isinstance(t, dict) and t.get("user_id") == callback.from_user.id and t.get("type") == "exchange_points"
]
if not user_exchanges:
text = "История заявок на обмен:\n\nУ вас пока нет заявок."
kb = get_back_keyboard() if callback.data == "user_history" else get_back_keyboard("exchange_points")
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=kb)
await callback.answer()
return
text = "История заявок на обмен:\n\n"
user_exchanges.sort(key=lambda x: x.get('ticket_number', 0), reverse=True)
for t in user_exchanges:
status = t.get("exchange_status", "pending")
if status == "pending":
status_emoji = "🟢 Ожидает"
elif status == "done":
status_emoji = "✅ Выполнено"
elif status == "rejected":
status_emoji = "❌ Отказано"
else:
status_emoji = status
text += f"🆔 №{t['ticket_number']} | {t.get('amount', '?')} баллов\n"
text += f"📊 Статус: {status_emoji}\n\n"
kb = get_back_keyboard() if callback.data == "user_history" else get_back_keyboard("exchange_points")
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=kb)
await callback.answer()
# --- АДМИН АРХИВ ---
@router.callback_query(F.data == "admin_archive_menu")
async def admin_archive_menu(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
try:
await callback.message.edit_text("🗑 Архив и удаление\n\nВыберите раздел для просмотра и удаления завершенных заявок.", parse_mode="HTML", reply_markup=get_admin_archive_keyboard())
except TelegramBadRequest:
pass
await callback.answer()
@router.callback_query(F.data == "admin_archive_tickets")
async def admin_archive_tickets_list(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
tickets_data = load_tickets()
real_tickets = {k:v for k,v in tickets_data.items() if isinstance(v, dict) and k != "last_ticket_id"}
closed_tickets = {k:v for k,v in real_tickets.items() if v.get("type") == "question" and v.get("status") == "closed"}
if not closed_tickets:
try:
await callback.message.edit_text("🗂 Завершённые тикеты\n\nСписок пуст.", parse_mode="HTML", reply_markup=get_admin_archive_keyboard())
except TelegramBadRequest:
await callback.answer()
return
text = "🗂 Завершённые тикеты:\n\n"
kb = []
sorted_tickets = sorted(closed_tickets.items(), key=lambda x: x[1].get('ticket_number', 0), reverse=True)
for tid, t in sorted_tickets:
last_msg = t["messages"][-1]["text"] if t["messages"] else "..."
text += f"🆔 №{t['ticket_number']} | ID: {t['user_id']}\n{escape_html(last_msg[:30])}...\n\n"
kb.append([
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"delete_ticket_{tid}"),
InlineKeyboardButton(text="👁 Подробно", callback_data=f"adm_detail_{tid}")
])
kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_archive_menu")])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(inline_keyboard=kb))
except TelegramBadRequest:
pass
await callback.answer()
@router.callback_query(F.data == "admin_archive_exchanges")
async def admin_archive_exchanges_list(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
tickets_data = load_tickets()
# Фильтруем завершенные обмены
closed_exchanges = [
(tid, t) for tid, t in tickets_data.items()
if isinstance(t, dict)
and t.get("type") == "exchange_points"
and t.get("exchange_status") in ["done", "rejected"]
]
if not closed_exchanges:
try:
await callback.message.edit_text("🔀 Завершённые заявки на обмен\n\nСписок пуст.", parse_mode="HTML", reply_markup=get_admin_archive_keyboard())
except TelegramBadRequest:
await callback.answer()
return
text = "🔀 Завершённые заявки на обмен:\n\n"
kb = []
sorted_exchanges = sorted(closed_exchanges, key=lambda x: x[1].get('ticket_number', 0), reverse=True)
for tid, t in sorted_exchanges:
status = "✅" if t.get("exchange_status") == "done" else "❌"
text += f"🆔 №{t['ticket_number']} | {status} | {t.get('amount', '?')} баллов\n"
text += f"👤 ID: {t['user_id']}\n\n"
kb.append([
InlineKeyboardButton(text="🗑 Удалить", callback_data=f"delete_exchange_{tid}"),
InlineKeyboardButton(text="ℹ️ Подробно", callback_data=f"adm_exch_detail_{tid}")
])
kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_archive_menu")])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(inline_keyboard=kb))
except TelegramBadRequest:
pass
await callback.answer()
# --- ТИКЕТЫ ---
@router.callback_query(F.data == "tickets_menu")
async def tickets_menu(callback: types.CallbackQuery):
try:
await callback.message.edit_text("📝 Меню тикетов:", parse_mode="HTML", reply_markup=get_tickets_menu_keyboard())
except TelegramBadRequest:
pass
await callback.answer()
@router.callback_query(F.data == "create_ticket")
async def create_ticket_start(callback: types.CallbackQuery, state: FSMContext):
await state.set_state(TicketStates.creating_ticket)
try:
await callback.message.edit_text("✍️ Опишите вашу проблему или вопрос:", reply_markup=get_back_keyboard("tickets_menu"))
except TelegramBadRequest:
pass
await callback.answer()
@router.message(TicketStates.creating_ticket)
async def create_ticket_finish(message: types.Message, state: FSMContext):
text = message.text
tickets_data = load_tickets()
current_id = tickets_data.get("last_ticket_id", -1) + 1
ticket_uuid = str(uuid.uuid4())
tickets_data["last_ticket_id"] = current_id
tickets_data[ticket_uuid] = {
"ticket_number": current_id,
"user_id": message.from_user.id,
"status": "open",
"type": "question",
"messages": [{"sender": "user", "text": text, "time": datetime.now().isoformat()}]
}
save_tickets(tickets_data)
await message.answer(f"✅ Тикет №{current_id} создан!", reply_markup=get_main_keyboard(message.from_user.id))
await state.clear()
admin_text = (
f"🔔 Новый вопрос!\n"
f"🆔 Номер: {current_id}\n"
f"👤 Пользователь: {message.from_user.full_name}\n"
f"💬 Сообщение:\n{escape_html(text)}"
)
try:
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="👁 Подробно", callback_data=f"adm_detail_{ticket_uuid}")],
[InlineKeyboardButton(text="✍️ Ответить", callback_data=f"adm_reply_{ticket_uuid}")],
[InlineKeyboardButton(text="🔴 Закрыть", callback_data=f"close_ticket_{ticket_uuid}")]
])
await bot.send_message(ADMIN_ID, admin_text, parse_mode="HTML", reply_markup=kb)
logger.info(f"Админ уведомлен о вопросе #{current_id}")
except Exception as e:
logger.error(f"Ошибка отправки админу: {e}")
# --- ПРОСМОТР ТИКЕТОВ ЮЗЕРОМ ---
@router.callback_query(F.data.startswith("list_open_tickets"))
async def user_list_open(callback: types.CallbackQuery):
await show_user_tickets(callback, "open")
@router.callback_query(F.data.startswith("list_closed_tickets"))
async def user_list_closed(callback: types.CallbackQuery):
await show_user_tickets(callback, "closed")
async def show_user_tickets(callback: types.CallbackQuery, status_filter: str):
tickets_data = load_tickets()
real_tickets = {k:v for k,v in tickets_data.items() if isinstance(v, dict) and k != "last_ticket_id"}
user_tickets = {k:v for k,v in real_tickets.items() if v["user_id"] == callback.from_user.id and v["status"] == status_filter}
if not user_tickets:
await callback.answer("Тикетов нет", show_alert=True)
return
text = f"{'Открытые' if status_filter == 'open' else 'Завершённые'} тикеты:\n\n"
kb = []
for tid, t in user_tickets.items():
emoji = "🟢" if t["status"] == "open" else "🔴"
last_msg = t["messages"][-1]["text"] if t["messages"] else "Нет сообщений"
text += f"{emoji} №{t['ticket_number']}\n{escape_html(last_msg[:60])}...\n\n"
if t["status"] == "open":
kb.append([
InlineKeyboardButton(text="💬 Ответить", callback_data=f"user_reply_{tid}"),
InlineKeyboardButton(text=f"🔴 Закрыть тикет №{t['ticket_number']}", callback_data=f"close_ticket_{tid}")
])
kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="tickets_menu")])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(inline_keyboard=kb))
except TelegramBadRequest:
pass
await callback.answer()
# --- АДМИН ПОДРОБНЫЙ ПРОСМОТ ТИКЕТА ---
@router.callback_query(F.data.startswith("adm_detail_"))
async def admin_view_ticket_detail(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
tid = callback.data.split("_")[-1]
tickets_data = load_tickets()
if tid not in tickets_data: return
t = tickets_data[tid]
status_emoji = "🟢 Открыт" if t["status"] == "open" else "🔴 Закрыт"
text = (
f"📋 Тикет №{t['ticket_number']}\n"
f"👤 ID Пользователя: {t['user_id']}\n"
f"📊 Статус: {status_emoji}\n\n"
f"История сообщений:\n\n"
)
for msg in t["messages"]:
sender = "👤 Пользователь" if msg["sender"] == "user" else "🛠️ Поддержка"
text += f"{sender} ({msg['time'][11:16]}):\n{escape_html(msg['text'])}\n\n"
kb = []
if t["status"] == "open":
kb.append([InlineKeyboardButton(text="✍️ Ответить", callback_data=f"adm_reply_{tid}")])
kb.append([InlineKeyboardButton(text="🔴 Закрыть", callback_data=f"close_ticket_{tid}")])
else:
kb.append([InlineKeyboardButton(text="🗑 Удалить", callback_data=f"delete_ticket_{tid}")])
kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_view_open" if t["status"] == "open" else "admin_view_closed")])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(inline_keyboard=kb))
except TelegramBadRequest:
pass
await callback.answer()
# --- ОТВЕТ ЮЗЕРА В ТИКЕТ ---
@router.callback_query(F.data.startswith("user_reply_"))
async def user_reply_start(callback: types.CallbackQuery, state: FSMContext):
tid = callback.data.split("_")[-1]
await state.update_data(reply_ticket_id=tid)
await state.set_state(TicketStates.user_reply)
try:
await callback.message.edit_text("💬 Введите ваш ответ:", reply_markup=get_back_keyboard("tickets_menu"))
except TelegramBadRequest:
pass
await callback.answer()
@router.message(TicketStates.user_reply)
async def user_reply_send(message: types.Message, state: FSMContext):
data = await state.get_data()
ticket_id = data.get("reply_ticket_id")
if not ticket_id: return
tickets_data = load_tickets()
if ticket_id not in tickets_data:
await message.answer("Тикет не найден")
await state.clear()
return
tickets_data[ticket_id]["messages"].append({"sender": "user", "text": message.text, "time": datetime.now().isoformat()})
save_tickets(tickets_data)
ticket = tickets_data[ticket_id]
admin_text = (
f"📩 Новое сообщение в тикете №{ticket['ticket_number']}\n"
f"👤 От: {message.from_user.full_name}\n"
f"💬 Сообщение:\n{escape_html(message.text)}"
)
try:
kb = InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="👁 Подробно", callback_data=f"adm_detail_{ticket_id}")],
[InlineKeyboardButton(text="✍️ Ответить", callback_data=f"adm_reply_{ticket_id}")],
[InlineKeyboardButton(text="🔴 Закрыть", callback_data=f"close_ticket_{ticket_id}")]
])
await bot.send_message(ADMIN_ID, admin_text, parse_mode="HTML", reply_markup=kb)
except Exception as e:
logger.error(f"Ошибка уведомления админа: {e}")
await message.answer("✅ Ответ отправлен администратору.")
await state.clear()
await message.answer("Нажмите 📂 Открытые тикеты, чтобы увидеть переписку.", reply_markup=get_main_keyboard(message.from_user.id))
# --- АДМИН ОТВЕТ ---
@router.callback_query(F.data.startswith("adm_reply_"))
async def admin_reply_start(callback: types.CallbackQuery, state: FSMContext):
if callback.from_user.id != ADMIN_ID: return
ticket_id = callback.data.split("_")[-1]
await state.update_data(reply_ticket_id=ticket_id)
await state.set_state(TicketStates.admin_reply)
try:
await callback.message.edit_text("📝 Введите ответ:", reply_markup=get_back_keyboard())
except TelegramBadRequest:
pass
await callback.answer()
@router.message(TicketStates.admin_reply)
async def admin_reply_send(message: types.Message, state: FSMContext):
if message.from_user.id != ADMIN_ID: return
data = await state.get_data()
ticket_id = data.get("reply_ticket_id")
if not ticket_id: return
tickets_data = load_tickets()
if ticket_id not in tickets_data:
await message.answer("Тикет не найден")
await state.clear()
return
tickets_data[ticket_id]["messages"].append({"sender": "admin", "text": message.text, "time": datetime.now().isoformat()})
save_tickets(tickets_data)
try:
user_id = tickets_data[ticket_id]["user_id"]
await bot.send_message(user_id, f"📩 Ответ поддержки:\n\n{message.text}", parse_mode="HTML")
await message.answer("✅ Ответ отправлен")
except Exception as e:
await message.answer(f"❌ Ошибка отправки: {e}")
logger.error(f"Ошибка отправки админа юзеру: {e}")
await state.clear()
# --- ЗАКРЫТИЕ ТИКЕТА ---
@router.callback_query(F.data.startswith("close_ticket_"))
async def close_ticket_action(callback: types.CallbackQuery):
tid = callback.data.split("_")[-1]
tickets_data = load_tickets()
if tid not in tickets_data or not isinstance(tickets_data[tid], dict):
await callback.answer("Тикет не найден")
return
ticket = tickets_data[tid]
if callback.from_user.id != ADMIN_ID and callback.from_user.id != ticket["user_id"]:
await callback.answer("Нет прав на закрытие этого тикета", show_alert=True)
return
if ticket["status"] != "open":
await callback.answer("Тикет уже закрыт", show_alert=True)
return
tickets_data[tid]["status"] = "closed"
save_tickets(tickets_data)
await callback.answer("Тикет закрыт")
try:
if callback.message.reply_markup:
await callback.message.edit_reply_markup(reply_markup=None)
else:
await user_list_open(callback)
except:
pass
# --- АДМИН ТИКЕТЫ ---
@router.callback_query(F.data == "admin_ticket_list_menu")
async def admin_ticket_menu(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
try:
await callback.message.edit_text("📋 Управление тикетами", parse_mode="HTML", reply_markup=get_admin_ticket_control_keyboard())
except TelegramBadRequest:
pass
await callback.answer()
@router.callback_query(F.data.startswith("admin_view_"))
async def admin_view_tickets_list(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
status_filter = "open" if "open" in callback.data else "closed"
tickets_data = load_tickets()
real_tickets = {k:v for k,v in tickets_data.items() if isinstance(v, dict) and k != "last_ticket_id"}
text = f"{'Открытые' if status_filter == 'open' else 'Завершённые'} тикеты:\n\n"
kb = []
sorted_tickets = sorted(real_tickets.items(), key=lambda x: x[1].get('ticket_number', 0), reverse=True)
for tid, t in sorted_tickets:
if t["status"] != status_filter: continue
user_info = f"ID: {t['user_id']}"
last_msg = t["messages"][-1]["text"] if t["messages"] else "..."
text += f"🆔 №{t['ticket_number']} ({user_info})\n{escape_html(last_msg[:50])}...\n\n"
buttons_row = []
if status_filter == "open":
buttons_row.append(InlineKeyboardButton(text="👁 Подробно", callback_data=f"adm_detail_{tid}"))
buttons_row.append(InlineKeyboardButton(text=f"✍️ Ответить", callback_data=f"adm_reply_{tid}"))
buttons_row.append(InlineKeyboardButton(text=f"🔴 Закрыть", callback_data=f"close_ticket_{tid}"))
else:
# Если тикет закрыт, добавляем кнопку удаления
buttons_row.append(InlineKeyboardButton(text="👁 Подробно", callback_data=f"adm_detail_{tid}"))
buttons_row.append(InlineKeyboardButton(text="🗑 Удалить", callback_data=f"delete_ticket_{tid}"))
if buttons_row:
kb.append(buttons_row)
kb.append([InlineKeyboardButton(text="🔙 Назад", callback_data="admin_ticket_list_menu")])
try:
await callback.message.edit_text(text, parse_mode="HTML", reply_markup=InlineKeyboardMarkup(inline_keyboard=kb))
except TelegramBadRequest:
pass
await callback.answer()
# --- АДМИН ИНСТРУМЕНТЫ ---
@router.callback_query(F.data == "admin_tools_menu")
async def admin_tools_menu(callback: types.CallbackQuery):
if callback.from_user.id != ADMIN_ID: return
try:
await callback.message.edit_text("⚙️ Функции администратора", parse_mode="HTML", reply_markup=get_admin_menu_keyboard())
except TelegramBadRequest:
pass
await callback.answer()
# --- ЗАПУСК ---
async def main():
dp.include_router(router)
await bot.delete_webhook(drop_pending_updates=True)
logger.info("Бот запускается...")
try:
await bot.send_message(
ADMIN_ID,
f"✅ Бот RemnaSupport {BOT_VERSION} успешно запущен\n"
f"Автор: M.GMHOST.RU",
parse_mode="HTML"
)
except Exception as e:
logger.warning(f"Не удалось отправить стартовое сообщение админу: {e}")
await dp.start_polling(bot)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Бот остановлен")
Когда все файлы созданы, выполните эту команду для запуска бота:
Чтобы увидеть, что происходит с ботом, используйте:
Чтобы выйти из просмотра логов, нажмите Ctrl + C.
Если вы изменили код или настройки, выполните эту команду для пересборки и перезапуска:
Если нужно удалить все тикеты и начать с нуля: