Первый коммит

This commit is contained in:
admin
2025-08-30 07:39:44 +07:00
commit d0baf76f8f
86 changed files with 7362 additions and 0 deletions

2
bot/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .core import *
from .handlers import *

2
bot/core/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
from .bots import *
from .webhook import *

260
bot/core/bots.py Normal file
View File

@@ -0,0 +1,260 @@
from datetime import datetime
from time import sleep
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.fsm.storage.memory import MemoryStorage
from aiogram.types import User, ChatAdministratorRights, BotDescription, BotShortDescription
from aiogram.utils.i18n import I18n, SimpleI18nMiddleware
from configs.config import BotSettings, BotEdit, Webhook, Permission
from middleware.loggers import log
# Экспортируем объекты модуля
__all__ = ("dp", "bot", "BotInfo", "i18n",)
# Диспетчер бота, языковых настроек и его хранилища
storage: MemoryStorage = MemoryStorage()
dp: Dispatcher = Dispatcher(storage=storage)
dp["is_active"]: bool = True
# Инициализация i18n
i18n: I18n = I18n(path="locales", default_locale="ru", domain="bot")
i18n_middleware: SimpleI18nMiddleware = SimpleI18nMiddleware(i18n=i18n)
i18n_middleware.setup(dp)
# Экземпляр бота с настройками по умолчанию
bot: Bot = Bot(token=BotSettings.BOT_TOKEN,
default=DefaultBotProperties(
parse_mode=BotSettings.PARSE_MODE,
disable_notification=BotSettings.DISABLE_NOTIFICATION,
protect_content=BotSettings.PROTECT_CONTENT,
allow_sending_without_reply=BotSettings.ALLOW_SENDING_WITHOUT_REPLY,
link_preview_is_disabled=BotSettings.LINK_PREVIEW_IS_DISABLED,
link_preview_prefer_small_media=BotSettings.LINK_PREVIEW_PREFER_SMALL_MEDIA,
link_preview_prefer_large_media=BotSettings.LINK_PREVIEW_PREFER_LARGE_MEDIA,
link_preview_show_above_text=BotSettings.LINK_PREVIEW_SHOW_ABOVE_TEXT,
show_caption_above_media=BotSettings.SHOW_CAPTION_ABOVE_MEDIA
)
)
class BotInfo:
"""Класс для хранения и инициализации данных бота."""
id: int = None
url: str = None
first_name: str = None
last_name: str = None
username: str = None
description: str = None
short_description: str = None
language_code: str = BotSettings.BOT_LANGUAGE
prefix: str = BotSettings.PREFIX
bot_owner: str = BotSettings.OWNER
added_to_attachment_menu: bool = False
supports_inline_queries: bool = False
can_connect_to_business: bool = False
has_main_web_app: bool = False
can_join_groups: bool = False
can_read_all_group_messages: bool = False
@classmethod
@log(level='INFO', log_type='BOT', text='Настройка вебхука бота')
async def webhook(cls, bots: Bot = bot, webhook_url: str = Webhook.WEBHOOK_URL,
use_webhook: bool = Webhook.WEBHOOK) -> None:
"""
Удаление или установка вебхука.
:param bots: Объект бота для управления.
:param use_webhook: Статус использования вебхука, поумолчанию (true).
:param webhook_url: Ссылка на вебхук.
"""
# Удаляем текущий вебхук
await bots.delete_webhook(drop_pending_updates=True)
# Если включен вебхук — устанавливаем новый
if use_webhook:
if webhook_url is None:
raise ValueError("Для установки вебхука необходимо указать webhook_url")
await bots.set_webhook(webhook_url)
@classmethod
@log(level='INFO', log_type='BOT', text='Получение информации о боте')
async def info(cls, bots: Bot = bot) -> dict:
"""
Получает и сохраняет информацию о боте.
:param bots: Объект бота для управления.
:return: Словарь с персональными данными о боте.
"""
bot_info: User = await bots.get_me()
cls.id = bot_info.id
cls.url = f'tg://user?id={cls.id}'
cls.first_name = bot_info.first_name
cls.last_name = bot_info.last_name
cls.username = bot_info.username
cls.description = getattr(bot_info, 'description', '')
cls.short_description = getattr(bot_info, 'short_description', '')
cls.language_code = bot_info.language_code
cls.is_premium = bot_info.is_premium
cls.added_to_attachment_menu = bot_info.added_to_attachment_menu
cls.supports_inline_queries = bot_info.supports_inline_queries
cls.can_connect_to_business = bot_info.can_connect_to_business
cls.has_main_web_app = bot_info.has_main_web_app
cls.can_join_groups = getattr(bot_info, 'can_join_groups', False)
cls.can_read_all_group_messages = getattr(bot_info, 'can_read_all_group_messages', False)
return {
'id': cls.id,
'url': cls.url,
'first_name': cls.first_name,
'last_name': cls.last_name,
'username': cls.username,
'description': cls.description,
'short_description': cls.short_description,
'language_code': cls.language_code,
'prefix': cls.prefix,
'bot_owner': cls.bot_owner,
'is_premium': cls.is_premium,
'added_to_attachment_menu': cls.added_to_attachment_menu,
'supports_inline_queries': cls.supports_inline_queries,
'can_connect_to_business': cls.can_connect_to_business,
'has_main_web_app': cls.has_main_web_app,
'can_join_groups': cls.can_join_groups,
'can_read_all_group_messages': cls.can_read_all_group_messages,
}
@staticmethod
@log(level='INFO', log_type='BOT', text='Установка прав администратора')
async def set_administrator_rights(bots: Bot = bot, rights: ChatAdministratorRights = BotEdit.RIGHTS) -> None:
"""
Устанавливает права администратора по умолчанию.
:param bots: Объект бота для управления.
:param rights: Заданные права администратора бота, по умолчанию словарь из конфигов.
"""
bot_rights: ChatAdministratorRights = await bots.get_my_default_administrator_rights()
if bot_rights != rights:
await bots.set_my_default_administrator_rights(rights)
@staticmethod
@log(level='INFO', log_type='BOT', text='Обновление имени бота')
async def set_name(bots: Bot = bot, new_name: str = BotEdit.NAME) -> None:
"""
Устанавливает имя бота из конфига.
:param bots: Объект бота для управления.
:param new_name: Новое имя бота, по умолчанию из конфигов.
"""
current_name: str = (await bots.get_me()).first_name
if not (1 <= len(new_name) <= 32):
raise ValueError("Имя бота должно быть от 1 до 32 символов.")
if current_name != new_name:
await bots.set_my_name(new_name)
@staticmethod
@log(level='INFO', log_type='BOT', text='Обновление описания бота')
async def set_description(bots: Bot = bot, new_description: str = BotEdit.DESCRIPTION) -> None:
"""
Устанавливает полное описание бота.
:param bots: Объект бота для управления.
:param new_description: Новое описание бота, по умолчанию из конфигов.
"""
current_description: BotDescription = await bots.get_my_description()
if not (0 < len(new_description) <= 255):
raise ValueError("Описание должно быть от 1 до 255 символов.")
if current_description != new_description:
await bots.set_my_description(description=new_description)
@staticmethod
@log(level='INFO', log_type='BOT', text='Обновление короткого описания бота')
async def set_short_description(bots: Bot = bot, new_short: str = BotEdit.SHORT_DESCRIPTION) -> None:
"""
Устанавливает короткое описание виджета.
:param bots: Объект бота для управления.
:param new_short: Новое короткое описание бота, по умолчанию из конфигов.
"""
current_short: BotShortDescription = await bots.get_my_short_description()
if not (0 < len(new_short) <= 512):
raise ValueError("Короткое описание должно быть от 1 до 512 символов.")
if current_short != new_short:
await bots.set_my_short_description(short_description=new_short)
@staticmethod
def start_info_out() -> str:
bot_time: str = f"Бот @{BotInfo.username} запущен в {datetime.now().strftime("%S:%M:%H %d-%m-%Y")}\n"
bot_name: str = f"Основное имя: {BotInfo.first_name}\n"
bot_postname: str = f" Доп. имя: {BotInfo.last_name}\n"
bot_username: str = f" Юзернейм: @{BotInfo.username}\n"
bot_id: str = f" ID: {BotInfo.id}\n"
bot_can_join_groups: str = f" Может ли вступать в группы: {BotInfo.can_join_groups}\n"
bot_can_read_all_group_messages: str = f" Чтение всех сообщений: {BotInfo.can_read_all_group_messages}\n"
bot_added_to_attachment_menu: str = f" Добавлен в меню вложений: {BotInfo.added_to_attachment_menu}\n"
bot_supports_inline_queries: str = f" Поддерживает инлайн-запросы: {BotInfo.supports_inline_queries}\n"
bot_can_connect_to_business: str = f" Подключение к бизнес-аккаунтам: {BotInfo.can_connect_to_business}\n"
bot_has_main_web_app: str = f" Основное веб-приложение: {BotInfo.has_main_web_app}\n"
# Формируем полный текст с выводом информации о боте
bot_all_info: str = (f"{bot_name} {bot_postname} {bot_username} {bot_id} "
f"{bot_can_join_groups} {bot_can_read_all_group_messages} "
f"{bot_added_to_attachment_menu} {bot_supports_inline_queries} {bot_can_connect_to_business} "
f"{bot_has_main_web_app}")
# Печатаем все данные в консоль с задержкой в 1 секунду
sleep(1)
print(f"\033[34m{bot_all_info}\033[0m")
# Записываем информацию в файл
try:
with open("Logs/info.log", 'w', encoding='utf-8') as log_file:
log_file.write(f"{bot_time}{bot_all_info}")
# Создание файла bot_start.log
with open("Logs/bot_start.log", 'a', encoding='utf-8') as log_start_file:
log_start_file.write(f"{bot_time}\n")
return bot_all_info
# Проверка на ошибку и ее логирование
except Exception as e:
error: str = f"Ошибка при получении ID пользователя: {e}"
raise error
@classmethod
@log(level='INFO', log_type='START', text='Процесс запуска бота!')
async def setup(cls, bots: Bot = bot, perm: bool = Permission.BOT_EDIT):
"""
Выполняет полную настройку бота.
:param perm: Разрешение на изменения бота.
:param bots: Объект бота для управления.
"""
await cls.webhook(bots=bots)
await cls.info(bots=bots)
if perm:
await cls.set_administrator_rights(bots=bots)
await cls.set_description(bots=bots)
await cls.set_short_description(bots=bots)
await cls.set_name(bots=bots)
cls.start_info_out()

