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="🖨 Получить логи"), ]