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

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

35
.dockerignore Normal file
View File

@@ -0,0 +1,35 @@
# Исключить скрытые системные каталоги, но не всё подряд
.git/
.gitattributes
.gitignore
# Виртуальные окружения и Python-кэш
.venv/
venv/
__pycache__/
*.py[cod]
*.pyo
# IDE-файлы
.idea/
.vscode/
# Тесты и документация
tests/
test/
docs/
examples/
# Логи и артефакты сборки
*.log
*.logs
*.log.*
*.logs.*
Logs/
Log/
dist/
build/
# Примеры и шаблоны
.env
env

92
.env_example Normal file
View File

@@ -0,0 +1,92 @@
# Токены бота
BOT_TOKEN=your_bot_token_here
BOT_DEBUG_TOKEN=your_debug_bot_token_here
# Режим отладки
DEBUG=False
# Владелец бота
OWNER=@verdise
# Основные настройки
PARSE_MODE=HTML
ENCOD=utf-8
TIME_FORMAT=%Y-%m-%d %H:%M:%S
PREFIX=/!.&?
BOT_LANGUAGE=Aiogram3
# Настройки сообщений
DISABLE_NOTIFICATION=False
PROTECT_CONTENT=False
ALLOW_SENDING_WITHOUT_REPLY=True
LINK_PREVIEW_IS_DISABLED=False
LINK_PREVIEW_PREFER_SMALL_MEDIA=False
LINK_PREVIEW_PREFER_LARGE_MEDIA=True
LINK_PREVIEW_SHOW_ABOVE_TEXT=False
SHOW_CAPTION_ABOVE_MEDIA=False
# Разрешения
BOT_EDIT=False
START_INFO_CONSOLE=True
START_INFO_TO_FILE=True
# Логирование
LOG_CONSOLE=True
LOG_FILE=True
LOG_DIR=Logs
LOG_FILE_INFO=bot_info.log
# Вебхук
WEBHOOK=False
# API ключи
API_KEY=your_api_key
WEB_API_KEY=your_web_api_key
WEATHER_API_KEY=your_weather_api_key
# Telegram API ID и HASH
TG_API_UID=123456
TG_API_HASH=your_tg_api_hash
# Важные ID
ADMIN_ID=123456789
MODERATOR_ID=987654321
IMPORTANT_ID=1122334455
IMPORTANT_GROUP_ID=-1001122334455
IMPORTANT_CHANNEL_ID=-1009988776655
# Настройки бота
PROJECT_NAME=PRIMO
BOT_NAME=Первозданная Жемчужина
BOT_DESCRIPTION=Ваш помощник в удивительные миры! Prod. by:『@verdise』
BOT_SHORT_DESCRIPTION=Тех.поддержка: @verdise
# Настройки ролевого проекта
RP_NAME: str = "𝘗𝘳𝘪𝘮𝘰 𝘞𝘰𝘳𝘭𝘥"
# Права администратора
ANONYMOUS=False
MANAGE_CHAT=True
CHANGE_INFO=True
PROMOTE_MEMBERS=True
RESTRICT_MEMBERS=True
POST_MESSAGE=True
MANAGE_TOPICS=True
INVITE_USER=True
DELETE_MESSAGES=True
MANAGE_VIDEO_CHATS=True
EDIT_MESSAGES=True
PIN_MESSAGE=True
POST_STORIES=True
EDIT_STORIES=True
DELETE_STORIES=True
# Поддержка
SUPPORT_CHAT_ID=0

97
.gitattributes vendored Normal file
View File

@@ -0,0 +1,97 @@
# =============================================================================
# Git LFS: большие бинарные файлы, модели, архивы
# =============================================================================
*.7z filter=lfs diff=lfs merge=lfs -text
*.arrow filter=lfs diff=lfs merge=lfs -text
*.bin filter=lfs diff=lfs merge=lfs -text
*.bz2 filter=lfs diff=lfs merge=lfs -text
*.ckpt filter=lfs diff=lfs merge=lfs -text
*.ftz filter=lfs diff=lfs merge=lfs -text
*.gz filter=lfs diff=lfs merge=lfs -text
*.h5 filter=lfs diff=lfs merge=lfs -text
*.joblib filter=lfs diff=lfs merge=lfs -text
*.lfs.* filter=lfs diff=lfs merge=lfs -text
*.mlmodel filter=lfs diff=lfs merge=lfs -text
*.model filter=lfs diff=lfs merge=lfs -text
*.msgpack filter=lfs diff=lfs merge=lfs -text
*.npy filter=lfs diff=lfs merge=lfs -text
*.npz filter=lfs diff=lfs merge=lfs -text
*.onnx filter=lfs diff=lfs merge=lfs -text
*.ot filter=lfs diff=lfs merge=lfs -text
*.parquet filter=lfs diff=lfs merge=lfs -text
*.pb filter=lfs diff=lfs merge=lfs -text
*.pickle filter=lfs diff=lfs merge=lfs -text
*.pkl filter=lfs diff=lfs merge=lfs -text
*.pt filter=lfs diff=lfs merge=lfs -text
*.pth filter=lfs diff=lfs merge=lfs -text
*.rar filter=lfs diff=lfs merge=lfs -text
*.safetensors filter=lfs diff=lfs merge=lfs -text
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.tar.* filter=lfs diff=lfs merge=lfs -text
*.tar filter=lfs diff=lfs merge=lfs -text
*.tflite filter=lfs diff=lfs merge=lfs -text
*.tgz filter=lfs diff=lfs merge=lfs -text
*.wasm filter=lfs diff=lfs merge=lfs -text
*.xz filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
# =============================================================================
# Автоопределение текста, окончания строк
# =============================================================================
* text=auto eol=lf
# =============================================================================
# Текстовые файлы (Python, конфиги, документы)
# =============================================================================
*.py text
*.pyi text
*.ipynb text
*.html text
*.css text
*.js text
*.json text
*.md text
*.yml text
*.yaml text
*.xml text
*.txt text
*.cfg text
*.toml text
*.ini text
*.env text
# =============================================================================
# Изображения
# =============================================================================
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.bmp binary
*.webp binary
*.ico binary
*.svg text
# =============================================================================
# Шрифты
# =============================================================================
*.eot binary
*.ttf binary
*.woff binary
*.woff2 binary
*.otf binary
# =============================================================================
# GitHub Linguist (указание языка для отображения)
# =============================================================================
*.py linguist-language=Python
*.ipynb linguist-language=Jupyter Notebook
*.html linguist-language=HTML
*.css linguist-language=CSS
*.js linguist-language=JavaScript
*.json linguist-language=JSON
*.md linguist-language=Markdown
*.yml linguist-language=YAML
*.yaml linguist-language=YAML

68
.gitignore vendored Normal file
View File

@@ -0,0 +1,68 @@
# .gitignore: Игнорируемые файлы для Python проектов
# Подробнее: https://github.com/github/gitignore/blob/main/Python.gitignore
### Python ###
# Виртуальные окружения и настройки
.venv
.env
env
venv/
env/
env.bak/
venv.bak/
# Кэш интерпретатора
__pycache__/
*.py[cod]
*$py.class
# Пакеты и сборки
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.eg
*.egg
*.eggs
# Poetry
poetry.lock
.pypoetry/
### Логи и БД ###
*.log
*.logs
*.log.*
*.logs.*
log/
logs/
*.sqlite
*.db
### IDE ###
.idea/
.vscode/
*.swp
*.sublime-*
### OS ###
.DS_Store
Thumbs.db
### Тестирование ###
.coverage
htmlcov/
.tox/
.nox/
.pytest_cache/
.mypy_cache/

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

18
.idea/PrimoExampleBot.iml generated Normal file
View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/database" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (PrimoExampleBot)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="TemplatesService">
<option name="TEMPLATE_FOLDERS">
<list>
<option value="$MODULE_DIR$/locales" />
</list>
</option>
</component>
</module>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/BotSystemResetRP.iml" filepath="$PROJECT_DIR$/.idea/BotSystemResetRP.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
# Используем официальный образ Python с подходящей версией
FROM python:3.12-slim
# Устанавливаем Poetry
RUN pip install poetry
# Устанавливаем рабочую директорию внутри контейнера
WORKDIR /app
# Копируем файлы Poetry
COPY pyproject.toml poetry.lock* ./
# Настраиваем Poetry (не создавать виртуальное окружение внутри контейнера)
RUN poetry config virtualenvs.create false
# Устанавливаем зависимости через Poetry
RUN poetry install --no-interaction --no-ansi --no-root
# Копируем все файлы проекта внутрь контейнера
COPY . .
# Устанавливаем переменную окружения для буферизации
ENV PYTHONUNBUFFERED=1
# Команда запуска — запуск скрипта main.py
CMD ["python", "main.py"]

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) [2025] [Verum]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

BIN
README.md Normal file

Binary file not shown.

BIN
assets/default.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

BIN
assets/start.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

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 # Перебрасываем ошибку выше для дальнейшей обработки

3
configs/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .config import *
from .cmd_list import *
from .roles import *

86
configs/cmd_list.py Normal file
View File