37
bot/core/webhook.py Normal file
View File

@@ -0,0 +1,37 @@
from typing import Any
from fastapi import FastAPI, Request
from uvicorn import Config, Server
from aiogram.types import Update
from configs import Webhook
from .bots import dp, bot
# Настройки экспорта
__all__ = ("app", "config", "server",)
# Создаём FastAPI приложение
app: FastAPI = FastAPI()
# Создаём конфиг для uvicorn
config: Config = Config(
app="bot.core.webhook:app",
host=Webhook.WEBAPP_HOST,
port=Webhook.WEBAPP_PORT,
log_level=Webhook.LOG_LEVEL, # выводить только предупреждения и ошибки
access_log=Webhook.ACCES_LOG # <-- отключает все HTTP-access логи
)
# Создание вебхук-сервера
server: Server = Server(config)
@app.post("/webhook")
async def telegram_webhook(request: Request) -> dict[str, Any]:
"""
Обработчик POST-запроса от Telegram.
"""
update: Update = Update(**await request.json())
await dp.feed_update(bot, update)
return {"ok": True}

5
bot/filters/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .callback import *
from .chat_rights import *
from .chat_type import *
from .message_content import *
from .subscrided import *

21
bot/filters/callback.py Normal file
View File

@@ -0,0 +1,21 @@
from aiogram.filters import BaseFilter
from aiogram.types import CallbackQuery
# Настройка экспорта
__all__ = ("CallbackDataStartsWith",)
class CallbackDataStartsWith(BaseFilter):
"""
Фильтр для callback_data, начинающихся с префикса.
Example:
@router.callback_query(CallbackDataStartsWith("menu:"))
async def handler(cb: CallbackQuery):
await cb.answer("Это callback из меню ✅")
"""
def __init__(self, prefix: str) -> None:
self.prefix = prefix
async def __call__(self, callback: CallbackQuery) -> bool:
return bool(callback.data and callback.data.startswith(self.prefix))

View File

@@ -0,0 +1,73 @@
from aiogram import Bot
from aiogram.filters import BaseFilter
from aiogram.types import Message, ResultChatMemberUnion
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
# Настройка экспорта
__all__ = ("IsChatCreator", "IsAdmin", "IsModerator",)
class IsChatCreator(BaseFilter):
"""
Пользователь является создателем чата.
Example:
@router.message(IsChatCreator())
async def handler(msg: Message):
await msg.answer("Ты создатель этого чата 👑")
"""
async def __call__(self, message: Message, bot: Bot) -> bool:
try:
member: ResultChatMemberUnion = await bot.get_chat_member(message.chat.id, message.from_user.id)
return member.status == "creator"
except (TelegramBadRequest, TelegramForbiddenError):
return False
class IsAdmin(BaseFilter):
"""
Пользователь является администратором (или создателем).
Example:
@router.message(IsAdmin())
async def handler(msg: Message):
await msg.answer("Ты админ ✅")
"""
async def __call__(self, message: Message, bot: Bot) -> bool:
try:
member: ResultChatMemberUnion = await bot.get_chat_member(message.chat.id, message.from_user.id)
return member.status in {"administrator", "creator"}
except (TelegramBadRequest, TelegramForbiddenError):
return False
class IsModerator(BaseFilter):
"""
Администратор с модераторскими правами:
- удаление сообщений
- ограничение пользователей
- закрепление сообщений
Example:
@router.message(IsModerator())
async def handler(msg: Message):
await msg.answer("Ты модератор ✅")
"""
async def __call__(self, message: Message, bot: Bot) -> bool:
try:
member: ResultChatMemberUnion = await bot.get_chat_member(message.chat.id, message.from_user.id)
if member.status == "creator":
return True
if member.status != "administrator":
return False
required_rights: list[bool] = [
getattr(member, "can_delete_messages", False),
getattr(member, "can_restrict_members", False),
getattr(member, "can_pin_messages", False),
]
return all(required_rights)
except (TelegramBadRequest, TelegramForbiddenError):
return False

31
bot/filters/chat_type.py Normal file
View File

@@ -0,0 +1,31 @@
from aiogram.filters import BaseFilter
from aiogram.types import Message
# Настройка экспорта
__all__ = ("IsPrivate", "IsGroup",)
class IsPrivate(BaseFilter):
"""
Сообщение в личке с ботом.
Example:
@router.message(IsPrivate())
async def handler(msg: Message):
await msg.answer("Это ЛС ✅")
"""
async def __call__(self, message: Message) -> bool:
return message.chat.type == "private"
class IsGroup(BaseFilter):
"""
Сообщение в группе или супергруппе.
Example:
@router.message(IsGroup())
async def handler(msg: Message):
await msg.answer("Это сообщение в группе ✅")
"""
async def __call__(self, message: Message) -> bool:
return message.chat.type in {"group", "supergroup"}

View File

@@ -0,0 +1,67 @@
from aiogram.filters import BaseFilter
from aiogram.types import Message
# Настройка экспорта
__all__ = ("IsReply", "IsForwarded", "HasMedia", "ContainsURL",)
class IsReply(BaseFilter):
"""
Сообщение является ответом.
Example:
@router.message(IsReply())
async def handler(msg: Message):
await msg.answer("Это реплай ✅")
"""
async def __call__(self, message: Message) -> bool:
return message.reply_to_message is not None
class IsForwarded(BaseFilter):
"""
Сообщение переслано из другого чата/от пользователя.
Example:
@router.message(IsForwarded())
async def handler(msg: Message):
await msg.answer("Это пересланное сообщение 🔄")
"""
async def __call__(self, message: Message) -> bool:
return (message.forward_from is not None) or (message.forward_from_chat is not None)
class HasMedia(BaseFilter):
"""
Сообщение содержит медиа (фото, видео, документ и т.д.).
Example:
@router.message(HasMedia())
async def handler(msg: Message):
await msg.answer("Это медиа ✅")
"""
async def __call__(self, message: Message) -> bool:
return any([
message.photo,
message.video,
message.document,
message.audio,
message.voice,
message.video_note,
message.sticker,
])
class ContainsURL(BaseFilter):
"""
Сообщение содержит ссылку (http/https).
Example:
@router.message(ContainsURL())
async def handler(msg: Message):
await msg.answer("Это сообщение с ссылкой 🔗")
"""
async def __call__(self, message: Message) -> bool:
if not message.text:
return False
return "http://" in message.text or "https://" in message.text

