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
+42 -20
View File
@@ -7,7 +7,13 @@ from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode 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.core import close_database
from tgbot.database.repository import prepare_database from tgbot.database.repository import prepare_database
from tgbot.middlewares import register_all_middlewares 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.bot_logging import bot_logger
from tgbot.utils.misc_functions import autobackup_admin, startup_notify from tgbot.utils.misc_functions import autobackup_admin, startup_notify
# Включаем мгновенный вывод print() без flush=True в каждом вызове
def configure_console_output() -> None: def configure_console_output() -> None:
for stream in (sys.stdout, sys.stderr): for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"): 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: if BOT_DATABASE_EXPORT:
BOT_SCHEDULER.add_job( BOT_SCHEDULER.add_job(
autobackup_admin, autobackup_admin,
@@ -42,6 +48,20 @@ async def scheduler_start(bot):
misfire_grace_time=60, 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: if not BOT_SCHEDULER.running:
BOT_SCHEDULER.start() BOT_SCHEDULER.start()
@@ -49,39 +69,41 @@ async def scheduler_start(bot):
# Запуск бота и базовой обвязки # Запуск бота и базовой обвязки
async def main(): async def main():
validate_bot_config() validate_bot_config()
await prepare_database() # Проверка готовности БД await prepare_database()
dp = Dispatcher() # Диспетчер событий dp = Dispatcher()
arSession = AsyncRequestSession() # Общая сессия aiohttp arSession = AsyncRequestSession()
bot = Bot( # Образ Бота bot = Bot(
token=BOT_TOKEN, token=BOT_TOKEN,
default=DefaultBotProperties( default=DefaultBotProperties(
parse_mode=ParseMode.HTML parse_mode=ParseMode.HTML, link_preview_is_disabled=True
), ),
) )
register_all_middlewares(dp) # Подключение мидлварей register_all_middlewares(dp)
register_all_routers(dp) # Подключение роутера register_all_routers(dp)
try: try:
await set_commands(bot) # Обновление команды в Telegram await set_commands(bot)
await startup_notify(bot) # Сообщаем админам о старте await startup_notify(bot)
await scheduler_start(bot) # Подключение задач по расписанию await scheduler_start(bot)
bot_info = await bot.get_me() bot_info = await bot.get_me()
bot_logger.info("Бот запущен: @%s", bot_info.username) 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.LIGHTBLUE_EX + "~~~~~ TG developer - @djimbox ~~~~~")
print(colorama.Fore.RESET) print(colorama.Fore.RESET)
if len(get_admins()) == 0: if len(get_admins()) == 0:
print("***** УКАЖИТЕ BOT_ADMIN_IDS В .env *****") print("***** УКАЖИТЕ BOT_ADMIN_IDS В .env *****")
await bot.delete_webhook() # Сбрасывание вебхука, если он был await bot.delete_webhook()
await bot.get_updates(offset=-1) # Чистка старых апдейтов await bot.get_updates(offset=-1)
# Запуск бота (polling режим)
await dp.start_polling( await dp.start_polling(
bot, bot,
arSession=arSession, arSession=arSession,
@@ -91,9 +113,9 @@ async def main():
if BOT_SCHEDULER.running: if BOT_SCHEDULER.running:
BOT_SCHEDULER.shutdown(wait=False) BOT_SCHEDULER.shutdown(wait=False)
await arSession.close() # Закрытие сессии aiohttp await arSession.close()
await bot.session.close() # Закрытие сессии API Telegram await bot.session.close()
await close_database() # Закрытие соединений с БД await close_database()
if __name__ == "__main__": if __name__ == "__main__":
+2 -2
View File
@@ -1,7 +1,7 @@
# - *- coding: utf- 8 - *- # - *- coding: utf- 8 - *-
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import List from typing import Any, List
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
from pydantic import AliasChoices, Field, field_validator from pydantic import AliasChoices, Field, field_validator
@@ -24,7 +24,7 @@ class Settings(BaseSettings):
bot_token: str = Field( bot_token: str = Field(
default="", validation_alias=AliasChoices("BOT_TOKEN", "bot_token") 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") default_factory=list, validation_alias=AliasChoices("BOT_ADMIN_IDS", "admin_id")
) )
database_export: bool = Field( database_export: bool = Field(
+2
View File
@@ -1,5 +1,7 @@
from .db_pending import PendingModel, PendingRepository
from .db_settings import SettingsModel, SettingsRepository from .db_settings import SettingsModel, SettingsRepository
from .db_users import UserModel, UsersRepository from .db_users import UserModel, UsersRepository
ModelSettings = SettingsModel ModelSettings = SettingsModel
ModelUsers = UserModel ModelUsers = UserModel
ModelPending = PendingModel
+53
View File
@@ -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())
)
+7
View File
@@ -18,6 +18,10 @@ class SettingsModel(Base):
status_notify: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) 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: 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( misc_finl_text: Mapped[str] = mapped_column(
String(255), nullable=True, default=None String(255), nullable=True, default=None
) )
@@ -25,6 +29,9 @@ class SettingsModel(Base):
String(255), nullable=True, default=None String(255), nullable=True, default=None
) )
# Интервал напоминаний в часах: 0 = выключены, допустимые значения: 5, 12, 24
remind_interval: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
ModelBase = SettingsModel ModelBase = SettingsModel
BaseModel = SettingsModel BaseModel = SettingsModel
+29 -3
View File
@@ -87,20 +87,46 @@ class BaseRepository(Generic[ModelTranslator]):
) )
# Готовим подключение к БД. Сами миграции запускаются отдельно через migrate.py. # Готовим подключение к БД: создаём таблицы и накатываем недостающие колонки.
async def prepare_database() -> None: async def prepare_database() -> None:
database_path.parent.mkdir(parents=True, exist_ok=True) database_path.parent.mkdir(parents=True, exist_ok=True)
# Импортируем модели, чтобы репозитории работали с уже загруженными таблицами # Импортируем модели, чтобы Base.metadata знала обо всех таблицах
import tgbot.database # noqa: F401 import tgbot.database # noqa: F401
from tgbot.database.core import Base, engine
from tgbot.database.db_settings import SettingsRepository 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: try:
await SettingsRepository().ensure_default() await SettingsRepository().ensure_default()
except SQLAlchemyError as error: except SQLAlchemyError as error:
raise RuntimeError( raise RuntimeError(
"База данных не готова. Запусти миграции командой: python migrate.py" "База данных не готова — проверь подключение к SQLite"
) from error ) from error
bot_logger.info("База данных готова") 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
+6 -28
View File
@@ -5,31 +5,9 @@ from aiogram.utils.keyboard import InlineKeyboardBuilder
from tgbot.utils.const_functions import ikb from tgbot.utils.const_functions import ikb
# Инлайн-клавиатура для пользователя def _build_msg_keyboard(finl_text, finl_link):
def user_finl() -> InlineKeyboardMarkup: if finl_text and finl_link:
keyboard = InlineKeyboardBuilder() kb = InlineKeyboardBuilder()
kb.row(ikb(finl_text, url=finl_link))
keyboard.row( return kb.as_markup()
ikb("Действие", data="user_inline_x"), return None
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()
+16 -11
View File
@@ -2,8 +2,9 @@
from aiogram import Dispatcher from aiogram import Dispatcher
from tgbot.routers import main_errors, main_missed, main_start 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.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 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_errors.router.message.filter(IsPrivate())
main_start.router.message.filter(IsPrivate()) main_start.router.message.filter(IsPrivate())
user_menu.router.message.filter(IsPrivate()) # Панель /admin — только для приватных чатов и только для админов
user_menu.router.callback_query.filter(IsPrivate()) 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.message.filter(IsPrivate(), IsAdmin())
admin_menu.router.callback_query.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.message.filter(IsPrivate())
main_missed.router.callback_query.filter(IsPrivate()) main_missed.router.callback_query.filter(IsPrivate())
# Базовые роутеры, которые нужны всегда # channel_requests не фильтруем по IsPrivate — заявки приходят от каналов
dp.include_router(main_errors.router) # Ошибки dp.include_router(channel_requests.router) # ChatJoinRequest
dp.include_router(main_start.router) # Старт и главное меню
# Роутеры для пользователей и админов # Базовые роутеры
dp.include_router(user_menu.router) # Пользовательские обработчики dp.include_router(main_errors.router)
dp.include_router(admin_menu.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 import aiofiles
from aiogram import Router, Bot, F from aiogram import Router, Bot, F
from aiogram.filters import Command 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 aiogram.utils.media_group import MediaGroupBuilder
from tgbot.data.config import BOT_DATABASE_EXPORT, PATH_DATABASE, PATH_LOGS from tgbot.data.config import BOT_DATABASE_EXPORT, PATH_DATABASE, PATH_LOGS
from tgbot.database.db_users import UserModel 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.const_functions import get_date
from tgbot.utils.misc.bot_models import FSM, ARS from tgbot.utils.misc.bot_models import FSM, ARS
from tgbot.utils.const_functions import _show_panel
router = Router(name=__name__) router = Router(name=__name__)
LOGS_DIR = Path(PATH_LOGS).parent LOGS_DIR = Path(PATH_LOGS).parent
SERVICE_LOG_FILES = ( SERVICE_LOG_FILES = (
@@ -21,14 +22,12 @@ SERVICE_LOG_FILES = (
) )
# Кнопка админского меню # ─── Команда /admin ──────────────────────────────────────────────────────────
@router.message(Command("admin"))
@router.message(F.text == "Админ-меню") @router.message(F.text == "Админ-меню")
async def admin_button_inline( async def cmd_admin(message: Message, state: FSM, **_) -> None:
message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel
):
await state.clear() await state.clear()
await _show_panel(message)
await message.answer("Инлайн-клавиатура админа", reply_markup=admin_finl())
# Отправка файла базы админам # Отправка файла базы админам
+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 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 aiogram.types import Message
from tgbot.database.db_users import UserModel from tgbot.database.db_users import UserModel
from tgbot.keyboards.reply_main import menu_frep from tgbot.database.db_settings import SettingsRepository
from tgbot.utils.const_functions import ded from tgbot.utils.const_functions import smart_message
from tgbot.utils.misc.bot_models import FSM, ARS from tgbot.utils.misc.bot_models import FSM, ARS
from tgbot.keyboards.inline_main import _build_msg_keyboard
router = Router(name=__name__) router = Router(name=__name__)
# Главное меню пользователя # Главное меню пользователя
# @router.message(F.text.in_(("menu", "return", "start"))) @router.message(F.text.in_({"menu", "return", "start"}))
# @router.message(Command(commands=["start"])) @router.message(Command(commands=["start", "menu"]))
async def main_start( async def main_start(
message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel message: Message, bot: Bot, state: FSM, arSession: ARS, User: UserModel
): ):
await state.clear() await state.clear()
await message.answer( settings = await SettingsRepository().get()
ded(f""" keyboard = _build_msg_keyboard(settings.misc_finl_text, settings.misc_finl_link)
🔸 Привет, {User.user_name}
🔸 Введи /start или /menu await smart_message(
"""), bot=bot,
reply_markup=menu_frep(message.from_user.id), 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),
)
+129 -29
View File
@@ -8,10 +8,19 @@ from datetime import datetime
from typing import List, Optional, Union from typing import List, Optional, Union
from aiogram import Bot from aiogram import Bot
from aiogram.types import (InlineKeyboardButton, KeyboardButton, WebAppInfo, Message, InlineKeyboardMarkup, from aiogram.utils.keyboard import InlineKeyboardBuilder
ReplyKeyboardMarkup) from aiogram.types import (
InlineKeyboardButton,
InlineKeyboardMarkup,
WebAppInfo,
Message,
KeyboardButton,
ReplyKeyboardMarkup,
CallbackQuery,
)
from pytz import timezone from pytz import timezone
from tgbot.database.db_settings import SettingsRepository
from tgbot.data.config import get_admins, BOT_TIMEZONE from tgbot.data.config import get_admins, BOT_TIMEZONE
from tgbot.utils.misc.bot_logging import bot_logger from tgbot.utils.misc.bot_logging import bot_logger
@@ -24,11 +33,11 @@ def rkb(text: str) -> KeyboardButton:
# Быстрая сборка инлайн-кнопки # Быстрая сборка инлайн-кнопки
def ikb( def ikb(
text: str, text: str,
data: Optional[str] = None, data: Optional[str] = None,
url: Optional[str] = None, url: Optional[str] = None,
switch: Optional[str] = None, switch: Optional[str] = None,
web: Optional[str] = None, web: Optional[str] = None,
) -> InlineKeyboardButton: ) -> InlineKeyboardButton:
if data is not None: if data is not None:
return InlineKeyboardButton(text=text, callback_data=data) return InlineKeyboardButton(text=text, callback_data=data)
@@ -52,11 +61,11 @@ async def del_message(message: Message):
# Отправка текста с фото, если оно передано или обычным сообщением # Отправка текста с фото, если оно передано или обычным сообщением
async def smart_message( async def smart_message(
bot: Bot, bot: Bot,
user_id: int, user_id: int,
text: str, text: str,
keyboard: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None, keyboard: Optional[Union[InlineKeyboardMarkup, ReplyKeyboardMarkup]] = None,
photo: Optional[str] = None, photo: Optional[str] = None,
): ):
if photo is not None and photo.title() != "None": if photo is not None and photo.title() != "None":
await bot.send_photo( 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, disable_web_page_preview=True,
) )
except Exception: 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]: 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 еще и время # Возвращает текущую дату, при full=True еще и время
@@ -137,11 +148,17 @@ def convert_date(from_time, full=True, second=True) -> Union[str, int]:
if from_time.isdigit(): if from_time.isdigit():
from_timestamp = int(from_time) from_timestamp = int(from_time)
if full: 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: 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: 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: else:
parts = from_time.split() 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: def convert_times(get_time: int, get_type: str = "day") -> str:
get_time = int(get_time) get_time = int(get_time)
if get_time < 0: get_time = 0 if get_time < 0:
get_time = 0
if get_type == "second": if get_type == "second":
get_list = ['секунда', 'секунды', 'секунд'] get_list = ["секунда", "секунды", "секунд"]
elif get_type == "minute": elif get_type == "minute":
get_list = ['минута', 'минуты', 'минут'] get_list = ["минута", "минуты", "минут"]
elif get_type == "hour": elif get_type == "hour":
get_list = ['час', 'часа', 'часов'] get_list = ["час", "часа", "часов"]
elif get_type == "day": elif get_type == "day":
get_list = ['день', 'дня', 'дней'] get_list = ["день", "дня", "дней"]
elif get_type == "month": elif get_type == "month":
get_list = ['месяц', 'месяца', 'месяцев'] get_list = ["месяц", "месяца", "месяцев"]
else: else:
get_list = ['год', 'года', 'лет'] get_list = ["год", "года", "лет"]
if get_time % 10 == 1 and get_time % 100 != 11: if get_time % 10 == 1 and get_time % 100 != 11:
count = 0 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: def is_bool(value: Union[bool, str, int]) -> bool:
value = str(value).strip().lower() 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 return True
elif value in ('n', 'no', 'f', 'false', 'off', '0'): elif value in ("n", "no", "f", "false", "off", "0"):
return False return False
else: else:
raise ValueError(f"Некорректное bool-значение: {value}") 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] str_amount = str_amount[:remains_save]
if "." in str(str_amount): 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) return str(str_amount)
@@ -299,7 +319,8 @@ def is_number(get_number: Union[str, int, float]) -> bool:
if str(get_number).isdigit(): if str(get_number).isdigit():
return True return True
else: else:
if "," in str(get_number): get_number = str(get_number).replace(",", ".") if "," in str(get_number):
get_number = str(get_number).replace(",", ".")
try: try:
float(get_number) float(get_number)
@@ -318,3 +339,82 @@ def format_rate(amount: Union[float, int], around: int = 2) -> str:
response = response.rstrip("0").rstrip(".") response = response.rstrip("0").rstrip(".")
return response 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 (
"⚙️ <b>Панель управления</b>\n\n"
f"<b>🤖 Статус бота</b>: {status}\n"
f"<b>🔔 Напоминания</b>: {remind}\n"
f"<b>📝 Сообщение</b>: <blockquote>{msg_info}\n</blockquote>\n"
f"<b>🔗 Кнопка</b>: <i>{btn_info}</i>"
)
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)
+1 -2
View File
@@ -8,13 +8,12 @@ from tgbot.utils.misc.bot_logging import bot_logger
# Команды для обычных пользователей # Команды для обычных пользователей
user_commands = [ user_commands = [
BotCommand(command="start", description="♻️ Перезапуск бота"), BotCommand(command="start", description="♻️ Перезапуск бота"),
# BotCommand(command="menu", description="🌀 Получение клавиатуры"),
] ]
# Команды для админов # Команды для админов
admin_commands = [ admin_commands = [
BotCommand(command="start", description="♻️ Перезапуск бота"), BotCommand(command="start", description="♻️ Перезапуск бота"),
# BotCommand(command="menu", description="🌀 Получение клавиатуры"), BotCommand(command="admin", description="⚙️ Панель управления"),
BotCommand(command="log", description="🖨 Получить логи"), BotCommand(command="log", description="🖨 Получить логи"),
] ]