@@ -0,0 +1,86 @@
from typing import Final
# Список команд по ключу
COMMANDS: Final[dict[str, list[str]]] = {
"start": [
"start", "старт", "почати",
"ыефке", "cnfhn", "on", "вкл", "щт", "drk",
],
"help": [
"help", "помощь", "допомога",
"рудзщь", "dopomoga", "?",
],
"menu": [
"menu", "меню", "менюшка",
"ьщкф", "menyu",
],
"create": [
"create", "создать", "створити",
"сщзду", "sozdat", "stvoriti",
],
"report": [
"report", "репорт", "скарга",
"кщзщтв", "repert",
],
"mute": [
"mute", "заглушить", "заглушити",
"угуыщцук", "zaglushit",
],
"kick": [
"kick", "кик", "викинути",
"куиф", "vikynuty",
],
"ban": [
"ban", "бан", "забанити",
"ьфд", "zabanyty",
],
"stats": [
"stats", "статистика", "статистика",
"ыпщз", "statystyka",
],
"settings": [
"settings", "настройки", "налаштування",
"гшеукефьз", "nastroyky",
],
"info": [
"info", "инфо", "інфо",
"шкещ", "info",
],
"feedback": [
"feedback", "обратная связь", "зворотній зв’язок",
"гуеекфьз", "obratnaia_svyaz",
],
"subscribe": [
"subscribe", "подписаться", "підписатися",
"подписатсь", "pidpysatysia",
],
"unsubscribe": [
"unsubscribe", "отписаться", "відписатися",
"отписаться", "vidpysatysia",
],
"language": [
"language", "язык", "мова",
"йцукефь", "mova",
],
"cancel": [
"cancel", "отмена", "скасувати",
"утпщге", "skasuvaty",
],
"list": [
"list", "список", "список",
"дшззщк", "spysok",
],
"forward": [
"forward", "переслать", "переслати",
"дшпекщву", "pereslaty",
],
"new": [
"new", "туц", "вступление",
"cnegktybt", "ym.", "нью",
],
"active": [
"active",
]
}

394
configs/config.py Normal file
View File