39
bot/filters/subscrided.py Normal file
View File

@@ -0,0 +1,39 @@
from aiogram.types import Message, ResultChatMemberUnion
from aiogram.filters import BaseFilter
from aiogram import Bot
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from typing import Union
# Настройки экспорта
__all__ = ("FilterSubscribed",)
class FilterSubscribed(BaseFilter):
"""
Фильтр для проверки подписки пользователя на один или несколько каналов.
Поддерживает как публичные каналы (username), так и приватные (ID).
Пример:
# Проверка сразу двух каналов: публичный по username и приватный по ID
@router.message(FilterSubscribed(["@public_channel", -1001234567890]))
async def only_subscribed(message: Message):
await message.answer("Ты подписан и на публичный, и на приватный канал ✅")
"""
def __init__(self, channels: list[Union[str, int]]) -> None:
self.channels = channels
async def __call__(self, message: Message, bot: Bot) -> bool:
for channel in self.channels:
try:
member: ResultChatMemberUnion = await bot.get_chat_member(
chat_id=channel,
user_id=message.from_user.id
)
if member.status in ("left", "kicked"):
return False
except (TelegramBadRequest, TelegramForbiddenError):
# Канал недоступен, либо у бота нет прав
return False
return True

14
bot/handlers/__init__.py Normal file
View File

@@ -0,0 +1,14 @@
from aiogram import Router
from bot.handlers.commands import router as cmd_routers
from .messages import router as messages_routers
# Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name=__name__)
# Подключение роутеров
router.include_routers(
cmd_routers,
messages_routers,
)

View File

@@ -0,0 +1,13 @@
from aiogram import Router
from .admins import router as admin_cmd_router
from .users import router as users_cmd_router
# Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name=__name__)
# Подключение роутеров
router.include_routers(
admin_cmd_router,
users_cmd_router,
)

View File

@@ -0,0 +1,11 @@
from aiogram import Router
from .settings_cmd import router as settings_cmd_router
# Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name=__name__)
# Подключение роутеров
router.include_routers(
settings_cmd_router,
)

View File

