feat(admin): add admin panel and reminder system

This commit is contained in:
2026-07-30 19:46:16 +05:00
parent ecc006287b
commit e79624a13a
17 changed files with 582 additions and 148 deletions
+16 -11
View File
@@ -2,8 +2,9 @@
from aiogram import Dispatcher
from tgbot.routers import main_errors, main_missed, main_start
from tgbot.routers import channel_requests
from tgbot.routers.admin import admin_menu
from tgbot.routers.user import user_menu
from tgbot.routers.admin import admin_panel
from tgbot.utils.misc.bot_filters import IsAdmin, IsPrivate
@@ -13,8 +14,9 @@ def register_all_routers(dp: Dispatcher):
main_errors.router.message.filter(IsPrivate())
main_start.router.message.filter(IsPrivate())
user_menu.router.message.filter(IsPrivate())
user_menu.router.callback_query.filter(IsPrivate())
# Панель /admin — только для приватных чатов и только для админов
admin_panel.router.message.filter(IsPrivate(), IsAdmin())
admin_panel.router.callback_query.filter(IsPrivate(), IsAdmin())
admin_menu.router.message.filter(IsPrivate(), IsAdmin())
admin_menu.router.callback_query.filter(IsPrivate(), IsAdmin())
@@ -22,13 +24,16 @@ def register_all_routers(dp: Dispatcher):
main_missed.router.message.filter(IsPrivate())
main_missed.router.callback_query.filter(IsPrivate())
# Базовые роутеры, которые нужны всегда
dp.include_router(main_errors.router) # Ошибки
dp.include_router(main_start.router) # Старт и главное меню
# channel_requests не фильтруем по IsPrivate — заявки приходят от каналов
dp.include_router(channel_requests.router) # ChatJoinRequest
# Роутеры для пользователей и админов
dp.include_router(user_menu.router) # Пользовательские обработчики
dp.include_router(admin_menu.router) # Админские обработчики
# Базовые роутеры
dp.include_router(main_errors.router)
dp.include_router(main_start.router)
# Обработка всего, что не поймали выше
dp.include_router(main_missed.router) # Пропущенные сообщения и колбэки
# Пользователи и админы
dp.include_router(admin_panel.router) # /admin панель (раньше admin_menu)
dp.include_router(admin_menu.router) # утилиты: /db, /log, /clear_log
# Всё, что не поймали выше
dp.include_router(main_missed.router)
+7 -8
View File
@@ -4,15 +4,16 @@ from pathlib import Path
import aiofiles
from aiogram import Router, Bot, F
from aiogram.filters import Command
from aiogram.types import FSInputFile, Message, CallbackQuery
from aiogram.types import FSInputFile, Message
from aiogram.utils.media_group import MediaGroupBuilder
from tgbot.data.config import BOT_DATABASE_EXPORT, PATH_DATABASE, PATH_LOGS
from tgbot.database.db_users import UserModel
from tgbot.keyboards.inline_main import admin_finl
from tgbot.utils.const_functions import get_date
from tgbot.utils.misc.bot_models import FSM, ARS
from tgbot.utils.const_functions import _show_panel
router = Router(name=__name__)
LOGS_DIR = Path(PATH_LOGS).parent
SERVICE_LOG_FILES = (
@@ -21,14 +22,12 @@ SERVICE_LOG_FILES = (
)
# Кнопка админского меню
# ─── Команда /admin ──────────────────────────────────────────────────────────
@router.message(Command("admin"))
@router.message(F.text == "Админ-меню")
async def admin_button_inline(
message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel
):
async def cmd_admin(message: Message, state: FSM, **_) -> None:
await state.clear()
await message.answer("Инлайн-клавиатура админа", reply_markup=admin_finl())
await _show_panel(message)
# Отправка файла базы админам
+181
View File
@@ -0,0 +1,181 @@
# - *- coding: utf- 8 - *-
import html as _html
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, Message
from aiogram.utils.keyboard import InlineKeyboardBuilder
from tgbot.database.db_settings import SettingsRepository
from tgbot.utils.const_functions import ikb, _interval_label, _interval_kb, _show_panel
from tgbot.utils.misc.bot_models import FSM
router = Router(name=__name__)
ALLOWED_INTERVALS = (0, 5, 12, 24)
# ─── FSM ────────────────────────────────────────────────────────────────────
class AdminStates(StatesGroup):
wait_message = State() # ждём новое приветственное сообщение
wait_btn_text = State() # ждём текст inline-кнопки
wait_btn_link = State() # ждём ссылку inline-кнопки
# ─── Команда /admin ──────────────────────────────────────────────────────────
@router.message(Command("admin"))
async def cmd_admin(message: Message, state: FSM, **_) -> None:
await state.clear()
await _show_panel(message)
# ─── Главное меню (возврат из подменю) ───────────────────────────────────────
@router.callback_query(F.data == "adm:main")
async def adm_main(call: CallbackQuery, state: FSM, **_) -> None:
await state.clear()
await _show_panel(call)
# ─── Включить / выключить бота ───────────────────────────────────────────────
@router.callback_query(F.data == "adm:toggle")
async def adm_toggle(call: CallbackQuery, **_) -> None:
repo = SettingsRepository()
settings = await repo.get()
await repo.update(status_work=not settings.status_work)
await _show_panel(call)
# ─── Изменение приветственного сообщения ─────────────────────────────────────
@router.callback_query(F.data == "adm:set_msg")
async def adm_set_msg(call: CallbackQuery, state: FSM, **_) -> None:
await state.set_state(AdminStates.wait_message)
kb = InlineKeyboardBuilder()
kb.row(ikb("◀ Отмена", data="adm:cancel"))
await call.message.edit_text(
"📝 <b>Отправьте новое приветственное сообщение</b>\n\n"
"• Просто текст <b>или</b> фото с подписью (caption)\n"
"• HTML-разметка поддерживается\n\n"
"Чтобы отменить — нажмите кнопку ниже.",
reply_markup=kb.as_markup(),
)
await call.answer()
@router.message(AdminStates.wait_message)
async def adm_recv_message(message: Message, state: FSM, **_) -> None:
repo = SettingsRepository()
if message.photo:
await repo.update(
misc_message=message.caption or "",
misc_message_media=message.photo[-1].file_id,
)
elif message.text:
await repo.update(misc_message=message.text, misc_message_media=None)
else:
await message.answer("❌ Поддерживаются только текст и фото с подписью")
return
await state.clear()
settings = await repo.get()
await message.answer("✅ Сообщение сохранено")
await _show_panel(message, settings)
# ─── Интервал напоминаний ─────────────────────────────────────────────────────
@router.callback_query(F.data == "adm:interval")
async def adm_interval_menu(call: CallbackQuery, **_) -> None:
settings = await SettingsRepository().get()
await call.message.edit_text(
"🔔 <b>Интервал напоминаний</b>\n\n"
"Бот повторно отправит сообщение через выбранный промежуток.\n"
"<b>Выкл</b> — напоминания не отправляются.",
reply_markup=_interval_kb(settings.remind_interval),
)
await call.answer()
@router.callback_query(F.data.startswith("adm:int:"))
async def adm_set_interval(call: CallbackQuery, **_) -> None:
try:
hours = int(call.data.split(":")[2])
except (IndexError, ValueError):
await call.answer("Неверное значение", show_alert=True)
return
if hours not in ALLOWED_INTERVALS:
await call.answer("Неверное значение", show_alert=True)
return
await SettingsRepository().update(remind_interval=hours)
await call.answer(f"✅ Интервал: {_interval_label(hours)}")
await _show_panel(call)
# ─── Кнопка в приветственном сообщении ───────────────────────────────────────
@router.callback_query(F.data == "adm:set_btn")
async def adm_set_btn(call: CallbackQuery, state: FSM, **_) -> None:
await state.set_state(AdminStates.wait_btn_text)
kb = InlineKeyboardBuilder()
kb.row(ikb("◀ Отмена", data="adm:cancel"))
await call.message.edit_text(
"🔗 <b>Введите текст кнопки</b>\n\nНапример: <code>Перейти на сайт</code>",
reply_markup=kb.as_markup(),
)
await call.answer()
@router.message(AdminStates.wait_btn_text, F.text)
async def adm_recv_btn_text(message: Message, state: FSM, **_) -> None:
await state.update_data(btn_text=message.text.strip())
await state.set_state(AdminStates.wait_btn_link)
await message.answer(
"🔗 <b>Теперь введите ссылку для кнопки</b>\n\n"
"Например: <code>https://example.com</code>"
)
@router.message(AdminStates.wait_btn_link, F.text)
async def adm_recv_btn_link(message: Message, state: FSM, **_) -> None:
data = await state.get_data()
btn_text = data.get("btn_text", "")
btn_link = message.text.strip()
if not btn_link.startswith(("http://", "https://", "tg://")):
await message.answer(
"❌ Ссылка должна начинаться с <code>http://</code>, "
"<code>https://</code> или <code>tg://</code>"
)
return
repo = SettingsRepository()
await repo.update(misc_finl_text=btn_text, misc_finl_link=btn_link)
await state.clear()
settings = await repo.get()
await message.answer("✅ Кнопка сохранена")
await _show_panel(message, settings)
@router.callback_query(F.data == "adm:clr_btn")
async def adm_clr_btn(call: CallbackQuery, **_) -> None:
await SettingsRepository().update(misc_finl_text=None, misc_finl_link=None)
await call.answer("🗑 Кнопка удалена")
await _show_panel(call)
# ─── Отмена текущего действия ─────────────────────────────────────────────────
@router.callback_query(F.data == "adm:cancel")
async def adm_cancel(call: CallbackQuery, state: FSM, **_) -> None:
await state.clear()
await _show_panel(call)
+91
View File
@@ -0,0 +1,91 @@
# - *- coding: utf- 8 - *-
from aiogram import Bot, Router
from aiogram.exceptions import TelegramForbiddenError
from aiogram.types import ChatJoinRequest
from tgbot.database.db_pending import PendingRepository
from tgbot.database.db_settings import SettingsRepository
from tgbot.utils.const_functions import get_unix, smart_message
from tgbot.utils.misc.bot_logging import bot_logger
from tgbot.keyboards.inline_main import _build_msg_keyboard
router = Router(name=__name__)
@router.chat_join_request()
async def on_join_request(update: ChatJoinRequest, bot: Bot) -> None:
user_id = update.from_user.id
settings = await SettingsRepository().get()
if not settings.status_work:
return
if not settings.misc_message:
return
keyboard = _build_msg_keyboard(settings.misc_finl_text, settings.misc_finl_link)
try:
await smart_message(
bot=bot,
user_id=user_id,
text=settings.misc_message,
keyboard=keyboard,
photo=settings.misc_message_media,
)
except TelegramForbiddenError:
bot_logger.info("Пользователь %s заблокировал бота, пропускаем", user_id)
return
except Exception:
bot_logger.warning(
"Не удалось отправить сообщение пользователю %s", user_id, exc_info=True
)
return
if settings.remind_interval > 0:
await PendingRepository().add(user_id=user_id)
async def send_reminders(bot: Bot) -> None:
settings = await SettingsRepository().get()
if settings.remind_interval == 0 or not settings.misc_message:
return
interval_seconds = settings.remind_interval * 3600
now = get_unix()
pending_repo = PendingRepository()
pending_users = await pending_repo.get_all()
keyboard = _build_msg_keyboard(settings.misc_finl_text, settings.misc_finl_link)
for row in pending_users:
reference = row.last_remind_at if row.last_remind_at > 0 else row.joined_at
if now - reference < interval_seconds:
continue
try:
await smart_message(
bot=bot,
user_id=row.user_id,
text=settings.misc_message,
keyboard=keyboard,
photo=settings.misc_message_media,
)
await pending_repo.update_remind_at(user_id=row.user_id)
except TelegramForbiddenError:
await pending_repo.delete(user_id=row.user_id)
bot_logger.info(
"Пользователь %s заблокировал бота, удалён из очереди", row.user_id
)
except Exception:
bot_logger.warning(
"Не удалось отправить напоминание пользователю %s",
row.user_id,
exc_info=True,
)
+1 -1
View File
@@ -26,4 +26,4 @@ class MessageNotModifiedHandler(ErrorHandler):
)
return True
raise self.event
raise self.exception
+15 -10
View File
@@ -4,25 +4,30 @@ from aiogram.filters import Command
from aiogram.types import Message
from tgbot.database.db_users import UserModel
from tgbot.keyboards.reply_main import menu_frep
from tgbot.utils.const_functions import ded
from tgbot.database.db_settings import SettingsRepository
from tgbot.utils.const_functions import smart_message
from tgbot.utils.misc.bot_models import FSM, ARS
from tgbot.keyboards.inline_main import _build_msg_keyboard
router = Router(name=__name__)
# Главное меню пользователя
# @router.message(F.text.in_(("menu", "return", "start")))
# @router.message(Command(commands=["start"]))
@router.message(F.text.in_({"menu", "return", "start"}))
@router.message(Command(commands=["start", "menu"]))
async def main_start(
message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel
):
await state.clear()
await message.answer(
ded(f"""
🔸 Привет, {User.user_name}
🔸 Введи /start или /menu
"""),
reply_markup=menu_frep(message.from_user.id),
settings = await SettingsRepository().get()
keyboard = _build_msg_keyboard(settings.misc_finl_text, settings.misc_finl_link)
await smart_message(
bot=bot,
user_id=message.from_user.id,
text=settings.misc_message,
keyboard=keyboard,
photo=settings.misc_message_media,
)
View File
-34
View File
@@ -1,34 +0,0 @@
# - *- coding: utf- 8 - *-
from aiogram import Router, Bot, F
from aiogram.filters import Command
from aiogram.types import Message, CallbackQuery
from tgbot.database.db_users import UserModel
from tgbot.keyboards.inline_main import user_finl
from tgbot.keyboards.reply_main import menu_frep
from tgbot.utils.misc.bot_models import FSM, ARS
router = Router(name=__name__)
# Кнопка пользовательского меню
# @router.message(F.text == "Пользовательское меню")
async def user_button_inline(
message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel
):
await state.clear()
await message.answer("Инлайн-клавиатура пользователя", reply_markup=user_finl())
# Команда возврата в меню
# @router.message(Command(commands="menu"))
async def user_command_menu(
message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel
):
await state.clear()
await message.answer(
"Команда /menu открывает главное меню",
reply_markup=menu_frep(message.from_user.id),
)