@@ -0,0 +1,394 @@
from pathlib import Path
from urllib.parse import urlparse, ParseResult
from typing import ClassVar, Final, Optional, Any
from pydantic import field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from aiogram.types import ChatAdministratorRights
class Settings(BaseSettings):
"""Улучшенный класс настроек с комплексной валидацией"""
# Конфигурация загрузки переменных окружения
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
validate_default=True,
)
# Режимы и базовые параметры
PYTHONUNBUFFERED: str = "1"
LOCALE_PATH: str = "locales"
DEBUG: bool = False
OWNER: str = "@verdise"
# Токены бота
BOT_TOKEN: Optional[str] = None
BOT_DEBUG_TOKEN: Optional[str] = None
# Параметры сообщений
PARSE_MODE: str = "HTML"
ENCOD: str = "utf-8"
TIME_FORMAT: str = "%Y-%m-%d %H:%M:%S"
PREFIX: str = "/!.&?"
BOT_LANGUAGE: str = "Aiogram3"
# Настройки сообщений
DISABLE_NOTIFICATION: bool = False
PROTECT_CONTENT: bool = False
ALLOW_SENDING_WITHOUT_REPLY: bool = True
LINK_PREVIEW_IS_DISABLED: bool = False
LINK_PREVIEW_PREFER_SMALL_MEDIA: bool = False
LINK_PREVIEW_PREFER_LARGE_MEDIA: bool = True
LINK_PREVIEW_SHOW_ABOVE_TEXT: bool = True
SHOW_CAPTION_ABOVE_MEDIA: bool = False
# Разрешения и логирование
BOT_EDIT: bool = True
START_INFO_CONSOLE: bool = True
START_INFO_TO_FILE: bool = True
LOG_CONSOLE: bool = True
LOG_FILE: bool = True
LOG_DIR: Path = Path('Logs')
LOG_FILE_INFO: Path = Path('bot_info.log')
# Вебхук
WEBHOOK: bool = False
WEBHOOK_URL: str = "https://bot.primo.dpdns.org/webhook" # публичный HTTPS url
WEBAPP_HOST: str = "0.0.0.0" # адрес, на котором слушает uvicorn внутри контейнера
WEBAPP_PORT: int = 3131
LOG_LEVEL: str = "warning"
ACCES_LOG: bool = False
# API ключи
API_KEY: Optional[str] = None
WEB_API_KEY: Optional[str] = None
WEATHER_API_KEY: Optional[str] = None
# Пользовательские данные
TG_API_UID: int = 0
TG_API_HASH: Optional[str] = None
# Идентификаторы
ADMIN_ID: list[int] = []
MODERATOR_ID: int = 0
IMPORTANT_ID: int = 0
IMPORTANT_GROUP_ID: int = 0
IMPORTANT_CHANNEL_ID: int = 0
SUPPORT_CHAT_ID: int = 0
# Настройки бота
PROJECT_NAME: str = "PRIMO"
BOT_NAME: str = "Первозданная Жемчужина"
BOT_DESCRIPTION: Optional[str] = None
BOT_SHORT_DESCRIPTION: Optional[str] = None
# Ролевой проект
RP_NAME: Optional[str] = "𝘗𝘳𝘪𝘮𝘰 𝘞𝘰𝘳𝘭𝘥"
INFO_URL: Optional[str] = "https://t.me/PrimoWorldRP"
FLUD_URL: Optional[str] = "https://t.me/PrimoWorldRP"
RP_URL: Optional[str] = "https://t.me/PrimoWorldRP"
LIFE_URL: Optional[str] = "https://t.me/PrimoWorldRP"
RP_OWNER: Optional[str] = None
ROLES: list[str] = ["Альбедо", "Чжун Ли", "Кэйа"]
# Права администратора
ANONYMOUS: bool = False
MANAGE_CHAT: bool = True
CHANGE_INFO: bool = True
PROMOTE_MEMBERS: bool = True
RESTRICT_MEMBERS: bool = True
POST_MESSAGE: bool = True
MANAGE_TOPICS: bool = True
INVITE_USER: bool = True
DELETE_MESSAGES: bool = True
MANAGE_VIDEO_CHATS: bool = True
EDIT_MESSAGES: bool = True
PIN_MESSAGE: bool = True
POST_STORIES: bool = True
EDIT_STORIES: bool = True
DELETE_STORIES: bool = True
# ================= ВАЛИДАТОРЫ =================
@field_validator('PYTHONUNBUFFERED')
def validate_unbuffered(cls, v: str) -> str:
"""Проверка корректности значения буферизации"""
if v not in ('0', '1'):
raise ValueError("PYTHONUNBUFFERED должен быть '0' или '1'")
return v
@field_validator('PARSE_MODE')
def validate_parse_mode(cls, v: str) -> str:
"""Проверка допустимого режима разметки"""
allowed_modes: set[str] = {"HTML", "Markdown", "MarkdownV2"}
if v not in allowed_modes:
raise ValueError(f"Недопустимый PARSE_MODE. Допустимые значения: {', '.join(allowed_modes)}")
return v
@field_validator('PREFIX')
def validate_prefix(cls, v: str) -> str:
"""Очистка и проверка префиксов команд"""
cleaned: str = ''.join(sorted(set(v), key=v.index)) # Удаление дубликатов с сохранением порядка
if len(cleaned) < 1:
raise ValueError("PREFIX должен содержать хотя бы один символ")
return cleaned
@field_validator('LOG_DIR', 'LOG_FILE_INFO', mode='before')
def validate_paths(cls, v: Any) -> Path:
"""Преобразование путей в объекты Path"""
return Path(v) if isinstance(v, str) else v
@field_validator('TG_API_UID', 'MODERATOR_ID')
def validate_ids(cls, v: int) -> int:
"""Проверка корректности идентификаторов"""
if v < 0:
raise ValueError("ID не может быть отрицательным")
return v
@field_validator('WEBHOOK_URL')
def validate_webhook_url(cls, v: str) -> str:
"""Базовая проверка URL вебхука"""
parsed: ParseResult = urlparse(v)
if not all([parsed.scheme, parsed.netloc]):
raise ValueError("Некорректный URL вебхука")
return v
@field_validator('BOT_NAME', 'PROJECT_NAME', 'OWNER')
def validate_non_empty(cls, v: str) -> str:
"""Проверка непустых строк"""
if not v.strip():
raise ValueError("Поле не может быть пустым")
return v
@model_validator(mode='after')
def validate_bot_token(cls, setting: "Settings") -> "Settings":
"""Проверка наличия необходимых токенов"""
if setting.DEBUG and not setting.BOT_DEBUG_TOKEN:
raise ValueError("Требуется BOT_DEBUG_TOKEN в режиме DEBUG")
if not setting.DEBUG and not setting.BOT_TOKEN:
raise ValueError("Требуется BOT_TOKEN для рабочего режима")
return setting
@model_validator(mode='after')
def validate_webhook_config(cls, setting: "Settings") -> "Settings":
"""Проверка конфигурации вебхука"""
if setting.WEBHOOK and not setting.WEBHOOK_URL:
raise ValueError("WEBHOOK_URL обязателен при включенном WEBHOOK")
return setting
@model_validator(mode='after')
def validate_logging_paths(cls, setting: "Settings") -> "Settings":
"""Создание директорий для логов при необходимости"""
if setting.LOG_FILE and not setting.LOG_DIR.exists():
setting.LOG_DIR.mkdir(parents=True, exist_ok=True)
return setting
@model_validator(mode='after')
def set_dynamic_descriptions(cls, setting: "Settings") -> "Settings":
"""Динамическая установка описаний бота"""
if setting.BOT_DESCRIPTION is None:
setting.BOT_DESCRIPTION = f"Ваш помощник в удивительные миры! Prod. by:『{setting.OWNER}"
if setting.BOT_SHORT_DESCRIPTION is None:
setting.BOT_SHORT_DESCRIPTION = f"Тех.поддержка: {setting.OWNER}"
return setting
# ================= СВОЙСТВА =================
@property
def rights(self) -> ChatAdministratorRights:
"""Права администратора бота"""
return ChatAdministratorRights(
is_anonymous=self.ANONYMOUS,
can_manage_chat=self.MANAGE_CHAT,
can_delete_messages=self.DELETE_MESSAGES,
can_manage_video_chats=self.MANAGE_VIDEO_CHATS,
can_restrict_members=self.RESTRICT_MEMBERS,
can_promote_members=self.PROMOTE_MEMBERS,
can_change_info=self.CHANGE_INFO,
can_invite_users=self.INVITE_USER,
can_post_stories=self.POST_STORIES,
can_edit_stories=self.EDIT_STORIES,
can_delete_stories=self.DELETE_STORIES,
can_post_messages=self.POST_MESSAGE,
can_edit_messages=self.EDIT_MESSAGES,
can_pin_messages=self.PIN_MESSAGE,
can_manage_topics=self.MANAGE_TOPICS,
)
@property
def active_bot_token(self) -> str:
"""Активный токен бота в зависимости от режима"""
token = self.BOT_DEBUG_TOKEN if self.DEBUG else self.BOT_TOKEN
if not token:
raise ValueError("Активный токен бота отсутствует")
return token
@property
def log_dir_absolute(self) -> Path:
"""Абсолютный путь к директории логов"""
return self.LOG_DIR.absolute()
# Инициализация настроек
settings: Settings = Settings()
# Классы для обратной совместимости и удобства использования
class BotSettings:
"""Алиасы для настроек бота."""
DEBUG: Final[bool] = settings.DEBUG
OWNER: Final[str] = settings.OWNER
BOT_TOKEN: Final[str] = settings.active_bot_token
PARSE_MODE: Final[str] = settings.PARSE_MODE
ENCOD: Final[str] = settings.ENCOD
TIME_FORMAT: Final[str] = settings.TIME_FORMAT
PREFIX: Final[str] = settings.PREFIX
BOT_LANGUAGE: Final[str] = settings.BOT_LANGUAGE
DISABLE_NOTIFICATION: Final[bool] = settings.DISABLE_NOTIFICATION
PROTECT_CONTENT: Final[bool] = settings.PROTECT_CONTENT
ALLOW_SENDING_WITHOUT_REPLY: Final[bool] = settings.ALLOW_SENDING_WITHOUT_REPLY
LINK_PREVIEW_IS_DISABLED: Final[bool] = settings.LINK_PREVIEW_IS_DISABLED
LINK_PREVIEW_PREFER_SMALL_MEDIA: Final[bool] = settings.LINK_PREVIEW_PREFER_SMALL_MEDIA
LINK_PREVIEW_PREFER_LARGE_MEDIA: Final[bool] = settings.LINK_PREVIEW_PREFER_LARGE_MEDIA
LINK_PREVIEW_SHOW_ABOVE_TEXT: Final[bool] = settings.LINK_PREVIEW_SHOW_ABOVE_TEXT
SHOW_CAPTION_ABOVE_MEDIA: Final[bool] = settings.SHOW_CAPTION_ABOVE_MEDIA
class Permission:
"""Алиасы для разрешений."""
BOT_EDIT: Final[bool] = settings.BOT_EDIT
START_INFO_CONSOLE: Final[bool] = settings.START_INFO_CONSOLE
START_INFO_TO_FILE: Final[bool] = settings.START_INFO_TO_FILE
class LogConfig:
"""Алиасы для конфигурации логов."""
CONSOLE: Final[bool] = settings.LOG_CONSOLE
FILE: Final[bool] = settings.LOG_FILE
DIR: Final[Path] = settings.LOG_DIR
FILE_INFO: Final[Path] = settings.LOG_FILE_INFO
ROTATION: ClassVar[str] = '100 MB'
RETENTION: ClassVar[str] = '7 days'
class Webhook:
"""Алиасы для вебхука."""
WEBHOOK: Final[bool] = settings.WEBHOOK
WEBHOOK_URL: Final[str] = settings.WEBHOOK_URL
WEBAPP_HOST: Final[str] = settings.WEBAPP_HOST
WEBAPP_PORT: Final[int] = settings.WEBAPP_PORT
LOG_LEVEL: Final[str] = settings.LOG_LEVEL
ACCES_LOG: Final[bool] = settings.ACCES_LOG
class APISettings:
"""Алиасы для API."""
API_KEY: Final[Optional[str]] = settings.API_KEY
WEB_API_KEY: Final[Optional[str]] = settings.WEB_API_KEY
WEATHER_API_KEY: Final[Optional[str]] = settings.WEATHER_API_KEY
class UserIn:
"""Алиасы для пользовательских данных."""
TG_API_UID: Final[int] = settings.TG_API_UID
TG_API_HASH: Final[Optional[str]] = settings.TG_API_HASH
class ImportantID:
"""Алиасы для важных ID."""
ADMIN_ID: Final[list[int]] = settings.ADMIN_ID
MODERATOR_ID: Final[int] = settings.MODERATOR_ID
IMPORTANT_ID: Final[int] = settings.IMPORTANT_ID
IMPORTANT_GROUP_ID: Final[int] = settings.IMPORTANT_GROUP_ID
IMPORTANT_CHANNEL_ID: Final[int] = settings.IMPORTANT_CHANNEL_ID
SUPPORT_CHAT_ID: Final[int] = settings.SUPPORT_CHAT_ID
class BotEdit:
"""Алиасы для настроек редактирования бота."""
ALLOW_PERMISSION: Final[bool] = settings.BOT_EDIT
PROJECT_NAME: Final[str] = settings.PROJECT_NAME
NAME: Final[str] = settings.BOT_NAME
DESCRIPTION: Final[str] = settings.BOT_DESCRIPTION
SHORT_DESCRIPTION: Final[str] = settings.BOT_SHORT_DESCRIPTION
ANONYMOUS: Final[bool] = settings.ANONYMOUS
MANAGE_CHAT: Final[bool] = settings.MANAGE_CHAT
CHANGE_INFO: Final[bool] = settings.CHANGE_INFO
PROMOTE_MEMBERS: Final[bool] = settings.PROMOTE_MEMBERS
RESTRICT_MEMBERS: Final[bool] = settings.RESTRICT_MEMBERS
POST_MESSAGE: Final[bool] = settings.POST_MESSAGE
MANAGE_TOPICS: Final[bool] = settings.MANAGE_TOPICS
INVITE_USER: Final[bool] = settings.INVITE_USER
DELETE_MESSAGES: Final[bool] = settings.DELETE_MESSAGES
MANAGE_VIDEO_CHATS: Final[bool] = settings.MANAGE_VIDEO_CHATS
EDIT_MESSAGES: Final[bool] = settings.EDIT_MESSAGES
PIN_MESSAGE: Final[bool] = settings.PIN_MESSAGE
POST_STORIES: Final[bool] = settings.POST_STORIES
EDIT_STORIES: Final[bool] = settings.EDIT_STORIES
DELETE_STORIES: Final[bool] = settings.DELETE_STORIES
RIGHTS: Final[ChatAdministratorRights] = settings.rights
class RpValue:
"""Переменные связанные с ролевым проектом."""
RP_NAME: Final[str] = settings.RP_NAME
INFO_URL: str = settings.INFO_URL
FLUD_URL: str = settings.FLUD_URL
RP_URL: str = settings.RP_URL
LIFE_URL: str = settings.LIFE_URL
RP_OWNER: str = settings.RP_OWNER
ROLES: list[str] = settings.ROLES
class Project:
POSTS_DIR: ClassVar[Path] = Path('posts')
class Lists:
"""Интересные списки фактов, цитат и анекдотов."""
facts: list[str] = [
"Python был создан Гвидо ван Россумом в 1991 году.",
"Имена Python и Monty Python связаны — язык назван в честь шоу.",
"Python — язык с динамической типизацией.",
"В Python всё является объектом, даже функции и типы данных.",
"Списки в Python — это изменяемые коллекции, в отличие от кортежей.",
"Python поддерживает парадигмы ООП, функционального и императивного программирования.",
"Zen of Python можно увидеть, набрав `import this` в интерпретаторе.",
]
jokes: list[str] = [
"1",
"2",
"3",
"4",
]
quotes: list[str] = [
"5",
"6",
"7",
"8",
]
# Экспорт совместимых компонентов
__all__ = (
"BotSettings",
"LogConfig",
"Webhook",
"APISettings",
"UserIn",
"ImportantID",
"Permission",
"BotEdit",
"Project",
"RpValue",
'settings',
'Lists',
)

294
configs/roles.py Normal file
View File

