commit 0f05fc8455ab7c8211e2d6ac98ab4403d2821f62 Author: admin Date: Mon Sep 8 00:40:18 2025 +0700 типо да diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b1d6af --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.env_example b/.env_example new file mode 100644 index 0000000..5bd9dfa --- /dev/null +++ b/.env_example @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5a679a5 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..952ead7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,73 @@ +# .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/ +test/ +tests/ +Test/ +Tests/ +count.py diff --git a/.idea/PrimoAranarBot.iml b/.idea/PrimoAranarBot.iml new file mode 100644 index 0000000..7338d77 --- /dev/null +++ b/.idea/PrimoAranarBot.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..5d2654d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..a3659fd --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,258 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {} + { + "isMigrated": true +} + { + "associatedIndex": 6 +} + + + + { + "keyToString": { + "ModuleVcsDetector.initialDetectionPerformed": "true", + "Python.main.executor": "Run", + "RunOnceActivity.ShowReadmeOnStart": "true", + "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager": "true", + "RunOnceActivity.git.unshallow": "true", + "git-widget-placeholder": "master", + "last_opened_file_path": "C:/Users/admin/Documents/Projects/Python/PrimoAranarBot", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "settings.editor.selected.configurable": "com.jetbrains.python.configuration.PyActiveSdkModuleConfigurable", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1754643627985 + + + + + + + + + + file://$PROJECT_DIR$/bot/handlers/__init__.py + + + file://$PROJECT_DIR$/bot/handlers/secret/secret1.py + + + file://$PROJECT_DIR$/bot/handlers/messages/__init__.py + + + file://$PROJECT_DIR$/bot/handlers/messages/default.py + 59 + + + file://$PROJECT_DIR$/bot/core/__init__.py + + + + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4cbd8d7 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..889866f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e963fd8 Binary files /dev/null and b/README.md differ diff --git a/assets/default.jpg b/assets/default.jpg new file mode 100644 index 0000000..a024412 Binary files /dev/null and b/assets/default.jpg differ diff --git a/assets/start.jpg b/assets/start.jpg new file mode 100644 index 0000000..2fb9564 Binary files /dev/null and b/assets/start.jpg differ diff --git a/bot/__init__.py b/bot/__init__.py new file mode 100644 index 0000000..f61f236 --- /dev/null +++ b/bot/__init__.py @@ -0,0 +1,3 @@ +from .core import * +from .handlers import * +from .middlewares import * diff --git a/bot/core/__init__.py b/bot/core/__init__.py new file mode 100644 index 0000000..f6efb9b --- /dev/null +++ b/bot/core/__init__.py @@ -0,0 +1,2 @@ +from .bots import * +from .webhook import * diff --git a/bot/core/bots.py b/bot/core/bots.py new file mode 100644 index 0000000..7d4166c --- /dev/null +++ b/bot/core/bots.py @@ -0,0 +1,203 @@ +from datetime import datetime + +from asyncio import sleep +from aiogram import Bot, Dispatcher +from aiogram.client.default import DefaultBotProperties +from aiogram.exceptions import TelegramRetryAfter +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") + +# FSM-хранилище и диспетчер +storage: MemoryStorage = MemoryStorage() +dp: Dispatcher = Dispatcher(storage=storage) +dp["is_active"]: bool = True + +# Локализация +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 = None + url: str | None = None + first_name: str | None = None + last_name: str | None = None + username: str | None = None + description: str | None = None + short_description: str | None = 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: + """ + Удаление или установка вебхука. + """ + await bots.delete_webhook(drop_pending_updates=True) + + if use_webhook: + if webhook_url is None: + raise ValueError("Для установки вебхука необходимо указать webhook_url") + try: + await bots.set_webhook(Webhook.WEBHOOK_URL) + except TelegramRetryAfter as e: + print(f"Flood control: повтор через {e.retry_after} секунд") + await sleep(e.retry_after) + await bots.set_webhook(Webhook.WEBHOOK_URL) + + @classmethod + @log(level="INFO", log_type="BOT", text="Получение информации о боте") + async def info(cls, bots: Bot = bot) -> dict[str, object]: + """ + Получает и сохраняет информацию о боте. + """ + 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.language_code = bot_info.language_code + cls.is_premium = getattr(bot_info, "is_premium", False) + 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, + "language_code": cls.language_code, + "prefix": cls.prefix, + "bot_owner": cls.bot_owner, + } + + @staticmethod + @log(level="INFO", log_type="BOT", text="Установка прав администратора") + async def set_administrator_rights( + bots: Bot = bot, rights: ChatAdministratorRights = BotEdit.RIGHTS + ) -> None: + current_rights: ChatAdministratorRights = await bots.get_my_default_administrator_rights() + if current_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: + 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: + current_description: BotDescription = await bots.get_my_description() + if not (0 < len(new_description) <= 255): + raise ValueError("Описание должно быть от 1 до 255 символов.") + if current_description.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: + current_short: BotShortDescription = await bots.get_my_short_description() + if not (0 < len(new_short) <= 512): + raise ValueError("Короткое описание должно быть от 1 до 512 символов.") + if current_short.short_description != new_short: + await bots.set_my_short_description(short_description=new_short) + + @staticmethod + def start_info_out(out: bool = True) -> 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}") + + if out: + 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: + raise f"Ошибка при получении ID пользователя: {e}" + + @classmethod + @log(level="INFO", log_type="START", text="Процесс запуска бота!") + async def setup(cls, bots: Bot = bot, perm: bool = Permission.BOT_EDIT): + 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) diff --git a/bot/core/webhook.py b/bot/core/webhook.py new file mode 100644 index 0000000..b327004 --- /dev/null +++ b/bot/core/webhook.py @@ -0,0 +1,44 @@ +from typing import Any + +from aiohttp import web +from aiogram.types import Update + +from .bots import dp, bot +from middleware import loggers + +class WebhookApp: + """Приложение aiohttp для обработки webhook-запросов.""" + + def __init__(self, host: str = "0.0.0.0", port: int = 8080) -> None: + self.host = host + self.port = port + self.app: web.Application = web.Application() + self.app.router.add_post("/webhook", self.handle_update) + self.runner: web.AppRunner | None = None + self.site: web.TCPSite | None = None + + @staticmethod + async def handle_update(request: web.Request) -> web.Response: + """Обработчик входящих запросов от Telegram.""" + try: + update_json: dict[str, Any] = await request.json() + update: Update = Update.model_validate(update_json) + await dp.feed_update(bot=bot, update=update) + except Exception as e: + print(f"Ошибка обработки webhook-запроса: {e}") + return web.Response(status=500) + + return web.Response(status=200) + + async def start(self) -> None: + """Асинхронный запуск aiohttp-приложения.""" + self.runner = web.AppRunner(self.app) + await self.runner.setup() + self.site = web.TCPSite(self.runner, self.host, self.port) + await self.site.start() + loggers.info(f"🌍 Webhook сервер запущен на http://{self.host}:{self.port}") + + async def stop(self) -> None: + """Остановка aiohttp-приложения.""" + if self.runner: + await self.runner.cleanup() diff --git a/bot/filters/__init__.py b/bot/filters/__init__.py new file mode 100644 index 0000000..bd658ac --- /dev/null +++ b/bot/filters/__init__.py @@ -0,0 +1,5 @@ +from .callback import * +from .chat_rights import * +from .chat_type import * +from .message_content import * +from .subscrided import * diff --git a/bot/filters/callback.py b/bot/filters/callback.py new file mode 100644 index 0000000..8a51146 --- /dev/null +++ b/bot/filters/callback.py @@ -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)) diff --git a/bot/filters/chat_rights.py b/bot/filters/chat_rights.py new file mode 100644 index 0000000..147a692 --- /dev/null +++ b/bot/filters/chat_rights.py @@ -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 diff --git a/bot/filters/chat_type.py b/bot/filters/chat_type.py new file mode 100644 index 0000000..9b1f1a1 --- /dev/null +++ b/bot/filters/chat_type.py @@ -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"} diff --git a/bot/filters/message_content.py b/bot/filters/message_content.py new file mode 100644 index 0000000..595dc7d --- /dev/null +++ b/bot/filters/message_content.py @@ -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 diff --git a/bot/filters/subscrided.py b/bot/filters/subscrided.py new file mode 100644 index 0000000..a8b019c --- /dev/null +++ b/bot/filters/subscrided.py @@ -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 diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py new file mode 100644 index 0000000..03e67fb --- /dev/null +++ b/bot/handlers/__init__.py @@ -0,0 +1,15 @@ +from aiogram import Router +#from .commands import router as cmd_routers +from .messages import router as messages_routers +from .secret import router as secret_routers + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name=__name__) + +# Подключение роутеров +router.include_routers( + #cmd_routers, +secret_routers, + messages_routers, +) diff --git a/bot/handlers/commands/__init__.py b/bot/handlers/commands/__init__.py new file mode 100644 index 0000000..5ee813c --- /dev/null +++ b/bot/handlers/commands/__init__.py @@ -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, +) diff --git a/bot/handlers/commands/admins/__init__.py b/bot/handlers/commands/admins/__init__.py new file mode 100644 index 0000000..409f762 --- /dev/null +++ b/bot/handlers/commands/admins/__init__.py @@ -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, +) diff --git a/bot/handlers/commands/admins/settings_cmd.py b/bot/handlers/commands/admins/settings_cmd.py new file mode 100644 index 0000000..f65094d --- /dev/null +++ b/bot/handlers/commands/admins/settings_cmd.py @@ -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 = _( + """Добро пожаловать, {name}! + +Я ваш искусственный помощник по ролевой - {rp_name}! +Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще! +Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре! + +Интересный факт: +
{fact}
+""" + ).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) diff --git a/bot/handlers/commands/users/__init__.py b/bot/handlers/commands/users/__init__.py new file mode 100644 index 0000000..84c8ebb --- /dev/null +++ b/bot/handlers/commands/users/__init__.py @@ -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, +) diff --git a/bot/handlers/commands/users/active.py b/bot/handlers/commands/users/active.py new file mode 100644 index 0000000..a964c06 --- /dev/null +++ b/bot/handlers/commands/users/active.py @@ -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,) diff --git a/bot/handlers/commands/users/anketa_cmd.py b/bot/handlers/commands/users/anketa_cmd.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/handlers/commands/users/new_cmd.py b/bot/handlers/commands/users/new_cmd.py new file mode 100644 index 0000000..6e4dc2d --- /dev/null +++ b/bot/handlers/commands/users/new_cmd.py @@ -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"🎉 Ваша анкета принята!\n\nДобро пожаловать в проект!\n\nФлуд: {RpValue.FLUD_URL}\nРолевая: {RpValue.RP_URL}", + "reject": "❌ Ваша анкета отклонена.\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"Проверьте данные анкеты:\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'Анкета\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"Ответ администратора:\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}") \ No newline at end of file diff --git a/bot/handlers/commands/users/start_cmd.py b/bot/handlers/commands/users/start_cmd.py new file mode 100644 index 0000000..d213f48 --- /dev/null +++ b/bot/handlers/commands/users/start_cmd.py @@ -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 = _( + """Добро пожаловать, {name}! + +Я ваш искусственный помощник по ролевой - {rp_name}! +Моя цель — помочь вам сориентироваться и сделать ваше вступление куда проще! +Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на клавиатуре! + +Интересный факт: +
{fact}
+""" + ).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) diff --git a/bot/handlers/messages/__init__.py b/bot/handlers/messages/__init__.py new file mode 100644 index 0000000..c2ab424 --- /dev/null +++ b/bot/handlers/messages/__init__.py @@ -0,0 +1,15 @@ +from aiogram import Router +from .default import router as default_message_router +from .reply_msg import router as reply_message_router + +# Настройка экспорта и роутера +__all__ = ('router',) +router: Router = Router(name=__name__) + +# Подготовка роутера команд +#router.include_routers( +#reply_message_router, +#) + +# Подключение стандартного роутера +router.include_router(default_message_router) diff --git a/bot/handlers/messages/default.py b/bot/handlers/messages/default.py new file mode 100644 index 0000000..5673d21 --- /dev/null +++ b/bot/handlers/messages/default.py @@ -0,0 +1,139 @@ +from typing import Dict, List + +from aiogram import Router +from aiogram.fsm.context import FSMContext +from aiogram.types import Message + +from bot.utils import get_best_response + + +# Настройка экспорта и роутера +__all__ = ("router",) +router: Router = Router(name="message_router") + + +# === Словарь ключевых слов (синонимы) и возможных ответов === +RESPONSES: Dict[str, Dict[str, List[str]]] = { + "док": { + "keywords": ["доктор", "док", "дотторе", "зандик"], + "answers": [ + "Дотторе довольно милый друг! Мне нравится проводить с ним время!", + "Иногда он бывает слишком суровым... Но я верю, что смогу его перевоспитать!", + "Мне иногда кажется, что он знает больше историй, чем хранится в библиотеке!", + "Дотторе говорит загадками... а я всё равно не всегда понимаю!", + "Он умный, но я уверен — внутри он добрый!", + "Дотторе иногда ворчит, но всё равно заботится обо мне по-своему!", + "Он часто думает о науке... а я думаю о печеньках!", + "Мне кажется, он притворяется злым, а на самом деле просто боится дружбы.", + "Когда он работает, в комнате становится тихо... даже огонь боится мешать ему.", + "Я иногда думаю... а улыбается ли он, когда меня не видит?", + ], + }, + "ара": { + "keywords": ["ара", "аранара", "аранары", "ары", "кто ты", "ты кто"], + "answers": [ + "Мы, аранары, очень любим веселиться и смеяться!", + "Хи-хи! 🌱 Ты можешь звать меня Ари!", + "Наш народ живёт уже тысячи лет... но мы не умеем считать!", + "Я маленький грибочек, но у меня большое сердце!", + "Аранара — это хранитель улыбок и весёлых историй!", + "Я люблю играть с детьми и рассказывать им истории!", + "Говорят, что аранары видят то, что скрыто от других.", + "Я — часть этой библиотеки, её дыхание и её смех!", + "Аранара — это маленький проводник в мир грёз и чудес.", + "Мы появляемся там, где нужен друг, даже если никто не звал!", + ], + }, + "малыш": { + "keywords": ["малыш", "девочка", "малышка", "она", "болезнь"], + "answers": [ + "Она милая девочка! Жаль, что больна!", + "Она обожает сказки! Может, именно поэтому засыпает так сладко.", + "А как её зовут?.. Я всегда забываю спросить!", + "Иногда во сне она улыбается... значит, ей снятся хорошие истории.", + "Дотторе грустит, когда смотрит на неё... но я верю, он её спасёт!", + "Она словно светильник в тёмной комнате... даже если свет её тускнеет.", + "Я думаю, её мечты сильнее болезни.", + ], + }, + "эфир": { + "keywords": ["эфир", "проект", "изобретение", "сплав", "эксперимент", "ядро"], + "answers": [ + "Эфир звучит как ветер, который нельзя поймать... но можно почувствовать!", + "Дотторе часто говорит о проектах, но я понимаю в них только половину!", + "Каждый новый сплав для него как новая история для меня.", + "Эксперимент — это как игра, только иногда она пахнет гарью...", + "Я слышал, что ядро может изменить всё... даже судьбы людей.", + "В лаборатории так много звуков — шипение кислот, стук молотов, шёпот формул.", + "Иногда мне кажется, что изобретения Дотторе живут своей жизнью...", + "Эфир? Кефир? ЗЕФИР!", + ], + }, + "мысль": { + "keywords": ["мысл", "мысль", "мысли", "думаешь"], + "answers": [ + "О чём я думаю?.. Иногда о печеньках!", + "Голова как будто полная тумана...", + "Кажется, я что-то забыл... но не могу вспомнить...", + "Мысли приходят и уходят, как маленькие птички.", + "А ты когда-нибудь задумывался, откуда приходят мысли?", + "Иногда мои мысли путаются и превращаются в сказки.", + "Я думаю, что думать — это тяжело... лучше веселиться!", + "Может, мысли — это просто шёпот библиотеки в моей голове?", + "Когда я думаю слишком долго — у меня начинает чесаться макушка!", + "Мысли — как облака... смотришь, и они уже другие.", + ], + }, + "тайн": { + "keywords": ["тайн", "тайны", "тайну", "тайна"], + "answers": [ + "Тайны? О-о, мы играем в детективов?!", + "Я знаю много секретов... но не все можно рассказывать!", + "Иногда самые большие тайны прячутся на виду.", + "Тайна — это как закрытая книга. Ты хочешь открыть её?", + "Хи-хи... а если твоя тайна уже записана в библиотеке?", + "Некоторые тайны лучше хранить, чем раскрывать.", + "Каждый друг — это тоже тайна, которую мы открываем постепенно.", + "А твои секреты я храню надёжнее любого сундука!", + "Тайна — это искра любопытства! Без неё жизнь скучная.", + "Ш-ш-ш... хочешь услышать одну маленькую, но очень смешную тайну?", + ], + }, +} + +# === Случайные фразы, если совпадения нет === +RANDOM_PHRASES: List[str] = [ + "Я Ари! Компаньон Дотторе и ваш лучший друг! Можете обращаться ко мне!", + "Я живу здесь уже десятки лет... и мне всё ещё весело!", + "Кхм... почему ты так странно разговариваешь? Ничего не понимаю!", + "Мы играем в шарады? Давай попробуй ещё раз, может я пойму хоть одно слово!", + "Ты кажешься таким загадочным... прямо как проекты Дотторе, которые меня вечно пугают!", + "Ой! Ты меня напугал! Но всё равно приятно видеть нового друга!", + "Если вдруг станет грустно — просто обними аранару. Мы очень мягкие!", + "Иногда даже мне хочется спрятаться между колб и подремать...", + "А может, именно твоё слово станет началом новой истории?", + "Дотторе говорит, что я слишком болтлив... а разве это плохо?", + "Ты такой серьёзный... может, стоит немного пошутить?", + "Иногда кажется, что слова сами выбирают нас, а не мы их!", +] + + +# === Хэндлеры === +@router.message() +async def handle_message(message: Message, state: FSMContext) -> None: + """ + Обрабатывает входящие сообщения от пользователя. + Определяет ответ по ключевым словам или случайную фразу. + + :param message: объект сообщения + :param state: FSMContext для работы с состояниями + """ + await state.clear() + + response: str = get_best_response( + message.text or "", + responses=RESPONSES, + random_phrases=RANDOM_PHRASES, + ) + + await message.answer(text=response) diff --git a/bot/handlers/messages/reply_msg.py b/bot/handlers/messages/reply_msg.py new file mode 100644 index 0000000..e718873 --- /dev/null +++ b/bot/handlers/messages/reply_msg.py @@ -0,0 +1,39 @@ +from random import choice +from typing import List +from aiogram import Router +from aiogram.fsm.context import FSMContext +from aiogram.types import Message + +router: Router = Router(name="reply_router") + +RANDOM_PHRASES: List[str] = [ + "Бла-бла-бла!", "Хва-а-а-тит!", "Серьёзно? 😏", "Опять ты это говоришь...", + "Хи-хи, смешно же!", "Ты снова шутник?", "Я уже слышал это раньше!", "Эй, не надо так!", + "Ладно, ладно, хватит!", "Хмм... интересно...", "Ты меня удивляешь!", "А давай лучше что-то новое?", + "Не могу поверить!", "Ахаха, это забавно!", "Серьёзно? Ну ладно...", "Эй, это уже слишком!", + "О, это было неожиданно!", +] + + +@router.message() +async def reply_message(message: Message, state: FSMContext) -> None: + # Достаём данные из состояния + data = await state.get_data() + last_bot_text = data.get("last_bot_text", "") + + # КРИТИЧЕСКИ ВАЖНО: Проверяем, что состояние не пустое после перезапуска. + # Если состояние пустое (например, после перезапуска), то мы НЕ должны считать, + if last_bot_text and message.text and message.text.strip() == last_bot_text.strip(): + response = "Не повторяй за мной!" + else: + response = choice(RANDOM_PHRASES) + + ids = message.message_id-1 + print(str()) + + # Отправляем ответ и ПОЛУЧАЕМ ОБЪЕКТ ОТПРАВЛЕННОГО СООБЩЕНИЯ + sent_message = await message.reply(response) + + # Сохраняем текст последнего сообщения бота в состоянии + # Теперь состояние будет обновлено после каждого сообщения бота + await state.update_data(last_bot_text=sent_message.text) \ No newline at end of file diff --git a/bot/handlers/secret/__init__.py b/bot/handlers/secret/__init__.py new file mode 100644 index 0000000..34f24a0 --- /dev/null +++ b/bot/handlers/secret/__init__.py @@ -0,0 +1,13 @@ +from aiogram import Router +from .secret1 import router as secret1_router +#from .secret2 import router as secret2_router + +# Настройка экспорта и роутера +__all__ = ('router',) +router: Router = Router(name=__name__) + +# Подключение секретного роутера +router.include_routers( +secret1_router, +#secret2_router, +) diff --git a/bot/handlers/secret/secret1.py b/bot/handlers/secret/secret1.py new file mode 100644 index 0000000..8c13788 --- /dev/null +++ b/bot/handlers/secret/secret1.py @@ -0,0 +1,45 @@ +from aiogram import Router, F +from aiogram.fsm.context import FSMContext +from aiogram.types import Message +from aiogram.utils.markdown import hide_link + +from middleware.loggers import log + +# Настройки экспорта и роутера +__all__ = ("router",) +router: Router = Router(name="secret_router") +CMD: str = "secret_1" + + +@router.message(F.text.lower() == "истинная цель короля всегда было мироздание") +@log(level='INFO', log_type=CMD.upper(), text=f"использовал команду /{CMD}") +async def secret1_cmd(message: Message, state: FSMContext) -> None: + """Обработчик секретов""" + await state.clear() + + # Формируем приветственное сообщение + text: str = f"""{hide_link("https://rp.primo.dpdns.org/wp-content/uploads/2025/08/1234567.png")} +Запись №-...18 + +
Значит, этот правда действительно существует… Хах.. Хахахах! Я смог найти решение! Я!! СМОГ!!! +Все линии, пропорции, каждый слой металла и кристалла — всё сходится. +Сколько лет я скитался по лабораториям, библиотекам, ища этот след… а ведь всё это время ключ к моей цели лежал прямо перед глазами — на чертеже.
+ +
Получится ли у меня...?\n +Я создаю не просто броню. Я пытаюсь воплотить в материале замысел, который перевернёт всё, что мы знали. +Каждый слой, каждая руна — это шаг к воплощению моей идеи. Даже спустя десятки лет я помню, как возвращал из небытия те конструкции, что раньше казались невозможными…
+ +
Возможно ли, что сама структура материи и магии изменит.. +Или же это моя броня станет первым устройством, способное изменить их мнение?..
+ +
И всё же один вопрос не даёт мне покоя: сможет ли этот замысел завершить то, что я задумал… +Станет ли моя броня инструментом, с помощью которого замысел воплотится в реальность?..
+ +
Пожалуй, придётся ещё раз вернуться к чертежам и проверить расчёты. +Что-то подсказывает мне: каждая линия, каждый символ на этом листе — это не просто металл и руны, это путь к моей великой… идее. ~
+ +Да… это оно. Всё ведёт к замыслу, к который я стремился десятилетиями…""" + + + # Отправляем сообщение + await message.reply(text=text) \ No newline at end of file diff --git a/bot/handlers/secret/secret2.py b/bot/handlers/secret/secret2.py new file mode 100644 index 0000000..3cadc7f --- /dev/null +++ b/bot/handlers/secret/secret2.py @@ -0,0 +1,134 @@ +from aiogram import Router, F +from aiogram.filters import Command, StateFilter +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import Message + +# Создаем роутер +knowledge_router = Router() + + +# Определяем состояния +class KnowledgeStates(StatesGroup): + question1 = State() + question2 = State() + question3 = State() + question4 = State() + question5 = State() + question6 = State() + + +# Вопросы и ответы (замените на свои) +QUESTIONS = { + 1: "Вопрос1", + 2: "Вопрос2", + 3: "Вопрос3", + 4: "Вопрос4", + 5: "Вопрос5", + 6: "Вопрос6" +} + +ANSWERS = { + 1: {"Ответ 11": "СообщениеА1", "Ответ 12": "СообщениеБ1"}, + 2: {"Ответ 21": "СообщениеА2", "Ответ 22": "СообщениеБ2"}, + 3: {"Ответ 31": "СообщениеА3", "Ответ 32": "СообщениеБ3"}, + 4: {"Ответ 41": "СообщениеА4", "Ответ 42": "СообщениеБ4"}, + 5: {"Ответ 51": "СообщениеА5", "Ответ 52": "СообщениеБ5"}, + 6: {"Ответ 61": "СообщениеА6", "Ответ 62": "СообщениеБ6"} +} + +FINAL_MESSAGES = { + "all_1": "ИТОГ1 - Все ответы первого типа!", + "all_2": "ИТОГ2 - Все ответы второго типа!", + "mixed": "ИТОГ1 - Смешанные ответы!" +} + + +# Запуск сессии знаний +@knowledge_router.message(StateFilter(None), Command("знания")) +@knowledge_router.message(StateFilter(None), F.text.casefold() == "пора заняться знаниями") +async def start_knowledge_session(message: Message, state: FSMContext): + await message.answer("Отлично! Начинаем сессию знаний! 🧠") + await message.answer(QUESTIONS[1]) + await state.set_state(KnowledgeStates.question1) + await state.update_data(answers={}) + + +# Обработчики для каждого вопроса +@knowledge_router.message(KnowledgeStates.question1, F.text.in_(ANSWERS[1].keys())) +async def process_question1(message: Message, state: FSMContext): + user_answer = message.text + response_message = ANSWERS[1][user_answer] + + # Сохраняем ответ + answer_code = 1 if user_answer == "Ответ 11" else 2 + await state.update_data(answers={"q1": answer_code}) + + # Отправляем сообщение и следующий вопрос + await message.answer(response_message + "\n\n" + QUESTIONS[2]) + await state.set_state(KnowledgeStates.question2) + + +@knowledge_router.message(KnowledgeStates.question2, F.text.in_(ANSWERS[2].keys())) +async def process_question2(message: Message, state: FSMContext): + user_answer = message.text + response_message = ANSWERS[2][user_answer] + + # Сохраняем ответ + answer_code = 1 if user_answer == "Ответ 21" else 2 + data = await state.get_data() + answers = data.get("answers", {}) + answers["q2"] = answer_code + await state.update_data(answers=answers) + + # Отправляем сообщение и следующий вопрос + await message.answer(response_message + "\n\n" + QUESTIONS[3]) + await state.set_state(KnowledgeStates.question3) + + +# Добавьте аналогичные обработчики для question3-question5 + +@knowledge_router.message(KnowledgeStates.question6, F.text.in_(ANSWERS[6].keys())) +async def process_question6(message: Message, state: FSMContext): + user_answer = message.text + response_message = ANSWERS[6][user_answer] + + # Сохраняем ответ + answer_code = 1 if user_answer == "Ответ 61" else 2 + data = await state.get_data() + answers = data.get("answers", {}) + answers["q6"] = answer_code + await state.update_data(answers=answers) + + # Отправляем финальное сообщение + await message.answer(response_message) + await finish_knowledge_session(message, state) + + +# Обработчики для некорректных ответов +@knowledge_router.message(KnowledgeStates.question1) +async def process_incorrect_answer1(message: Message): + await message.answer("Пожалуйста, выберите один из предложенных вариантов ответа.") + await message.answer(QUESTIONS[1]) + + +@knowledge_router.message(KnowledgeStates.question2) +async def process_incorrect_answer2(message: Message): + await message.answer("Пожалуйста, выберите один из предложенных вариантов ответа.") + await message.answer(QUESTIONS[2]) + + +# Добавьте аналогичные обработчики для остальных вопросов + +# Завершение сессии +async def finish_knowledge_session(message: Message, state: FSMContext): + data = await state.get_data() + answers = data.get("answers", {}) + + # Проверяем результаты + if all(answer == 2 for answer in answers.values()): + await message.answer(FINAL_MESSAGES["all_2"]) + else: + await message.answer(FINAL_MESSAGES["mixed"]) + + await state.clear() diff --git a/bot/keyboards/__init__.py b/bot/keyboards/__init__.py new file mode 100644 index 0000000..fe147a9 --- /dev/null +++ b/bot/keyboards/__init__.py @@ -0,0 +1,2 @@ +from .reply import * +from .inline import * diff --git a/bot/keyboards/inline/__init__.py b/bot/keyboards/inline/__init__.py new file mode 100644 index 0000000..ca2e2a9 --- /dev/null +++ b/bot/keyboards/inline/__init__.py @@ -0,0 +1 @@ +from .decision import * diff --git a/bot/keyboards/inline/decision.py b/bot/keyboards/inline/decision.py new file mode 100644 index 0000000..05d39bd --- /dev/null +++ b/bot/keyboards/inline/decision.py @@ -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() diff --git a/bot/keyboards/reply/__init__.py b/bot/keyboards/reply/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/middlewares/__init__.py b/bot/middlewares/__init__.py new file mode 100644 index 0000000..fc7fe2d --- /dev/null +++ b/bot/middlewares/__init__.py @@ -0,0 +1,48 @@ +from aiogram import Dispatcher, Bot + +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", + "setup_middlewares",) + + +def setup_middlewares(dp: Dispatcher, bot: Bot, channel_ids: list[int | str] = None) -> None: + """ + Регистрирует все middleware в диспетчере. + """ + channel_ids: list = 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) diff --git a/bot/middlewares/error_mdw.py b/bot/middlewares/error_mdw.py new file mode 100644 index 0000000..d122b21 --- /dev/null +++ b/bot/middlewares/error_mdw.py @@ -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 + ) \ No newline at end of file diff --git a/bot/middlewares/logging_mdw.py b/bot/middlewares/logging_mdw.py new file mode 100644 index 0000000..a607fee --- /dev/null +++ b/bot/middlewares/logging_mdw.py @@ -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_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 \ No newline at end of file diff --git a/bot/middlewares/msg_mdw.py b/bot/middlewares/msg_mdw.py new file mode 100644 index 0000000..1feee35 --- /dev/null +++ b/bot/middlewares/msg_mdw.py @@ -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} сохранено в БД") diff --git a/bot/middlewares/spam_mdw.py b/bot/middlewares/spam_mdw.py new file mode 100644 index 0000000..cc6c509 --- /dev/null +++ b/bot/middlewares/spam_mdw.py @@ -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) \ No newline at end of file diff --git a/bot/middlewares/subscription_mdw.py b/bot/middlewares/subscription_mdw.py new file mode 100644 index 0000000..66d8b2d --- /dev/null +++ b/bot/middlewares/subscription_mdw.py @@ -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) \ No newline at end of file diff --git a/bot/middlewares/time_mdw.py b/bot/middlewares/time_mdw.py new file mode 100644 index 0000000..85d85d4 --- /dev/null +++ b/bot/middlewares/time_mdw.py @@ -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 + ) \ No newline at end of file diff --git a/bot/states/__init__.py b/bot/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bot/states/anketa_states.py b/bot/states/anketa_states.py new file mode 100644 index 0000000..c3ba68b --- /dev/null +++ b/bot/states/anketa_states.py @@ -0,0 +1,5 @@ +# bot/states/form.py +from aiogram.fsm.state import State, StatesGroup + +class StartForm(StatesGroup): + waiting_for_application: State = State() diff --git a/bot/states/new_states.py b/bot/states/new_states.py new file mode 100644 index 0000000..f9b5fcf --- /dev/null +++ b/bot/states/new_states.py @@ -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() diff --git a/bot/templates/__init__.py b/bot/templates/__init__.py new file mode 100644 index 0000000..b969334 --- /dev/null +++ b/bot/templates/__init__.py @@ -0,0 +1 @@ +from .message_callback import * diff --git a/bot/templates/message_callback.py b/bot/templates/message_callback.py new file mode 100644 index 0000000..917ebc1 --- /dev/null +++ b/bot/templates/message_callback.py @@ -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 + ) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py new file mode 100644 index 0000000..0fb8d4c --- /dev/null +++ b/bot/utils/__init__.py @@ -0,0 +1,5 @@ +from .interesting_facts import * +from .usernames import * +from .pagination import * +from .type_message import * +from .argument import * diff --git a/bot/utils/argument.py b/bot/utils/argument.py new file mode 100644 index 0000000..3624932 --- /dev/null +++ b/bot/utils/argument.py @@ -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 diff --git a/bot/utils/interesting_facts.py b/bot/utils/interesting_facts.py new file mode 100644 index 0000000..5f17108 --- /dev/null +++ b/bot/utils/interesting_facts.py @@ -0,0 +1,54 @@ +from random import choice +from typing import Dict, List, Optional + +from configs.config import Lists + +__all__ = ("interesting_fact", "get_best_response",) + +def interesting_fact(mode: str = "факт", lists: Optional[list[str]] = None) -> str: + """ + Возвращает случайный факт, анекдот или цитату, в зависимости от режима. + + :param mode: строка, определяющая тип контента ("факт", "анекдот", "цитата"). + :param lists: необязательный список строк, из которого можно выбирать вручную. + :return: случайный элемент из соответствующего списка. + """ + if lists is not None: + return choice(lists) + + mode = 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) + + +def get_best_response( + user_text: str, + responses: Dict[str, Dict[str, List[str]]], + random_phrases: List[str], +) -> str: + """ + Подбирает наиболее подходящий ответ на сообщение пользователя. + Сначала ищет ключевые слова и их синонимы, если совпадений нет — выдаёт случайную фразу. + + :param user_text: текст сообщения пользователя + :param responses: словарь с ключевыми словами и ответами + :param random_phrases: список случайных фраз, если совпадений нет + :return: строка с ответом + """ + normalized_text: str = user_text.lower() + + # Перебор ключевых слов в словаре + for _, data in responses.items(): + for keyword in data["keywords"]: + if keyword in normalized_text: + return choice(data["answers"]) + + # Если совпадений нет — выдаём случайную фразу + return choice(random_phrases) diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py new file mode 100644 index 0000000..ff93cbf --- /dev/null +++ b/bot/utils/pagination.py @@ -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 diff --git a/bot/utils/random_lists.py b/bot/utils/random_lists.py new file mode 100644 index 0000000..290ee3d --- /dev/null +++ b/bot/utils/random_lists.py @@ -0,0 +1,18 @@ +from random import choice + +def get_best_response(user_text: str) -> str: + """ + Подбирает наиболее подходящий ответ на сообщение пользователя. + Сначала ищет ключевые слова и их синонимы, если совпадений нет — выдаёт случайную фразу. + + :param user_text: текст сообщения пользователя + :return: строка с ответом + """ + normalized_text: str = user_text.lower() + + for _, data in RESPONSES.items(): + for keyword in data["keywords"]: + if keyword in normalized_text: + return choice(data["answers"]) + + return choice(RANDOM_PHRASES) diff --git a/bot/utils/type_message.py b/bot/utils/type_message.py new file mode 100644 index 0000000..eab769e --- /dev/null +++ b/bot/utils/type_message.py @@ -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}") diff --git a/bot/utils/usernames.py b/bot/utils/usernames.py new file mode 100644 index 0000000..ad71431 --- /dev/null +++ b/bot/utils/usernames.py @@ -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 # Перебрасываем ошибку выше для дальнейшей обработки diff --git a/configs/__init__.py b/configs/__init__.py new file mode 100644 index 0000000..8846c59 --- /dev/null +++ b/configs/__init__.py @@ -0,0 +1,3 @@ +from .config import * +from .cmd_list import * +from .roles import * diff --git a/configs/cmd_list.py b/configs/cmd_list.py new file mode 100644 index 0000000..9ec6853 --- /dev/null +++ b/configs/cmd_list.py @@ -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", + ] +} diff --git a/configs/config.py b/configs/config.py new file mode 100644 index 0000000..f0f82ea --- /dev/null +++ b/configs/config.py @@ -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 + WEBHOOK_HOST: Final[str] = settings.WEBAPP_HOST + WEBHOOK_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', +) diff --git a/configs/roles.py b/configs/roles.py new file mode 100644 index 0000000..5e27e88 --- /dev/null +++ b/configs/roles.py @@ -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 diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..318dc67 --- /dev/null +++ b/database/__init__.py @@ -0,0 +1 @@ +from .database import * \ No newline at end of file diff --git a/database/database.py b/database/database.py new file mode 100644 index 0000000..b9f5bb4 --- /dev/null +++ b/database/database.py @@ -0,0 +1,1171 @@ +from __future__ import annotations + +import enum +from datetime import datetime, timedelta, timezone +from typing import Optional, Tuple, List, Protocol, runtime_checkable, Dict, Any, Union + +from sqlalchemy import ( + BigInteger, + String, + DateTime, + ForeignKey, + Text, + Enum as SAEnum, + func, + select, + and_, + Integer, case, +) +from sqlalchemy import text as sql_text +from sqlalchemy.engine import Result +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import ( + DeclarativeBase, + Mapped, + mapped_column, + relationship, +) +from sqlalchemy.sql import Select + +__all__: Tuple[str, ...] = ( + "UserStatus", + "User", + "UserMessage", + "Role", + "RoleRegion", + "RoleMessage", + "BotDatabase", + "db" +) + + +# ====================================================== +# База декларативных моделей (SQLAlchemy 2.0 style) +# ====================================================== +class Base(DeclarativeBase): + """Базовый класс декларативных моделей SQLAlchemy.""" + pass + + +# ====================================================== +# Перечисления / константы +# ====================================================== +class UserStatus(str, enum.Enum): + """ + Статус пользователя в системе. + + Значения: + - ACTIVE — обычный пользователь + - ADMIN — администратор + - BANNED — заблокирован + """ + ACTIVE = "active" + ADMIN = "admin" + BANNED = "banned" + + +class RoleRegion(str, enum.Enum): + """ + Регионы персонажей в играх. + + Значения для Genshin Impact: + - MONDSTADT - Мондштадт + - LIYUE - Ли Юэ + - INAZUMA - Инадзума + - SUMERU - Сумеру + - FONTAINE - Фонтейн + - NATLAN - Натлан + - SNEZHNAYA - Снежная + - KHAENRIAH - Каэнри'ах + - GENSHIN_OTHER - Другие (Genshin Impact) + + Значения для Honkai: Star Rail: + - HSR_STAR - Звездный экспресс + - HSR_GERTA - Космическая станция Герта + - HSR_YARILO - Ярило-VI + - HSR_LOFU - Лофу Сяньчжоу + - HSR_PENACONY - Пенакония + - HSR_AMPHOREUS - Амфореус + - HSR_HUNTER - Охотники за Стеллар + - HSR_KMM - КММ + - HSR_EONS - Эоны + - HSR_FIRE_MANSION - Вечногорящий особняк + - HSR_LORDS - Лорды Опустошители + - HSR_OTHER - Прочие (Honkai: Star Rail) + - HSR_FATE - Фейт + """ + # Genshin Impact регионы + MONDSTADT = "Мондштадт" + LIYUE = "Ли Юэ" + INAZUMA = "Инадзума" + SUMERU = "Сумеру" + FONTAINE = "Фонтейн" + NATLAN = "Натлан" + SNEZHNAYA = "Снежная" + KHAENRIAH = "Каэнри'ах" + GENSHIN_OTHER = "Другие (Genshin Impact)" + + # Honkai: Star Rail регионы + HSR_STAR = "Звездный экспресс" + HSR_GERTA = "Космическая станция Герта" + HSR_YARILO = "Ярило-VI" + HSR_LOFU = "Лофу Сяньчжоу" + HSR_PENACONY = "Пенакония" + HSR_AMPHOREUS = "Амфореус" + HSR_HUNTER = "Охотники за Стеллар" + HSR_KMM = "КММ" + HSR_EONS = "Эоны" + HSR_FIRE_MANSION = "Вечногорящий особняк" + HSR_LORDS = "Лорды Опустошители" + HSR_OTHER = "Прочие (Honkai: Star Rail)" + HSR_FATE = "Фейт" + + +# ====================================================== +# Протоколы для минимальной типизации aiogram-сообщений +# (чтобы не тянуть aiogram как зависимость, но иметь строгие типы) +# ====================================================== +@runtime_checkable +class SupportsUser(Protocol): + """Протокол для объекта пользователя с обязательными полями.""" + id: int + username: Optional[str] + full_name: Optional[str] + + +@runtime_checkable +class SupportsAiogramMessage(Protocol): + """Протокол для объекта сообщения с обязательными полями.""" + from_user: SupportsUser + text: Optional[str] + + +@runtime_checkable +class SupportsAiogramBot(Protocol): + """Протокол для объекта бота с методом редактирования сообщений.""" + + async def edit_message_text( + self, + chat_id: Union[int, str], + message_id: int, + text: str, + **kwargs: Any + ) -> Any: + ... + + +# ====================================================== +# Модели +# ====================================================== +class User(Base): + """ + Модель пользователя Telegram. + + Таблица: + users + + Атрибуты: + id (int) - Telegram ID пользователя (PK) + username (Optional[str]) - Никнейм (@username) + full_name (Optional[str]) - Полное имя + status (UserStatus) - Статус: active/admin/banned + created_at (datetime) - Дата создания записи (tz-aware) + updated_at (datetime) - Дата последнего обновления (tz-aware) + messages (List[UserMessage]) - Связанные сообщения + roles (List[Role]) - Роли, которые занимает пользователь + + Индексы: + - ix_users_status + - ix_users_username + + Пример: + >> user = User(id=123, username="test", full_name="Test User") + """ + + __tablename__: str = "users" + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + username: Mapped[Optional[str]] = mapped_column(String, nullable=True) + full_name: Mapped[Optional[str]] = mapped_column(String, nullable=True) + + # Для SQLite используем строковый Enum (native_enum=False) + status: Mapped[UserStatus] = mapped_column( + SAEnum(UserStatus, native_enum=False), + nullable=False, + default=UserStatus.ACTIVE, + ) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + nullable=False, + ) + + messages: Mapped[List["UserMessage"]] = relationship( + back_populates="user", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + roles: Mapped[List["Role"]] = relationship( + back_populates="occupied_by_user", + passive_deletes=True, + ) + + +class UserMessage(Base): + """ + Сообщение пользователя. + + Таблица: + user_messages + + Атрибуты: + id (int) - ID сообщения (PK) + user_id (int) - FK -> users.id + message_text (str) - Текст сообщения + created_at (datetime) - Метка времени (UTC, tz-aware) + + Индексы: + - ix_user_messages_user_id_created_at (user_id, created_at) + + Пример: + >> message = UserMessage(user_id=123, message_text="Hello") + """ + + __tablename__: str = "user_messages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + BigInteger, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + message_text: Mapped[str] = mapped_column(Text, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + nullable=False + ) + + user: Mapped["User"] = relationship(back_populates="messages") + + +class Role(Base): + """ + Роль (персонаж). + + Таблица: + roles + + Атрибуты: + id (int) - ID роли (PK) + name (str) - Название роли (уникально) + region (RoleRegion) - Регион персонажа + occupied_by (Optional[int]) - Пользователь, который занимает роль (FK -> users.id) + occupied_by_user (Optional[User]) - Обратная связь на пользователя + + Ограничения: + - Уникальность name + + Пример: + >> role = Role(name="Альбедо", region=RoleRegion.MONDSTADT) + """ + + __tablename__: str = "roles" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String, nullable=False, unique=True) + region: Mapped[RoleRegion] = mapped_column( + SAEnum(RoleRegion, native_enum=False), + nullable=False, + default=RoleRegion.GENSHIN_OTHER, + ) + occupied_by: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("users.id"), + nullable=True + ) + occupied_by_user: Mapped[Optional["User"]] = relationship( + "User", + back_populates="roles" + ) + + +class RoleMessage(Base): + """ + Модель для хранения информации о сообщениях с списками ролей. + + Таблица: + role_messages + + Атрибуты: + id (int) - ID записи + game_type (str) - тип игры ('genshin' или 'hsr') + channel_id (int) - ID канала + message_id (int) - ID сообщения + message_text (str) - исходный текст сообщения + + Пример: + >> role_msg = RoleMessage( + >> game_type="genshin", + >> channel_id=-100123456, + >> message_id=123, + >> message_text="Список персонажей" + >> ) + """ + __tablename__: str = "role_messages" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + game_type: Mapped[str] = mapped_column(String, nullable=False) # 'genshin' или 'hsr' + channel_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + message_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + message_text: Mapped[str] = mapped_column(Text, nullable=False) + + +# ====================================================== +# Утилиты для расчёта периодов (день/неделя/месяц) +# ====================================================== +def _start_of_day(dt: datetime) -> datetime: + """ + Начало дня для tz-aware datetime. + + Args: + dt: текущая дата/время (tz-aware) + + Returns: + datetime: 00:00:00 того же дня. + + Пример: + >> now = datetime.now(timezone.utc) + >> start = _start_of_day(now) + """ + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _start_of_week_monday(dt: datetime) -> datetime: + """ + Начало недели (понедельник 00:00:00) для tz-aware datetime. + + Args: + dt: текущая дата/время (tz-aware) + + Returns: + datetime: понедельник 00:00:00 текущей недели. + + Пример: + >> now = datetime.now(timezone.utc) + >> start = _start_of_week_monday(now) + """ + monday: datetime = dt - timedelta(days=dt.weekday()) + return monday.replace(hour=0, minute=0, second=0, microsecond=0) + + +def _start_of_month(dt: datetime) -> datetime: + """ + Начало месяца (первое число 00:00:00) для tz-aware datetime. + + Args: + dt: текущая дата/время (tz-aware) + + Returns: + datetime: первое число 00:00:00 текущего месяца. + + Пример: + >> now = datetime.now(timezone.utc) + >> start = _start_of_month(now) + """ + return dt.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + + +# ====================================================== +# Класс управления базой данных +# ====================================================== +class BotDatabase: + """ + Асинхронный менеджер базы данных для Telegram-бота (SQLite + SQLAlchemy 2.x). + + Возможности: + - Автосоздание базы и всех таблиц. + - Учёт пользователей (регистрация, бан, разбан, список ID). + - Учёт сообщений (логирование, статистика за день/неделю/месяц/всё время). + - Система ролей (создание, назначение, освобождение, статус). + - Управление сообщениями с ролями в Telegram. + + Замечания: + - Все временные метки сохраняются в UTC (tz-aware). + - Неделя считается с понедельника по воскресенье. + + Пример: + >> db = BotDatabase() # doctest: +SKIP + >> await db.init_db() # doctest: +SKIP + >> await db.add_user(1001, "user1", "User One") # doctest: +SKIP + >> await db.add_message(1001, "Привет") # doctest: +SKIP + >> await db.init_roles([ # doctest: +SKIP + >> ("Альбедо", RoleRegion.MONDSTADT), # doctest: +SKIP + >> ("Чжун Ли", RoleRegion.LIYUE) # doctest: +SKIP + >> ]) # doctest: +SKIP + >> await db.assign_role("Альбедо", 1001) # doctest: +SKIP + >> ids = await db.get_user_ids() # doctest: +SKIP + >> stats = await db.get_message_stats(1001) # doctest: +SKIP + """ + + DEFAULT_DB_URL: str = "sqlite+aiosqlite:///./bot.db" + + def __init__(self, db_url: Optional[str] = None, echo: bool = False) -> None: + """ + Инициализация менеджера БД. + + Args: + db_url: строка подключения к базе (по умолчанию создаётся ./bot.db). + echo: логирование SQL (для отладки). + + Raises: + ValueError: если db_url пустая строка. + + Пример: + >> db = BotDatabase() # Создаст базу в файле ./bot.db + >> db = BotDatabase("sqlite+aiosqlite:///./test.db", echo=True) # Создаст test.db с логированием SQL + """ + if db_url is not None and not db_url.strip(): + raise ValueError("db_url не может быть пустой строкой") + + url: str = db_url or self.DEFAULT_DB_URL + self.engine: AsyncEngine = create_async_engine(url, echo=echo, future=True) + self.session_factory: async_sessionmaker[AsyncSession] = async_sessionmaker( + bind=self.engine, expire_on_commit=False, class_=AsyncSession + ) + + # ----------------------- Инициализация схемы ----------------------- + async def init_db(self) -> None: + """ + Создаёт все таблицы в базе данных. + + Пример: + >> await db.init_db() + """ + async with self.engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async def dispose(self) -> None: + """ + Корректно закрывает соединения с БД. + + Пример: + >> await db.dispose() + """ + await self.engine.dispose() + + # ----------------------- Пользователи ----------------------- + async def add_user( + self, + user_id: int, + username: Optional[str] = None, + full_name: Optional[str] = None, + is_admin: bool = False, + ) -> None: + """ + Регистрирует пользователя (idempotent): если запись уже есть — ничего не делает. + + Args: + user_id: Telegram ID пользователя. + username: никнейм (@username). + full_name: полное имя. + is_admin: установить статус ADMIN при создании. + + Пример: + >> await db.add_user(42, username="neo", full_name="Thomas Anderson", is_admin=True) + """ + async with self.session_factory() as session: + existing: Optional[User] = await session.get(User, user_id) + if existing is not None: + return + + status: UserStatus = UserStatus.ADMIN if is_admin else UserStatus.ACTIVE + new_user: User = User( + id=user_id, + username=username, + full_name=full_name, + status=status, + ) + session.add(new_user) + await session.commit() + + async def ensure_user_from_message(self, message: SupportsAiogramMessage) -> None: + """ + Гарантирует наличие пользователя в БД на основе aiogram-сообщения. + + Args: + message: объект, совместимый с aiogram.types.Message (имеет from_user с полями id/username/full_name). + + Пример: + >> # в хендлере aiogram: + >> await db.ensure_user_from_message(message) + """ + # Строгая проверка протокола: + if not isinstance(message, SupportsAiogramMessage): + raise TypeError("message не соответствует протоколу SupportsAiogramMessage") + + from_user: SupportsUser = message.from_user + if not isinstance(from_user, SupportsUser): + raise TypeError("message.from_user не соответствует протоколу SupportsUser") + + await self.add_user( + user_id=from_user.id, + username=from_user.username, + full_name=from_user.full_name, + ) + + async def set_admin(self, user_id: int, make_admin: bool = True) -> None: + """ + Повышает/понижает пользователя до/с администратора. + + Args: + user_id: Telegram ID. + make_admin: True — сделать админом, False — вернуть в ACTIVE. + + Пример: + >> await db.set_admin(42, make_admin=True) # Сделать админом + >> await db.set_admin(42, make_admin=False) # Убрать админа + """ + async with self.session_factory() as session: + user: Optional[User] = await session.get(User, user_id) + if user is None: + return + user.status = UserStatus.ADMIN if make_admin else UserStatus.ACTIVE + await session.commit() # Убедитесь, что этот вызов есть! + + async def ban_user(self, user_id: int) -> None: + """ + Банит пользователя (status=BANNED). + + Args: + user_id: Telegram ID. + + Пример: + >> await db.ban_user(1001) + """ + async with self.session_factory() as session: + user: Optional[User] = await session.get(User, user_id) + if user is None: + return + user.status = UserStatus.BANNED + await session.commit() + + async def unban_user(self, user_id: int) -> None: + """ + Разбанивает пользователя (возвращает в ACTIVE, если был BANNED). + + Args: + user_id: Telegram ID. + + Пример: + >> await db.unban_user(1001) + """ + async with self.session_factory() as session: + user: Optional[User] = await session.get(User, user_id) + if user is None: + return + if user.status == UserStatus.BANNED: + user.status = UserStatus.ACTIVE + await session.commit() + + async def get_user(self, user_id: int) -> Optional[User]: + """ + Возвращает пользователя по ID. + + Args: + user_id: Telegram ID. + + Returns: + Optional[User]: объект или None. + + Пример: + >> user = await db.get_user(1001) + >> if user: + >> print(user.username) + """ + async with self.session_factory() as session: + return await session.get(User, user_id) + + async def get_all_users(self, include_banned: bool = False) -> List[User]: + """ + Возвращает список пользователей. + + Args: + include_banned: включать ли забаненных (по умолчанию False). + + Returns: + List[User]: список пользователей. + + Пример: + >> users = await db.get_all_users() + >> for user in users: + >> print(f"{user.id}: {user.username}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[User]] = select(User) + if not include_banned: + stmt = stmt.where(User.status != UserStatus.BANNED) + res: Result[Tuple[User]] = await session.execute(stmt) + return list(res.scalars().all()) + + async def get_user_ids( + self, + only_active: bool = True, + include_admins: bool = True, + order_asc: bool = True, + ) -> List[int]: + """ + Возвращает список ID пользователей (для рассылок и т.п.). + + Args: + only_active: Исключать ли забаненных (True по умолчанию). + include_admins: Включать ли администраторов. + order_asc: Сортировать по возрастанию (иначе по убыванию). + + Returns: + List[int]: список Telegram ID. + + Пример: + >> active_user_ids = await db.get_user_ids(only_active=True, include_admins=False) + """ + async with self.session_factory() as session: + stmt: Select[Tuple[int]] = select(User.id) + if only_active: + stmt = stmt.where(User.status != UserStatus.BANNED) + if not include_admins: + stmt = stmt.where(User.status != UserStatus.ADMIN) + stmt = stmt.order_by(User.id.asc() if order_asc else User.id.desc()) + + res: Result[Tuple[int]] = await session.execute(stmt) + ids: List[int] = list(res.scalars().all()) + return ids + + # ----------------------- Сообщения / статистика ----------------------- + async def add_message( + self, + user_id: int, + message_text: str, + created_at: Optional[datetime] = None, + ) -> None: + async with self.session_factory() as session: + # Сначала пытаемся найти пользователя + user: Optional[User] = await session.get(User, user_id) + + # Если пользователя нет, создаем его + if user is None: + user = User(id=user_id, status=UserStatus.ACTIVE) + session.add(user) + await session.flush() + + # Используем переданную дату или текущее время + if created_at is not None: + # Убедимся, что дата имеет временную зону + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + ts = created_at + else: + ts = datetime.now(timezone.utc) + + record: UserMessage = UserMessage(user_id=user_id, message_text=message_text, created_at=ts) + session.add(record) + await session.commit() + + async def check_connection(self) -> bool: + """Проверяет соединение с базой данных""" + try: + async with self.session_factory() as session: + await session.execute(sql_text("SELECT 1")) + return True + except Exception as e: + print(f"Ошибка подключения к БД: {e}") + return False + + async def add_message_from_message(self, message: SupportsAiogramMessage) -> None: + """ + Логирует сообщение напрямую из aiogram (минимальный контракт через Protocol). + + Args: + message: объект, совместимый с aiogram.types.Message. + + Пример: + >> await db.add_message_from_message(message) + """ + if not isinstance(message, SupportsAiogramMessage): + raise TypeError("message не соответствует протоколу SupportsAiogramMessage") + from_user: SupportsUser = message.from_user + if not isinstance(from_user, SupportsUser): + raise TypeError("message.from_user не соответствует протоколу SupportsUser") + + await self.add_message( + user_id=from_user.id, + message_text=message.text or "", + ) + + async def get_message_stats(self, user_id: int) -> Tuple[int, int, int, int]: + """ + Возвращает статистику сообщений пользователя: + (за текущий день, за текущую неделю [Пн-Вс], за текущий месяц, за всё время). + + Все границы считаются по UTC и округляются к началу периода. + + Args: + user_id: Telegram ID. + + Returns: + Tuple[int, int, int, int]: (day, week, month, total) + + Пример: + >> day, week, month, total = await db.get_message_stats(1001) + >> print(f"За день: {day}, за неделю: {week}, за месяц: {month}, всего: {total}") + """ + async with self.session_factory() as session: + now: datetime = datetime.now(timezone.utc) + day_start: datetime = _start_of_day(now) + week_start: datetime = _start_of_week_monday(now) + month_start: datetime = _start_of_month(now) + epoch_start: datetime = datetime(1970, 1, 1, tzinfo=timezone.utc) + + async def _count_from(since: datetime) -> int: + stmt: Select[Tuple[int]] = select(func.count()).where( + and_(UserMessage.user_id == user_id, UserMessage.created_at >= since) + ) + res: Result[Tuple[int]] = await session.execute(stmt) + return int(res.scalar() or 0) + + day_count: int = await _count_from(day_start) + week_count: int = await _count_from(week_start) + month_count: int = await _count_from(month_start) + total_count: int = await _count_from(epoch_start) + + return day_count, week_count, month_count, total_count + + # ----------------------- Роли ----------------------- + async def init_roles(self, roles: List[Tuple[str, RoleRegion]]) -> None: + """ + Создаёт роли персонажей. Если таблицы нет — создаёт таблицы. + + Args: + roles: список кортежей (имя_роли, регион) + + Пример: + >> roles = [ + >> ("Альбедо", RoleRegion.MONDSTADT), + >> ("Чжун Ли", RoleRegion.LIYUE), + >> ("Вельт", RoleRegion.HSR_STAR) + >> ] + >> await db.init_roles(roles) + """ + # создаём таблицы перед вставкой ролей + await self.init_db() + + async with self.session_factory() as session: + for name, region in roles: + stmt = select(Role).where(Role.name == name) + res = await session.execute(stmt) + role = res.scalar_one_or_none() + if not role: + session.add(Role(name=name, region=region)) + await session.commit() + + async def assign_role(self, role_name: str, user_id: int, bot: Optional[SupportsAiogramBot] = None) -> bool: + """ + Назначает пользователя на роль, если она свободна. + + Args: + role_name: название роли (уникальное). + user_id: Telegram ID пользователя. + bot: экземпляр бота для обновления сообщения (опционально). + + Returns: + bool: True — если назначение выполнено, False — если такой роли нет или роль уже занята. + + Пример: + >> success = await db.assign_role("Альбедо", 1001, bot) + >> if success: + >> print("Роль назначена") + """ + async with self.session_factory() as session: + role_stmt: Select[Tuple[Role]] = select(Role).where(Role.name == role_name) + role_res: Result[Tuple[Role]] = await session.execute(role_stmt) + role: Optional[Role] = role_res.scalar_one_or_none() + if role is None or role.occupied_by is not None: + return False + + user: Optional[User] = await session.get(User, user_id) + if user is None or user.status == UserStatus.BANNED: + return False + + role.occupied_by = user_id + await session.commit() + + # Обновляем сообщение с ролями если передан бот + if bot: + # Определяем игру по региону + game_type: str = "hsr" if role.region.name.startswith("HSR_") else "genshin" + await self.update_role_message(game_type, bot) + + return True + + async def release_role(self, role_name: str, bot: Optional[SupportsAiogramBot] = None) -> bool: + """ + Освобождает роль (устанавливает occupied_by = NULL). + + Args: + role_name: название роли. + bot: экземпляр бота для обновления сообщения (опционально). + + Returns: + bool: True если роль была освобождена, False если роль не найдена или уже свободна. + + Пример: + >> success = await db.release_role("Альбедо", bot) + >> if success: + >> print("Роль освобождена") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.name == role_name) + res: Result[Tuple[Role]] = await session.execute(stmt) + role: Optional[Role] = res.scalar_one_or_none() + + if role is None or role.occupied_by is None: + return False + + role.occupied_by = None + await session.commit() + + # Обновляем сообщение с ролями если передан бот + if bot: + # Определяем игру по региону + game_type: str = "hsr" if role.region.name.startswith("HSR_") else "genshin" + await self.update_role_message(game_type, bot) + + return True + + async def get_role_status(self) -> List[Tuple[str, Optional[int]]]: + """ + Возвращает текущий статус всех ролей. + + Returns: + List[Tuple[str, Optional[int]]]: пары (role_name, user_id | None) + + Пример: + >> roles = await db.get_role_status() + >> for name, user_id in roles: + >> status = "занята" if user_id else "свободна" + >> print(f"{name}: {status}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).order_by(Role.name.asc()) + res: Result[Tuple[Role]] = await session.execute(stmt) + roles: List[Role] = list(res.scalars().all()) + return [(r.name, r.occupied_by) for r in roles] + + async def get_roles_by_user(self, user_id: int) -> List[str]: + """ + Возвращает список имён ролей, занятых указанным пользователем. + + Args: + user_id: Telegram ID. + + Returns: + List[str]: имена ролей. + + Пример: + >> roles = await db.get_roles_by_user(1001) + >> print(f"Пользователь занимает роли: {', '.join(roles)}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[str]] = select(Role.name).where(Role.occupied_by == user_id) + res: Result[Tuple[str]] = await session.execute(stmt) + names: List[str] = list(res.scalars().all()) + return names + + async def release_roles_by_user(self, user_id: int, bot: Optional[SupportsAiogramBot] = None) -> int: + """ + Освобождает все роли, занятые указанным пользователем. + + Args: + user_id: Telegram ID. + bot: экземпляр бота для обновления сообщения (опционально). + + Returns: + int: количество освобождённых ролей. + + Пример: + >> count = await db.release_roles_by_user(1001, bot) + >> print(f"Освобождено {count} ролей") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.occupied_by == user_id) + res: Result[Tuple[Role]] = await session.execute(stmt) + roles: List[Role] = list(res.scalars().all()) + + for r in roles: + r.occupied_by = None + + if roles: + await session.commit() + + # Обновляем сообщения с ролями если передан бот + if bot: + # Обновляем оба типа сообщений, так как пользователь мог занимать роли в обеих играх + await self.update_role_message("genshin", bot) + await self.update_role_message("hsr", bot) + + return len(roles) + + async def get_available_roles(self, region: Optional[RoleRegion] = None) -> List[Role]: + """ + Возвращает список свободных ролей. + + Args: + region: фильтр по региону (опционально). + + Returns: + List[Role]: список свободных ролей. + + Пример: + >> free_roles = await db.get_available_roles(RoleRegion.MONDSTADT) + >> for role in free_roles: + >> print(role.name) + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.occupied_by.is_(None)) + if region: + stmt = stmt.where(Role.region == region) + stmt = stmt.order_by(Role.name.asc()) + + res: Result[Tuple[Role]] = await session.execute(stmt) + return list(res.scalars().all()) + + async def get_occupied_roles(self, region: Optional[RoleRegion] = None) -> List[Role]: + """ + Возвращает список занятых ролей. + + Args: + region: фильтр по региону (опционально). + + Returns: + List[Role]: список занятых ролей. + + Пример: + >> occupied_roles = await db.get_occupied_roles(RoleRegion.MONDSTADT) + >> for role in occupied_roles: + >> print(f"{role.name} занята пользователем {role.occupied_by}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.occupied_by.is_not(None)) + if region: + stmt = stmt.where(Role.region == region) + stmt = stmt.order_by(Role.name.asc()) + + res: Result[Tuple[Role]] = await session.execute(stmt) + return list(res.scalars().all()) + + async def get_role_by_name(self, role_name: str) -> Optional[Role]: + """ + Возвращает роль по имени. + + Args: + role_name: название роли. + + Returns: + Optional[Role]: объект роли или None если не найдена. + + Пример: + >> role = await db.get_role_by_name("Альбедо") + >> if role: + >> print(f"Регион: {role.region}, занята: {role.occupied_by is not None}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.name == role_name) + res: Result[Tuple[Role]] = await session.execute(stmt) + return res.scalar_one_or_none() + + async def get_roles_by_region(self, region: RoleRegion) -> List[Role]: + """ + Возвращает все роли в указанном регионе. + + Args: + region: регион для фильтрации. + + Returns: + List[Role]: список ролей в регионе. + + Пример: + >> mondstadt_roles = await db.get_roles_by_region(RoleRegion.MONDSTADT) + >> for role in mondstadt_roles: + >> status = "занята" if role.occupied_by else "свободна" + >> print(f"{role.name}: {status}") + """ + async with self.session_factory() as session: + stmt: Select[Tuple[Role]] = select(Role).where(Role.region == region).order_by(Role.name.asc()) + res: Result[Tuple[Role]] = await session.execute(stmt) + return list(res.scalars().all()) + + async def get_region_stats(self) -> Dict[RoleRegion, Dict[str, int]]: + """ + Возвращает статистику по регионам: количество свободных и занятых ролей. + """ + async with self.session_factory() as session: + # Используем агрегатные функции для подсчета статистики + stmt = select( + Role.region, + func.count().label("total"), + func.sum(case((Role.occupied_by.is_not(None), 1), else_=0)).label("occupied"), + func.sum(case((Role.occupied_by.is_(None), 1), else_=0)).label("free") + ).group_by(Role.region).order_by(Role.region.asc()) + + res = await session.execute(stmt) + results = res.all() + + stats: Dict[RoleRegion, Dict[str, int]] = {} + for region, total, occupied, free in results: + stats[region] = { + "total": total, + "occupied": occupied, + "free": free + } + + return stats + + # ----------------------- Управление сообщениями с ролями ----------------------- + async def save_role_message( + self, + game_type: str, + channel_id: int, + message_id: int, + message_text: str + ) -> None: + """ + Сохраняет информацию о сообщении со списком ролей. + + Args: + game_type: 'genshin' или 'hsr' + channel_id: ID канала + message_id: ID сообщения + message_text: исходный текст сообщения + + Пример: + >> await db.save_role_message( + >> game_type="genshin", + >> channel_id=-100123456, + >> message_id=123, + >> message_text="Список персонажей Genshin Impact" + >> ) + """ + async with self.session_factory() as session: + # Удаляем старую запись если есть + stmt = select(RoleMessage).where(RoleMessage.game_type == game_type) + result = await session.execute(stmt) + existing = result.scalar_one_or_none() + + if existing: + await session.delete(existing) + + # Создаем новую запись + new_message = RoleMessage( + game_type=game_type, + channel_id=channel_id, + message_id=message_id, + message_text=message_text + ) + session.add(new_message) + await session.commit() + + async def update_role_message(self, game_type: str, bot: SupportsAiogramBot) -> bool: + """ + Обновляет сообщение со списком ролей в Telegram. + + Args: + game_type: 'genshin' или 'hsr' + bot: экземпляр aiogram Bot + + Returns: + bool: True если сообщение обновлено, False если сообщение не найдено + + Пример: + >> success = await db.update_role_message("genshin", bot) + >> if success: + >> print("Сообщение обновлено") + """ + async with self.session_factory() as session: + # Получаем информацию о сообщении + stmt = select(RoleMessage).where(RoleMessage.game_type == game_type) + result = await session.execute(stmt) + role_message = result.scalar_one_or_none() + + if not role_message: + return False + + # Получаем статус всех ролей + roles_status = await self.get_role_status() + role_status_dict = {name: user_id for name, user_id in roles_status} + + # Обновляем текст сообщения + lines = role_message.message_text.split('\n') + updated_lines = [] + + for line in lines: + # Пропускаем заголовки и пустые строки + if not line.strip() or any(marker in line for marker in ['ᵎ', 'СПИСОК', 'Если персонажа']): + updated_lines.append(line) + continue + + # Проверяем, есть ли роль в этом сообщении + role_name = line.strip().replace('✅', '').replace('🕒', '').strip() + + if role_name in role_status_dict: + if role_status_dict[role_name] is not None: + # Роль занята - добавляем галочку + updated_line = f"{role_name} ✅" + else: + # Роль свободна - оставляем как есть + updated_line = role_name + else: + # Роль не найдена в базе - оставляем как есть + updated_line = line + + updated_lines.append(updated_line) + + updated_text = '\n'.join(updated_lines) + + # Обновляем сообщение в Telegram + try: + await bot.edit_message_text( + chat_id=role_message.channel_id, + message_id=role_message.message_id, + text=updated_text + ) + return True + except Exception as e: + print(f"Ошибка при обновлении сообщения: {e}") + return False + + async def init_default_roles(self) -> None: + """ + Инициализирует стандартные списки ролей для Genshin Impact и Honkai: Star Rail. + + Пример: + >> await db.init_default_roles() + """ + # Роли для Genshin Impact с регионами + from configs import all_roles + await self.init_roles(all_roles) + + +# Глобальный экземпляр базы данных +db: BotDatabase = BotDatabase() diff --git a/locales/en/LC_MESSAGES/bot.mo b/locales/en/LC_MESSAGES/bot.mo new file mode 100644 index 0000000..fc24c7e Binary files /dev/null and b/locales/en/LC_MESSAGES/bot.mo differ diff --git a/locales/en/LC_MESSAGES/bot.po b/locales/en/LC_MESSAGES/bot.po new file mode 100644 index 0000000..76c2814 --- /dev/null +++ b/locales/en/LC_MESSAGES/bot.po @@ -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 , 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 \n" +"Language: en\n" +"Language-Team: en \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 "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/locales/messages.pot b/locales/messages.pot new file mode 100644 index 0000000..1bfd65d --- /dev/null +++ b/locales/messages.pot @@ -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 , 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 \n" +"Language-Team: LANGUAGE \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 "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/locales/ru/LC_MESSAGES/bot.mo b/locales/ru/LC_MESSAGES/bot.mo new file mode 100644 index 0000000..bc4eb55 Binary files /dev/null and b/locales/ru/LC_MESSAGES/bot.mo differ diff --git a/locales/ru/LC_MESSAGES/bot.po b/locales/ru/LC_MESSAGES/bot.po new file mode 100644 index 0000000..82e0714 --- /dev/null +++ b/locales/ru/LC_MESSAGES/bot.po @@ -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 , 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 \n" +"Language: ru\n" +"Language-Team: ru \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 "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/locales/uk/LC_MESSAGES/bot.mo b/locales/uk/LC_MESSAGES/bot.mo new file mode 100644 index 0000000..914b389 Binary files /dev/null and b/locales/uk/LC_MESSAGES/bot.mo differ diff --git a/locales/uk/LC_MESSAGES/bot.po b/locales/uk/LC_MESSAGES/bot.po new file mode 100644 index 0000000..39c6457 --- /dev/null +++ b/locales/uk/LC_MESSAGES/bot.po @@ -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 , 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 \n" +"Language: uk\n" +"Language-Team: uk \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 "" +"Добро пожаловать, {name}!\n" +"\n" +"Мое имя - {bot_name}! Я искусственный интеллект и сказитель ваших " +"историй! \n" +"Моя цель — помочь вам сориентироваться и сделать ваши истории куда " +"интереснее! \n" +"Надеюсь, я смогу вам помочь! Пожалуйста, выберите нужную функцию на " +"клавиатуре!\n" +"\n" +"Интересный факт:\n" +"
{fact}
\n" +msgstr "" + diff --git a/main.py b/main.py new file mode 100644 index 0000000..a6c8fa9 --- /dev/null +++ b/main.py @@ -0,0 +1,75 @@ +import asyncio +import sys + +from bot import dp, bot, BotInfo, WebhookApp, setup_middlewares, router +from configs.config import Webhook +from database import db +from middleware import setup_logging + + +async def on_startup() -> None: + """Действия при запуске бота.""" + setup_logging() + + # Создание базы данных + 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) + BotInfo.start_info_out() + + +async def run_polling() -> None: + """Запуск в режиме polling.""" + try: + await on_startup() + await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()) + finally: + await bot.session.close() + + +async def run_webhook() -> None: + """Запуск в режиме webhook.""" + app: WebhookApp = WebhookApp(host=Webhook.WEBHOOK_HOST, port=Webhook.WEBHOOK_PORT) + try: + await on_startup() + await app.start() + # держим процесс живым + while True: + await asyncio.sleep(3600) + finally: + await app.stop() + await bot.session.close() + + +async def main() -> None: + # Запуск в нужном режиме + if Webhook.WEBHOOK: + await run_webhook() + else: + await run_polling() + + +if __name__ == "__main__": + # Защита для Windows + if sys.platform.startswith("win"): + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + try: + asyncio.run(main()) + except (KeyboardInterrupt, SystemExit): + print("❌ Бот остановлен!") + sys.exit(0) diff --git a/middleware/__init__.py b/middleware/__init__.py new file mode 100644 index 0000000..e719cd0 --- /dev/null +++ b/middleware/__init__.py @@ -0,0 +1,2 @@ +from .loggers import * +from .validators import * diff --git a/middleware/loggers/__init__.py b/middleware/loggers/__init__.py new file mode 100644 index 0000000..d668488 --- /dev/null +++ b/middleware/loggers/__init__.py @@ -0,0 +1 @@ +from .logs import * diff --git a/middleware/loggers/logs.py b/middleware/loggers/logs.py new file mode 100644 index 0000000..6cf9a68 --- /dev/null +++ b/middleware/loggers/logs.py @@ -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] = ( + '{time:YYYY-MM-DD HH:mm:ss.SSS} | ' + '{extra[system]}-{extra[log_type]} | ' + '{extra[user]} | {message}' + ) + + 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' + """ + 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 diff --git a/middleware/validators/__init__.py b/middleware/validators/__init__.py new file mode 100644 index 0000000..98878dc --- /dev/null +++ b/middleware/validators/__init__.py @@ -0,0 +1,2 @@ +from .email_vld import * +from .url_vld import * diff --git a/middleware/validators/email_vld.py b/middleware/validators/email_vld.py new file mode 100644 index 0000000..c30d0e7 --- /dev/null +++ b/middleware/validators/email_vld.py @@ -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 diff --git a/middleware/validators/url_vld.py b/middleware/validators/url_vld.py new file mode 100644 index 0000000..6da66da --- /dev/null +++ b/middleware/validators/url_vld.py @@ -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'{text}' + + except ValueError as e: + raise e # Перебрасываем ошибку выше для дальнейшей обработки или уведомления diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..edf0e94 --- /dev/null +++ b/pyproject.toml @@ -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)", + "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)", + "fastapi (>=0.116.1,<0.117.0)", + "uvicorn (>=0.35.0,<0.36.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_*"