@@ -0,0 +1,51 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.utils.i18n import gettext as _
from bot.templates import msg_photo
from bot.utils.interesting_facts import interesting_fact
from bot.core.bots import BotInfo
from configs import COMMANDS, RpValue
# Настройки экспорта и роутера
__all__ = ("router",)
CMD: str = "settings".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
@router.callback_query(F.data.lower() == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
async def start_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
"""Обработчик команды /start"""
await state.clear()
# Создание инлайн-клавиатуры
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(InlineKeyboardButton(text="Инфо-канал🗂", url=RpValue.INFO_URL))
ikb.row(InlineKeyboardButton(text="Вступление🚀", callback_data='new'),
InlineKeyboardButton(text="Анкета📖", callback_data='anketa'))
ikb.row(InlineKeyboardButton(text="Связь с администрацией🌐", callback_data='admin'))
# Формируем приветственное сообщение
text: str = _(
"""Добро пожаловать, <a href="{url}">{name}</a>!
Я ваш искусственный помощник по ролевой - <b>{rp_name}</b>!
Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще!
Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре!
Интересный факт:
<blockquote>{fact}</blockquote>
"""
).format(
url=message.from_user.url if message.from_user else "",
name=message.from_user.first_name if message.from_user else "пользователь",
rp_name=RpValue.RP_NAME,
fact=interesting_fact(),
)
# Отправляем сообщение
await msg_photo(message=message, text=text, file=f'assets/{CMD}.jpg', markup=ikb)

View File

@@ -0,0 +1,13 @@
from aiogram import Router
from .start_cmd import router as start_cmd_router
from .active import router as active_cmd_router
# Настройка экспорта и роутера
__all__ = ("router",)
router: Router = Router(name=__name__)
# Подключение роутеров
router.include_routers(
start_cmd_router,
active_cmd_router,
)

View File

@@ -0,0 +1,45 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery
from bot.templates import msg_photo
from bot.core.bots import BotInfo
from configs import COMMANDS
from database import db
# Настройки экспорта и роутера
__all__ = ("router",)
CMD: str = "active".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
@router.callback_query(F.data.lower() == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
async def active_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
"""Обработчик команды /active"""
await state.clear()
# Получить статистику сообщений пользователя
day, week, month, total = await db.get_message_stats(message.from_user.id)
print(f"За день: {day} сообщений")
print(f"За неделю: {week} сообщений")
print(f"За месяц: {month} сообщений")
print(f"Всего: {total} сообщений")
# Формируем приветственное сообщение
text: str =f"""
За день: {day} сообщений
За неделю: {week} сообщений
За месяц: {month} сообщений
Всего: {total} сообщений
"""
# Отправляем сообщение
await msg_photo(message=message, text=text,)

View File

@@ -0,0 +1,218 @@
import re
from typing import Optional, Dict, Tuple
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.utils.i18n import gettext as _
from bot.core.bots import BotInfo
from bot.keyboards.inline.decision import decision_keyboard
from bot.states.new_states import NewStates
from bot.templates import msg
from middleware.loggers import log
from configs import COMMANDS, ImportantID, RpValue
# Глобальная мапа для хранения связей пользователь-топик
user_topic_map: Dict[Tuple[int, str], int] = {}
__all__ = ("router",)
CMD: str = "new"
router: Router = Router(name=f"{CMD}_cmd_router")
TOPIC_TYPE: str = "anketa"
TEXTS: Dict[str, Dict[str, str]] = {
"anketa": {
"accept": f"<b>🎉 Ваша анкета принята!</b>\n\nДобро пожаловать в проект!\n\nФлуд: {RpValue.FLUD_URL}\nРолевая: {RpValue.RP_URL}",
"reject": "<b>❌ Ваша анкета отклонена.</b>\n\nВы можете попробовать позже."
}
}
async def validate_russian_text(text: str) -> bool:
"""Проверяет текст на соответствие русским буквам, пробелам и дефисам."""
return bool(re.fullmatch(r"[А-Яа-яЁё\s\-]+", text))
# ===================== Команда /new =====================
@router.callback_query(F.data == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
@log(level='INFO', log_type=CMD.upper(), text=f"использовал команду /{CMD}")
async def new_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
"""
Начало анкеты /new.
Отправляет пользователю сообщение с просьбой указать желаемую роль.
"""
await state.clear()
await state.set_state(NewStates.role)
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start'))
text: str = _(
"Пожалуйста, отправьте желаемую роль:\n"
"(только русские буквы, пробелы или дефисы)"
)
await msg(message=message, text=text, markup=ikb)
# ===================== Обработка роли =====================
@router.message(NewStates.role)
async def process_role(message: Message, state: FSMContext) -> None:
"""Обрабатывает ввод роли и запрашивает сортол."""
if not await validate_russian_text(message.text):
await message.reply("Ошибка: роль должна содержать только русские буквы, пробелы или дефисы.")
return
await state.update_data(role=message.text.strip().title())
await state.set_state(NewStates.sorol)
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start'))
await message.reply(
text="Теперь укажите желаемый сортол:\n(только русские буквы, пробелы или дефисы)",
reply_markup=ikb.as_markup()
)
# ===================== Обработка сортола =====================
@router.message(NewStates.sorol)
async def process_sortol(message: Message, state: FSMContext) -> None:
"""Обрабатывает ввод сортола и запрашивает кодовую фразу."""
if not await validate_russian_text(message.text):
await message.reply("Ошибка: сорол должен содержать только русские буквы, пробелы или дефисы.")
return
await state.update_data(sortol=message.text.strip().title())
await state.set_state(NewStates.code_phrase)
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(InlineKeyboardButton(text="Отмена↩️", callback_data='start'))
await message.reply(
text="Теперь введите кодовую фразу из правил:",
reply_markup=ikb.as_markup()
)
# ===================== Обработка кодовой фразы =====================
@router.message(NewStates.code_phrase)
async def process_code_phrase(message: Message, state: FSMContext) -> None:
"""Обрабатывает ввод кодовой фразы и показывает предпросмотр анкеты."""
code_phrase = message.text.strip()
if not code_phrase:
await message.reply("Кодовая фраза не может быть пустой.")
return
await state.update_data(code_phrase=code_phrase)
data: Dict[str, str] = await state.get_data()
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(
InlineKeyboardButton(text="Отправить!", callback_data="submit_new"),
InlineKeyboardButton(text="Отмена↩️", callback_data="start")
)
text: str = (
f"<b>Проверьте данные анкеты:</b>\n\n"
f"• Роль: {data['role']}\n"
f"• Сортол: {data['sortol']}\n"
f"• Кодовая фраза: {data['code_phrase']}"
)
await message.reply(text, reply_markup=ikb.as_markup())
# ===================== Отправка анкеты в поддержку =====================
@router.callback_query(F.data == "submit_new")
async def submit_new_cmd(callback: CallbackQuery, state: FSMContext) -> None:
"""Отправляет анкету в топик форума поддержки и создает запись в мапе."""
data: Dict[str, str] = await state.get_data()
user = callback.from_user
# Создаем топик в форуме
topic = await callback.bot.create_forum_topic(
chat_id=ImportantID.SUPPORT_CHAT_ID,
name=f"Анкета от {user.full_name}"
)
thread_id: int = topic.message_thread_id
# Сохраняем связь пользователь-топик
user_topic_map[(user.id, TOPIC_TYPE)] = thread_id
# Формируем текст анкеты
text: str = (
f'<b><a href="tg://user?id={user.id}">Анкета</a></b>\n\n'
f"• Роль: {data['role']}\n"
f"• Сортол: {data['sortol']}\n"
f"• Кодовая фраза: {data['code_phrase']}"
)
# Отправляем в топик с кнопками принятия/отклонения
await callback.bot.send_message(
chat_id=ImportantID.SUPPORT_CHAT_ID,
message_thread_id=thread_id,
text=text,
parse_mode="HTML",
reply_markup=decision_keyboard(thread_id=thread_id, kind=TOPIC_TYPE)
)
await callback.message.edit_text("✅ Ваша анкета успешно отправлена на рассмотрение!")
await state.clear()
# ===================== Обработка решения админов =====================
@router.callback_query(F.data.regexp(r"^([a-z_]+):(accept|reject):(\d+)$"))
async def process_decision_callback(callback: CallbackQuery) -> None:
"""Обрабатывает решение администраторов и отправляет результат пользователю."""
kind, action, thread_id_str = callback.data.split(":")
thread_id = int(thread_id_str)
# Ищем пользователя по thread_id в мапе
user_id = None
for (uid, k), tid in user_topic_map.items():
if k == kind and tid == thread_id:
user_id = uid
break
if not user_id:
await callback.answer("Пользователь не найден.", show_alert=True)
return
text_to_send: Optional[str] = TEXTS.get(kind, {}).get(action)
if not text_to_send:
await callback.answer("Некорректные данные.", show_alert=True)
return
await callback.bot.send_message(chat_id=user_id, text=text_to_send, parse_mode="HTML")
await callback.message.edit_reply_markup(reply_markup=None)
await callback.answer("Ответ отправлен пользователю.")
# ===================== Пересылка ответов админов пользователю =====================
@router.message(F.is_topic_message, F.reply_to_message, ~F.from_user.is_bot)
async def forward_reply_to_user(message: Message) -> None:
"""Пересылает ответы администраторов из топика пользователю."""
thread_id = message.message_thread_id
if not thread_id:
return
# Ищем пользователя по thread_id
user_id = None
for (uid, _), tid in user_topic_map.items():
if tid == thread_id:
user_id = uid
break
if not user_id:
return
reply_text: str = f"<b>Ответ администратора:</b>\n{message.html_text}"
try:
await message.bot.send_message(chat_id=user_id, text=reply_text, parse_mode="HTML")
except Exception as e:
await message.reply(f"⚠️ Не удалось отправить сообщение пользователю: {e}")

View File

@@ -0,0 +1,51 @@
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message, CallbackQuery, InlineKeyboardButton
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.utils.i18n import gettext as _
from bot.templates import msg_photo
from bot.utils.interesting_facts import interesting_fact
from bot.core.bots import BotInfo
from configs import COMMANDS, RpValue
# Настройки экспорта и роутера
__all__ = ("router",)
CMD: str = "start".lower()
router: Router = Router(name=f"{CMD}_cmd_router")
@router.callback_query(F.data.lower() == CMD)
@router.message(Command(*COMMANDS[CMD], prefix=BotInfo.prefix, ignore_case=True))
async def start_cmd(message: Message | CallbackQuery, state: FSMContext) -> None:
"""Обработчик команды /start"""
await state.clear()
# Создание инлайн-клавиатуры
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(InlineKeyboardButton(text="Инфо-канал🗂", url=RpValue.INFO_URL))
ikb.row(InlineKeyboardButton(text="Вступление🚀", callback_data='new'),
InlineKeyboardButton(text="Анкета📖", callback_data='anketa'))
ikb.row(InlineKeyboardButton(text="Связь с администрацией🌐", callback_data='admin'))
# Формируем приветственное сообщение
text: str = _(
"""Добро пожаловать, <a href="{url}">{name}</a>!
Я ваш искусственный помощник по ролевой - <b>{rp_name}</b>!
Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще!
Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре!
Интересный факт:
<blockquote>{fact}</blockquote>
"""
).format(
url=message.from_user.url if message.from_user else "",
name=message.from_user.first_name if message.from_user else "пользователь",
rp_name=RpValue.RP_NAME,
fact=interesting_fact(),
)
# Отправляем сообщение
await msg_photo(message=message, text=text, file=f'assets/{CMD}.jpg', markup=ikb)

View File

@@ -0,0 +1,13 @@
from aiogram import Router
from .default import router as default_message_router
# Настройка экспорта и роутера
__all__ = ('router',)
router: Router = Router(name=__name__)
# Подготовка роутера команд
#router.include_routers(
#)
# Подключение стандартного роутера
router.include_router(default_message_router)

View File

@@ -0,0 +1,15 @@
from aiogram import Router
from aiogram.types import Message, CallbackQuery
from bot.utils import type_msg
from middleware.loggers import loggers
# Настройки экспорта и роутера
__all__ = ("router",)
CMD: str = "msg"
router: Router = Router(name=f"{CMD}_cmd_router")
@router.message()
async def default_messages(message: Message | CallbackQuery) -> None:
"""Обработчик всех необработанных сообщений."""

View File

@@ -0,0 +1,2 @@
from .reply import *
from .inline import *

View File

@@ -0,0 +1 @@
from .decision import *

View File

@@ -0,0 +1,17 @@
from aiogram.utils.keyboard import InlineKeyboardBuilder
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
def decision_keyboard(thread_id: int, kind: str) -> InlineKeyboardMarkup:
"""
Получение клавиатуры Принятия\Отклонить.
:param thread_id: Айди действия.
:param kind: Вид для клавиатуры.
:return: Инлайн-клавиатуру (Принять, Отклонить).
"""
ikb: InlineKeyboardBuilder = InlineKeyboardBuilder()
ikb.row(
InlineKeyboardButton(text="✅ Принять", callback_data=f"{kind}:accept:{thread_id}"),
InlineKeyboardButton(text="❌ Отклонить", callback_data=f"{kind}:reject:{thread_id}")
)
return ikb.as_markup()

View File

View File

@@ -0,0 +1,47 @@
from aiogram import Dispatcher
from configs import ImportantID
from .logging_mdw import LoggingMiddleware
from .msg_mdw import MessageCounterMiddleware
from .spam_mdw import RateLimitMiddleware
from .subscription_mdw import SubscriptionMiddleware
from .error_mdw import ErrorHandlingMiddleware
from .time_mdw import TimingMiddleware
__all__ = [
LoggingMiddleware,
SubscriptionMiddleware,
RateLimitMiddleware,
ErrorHandlingMiddleware,
TimingMiddleware,
MessageCounterMiddleware,
]
def setup_middlewares(dp: Dispatcher, bot, channel_ids: list[int | str] = None) -> None:
"""
Регистрирует все middleware в диспетчере.
"""
channel_ids = channel_ids or []
# Middleware для ВСЕХ событий (update level)
middlewares_updates: list = [
TimingMiddleware(), # Замер времени
LoggingMiddleware(), # Логирование
ErrorHandlingMiddleware(admin_ids=ImportantID.ADMIN_ID), # Обработка ошибок
]
# Middleware только для СООБЩЕНИЙ (message level)
middlewares_msg: list = [
#RateLimitMiddleware(rate_limit=3, time_period=5.0), # Антифлуд
#SubscriptionMiddleware(bot=bot, channel_ids=channel_ids), # Проверка подписки
MessageCounterMiddleware(), # Подсчет сообщений
]
# Регистрируем middleware для всех событий
for middleware in middlewares_updates:
dp.update.middleware(middleware)
# Регистрируем middleware только для сообщений
for middleware in middlewares_msg:
dp.message.middleware(middleware)

View File

@@ -0,0 +1,201 @@
from typing import Callable, Awaitable, Any, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery, Update
from middleware.loggers import loggers # ваш логгер
class ErrorHandlingMiddleware(BaseMiddleware):
"""
Middleware для глобальной обработки ошибок в хендлерах.
Зачем нужен:
- Централизованная обработка исключений
- Уведомление администраторов об ошибках
- Graceful degradation при сбоях
"""
def __init__(self, admin_ids: list[int]):
"""
Инициализация middleware обработки ошибок.
Args:
admin_ids: Список ID администраторов для уведомлений
"""
self.admin_ids = admin_ids
super().__init__()
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""
Перехватывает и обрабатывает ошибки в хендлерах.
"""
try:
return await handler(event, data)
except Exception as e:
# Получаем информацию о пользователе безопасным способом
user_str = self._extract_user_info(event)
# Логируем ошибку
error_message = f"Ошибка в хендлере: {type(e).__name__}: {str(e)}"
loggers.error(
text=error_message,
log_type="HANDLER_ERROR",
user=user_str
)
# Уведомляем администраторов
await self._notify_admins(error_message, event, user_str)
# Отправляем пользователю сообщение об ошибке
await self._send_error_message(event, user_str)
return None
@staticmethod
def _extract_user_info(event: TelegramObject) -> str:
"""
Безопасно извлекает информацию о пользователе из события.
Args:
event: Объект события
Returns:
Строка с идентификатором пользователя
"""
user_str = "@System"
# Для Message и CallbackQuery
if isinstance(event, (Message, CallbackQuery)) and hasattr(event, 'from_user') and event.from_user:
user = event.from_user
user_str = f"@{user.username}" if user.username else f"id{user.id}"
# Для Update (который содержит message или callback_query)
elif isinstance(event, Update):
# Пытаемся найти пользователя в различных полях Update
user_object = None
if event.message and event.message.from_user:
user_object = event.message.from_user
elif event.edited_message and event.edited_message.from_user:
user_object = event.edited_message.from_user
elif event.callback_query and event.callback_query.from_user:
user_object = event.callback_query.from_user
elif event.channel_post and event.channel_post.from_user:
user_object = event.channel_post.from_user
elif event.edited_channel_post and event.edited_channel_post.from_user:
user_object = event.edited_channel_post.from_user
if user_object:
user_str = f"@{user_object.username}" if user_object.username else f"id{user_object.id}"
return user_str
@staticmethod
def _extract_event_text(event: TelegramObject) -> str:
"""
Безопасно извлекает текст из события.
Args:
event: Объект события
Returns:
Текст события или пустая строка
"""
event_text = ""
# Для Message
if isinstance(event, Message) and hasattr(event, 'text') and event.text:
event_text = event.text
# Для CallbackQuery
elif isinstance(event, CallbackQuery) and hasattr(event, 'data') and event.data:
event_text = f"callback: {event.data}"
# Для Update
elif isinstance(event, Update):
if event.message and event.message.text:
event_text = event.message.text
elif event.callback_query and event.callback_query.data:
event_text = f"callback: {event.callback_query.data}"
elif event.edited_message and event.edited_message.text:
event_text = event.edited_message.text
return event_text[:100] + "..." if len(event_text) > 100 else event_text
async def _notify_admins(
self,
error_message: str,
event: TelegramObject,
user_str: str
) -> None:
"""Уведомляет администраторов об ошибке."""
from aiogram import Bot
bot: Bot = event.bot if hasattr(event, 'bot') else None
if bot:
for admin_id in self.admin_ids:
try:
event_info = f"Событие: {type(event).__name__}"
event_text = self._extract_event_text(event)
if event_text:
event_info += f", текст: {event_text}"
full_message = (
f"🚨 Ошибка в боте:\n\n"
f"Пользователь: {user_str}\n"
f"Ошибка: {error_message}\n"
f"{event_info}"
)
await bot.send_message(admin_id, full_message)
loggers.info(
text=f"Администратор {admin_id} уведомлен об ошибке",
log_type="ADMIN_NOTIFIED",
user=user_str
)
except Exception as e:
loggers.error(
text=f"Не удалось уведомить админа {admin_id}: {e}",
log_type="ADMIN_NOTIFY_ERROR",
user=user_str
)
@staticmethod
async def _send_error_message(
event: TelegramObject,
user_str: str
) -> None:
"""Отправляет пользователю сообщение об ошибке."""
error_text = (
"⚠️ Произошла непредвиденная ошибка. "
"Разработчики уже уведомлены и работают над исправлением.\n\n"
"Попробуйте повторить действие позже или нажмите /start"
)
try:
if isinstance(event, Message):
await event.answer(error_text)
elif isinstance(event, CallbackQuery):
await event.message.answer(error_text)
await event.answer()
elif isinstance(event, Update) and event.message:
await event.message.answer(error_text)
loggers.info(
text="Пользователю отправлено сообщение об ошибке",
log_type="ERROR_MESSAGE_SENT",
user=user_str
)
except Exception as e:
loggers.error(
text=f"Не удалось отправить сообщение об ошибке: {e}",
log_type="ERROR_MESSAGE_FAILED",
user=user_str
)

View File

@@ -0,0 +1,271 @@
from typing import Callable, Awaitable, Any, Dict, Optional, Tuple, Set
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Update, Message, CallbackQuery, MaybeInaccessibleMessageUnion, User
from bot.utils import type_msg
from middleware.loggers import loggers # ваш глобальный логгер
from configs import BotSettings, COMMANDS # импортируем настройки и команды
class LoggingMiddleware(BaseMiddleware):
"""
Middleware для логирования апдейтов с определением типа события,
пользователя и добавлением префикса проекта к типу лога.
Автоматически добавляет префикс проекта (например, 'PRIMO-') к типам логов:
- PRIMO-UPDATE: общий апдейт без определенного типа
- PRIMO-MSG: текстовое сообщение от пользователя
- PRIMO-CMD: команда (сообщение, начинающееся с любого префикса)
- PRIMO-CBD: callback query от инлайн-кнопок
"""
# Префикс проекта для логов
PROJECT_PREFIX: str = "PRIMO"
# Кэш для всех команд из COMMANDS
_all_commands: Optional[Set[str]] = None
def __init__(self):
super().__init__()
# Предварительно загружаем все команды
self._load_all_commands()
def _load_all_commands(self) -> None:
"""Загружает все команды из COMMANDS в множество для быстрого поиска."""
if self._all_commands is None:
self._all_commands = set()
for command_list in COMMANDS.values():
self._all_commands.update(command_list)
def _is_command(self, text: str) -> bool:
"""
Проверяет, является ли текст командой с любым префиксом.
Args:
text: Текст для проверки
Returns:
True если это команда, False если нет
"""
if not text:
return False
# Проверяем все префиксы из BotSettings
for prefix in BotSettings.PREFIX:
if text.startswith(prefix):
# Извлекаем команду без префикса
command_without_prefix = text[len(prefix):].strip()
# Проверяем, есть ли такая команда в нашем списке
if command_without_prefix in self._all_commands:
return True
# Также проверяем команды с префиксом / (стандартные)
if text.startswith('/'):
command_without_slash = text[1:].strip()
if command_without_slash in self._all_commands:
return True
return False
@staticmethod
def _extract_command_name(text: str) -> str:
"""
Извлекает название команды из текста.
Args:
text: Текст команды с префиксом
Returns:
Название команды без префикса
"""
for prefix in BotSettings.PREFIX:
if text.startswith(prefix):
return text[len(prefix):].strip()
if text.startswith('/'):
return text[1:].strip()
return text
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""
Обрабатывает входящее событие, определяет его тип, логирует с префиксом проекта
и передает следующему обработчику.
Args:
handler: Следующий обработчик в цепочке middleware
event: Входящее событие для обработки (Update, Message, CallbackQuery)
data: Словарь с контекстными данными FSM
Returns:
Результат выполнения следующего обработчика
Raises:
Exception: Любое исключение, возникшее при обработке хендлером
"""
# Определяем тип события и информацию для логирования
log_type: str
log_text: str
message_obj: Optional[Message]
log_type, log_text, message_obj = self._determine_event_type(event)
# Добавляем префикс проекта к типу лога
prefixed_log_type: str = f"{log_type}"
# Определяем информацию о пользователе
user_str: str = self._extract_user_info(event, message_obj)
# Логируем получение события с префиксом проекта
loggers.info(
text=log_text,
log_type=prefixed_log_type,
user=user_str
)
try:
# Передаем событие следующему обработчику
result: Any = await handler(event, data)
# Логируем успешное выполнение для команд
if log_type == "CMD":
loggers.info(
text=f"[SUCCESS] команда обработана",
log_type=prefixed_log_type,
user=user_str
)
return result
except Exception as e:
# Логируем ошибку при обработке с префиксом проекта
loggers.error(
text=f"Ошибка обработки: {str(e)}",
log_type=prefixed_log_type,
user=user_str
)
raise
def _determine_event_type(
self,
event: TelegramObject
) -> Tuple[str, str, Optional[Message]]:
"""
Определяет тип события и извлекает информацию для логирования.
Args:
event: Объект события для анализа
Returns:
Кортеж из (тип_лога, текст_лога, объект_сообщения)
"""
log_type: str = "UPDATE"
log_text: str = f"Получен апдейт: {type(event).__name__}"
message_obj: Optional[Message] = None
# Обработка Update объектов (основной тип в middleware)
if isinstance(event, Update):
# Пытаемся найти сообщение в различных полях Update
message_obj = (
event.message or
event.edited_message or
event.channel_post or
event.edited_channel_post
)
if message_obj and message_obj.text:
if self._is_command(message_obj.text):
log_type: str = "CMD"
log_text: str = f"использовал команду '{message_obj.text}'"
else:
log_type: str = "MSG"
log_text: str = f"получено сообщение: {message_obj.text!r}"
elif message_obj:
# Не текстовое сообщение (фото, видео и т.д.)
log_type: str = "MSG"
log_text: str = f"получено сообщение: '{type_msg(message_obj)}'"
elif event.callback_query:
# Обработка callback query
callback: CallbackQuery = event.callback_query
log_type: str = "CBD"
log_text: str = f"получен callback: {callback.data!r}"
if callback.message:
message_obj: Optional[MaybeInaccessibleMessageUnion] = callback.message
# Прямая обработка Message (если мидлварь зарегистрирован на messages)
elif isinstance(event, Message):
message_obj = event
if event.text and self._is_command(event.text):
log_type: str = "CMD"
log_text: str = f"использовал команду '{event.text}'"
elif event.text:
log_type: str = "MSG"
log_text: str = f"получено сообщение: {event.text!r}"
else:
log_type: str = "MSG"
log_text: str = f"получено сообщение типа: {event.content_type}"
# Прямая обработка CallbackQuery (если мидлварь зарегистрирован на callbacks)
elif isinstance(event, CallbackQuery):
log_type: str = "CBD"
log_text: str = f"получен callback: {event.data!r}"
if event.message:
message_obj = event.message
return log_type, log_text, message_obj
@staticmethod
def _extract_user_info(
event: TelegramObject,
message: Optional[Message] = None
) -> str:
"""
Извлекает информацию о пользователе из события.
Args:
event: Объект события (Update, Message или CallbackQuery)
message: Объект Message (если уже определен)
Returns:
Строка с идентификатором пользователя в формате '@username' или 'id<user_id>'
"""
user_str: str = "@System"
# Для CallbackQuery извлекаем пользователя из самого callback'а
if isinstance(event, CallbackQuery) and hasattr(event, 'from_user') and event.from_user:
user = event.from_user
user_str: str = f"@{user.username}" if user.username else f"id{user.id}"
# Для Message извлекаем пользователя из сообщения
elif isinstance(event, Message) and hasattr(event, 'from_user') and event.from_user:
user = event.from_user
user_str: str = f"@{user.username}" if user.username else f"id{user.id}"
# Для Update с callback_query
elif (isinstance(event, Update) and
event.callback_query and
hasattr(event.callback_query, 'from_user') and
event.callback_query.from_user):
user = event.callback_query.from_user
user_str: str = f"@{user.username}" if user.username else f"id{user.id}"
# Для Update с сообщением
elif (isinstance(event, Update) and
(event.message or event.edited_message) and
hasattr(event.message or event.edited_message, 'from_user')):
msg = event.message or event.edited_message
if msg and msg.from_user:
user: Optional[User] = msg.from_user
user_str: str = f"@{user.username}" if user.username else f"id{user.id}"
# Если передан message объект
elif message and hasattr(message, 'from_user') and message.from_user:
user: Optional[User] = message.from_user
user_str: str = f"@{user.username}" if user.username else f"id{user.id}"
return user_str

View File

@@ -0,0 +1,55 @@
import logging
from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware
from aiogram.enums import ChatType
from aiogram.types import Message
from database import db
logger = logging.getLogger(__name__)
class MessageCounterMiddleware(BaseMiddleware):
"""
Middleware для подсчёта сообщений в группах и супергруппах.
"""
async def __call__(
self,
handler: Callable[[Message, Dict[str, Any]], Awaitable[Any]],
event: Any,
data: Dict[str, Any]
) -> Any:
if not isinstance(event, Message):
return await handler(event, data)
# Проверяем, что сообщение пришло из группового чата и не от бота
if (event.chat.type in (ChatType.GROUP, ChatType.SUPERGROUP) and
not event.from_user.is_bot):
try:
await self.process_group_message(event)
except Exception as e:
logger.error(msg=f"Ошибка при обработке сообщения: {e}", exc_info=True)
return await handler(event, data)
@staticmethod
async def process_group_message(message: Message) -> None:
"""
Обработка сообщения из группового чата.
"""
user_id = message.from_user.id
message_text = message.text or message.caption or ""
# Добавляем пользователя (если его ещё нет)
await db.add_user(
user_id=user_id,
username=message.from_user.username,
full_name=message.from_user.full_name,
)
# Сохраняем сообщение
await db.add_message(
user_id=user_id,
message_text=message_text,
created_at=message.date,
)
logger.info(f"Сообщение от пользователя {user_id} сохранено в БД")

View File

@@ -0,0 +1,97 @@
from typing import Callable, Awaitable, Any, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery
import time
from collections import defaultdict
from middleware.loggers import loggers # ваш логгер
class RateLimitMiddleware(BaseMiddleware):
"""
Middleware для ограничения частоты запросов от пользователей (анти-спам).
Зачем нужен:
- Защита от DDoS и флуда
- Предотвращение злоупотребления ботом
- Контроль нагрузки на сервер
"""
def __init__(self, rate_limit: int = 10, time_period: float = 2.0):
"""
Инициализация rate limit middleware.
Args:
rate_limit: Максимальное количество запросов за период
time_period: Период времени в секундах
"""
self.rate_limit = rate_limit
self.time_period = time_period
self.user_calls: Dict[int, list[float]] = defaultdict(list)
super().__init__()
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any],
log: bool = False,
) -> Any:
"""
Проверяет rate limit перед обработкой запроса.
"""
# Пропускаем не-сообщения и не-колбэки
if not isinstance(event, (Message, CallbackQuery)):
return await handler(event, data)
user_id: int = event.from_user.id
user_str: str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
current_time: float = time.time()
# Очищаем старые запросы
self.user_calls[user_id] = [
call_time for call_time in self.user_calls[user_id]
if current_time - call_time < self.time_period
]
# Логируем текущее состояние rate limit
if log:
loggers.debug(
text=f"Rate limit: {len(self.user_calls[user_id])}/{self.rate_limit} за {self.time_period}сек",
log_type="RATE_LIMIT_STATUS",
user=user_str
)
# Проверяем текущий лимит
if len(self.user_calls[user_id]) >= self.rate_limit:
# Логируем попытку спама
if log:
loggers.warning(
text=f"Превышен rate limit ({self.rate_limit}/{self.time_period}сек)",
log_type="RATE_LIMIT_EXCEEDED",
user=user_str
)
# Отправляем сообщение о превышении лимита
if isinstance(event, Message):
await event.answer(
text="⏳ Слишком много запросов! Пожалуйста, подождите немного.",
)
elif isinstance(event, CallbackQuery):
await event.answer(
text="⏳ Подождите немного перед следующим действием.",
show_alert=True
)
return None
# Добавляем текущий запрос и продолжаем обработку
self.user_calls[user_id].append(current_time)
loggers.debug(
text=f"Запрос добавлен в rate limit",
log_type="RATE_LIMIT_ADDED",
user=user_str
)
return await handler(event, data)

View File

@@ -0,0 +1,115 @@
from typing import Callable, Awaitable, Any, Dict
from aiogram import BaseMiddleware, Bot
from aiogram.types import TelegramObject, Message, CallbackQuery
from aiogram.exceptions import TelegramBadRequest
from middleware.loggers import loggers # ваш логгер
class SubscriptionMiddleware(BaseMiddleware):
"""
Middleware для проверки подписки пользователя на необходимые каналы.
Блокирует обработку команд, если пользователь не подписан.
Зачем нужен:
- Автоматическая проверка подписки для всех входящих сообщений
- Единая точка управления подписками
- Предотвращение доступа к функционалу без подписки
"""
def __init__(self, bot: Bot, channel_ids: list[int | str]):
"""
Инициализация middleware проверки подписки.
Args:
bot: Экземпляр бота
channel_ids: Список ID каналов/чатов для проверки подписки
"""
self.bot = bot
self.channel_ids = channel_ids
super().__init__()
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any]
) -> Any:
"""
Проверяет подписку пользователя перед обработкой команды.
"""
# Пропускаем не-сообщения и не-колбэки
if not isinstance(event, (Message, CallbackQuery)):
return await handler(event, data)
user_id: int = event.from_user.id
user_str: str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}"
# Логируем начало проверки подписки
loggers.info(
text=f"Проверка подписки для пользователя",
log_type="SUBSCRIPTION_CHECK",
user=user_str
)
# Проверяем подписку на все required каналы
not_subscribed_channels: list[str] = []
for channel_id in self.channel_ids:
try:
member = await self.bot.get_chat_member(
chat_id=channel_id,
user_id=user_id
)
# Проверяем, что пользователь является участником
if member.status not in ['member', 'administrator', 'creator']:
not_subscribed_channels.append(str(channel_id))
except TelegramBadRequest as e:
loggers.error(
text=f"Ошибка проверки подписки на канал {channel_id}: {e}",
log_type="SUBSCRIPTION_ERROR",
user=user_str
)
# Если пользователь не подписан на некоторые каналы
if not_subscribed_channels:
loggers.warning(
text=f"Пользователь не подписан на каналы: {', '.join(not_subscribed_channels)}",
log_type="SUBSCRIPTION_FAILED",
user=user_str
)
warning_text = (
"📢 Для использования бота необходимо подписаться на наши каналы!\n\n"
"После подписки нажмите /start для продолжения."
)
# Создаем кнопку "Проверить подписку"
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
keyboard = InlineKeyboardMarkup(
inline_keyboard=[[
InlineKeyboardButton(
text="✅ Я подписался",
callback_data="check_subscription"
)
]]
)
if isinstance(event, Message):
await event.answer(warning_text, reply_markup=keyboard)
elif isinstance(event, CallbackQuery):
await event.message.answer(warning_text, reply_markup=keyboard)
await event.answer()
return None
# Логируем успешную проверку подписки
loggers.info(
text="Пользователь подписан на все required каналы",
log_type="SUBSCRIPTION_SUCCESS",
user=user_str
)
# Если подписка есть, продолжаем обработку
return await handler(event, data)