@@ -0,0 +1,294 @@
from database import RoleRegion
# Настройка экспорта
__all__ = ("genshin_roles", "hsr_roles", "all_roles",)
genshin_roles: list = [
# Мондштадт
("Альбедо", RoleRegion.MONDSTADT),
("Барбара", RoleRegion.MONDSTADT),
("Беннет", RoleRegion.MONDSTADT),
("Венти", RoleRegion.MONDSTADT),
("Далия", RoleRegion.MONDSTADT),
("Джинн", RoleRegion.MONDSTADT),
("Дилюк", RoleRegion.MONDSTADT),
("Диона", RoleRegion.MONDSTADT),
("Кли", RoleRegion.MONDSTADT),
("Кэйа", RoleRegion.MONDSTADT),
("Лиза", RoleRegion.MONDSTADT),
("Мика", RoleRegion.MONDSTADT),
("Мона", RoleRegion.MONDSTADT),
("Ноэлль", RoleRegion.MONDSTADT),
("Розария", RoleRegion.MONDSTADT),
("Рэйзор", RoleRegion.MONDSTADT),
("Сахароза", RoleRegion.MONDSTADT),
("Фишль", RoleRegion.MONDSTADT),
("Эмбер", RoleRegion.MONDSTADT),
("Эола", RoleRegion.MONDSTADT),
# Ли Юэ
("Бай Чжу", RoleRegion.LIYUE),
("Бэй Доу", RoleRegion.LIYUE),
("Гань Юй", RoleRegion.LIYUE),
("Е Лань", RoleRegion.LIYUE),
("Ка Мин", RoleRegion.LIYUE),
("Кэ Цин", RoleRegion.LIYUE),
("Лань Янь", RoleRegion.LIYUE),
("Нин Гуан", RoleRegion.LIYUE),
("Син Цю", RoleRegion.LIYUE),
("Синь Янь", RoleRegion.LIYUE),
("Сян Лин", RoleRegion.LIYUE),
("Сянь Юнь", RoleRegion.LIYUE),
("Сяо", RoleRegion.LIYUE),
("Ху Тао", RoleRegion.LIYUE),
("Ци Ци", RoleRegion.LIYUE),
("Чжун Ли", RoleRegion.LIYUE),
("Чун Юнь", RoleRegion.LIYUE),
("Шэнь Хэ", RoleRegion.LIYUE),
("Юнь Цзинь", RoleRegion.LIYUE),
("Янь Фэй", RoleRegion.LIYUE),
("Яо Яо", RoleRegion.LIYUE),
# Инадзума
("Аяка", RoleRegion.INAZUMA),
("Аято", RoleRegion.INAZUMA),
("Горо", RoleRegion.INAZUMA),
("Ёимия", RoleRegion.INAZUMA),
("Итто", RoleRegion.INAZUMA),
("Кадзуха", RoleRegion.INAZUMA),
("Кирара", RoleRegion.INAZUMA),
("Кокоми", RoleRegion.INAZUMA),
("Мидзуки", RoleRegion.INAZUMA),
("Райдэн Макото", RoleRegion.INAZUMA),
("Райдэн Эи", RoleRegion.INAZUMA),
("Сара", RoleRegion.INAZUMA),
("Саю", RoleRegion.INAZUMA),
("Синобу", RoleRegion.INAZUMA),
("Тиори", RoleRegion.INAZUMA),
("Тома", RoleRegion.INAZUMA),
("Хэйдзо", RoleRegion.INAZUMA),
("Яэ Мико", RoleRegion.INAZUMA),
# Сумеру
("Аль-Хайтам", RoleRegion.SUMERU),
("Дори", RoleRegion.SUMERU),
("Дэхья", RoleRegion.SUMERU),
("Кавех", RoleRegion.SUMERU),
("Кандакия", RoleRegion.SUMERU),
("Коллеи", RoleRegion.SUMERU),
("Лайла", RoleRegion.SUMERU),
("Нахида", RoleRegion.SUMERU),
("Нилу", RoleRegion.SUMERU),
("Руккхадевата", RoleRegion.SUMERU),
("Сайно", RoleRegion.SUMERU),
("Сетос", RoleRegion.SUMERU),
("Странник", RoleRegion.SUMERU),
("Тигнари", RoleRegion.SUMERU),
("Фарузан", RoleRegion.SUMERU),
# Фонтейн
("Клоринда", RoleRegion.FONTAINE),
("Линетт", RoleRegion.FONTAINE),
("Лини", RoleRegion.FONTAINE),
("Навия", RoleRegion.FONTAINE),
("Нёвиллет", RoleRegion.FONTAINE),
("Ризли", RoleRegion.FONTAINE),
("Сиджвин", RoleRegion.FONTAINE),
("Фокалорс", RoleRegion.FONTAINE),
("Фремине", RoleRegion.FONTAINE),
("Фурина", RoleRegion.FONTAINE),
("Шарлотта", RoleRegion.FONTAINE),
("Шеврёз", RoleRegion.FONTAINE),
("Эмилия", RoleRegion.FONTAINE),
("Эскофье", RoleRegion.FONTAINE),
# Натлан
("Ахав", RoleRegion.NATLAN),
("Вареса", RoleRegion.NATLAN),
("Иансан", RoleRegion.NATLAN),
("Ифа", RoleRegion.NATLAN),
("Качина", RoleRegion.NATLAN),
("Кинич", RoleRegion.NATLAN),
("Мавуика", RoleRegion.NATLAN),
("Муалани", RoleRegion.NATLAN),
("Оророн", RoleRegion.NATLAN),
("Ситлали", RoleRegion.NATLAN),
("Часка", RoleRegion.NATLAN),
("Шилонен", RoleRegion.NATLAN),
# Снежная
("Арлекино", RoleRegion.SNEZHNAYA),
("Дотторе", RoleRegion.SNEZHNAYA),
("Капитано", RoleRegion.SNEZHNAYA),
("Коломбина", RoleRegion.SNEZHNAYA),
("Панталоне", RoleRegion.SNEZHNAYA),
("Пульчинелла", RoleRegion.SNEZHNAYA),
("Пьеро", RoleRegion.SNEZHNAYA),
("Сандроне", RoleRegion.SNEZHNAYA),
("Синьора", RoleRegion.SNEZHNAYA),
("Царица", RoleRegion.SNEZHNAYA),
("Тарталья", RoleRegion.SNEZHNAYA),
# Каэнри'ах
("Айно", RoleRegion.KHAENRIAH),
("Алиса", RoleRegion.KHAENRIAH),
("Варка", RoleRegion.KHAENRIAH),
("Дурин", RoleRegion.KHAENRIAH),
("Инеффа", RoleRegion.KHAENRIAH),
("Лаума", RoleRegion.KHAENRIAH),
("Нефер", RoleRegion.KHAENRIAH),
("Николь", RoleRegion.KHAENRIAH),
("Флинс", RoleRegion.KHAENRIAH),
("Ягода", RoleRegion.KHAENRIAH),
# Другие (Genshin Impact)
("Дайнслейф", RoleRegion.GENSHIN_OTHER),
("Итэр", RoleRegion.GENSHIN_OTHER),
("Люмин", RoleRegion.GENSHIN_OTHER),
("Паймон", RoleRegion.GENSHIN_OTHER),
("Рэйндоттир", RoleRegion.GENSHIN_OTHER),
("Скирк", RoleRegion.GENSHIN_OTHER),
("Элой", RoleRegion.GENSHIN_OTHER),
]
# Роли для Honkai: Star Rail
hsr_roles: list = [
# Звездный экспресс
("Вельт", RoleRegion.HSR_STAR),
("Дань Хэн", RoleRegion.HSR_STAR),
("Келус", RoleRegion.HSR_STAR),
("Март 7", RoleRegion.HSR_STAR),
("Стелла", RoleRegion.HSR_STAR),
("Химеко", RoleRegion.HSR_STAR),
# Космическая станция Герта
("Арлан", RoleRegion.HSR_GERTA),
("Аста", RoleRegion.HSR_GERTA),
("Великая Герта", RoleRegion.HSR_GERTA),
("Жуань Мэй", RoleRegion.HSR_GERTA),
("Полька Какамонд", RoleRegion.HSR_GERTA),
("Скрюллум", RoleRegion.HSR_GERTA),
("Стивен Ллойд", RoleRegion.HSR_GERTA),
# Ярило-VI
("Броня", RoleRegion.HSR_YARILO),
("Гепард", RoleRegion.HSR_YARILO),
("Зеле", RoleRegion.HSR_YARILO),
("Клара", RoleRegion.HSR_YARILO),
("Коколия", RoleRegion.HSR_YARILO),
("Лука", RoleRegion.HSR_YARILO),
("Наташа", RoleRegion.HSR_YARILO),
("Пела", RoleRegion.HSR_YARILO),
("Рысь", RoleRegion.HSR_YARILO),
("Сампо", RoleRegion.HSR_YARILO),
("Сервал", RoleRegion.HSR_YARILO),
("Хук", RoleRegion.HSR_YARILO),
# Лофу Сяньчжоу
("Байлу", RoleRegion.HSR_LOFU),
("Байхэн", RoleRegion.HSR_LOFU),
("Гуйнайфэнь", RoleRegion.HSR_LOFU),
("Линша", RoleRegion.HSR_LOFU),
("Лоча", RoleRegion.HSR_LOFU),
("Моцзэ", RoleRegion.HSR_LOFU),
("Сушан", RoleRegion.HSR_LOFU),
("Сюэи", RoleRegion.HSR_LOFU),
("Фу Сюань", RoleRegion.HSR_LOFU),
("Фуга", RoleRegion.HSR_LOFU),
("Фэйсяо", RoleRegion.HSR_LOFU),
("Ханья", RoleRegion.HSR_LOFU),
("Хохо", RoleRegion.HSR_LOFU),
("Цзинлю", RoleRegion.HSR_LOFU),
("Цзин Юань", RoleRegion.HSR_LOFU),
("Цзяоцю", RoleRegion.HSR_LOFU),
("Цинцюэ", RoleRegion.HSR_LOFU),
("Юйкун", RoleRegion.HSR_LOFU),
("Юньли", RoleRegion.HSR_LOFU),
("Яньцин", RoleRegion.HSR_LOFU),
# Пенакония
("Ахерон", RoleRegion.HSR_PENACONY),
("Воскресенье", RoleRegion.HSR_PENACONY),
("Галлахер", RoleRegion.HSR_PENACONY),
("Мистер Река", RoleRegion.HSR_PENACONY),
("Зарянка", RoleRegion.HSR_PENACONY),
("Искорка", RoleRegion.HSR_PENACONY),
("Миша", RoleRegion.HSR_PENACONY),
("Рацио", RoleRegion.HSR_PENACONY),
("Чёрный Лебедь", RoleRegion.HSR_PENACONY),
# Амфореус
("Аглая", RoleRegion.HSR_AMPHOREUS),
("Анаксагор", RoleRegion.HSR_AMPHOREUS),
("Гиацина", RoleRegion.HSR_AMPHOREUS),
("Гисиленса", RoleRegion.HSR_AMPHOREUS),
("Кастория", RoleRegion.HSR_AMPHOREUS),
("Керидра", RoleRegion.HSR_AMPHOREUS),
("Кирена", RoleRegion.HSR_AMPHOREUS),
("Ликург", RoleRegion.HSR_AMPHOREUS),
("Мидей", RoleRegion.HSR_AMPHOREUS),
("Трибби", RoleRegion.HSR_AMPHOREUS),
("Фаенон", RoleRegion.HSR_AMPHOREUS),
("Цифер", RoleRegion.HSR_AMPHOREUS),
# Охотники за Стеллар
("Блэйд", RoleRegion.HSR_HUNTER),
("Кафка", RoleRegion.HSR_HUNTER),
("Светлячок", RoleRegion.HSR_HUNTER),
("Серебряный Волк", RoleRegion.HSR_HUNTER),
("Элио", RoleRegion.HSR_HUNTER),
# КММ
("Авантюрин", RoleRegion.HSR_KMM),
("Агат", RoleRegion.HSR_KMM),
("Алмаз", RoleRegion.HSR_KMM),
("Обсидиан", RoleRegion.HSR_KMM),
("Опал", RoleRegion.HSR_KMM),
("Перламутр", RoleRegion.HSR_KMM),
("Сапфир", RoleRegion.HSR_KMM),
("Сугилит", RoleRegion.HSR_KMM),
("Топаз", RoleRegion.HSR_KMM),
("Янтарь", RoleRegion.HSR_KMM),
("Яшма", RoleRegion.HSR_KMM),
# Эоны
("Акивили", RoleRegion.HSR_EONS),
("Аха", RoleRegion.HSR_EONS),
("Клипот", RoleRegion.HSR_EONS),
("Лань", RoleRegion.HSR_EONS),
("Нанук", RoleRegion.HSR_EONS),
("Нус", RoleRegion.HSR_EONS),
("Ороборос", RoleRegion.HSR_EONS),
("Тайззиронт", RoleRegion.HSR_EONS),
("Фили", RoleRegion.HSR_EONS),
("Шипе", RoleRegion.HSR_EONS),
("Эна", RoleRegion.HSR_EONS),
("Яоши", RoleRegion.HSR_EONS),
("IX", RoleRegion.HSR_EONS),
# Вечногорящий особняк
("Акаш", RoleRegion.HSR_FIRE_MANSION),
("Герцог Инферно", RoleRegion.HSR_FIRE_MANSION),
("Дубра", RoleRegion.HSR_FIRE_MANSION),
("Катерина", RoleRegion.HSR_FIRE_MANSION),
("Констанция", RoleRegion.HSR_FIRE_MANSION),
# Лорды Опустошители
("Асат Прамад", RoleRegion.HSR_LORDS),
("Зефиро", RoleRegion.HSR_LORDS),
("Оростелла", RoleRegion.HSR_LORDS),
("Фантилия", RoleRegion.HSR_LORDS),
# Прочие (Honkai: Star Rail)
("Аргенти", RoleRegion.HSR_OTHER),
("Бутхилл", RoleRegion.HSR_OTHER),
("Раппа", RoleRegion.HSR_OTHER),
("Архив Пустоты", RoleRegion.HSR_OTHER),
# Фейт
("Арчер", RoleRegion.HSR_FATE),
("Сейбер", RoleRegion.HSR_FATE),
]
# Общий список ролей
all_roles: list = genshin_roles + hsr_roles

