From e79624a13a5ce0582b85d239d3728a579aea9bf5 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 30 Jul 2026 19:46:16 +0500 Subject: [PATCH] feat(admin): add admin panel and reminder system --- main.py | 62 ++++++---- tgbot/data/config.py | 4 +- tgbot/database/__init__.py | 2 + tgbot/database/db_pending.py | 53 +++++++++ tgbot/database/db_settings.py | 7 ++ tgbot/database/repository.py | 32 ++++- tgbot/keyboards/inline_main.py | 34 +----- tgbot/routers/__init__.py | 27 +++-- tgbot/routers/admin/admin_menu.py | 15 ++- tgbot/routers/admin/admin_panel.py | 181 +++++++++++++++++++++++++++++ tgbot/routers/channel_requests.py | 91 +++++++++++++++ tgbot/routers/main_errors.py | 2 +- tgbot/routers/main_start.py | 25 ++-- tgbot/routers/user/__init__.py | 0 tgbot/routers/user/user_menu.py | 34 ------ tgbot/utils/const_functions.py | 158 ++++++++++++++++++++----- tgbot/utils/misc/bot_commands.py | 3 +- 17 files changed, 582 insertions(+), 148 deletions(-) create mode 100644 tgbot/database/db_pending.py create mode 100644 tgbot/routers/admin/admin_panel.py create mode 100644 tgbot/routers/channel_requests.py delete mode 100644 tgbot/routers/user/__init__.py delete mode 100644 tgbot/routers/user/user_menu.py diff --git a/main.py b/main.py index d33799b..f6e6dc9 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,13 @@ from aiogram import Bot, Dispatcher from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode -from tgbot.data.config import BOT_DATABASE_EXPORT, BOT_TOKEN, BOT_SCHEDULER, get_admins, validate_bot_config +from tgbot.data.config import ( + BOT_DATABASE_EXPORT, + BOT_TOKEN, + BOT_SCHEDULER, + get_admins, + validate_bot_config, +) from tgbot.database.core import close_database from tgbot.database.repository import prepare_database from tgbot.middlewares import register_all_middlewares @@ -17,7 +23,7 @@ from tgbot.utils.misc.bot_commands import set_commands from tgbot.utils.misc.bot_logging import bot_logger from tgbot.utils.misc_functions import autobackup_admin, startup_notify -# Включаем мгновенный вывод print() без flush=True в каждом вызове + def configure_console_output() -> None: for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): @@ -29,7 +35,7 @@ colorama.init() # Запуск задач по расписанию -async def scheduler_start(bot): +async def scheduler_start(bot: Bot) -> None: if BOT_DATABASE_EXPORT: BOT_SCHEDULER.add_job( autobackup_admin, @@ -42,6 +48,20 @@ async def scheduler_start(bot): misfire_grace_time=60, ) + # Проверяем очередь напоминаний каждые 30 минут + from tgbot.routers.channel_requests import send_reminders + + BOT_SCHEDULER.add_job( + send_reminders, + trigger="interval", + minutes=30, + args=(bot,), + id="send_reminders", + replace_existing=True, + coalesce=True, + misfire_grace_time=120, + ) + if not BOT_SCHEDULER.running: BOT_SCHEDULER.start() @@ -49,39 +69,41 @@ async def scheduler_start(bot): # Запуск бота и базовой обвязки async def main(): validate_bot_config() - await prepare_database() # Проверка готовности БД + await prepare_database() - dp = Dispatcher() # Диспетчер событий - arSession = AsyncRequestSession() # Общая сессия aiohttp + dp = Dispatcher() + arSession = AsyncRequestSession() - bot = Bot( # Образ Бота + bot = Bot( token=BOT_TOKEN, default=DefaultBotProperties( - parse_mode=ParseMode.HTML + parse_mode=ParseMode.HTML, link_preview_is_disabled=True ), ) - register_all_middlewares(dp) # Подключение мидлварей - register_all_routers(dp) # Подключение роутера + register_all_middlewares(dp) + register_all_routers(dp) try: - await set_commands(bot) # Обновление команды в Telegram - await startup_notify(bot) # Сообщаем админам о старте - await scheduler_start(bot) # Подключение задач по расписанию + await set_commands(bot) + await startup_notify(bot) + await scheduler_start(bot) bot_info = await bot.get_me() bot_logger.info("Бот запущен: @%s", bot_info.username) - print(colorama.Fore.LIGHTYELLOW_EX + f"~~~~~ Бот запущен - @{bot_info.username} ~~~~~") + print( + colorama.Fore.LIGHTYELLOW_EX + + f"~~~~~ Бот запущен - @{bot_info.username} ~~~~~" + ) print(colorama.Fore.LIGHTBLUE_EX + "~~~~~ TG developer - @djimbox ~~~~~") print(colorama.Fore.RESET) if len(get_admins()) == 0: print("***** УКАЖИТЕ BOT_ADMIN_IDS В .env *****") - await bot.delete_webhook() # Сбрасывание вебхука, если он был - await bot.get_updates(offset=-1) # Чистка старых апдейтов + await bot.delete_webhook() + await bot.get_updates(offset=-1) - # Запуск бота (polling режим) await dp.start_polling( bot, arSession=arSession, @@ -91,9 +113,9 @@ async def main(): if BOT_SCHEDULER.running: BOT_SCHEDULER.shutdown(wait=False) - await arSession.close() # Закрытие сессии aiohttp - await bot.session.close() # Закрытие сессии API Telegram - await close_database() # Закрытие соединений с БД + await arSession.close() + await bot.session.close() + await close_database() if __name__ == "__main__": diff --git a/tgbot/data/config.py b/tgbot/data/config.py index 995a324..90b69c6 100644 --- a/tgbot/data/config.py +++ b/tgbot/data/config.py @@ -1,7 +1,7 @@ # - *- coding: utf- 8 - *- from functools import lru_cache from pathlib import Path -from typing import List +from typing import Any, List from apscheduler.schedulers.asyncio import AsyncIOScheduler from pydantic import AliasChoices, Field, field_validator @@ -24,7 +24,7 @@ class Settings(BaseSettings): bot_token: str = Field( default="", validation_alias=AliasChoices("BOT_TOKEN", "bot_token") ) - admin_ids: List[int] = Field( + admin_ids: Any = Field( default_factory=list, validation_alias=AliasChoices("BOT_ADMIN_IDS", "admin_id") ) database_export: bool = Field( diff --git a/tgbot/database/__init__.py b/tgbot/database/__init__.py index 7a5da8b..b7673e1 100644 --- a/tgbot/database/__init__.py +++ b/tgbot/database/__init__.py @@ -1,5 +1,7 @@ +from .db_pending import PendingModel, PendingRepository from .db_settings import SettingsModel, SettingsRepository from .db_users import UserModel, UsersRepository ModelSettings = SettingsModel ModelUsers = UserModel +ModelPending = PendingModel diff --git a/tgbot/database/db_pending.py b/tgbot/database/db_pending.py new file mode 100644 index 0000000..b5a8abc --- /dev/null +++ b/tgbot/database/db_pending.py @@ -0,0 +1,53 @@ +# - *- coding: utf- 8 - *- +from sqlalchemy import BigInteger, Integer +from sqlalchemy import update as sqlalchemy_update +from sqlalchemy.dialects.sqlite import insert +from sqlalchemy.orm import Mapped, mapped_column + +from tgbot.database.core import Base, session_scope +from tgbot.database.repository import BaseRepository +from tgbot.utils.const_functions import get_unix + + +# Модель для хранения пользователей, которым бот отправил приветствие +# и которые ещё не вышли из очереди напоминаний +class PendingModel(Base): + __tablename__ = "storage_pending" + + increment: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False, unique=True, index=True) + joined_at: Mapped[int] = mapped_column(Integer, nullable=False, default=get_unix) + last_remind_at: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + + +ModelBase = PendingModel +BaseModel = PendingModel + + +# Репозиторий для управления очередью напоминаний +class PendingRepository(BaseRepository[PendingModel]): + def __init__(self): + super().__init__() + self.table_model = PendingModel + self.storage_name = PendingModel.__tablename__ + + # Добавляем пользователя в очередь, если его ещё нет + async def add(self, user_id: int) -> None: + statement = insert(PendingModel).values( + user_id=user_id, + joined_at=get_unix(), + last_remind_at=0, + ) + statement = statement.on_conflict_do_nothing(index_elements=[PendingModel.user_id]) + + async with session_scope() as session: + await session.execute(statement) + + # Обновляем время последнего напоминания + async def update_remind_at(self, user_id: int) -> None: + async with session_scope() as session: + await session.execute( + sqlalchemy_update(PendingModel) + .where(PendingModel.user_id == user_id) + .values(last_remind_at=get_unix()) + ) diff --git a/tgbot/database/db_settings.py b/tgbot/database/db_settings.py index 78d5792..d1b6085 100644 --- a/tgbot/database/db_settings.py +++ b/tgbot/database/db_settings.py @@ -18,6 +18,10 @@ class SettingsModel(Base): status_notify: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) misc_message: Mapped[str] = mapped_column(String(4096), nullable=True, default=None) + misc_message_media: Mapped[str] = mapped_column( + String(255), nullable=True, default=None + ) + misc_finl_text: Mapped[str] = mapped_column( String(255), nullable=True, default=None ) @@ -25,6 +29,9 @@ class SettingsModel(Base): String(255), nullable=True, default=None ) + # Интервал напоминаний в часах: 0 = выключены, допустимые значения: 5, 12, 24 + remind_interval: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + ModelBase = SettingsModel BaseModel = SettingsModel diff --git a/tgbot/database/repository.py b/tgbot/database/repository.py index 461341d..1cf50df 100644 --- a/tgbot/database/repository.py +++ b/tgbot/database/repository.py @@ -87,20 +87,46 @@ class BaseRepository(Generic[ModelTranslator]): ) -# Готовим подключение к БД. Сами миграции запускаются отдельно через migrate.py. +# Готовим подключение к БД: создаём таблицы и накатываем недостающие колонки. async def prepare_database() -> None: database_path.parent.mkdir(parents=True, exist_ok=True) - # Импортируем модели, чтобы репозитории работали с уже загруженными таблицами + # Импортируем модели, чтобы Base.metadata знала обо всех таблицах import tgbot.database # noqa: F401 + from tgbot.database.core import Base, engine from tgbot.database.db_settings import SettingsRepository + # Создаём все таблицы (если их ещё нет) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + # Мягкая миграция: добавляем колонку remind_interval если её нет + await _migrate_settings_columns() + try: await SettingsRepository().ensure_default() except SQLAlchemyError as error: raise RuntimeError( - "База данных не готова. Запусти миграции командой: python migrate.py" + "База данных не готова — проверь подключение к SQLite" ) from error bot_logger.info("База данных готова") + + +async def _migrate_settings_columns() -> None: + """Добавляет новые колонки в storage_settings для существующих БД.""" + from sqlalchemy import text + from tgbot.database.core import engine + + migrations = [ + "ALTER TABLE storage_settings ADD COLUMN remind_interval INTEGER NOT NULL DEFAULT 0", + ] + + async with engine.begin() as conn: + for sql in migrations: + try: + await conn.execute(text(sql)) + except Exception: + # Колонка уже существует — игнорируем + pass diff --git a/tgbot/keyboards/inline_main.py b/tgbot/keyboards/inline_main.py index f64b40f..6167ea2 100644 --- a/tgbot/keyboards/inline_main.py +++ b/tgbot/keyboards/inline_main.py @@ -5,31 +5,9 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder from tgbot.utils.const_functions import ikb -# Инлайн-клавиатура для пользователя -def user_finl() -> InlineKeyboardMarkup: - keyboard = InlineKeyboardBuilder() - - keyboard.row( - ikb("Действие", data="user_inline_x"), - ikb("Раздел", data="user_inline:user_btn"), - ikb("Скоро", data="..."), - ).row( - ikb("Неизвестная кнопка", data="unknown"), - ) - - return keyboard.as_markup() - - -# Инлайн-клавиатура для админа -def admin_finl() -> InlineKeyboardMarkup: - keyboard = InlineKeyboardBuilder() - - keyboard.row( - ikb("Действие", data="admin_inline_x"), - ikb("Раздел", data="admin_inline:admin_btn"), - ikb("Скоро", data="..."), - ).row( - ikb("Неизвестная кнопка", data="unknown"), - ) - - return keyboard.as_markup() +def _build_msg_keyboard(finl_text, finl_link): + if finl_text and finl_link: + kb = InlineKeyboardBuilder() + kb.row(ikb(finl_text, url=finl_link)) + return kb.as_markup() + return None diff --git a/tgbot/routers/__init__.py b/tgbot/routers/__init__.py index 19c780b..44f5dbd 100644 --- a/tgbot/routers/__init__.py +++ b/tgbot/routers/__init__.py @@ -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) diff --git a/tgbot/routers/admin/admin_menu.py b/tgbot/routers/admin/admin_menu.py index 5ecff4e..21c7e09 100644 --- a/tgbot/routers/admin/admin_menu.py +++ b/tgbot/routers/admin/admin_menu.py @@ -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) # Отправка файла базы админам diff --git a/tgbot/routers/admin/admin_panel.py b/tgbot/routers/admin/admin_panel.py new file mode 100644 index 0000000..2cbae9b --- /dev/null +++ b/tgbot/routers/admin/admin_panel.py @@ -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( + "📝 Отправьте новое приветственное сообщение\n\n" + "• Просто текст или фото с подписью (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( + "🔔 Интервал напоминаний\n\n" + "Бот повторно отправит сообщение через выбранный промежуток.\n" + "Выкл — напоминания не отправляются.", + 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( + "🔗 Введите текст кнопки\n\nНапример: Перейти на сайт", + 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( + "🔗 Теперь введите ссылку для кнопки\n\n" + "Например: https://example.com" + ) + + +@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( + "❌ Ссылка должна начинаться с http://, " + "https:// или tg://" + ) + 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) diff --git a/tgbot/routers/channel_requests.py b/tgbot/routers/channel_requests.py new file mode 100644 index 0000000..5b157f7 --- /dev/null +++ b/tgbot/routers/channel_requests.py @@ -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, + ) diff --git a/tgbot/routers/main_errors.py b/tgbot/routers/main_errors.py index 1da9f51..aa7fffd 100644 --- a/tgbot/routers/main_errors.py +++ b/tgbot/routers/main_errors.py @@ -26,4 +26,4 @@ class MessageNotModifiedHandler(ErrorHandler): ) return True - raise self.event + raise self.exception diff --git a/tgbot/routers/main_start.py b/tgbot/routers/main_start.py index 201eaeb..d7b10f7 100644 --- a/tgbot/routers/main_start.py +++ b/tgbot/routers/main_start.py @@ -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, ) diff --git a/tgbot/routers/user/__init__.py b/tgbot/routers/user/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tgbot/routers/user/user_menu.py b/tgbot/routers/user/user_menu.py deleted file mode 100644 index 0a92f10..0000000 --- a/tgbot/routers/user/user_menu.py +++ /dev/null @@ -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), - ) diff --git a/tgbot/utils/const_functions.py b/tgbot/utils/const_functions.py index f5b07ee..2de69f2 100644 --- a/tgbot/utils/const_functions.py +++ b/tgbot/utils/const_functions.py @@ -8,10 +8,19 @@ from datetime import datetime from typing import List, Optional, Union from aiogram import Bot -from aiogram.types import (InlineKeyboardButton, KeyboardButton, WebAppInfo, Message, InlineKeyboardMarkup, - ReplyKeyboardMarkup) +from aiogram.utils.keyboard import InlineKeyboardBuilder +from aiogram.types import ( + InlineKeyboardButton, + InlineKeyboardMarkup, + WebAppInfo, + Message, + KeyboardButton, + ReplyKeyboardMarkup, + CallbackQuery, +) from pytz import timezone +from tgbot.database.db_settings import SettingsRepository from tgbot.data.config import get_admins, BOT_TIMEZONE from tgbot.utils.misc.bot_logging import bot_logger @@ -24,11 +33,11 @@ def rkb(text: str) -> KeyboardButton: # Быстрая сборка инлайн-кнопки def ikb( - text: str, - data: Optional[str] = None, - url: Optional[str] = None, - switch: Optional[str] = None, - web: Optional[str] = None, + text: str, + data: Optional[str] = None, + url: Optional[str] = None, + switch: Optional[str] = None, + web: Optional[str] = None, ) -> InlineKeyboardButton: if data is not None: return InlineKeyboardButton(text=text, callback_data=data) @@ -52,11 +61,11 @@ async def del_message(message: Message): # Отправка текста с фото, если оно передано или обычным сообщением async def smart_message( - bot: Bot, - user_id: int, - text: str, - keyboard: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None, - photo: Optional[str] = None, + bot: Bot, + user_id: int, + text: str, + keyboard: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None, + photo: Optional[str] = None, ): if photo is not None and photo.title() != "None": await bot.send_photo( @@ -85,7 +94,9 @@ async def send_admins(bot: Bot, text: str, markup=None, not_me=0): disable_web_page_preview=True, ) except Exception: - bot_logger.warning("Не удалось отправить сообщение админу %s", admin, exc_info=True) + bot_logger.warning( + "Не удалось отправить сообщение админу %s", admin, exc_info=True + ) ################################## РАЗНОЕ ###################################### @@ -108,7 +119,7 @@ def clear_list(get_list: list) -> list: # Делит список на части нужного размера def split_list(get_list: list, count: int) -> List[list]: - return [get_list[i:i + count] for i in range(0, len(get_list), count)] + return [get_list[i : i + count] for i in range(0, len(get_list), count)] # Возвращает текущую дату, при full=True еще и время @@ -137,11 +148,17 @@ def convert_date(from_time, full=True, second=True) -> Union[str, int]: if from_time.isdigit(): from_timestamp = int(from_time) if full: - to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M:%S") + to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime( + "%d.%m.%Y %H:%M:%S" + ) elif second: - to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y %H:%M") + to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime( + "%d.%m.%Y %H:%M" + ) else: - to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime("%d.%m.%Y") + to_time = datetime.fromtimestamp(from_timestamp, bot_timezone).strftime( + "%d.%m.%Y" + ) else: parts = from_time.split() @@ -215,20 +232,21 @@ def gen_password(len_password: int = 16, type_password: str = "default") -> str: # Склоняет единицы времени под число def convert_times(get_time: int, get_type: str = "day") -> str: get_time = int(get_time) - if get_time < 0: get_time = 0 + if get_time < 0: + get_time = 0 if get_type == "second": - get_list = ['секунда', 'секунды', 'секунд'] + get_list = ["секунда", "секунды", "секунд"] elif get_type == "minute": - get_list = ['минута', 'минуты', 'минут'] + get_list = ["минута", "минуты", "минут"] elif get_type == "hour": - get_list = ['час', 'часа', 'часов'] + get_list = ["час", "часа", "часов"] elif get_type == "day": - get_list = ['день', 'дня', 'дней'] + get_list = ["день", "дня", "дней"] elif get_type == "month": - get_list = ['месяц', 'месяца', 'месяцев'] + get_list = ["месяц", "месяца", "месяцев"] else: - get_list = ['год', 'года', 'лет'] + get_list = ["год", "года", "лет"] if get_time % 10 == 1 and get_time % 100 != 11: count = 0 @@ -244,9 +262,9 @@ def convert_times(get_time: int, get_type: str = "day") -> str: def is_bool(value: Union[bool, str, int]) -> bool: value = str(value).strip().lower() - if value in ('y', 'yes', 't', 'true', 'on', '1'): + if value in ("y", "yes", "t", "true", "on", "1"): return True - elif value in ('n', 'no', 'f', 'false', 'off', '0'): + elif value in ("n", "no", "f", "false", "off", "0"): return False else: raise ValueError(f"Некорректное bool-значение: {value}") @@ -266,9 +284,11 @@ def snum(amount: Union[int, float], remains: int = 2) -> str: str_amount = str_amount[:remains_save] if "." in str(str_amount): - while str(str_amount).endswith('0'): str_amount = str(str_amount)[:-1] + while str(str_amount).endswith("0"): + str_amount = str(str_amount)[:-1] - if str(str_amount).endswith('.'): str_amount = str(str_amount)[:-1] + if str(str_amount).endswith("."): + str_amount = str(str_amount)[:-1] return str(str_amount) @@ -299,7 +319,8 @@ def is_number(get_number: Union[str, int, float]) -> bool: if str(get_number).isdigit(): return True else: - if "," in str(get_number): get_number = str(get_number).replace(",", ".") + if "," in str(get_number): + get_number = str(get_number).replace(",", ".") try: float(get_number) @@ -318,3 +339,82 @@ def format_rate(amount: Union[float, int], around: int = 2) -> str: response = response.rstrip("0").rstrip(".") return response + + +# ─── Вспомогательные функции ───────────────────────────────────────────────── + + +def _interval_label(hours: int) -> str: + labels = {0: "выкл", 5: "5 ч", 12: "12 ч", 24: "24 ч"} + return labels.get(hours, f"{hours} ч") + + +def _status_icon(flag: bool) -> str: + return "✅" if flag else "❌" + + +def _panel_text(s) -> str: + remind = _interval_label(s.remind_interval) + status = _status_icon(s.status_work) + + msg_info = s.misc_message or "не задано" + btn_info = ( + f"{s.misc_finl_text} → {s.misc_finl_link}" + if s.misc_finl_text and s.misc_finl_link + else "нет" + ) + + return ( + "⚙️ Панель управления\n\n" + f"🤖 Статус бота: {status}\n" + f"🔔 Напоминания: {remind}\n" + f"📝 Сообщение:
{msg_info}\n
\n" + f"🔗 Кнопка: {btn_info}" + ) + + +def _panel_kb(s) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + status_label = "🔴 Выключить бота" if s.status_work else "🟢 Включить бота" + kb.row(ikb(status_label, data="adm:toggle")) + kb.row(ikb("✏️ Изменить сообщение", data="adm:set_msg")) + kb.row(ikb("🔔 Интервал напоминаний", data="adm:interval")) + if s.misc_finl_text and s.misc_finl_link: + kb.row( + ikb("🔗 Изменить кнопку", data="adm:set_btn"), + ikb("🗑 Убрать кнопку", data="adm:clr_btn"), + ) + else: + kb.row(ikb("🔗 Добавить кнопку", data="adm:set_btn")) + kb.row(ikb("✖️ Закрыть", data="close_this")) + return kb.as_markup() + + +def _interval_kb(current: int) -> InlineKeyboardMarkup: + kb = InlineKeyboardBuilder() + for h in (5, 12, 24): + mark = "✔ " if current == h else "" + kb.add(ikb(f"{mark}{_interval_label(h)}", data=f"adm:int:{h}")) + mark = "✔ " if current == 0 else "" + kb.add(ikb(f"{mark}Выкл", data="adm:int:0")) + kb.adjust(3, 1) + kb.row(ikb("◀ Назад", data="adm:main")) + return kb.as_markup() + + +async def _show_panel(target, settings=None) -> None: + """Отправить или обновить панель (target — Message или CallbackQuery).""" + if settings is None: + settings = await SettingsRepository().get() + + text = _panel_text(settings) + kb = _panel_kb(settings) + + if isinstance(target, CallbackQuery): + try: + await target.message.edit_text(text, reply_markup=kb) + except Exception: + await target.message.answer(text, reply_markup=kb) + await target.answer() + else: + await target.answer(text, reply_markup=kb) diff --git a/tgbot/utils/misc/bot_commands.py b/tgbot/utils/misc/bot_commands.py index fbd4ab0..d44451a 100644 --- a/tgbot/utils/misc/bot_commands.py +++ b/tgbot/utils/misc/bot_commands.py @@ -8,13 +8,12 @@ from tgbot.utils.misc.bot_logging import bot_logger # Команды для обычных пользователей user_commands = [ BotCommand(command="start", description="♻️ Перезапуск бота"), - # BotCommand(command="menu", description="🌀 Получение клавиатуры"), ] # Команды для админов admin_commands = [ BotCommand(command="start", description="♻️ Перезапуск бота"), - # BotCommand(command="menu", description="🌀 Получение клавиатуры"), + BotCommand(command="admin", description="⚙️ Панель управления"), BotCommand(command="log", description="🖨 Получить логи"), ]