View File

@@ -0,0 +1,82 @@
from typing import Callable, Awaitable, Any, Dict
from aiogram import BaseMiddleware
from aiogram.types import TelegramObject, Message, CallbackQuery, Update
from time import time
from middleware.loggers import loggers # ваш логгер
class TimingMiddleware(BaseMiddleware):
"""
Middleware для измерения времени выполнения хендлеров.
Зачем нужен:
- Мониторинг производительности хендлеров
- Выявление медленных запросов
- Оптимизация кода бота
"""
async def __call__(
self,
handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: Dict[str, Any],
perm: str = None,
) -> Any:
"""
Измеряет время выполнения хендлера.
"""
start_time: float = time()
try:
result = await handler(event, data)
return result
finally:
execution_time: float = time() - start_time
# Получаем информацию о пользователе безопасным способом
user_str: str = "@System"
# Для Message и CallbackQuery
if isinstance(event, (Message, CallbackQuery)) and hasattr(event, 'from_user') and event.from_user:
user = event.from_user
user_str = f"@{user.username}" if user.username else f"id{user.id}"
# Для Update (который содержит message или callback_query)
elif isinstance(event, Update):
# Пытаемся найти пользователя в различных полях Update
user_object = None
if event.message and event.message.from_user:
user_object = event.message.from_user
elif event.edited_message and event.edited_message.from_user:
user_object = event.edited_message.from_user
elif event.callback_query and event.callback_query.from_user:
user_object = event.callback_query.from_user
elif event.channel_post and event.channel_post.from_user:
user_object = event.channel_post.from_user
elif event.edited_channel_post and event.edited_channel_post.from_user:
user_object = event.edited_channel_post.from_user
if user_object:
user_str = f"@{user_object.username}" if user_object.username else f"id{user_object.id}"
# Логируем время выполнения
if execution_time > 1.0 and perm: # Медленные запросы
loggers.warning(
text=f"Медленный хендлер: {execution_time:.2f}сек",
log_type="SLOW_HANDLER",
user=user_str
)
elif execution_time > 0.5 and perm == "medium": # Средние запросы
loggers.info(
text=f"Среднее время выполнения: {execution_time:.3f}сек",
log_type="HANDLER_TIMING",
user=user_str
)
elif perm == "fast": # Быстрые запросы
loggers.debug(
text=f"Быстрое выполнение: {execution_time:.3f}сек",
log_type="HANDLER_TIMING_FAST",
user=user_str
)