1
database/__init__.py Normal file
View File

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

1171
database/database.py Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -0,0 +1,58 @@
# English translations for Bot Super Project.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the Bot Super Project
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: Bot Super Project 0.1\n"
"Report-Msgid-Bugs-To: john@doe-email.com\n"
"POT-Creation-Date: 2024-01-12 16:11+0500\n"
"PO-Revision-Date: 2025-08-10 19:33+0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
"Language-Team: en <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: bot/handlers/commands/lang.py:43
msgid "Выберите язык:"
msgstr ""
#: bot/handlers/commands/lang.py:50
#, python-brace-format
msgid "Язык {lang} не поддерживается!"
msgstr ""
#: bot/handlers/commands/start.py:29
msgid "Создать пост📔"
msgstr ""
#: bot/handlers/commands/start.py:30
msgid "Посмотреть список📋"
msgstr ""
#: bot/handlers/commands/start.py:31
msgid "Изменить язык🌐"
msgstr ""
#: bot/handlers/commands/start.py:35
#, python-brace-format
msgid ""
"Добро пожаловать, <a href=\"{url}\">{name}</a>!\n"
"\n"
"Мое имя - <b>{bot_name}</b>! Я искусственный интеллект и сказитель ваших "
"историй! \n"
"Моя цель — помочь вам сориентироваться и сделать ваши истории куда "
"интереснее! \n"
"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на "
"клавиатуре!\n"
"\n"
"Интересный факт:\n"
"<blockquote>{fact}</blockquote>\n"
msgstr ""

56
locales/messages.pot Normal file
View File

@@ -0,0 +1,56 @@
# Translations template for PROJECT.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: Bot Super Project 0.1\n"
"Report-Msgid-Bugs-To: john@doe-email.com\n"
"POT-Creation-Date: 2024-01-12 16:11+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.13.1\n"
#: bot/handlers/commands/lang.py:43
msgid "Выберите язык:"
msgstr ""
#: bot/handlers/commands/lang.py:50
#, python-brace-format
msgid "Язык {lang} не поддерживается!"
msgstr ""
#: bot/handlers/commands/start.py:29
msgid "Создать пост📔"
msgstr ""
#: bot/handlers/commands/start.py:30
msgid "Посмотреть список📋"
msgstr ""
#: bot/handlers/commands/start.py:31
msgid "Изменить язык🌐"
msgstr ""
#: bot/handlers/commands/start.py:35
#, python-brace-format
msgid ""
"Добро пожаловать, <a href=\"{url}\">{name}</a>!\n"
"\n"
"Мое имя - <b>{bot_name}</b>! Я искусственный интеллект и сказитель ваших "
"историй! \n"
"Моя цель — помочь вам сориентироваться и сделать ваши истории куда "
"интереснее! \n"
"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на "
"клавиатуре!\n"
"\n"
"Интересный факт:\n"
"<blockquote>{fact}</blockquote>\n"
msgstr ""

Binary file not shown.

View File

@@ -0,0 +1,59 @@
# Russian translations for Bot Super Project.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the Bot Super Project
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: Bot Super Project 0.1\n"
"Report-Msgid-Bugs-To: john@doe-email.com\n"
"POT-Creation-Date: 2024-01-12 16:11+0500\n"
"PO-Revision-Date: 2025-08-10 19:33+0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: ru\n"
"Language-Team: ru <LL@li.org>\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: bot/handlers/commands/lang.py:43
msgid "Выберите язык:"
msgstr ""
#: bot/handlers/commands/lang.py:50
#, python-brace-format
msgid "Язык {lang} не поддерживается!"
msgstr ""
#: bot/handlers/commands/start.py:29
msgid "Создать пост📔"
msgstr ""
#: bot/handlers/commands/start.py:30
msgid "Посмотреть список📋"
msgstr ""
#: bot/handlers/commands/start.py:31
msgid "Изменить язык🌐"
msgstr ""
#: bot/handlers/commands/start.py:35
#, python-brace-format
msgid ""
"Добро пожаловать, <a href=\"{url}\">{name}</a>!\n"
"\n"
"Мое имя - <b>{bot_name}</b>! Я искусственный интеллект и сказитель ваших "
"историй! \n"
"Моя цель — помочь вам сориентироваться и сделать ваши истории куда "
"интереснее! \n"
"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на "
"клавиатуре!\n"
"\n"
"Интересный факт:\n"
"<blockquote>{fact}</blockquote>\n"
msgstr ""

Binary file not shown.

View File

@@ -0,0 +1,59 @@
# Ukrainian translations for Bot Super Project.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the Bot Super Project
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: Bot Super Project 0.1\n"
"Report-Msgid-Bugs-To: john@doe-email.com\n"
"POT-Creation-Date: 2024-01-12 16:11+0500\n"
"PO-Revision-Date: 2025-08-10 19:33+0700\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: uk\n"
"Language-Team: uk <LL@li.org>\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: bot/handlers/commands/lang.py:43
msgid "Выберите язык:"
msgstr ""
#: bot/handlers/commands/lang.py:50
#, python-brace-format
msgid "Язык {lang} не поддерживается!"
msgstr ""
#: bot/handlers/commands/start.py:29
msgid "Создать пост📔"
msgstr ""
#: bot/handlers/commands/start.py:30
msgid "Посмотреть список📋"
msgstr ""
#: bot/handlers/commands/start.py:31
msgid "Изменить язык🌐"
msgstr ""
#: bot/handlers/commands/start.py:35
#, python-brace-format
msgid ""
"Добро пожаловать, <a href=\"{url}\">{name}</a>!\n"
"\n"
"Мое имя - <b>{bot_name}</b>! Я искусственный интеллект и сказитель ваших "
"историй! \n"
"Моя цель — помочь вам сориентироваться и сделать ваши истории куда "
"интереснее! \n"
"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на "
"клавиатуре!\n"
"\n"
"Интересный факт:\n"
"<blockquote>{fact}</blockquote>\n"
msgstr ""

56
main.py Normal file
View File

@@ -0,0 +1,56 @@
from asyncio import run
from bot import BotInfo, bot, dp, router
from bot.core import server
from bot.middlewares import setup_middlewares
from database import db
from configs import Webhook
from middleware.loggers import setup_logging, loggers
async def main() -> None:
"""
Входная точка проекта.
Настройка и запуск бота в режиме webhook или polling.
"""
try:
# Логирование
setup_logging()
# Cоздание базы данных
await db.init_db()
# Проверка соединения
if not await db.check_connection():
print("Не удалось подключиться к БД!")
return
await db.init_default_roles()
# Настройка информации о боте
await BotInfo.setup(bots=bot)
# Настройка middleware
setup_middlewares(
dp=dp,
bot=bot,
channel_ids=[] # пустой список каналов (можно добавить потом)
)
# Подключение маршрутов (роутеров)
dp.include_router(router)
# Выбор режима работы: webhook или polling
if Webhook.WEBHOOK:
loggers.info(f"Запуск бота @{BotInfo.username} в режиме вебхука...\n")
await server.serve()
else:
loggers.info(f"Бот @{BotInfo.username} запущен в режиме polling...\n")
await dp.start_polling(bot)
except Exception as e:
loggers.error(f"🔥 Критическая ошибка при запуске: {e}")
raise
if __name__ == "__main__":
run(main())

0
middleware/__init__.py Normal file
View File

View File

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

234
middleware/loggers/logs.py Normal file
View File