0
bot/states/__init__.py Normal file
View File

View File

@@ -0,0 +1,5 @@
# bot/states/form.py
from aiogram.fsm.state import State, StatesGroup
class StartForm(StatesGroup):
waiting_for_application: State = State()

8
bot/states/new_states.py Normal file
View File

@@ -0,0 +1,8 @@
# bot/states/new_states.py
from aiogram.fsm.state import State, StatesGroup
class NewStates(StatesGroup):
role: State = State()
sorol: State = State()
code_phrase: State = State()
rules: State = State()

View File

@@ -0,0 +1 @@
from .message_callback import *

View File

@@ -0,0 +1,77 @@
from typing import Union
from aiogram.types import FSInputFile, CallbackQuery, Message, ReplyKeyboardMarkup, InlineKeyboardMarkup
from aiogram.utils.keyboard import ReplyKeyboardBuilder, InlineKeyboardBuilder
# Настройка экспорта
__all__ = ('msg', 'msg_photo')
async def msg(message: Message | CallbackQuery,
text: str = "Сообщение отправлено!",
markup: Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, None] = None) -> None:
"""
Шаблон для ответа на сообщение текстом.
:param message: Объект сообщения или callback-запроса.
:param text: Текст отправного сообщения от бота.
:param markup: Кнопки сообщения (инлайн или реплай).
"""
# Преобразуем клавиатуру
reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, None] = None
if markup:
if isinstance(markup, InlineKeyboardBuilder):
reply_markup: InlineKeyboardMarkup = markup.as_markup()
elif isinstance(markup, ReplyKeyboardBuilder):
reply_markup: ReplyKeyboardMarkup = markup.as_markup(resize_keyboard=True)
# Обработчик ответа на сообщение
if isinstance(message, Message):
await message.reply(
text=text,
reply_markup=reply_markup
)
# Обработчик ответа на callback
else:
await message.message.reply(
text=text,
reply_markup=reply_markup
)
async def msg_photo(
message: Message | CallbackQuery,
text: str = "Сообщение отправлено!",
file: str = "assets/default.jpg",
markup: Union[InlineKeyboardBuilder, ReplyKeyboardBuilder, None] = None) -> None:
"""
Шаблон для ответа на сообщение фотографией.
:param message: Объект сообщения или callback-запроса.
:param file: Путь к фотографии для ответа.
:param text: Подпись к фото.
:param markup: Кнопки сообщения (инлайн или реплай).
"""
# Преобразуем клавиатуру
reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup, None] = None
if markup:
if isinstance(markup, InlineKeyboardBuilder):
reply_markup = markup.as_markup()
elif isinstance(markup, ReplyKeyboardBuilder):
reply_markup = markup.as_markup(resize_keyboard=True)
# Обработчик ответа на сообщение
if isinstance(message, Message):
await message.reply_photo(
photo=FSInputFile(file),
caption=text,
reply_markup=reply_markup
)
# Обработчик ответа на callback
else:
await message.message.reply_photo(
photo=FSInputFile(file),
caption=text,
reply_markup=reply_markup
)

5
bot/utils/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
from .interesting_facts import *
from .usernames import *
from .pagination import *
from .type_message import *
from .argument import *

59
bot/utils/argument.py Normal file
View File

@@ -0,0 +1,59 @@
from __future__ import annotations
from typing import Optional
from configs import BotSettings
__all__ = ("is_command", "find_argument")
def is_command(message: Optional[str]) -> bool:
"""
Проверяет, является ли сообщение командой.
Сообщение считается командой, если:
1. Оно не пустое;
2. Начинается с префикса команды, указанного в настройках.
Args:
message (Optional[str]): Входное сообщение.
Returns:
bool: True, если сообщение является командой, иначе False.
Пример:
>>> is_command("/start")
True
>>> is_command("hello")
False
"""
if not message:
return False
return message.strip().startswith(BotSettings.PREFIX)
def find_argument(message: Optional[str]) -> Optional[str]:
"""
Извлекает аргумент команды из сообщения.
Аргументом считается текст после первой команды и пробела.
Если аргумента нет — возвращает None.
Args:
message (Optional[str]): Входное сообщение.
Returns:
Optional[str]: Аргумент команды или None, если его нет.
Пример:
>>> find_argument("/start referrer")
'referrer'
>>> find_argument("/start")
None
>>> find_argument("hello")
None
"""
if not is_command(message):
return None
parts = message.strip().split(maxsplit=1)
return parts[1] if len(parts) > 1 else None