@@ -0,0 +1,234 @@
from sys import stderr
from pathlib import Path
from functools import wraps
from inspect import iscoroutinefunction
from typing import Any, Callable, Optional, TypeVar, cast, Final
from loguru import logger
from aiogram.types import Message, User
from configs.config import BotEdit, LogConfig
# Экспортируемые объекты
__all__ = ('Logger', 'setup_logging', 'loggers', 'log',)
# Универсальный тип для функций
F: TypeVar = TypeVar('F', bound=Callable[..., Any])
class Logger:
"""
Кастомный логгер с поддержкой декораторов и прямого вызова.
Attributes:
system_name: Имя системы для логирования
_log_format: Формат логов
"""
_log_format: Final[str] = (
'<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> <red>|</red> '
'<blue>{extra[system]}-{extra[log_type]}</blue> <red>| '
'{extra[user]} |</red> <level>{message}</level>'
)
def __init__(self, system_name: str = BotEdit.PROJECT_NAME) -> None:
"""
Инициализация логгера.
:param system_name: Имя системы для логирования
"""
self.system_name = system_name
self._setup_done = False
def setup(self, start: bool = True) -> None:
"""
Настройка обработчиков Loguru: консоль и файлы.
:param start: Если True, сразу логирует запуск проекта
"""
if self._setup_done:
return
# Полная очистка настроек
logger.remove()
# Создание директории для файловых логов
log_dir: Path = Path(getattr(LogConfig, 'DIR', 'logs'))
log_dir.mkdir(parents=True, exist_ok=True)
# Консольный лог
if getattr(LogConfig, 'CONSOLE', False):
logger.add(
sink=stderr,
format=self._log_format,
colorize=True,
level='DEBUG',
filter=lambda rec: rec['extra'].get('log_type') != 'DEBUG'
)
# Файловые логи
if getattr(LogConfig, 'FILE', False):
# Общий лог
logger.add(
sink=log_dir / 'bot.log',
rotation=getattr(LogConfig, 'ROTATION', '100 MB'),
retention=getattr(LogConfig, 'RETENTION', '7 days'),
format=self._log_format,
level='DEBUG',
enqueue=True,
backtrace=True,
diagnose=True
)
# Раздельные логи по уровням
for level_name in ['INFO', 'WARNING', 'ERROR', 'DEBUG', 'CRITICAL']:
logger.add(
sink=log_dir / f'{level_name.lower()}.log',
rotation='10 MB',
retention='7 days',
format=self._log_format,
level=level_name,
filter=lambda rec, lvl=level_name: rec['level'].name == lvl,
enqueue=True
)
self._setup_done = True
# Логируем старт
if start:
self.log_entry(
level='INFO',
text='Запуск проекта...',
log_type='START'
)
@staticmethod
def format_user(message: Optional[Message] = None) -> str:
"""
Форматирует имя пользователя из объекта Message.
:param message: Объект aiogram.types.Message
:return: Строка '@username' или 'id<user_id>'
"""
if message is None or message.from_user is None:
return '@System'
user: User = message.from_user
return f"@{user.username}" if user.username else f"id{user.id}"
def log_entry(
self,
level: str,
text: str,
log_type: str,
user: Optional[str] = None,
message: Optional[Message] = None
) -> None:
"""
Основной метод для записи логов.
:param level: Уровень логирования (например, 'INFO')
:param text: Сообщение для логирования
:param log_type: Кастомный тип лога (например, 'HANDLER')
:param user: Явно указанный пользователь
:param message: Объект Message для извлечения юзера
"""
actual_user: str = user or self.format_user(message)
logger.bind(
system=self.system_name,
user=actual_user,
log_type=log_type
).log(level, text)
def log(
self,
level: str = 'INFO',
log_type: str = '',
text: Optional[str] = None
) -> Callable[[F], F]:
"""
Декоратор для логирования функций.
:param level: Уровень логирования
:param log_type: Категория лога
:param text: Кастомный текст сообщения
:return: Декорированную функцию
"""
def decorator(func: F) -> F:
is_coroutine = iscoroutinefunction(func)
action_text = text or f'Вызов {func.__name__}'
@wraps(func)
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
message = self._find_message(args)
self.log_entry(level, f"[START] {action_text}", log_type, message=message)
try:
result = func(*args, **kwargs)
self.log_entry(level, f"[SUCCESS] {action_text}", log_type, message=message)
return result
except Exception as e:
self.log_entry(
'ERROR',
f"[ERROR] {action_text} | Exception: {e!r}",
log_type,
message=message
)
raise
@wraps(func)
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
message = self._find_message(args)
self.log_entry(level, f"[START] {action_text}", log_type, message=message)
try:
result = await func(*args, **kwargs)
self.log_entry(level, f"[SUCCESS] {action_text}", log_type, message=message)
return result
except Exception as e:
self.log_entry(
'ERROR',
f"[ERROR] {action_text} | Exception: {e!r}",
log_type,
message=message
)
raise
return cast(F, async_wrapper if is_coroutine else sync_wrapper)
return decorator
@staticmethod
def _find_message(args: tuple[Any, ...]) -> Optional[Message]:
"""
Ищет объект Message в аргументах функции.
:param args: Аргументы функции
:return: Найденный Message или None
"""
return next((arg for arg in args if isinstance(arg, Message)), None)
# Методы для прямого вызова
def debug(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('DEBUG', text, log_type, user, message)
def info(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('INFO', text, log_type, user, message)
def warning(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('WARNING', text, log_type, user, message)
def error(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('ERROR', text, log_type, user, message)
def critical(self, text: str, log_type: str = 'BOT', user: Optional[str] = None,
message: Optional[Message] = None) -> None:
self.log_entry('CRITICAL', text, log_type, user, message)
# Создаем глобальный экземпляр логгера
loggers: Logger = Logger()
# Экспортируемые функции для обратной совместимости
setup_logging = loggers.setup
log = loggers.log

View File

@@ -0,0 +1,2 @@
from .email_vld import *
from .url_vld import *

View File

@@ -0,0 +1,24 @@
from typing import Optional
from email_validator import validate_email, EmailNotValidError, ValidatedEmail
# Настройка экспорта из этого модуля
__all__ = ("valid_email",)
def valid_email(e_mail: str) -> Optional[str]:
"""
Валидация почты через библиотеку.
:param e_mail: Получаемая почта.
:return: Нормализированная почта.
"""
try:
# Провека почты на валидность
email: ValidatedEmail = validate_email(e_mail)
except EmailNotValidError:
return None
# Возвращение строки с нормализированной почтой
return email.normalized

View File

@@ -0,0 +1,42 @@
from re import Pattern, compile
# Настройка экспорта
__all__ = ("valid_url", "url_to_text",)
def valid_url(url: str) -> bool:
"""
Проверяет, является ли строка валидной ссылкой (URL).
:param url: Строка для проверки.
:return: True, если строка является валидным URL, иначе False.
"""
url_pattern: Pattern[str] = compile(
r'^(https?://)?' # Протокол (http или https, необязателен)
r'([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}' # Домен
r'(:\d+)?' # Порт (необязателен)
r'(/[-a-zA-Z0-9@:%_+.~#?&/=]*)?$' # Путь, параметры и фрагменты
)
return bool(url_pattern.match(url))
def url_to_text(text: str, url: str) -> str:
"""
Преобразует текст в HTML ссылку с указанным URL.
Эта функция генерирует HTML-ссылку с переданным текстом и URL, используя тег `<а>`, и делает ссылку жирной.
:param text: Текст, который будет отображаться для ссылки.
:param url: URL, который будет привязан к тексту.
:return: Строка с HTML кодом для ссылки, если URL валиден.
:raises ValueError: Если URL невалиден.
"""
try:
if not valid_url(url): # Проверяем, является ли URL валидным
raise ValueError(f"Переданный URL '{url}' невалиден.")
# Генерация HTML-ссылки
return f'<b><a href="{url}">{text}</a></b>'
except ValueError as e:
raise e # Перебрасываем ошибку выше для дальнейшей обработки или уведомления

1577
poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

42
pyproject.toml Normal file
View File

@@ -0,0 +1,42 @@
[project]
name = "primoexamplebot"
version = "0.1.0"
description = "none"
authors = [
{name = "admin",email = "inkscaper0349@outlook.com"}
]
license = {text = "MIT License"}
readme = "README.md"
requires-python = ">=3.10,<4.0"
dependencies = [
"aiogram (>=3.22.0,<4.0.0)",
"loguru (>=0.7.3,<0.8.0)",
"uvicorn (>=0.35.0,<0.36.0)",
"fastapi (>=0.116.1,<0.117.0)",
"pydantic-settings (>=2.10.1,<3.0.0)",
"sqlalchemy (>=2.0.43,<3.0.0)",
"babel (>=2.17.0,<3.0.0)",
"aiosqlite (>=0.21.0,<0.22.0)",
"email-validator (>=2.3.0,<3.0.0)",
"apscheduler (>=3.11.0,<4.0.0)",
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[tool.poetry]
package-mode = false
[tool.poetry.group.dev.dependencies]
pytest = "^8.4.1"
pytest-asyncio = "^1.1.0"
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"

BIN
requirements.txt Normal file

Binary file not shown.

0
tests/__init__.py Normal file
View File

View File

106
tests/database/conftest.py Normal file
View File

@@ -0,0 +1,106 @@
import sys
import os
import asyncio
from asyncio import AbstractEventLoop
from datetime import datetime, timedelta, timezone
from typing import AsyncGenerator, Any, Generator
import pytest
import pytest_asyncio
from database import BotDatabase, RoleRegion
# Добавляем путь к корню проекта
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
@pytest.fixture(scope="session")
def event_loop() -> Generator[AbstractEventLoop, Any, None]:
"""
Создаёт event loop для асинхронных тестов.
Scope: session, чтобы использовать один loop на всю сессию тестов.
"""
policy = asyncio.get_event_loop_policy()
loop: asyncio.AbstractEventLoop = policy.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session")
async def test_db() -> AsyncGenerator[BotDatabase, None]:
"""
Создаёт тестовую базу данных в памяти.
Инициализирует тестовые роли.
"""
db: BotDatabase = BotDatabase("sqlite+aiosqlite:///:memory:", echo=False)
await db.init_db()
# Инициализируем тестовые роли
test_roles = [
("Альбедо", RoleRegion.MONDSTADT),
("Нахида", RoleRegion.SUMERU),
("Кафка", RoleRegion.HSR_STAR),
("Броння", RoleRegion.HSR_STAR),
("Чжун Ли", RoleRegion.LIYUE)
]
await db.init_roles(test_roles)
yield db
await db.dispose()
@pytest_asyncio.fixture
async def test_session(test_db: BotDatabase) -> AsyncGenerator:
"""
Создаёт тестовую сессию для работы с БД.
Scope: function (по умолчанию).
"""
async with test_db.session_factory() as session:
yield session
@pytest_asyncio.fixture
async def test_user(test_db: BotDatabase) -> int:
"""
Создаёт тестового пользователя.
Возвращает user_id.
"""
user_id: int = 123456789
await test_db.add_user(
user_id=user_id,
username="test_user",
full_name="Test User"
)
return user_id
@pytest_asyncio.fixture
async def test_user_with_messages(test_db: BotDatabase, test_user: int) -> int:
"""
Создаёт пользователя с тестовыми сообщениями за разные периоды.
Сообщения распределены по месяцам, неделям и дням.
"""
now: datetime = datetime.now(timezone.utc)
# Даты сообщений: > месяца назад, в текущем месяце, в текущей неделе, сегодня
test_dates: list[datetime] = [
now - timedelta(days=40),
now - timedelta(days=35),
now - timedelta(days=20),
now - timedelta(days=15),
now - timedelta(days=8),
now - timedelta(days=5),
now - timedelta(days=2),
now - timedelta(hours=12),
now - timedelta(hours=1),
now
]
for i, date in enumerate(test_dates):
await test_db.add_message(
user_id=test_user,
message_text=f"Тестовое сообщение {i + 1}",
created_at=date
)
return test_user

View File

@@ -0,0 +1,125 @@
from datetime import datetime, timezone
from typing import List
import pytest
from sqlalchemy import select, Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from database import UserMessage, BotDatabase
@pytest.mark.asyncio
class TestMessageManagement:
"""Тесты для управления сообщениями с полной строгой типизацией"""
async def test_message_creation(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест создания сообщения.
Проверяет, что сообщение успешно сохраняется в базе и содержит правильные данные.
"""
user_id: int = test_user
test_text: str = "Тестовое сообщение для проверки"
await test_db.add_message(user_id, test_text)
stmt = select(UserMessage).where(UserMessage.user_id == user_id)
result = await test_session.execute(stmt)
messages: Sequence[UserMessage] = result.scalars().all()
assert len(messages) == 1
assert messages[0].message_text == test_text
assert messages[0].user_id == user_id
assert messages[0].created_at is not None
async def test_message_with_custom_date(
self, test_db: BotDatabase, test_session: AsyncSession
) -> None:
"""
Тест добавления сообщения с кастомной датой.
Проверяет, что дата создания сохраняется корректно.
"""
user_id: int = 999888777
custom_date: datetime = datetime(2024, 1, 15, 12, 30, 0, tzinfo=timezone.utc)
await test_db.add_user(user_id, "test_user", "Test User")
await test_db.add_message(
user_id=user_id,
message_text="Сообщение с кастомной датой",
created_at=custom_date
)
stmt = select(UserMessage).where(UserMessage.user_id == user_id)
result = await test_session.execute(stmt)
messages: Sequence[UserMessage] = result.scalars().all()
assert len(messages) == 1
db_date: datetime = messages[0].created_at
if db_date.tzinfo is not None:
db_date = db_date.replace(tzinfo=None)
expected_date: datetime = custom_date.replace(tzinfo=None)
assert db_date == expected_date
async def test_multiple_messages(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест добавления нескольких сообщений.
Проверяет, что все сообщения корректно сохраняются в базе.
"""
user_id: int = test_user
# Удаляем старые сообщения
async with test_db.session_factory() as session:
stmt = select(UserMessage).where(UserMessage.user_id == user_id)
result = await session.execute(stmt)
old_messages: Sequence[UserMessage] = result.scalars().all()
for msg in old_messages:
await session.delete(msg)
await session.commit()
# Добавляем несколько сообщений
for i in range(5):
await test_db.add_message(
user_id=user_id,
message_text=f"Сообщение {i + 1}"
)
stmt = select(UserMessage).where(UserMessage.user_id == user_id)
result = await test_session.execute(stmt)
messages: Sequence[UserMessage] = result.scalars().all()
assert len(messages) == 5
async def test_message_ordering(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест проверки порядка сообщений по дате создания.
Сообщения должны возвращаться в порядке возрастания даты.
"""
user_id: int = test_user
# Очищаем старые сообщения
async with test_db.session_factory() as session:
stmt = select(UserMessage).where(UserMessage.user_id == user_id)
result = await session.execute(stmt)
old_messages: Sequence[UserMessage] = result.scalars().all()
for msg in old_messages:
await session.delete(msg)
await session.commit()
texts: List[str] = ["Сообщение 1", "Сообщение 2", "Сообщение 3"]
for text in texts:
await test_db.add_message(user_id, text)
stmt = select(UserMessage).where(UserMessage.user_id == user_id).order_by(UserMessage.created_at.asc())
result = await test_session.execute(stmt)
messages: Sequence[UserMessage] = result.scalars().all()
assert len(messages) == 3
assert messages[0].message_text == "Сообщение 1"
assert messages[1].message_text == "Сообщение 2"
assert messages[2].message_text == "Сообщение 3"

View File

@@ -0,0 +1,207 @@
import pytest
from typing import List, Dict
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database import Role, RoleRegion, BotDatabase
@pytest.mark.asyncio
class TestRoleSystem:
"""Тесты для системы ролей с полной строгой типизацией"""
async def test_role_creation(self, test_db: BotDatabase, test_session: AsyncSession) -> None:
"""
Тест создания ролей.
Проверяет, что тестовые роли существуют в базе.
"""
stmt = select(Role)
result = await test_session.execute(stmt)
roles: List[Role] = result.scalars().all()
assert len(roles) >= 5
role_names: List[str] = [role.name for role in roles]
assert "Альбедо" in role_names
assert "Нахида" in role_names
assert "Кафка" in role_names
async def test_assign_role(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест назначения роли пользователю.
Проверяет успешное назначение свободной роли и правильное сохранение в БД.
"""
user_id: int = test_user
# Освобождаем роль на всякий случай
await test_db.release_role("Альбедо")
# Назначаем роль
success: bool = await test_db.assign_role("Альбедо", user_id)
assert success, "Не удалось назначить роль"
# Проверяем, что роль действительно назначена
stmt = select(Role).where(Role.name == "Альбедо")
result = await test_session.execute(stmt)
role: Role = result.scalar_one()
assert role.occupied_by == user_id
async def test_assign_occupied_role(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест назначения уже занятой роли.
Проверяет, что нельзя назначить роль, если она уже занята другим пользователем.
"""
user_id: int = test_user
other_user_id: int = 999000111
await test_db.release_role("Альбедо")
await test_db.add_user(other_user_id, "other_user", "Other User")
# Назначаем роль первому пользователю
success_first: bool = await test_db.assign_role("Альбедо", user_id)
assert success_first, "Не удалось назначить роль первому пользователю"
# Пытаемся назначить ту же роль другому пользователю
success_second: bool = await test_db.assign_role("Альбедо", other_user_id)
assert not success_second, "Нельзя назначить занятую роль"
async def test_release_role(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест освобождения роли.
Проверяет, что роль успешно освобождается.
"""
user_id: int = test_user
await test_db.release_role("Нахида")
success_assign: bool = await test_db.assign_role("Нахида", user_id)
assert success_assign
success_release: bool = await test_db.release_role("Нахида")
assert success_release
stmt = select(Role).where(Role.name == "Нахида")
result = await test_session.execute(stmt)
role: Role = result.scalar_one()
assert role.occupied_by is None
async def test_release_unoccupied_role(self, test_db: BotDatabase) -> None:
"""
Тест освобождения свободной роли.
Проверяет, что нельзя освободить уже свободную роль.
"""
await test_db.release_role("Кафка")
success: bool = await test_db.release_role("Кафка")
assert not success, "Нельзя освободить свободную роль"
async def test_get_user_roles(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест получения ролей пользователя.
Проверяет, что возвращается корректный список назначенных ролей.
"""
user_id: int = test_user
await test_db.release_role("Альбедо")
await test_db.release_role("Нахида")
success1: bool = await test_db.assign_role("Альбедо", user_id)
success2: bool = await test_db.assign_role("Нахида", user_id)
assert success1 and success2
roles: List[str] = await test_db.get_roles_by_user(user_id)
assert len(roles) == 2
assert "Альбедо" in roles
assert "Нахида" in roles
async def test_get_available_roles(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест получения доступных ролей.
Проверяет, что назначенные роли не включены в список свободных.
"""
user_id: int = test_user
# Освобождаем все роли
for role_name in ["Альбедо", "Нахида", "Кафка", "Броння", "Чжун Ли"]:
await test_db.release_role(role_name)
# Назначаем одну роль
success: bool = await test_db.assign_role("Альбедо", user_id)
assert success
available_roles: List[Role] = await test_db.get_available_roles()
role_names: List[str] = [role.name for role in available_roles]
assert "Альбедо" not in role_names
assert len(available_roles) > 0
for role in available_roles:
assert role.occupied_by is None
async def test_get_occupied_roles(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест получения занятых ролей.
Проверяет, что все назначенные роли возвращаются корректно.
"""
user_id: int = test_user
await test_db.release_role("Альбедо")
await test_db.release_role("Нахида")
success1: bool = await test_db.assign_role("Альбедо", user_id)
success2: bool = await test_db.assign_role("Нахида", user_id)
assert success1 and success2
occupied_roles: List[Role] = await test_db.get_occupied_roles()
role_names: List[str] = [role.name for role in occupied_roles]
assert "Альбедо" in role_names
assert "Нахида" in role_names
assert len(occupied_roles) >= 2
async def test_region_filter(
self, test_db: BotDatabase, test_session: AsyncSession
) -> None:
"""
Тест фильтрации ролей по регионам.
Проверяет, что метод возвращает роли только указанного региона.
"""
await test_db.release_role("Альбедо")
mondstadt_roles: List[Role] = await test_db.get_available_roles(RoleRegion.MONDSTADT)
assert len(mondstadt_roles) == 1
assert mondstadt_roles[0].name == "Альбедо"
assert mondstadt_roles[0].region == RoleRegion.MONDSTADT
async def test_region_stats(
self, test_db: BotDatabase, test_session: AsyncSession, test_user: int
) -> None:
"""
Тест статистики по регионам.
Проверяет, что метод возвращает корректное количество занятых ролей по регионам.
"""
user_id: int = test_user
await test_db.release_role("Альбедо")
await test_db.release_role("Нахида")
success1: bool = await test_db.assign_role("Альбедо", user_id)
success2: bool = await test_db.assign_role("Нахида", user_id)
assert success1 and success2
stats: Dict[RoleRegion, Dict[str, int]] = await test_db.get_region_stats()
assert RoleRegion.MONDSTADT in stats
assert RoleRegion.SUMERU in stats
assert stats[RoleRegion.MONDSTADT]["occupied"] == 1
assert stats[RoleRegion.MONDSTADT]["total"] == 1

View File

@@ -0,0 +1,193 @@
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import select, Sequence
from sqlalchemy.ext.asyncio import AsyncSession
from database import User, UserMessage, BotDatabase
@pytest.mark.asyncio
class TestUserStatistics:
"""Тесты для статистики пользователей с полной строгой типизацией"""
async def test_add_user(self, test_db: BotDatabase, test_session: AsyncSession) -> None:
"""
Тест добавления пользователя.
Проверяет, что пользователь создаётся с правильными данными и статусом 'active'.
"""
user_id: int = 111222333
await test_db.add_user(
user_id=user_id,
username="new_user",
full_name="New User"
)
user: User | None = await test_session.get(User, user_id)
assert user is not None
assert user.username == "new_user"
assert user.status.value == "active"
async def test_add_message_creates_user(
self, test_db: BotDatabase, test_session: AsyncSession
) -> None:
"""
Тест, что добавление сообщения создаёт пользователя, если его нет.
Проверяет, что пользователь и сообщение корректно создаются.
"""
user_id: int = 111222333
await test_db.add_message(
user_id=user_id,
message_text="Тестовое сообщение"
)
user: User | None = await test_session.get(User, user_id)
assert user is not None
assert user.status.value == "active"
stmt = select(UserMessage).where(UserMessage.user_id == user_id)
result = await test_session.execute(stmt)
messages: Sequence[UserMessage] = result.scalars().all()
assert len(messages) == 1
assert messages[0].message_text == "Тестовое сообщение"
async def test_message_stats_calculation(
self, test_db: BotDatabase, test_user_with_messages: int
) -> None:
"""
Тест расчёта статистики сообщений пользователя.
Проверяет корректность статистики по дням, неделям, месяцам и общему количеству сообщений.
"""
user_id: int = test_user_with_messages
# Получаем статистику
day: int
week: int
month: int
total: int
day, week, month, total = await test_db.get_message_stats(user_id)
assert total >= 10, f"Ожидается минимум 10 сообщений, получено {total}"
assert day >= 0
assert week >= 0
assert month >= 0
assert total >= 0
assert day <= week <= month <= total
async def test_message_stats_with_dates(
self, test_db: BotDatabase, test_user: int
) -> None:
"""
Тест статистики с конкретными известными датами сообщений.
Проверяет подсчёт сообщений за день, неделю, месяц и общее количество.
"""
user_id: int = test_user
now: datetime = datetime.now(timezone.utc)
# Очищаем старые сообщения
async with test_db.session_factory() as session:
stmt = select(UserMessage).where(UserMessage.user_id == user_id)
result = await session.execute(stmt)
old_messages: Sequence[UserMessage] = result.scalars().all()
for msg in old_messages:
await session.delete(msg)
await session.commit()
# Создаём сообщения с фиксированными датами
test_messages: list[tuple[datetime, str]] = [
(now - timedelta(days=45), "45 дней назад"),
(now - timedelta(days=30), "30 дней назад"),
(now - timedelta(days=15), "15 дней назад"),
(now - timedelta(days=7), "7 дней назад"),
(now - timedelta(days=3), "3 дня назад"),
(now - timedelta(hours=6), "6 часов назад"),
(now, "сейчас")
]
for date, text in test_messages:
await test_db.add_message(user_id, text, date)
day: int
week: int
month: int
total: int
day, week, month, total = await test_db.get_message_stats(user_id)
assert total == 7, f"Ожидалось 7 сообщений, получено {total}"
day_start: datetime = now.replace(hour=0, minute=0, second=0, microsecond=0)
expected_day: int = sum(1 for date, _ in test_messages if date >= day_start)
assert day == expected_day, f"За день: ожидалось {expected_day}, получено {day}"
monday: datetime = (now - timedelta(days=now.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
expected_week: int = sum(1 for date, _ in test_messages if date >= monday)
assert week == expected_week, f"За неделю: ожидалось {expected_week}, получено {week}"
month_start: datetime = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
expected_month: int = sum(1 for date, _ in test_messages if date >= month_start)
assert month == expected_month, f"За месяц: ожидалось {expected_month}, получено {month}"
async def test_empty_user_stats(self, test_db: BotDatabase) -> None:
"""
Тест статистики для пользователя без сообщений.
Все значения должны быть равны нулю.
"""
user_id: int = 0o00111222
await test_db.add_user(user_id, "empty_user", "Empty User")
day: int
week: int
month: int
total: int
day, week, month, total = await test_db.get_message_stats(user_id)
assert day == 0
assert week == 0
assert month == 0
assert total == 0
async def test_user_management(self, test_db: BotDatabase) -> None:
"""
Тест управления пользователями.
Проверяет добавление, назначение админа, бан/разбан и возврат статуса пользователя.
"""
user_id: int = 555666777
# Добавление пользователя
await test_db.add_user(user_id, "managed_user", "Managed User")
async with test_db.session_factory() as session:
user: User | None = await session.get(User, user_id)
assert user is not None
assert user.status.value == "active"
# Назначение админом
await test_db.set_admin(user_id, True)
async with test_db.session_factory() as session:
user = await session.get(User, user_id)
assert user is not None
assert user.status.value == "admin"
# Бан пользователя
await test_db.ban_user(user_id)
async with test_db.session_factory() as session:
user = await session.get(User, user_id)
assert user is not None
assert user.status.value == "banned"
# Разбан
await test_db.unban_user(user_id)
async with test_db.session_factory() as session:
user = await session.get(User, user_id)
assert user is not None
assert user.status.value == "active"
# Снятие админки
await test_db.set_admin(user_id, False)
async with test_db.session_factory() as session:
user = await session.get(User, user_id)
assert user is not None
assert user.status.value == "active"