View File

@@ -0,0 +1,29 @@
from random import choice
from configs.config import Lists
# Настройки экспорта
__all__ = ("interesting_fact",)
def interesting_fact(mode: str = "факт", lists: list[str] = None) -> str:
"""
Возвращает случайный факт, анекдот или цитату, в зависимости от режима.
:param mode: Строка, определяющая тип контента ("факт", "анекдот", "цитата").
:param lists: Необязательный список строк, из которого можно выбирать вручную.
:return: Случайный элемент из соответствующего списка.
"""
if lists is not None:
return choice(lists)
mode: str = mode.lower()
if mode == "анекдот":
source: list[str] = Lists.jokes
elif mode == "цитата":
source: list[str] = Lists.quotes
else:
source: list[str] = Lists.facts
return choice(source)

28
bot/utils/pagination.py Normal file
View File

@@ -0,0 +1,28 @@
from aiogram.types import InlineKeyboardButton
# Настройка экспорта в модули
__all__ = ('pagination_btn',)
def pagination_btn(action: str,
page: int = 0,
total_posts: int = 0,
bt_page: int = 5) -> list[InlineKeyboardButton]:
"""
Создает кнопки для пагинации.
:param action: Действие в котором нужна пангинация.
:param page: Номер начальной страницы, по умолчанию 0.
:param total_posts: Количество постов.
:param bt_page: Количество кнопок на одной странице.
:return: Готовый лист списка инлайн-кнопок.
"""
navigation_buttons: list[InlineKeyboardButton] = []
if page > 0:
navigation_buttons.append(InlineKeyboardButton(
text="", callback_data=f"{action}_page_{page - 1}"
))
if (page + 1) * bt_page < total_posts:
navigation_buttons.append(InlineKeyboardButton(
text="", callback_data=f"{action}_page_{page + 1}"
))
return navigation_buttons

85
bot/utils/type_message.py Normal file
View File

@@ -0,0 +1,85 @@
from typing import Final
from aiogram.types import Message
# Настройка экспорта
__all__ = ("CHAT_TYPES", "CONTENT_TYPE_RU", "type_chat", "type_msg")
# Словарь сопоставлений "chat_type -> русское название"
CHAT_TYPES: Final[dict[str, str]] = {
"private": "Личный",
"group": "Группа",
"supergroup": "Группа",
"channel": "Канал",
}
# Словарь сопоставлений "content_type -> русское название"
CONTENT_TYPE_RU: Final[dict[str, str]] = {
"text": "Текст",
"animation": "Гиф",
"audio": "Аудио",
"document": "Файл",
"photo": "Фото",
"sticker": "Стикер",
"video": "Видео",
"video_note": "Видеосообщение",
"voice": "Голосовое сообщение",
"contact": "Контакт",
"dice": "Кубик",
"game": "Игра",
"poll": "Опрос",
"venue": "Место",
"location": "Локация",
"new_chat_members": "Новые участники чата",
"left_chat_member": "Участник вышел",
"new_chat_title": "Новое название чата",
"new_chat_photo": "Новая картинка чата",
"delete_chat_photo": "Удалена картинка чата",
"group_chat_created": "Создана группа",
"supergroup_chat_created": "Создана супергруппа",
"channel_chat_created": "Создан канал",
"message_auto_delete_timer_changed": "Изменён автоудалитель",
"migrate_to_chat_id": "Группа → супергруппа",
"migrate_from_chat_id": "Супергруппа → группа",
"pinned_message": "Закреплённое сообщение",
"invoice": "Счёт",
"successful_payment": "Успешный платёж",
"connected_website": "Подключённый сайт",
"passport_data": "Данные Telegram Passport",
"proximity_alert_triggered": "Алерт о приближении",
"video_chat_scheduled": "Запланированный видеочат",
"video_chat_started": "Видеочат начался",
"video_chat_ended": "Видеочат завершён",
"video_chat_participants_invited": "Приглашены участники видеочата",
"web_app_data": "Данные из веб-приложения",
"forum_topic_created": "Создана тема форума",
"forum_topic_edited": "Изменена тема форума",
"forum_topic_closed": "Тема форума закрыта",
"forum_topic_reopened": "Тема форума открыта",
"general_forum_topic_hidden": "Общая тема скрыта",
"general_forum_topic_unhidden": "Общая тема снова отображается",
"giveaway_created": "Создан розыгрыш",
"giveaway": "Розыгрыш",
"giveaway_completed": "Розыгрыш завершён",
"message_reaction": "Реакция на сообщение",
}
def type_msg(message: Message) -> str:
"""
Определяет и возвращает тип сообщения на русском языке.
:param message: объект Message от aiogram
:return: строка с типом сообщения
"""
return CONTENT_TYPE_RU.get(message.content_type, f"Неизвестный тип ({message.content_type})")
def type_chat(message: Message) -> str:
"""
Преобразует информацию о чате в его тип на русском языке.
:param message: Объект сообщения из aiogram, содержащий информацию о чате.
:return: Тип чата строкой.
"""
return CHAT_TYPES.get(message.chat.type, f"Неизвестный тип чата {message.chat.type}")

21
bot/utils/usernames.py Normal file
View File

@@ -0,0 +1,21 @@
from aiogram.types import Message
# Настройка экспорта в модули
__all__ = ('username', )
# Функция получения юзера или ID пользователя
def username(message: Message) -> str:
"""
Возвращает юзернейм пользователя из сообщения, или ID, если юзернейм не указан.
:param message: Объект сообщения из aiogram.
:return: Строка с юзернеймом пользователя или его ID.
:raises ValueError: Если в сообщении отсутствует информация о пользователе.
"""
try:
if message.from_user:
return f"@{message.from_user.username}" if message.from_user.username else f"@{message.from_user.id}"
raise ValueError("Информация о пользователе отсутствует в сообщении.")
except ValueError as e:
raise e # Перебрасываем ошибку выше для дальнейшей обработки